/* ─────────────────────────────────────────────────────────────────────────────
   Gushi — the shared design system for the static site.

   Three structural ideas, and everything below follows from them.

   1. THE PAGE IS ONE FLUID GRID, NOT A STACK OF BANDS.
      Every section is a `.row`: a narrow RAIL carrying a number and a label,
      and a wide COLUMN carrying the heading and the content, with a hairline
      across both at the top. Content that needs more room (`.bleed`) breaks
      left across the rail to the full page width.

      FLUID means it uses the screen it is given. --pad, --rail, --gutter, the
      radii and every type size are `clamp()`ed against the viewport, and the
      only fixed number is --maxw, which stops the line length running away on
      a very wide display. Running text is capped in CHARACTERS (--measure:
      66ch), not pixels, so it stays readable at every size without a
      breakpoint. There are two media queries in this file and both of them are
      about layout collapsing, not about resizing type.

      This replaced alternating light/dark bands. Banding divides by shouting,
      and a page built from it reads as a template whatever is written on it. A
      rule divides by whispering, and it lets the whole page sit on one ground —
      which is what a document does. The rail is also load-bearing for reading:
      it is sticky, so on a long section you can always see which one you are
      in, without a line of JavaScript.

   2. THE RAMPS ARE ABSOLUTE, THE SURFACES ARE RELATIVE.
      `--ink-*` and `--paper-*` are the palette and never move. The semantic
      variables (--bg, --fg, --fg-2, --muted, --hair, --card) point at one ramp,
      and only the block under "Scheme" decides which. So `.surface-sunken`
      means "one step into the ground", never "the other ground" — which is
      what makes bringing dark back a one-block change rather than an audit.

      The site used to be dark-only with `.on-ink` / `.on-paper` naming absolute
      grounds. Those classes are gone. If you find one, it is stale.

   3. THE MEDIA IS MADE OF THE PRODUCT, AND THE MOTION IS TOO.
      Built from apps/web/public/data (the files the syllabus page reads) and
      apps/web/public/assets/shots (real captures of the running app):

        .flight    120 real HSK characters coming toward the reader as the
                   door page scrolls — CSS scroll-driven, no JavaScript
        .screens   a collage of real app captures
        .cardwall  real cards, pinyin in the frozen tone colours

      Plus licensed photography and Ming calligraphy in assets/media/ — see
      docs/brand.md and /credits, where attribution is a licence condition.

      The tone colours are why the generated pieces read as information rather
      than texture: --t1..--t5 mean the same thing here as in the app, so a
      learner who has used Gushi recognises them.

   4. THE SITE IS LIGHT. One ground, no scheme switch. It followed
      prefers-color-scheme once, which meant every reader with their OS in dark
      mode saw the ink version — a second design nobody was actually looking at.
      The ink ramp is still declared because docs/brand.md tracks one palette
      across four consumers, and because re-enabling dark is one @media block
      (there is a commented one at the end of this file). Nothing resolves to it
      today.

   No build step for this file, and none wanted: these pages must stay readable
   and indexable with JavaScript blocked, and a stylesheet that needs compiling
   is one more thing between the source and what the crawler sees.
   ──────────────────────────────────────────────────────────────────────────── */

/* The palette, the grid metrics and the type scale used to live here. They are
   in tokens.css now, because the Teaching Platform needs the same values and a
   second copy of these hexes would make PaletteTests.swift a liar the first
   time one of them moved. Both stylesheets <link> it; site.css assumes nothing
   about load order beyond that tokens.css comes first.

   See apps/web/public/assets/tokens.css and docs/brand.md. */


/* `.surface-sunken` is ONE STEP INTO the current ground, not the other ground.
   In dark mode it is #1B1A14, a barely-deeper ink — never paper. */
.surface        { background: var(--bg);        color: var(--fg); }
.surface-sunken { background: var(--bg-sunken); color: var(--fg); }

/* ── Base ─────────────────────────────────────────────────────────────────── */
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html { scroll-behavior: smooth; scroll-padding-top: calc(var(--pad) + 96px); }
html, body { margin: 0; padding: 0; }
body {
  background: var(--bg); color: var(--fg);
  font-family: 'Inter', system-ui, -apple-system, sans-serif;
  font-size: var(--fs-body); line-height: 1.62;
  -webkit-font-smoothing: antialiased;
  /* No `overflow-x: hidden` here on purpose. It hides overflow bugs instead of
     fixing them, and a phone-width layout that only looks right because the
     spill is clipped is not a phone-width layout. If something scrolls
     sideways, that is a bug in the component, and it should be visible. */
}
img, video { max-width: 100%; display: block; }
a { color: inherit; text-decoration: none; }
h1, h2, h3, h4 { margin: 0; font-weight: 500; }
p { margin: 0; }
::selection { background: rgba(200, 69, 46, .3); color: var(--fg); }

/* One focus rule for the whole site. Before this, exactly two controls had a
   visible focus ring and everything else was a guess. */
:focus-visible { outline: 2px solid var(--seal-text); outline-offset: 2px; border-radius: 3px; }

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0, 0, 0, 0); border: 0;
}

/* A skip link that actually appears. The previous one was `.sr-only` with no
   :focus escape — a keyboard user could focus it and see nothing at all. */
.skip { position: absolute; left: -9999px; top: 0; z-index: 100; }
.skip:focus {
  left: 12px; top: 12px; padding: 10px 16px; font-size: var(--fs-small); font-weight: 600;
  background: var(--card); color: var(--fg); border: 1px solid var(--fg); border-radius: 10px;
}
main:focus { outline: none; }

.serif { font-family: 'Fraunces', Georgia, serif; font-variant-numeric: tabular-nums lining-nums; }
.hz { font-family: 'Noto Serif SC', 'Songti SC', serif; }

.wrap    { max-width: var(--maxw); margin: 0 auto; padding: 0 var(--pad); }
.measure { max-width: var(--measure); }

/* ── The grid ───────────────────────────────────────────────────────────────
   .doc      the page, one column of rows
   .row      one section: rail + column, with a rule across the top
   .rail     the number and the label. Sticky, so on a long section you can
             always see which one you are in — CSS only, no scroll listener.
   .col      heading and content
   .row--hero    the opening, unnumbered and unruled
   .row--full    content spans rail and column (the three doors, a wide table)

   The alternating light/dark bands the previous version used are GONE. Banding
   is what made the page look like a template: it divides by shouting. A rule
   divides by whispering, and it lets the whole page sit on one ground, which is
   what a document does. */
/* Fluid: the page uses the screen it is given. --pad scales from 16px on a
   phone to 48px on a desktop, and it only stops growing at --maxw so a 27-inch
   display does not get a 2,000px line of text. It is NOT a fixed centred
   column — that is what made it sit in the middle of a big screen looking like
   a document someone forgot to maximise. */
.doc { max-width: var(--maxw); margin: 0 auto; padding: 0 var(--pad); }

/* THE SECTION COUNTER IS RESET ON `main`, NOT ON `.doc`, and that is a fix
   rather than a preference. A full-bleed photo band has to break out of the
   document column, which means it ends one `.doc` and starts another — so
   `/app` and `/teaching` have carried TWO `.doc` blocks for a long time and
   their numbering silently restarted at 01 halfway down the page. Nobody
   noticed because the second 01 was far from the first.

   The home page then grew three `.doc` blocks in a row and printed 01 three
   times in one screen, which is what surfaced it. Resetting per page instead of
   per column numbers the sections the way a reader counts them, and it does not
   care how many bands the layout needs. */
main { counter-reset: row; }

.row {
  display: grid; grid-template-columns: var(--rail) minmax(0, 1fr);
  gap: 0 var(--gutter);
  border-top: 1px solid var(--hair);
  padding: var(--section) 0;
  counter-increment: row;
}
.row--hero { border-top: 0; counter-increment: none; padding-top: clamp(56px, 9vw, 104px); padding-bottom: calc(var(--section) * .7); }

/* A full-width row has no room for a rail beside it, so the rail becomes a
   strap above — the same shape it takes on a phone, which keeps one idea
   rather than two. Putting the rail in column 1 and spanning the column across
   both is what you reach for first, and it lays them on top of each other. */
.row--full { grid-template-columns: minmax(0, 1fr); }
.row--full > .rail { position: static; display: flex; align-items: baseline; gap: 10px; margin-bottom: 26px; }
.row--full > .rail .n { margin-bottom: 0; }
.row--tight { padding: calc(var(--section) * .6) 0; }

.rail { position: sticky; top: calc(var(--pad) + 92px); align-self: start; }
.rail .n {
  display: block; font-family: 'Fraunces', Georgia, serif; font-size: 13px;
  font-variant-numeric: tabular-nums lining-nums; color: var(--muted); margin-bottom: 6px;
}
.rail .n::before { content: counter(row, decimal-leading-zero); }
.rail .l {
  display: block; font-size: 11.5px; font-weight: 600; letter-spacing: .12em;
  text-transform: uppercase; color: var(--fg-2); line-height: 1.45;
}

