/* Dark by default -- not a prefers-color-scheme fallback, but no longer the
   only theme either (see the light override below). color-scheme makes
   native form controls / scrollbars follow whichever theme is active. */

:root {
  color-scheme: dark;

  --bg: #232323;
  --text: #e8e8e8;
  --muted: #8a8a8a;
  /* #333 measured ~1.2:1 against #232323 -- dividers would nearly vanish.
     Not a text-contrast rule, just a practical bump so entries and the
     search underline stay visible. */
  --border: #404040;
  /* Primary accent, taken from the logo (#ABC142) so the mark and
     everything around it are one green -- they sit side by side in the
     header, and at deltaE2000 5.1 the previous #97c93c read as a mistake
     rather than a choice. Measures 7.81:1 as text on #232323, so it needs
     no lightened variant, same as the value it replaces. Wordmark, links,
     focus and active/selected states all use it directly. */
  --accent: #abc142;
  /* Secondary accent #299345 measures 4.01:1 as text on #232323, under
     the 4.5:1 minimum -- confirmed by the same check used for the last
     palette. Used only for fills, borders and hover backgrounds (via
     --accent-fill-rgb, an RGB triplet so rgba() can add opacity), never
     as foreground text. */
  --accent-fill-rgb: 41, 147, 69;
  /* Register tags. Archaic gets a warm amber so it reads as a different
     kind of remark from the accent green, which means "worth learning";
     literary reuses --muted, since those words are current and only their
     register is being noted. Measured 4.57:1 on this background -- the
     same weight as --muted's 4.55:1, so "recessive" stays a deliberate
     level rather than an accident of hue. Carried as an RGB triplet as
     well, for the same reason --accent-fill-rgb is: the badge needs a
     low-opacity wash of it and rgba() cannot take a hex var. */
  --register-archaic: #af8330;
  --register-archaic-rgb: 175, 131, 48;
  --muted-rgb: 138, 138, 138;
  /* How much horizontal room #global-controls needs, one side (~21rem of
     button+gap content plus its own 1.5rem edge offset). header uses this
     twice -- see the comment there for why symmetric clearance is what
     actually keeps the wordmark/search bar centered on the *viewport*,
     not just inside #app. */
  --controls-clearance: 22rem;
}

/* Light theme, toggled by JS setting data-theme on <html> (PLAN 6.7).
   Same four roles, same rgba(var(--accent-fill-rgb)) fill mechanism,
   checked with the same WCAG contrast math as dark -- see PLAN.md for the
   full table. The two accent hues swap jobs: the lime that's dark mode's
   text-safe primary only measures 1.88:1 on a light background, so here
   it's the decorative-only fill; #299345 darkened along its own hue to
   #1f7034 (5.72:1) becomes the text-safe primary instead. */
:root[data-theme="light"] {
  color-scheme: light;

  --bg: #f7f7f5;
  --text: #1c1c1a;   /* 15.91:1 */
  --muted: #666666;  /* 5.35:1 */
  --border: #cccccc; /* 1.50:1 -- visibility only, matches dark's 1.52:1 */
  --accent: #1f7034; /* 5.72:1 -- darkened secondary, unrelated to the lime */
  --accent-fill-rgb: 171, 193, 66; /* #abc142 reused -- 1.88:1, decorative only */
  --register-archaic: #80601a; /* 5.43:1 -- same hue, matched to --muted's 5.35 */
  --register-archaic-rgb: 128, 96, 26;
  --muted-rgb: 102, 102, 102;
}

* {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
}

html {
  height: 100%;
}

/* Sticky footer: body is a column that is *at least* as tall as the
   viewport, and #app takes the slack, so #attribution is pushed to the
   bottom of the screen on a short page and simply follows the content on
   a long one. min-height rather than height, or a page taller than the
   viewport would overflow a body frozen at exactly one screen.
   #global-controls and #word-popup are both position: fixed, so neither is
   a flex item here. */
body {
  min-height: 100%;
  display: flex;
  flex-direction: column;
  background: var(--bg);
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
    "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
  line-height: 1.6;
}

button {
  font: inherit;
  color: inherit;
  background: none;
  border: none;
  cursor: pointer;
}

/* grow, never shrink: taking the leftover vertical space is what holds the
   footer down. `margin: 0 auto` centers horizontally -- that's the cross
   axis in a column flex container.

   width: 100% is load-bearing, and for the same reason it is on `#app.empty
   header` further down. Auto margins on the cross axis do not just center a
   flex item, they *block* its stretch-to-fill, so #app was shrink-wrapping
   its content instead of taking the 46rem its max-width allows. Its width
   then came from whatever its widest child happened to contribute, which on
   the landing page meant the Recent list: with lookups stored, #app reached
   the cap and `#app.empty #search-bar`'s `min(42rem, 100%)` resolved to
   42rem; with Recent empty, the widest contributor was the search input's
   browser-default (~241px) and the whole page collapsed inward, clipping
   the placeholder. An explicit width gives the cap something real to apply
   to, so the landing page is the same size whether Recent has entries or
   not. box-sizing: border-box (top of file) keeps the padding inside it. */
#app {
  flex: 1 0 auto;
  width: 100%;
  max-width: 46rem;
  margin: 0 auto;
  padding: 0 1.5rem 3rem;
}

/* -- Global controls -------------------------------------------------------
   Saved / script / theme toggles. Pinned to the screen corner, outside
   #app entirely, so they're in the exact same spot regardless of empty
   vs. results layout (PLAN 6.8) -- no mode-conditional styling needed.
   Flat inset, not viewport-width math (that was tried and broke: it bet
   most windows are wide enough to push this past #app's edge, which
   wasn't true in practice). header's own viewport-relative max-width
   below is what actually guarantees no overlap -- see its comment. */

