/* ─────────────────────────────────────────────────────────────────────────────
   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; }

/* THE STICKY RAIL IS GONE, and it is worth saying what it was so nobody
   rebuilds it: a 190px first column holding a number and a section name,
   `position: sticky`, which tracked the reader down the page and then vanished
   at the section's end. Three faults in one gesture — it moved while you were
   reading, it left without being asked, and it spent an eighth of a wide
   screen on two words of label.

   What replaced it is the shape this grid already took on a phone: number,
   hairline, label, in one strap directly above the heading. That means ONE
   layout at every width instead of two, and it hands the whole document width
   back to what is underneath — which on /hsk-3.0 is four tables that used to
   claw the rail back with a negative margin. */
.row {
  display: grid; grid-template-columns: minmax(0, 1fr);
  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); }
/* Kept as an alias: every row is full width now, and `.row--full` in existing
   markup should keep meaning what it says rather than becoming a silent no-op
   somebody deletes and wonders about. */
.row--full { grid-template-columns: minmax(0, 1fr); }
.row--tight { padding: calc(var(--section) * .6) 0; }

.rail { display: flex; align-items: baseline; gap: 12px; margin-bottom: clamp(16px, 1.8vw, 26px); }
.rail .n {
  font-family: 'Fraunces', Georgia, serif; font-size: 14px;
  font-variant-numeric: tabular-nums lining-nums; color: var(--muted);
}
.rail .n::before { content: counter(row, decimal-leading-zero); }
/* The rule between the number and the label. It is what stops the strap from
   reading as two orphaned words, and it is the one piece of the old rail worth
   keeping: on a phone this grid has drawn it for a long time. */
.rail .n::after {
  content: ''; display: inline-block; width: clamp(18px, 2.4vw, 36px); height: 1px;
  background: var(--hair); vertical-align: middle; margin: 0 0 4px 12px;
}
.rail .l {
  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); }

/* `.bleed` used to pull a table left across the gutter and the rail to reach
   the full document width. With the rail gone the column IS the document, so
   there is nothing left to bleed into and the class is inert — kept because
   the markup that carries it still reads correctly, and because a table that
   wants the page is still the right instinct. */
.col > .bleed { margin-left: 0; }

/* 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);
}
/* `text-wrap: balance` on every heading, and it is a bug fix rather than a
   flourish: `.col > h2` is capped at 22ch, so "Too gamified or too much work."
   broke after "much" and left the word "work." alone on a second line. A
   heading with a one-word last line reads as a mistake at any size. Balancing
   evens the lines instead, and the browser only applies it to short blocks —
   there is nothing here it can make worse. */
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);
  text-wrap: balance;
}
h3, .h3 { font-family: 'Fraunces', Georgia, serif; font-size: var(--fs-h3); font-weight: 600; letter-spacing: -.2px; color: var(--fg); text-wrap: balance; }
.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 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
   the door page's own script so that it appears on EVERY page rather than only
   the door — which is why it outlived that script. 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` added `.js-shot` to <html> before first paint, and only the rules
   under that class hid anything.

   DEAD AS OF THE DOOR REWRITE. The landing page is now one drawn picture and
   two questions; `.stage`, `.charflight`, `.hero-glyph`, `.count`, the old
   `.door`/`.doors` cards and `.chapter`/`.seam`/`.onescreen` have no reader
   left, and neither does `.js-shot`. They are kept for one commit so the
   diff of the rewrite is readable on its own; delete the block wholesale,
   together with the charflight/heroGlyph/cardwall generators in
   tools/build-web.mjs, once nothing here is being compared against.
   ═══════════════════════════════════════════════════════════════════════════ */
.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 had announced itself — that file is gone, so these
   never apply now. 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. */
.chapter {
  /* overflow-x: clip stops anything inside a chapter widening the document and
     handing the reader a horizontal scrollbar. It was added for the fire, whose
     flames overflowed by 30px on a 320px viewport; the fire is gone and the
     rule stays, because the guarantee is worth having without it.

     `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; }
}

/* ═══ 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 / 1660; 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 review card — the one the copy is about, and the
     only slot whose crop keeps its four answer buttons — 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; }
}

/* ── One screen, on its own ───────────────────────────────────────────────
   The home page's single capture. Unlike .screens above, the frame's
   aspect-ratio IS the file's, so object-fit has nothing to crop: this shot is
   used precisely because of what sits at the bottom of it. */
.onescreen { margin: clamp(30px, 4vw, 52px) 0 0; }
.onescreen .sc {
  max-width: 380px; margin-inline: auto;
  aspect-ratio: 780 / 1696;
  border-radius: clamp(14px, 1.4vw, 22px); overflow: hidden;
  border: 1px solid var(--hair); background: var(--ink);
  box-shadow: 0 6px 14px rgba(26,24,19,.07), 0 44px 88px -44px rgba(26,24,19,.55);
}
.onescreen .sc img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: top center; }
.onescreen figcaption { max-width: 56ch; margin: clamp(18px, 2vw, 26px) auto 0; text-align: center; }

/* ── 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); }

/* ── THE BRAND EDGE ───────────────────────────────────────────────────────
   The top of the window, in the wash the three opener pages start on, and it
   STAYS once you scroll.

   Why it exists: the pill floats on `--pad` of whatever is behind it. At the
   top of /app, /teaching and /hsk-3.0 that is the ink wash, which is the
   point — the page's first colour is the brand's. Scroll one screen and the
   wash is gone; the pill was left floating on bare paper for the rest of a
   very long page, which is the moment the site stops looking like the page
   you arrived on.

   #548689 IS the wash, not a colour picked to go with it: every one of the
   three openers starts on `#2C6B71` at 80 %, and that over the paper is this.
   The bottom third fades out so nothing scrolling underneath meets an edge.

   `:has(.opener)` scopes it to exactly the three pages that open on the wash.
   /about, /privacy and the level pages open on paper and keep their bare pill;
   the door has no pill at all. A browser without `:has()` gets today's page,
   which is the correct fallback for something purely decorative. */
body:has(.opener)::before {
  content: ''; position: fixed; z-index: 55; inset: 0 0 auto 0; pointer-events: none;
  height: calc(var(--pad) + 96px);
  background: linear-gradient(to bottom, #548689 0, #5F9093 58%, rgba(95, 144, 147, 0) 100%);
}
/* AT THE VERY TOP IT IS NOT THERE AT ALL, where a scroll timeline exists to
   say so. The band is a flat colour and the wash under it is a gradient, so
   painted over the first screen it would be a slightly wrong rectangle laid
   on the real thing. Faded in across the first 320px the two never disagree:
   at rest you see the wash itself, and by the time the wash has left the
   window the band has replaced it. Same mechanism the door's character field
   uses — CSS scroll-driven, no listener. Without support the band simply
   stands still, which is a near-match rather than a mismatch. */
@keyframes brand-edge { from { opacity: 0; } to { opacity: 1; } }
@supports (animation-timeline: scroll()) {
  body:has(.opener)::before {
    opacity: 0; animation: brand-edge linear both;
    animation-timeline: scroll(root block); animation-range: 0 320px;
  }
}
.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, so each row becomes a small
     block: label, the old value, the new one.

     WHAT THIS REPLACED, because the shape is the whole point: the version
     markers used to be absolutely positioned in a 62px gutter down the left
     edge — a third of the width of a phone, holding two three-character
     labels and lining up with nothing else on the page. Old and new were set
     in the same size and weight, so a row read as two facts of equal standing
     rather than as one thing that changed.

     Now the marker is a chip at the head of its own line, the old value is
     quiet and small, and the new one is the largest thing in the row. The
     hanging indent is what keeps it tidy when a value wraps: the chip sits in
     the negative indent and every wrapped line lands under the first word
     rather than under the chip. */
  .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: 15px 0 17px; 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; margin-bottom: 9px;
    font-size: 11px; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; color: var(--muted);
  }
  .cmp .cmp-old, .cmp .cmp-new { width: auto; position: static; padding: 0 16px 0 58px; text-indent: -42px; }
  .cmp .cmp-old { color: var(--muted); font-size: 14.5px; line-height: 1.4; }
  .cmp .cmp-new { margin-top: 8px; color: var(--fg); font-size: 16.5px; font-weight: 600; line-height: 1.35; }
  .cmp .cmp-old::before, .cmp .cmp-new::before {
    content: '2.0'; position: static; display: inline-block; margin-right: 9px;
    padding: 2px 7px; border-radius: 6px; background: var(--bg-sunken);
    font-family: 'Inter', sans-serif; font-size: 10.5px; font-weight: 700; letter-spacing: .04em;
    color: var(--muted); text-indent: 0;
  }
  .cmp .cmp-new::before { content: '3.0'; background: var(--seal-soft); color: var(--seal-text); }
  /* text-indent is INHERITED, and an inline-block applies it to its own first
     line — so without these two resets the sub-line and the delta chip each
     draw their text 42px to the left of their own box, straight across the
     value. Every box inside a hanging indent has to opt out of it. */
  .cmp .cmp-new small { text-indent: 0; font-weight: 400; margin-top: 6px; }
  .cmp .delta { text-indent: 0; }
}

