/* Palettes.
   -----------------------------------------------------------------------------
   The **light** palette is the product default, and it is selected by the static
   `data-theme="light"` attribute in index.html — markup, so it applies to a
   browser that never runs a line of JavaScript.

   Bare `:root` below still holds the *dark* values. That looks backwards and is
   deliberate: inverting the two palettes would rewrite every colour and every
   comment in this section for no observable difference, since the attribute is
   always present. The only scenario it matters in is markup with the attribute
   stripped, which cannot happen from inside the app. See js/storage/theme.js. */
:root {
  --accent: #F8F184;
  --accent-soft: rgba(248, 241, 132, 0.12);
  --chart-key: #014BAA;
  --bg: #212222;
  --bg-elev: #1c1c1c;
  --text: #FFFFE3;
  --muted: #8a8782;
  --dim: #4f4c47;
  --border: #2a2a2a;
  /* Promoted from two hardcoded `#bf2105` sites (an incorrect character, and the
     forum 删除 button) so the theme can reach them.

     The two themes no longer share this value — the light palette overrides it,
     and records its own reasoning there. On dark, #bf2105 measures 2.6:1 against
     --bg, which is weak for the primary "you typed this wrong" mark; raising it
     is a separate decision that has not been taken. */
  --error: #bf2105;
  /* A shadow tuned for a dark surface looks muddy on a light one. */
  --shadow: 0 8px 30px rgba(0, 0, 0, 0.4);

  /* 输入法 dot colours for the 效率 × 速度 scatter. They live here rather than in
     JavaScript because canvas `fillStyle` cannot take `var()`, so the chart has to
     *resolve* them at draw time — the same route `--border` and `--muted` already
     take into that canvas. See imeColors() in js/ui/scatter-chart.js.
     未填 is deliberately the weakest in both themes: it is an absence, not a
     category. */
  --ime-pinyin: #7aa2f7;
  --ime-shuangpin: #9ece6a;
  --ime-wubi: #f8f184;
  --ime-other: #f7768e;
  --ime-unspecified: #6b6b6b;

  --font-cjk: "Noto Serif SC", "Source Han Serif SC", "Songti SC", "STSong", "SimSun", serif;
  --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;

  --char-size: 2.64rem; 
  --line-height: 1.9;

  /* Target *apparent* size of a header icon: the on-screen square its artwork
     should fill. One knob for all of them — see `.icon-btn img` for why the CSS
     box alone could never deliver this.
     31px needs the rail's buttons to be 84px: the sparsest asset needs a box of
     `--icon-ink / 0.379`, i.e. 81.8px, which must stay inside the button
     (tests/header-icon-size.spec.js asserts exactly that). */
  --icon-ink: 31px;

  /* Width of the fixed icon rail, and therefore the horizontal space the content
     must keep clear of it on each side. Referenced by `.app`, `.icon-btn` and the
     privacy popover, so the three can never disagree about where the rail ends. */
  --rail-w: 84px;

  /* The content width of the test screen, and therefore the left edge everything
     on it aligns to. Derived rather than chosen: `.app` caps at 1120px and spends
     48px of that on horizontal padding, so its content box is 1072px.

     Consumed by `.viewport` and `.timer`. That pairing is the whole point — the
     clock is a label for the text below it, so the two must share one left edge,
     and a literal 1072 repeated in two rules is exactly how they would silently
     drift apart later. */
  --content-w: 1072px;
}

/* Light: warm paper.
   -----------------------------------------------------------------------------
   Chosen to extend the existing character rather than fight it — the dark theme is
   already warm (`--text` is cream, not white; the greys are warm), and a
   neutral-white light theme reads cold under the CJK serif face.

   Contrast against this theme's own --bg, by the WCAG relative-luminance formula.
   Measured, not estimated, and re-measured whenever the palette is retuned —
   these had drifted badly from the values they claimed:
   --text 14.1:1 (AAA) · --muted 5.1:1 (AA) · --accent 10.4:1 (AAA) ·
   --error 4.4:1 (see 3 below). tests/theme.spec.js asserts them.

   Three values that look like mistakes and are not:

   1. `--bg-elev` is *lighter* than `--bg` here and *darker* than it on dark. A
      raised surface has to move away from the page in whichever direction reads as
      "closer to the light".
   2. `--accent` is a deep bronze, nothing like the dark theme's pale yellow. It
      has to be: #F8F184 on paper is ~1.2:1, and the accent draws the cursor
      underline — the one mark that must stay legible while typing.
   3. `--error` is 4.4:1, which is just under AA's 4.5:1 for normal text. This is a
      deliberate product decision (2026-08-28), not an oversight, and it is worth
      knowing exactly what it costs. The colour's dominant use is the
      incorrect-character mark in the typing text at --char-size (~42px), which is
      "large text" by WCAG: the floor there is 3:1 and 4.4:1 clears it with room.
      Its only normal-text use is the 0.8rem forum 删除 button's *hover* colour —
      that button rests at --muted (5.1:1) — so the shortfall costs one hover state
      on one small control. Going any paler needs a fresh decision, not a nudge.

   `--dim` is deliberately low-contrast in both themes (2.1:1 here, 1.9:1 on
   dark). It marks characters already typed, which must recede. */
