/* ═══════════════════════════════════════════════════════════════════════
   王清鑫 · Qingxin Wang

   月白 (a cool pale ground, not warm cream), 松烟 ink, and 石青 for
   structure and links. Three colours, no fourth — the Chinese register is
   carried by the paper, the hairlines and the name, not by ornament.
   ═══════════════════════════════════════════════════════════════════════ */

/* ─── tokens ─────────────────────────────────────────────────────────── */
:root {
  /* Height of the sticky bar at the top. Declared once because two other
     things have to know it: the hero centres itself around it, and jumps from
     the nav have to clear it. Change it here, not in .tb. */
  --bar: 3.15rem;

  /* 月白 — a pale ground with a cool green-grey bias, not cream */
  --paper: #f4f5f1;
  --paper-2: #ecefe7;
  --paper-3: #e2e5db;

  /* 松烟墨 — lampblack ink runs cool, never neutral grey */
  --ink: #16191a;
  --ink-2: #343a3a;
  --muted: #656c69;
  --faint: #939a94;

  --rule: #d9dcd2;
  --rule-2: #c2c8bb;

  --qing: #2f5d62;               /* 石青 — structure, links, emphasis */
  --qing-soft: rgba(47, 93, 98, 0.09);

  /* The two plates at the head and foot. On paper the ink just goes straight
     down: one pass, nothing under it. See the .plate block for why the dark
     theme has to do more than flip this. */
  --plate-top-bg: url(../img/plate-top.png);
  --plate-bottom-bg: url(../img/plate-bottom.png);

  /* 日月 — the one thing in the picture that answers the theme. Geometry lives
     once, in .plate-top::before; this carries the whole difference between the
     sun and the moon, so it is a complete background rather than a colour.

     日 — 烘云托月, the classical way to paint a disc in ink: the disc itself is
     barely touched and the sky around it is washed, so the light is made by
     what surrounds it. Four stops: lit paper, a quick falloff, the wash coming
     up, and the wash going out to nothing. --orb-cut is the mask; the sun is
     round, so it has none. */
  --orb: radial-gradient(circle closest-side,
         #fcfdf9 0 34%,
         rgba(252, 253, 249, 0.50) 46%,
         rgba(22, 25, 26, 0.030) 58%,
         rgba(22, 25, 26, 0.050) 72%,
         rgba(22, 25, 26, 0) 100%);
  --orb-cut: none;

  --font-display: "Prata", "Times New Roman", Georgia, serif;
  /* KingHwa sits in the body stack, not just in headings, because hanzi turn
     up inside otherwise-English lines — a supervisor's name in brackets, for
     one. Without it here the browser reaches for whatever CJK font the visitor
     happens to own; on this machine it split one three-character name across
     two fallbacks, one of them Japanese. Per-character fallback means Latin
     still comes from Spectral: the CN face is only ever consulted for the
     characters Spectral does not have. Chinese-page body type overrides this
     whole variable in zh.css. */
  --font-body: "Spectral", "KingHwa OldSong", Georgia, "Times New Roman", serif;
  /* The opening paragraph of the English hero, and nothing else. */
  --font-lede: "Playfair Display", "Spectral", Georgia, serif;
  --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  /* Chinese in headings; anything outside the subset falls through to the
     system stack, which is also what all body Chinese uses. */
  --font-cn: "KingHwa OldSong", "Songti SC", "STSong", "SimSun", serif;

  /* --shell is a BORDER box: it has to cover its own padding and borders.
     rail 13 + gap 3.5 + text 36 + meta 15 + 2×gutter 9 ≈ 76.5rem          */
  --shell: 80rem;
  --gutter: clamp(1.35rem, 3.4vw, 4.5rem);
  --rail: 13rem;
  --measure: 36rem;

  /* 每一节的正文都排在同一副栏格上，三条竖线定死：
     --label 左边那列小字（年份、技能名），--measure 正文最宽能到哪，
     --aside 右边那条窄道，放校徽和论文标签。三个数加上两条 --colgap
     就是版心的宽度，所以壹到伍的字都在同一条右界上收住。
     每节各自算自己的宽度时，右边留白就会一节一个样子。 */
  --label: 8.5rem;
  /* Measured, not guessed: the widest thing that ever stands in this lane is
     the TU Berlin wordmark at 93px, and after it the "Preprint" chip at 88px.
     5.75rem is 111px, which clears both. It was 7rem, and the 24px of slack
     showed up as margin on every line of the page. */
  --aside: 5.75rem;
  --colgap: clamp(1rem, 2.5vw, 2.25rem);

  --ease: cubic-bezier(0.22, 0.61, 0.36, 1);
  color-scheme: light;
}