/* ── 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; }
  /* The grid, the strap and the bleed used to be redefined here for phones.
     They are not any more: the base rules ARE the phone rules now, which is
     the whole point of dropping the rail. */
}

/* ── ≤ 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); }
  body:has(.opener)::before { height: calc(var(--pad) + 136px); }

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

  /* THE FOOTER, WHICH A PHONE HAD BEEN LEAVING RAGGED. `space-between` on a
     wrapping row is a desktop rule: at this width it put the copyright on its
     own line, then five links, then "Contact" alone underneath — three blocks
     of different lengths all flush left, which is what read as untidy. In one
     column the six links become a two-by-three grid with both columns flush,
     and the copyright goes UNDER them: a footer on a phone is read as "where
     else can I go", and the legal line is the answer to a question nobody
     asked first. Column-reverse rather than reordered markup, so the source
     order the screen reader and the crawler get is unchanged. */
  footer.site .foot-row { flex-direction: column-reverse; align-items: stretch; gap: clamp(18px, 5vw, 26px); }
  /* The row gap is small and the padding is on the link, so every one of the
     six is a 40px-tall target rather than a 17px line of text. */
  footer.site nav { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 20px; }
  footer.site nav a { padding: 11px 0; }

  /* 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, .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; }

  /* THE PAGE CAME OUT BLANK BELOW THE HERO UNTIL THIS RULE EXISTED, and it is
     worth knowing why, because the cause is invisible on screen: every
     section's column rises into view on a scroll-driven timeline
     (`animation-timeline: view(block)`, above) with `both` fill. Printing has
     no scroller, so the timeline never becomes active, the animation stays in
     its BEFORE phase, and `@keyframes rise` starts at `opacity: 0`. Seventeen
     sheets of white paper, every one of them technically correct.
     Anything given a scroll or view timeline has to be listed here. */
  .row > .col, .row > .rail, .chapter-body, .chapter-img img, .band--photo img,
  .gcard, .lead-ink > span { animation: none !important; opacity: 1 !important; }

  /* THE PER-LEVEL PAGES ARE THE PRINTABLE ONES, and a teacher printing
     /hsk-3.0/words/4 wants 989 rows on paper, not the navigation around them.
     This is also the honest answer to "is there a PDF": there is no
     server-made one, because a build with no dependencies cannot embed a CJK
     font, and Cmd-P through this stylesheet produces a better file than a
     hand-rolled writer would — real fonts, real hyphenation, selectable text.
     If a hosted PDF is ever wanted, it is generated from THIS. */
  .crumbs, .lvl-nav, .dl-inline, .rail, .wl-sets, .wl-head, .wl-tabs, .wl-bar,
  .wl-status, .lvl-links, .wl-a, .lang, p.fine[data-lang-note] { display: none; }
  .tbl { border: 0; background: none; overflow: visible; }
  .lvl-list table, table { min-width: 0; font-size: 9pt; }
  thead { display: table-header-group; }
  tr, .lvl-ex { break-inside: avoid; }
  th, td { padding: 4pt 6pt; border-bottom: 1px solid #ddd; }
  .wl-h { font-size: 13pt; }
  .lvl-ex { font-size: 8.5pt; }
  caption { padding: 0 0 6pt; }

  /* The credit travels with the paper. Without it a printed list is an
     anonymous handout, and a handout that names nobody is the one way this
     data leaves the site without carrying a link back. */
  footer.site { display: block; border: 0; padding-top: 8pt; }
  footer.site nav { display: none; }
  footer.site .foot-row span::after { content: " · gushi-chinese.online/hsk-3.0/downloads · CC BY-SA 4.0"; }
}

/* ─────────────────────────────────────────────────────────────────────────────
   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; }
   }
   ──────────────────────────────────────────────────────────────────────────── */


/* ═══ THE DOOR ════════════════════════════════════════════════════════════
   The landing page is one picture and two questions. It replaced a scroll-
   scrubbed opening built on GSAP, ScrollTrigger and Lenis; nothing here needs
   any of them, and the page carries no JavaScript at all.

   THE PICTURE'S OWN RULES ARE NOT HERE ANY MORE. `.scene`, `.frame`, `.art`,
   the birds and the haze moved to public/assets/scene.css on 2026-09-05, when
   the Teaching Platform's sign-in started drawing the same view behind its
   card and needed them on a host that does not load this file. What stayed is
   everything that is about the two QUESTIONS — which is everything the
   platform has no use for. */


/* A question. Two of them, one per person, each a real link. Anchored by its
   INNER edge so the leader line's origin never moves as the text rewraps, and
   centred on 60.1% — which is where the two heads are, once the fixed crop
   above is accounted for. Both sides sit at the same height on purpose: the
   page asks two questions of equal weight and a stagger would rank them. */
.q { position: absolute; display: block; width: min(27ch, 25vw); animation: door-rise .9s cubic-bezier(.2,.7,.3,1) both; }
.q--learner { right: 59%; top: 64.6%; transform: translateY(-50%); text-align: right; animation-delay: .15s; }
.q--teacher { left: 59%;  top: 64.6%; transform: translateY(-50%); text-align: left;  animation-delay: .3s; }
.q .who { display: block; margin-bottom: 9px; font-size: 11.5px; font-weight: 500; letter-spacing: .13em; text-transform: uppercase; color: var(--muted); }
.q .ask { margin: 0; font-family: 'Fraunces', Georgia, serif; font-weight: 500; font-size: clamp(19px, 1.9vw, 30px); line-height: 1.22; letter-spacing: -.015em; text-wrap: balance; }
.q .go { display: inline-block; margin-top: 12px; font-size: 14px; color: var(--seal-text); border-bottom: 1px solid transparent; padding-bottom: 1px; transition: border-color .25s; }
.q:hover .go, .q:focus-visible .go { border-bottom-color: currentColor; }
.q:focus-visible { outline: 2px solid var(--seal); outline-offset: 14px; border-radius: 4px; }
/* The leader line is CSS, not SVG: it starts at the edge of a box whose height
   the text decides. Fourteen pixels of gap, because flush against the last
   glyph it reads as a strikethrough continuing off the word. */
.q::after { content: ''; position: absolute; top: calc(50% + 2px); border-top: 1px solid var(--hair); width: clamp(28px, 4.4vw, 74px); }
.q--learner::after { right: -14px; transform: translateX(100%); }
.q--teacher::after { left: -14px;  transform: translateX(-100%); }
.q::before { content: ''; position: absolute; top: calc(50% - 1.5px); width: 5px; height: 5px; border-radius: 50%; background: var(--fg); opacity: .5; transition: opacity .25s, transform .25s; }
.q--learner::before { right: calc(-1 * clamp(28px, 4.4vw, 74px) - 18px); }
.q--teacher::before { left:  calc(-1 * clamp(28px, 4.4vw, 74px) - 18px); }
/* HER BUN REACHES FURTHER INTO THE GAP THAN HIS HEAD DOES. The two questions
   are anchored symmetrically at 59%, but the drawing is not symmetric: the
   teacher's hair sits up and to the RIGHT of her head, which put the dot 3px
   ON the bun while the learner's cleared his head by 11px. It read as part of
   the drawing rather than as the end of a line. So her line is one bun shorter
   and its dot moves out with it, which lands both sides at the same clearance.
   The offset is in vw because the drawing scales with the viewport and a fixed
   pixel would only be right at one width. */