:root[data-theme="light"] {
  --accent: #443602;
  --accent-soft: rgba(122, 97, 8, 0.061);
  --chart-key: #014BAA;
  --bg: #F4F0E6;
  --bg-elev: #FBF8F1;
  --text: #23211C;
  --muted: #6B655A;
  --dim: #aaa69c;
  --border: #DED7C7;
  --error: #d5290b;
  --shadow: 0 8px 30px rgba(60, 50, 30, 0.14);

  /* The dark dot colours collapse on paper: 双拼's green is 1.9:1 and 五笔's *is*
     the dark accent yellow. These all clear the 3:1 floor for graphical objects.
     拼音 4.8:1 · 双拼 4.4:1 · 五笔 4.0:1 · 其他 4.8:1 · 未填 3.2:1. */
  --ime-pinyin: #2E5FBF;
  --ime-shuangpin: #3F7D2E;
  --ime-wubi: #96700A;
  --ime-other: #491644;
  --ime-unspecified: #7b776f;
}

* {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
  padding: 0;
}

body {
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-ui);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  min-height: 100vh;
}

/* The two caps below are a pair: `.viewport`'s max-width is `.app`'s minus its
   48px of horizontal padding, so on a wide window the typing text claims all of
   the content width and no more.

   Why 1120px rather than full-bleed: at --char-size 2.64rem this yields ~23 Han
   characters per line (from 19), and comfortable CJK measure is ~30–40. Going
   wider starts costing reading comfort, which outranks visual polish. The other
   lever, if more is ever wanted, is a smaller --char-size — not more width. */
.app {
  /* Two caps in one, and the second is what keeps the icon rail off the text:
     `calc(100% - 2 * var(--rail-w))` reserves the rail's width on *both* sides, so
     the typing text stays centred on the screen while never passing under the
     rail. It only binds on windows that would have collided anyway — at 1440px the
     1120px cap still wins, so the widened viewport is untouched. */
  max-width: min(1120px, calc(100% - 2 * var(--rail-w)));
  margin: 0 auto;
  padding: 40px 24px 48px;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
}

/* Brand */
.brand {
  width: 100%;
  position: relative;
  margin-bottom: 28px;
  text-align: center;
}

.brand-name {
  font-family: var(--font-cjk);
  font-size: 1.2rem;
  font-weight: 500;
  letter-spacing: 0.35em;
  color: var(--text);
}

/* The icon rail: one column, docked to the right edge, as tall as the window.
   -----------------------------------------------------------------------------
   `justify-content: space-between` inside a full-height box is what produces
   "equal gaps, column as tall as the screen": the first icon sits at the top
   inset, the last at the bottom inset, and the six gaps between them are equal by
   construction — there is no per-icon spacing to re-tune when an icon is added.

   `height` is declared twice on purpose. Mobile browsers keep `100vh` at the
   *largest* viewport height while their chrome is showing, which would push the
   bottom icon under the address bar; `100dvh` tracks the currently visible height.
   The `100vh` line is the fallback for engines that do not know `dvh`.

   `pointer-events: none` on the container is load-bearing, not tidiness. The box
   now spans the whole window height, so its mostly-empty rectangle lies over the
   right edge of every panel — and a container with no background still receives
   pointer events, which is what once made panels' 关闭 buttons unclickable while
   looking completely normal. `.icon-btn` takes its own hit area back below. */
.top-actions {
  position: fixed;
  top: 0;
  right: 0;
  width: var(--rail-w);
  height: 100vh;
  height: 100dvh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: space-between;
  padding: 6px 0;
  pointer-events: none;
}

.icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* Square, and exactly as wide as the space `.app` reserves for the rail. */
  width: var(--rail-w);
  height: var(--rail-w);
  padding: 0;
  background: transparent;
  border: none;
  border-radius: 10px;
  cursor: pointer;
  /* The rail container is deliberately transparent to the mouse (see above), so
     each button re-enables itself. */
  pointer-events: auto;
  transition: background 120ms ease-out;
}

.icon-btn:hover {
  background: var(--accent-soft);
}

.icon-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Unwired header placeholders (信息页面 / 统计 / 论坛) read as inactive until
   Phase 2 wires them. A disabled button leaves the tab order and is announced
   as disabled, and the neutralized hover keeps it from looking clickable. */
.icon-btn:disabled {
  opacity: 0.45;
  cursor: default;
}

.icon-btn:disabled:hover {
  background: transparent;
}

/* Header icon artwork.
   -----------------------------------------------------------------------------
   Intent: make all header icons *look* the same size.

   The trap this replaces: every icon used one identical `width`/`height`, which
   looks like uniformity but is not. An `<img>` box says nothing about how much of
   that box an SVG paints in, and these assets were drawn with very different
   internal margins. Measured (see tests/header-icon-size.spec.js), the artwork
   covered anywhere from 38% to 100% of its own `viewBox`, so at one shared width
   隐私 rendered ~2.6× the apparent size of 静音, and 个人页面 ~2×.

   The fix: express the *ink* size as the constant and let the box follow. Each
   icon declares `--icon-fill` — the measured fraction of its `viewBox` that its
   artwork actually covers, on the icon's larger dimension — and the box is
   `--icon-ink / --icon-fill`. Every icon's artwork then lands on the same
   `--icon-ink` square, whatever margin its author left.

   Adding an icon: measure it, do not guess. The spec file measures every header
   icon, so a wrong (or missing) `--icon-fill` fails the suite rather than shipping
   as a subtly oversized button. Keep `--icon-ink / --icon-fill` below the
   `.icon-btn` size, which the same spec also enforces. */