/* Dark: 夜 — the same ink and paper, swapped, and 石青 lifted to carry. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --paper: #131617;
    --paper-2: #191d1e;
    --paper-3: #222829;
    --ink: #e9eae3;
    --ink-2: #c2c7bf;
    --muted: #8d948e;
    --faint: #69706b;
    --rule: #282e2f;
    --rule-2: #3a4142;
    --qing: #82b3af;
    --qing-soft: rgba(130, 179, 175, 0.12);

    /* Lit paper first, then the same black ink twice on top of it. */
    --plate-top-bg:
      url(../img/plate-top.png), url(../img/plate-top.png),
      linear-gradient(to bottom, rgba(233, 234, 227, 0.18), transparent);
    --plate-bottom-bg:
      url(../img/plate-bottom.png), url(../img/plate-bottom.png),
      linear-gradient(to top, rgba(233, 234, 227, 0.18), transparent);

    /* 月 — the same stops with light instead of ink, because on a dark ground
       the sky around a moon is washed brighter rather than darker. And a
       crescent, not a full disc: --orb-cut wipes the far limb away.

       The wipe is wide and very soft on purpose. A tight, hard-edged bite
       leaves the dark limb ringed by the surviving halo, and that reads as an
       eclipse — a black disc with a bright rim — not as a moon. A wide soft
       one takes the halo with it on that side, so the unlit half is simply
       sky. It is a broad brush, not a cut. */
    --orb: radial-gradient(circle closest-side,
           rgba(243, 244, 238, 0.70) 0 36%,
           rgba(238, 240, 232, 0.27) 48%,
           rgba(233, 234, 227, 0.08) 62%,
           transparent 100%);
    --orb-cut: radial-gradient(ellipse 46% 46% at 66% 31%,
               transparent 0 34%, #000 98%);

    color-scheme: dark;
  }
}
:root[data-theme="dark"] {
  --paper: #131617;
  --paper-2: #191d1e;
  --paper-3: #222829;
  --ink: #e9eae3;
  --ink-2: #c2c7bf;
  --muted: #8d948e;
  --faint: #69706b;
  --rule: #282e2f;
  --rule-2: #3a4142;
  --qing: #82b3af;
  --qing-soft: rgba(130, 179, 175, 0.12);

  --plate-top-bg:
    url(../img/plate-top.png), url(../img/plate-top.png),
    linear-gradient(to bottom, rgba(233, 234, 227, 0.18), transparent);
  --plate-bottom-bg:
    url(../img/plate-bottom.png), url(../img/plate-bottom.png),
    linear-gradient(to top, rgba(233, 234, 227, 0.18), transparent);

  --orb: radial-gradient(circle closest-side,
         rgba(243, 244, 238, 0.70) 0 36%,
         rgba(238, 240, 232, 0.27) 48%,
         rgba(233, 234, 227, 0.08) 62%,
         transparent 100%);
  --orb-cut: radial-gradient(ellipse 46% 46% at 66% 31%,
             transparent 0 34%, #000 98%);

  color-scheme: dark;
}

/* ─── reset & base ───────────────────────────────────────────────────── */
*,
*::before,
*::after { box-sizing: border-box; }

* { margin: 0; }

/* The whole scale hangs off the root, so every rem below — type, rails,
   measure — grows together. Scaling body alone strands the small text. */
html {
  font-size: clamp(17px, 15.85px + 0.24vw, 19.5px);
  -webkit-text-size-adjust: 100%;
  overflow-x: clip;              /* clip, not hidden — hidden kills sticky */
  scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}

body {
  background: var(--paper);
  color: var(--ink);
  position: relative;            /* the top plate hangs off this */
  font-family: var(--font-body);
  font-size: 1rem;
  font-weight: 400;
  line-height: 1.62;
  font-feature-settings: "kern" 1, "liga" 1;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
}

/* Nothing on this page sets its own line breaks; every paragraph is wrapped by
   the browser inside whatever measure it was given. Left alone, the browser
   fills each line greedily and takes whatever is left over as the last one,
   which kept stranding two or three words on a line of their own. `pretty`
   asks it to look ahead and steal from earlier lines so the last one is not a
   stub. Browsers that do not have it ignore the line and wrap as before.

   Headings are the other case and are handled where they are defined: they get
   `balance`, which evens out every line rather than only the last. That suits
   two or three lines of display type and is the wrong tool for a paragraph. */
p, li, dd, blockquote { text-wrap: pretty; }

/* Wrapped around every hyphenated compound by render.js, so a line never
   breaks inside one. See the note there for why it is a span and not the
   non-breaking hyphen character. The compounds this page carries are all short
   (the longest is "Natural-language" at 16 characters, 135px, against a 344px
   column on the narrowest phone) — a much longer one would overflow instead of
   breaking, which is the trade this makes. */
.nb { white-space: nowrap; }

img { max-width: 100%; display: block; }
ul, ol { list-style: none; padding: 0; }

a { color: inherit; }

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

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

.wrap {
  max-width: var(--shell);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

.mono {
  font-family: var(--font-mono);
  font-weight: 500;
  letter-spacing: 0.13em;
  text-transform: uppercase;
}

/* Off-screen via clip, not a negative offset — a -9999px element still
   counts toward documentElement.scrollWidth and shows up as page-wide
   horizontal overflow. */
.skip {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.skip:focus {
  width: auto;
  height: auto;
  overflow: visible;
  clip-path: none;
  left: 1rem;
  top: 1rem;
  z-index: 100;
  background: var(--ink);
  color: var(--paper);
  padding: 0.6rem 1rem;
  font: 500 0.8rem/1 var(--font-mono);
}

/* ─── page-load and scroll reveals ───────────────────────────────────── */
.rise {
  opacity: 0;
  transform: translateY(14px);
  transition: opacity 0.7s var(--ease), transform 0.7s var(--ease);
}
.rise.in { opacity: 1; transform: none; }

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

/* ═══ topbar ══════════════════════════════════════════════════════════ */
#topbar {
  position: sticky;
  top: 0;
  z-index: 40;
  /* Transparent for as long as the top plate is behind it. 88% paper over a
     blur was enough to mute the plate and put a seam across the picture at
     exactly the height of the bar, which is the one thing the plate is drawn
     to avoid. The paper arrives with .stuck, the moment the plate has scrolled
     clear — from there on there is running text underneath and the bar has to
     be opaque to stay legible. ui.js decides the moment; it used to watch the
     hero, which is a whole screen tall, so the name slid under a transparent
     bar for the length of the first screen. */
  background: transparent;
  border-bottom: 1px solid transparent;
  transition: border-color 0.3s var(--ease), background 0.3s var(--ease),
              backdrop-filter 0.3s var(--ease);
}
#topbar.stuck {
  background: color-mix(in srgb, var(--paper) 88%, transparent);
  backdrop-filter: saturate(1.6) blur(8px);
  border-bottom-color: var(--rule);
}

/* Sentinel for the bar's paper background. One pixel tall, invisible, sitting
   at the top of the name; ui.js watches it cross under the bar. It has to be a
   real box in the flow — a zero-height element is a degenerate case for
   IntersectionObserver, and the hero itself is a whole screen tall, which is
   what made the bar stay transparent while the name slid under it. */
.tb-sentinel {
  display: block;
  height: 1px;
  margin-bottom: -1px;
  pointer-events: none;
}

/* No name up here. The page is one person's CV and it says so in 60pt type
   two centimetres below; repeating it in the corner as a logotype would be
   branding, and this is not a brand. So the bar carries only the way around
   and the light switch: sections left, toggle right. */
.tb {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 1.5rem;
  min-height: var(--bar);
}

.tb-nav { display: flex; gap: 1.35rem; margin-right: auto; }
.tb-nav a {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  font-weight: 400;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
  text-decoration: none;
  padding-block: 0.35rem;
  border-bottom: 1px solid transparent;
  transition: color 0.2s, border-color 0.2s;
}
.tb-nav a:hover { color: var(--ink); }
.tb-nav a[aria-current="true"] {
  color: var(--ink);
  border-bottom-color: var(--qing);
}
@media (max-width: 60rem) { .tb-nav { display: none; } }

.toggle {
  font-family: var(--font-mono);
  font-size: 0.65rem;
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted);
  background: none;
  border: 1px solid var(--rule-2);
  border-radius: 2px;
  padding: 0.3rem 0.6rem;
  cursor: pointer;
  transition: color 0.2s, border-color 0.2s;
}
.toggle:hover { color: var(--ink); border-color: var(--ink-2); }
/* The language switch is a link, not a button — it goes to another document,
   so it has to be middle-clickable and copyable like any other address. It
   borrows the toggle's shape and only undoes what a link brings with it. */
a.toggle {
  display: inline-block;
  text-decoration: none;
  line-height: 1.35;
}
/* 「中文」 is the only Chinese on the English page outside the name and the
   numerals, and the mono face has no hanzi in it. Point it at the face that
   page is already loading instead of dropping through to whatever the
   visitor's machine happens to have. On the Chinese page the same chip reads
   "EN" and falls straight through to the mono face. */
.toggle.lang { font-family: "KingHwa OldSong", var(--font-mono); }

