/* Kanji Trail — mobile-first, large touch targets, light and dark. */

:root {
  --bg: #fdf6f0;
  --surface: #ffffff;
  --ink: #23201e;
  --ink-soft: #6d6560;
  --line: #e8ded6;
  --accent: #e8553d;
  --accent-ink: #ffffff;
  /* A translucent halo of the accent, for the ring around a grabbed control
     (so far: the story bookmark). Declared per theme rather than mixed at
     use, because a fixed alpha over a dark ground reads far heavier than the
     same alpha over a light one. */
  --accent-soft: rgba(232, 85, 61, 0.22);
  --ok: #2e9e6b;
  --ok-bg: #e4f6ec;
  --bad: #d94141;
  --bad-bg: #fdeaea;
  --radius: 18px;
  --tap: 52px;

  /* Story illustrations (stories-plan.md §8.8). Inline art is SVG precisely so
     its colours can be these variables and follow the theme — which only works
     because the markup is inlined into the DOM rather than referenced by
     <img>, where page CSS would never reach it. Muted on purpose: a picture
     sits inside a page of Japanese somebody is reading and must not out-shout
     the text.

     --art-ink is the DRAWING's line, and unlike the UI's --ink it does NOT
     flip between themes: it stays dark in both, because an ant is a dark
     animal in every light and inverting it produced a white ant on a dark
     river. Dark mode dims and desaturates the GROUND instead, which is what a
     printed plate does and keeps the drawing recognisably the same drawing. */
  --art-sky: #efe3d5;
  --art-water: #8ebdd6;
  --art-water-far: #b3d3e2;
  --art-water-near: #6ca2bd;
  --art-bank: #cdb289;
  --art-leaf: #7cb36a;
  --art-leaf-vein: #4c8440;
  --art-accent: #e0a63f;
  --art-pale: #faf4ec;
  --art-ink: #3b322c;

  /* Mastery scale for the set-overview grid: 0 (never introduced) through 4
     (at or past the top box). Deliberately distinct from --ok/--ok-bg —
     those mean "just answered correctly", this means "known over time". */
  --tier-0: var(--surface);
  --tier-0-ink: var(--ink-soft);
  --tier-1: #e3f3e3;
  --tier-1-ink: #2f5233;
  --tier-2: #a9dbac;
  --tier-2-ink: #1f4023;
  --tier-3: #5cb663;
  --tier-3-ink: #ffffff;
  --tier-4: #2e8b3d;
  --tier-4-ink: #ffffff;

  /* Text-size control (see the pinch-to-open panel in app.js —
     bindPinchZoom()/openFontSizePanel()). Set as an inline style on
     <html> once a saved or slider-chosen value loads (applyFontScale()),
     which overrides this stylesheet default of 1 (100%, unscaled). Capped
     at 1.5 (FONT_SCALE_MAX in app.js) — see styles.css's overflow audit
     notes near .choice-grid-text for why that ceiling and not higher. */
  --app-font-scale: 1;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #17151a;
    --surface: #221f26;
    --ink: #f2ece8;
    --ink-soft: #a49b96;
    --line: #35303a;
    --accent: #ff6f56;
    --accent-ink: #21100c;
    --accent-soft: rgba(255, 111, 86, 0.3);
    --ok: #57d39a;
    --ok-bg: #17342a;
    --bad: #ff7b7b;
    --bad-bg: #3a1f22;

    /* Dark: the same picture, dimmed and desaturated rather than inverted, and
       kept light enough for the (still dark) --art-ink line to read on it. A
       fully dark ground would need a light subject, which is how the ant came
       out white. */
    --art-sky: #3b3442;
    --art-water: #4d6e81;
    --art-water-far: #3f5b6b;
    --art-water-near: #628ea5;
    --art-bank: #6b5a44;
    --art-leaf: #5d8f52;
    --art-leaf-vein: #3c6733;
    --art-accent: #cb9a3f;
    --art-pale: #e3dbd2;
    --art-ink: #221c18;

    --tier-0: var(--surface);
    --tier-0-ink: var(--ink-soft);
    --tier-1: #1d3320;
    --tier-1-ink: #a9dbac;
    --tier-2: #2f6a37;
    --tier-2-ink: #d9f0da;
    --tier-3: #3f9a4c;
    --tier-3-ink: #ffffff;
    --tier-4: #57c765;
    --tier-4-ink: #0c1f0e;
  }
}

/* --- Accent colour picker (Settings > Theme colour, see ACCENT_COLORS and
   applyAccentColor() in app.js) -------------------------------------------
   Only --accent/--accent-ink change per colour — every other token (--ok/
   --bad/--tier-*/etc.) stays put, since those carry their own meaning
   (correct/incorrect, mastery level) independent of the learner's chosen
   brand colour. Per-profile: applyAccentColor() sets data-accent on <html>
   whenever a profile opens, and resets it on the profile-picker screen —
   see renderProfiles()/openProfile() in app.js. "coral" is also the
   unattributed default above, repeated here so every choice, including the
   one nothing needs to override, is an explicit, equally-real option
   rather than a special case. */