#global-controls {
  position: fixed;
  top: 1.5rem;
  right: 1.5rem;
  display: flex;
  align-items: center;
  gap: 1rem;
  z-index: 10;
}

/* -- Header / search box -------------------------------------------------
   Same DOM in both states; layout mode (PLAN 6.6) just repositions it. */

/* Centering header *within #app* isn't the same as centering it on the
   viewport -- #app is centered on the viewport, but is capped at 46rem,
   so at any window narrower than roughly 90rem (46rem of #app + 2 x 22rem
   of clearance) #app's own edge sits closer to the true viewport edge
   than #global-controls needs. A one-sided reservation (padding-right)
   fixed the overlap, but visibly pushed the content off viewport-center,
   since it only shrank the box on one side. Reserving the same amount
   on *both* sides keeps it symmetric -- genuinely
   centered on the viewport, matching #global-controls' own clearance
   need on the side that actually matters -- and shrinks header's own
   max-width as the viewport narrows, rather than trying to reposition a
   fixed-width box relative to a viewport of unknown width. */
header {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 1.25rem 0;
  max-width: calc(100vw - 2 * var(--controls-clearance));
  margin: 0 auto;
}

/* -- Logo (15) -------------------------------------------------------------
   The mark is inline SVG rather than <img> so it inherits `color`, which
   matters because --accent differs between themes (#abc142 on dark,
   #1f7034 on light) and a fixed-colour file would be wrong in one of them.

   The name beside it is real text, not the artwork's wordmark. The source
   lockup stacks the two vertically, so mark-left/name-right could only
   come from separating them -- and text is the better half of that trade
   anyway: it stays selectable, is what a screen reader announces, scales
   with the rest of the type, and needs no new asset when it is restyled.
   Written "MyCidian" and lowercased here to match the letterforms, so
   copying it still yields the brand's own casing. */

#wordmark {
  color: var(--accent);
  padding: 0.15rem 0.4rem;
  margin: -0.15rem -0.4rem;
  border-radius: 0.3rem;
  display: flex;
  align-items: center;
}

.logo {
  display: flex;
  align-items: center;
  gap: 0.55rem;
}

/* Mark beside the name in the results header, where vertical space is
   what the search bar is competing for; stacked on the homepage, where
   there is room and the lockup is the point. */
.logo-mark {
  display: block;
  width: 2.8rem;
  height: auto;
  flex: none;
}

.logo-type {
  /* The artwork is a geometric rounded sans; the system stack cannot match
     it exactly, so this aims for the same weight and rhythm rather than a
     forgery -- slightly tight tracking, no descender drama. Cased
     "MyCidian", matching the title, the manifest and everything else that
     writes the name; only the drawn wordmark is lowercase, being a
     drawing. */
  font-size: 1.9rem;
  font-weight: 600;
  line-height: 1;
  letter-spacing: -0.01em;
  white-space: nowrap;
}

/* Secondary green as a background fill on hover, not text color -- it
   fails contrast as text (see :root). */
#wordmark:hover {
  background: rgba(var(--accent-fill-rgb), 0.2);
}

#search-bar {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  border-bottom: 1px solid var(--border);
}

#search-bar:focus-within {
  border-bottom-color: var(--accent);
}

#search {
  flex: 1 1 auto;
  min-width: 0;
  background: transparent;
  border: none;
  color: var(--text);
  font-size: 1.15rem;
  padding: 0.6rem 0.1rem;
}

#search::placeholder {
  color: var(--muted);
}

#search:focus {
  outline: none;
}

#search-submit {
  display: inline-flex;
  align-items: center;
}

.nav-btn {
  flex: 0 0 auto;
  font-size: 0.85rem;
  color: var(--muted);
  white-space: nowrap;
  padding: 0.15rem 0.4rem;
  margin: -0.15rem -0.4rem;
  border-radius: 0.3rem;
}

.nav-btn:hover {
  background: rgba(var(--accent-fill-rgb), 0.2);
}

/* -- Empty state: wordmark + a centered search box, upper-center rather
   than mid-screen (PLAN 6.8) -------------------------------------------- */

#app.empty {
  min-height: 100%;
  display: flex;
  flex-direction: column;
  padding-top: 12vh;
}

#app.empty header {
  flex-direction: column;
  gap: 1.5rem;
  padding: 0;
  /* Unlike the results view, header and #global-controls never share a
     vertical band here -- header starts padding-top: 12vh down from the
     top, well below where the controls sit (top: 1.5rem) on any normal
     screen height. The results-view clearance (max-width, inherited
     from the base header rule) isn't needed for that reason here. */
  max-width: none;
  /* header's own margin: 0 auto (base rule) puts auto side-margins on it;
     as a flex item those block stretch-to-fill regardless of the parent's
     align-items, so header was shrinking to its content's intrinsic width
     instead of taking the space available -- #search-bar's own
     width: min(38rem, 100%) then had no definite width to resolve that
     100% against, and fell back to the input's raw browser-default width
     (measured ~241px). An explicit width gives header a real box, so the
     search bar's own width rule has something real to resolve against. */
  width: 100%;
}

/* Homepage: mark stacked above the name, both considerably larger -- this
   is the one screen whose whole job is the identity and the search box. */
#app.empty .logo {
  flex-direction: column;
  gap: 0.85rem;
}

#app.empty .logo-mark {
  width: 8rem;
}

#app.empty .logo-type {
  font-size: 3.4rem;
}

/* Wide enough that the placeholder is never clipped: "Search Chinese,
   pinyin, or English" runs ~26rem at the size below, and the magnifier
   and its gap take another 2rem. */
#app.empty #search-bar {
  width: min(42rem, 100%);
}

#app.empty #search {
  text-align: center;
  font-size: 1.35rem;
}

#app.empty #status,
#app.empty #suggestion,
#app.empty #results,
#app.empty #segments,
#app.empty #results-summary,
#app.empty #results-pager {
  display: none;
}