/* While the bar is transparent it is not sitting on paper, it is sitting on the
   picture. --muted measures 3.5:1 against the wash at its densest, which is
   under the threshold for type this small, and it was under it in the light
   theme too before the dark one was ever built. Type over a picture has to be
   stronger than type over paper. --muted comes back with .stuck, where the bar
   has laid down its own ground and there is nothing behind it. */
#topbar:not(.stuck) .tb-nav a,
#topbar:not(.stuck) .toggle { color: var(--ink-2); }
#topbar:not(.stuck) .toggle { border-color: var(--rule-2); }

/* ═══ hero — 匾 ═══════════════════════════════════════════════════════ */
/* One screen, one thing. The hero is given the whole first screen so the rule
   at the top of the sections below is pushed off the fold, and the eye is not
   asked to start reading the next page before it has finished this one.
   svh, not vh: on a phone vh is the height with the browser chrome retracted,
   so a 100vh block always hangs a strip of the next section below the fold —
   which is the exact thing this is here to prevent.
   The block is then centred in that screen rather than sitting at the top of
   it, so the air falls above and below instead of all underneath. */
.hero {
  --pad: clamp(2.5rem, 6vw, 5.5rem);
  min-height: 100svh;
  display: grid;
  align-content: center;
  /* The bar above is in the flow, so a hero that is one screen tall starts one
     bar-height down and its middle lands a whole bar below the middle of what
     you can see. Twice the bar on the bottom pulls the block back up onto the
     centre of the visible screen. Where the block is too tall for that, the
     hero simply grows and the centring stops mattering. */
  padding-block: var(--pad) calc(var(--pad) + 2 * var(--bar));
}

/* ═══ 版画 — the two plates ════════════════════════════════════════════
   One photograph cut across the middle. The top half washes down out of the
   head of the page, the bottom half washes up out of the foot, and the whole
   CV sits inside the picture. Generated by scripts/make-plate.py; delete the
   two PNGs and the page loses them without leaving a hole.

   The PNG is black in its colour channels and carries the photograph in alpha:
   ink and nothing else, so no fourth colour enters the page.

   Dark mode does NOT invert it, and that is the whole point. Inverting turns a
   painting into its photographic negative: every stroke that was ink becomes a
   light-emitting shape on black, and representational shapes that emit light
   read as apparitions. The painting has to keep the relationship it was made
   with, ink darker than the ground it sits on. So the dark theme lays down a
   patch of faint light first — the paper of the painting, lit — and draws the
   same black ink on top of it. Twice, because one pass of 17% ink does not
   separate from a ground that has just been lifted 18%.

   Both layer stacks live in --plate-top-bg / --plate-bottom-bg beside the
   colour tokens, so the theme switch stays in one place like every other one.

   A CSS mask painted in currentColor would be the tidier way to say that, and
   it is not usable here: mask images are CORS-restricted and a file:// page
   drops them silently, which would mean the plates are there on the live site
   and gone whenever the file is opened by double-clicking it. Background
   images have no such rule.

   background-size is 100% 100% rather than cover on purpose. The fade is baked
   down the height of the PNG, so cropping would cut it off part-way and leave
   the one thing this is here to avoid: a findable edge. Stretching a soft wash
   by a few percent is invisible; a hard edge is not.

   Absolute, so neither plate is in the flow and neither moves the name off the
   centre of the first screen. The top one hangs off <body> rather than off the
   hero so that it starts at the true top of the page: the hero begins one bar
   down, and a plate that began there put a seam across the page at exactly the
   height of the bar. The bar is 88% paper over a blur, so the wash reads
   through it and the top edge of the page is the top edge of the picture.

   Full width, not the width of the text block. main's own hairlines and the
   tinted ground behind Tools already run edge to edge, so this is the register
   the page uses for anything that is paper rather than type. */
.plate {
  position: absolute;
  inset-inline: 0;
  height: var(--plate-h, clamp(7rem, 22vh, 15rem));
  background-repeat: no-repeat;
  background-size: 100% 100%;
  pointer-events: none;
  z-index: 0;
}
.plate-top    { top: 0;    background-image: var(--plate-top-bg); }
/* The foot gets more of the picture than the head does. There is nothing
   under the last section but air, so the wash has room to come up into it,
   where at the top it has to be finished before it reaches the name. */
.plate-bottom {
  bottom: 0;
  --plate-h: clamp(11rem, 34vh, 23rem);
  background-image: var(--plate-bottom-bg);
}

/* 日月 — one disc in the top plate, and the only thing on the page that is a
   different object in each theme rather than the same object recoloured.
   Light: the sun. Dark: the moon.

   Two earlier attempts are worth keeping, because each failed for a reason that
   is not obvious until you have looked at it. The first was orthodox 留白 —
   leave the disc blank, wash the ink around it — and it came out invisible: at
   61% across, the plate has already faded to bare paper, so a patch of paper
   laid on paper erases nothing. There was no mist there to open. The second
   drew the sun instead: a hairline rim with a wash inside. That was legible and
   wrong. A hairline circle is a diagram. Nothing in a landscape has a drawn
   edge, and the one on the page that does is the portrait frame.

   What works is the old technique the second attempt was reaching for and
   missed — 烘云托月. You do not paint the disc. You wash the sky around it, and
   the disc is what is left. So the light theme is four stops: lit paper at the
   centre, a quick falloff, the wash coming up outside it, and the wash going
   out to nothing. The rim is not drawn, it is where the wash begins.

   Dark keeps all four stops and inverts only the paint, because on a dark
   ground the sky around a moon is washed brighter rather than darker. Same
   shape, same proportions, different light — which is the point: it should read
   as one object seen twice, not as two ornaments.

   Dark goes one step further and makes it a crescent — see --orb-cut, and the
   note there about why the wipe has to be a wide soft brush rather than a bite.

   The box is therefore the whole halo, not the disc: no border-radius and no
   box-shadow, or the wash would be clipped at the very edge it exists to make.
   Roughly the inner three quarters of it is what you actually see; the rest is
   the wash running out. So width is not the size of the disc, and 3rem here
   puts about 43px of visible weather on a 1440 screen.

   Small is the point. This is one mark in a lot of empty paper, and the moment
   it is big enough to look at directly it stops being weather and starts being
   a logo. Two earlier passes were twice this size and both of them took the
   page over.

   x is a percentage of the plate, so it keeps its place in the picture at every
   width. y is half the bar instead, which pins it to the bar's centre line —
   the empty middle of the bar, level with the two buttons, and it stays there
   when a taller window makes the plate taller. translate rather than negative
   margins, so the two values mean the centre of the disc, which is how you
   would point at it.

   Nothing here animates and nothing is announced: .plate is aria-hidden and
   print.css drops it whole, which is right. On paper there is no theme to
   answer. */