:root[data-accent="coral"] { --accent: #e8553d; --accent-ink: #ffffff; }
:root[data-accent="blue"] { --accent: #2f6fed; --accent-ink: #ffffff; }
:root[data-accent="purple"] { --accent: #8451d6; --accent-ink: #ffffff; }
:root[data-accent="pink"] { --accent: #e0559a; --accent-ink: #ffffff; }
:root[data-accent="teal"] { --accent: #12968a; --accent-ink: #ffffff; }
:root[data-accent="amber"] { --accent: #c9821a; --accent-ink: #2a1600; }
:root[data-accent="green"] { --accent: #2f9e44; --accent-ink: #ffffff; }
:root[data-accent="indigo"] { --accent: #4a55d1; --accent-ink: #ffffff; }
:root[data-accent="crimson"] { --accent: #d1273f; --accent-ink: #ffffff; }
:root[data-accent="sky"] { --accent: #0891b2; --accent-ink: #ffffff; }

@media (prefers-color-scheme: dark) {
  :root[data-accent="coral"] { --accent: #ff6f56; --accent-ink: #21100c; }
  :root[data-accent="blue"] { --accent: #7aa6ff; --accent-ink: #0b1733; }
  :root[data-accent="purple"] { --accent: #b794f9; --accent-ink: #221040; }
  :root[data-accent="pink"] { --accent: #ff8fc4; --accent-ink: #3a0f26; }
  :root[data-accent="teal"] { --accent: #4fd9c8; --accent-ink: #052925; }
  :root[data-accent="amber"] { --accent: #f3bb52; --accent-ink: #2a1600; }
  :root[data-accent="green"] { --accent: #5cd679; --accent-ink: #062510; }
  :root[data-accent="indigo"] { --accent: #9aa0ff; --accent-ink: #14163f; }
  :root[data-accent="crimson"] { --accent: #ff7a90; --accent-ink: #3a0512; }
  :root[data-accent="sky"] { --accent: #63e0f5; --accent-ink: #052a30; }
}

* { box-sizing: border-box; }

/* The browser's own [hidden]{display:none} default has the same specificity
   as a single class selector, and author rules beat the UA stylesheet at
   equal specificity — so any component below that sets its own `display`
   (flex/grid) silently wins over `.hidden = true` in JS, and the element
   stays visible. !important here is deliberate and load-bearing: it is what
   makes `hidden` an unconditional override no future component class needs
   to remember to guard against. Found via a genuine bug (kanji-info and the
   Show answers/Advanced row staying visible when JS had already hidden
   them) — do not remove this without re-auditing every `.hidden =` in app.js. */
[hidden] { display: none !important; }

/* Almost nothing below this line is sized in rem — body and every component
   set an explicit px font-size, which is why --app-font-scale (above) can't
   just ride on the cascade the way a page-zoom shortcut does in a desktop
   browser. So the default here is that EVERY rule below that sets a literal
   px font-size (or a clamp() built from px bounds, like .glyph-xl) multiplies
   it via calc(Npx * var(--app-font-scale, 1)) at its own rule — the same
   mechanical pattern repeated at every one of them rather than tagged with
   its own paragraph each time (an earlier pass here scaled only a curated
   handful and called the rest "a deliberate partial win"; real device
   testing found that read as broken, not restrained — command-plus on a
   desktop browser does not ask which elements deserve it). html's own
   font-size just above is part of the same mechanism, so anything that ever
   does use rem gets it for free too.
   The exceptions are commented individually, right on the rule, and fall
   into a few kinds: an icon/emoji glyph or a "•"/"✓"/"+"/"−" status-marker
   character, sized as UI chrome rather than as text to read; the small-A
   slider anchor in the text-size panel below, deliberately kept fixed to
   contrast against the large-A beside it that already grows; and a ruby
   (furigana) rt sized in em against an ancestor that itself scales, which
   already inherits the effect for free and would double it if also wrapped
   in calc(). See bindPinchZoom()/applyFontScale() in app.js. */
html {
  font-size: calc(16px * var(--app-font-scale, 1));
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  font-size: calc(17px * var(--app-font-scale, 1));
  line-height: 1.45;
  -webkit-text-size-adjust: 100%;
  -webkit-tap-highlight-color: transparent;
  /* Disables double-tap-to-zoom (and the ~300ms tap delay it implies) and
     native pinch-zoom, app-wide, without touching the viewport meta tag —
     that would need user-scalable=no, an accessibility regression (WCAG
     1.4.4) with no replacement. `manipulation` (pan-x pan-y pinch-zoom)
     would leave native pinch-zoom enabled at the browser level: per the
     touch-action spec that hands the gesture to the browser's own
     compositor, which starts zooming regardless of any preventDefault() a
     JS pointermove/touchmove handler calls — confirmed on a real phone,
     where bindPinchZoom() below "worked" only in the sandboxed test that
     dispatches synthetic events, never against a genuine two-finger pinch.
     pan-x pan-y omits pinch-zoom from what the browser handles, which is
     what actually lets bindPinchZoom() (app.js) intercept a real pinch and
     open the Text size panel instead of the viewport zooming — the thing
     that keeps this WCAG 1.4.4-safe without the accidental, easy-to-miss
     zooming pinch-to-zoom caused on a phone. Still removes the double-tap
     gesture, same as manipulation did, since any non-auto value does. */
  touch-action: pan-x pan-y;
  /* Nothing in this app is meant to be selected or copied — every character,
     reading and button label here is UI, not content a learner would ever
     want to grab text from. Reported from real use on iPad + Apple Pencil:
     a palm resting on the glass mid-stroke plus the pencil's own contact
     reads to iOS as two simultaneous touch points, which it's happy to
     interpret as "select from here to there" over whatever text sits
     between them — the resulting blue selection (and, on a plain long
     press, the Copy/Look Up callout menu) has nothing to do with anything
     the app itself renders in response to a touch. user-select: none
     removes the selection gesture itself (this doesn't affect typing or
     selecting text inside the profile-name/search <input> fields — form
     controls own their text-editing UI independently of an ancestor's
     user-select); -webkit-touch-callout: none removes the long-press menu
     iOS shows even where nothing is selectable. */
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}

/* --- Splash --------------------------------------------------------------
   See index.html for why this exists as plain HTML rather than something
   app.js renders. Sits above everything else and gets hidden for good once
   boot() has real content to show. */
.splash {
  position: fixed;
  inset: 0;
  z-index: 999;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 18px;
  background: var(--bg);
}
.splash-mark { width: 72px; height: 72px; border-radius: 20px; font-size: 28px; }  /* fixed-size logo badge, not reading text */
.splash-spinner {
  width: 28px; height: 28px;
  border-radius: 50%;
  border: 3px solid var(--line);
  border-top-color: var(--accent);
  animation: splash-spin 0.8s linear infinite;
}
@keyframes splash-spin { to { transform: rotate(360deg); } }

/* --- Data loading pill -----------------------------------------------------
   See #data-loading in index.html. A small fixed pill rather than a
   full-screen overlay — loading one grade's data is a background hand-off,
   not a blocking event the way the initial splash is. */
.data-loading {
  position: fixed;
  bottom: max(16px, env(safe-area-inset-bottom));
  left: 50%;
  transform: translateX(-50%);
  z-index: 998;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 16px;
  border-radius: 999px;
  background: var(--surface);
  border: 1px solid var(--line);
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
  font-size: calc(14px * var(--app-font-scale, 1));
  color: var(--ink-soft);
}
.data-loading-spinner {
  width: 14px; height: 14px;
  border-radius: 50%;
  border: 2px solid var(--line);
  border-top-color: var(--accent);
  animation: splash-spin 0.8s linear infinite;
}

/* --- Install banner -------------------------------------------------------
   See #install-banner in index.html for why this exists. A wide bottom bar
   rather than the data-loading pill's shape — this needs room for a
   sentence of text plus a button, not just a spinner and a word. */
.install-banner {
  position: fixed;
  left: 12px; right: 12px;
  bottom: max(12px, env(safe-area-inset-bottom));
  z-index: 997;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 14px 14px 14px 16px;
  border-radius: var(--radius);
  background: var(--surface);
  border: 1px solid var(--line);
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.install-banner-text { margin: 0; flex: 1; font-size: calc(14px * var(--app-font-scale, 1)); line-height: 1.4; color: var(--ink); }
.install-banner-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.install-banner-actions .btn-primary { min-height: 40px; padding: 0 14px; font-size: calc(14px * var(--app-font-scale, 1)); }
.install-banner-actions .btn-icon { width: 36px; min-height: 36px; font-size: 15px; }  /* icon glyph, not reading text */

#app {
  max-width: 620px;
  margin: 0 auto;
  padding: max(16px, env(safe-area-inset-top)) 16px max(24px, env(safe-area-inset-bottom));
}

.screen { display: flex; flex-direction: column; gap: 16px; }

/* Every screen switch goes through show() in app.js, which just flips which
   `.screen` has [hidden] — instant, which reads as abrupt given how fast
   everything else here is. A plain fade-and-rise on whichever screen just
   became visible is enough to read as a deliberate transition rather than a
   cut, without the complexity (and failure modes — see navSeq in app.js) of
   trying to cross-fade the outgoing screen too. Restarts for free every time
   [hidden] toggles off, because the element is genuinely re-entering layout
   (display: none -> flex) rather than just changing a class on something
   already rendered. */
/* Two variants, and the split is load-bearing rather than cosmetic. A
   `transform` — even one only touched by a keyframe, and only for 220ms —
   makes the element a containing block for its own `position: fixed`
   descendants. On the quiz and summary screens that descendant is
   .bottom-bar, which promptly stops being pinned to the VIEWPORT and pins
   itself to the bottom of the SCREEN box instead: taller than the viewport
   whenever the content scrolls, so Next slides below the fold and taps at
   it hit nothing until the animation ends. Reported as needing three or
   four presses on Next after coming back from "Full details" (which
   re-triggers the animation with Next already showing — every other way of
   entering the quiz screen has it hidden, which is why this hid for so
   long). Those screens therefore fade without rising. */
@media (prefers-reduced-motion: no-preference) {
  .screen:not([hidden]) { animation: screen-enter 220ms ease-out; }
  .screen:not([hidden]):has(.bottom-bar) { animation: screen-fade 220ms ease-out; }
}
@keyframes screen-enter {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: translateY(0); }
}
@keyframes screen-fade {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* Reserves room for the fixed .bottom-bar (see above) so it never overlaps
   the last bit of real content — a chip in a long summary list, a choice on
   a short quiz card. Sized generously rather than exactly (the bar's real
   height varies with how many buttons it holds), which just means some
   trailing empty space above it on a short screen, not overlap on a tall
   one. */
/* The quiz screen reserves far less than the others because #quiz-info
   (meanings, example word, hint — the tall, variable part) no longer sits
   at the bottom: it moved up under the character, so the last thing above
   the bar is now the fixed-height "Advanced / Show answers" row. What's
   left is just clearance for the bar itself — #app's own bottom padding
   already covers the home-indicator inset that the bar's padding also
   respects, so this does not need to grow with it.

   The min-height + margin-top: auto pair below is the real point. Without
   it the answers ended wherever the card above them happened to stop —
   which on a tall screen left a large dead gap between the last choice and
   the Next button pinned to the bottom, so answering and then advancing
   meant a long reach across nothing. Now the empty space collects UNDER
   the character instead, and the answers sit just above Next whatever the
   screen height. On a short screen the auto margin simply collapses to
   nothing and the layout behaves exactly as it did before. */
/* Room for the pinned #writing-result bar while it's showing (same
   reservation quiz's bottom-bar makes, and the same button/rate-row shapes)
   so the board/kanji-info above can scroll clear of it instead of sitting
   underneath. Keyed off #writing-result's own `hidden` attribute directly,
   unlike #screen-overview's :has(.bottom-bar > :not([hidden])) below — its
   direct children are the buttons themselves, but #writing-result's are the
   always-present .row/#writing-rate wrappers, which that selector shape
   would match regardless of #writing-result's own hidden state. */
#screen-writing:has(#writing-result:not([hidden])) { padding-bottom: 92px; }
/* Same reservation for the Free-mode self-grade bar (kana-quest-feedback#13)
   — taller than #writing-result's single button row (a hint line, the
   question, then the Yes/No row), hence the bigger number. */
#screen-writing:has(#writing-self-grade:not([hidden])) { padding-bottom: 160px; }

#screen-quiz {
  padding-bottom: 92px;
  /* A slightly tighter rhythm than the 16px every other screen uses: this
     is the one screen that has to fit a character, its details and every
     answer between a sticky header and a fixed bar without scrolling, and
     4px off each gap is the cheapest 12px there is to find. */
  gap: 12px;
  min-height: 100vh;
  min-height: calc(100dvh - max(16px, env(safe-area-inset-top)) - max(24px, env(safe-area-inset-bottom)));
}
#quiz-choices { margin-top: auto; }
#screen-summary { padding-bottom: 320px; }

h1, h2, h3 { margin: 0; line-height: 1.2; }
h1 { font-size: calc(26px * var(--app-font-scale, 1)); }
h2 { font-size: calc(20px * var(--app-font-scale, 1)); }
h3 { font-size: calc(18px * var(--app-font-scale, 1)); }

.screen-title { font-size: calc(22px * var(--app-font-scale, 1)); font-weight: 600; }
.hint { margin: 0; color: var(--ink-soft); font-size: calc(14px * var(--app-font-scale, 1)); }
.footnote { margin: 8px 0 0; color: var(--ink-soft); font-size: calc(13px * var(--app-font-scale, 1)); text-align: center; }

/* --- Changelog (Settings > App version, see renderChangelog() in app.js) - */

.changelog-date { font-weight: 600; color: var(--ink); }
.changelog-list { margin: 4px 0 0; padding-left: 20px; }
.changelog-list li { margin: 4px 0; font-size: calc(14px * var(--app-font-scale, 1)); color: var(--ink-soft); }
/* One block per prior date inside #changelog-history — same shape as the
   always-visible "current" block above, just repeated. */
.changelog-entry + .changelog-entry { margin-top: 14px; }

/* --- Japanese glyphs ---------------------------------------------------- */

.glyph {
  font-family: "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic",
               "Noto Sans JP", "Noto Sans CJK JP", sans-serif;
  font-weight: 400;
  line-height: 1;
}
/* The character/word being taught or tested — the single most prominent
   thing on almost every screen, so it's one of the few rules in this file
   that genuinely scales with --app-font-scale (see the comment above
   `html` near the top of this file). Wrapping the whole clamp() in calc()
   rather than converting its px bounds to rem: the vw term is what actually
   wins on a phone-width viewport, and rem bounds alone would leave the
   rendered size unmoved by the slider until the scale pushed past them. */
.glyph-xl { font-size: calc(clamp(96px, 34vw, 168px) * var(--app-font-scale, 1)); }
/* A vocab word can run to several characters (自動車, こんにちは) where a kana
   or single kanji glyph never does — glyph-xl's 168px would overflow a
   phone width immediately. Sized to comfortably fit ~6 characters, and
   allowed to wrap rather than shrink further, since a fixed-size hint is
   more useful than a still-too-small one on a long word. Scales with
   --app-font-scale, same reasoning as .glyph-xl above. */
.glyph-vocab { font-size: calc(clamp(40px, 10vw, 64px) * var(--app-font-scale, 1)); word-break: keep-all; overflow-wrap: anywhere; }
.glyph-vocab rt {
  font-size: 0.4em;  /* ruby: em ratio of .glyph-vocab, scales for free once its parent does */
  color: var(--ink-soft);
  font-weight: 400;
  user-select: none;
}

/* Recall mode's prompt (vocab-plan.md §6.1) is an English gloss, not a
   Japanese glyph — its own class rather than .glyph so it gets a plain sans
   stack instead of the CJK font list, sized like a short headline rather
   than a giant character. This IS the question in Recall mode, so like
   .glyph-xl above it scales with --app-font-scale. */
.quiz-prompt-text {
  font-size: calc(clamp(28px, 7vw, 40px) * var(--app-font-scale, 1));
  font-weight: 700;
  line-height: 1.2;
}
/* The reveal-ladder tap target (vocab-plan.md §5.2/§5.3) — a dotted
   underline is the one affordance telling a learner this word can be
   tapped for help, since nothing else on the card is (a stray tap
   elsewhere on the quiz screen deliberately does nothing — see c51d904). */
.vocab-word-tap { cursor: pointer; text-decoration: underline dotted; text-underline-offset: 8px; }

/* Once a question is answered — Next showing, or (Writing) the result panel
   up — the tested glyph itself becomes a way through to its own detail
   screen: "click wherever possible" extends to the very thing just
   answered, not only a separate "Full details" link. A class of its own
   rather than reusing .vocab-word-tap: that one already means something
   different (whether the reveal ladder has anything left to show) and the
   two must be free to disagree, which they do the moment a vmeaning
   question resolves and the reveal ladder is spent but the glyph itself
   only now becomes tappable for detail. See clickQuizGlyph() in app.js. */
.quiz-glyph-tap { cursor: pointer; text-decoration: underline dotted; text-underline-offset: 8px; }
/* #writing-result-glyph is a <button> (for a real, focusable tap target)
   carrying .glyph/.glyph-lg on the same element — those already set every
   font property explicitly (family, weight, size, line-height), so this
   only strips the browser's own button chrome. Deliberately NOT `font:
   inherit`: that shorthand resets font-size too, and a <button>'s inherited
   context is nowhere near .glyph-lg's 56-88px — it would shrink the glyph
   to whatever tiny size surrounds it instead of leaving .glyph-lg's own
   font-size alone. */
button.quiz-glyph-tap {
  background: none; border: none; padding: 0; color: inherit;
  display: block; margin: 0 auto;
}

/* "Hide furigana in future" (vocab-plan.md §5.3) — quiet and small, same
   reasoning as .quiz-info-more: an offer, not a step, so it must not read as
   louder than Next. Negative margin-top pulls it up against the glyph above
   it rather than sitting a full flex gap away, since it's a comment on THAT
   word specifically. */
.quiz-hide-furigana {
  min-height: 28px;
  padding: 0 10px;
  font-size: calc(13px * var(--app-font-scale, 1));
  margin-top: -6px;
}

.romaji {
  font-size: calc(30px * var(--app-font-scale, 1));
  font-weight: 600;
  letter-spacing: 0.04em;
  color: var(--accent);
}

/* Vocabulary only: how a word is actually SAID, when that differs from its
   literal romaji spelling (こんばんは "konbanha" vs said "konbanwa"; a long
   vowel needing a macron, とう -> "tō"). Deliberately smaller and quieter
   than .romaji, which is still the primary, always-correct answer to "how
   is this spelled in romaji" — this is a secondary note, not a correction.
   See pronunciationFor() in vocab.js. */
.pronunciation {
  font-size: calc(16px * var(--app-font-scale, 1));
  font-style: italic;
  color: var(--ink-soft);
  margin-top: -6px;
}

/* --- Structure ---------------------------------------------------------- */

.brand { display: flex; align-items: center; gap: 12px; justify-content: center; }
.brand-mark {
  font-family: "Hiragino Sans", "Noto Sans JP", sans-serif;
  background: var(--accent); color: var(--accent-ink);
  width: 52px; height: 52px; border-radius: 16px;
  display: grid; place-items: center; font-size: 20px;  /* fixed-size logo/emoji badge, not reading text */
}

.topbar {
  display: grid;
  /* Four columns, not three: the fourth is the always-present Feedback
     icon (feedback-plan.md, "Entry points"). It sits in a header corner on
     every screen including the quiz, lesson and writing ones — those are
     untimed, and the moment of frustration or of an idea IS that screen —
     but never inside #quiz-choices or any other answer control, so a wrong
     tap can't register as an answer. The third column becomes `auto` so a
     screen whose right slot is a bare spacer collapses it instead of
     leaving a tap-sized hole next to the new icon. */
  /* minmax(0, 1fr), not bare 1fr: the quiz/writing progress bar sits in
     this column and, being a childless bar with no text, should never need
     more than 0 as its content-based minimum — but Safari's grid track
     sizing has a known history of getting that minimum wrong for a fluid
     column beside a sticky-positioned, negative-margin full-bleed parent
     (reported: the progress bar invisible on an iOS home-screen install,
     kana-quest-feedback#9), leaving the bar collapsed while the fixed-width
     columns beside it (back button, counter, feedback icon) still render
     fine off their own content size. minmax(0, 1fr) is the standard,
     harmless-everywhere-else fix: it tells the track explicitly that it may
     shrink to 0 before growing, instead of leaving Safari to infer it. */
  grid-template-columns: var(--tap) minmax(0, 1fr) auto auto;
  align-items: center;
  gap: 8px;
  min-height: var(--tap);
  /* Sticks to the top of the viewport on scroll, so the back button and
     title stay reachable on a long screen (set overview, course list,
     summary chips) instead of scrolling away with the content under them.
     Escapes #app's own top/side padding via negative margins and
     re-applies the same amounts as its own padding, so nothing shifts
     before the first scroll; background + z-index cover scrolled content
     once it's actually stuck. */
  position: sticky;
  top: 0;
  z-index: 5;
  background: var(--bg);
  margin: calc(-1 * max(16px, env(safe-area-inset-top))) -16px 0;
  padding: max(16px, env(safe-area-inset-top)) 16px 0;
}
.topbar h2 { text-align: center; }
.counter { text-align: right; color: var(--ink-soft); font-variant-numeric: tabular-nums; font-size: calc(14px * var(--app-font-scale, 1)); }

.card {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 16px;
}
/* The dual-run notice (rename-and-hosting-plan.md §8). An ordinary card
   with the accent brought to its leading edge — this has to be noticed on
   a screen that already carries a sync nudge and a course nudge, without
   being dressed as an error. Everything here is a theme token, so it
   follows light and dark like the rest. Buttons wrap rather than shrink:
   two full-width rows on a phone beat four squashed ones. */
.migration-notice {
  border-left: 4px solid var(--accent);
}
.migration-notice .row { flex-wrap: wrap; }
.migration-notice .row > * { flex: 1 1 12rem; }

.stack { display: flex; flex-direction: column; gap: 12px; }
.row { display: flex; gap: 10px; }
.row > * { flex: 1; }

/* Keeps #writing-next centered and roughly double the width of its
   siblings regardless of which ones are showing (see
   kana-quest-feedback#10) — Try again and Switch-mode flank it via `order`
   rather than DOM position, so hiding either one still leaves Next in the
   middle instead of drifting to an edge. flex-grow:2 vs the row's default
   1 is a 2:1 share of the free space, not an exact 2x pixel width — each
   button keeps its own padding regardless of flex-basis, so the rendered
   ratio is closer to 1.6x; min-width:0 at least keeps that ratio stable
   instead of letting a long label (e.g. "Switch to easier mode") widen
   its own button past its share. */
#writing-result .row > * { min-width: 0; }
#writing-result .row > #writing-retry { order: -1; }
#writing-result .row > #writing-next { order: 0; flex-grow: 2; }
#writing-result .row > #writing-switch-mode { order: 1; }

/* Pins its content to the bottom of the viewport, thumb-reachable
   regardless of how tall the screen's own content is — the quiz's Next
   button and the session summary's action stack, both usually combined
   with .stack. Collapses to nothing (no bar, no border) whenever every
   direct child is [hidden], so a screen with nothing to offer yet (Next,
   before a question is answered) shows no empty bar at the bottom — see
   the quiz screen in index.html. */
.bottom-bar { display: none; }
.bottom-bar:has(> :not([hidden])) {
  display: flex;
  flex-direction: column;
  position: fixed;
  z-index: 20;
  left: 0;
  right: 0;
  bottom: 0;
  max-width: 620px;
  margin: 0 auto;
  background: var(--bg);
  border-top: 1px solid var(--line);
  padding: 12px 16px max(12px, env(safe-area-inset-bottom));
}

/* --- Buttons ------------------------------------------------------------ */

.btn {
  min-height: var(--tap);
  padding: 0 18px;
  border-radius: 14px;
  border: 1px solid var(--line);
  background: var(--surface);
  color: var(--ink);
  font-size: calc(17px * var(--app-font-scale, 1));
  font-weight: 600;
  font-family: inherit;
  cursor: pointer;
}
.btn:active { transform: scale(0.98); }
.btn:disabled { opacity: 0.45; cursor: default; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
.btn-quiet { background: transparent; color: var(--ink-soft); }
.btn-danger { background: transparent; border-color: var(--bad); color: var(--bad); }
.btn-icon { width: var(--tap); padding: 0; display: grid; place-items: center; font-size: 20px; }  /* icon glyphs only (✕ ← ⚙️ 💬 …), not reading text */
/* Drops the ordinary .btn border too — a boxed-in "✕" reads as a peer of
   the progress bar next to it (see quit-session in the quiz/writing
   header); a plain, borderless glyph reads as secondary the way it should. */
.btn-icon.btn-quiet { border-color: transparent; }

/* The character-detail back button when it is spelling out where it goes
   ("← Back to test", set in renderCharacterDetail) rather than showing a
   bare arrow. The .topbar rule lets its column size to the text instead of
   staying a square tap target; the title beside it stays centred in what's
   left. */
.btn-back-text { padding: 0 12px; font-size: calc(15px * var(--app-font-scale, 1)); font-weight: 600; white-space: nowrap; }
.topbar:has(.btn-back-text) { grid-template-columns: auto 1fr auto; }
.wide { width: 100%; }

/* The explicit Easy/OK/Hard rating control (see showRatingBar()/
   rateAndAdvance() in app.js) that replaces a plain Next button on a
   correct answer — #quiz-rate on the quiz screen, #writing-rate on
   writing's. OK sits in the middle at roughly double width ("wait longer
   to show" / "show again soon" flank the default "this came at a good
   time"), matching today's Next in every other way: same look
   (.btn-primary), same Enter-key binding (primaryAdvanceButton() in
   app.js), same RATING.GOOD it always defaulted to. */
.rate-row > * {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  min-height: 58px;
  padding: 8px 4px;
}
.rate-row .rate-ok { flex: 2; }
.rate-sub { font-size: calc(11px * var(--app-font-scale, 1)); font-weight: 500; opacity: 0.75; }

/* The "I know this! / I just guessed" control (see
   confirmPlacementAndAdvance() in app.js) that replaces a plain Next button
   on a correct answer during a "Test unlearned" session — #quiz-placement.
   Same stacked label + .rate-sub shape as the rating row above, but a two-
   way split instead of three: "I know this!" takes the right two thirds and
   carries the .btn-primary weight Next has everywhere else, since it is
   exactly what Next already did here; "I just guessed" sits quieter in the
   left third, deliberately easy to find but never the accidental press. */
.confirm-row > * {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  min-height: 58px;
  padding: 8px 4px;
}
.confirm-row .confirm-know { flex: 2; }
.confirm-row .confirm-guess { flex: 1; }

.linkish {
  background: none; border: none; padding: 0;
  color: var(--accent); font: inherit; font-size: calc(13px * var(--app-font-scale, 1));
  text-decoration: underline; cursor: pointer;
}

/* --- Profiles ----------------------------------------------------------- */

.profile-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
  gap: 12px;
}
.profile-card {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 20px 12px;
  display: flex; flex-direction: column; align-items: center; gap: 8px;
  font: inherit; color: inherit; cursor: pointer;
}
.avatar { font-size: 40px; line-height: 1; }  /* emoji avatar picker icon, not reading text */
.profile-name { font-weight: 600; }

.field { display: flex; flex-direction: column; gap: 6px; font-size: calc(14px * var(--app-font-scale, 1)); color: var(--ink-soft); }
.field input[type="text"] {
  min-height: var(--tap);
  padding: 0 14px;
  /* max(), not the bare calc(): iOS Safari force-zooms the whole page to
     make a focused text input legible whenever its computed font-size
     drops below 16px — nothing to do with pinch or double-tap, and
     touch-action (see body above) can't touch it since no gesture is
     involved. At the small end of --app-font-scale (0.85, see
     FONT_SCALE_MIN in app.js) 17px*scale falls under that floor, so a
     learner who had shrunk the text got zoomed in just by tapping the
     sync-code or profile-name field — and since a real pinch is
     intercepted app-wide (above) to open the Text size panel, they had no
     gesture left to zoom back out with. Flooring at 16px keeps the field
     scaling with --app-font-scale above that point, same as everywhere
     else, while never crossing the threshold that triggers it. */
  font-size: max(16px, calc(17px * var(--app-font-scale, 1)));
  font-family: inherit;
  color: var(--ink);
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 12px;
}
.field input[type="range"] { accent-color: var(--accent); height: 32px; }
/* A <textarea> inherits none of the above and defaults to a white ground and
   a monospace face, which in dark mode is a glaring white box. The feedback
   form is the only textarea in the app, but the rule belongs with its
   siblings rather than off in the feedback section. */
.field textarea {
  padding: 12px 14px;
  /* Same iOS force-zoom floor as .field input[type="text"] above. */
  font-size: max(16px, calc(17px * var(--app-font-scale, 1)));
  font-family: inherit;
  line-height: 1.45;
  color: var(--ink);
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 12px;
  resize: vertical;
}

/* Lets a mic button sit inside the title input / details textarea instead of
   competing with it for a row of its own. Hidden by setupFeedbackVoiceButtons
   in app.js when the browser has no SpeechRecognition to offer. */
.field-with-mic { position: relative; }
.field-with-mic input, .field-with-mic textarea { padding-right: 44px; width: 100%; }
.mic-btn {
  position: absolute;
  right: 4px;
  top: 4px;
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background: none;
  color: var(--ink-soft);
  font-size: 16px;  /* mic icon glyph, not reading text */
  line-height: 1;
  cursor: pointer;
  border-radius: 50%;
}
.field-with-mic input ~ .mic-btn { top: 50%; transform: translateY(-50%); }
.mic-btn:hover, .mic-btn:active { background: var(--line); }
.mic-btn.listening { color: #fff; background: #e5484d; animation: mic-pulse 1.4s ease-in-out infinite; }
@keyframes mic-pulse {
  0%, 100% { box-shadow: 0 0 0 0 rgba(229, 72, 77, 0.45); }
  50% { box-shadow: 0 0 0 6px rgba(229, 72, 77, 0); }
}

.search-field { position: relative; }
.search-input {
  min-height: var(--tap);
  width: 100%;
  padding: 0 40px 0 14px;
  /* Same iOS force-zoom floor as .field input[type="text"] in the field
     rules above — see the comment there. */
  font-size: max(16px, calc(17px * var(--app-font-scale, 1)));
  font-family: inherit;
  color: var(--ink);
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 12px;
}
/* The custom clear button below replaces the native one so it looks and
   behaves the same across browsers (some hide their own on type="search"). */
.search-input::-webkit-search-cancel-button { display: none; }
.search-clear {
  position: absolute;
  right: 4px;
  top: 50%;
  transform: translateY(-50%);
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background: none;
  color: var(--ink-soft);
  font-size: 16px;  /* ✕ icon glyph, not reading text */
  line-height: 1;
  cursor: pointer;
  border-radius: 50%;
}
.search-clear:hover, .search-clear:active { background: var(--line); }

.emoji-picker { display: flex; flex-wrap: wrap; gap: 8px; }
.emoji-option {
  width: 46px; height: 46px;
  font-size: 24px;  /* emoji picker tile, not reading text */
  border-radius: 12px;
  border: 2px solid transparent;
  background: var(--bg);
  cursor: pointer;
}
.emoji-option.selected { border-color: var(--accent); }

/* Settings > Theme colour — one circle per ACCENT_COLORS entry (app.js),
   filled with that colour's own light-mode accent regardless of the
   CURRENTLY active theme, so every option stays recognisable as itself
   even while looking at the app already skinned in a different one. The
   selected ring is --ink, not --accent, for the same reason: --accent IS
   the thing being chosen, so it can't also be what marks the choice. */
.color-picker { display: flex; flex-wrap: wrap; gap: 10px; }
.color-option {
  width: 46px; height: 46px;
  border-radius: 50%;
  border: 3px solid transparent;
  padding: 0;
  cursor: pointer;
}
.color-option.selected { border-color: var(--ink); }

/* --- Sync code display ---------------------------------------------------
   Monospace and letter-spaced so a hand-typed code is easy to compare
   character by character against another screen — the same reasoning as
   the code's own dash-grouping (sync-plan.md §1.2). */
/* syncTurnOnFromNudge() (app.js) scrolls this card into view with
   block: 'start' — without this, that lands the card flush against the
   viewport top, right behind the sticky .topbar above it, hiding the code
   and Copy code rather than revealing them. */
#sync-card { scroll-margin-top: calc(max(16px, env(safe-area-inset-top)) + 52px + 16px); }

.sync-code-row { align-items: center; }
.sync-code {
  flex: 1;
  font-family: ui-monospace, "SF Mono", Menlo, monospace;
  font-size: calc(17px * var(--app-font-scale, 1));
  font-weight: 700;
  letter-spacing: 0.04em;
  padding: 0 4px;
  overflow-wrap: break-word;
}

/* Copy code: .btn-quiet's plain scale-down :active is too subtle on a
   transparent-background button to register as "I actually touched this" —
   a background fill on press is what makes contact register at all, and
   the same fill stays through the "Copied!" confirmation right after, so
   feedback and result read as one continuous thing rather than a flash
   followed by an unrelated label change. */
#sync-copy-code {
  transition: background-color 100ms ease, color 100ms ease;
}
#sync-copy-code:active,
#sync-copy-code.copied {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-ink);
}

/* --- Mode picker -------------------------------------------------------- */

/* flex-wrap: wrap is the load-bearing part here, added once real-device
   testing at a large --app-font-scale found the four-option pickers
   (#writing-mode-picker's Dynamic/Trace/Guided/Free, .writing-difficulty's
   copy of the same choices) pushing their last segment past the edge of the
   screen — flex items don't shrink below their own text's width by default,
   so a row that no longer fits overflows instead of compressing. Wrapping
   to a second line fixes it for every .segmented instance at once, kana/
   katakana/kanji/vocab/read tabs and two-option Reading/Writing pickers
   included; those already have room to spare at any scale, so this is a
   no-op for them and only engages where a picker actually runs out of
   space. Same technique .onboarding-scale .segmented below already used
   for its own four wide options, generalized here instead of repeated
   per picker. */
.segmented {
  display: flex; flex-wrap: wrap; gap: 4px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 14px;
  padding: 4px;
}
.segment {
  flex: 1; min-height: 44px;
  border: none; border-radius: 10px;
  background: transparent; color: var(--ink-soft);
  font: inherit; font-weight: 600; cursor: pointer;
}
.segment.active { background: var(--accent); color: var(--accent-ink); }
.segment small { font-weight: 500; opacity: 0.7; font-size: calc(11px * var(--app-font-scale, 1)); }
/* How many are due in a mode you're not currently viewing — e.g. "Yomi"
   while looking at Definition. Only appears when nonzero (see
   renderModePicker() in app.js), so a quiet mode stays quiet. */
.segment-badge {
  margin-left: 4px;
  font-size: calc(11px * var(--app-font-scale, 1)); font-weight: 800;
  padding: 1px 6px; border-radius: 99px;
  background: var(--accent); color: var(--accent-ink);
}
.segment.active .segment-badge { background: var(--accent-ink); color: var(--accent); }

/* --- Course cards ------------------------------------------------------- */

.course-card { display: flex; flex-direction: column; gap: 12px; }
.course-head { display: flex; justify-content: space-between; align-items: flex-start; }
.course-native { color: var(--ink-soft); font-size: calc(14px * var(--app-font-scale, 1)); }
/* Vocab only: which GCSE-style group and frequency level this unit is —
   e.g. "Common words 2 · Local area, holiday and travel" — so a unit and
   its Higher-tier sibling don't look identical on this card. Not ".tier-*":
   that name already belongs to Leitner mastery boxes elsewhere on screen. */
.course-group {
  display: inline-block;
  margin: 2px 0 4px;
  padding: 2px 9px;
  border-radius: 99px;
  background: var(--bg);
  border: 1px solid var(--line);
  color: var(--ink-soft);
  font-size: calc(12px * var(--app-font-scale, 1));
  font-weight: 600;
}
.course-count { font-size: calc(24px * var(--app-font-scale, 1)); font-weight: 700; font-variant-numeric: tabular-nums; }
.course-count span { font-size: calc(15px * var(--app-font-scale, 1)); font-weight: 500; color: var(--ink-soft); }

.progress {
  height: 8px; border-radius: 99px;
  background: var(--line);
  overflow: hidden;
}
.progress-fill { height: 100%; background: var(--accent); border-radius: 99px; transition: width 0.25s ease; }
/* Thicker and more saturated than the quiet exit "✕" beside it in the quiz/
   writing header (see .btn-icon.btn-quiet below) — progress through the
   session is the thing worth a learner's attention there, not the way out
   of it. Scoped to .topbar specifically so the small script-card progress
   bars on the home screen (script-meta .progress) stay their own size. */
.topbar .progress { height: 14px; min-width: 0; }

.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.actions .btn { padding: 0 10px; }
.actions .btn b { font-weight: 800; }
/* The waiting-list link sits under the 2-up Review/Learn pair as a full
   row of its own — it is a third, lesser action about the pair above it,
   not a fourth quarter of a 2x2 grid (see renderQuickActions in app.js). */
#quick-waiting { grid-column: 1 / -1; }

/* Review / Test unlearned / Learn on the course card: stacked full-width
   rows rather than actions's 2-up grid, each with room for a one-line
   subtitle underneath explaining what it does (see renderCourse() and
   choosePrimaryAction() in app.js) — the discoverability review found the
   old layout left that distinction to button colour alone. */
.study-actions { display: flex; flex-direction: column; gap: 10px; }
.study-action { display: flex; flex-direction: column; gap: 2px; }
.study-action .btn { width: 100%; }
.action-subtitle { margin: 0 2px; font-size: calc(12px * var(--app-font-scale, 1)); color: var(--ink-soft); }

.badges { display: flex; flex-wrap: wrap; gap: 6px; }
.badge {
  font-size: calc(13px * var(--app-font-scale, 1)); font-weight: 600;
  padding: 4px 10px; border-radius: 99px;
  background: var(--bg); border: 1px solid var(--line); color: var(--ink-soft);
}
.badge-due { color: var(--accent); border-color: var(--accent); }
.badge-new { color: var(--ok); border-color: var(--ok); }

/* --- Lesson & quiz ------------------------------------------------------ */

.teach-card, .quiz-card {
  background: var(--surface);
  border: 2px solid var(--line);
  border-radius: 24px;
  padding: 28px 16px;
  display: flex; flex-direction: column; align-items: center; gap: 14px;
  min-height: 44vh;
  justify-content: center;
  transition: border-color 0.15s ease, background 0.15s ease;
}
/* Sized to the glyph it holds plus its own padding, rather than to a share
   of the viewport. 32vh used to be right when this card also had to hold a
   30px ✓/✗ under the character; with the card's own colour carrying that
   verdict instead, the same 32vh was simply empty space — and on a short
   phone (iPhone 13 mini) it was enough of it to push the answers down
   behind the Next bar once the info panel appeared. The floor keeps a
   narrow screen's clamped-down glyph from looking cramped in it. */
.quiz-card { min-height: 168px; padding: 20px 16px; }
.quiz-card.is-correct { border-color: var(--ok); background: var(--ok-bg); }
.quiz-card.is-wrong { border-color: var(--bad); background: var(--bad-bg); }

/* Text only now — "Try once more", or the romaji answer after a second
   miss. Right and wrong are said by the card's colour, so there is no ✓/✗
   here to need 30px, and an empty line takes no room at all (display: none
   removes the flex gap above it too, which min-height: 0 would not). */
.feedback { font-size: calc(24px * var(--app-font-scale, 1)); font-weight: 700; }
.feedback:empty { display: none; }
.feedback.ok { color: var(--ok); }
.feedback.bad { color: var(--bad); }
/* A vocab two-part question announcing its own second part (Meaning's
   reading follow-up, Recall's spelling follow-up) — not a grading result,
   so it reads as an instruction rather than a verdict: quieter and smaller
   than .ok/.bad, matching .hint elsewhere rather than shouting in accent
   colour the way a right/wrong answer does. */
.feedback.hint { font-size: calc(17px * var(--app-font-scale, 1)); font-weight: 600; color: var(--ink-soft); }

/* --- Writing (Trace / Guided / Free) --------------------------------------- */
/* See writing-mode-plan.md. The guide is drawn at the character's TRUE
   position, never shifted to match where the learner started — placement
   within the quartered box is part of the orthography being taught, not
   just the grading, so the guide is allowed to visibly disagree with the
   learner's ink rather than chase it. */

.writing-prompt { text-align: center; }
.writing-kanji-info { display: flex; flex-direction: column; align-items: center; gap: 6px; margin-top: 6px; }
.writing-kanji-info .kanji-word { justify-content: center; }
/* min-height/line-height reserve one line's worth of space whether or not
   there's text: Free mode sets this to '' until the first stroke lands (see
   updateWritingStrokeCounter() in app.js), and without a reserved height the
   board below jumps down the moment the count text appears — reported as
   messing up the second stroke. */
.writing-stroke-counter { text-align: center; margin: 4px 0; min-height: 1.2em; line-height: 1.2; }
/* Sits in the same slot as .writing-prompt/.writing-stroke-counter above —
   exactly one of the two is ever visible, see index.html — so a finished
   character replaces the prompt rather than adding new space below it. */
.writing-result-message { text-align: center; font-size: calc(18px * var(--app-font-scale, 1)); font-weight: 700; margin: 4px 0; }

/* Deliberately last on the screen and modest in size — see the comment on
   .writing-difficulty in index.html. "Difficulty level" is a label, not a
   heading, so it stays visually quiet next to the segmented control. */
.writing-difficulty { margin: 16px auto 0; max-width: min(78vw, 340px); }
.writing-difficulty-label { text-align: center; margin: 0 0 4px; }

.writing-board {
  position: relative;
  width: min(66vw, 300px);
  aspect-ratio: 1 / 1;
  margin: 0 auto;
  background: var(--surface);
  border: 2px solid var(--line);
  border-radius: 16px;
  overflow: hidden;
}
.writing-quadrants,
.writing-guide,
.writing-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
.writing-quadrants line {
  stroke: var(--line);
  stroke-width: 0.6;
  stroke-dasharray: 3 3;
}
.writing-guide svg { width: 100%; height: 100%; }
/* Full opacity/border/background from .stroke-svg would hide the quadrant
   lines behind an opaque box — the writing-board div supplies those
   instead, so the guide layer itself has to stay transparent. */
.writing-guide .stroke-svg { background: transparent; border: none; border-radius: 0; }

/* Baseline visibility per mode — see renderGuide() in writing.js. Guided and
   Free both start every stroke invisible; only Trace shows the full model
   up front. The per-stroke status rules below come later in the cascade so
   they win at equal specificity regardless of which mode class is set. */
.writing-guide.mode-trace .stroke-path { opacity: 0.3; }
.writing-guide.mode-trace .stroke-number { opacity: 0.55; }
.writing-guide.mode-guided .stroke-path,
.writing-guide.mode-free .stroke-path { opacity: 0; }
.writing-guide.mode-guided .stroke-number,
.writing-guide.mode-free .stroke-number { opacity: 0; }

/* Hold-to-peek (Guided/Free only — see #writing-hints in index.html and
   setGuidePeekFull()/setStrokePeek() in writing.js). Same specificity as
   the mode rules above, so source order alone decides: these come AFTER
   the mode rules, so peeking wins over "not yet shown" — but BEFORE the
   graded-status rules below, so a stroke already marked right/wrong never
   dims back down to a plain peek while another stroke is being peeked at. */
.writing-guide.peek-full .stroke-path { opacity: 0.3; }
.writing-guide .stroke-path.stroke-path-peek { opacity: 0.3; }

/* A stroke turns solid and green the moment it's accepted (Trace, Guided,
   and a correct stroke in Free mode's end-of-attempt review) — visible
   progress feedback, distinct from strokes still to come. Wrong/missing are
   Free mode's review only, from markGuideStrokeReview() in writing.js. */
.writing-guide .stroke-path.stroke-path-done { opacity: 0.9; stroke: var(--ok); }
.writing-guide .stroke-path.stroke-path-wrong { opacity: 0.9; stroke: var(--bad); }
.writing-guide .stroke-path.stroke-path-missing {
  opacity: 0.5;
  stroke: var(--ink-soft);
  stroke-dasharray: 4 3;
}

.writing-canvas { touch-action: none; cursor: crosshair; }

.writing-feedback { min-height: 1.4em; text-align: center; }
.writing-feedback.is-bad { color: var(--bad); }

/* Left-stacked, not the shared full-width .row — see the comment on
   #writing-hints in index.html. #writing-result (Try again / Next) used to
   flow into this exact vertical slot the instant these hide, with Next
   landing on the right; a fast tap aimed at Next used to land on "Show full
   character" because .row's flex:1 stretched these across the full width,
   putting the second button in that same right-hand spot. Stacking them to
   the left instead, at their own natural (non-stretched) width, kept the
   right side clear of both buttons. #writing-result is now a pinned
   .bottom-bar (see below) rather than inline content, so this exact
   collision can no longer happen either way — left-stacking is kept anyway
   since it's still the more deliberate layout for a hold-to-peek control. */
.writing-hints {
  margin: 4px 0;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
}
.writing-hints .btn { font-size: calc(13px * var(--app-font-scale, 1)); padding: 8px 6px; min-height: 40px; width: auto; }
/* Once the viewport is wide enough to spare it (tablets, and phones in
   landscape), these no longer need to be squeezed as tightly as the
   narrow-portrait-phone case above assumes — reported as hard to tap on a
   tablet in landscape (kana-quest-feedback#8). */
@media (min-width: 640px) {
  .writing-hints .btn { font-size: calc(15px * var(--app-font-scale, 1)); padding: 10px 16px; min-height: 44px; }
}

.writing-self-grade-question { text-align: center; font-weight: 600; margin: 0; }

/* Ten answers (up to fifteen once Advanced is tapped), in as many columns as
   comfortably fit a reading — a narrow phone gets fewer, wider columns
   (bigger tap targets) instead of always cramming five across; a roomier
   screen gets more instead of each button stretching out with empty space.
   Flexbox wrap rather than CSS Grid's repeat(auto-fit, ...): the grid version
   of this needs the container's resolved width to pick a track count, and
   inside a .screen flex column that width isn't settled soon enough — the
   grid ends up sized for as many auto-fit tracks as fit ITS OWN min-content,
   which can exceed what .screen actually has and overflow the screen
   sideways. Flex-wrap has no such chicken-and-egg step: it just lays items
   into the real available width one row at a time. Capped at a max-width so
   it never sprawls edge-to-edge inside the wider #app shell.

   Each button is sized to its own text (flex-basis: auto, no shrink) rather
   than forced into equal-width columns — a short reading like ぬ and a long
   bracketed one like か（う） used to be squeezed into the same column width,
   which meant the long one wrapped mid-word (すいよ / うじつ). Now a row
   simply holds fewer, wider buttons once one of them needs more room; only
   flex-grow still divides up any space a row has left over, so a row of
   short answers still fills the width evenly. Row height is content-sized,
   not fixed, so a button that's alone on its own final row can still wrap
   if it's genuinely wider than the whole container — safe to let rows grow
   since a button's text never changes after it's rendered, only its colour
   once the round resolves. */
.choice-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  max-width: 480px;
  margin: 0 auto;
}
.choice {
  /* iOS Safari can inflate text inside a narrow flex item independently of
     the rest of the page ("font boosting"/text autosizing) even with
     text-size-adjust: 100% set on body — the override doesn't reliably
     reach every nested flex context. Repeating the override directly on the
     element most likely to be inflated is the standard, low-cost defence. */
  -webkit-text-size-adjust: 100%;
  /* grow: divide up leftover row space; shrink: 0 so a button is never
     compressed below its own text — that's what used to force a wrap
     mid-word. basis: auto sizes it to its content (text + padding) first. */
  flex: 1 0 auto;
  min-width: 64px;
  /* A generous ceiling, not a tight fit — no real reading or kana word in
     this app comes anywhere near it, so this never actually engages;
     it just stops one pathologically long string from stretching a button
     across the whole row. white-space: nowrap below means text past this
     width would overflow the box rather than wrap — an acceptable trade for
     data that in practice never gets close. */
  max-width: 260px;
  min-height: 56px;
  padding: 6px 14px;
  border-radius: 14px;
  border: 2px solid var(--line);
  background: var(--surface);
  color: var(--ink);
  font-family: inherit;
  font-size: calc(18px * var(--app-font-scale, 1));
  font-weight: 700;
  line-height: 1.2;
  cursor: pointer;
  white-space: nowrap;
  transition: background 0.12s ease, border-color 0.12s ease;
}
.choice:active { transform: scale(0.96); }
.choice.is-right { border-color: var(--ok); background: var(--ok-bg); color: var(--ok); }
.choice.is-wrong { border-color: var(--bad); background: var(--bad-bg); color: var(--bad); }
/* Kanji reading quiz, once the round is over: which correct reading's
   example word is currently shown below. */
.choice.is-right.is-active { box-shadow: 0 0 0 3px var(--accent) inset; }
/* A distractor nobody ever tapped, now locked once the round is over —
   dimmed so it doesn't compete with the readings that are still clickable. */
.choice:disabled:not(.is-right):not(.is-wrong) { opacity: 0.35; }

/* Definition mode: four English meanings in two rows of two, always exactly
   two — unlike .choice-grid's reading buttons, this doesn't need to adapt to
   screen width, so it stays plain CSS Grid with a literal (not auto-fit)
   column count, which sidesteps the flex-container sizing issue described
   above entirely. They need far more room than romaji, so the cells are tall
   and the text wraps. */
.choice-grid-text {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  grid-auto-rows: minmax(88px, auto);
}
.choice-grid-text .choice {
  flex: initial;
  max-width: none;
  /* These buttons ARE the English definitions being chosen between —
     scales with --app-font-scale (see the comment above `html` near the
     top of this file). Unlike the plain romaji .choice above, max-width is
     already `none` here and the cell wraps (white-space: normal further
     down), so a larger scale grows the row instead of overflowing it. */
  font-size: calc(16px * var(--app-font-scale, 1));
  font-weight: 600;
  line-height: 1.3;
  padding: 10px 12px;
  white-space: normal;
  overflow-wrap: anywhere;
}

/* A vocab Meaning label carries every sense the word has (vocab-plan.md
   §5.6) — "why, for what reason / how, in what way" is four wrapped lines in
   half a phone's width. One full-width column instead, and the row minimum
   drops accordingly since these buttons are now sized by their text rather
   than padded up to a square. Applied per question by the length of the
   longest option, so short-gloss units keep the two-column grid. */
.choice-grid-text.choice-grid-wide {
  grid-template-columns: 1fr;
  grid-auto-rows: minmax(56px, auto);
}
.choice-grid-text.choice-grid-wide .choice {
  text-align: left;
  overflow-wrap: normal;
}

@media (max-width: 360px) {
  .choice-grid-text { grid-auto-rows: minmax(80px, auto); }
  .choice-grid-text .choice { font-size: calc(15px * var(--app-font-scale, 1)); }
  .choice-grid-text.choice-grid-wide { grid-auto-rows: minmax(52px, auto); }
}

/* --- Script picker (home) ------------------------------------------------ */

.script-grid { display: flex; flex-direction: column; gap: 12px; }
.script-card {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: 14px;
  padding: 16px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}
.script-card:active { transform: scale(0.99); }
.script-sample {
  font-size: calc(40px * var(--app-font-scale, 1));
  width: 56px;
  text-align: center;
  color: var(--accent);
}
.script-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.script-name { font-size: calc(19px * var(--app-font-scale, 1)); font-weight: 700; }
.script-native {
  font-family: "Hiragino Sans", "Noto Sans JP", sans-serif;
  font-size: calc(14px * var(--app-font-scale, 1));
  color: var(--ink-soft);
}
.script-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; width: 92px; }
.script-count { font-size: calc(13px * var(--app-font-scale, 1)); color: var(--ink-soft); font-variant-numeric: tabular-nums; }
/* Per-mode split of the due count above, e.g. "Yomi 5 · Writing 2" — only
   shown when the backlog actually spans more than one mode (see
   renderHome() in app.js), so a single-mode tile stays exactly as quiet as
   before. */
.script-breakdown { font-size: calc(11px * var(--app-font-scale, 1)); color: var(--ink-soft); text-align: right; }
.script-meta .progress { width: 100%; display: block; }
.script-meta .progress-fill { display: block; }

/* --- Grade picker (kanji only) ------------------------------------------- */

/* Two short horizontally-scrolling rows — the unit groups, then the units of
   whichever group is open — instead of one grid that wrapped to eight rows
   once vocabulary arrived with thirty-odd units and pushed the screen's
   actual buttons out of sight. Fixed height regardless of how many units a
   script grows to. See renderGradePicker() in app.js. */
.unit-group-row, .unit-row {
  display: flex;
  gap: 6px;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  /* Room for the tiles' own focus ring, cancelled again by the negative
     margin so the rows still sit on the screen's normal gutter. */
  padding: 2px;
  margin: -2px;
}
.unit-group-row::-webkit-scrollbar, .unit-row::-webkit-scrollbar { display: none; }

.unit-group {
  flex: 0 0 auto;
  display: flex; align-items: center; gap: 3px;
  min-height: 38px;
  padding: 0 14px;
  border-radius: 99px;
  border: 1px solid var(--line);
  background: var(--surface);
  color: var(--ink-soft);
  font: inherit; font-size: calc(14px * var(--app-font-scale, 1)); font-weight: 600;
  white-space: nowrap;
  cursor: pointer;
}
.unit-group.active { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
/* Reviews are waiting somewhere in this group — the only way a due unit
   stays findable now that just one group's units are on screen at a time. */
.unit-group-dot { font-size: 17px; line-height: 1; color: var(--accent); }  /* • status dot, not reading text */
.unit-group.active .unit-group-dot { color: var(--accent-ink); }

.unit-row .grade { flex: 0 0 auto; min-width: 54px; padding: 0 10px; }
.grade {
  position: relative;
  min-height: 46px;
  border-radius: 12px;
  border: 1px solid var(--line);
  background: var(--surface);
  color: var(--ink);
  font: inherit;
  font-size: calc(17px * var(--app-font-scale, 1));
  font-weight: 700;
  cursor: pointer;
}
.grade.active { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
/* A dot marks a grade with reviews waiting, so the one to open is visible
   without tapping through all six. */
.grade-dot {
  position: absolute;
  top: 3px; right: 7px;
  font-size: 16px;  /* • status dot, not reading text */
  line-height: 1;
  color: var(--accent);
}
.grade.active .grade-dot { color: var(--accent-ink); }

/* Kanji meanings + example word, shown as feedback after grading. */
.kanji-info {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 12px 16px;
  display: flex; flex-direction: column; gap: 8px;
}
/* A kanji's own English meaning — definition text, scales with
   --app-font-scale (see the comment above `html` near the top of this
   file). */
.kanji-meanings { font-size: calc(16px * var(--app-font-scale, 1)); font-weight: 600; color: var(--ink); }

/* Readings/meanings on the left, "Full details" tucked into the top right
   of the same line. align-items: flex-start keeps the button at the top of
   a meanings list that wraps to two lines rather than floating to its
   middle. */
.kanji-info-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
}
/* Quiet and small: an offer, not a step. Anything wider or louder would
   read as the thing to press next, which is Next's job. flex: none stops it
   being squeezed by a long list of meanings beside it. */
.quiz-info-more {
  flex: none;
  min-height: 32px;
  padding: 0 10px;
  font-size: calc(13px * var(--app-font-scale, 1));
}

/* Reused on the lesson (teach) card, so it needs its own text sizing rather
   than the feedback-panel sizing above. */
.teach-card .kanji-meanings { font-size: calc(16px * var(--app-font-scale, 1)); text-align: center; }
.teach-card .kanji-word { justify-content: center; }
/* The lesson card is a centred column, so its word slot centres too — the
   tray inside it included. */
.teach-card .word-line { justify-content: center; }
.teach-card .word-tray { justify-content: center; }

/* Tappable readings on the lesson card — tapping one shows the example word
   below, same idea as tapping a reading after a Yomi question resolves. */
/* --- Component breakdown and mnemonic ----------------------------------
   Shown on the character detail screen, the lesson card, and behind the
   "Hint" button in the Yomi/Definition and Writing quizzes. Everything here
   is deliberately quieter than the reading chips it sits near: a breakdown
   is supporting evidence for the character above it, never the thing being
   answered. */

.components-wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 10px 12px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--bg);
}

/* Same wrap-and-centre behaviour as .reading-chips — 2-4 tiles sit on one
   row on a phone, and the rare five-part kanji wraps rather than shrinking
   its glyphs below reading size. */
.component-row { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }

.component-tile {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  min-width: 56px;
  padding: 6px 10px;
  border: 1px solid transparent;
  border-radius: 12px;
  background: var(--surface);
  font-family: inherit;
  text-align: center;
}
.component-glyph { font-size: calc(28px * var(--app-font-scale, 1)); line-height: 1.1; color: var(--ink); }
/* Wraps rather than truncating: "rice field" and "bending person" are real
   keywords, and a clipped one teaches the wrong word. */
.component-meaning {
  font-size: calc(12px * var(--app-font-scale, 1));
  line-height: 1.25;
  color: var(--ink-soft);
  max-width: 84px;
}
/* Only components this app teaches as kanji in their own right are tappable
   (see paintComponents in app.js) — the border is the only signal that one
   of a row's tiles goes somewhere and another doesn't. */
.component-tile-link { border-color: var(--line); color: inherit; cursor: pointer; }
.component-tile-link .component-glyph { color: var(--accent); }
.component-tile-link:active { transform: scale(0.96); }

.mnemonic-text {
  margin: 0;
  font-size: calc(14px * var(--app-font-scale, 1));
  line-height: 1.45;
  color: var(--ink);
  text-align: center;
  max-width: 34em;
  /* It is a <button>, but it has to read as the sentence it is — the tap
     target is the whole hint, not a control sitting next to one. Everything
     here is undoing the browser's button chrome; the only thing that says
     "this does something" is the press state below, plus the explicit
     Add/Edit button underneath it. */
  display: block;
  width: 100%;
  padding: 4px 6px;
  border: 0;
  background: none;
  font-family: inherit;
  font-weight: inherit;
  cursor: pointer;
  border-radius: 8px;
}
/* The whole point of making the sentence tappable: the moment you decide you
   don't like a hint, changing it is one tap on the thing you are objecting
   to, not a hunt for a control. */
.mnemonic-text:active { background: var(--surface); }
.mnemonic-text:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* A hint the learner wrote themselves, rather than the built-in one. Marked
   quietly — an accent bar down the side rather than a colour change on the
   text, which would fight the reading. The point is only that their own
   words are never mistaken for the app's. */
.mnemonic-text.is-own {
  border-left: 3px solid var(--accent);
  padding-left: 10px;
  text-align: left;
}

/* "Add your own hint" / "Edit this hint". Deliberately the quietest thing in
   the panel: it is an offer that should be findable on every kanji and
   pressing on none of them. */
.mnemonic-edit-btn {
  min-height: 34px;
  padding: 4px 12px;
  font-size: calc(13px * var(--app-font-scale, 1));
  border-color: transparent;
  text-decoration: underline;
}

.mnemonic-editor { width: 100%; display: flex; flex-direction: column; gap: 8px; }
.mnemonic-input {
  width: 100%;
  box-sizing: border-box;
  padding: 10px 12px;
  border: 2px solid var(--line);
  border-radius: 12px;
  background: var(--surface);
  color: var(--ink);
  font-family: inherit;
  /* Same iOS force-zoom floor as .field input[type="text"] in styles.css's
     field rules above (see the comment there) — this one crossed the 16px
     trigger even at the default scale (15px * 1), so it force-zoomed on
     every focus, not just at a shrunk text size. */
  font-size: max(16px, calc(15px * var(--app-font-scale, 1)));
  line-height: 1.45;
  resize: vertical;
}
.mnemonic-input:focus { outline: none; border-color: var(--accent); }
/* Save and Cancel share the row evenly; the reset below is full width and
   quieter still, since undoing your own work should never be the easiest
   thing to hit by accident. */
.mnemonic-editor-actions { gap: 8px; }
.mnemonic-reset { font-size: calc(13px * var(--app-font-scale, 1)); min-height: 34px; }

/* The mid-question "Hint" button, in both quiz surfaces. Visibly its own
   thing rather than another .btn-quiet — a nudge toward remembering, not a
   route to the answer, which is what its neighbours ("Show answers",
   "Show full character") offer. Accent-tinted and outlined, but never
   filled: a filled button in either row would read as the step to take
   next, which is Next's job. */
.btn-hint {
  background: transparent;
  border-color: var(--accent);
  color: var(--accent);
  font-weight: 600;
}

.reading-chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
.reading-chip {
  min-height: 40px;
  padding: 6px 14px;
  border-radius: 99px;
  border: 2px solid var(--line);
  background: var(--surface);
  color: var(--accent);
  font-family: inherit;
  font-size: calc(18px * var(--app-font-scale, 1));
  font-weight: 700;
  cursor: pointer;
}
.reading-chip:active { transform: scale(0.96); }
.reading-chip.is-active { border-color: var(--accent); background: var(--accent); color: var(--accent-ink); }
/* Marks a reading that vocab exposure (vocab-plan.md §5.3) has promoted to
   "hidden by default in words" — a small dot rather than a colour change, so
   it reads as a footnote and never competes with is-active above. */
.reading-chip.is-exposed { position: relative; }
.reading-chip.is-exposed::after {
  content: '';
  position: absolute;
  top: 4px;
  right: 6px;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--ok);
}

/* Uncommon yomi (kanji-expansion-plan.md's "uncommon yomi" section): greyed
   out so it reads as "not tested unless you ask for it" — on the kanji
   detail screen's "Other readings" section and the quiz's post-round
   "Advanced" panel alike. Once a learner opts a reading into study, this
   modifier is simply omitted so the chip renders identical to a common one
   — "turns theme-colored" is the absence of this rule, not a separate one. */
.reading-chip.is-uncommon { color: var(--ink-soft); border-color: var(--line); }

/* Small text badge spelling out common/uncommon explicitly, alongside the
   colour, in the quiz's post-round panel where both tiers sit in one list —
   the kanji detail screen already says "uncommon" via its own section
   heading, so it only ever appears there through the chip above. */
.reading-chip-badge {
  display: inline-block;
  margin-left: 6px;
  padding: 1px 6px;
  border-radius: 99px;
  background: var(--line);
  color: var(--ink-soft);
  font-size: calc(11px * var(--app-font-scale, 1));
  font-weight: 700;
  text-transform: uppercase;
  vertical-align: middle;
}

.reading-chip-wrap { display: inline-flex; align-items: center; gap: 4px; }

/* The +/- yomi-study toggle beside an uncommon reading chip — round and
   small so it reads as a secondary control, not a second button of equal
   weight to the chip itself. */
.reading-chip-toggle {
  width: 28px;
  height: 28px;
  min-height: 0;
  padding: 0;
  border-radius: 50%;
  border: 2px solid var(--line);
  background: var(--surface);
  color: var(--ink-soft);
  font-family: inherit;
  font-size: 17px;  /* +/− icon glyph in a fixed circle, not reading text */
  font-weight: 700;
  line-height: 1;
  cursor: pointer;
}
.reading-chip-toggle:active { transform: scale(0.92); }
.reading-chip-toggle.is-studied { border-color: var(--accent); color: var(--accent); }

.uncommon-readings-wrap { margin-top: 4px; text-align: center; }
.uncommon-readings-label { margin-bottom: 6px; }

.all-readings-panel { margin-top: 8px; }

.kanji-word { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.kanji-word .word-kanji {
  font-family: "Hiragino Sans", "Noto Sans JP", sans-serif;
  font-size: calc(22px * var(--app-font-scale, 1)); font-weight: 600;
}
.kanji-word .word-kana { color: var(--ink-soft); font-size: calc(15px * var(--app-font-scale, 1)); }
.kanji-word .word-en { color: var(--ink-soft); font-size: calc(14px * var(--app-font-scale, 1)); }

/* A drillable word (buildWordRow() in app.js): the word itself opens a tray
   holding its own kanji as chips and, where the word is taught here too, a
   link to its full detail screen — while the "Add" badge beside it stays a
   separate one-tap control that never needs the tray opened. Used by every
   non-testing surface that shows a word; the quiz and writing panels keep
   the plain renderWord() output and none of this. */
.word-row { display: flex; flex-direction: column; gap: 6px; }
.word-line { display: flex; align-items: baseline; gap: 8px; }
.word-main { flex: 1; min-width: 0; }
button.word-main {
  padding: 8px 10px;
  margin: -8px -10px;
  border: none;
  border-radius: 12px;
  background: none;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
button.word-main:active { background: var(--line); }
/* A caret that turns as the tray opens — the only always-visible hint that
   the row does anything at all, since the word itself has to keep looking
   like a word. */
button.word-main::after {
  content: '›';
  margin-left: 6px;
  color: var(--ink-soft);
  font-weight: 700;
  display: inline-block;
  transition: transform 0.15s ease;
}
.word-row.is-open button.word-main::after { transform: rotate(90deg); }

.word-add-badge {
  padding: 3px 10px;
  border-radius: 99px;
  font-size: calc(13px * var(--app-font-scale, 1));
  font-weight: 600;
  white-space: nowrap;
  border: 2px solid transparent;
}
button.word-add-badge {
  border-color: var(--accent);
  color: var(--accent);
  background: none;
  font-family: inherit;
  cursor: pointer;
}
button.word-add-badge:active { background: var(--accent); color: var(--accent-ink); }
.word-row.is-added .word-add-badge { background: var(--ok-bg); color: var(--ok); }

/* Spoken/written register badges (buildRegisterBadges() in app.js) — small,
   quiet icons, not another add/studying-style pill, since they're purely
   informational. Tapping one opens .word-reg-popover (openRegisterPopoverFor
   in app.js): a floating explanation, not inline text, so opening it never
   reflows the row the badge itself sits in — a reflow would carry the badge
   out from under a second tap meant to close it. */
.word-reg-badges { display: flex; align-items: center; gap: 1px; flex-shrink: 0; }
.word-reg-badge {
  border: none;
  background: none;
  padding: 4px;
  font-size: 14px;  /* icon badge, see comment above */
  line-height: 1;
  cursor: pointer;
  opacity: 0.6;
  border-radius: 8px;
}
.word-reg-badge:active { opacity: 1; background: var(--line); }

/* Positioned in JS (top/left, plus a tail-top/tail-bottom class) relative to
   the badge that opened it — see openRegisterPopoverFor(). Dark, inverted
   from the page (var(--ink) background / var(--bg) text) like a native
   tooltip, and deliberately NOT theme-accent-colored, since it's neutral
   informational chrome rather than an interactive control. */
.word-reg-popover {
  position: fixed;
  z-index: 1000;
  max-width: 220px;
  padding: 8px 12px;
  border-radius: 10px;
  background: var(--ink);
  color: var(--bg);
  font-size: calc(13px * var(--app-font-scale, 1));
  line-height: 1.4;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
}
.word-reg-popover-tail {
  position: absolute;
  width: 10px;
  height: 10px;
  background: inherit;
  transform: rotate(45deg);
}
.word-reg-popover.tail-top .word-reg-popover-tail { top: -5px; }
.word-reg-popover.tail-bottom .word-reg-popover-tail { bottom: -5px; }

.word-tray {
  display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
  padding: 10px;
  border-radius: 12px;
  background: var(--bg);
}
/* Smaller than a reading chip: these sit inside a row, not on a screen of
   their own, and a full-size chip per kanji would dwarf the word above. */
.reading-chip-sm { min-height: 34px; padding: 4px 12px; font-size: calc(17px * var(--app-font-scale, 1)); }
.word-more { padding: 0 12px; min-height: 34px; font-size: calc(14px * var(--app-font-scale, 1)); }
/* Its own line under the chips rather than squeezed alongside them — it is
   a sentence, and wrapping it into the gap beside a chip reads as clutter. */
.word-note { margin: 0; flex-basis: 100%; }

/* --- Summary ------------------------------------------------------------ */

.score { margin: 0; font-size: calc(20px * var(--app-font-scale, 1)); font-weight: 600; text-align: center; }

/* Milestone celebration (review-followups.md item 4) — the same restrained
   .card surface used throughout, not a new visual language: no confetti,
   animation, or colour beyond the accent already used for a milestone-worthy
   character stroke. Centered and a touch larger than plain body text is the
   entire distinction from an ordinary card. */
.milestone-card { text-align: center; border-color: var(--accent); }
.milestone-card p { margin: 0; font-size: calc(18px * var(--app-font-scale, 1)); font-weight: 600; }
.chip-grid { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
.chip {
  display: flex; flex-direction: column; align-items: center;
  min-width: 58px; padding: 8px 10px;
  border-radius: 14px; border: 1px solid var(--line); background: var(--surface);
}
.chip-ok { border-color: var(--ok); background: var(--ok-bg); }
.chip-bad { border-color: var(--bad); background: var(--bad-bg); }
.chip-kana {
  font-family: "Hiragino Sans", "Noto Sans JP", sans-serif;
  font-size: calc(26px * var(--app-font-scale, 1)); line-height: 1.1;
}
.chip-romaji { font-size: calc(12px * var(--app-font-scale, 1)); color: var(--ink-soft); }

/* --- Set overview --------------------------------------------------------- */

/* A clearly-bordered, accent-coloured button — the plain-text version of
   this ("Current set: ...") wasn't obviously tappable. */
.overview-button { border-color: var(--accent); color: var(--accent); font-weight: 700; }

/* One whole course (up to 200 characters for the biggest kanji grade) in a
   single scrolling grid, not one 5-character set at a time. Tiles are sized
   from --tap (the app's standard minimum touch target) rather than shrunk to
   force a specific column count onto one screen — readable and tappable
   matters more than fitting everything above the fold, and the screen
   scrolls for the rest. */
.overview-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(var(--tap), 1fr));
  gap: 8px;
}
.overview-tile {
  aspect-ratio: 1;
  border-radius: 14px;
  border: 2px solid var(--line);
  font-family: "Hiragino Sans", "Noto Sans JP", sans-serif;
  font-size: calc(clamp(20px, 6vw, 28px) * var(--app-font-scale, 1));
  cursor: pointer;
}
/* Vocab words, unlike a kana/kanji tile's single glyph, can run to four or
   five characters — left in horizontal type inside a roughly-square tile,
   that wrapped at arbitrary character boundaries (see kana-quest issue: a
   family-words unit rendering 女の子 as "女の" over "子"). Set top-to-bottom
   instead, the traditional direction for a single word or short label in
   Japanese, so a word is always one unbroken column no matter its length —
   no wrap to fix, and it reads as more distinctly Japanese than a cramped
   horizontal line would. text-orientation is left at its 'mixed' default
   rather than forced 'upright': that's what lets a chōon (ー) rotate to a
   vertical stroke the way real tategaki renders it, while kana and kanji
   stay upright either way. The fixed square gives way to auto height, so a
   longer word's tile grows taller instead of shrinking its text or wrapping. */
.overview-tile.overview-tile-vocab {
  aspect-ratio: auto;
  writing-mode: vertical-rl;
  white-space: nowrap;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: var(--tap);
  padding: 10px 4px;
}
.overview-tile:active { transform: scale(0.96); }
.overview-tile.tier-0 { background: var(--tier-0); color: var(--tier-0-ink); }
.overview-tile.tier-1 { background: var(--tier-1); color: var(--tier-1-ink); border-color: var(--tier-1); }
.overview-tile.tier-2 { background: var(--tier-2); color: var(--tier-2-ink); border-color: var(--tier-2); }
.overview-tile.tier-3 { background: var(--tier-3); color: var(--tier-3-ink); border-color: var(--tier-3); }
.overview-tile.tier-4 { background: var(--tier-4); color: var(--tier-4-ink); border-color: var(--tier-4); }
/* Enrolled but not yet taught — orthogonal to the tier-0..4 mastery ramp
   above (which is entirely about progress, not intent), so this is a border
   treatment rather than another fill colour, and can combine with tier-0
   only: any real progress moves a tile off tier-0 and this stops applying. */
.overview-tile.is-pending { border-color: var(--accent); border-style: dashed; }

/* The overview's pinned head: back/title row plus the mode picker under
   it, stuck together at the top of the viewport. Takes over the .topbar's
   own sticky treatment (same negative-margin escape from #app's padding,
   re-applied as padding) so the two rows travel as one block; the topbar
   inside is put back to normal flow. */
.overview-head {
  position: sticky;
  top: 0;
  z-index: 5;
  display: flex; flex-direction: column; gap: 12px;
  background: var(--bg);
  margin: calc(-1 * max(16px, env(safe-area-inset-top))) -16px 0;
  padding: max(16px, env(safe-area-inset-top)) 16px 8px;
}
.overview-head .topbar { position: static; margin: 0; padding: 0; }

/* "Mark as known" select mode (see the section of that name in app.js).
   A ticked tile keeps its mastery fill — that's still what the colour
   means — and gains an accent ring plus a corner check, so the two facts
   (how well known, and whether it's selected) never fight over the same
   channel. A tile that CAN'T be ticked (already well known) is left
   looking exactly as it does outside select mode — deliberately: dimming
   it read as "poorly known" in the very colour language the grid teaches,
   and had learners tapping the bright ones thinking those were the gaps.
   The tap explains itself on the hint line instead. */
.overview-tile {
  /* A long press enters select mode (bindLongPress in app.js) — keep iOS
     from popping its callout, or selecting the glyph as text, meanwhile. */
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
}
.overview-tile.is-selectable { position: relative; }
.overview-tile.is-selected {
  border-color: var(--accent);
  box-shadow: inset 0 0 0 2px var(--accent);
}
.overview-tile.is-selected::after {
  content: '✓';
  position: absolute;
  top: 1px; right: 5px;
  font-family: inherit;
  font-size: 13px; font-weight: 800; line-height: 1;  /* ✓ checkmark icon overlay, not reading text */
  color: var(--accent);
}
/* A vocab tile is set vertically (see .overview-tile-vocab above); pin the
   check to the physical top-right corner regardless. */
.overview-tile.overview-tile-vocab.is-selected::after { writing-mode: horizontal-tb; }
/* Room for the pinned action bar while it's showing (same reservation the
   summary screen makes — see #screen-summary), so the last row of tiles
   can scroll clear of it. Two stacked buttons at most. */
#screen-overview:has(.bottom-bar > :not([hidden])) { padding-bottom: 150px; }

.mastery-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; }
.mastery-swatch {
  display: inline-block;
  width: 14px; height: 14px;
  border-radius: 4px;
  border: 1px solid var(--line);
  margin-right: -4px;
}
.mastery-swatch.tier-0 { background: var(--tier-0); }
.mastery-swatch.tier-1 { background: var(--tier-1); border-color: var(--tier-1); }
.mastery-swatch.tier-2 { background: var(--tier-2); border-color: var(--tier-2); }
.mastery-swatch.tier-3 { background: var(--tier-3); border-color: var(--tier-3); }
.mastery-swatch.tier-4 { background: var(--tier-4); border-color: var(--tier-4); }
.mastery-swatch.is-pending { background: var(--tier-0); border-color: var(--accent); border-style: dashed; }

/* --- Character detail ------------------------------------------------------ */

/* The character detail screen's own headline glyph — scales with
   --app-font-scale, same reasoning as .glyph-xl above. */
.glyph-lg { font-size: calc(clamp(56px, 18vw, 88px) * var(--app-font-scale, 1)); }

.detail-head { display: flex; align-items: center; justify-content: center; gap: 20px; flex-wrap: wrap; }
.detail-stroke-wrap { display: flex; flex-direction: column; align-items: center; gap: 8px; }

#detail-stroke { width: clamp(140px, 40vw, 200px); }
#lesson-stroke { width: clamp(120px, 34vw, 170px); }
.stroke-svg {
  width: 100%; height: auto;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 12px;
}
.stroke-path {
  fill: none;
  stroke: var(--ink);
  stroke-width: 3;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.stroke-number { font-size: calc(8px * var(--app-font-scale, 1)); fill: var(--accent); }
.stroke-fallback-glyph {
  font-family: "Hiragino Sans", "Noto Sans JP", sans-serif;
  font-size: calc(60px * var(--app-font-scale, 1));
  fill: var(--ink);
}

.detail-unit { margin: 0; text-align: center; }

.mastery-label { margin: 0; text-align: center; font-weight: 700; }
.mastery-label.tier-0 { color: var(--ink-soft); }
.mastery-label.tier-1 { color: var(--tier-2); }
.mastery-label.tier-2 { color: var(--tier-2); }
.mastery-label.tier-3 { color: var(--tier-3); }
.mastery-label.tier-4 { color: var(--tier-4); }

/* Vocab detail's example sentences: the word in use, furigana over the whole
   sentence, translation underneath, and every word in it tappable. A card
   rather than a bare paragraph — these are the one block on this screen that
   is a quotation from somewhere else rather than a fact about the word. */
.detail-example {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 14px 16px;
}
.detail-example h3 { margin: 0 0 10px; font-size: calc(14px * var(--app-font-scale, 1)); color: var(--ink-soft); }
/* Three sentences in one card need dividing, or they read as one paragraph
   of Japanese with three translations somewhere below it. */
.example + .example { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line); }
.example-tag { margin: 0 0 6px; font-size: calc(12px * var(--app-font-scale, 1)); color: var(--ink-soft); font-style: italic; }

/* line-height has to carry the furigana sitting above the line as well as
   the line itself — at a normal 1.5 the ruby of one line collides with the
   descenders of the one above it as soon as the sentence wraps. */
.example-jp {
  margin: 0;
  font-family: "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic",
               "Noto Sans JP", "Noto Sans CJK JP", sans-serif;
  font-size: calc(21px * var(--app-font-scale, 1));
  line-height: 2.1;
  color: var(--ink);
  /* 禁則処理, the half of it the browser can do unaided — same settings as
     .reader-body. The other half (a 。 in its own inline-block box) is done
     by the .example-run grouping below, exactly as the reader does it. */
  line-break: strict;
  word-break: normal;
  overflow-wrap: normal;
}
.example-run { white-space: nowrap; }
.example-piece {
  display: inline-block;
  vertical-align: bottom;
  /* A word here is a <button>: strip everything a button brings with it, so
     a sentence reads as a sentence and not as a row of controls. What marks
     one as tappable is the underline below, and only on press does it take
     any colour of its own. */
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
  text-align: inherit;
}
.example-jp ruby { ruby-position: over; }
.example-jp rt {
  font-size: 0.5em;  /* ruby: em ratio of .example-jp, scales for free */
  font-weight: 400;
  color: var(--ink-soft);
  user-select: none;
}
/* Every word is tappable, so marking each one would underline the whole
   sentence and say nothing. The affordance is carried by the one word that
   IS marked (the word being studied, below) plus the tap itself. */
.example-word { cursor: pointer; }
.example-word.is-active {
  background: color-mix(in srgb, var(--accent) 18%, transparent);
  border-radius: 5px;
}
@supports not (background: color-mix(in srgb, red 50%, transparent)) {
  .example-word.is-active { background: var(--ok-bg); }
}

/* The word this sentence is an example OF. Underlined rather than
   background-filled: a highlight block behind text that already carries
   furigana above it reads as a second, competing box. */
.example-hit {
  color: var(--accent);
  border-bottom: 2px solid var(--accent);
}
.example-hit rt { color: var(--accent); }

.example-en { margin: 8px 0 0; font-size: calc(15px * var(--app-font-scale, 1)); color: var(--ink-soft); }

/* The tapped word, under the sentence it was tapped in — near enough that
   the two are obviously connected, unlike the reader's bottom sheet, which
   has a whole scrolling story to stay clear of. */
.example-word-panel {
  margin-top: 10px;
  padding: 10px 12px;
  border-radius: 12px;
  background: var(--bg);
  border: 1px solid var(--line);
}
.example-word-written {
  margin: 0;
  font-family: "Hiragino Sans", "Noto Sans JP", sans-serif;
  font-size: calc(24px * var(--app-font-scale, 1));
  line-height: 1.9;
  color: var(--ink);
}
.example-word-written rt { font-size: 0.45em; color: var(--ink-soft); }  /* ruby: em ratio of .example-word-written, scales for free */
.example-word-panel .hint { margin: 2px 0 0; }
.example-word-meaning { margin: 4px 0 0; font-size: calc(15px * var(--app-font-scale, 1)); font-weight: 600; color: var(--ink); }
.example-word-panel .reading-chips { margin-top: 8px; }
.example-word-panel .word-more { margin-top: 6px; }

/* --- Study history (character detail's "My study history", see
   openStudyHistory()/buildStudyHistory() in app.js) --------------------- */

.study-history-graph {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 8px 4px 0;
}
.study-history-svg { width: 100%; height: auto; display: block; }
.study-history-axis { stroke: var(--line); stroke-width: 1; }
.study-history-tick-label { font-size: calc(10px * var(--app-font-scale, 1)); fill: var(--ink-soft); }
.study-history-dot { stroke: var(--surface); stroke-width: 1.5; }
.study-history-dot-pass { fill: var(--ok); }
.study-history-dot-fail { fill: var(--bad); }
/* Deliberately --ink-soft, not --accent: the default accent (coral) reads
   as near-identical to --bad's red, which would make "added" and "failed"
   indistinguishable at a glance. */
.study-history-dot-added { fill: var(--ink-soft); }

.study-history-list { display: flex; flex-direction: column; gap: 2px; }
.study-history-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  padding: 10px 4px;
  border-bottom: 1px solid var(--line);
}
.study-history-row-date { color: var(--ink-soft); font-size: calc(14px * var(--app-font-scale, 1)); flex-shrink: 0; }
.study-history-row-right { display: flex; align-items: baseline; gap: 8px; text-align: right; }
.study-history-row-result { font-weight: 700; }
.study-history-row-pass { color: var(--ok); }
.study-history-row-fail { color: var(--bad); }
.study-history-row-added { color: var(--ink-soft); }
.study-history-row-mode { color: var(--ink-soft); font-size: calc(13px * var(--app-font-scale, 1)); }

/* --- Stories (stories-plan.md) ------------------------------------------
   Not a fifth course card — see §8.1 for why. Everything below is either
   the home screen's one wide Read card, the library, or the reader itself,
   which is deliberately spare: the text is the screen. */

.read-card {
  display: flex;
  align-items: center;
  gap: 14px;
  text-align: left;
  width: 100%;
  cursor: pointer;
  /* Buttons pick up the platform's own link-blue tint by default (nothing
     else on the page sets a text colour on a bare <button>) — every script
     card already overrides this the same way, via .script-card's own
     color: inherit, so the read card matches instead of standing out as a
     link. */
  color: inherit;
}
/* .script-sample (glyph size/colour/width) is reused as-is for 読, same
   treatment as あ/ア/学/語 above — only the class below still carries is the
   line-height, since .script-sample doesn't set one and this button's
   cross-axis centering (align-items) depends on it not inheriting the
   glyph's own tall line box. */
.read-card-icon { line-height: 1; }
.read-card-text { display: flex; flex-direction: column; gap: 2px; }
.read-card-title { font-weight: 700; font-size: calc(19px * var(--app-font-scale, 1)); }

.story-level-strip {
  display: flex;
  gap: 6px;
  overflow-x: auto;
  padding-bottom: 2px;
}
.story-level-strip .segment { flex: 0 0 auto; min-width: 48px; padding: 0 14px; }
.story-level-name { text-align: center; margin: -4px 0 4px; }

.story-continue { text-align: left; cursor: pointer; }
.story-card-title { font-weight: 700; font-size: calc(17px * var(--app-font-scale, 1)); }

/* The library shelf (§8.7/§8.8): a two-up grid of poster tiles rather than a
   list of rows, so the shelf reads as a shelf of book covers instead of a
   wall of small thumbnails and text — see buildStoryTile() in app.js. */
.story-list {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 14px;
  margin-top: 4px;
}
/* The empty-shelf and filter-empty messages are the list's only child — let
   either span both columns instead of sitting in one narrow cell. */
.story-list > p { grid-column: 1 / -1; }

.story-tile { cursor: pointer; }
.story-tile:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: var(--radius); }

.story-cover {
  position: relative;
  width: 100%;
  aspect-ratio: 3 / 4;     /* reserved before any image arrives */
  border-radius: var(--radius);
  overflow: hidden;
  display: grid;
  place-items: center;
  background: var(--line);
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.14);
}
.story-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* The generated tile that carries a story with no cover yet — painted from
   the id, so it costs no bytes and no request, and the shelf looks finished
   from the first commit instead of waiting on 42 illustrations. */
.story-cover-placeholder {
  width: 100%; height: 100%;
  display: grid; place-items: center;
  font-size: calc(30px * var(--app-font-scale, 1));
  font-weight: 700;
  color: #fff;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
}

/* Title overlaid on the cover itself — Japanese first and larger, English
   underneath and smaller — over a scrim rather than a solid band, so the
   art underneath still reads as art. pointer-events: none so a tap here
   still reaches the tile's own click-to-open handler beneath it. */
.story-cover-title {
  position: absolute; left: 0; right: 0; bottom: 0;
  display: flex; flex-direction: column; gap: 1px;
  padding: 22px 10px 10px;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.78), rgba(0, 0, 0, 0) 75%);
  pointer-events: none;
}
.story-cover-title-ja {
  color: #fff; font-weight: 800;
  font-size: calc(14px * var(--app-font-scale, 1));
  line-height: 1.25;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
.story-cover-title-en {
  color: rgba(255, 255, 255, 0.85);
  font-size: calc(11px * var(--app-font-scale, 1));
  line-height: 1.3;
}

/* The read/reading mark (§8.7) — small enough not to compete with the title
   overlay it shares the cover with. */
.story-cover-status {
  position: absolute; top: 6px; left: 6px; z-index: 1;
  width: 22px; height: 22px;
  display: grid; place-items: center;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.42);
  color: #fff;
  font-size: 13px; font-weight: 700;
}
.story-cover-progress {
  position: absolute; left: 0; right: 0; bottom: 0; z-index: 1;
  height: 4px;
  background: rgba(0, 0, 0, 0.3);
}
.story-cover-progress-fill { display: block; height: 100%; background: var(--accent); }

/* The "page turn" flip (§8.8) — a small corner button, always above the
   popup it opens so a second tap on it, the same spot every time, closes
   the description again instead of needing to be found somewhere new. */
.story-cover-flip {
  position: absolute; top: 6px; right: 6px; z-index: 2;
  width: 30px; height: 30px; padding: 0;
  display: grid; place-items: center;
  border: 0; border-radius: 50%;
  background: rgba(0, 0, 0, 0.4);
  color: #fff;
  cursor: pointer;
}
.story-cover-flip[aria-expanded="true"] { background: var(--accent); }

/* The description popup itself: inset within the cover rather than filling
   it, so a frame of the cover — and the flip button above, which sits
   outside this inset — stays visible and tappable (opening the story) even
   while the popup is open. A click inside it is deliberately a no-op (see
   buildStoryTile in app.js) other than scrolling a long blurb; the × here
   and the flip button are its only two ways to close. */
.story-cover-popup {
  position: absolute; inset: 40px 8px 8px 8px; z-index: 3;
  display: flex; flex-direction: column; gap: 6px;
  padding: 10px 12px;
  border-radius: 12px;
  background: var(--surface);
  color: var(--ink);
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
  overflow-y: auto;
}
.story-cover-popup-close {
  position: absolute; top: 2px; right: 2px;
  width: 26px; height: 26px; padding: 0;
  display: grid; place-items: center;
  border: 0; border-radius: 50%;
  background: none;
  color: var(--ink-soft);
  font-size: 18px; line-height: 1;
  cursor: pointer;
}
.story-cover-popup-blurb {
  margin: 0; padding-right: 20px;
  font-size: calc(12.5px * var(--app-font-scale, 1));
  line-height: 1.4;
}
.story-cover-popup-meta { margin: 0; font-size: calc(11px * var(--app-font-scale, 1)); }

/* A series is one tile that opens to its current chapter, plus a toggle
   revealing the rest (§8.2) — tall enough with that toggle and list that it
   spans the grid's full width rather than squeezing into one column. */
.story-series { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 8px; }
.story-series-head { max-width: 260px; }
.story-series-toggle { align-self: flex-start; padding: 4px 0; }
.story-series-chapters { gap: 6px; }

/* A chapter inside an expanded series list — the shelf's old row treatment,
   kept for that one nested spot now that the shelf itself is the poster
   grid above: a 38px thumbnail has no room for a flip-open description, and
   doesn't need one when the series' own head tile already shows the
   current chapter's blurb. */
.story-chapter-row {
  width: 100%;
  text-align: left; display: flex; flex-direction: row; gap: 12px; align-items: flex-start;
  padding: 6px 0 6px 12px; border: 0; border-left: 2px solid var(--line, #e5ded8);
  border-radius: 0; box-shadow: none; background: none; cursor: pointer; color: inherit; font: inherit;
}
.story-chapter-row .story-cover { width: 38px; box-shadow: none; }
.story-card-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }

/* The read-state filter. Scrolls rather than wraps, so it stays one line on a
   narrow phone and never pushes the shelf itself below the fold. */
.story-filter {
  display: flex; gap: 6px; overflow-x: auto; margin: 8px 0 4px;
  scrollbar-width: none;
}
.story-filter::-webkit-scrollbar { display: none; }
.story-filter .segment { flex: 0 0 auto; }
.story-filter-empty { text-align: center; padding: 18px 0; }

/* An inline illustration, between two paragraphs (§8.8). `aspect-ratio` is set
   from image dimensions or the SVG's viewBox, so it holds its final height from
   the very first layout and nothing below it ever moves as art appears —
   §8.3's no-reflow rule, extended from interaction to loading. */
.reader-art {
  margin: 18px auto;
  max-width: 460px;
  width: 100%;
  line-height: 0;
}
.reader-art svg, .reader-art img { width: 100%; height: 100%; display: block; }
.reader-art-painted { overflow: hidden; border-radius: 10px; }
.reader-art img { object-fit: contain; }

/* The reader itself — everything that isn't the story is a failure of
   nerve (§8.3). The top bar and progress line stay minimal on purpose. */
.reader-topbar h2 { font-size: calc(16px * var(--app-font-scale, 1)); }
.reader-progress { height: 4px; margin: 4px 0 12px; }
.reader-body {
  font-family: "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic",
               "Noto Sans JP", "Noto Sans CJK JP", sans-serif;
  font-size: calc(18px * var(--app-font-scale, 1));
  line-height: 2.4;   /* room for furigana, whether or not it's showing */
  padding-bottom: 40px;
  /* The strictest set of Japanese line-breaking prohibitions the browser
     offers, for what it CAN handle on its own: small kana and the long-vowel
     mark inside a single token's own text. Across tokens it is powerless
     (see .reader-run above). `word-break: normal` keeps it from breaking a
     word anywhere it likes to make the line fit. */
  line-break: strict;
  word-break: normal;
  overflow-wrap: normal;
}
.reader-para { margin: 0 0 22px; }
.reader-sentence { display: inline; }

/* The bookmark (stories-plan.md §7.6) — the learner's own marker for where
   they stopped, positioned in pixels by positionReaderBookmark() at the end
   of a sentence's last line.

   Two jobs pulling opposite ways: it has to be findable at a glance on a
   screen of unspaced kanji, and it must not compete with the text for
   attention while reading. So it is a thin accent rule rather than an icon —
   the same colour as everything else the app calls attention with, at a
   width that reads as punctuation rather than furniture.

   `position: relative` on the body is what these coordinates are relative
   to; the body is the positioned ancestor for nothing else. */
.reader-body { position: relative; }
.reader-bookmark {
  position: absolute;
  /* Centred on the end of the line rather than starting there, so it sits in
     the gap after the 。 instead of shunting into the next sentence. */
  transform: translateX(-50%);
  /* Tall and narrow on purpose. Line-height 2.4 leaves generous vertical
     space either side of the text, so height is free — width is not: every
     pixel of it is taken from a tappable word. A 16px column is enough to
     put a thumb on and narrow enough to sit between two sentences. */
  width: 16px;
  padding: 0;
  border: 0;
  background: none;
  cursor: grab;
  /* The marker handles its own drag (bindReaderBookmarkDrag); without this
     the browser scrolls the page out from under the gesture instead. */
  touch-action: none;
  z-index: 2;
  /* Transitions the MOVE, not the fade: watching it travel to where you put
     it is what tells you it went there. */
  transition: left 140ms ease, top 140ms ease;
}
.reader-bookmark::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: 4px;
  height: 1.35em;
  border-radius: 2px;
  background: var(--accent);
  /* The notch that makes a vertical bar read as a ribbon rather than a
     cursor or a text caret. */
  clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 72%, 0 100%);
  transition: width 120ms ease, box-shadow 120ms ease, height 120ms ease;
}
.reader-bookmark:hover::before { width: 6px; }
.reader-bookmark:focus-visible { outline: none; }
.reader-bookmark:focus-visible::before,
.reader-bookmark.is-armed::before,
.reader-bookmark.is-dragging::before {
  width: 7px;
  height: 1.6em;
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.reader-bookmark.is-dragging { cursor: grabbing; transition: none; }

/* One pulse on reopening a story, so the eye is told where to look instead
   of having to find a deliberately quiet marker (scrollToReaderMark). */
.reader-bookmark.is-landing::before { animation: bookmark-land 1.6s ease-out; }
@keyframes bookmark-land {
  0%, 100% { box-shadow: 0 0 0 0 transparent; }
  20%, 60% { box-shadow: 0 0 0 5px var(--accent-soft); }
}
@media (prefers-reduced-motion: reduce) {
  .reader-bookmark { transition: none; }
  .reader-bookmark.is-landing::before { animation: none; }
}
/* 禁則処理: one unbreakable run of tokens — a word plus the punctuation that
   may not start a line, or an opening bracket plus what follows it. Built in
   renderReaderParagraph(); see the NO_LINE_START/NO_LINE_END note there for
   why the browser's own kinsoku can't do this for us.

   `nowrap` here also reaches INSIDE each token, since a token is an
   inline-block and inherits it, so a token can never be split across two
   lines. That is right for every token that fits the column and wrong for
   one that does not: ドラキュラ has eight kana forms of 19–29 characters
   (a whole clause tokenised as one word), and at phone width they simply
   hang off the right-hand edge. Letting the token wrap is not the fix —
   it is an inline-block, so the 。 or 、 welded to its right would then
   render beside its FIRST line, i.e. in the middle of the word. The fix
   belongs in the story source: tokenise those clauses into real words,
   which also makes them worth tapping. */
.reader-run { white-space: nowrap; }
.reader-token {
  position: relative;
  display: inline-block;
  vertical-align: bottom;
}
.reader-token ruby { ruby-position: over; }
.reader-token rt { font-size: 0.5em; color: var(--ink-soft); user-select: none; }  /* ruby: em ratio of .reader-body, scales for free */
.reader-tap { cursor: pointer; }
.reader-translate-tap { cursor: pointer; }
/* The last-tapped word (setReaderActiveToken in app.js) — a place-keeper,
   so the definition sheet at the bottom of the screen is visibly ABOUT
   something, and so coming back from a kanji's own page is a glance rather
   than a re-read. Deliberately a soft tint rather than a border or a solid
   fill: it has to be findable at a glance without competing with the text
   for attention, and without shifting the line by so much as a pixel. */
.reader-token-active {
  background: color-mix(in srgb, var(--accent) 18%, transparent);
  border-radius: 5px;
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent);
}
/* color-mix is recent enough to be worth a fallback for older iOS Safari,
   which would otherwise drop the declaration and show no highlight at all. */
@supports not (background: color-mix(in srgb, red 50%, transparent)) {
  .reader-token-active { background: var(--ok-bg); box-shadow: 0 0 0 2px var(--ok-bg); }
}
/* A dotted underline under the WORD glyphs only (not any rt already shown),
   the one hint that a token can be tapped for help — same language
   .vocab-word-tap already uses in the quiz. */
.reader-tap:not(:has(rt)) { text-decoration: underline dotted; text-underline-offset: 6px; }
.reader-romaji-pop {
  position: absolute;
  left: 0;
  top: 100%;
  font-size: calc(11px * var(--app-font-scale, 1));
  color: var(--ink-soft);
  white-space: nowrap;
  line-height: 1.2;
}
.reader-translation {
  margin: 4px 0 0;
  padding-left: 10px;
  border-left: 2px solid var(--line);
  color: var(--ink-soft);
  font-size: calc(15px * var(--app-font-scale, 1));
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

.reader-source { text-align: center; margin: 24px 0; }

/* The info panel and the settings sheet — both overlays, never part of the
   reader's own scroll flow, so neither can reflow a paragraph under a
   learner's thumb (stories-plan.md §5.7/§8.3). Every tap on a word opens
   this (openReaderCard in app.js) starting with just the word itself; the
   "Show definition" button is what pulls in the rest. */
.reader-card {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 25;
  background: var(--surface);
  border-top: 1px solid var(--line);
  border-radius: 16px 16px 0 0;
  box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.15);
  max-height: 70vh;
  overflow-y: auto;
  padding-bottom: env(safe-area-inset-bottom);
}
.reader-card-body { padding: 18px 16px; display: flex; flex-direction: column; gap: 10px; }
.reader-card-head { display: flex; align-items: baseline; gap: 12px; }
/* The reader's tap-a-word definition card — glyph, reading and gloss all
   scale with --app-font-scale (see the comment above `html` near the top of
   this file). */
.reader-card-glyph { font-size: calc(40px * var(--app-font-scale, 1)); }
.reader-card-reading { color: var(--ink-soft); font-size: calc(16px * var(--app-font-scale, 1)); }
.reader-card-gloss { font-size: calc(16px * var(--app-font-scale, 1)); font-weight: 600; }
.reader-card-chips { flex-wrap: wrap; }
.reader-card-chips:empty { display: none; }

/* Sits at the end of the text rather than pinned to the viewport: it is a
   thing you arrive at by finishing, not a control hovering over the reading
   (stories-plan.md §8.3 — everything on this screen that isn't the story is
   a failure of nerve). */
.reader-finished { margin: 8px 0 4px; }

.reader-end { margin-top: 16px; }
.reader-end-word {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 8px 0;
  border-bottom: 1px solid var(--line);
}

/* --- First run: self-placement (onboarding-plan.md) ----------------------
   Screen A's three choices are tall two-line buttons rather than the app's
   usual single-line ones: each is a whole route through the app, and the
   subtitle is what actually distinguishes them. Same .action-subtitle as
   the course screen's ladder, so the two read as the same kind of thing.
   Screen C's scales reuse .segmented/.segment wholesale — see
   renderOnboardingScales() in app.js. */
/* Both of these end in a fixed .bottom-bar, so they reserve room for it the
   same way the summary and quiz screens do — otherwise the bar sits on top
   of the last paragraph / the last scale. */
#screen-onboarding-guide,
#screen-onboarding-placement { padding-bottom: 96px; }

/* The guide's top bar carries a text "Skip" on the right where every other
   screen has a square icon button, so its column has to size to the text —
   the mirror image of .topbar:has(.btn-back-text) above. Without this the
   word is clipped by the fixed --tap column. */
.topbar:has(.onboarding-skip-top) { grid-template-columns: var(--tap) 1fr auto; }
.onboarding-skip-top { padding: 0 12px; font-size: calc(15px * var(--app-font-scale, 1)); white-space: nowrap; }

.onboarding-choices { display: flex; flex-direction: column; gap: 12px; }
.onboarding-choice {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: center;
  gap: 3px;
  padding: 14px 16px;
  text-align: left;
}
.onboarding-choice .action-subtitle { margin: 0; font-weight: 500; line-height: 1.35; }

.onboarding-section { display: flex; flex-direction: column; gap: 8px; }
.onboarding-section h3 { margin: 0; font-size: calc(17px * var(--app-font-scale, 1)); }
.onboarding-section p { margin: 0; }
/* Four scales, each free to wrap onto two rows on a narrow phone: "read and
   write all of it" will not fit beside three other options at 375px, and
   squeezing it would make the longest, most specific answer the hardest to
   read. */
.onboarding-scale .segmented { flex-wrap: wrap; }
.onboarding-scale .segment { flex: 1 0 auto; }

/* The set overview's placement nudge (§5) — a card, not a banner, so it
   scrolls away with the rest of the page rather than covering tiles. */
.overview-nudge { border-color: var(--accent); }

/* --- Self-placement: the course banner and the sweep ------------------- */

/* Sits at the top of a script's course screen, above everything the learner
   would otherwise reach for. Bordered in the accent for the same reason the
   overview nudge it replaces was: it is a prompt, not a status line. */
.course-nudge { border-color: var(--accent); }

/* The sweep's pinned head. Which phase this is and what to tap are the whole
   screen, and a list running to three thousand tiles would otherwise scroll
   the instruction out of sight within one flick. Same construction, and the
   same reasoning, as .overview-head above. */
.sweep-head {
  position: sticky;
  top: 0;
  z-index: 5;
  display: flex; flex-direction: column; gap: 8px;
  background: var(--bg);
  margin: calc(-1 * max(16px, env(safe-area-inset-top))) -16px 0;
  padding: max(16px, env(safe-area-inset-top)) 16px 8px;
  box-shadow: 0 6px 12px -12px rgba(0, 0, 0, 0.5);
}
.sweep-head .topbar { position: static; margin: 0; padding: 0; }
.sweep-instruction { margin: 0; font-size: calc(15px * var(--app-font-scale, 1)); font-weight: 600; line-height: 1.35; }

.sweep-section { display: flex; flex-direction: column; gap: 8px; margin-bottom: 18px; }
/* Sticks just under the pinned head, so the grade or unit being scrolled
   through is always named — the one piece of orientation a single
   three-thousand-tile list otherwise has none of. */
.sweep-unit-title {
  position: sticky;
  top: 96px;
  z-index: 4;
  margin: 0;
  padding: 4px 0;
  background: var(--bg);
  font-size: calc(15px * var(--app-font-scale, 1));
  color: var(--ink-soft);
}
.sweep-sentinel { text-align: center; min-height: 24px; }

/* Everything before the boundary: claimed, and painted in the app's own
   "well known" green so the sweep's result reads in the same colour
   language the set overview already teaches. */
.overview-tile.is-known {
  background: var(--tier-4);
  color: var(--tier-4-ink);
  border-color: var(--tier-4);
}
/* The boundary tile itself — the first one NOT known. An accent ring, not a
   fill: it is a marker for where the line falls, not a claim about the
   character under it. */
.overview-tile.is-boundary {
  border-color: var(--accent);
  border-style: dashed;
  box-shadow: inset 0 0 0 2px var(--accent);
}
/* The sweep's tiles are never in the overview's own select mode, so they
   never pick up .is-selectable — but the ✓ corner mark on a picked one
   still needs somewhere to anchor. */
.sweep-grid .overview-tile { position: relative; }
/* Room for the pinned confirm bar, same reservation #screen-overview makes
   — a button, its note and the skip link. */
#screen-sweep { padding-bottom: 190px; }

/* The set overview's two bulk jobs, side by side. Equal halves: neither
   "what do I already know" nor "what shall I learn next" outranks the other,
   and a learner arriving from either direction should find theirs where they
   expect it. Each collapses to full width while the other is hidden (which
   is what happens once one of them is running). */
#overview-toolbar > .btn { flex: 1 1 0; min-width: 0; }

/* The detail screen's per-mode study buttons. A mode already being studied
   reads as a settled fact, not as an offer — hence the filled "known" green
   rather than the accent, which everywhere else in the app means "do this
   next". Tapping still turns it back off; the label says "✓ Studying…", so
   nothing is claiming to be un-tappable. */
.detail-mode { text-align: left; }
.detail-mode.is-studying {
  background: var(--ok-bg);
  border-color: var(--ok);
  color: var(--ok);
}

/* Previous / Next along the grid this screen was opened from. */
.detail-pager { align-items: center; justify-content: space-between; gap: 8px; }
.detail-pager .btn { flex: 0 0 auto; }
.detail-pager .hint { flex: 1 1 auto; text-align: center; }

/* The history button + study-mode buttons (#detail-secondary, feedback #15)
   — collapsed behind this toggle for kanji/vocab so the actual definition
   further down doesn't need a long scroll past them. Same caret language as
   button.word-main::after above: a quiet ›-style arrow that rotates open,
   driven off the wrapper's "is-open" class exactly like that pattern. */
#detail-secondary { display: flex; flex-direction: column; gap: 8px; }
.detail-secondary-toggle {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
}
.detail-secondary-toggle::after {
  content: '▾';
  display: inline-block;
  transition: transform 0.15s ease;
}
#detail-secondary.is-open .detail-secondary-toggle::after { transform: rotate(180deg); }

/* --- Feedback and My contributions (feedback-plan.md) ------------------- */

/* Quiet on purpose. This icon is on every screen in the app, including the
   middle of a quiz, so it has to be findable without ever competing with
   the thing the learner is actually doing. */
.feedback-icon { font-size: 17px; opacity: 0.55; }  /* 💬 icon, not reading text (see comment above) */
.feedback-icon:hover, .feedback-icon:focus-visible { opacity: 1; }

/* A sheet, not a screen: whatever was underneath stays mounted and
   untouched, which is what lets the form open mid-session without
   disturbing the session. */
.sheet { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: flex-end; justify-content: center; }
.sheet-scrim { position: absolute; inset: 0; background: rgba(0, 0, 0, 0.35); }
.sheet-body {
  position: relative;
  width: 100%;
  max-width: 620px;
  max-height: 90vh;
  overflow-y: auto;
  background: var(--surface);
  border-radius: var(--radius) var(--radius) 0 0;
  border: 1px solid var(--line);
  border-bottom: none;
  box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.2);
  padding: 18px 16px max(18px, env(safe-area-inset-bottom));
  display: flex;
  flex-direction: column;
  gap: 12px;
}
@media (min-width: 640px) {
  /* Centred rather than bottom-anchored once there is room for it — a sheet
     glued to the bottom edge of a desktop window reads as a mobile port. */
  .sheet { align-items: center; }
  .sheet-body { border-radius: var(--radius); border-bottom: 1px solid var(--line); }
}
.sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.sheet-head h3 { margin: 0; }

/* --- Text size panel (pinch-to-open, see bindPinchZoom() in app.js) -----
   Deliberately small — a slider and a close button, nothing else — since
   opening it is already an interruption to whatever screen was underneath.
   Its own heading, "A"/"A" hints and readout scale with --app-font-scale,
   same technique as the rest of this file's scaled rules: dragging the
   slider all the way up should make the panel asking for that size look
   the part, not stay pinned at its own unscaled size while everything else
   grows. */
.font-size-body { max-width: 340px; }
#font-size-heading { font-size: calc(19px * var(--app-font-scale, 1)); }
.font-size-row { align-items: center; gap: 10px; }
.font-size-hint { color: var(--ink-soft); line-height: 1; }
.font-size-hint-small { font-size: 14px; }  /* fixed small-A slider anchor; large-A below grows to preview the scale instead */
.font-size-hint-large { font-size: calc(22px * var(--app-font-scale, 1)); }
.font-size-row input[type="range"] { flex: 1; accent-color: var(--accent); height: 32px; }
#font-size-value { text-align: center; font-size: calc(14px * var(--app-font-scale, 1)); font-weight: 600; }

.feedback-count { text-align: right; margin: 0; }
/* `.row > *` is flex:1, which would stretch the checkbox itself to half the
   sheet. Only the label should take the slack. */
.feedback-diag-toggle { align-items: flex-start; gap: 10px; cursor: pointer; }
.feedback-diag-toggle > input { flex: 0 0 auto; width: 20px; height: 20px; margin-top: 1px; }
.feedback-diag-toggle > span { flex: 1; }
/* Inside .stack (a flex column) a bare button stretches full width and its
   text centres; these read as links in a sentence, so they sit left. */
#feedback-step-form .linkish, #feedback-step-done .linkish { align-self: flex-start; text-align: left; }
.feedback-diag-list {
  margin: 0;
  padding: 10px 12px 10px 28px;
  border-radius: 12px;
  background: var(--bg);
  color: var(--ink-soft);
  font-size: calc(13px * var(--app-font-scale, 1));
  line-height: 1.5;
}
.feedback-privacy { margin: 0; }
.feedback-turnstile:empty { display: none; }
.feedback-error {
  margin: 0;
  padding: 10px 12px;
  border-radius: 12px;
  background: var(--bad-bg);
  color: var(--bad);
  font-size: calc(14px * var(--app-font-scale, 1));
}
.feedback-done-mark { font-size: 40px; margin: 4px 0 0; text-align: center; }  /* 🌱 emoji, aria-hidden */
#feedback-step-done h4 { margin: 0; text-align: center; }
#feedback-step-done .hint { text-align: center; }

/* --- The improvement garden -------------------------------------------- */

/* One drawing per growth stage, in currentColor so the learner's own accent
   flows through without five colour variants. Decorative and aria-hidden:
   growth is a stage swap, not an animation timeline. */
.garden {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  justify-content: center;
  gap: 6px;
  min-height: 60px;
  padding: 4px 0;
  color: var(--accent);
}
.garden:empty { display: none; }
.garden svg { width: 42px; height: 54px; display: block; }
.garden-plant { transition: transform 180ms ease; }
.garden-plant:hover { transform: translateY(-2px); }
@media (prefers-reduced-motion: reduce) {
  .garden-plant { transition: none; }
  .garden-plant:hover { transform: none; }
}

.contributions-header { align-items: center; text-align: center; }
.contributions-hello { margin: 0; font-size: calc(16px * var(--app-font-scale, 1)); }
.contributions-hello .avatar { font-size: 20px; }  /* emoji avatar icon, not reading text */
.contributions-totals { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; width: 100%; }
.contributions-totals div { display: flex; flex-direction: column; gap: 2px; }
.contributions-totals b { font-size: calc(22px * var(--app-font-scale, 1)); }
.contributions-totals .hint { font-size: calc(12px * var(--app-font-scale, 1)); line-height: 1.3; }
.contributions-empty { text-align: center; padding: 8px 4px 0; }
.contributions-footnote { padding-top: 4px; }

/* The two badges under My contributions in Settings (renderContributionBadges
   in app.js) and the compact list they open (#contributions-list-sheet). */
.contribution-badges { flex-wrap: wrap; }
.contribution-badge {
  display: flex; align-items: baseline; gap: 5px;
  min-height: var(--tap);
  padding: 6px 14px;
  border: 1px solid var(--line);
  border-radius: 99px;
  background: var(--surface);
  color: var(--ink);
  font: inherit; font-size: calc(13px * var(--app-font-scale, 1));
  cursor: pointer;
}
.contribution-badge:hover, .contribution-badge:focus-visible { border-color: var(--accent); }
.contribution-badge b { font-size: calc(16px * var(--app-font-scale, 1)); }
.contribution-badge.is-closed { border-color: var(--ok); color: var(--ok); }

.contributions-rows { gap: 8px; }
.contribution-row {
  padding: 10px 12px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface);
}
.contribution-row.is-closed { border-color: var(--ok); background: var(--ok-bg); }
.contribution-row-head { display: flex; align-items: center; gap: 8px; }
.contribution-row-title {
  flex: 1;
  text-align: left;
  background: none; border: none; padding: 0;
  color: var(--ink); font: inherit; font-weight: 600;
  cursor: pointer;
}
.contribution-row.is-closed .contribution-row-title { color: var(--ok); }
.contribution-row-celebrate {
  flex: 0 0 auto;
  background: none; border: none; padding: 0;
  font-size: 18px; line-height: 1; cursor: pointer;  /* 🎉 emoji icon button */
}
.contribution-row-detail { margin: 6px 0 0; }

.contribution-card { display: flex; flex-direction: column; gap: 6px; }
.contribution-head { display: flex; align-items: baseline; gap: 8px; }
.contribution-kind { font-size: calc(12px * var(--app-font-scale, 1)); color: var(--ink-soft); text-transform: uppercase; letter-spacing: 0.04em; }
.contribution-date { font-size: calc(12px * var(--app-font-scale, 1)); color: var(--ink-soft); margin-left: auto; }
.contribution-title { margin: 0; font-weight: 600; }
.contribution-status { margin: 0; font-size: calc(14px * var(--app-font-scale, 1)); }
.contribution-card.is-released { border-color: var(--ok); }
.contribution-card.is-released .contribution-status { color: var(--ok); font-weight: 600; }
.contribution-card.is-failed .contribution-status { color: var(--bad); }
.contribution-timeline { margin: 4px 0 0; padding-left: 18px; font-size: calc(13px * var(--app-font-scale, 1)); color: var(--ink-soft); line-height: 1.6; }
.contribution-actions { display: flex; flex-wrap: wrap; gap: 8px; padding-top: 4px; }
.contribution-actions .btn { min-height: 40px; padding: 0 12px; font-size: calc(14px * var(--app-font-scale, 1)); }

.celebration-body { text-align: center; align-items: center; }
.celebration-mark { font-size: 46px; margin: 4px 0 0; }  /* 🌸 emoji, aria-hidden */
.celebration-body h3 { margin: 0; }
.celebration-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; width: 100%; }
.celebration-list li {
  padding: 12px 14px;
  border-radius: var(--radius);
  background: var(--ok-bg);
  color: var(--ink);
  text-align: left;
  font-size: calc(14px * var(--app-font-scale, 1));
  line-height: 1.5;
}
.celebration-list b { display: block; }

/* --- Side by side: two kanji compared (kanji-compare.js, renderCompare in
   app.js) -----------------------------------------------------------------

   A two-column grid with full-width band headings between the rows, rather
   than two independent columns: the whole reason for showing a pair side by
   side is that the eye can run straight across from one meaning to the
   other, which only works if the rows line up. CSS Grid aligns them for
   free, even when one character has three meanings and the other has one. */
.compare-body { max-width: 540px; }
.compare-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 6px 12px;
  align-items: start;
}
.compare-band {
  grid-column: 1 / -1;
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 6px;
  font-size: calc(11px * var(--app-font-scale, 1));
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-soft);
}
/* Rules either side of the label, so a band reads as a divider between two
   rows rather than as a heading belonging to the row above it. */
.compare-band::before,
.compare-band::after { content: ''; flex: 1; height: 1px; background: var(--line); }
/* min-width: 0 or a long unbroken meaning would push its column past its
   share of the grid instead of wrapping inside it. */
.compare-cell { min-width: 0; text-align: center; }
.compare-cell p { margin: 0; }
.compare-glyph {
  display: block;
  width: 100%;
  border: none;
  background: none;
  padding: 0;
  font-family: inherit;
  color: var(--ink);
  cursor: pointer;
  line-height: 1.1;
  font-size: calc(clamp(52px, 16vw, 76px) * var(--app-font-scale, 1));
}
.compare-glyph:active { transform: scale(0.96); }
.compare-glyph:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 12px; }
.compare-meaning { font-weight: 700; font-size: calc(16px * var(--app-font-scale, 1)); line-height: 1.3; }
.compare-readings { color: var(--ink-soft); font-size: calc(15px * var(--app-font-scale, 1)); line-height: 1.4; }
.compare-hint { font-size: calc(14px * var(--app-font-scale, 1)); line-height: 1.4; color: var(--ink-soft); text-align: left; }
.compare-quiet { margin: 0; font-style: italic; }
/* Tighter than the detail screen's own component row: two of these have to
   sit next to each other in a phone's width. */
.compare-part-row { gap: 6px; }
.compare-part-row .component-tile { padding: 4px 6px; min-width: 0; }
.compare-part-row .component-glyph { font-size: calc(22px * var(--app-font-scale, 1)); }
/* The part BOTH characters are built from — ringed on both sides at once,
   so what is left over reads as the difference without having to be marked
   itself. Marking the shared part rather than the different ones is the
   whole trick: it is the shared part that made them look alike. */
.compare-part-row .component-tile.is-shared {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.compare-summary {
  grid-column: 1 / -1;
  margin: 8px 0 0;
  padding: 10px 12px;
  border-radius: 12px;
  background: var(--bg);
  border: 1px solid var(--line);
  text-align: left;
  line-height: 1.45;
}
.compare-details { font-size: calc(15px * var(--app-font-scale, 1)); }

/* The detail screen's way in — a collapsed row of look-alikes under the
   component breakdown. */
.compare-entry { margin-top: 10px; }
.compare-candidates { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 10px; }
.compare-candidate {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  min-width: 72px;
  max-width: 120px;
  padding: 8px 10px;
  border: 2px solid var(--line);
  border-radius: 14px;
  background: var(--surface);
  color: var(--ink);
  font-family: inherit;
  cursor: pointer;
}
.compare-candidate:active { transform: scale(0.96); }
.compare-candidate-glyph { font-size: calc(30px * var(--app-font-scale, 1)); line-height: 1.1; color: var(--accent); }
.compare-candidate-meaning {
  font-size: calc(12px * var(--app-font-scale, 1));
  line-height: 1.25;
  color: var(--ink-soft);
  text-align: center;
}

/* A Definition question's wrong answers, once it has resolved: still
   tappable, but now they open the comparison rather than answering
   anything (armChoiceComparison in app.js). Quieter than a live button and
   louder than the 0.35 a plain disabled distractor gets — they are no
   longer the question, but they are no longer inert either. The arrow is
   the only affordance there is room for on a button whose text is already
   a full English meaning. */
.choice.choice-compare { opacity: 0.72; }
.choice.choice-compare::after {
  content: ' \21c4';
  margin-left: 5px;
  font-weight: 700;
  color: var(--ink-soft);
}
.choice.choice-compare.is-wrong::after { color: var(--bad); }

@media (max-width: 360px) {
  /* Two component tiles per side stop fitting much below this; the meanings
     under them are what goes first. */
  .compare-part-row .component-meaning { font-size: calc(10px * var(--app-font-scale, 1)); }
  .compare-grid { gap: 6px 8px; }
}

/* A compare candidate this learner has genuinely answered in place of the
   character on screen (confusedKanjiFor in app.js), as opposed to one the
   ranking merely suggests. Ringed rather than recoloured: the tile still has
   to read as one of a row of the same kind of thing. */
.compare-candidate.is-confused {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.compare-candidate-badge {
  font-size: calc(10px * var(--app-font-scale, 1));
  font-weight: 700;
  letter-spacing: 0.02em;
  color: var(--accent);
  text-align: center;
  line-height: 1.2;
}

/* The summary's "you mixed these up" buttons. Sit directly under "Practise
   N missed" and stay visually quieter than it — practising the misses is
   still the primary action; this is the more specific offer underneath. */
.summary-compare { display: flex; flex-direction: column; gap: 8px; }
.summary-compare-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  flex-wrap: wrap;
}
.summary-compare-pair {
  font-size: calc(19px * var(--app-font-scale, 1));
  font-weight: 700;
  line-height: 1.2;
}
.summary-compare-count {
  font-size: calc(12px * var(--app-font-scale, 1));
  font-weight: 700;
  color: var(--accent);
}