.q--teacher::after  { width: calc(clamp(28px, 4.4vw, 74px) - clamp(8px, 1vw, 18px)); }
.q--teacher::before { left:  calc(-1 * (clamp(28px, 4.4vw, 74px) - clamp(8px, 1vw, 18px)) - 18px); }
.q:hover::before, .q:focus-visible::before { opacity: 1; transform: scale(1.5); }

/* THE THIRD WAY IN. The two questions are doors for two people; the HSK hub
   belongs to neither and is what most people arriving from a search actually
   came for. So it sits under the picture's centre, on the book, in the quiet
   link style the questions use for their own "see the app". It carries no
   kicker: a label would make it a third door, and it is not one.

   It has its own keyframe because `.q` animates with translateY(-50%) baked
   in, and this one is centred on X instead. */
.door-hub {
  position: absolute; left: 50%; top: 80.5%; transform: translateX(-50%);
  white-space: nowrap; font-size: 14px; color: var(--seal-text);
  border-bottom: 1px solid transparent; padding-bottom: 2px;
  animation: door-rise-c .9s cubic-bezier(.2,.7,.3,1) both; animation-delay: .45s;
}
.door-hub:hover, .door-hub:focus-visible { border-bottom-color: currentColor; }
@keyframes door-rise-c {
  from { opacity: 0; transform: translate(-50%, 10px); }
  to   { opacity: 1; transform: translate(-50%, 0); }
}

@keyframes door-rise { from { opacity: 0; transform: translateY(calc(-50% + 10px)); } to { opacity: 1; transform: translateY(-50%); } }

/* The picture's half of this lives in scene.css — the flocks, the wings and
   the haze, which are the same on both hosts. These two are the landing page's
   own entrances and stop only here. */
@media (prefers-reduced-motion: reduce) {
  .q, .door-hub { animation: none; }
}