.col { min-width: 0; }
.col > * + * { margin-top: clamp(16px, 1.4vw, 24px); }
.col > h2 + p, .col > h2 + .lead { margin-top: clamp(12px, 1.1vw, 18px); }
.col > h2 { max-width: 22ch; }
.col > .lead, .col > .body, .col > p, .col p.body { max-width: var(--measure); }

/* Content that needs more room than the column gives breaks LEFT, back across
   the gutter and the rail, to the full document width. Tables mostly. This is
   what keeps the grid from feeling rigid: the rail is a place for a label, not
   a wall, and a syllabus table is allowed to use the page.

   It bleeds left rather than right so the right edge stays flush with every
   heading above it — a table that overhangs on the right reads as a mistake. */
.col > .bleed { margin-left: calc(-1 * (var(--rail) + var(--gutter))); }

/* The section label was a `.kicker` above the heading before the rail existed.
   In the grid it lives in the rail, so any left in the column would be a
   duplicate. */
.col > .kicker { display: none; }

/* ── Type ─────────────────────────────────────────────────────────────────── */
.kicker {
  display: block; font-size: 12px; font-weight: 600; letter-spacing: .14em;
  text-transform: uppercase; color: var(--muted); margin-bottom: 14px;
}
h1, .h1 {
  font-family: 'Fraunces', Georgia, serif; font-weight: 500;
  font-size: var(--fs-h1); line-height: 1.1; letter-spacing: -.7px; color: var(--fg);
}
h2, .h2 {
  font-family: 'Fraunces', Georgia, serif; font-weight: 500;
  font-size: var(--fs-h2); line-height: 1.18; letter-spacing: -.4px; color: var(--fg);
}
h3, .h3 { font-family: 'Fraunces', Georgia, serif; font-size: var(--fs-h3); font-weight: 600; letter-spacing: -.2px; color: var(--fg); }
.lead { color: var(--fg-2); font-size: var(--fs-lead); line-height: 1.55; margin-top: 16px; max-width: var(--measure); }
.body { color: var(--fg-2); margin-top: 16px; max-width: var(--measure); }
.body strong, .lead strong { color: var(--fg); font-weight: 600; }
.fine { font-size: var(--fs-fine); color: var(--muted); line-height: 1.55; max-width: var(--measure); }
/* A global `a` inherits its colour, which makes a link inside small print
   invisible as a link. Underline it and lift it. */
.fine a, .body a, .lead a { color: var(--seal-text); text-decoration: underline; text-underline-offset: 2px; }
.fine a:hover, .body a:hover, .lead a:hover { text-decoration-thickness: 2px; }
.head { margin-bottom: 32px; }

/* The opening. Heading and lead sit across the whole grid; the CTA sits under
   them in the column position, so the eye already learns where the column is
   before the first rule appears. */
.row--hero > .col { grid-column: 1 / -1; min-width: 0; }
/* The flight is a backdrop layer inside the hero, so the hero is the
   positioning context and everything else in it needs a stacking order. */
.row--flight { position: relative; isolation: isolate; }
.row--flight > .col { position: relative; z-index: 1; }
/* The flight needs a stage. Without a min-height the hero is only as tall as
   two lines of text and there is nowhere for a character to travel. */
.row--flight { min-height: clamp(460px, 66vh, 820px); display: grid; align-content: center; padding-bottom: clamp(48px, 6vw, 110px); }
.row--hero h1 { max-width: 20ch; letter-spacing: -.03em; line-height: 1.04; }
.row--hero .lead { font-size: clamp(18px, 1.5vw, 24px); max-width: 52ch; margin-top: clamp(18px, 1.8vw, 30px); }
.row--hero .cta-row { margin-top: clamp(26px, 2.6vw, 44px); }

/* A hero that carries a figure splits: copy left, image right. Centring the
   screenshot under a left-aligned headline leaves the right half of a wide
   screen empty and the picture floating in the middle of nothing. */
.hero-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, .82fr); gap: clamp(28px, 4vw, 80px); align-items: center; }
.hero-split > figure { margin: 0; }
.hero-split .shot { margin-inline: 0; }
@media (max-width: 940px) {
  .hero-split { grid-template-columns: 1fr; gap: clamp(24px, 4vw, 40px); }
  .hero-split .shot { margin-inline: auto; }
}

/* The display size is the door's alone. /teaching's German H1 is
   "Sie wissen nicht, was Ihre Schüler zwischen zwei Stunden gelernt haben." —
   at display size it would become a five-line tower whatever the measure. */
.row--display h1 { font-size: var(--fs-display); max-width: 16ch; }

/* ── Full-bleed bands ──────────────────────────────────────────────────────
   A `.band` is a sibling of `.doc`, not a child of it — which is why the grid
   container is opened per page rather than in the nav partial. The usual trick
   (`width:100vw; margin-left:calc(50% - 50vw)`) is not used here on purpose:
   100vw includes the scrollbar, so it overflows by 15px on every desktop
   browser and you end up reaching for `overflow-x:hidden`, which hides real
   bugs. A sibling needs no trick. */
.band { position: relative; }
.band img { width: 100%; display: block; }

/* Photography: cropped to a chosen height so the band is a decision, not
   whatever aspect the file happens to be. */
.band--photo { height: clamp(280px, 46vw, 620px); overflow: hidden; }
.band--photo img { height: 100%; object-fit: cover; }
.band--short { height: clamp(220px, 30vw, 420px); }

/* Scroll art: never cropped. These are complete works — a Ming hand-scroll cut
   off at the edge is vandalism, not art direction. They sit on the page's own
   ground because their paper is already the same warm off-white, so the join
   is nearly invisible. */
.band--scroll { background: var(--bg-sunken); padding: clamp(24px, 3vw, 56px) 0; }
.band--scroll img { width: 100%; height: auto; object-fit: contain; }

/* One line under a band: what it is, and who it is by when the licence asks.
   Sits in the page gutter so it lines up with the text above and below. */
.band-note {
  max-width: var(--maxw); margin: 10px auto 0; padding: 0 var(--pad);
  font-size: var(--fs-fine); color: var(--muted);
}
.band-note a { color: var(--muted); text-decoration: underline; text-underline-offset: 2px; }
.band-note a:hover { color: var(--fg); }

/* ── The credits grid ─────────────────────────────────────────────────── */
.creditgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: clamp(16px, 1.6vw, 28px); }
.credit { margin: 0; }
.credit img {
  /* `height: auto` is required, not tidiness: the width/height attributes on
     the <img> are a presentational hint the browser applies as a real height,
     and it beats `aspect-ratio` because nothing else sets height. Without it
     every thumbnail renders 600px tall. */
  width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; border-radius: var(--radius); border: 1px solid var(--hair); background: var(--bg-sunken); }
.credit figcaption { margin-top: 10px; font-size: var(--fs-fine); color: var(--muted); line-height: 1.5; }
.credit figcaption b { display: block; color: var(--fg); font-weight: 600; }
.credit figcaption span { display: block; }
.credit a { color: var(--muted); text-decoration: underline; text-underline-offset: 2px; }
.credit a:hover { color: var(--fg); }

/* ── A pair or trio of big images inside a row ─────────────────────────── */
.figrow { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: clamp(10px, 1vw, 18px); }
.figrow figure { margin: 0; }
.figrow .fr {
  overflow: hidden; border-radius: var(--radius); border: 1px solid var(--hair);
  aspect-ratio: 4 / 5; background: var(--bg-sunken);
}
.figrow .fr img { width: 100%; height: 100%; object-fit: cover; }
.figrow figcaption { margin-top: 10px; font-size: var(--fs-fine); color: var(--muted); }

/* ── The door cards get a picture ─────────────────────────────────────────
   The card was a rule and a character; with an image on top it becomes the
   thing you actually want to click. The mark moves onto the picture. */