.plate-top::before {
  content: "";
  position: absolute;
  left: 76%;
  top: calc(var(--bar) / 2);
  width: clamp(2.6rem, 4vw, 3.2rem);
  aspect-ratio: 1;
  translate: -50% -50%;
  background: var(--orb);
  -webkit-mask-image: var(--orb-cut);
  mask-image: var(--orb-cut);
}
/* Same 60rem as .tb-nav above, and for the same reason: below it the nav is
   gone and the bar is nothing but the two buttons, which then sit right where
   76% is. Move it into the half of the bar that just emptied — left of the
   buttons, right of the birds. Only x moves: y is tied to the bar, and the bar
   is the same height at every width. */
@media (max-width: 60rem) {
  .plate-top::before { left: 33%; }
}

/* Type over plate, not under it. */
.hero-grid { position: relative; z-index: 1; }

/* 尾花 — the mark that says the volume is over. Four rounds of drawn cloud
   motifs went in here and every one of them read as clip-art at hairline
   weight, which is the page's own argument coming back at me: the header of
   this file says the Chinese register is carried by the paper, the hairlines
   and the name, not by ornament. So the ending is a hairline too, just shorter
   than the ones above it. A rule that stops early is a full stop. */
.tailpiece {
  width: clamp(4rem, 9vw, 7rem);
  height: 1px;
  /* Tighter than it was. The plate now fills the space under the last section,
     so the long run of blank paper that used to separate the text from the end
     of the page is doing nothing except making the foot feel unfinished. */
  /* Bottom tightened: the rule is now followed by the "last updated" line and
     the two read as one unit, so only a breath sits between them. The 2–3.25rem
     that used to be here moved onto .updated; the foot is the same height. */
  margin: clamp(2.5rem, 5vw, 4rem) auto clamp(0.95rem, 1.9vw, 1.35rem);
  background: var(--rule-2);
}

/* The date under the rule. The least important line on the page — and still
   worth having, because a reader wants to know whether any of this is current.
   Specs are lifted from .place (the year/place tier) rather than invented: the
   page has one look for secondary marks, and a second one would be a second
   decision for the reader to make.
   The date itself is wrapped in <time datetime> so it is machine-readable;
   render.js sets the text, 14 August 2026 on the English page and
   2026 年 8 月 14 日 on the Chinese one. */
.updated {
  margin: 0 auto clamp(2rem, 4vw, 3.25rem);
  text-align: center;
  font-family: var(--font-mono);
  font-size: 0.66rem;
  font-weight: 400;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: var(--faint);
}
/* Takes up nothing when there is no date, or when data/updated.js is missing. */
.updated:empty { display: none; }

/* Three columns, and the middle one is the timeline standing on end — it is the
   rule between text and identity as well as a piece of content, which is one
   horizontal hairline fewer in a hero that had too many. */
.hero-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto minmax(0, 17.5rem);
  gap: clamp(1.75rem, 3.6vw, 3.4rem);
  align-items: start;
}
@media (max-width: 62rem) {
  .hero-grid { grid-template-columns: minmax(0, 1fr); }
}

/* Name, then what the name does, then a rule under the lot. The label used to
   sit out at the right end of the name line, where it read as a tag pinned to
   the name; stacked underneath it reads as a subtitle to it. Change the 0.62rem
   to move the label closer to or further from the name. */
.masthead {
  display: grid;
  gap: 1.05rem;          /* 名字 → 标签 */
  padding-bottom: 1.4rem;/* 标签 → 横线 */
  border-bottom: 1px solid var(--rule-2);
}
.names {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.2rem 0.95rem;
}
.name-lat {
  font-family: var(--font-display);
  font-size: clamp(1.02rem, 0.78rem + 0.8vw, 1.5rem);
  font-weight: 400;
  line-height: 1.1;
  letter-spacing: 0.26em;
  text-transform: uppercase;
  color: var(--ink-2);
  /* the tracking adds a trailing gap after the last letter; pull it back */
  margin-right: -0.26em;
}
.eyebrow {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  font-weight: 500;
  letter-spacing: 0.17em;
  text-transform: uppercase;
  color: var(--qing);
  /* On a narrow phone this label has to wrap. Balanced wrapping breaks it
     near the middle — beside the "·" — instead of stranding the last word
     alone on line two, and it keeps doing that whatever text you type in. */
  text-wrap: balance;
}

/* Large enough to be the mark on the page, small enough that it is still a
   name and not a monument. 王 carries ~0.055em of air on its left, so it is
   pulled back onto the same left edge as everything else in the column. */
.name-cn {
  font-family: var(--font-cn);
  font-size: clamp(2.05rem, 1.45rem + 1.9vw, 2.85rem);
  font-weight: 400;
  line-height: 1.05;
  letter-spacing: 0.06em;
  white-space: nowrap;
  margin-left: -0.055em;
  margin-right: -0.06em;
}

/* The hero is already split left/right, so splitting this again put the text
   in a 300px trench at desktop widths. It stacks at every size now: the
   paragraph gets the full column, the figures sit under it. */
/* The text column runs the full height of the hero so its last line can sit on
   the same baseline as the last line of the identity card. The hero is three
   columns of very different lengths; letting the short one stop early left a
   hole at the foot of the page. Now the slack falls between the paragraph and
   the figures, where it reads as air, and all three columns close together. */
.lede {
  align-self: stretch;
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
}

.intro {
  /* Two rows between the two hairlines: the paragraph takes whatever height is
     going, the counts stay their own height at the foot. The paragraph is then
     centred inside its row, so the air splits above and below it instead of
     piling up underneath. It used to sit at the top of that space, which read
     as a paragraph that had stopped rather than a paragraph placed there.
     The breathing room is padding on the paragraph, not a margin here, because
     padding is symmetric and rides with the centring — a margin on this side
     only would push the text off centre by its own height. Where the hero is a
     single column there is no spare height, the centring becomes a no-op and
     the padding is all that is left, which is the spacing it used to have. */
  grid-template-rows: minmax(0, 1fr) auto;
  align-items: center;
  row-gap: 0;
  /* The lede's type size, kept as a variable because the figures underneath
     have to be exactly as wide as the paragraph and are set much smaller.
     A plain "36em" on both would resolve against two different font sizes and
     leave the hairline short — it did, by 128px.
     Up 8% when the paragraph moved to Playfair italic. Not because the letters
     got smaller — measured, Playfair's x-height is 0.542 against Spectral's
     0.450, so they are physically larger — but because a high-contrast face
     puts far less ink on the paper at the same size, and the eye reads thin as
     small. 24.7px at 1440; still four lines, and one step further starts
     competing with the name. */
  --lede: clamp(1.12rem, 1.04rem + 0.32vw, 1.30rem);
  display: grid;
}

/* The three ends the standfirst describes, drawn instead of said. Stood on end
   in the middle column of the hero, it does two jobs at once. It is the route,
   and it is the rule dividing text from identity, so the horizontal hairline it
   used to add underneath the name is simply gone.
   All of it is 石青 — the earlier stages are that same colour held back, and the
   current stage is the only filled node. Add a fourth <li> and the rail carries
   it; the nodes redistribute themselves down the line.
   --node is the dot; the rail is centred on it, so changing one moves both. */