/* -- Recent lookups: only ever shown on the empty landing view ---------- */

#recent-section {
  width: min(30rem, 100%);
  margin: 2rem auto 0;
}

/* Mode-conditional, mirroring how #status/#results are hidden on the empty
   view. The JS only ever sets [hidden] from "is the list empty", so
   nothing was hiding this once a search ran and it stayed visible below
   the results. Doing it in CSS rather than adding another JS assignment
   keeps it impossible to desync from the layout mode. */
#app:not(.empty) #recent-section {
  display: none;
}

#recent-header {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  margin: 0 0 0.25rem;
}

#recent-label {
  color: var(--muted);
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  margin: 0;
}

#clear-history {
  font-size: 0.75rem;
}

#recent-section .entry {
  padding: 0.9rem 0;
}

#recent-section .entry:first-child {
  padding-top: 0.9rem;
}

/* -- Results ------------------------------------------------------------- */

/* -- "Did you mean ...?" (10.1) -------------------------------------------
   Deliberately quiet: muted body text like #status, with only the corrected
   word itself carrying the accent, so it reads as an aside about the results
   rather than competing with them. */

#suggestion {
  color: var(--muted);
  margin: 0 0 0.5rem;
  font-size: 0.95rem;
}

.suggestion-word {
  font-style: italic;
  color: var(--accent);
  padding: 0.1rem 0.3rem;
  margin: -0.1rem -0.3rem;
  border-radius: 0.3rem;
}

.suggestion-word:hover {
  background: rgba(var(--accent-fill-rgb), 0.2);
  text-decoration: underline;
}

#status {
  color: var(--muted);
  min-height: 1.2em;
  margin: 0 0 0.5rem;
}

#results {
  list-style: none;
  margin: 0;
  padding: 0;
}

/* -- Sentence segmentation chips -------------------------------------------
   Sits at the foot of the results column, after the pager: the first chip is
   the word the results were actually for, and the rest are the other words
   of the sentence, each one click away. */

#segments {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin: 2rem 0 0;
}

.segment-chip {
  font-size: 1.05rem;
  line-height: 1.2;
  padding: 0.35rem 0.75rem;
  border: 1px solid var(--border);
  border-radius: 1.2rem;
  color: inherit;
  text-decoration: none;
}

.segment-chip:hover {
  border-color: var(--accent);
  background: rgba(var(--accent-fill-rgb), 0.12);
}

.segment-chip-current {
  border-color: var(--accent);
  color: var(--accent);
}

#results-summary {
  color: var(--muted);
  font-size: 0.85rem;
  margin: 1.25rem 0 0;
}

.results-capped-notice {
  font-style: italic;
}

#results-pager {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 1.5rem;
  margin: 1.5rem 0 0;
}

.results-pager-position {
  color: var(--muted);
  font-size: 0.85rem;
}

.entry {
  display: flex;
  gap: 2.5rem;
  padding: 1.75rem 0;
  border-bottom: 1px solid var(--border);
}

.entry:first-child {
  padding-top: 0.5rem;
}

.entry-left {
  flex: 0 0 11rem;
  min-width: 0;
}

.entry-right {
  flex: 1 1 auto;
  min-width: 0;
}

.entry-top {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 0.5rem;
}

.pinyin {
  color: var(--text);
  font-size: 0.9rem;
}

/* Audio (Phase 14).

   One rule set for every play button in the app -- result rows, the Word
   Page, character readings -- because they are the same control. Sized in
   em rather than rem so it tracks whatever text it sits beside: it is
   noticeably larger on the Character Page heading than in a result row
   without either being restyled. */
.audio-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.5em;
  height: 1.5em;
  padding: 0;
  flex: none;
  vertical-align: -0.3em;
  border-radius: 50%;
  color: var(--muted);
  transition: color 0.12s, background 0.12s;
}

.audio-btn svg {
  width: 1em;
  height: 1em;
  fill: currentColor;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
}

/* The cone is a filled shape, the two arcs are strokes. Splitting them
   this way is what lets one currentColor drive the whole icon. */
.audio-btn .wave {
  fill: none;
}

.audio-btn:hover {
  color: var(--accent);
  background: rgba(var(--accent-fill-rgb), 0.2);
}

.audio-btn.playing {
  color: var(--accent);
}

/* Only the outer arc pulses. Animating the whole icon made the button look
   like it was loading rather than sounding. */
.audio-btn.playing .wave-2 {
  animation: audio-pulse 0.6s ease-in-out infinite;
}

@keyframes audio-pulse {
  50% {
    opacity: 0.25;
  }
}

@media (prefers-reduced-motion: reduce) {
  .audio-btn.playing .wave-2 {
    animation: none;
  }
}

/* Keeps a reading and its button together when a list of them wraps, so a
   button never starts a line on its own away from the sound it plays. */
.pinyin-item {
  white-space: nowrap;
}

.pinyin-item .audio-btn {
  margin-left: 0.15em;
}

/* .entry-top is space-between with the reading at one end and the star at
   the other; the play button belongs with the reading, so the star is
   pushed right explicitly rather than sharing the free space three ways. */
.entry-top .audio-btn {
  margin-left: 0.2rem;
}

.entry-top .star {
  margin-left: auto;
}

.word-page-word .audio-btn,
.word-reading-pinyin .audio-btn {
  margin-left: 0.35em;
}

.star {
  font-size: 1.1rem;
  line-height: 1;
  color: var(--muted);
  padding: 0.1rem 0.3rem;
  margin: -0.1rem -0.3rem;
  border-radius: 0.3rem;
}

.star:hover {
  background: rgba(var(--accent-fill-rgb), 0.2);
}

.star.active {
  color: var(--accent);
}

.headword {
  display: flex;
  align-items: baseline;
  gap: 0.4rem;
  flex-wrap: wrap;
  margin: 0.15rem 0 0.5rem;
}