.icon-btn img {
  /* Fallback of 1 = "assume the artwork fills its viewBox", the conservative
     guess: an un-measured icon comes out too small, never spilling out of its
     button. */
  --icon-fill: 1;
  width: calc(var(--icon-ink) / var(--icon-fill));
  height: calc(var(--icon-ink) / var(--icon-fill));
  display: block;
}

#info-btn img { --icon-fill: 0.399; }
#stats-btn img { --icon-fill: 0.407; }
#forum-btn img { --icon-fill: 0.425; }
#profile-btn img { --icon-fill: 0.795; }
/* privacy.svg's artwork slightly overflows its own viewBox (top edge, ~1.4% of
   the height), and an outer <svg> clips its viewport — so the *visible* ink is the
   full box height. Normalized against the visible ink, not the drawn ink. */
#privacy-btn img { --icon-fill: 1; }
/* One value for both states: sound-on.svg (0.379) and mute.svg (0.378) were drawn
   to the same margin, so swapping the src does not change the apparent size. */
#mute-btn img { --icon-fill: 0.379; }
/* The theme toggle fills 94% of its viewBox — the same trap as privacy.svg: without
   an explicit `--icon-fill` it would fall back to 1 and render far larger than the
   other icons. */
#theme-btn img { --icon-fill: 0.944; }