.arc {
  --node: 0.46rem;
  position: relative;
  align-self: stretch;           /* the hero grid is align-items:start */
  display: grid;
  align-content: space-between;
  justify-items: start;
  min-height: 17rem;             /* keeps the line honest on a short hero */
  padding-block: 0.3rem;
  font-family: var(--font-mono);
  font-size: 0.68rem;
  font-weight: 400;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--qing);
}
/* the rail — top to bottom, because here it is also the column rule */
.arc::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(var(--node) / 2 - 0.5px);
  width: 1px;
  background: color-mix(in srgb, var(--qing) 26%, transparent);
}
.arc li {
  position: relative;
  padding-left: 1.25rem;
  writing-mode: vertical-rl;     /* labels read downward, alongside the rail */
  /* Held back, not greyed out — same hue, a little thinner on the page. It was
     58% and looked right, but 13px type at 2.6:1 is not something you can read,
     only recognise. 85% clears 4.5:1 in both themes; what marks the current
     stage is the filled node and the heavier weight, which cost no contrast. */
  color: color-mix(in srgb, var(--qing) 85%, transparent);
}
.arc .now { color: var(--qing); font-weight: 500; }

/* the node, sitting on the rail at the head of its label */
.arc li::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  width: var(--node);
  height: var(--node);
  border: 1px solid currentColor;
  border-radius: 50%;
  background: var(--paper);
}
.arc .now::before { background: currentColor; }

/* Below 62rem the hero stops being two columns, so there is no middle to stand
   in. Same timeline, laid down: labels upright, nodes strung together by their
   own short connectors rather than by one rule running the whole height. */
@media (max-width: 62rem) {
  .arc {
    align-self: auto;
    align-content: start;
    min-height: 0;
    padding-block: 0;
  }
  .arc::before { content: none; }
  .arc li {
    writing-mode: horizontal-tb;
    padding: 0 0 0.95rem 1.35rem;
  }
  .arc li:last-child { padding-bottom: 0; }
  /* sat on the cap height of the label, not on the middle of the line box —
     the labels are all caps, so the line box has descender space nothing uses */
  .arc li::before { top: 0.2rem; }
  .arc li:not(:last-child)::after {
    content: "";
    position: absolute;
    top: 0.42rem;
    left: calc(var(--node) / 2 - 0.5px);
    width: 1px;
    height: calc(100% - 0.42rem);
    background: color-mix(in srgb, var(--qing) 30%, transparent);
  }
}

/* Capped, not stretched. Once the intro stopped splitting into two columns
   these sat in a full-width row, and two hairlines spanning half the page
   each made an index of two numbers look like a scoreboard. */
/* Both counted from the data files, never typed in — see renderFigures() in
   render.js. Five and two are not numbers that carry a 24pt setting; at that
   size the block read as a scoreboard for a score nobody is keeping. So it is
   one quiet line the width of the paragraph above it, footing the column the
   way a caption foots a plate. The number sits before its label rather than
   over it, because "5 PUBLICATIONS" is a phrase and reads in one go, where a
   stacked "2 / TOOLS" makes you assemble it. */
.figures {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.4rem 1.6rem;
  max-width: calc(36 * var(--lede));   /* 36em of the lede, not of this line */
  padding-top: 0.95rem;
  border-top: 1px solid var(--rule);
}
.figures > div { display: flex; align-items: baseline; gap: 0.42rem; }
.figures dt,
.figures dd {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  font-weight: 500;
  letter-spacing: 0.13em;
}
.figures dt {
  text-transform: uppercase;
  color: var(--muted);
  order: 2;                   /* label after the number, not under it */
}
.figures dd {
  color: var(--ink-2);
  font-variant-numeric: tabular-nums;
}

/* The lede reads in three weights: a run-up, the one line worth remembering,
   and the sentence saying what that line means in practice. It used to be a
   single four-sentence paragraph, and a four-sentence paragraph has no loudest
   word; everything in it arrives at the same volume, so nothing is kept.
   Splitting it also gives the column the height it was short of. The hero is as
   tall as the photograph beside it and one paragraph could not reach the foot,
   which is where the hole in the bottom-left corner came from.
   The three sit closer to each other than to anything else around them, so they
   still read as one thought and not as three. */
.lede-text {
  display: grid;
  gap: clamp(1.4rem, 2.6vw, 2rem);
}

/* The one line worth remembering, and it is set at reading size. It ran at
   display size for a while and the extra weight turned a working note into a
   slogan; a sentence that large is a claim being made at you rather than a
   thing being said. Body face, body size, one step of weight, same measure as
   the paragraphs around it. It is the only bold on the page, which is all the
   emphasis a single line needs.
   600 is a real cut of Spectral and is self-hosted, so this is a drawn bold
   and not the smeared one a browser makes when the weight is missing. */
.pull {
  max-width: 36em;
  font-size: var(--lede);
  font-weight: 600;
  line-height: 1.62;
  color: var(--ink);
  text-wrap: pretty;
}

/* Same size as the run-up above the big line and a shade lighter, so it reads
   as the note under that line rather than as a second paragraph. */
.coda {
  max-width: 36em;
  font-size: var(--lede);
  font-weight: 300;
  line-height: 1.62;
  color: var(--muted);
  text-wrap: pretty;
}

/* Sized as a short paragraph, not as a pull quote. It runs several sentences
   now, and at pull-quote size that turned into a wall of large type. */
.standfirst {
  /* Playfair Display Italic, and it is the only thing on either page set in
     it. The Chinese page opens in brush calligraphy; the Latin equivalent of
     handwriting is not a script face — a script across a whole paragraph reads
     as a wedding invitation — it is the italic, which is literally what the
     chancery hand became when printing took it in. So both pages open in a
     written hand, each in its own script, and neither is a translation of the
     other's idea.
     Playfair's weight axis starts at 400, so the 300 below clamps up and this
     paragraph is a step darker than the Spectral it replaced. Size and measure
     are untouched all the same — measured at 1440 it still breaks to four
     lines, exactly as before, because Playfair's larger x-height buys back
     what its wider italic spends. The Chinese page sets its own face on
     .lede-text and overrides this rule in zh.css. */
  font-family: var(--font-lede);
  font-style: italic;
  /* 36em is the last step that still breaks at ~62 characters a line. 38em
     drops a line but jumps the measure to 82, which is past comfortable. */
  max-width: 36em;
  /* Equal top and bottom, so the paragraph stays centred between the rule under
     the name and the rule over the counts however much spare height there is. */
  padding-block: clamp(1.9rem, 3.4vw, 2.9rem);
  font-size: var(--lede);
  font-weight: 300;
  line-height: 1.62;
  color: var(--ink-2);
  text-wrap: pretty;
}
/* Roman inside an italic paragraph. The rule is older than the web: when the
   run of text is already italic, the thing you are marking goes upright,
   because otherwise there is nothing to see. 石青 does the rest. */