.primary {
  font-size: 2.1rem;
  line-height: 1.2;
}

.secondary {
  color: var(--muted);
  font-size: 1rem;
  cursor: pointer;
  padding: 0.1rem 0.3rem;
  margin: -0.1rem -0.3rem;
  border-radius: 0.3rem;
}

.secondary:hover {
  background: rgba(var(--accent-fill-rgb), 0.2);
}

/* Stacks its rows: HSK on one line, register tags on the next. Wrapping
   within a row is .badge-row's job, so a long row still wraps rather than
   pushing the column wider. */
.badges {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.35rem;
}

.badge-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
}

.hsk-badge {
  display: inline-block;
  font-size: 0.75rem;
  /* Text is the primary accent (legible); the badge's fill and border
     carry the secondary accent instead, per its fills/borders-only role. */
  color: var(--accent);
  background: rgba(var(--accent-fill-rgb), 0.15);
  border: 1px solid rgba(var(--accent-fill-rgb), 0.5);
  border-radius: 1rem;
  padding: 0.15rem 0.6rem;
}

/* Same geometry and size as .hsk-badge, deliberately not the same colour.
   The HSK badge is accent green because it means "worth learning"; these
   mean something closer to the opposite, so both sit at the recessive
   weight the page already uses for secondary text.
   Hue is what separates them, since two identically grey pills would make
   the reader stop and read to tell "this word is dead" from "this word is
   formal". Archaic is warm amber, literary plain grey, each washed over
   its own colour at low opacity the way .hsk-badge is over the accent. */
.register-badge {
  display: inline-block;
  font-size: 0.75rem;
  border-radius: 1rem;
  padding: 0.15rem 0.6rem;
  cursor: help;
}

.register-badge.archaic {
  color: var(--register-archaic);
  background: rgba(var(--register-archaic-rgb), 0.12);
  border: 1px solid rgba(var(--register-archaic-rgb), 0.55);
}

.register-badge.literary {
  color: var(--muted);
  background: rgba(var(--muted-rgb), 0.12);
  border: 1px solid rgba(var(--muted-rgb), 0.45);
}

.senses {
  margin: 0;
  padding-left: 1.4rem;
  color: var(--text);
}

.senses li {
  padding-left: 0.3rem;
  margin-bottom: 0.6rem;
}

.senses li:last-child {
  margin-bottom: 0;
}

.senses li::marker {
  color: var(--muted);
}

.example {
  margin: 0.9rem 0 0;
  padding-left: 1.7rem; /* matches .senses li's text indent: 1.4rem (ol) + 0.3rem (li) */
  font-size: 0.9rem;
}

/* A notch above the surrounding 0.9rem block: Chinese at body size is
   harder to read than Latin at the same size, and the sentence is the part
   meant to be read rather than scanned. Small enough not to reflow the row. */
.example-zh {
  color: var(--text);
  font-size: 1.05rem;
}

.example-pinyin {
  display: block;
  color: var(--text);
}

/* The word the example is illustrating. Weight only -- no color, because
   the accent is already carrying links and the searched word should read
   as emphasis within the sentence, not as another thing to click. */
.example-match {
  font-weight: 700;
}

/* Hoverable words carry no resting decoration: an example sentence is to
   be read, and a dozen underlines through it would wreck that. The hint
   appears under the pointer instead. */
.example-word {
  cursor: help;
  border-radius: 0.2rem;
}

.example-word:hover {
  background: rgba(var(--accent-fill-rgb), 0.18);
}

/* -- Word preview popup ---------------------------------------------------
   Fixed rather than absolute: it is positioned in viewport coordinates and
   dismissed on scroll, so it never has to track a moving anchor. */

#word-popup {
  position: fixed;
  z-index: 50;
  max-width: min(22rem, calc(100vw - 2rem));
  max-height: 60vh;
  overflow-y: auto;
  padding: 0.75rem 0.9rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.28);
  font-size: 0.9rem;
}

#word-popup[hidden] {
  display: none;
}

.popup-head {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin: 0 0 0.4rem;
}

.popup-headword {
  font-size: 1.25rem;
}

.popup-headword-alt {
  font-size: 1rem;
  color: var(--muted);
}

.popup-pinyin {
  color: var(--text);
}

.popup-senses {
  margin: 0;
  padding-left: 1.2rem;
}

.popup-senses li {
  margin: 0.15rem 0;
}

.popup-link {
  display: inline-block;
  margin-top: 0.6rem;
  font-size: 0.8rem;
  color: var(--accent);
  text-decoration: none;
}

.popup-link:hover {
  text-decoration: underline;
}

/* -- Footer --------------------------------------------------------------
   Outside #app deliberately: attribution stays put regardless of empty vs.
   results layout, instead of getting swept into #app.empty's centering.
   Last flex item in body, so it sits at the foot of the viewport when the
   results are short and after the content when they aren't -- see body. */

#attribution {
  flex-shrink: 0;
  max-width: 46rem;
  margin: 0 auto;
  padding: 1.5rem 1.5rem 2rem;
  color: var(--muted);
  font-size: 0.8rem;
}

#attribution a {
  color: inherit;
}

/* -- Character sidebar (11.3) ---------------------------------------------
   Two columns only once results are on screen. #app widens to make room
   rather than the results column shrinking inside the old 46rem cap, so
   entries keep the measure they were designed at.

   No clearance hack is needed against #global-controls here, unlike
   header's: the sidebar starts below the header, which is already below
   the controls' fixed band at the top of the viewport. */

#layout {
  display: block;
}

#app.with-sidebar {
  max-width: 64rem;
}

#app.with-sidebar #layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 15rem;
  gap: 2.5rem;
  align-items: start;
}

#char-sidebar {
  min-width: 0;
}

#char-sidebar-label {
  color: var(--muted);
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  font-weight: 600;
  margin: 0.5rem 0 0.75rem;
}