@media (max-width: 900px) {
  /* A PHONE GETS THE WHOLE PICTURE, NOT A STRIP CUT OUT OF ITS MIDDLE.
     What this replaced: `width: 200%` with negative margins on all four sides,
     which cropped to the two figures and the ledge. It threw away the two
     things that make this drawing the brand — the teal wash across the top and
     the birds crossing it — and what was left on a phone was a pale band with
     two dots in it.

     The fix is the drawing's own `preserveAspectRatio="xMidYMid slice"`: an
     <svg> with that crops to any box the way object-fit:cover crops a photo.
     So the art is given the HEIGHT it should have and 100% width, and the
     browser keeps the full vertical run — wash, birds, mist, ranges, ledge —
     and spends width instead. Width is the axis this composition has to
     spare: the middle is deliberately empty, and it is the middle that
     survives.

     The questions then sit in normal flow beneath it, which is why .frame goes
     back to being a plain block: on the desktop it exists only so the two
     questions can be positioned in percentages of the PICTURE, and once they
     are stacked under it there is nothing left for it to do. */
  .scene { height: auto; }
  .frame { position: static; translate: none; width: 100%; height: auto; }
  /* Two caps, not one: the clamp is what a portrait phone gets, and the min()
     is the one that saves a phone held sideways — 54svh of a 390px-tall
     landscape window is 210px, and without the second cap the 300px floor
     would fill the whole screen with picture and push both questions off it.
     The negative bottom margin trims the empty paper under the ledge; it is
     the same off-white the page sits on, so nothing is lost and the first
     question moves a third of a screen closer to the two people it is about. */
  .art { width: 100%; height: min(clamp(300px, 54svh, 560px), 64svh); margin: 0 0 -5svh; }

  /* A phone held sideways crops the drawing the OTHER WAY — the box is now
     wider than 16:10, so `slice` takes the top and bottom off and the ledge
     lands near the lower edge of what is left. There is no empty paper to
     trim there, and trimming anyway put the first question straight across
     the two people. The one case where the trim is wrong is the one case
     where an aspect-ratio query can say so. */
  @media (orientation: landscape) {
    .art { margin-bottom: 0; }
  }

  .q { position: static; width: auto; text-align: left; transform: none; padding: 0 clamp(18px, 6vw, 32px); }
  .q--learner { margin-top: clamp(18px, 5vw, 34px); }
  .q--teacher { margin-top: clamp(22px, 6vw, 40px); }
  .q::before, .q::after { display: none; }
  .q .ask { font-size: clamp(21px, 5.4vw, 30px); }
  /* Stacked, it is simply the last of the three, in the flow with the rest —
     and that is exactly the problem the rule above it solves. On the desktop
     the hub sits under the picture's CENTRE, visibly outside both questions.
     Stacked, it lands one line under "See the Teaching Platform" in the same
     ink at the same size, and reads as a second link belonging to the
     teacher's door. The hairline says it belongs to neither: it is the same
     hair the sections and the footer are separated by, inset to the text
     rather than bled to the edges, because it divides two blocks of copy and
     not two bands of the page. */
  .door-hub { position: static; transform: none; margin-top: clamp(26px, 7vw, 40px); padding: 0 clamp(18px, 6vw, 32px); display: block; white-space: normal; }
  .door-hub::before { content: ''; display: block; margin-bottom: clamp(20px, 5vw, 30px); border-top: 1px solid var(--hair); }
  @keyframes door-rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
  @keyframes door-rise-c { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
}

/* ═══ The syllabus tables ═══════════════════════════════════════════════════
   Shared by the two live widgets on /hsk-3.0 and by the fifty per-level pages,
   which render the same rows from the same JSON. These rules were written in
   the <style> block of pages/hsk.html and stayed there when the level pages
   were added, and the consequence was quiet rather than broken: /hsk-3.0/words/4
   set its hanzi at body size, dropped the breadcrumb to default type and drew
   its play buttons as the browser's own grey chrome. One table on two pages
   gets one set of rules; only the #wordlist- and #grammar-scoped overrides
   stayed behind, because those genuinely belong to the hub.

   THE PLAY ICON IS DRAWN HERE RATHER THAN IN THE MARKUP, and that is a
   page-weight decision. The icon is one 240-byte path, and /hsk-3.0/words/6
   carries 1,776 rows: inline, the same picture was repeated until it was HALF
   the megabyte that page weighed — 518 KB of 1,044 KB, and 3,552 DOM nodes
   that exist to draw a speaker. As a mask it is one declaration, downloaded
   once with the stylesheet and cached across all fifty pages, and the button
   is an empty element. The accessible name is on the button either way. */
:root {
  --ic-play: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M4 9v6h4l5 4V5L8 9H4zm12.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4zM14 2v2a8 8 0 0 1 0 16v2a10 10 0 0 0 0-20z'/%3E%3C/svg%3E");
}
.crumbs { font-size: var(--fs-fine); color: var(--muted); margin-bottom: 14px; }
.crumbs a { color: var(--fg-2); }

/* The hanzi rules sit on the CELL, not on an inner .hz span. That is what lets
   the per-level pages write <td class="wl-h">爱情</td> and save a wrapper on
   every one of 10,896 rows; the hub's own rows still carry the span and simply
   inherit through it. */
.wl-h { white-space: nowrap; font-family: 'Noto Serif SC', 'Songti SC', serif; font-size: 22px; color: var(--fg); }
.wl-p { font-weight: 600; white-space: nowrap; }
.wl-p span + span::before { content: ''; }
.wl-m { color: var(--fg-2); }
.wl-l { white-space: nowrap; }
.wl-a { text-align: right; white-space: nowrap; }
.wl-a .icon {
  border: 1px solid var(--hair); background: transparent; color: var(--fg-2); cursor: pointer;
  width: 32px; height: 32px; border-radius: 9px; display: inline-flex; align-items: center;
  justify-content: center; margin-left: 6px; transition: color .15s ease, border-color .15s ease, transform .15s ease;
}
.wl-a .icon:hover { color: var(--fg); border-color: var(--fg-2); }
.wl-a .icon.is-playing { color: var(--seal); border-color: var(--seal); }
.wl-a .icon.open svg { transform: rotate(180deg); }
.icon.play::before {
  content: ''; width: 15px; height: 15px; background: currentColor;
  -webkit-mask-image: var(--ic-play); mask-image: var(--ic-play);
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: center; mask-position: center;
  -webkit-mask-size: contain; mask-size: contain;
}
.wl-ex td { background: var(--bg); padding-top: 4px; }
.wl-ex .hz { font-family: 'Noto Serif SC', serif; font-size: 19px; color: var(--fg); margin-right: 12px; }
.wl-ex .ex-py { color: var(--muted); margin-right: 12px; font-size: 14px; }
.wl-ex .ex-en { color: var(--fg-2); font-size: 14px; }

/* Grammar point names are not short labels — several carry their own example
   list ("离合词（睡觉、说话、上课……）"). They have to wrap and be capped, or the
   column that explains the point gets pushed off the side of the table. */
.gr-n { width: 34%; max-width: 320px; font-family: 'Noto Serif SC', serif; font-size: 19px; color: var(--fg); line-height: 1.35; }
.gr-n .hz { display: block; }
.gr-n .gr-pt { display: block; font-size: 13px; color: var(--muted); margin-top: 3px; }
.gr-c { color: var(--fg-2); white-space: nowrap; }
.gr-cz { display: block; font-family: 'Noto Serif SC', serif; font-size: 13px; color: var(--muted); margin-top: 2px; }
.gr-en { color: var(--fg-2); min-width: 260px; }

@media (max-width: 640px) {
  .wl-h { font-size: 19px; }
  .wl-p { font-size: 13.5px; }
  .wl-a .icon { width: 30px; height: 30px; margin-left: 2px; }
  .gr-n { width: 44%; max-width: none; font-size: 16px; }
}

/* ═══ The per-level list pages ══════════════════════════════════════════════
   /hsk-3.0/words/4 and its 49 siblings. They reuse the document grid and the
   table styles; these are the rules that are theirs alone. */
/* The table's own size, which the hub sets on #wordlist and #grammar and this
   page therefore has to set for itself. Same 15px, so a row looks identical
   whichever of the two pages you meet it on. */
.lvl-list table { min-width: 560px; font-size: 15px; }
.lvl-list td { vertical-align: top; }
@media (max-width: 640px) {
  .lvl-list table { min-width: 0; font-size: 14.5px; }
  .lvl-list th, .lvl-list td { padding: 10px 5px; }
}

.lvl-nav { display: flex; flex-wrap: wrap; gap: 8px; margin-top: clamp(20px, 2.4vw, 32px); }
.lvl-nav a {
  font-size: 14px; font-weight: 600; line-height: 1; color: var(--fg-2);
  padding: 10px 16px; border: 1px solid var(--hair); border-radius: 999px;
  background: var(--card); transition: color .16s ease, border-color .16s ease;
}
.lvl-nav a:hover { color: var(--fg); border-color: var(--fg-2); }
.lvl-nav a.is-on { background: var(--fg); border-color: var(--fg); color: var(--bg); }
/* The example sentence is a block under the meaning rather than a row behind a
   toggle: on a page that exists to BE the complete list, it is the most
   valuable text on it and it belongs in the markup, visible. */
.lvl-ex { display: block; margin-top: 5px; font-size: 13.5px; line-height: 1.5; }
/* The Chinese is the FIRST span and carries no class of its own: on a page
   with 1,854 example sentences on it, class="hz" is 12 bytes repeated 1,854
   times to say something the position already says. */
.lvl-ex > span:first-child { font-family: 'Noto Serif SC', 'Songti SC', serif; font-size: 15px; color: var(--fg); margin-right: 9px; }
.lvl-ex .ex-py { color: var(--muted); margin-right: 9px; }
.lvl-ex .ex-en { color: var(--fg-2); }

/* ═══ /hsk-3.0/downloads ════════════════════════════════════════════════════
   The syllabus as files. The table is a download table and not a data table:
   every cell in the last three columns is a target, so each is drawn as a
   control with its own size on it — a reader deciding between a 40 kB CSV and
   a 2 MB JSON is deciding with that number. */
.dl-tbl table { min-width: 620px; }
/* The level column absorbs every spare pixel so the three format columns
   collapse onto their buttons; left to itself the table spreads five equal
   columns and the controls drift into the middle of their own whitespace. */
.dl-tbl thead th:first-child, .dl-tbl tbody th { width: 100%; }
.dl-tbl tbody th { color: var(--fg); font-weight: 600; white-space: nowrap; }
.dl-tbl tbody tr:last-child th { border-bottom: none; }
.dl-tbl tbody th a { text-decoration: underline; text-decoration-color: var(--hair); text-underline-offset: 3px; }
.dl-tbl tbody th a:hover { text-decoration-color: var(--fg-2); }
.dl-tbl .dl-n { text-align: right; color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
.dl-tbl td a {
  display: inline-flex; align-items: baseline; gap: 7px; font-weight: 600; color: var(--fg);
  border: 1px solid var(--hair); border-radius: 9px; padding: 6px 11px; white-space: nowrap;
  transition: border-color .15s ease, background .15s ease;
}
.dl-tbl td a:hover { border-color: var(--fg-2); background: var(--bg); }
.dl-s { font-style: normal; font-weight: 500; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
.btn .dl-s { color: inherit; opacity: .72; }

/* The same three files offered at the foot of the level page they are a copy
   of, where somebody who has just scrolled a list is standing. */
.dl-inline, .col > .dl-inline { max-width: none; margin-top: 18px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: var(--fs-fine); color: var(--muted); }
.dl-inline a {
  display: inline-flex; align-items: baseline; gap: 6px; font-weight: 600; color: var(--fg);
  border: 1px solid var(--hair); border-radius: 999px; padding: 6px 13px; background: var(--card);
  transition: border-color .15s ease;
}
.dl-inline a:hover { border-color: var(--fg-2); }
.dl-inline a:last-child { border-color: transparent; background: transparent; padding-left: 4px; color: var(--fg-2); }
.dl-inline a:last-child:hover { color: var(--fg); }

.deflist { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 10px 24px; margin: 22px 0 18px; font-size: var(--fs-fine); }
.deflist dt { margin: 0; }
.deflist dd { margin: 0; color: var(--fg-2); line-height: 1.5; }
.deflist code, .snip code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }
.deflist code { color: var(--fg); }
.snip { margin: 12px 0 18px; padding: 14px 16px; overflow-x: auto; background: var(--card); border: 1px solid var(--hair); border-radius: var(--radius); }
.snip code { color: var(--fg-2); white-space: pre; }
@media (max-width: 640px) {
  .deflist { grid-template-columns: 1fr; gap: 3px 0; }
  .deflist dd { margin-bottom: 13px; }
  .dl-tbl table { min-width: 560px; }
  .dl-tbl th, .dl-tbl td { padding: 10px 8px; }
}

/* ═══ The download opt-in ═══════════════════════════════════════════════════
   Two fields and a tick box under the files, revealed by assets/optin.js. It
   is styled to look like what it is — a quiet question at the end of a
   reference page — and deliberately NOT like the primary action: the primary
   action on this page is the download above it, and a signup form that shouts
   louder than the thing it sits under reads as the gate we decided not to
   build. */
.optin { max-width: var(--measure); margin-top: clamp(20px, 2vw, 28px); }
.optin-fields { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 14px; }
.optin-f { display: grid; gap: 6px; }
.optin-f > span { font-size: var(--fs-fine); font-weight: 600; color: var(--fg-2); }
.optin-f input {
  width: 100%; font: inherit; font-size: 15.5px; color: var(--fg); background: var(--card);
  border: 1px solid var(--hair); border-radius: 12px; padding: 12px 15px;
}
.optin-f input:focus { outline: 2px solid var(--seal); outline-offset: 1px; }

/* The honeypot has to be genuinely unreachable, not just invisible: off-screen
   rather than display:none, because some bots skip what is display:none, and
   aria-hidden + tabindex -1 keep it away from a screen reader and the keyboard.
   A sighted reader never meets it either way. */
.optin-hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }

.optin-consent { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: start; margin-top: 18px; font-size: var(--fs-fine); color: var(--fg-2); line-height: 1.5; }
.optin-consent input { width: 17px; height: 17px; margin-top: 2px; accent-color: var(--seal); }
.optin-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin-top: 18px; }
.optin-status { font-size: var(--fs-fine); line-height: 1.5; }
.optin-status.is-ok { color: var(--fg-2); }
.optin-status.is-bad { color: var(--seal-text); }
.optin .fine { margin-top: 16px; }
.optin .fine a { color: var(--seal-text); border-bottom: 1px solid transparent; }
.optin .fine a:hover { border-bottom-color: currentColor; }
@media (max-width: 640px) {
  .optin-fields { grid-template-columns: 1fr; }
}