.standfirst em {
  font-style: normal;
  color: var(--qing);
}
/* The one line worth remembering, marked inside the paragraph rather than
   lifted out of it. 600 is a real cut of Spectral and is self-hosted, so this
   is a drawn bold and not the smeared one a browser makes when the weight is
   missing. It is the only bold in the running text of the whole site. */
.standfirst b {
  font-weight: 600;
  color: var(--ink);
}

/* right rail of the hero: portrait, standing, facts, links
   Everything in here is set one notch smaller than it wants to be. It is the
   caption to the page, not the page, and it was running 98px longer than the
   text column beside it — the whole of the hole in the bottom-left corner was
   this rail being too loud. Type only; the photograph stayed 13rem, because
   shrinking that as well makes the rail the short column and puts the same
   hole back on the other side. */
.ident { display: grid; gap: 0.92rem; }

/* Once the hero stops being two columns the rail would be mostly photograph, so
   the portrait steps aside and the standing, facts and links stack next to it.
   The portrait has to be told to span the three rows — left to place itself it
   took one cell and left the other three items to fill a 2×2 grid full of
   holes. Delete assets/img/portrait.jpg and the :has() rule below puts the rail
   back to a single column instead of indenting everything past a missing photo. */
@media (max-width: 62rem) {
  .ident {
    grid-template-columns: minmax(0, 16rem) minmax(0, 1fr);
    grid-template-rows: repeat(3, auto);
    align-items: start;
    column-gap: clamp(1.5rem, 4vw, 2.75rem);
  }
  .ident > .portrait { grid-column: 1; grid-row: 1 / -1; }
  .ident > :not(.portrait) { grid-column: 2; }
  .ident:not(:has(> .portrait)) { grid-template-columns: minmax(0, 1fr); }
  .ident:not(:has(> .portrait)) > * { grid-column: 1; }
}
@media (max-width: 34rem) {
  .ident { grid-template-columns: minmax(0, 1fr); grid-template-rows: none; }
  .ident > .portrait,
  .ident > :not(.portrait) { grid-column: 1; grid-row: auto; }
}

/* Sized against the left column's height, not the column it sits in. The
   portrait is square, so every pixel of width is a pixel of extra height in a
   rail that already carries the standing, the facts and the links, and the
   rail has to finish level with the text beside it. 11.5rem is what balances
   the two columns now that the big line has come back down to reading size;
   it was 13rem while that line was display type. Change one and re-check the
   other, they are the two ends of the same seesaw. */
.portrait {
  max-width: 11.5rem;
  border: 1px solid var(--rule-2);
  padding: 0.4rem;
  background: var(--paper-2);
}

/* Promoted out of the facts list: what you are doing now is the one thing a
   reader should get without reading, so it is set as a heading rather than
   as a labelled row. */
.status {
  font-family: var(--font-display);
  font-size: 1.02rem;
  line-height: 1.25;
  color: var(--ink);
  padding-bottom: 0.55rem;
  border-bottom: 1px solid var(--rule-2);
  text-wrap: balance;
}
.portrait img { aspect-ratio: 1; object-fit: cover; width: 100%; }

.facts { display: grid; gap: 0; }
.facts > div {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 0.03rem;
  padding: 0.36rem 0;
  border-top: 1px solid var(--rule);
}
.facts > div:first-child { border-top: none; padding-top: 0; }
/* --muted, not --faint. These labels are the field names, so they carry the
   only clue to what each value is, and at 10pt the faint tier measured 2.6:1
   against the page — visible, but not readable. --muted is an existing token
   and lands at 4.9:1 light / 5.9:1 dark. */
.facts dt {
  font-family: var(--font-mono);
  font-size: 0.63rem;
  font-weight: 500;
  line-height: 1.35;      /* the size stays; only the leading comes in */
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--muted);
}
/* Same reason as .eyebrow — a fact that has to wrap should break at its
   separator, not drop one word onto a line of its own. */
.facts dd {
  font-size: 0.86rem;
  line-height: 1.42;
  color: var(--ink-2);
  text-wrap: balance;
}

.links {
  display: flex;
  flex-wrap: wrap;
  gap: 0.45rem 1.15rem;
  padding-top: 0.85rem;
  border-top: 1px solid var(--rule-2);
}
.links a {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  font-weight: 500;
  letter-spacing: 0.06em;
  color: var(--qing);
  text-decoration: none;
  border-bottom: 1px solid var(--qing-soft);
  transition: border-color 0.2s;
}
.links a:hover { border-bottom-color: var(--qing); }
/* The ↗ marks a link that leaves the site. Generating it in CSS keeps the
   markup in index.html down to the label — the part you actually edit. */
.links a[href^="http"]::after {
  content: " ↗";
  font-size: 0.85em;
  opacity: 0.65;
}

/* ═══ sections ════════════════════════════════════════════════════════ */
/* 版心 — the ruled boundary of the text block on an old printed page.
   Two hairlines, nothing else. */
main {
  border-top: 1px solid var(--rule-2);
  border-bottom: 1px solid var(--rule-2);
  position: relative;            /* the bottom plate hangs off this */
}
.frame {
  max-width: var(--shell);
  margin-inline: auto;
  padding-inline: var(--gutter);
  border-left: 1px solid var(--rule);
  border-right: 1px solid var(--rule);
}
@media (max-width: 48rem) {
  .frame { border-inline: none; }
}

/* Same bargain as the hero: one screen, one section. A section shorter than the
   screen is centred in it rather than parked at the top, so its own rule at the
   top and the next section's rule sit an equal distance away and the section
   reads as a page rather than as the start of a scroll. A section longer than
   the screen simply grows past it; min-height sets a floor, not a ceiling.
   box-sizing is border-box throughout, so the padding is inside the screen and
   a full-height section is exactly one screen, not one screen plus padding. */
.sec {
  display: grid;
  grid-template-columns: var(--rail) minmax(0, 1fr);
  gap: 0 clamp(1.5rem, 3.5vw, 3.5rem);
  min-height: 100svh;
  align-content: center;
  padding-block: clamp(3rem, 6vw, 5.5rem);
  border-top: 1px solid var(--rule);
}
.sec:first-child { border-top: none; }
/* Anchors from the top bar have to clear the top bar itself, which is sticky
   and would otherwise sit on top of the first line of whatever you jumped to. */
.sec, .hero { scroll-margin-top: var(--bar); }
@media (max-width: 62rem) {
  .sec { grid-template-columns: minmax(0, 1fr); gap: 1.6rem; }
}

/* the rail — the numeral sits flush, the title steps in behind it. That
   small offset is the whole of the 错落; keep it. */
.rail { position: sticky; top: 5rem; align-self: start; }
@media (max-width: 62rem) {
  .rail { position: static; }
}