#char-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.char-card-link {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.6rem 0.7rem;
  border: 1px solid var(--border);
  border-radius: 0.4rem;
  color: inherit;
  text-decoration: none;
}

.char-card-link:hover {
  border-color: var(--accent);
  background: rgba(var(--accent-fill-rgb), 0.12);
}

.char-card-glyph {
  font-size: 2rem;
  line-height: 1;
  flex: 0 0 auto;
}

.char-card-meta {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.char-card-strokes {
  color: var(--muted);
  font-size: 0.75rem;
}

/* Sidebar cards are narrow; a long gloss is clamped rather than allowed to
   set the column's height. The full text is on the character page. */
.char-card-gloss {
  font-size: 0.8rem;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* Below this the two columns stop fitting side by side; the breakout moves
   under the results instead of squeezing them. */
/* Symmetric clearance centres the header on the *viewport*, but it costs
   44rem to do it, and it only works while there is a viewport wide enough
   to spare that. Below it the calc goes to zero and the header collapses
   behind the fixed controls -- at 390px the lockup and "HSK levels" sat
   on top of each other.
   The cutoff is 90rem rather than the 74rem that merely avoids collapse:
   at 1280px, clearance mode leaves the header 36rem, and once the lockup
   has taken its share the search bar is narrower than its own
   placeholder. Dropping the controls into their own row buys the header
   the full width instead, which is what makes the results view legible on
   an ordinary laptop. */
@media (max-width: 90rem) {
  #global-controls {
    position: static;
    flex-direction: row;
    flex-wrap: wrap;
    justify-content: flex-end;
    align-items: center;
    gap: 0.4rem 1rem;
    padding: 1rem 1.25rem 0;
  }

  header {
    max-width: 100%;
    padding-left: 1.25rem;
    padding-right: 1.25rem;
  }
}

@media (max-width: 60rem) {
  #app.with-sidebar {
    max-width: 46rem;
  }

  #app.with-sidebar #layout {
    display: block;
  }

  /* The word page's character column stacks at the same width, for the
     same reason. */
  #app.word {
    max-width: 46rem;
  }

  .word-layout {
    display: block;
  }

  /* Stacked, there is no second column to stay clear of, so the caps that
     keep the two snug side by side would just narrow the page for nothing. */
  .word-main {
    min-width: 0;
    max-width: none;
  }

  .word-chars {
    margin-top: 2rem;
  }

  #char-sidebar {
    margin-top: 2rem;
  }

  #char-list {
    flex-direction: row;
    flex-wrap: wrap;
  }

  .char-card-link {
    width: auto;
  }

  .char-card-gloss {
    display: none;
  }
}

/* -- Character page (11.4) ------------------------------------------------- */

#app.character {
  max-width: 46rem;
}

/* -- Word page -------------------------------------------------------------
   The Character Page's sibling, one width wider than a results column
   because the character grid wants room for two or three glyphs abreast. */

/* The whole page one notch larger, as one number rather than twenty
   individually multiplied sizes that would drift apart on the next
   adjustment. `zoom` rather than `transform: scale` because zoom affects
   layout -- the grid, the column caps and the stroke boxes all grow
   together and the page still reflows normally. Change this one value to
   retune the page's overall size. */
#word-view {
  zoom: 1.125;
}

#app.word {
  max-width: 64rem;
}

/* Both columns sized to their contents, the pair centered as one block.
   An `auto` main track plus justify-content: center is what keeps the two
   snug -- a 1fr track would stretch to the container and reintroduce the
   gap between them that this layout exists to remove. */
.word-layout {
  display: grid;
  grid-template-columns: auto 15rem;
  justify-content: center;
  gap: 2rem;
  align-items: start;
}

/* Capped so a long example sentence can't stretch the column past a
   comfortable measure, floored so short glosses can't collapse it into a
   ribbon. Between the two the column is exactly as wide as it needs to be. */
.word-main {
  min-width: 20rem;
  max-width: 32rem;
}

.word-chars {
  min-width: 0;
}

.word-page-word {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.6rem;
  font-size: 2.6rem;
  font-weight: 600;
  margin: 0 0 0.75rem;
  line-height: 1.2;
}

/* The other script, clickable to switch to it -- same affordance as the
   result rows' click-to-promote, scaled to the heading. */
.word-page-alt {
  font-size: 1.4rem;
  font-weight: 400;
}

.word-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 1.5rem;
  align-items: baseline;
  padding-bottom: 1rem;
  border-bottom: 1px solid var(--border);
}

.word-meta-item {
  display: flex;
  align-items: baseline;
  gap: 0.4rem;
  font-size: 0.9rem;
}

.word-meta-label {
  color: var(--muted);
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

/* The composition line: 电 electric + 话 speech. Sized between the headword
   and the body so it reads as a summary of the word, not as another sense. */
.word-composition {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.6rem;
  margin: 1.25rem 0 0;
  font-size: 0.95rem;
}

.word-composition-part {
  display: flex;
  align-items: baseline;
  gap: 0.35rem;
}

.word-composition-char {
  font-size: 1.35rem;
}

.word-composition-gloss {
  color: var(--muted);
}

.word-composition-plus {
  color: var(--muted);
}

.word-section-label {
  font-size: 0.8rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted);
  margin: 2rem 0 0.75rem;
}

.word-reading {
  margin: 1.5rem 0 0;
}

.word-reading-pinyin {
  font-size: 1.1rem;
  font-weight: 600;
  color: var(--accent);
  margin: 0 0 0.4rem;
}

/* Senses and examples sit just above the 0.75rem metadata labels. Scoped to
   the word page on purpose: .example-zh is a notch larger everywhere else,
   where it sits in a wider result row rather than beside a 2.6rem headword. */
.word-reading .senses,
.word-reading .example,
.word-reading .example-zh {
  font-size: 0.85rem;
}