.door { padding: 0; overflow: hidden; }
.door .thumb { position: relative; aspect-ratio: 16 / 10; overflow: hidden; background: var(--bg-sunken); }
.door .thumb img { width: 100%; height: 100%; object-fit: cover; transition: transform .5s cubic-bezier(.22,.61,.36,1); }
.door:hover .thumb img { transform: scale(1.04); }
.door .mark {
  position: absolute; left: clamp(16px, 1.4vw, 22px); bottom: clamp(10px, 1vw, 16px);
  margin: 0; color: #FFFDF8; text-shadow: 0 2px 14px rgba(0,0,0,.6);
}
.door:hover .mark { color: #FFFDF8; }
.door .body { display: flex; flex-direction: column; flex: 1; padding: clamp(20px, 1.8vw, 30px); }
.door .who { margin-top: 0; }

/* ═══ MEDIA ═══════════════════════════════════════════════════════════════════
   Three built pieces, all made from the product's own content — the real HSK
   characters and the real app screens. No stock, no 3D render, no illustration
   set. That constraint is the whole reason these read as ours: a wall of 180
   characters is a thing only this product can put on a page truthfully.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── The fire ─────────────────────────────────────────────────────────────
   A puff of 火 / 炎 from the dragon's mouth at each of the three arrivals. Both
   are real entries in our own hsk-chars data, so the fire is made of the same
   material as the product — the site's own rule about media, applied to motion.

   Amber `--t2` core with `--t1` every third. `--seal` is NOT used: the page's
   seal budget is the 故事 chop in the nav, and spending it on decoration is
   what makes an accent stop meaning anything.

   Created by door.js, so opacity:0 is the correct base state — a frozen
   fireball is a smudge, not a picture. This is the one place on the site where
   the base rule is the hidden state rather than the finished one.

   left/top are written by door.js from the heading's measured box, so the
   burst crosses the chapter title. z-index 51 puts the flames one layer above
   the dragon itself (50), because they come out of its mouth and must not be
   occluded by its own body. Both clear the page content; both stay under the
   floating nav at 60. */
.puff { position: absolute; left: 50%; top: 48%; z-index: 51; pointer-events: none; }
.puff i {
  position: absolute; font-style: normal; opacity: 0;
  font-size: clamp(30px, 3.4vw, 58px); line-height: 1; color: var(--t2);
  text-shadow: 0 0 clamp(8px, 1vw, 18px) rgba(224, 162, 60, .55),
               0 0 clamp(3px, .4vw, 7px) rgba(216, 92, 80, .45);
}
.puff i:nth-child(3n) { color: var(--t1); }
/* The teaching chapter used to be excluded here, on a rule that belongs to the
   /teaching PAGE — which still has no dragon and no fire. On the door, skipping
   the middle chapter of three only made it look forgotten. */

/* ── The cursor ───────────────────────────────────────────────────────────
   A dot that tracks the pointer exactly, and a gapped ring that trails it. The
   lag between the two is the entire effect.

   Fine pointers only — created by site.js behind a `(hover: hover) and
   (pointer: fine)` check, so on touch these never exist. It moved out of
   door.js so that it appears on EVERY page rather than only the door; see the
   note there. And the native cursor is deliberately NOT hidden: `cursor: none`
   is what most award sites do, and it breaks anyone relying on OS cursor
   enlargement or a high-contrast cursor. Ours sits alongside the real one.

   `translate` below is the INITIAL centring only. site.js overwrites the
   property every frame with the position and the centring folded together, so
   `rotate` stays a real CSS property and its transition still runs. */
.cur-dot, .cur-ring {
  position: fixed; left: 0; top: 0; z-index: 90;
  pointer-events: none; opacity: 0;
  translate: -50% -50%;
  transition: opacity .25s ease, width .22s ease, height .22s ease,
              border-radius .22s ease, border-color .22s ease;
}
.cur-dot  { width: 5px; height: 5px; border-radius: 50%; background: var(--t2); }
.cur-ring {
  width: 34px; height: 34px; border-radius: 50%;
  /* The gap is what keeps it from reading as a loading spinner. */
  border: 1.5px solid var(--t2);
  border-top-color: transparent; border-right-color: transparent;
  rotate: -45deg;
}
.cur-dot.on, .cur-ring.on { opacity: .85; }
.cur-ring.wide { width: 52px; height: 52px; opacity: .55; }
/* Over a character the ring squares off into a 田字格 — the grid Chinese
   children practise writing in. Nobody else's cursor can do that. */
.cur-ring.grid {
  width: 46px; height: 46px; border-radius: 3px; rotate: 0deg;
  border-color: var(--t2); opacity: .7;
  background:
    linear-gradient(var(--t2), var(--t2)) center / 1px 100% no-repeat,
    linear-gradient(var(--t2), var(--t2)) center / 100% 1px no-repeat;
}

/* ═══ THE OPENING SHOT ════════════════════════════════════════════════════════
   One character, pulled back until the reader sees the whole syllabus.

   THE RULE THAT MAKES THIS SAFE, and it is inverted from every other animation
   on this site: everything below is the FINISHED FRAME. The stage renders
   complete with no JavaScript at all — headline, count, character field, the
   mist. `door.js` does not reveal it; it takes the finished frame apart and
   plays it back. So a blocked bundle, a syntax error or a slow network leaves a
   composed hero rather than an empty box, and every word stays in the HTML for
   a crawler that never runs a line of script.

   `door.js` adds `.js-shot` to <html> before first paint. Only the rules under
   that class hide anything.
   ═══════════════════════════════════════════════════════════════════════════ */
.stage { position: relative; }
.stage-inner {
  position: relative; overflow: hidden;
  min-height: clamp(560px, 88vh, 1000px);
  display: grid; place-items: center;
  padding: var(--section) var(--pad);
}

/* The subject. Behind the words, in front of the field. */
.hero-glyph {
  position: absolute; left: 50%; top: 46%;
  translate: -50% -50%;
  font-size: clamp(200px, 34vw, 520px); line-height: 1;
  color: var(--fg); opacity: .07;
  /* Whatever the scale, the glyph must stay a GLYPH. At scale 9 the base size
     puts a single horizontal stroke across the whole screen, which reads as a
     grey bar rather than as a character — the shot has nothing to pull back
     FROM. 2.6 is the ceiling where 学 still reads as 学. */
  pointer-events: none; user-select: none; z-index: 1;
}

/* The cloud. Drifts ACROSS the field rather than with it — the faster parallax
   rate is the thing that sells the height. */
.mist {
  position: absolute; inset: -10% -18%; z-index: 3;
  pointer-events: none; user-select: none; opacity: 0;
  -webkit-mask-image: radial-gradient(58% 62% at 50% 50%, #000 30%, transparent 82%);
  mask-image: radial-gradient(58% 62% at 50% 50%, #000 30%, transparent 82%);
}
.mist img { width: 100%; height: 100%; object-fit: cover; filter: saturate(.35) brightness(1.12); }

.stage-copy { position: relative; z-index: 4; width: 100%; max-width: var(--maxw); }
.stage-copy h1 { font-size: var(--fs-display); max-width: 16ch; letter-spacing: -.03em; line-height: 1.04; }
.stage-copy .lead { font-size: clamp(18px, 1.5vw, 24px); max-width: 52ch; margin-top: clamp(18px, 1.8vw, 30px); }

/* The count. The pull-back exists to make this land, so it is set like a fact,
   not like a caption. */
.count { margin-top: clamp(26px, 3vw, 48px); }
.count-k {
  display: block; font-size: 11.5px; font-weight: 600; letter-spacing: .14em;
  text-transform: uppercase; color: var(--muted); margin-bottom: 8px;
}
.count-n {
  display: block; font-family: 'Fraunces', Georgia, serif;
  font-size: clamp(19px, 1.9vw, 30px); color: var(--fg);
  font-variant-numeric: tabular-nums lining-nums; letter-spacing: -.01em;
}
/* The number alone is a boast. This is the sentence that turns it into the
   argument the rest of the page runs on, so it lands with the count and not as
   a separate paragraph further down. */
.count-w {
  display: block; max-width: 48ch; margin-top: 12px;
  font-size: var(--fs-small); line-height: 1.55; color: var(--fg-2);
}

/* ── The seam ─────────────────────────────────────────────────────────────
   The bridge between the two halves. It is deliberately NOT a chapter: no
   photograph, no alternating column, no mark. A reader scrolling past three
   picture-and-text chapters needs this one to look like a different KIND of
   thing, because it is — it is the argument the two chapters are evidence for.

   The sunken background does that work on its own; adding a rule or a border
   as well would be saying it twice. */
.seam {
  background: var(--bg-sunken);
  border-block: 1px solid var(--hair);
  padding-block: clamp(8px, 1.4vw, 22px);
}
.seam-note {
  margin-top: clamp(18px, 2vw, 28px); padding-top: 18px;
  border-top: 1px solid var(--hair);
  max-width: 62ch; font-size: var(--fs-small); line-height: 1.6; color: var(--muted);
}

/* ── Only what the script controls ────────────────────────────────────────
   Hidden ONLY once door.js has announced itself. Without .js-shot every one of
   these is visible and composed.

   The HEADLINE IS NOT IN THIS LIST, deliberately. It is the page's largest
   contentful paint and the first thing a person reads; hiding it until a
   scrubbed timeline decides otherwise would trade a real metric and a real
   first impression for a beat of theatre. Only the count — the payoff the
   pull-back exists to deliver — waits. */
.js-shot .stage-inner { min-height: 100svh; }
.js-shot .hero-glyph  { opacity: 0; }
.js-shot .count       { opacity: 0; }
.js-shot .flight span { opacity: 0; }

/* ── The flight ───────────────────────────────────────────────────────────
   A field of real HSK characters that comes toward the reader as the page
   scrolls, behind the door's headline.

   THREE THINGS MAKE THIS SAFE TO SHIP.

   1. No JavaScript. It is a CSS scroll-driven animation
      (`animation-timeline: scroll(root block)`), so there is no scroll
      listener, no rAF loop and no main-thread work — the compositor drives it.
      A JS parallax of 84 elements is exactly the thing that makes a page feel
      cheap on a mid-range laptop.

   2. The still frame is the base rule; only the animation is inside @supports.
      Firefox has no scroll timelines yet, and there the same markup renders as
      a static depth field rather than 84 characters piled in the centre. Write
      it the other way round and the fallback is a bug.

   3. `prefers-reduced-motion` turns it off completely. Motion toward the
      viewer is the specific kind that triggers vestibular symptoms, so this is
      not a nicety — and the still field is genuinely good, so nothing is lost.

   The brief said no scroll animations. This is a deliberate exception, asked
   for directly: it is the product's own material moving the way the product
   works, not a decorative parallax bought off a shelf. */
.flight {
  position: absolute; inset: 0; overflow: hidden; z-index: 0;
  pointer-events: none; user-select: none;
  font-size: clamp(26px, 3.4vw, 62px); line-height: 1; color: var(--fg);
  /* The headline sits in the middle, so the middle has to stay quiet. */
  /* A small, soft hole behind the headline — nothing more. Two earlier
     versions used a large ellipse and it cut the field into a crescent on one
     side, because everything inside the ellipse vanished and everything
     outside it survived. The hole only has to cover the words. */
  /* The hole covers the headline AND the lead beneath it, which is why it is
     an ellipse sitting slightly below centre rather than a circle behind the
     H1. It never reaches full transparency: characters should thin out behind
     the words, not vanish and reappear at the edge of an invisible circle. */
  -webkit-mask-image: radial-gradient(38% 50% at 31% 52%, rgba(0,0,0,.06) 0%, rgba(0,0,0,.42) 55%, #000 100%);
  mask-image: radial-gradient(38% 50% at 31% 52%, rgba(0,0,0,.06) 0%, rgba(0,0,0,.42) 55%, #000 100%);
}
.flight span {
  position: absolute; left: 50%; top: 50%;
  /* The still frame: mid-flight, scattered. This is what Firefox and anyone
     with reduced motion sees, and it has to stand on its own. */
  transform: translate(-50%, -50%) translate(calc(var(--dx) * .46), calc(var(--dy) * .46)) scale(1.15);
  opacity: var(--op);
}
.flight span[class^="py"] { opacity: calc(var(--op) + .3); }

/* --dx/--dy are the exit offset; the character travels from almost nothing at
   the centre out past the edge of the screen, growing as it comes. The opacity
   plateau is wide (10%–78%) so a character spends most of its pass readable
   rather than most of it fading. */
@keyframes fly {
  0%   { opacity: 0; transform: translate(-50%, -50%) translate(calc(var(--dx) * .06), calc(var(--dy) * .06)) scale(.12); }
  10%  { opacity: var(--op); }
  78%  { opacity: var(--op); }
  100% { opacity: 0; transform: translate(-50%, -50%) translate(var(--dx), var(--dy)) scale(2.9); }
}

/* On a phone the headline and lead run the full width, so an off-centre hole
   protects nothing. The mask goes central and wide, and the whole layer is
   dialled back — the per-span opacity is animated, so it has to be turned down
   on the CONTAINER or the keyframes would just overwrite it. */
@media (max-width: 900px) {
  .flight {
    opacity: .62;
    -webkit-mask-image: radial-gradient(76% 40% at 50% 52%, rgba(0,0,0,.04) 0%, rgba(0,0,0,.34) 58%, #000 100%);
    mask-image: radial-gradient(76% 40% at 50% 52%, rgba(0,0,0,.04) 0%, rgba(0,0,0,.34) 58%, #000 100%);
  }
}

@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    .flight span {
      animation: fly linear both;
      animation-timeline: scroll(root block);
      /* Lengths, not percentages: a percentage range is measured against the
         whole document, so the effect would run at a different speed on a
         short page than on a long one. The pass happens in the first ~900px of
         scroll on every page that uses it. Ranges start negative so the field
         is already populated at scroll 0. */
      animation-range: var(--r0) var(--r1);
    }
  }
}

/* ── A chapter ────────────────────────────────────────────────────────────
   Alternating full-width halves: picture on one side, words on the other. The
   page has three of them and they are the reason the door is worth scrolling
   rather than a set of tiles you skim. They also give the dragon open water:
   it swims behind the page, so it needs stretches that are not covered by an
   opaque card. */
.chapter {
  /* overflow-x: clip is the guarantee behind the fire's reach clamp in door.js.
     The clamp keeps flames on screen by arithmetic; this makes going off it
     impossible. Two rounds of fitting constants got the mid-burst overflow from
     30px to 1px but never to zero, because a rotated glyph's axis-aligned box
     grows by up to √2 and the rotation is chosen per flame.

     `clip`, not `hidden`: hidden would make this a scroll container, and mixing
     hidden with visible on the other axis silently coerces that axis to auto.
     clip is the one value that can be applied to a single axis and leaves
     overflow-y genuinely visible, which the chapter needs. */
  overflow-x: clip; overflow-y: visible;
  display: grid; grid-template-columns: 1fr 1fr; align-items: center;
  gap: clamp(24px, 4vw, 76px);
  max-width: var(--maxw); margin: 0 auto; padding: clamp(48px, 6vw, 96px) var(--pad);
}
/* Alternate the side the picture sits on. `:nth-of-type` and not
   `:nth-child`: the chapters are siblings of the .doc wrappers, so counting
   children counts those too and the alternation comes out wrong. */
.chapter:nth-of-type(even) > .chapter-img { order: 2; }
.chapter-img { position: relative; margin: 0; border-radius: var(--radius); overflow: hidden; background: var(--bg-sunken); }
.chapter-img img {
  /* `height: auto` is not optional here: the width/height attributes on the
     <img> are a presentational hint that beats `aspect-ratio`, because nothing
     else sets height. Without it the picture renders at its declared 1000px
     and swallows the viewport. Same trap as .credit img. */
  width: 100%; height: auto; aspect-ratio: 8 / 5; object-fit: cover;
}
.chapter-img .mark {
  position: absolute; left: clamp(18px, 1.6vw, 28px); bottom: clamp(12px, 1.2vw, 20px);
  font-family: 'Noto Serif SC', serif; font-size: clamp(38px, 4vw, 66px); line-height: 1;
  color: #FFFDF8; text-shadow: 0 2px 18px rgba(0,0,0,.65);
}
.chapter-body { max-width: 46ch; }
.chapter-body .who { display: block; font-size: 11.5px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); margin-bottom: 12px; }
.chapter-body h2 { font-size: var(--fs-h1); letter-spacing: -.02em; }
.chapter-body .lead { margin-top: 14px; }
.chapter-body .body { margin-top: 14px; }
.chapter-body .btn { margin-top: clamp(20px, 2vw, 30px); }
@media (max-width: 900px) {
  .chapter { grid-template-columns: 1fr; }
  .chapter:nth-of-type(even) > .chapter-img { order: 0; }
}

/* ── 龙 · the dragon ──────────────────────────────────────────────────────
   A dragon dance: a head, and a body of characters that each arrive where the
   head was a moment ago. Every segment runs the SAME `swim` keyframes on the
   same scroll timeline; the only difference is `animation-range`, staggered by
   74px of scroll per segment. That is what makes a chain rather than a swarm —
   the body is literally following the head's path, one beat behind.

   The layer is fixed and pointer-events: none, so the dragon crosses the whole
   page as you read it rather than belonging to one section. It sits under the
   content and above the ground, at an opacity where you notice it move and
   never have to read around it.

   Same three guarantees as everything else here: still by default, animated
   only inside @supports, gone entirely under prefers-reduced-motion — and in
   the still case the whole layer is hidden, because a dragon frozen mid-stride
   is a smudge, not a picture. This is the one piece of motion that has nothing
   to say when it cannot move. */
.dragon {
  /* IN FRONT OF THE PAGE, not behind it. This started at z-index -1, on the
     reasoning that a dragon is scenery and scenery belongs behind the words.
     That was wrong about what this thing is: it is a character touring the
     page, and a character that passes behind every paragraph reads as a
     watermark. In front, it reads as an animal in the room.

     50 is chosen, not arbitrary. It clears every in-flow layer on the page and
     stays under the two things that must never be covered: the floating nav
     (60) and the skip link (100). `pointer-events: none` is what makes this
     safe — nothing under the dragon becomes unclickable as it passes.

     The old note is worth keeping because the fact is still true and still
     catches people: a fixed element at z-index 0 paints ABOVE static in-flow
     content, so `0` was never the "neutral" value it looks like. */
  position: fixed; inset: 0; z-index: 50; overflow: hidden;
  pointer-events: none; user-select: none;
  display: none;                 /* shown only where it can actually swim */
}
.dragon .seg {
  /* top: 0, not 46%. The path in door.js carries absolute viewport
     coordinates, so any CSS offset here is added on top of them and the whole
     animal sits that far down the screen. */
  position: absolute; left: 0; top: 0;
  font-size: calc(clamp(30px, 3.6vw, 58px) * var(--sz));
  line-height: 1; color: var(--fg); opacity: var(--op, .4);
  will-change: transform;
  transform-origin: 50% 50%;
  /* A cast shadow, now that there is something to cast it onto. This is the
     whole difference between "in front" and "at a higher z-index": without it
     the segments look like they are printed on the text rather than floating
     over it. text-shadow, not filter: drop-shadow — 22 of these are being
     transformed every frame and a filter on each would be a real cost. */
  text-shadow: 0 6px 20px rgba(26, 24, 19, .22);
}
.dragon .head {
  width: calc(clamp(74px, 7vw, 116px) * var(--sz)); height: auto;
  opacity: .82;
  /* One element, so a real drop-shadow is affordable here and text-shadow
     would do nothing — the head is an image with its own alpha. */
  filter: drop-shadow(0 10px 24px rgba(26, 24, 19, .26));
}

}

/* The dragon is driven by door.js now, not by CSS.
   The scroll-driven `swim` animation that used to live here is GONE, and its
   removal was not tidying: a RUNNING CSS ANIMATION BEATS AN INLINE STYLE, so
   while it existed it silently overrode every transform GSAP wrote. The head
   followed the old baked keyframes, finished at ~4,828px and parked off-screen
   for the rest of the page — the exact bug the rewrite was meant to fix, still
   happening, with the new code running and having no visible effect.

   `display: none` stays as the base rule: door.js switches it on, so no-JS and
   reduced-motion readers get no frozen dragon smeared across the page. */

/* ═══ MOTION ═════════════════════════════════════════════════════════════════
   Everything below is CSS scroll-driven: `animation-timeline: view()`, which
   ties an element's animation to its own passage through the viewport. No
   scroll listener, no IntersectionObserver, no rAF — the compositor drives it
   and the main thread never hears about it.

   THE SAFETY RULE, and it is the same one as the flight: the FINISHED state is
   the base rule, and only the animation lives inside @supports. So a browser
   without scroll timelines (Firefox today) shows the page fully composed, and
   nothing is ever hidden waiting for an event that will not come. Write it the
   other way round — hide by default, reveal on scroll — and one unsupported
   browser or one JS error means a blank page.

   `prefers-reduced-motion: reduce` turns all of it off.
   ═══════════════════════════════════════════════════════════════════════════ */

@keyframes rise {
  from { opacity: 0; transform: translateY(26px); }
  to   { opacity: 1; transform: none; }
}
/* A slow push-in on the big photographs. The image starts slightly oversized
   and settles as the band crosses the screen — the movement is 6%, which is
   enough to feel alive and not enough to notice as an effect. */
@keyframes settle {
  from { transform: scale(1.09); }
  to   { transform: scale(1); }
}
/* The lead sentence brightens word by word as the door page scrolls. */
@keyframes ink {
  from { color: var(--muted); }
  to   { color: var(--fg-2); }
}

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    /* Sections rise as they arrive.
       `entry 0% entry 55%` and NOT a `cover` range, which is the obvious
       choice and is wrong at the end of a document: `cover` progresses as the
       element crosses the whole viewport, and the LAST section can never be
       centred because the page stops scrolling. It would sit permanently
       half-faded while fully on screen. An `entry` range completes as soon as
       the element is on screen, which is the only thing the reveal is
       actually about. */
    .row > .col, .row > .rail {
      animation: rise .01s linear both;
      animation-timeline: view(block);
      animation-range: entry 0% entry 55%;
    }
    /* The hero is on screen at load; animating it would flash. */
    .row--hero > .col, .row--hero > .rail { animation: none; }

    /* The chapters: the words rise, the picture settles out of a slight
       oversize. Two different motions on purpose — if both did the same thing
       the pair would read as one block sliding, which is the cheap version. */
    .chapter-body {
      animation: rise .01s linear both;
      animation-timeline: view(block);
      animation-range: entry 0% entry 55%;
    }
    .chapter-img img {
      animation: settle .01s linear both;
      animation-timeline: view(block);
      animation-range: entry 0% cover 60%;
    }

    .band--photo img {
      animation: settle .01s linear both;
      animation-timeline: view(block);
      animation-range: entry 0% exit 100%;
    }

    /* The cards arrive one after another. The stagger comes from each card's
       own position, so it needs no per-card delay: a card lower in the row
       enters the viewport later and therefore starts later. */
    .gcard {
      animation: rise .01s linear both;
      animation-timeline: view(block);
      animation-range: entry 0% entry 60%;
    }

    .lead-ink > span {
      animation: ink .01s linear both;
      animation-timeline: scroll(root block);
      animation-range: var(--i0) var(--i1);
    }
  }
}

/* ── The screen collage ───────────────────────────────────────────────────
   Real captures of the running app, offset against each other rather than one
   centred phone. Axis-aligned and not rotated: tilted screenshots read as a
   stock mock-up, and these are the actual product.

   The three are sized and lifted differently so the group has a front and a
   back. Only the first is eager — the other two are decoration in the LCP
   sense even though they are real content. */
.screens { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(8px, .9vw, 16px); align-items: start; }
.screens .sc { border-radius: clamp(10px, 1vw, 16px); overflow: hidden; border: 1px solid var(--hair); background: var(--ink); }
.screens .sc img { width: 100%; height: 100%; object-fit: cover; object-position: top center; }
.screens .sc-1 { aspect-ratio: 780 / 1380; margin-top: clamp(18px, 2.4vw, 44px); }
.screens .sc-2 { aspect-ratio: 780 / 1560; box-shadow: 0 6px 12px rgba(26,24,19,.06), 0 30px 60px -34px rgba(26,24,19,.5); z-index: 2; }
.screens .sc-3 { aspect-ratio: 780 / 1320; margin-top: clamp(30px, 4vw, 76px); }
@media (max-width: 560px) {
  /* Three phone captures side by side stop being legible below about 560px.
     The middle one is the Today screen — the one the copy is about — so it is
     the one that stays. */
  .screens { grid-template-columns: 1fr; max-width: 300px; margin-inline: auto; }
  .screens .sc-1, .screens .sc-3 { display: none; }
  .screens .sc-2 { margin-top: 0; }
}

/* ── The card wall ────────────────────────────────────────────────────────
   Real cards: character, pinyin in its own frozen tone colour, first gloss.
   This is the one piece of media that shows what the product actually
   contains, which is why it sits under the claim "every character, already a
   story" rather than beside the download button. */
.cardwall { display: flex; flex-wrap: wrap; gap: clamp(8px, .9vw, 14px); }
.gcard {
  margin: 0; transform: translateY(var(--lift, 0));
  display: flex; flex-direction: column; gap: 2px;
  min-width: clamp(96px, 8.5vw, 132px); padding: clamp(14px, 1.2vw, 20px);
  background: var(--card); border: 1px solid var(--hair); border-radius: clamp(10px, 1vw, 14px);
  transition: transform .18s ease, border-color .18s ease;
}
.cardwall:hover .gcard { transform: translateY(0); }
.gcard:hover { border-color: var(--fg-2); }
.gcard-h { font-size: clamp(28px, 2.6vw, 40px); line-height: 1.1; color: var(--fg); }
.gcard-p { font-size: var(--fs-small); font-weight: 600; font-variant-numeric: tabular-nums; }
.gcard-m { font-size: var(--fs-fine); color: var(--muted); line-height: 1.35; }
@media (prefers-reduced-motion: reduce) {
  .gcard, .cardwall:hover .gcard { transform: none; }
}

/* ── Buttons ──────────────────────────────────────────────────────────────── */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 9px;
  font-family: inherit; font-size: var(--fs-small); font-weight: 600;
  padding: 15px 30px; min-height: 52px; border-radius: 999px;
  border: 1px solid transparent; cursor: pointer;
  transition: background .16s ease, border-color .16s ease, color .16s ease, transform .16s ease;
}
.btn-primary:hover { transform: translateY(-1px); }
.btn-primary { background: var(--seal); color: var(--seal-on); }
.btn-primary:hover { background: var(--seal-press); }
.btn-ghost { border-color: var(--hair); color: var(--fg); }
.btn-ghost:hover { border-color: var(--fg-2); }
.btn-sm { font-size: var(--fs-fine); padding: 9px 17px; min-height: 40px; }