/* ── The bar a download raises ──────────────────────────────────────────────
   Built by assets/optin.js, never shipped in the HTML: it exists only for
   somebody who has already taken a file, so there is nothing here for a
   crawler to read as a paywall and nothing on the page for a reader who never
   downloads.

   IT IS NOT A MODAL. No backdrop, no focus trap, no scroll lock — the page
   behind it stays usable and the file is already downloading. That is the
   whole difference between this and the popup the rest of the internet puts in
   front of the same data. It closes on the ×, it remembers, and it never comes
   back. */
.optin-bar {
  position: fixed; z-index: 60; right: clamp(12px, 2vw, 26px); bottom: clamp(12px, 2vw, 26px);
  width: min(420px, calc(100vw - 24px)); padding: clamp(18px, 2vw, 24px);
  background: var(--card); border: 1px solid var(--hair); border-radius: var(--radius);
  box-shadow: 0 18px 44px -18px rgba(21, 20, 15, .34), 0 2px 8px -4px rgba(21, 20, 15, .18);
  transform: translateY(14px); opacity: 0;
  transition: transform .24s ease, opacity .24s ease;
}
.optin-bar.is-up { transform: none; opacity: 1; }
.optin-bar .optin { margin-top: 14px; max-width: none; }
.optin-bar .optin-fields { grid-template-columns: 1fr; gap: 10px; }
.optin-bar .optin-consent { margin-top: 14px; }
.optin-bar .optin-actions { margin-top: 14px; }
.optin-bar-t { font-size: 15.5px; line-height: 1.45; color: var(--fg); font-weight: 600; padding-right: 26px; }
.optin-x {
  position: absolute; top: 10px; right: 10px; width: 30px; height: 30px;
  font: inherit; font-size: 20px; line-height: 1; cursor: pointer; color: var(--muted);
  background: transparent; border: 0; border-radius: 8px;
}
.optin-x:hover { color: var(--fg); background: var(--bg); }
@media (prefers-reduced-motion: reduce) {
  .optin-bar { transition: none; transform: none; }
}
@media (max-width: 640px) {
  /* Full width at the foot of the screen, where a phone's own sheets appear. */
  .optin-bar { left: 12px; right: 12px; bottom: 12px; width: auto; }
}
/* It is a courtesy on screen and noise on paper. */
@media print { .optin-bar { display: none; } }

/* ═══ /app ════════════════════════════════════════════════════════════════ */

/* The opening band bleeds past .doc, so it is a SIBLING of the document grid
   — see the note on `.band`: bleeding a child of a max-width container to the
   viewport edge needs a 100vw trick that overflows by the scrollbar. */
.opener { position: relative; isolation: isolate; margin-top: calc(-1 * (var(--pad) + 76px)); padding-top: calc(var(--pad) + 132px); padding-bottom: clamp(40px, 5vw, 86px); overflow: hidden; }
/* THE WASH REACHES THE TOP EDGE ON A PHONE TOO. The rule above pulls the
   opener up under the pill by `--pad + 76px`, which is the pill's height on a
   desktop. On a phone the pill grows a second row and the body is padded by
   `--pad + 116px` instead — so 40px of bare paper stood above the wash,
   exactly where the page's first colour belongs. The padding grows by the same
   40px, so the headline does not move.
   IT LIVES HERE AND NOT IN THE ≤720 BLOCK ABOVE, which is where it was
   written first and where it did nothing: that block comes earlier in the
   file, the two rules have the same specificity, and the later one wins. */
@media (max-width: 720px) {
  .opener { margin-top: calc(-1 * (var(--pad) + 116px)); padding-top: calc(var(--pad) + 172px); }
}
.opener-art { position: absolute; inset: 0; z-index: -2; width: 100%; height: 100%; display: block; }
/* Forty real HSK entries, each in the tone colour the app gives it — the same
   colours, for the same tones, which is the only reason a character field is
   allowed to be decorative here. Positions are fixed, not random: a
   background that reshuffles on reload cannot be judged twice. */
.glyphs { position: absolute; inset: 0; z-index: -1; overflow: hidden; pointer-events: none; }
.glyphs span { position: absolute; font-family: 'Noto Serif SC', 'Songti SC', serif; font-weight: 500; line-height: 1; user-select: none; }
.opener-in { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) minmax(180px, .42fr); gap: clamp(20px, 4vw, 64px); align-items: center; }
.opener h1 { margin: 0; font-size: var(--fs-display); line-height: 1.02; letter-spacing: -.025em; text-wrap: balance; }
.opener h1 .two { display: block; color: var(--seal-text); }
/* The lead came out from between the headline and the badges, and the two
   closed up on each other. This is the gap that paragraph used to hold. */
.opener .cta-row { margin-top: clamp(30px, 3.4vw, 52px); }
.dragon { display: block; width: 100%; max-width: 280px; margin-left: auto; height: auto; }

/* Two columns on one level. The right one used to hang lower so the eye would
   FALL from one brand to the other instead of comparing the lists row by row.
   With the paragraph that framed them gone, hanging one of two equal claims
   lower stopped reading as intent and started reading as a layout that
   slipped. */
.duel { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: clamp(28px, 5vw, 90px); align-items: start; }
.brandmark { display: flex; align-items: center; gap: clamp(14px, 1.8vw, 24px); margin: 0 0 24px; }
.brandmark img { width: clamp(72px, 9.5vw, 132px); height: auto; border-radius: clamp(16px, 2.2vw, 30px); }
.brandmark b { font-weight: 700; letter-spacing: -.04em; font-size: clamp(34px, 5vw, 64px); line-height: 1; }
.plist { list-style: none; margin: 0; padding: 0; display: grid; gap: 14px; }
.plist li { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 12px; align-items: start; font-size: var(--fs-body); line-height: 1.5; }
.plist .m { width: 22px; height: 22px; margin-top: 1px; }
.plist .yes { color: var(--jade); }
.plist .no { color: var(--seal); }
.plist li.is-no { color: var(--fg-2); }
/* The bold inside a negative point takes FULL ink, not the softened grey
   the rest of the line sits in. The grey is the hierarchy — a drawback
   reads quieter than a strength — but emphasis set in the same grey it is
   emphasising stops being emphasis. This is what makes the column
   scannable: six bold fragments, three of them wins and three of them
   costs, and the mark beside each says which. */
.plist li.is-no b { color: var(--fg); }

/* The hinge between what the others do and what this does. It reads upward,
   which is the whole reason the arrow is drawn rather than typed: two strokes
   leave the two columns above and merge into one before the head. A "↓" glyph
   would point at the logo; this one says the two lists arrive AT it. */
/* No top padding: the section above ends on its own, and a second one was
   the empty band under the last bullet. */
.seam-chop { display: grid; place-items: center; gap: clamp(14px, 1.8vw, 22px); padding: 0 0 clamp(34px, 4.4vw, 68px); text-align: center; }
.seam-chop .claim { margin: 0; max-width: 26ch; font-family: 'Fraunces', Georgia, serif; font-weight: 500; font-size: clamp(21px, 2.4vw, 34px); line-height: 1.2; letter-spacing: -.02em; text-wrap: balance; }
.seam-chop .merge { display: block; width: clamp(76px, 8vw, 108px); height: auto; color: var(--fg-2); }
.seam-chop .chop { width: clamp(88px, 11vw, 136px); height: clamp(88px, 11vw, 136px); border-radius: clamp(18px, 2.2vw, 28px); box-shadow: 0 18px 40px -22px rgba(200, 69, 46, .75); }
.seam-chop .chop span { font-size: clamp(30px, 3.8vw, 48px); }
.seam-chop .name { font-family: 'Fraunces', Georgia, serif; font-weight: 600; font-size: clamp(32px, 4.6vw, 58px); letter-spacing: -.03em; }

/* The six. The column RATIO flips with the order, not just the order: a
   screenshot dropped into the narrower of two unequal columns on every other
   row is how "alternating" turns into "slightly different each time". */