/* Screens */
.screen {
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.screen[hidden] {
  display: none;
}

/* Settings */
.settings {
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  margin-bottom: 22px;
  max-height: 140px;
  overflow: hidden;
  transition: opacity 150ms ease-out;
}

/* Hiding the settings must NOT remove their box.
   -----------------------------------------------------------------------------
   Intent: the typing text must not move at the moment a test starts.

   These two blocks used to collapse to `max-height: 0` and drop their margins,
   which pulled the timer, the viewport and the live stats up by the block's own
   height — a measured 104px jump for `.settings` and 36px for `.settings-row` —
   at exactly the moment the user's eye is on the first character.

   So the space is reserved instead: only paint and interactivity go away. Nothing
   below moves, and no spacer height has to be kept in sync with the rows.

   `visibility: hidden` is deliberately NOT used here. `setSettingsVisible()` /
   `setMode()` already set `inert`, which takes these controls out of both the tab
   order and the accessibility tree, so `visibility` would add nothing except a
   stepped (non-interpolable) value that breaks the fade. */
.settings.hidden {
  opacity: 0;
  pointer-events: none;
}

.settings-row {
  display: flex;
  gap: 10px;
  max-height: 44px;
  overflow: hidden;
  transition: opacity 150ms ease-out;
}

.settings-row.hidden {
  opacity: 0;
  pointer-events: none;
}

.seg {
  appearance: none;
  background: transparent;
  border: none;
  color: var(--muted);
  font-family: var(--font-cjk);
  font-size: 1rem;
  padding: 7px 16px;
  cursor: pointer;
  border-radius: 999px;
  transition: color 120ms ease-out, background 120ms ease-out;
}

.seg:hover {
  color: var(--text);
}

.seg.active {
  color: var(--accent);
  background: var(--accent-soft);
}

.seg:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Timer */
.timer {
  font-family: var(--font-ui);
  font-size: 2.6rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  color: var(--text);
  /* Flush with the left edge of the typing text, not centred.
     ---------------------------------------------------------------------------
     Intent: the clock reads as a label for the text it is timing. Centred, it
     floated free of everything else on screen and sat over the middle of a line
     it has no relationship with.

     `.screen` centres its children, so the alignment is bought by claiming the
     full content width (capped at the same `--content-w` the viewport uses) and
     letting the digits sit at its start. The two blocks are then centred as
     *boxes*, which puts their left edges on the same x at every window size —
     including ≤560px, where `.app` drops its cap and both simply fill the
     content box. Applies to both modes: 常见一千 counts down and 名著节录 counts
     up through this same element. */
  width: 100%;
  max-width: var(--content-w);
  text-align: left;
  /* Since `.settings.hidden` reserves its box (above), this margin is now the
     ONLY thing setting the timer's vertical position — the settings block no
     longer contributes a variable amount that disappears mid-test. Reserved for
     hand-tuning: measured tops at 1440×900 are timer 334px, viewport 394px,
     both now constant from ready through running. */
  margin-top: 138px;
  margin-bottom: 18px;
  font-variant-numeric: tabular-nums;
  line-height: 1;
}

/* Viewport */
.viewport {
  width: 100%;
  /* Paired with `.app { max-width: 1120px }` — see the note on `--content-w`. */
  max-width: var(--content-w);
  height: calc(3 * var(--char-size) * var(--line-height));
  overflow: hidden;
  position: relative;
}

/* Out-of-focus warning: covers the typing text and centres one short line over it.
   -----------------------------------------------------------------------------
   `backdrop-filter` — and any other real blur — is deliberately avoided: a blur on
   a full-width block of CJK text is expensive on modest hardware, and the point
   here is legibility, not effect. A nearly-opaque scrim buys the same outcome for
   free.

   Why 95% and not the 78% this shipped with: at 78% the three lines behind stayed
   plainly readable and competed with the message, which is the one thing on screen
   that has to be read. The remaining 5% is deliberate — it keeps the overlay from
   reading as a blank panel, so the text is still visibly *there*, just out of
   play. */
.focus-warning {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 12px;
  box-sizing: border-box;
  text-align: center;
  background: color-mix(in srgb, var(--bg) 95%, transparent);
  color: var(--muted);
  font-family: var(--font-ui);
  font-size: 0.95rem;
  transition: opacity 120ms ease-out;
}

@media (prefers-reduced-motion: reduce) {
  .focus-warning {
    transition: none;
  }
}

/* `display: flex` above outranks the UA stylesheet's `[hidden] { display: none }`,
   so the attribute needs an explicit author rule to keep working. */
.focus-warning[hidden] {
  display: none;
}

.offset {
  will-change: transform;
  transition: transform 150ms ease-out;
  font-family: var(--font-cjk);
  font-size: var(--char-size);
  line-height: var(--line-height);
  text-align: left;
  word-break: break-word;
  overflow-wrap: anywhere;
}

.offset.no-transition {
  transition: none;
}

.char {
  color: var(--text);
  transition: color 120ms ease-out;
}

.char.done {
  color: var(--dim);
}

.char.incorrect {
  color: var(--error);
}

.char.cursor {
  box-shadow: inset 0 -3px 0 0 var(--accent);
}

/* Caret blink — a prompt, never decoration.
   -----------------------------------------------------------------------------
   Intent: before the first counted key nothing on the page moves, so a first-time
   visitor gets no signal that the site is waiting for them. A blinking caret is
   the one universally understood "type here".

   It stops the moment the test starts, and that is the deliberate half of the
   rule: from then on the caret is driven by the user's own typing, and an
   underline blinking as it races along the line reads as noise rather than as
   feedback.

   The phase is owned by `#offset.caret-blink`, toggled from the engine's
   ready/running states — see TestScreen.setCaretBlinking. The class is also
   dropped while the out-of-focus warning is up, so nothing flickers underneath
   the one line the user is meant to read there.

   The keyframes restate the exact `box-shadow` of the static caret above, so a
   *running* caret renders identically to what shipped before this animation
   existed: the blink is added, nothing is redefined. `step-end` keeps the swap
   discrete — interpolating the accent towards `transparent` would fade the caret
   through a washed-out tint instead of switching it cleanly off.

   `prefers-reduced-motion: reduce` needs no rule here: the global reset near the
   end of this file already kills every animation, which leaves the caret solid.
   That is the right fallback, since a solid caret still marks the position. */
@keyframes caret-blink {
  0%,
  100% {
    box-shadow: inset 0 -3px 0 0 var(--accent);
  }
  50% {
    box-shadow: inset 0 -3px 0 0 transparent;
  }
}

.offset.caret-blink .char.cursor {
  animation: caret-blink 1.06s step-end infinite;
}

.entry {
  white-space: nowrap;
}

.sep {
  /* The inter-entry space must stay collapsible. `white-space: pre` would
     preserve it across a line break, leaving a stray leading space on every
     wrapped line (and, in WebKit, disabling wrapping entirely). `normal` lets
     it collapse at the break so all three lines stay flush-left. */
  white-space: normal;
}

/* Live stats */
.live-stats {
  display: flex;
  gap: 0;
  margin-top: 20px;
  color: var(--muted);
  font-size: 0.95rem;
}

/* IME-off hint: one quiet line under the live stats. Deliberately not an alert or a
   colour warning — it is a helpful guess, not an error. */
.ime-hint {
  margin: 10px 0 0;
  color: var(--muted);
  font-family: var(--font-ui);
  font-size: 0.85rem;
}

.stat + .stat {
  margin-left: 28px;
  padding-left: 28px;
  border-left: 1px solid var(--border);
}

.stat b {
  color: var(--text);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

/* Results */
.results-screen {
  gap: 26px;
}

/* The results region takes focus when a test ends, so a keyboard or screen-reader
   user is not dropped on `<body>` (see ResultsScreen.focusForResultsScreen).
   Chromium treats that scripted focus move as keyboard-initiated and paints its
   default ring around the *entire* screen — a bright box the design never had.

   Suppressed here and only here: this section is `tabindex="-1"`, so it is neither
   an operable control nor a tab stop, and WCAG 2.4.7 (Focus Visible) attaches to
   keyboard-operable components. Every control inside it — 再试一次 above all — keeps
   its own `:focus-visible` ring, which is what a sighted keyboard user actually
   navigates by. */
.results-screen:focus,
.results-screen:focus-visible {
  outline: none;
}

.results-context {
  margin: 10px 0 0;
  margin-top: 50px;
  color: var(--muted);
  font-size: 0.9rem;
  letter-spacing: 0.05em;
}

.results-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(180px, 1fr));
  gap: 28px 48px;
  margin: 0;
  padding: 0;
}

.result-item dt {
  font-size: 0.85rem;
  color: var(--muted);
  margin-bottom: 6px;
}

.result-item dd {
  margin: 0;
  font-family: var(--font-cjk);
  font-size: 3.0rem;
  color: var(--text);
}

.result-item dd b {
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

.result-item .unit {
  font-size: 0.95rem;
  color: var(--muted);
  margin-left: 4px;
}

.gap-note {
  color: var(--accent);
  font-size: 0.9rem;
  margin: -10px 0 0;
}

.retry-btn {
  appearance: none;
  background: transparent;
  border: 1px solid var(--accent);
  color: var(--accent);
  font-family: var(--font-cjk);
  font-size: 1.05rem;
  padding: 10px 34px;
  border-radius: 8px;
  cursor: pointer;
  transition: background 120ms ease-out, color 120ms ease-out;
}

.retry-btn:hover {
  background: var(--accent);
  color: var(--bg);
}

.retry-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

/* Load-error state (content fetch failed). Shown in place of the typing area. */
.load-error {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
  margin: 8px 0 24px;
}

.load-error[hidden] {
  display: none;
}

.load-error-msg {
  color: var(--muted);
  font-family: var(--font-cjk);
  font-size: 1.05rem;
  margin: 0;
}

.speed-chart-card {
  width: 100%;
  margin-top: 50px;
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 16px 16px 12px;
}

.chart-legend {
  display: flex;
  gap: 18px;
  margin-bottom: 10px;
  font-size: 0.85rem;
  color: var(--muted);
}

.legend-item {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}

.dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  display: inline-block;
}