/* One card per character in the right-hand column, matching where the
   results page puts its character breakout. No stroke diagram in these --
   the animations are in the row at the foot of the page -- so a card is
   just the facts, with the glyph itself linking to the Character Page. */
/* The whole card navigates to the character's page (see
   buildWordCharacterCard), so it takes the pointer too -- the accent border
   below already promised the box was interactive, and only the glyph was. */
.word-char-card {
  padding: 0.7rem 0.8rem;
  margin-bottom: 0.75rem;
  border: 1px solid var(--border);
  border-radius: 0.4rem;
  cursor: pointer;
}

.word-char-card:hover {
  border-color: var(--accent);
}

.word-char-info {
  min-width: 0;
}

.word-char-glyph {
  display: inline-block;
  font-size: 1.8rem;
  line-height: 1.1;
  color: inherit;
  text-decoration: none;
}

.word-char-glyph:hover {
  color: var(--accent);
}

.word-char-pinyin {
  color: var(--text);
  font-size: 0.85rem;
  margin: 0.15rem 0 0;
}

.word-char-def {
  font-size: 0.85rem;
  margin: 0.35rem 0 0;
}

.word-char-facts {
  margin: 0.5rem 0 0;
  font-size: 0.75rem;
  color: var(--muted);
}

.word-char-facts dt {
  float: left;
  clear: left;
  width: 4.5rem;
}

.word-char-facts dd {
  margin: 0 0 0.15rem 4.5rem;
  word-break: break-word;
}

/* Secondary action under the headword in a result row -- deliberately
   quiet, so it never competes with the headword it belongs to. */
.full-entry {
  display: inline-block;
  margin-top: 0.5rem;
  font-size: 0.75rem;
  text-decoration: none;
}

.char-page-top {
  display: flex;
  gap: 2.5rem;
  align-items: flex-start;
  padding: 1rem 0 2rem;
  flex-wrap: wrap;
}

.char-page-art {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.75rem;
}

.stroke-svg {
  width: 12rem;
  height: 12rem;
  border: 1px solid var(--border);
  border-radius: 0.4rem;
}

/* -- Interactive stroke box (Word Page + Character Page) ------------------ */

/* Just the diagram now -- its width is the drawing's width, nothing else,
   which is what lets the row sit tight. */
.stroke-box {
  display: flex;
  flex-direction: column;
  align-items: center;
}

.stroke-section {
  margin-bottom: 1.5rem;
}

/* The diagram is the control, so it has to look like one. */
.stroke-interactive {
  cursor: pointer;
  user-select: none;
}

.stroke-interactive:hover {
  border-color: var(--accent);
}

/* One per group, sitting under the whole row rather than under each box --
   see STROKE_HINT in app.js for why per-box captions were the problem. */
.stroke-hint {
  margin: 0.5rem 0 0;
  color: var(--muted);
  font-size: 0.65rem;
  line-height: 1.3;
}

/* One box per character, side by side, stepping down in size as the word
   gets longer (--stroke-size is set per word in app.js). Wraps rather than
   shrinking further once a row genuinely can't hold them. */
/* Tight, so the characters of a word read as that word rather than as
   separate diagrams that happen to sit near each other. */
.stroke-row {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-start;
  gap: 0.5rem;
  margin: 1.25rem 0 0;
}

.stroke-row .stroke-svg {
  width: var(--stroke-size, 11rem);
  height: var(--stroke-size, 11rem);
}

@media (max-width: 34rem) {
  /* Below this the step function can't win -- let them wrap at a size that
     stays legible instead. */
  .stroke-row .stroke-svg {
    width: 8rem;
    height: 8rem;
  }
}

/* The landing search box is sized so its placeholder always fits, and on a
   phone the limit is the viewport rather than the rule above: at 390px
   there is ~21rem of usable width, and "Search Chinese, pinyin, or
   English" needs more than that at 1.35rem. Shrinking the type is the
   honest fix -- clipping the prompt hides what can be typed. */
@media (max-width: 30rem) {
  #app.empty .logo-mark {
    width: 6.5rem;
  }

  #app.empty .logo-type {
    font-size: 2.6rem;
  }

  #app.empty #search {
    font-size: 1rem;
  }
}

.stroke-ghost {
  fill: var(--border);
}

.stroke-draw {
  stroke: var(--accent);
}

@keyframes stroke-draw {
  to {
    stroke-dashoffset: 0;
  }
}

/* Anyone who has asked the OS for less motion gets the finished glyph
   immediately rather than a sequence they didn't want. */
@media (prefers-reduced-motion: reduce) {
  .stroke-draw {
    animation: none !important;
    stroke-dashoffset: 0 !important;
  }
}

.char-page-info {
  flex: 1 1 16rem;
  min-width: 0;
}

.char-page-glyph {
  font-size: 3.5rem;
  line-height: 1;
  margin: 0;
  font-weight: 400;
}

.char-page-pinyin {
  color: var(--text);
  margin: 0.4rem 0 0;
}

.char-page-def {
  margin: 0.75rem 0 0;
}

.char-facts {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.3rem 1rem;
  margin: 1.25rem 0 0;
  font-size: 0.9rem;
}

.char-facts dt {
  color: var(--muted);
}

.char-facts dd {
  margin: 0;
}

/* Alternate script forms. A character can have several -- 台 is 臺, 颱 and
   檯 -- so these sit in one dd with a gap rather than assuming a single
   glyph. Sized up a little because they are glyphs to read, not labels. */
.char-variant {
  color: var(--accent);
  text-decoration: none;
  font-size: 1.15rem;
  line-height: 1;
  margin-right: 0.5rem;
}

.char-variant:last-child {
  margin-right: 0;
}

.char-variant:hover {
  text-decoration: underline;
}