/* THE HEADING COMES FIRST IN THE MARKUP, ALWAYS, AND THE PICTURE FOLLOWS.
   It used to be the other way round, because on a desktop the odd rows put
   the capture in the left column and the source order was written to match.
   Stacked on a phone that is what the reader actually got: a screenshot of a
   screen nobody has named yet, then a number, then the sentence that says
   what was in it. Same for anyone listening to the page — the alt text of a
   dense capture, read out before the heading it illustrates.
   So the order is heading, then capture, and the LEFT-RIGHT alternation on
   the desktop is done here in `order` where it belongs: it is a rhythm, not
   a reading order. */
.feat { display: grid; grid-template-columns: minmax(0, .92fr) minmax(0, 1fr); gap: clamp(26px, 5vw, 84px); align-items: center; padding: clamp(34px, 5vw, 74px) 0; border-top: 1px solid var(--hair); }
.feat > .feat-media { order: -1; }
.feat:nth-of-type(even) { grid-template-columns: minmax(0, 1fr) minmax(0, .92fr); }
.feat:nth-of-type(even) > .feat-media { order: 0; }
.feat-body h3 { margin: 0 0 12px; font-size: var(--fs-h2); line-height: 1.15; letter-spacing: -.02em; text-wrap: balance; }
.feat-body .n { display: block; margin-bottom: 10px; font-size: var(--fs-fine); letter-spacing: .13em; color: var(--muted); }
.feat-media { display: flex; justify-content: center; margin: 0; }
.feat-media .fsrs-graph { width: 100%; max-width: 560px; margin: 0; }
/* A FRAME THAT HAS NOT LOADED YET MUST NOT BE A BLACK SLAB. The background
   was --ink, on the theory that a dark screenshot belongs on a dark ground.
   What it produced is a phone-shaped black rectangle for as long as the
   image takes to arrive — which, this far down a lazily-loaded page, is
   exactly what a reader reports as "the screenshot is just black".
   display:block is the other half: these frames were <span>s and only
   behaved because a flex or grid parent blockified them. */
.capture { display: block; width: 100%; max-width: 296px; border-radius: clamp(14px, 1.4vw, 22px); overflow: hidden; border: 1px solid var(--hair); background: var(--bg-sunken); box-shadow: 0 6px 12px rgba(26,24,19,.06), 0 34px 64px -38px rgba(26,24,19,.55); margin: 0; }
.capture img { display: block; width: 100%; height: auto; }
/* Row three makes two claims — the calendar and the skills breakdown are
   different arguments and one capture cannot be both. */
.feat-media--two { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: clamp(10px, 1.4vw, 20px); align-items: start; }
.feat-media--two .capture { max-width: none; }

/* The closing band fades in AND out. The opener may start at full strength
   because the top of the page has nothing above it to meet; a band in the
   middle of a document has two edges, and either one left hard reads as a
   coloured box someone dropped on the page. */
.closer { position: relative; overflow: hidden; margin-top: clamp(20px, 2.6vw, 44px); padding: clamp(56px, 7vw, 104px) 0 clamp(52px, 7vw, 100px); }
.closer-art { position: absolute; inset: 0; z-index: -1; width: 100%; height: 100%; }
.closer-in { display: grid; grid-template-columns: minmax(140px, .3fr) minmax(0, 1fr); gap: clamp(20px, 4vw, 56px); align-items: center; }
/* No bottom margin: the heading is the only thing above the badges now, and
   the 10px was the gap to a paragraph that is gone. */
.closer h2 { margin: 0; font-size: var(--fs-h1); line-height: 1.06; letter-spacing: -.025em; text-wrap: balance; }
.closer .lead { max-width: 46ch; }

@media (max-width: 860px) {
  .opener-in { grid-template-columns: 1fr; }
  .dragon { max-width: 170px; margin: 22px auto 0; }
  .duel { grid-template-columns: 1fr; gap: 44px; }
  .feat, .feat:nth-of-type(even) { grid-template-columns: 1fr; gap: 24px; }
  /* One column: the rhythm has nowhere to happen, so every row falls back to
     the source order — heading, then the capture it is about. */
  .feat > .feat-media, .feat:nth-of-type(even) > .feat-media { order: 0; }
  .closer-in { grid-template-columns: 1fr; text-align: center; justify-items: center; }
  /* The grid centres the block; the row inside it still packs its badges to
     the left, so a centred heading sat over two badges hugging the left
     margin. Centring the row is the other half of the same instruction. */
  .closer-in .cta-row { justify-content: center; }
}

/* THE GRAPH'S TYPE IS SET IN USER UNITS, AND USER UNITS SHRINK.
   The FSRS drawing is a 640-wide viewBox scaled to whatever column it is in.
   On a desktop that is about 560 CSS px and 12 units render as ~10.5px. On a
   phone the same drawing is 358px wide, so every label renders at 6.7px —
   present, and unreadable, which is worse than absent on the one graphic that
   carries an argument. The sizes below are the desktop ones multiplied back
   up by roughly the ratio the drawing lost. */
@media (max-width: 640px) {
  .fsrs-graph text { font-size: 19px; }
  .fsrs-graph .lab, .fsrs-graph .dim, .fsrs-graph .gap text { font-size: 18px; }
  .fsrs-graph .curve-lab { font-size: 19px; }
  /* "ohne Wiederholung" starts at x=470 of a 640 viewBox and runs right, so it
     is the one label that scaling up pushes off the drawing and into the page
     margin. It stays a notch smaller. */
  .fsrs-graph .curve-lab.dim { font-size: 16px; }
}

/* The door carries no navigation bar, so it also cancels the padding the bar
   reserves. Three links do not need a menu; two questions need one less. The
   mark and the language switch sit in the corners of the picture instead, in
   full-strength ink — on the deepest part of the wash the muted grey the bar
   uses measures 2.3:1, which is unreadable rather than quiet, so here the
   current language is told apart by WEIGHT alone. */
body.door-page { padding-top: 0; }
.door-mark, .door-lang { position: absolute; z-index: 3; top: clamp(18px, 2.4vw, 34px); }
.door-mark { left: clamp(18px, 3vw, 44px); display: flex; align-items: center; gap: 11px; color: inherit; }
.door-mark .chop { width: 34px; height: 34px; border-radius: 8px; font-size: 12px; }
.door-mark .brand { font-family: 'Fraunces', Georgia, serif; font-weight: 600; font-size: 19px; letter-spacing: -.01em; }
.door-lang { right: clamp(18px, 3vw, 44px); display: inline-flex; align-items: center; gap: 7px; font-size: 13.5px; line-height: 34px; }
.door-lang a { color: var(--fg); padding: 4px 3px; }
.door-lang a:hover { text-decoration: underline; text-underline-offset: 4px; }
.door-lang a[aria-current] { font-weight: 600; }
.door-lang i { color: rgba(26,24,19,.3); font-style: normal; }

/* ═══ /teaching ═══════════════════════════════════════════════════════════ */

/* The opener is /app's, with the headline and the button on one line instead
   of a headline over a lead — this page asks for a conversation, not a
   download, and one button next to the claim is the whole offer. */
/* The wash is anchored to the BOTTOM of this box (xMidYMax), so growing the
   padding walks the ridge downward rather than stretching it. The section
   below is then pulled back up into that space by the same amount, which puts
   the mountains behind its first screenful instead of leaving it on bare
   paper. The opener keeps overflow:hidden and its own stacking context, so
   nothing it holds can paint over what follows. */
.opener--teach { padding-bottom: clamp(200px, 25vw, 380px); }
.row--under { border-top: 0; padding-top: 0; margin-top: calc(-1 * clamp(140px, 17vw, 270px)); }
.opener-in--teach { grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: clamp(28px, 6vw, 96px); }
.opener--teach h1 { margin: 0; font-size: var(--fs-display); line-height: 1.02; letter-spacing: -.025em; text-wrap: balance; }
.opener--teach h1 .two { display: block; color: var(--seal-text); }
.opener--teach .cta-row { margin: 0; }
/* 教, to teach — one character behind the words, the way the door page used to
   open on 学. Four per cent, so it is a ground and not a graphic. */
.opener-glyph {
  position: absolute; z-index: -1; right: 2vw; top: 50%; translate: 0 -46%;
  font-size: clamp(240px, 34vw, 560px); line-height: .8; color: var(--fg);
  opacity: .045; user-select: none; pointer-events: none;
}