.dot-hanzi {
  background: var(--accent);
}

.dot-key {
  background: var(--chart-key);
}

.speed-chart {
  width: 100%;
  height: 220px;
  display: block;
}

/* Visually hidden, focusable typing input */
.typing-input {
  position: absolute;
  top: 0;
  left: 0;
  width: 1px;
  height: 1px;
  margin: 0;
  padding: 0;
  border: 0;
  overflow: hidden;
  opacity: 0;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Shared panel for the info / stats / forum / profile screens. Each is a hidden
   <section> that replaces the typing viewport when active; the fixed header
   icons stay visible above them. */
/* Shared panel for the info / stats / forum / profile screens. Each is a hidden
   <section> that replaces the typing viewport when active; the fixed rail icons
   stay visible beside them.

   These panels are the "everything that is not the test" surface, and they are
   styled as one card so that four different screens cannot drift apart: a raised
   surface, a hairline border, a soft shadow, and a ruled header. Nothing here is
   load-bearing — it is the calm-and-legible end of the product principles. */
.panel {
  width: 100%;
  max-width: 760px;
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-radius: 16px;
  padding: 24px 32px 32px;
  text-align: left;
  box-shadow: var(--shadow);
}

.panel-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  /* A rule under the head, so the title reads as a header rather than as the
     first paragraph. */
  margin-bottom: 22px;
  padding-bottom: 14px;
  border-bottom: 1px solid var(--border);
}

.panel-title {
  font-family: var(--font-cjk);
  font-size: 1.35rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  margin: 0;
  color: var(--text);
}

.panel-close {
  appearance: none;
  background: transparent;
  border: 1px solid var(--border);
  color: var(--muted);
  font-family: var(--font-ui);
  font-size: 0.9rem;
  padding: 6px 16px;
  border-radius: 999px;
  cursor: pointer;
  white-space: nowrap;
  transition: color 120ms ease-out, border-color 120ms ease-out, background 120ms ease-out;
}

.panel-close:hover {
  color: var(--accent);
  border-color: var(--accent);
  background: var(--accent-soft);
}

.panel-close:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.panel-body {
  display: flex;
  flex-direction: column;
  gap: 22px;
}

.panel-body h2 {
  font-family: var(--font-cjk);
  font-size: 1.05rem;
  font-weight: 600;
  color: var(--accent);
  margin: 0 0 8px;
}

.panel-body p,
.panel-body li {
  color: var(--text);
  font-size: 0.95rem;
  line-height: 1.75;
  margin: 0;
}

.panel-body ul {
  margin: 0;
  padding-left: 20px;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.panel-body li strong {
  color: var(--text);
  font-weight: 600;
}

/* The information page: a lede, then collapsed Q&A.
   -----------------------------------------------------------------------------
   Intent: every question scannable, every answer one click away, and the whole
   page readable without scrolling past prose you did not ask for.

   The marker is drawn as a rotating chevron rather than the platform triangle so
   the row looks the same in every browser; the native marker is removed twice
   because Safari still needs the `-webkit-details-marker` form. Rotation is the
   only animation, and `prefers-reduced-motion` already switches it off globally. */
.info-lede {
  color: var(--text);
  font-size: 1rem;
  line-height: 1.85;
  margin: 0;
}

.info-hint {
  color: var(--muted);
  font-size: 0.85rem;
  margin: -14px 0 0;
}

.faq-list {
  display: flex;
  flex-direction: column;
  border-top: 1px solid var(--border);
}

.faq {
  border-bottom: 1px solid var(--border);
}

.faq summary {
  /* `list-style: none` + the -webkit rule below: hide the platform disclosure
     triangle in favour of the chevron drawn in ::after. */
  list-style: none;
  cursor: pointer;
  position: relative;
  padding: 13px 34px 13px 2px;
  font-family: var(--font-cjk);
  font-size: 1rem;
  color: var(--text);
  transition: color 120ms ease-out;
}

.faq summary::-webkit-details-marker {
  display: none;
}

.faq summary:hover,
.faq[open] summary {
  color: var(--accent);
}

.faq summary:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 6px;
}

.faq summary::after {
  content: '';
  position: absolute;
  right: 10px;
  top: 50%;
  width: 7px;
  height: 7px;
  border-right: 1.5px solid var(--muted);
  border-bottom: 1.5px solid var(--muted);
  transform: translateY(-70%) rotate(45deg);
  transition: transform 150ms ease-out;
}

.faq[open] summary::after {
  transform: translateY(-30%) rotate(225deg);
  border-color: var(--accent);
}

.faq-a {
  padding: 0 34px 16px 2px;
}

.faq-a p {
  color: var(--muted);
  font-size: 0.92rem;
  line-height: 1.9;
  margin: 0;
}