.char-words-label {
  color: var(--muted);
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  font-weight: 600;
  margin: 0 0 0.25rem;
  border-top: 1px solid var(--border);
  padding-top: 1.5rem;
}

#char-words {
  list-style: none;
  margin: 0;
  padding: 0;
}

.char-words-empty {
  color: var(--muted);
}

/* Headword as a link, on the character page's word list only. Search
   results keep inert headwords (PLAN 7.3, revised: clicking a result does
   nothing), so this is styled on the opt-in class rather than on .primary. */
.primary-link {
  color: inherit;
  text-decoration: none;
  border-radius: 0.3rem;
  padding: 0 0.2rem;
  margin: 0 -0.2rem;
}

.primary-link:hover {
  color: var(--accent);
  background: rgba(var(--accent-fill-rgb), 0.2);
}

/* -- Measure words (12.1) -------------------------------------------------
   Sits between the senses and the example sentence: it's a property of the
   word rather than a meaning, so it reads as an aside, muted like .example
   with only the classifier itself in normal text. */

.classifiers {
  margin: 0.7rem 0 0;
  padding-left: 1.7rem; /* aligns with .senses li text, as .example does */
  font-size: 0.9rem;
  color: var(--muted);
}

.classifiers-label {
  color: var(--muted);
}

.classifier-form {
  color: var(--text);
}

.classifier-pinyin {
  color: var(--text);
}

/* -- HSK browsing (13) ----------------------------------------------------
   The corner controls become two stacked rows. The first row keeps its
   existing spacing exactly; the second sits under it, right-aligned to the
   same edge, rather than crowding a fourth item onto the first. */

#global-controls {
  flex-direction: column;
  align-items: flex-end;
  gap: 0.4rem;
}

.controls-row {
  display: flex;
  align-items: center;
  gap: 1rem;
}

#hsk-toggle {
  text-decoration: none;
}

/* Homepage entry point. Shown only on the empty view, like #recent-section. */
#hsk-home-link {
  width: min(30rem, 100%);
  margin: 1.5rem auto 0;
  font-size: 0.85rem;
}

#hsk-home-link a {
  color: var(--muted);
  text-decoration: none;
}

#hsk-home-link a:hover {
  color: var(--accent);
}

#app:not(.empty) #hsk-home-link {
  display: none;
}

/* The badge is now a link through to its level. */
.hsk-badge {
  text-decoration: none;
}

.hsk-badge:hover {
  background: rgba(var(--accent-fill-rgb), 0.35);
  border-color: var(--accent);
}

/* -- HSK index and level pages -------------------------------------------- */

#app.hsk {
  max-width: 46rem;
}

.hsk-heading {
  font-size: 1.6rem;
  margin: 0.5rem 0 0.25rem;
  font-weight: 600;
}

.hsk-note {
  color: var(--muted);
  font-size: 0.9rem;
  margin: 0 0 1.5rem;
}

.hsk-back {
  display: inline-block;
  color: var(--muted);
  text-decoration: none;
  font-size: 0.85rem;
  margin-top: 1rem;
}

.hsk-back:hover {
  color: var(--accent);
}

.hsk-level-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
  gap: 0.75rem;
}

.hsk-level-card {
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
  padding: 1rem;
  border: 1px solid var(--border);
  border-radius: 0.4rem;
  color: inherit;
  text-decoration: none;
}

.hsk-level-card:hover {
  border-color: var(--accent);
  background: rgba(var(--accent-fill-rgb), 0.12);
}

.hsk-level-name {
  font-size: 1.2rem;
  font-weight: 600;
}

.hsk-level-count {
  color: var(--muted);
  font-size: 0.8rem;
}

/* One line per word: a level page is for scanning, so the grid keeps
   headword, reading and gloss in fixed columns down the page. */
.hsk-word-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.hsk-word-link {
  display: grid;
  grid-template-columns: 7rem 7rem minmax(0, 1fr);
  gap: 1rem;
  align-items: baseline;
  padding: 0.55rem 0.4rem;
  margin: 0 -0.4rem;
  border-bottom: 1px solid var(--border);
  color: inherit;
  text-decoration: none;
}

.hsk-word-link:hover {
  background: rgba(var(--accent-fill-rgb), 0.12);
}

.hsk-word-hz {
  font-size: 1.25rem;
}

.hsk-word-pinyin {
  color: var(--text);
  font-size: 0.85rem;
}

.hsk-word-gloss {
  font-size: 0.9rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.hsk-pager {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 1.5rem;
  margin: 2rem 0 0;
}

.hsk-pager-position {
  color: var(--muted);
  font-size: 0.85rem;
}

@media (max-width: 34rem) {
  .hsk-word-link {
    grid-template-columns: 5rem minmax(0, 1fr);
  }

  .hsk-word-pinyin {
    grid-column: 2;
  }

  .hsk-word-gloss {
    grid-column: 2;
    white-space: normal;
  }
}

/* Below this the compact lockup crowds the search bar, so the wordmark
   drops and the mark alone carries the identity -- it is the part that
   survives shrinking, which is why the favicon uses it too. */
@media (max-width: 30rem) {
  #app:not(.empty) .logo-type {
    display: none;
  }
}

/* -- Accounts (Phase 1) ---------------------------------------------------
   The sign-in and account panels. Both use the same overlay: a fixed
   backdrop that centres one card. Colours come from the same variables as
   everything else, so light theme is covered without a second ruleset. */

#auth-overlay,
#account-overlay {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.5rem;
  background: rgba(0, 0, 0, 0.55);
}

/* [hidden] loses to `display: flex` on specificity grounds, so the panels
   would stay on screen permanently without this. */
#auth-overlay[hidden],
#account-overlay[hidden] {
  display: none;
}

#auth-panel,
#account-panel {
  position: relative;
  width: 100%;
  max-width: 22rem;
  padding: 1.5rem;
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  background: var(--bg);
}