/* Store badge. `APP_STORE_URL` in copy.js decides which of these renders: with
   a URL it is an <a class="store">, without one it is a <span class="store
   is-soon"> that says so. Never a link to nowhere — a dead primary CTA is
   worse than an honest "not yet". */
.store {
  display: inline-flex; align-items: center; gap: 12px; border-radius: 14px;
  padding: 11px 20px 11px 16px; border: 1px solid var(--hair); color: var(--fg);
  transition: border-color .16s ease;
}
a.store:hover { border-color: var(--fg-2); }
.store small { display: block; font-size: 10.5px; font-weight: 500; color: var(--muted); line-height: 1.2; }
.store b { display: block; font-size: 17px; font-weight: 600; letter-spacing: -.2px; line-height: 1.2; }
.store.is-soon { opacity: .55; cursor: default; }
.cta-row { display: flex; flex-wrap: wrap; gap: 12px 16px; align-items: center; margin-top: 26px; }
.cta-note { font-size: var(--fs-fine); color: var(--muted); }

/* ── The shell: a floating pill ────────────────────────────────────────────
   The bar detaches from the top edge and floats on the page, which is what
   makes the ground read as one continuous sheet rather than a header stacked
   on a body. Still no backdrop-filter, no scroll state and no size transition:
   a header that resizes as you scroll IS a scroll animation. */