/* Text inputs shared by the forum/profile forms */
.text-input {
  width: 100%;
  /* A faint inset fill rather than a bare outline: an empty box on a raised card
     otherwise reads as disabled. */
  background: color-mix(in srgb, var(--bg) 55%, transparent);
  border: 1px solid var(--border);
  border-radius: 10px;
  color: var(--text);
  font-family: var(--font-ui);
  font-size: 0.95rem;
  padding: 10px 12px;
  resize: vertical;
  transition: border-color 120ms ease-out, background 120ms ease-out;
}

.text-input::placeholder {
  color: var(--dim);
}

.text-input:hover {
  border-color: var(--muted);
}

.text-input:focus {
  border-color: var(--accent);
  background: var(--bg);
}

.text-input:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

/* Stats & leaderboard */
.stats-toolbar {
  display: flex;
  align-items: center;
  gap: 12px;
}

.stats-label {
  color: var(--muted);
  font-size: 0.9rem;
}

.stats-select {
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text);
  font-family: var(--font-ui);
  font-size: 0.95rem;
  padding: 8px 12px;
}

.leaderboard {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
}

/* One row idiom for the three lists (leaderboard, personal bests, replays) and the
   forum: a hairline separator, a rounded hover wash, and no line under the last
   row — a trailing rule with nothing below it looks like a truncated table. The
   negative inline margin lets the hover wash extend past the text without moving
   it. */
.board-row {
  display: grid;
  grid-template-columns: 44px 1fr auto;
  gap: 8px 12px;
  align-items: baseline;
  padding: 10px 10px;
  margin: 0 -10px;
  border-radius: 8px;
  border-bottom: 1px solid var(--border);
  transition: background 120ms ease-out;
}

.board-row:last-child {
  border-bottom: none;
}

.board-row:hover {
  background: var(--accent-soft);
}

.board-rank {
  color: var(--accent);
  font-variant-numeric: tabular-nums;
}

.board-name {
  color: var(--text);
  font-family: var(--font-cjk);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.board-hanzi {
  color: var(--accent);
  font-variant-numeric: tabular-nums;
}

.board-meta {
  color: var(--muted);
  font-size: 0.82rem;
  grid-column: 2;
}

/* The username and its 个人页面 icon are one unit.
   -----------------------------------------------------------------------------
   Intent: the icon opens *that user's* profile, so it has to read as belonging to
   the name — a fixed typographic gap after the name's last glyph.

   The trap this replaces: `.board-name` was `flex: 1 1 auto`, so it stretched to
   fill the row's `1fr` column and shoved the icon to the column's far right, where
   it landed against 汉字速度 and looked like a column of its own. Shrink-wrapping
   the name (`0 1 auto`) lets the icon follow the text instead.

   `gap` is in `em` on purpose: the offset is a property of the type, so it scales
   with the row rather than being frozen at 8 device pixels. */
.board-name-cell {
  display: flex;
  align-items: center;
  gap: 0.5em;
  min-width: 0;
}

/* `min-width: 0` with `flex-shrink: 1` is what keeps the icon reachable: a name
   that outgrows its column ellipses under pressure instead of pushing the button
   out of the row. A truncated name is recoverable — the profile page shows it in
   full — an unreachable button is not. Names are at most 8 characters now, so this
   bites on narrow viewports rather than on every row. */
.board-name-cell .board-name {
  min-width: 0;
  flex: 0 1 auto;
}

.board-profile {
  appearance: none;
  background: transparent;
  border: none;
  padding: 0;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  flex: 0 0 auto;
  /* Buttons do not inherit type. Left alone, this one computes to 11px from the UA
     stylesheet while the row around it is 15.2px, which silently breaks any `em`
     measured inside it — the icon below came out *smaller* than the 18px it was
     replacing. Inheriting makes the button honest about the row it lives in. */
  font-size: inherit;
}

/* CSS owns the icon's visual size, not the `width`/`height` attributes in
   stats-screen.js — those stay only as the pre-stylesheet hint that reserves the
   box. `em` so the icon tracks the row's type (which is why the `font-size:
   inherit` above matters): 1.3em ≈ 19.8px against the row's 15.2px, the ~10%
   increase asked for over the 18px it shipped at. At 18px it read as an
   incidental glyph rather than the row's one action. */
.board-profile img {
  display: block;
  width: 1.3em;
  height: 1.3em;
}

.board-profile:hover img {
  opacity: 0.65;
}

.stats-your-rank {
  color: var(--muted);
  font-size: 0.9rem;
  margin: 14px 0 0;
}

.stats-fairness-note {
  color: var(--muted);
  font-size: 0.82rem;
  margin: 6px 0 0;
}

/* 效率 × 速度 scatter. Reuses `.legend-item` / `.dot` from the results chart so the
   two charts read as the same visual family. */
.scatter-card {
  margin-top: 24px;
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 12px 16px;
}

.scatter-title {
  margin: 0 0 8px;
  color: var(--muted);
  font-size: 0.85rem;
  font-weight: 500;
}

.scatter-chart {
  width: 100%;
  height: 200px;
  display: block;
}

.scatter-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 14px;
  margin-top: 8px;
  font-size: 0.82rem;
  color: var(--muted);
}

/* Forum */
.forum-status {
  color: var(--muted);
  font-size: 0.9rem;
  margin: 0;
}

.forum-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
}

.forum-load-more {
  margin-top: 12px;
}