/* A row with no rail: sections two and three make their argument as a picture
   and would read as a numbered chapter otherwise. */
.row--plain { grid-template-columns: minmax(0, 1fr); }

/* Three wishes, rising away from the person having them. The stagger is the
   point — they arrive one after another, not as a list of three equal claims,
   and the widths shrink upward so the stack reads as speech leaving a head
   rather than as a table. */
/* THOUGHTS, NOT SPEECH. She is not saying these to anyone — she is wishing
   them, which is why the corners are all equally round and why the tail is
   two loose dots rather than a pointer. A speech tail claims an audience; a
   dot trail claims only a head. The stack is lifted clear of her by roughly
   the height of that head, so the dots have somewhere to climb from. */
/* CENTRED AS A GROUP. It used to span the whole document column, which on a
   wide screen put the bubbles hard against the right edge and left the left
   half of the page empty — a cluster only reads as a cluster if it has air on
   both sides of it. The cap is the width of the stack plus her, not the width
   of the page. */
.imagine { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: clamp(16px, 3vw, 48px); align-items: end; max-width: 1040px; margin-inline: auto; }
/* The lift is measured against HER, not against the page: the bottom of the
   stack has to clear the top of her head, or the dot trail sets off downward
   past a head it is supposed to be climbing from. */
.imagine-bubbles { display: grid; gap: clamp(12px, 1.6vw, 20px); justify-items: end; margin-bottom: clamp(64px, 10.5vw, 158px); }
/* The stagger, and it is a horizontal one: each bubble sits further left than
   the one below it, so the three drift up and away from her head instead of
   stacking into a column of three equal boxes with one flush edge. */
.imagine-bubbles .bubble:nth-child(1) { margin-right: clamp(0px, 5.5vw, 78px); }
.imagine-bubbles .bubble:nth-child(2) { margin-right: clamp(0px, 2.6vw, 38px); }
.bubble {
  position: relative; margin: 0; width: fit-content; max-width: 34ch;
  padding: clamp(14px, 1.6vw, 22px) clamp(18px, 2.2vw, 30px);
  border: 1px solid var(--hair); border-radius: clamp(22px, 2.6vw, 36px);
  background: var(--card); box-shadow: var(--shadow);
  font-family: 'Fraunces', Georgia, serif; font-weight: 500;
  font-size: clamp(17px, 1.9vw, 27px); line-height: 1.25; letter-spacing: -.015em;
  text-wrap: balance;
}
/* The trail hangs off the LAST bubble, the one nearest her, and steps down and
   out toward her head: bigger nearer the thought, smaller nearer the thinker. */
.bubble:last-child::after,
.bubble:last-child::before {
  content: ''; position: absolute;
  border: 1px solid var(--hair); border-radius: 50%;
  background: var(--card); box-shadow: var(--shadow);
}
.bubble b { font-weight: 600; color: var(--fg); }
.bubble:last-child::after  { right: 20px; bottom: -22px; width: 16px; height: 16px; }
.bubble:last-child::before { right: -8px; bottom: -46px; width: 10px; height: 10px; }
/* THE FADE IS IN THE PLAIN TEXT, NOT IN THE WHOLE BUBBLE.
   `opacity: .55` on the first wish took its bold clause down with it, so the
   one part of the sentence that has to land — the thing you would actually
   know — was the faintest ink in the stack. The sequence is still there: the
   sentence AROUND the claim lightens as you read upward. The claim itself is
   full ink in all three, which is what `b` is for. */
.imagine-bubbles .bubble:nth-child(1) { color: var(--muted); }
.imagine-bubbles .bubble:nth-child(2) { color: var(--fg-2); }
.imagine-teacher { width: clamp(130px, 16vw, 224px); height: auto; display: block; }
.seam-chop--answer { padding-top: clamp(26px, 3.4vw, 52px); }
.seam-chop--answer .chop { width: clamp(72px, 8.5vw, 108px); height: clamp(72px, 8.5vw, 108px); border-radius: clamp(16px, 1.9vw, 24px); box-shadow: 0 16px 36px -20px rgba(200, 69, 46, .7); }
.seam-chop--answer .chop span { font-size: clamp(24px, 2.9vw, 38px); }

/* Section three, the statement. It replaced an analogy that spanned the whole
   document width in two halves; a quotation is one voice, so it takes one
   column and a measure narrow enough to read as speech. The seal rule down
   the left is the same idiom the paper card in section four uses — the site's
   mark for "this is quoted, not written here" — and it is what keeps a block
   of large serif from reading as a second heading.

   THE CAP IS IN PIXELS, NOT `ch`. The figure inherits the body sans, so a `ch`
   here would be measured in a font the quotation is not set in — 30ch came out
   around 250px and broke the sentence into five-word lines. 760 is roughly 45
   characters of Fraunces at the size below: a pull quote, not a column. */
.statement { margin: 0; max-width: min(100%, 760px); display: grid; gap: clamp(14px, 1.8vw, 24px); }
.statement blockquote { margin: 0; display: grid; gap: clamp(10px, 1.2vw, 16px); padding-left: clamp(18px, 2.4vw, 34px); border-left: 3px solid var(--seal); }
/* 400, WHICH IS A WEIGHT NOTHING ELSE ON THE SITE USES. Every other serif
   here is 500 or 600, because everything else is a heading, a claim or a
   name — things that are meant to be read before the sentence next to them.
   A quotation is not one of those: set at 500 across four lines of 32px it
   read as a second headline, all emphasis and no sentence. So the voice is
   light and one clause carries the ink, which is the same split `.bubble` and
   the benefits list use. (The 400 rides in on the Fraunces request in
   partials/head.html — it is a variable font, so the extra weight costs a
   wider axis range and not a second file.) */
.statement p { margin: 0; font-family: 'Fraunces', Georgia, serif; font-weight: 400; font-size: clamp(20px, 2.3vw, 32px); line-height: 1.26; letter-spacing: -.02em; text-wrap: balance; color: var(--fg-2); }
.statement b { font-weight: 600; color: var(--fg); }
/* The signature, in the same small tracked caps the rest of the site uses for
   "what this is": the shot label, the rail. It sits under the rule rather than
   inside it — an attribution is not part of the quotation. */
.statement figcaption { padding-left: clamp(18px, 2.4vw, 34px); font-size: 11.5px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); }

/* The reference, set rather than photographed: real text, so a crawler reads
   it, a reader can select it, and a wrong volume number is a copy edit. */
/* The two findings are the section; the reference is the receipt. So the
   citation gets the narrow column and small type, and the points get serif at
   heading size — the same weight the page gives a claim it wants believed. */
.study { display: grid; grid-template-columns: minmax(0, .52fr) minmax(0, 1fr); gap: clamp(24px, 4vw, 72px); align-items: center; margin-top: clamp(22px, 2.6vw, 38px); }
.paper { display: grid; gap: 8px; margin: 0; padding: clamp(16px, 1.8vw, 24px); border: 1px solid var(--hair); border-left: 3px solid var(--seal); border-radius: var(--radius); background: var(--card); box-shadow: var(--shadow); color: var(--fg); transition: border-color .16s ease, transform .16s ease; }
.paper:hover { border-color: var(--fg-2); transform: translateY(-1px); }
.paper-kind { font-size: 10.5px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
.paper-title { font-family: 'Fraunces', Georgia, serif; font-weight: 600; font-size: var(--fs-small); line-height: 1.3; }
.paper-meta { font-size: var(--fs-fine); color: var(--fg-2); }
.paper-go { margin-top: 4px; font-size: var(--fs-fine); font-weight: 600; color: var(--seal-text); }
.study .plist { gap: clamp(20px, 2.4vw, 32px); }
.study .plist li { grid-template-columns: 26px minmax(0, 1fr); gap: 16px; font-family: 'Fraunces', Georgia, serif; font-weight: 500; font-size: clamp(19px, 2.1vw, 30px); line-height: 1.22; letter-spacing: -.018em; text-wrap: balance; }
.study .plist .m { width: 26px; height: 26px; margin-top: 4px; }

/* The four benefits are the section and the capture is the evidence, so the
   list gets serif at the size the study's findings get and the screenshot
   takes the narrower column. The bold carries the claim, the rest the
   qualifier — the same split the comparison on /app uses. */
.benefits { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, .88fr); gap: clamp(24px, 4vw, 64px); align-items: center; margin-top: clamp(22px, 2.6vw, 38px); }
.benefits .plist { gap: clamp(18px, 2.2vw, 28px); }
.benefits .plist li { grid-template-columns: 26px minmax(0, 1fr); gap: 16px; font-family: 'Fraunces', Georgia, serif; font-weight: 500; font-size: clamp(18px, 2vw, 28px); line-height: 1.24; letter-spacing: -.018em; text-wrap: balance; color: var(--fg-2); }
.benefits .plist li b { font-weight: 600; color: var(--fg); }
.benefits .plist .m { width: 26px; height: 26px; margin-top: 3px; }
/* The capture is a picture of numbers until something says whose screen it is,
   so the frame carries a label — small, tracked and muted, the same idiom the
   rest of the site uses for "what this is". It sits ABOVE the frame and flush
   right: inside it would cover the course switcher in the corner of the very
   screen it is naming. */