header.bar {
  position: fixed; top: var(--pad); left: var(--pad); right: var(--pad); z-index: 60;
  display: flex; justify-content: center; pointer-events: none;
}
.bar-in {
  pointer-events: auto;
  display: flex; align-items: center; justify-content: space-between; gap: clamp(16px, 3vw, 44px);
  width: 100%; max-width: var(--maxw); min-height: 56px;
  padding: 6px 10px 6px 16px;
  background: var(--card); border: 1px solid var(--hair); border-radius: 999px;
  box-shadow: 0 1px 2px rgba(26, 24, 19, .04), 0 10px 30px -18px rgba(26, 24, 19, .35);
}
/* The pill floats, so the page leaves room for it. */
body { padding-top: calc(var(--pad) + 76px); }
.logo { display: flex; align-items: center; gap: 10px; }
.chop {
  width: 28px; height: 28px; border-radius: 8px; background: var(--seal);
  display: flex; align-items: center; justify-content: center; flex: none;
}
.chop span { font-family: 'Noto Serif SC', serif; font-size: 11px; line-height: 1.05; color: #F4EEE0; font-weight: 600; text-align: center; }
.brand { font-family: 'Fraunces', Georgia, serif; font-size: 19px; font-weight: 600; color: var(--fg); letter-spacing: .2px; }

nav.links { display: flex; gap: 24px; align-items: center; }
nav.links a { color: var(--fg-2); font-size: 14.5px; font-weight: 500; transition: color .16s ease; }
nav.links a:hover { color: var(--fg); }
nav.links a[aria-current="page"] { color: var(--fg); border-bottom: 1px solid currentColor; padding-bottom: 2px; }

/* The DE | EN switcher. Two real anchors so it works with JS blocked and a
   crawler can follow both; site.js only remembers the choice for next time. */
.lang { display: inline-flex; align-items: center; gap: 7px; font-size: var(--fs-fine); padding-right: 10px; }
.lang a { color: var(--muted); padding: 4px 2px; }
.lang a:hover { color: var(--fg); }
.lang a[aria-current] { color: var(--fg); font-weight: 600; }
.lang i { color: var(--hair); font-style: normal; }

/* ── Cards and the door ───────────────────────────────────────────────────── */
.card {
  background: var(--card); border: 1px solid var(--hair); border-radius: var(--radius);
  padding: clamp(20px, 1.8vw, 32px); box-shadow: var(--shadow);
}
.card h3 { margin-bottom: 8px; }
.card p { font-size: var(--fs-small); color: var(--fg-2); }

/* ── The doors ─────────────────────────────────────────────────────────────
   Large panels, edge to edge across the page, with enough padding that the
   card is mostly air. Size is what separates a panel that reads as considered
   from a tile that reads as a dashboard — the previous pass had the right idea
   at half the scale.

   `auto-fit` + `minmax` means they reflow on their own; no media query decides
   when three become one. */
.doors { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: clamp(10px, 1vw, 16px); }

/* ── The modality grid ────────────────────────────────────────────────────
   Six things that are peers. `auto-fit` with a 250px floor gives three across
   on a wide screen, two on a tablet and one on a phone without a single media
   query, and — because they are peers — none of them gets a card, a border or
   a number. The rule above each one is the only separation they need, and it
   is what keeps six items from reading as a pricing table.

   minmax(0, 1fr), not 1fr: a bare 1fr takes its minimum from content, and a
   long unbroken word in one cell would then push the whole page sideways. That
   has already happened twice in this file. */
.mods {
  /* 320px, not 250px: the floor is what decides the column count, and six items
     landing as 4 + 2 leaves a hole that reads as a missing seventh. At the
     widest the text column is about 1200px, so a 250px floor fits four across
     (1200/4 = 300) while 320 does not — which forces the 3 + 3 the content
     actually wants. Two rows of three, then two of two, then six of one, with
     no media query in it.

     min(320px, 100%), not a bare 320px: a minmax() floor is a HARD minimum, so
     on a 320px phone the track stayed 320px wide inside a column narrower than
     that and pushed the page 16px sideways. Measured, in the same audit that
     has caught this class of bug three times now. Wrapping the floor in min()
     lets it collapse when there is genuinely less room, and changes nothing at
     any width where there is not. */
  display: grid; grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
  gap: clamp(22px, 2.4vw, 40px) clamp(20px, 2.4vw, 44px);
  margin-top: clamp(26px, 3vw, 42px);
}
.mods--2 { grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); }
.mod { min-width: 0; padding-top: 16px; border-top: 1px solid var(--hair); }
.mod h3 { font-size: clamp(17px, 1.35vw, 21px); letter-spacing: -.01em; }
.mod .body { margin-top: 8px; font-size: var(--fs-small); line-height: 1.6; }
.door {
  display: flex; flex-direction: column;
  background: var(--card); border: 1px solid var(--hair); border-radius: var(--radius);
  transition: border-color .18s ease, transform .18s ease, box-shadow .18s ease;
}
.door:hover {
  border-color: var(--fg-2); transform: translateY(-3px);
  box-shadow: 0 2px 4px rgba(26, 24, 19, .04), 0 22px 44px -26px rgba(26, 24, 19, .4);
}
/* The character is the card's mark: the product's own material rather than an
   icon set, which the brief rules out and which would look bought anyway. */