.forum-post {
  /* `relative` anchors the 评论 button to this row's bottom-right corner, and the
     extra bottom padding reserves a clear band for it so a fully-wrapped excerpt
     can never run underneath. */
  position: relative;
  padding: 12px 10px 32px;
  margin: 0 -10px;
  border-radius: 8px;
  border-bottom: 1px solid var(--border);
  transition: background 120ms ease-out;
}

.forum-post:last-child {
  border-bottom: none;
}

.forum-post:hover {
  background: var(--accent-soft);
}

.forum-post-title {
  appearance: none;
  background: transparent;
  border: none;
  padding: 0;
  color: var(--text);
  font-family: var(--font-cjk);
  font-size: 1.05rem;
  font-weight: 600;
  cursor: pointer;
  text-align: left;
}

.forum-post-title:hover {
  color: var(--accent);
}

/* The per-row 评论 button, docked to the post's bottom-right corner.
   -----------------------------------------------------------------------------
   The title stays the "read this thread" door; this is the "comment on this
   thread" door, so it sits where an action belongs (the corner) rather than
   crowding the title.

   Sized independently of the header icons on purpose: `.icon-btn`'s 31px ink
   would dominate a row whose title is 1.05rem, so this uses ~18px. The
   `--icon-fill` mechanism is the same one the header uses, and for the same
   reason: `clickAndComment.svg` paints ~95% of its own viewBox, so a naive
   `width: 18px` would render it roughly three times the apparent size of a
   normalized header icon. See the note on `.icon-btn img`. */
.forum-post-comment {
  position: absolute;
  right: 10px;
  bottom: 6px;
  appearance: none;
  background: transparent;
  border: none;
  border-radius: 6px;
  padding: 2px;
  cursor: pointer;
}

.forum-post-comment img {
  --icon-fill: 0.953;
  width: calc(18px / var(--icon-fill));
  height: calc(18px / var(--icon-fill));
  display: block;
}

.forum-post-comment:hover {
  background: var(--accent-soft);
}

.forum-post-comment:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.forum-post-meta {
  color: var(--muted);
  font-size: 0.8rem;
  margin: 4px 0 6px;
}

.forum-post-excerpt {
  color: var(--text);
  font-size: 0.9rem;
  margin: 0;
  line-height: 1.6;
}

/* The compose boxes read as a distinct area, not as more list rows: a bordered
   sub-card inside the panel card. */
.forum-form {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin-top: 20px;
  padding: 16px;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: color-mix(in srgb, var(--bg) 45%, transparent);
}

.forum-detail-title {
  font-family: var(--font-cjk);
  font-size: 1.2rem;
  margin: 12px 0 6px;
}

.forum-detail-meta {
  color: var(--muted);
  font-size: 0.8rem;
  margin: 0 0 10px;
}

.forum-detail-body {
  color: var(--text);
  line-height: 1.75;
  white-space: pre-wrap;
}

.forum-comments {
  list-style: none;
  margin: 16px 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.forum-comment {
  border-left: 2px solid var(--border);
  padding-left: 12px;
}

.forum-comment-meta {
  color: var(--muted);
  font-size: 0.8rem;
}

.forum-comment-body {
  color: var(--text);
  font-size: 0.92rem;
  line-height: 1.6;
  white-space: pre-wrap;
  margin: 4px 0;
}

.forum-delete {
  appearance: none;
  background: transparent;
  border: none;
  padding: 0;
  color: var(--muted);
  font-size: 0.8rem;
  cursor: pointer;
}

.forum-delete:hover {
  color: var(--error);
}

/* Profile */
.profile-head {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.profile-username {
  font-family: var(--font-cjk);
  font-size: 1.2rem;
  font-weight: 600;
  color: var(--accent);
}

/* The rename row: label, field, counter, button on one line.
   -----------------------------------------------------------------------------
   Intent: the field is the smallest text input on the site because the value it
   holds is at most 8 characters — a full-width box would promise room that does
   not exist. `flex-wrap` lets the row break on a narrow viewport rather than
   squeezing the button off the panel. */
.profile-name-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
}

.profile-name-row[hidden] {
  display: none;
}

.profile-name-label {
  color: var(--muted);
  font-size: 0.9rem;
}

/* Width in `ch` so the box is sized by the type it holds. Latin `ch` is narrower
   than a Han glyph, so 10 leaves comfortable room for 8 CJK characters. */
.profile-name-input {
  width: 10ch;
  flex: 0 1 auto;
}

.profile-name-count {
  color: var(--dim);
  font-size: 0.8rem;
  font-variant-numeric: tabular-nums;
}

.profile-bio-row {
  display: flex;
  gap: 10px;
  align-items: flex-start;
}

.profile-bio-row[hidden] {
  display: none;
}

.profile-bio {
  color: var(--muted);
  font-size: 0.9rem;
  margin: 0;
}

/* 保存简介 feedback. Empty most of the time, so it must not reserve space or push
   the bio around when it appears. */
.profile-bio-status {
  color: var(--muted);
  font-size: 0.85rem;
  margin: 0;
}

.profile-bio-status:empty {
  display: none;
}

.pb-list,
.replay-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
}

.pb-row,
.replay-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 9px 10px;
  margin: 0 -10px;
  border-radius: 8px;
  border-bottom: 1px solid var(--border);
  transition: background 120ms ease-out;
}

.pb-row:last-child,
.replay-row:last-child {
  border-bottom: none;
}

.pb-row:hover,
.replay-row:hover {
  background: var(--accent-soft);
}

.pb-label,
.replay-label {
  color: var(--text);
  font-size: 0.92rem;
}