#auth-panel h2,
#account-panel h2 {
  margin: 0 0 1rem;
  font-size: 1.1rem;
}

#auth-close,
#account-close {
  position: absolute;
  top: 0.75rem;
  right: 0.75rem;
}

#auth-form {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

/* Not hidden by anything today, unlike the two below it. Kept uniform on
   purpose: the rule is that a panel element with a display value carries
   its own [hidden] override, so the first person to write
   `authForm.hidden = true` does not rediscover the specificity trap. */
#auth-form[hidden] {
  display: none;
}

#auth-form label {
  color: var(--muted);
  font-size: 0.8rem;
}

#auth-form input {
  margin-bottom: 0.5rem;
  padding: 0.4rem 0.5rem;
  border: 1px solid var(--border);
  border-radius: 0.3rem;
  background: var(--bg);
  color: var(--text);
  font: inherit;
}

#auth-form input:focus {
  outline: 2px solid var(--accent);
  outline-offset: -1px;
}

#auth-code-field {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

/* Same trap as the overlays above, and the reason the signup code field
   was visible on the sign-in view: `display` set through an ID selector
   beats the `[hidden] { display: none }` in the user-agent stylesheet, so
   the element stayed on screen while its .hidden property said otherwise.
   Any rule that gives one of these panels a display value needs this. */
#auth-code-field[hidden] {
  display: none;
}

/* Explanatory lines in the create-account view. --muted rather than
   --text: they are context for the fields, not content of equal weight. */
.auth-note {
  margin: 0 0 0.75rem;
  color: var(--muted);
  font-size: 0.8rem;
  line-height: 1.5;
}

/* The unrecoverable-password warning. Given the amber the register tags
   already use for "take note of this" rather than the error red, which
   would read as something having gone wrong -- nothing has; it is a
   consequence of choosing to create an account. */
.auth-note-warn {
  padding: 0.5rem 0.6rem;
  border: 1px solid rgba(var(--register-archaic-rgb), 0.4);
  border-radius: 0.3rem;
  background: rgba(var(--register-archaic-rgb), 0.1);
  color: var(--register-archaic);
}

#auth-submit {
  margin-top: 0.5rem;
  padding: 0.4rem;
  border: 1px solid var(--border);
  border-radius: 0.3rem;
  color: var(--text);
  font-size: 0.9rem;
}

#auth-submit:disabled {
  opacity: 0.6;
}

/* Errors are the one place a literal colour is warranted: the accent is a
   lime that reads as success, and --muted is what ordinary labels use. */
#auth-error {
  margin: 0.25rem 0 0;
  color: #e06c6c;
  font-size: 0.85rem;
}

#auth-switch {
  margin: 1rem 0 0;
  text-align: center;
}

#account-name {
  margin: 0 0 1rem;
  font-size: 0.9rem;
}

#account-panel .nav-btn {
  display: block;
  margin: 0.5rem 0 0;
}

/* The export is an <a> so the browser handles the download, but it sits in
   a column of buttons and should not read as a link among them. */
#export-saved {
  text-decoration: none;
  width: fit-content;
}

/* Explains why the download is there rather than leaving it a bare
   control. Sits under it, so the reason follows the thing it justifies. */
#account-panel #export-note {
  margin: 0.4rem 0 1rem;
}

/* -- Static content pages (FAQ, Contact) ----------------------------------
   Plain prose pages with no data behind them. Same type scale and colours
   as the rest of the site rather than a separate treatment. */

.page-title {
  margin: 1.5rem 0 1rem;
  font-size: 1.4rem;
}

.page-empty {
  color: var(--muted);
}

.page-facts {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.3rem 1rem;
  margin: 1.25rem 0 0;
  font-size: 0.9rem;
}

.page-facts dt {
  color: var(--muted);
}

.page-facts dd {
  margin: 0;
}

/* A value that is deliberately absent, not missing by accident. */
.page-pending {
  color: var(--muted);
  font-style: italic;
}

.page-aside {
  margin-top: 2rem;
  color: var(--muted);
  font-size: 0.9rem;
}

/* Site links sit above the licensing block, which is long enough to bury
   them, and are given the body colour so they do not read as fine print. */
#footer-links {
  margin: 0 0 0.75rem;
  color: var(--text);
}

/* -- About page -----------------------------------------------------------
   Prose, so it is narrower than the 46rem #app allows: long measures are
   hard to read, and this is the only page on the site that is mostly text. */

#about-view {
  max-width: 38rem;
}

#about-view p {
  margin: 0 0 1rem;
}

.about-lede {
  font-size: 1.05rem;
}

.about-heading {
  margin: 2.25rem 0 1rem;
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
  font-weight: 600;
}

.about-notes,
.about-sources {
  margin: 0;
}

.about-notes dt,
.about-sources dt {
  font-weight: 600;
  margin-top: 1rem;
}

.about-notes dd,
.about-sources dd {
  margin: 0.15rem 0 0;
  color: var(--muted);
}

/* Figures come from /api/stats and are empty until it answers, so they get
   no separator of their own -- an orphaned bullet would sit there on the
   rare load where the fetch fails. */
.about-count {
  display: block;
  font-size: 0.85rem;
  color: var(--text);
}

.about-count:empty {
  display: none;
}

/* The audio caveat. Amber, matching the register tags and the sign-up
   warning: worth noticing, but nothing has gone wrong. */
.about-caveat {
  display: block;
  margin-top: 0.5rem;
  padding: 0.5rem 0.6rem;
  border-left: 2px solid rgba(var(--register-archaic-rgb), 0.6);
  background: rgba(var(--register-archaic-rgb), 0.1);
  color: var(--register-archaic);
  font-size: 0.85rem;
  font-style: normal;
}

/* Site links read as chrome rather than as app controls, which the row
   above them are. */
.controls-row-site .nav-btn {
  font-size: 0.8rem;
  text-decoration: none;
}