.door .mark {
  font-family: 'Noto Serif SC', serif; font-size: clamp(34px, 3.4vw, 54px); line-height: 1;
}
.door .who { display: block; font-size: 11.5px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); margin: 30px 0 10px; }
.door h2 { font-size: var(--fs-h3); font-weight: 600; letter-spacing: -.2px; }
.door p { font-size: var(--fs-small); color: var(--fg-2); margin-top: 10px; max-width: 34ch; }
.door .go { margin-top: auto; }
.door .go {
  align-self: flex-start; margin-top: 26px; padding: 9px 17px;
  border: 1px solid var(--hair); border-radius: 999px; color: var(--fg-2);
  font-size: 12.5px; font-weight: 600; letter-spacing: .04em;
  transition: border-color .16s ease, color .16s ease, background .16s ease;
}
.door .go::after { content: ' \2197'; }
.door:hover .go { border-color: var(--seal); background: var(--seal); color: var(--seal-on); }

.steps { display: grid; gap: 14px; margin: 26px 0 0; padding: 0; list-style: none; counter-reset: step; }
.steps li {
  display: flex; gap: 16px; counter-increment: step;
  font-size: var(--fs-body); color: var(--fg-2); max-width: var(--measure);
}
.steps li::before {
  content: counter(step); flex: 0 0 auto; width: 30px; height: 30px; border-radius: 8px;
  background: var(--bg-sunken); border: 1px solid var(--hair); color: var(--fg);
  display: flex; align-items: center; justify-content: center;
  font-family: 'Fraunces', serif; font-weight: 600; font-size: 15px; line-height: 1;
}
/* Three short lines that are a rhythm, not a list — the problem statement on
   /teaching and "what it is not". No bullets: a bullet makes them feel like
   features, and they are beats. */
.beats { display: grid; gap: 12px; margin-top: 24px; max-width: var(--measure); }
.beats p { color: var(--fg-2); padding-left: 16px; border-left: 2px solid var(--hair); }
.beats p b { color: var(--fg); font-weight: 600; }

/* The inversion, on /teaching. It is the single sentence the whole page is
   built to earn — "you release and read" instead of "you assemble and check" —
   so it is set apart from the numbered steps above it rather than becoming a
   fourth one. A step is a thing you do; this is what changes. */
.turn {
  margin-top: clamp(20px, 2.2vw, 30px); max-width: var(--measure);
  font-family: 'Fraunces', Georgia, serif;
  font-size: clamp(18px, 1.5vw, 23px); line-height: 1.4; color: var(--fg);
}

/* ── Figures: real screenshots, and one honest placeholder ────────────────── */
/* Unframed on purpose. The captures already carry the iOS status bar, so they
   read as a screenshot without a drawn device around them, and a 40px-radius
   phone body with a notch is decoration the brief does not want. */
figure { margin: 28px 0 0; }
figure img { border: 1px solid var(--hair); border-radius: 12px; margin-inline: auto; }
figcaption { margin-top: 12px; font-size: var(--fs-fine); color: var(--muted); line-height: 1.5; }

/* A phone capture is 780x1688 — shown whole it is 650px tall and becomes the
   page. Cropped to a fixed window it stays a screenshot and stops being a
   poster. The bottom fades rather than cutting on a hard line, the way a screen
   does when there is more of it than the page shows; `object-position: top`
   keeps the part the copy is talking about. */