.benefits-shot { margin: 0; display: grid; gap: 10px; }
.shot-label { justify-self: end; font-size: 11.5px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); }
.shot-frame { border: 1px solid var(--hair); border-radius: var(--radius); overflow: hidden; box-shadow: 0 6px 12px rgba(26,24,19,.06), 0 34px 64px -38px rgba(26,24,19,.5); }
.shot-frame img { display: block; width: 100%; height: auto; }

/* `text-align: left` is not redundant: the phone rule for `.closer-in` centres
   the text AND the items, and this class only ever overrode the second half —
   so on a phone the heading sat centred over a button flush left. Same
   specificity, later in the file, so this wins; on a desktop it is what the
   text does anyway. */
.closer-in--teach { grid-template-columns: minmax(0, 1fr); justify-items: start; text-align: left; gap: clamp(18px, 2.4vw, 30px); }

@media (max-width: 860px) {
  .opener-in--teach, .imagine, .study, .benefits { grid-template-columns: 1fr; }
  /* Stacked, the citation goes FIRST and the two findings under it. On the
     desktop the paper sits in the narrow column beside them and the eye takes
     both at once — the claim, with its receipt alongside. In one column that
     reading falls apart: two findings in serif at heading size, and then a
     grey card underneath that looks like a footnote nobody has to read. The
     paper on top names who is talking before the claims are made, which is
     the order a reader on a phone actually needs them in. (It used to carry
     `order: 2` here, which is what put it underneath.) */
  .imagine { justify-items: center; }
  .imagine-bubbles { justify-items: stretch; }
  .bubble { width: auto; max-width: none; }
  /* Stacked full-width, the stagger would only ragged the right edge. */
  .imagine-bubbles .bubble:nth-child(1), .imagine-bubbles .bubble:nth-child(2) { margin-right: 0; }
  /* Stacked, she sits under the whole column and the trail would point at
     nothing. */
  .imagine-bubbles { margin-bottom: 0; }
  .bubble:last-child::after, .bubble:last-child::before { display: none; }
  .benefits-shot { order: -1; }
}

/* ─────────────────────────────────────────────────────────────────────────────
   /partner — the flyer

   The narrowest page on the site, and the only one designed for two media at
   once: a link somebody opens, and a sheet somebody prints. Both readings want
   the same thing, which is why this costs so little CSS — a flyer is a short
   column of large type with air between the blocks, and that is a measure and
   three margins rather than a layout.

   IT DOES NOT USE `.row`. A row draws a hairline across the full document
   width and pads by `--section`, which on four short blocks is most of a screen
   of nothing between two sentences. The blocks here are separated by air, and
   only the last one carries a rule — the one place the page changes from
   telling you something to asking you for something.
   ────────────────────────────────────────────────────────────────────────── */
.flyer { max-width: 640px; padding: clamp(40px, 6vw, 84px) 0 clamp(56px, 7vw, 96px); }
.flyer-block { margin-top: clamp(34px, 4.4vw, 60px); }
/* Display size, and set as tight as the opener on /app and /teaching: at this
   size the 1.1 the body h1 carries opens a visible gap between two lines of one
   sentence. `balance` is what keeps the German heading from breaking as
   "Bauen Sie mit / uns." — the English one fits on a line and is unaffected. */
.flyer h1 { font-size: var(--fs-display); line-height: 1.04; letter-spacing: -.03em; text-wrap: balance; margin: 0; }
.flyer h2 { max-width: 22ch; }
.flyer .lead { max-width: none; }
.flyer .plist { margin-top: clamp(16px, 1.8vw, 24px); }
.flyer .plist li b { color: var(--fg); font-weight: 600; }
/* Same size and weight as the benefits list on /teaching, so the two pages
   read as one document rather than as two designs of the same offer. */
.flyer .plist .m { width: 26px; height: 26px; margin-top: 3px; }

/* The kicker names the stage the product is at before the heading says
   anything, and it is in the seal rather than the muted grey the rest of the
   site uses for this idiom: on a page whose whole subject is a limited offer,
   "Pilot" is the most load-bearing word above the fold. */
.flyer-kicker { display: block; font-size: 12px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--seal-text); margin: 0 0 16px; }

/* THE TWO FACTS A SCHOOL IS ACTUALLY WEIGHING, set at heading size because
   that is what they are worth. The length is in full ink and the price is not:
   free is the easier half to believe and the shorter half to read, and setting
   both in the same weight makes the pair read as a slogan instead of as terms. */
.flyer-deal .deal { margin: 0; font-family: 'Fraunces', Georgia, serif; font-weight: 400; font-size: clamp(21px, 2.4vw, 34px); line-height: 1.24; letter-spacing: -.02em; text-wrap: balance; color: var(--fg-2); padding-left: clamp(18px, 2.4vw, 34px); border-left: 3px solid var(--seal); }
.flyer-deal .deal b { font-weight: 600; color: var(--fg); }
/* The two facts stack rather than running on. Set as one line they read as a
   sentence about time that happens to mention money; on two lines they read as
   what they are — the length, and the price. */
.flyer-deal .deal b { display: block; }
.flyer-deal .deal span { display: block; }
.flyer-deal .fine { margin: clamp(16px, 1.8vw, 22px) 0 0; padding-left: clamp(18px, 2.4vw, 34px); }

.flyer-ask { border-top: 1px solid var(--hair); padding-top: clamp(30px, 3.6vw, 48px); }
.flyer-ask h2 { max-width: 24ch; }
.flyer-ask .fine { margin-top: 18px; }

/* Screen: the bar at the top of the window already carries this exact lockup a
   couple of centimetres above, so a second one would be a duplicate. Print
   turns it on — see below. */
.flyer-mark { display: none; align-items: center; gap: 10px; margin-bottom: clamp(26px, 3vw, 40px); }

@media print {
  /* ONE SHEET OF A4, and every rule here exists to keep it to one. The type
     comes down to print sizes, the air between the blocks comes down with it,
     and the two things a screen provides and paper cannot — the bar with the
     brand in it, and a button you can press — are replaced by the mark and by
     the address, which is already text on the page. */
  .flyer { max-width: none; padding: 0; }
  .flyer-mark { display: flex; margin-bottom: 20pt; }
  .flyer h1 { font-size: 34pt; }
  .flyer h2 { font-size: 17pt; }
  .flyer .lead { font-size: 13pt; color: #000; }
  .flyer-block { margin-top: 26pt; break-inside: avoid; }
  .flyer .plist { gap: 12pt; }
  .flyer .plist li { font-size: 12pt; }
  .flyer-deal .deal { font-size: 20pt; color: #000; }
  .flyer-deal .fine { font-size: 10.5pt; }
  .flyer-ask { padding-top: 22pt; }
  /* The global print block hides `.cta-row`, which is right — a printed button
     is a rectangle nobody can press. The line under it carries the address as
     real text, so the sheet still says how to answer it. */
  .flyer-ask .fine { font-size: 11pt; color: #000; }
  .flyer .fine a { color: #000; }
  /* The footer's print rule on /hsk-3.0/downloads appends the licence to the
     copyright line. Nothing on this page is licensed data, and the nav in the
     footer is links a sheet of paper cannot follow. */
  footer.site { display: none; }
}