.secnum {
  display: block;
  font-family: var(--font-cn);
  font-size: 2.6rem;
  line-height: 1;
  color: var(--rule-2);
  user-select: none;
}
.sectitle {
  margin-top: 0.5rem;
  padding-left: 0.12em;
  font-family: var(--font-display);
  font-size: 1.72rem;
  font-weight: 400;
  line-height: 1.08;
  letter-spacing: -0.005em;
}
.railnote {
  margin-top: 1.15rem;
  max-width: 16ch;
  font-size: 0.83rem;
  line-height: 1.5;
  color: var(--faint);
}
@media (max-width: 62rem) {
  .rail { display: flex; align-items: baseline; flex-wrap: wrap; gap: 0 0.9rem; }
  .secnum { font-size: 1.7rem; }
  .sectitle { margin-top: 0; padding-left: 0; }
  .railnote { flex-basis: 100%; margin-top: 0.5rem; max-width: 46ch; }
}

/* ─── entries: education, projects, tools ────────────────────────────── */
/* 96px between one entry's last line and the next entry's first line was the
   old setting, half above the hairline and half below. On a page where each
   section now gets a whole screen, that much air inside the list only made the
   list look unfinished; the rule between entries already does the separating,
   so the space around it can come down to what a rule needs to breathe. */
.entries > li + li {
  margin-top: 1.7rem;
  padding-top: 1.7rem;
  border-top: 1px solid var(--rule);
}

/* The shared grid. The third track is declared rather than left to `auto`
   precisely because `auto` measures whatever happens to be in it: an entry
   carrying a crest came out 18px narrower than one without, so Education and
   Projects ended their lines in different places. A fixed lane is the same
   width whether anything is standing in it or not. */
.entry {
  display: grid;
  grid-template-columns: var(--label) minmax(0, var(--measure)) var(--aside);
  gap: 0 var(--colgap);
  align-items: start;
}
@media (max-width: 52rem) {
  .entry { grid-template-columns: minmax(0, 1fr); gap: 0.35rem; }
}

.when {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  font-weight: 500;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--muted);
  padding-top: 0.42em;
  white-space: nowrap;
}
.when .now { color: var(--qing); }

.entry h3 {
  font-family: var(--font-display);
  font-size: 1.36rem;
  font-weight: 400;
  line-height: 1.22;
  text-wrap: balance;
}
/* A real underline sits on the text's own baseline metrics and can be
   offset clear of the descenders. A border-bottom can't — on a title that
   wraps to three lines it draws three heavy rules. */
.entry h3 a,
.pub-title a {
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-decoration-color: var(--rule-2);
  text-underline-offset: 0.19em;
  transition: color 0.2s, text-decoration-color 0.2s;
}
.entry h3 a:hover,
.pub-title a:hover { color: var(--qing); text-decoration-color: var(--qing); }

.org { margin-top: 0.3rem; color: var(--ink-2); font-size: 1.02rem; }

/* Hangs off the institution line, a step quieter than it. */
.supervisor { margin-top: 0.26rem; color: var(--muted); font-size: 0.92rem; }
.supervisor a { color: var(--qing); text-decoration: none; border-bottom: 1px solid var(--qing-soft); }
.supervisor a:hover { border-bottom-color: var(--qing); }

/* Sits under the institution now, not out in the right-hand column — that
   column carries the crest. Same quiet mono treatment, just left-aligned. */
.place {
  margin-top: 0.45rem;
  font-family: var(--font-mono);
  font-size: 0.66rem;
  font-weight: 400;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: var(--faint);
}

/* The crest is an aid to recognition, not a second logo wall: one ink-toned
   mark per entry, sized by height so a square crest and a wide wordmark read
   at the same weight. Drop the `filter` line to get the official colours. */
/* A fixed-width box with the mark centred in it. Aligning the right edges
   instead looked askew: a wide wordmark and a round seal have very different
   widths, so matching edges scatters their centres. One shared centre line. */
/* 校徽大小只有 --logo-h 一个旋钮，居中盒子跟着它一起长，所以改大不会被裁掉。
   单个校徽看着偏大偏小，在 data/education.js 里给它写 logoScale。 */
.entry-logo {
  --logo-h: 2.7rem;
  justify-self: end;
  width: calc(var(--logo-h) * 2.1);
  padding-top: 0.35rem;
  display: flex;
  justify-content: center;
}
.entry-logo img {
  height: calc(var(--logo-h) * var(--logo-scale, 1));
  width: auto;
  max-width: 100%;
  object-fit: contain;
  filter: grayscale(1) opacity(0.7);
}
:root[data-theme="dark"] .entry-logo img { filter: grayscale(1) invert(1) opacity(0.72); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .entry-logo img {
    filter: grayscale(1) invert(1) opacity(0.72);
  }
}

/* Stacked: the crest gets its own row under the text, so it lines up on the
   left margin with everything else rather than floating in a centred box. */
@media (max-width: 52rem) {
  .entry-logo {
    justify-self: start;
    width: auto;
    padding-top: 0.9rem;
    justify-content: flex-start;
  }
  .entry-logo { --logo-h: 2.2rem; }
}

.notes { margin-top: 0.85rem; color: var(--muted); font-size: 1rem; }
.notes li {
  position: relative;
  padding-left: 1.1rem;
  line-height: 1.58;
}
.notes li + li { margin-top: 0.5rem; }
.notes li::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.72em;
  width: 0.42rem;
  height: 1px;
  background: var(--rule-2);
}
/* Italic only — no colour lift. What goes in here is Latin running into English
   (in silico, in vitro, de novo), which is a convention of setting, not an
   emphasis. Darkening it turns a spelling rule into a shout, and it made the
   same term look two different ways on one page, since <em> inside .skills has
   no rule of its own. */
.notes em { font-style: italic; }
.notes a { color: var(--qing); text-decoration: none; border-bottom: 1px solid var(--qing-soft); }
.notes a:hover { border-bottom-color: var(--qing); }

.stack {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem 0.5rem;
  margin-top: 1rem;
}
.stack span {
  font-family: var(--font-mono);
  font-size: 0.64rem;
  font-weight: 400;
  letter-spacing: 0.05em;
  color: var(--muted);
  border: 1px solid var(--rule-2);
  border-radius: 2px;
  padding: 0.2rem 0.5rem;
}
.repo-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem 1.1rem;
  margin-top: 0.9rem;
  font-family: var(--font-mono);
  font-size: 0.66rem;
  letter-spacing: 0.06em;
  /* --muted, not --faint: at 11px this line measured 2.6:1 and read as an
     artefact rather than as text. Same fix as the labels in the hero card. */
  color: var(--muted);
}
/* Written by render.js, shown only by print.css. See the note there. */
.repo-url { display: none; }

/* ─── publications ───────────────────────────────────────────────────── */
.pubs > li + li {
  margin-top: 1.6rem;
  padding-top: 1.6rem;
  border-top: 1px solid var(--rule);
}

.pub {
  display: grid;
  grid-template-columns: var(--label) minmax(0, var(--measure)) var(--aside);
  gap: 0 var(--colgap);
  align-items: start;
}
@media (max-width: 52rem) {
  .pub { grid-template-columns: minmax(0, 1fr); gap: 0.4rem; }
}
/* .pub sets display:grid, which outranks the browser's own rule for the
   hidden attribute, so a folded paper would stay on screen without this. */