.shot {
  position: relative; width: 100%; max-width: clamp(280px, 26vw, 380px); margin: 0 auto;
  aspect-ratio: 780 / 1180; overflow: hidden;
  border: 1px solid var(--hair); border-radius: 12px;
  -webkit-mask-image: linear-gradient(to bottom, #000 86%, transparent 100%);
  mask-image: linear-gradient(to bottom, #000 86%, transparent 100%);
}
.shot img { width: 100%; height: 100%; object-fit: cover; object-position: top center; border: 0; border-radius: 0; }

/* The teacher dashboard does not exist yet. This is a labelled empty box and
   not a mock-up: a fake dashboard with invented student names and percentages
   IS invented numbers, shown to the one audience that would notice. The
   aspect-ratio is here so the page does not jump when the real image lands. */
/* 16/9 and capped at the reading measure. At 16/10 across the full column this
   is a 450px empty rectangle and it becomes the loudest thing on the page — a
   placeholder should hold a space, not occupy the room. */
.ph {
  aspect-ratio: 16 / 9; max-width: min(100%, 900px); background: var(--card);
  border: 1px solid var(--hair); border-radius: 12px;
  display: grid; place-items: center;
}
.ph span { font-size: var(--fs-fine); color: var(--muted); }

/* ── Tables ───────────────────────────────────────────────────────────────── */
/* `position: relative` is load-bearing, not tidiness. `.sr-only` is absolutely
   positioned, and an absolutely-positioned descendant is only clipped by an
   `overflow` ancestor that is ITSELF positioned. Without this, the screen-reader
   labels in the table headers escape the horizontal scroller and stretch the
   whole document — 35px of sideways scroll on a 320px phone, from a span one
   pixel wide that nobody can see. */
.tbl { position: relative; overflow-x: auto; border: 1px solid var(--hair); border-radius: var(--radius); background: var(--card); -webkit-overflow-scrolling: touch; }
table { border-collapse: collapse; width: 100%; min-width: 520px; font-size: 14.5px; }
caption { caption-side: top; text-align: left; padding: 15px 18px 4px; color: var(--muted); font-size: 12px; letter-spacing: .08em; text-transform: uppercase; font-weight: 600; }
th, td { padding: 12px 18px; text-align: left; border-bottom: 1px solid var(--hair); }
thead th { color: var(--fg); font-weight: 600; font-size: 13px; background: var(--card); }
tbody td { color: var(--fg-2); }
tbody td:first-child { color: var(--fg); font-weight: 600; }
tbody tr:last-child td { border-bottom: none; }

.sub-h { font-family: 'Fraunces', Georgia, serif; font-weight: 500; font-size: 21px; color: var(--fg); margin: 0 0 18px; letter-spacing: -.2px; }

/* A comparison reads as one thing changing, not two lists: the old column
   recedes, the new one is the page's own text colour, and the size of the
   change gets a chip so "×2" is visible without arithmetic. */
.cmp table { min-width: 620px; }
.cmp thead th { font-size: 14px; vertical-align: bottom; }
.cmp thead th span { display: block; font-family: 'Inter', sans-serif; font-size: 11.5px; font-weight: 500; color: var(--muted); letter-spacing: .02em; margin-top: 3px; text-transform: none; }
.cmp tbody th { color: var(--fg); font-weight: 600; font-size: 13.5px; width: 22%; vertical-align: top; white-space: nowrap; }
.cmp td { vertical-align: top; }
.cmp .cmp-old { color: var(--muted); width: 30%; }
.cmp .cmp-new { color: var(--fg); }
.cmp .cmp-new small { display: block; color: var(--fg-2); font-size: 12.5px; margin-top: 4px; line-height: 1.45; }
.cmp .cmp-new .hz { font-family: 'Noto Serif SC', serif; color: var(--fg-2); }
.cmp .delta {
  display: inline-block; margin-left: 7px; font-size: 11px; font-weight: 600;
  padding: 2px 8px; border-radius: 999px; white-space: nowrap;
  background: var(--seal-soft); color: var(--seal-text);
}
.cmp .delta.new { background: rgba(95, 168, 106, .18); color: #4C8B57; }
@media (max-width: 700px) {
  /* Three columns of prose do not fit a phone. Each row becomes a small block:
     the label, then old and new stacked and labelled, so the pairing survives
     without the header row. */
  .cmp table, .cmp thead, .cmp tbody, .cmp tr, .cmp th, .cmp td { display: block; min-width: 0; }
  /* A caption inside a display:block table shrink-wraps to nothing and sets one
     word per line — it has to be told it is a block too. */
  .cmp caption { display: block; width: 100%; padding: 15px 16px 10px; }
  .cmp thead { display: none; }
  .cmp tr { padding: 14px 0; border-bottom: 1px solid var(--hair); }
  .cmp tr:last-child { border-bottom: 0; }
  .cmp th, .cmp td { border: 0; padding: 0 16px; }
  .cmp tbody th { width: auto; white-space: normal; font-size: 12px; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); margin-bottom: 8px; }
  .cmp .cmp-old, .cmp .cmp-new { width: auto; position: relative; padding-left: 62px; }
  .cmp .cmp-old::before, .cmp .cmp-new::before { content: '2.0'; position: absolute; left: 16px; top: 1px; font-size: 11px; font-weight: 600; color: var(--muted); letter-spacing: .04em; }
  .cmp .cmp-new::before { content: '3.0'; color: var(--seal-text); }
  .cmp .cmp-new { margin-top: 6px; }
}

/* ── Duolingo · Anki · Gushi ──────────────────────────────────────────────── */
/* NAMES, NOT LOGOS, and this is not a style choice. Comparative advertising is
   lawful on the name when the comparison is objective (§6 UWG); a competitor's
   LOGO struck through is the textbook `herabsetzend` case and is the one
   version their lawyer can act on. A wordmark set in type and struck through
   reads identically at this size and carries none of that.

   Claims stay on what the tools are FOR. A claim about a rival's feature list
   goes stale without anyone here noticing. */
.vs table { min-width: 640px; table-layout: fixed; }
.vs thead th { font-size: 15px; vertical-align: bottom; padding-bottom: 14px; }
.vs thead th span { display: block; }
.vs thead th > span:last-child {
  font-family: 'Inter', sans-serif; font-size: 11px; font-weight: 600;
  letter-spacing: .1em; text-transform: uppercase; color: var(--muted); margin-top: 5px;
}
.vs .strike { position: relative; display: inline-block; color: var(--muted); font-weight: 600; }
.vs .strike::after {
  content: ''; position: absolute; left: -4%; right: -4%; top: 52%; height: 1.5px;
  background: var(--muted); transform: rotate(-4deg);
}
.vs .vs-logo { display: flex; align-items: center; gap: 8px; font-family: 'Fraunces', Georgia, serif; font-size: 19px; color: var(--fg); }
.vs .vs-logo .chop { width: 22px; height: 22px; border-radius: 6px; }
.vs .vs-logo .chop span { font-size: 8.5px; }
.vs tbody th { width: 19%; font-size: 13px; color: var(--fg); vertical-align: top; }
.vs td { vertical-align: top; font-size: 13.5px; line-height: 1.5; }
.vs .vs-out { color: var(--muted); }
.vs .vs-us { color: var(--fg); background: var(--seal-soft); }
.vs .vs-us b { color: var(--fg); font-weight: 600; }
.vs thead .vs-us, .vs tbody .vs-us { box-shadow: inset 1px 0 0 rgba(200, 69, 46, .22), inset -1px 0 0 rgba(200, 69, 46, .22); }
@media (max-width: 760px) {
  .vs table, .vs thead, .vs tbody, .vs tr, .vs th, .vs td { display: block; min-width: 0; }
  .vs caption { display: block; width: 100%; padding: 15px 16px 10px; }
  .vs thead { display: none; }
  .vs tr { padding: 14px 0; border-bottom: 1px solid var(--hair); }
  .vs tr:last-child { border-bottom: 0; }
  .vs th, .vs td { border: 0; padding: 0 16px; }
  .vs tbody th { width: auto; font-size: 12px; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); margin-bottom: 8px; }
  .vs td { position: relative; padding-left: 86px; margin-top: 5px; }
  .vs td::before {
    content: 'Duolingo'; position: absolute; left: 16px; top: 1px; width: 64px;
    font-size: 11px; font-weight: 600; color: var(--muted); text-decoration: line-through;
  }
  .vs td:nth-of-type(2)::before { content: 'Anki'; }
  .vs .vs-us { box-shadow: none; border-radius: 10px; padding-top: 8px; padding-bottom: 8px; margin-top: 8px; }
  .vs .vs-us::before { content: 'Gushi'; color: var(--seal-text); text-decoration: none; top: 9px; }
}

/* ── Tone colours ─────────────────────────────────────────────────────────── */
.py1 { color: var(--t1); } .py2 { color: var(--t2); } .py3 { color: var(--t3); }
.py4 { color: var(--t4); } .py5 { color: var(--t5); }

.tag { display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 9px; border-radius: 999px; }
.tag.el { background: rgba(95, 168, 106, .16); color: #4C8B57; }
.tag.mid { background: rgba(224, 162, 60, .18); color: #A9761F; }
.tag.adv { background: rgba(91, 139, 201, .18); color: #3F6FA8; }

/* ── The FSRS curve ───────────────────────────────────────────────────────── */
/* An explanatory drawing, not decoration: the hero claims "study only what you
   are about to forget" and this is what makes that claim believable. Every
   decay segment is the same SHAPE — the same 10% of memory lost — drawn over a
   longer and longer span, which is what expanding intervals actually are.
   The draw-on animation is gone with the rest of the scroll motion. */
.fsrs-graph { margin: 26px 0 0; }
.fsrs-graph svg { width: 100%; height: auto; display: block; overflow: visible; }
.fsrs-graph .ax { stroke: var(--hair); stroke-width: 1; }
.fsrs-graph .thresh { stroke: var(--muted); stroke-width: 1; stroke-dasharray: 3 5; opacity: .65; }
.fsrs-graph .decay { fill: none; stroke: var(--muted); stroke-width: 2; stroke-dasharray: 5 5; opacity: .75; }
.fsrs-graph .fsrs { fill: none; stroke: var(--seal); stroke-width: 2.6; stroke-linejoin: round; stroke-linecap: round; }
.fsrs-graph .dots circle { fill: var(--seal); }
.fsrs-graph text { font-family: 'Inter', sans-serif; font-size: 12px; fill: var(--fg-2); }
.fsrs-graph .lab { font-size: 11.5px; font-weight: 600; fill: var(--muted); }
.fsrs-graph .dim { fill: var(--muted); font-size: 11.5px; }
.fsrs-graph .gap text { font-size: 11.5px; font-weight: 600; fill: var(--muted); }
.fsrs-graph .curve-lab { font-size: 12.5px; font-weight: 600; }
.fsrs-graph .curve-lab.on { fill: var(--seal-text); }

/* ── Pricing ──────────────────────────────────────────────────────────────── */
.trial {
  display: flex; gap: 16px; align-items: flex-start; margin-top: 26px;
  background: var(--bg-sunken); border: 1px solid var(--hair); border-radius: var(--radius); padding: 20px 22px;
}
.trial .hz { font-size: 26px; color: var(--seal-text); line-height: 1; flex: none; }
.trial b { display: block; color: var(--fg); }
.trial span { display: block; font-size: var(--fs-small); color: var(--fg-2); margin-top: 4px; }
.plans { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-top: 16px; }
.plan { display: flex; flex-direction: column; position: relative; }
/* One card is marked, and only one. Two "best value" flags is no flag. */
.plan.best { border-color: var(--seal); }
.plan .flag {
  position: absolute; top: -10px; left: 22px; background: var(--seal); color: var(--seal-on);
  font-size: 10.5px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase;
  padding: 3px 10px; border-radius: 999px;
}
.plan .name { font-size: 12px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); }
.plan .price { font-family: 'Fraunces', Georgia, serif; font-size: 28px; color: var(--fg); margin-top: 8px; letter-spacing: -.4px; }
.plan .price small { font-family: 'Inter', sans-serif; font-size: 14px; color: var(--muted); font-weight: 400; }
.plan .note { font-size: var(--fs-fine); color: var(--muted); margin-top: 4px; }
.plan ul { margin: 16px 0 0; padding-left: 18px; font-size: var(--fs-small); color: var(--fg-2); }
.plan li { margin-bottom: 6px; }

/* ── FAQ ──────────────────────────────────────────────────────────────────── */
.faq { display: grid; gap: 10px; margin-top: 26px; }
.qa { background: var(--card); border: 1px solid var(--hair); border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow); }
.qa summary {
  list-style: none; cursor: pointer; padding: 17px 50px 17px 20px; position: relative;
  font-size: 16px; font-weight: 600; color: var(--fg); line-height: 1.4;
}
.qa summary::-webkit-details-marker { display: none; }
.qa summary::after {
  content: ''; position: absolute; right: 22px; top: 50%; width: 9px; height: 9px;
  border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted);
  transform: translateY(-70%) rotate(45deg); transition: transform .2s ease;
}
.qa[open] summary::after { transform: translateY(-30%) rotate(225deg); }
.qa .a { padding: 0 20px 18px; color: var(--fg-2); font-size: var(--fs-small); }

/* ── Mascot ───────────────────────────────────────────────────────────────── */
/* Twice on the whole site, small, and never on /teaching or /hsk-3.0: those
   two address professionals, and a cartoon dragon is not an argument. */
.mascot { display: block; height: auto; }

/* ── Footer ───────────────────────────────────────────────────────────────── */
/* NOT `.row` — that is the document-grid class now, and its
   `padding: var(--section) 0` cancels `.wrap`'s horizontal padding, which put
   the copyright hard against the left edge of the window and cut "Contact" off
   at the right. Utility class names that read like layout words are exactly
   the ones that collide later. */
footer.site { border-top: 1px solid var(--hair); padding: 28px 0 clamp(28px, 3vw, 48px); margin-top: var(--section); }
footer.site .foot-row {
  display: flex; flex-wrap: wrap; gap: 10px 24px; justify-content: space-between;
  align-items: center; font-size: var(--fs-fine); color: var(--muted);
}
footer.site nav { display: flex; flex-wrap: wrap; gap: 10px 20px; }
footer.site a { color: var(--muted); }
footer.site a:hover { color: var(--fg); }

/* ═══ RESPONSIVE ══════════════════════════════════════════════════════════════
   Three breakpoints, and that is the budget. Everything else is fluid —
   clamp() on type and section padding, auto-fit grids for the doors and the
   plans — so a component reflows on its own rather than waiting for a query.
   A layout that needs a fourth breakpoint is a component built wrong.
   ═════════════════════════════════════════════════════════════════════════ */

/* ── ≤ 900px ──────────────────────────────────────────────────────────────
   The grid collapses to one column and the rail becomes an inline strap above
   the heading — number, then label, on one line. It keeps the numbering, which
   is the part that carries the structure; it drops the second column, which is
   the part a phone has no room for. */
@media (max-width: 900px) {
  nav.links { gap: 18px; }
  nav.links a { font-size: 14px; }

  /* minmax(0, 1fr), not 1fr: a plain `1fr` track takes its minimum from its
     content, so the HSK tables (min-width 520px) push the track past the
     viewport and the whole page scrolls sideways — even though .tbl has
     overflow-x:auto and could have handled it. The 0 minimum is what lets the
     table scroll inside itself instead of widening the page. */
  .row { grid-template-columns: minmax(0, 1fr); }
  .col > .bleed { margin-left: 0; }   /* nothing to bleed into once the rail is inline */
  .rail { position: static; display: flex; align-items: baseline; gap: 10px; margin-bottom: 16px; }
  .rail .n { margin-bottom: 0; }
  .rail .n::after { content: ''; display: inline-block; width: 14px; height: 1px; background: var(--hair); vertical-align: middle; margin: 0 0 4px 10px; }
}

/* ── ≤ 640px: phone. The nav becomes two rows, never a menu. ───────────────
   No hamburger and no dropdown: the brief bans both, and three links do not
   need one. Row 1 is the wordmark and the language switch, row 2 is the three
   links spread across a hairline. Header goes 58px → 100px, which is the whole
   cost of not hiding the navigation behind a button. */
@media (max-width: 720px) {
  /* The pill keeps its shape and grows a second row instead of hiding the
     navigation behind a button. Three links do not need a menu. */
  .bar-in { flex-wrap: wrap; gap: 0; padding: 0 14px; border-radius: 24px; }
  .logo, .lang { min-height: 50px; display: flex; align-items: center; }
  nav.links {
    order: 3; flex-basis: 100%; min-height: 44px; gap: 0;
    justify-content: space-between; border-top: 1px solid var(--hair);
  }
  nav.links a { font-size: 13.5px; padding: 11px 0; }
  nav.links a[aria-current="page"] { padding-bottom: 9px; }
  body { padding-top: calc(var(--pad) + 116px); }
  html { scroll-padding-top: calc(var(--pad) + 128px); }

  .wrap, .doc { padding: 0 16px; }
  figure img.shot { max-width: 100%; }
  .trial { flex-direction: column; gap: 10px; }
  .steps li { gap: 12px; }

  /* Anything that can be tapped is at least 44px tall. */
  .lang a { padding: 12px 4px; }
  .btn { width: 100%; }
  .cta-row .btn { width: auto; }
}

/* At 320px "Teaching Platform" is the widest thing on the row. Shrinking the
   row is the right answer; introducing a menu is not. */
@media (max-width: 360px) {
  nav.links a { font-size: 12.5px; }
  .brand { font-size: 17px; }
}

/* ── Reduced motion ───────────────────────────────────────────────────────── */
/* There is almost nothing left to disable — the scroll reveals, the hero glow
   and the drawing curve are all gone — but smooth scrolling is still motion
   somebody did not ask for. */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; }
}