.pb-speed {
  color: var(--accent);
  font-variant-numeric: tabular-nums;
}

.replay-btn {
  appearance: none;
  background: transparent;
  border: 1px solid var(--accent);
  color: var(--accent);
  border-radius: 8px;
  padding: 4px 14px;
  cursor: pointer;
  font-size: 0.85rem;
}

.replay-btn:hover {
  background: var(--accent);
  color: var(--bg);
}

/* Replay player */
.replay-player {
  width: 100%;
  margin-top: 18px;
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 16px;
}

.replay-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12px;
}

.replay-target {
  font-family: var(--font-cjk);
  font-size: 1.4rem;
  line-height: 2;
  letter-spacing: 0.08em;
  word-break: break-all;
  min-height: 3em;
}

.replay-keystrip {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin-top: 12px;
}

.key-token {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 26px;
  height: 26px;
  padding: 0 6px;
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--muted);
  font-size: 0.8rem;
}

.key-token.active {
  color: var(--text);
  border-color: var(--accent);
  background: var(--accent-soft);
}

/* Privacy popover */
/* 隐私设置 popover.
   -----------------------------------------------------------------------------
   Positioned clear of the icon rail, which is the constraint that decides
   everything here: the rail is fixed to the right edge and now spans the whole
   window height, so the old `right: 24px` would drop this card straight onto two
   or three icons — and because the popover is `z-index: 10`, it would win, which
   does not read as an overlap but as 隐私设置 having swallowed them.

   Vertically centred rather than aligned to its own icon: with seven evenly
   spaced icons 隐私 is the fourth, i.e. ~46% down the rail, so the middle of the
   window is already where the eye is — and it needs no JavaScript to stay there
   when the window is resized.

   `max-height` + `overflow-y` so a short window scrolls the card instead of
   pushing its last row off screen. */
.privacy-popover {
  position: fixed;
  top: 50%;
  right: calc(var(--rail-w) + 14px);
  transform: translateY(-50%);
  width: 320px;
  max-height: calc(100dvh - 32px);
  overflow-y: auto;
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-radius: 16px;
  padding: 18px 20px 20px;
  z-index: 10;
  box-shadow: var(--shadow);
}

.privacy-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 14px;
  padding-bottom: 12px;
  border-bottom: 1px solid var(--border);
}

.privacy-title {
  color: var(--text);
  font-family: var(--font-cjk);
  font-weight: 600;
  letter-spacing: 0.06em;
}

.privacy-body {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.privacy-item {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  color: var(--text);
  font-size: 0.9rem;
  cursor: pointer;
  /* The whole row is the hit area, so the wash has to cover the whole row. */
  padding: 8px 10px;
  margin: 0 -10px;
  border-radius: 8px;
  transition: background 120ms ease-out;
}

.privacy-item:hover {
  background: var(--accent-soft);
}

/* Checkboxes and the select follow the theme accent instead of the browser's
   default blue, which is the one colour on the page nothing else uses. */
.privacy-item input[type="checkbox"] {
  accent-color: var(--accent);
  width: 15px;
  height: 15px;
  margin-top: 1px;
  cursor: pointer;
}

/* The 输入法 row is a labelled select, not a checkbox: it needs the label and the
   control on one baseline, and it must not inherit the checkbox row's pointer. */
.privacy-item-select {
  align-items: center;
  cursor: default;
  justify-content: space-between;
}

.privacy-item-select select {
  font-size: 0.9rem;
  padding: 4px 8px;
}

.privacy-note {
  color: var(--muted);
  font-size: 0.8rem;
  line-height: 1.6;
  margin: 0;
}

/* Reduced motion: disable all transitions/animations */
@media (prefers-reduced-motion: reduce) {
  * {
    transition: none !important;
    animation: none !important;
  }
}

/* Responsive */
@media (max-width: 560px) {
  :root {
    --char-size: 1.6rem;
    --line-height: 1.9;
    /* Smaller rail, so smaller ink. `--icon-ink / --icon-fill` must still fit the
       button: the sparsest icon needs 19 / 0.379 ≈ 50.1px inside 52px. */
    --icon-ink: 19px;
    --rail-w: 52px;
  }

  .app {
    /* The one place the layout is deliberately asymmetric. The rail is docked
       right, so only the right side needs clearing; a symmetric gutter here would
       cost ~2 Han characters on every line of a 360px screen, which is felt far
       more than a text block sitting a few pixels left of centre. */
    max-width: none;
    padding: 24px calc(var(--rail-w) + 6px) 32px 14px;
  }

  .results-grid {
    grid-template-columns: 1fr;
    gap: 18px;
  }

  .panel {
    padding: 20px 18px 24px;
    border-radius: 14px;
  }

  .privacy-popover {
    /* Room is scarce: the card spans the available content width instead of a
       fixed 320px, still stopping short of the rail. */
    left: 14px;
    right: calc(var(--rail-w) + 6px);
    width: auto;
  }
}

/* Short windows.
   -----------------------------------------------------------------------------
   Seven 84px buttons plus the insets need ~600px of height, which a laptop window
   can dip below. The rail shrinks rather than overflowing — and since `--rail-w`
   also drives the reserved gutter, the content gets the difference back. */
@media (min-width: 561px) and (max-height: 620px) {
  :root {
    --rail-w: 70px;
    /* 25 / 0.379 ≈ 66px, inside the 70px button. */
    --icon-ink: 25px;
  }
}