.pubs > li[hidden] { display: none; }

.pub-year {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  font-weight: 500;
  letter-spacing: 0.09em;
  color: var(--muted);
  padding-top: 0.42em;
}

.pub-title {
  font-family: var(--font-display);
  font-size: 1.28rem;
  font-weight: 400;
  line-height: 1.26;
  text-wrap: pretty;
}

.authors { margin-top: 0.5rem; font-size: 0.97rem; color: var(--muted); }
.authors .me { color: var(--ink); font-weight: 600; }

.au-more {
  font-family: var(--font-mono);
  font-size: 0.64rem;
  font-weight: 500;
  letter-spacing: 0.05em;
  color: var(--qing);
  background: none;
  border: none;
  border-bottom: 1px dotted var(--qing);
  padding: 0 0 1px;
  margin-left: 0.3em;
  cursor: pointer;
  white-space: nowrap;
}
.au-more:hover { background: var(--qing-soft); }

/* The fold at the foot of the publication list. It sits on the same hairline
   the entries use between each other, and indents to the title column, so it
   reads as the list continuing rather than as a control bolted underneath.
   改默认显示几篇：assets/js/render.js 搜 PUBS_SHOWN。 */
.more {
  display: block;
  width: 100%;
  margin-top: 2.35rem;
  padding: 0.9rem 0 0;
  /* Lines up under the text column, so it has to be built from the same two
     tokens the grid is. It spelled 8.5rem and the gap out by hand, which meant
     any change to --label quietly left this one button behind. */
  padding-left: calc(var(--label) + var(--colgap));
  font-family: var(--font-mono);
  font-size: 0.68rem;
  font-weight: 500;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--muted);
  text-align: left;
  background: none;
  border: none;
  border-top: 1px solid var(--rule);
  cursor: pointer;
  transition: color 0.2s;
}
.more:hover { color: var(--qing); }
@media (max-width: 52rem) { .more { padding-left: 0; } }

.venue { margin-top: 0.42rem; font-size: 1rem; color: var(--ink-2); }
.venue em { font-style: italic; }
.venue .vol { color: var(--muted); }
.doi-print { display: none; }

.pub-meta {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.4rem;
  padding-top: 0.45em;
}
@media (max-width: 52rem) {
  .pub-meta { flex-direction: row; align-items: center; flex-wrap: wrap; margin-top: 0.75rem; }
}

.tag {
  font-family: var(--font-mono);
  font-size: 0.63rem;
  font-weight: 500;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--muted);
  text-decoration: none;
  border: 1px solid var(--rule-2);
  border-radius: 2px;
  padding: 0.2rem 0.5rem;
  white-space: nowrap;
  transition: color 0.2s, border-color 0.2s;
}
a.tag:hover { color: var(--qing); border-color: var(--qing); }
.tag.kind { border-style: dashed; color: var(--faint); }

/* ─── skills & interests ─────────────────────────────────────────────── */
.subhead {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  font-weight: 500;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  color: var(--muted);
  padding-bottom: 0.7rem;
  border-bottom: 1px solid var(--rule);
}
.subhead + * { margin-top: 1.25rem; }
/* Air above the second subhead, so the rule under it reads as the top of a new
   list rather than the bottom of the previous one. Not on the first, which is
   already sitting against the section's own top. */
.subhead:not(:first-child) { margin-top: 2.5rem; }

/* The cards stop on the same right edge as the lines above them: the padding
   is the aside lane, kept empty here.

   The min is 13rem so that three cards still sit on one row inside that
   narrower band. Raising it past about 14rem drops one card onto a second row
   and leaves a hole in the bottom right, which reads as a card missing rather
   than as a deliberate two-column set. Widen the cards and you lose the row;
   the row is worth more. */
.interests {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
  gap: 1.9rem clamp(1.25rem, 2.4vw, 2.25rem);
  /* The same three tracks the lines above are built from, added up. Without
     the cap the cards keep growing on a wide screen while the entries stop at
     --measure, and the right edge splits in two again above about 1500px. */
  max-width: calc(var(--label) + var(--measure) + var(--aside) + 2 * var(--colgap));
  padding-right: calc(var(--aside) + var(--colgap));
}
/* Once the columns have stacked there is no aside lane to keep clear, and the
   padding would just be eating a phone's width. */
@media (max-width: 52rem) {
  .interests { padding-right: 0; }
}
/* No rule of its own. The subhead already draws one full-width line directly
   above these cards; a second line per card put four rules inside 30px of each
   other and made the gap between them read as a dead band. The column gaps
   separate the cards perfectly well on their own.

   align-content:start matters — grid items stretch to the tallest cell, and
   with auto rows the default `stretch` spreads that slack *between* the rows,
   so a short column's heading drifts down out of line with its neighbours. */
.interests li {
  display: grid;
  gap: 0.42rem;
  align-content: start;
}
.interests h4 {
  font-family: var(--font-display);
  font-size: 1.3rem;
  font-weight: 400;
  line-height: 1.2;
  text-wrap: balance;
}
.interests p { color: var(--muted); font-size: 0.97rem; line-height: 1.55; }
.interests a { color: var(--qing); text-decoration: none; border-bottom: 1px solid var(--qing-soft); }
.interests a:hover { border-bottom-color: var(--qing); }

.interests + .subhead { margin-top: 2.7rem; }

.skills > div {
  display: grid;
  grid-template-columns: var(--label) minmax(0, var(--measure)) var(--aside);
  gap: 0 var(--colgap);
  padding: 0.9rem 0;
  border-top: 1px solid var(--rule);
}
.skills > div:first-child { border-top: none; padding-top: 0; }
/* 52rem, the same width the entries stack at. They used to part company at
   44rem, so between the two there was a band where a degree had already
   stacked and a skill row was still in columns. */
@media (max-width: 52rem) {
  .skills > div { grid-template-columns: minmax(0, 1fr); gap: 0.2rem; }
}
.skills dt {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  font-weight: 500;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: var(--muted);
  padding-top: 0.42em;
}
/* No max-width of its own. It used to carry `46ch`, which is the same idea as
   --measure said in a different unit, and the two disagreed by 250px. */
.skills dd { color: var(--ink-2); font-size: 1rem; }
.skills dd b { font-weight: 600; color: var(--ink); }
.skills dd i { font-style: italic; color: var(--faint); }

/* ─── footer ─────────────────────────────────────────────────────────── */
.colophon { padding-block: clamp(2.5rem, 5vw, 4rem); }
.colophon p { max-width: 42ch; font-size: 0.9rem; color: var(--faint); line-height: 1.55; }
.colophon .orcid {
  display: block;
  margin-top: 0.7rem;
  font-family: var(--font-mono);
  font-size: 0.68rem;
  letter-spacing: 0.06em;
  color: var(--muted);
  text-decoration: none;
}
.colophon .orcid:hover { color: var(--qing); }