/* ── Print ────────────────────────────────────────────────────────────────── */
/* The HSK page is a reference, and reference pages get printed. */
@media print {
  header.bar, footer.site, .cta-row, .toc { display: none; }
  body { background: #fff; color: #000; font-size: 11pt; }
  .section { padding: 12pt 0; border: 0; }
  a[href^="http"]::after { content: " (" attr(href) ")"; font-size: 9pt; color: #555; }
}

/* ─────────────────────────────────────────────────────────────────────────────
   Dark mode, kept and disabled. Uncomment to bring it back; nothing else in
   this file needs to change, because every component reads the semantic
   variables and never an --ink-* or --paper-* directly.

   @media (prefers-color-scheme: dark) {
     :root {
       --bg: var(--ink); --bg-sunken: var(--ink-sunken); --card: var(--ink-card);
       --fg: var(--ink-fg); --fg-2: var(--ink-fg-2); --muted: var(--ink-muted);
       --hair: var(--ink-hair); --seal-text: var(--ink-seal-text); --shadow: none;
     }
     .cmp .delta.new { color: #8FCD72; }
     .tag.el { color: #8FCD72; } .tag.mid { color: var(--amber); } .tag.adv { color: #8BB0DE; }
   }
   ──────────────────────────────────────────────────────────────────────────── */
