:root {
  --safe-t: env(safe-area-inset-top,    0px);
  --safe-b: env(safe-area-inset-bottom, 0px);
  --safe-l: env(safe-area-inset-left,   0px);
  --safe-r: env(safe-area-inset-right,  0px);
  /* CJK stack. 'ShuaziExtB' (see @font-face below) is a tiny bundled subset that
     covers the rare CJK Extension B components (𠂇 𢆶 𢛳) the app uses; its
     unicode-range limits it to astral-plane glyphs, so normal hanzi still use the
     system fonts. The Windows-only SimSun-ExtB / MingLiU-ExtB stay as a native
     fallback. Without any of these, Ext-B glyphs render as tofu — notably on iOS,
     which ships no glyph for them at all. */
  --cjk: 'ShuaziExtB','PingFang SC','Hiragino Sans GB','Noto Sans CJK SC','Microsoft YaHei','SimSun-ExtB','MingLiU-ExtB',sans-serif;
}

/* Bundled Ext-B subset (~900 B). unicode-range covers the whole CJK Extension B
   block so the browser only fetches/uses it for those code points; every other
   character falls straight through to the system fonts in --cjk. */
@font-face {
  font-family: 'ShuaziExtB';
  src: url('/fonts/shuazi-extb.woff2') format('woff2');
  font-display: swap;
  unicode-range: U+20000-2A6DF;
}
[data-theme="dark"] {
  --bg:        #11161a; /* lifted off near-black to soften the harsh edge contrast */
  --surf:      #181f25;
  --surf2:     #212a31; /* a touch more elevation separation from --surf */
  --bdr:       rgba(255,255,255,.08);
  --txt:       #dbe3e8; /* softened from #e8f0f4 to reduce white-on-black halation */
  --muted:     #94a7b3;
  --faint:     #7593a3; /* lifted for ~5.6:1 contrast on the dark bg */
  --red:       #d9705a;
  --red-bg:    rgba(217,112,90,.15);
  --green:     #68bf8a;
  --green-bg:  rgba(104,191,138,.14);
  --blue:      #64a0e6;
  --blue-bg:   rgba(100,160,230,.15);
  /* Entity-type accents (char / word / radical / component) — one fixed color per
     type, reused by every badge, chip and link so color always encodes the type. */
  --char:        var(--blue);   --char-bg:      var(--blue-bg);
  --word:        #9b7ede;       --word-bg:      rgba(155,126,222,.16);
  --radical:     #4bb3a3;       --radical-bg:   rgba(75,179,163,.16);
  --component:   #9a9aa6;       --component-bg: rgba(154,154,166,.16);
  --sh:        0 28px 70px rgba(0,0,0,.6), 0 4px 14px rgba(0,0,0,.3);
  --tabbar:    rgba(17,22,26,.88);
  --bar-bg:    rgba(255,255,255,.18);
  --bar-act:   rgba(255,255,255,.92);
}
[data-theme="light"] {
  /* iOS structure (grouped gray background + pure white surfaces), but keeping
     the medium theme's ink and accent colors — only structure/bg change. */
  --bg:        #f2f2f7; /* systemGroupedBackground */
  --surf:      #ffffff;
  --surf2:     #ffffff;
  --bdr:       rgba(0,0,0,.08);
  --txt:       #251d17;
  --muted:     #645750;
  --faint:     #635649;
  --red:       #b04030;
  --red-bg:    rgba(176,64,48,.11);
  --green:     #3a7a56;
  --green-bg:  rgba(58,122,86,.11);
  --blue:      #2560b0;
  --blue-bg:   rgba(37,96,176,.11);
  /* Entity-type accents — see the dark block above. */
  --char:        var(--blue);   --char-bg:      var(--blue-bg);
  --word:        #8158d4;       --word-bg:      rgba(129,88,212,.14);
  --radical:     #3e9c8f;       --radical-bg:   rgba(62,156,143,.14);
  --component:   #7e7e88;       --component-bg: rgba(120,120,130,.14);
  --sh:        0 28px 70px rgba(0,0,0,.10), 0 4px 14px rgba(0,0,0,.05);
  --tabbar:    rgba(249,249,249,.94); /* iOS tab-bar material */
  --bar-bg:    rgba(0,0,0,.18);
  --bar-act:   rgba(0,0,0,.85);
}
/* Light theme: modal content boxes (palabra, nivel, pinyin) are light gray */
[data-theme="light"] .modal .info-cell,
[data-theme="light"] .modal .words-list,
[data-theme="light"] .modal .word-exp {
  background: #f5f5fa;
}
/* Light theme: cards page - second card (back) content boxes are light gray */
[data-theme="light"] .card .info-cell,
[data-theme="light"] .card .words-list,
[data-theme="light"] .card .word-exp {
  background: #f5f5fa;
}
/* Light theme: cards page - first card (front) example box is light gray */
[data-theme="light"] .card .ans-example {
  background: #f5f5fa;
}
/* Light theme: grid hover is light gray, not white */
[data-theme="light"] .char-tile:not(.known):not(.repaso):hover {
  background: #f5f5fa;
}

[data-theme="medium"] {
  /* the original warm-paper light theme */
  --bg:        #e4ded3; /* gently warmed, low-glare base */
  --surf:      #ede7de; /* soft warm surface */
  --surf2:     #f3eee5; /* was #ffffff — no pure white */
  --bdr:       rgba(0,0,0,.08);
  --txt:       #251d17; /* slightly softened ink, not full black */
  --muted:     #645750;
  --faint:     #635649;
  --red:       #b04030;
  --red-bg:    rgba(176,64,48,.11);
  --green:     #3a7a56;
  --green-bg:  rgba(58,122,86,.11);
  --blue:      #2560b0;
  --blue-bg:   rgba(37,96,176,.11);
  /* Entity-type accents — see the dark block above. */
  --char:        var(--blue);   --char-bg:      var(--blue-bg);
  --word:        #8158d4;       --word-bg:      rgba(129,88,212,.14);
  --radical:     #3e9c8f;       --radical-bg:   rgba(62,156,143,.14);
  --component:   #7e7e88;       --component-bg: rgba(120,120,130,.14);
  --sh:        0 28px 70px rgba(28,21,16,.18), 0 4px 14px rgba(28,21,16,.08);
  --tabbar:    rgba(228,222,211,.92);
  --bar-bg:    rgba(0,0,0,.18);
  --bar-act:   rgba(0,0,0,.85);
}

*,*::before,*::after { box-sizing:border-box; margin:0; padding:0; }
html,body {
  width:100%; height:100%; height:100dvh;
  overflow:hidden; overscroll-behavior:none;
  -webkit-text-size-adjust:100%;
  -webkit-tap-highlight-color:transparent;
  touch-action:none;
}
/* Fluid root: every rem-based size across the app scales gently with the
   viewport (≈16.1px on a small phone → 17.5px on a tablet). Legible on tiny
   screens, roomier on large ones, without touching layout (which uses px/%/vw). */
html { font-size:clamp(15.5px, 14.8px + 0.4vw, 17.5px); }

body {
  font-family:-apple-system,'Helvetica Neue',sans-serif;
  background:var(--bg); color:var(--txt);
  -webkit-font-smoothing:antialiased;
  display:flex; justify-content:center;
}
button { font:inherit; color:inherit; border:none; background:none; cursor:pointer; }

/* ── ROOT LAYOUT ── */
.root {
  width:100%; max-width:860px;
  height:100dvh;
  display:grid;
  grid-template-rows: 1fr auto;
  box-shadow:0 0 60px rgba(0,0,0,.4);
}

/* ── SCREENS ── */
.screens {
  overflow:hidden; position:relative;
}
.screen {
  position:absolute; inset:0;
  display:flex; flex-direction:column;
  padding-top:    max(14px, var(--safe-t));
  padding-left:   max(16px, var(--safe-l));
  padding-right:  max(16px, var(--safe-r));
  padding-bottom: 8px;
  gap:10px;
  opacity:0; pointer-events:none;
  transition: opacity 220ms ease;
}
.screen.active { opacity:1; pointer-events:all; }

/* ── TAB BAR ── */
.tabbar {
  position:relative;
  display:flex;
  touch-action:none;
  user-select:none; -webkit-user-select:none; -webkit-touch-callout:none;
  background: var(--tabbar);
  /* Whole-bar liquid glass: stronger blur + saturation lifts the colour behind it. */
  backdrop-filter: blur(24px) saturate(180%);
  -webkit-backdrop-filter: blur(24px) saturate(180%);
  padding-bottom: max(4px, var(--safe-b));
  padding-top: 2px;
  /* Liquid-glass tuning (per theme) */
  --glass-c:#bbbbbc; --glass-light:#fff; --glass-dark:#000;
  --glass-rl:1; --glass-rd:1; --glass-sat:170%;
  --glass-fill:28%;   /* clearer glass (iOS 26 lets the backdrop show through) */
  /* Specular glass rim across the whole bar (bright top edge + soft inner shading +
     a faint lift above), so every tab sits on the glass — like the knob, cross-browser. */
  box-shadow:
    inset 0 1px 0 0       color-mix(in srgb, var(--glass-light) calc(var(--glass-rl) * 60%), transparent),
    inset 0 10px 14px -12px color-mix(in srgb, var(--glass-light) calc(var(--glass-rl) * 32%), transparent),
    inset 0 -8px 12px -12px color-mix(in srgb, var(--glass-dark) calc(var(--glass-rd) * 14%), transparent),
    0 -2px 18px           color-mix(in srgb, var(--glass-dark) calc(var(--glass-rd) * 7%), transparent);
}
[data-theme="dark"] .tabbar {
  --glass-rl:.42; --glass-rd:2;
  --glass-fill:13%;   /* clearer glass on dark */
}

/* The sliding "liquid glass" knob behind the active tab. */
.tab-pill {
  position:absolute;
  left:0; top:0;
  width:0; height:0;
  border-radius:18px;   /* rounder, capsule-like (must match PILL_R in ui.js) */
  pointer-events:none;
  opacity:0;
  isolation:isolate;
  background-color: color-mix(in srgb, var(--glass-c) var(--glass-fill), transparent);
  -webkit-backdrop-filter: blur(6px) saturate(var(--glass-sat));
  backdrop-filter: blur(2px) saturate(var(--glass-sat)) url(#liquidGlass);
  /* Multi-layer specular glass edges (light reflexes + dark refraction). */
  box-shadow:
    inset 0 0 0 1px      color-mix(in srgb, var(--glass-light) calc(var(--glass-rl) * 10%), transparent),
    inset 2px 1px 0 -1px  color-mix(in srgb, var(--glass-light) calc(var(--glass-rl) * 90%), transparent),
    inset -1.5px -1px 0 -1px color-mix(in srgb, var(--glass-light) calc(var(--glass-rl) * 80%), transparent),
    inset -2px -6px 1px -5px color-mix(in srgb, var(--glass-light) calc(var(--glass-rl) * 60%), transparent),
    inset -1px 2px 3px -1px color-mix(in srgb, var(--glass-dark) calc(var(--glass-rd) * 20%), transparent),
    inset 0 -4px 1px -2px  color-mix(in srgb, var(--glass-dark) calc(var(--glass-rd) * 10%), transparent),
    0 3px 6px 0           color-mix(in srgb, var(--glass-dark) calc(var(--glass-rd) * 8%), transparent);
  /* translate/scale are independent props → they compose with the filter. */
  transition:
    translate .44s cubic-bezier(1,0,.4,1),
    width .44s cubic-bezier(1,0,.4,1),
    scale .38s cubic-bezier(.34,1.45,.5,1),
    opacity .25s ease;
  will-change: translate, width, scale;
}
.tab-pill.ready { opacity:1; }

/* Liquid squish while sliding — direction set via JS transform-origin. */
.tab-pill.sliding { animation: pillSquish .44s ease; }
@keyframes pillSquish {
  0%   { scale: 1 1; }
  45%  { scale: 1.16 .94; }
  100% { scale: 1 1; }
}
.tab {
  position:relative;
  z-index:1;
  flex:1; display:flex; flex-direction:column;
  align-items:center; justify-content:center;
  gap:2px; padding:5px 0;
  color:var(--faint);
  transition: color 180ms ease;
  min-height:44px;
}
.tab.active { color:var(--blue); }
.tab svg { width:26px; height:26px; }
.tab span { font-size:.68rem; font-weight:600; letter-spacing:.02em; }
/* Outline icon when idle, filled icon when active. */
.tab .ic-fill { display:none; }
.tab.active .ic-line { display:none; }
.tab.active .ic-fill { display:block; }

/* ══════════════════════════════
   SCREEN 1 — FLASHCARDS
══════════════════════════════ */
.header {
  display:flex; align-items:center; justify-content:space-between;
}
.brand { display:flex; align-items:center; gap:10px; }
.logo {
  width:34px; height:34px; flex-shrink:0;
  border-radius:10px; object-fit:cover; display:block;
  filter:brightness(1.18); opacity:.5;
}
.title   { font-size:.95rem; font-weight:700; line-height:1.1; }
.sub     { font-size:.68rem; color:var(--faint); margin-top:0px; }
.hdr-stats { display:flex; gap:14px; align-items:center; }
.st { text-align:center; }
.st .v { font-size:.92rem; font-weight:700; font-variant-numeric:tabular-nums; line-height:1; }
.st .l { font-size:.66rem; text-transform:uppercase; letter-spacing:.06em; color:var(--faint); }
#sKnown .v  { color:var(--green); }
#sRepaso .v { color:var(--blue); }
.icon-btn {
  width:28px; height:28px; display:grid; place-items:center;
  border-radius:50%; background:var(--surf); border:1px solid var(--bdr); color:var(--muted);
}
.sort-btn { width:auto; border-radius:999px; padding:0 10px; gap:4px; display:flex; flex-direction:row; }
.icon-btn:active { opacity:.6; }
.icon-btn.active { background:var(--blue-bg); border-color:var(--blue); color:var(--blue); }

/* deck */
/* Thin deck-progress bar: fills with the share of the deck already seen.
   Bottom-of-screen, kept fine and discreet. */
.deck-progress { display:flex; align-items:center; gap:7px; padding:0 4px; }
.deck-progress-track { flex:1; height:2px; border-radius:999px; background:var(--bdr); overflow:hidden; }
.deck-progress-fill { height:100%; width:0; background:var(--muted); border-radius:999px; transition:width .35s cubic-bezier(.22,1,.36,1); }
.deck-progress-pct { font-size:.54rem; font-weight:700; color:var(--faint); min-width:26px; text-align:right; letter-spacing:.03em; }

.deck-wrap { position:relative; flex:1; }
.deck      { position:absolute; inset:0; }

/* card */
.card {
  position:absolute; inset:0; border-radius:24px;
  background:var(--surf2); border:1px solid var(--bdr);
  box-shadow:var(--sh); overflow:hidden;
  touch-action:none; user-select:none; -webkit-user-select:none;
  display:flex; flex-direction:column;
}
.card::before {
  content:''; position:absolute; inset:0; pointer-events:none; z-index:0;
  background:
    radial-gradient(ellipse 55% 30% at 80% 8%,  rgba(143,164,176,.045),transparent),
    radial-gradient(ellipse 45% 25% at 15% 92%, rgba(143,164,176,.03),transparent);
}
.card.stack-under { transform:scale(.97) translateY(8px); opacity:.5; z-index:0; }
.card.top   { z-index:1; }
.card.fly   { transition:transform 230ms cubic-bezier(0.16,1,0.3,1), opacity 200ms ease; }
.card.snap  { transition:transform 260ms cubic-bezier(0.16,1,0.3,1), opacity 200ms ease; }

.tint { position:absolute; inset:0; z-index:2; border-radius:inherit; pointer-events:none; opacity:0; }
/* Gradients spanning the whole card: strongest on the side you're swiping
   toward, fading edge-to-edge, so the colour is never a flat uniform wash. */
.tint-l { background:linear-gradient(to right,  rgba(143,164,176,.22), rgba(143,164,176,0)); }
.tint-r { background:linear-gradient(to left,   rgba(104,191,138,.2),  rgba(104,191,138,0)); }
.tint-u { background:linear-gradient(to bottom, rgba(100,160,230,.2),  rgba(100,160,230,0)); }
/* Light/medium themes are tuned per-direction so each colour reads evenly. */
[data-theme="light"] .tint-l,
[data-theme="medium"] .tint-l { background:linear-gradient(to right,  rgba(143,164,176,.3),  rgba(143,164,176,0)); }
[data-theme="light"] .tint-r,
[data-theme="medium"] .tint-r { background:linear-gradient(to left,   rgba(58,122,86,.18),   rgba(58,122,86,0)); }
[data-theme="light"] .tint-u,
[data-theme="medium"] .tint-u { background:linear-gradient(to bottom, rgba(37,96,176,.16),   rgba(37,96,176,0)); }
[data-theme="light"] .chip-r,
[data-theme="medium"] .chip-r { background:rgba(58,122,86,.18); }
[data-theme="light"] .chip-u,
[data-theme="medium"] .chip-u { background:rgba(37,96,176,.18); }
.chip { position:absolute; top:52px; z-index:3; padding:4px 12px; border-radius:999px; font-size:.68rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase; opacity:0; pointer-events:none; }
.chip-l { left:34px;  background:rgba(143,164,176,.25); color:var(--muted); }
.chip-r { right:26px; background:var(--green-bg); color:var(--green); }
.chip-u { left:50%; transform:translateX(-50%); top:30px; background:var(--blue-bg); color:var(--blue); }

/* story bar */
.story-bar { position:relative; z-index:4; display:flex; gap:5px; padding:14px 14px 0; }
.seg { flex:1; height:3px; border-radius:2px; background:var(--bar-bg); }
.seg.active { background:var(--bar-act); }

/* tap zones */
.tap-zones { position:absolute; inset:0; z-index:5; display:grid; grid-template-columns:28% 1fr 28%; pointer-events:none; }
.tz { pointer-events:auto; background:transparent; -webkit-tap-highlight-color:transparent; }

/* card body */
.card-body {
  position:relative; z-index:3; flex:1;
  display:flex; flex-direction:column;
  padding:10px 20px 18px; overflow:hidden;
}
.card-top { display:flex; align-items:center; justify-content:space-between; margin-bottom:4px; }
.prog { font-size:.68rem; color:var(--faint); }

/* HSK pills */
.hsk-pill { padding:4px 11px; border-radius:999px; font-size:.66rem; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
.hsk-1 { background:rgba(220,158,40,.14);  color:#d89e32; border:1px solid rgba(220,158,40,.28); }
.hsk-2 { background:rgba(42,118,136,.16);  color:#3a9aaf; border:1px solid rgba(42,118,136,.30); }
.radical { background:rgba(62,156,143,.14); color:#3e9c8f; border:1px solid rgba(62,156,143,.28); }
.component { background:rgba(120,120,130,.14); color:#7e7e88; border:1px solid rgba(120,120,130,.28); }
.hsk-3 { background:rgba(210,95,30,.14);   color:#c8612a; border:1px solid rgba(210,95,30,.28); }
.hsk-4 { background:rgba(168,28,38,.16);   color:#c04050; border:1px solid rgba(168,28,38,.30); }
.hsk-5 { background:rgba(55,90,148,.20);   color:#6890c8; border:1px solid rgba(55,90,148,.38); }
.hsk-6 { background:rgba(118,60,115,.16);  color:#9e6e9c; border:1px solid rgba(118,60,115,.30); }
.hsk-7 { background:rgba(70,74,82,.20);    color:#6b6f78; border:1px solid rgba(70,74,82,.40); }

/* hanzi — fills remaining space, always centred */
.hanzi-wrap {
  flex:1; display:flex; align-items:center; justify-content:center;
}
.hanzi {
  font-family:var(--cjk);
  font-size:clamp(118px, 34vw, 190px); font-weight:400; line-height:1;
  color:var(--txt); -webkit-text-fill-color:var(--txt);
}

/* answer block fades in below */
.answer-area {
  min-height:90px; display:flex; flex-direction:column;
  align-items:center; gap:6px; padding-top:6px;
}
.answer-block { width:100%; display:flex; flex-direction:column; align-items:center; gap:7px; animation:fadeUp 180ms ease both; }
@keyframes fadeUp { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} }
.ans-pinyin  { font-size:1.25rem; font-weight:600; color:var(--txt); }
.ans-meaning { font-size:.9rem; color:var(--muted); text-align:center; }
.ans-example {
  margin-top:2px; background:var(--surf); border:1px solid var(--bdr);
  border-radius:14px; padding:9px 14px;
  font-size:.78rem; text-align:center; line-height:1.4; width:100%;
}
.ans-example span { display:block; font-size:.68rem; font-weight:700; letter-spacing:.07em; color:var(--faint); margin-bottom:2px; }
.ans-example strong { color:var(--txt); }
.ans-example em { color:var(--faint); font-style:italic; }
.ans-hint { font-size:.68rem; color:var(--faint); margin-top:2px; }
.tap-hint { font-size:.68rem; color:var(--faint); text-align:center; }

/* card bottom — reserve room on the right so the listen button sits beside the
   HSK level pill instead of overlapping it. */
.card-bottom { display:flex; align-items:center; justify-content:space-between; margin-top:6px; padding-right:46px; }

/* Listen button: plays the character's pronunciation. Sits above the tap zones
   (z-index 6 > 5) so it's tappable, and hides on the info page. */
.listen-btn {
  position:absolute; bottom:14px; right:18px; z-index:6;
  width:34px; height:34px; border-radius:50%;
  display:grid; place-items:center;
  background:var(--surf); border:1px solid var(--bdr);
  color:var(--muted); pointer-events:auto;
  transition:transform 120ms ease, color 150ms ease, background 150ms ease, opacity 180ms ease;
}
.listen-btn svg { width:18px; height:18px; }
.listen-btn:active { transform:scale(.9); }
.listen-btn.playing {
  color:var(--blue); background:var(--blue-bg);
  border-color:color-mix(in srgb, var(--blue) 30%, var(--bdr));
}
.card[data-page="1"] .listen-btn { opacity:0; pointer-events:none; }
@media (hover:hover) and (pointer:fine) {
  .listen-btn:hover { color:var(--blue); }
}

/* pages slider */
.pages { position:absolute; inset:0; display:flex; transition:transform 280ms cubic-bezier(0.4,0,0.2,1); will-change:transform; }
.page { min-width:100%; height:100%; display:flex; flex-direction:column; padding:10px 20px 18px; overflow:hidden; }

/* info page */
.page.info-page { gap:10px; overflow-y:auto; -webkit-overflow-scrolling:touch; justify-content:flex-start; }
.page.info-page::-webkit-scrollbar { display:none; }
.info-row { display:grid; grid-template-columns:1fr 1fr; gap:7px; width:100%; }
.info-cell { background:var(--surf); border:1px solid var(--bdr); border-radius:14px; padding:9px 12px; }
.info-cell .lbl { font-size:.62rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--faint); margin-bottom:2px; }
.info-cell .val { font-size:.88rem; font-weight:600; color:var(--txt); line-height:1.3; }
.info-cell .val .hsk-pill { white-space:nowrap; max-width:100%; }
.info-cell.full { grid-column:1/-1; }
.info-cell .card-word-btn { display:inline-flex; align-items:center; width:auto; margin:2px 0 0; padding:3px 11px; border-radius:10px; background:var(--surf2); border:1px solid var(--bdr); cursor:pointer; color:var(--txt); font-size:1.45rem; line-height:1.15; -webkit-tap-highlight-color:transparent; transition:background .15s; }
.info-cell .card-word-btn:active { background:var(--bdr); }
.info-cell.cell-listen { position:relative; }
.info-cell.cell-listen .cell-listen-main { padding-right:30px; }
.cell-listen-btn {
  position:absolute; bottom:8px; right:8px;
  width:22px; height:22px; border-radius:50%;
  display:grid; place-items:center;
  background:var(--surf2); border:1px solid var(--bdr);
  color:var(--muted); cursor:pointer;
  transition:transform 120ms ease, color 150ms ease, background 150ms ease;
}
.cell-listen-btn svg { width:12px; height:12px; }
.phrase-listen-btn { top:7px; right:7px; bottom:auto; }
.cell-listen-btn:active { transform:scale(.9); }
.cell-listen-btn.playing {
  color:var(--blue); background:var(--blue-bg);
  border-color:color-mix(in srgb, var(--blue) 30%, var(--bdr));
}
@media (hover:hover) and (pointer:fine) {
  .cell-listen-btn:hover { color:var(--blue); }
}
.char-chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:3px; }
.char-chip { display:inline-flex; align-items:center; gap:5px; background:var(--surf2); border:1px solid var(--bdr); border-radius:10px; padding:3px 9px; }
.char-chip .cc-glyph { font-family:var(--cjk); font-size:1.05rem; font-weight:600; color:var(--txt); line-height:1.2; }
.char-chip .cc-py { font-size:.66rem; color:var(--muted); }
.char-chips.stack .char-chip { flex-direction:column; gap:1px; padding:4px 8px; }
.char-chips.stack .char-chip .cc-py { text-align:center; }
.char-chip-empty { font-size:.85rem; color:var(--faint); }
.char-chip-link { cursor:pointer; font:inherit; background:var(--blue-bg); border-color:color-mix(in srgb, var(--blue) 35%, transparent); transition:background .15s, border-color .15s, transform .1s; -webkit-tap-highlight-color:transparent; }
.char-chip-link .cc-glyph { color:var(--blue); }
.char-chip-link .cc-py { color:var(--blue); }
@media (hover:hover) and (pointer:fine) { .char-chip-link:hover { background:color-mix(in srgb, var(--blue) 22%, transparent); border-color:var(--blue); } }
.char-chip-link:active { transform:scale(.94); background:color-mix(in srgb, var(--blue) 30%, transparent); }
.char-chip-radical { background:var(--radical-bg); border-color:color-mix(in srgb, var(--radical) 35%, transparent); }
.char-chip-radical .cc-glyph, .char-chip-radical .cc-py { color:var(--radical); }
@media (hover:hover) and (pointer:fine) { .char-chip-radical:hover { background:color-mix(in srgb, var(--radical) 22%, transparent); border-color:var(--radical); } }
.char-chip-radical:active { background:color-mix(in srgb, var(--radical) 30%, transparent); }
.char-chip-component { background:var(--component-bg); border-color:color-mix(in srgb, var(--component) 35%, transparent); }
.char-chip-component .cc-glyph, .char-chip-component .cc-py { color:var(--component); }
@media (hover:hover) and (pointer:fine) { .char-chip-component:hover { background:color-mix(in srgb, var(--component) 22%, transparent); border-color:var(--component); } }
.char-chip-component:active { background:color-mix(in srgb, var(--component) 30%, transparent); }
/* Tappable compound-character glyph (radical / component modals): the list is
   homogeneous (every row leads to a char), so it stays calm like .word-exp-link —
   blue underlined text rather than a filled pill. */
.compound-char { align-self:flex-start; cursor:pointer; font-family:var(--cjk); font-size:.9rem; font-weight:700; line-height:1.2; color:var(--char); background:transparent; border:none; border-radius:6px; padding:1px 3px; text-decoration:underline; text-decoration-color:color-mix(in srgb, var(--char) 45%, transparent); text-underline-offset:3px; -webkit-appearance:none; appearance:none; -webkit-tap-highlight-color:transparent; transition:background .15s, transform .1s; }
@media (hover:hover) and (pointer:fine) { .compound-char:hover { background:var(--char-bg); } }
.compound-char:active { transform:scale(.94); background:var(--char-bg); }
/* --mb drives the accent so the back button can be tinted by its destination:
   blue → char (default), green → radical, gray → component. */
.modal-back { --mb:var(--blue); --mb-bg:var(--blue-bg); display:inline-flex; align-items:center; gap:5px; align-self:flex-start; margin:-2px 0 10px; padding:5px 12px 5px 9px; background:var(--mb-bg); border:1px solid color-mix(in srgb, var(--mb) 35%, transparent); border-radius:999px; color:var(--mb); font-size:.78rem; font-weight:600; cursor:pointer; transition:background .15s, border-color .15s, transform .1s; -webkit-tap-highlight-color:transparent; }
.modal-back svg { stroke:var(--mb); }
.modal-back span { font-family:var(--cjk); color:var(--mb); }
@media (hover:hover) and (pointer:fine) { .modal-back:hover { background:color-mix(in srgb, var(--mb) 22%, transparent); border-color:var(--mb); } }
.modal-back:active { transform:scale(.96); background:color-mix(in srgb, var(--mb) 30%, transparent); }
.modal-back-radical   { --mb:var(--radical);   --mb-bg:var(--radical-bg); }
.modal-back-component { --mb:var(--component); --mb-bg:var(--component-bg); }
.modal-back-word      { --mb:var(--word);      --mb-bg:var(--word-bg); }
.cc-desc { font-weight:500; color:var(--muted); }
.words-title { font-size:.68rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--faint); width:100%; }
.words-list { background:var(--surf); border:1px solid var(--bdr); border-radius:16px; overflow:hidden; width:100%; }
.word-item { padding:9px 13px; font-size:.78rem; color:var(--muted); line-height:1.4; border-bottom:1px solid var(--bdr); display:flex; flex-direction:column; gap:1px; }
.word-item:last-child { border-bottom:none; }
.word-item strong { color:var(--txt); font-size:.9rem; font-family:var(--cjk); }

/* collapsible word groups in modal */
.word-group { border-bottom:1px solid var(--bdr); }
.word-group:last-child { border-bottom:none; }
.word-group-header {
  width:100%; display:flex; align-items:center; gap:6px;
  padding:9px 13px; font-size:.72rem; font-weight:700;
  color:var(--muted); text-align:left; cursor:pointer;
  background:rgba(0,0,0,.12);
}
.word-group-header span:first-child { flex:1; }
.word-group-count {
  font-size:.66rem; background:var(--bdr); color:var(--faint);
  border-radius:999px; padding:1px 7px; font-weight:600;
}
.word-group-chev { transition:transform 200ms ease; flex-shrink:0; }
.word-group-chev.open { transform:rotate(180deg); }
.word-group-body { overflow:hidden; transition:max-height 250ms ease, opacity 200ms ease; max-height:2000px; opacity:1; }
.word-group-body.collapsed { max-height:0; opacity:0; }
.word-pinyin  { font-size:.74rem; color:var(--muted); }
.word-meaning { font-size:.74rem; color:var(--faint); }

/* expandable word → phrases in modal */
.word-exp { background:var(--surf); border:1px solid var(--bdr); border-radius:9px; overflow:hidden; }
.word-exp-header {
  width:100%; display:flex; align-items:flex-start; gap:8px;
  padding:7px 9px; text-align:left; cursor:pointer; background:transparent;
}
.word-exp-info { flex:1; min-width:0; display:flex; flex-direction:column; gap:1px; align-items:flex-start; }
.word-exp-info strong { color:var(--txt); font-size:.9rem; font-family:var(--cjk); }
/* In the char modal the compound-words list is homogeneous (every row is a word),
   so the glyph stays calm: purple underlined text rather than a filled pill — the
   type badge already gives context and a long column of pills reads as noise. */
.word-exp-link { align-self:flex-start; cursor:pointer; font-family:var(--cjk); font-size:.9rem; font-weight:700; line-height:1.2; color:var(--word); background:transparent; border:none; border-radius:6px; padding:1px 3px; text-decoration:underline; text-decoration-color:color-mix(in srgb, var(--word) 45%, transparent); text-underline-offset:3px; -webkit-appearance:none; appearance:none; -webkit-tap-highlight-color:transparent; transition:background .15s, transform .1s; }
@media (hover:hover) and (pointer:fine) { .word-exp-link:hover { background:var(--word-bg); } }
.word-exp-link:active { transform:scale(.94); background:var(--word-bg); }
.word-exp-chev { transition:transform 200ms ease; flex-shrink:0; margin-top:4px; color:var(--faint); }
.word-exp-chev.open { transform:rotate(180deg); }
.phrase-body { overflow:hidden; transition:max-height 250ms ease, opacity 200ms ease; max-height:1500px; opacity:1; }
.phrase-body.collapsed { max-height:0; opacity:0; }
.phrase-item {
  padding:7px 11px 7px 18px; border-top:1px solid var(--bdr);
  background:var(--surf2); display:flex; flex-direction:column; gap:1px;
}
.wp-hanzi   { color:var(--txt); font-size:.9rem; font-weight:600; line-height:1.35; font-family:var(--cjk); }
.wp-pinyin  { font-size:.74rem; color:var(--muted); line-height:1.3; }
.wp-meaning { font-size:.74rem; color:var(--faint); line-height:1.3; }

/* done */
.done {
  position:absolute; inset:0; display:flex; flex-direction:column;
  align-items:center; justify-content:center; gap:14px; text-align:center; padding:28px;
  background:var(--surf2); border:1px solid var(--bdr); border-radius:24px; box-shadow:var(--sh);
}
.done-emoji { font-size:2.8rem; }

/* ── PHRASE CARDS ── */
.phrase-card {
  position:absolute; inset:0;
  background:var(--surf); border:1px solid var(--bdr); border-radius:24px;
  display:flex; align-items:center; justify-content:center;
  cursor:grab; user-select:none; -webkit-user-select:none;
  will-change:transform; overflow:hidden;
}
/* Cap the content on tall/wide desktop windows so it keeps phone-like proportions
   (centred column) instead of the text ballooning to fill an 860px-wide, full-height
   card. On a phone these caps rarely bind, so mobile is unchanged. */
/* Size container: text sizes below are expressed in cqh (1% of this box's height),
   so they scale with the card and are identical across every card on a device — no
   JS. Its own size is fixed by the flex/height rules, independent of its content. */
.phrase-inner {
  padding:24px 14px; display:flex; flex-direction:column; gap:12px;
  width:100%; max-width:520px; height:100%; max-height:560px; margin:0 auto;
  overflow:hidden;
  container-type:size;
}
/* Top 2/3 of the card: the text, vertically centred. Bottom 1/3: the image. The 2:1
   flex split holds regardless of content length. One base font (clamp in cqh) drives
   the block; children are `em` multiples of it, so the whole thing scales together.
   A soft top/bottom mask fades the edges only when a rare long phrase overflows the
   centred box (short phrases don't reach the edges, so the fade is invisible). */
.phrase-text {
  flex:2; min-height:0; overflow:hidden;
  display:flex; flex-direction:column; gap:.5em; justify-content:center;
  font-size:clamp(.8rem, 3cqh, 1.2rem);
  padding-block:.5em;   /* breathing room so the fade lands mostly off the text */
  -webkit-mask-image:linear-gradient(to bottom, transparent 0, #000 4%, #000 96%, transparent 100%);
          mask-image:linear-gradient(to bottom, transparent 0, #000 4%, #000 96%, transparent 100%);
}
.phrase-hanzi {
  font-family:var(--cjk);
  font-size:2.8em; font-weight:400; line-height:1; color:var(--txt); flex-shrink:0;
  white-space:nowrap;   /* the phrase title stays on a single row */
}
.phrase-pinyin { font-size:1em; color:var(--muted); font-weight:300; flex-shrink:0; }
.phrase-literal { font-size:.82em; color:var(--faint); font-style:italic; flex-shrink:0; }
.phrase-divider { height:1px; background:var(--bdr); margin:.13em 0; flex-shrink:0; }
.phrase-meaning { font-size:.92em; color:var(--txt); line-height:1.65; font-weight:300; flex-shrink:0; }
.phrase-origin { font-size:.78em; color:var(--faint); line-height:1.55; flex-shrink:0; }
.phrase-img { width:100%; border-radius:12px; object-fit:contain; flex:1; min-height:0; margin-top:0; mix-blend-mode:multiply; user-select:none; -webkit-user-select:none; -webkit-user-drag:none; pointer-events:none; }
[data-theme="dark"] .phrase-img { mix-blend-mode:screen; }
.done h2 { font-size:1.2rem; font-weight:700; }
.done p  { font-size:.84rem; color:var(--muted); }
.done-pills { display:flex; flex-wrap:wrap; justify-content:center; gap:9px; margin-top:4px; }
.pill { min-height:44px; padding:10px 16px; border-radius:999px; background:var(--surf); border:1px solid var(--bdr); font-size:.8rem; font-weight:600; }
.pill:active { opacity:.7; transform:scale(.96); }

/* ══════════════════════════════
   SCREEN 2 — HSK GROUPS
══════════════════════════════ */
.groups-header {
  display:flex; align-items:center; justify-content:space-between;
}
.groups-title { font-size:.95rem; font-weight:700; }
.groups-sub   { font-size:.65rem; color:var(--faint); margin-top:1px; }

.groups-scroll {
  flex:1; overflow-y:auto; -webkit-overflow-scrolling:touch;
  overscroll-behavior-y:contain;   /* suppress the browser's native pull-to-refresh */
  display:flex; flex-direction:column;
  padding-bottom:4px;
  position:relative;               /* anchor the pull-to-refresh indicator */
}
.groups-scroll > #groups-content { display:flex; flex-direction:column; gap:8px; }
.groups-legend { display:flex; gap:14px; padding:0 2px 4px; }
.groups-legend-item { display:flex; align-items:center; gap:5px; font-size:.66rem; color:var(--faint); }
.groups-legend-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
.known-dot  { background:var(--green); }
.review-dot { background:var(--blue); }

/* The native scrollbar can't be dragged on touch, so it's hidden in favour of the
   custom fast-scroll thumb below (see js/groups-scrollbar.js). */
.groups-scroll::-webkit-scrollbar { width:0; height:0; }
.groups-scroll { scrollbar-width:none; }

/* The whole app is a touch-only surface: no accidental text selection or iOS
   long-press callout on any text, tile or empty gap. Applied at the root so it
   also covers body-level overlays (modals, toast, coach tip, scroll thumb) that
   sit outside the per-screen containers. */
body {
  user-select:none; -webkit-user-select:none; -webkit-touch-callout:none;
}
/* Opt selectable content back in — anywhere in the app: form fields; the Translator
   hanzi translation (.js-zh); and the example phrases in the Word modal
   (.word-phrases-list) and Char modal (.phrase-body). The sent message (.js-original)
   is deliberately NOT selectable — tapping it raises a "Copy" popover instead. */
input, textarea, [contenteditable="true"],
.js-zh,
.word-phrases-list, .phrase-body {
  user-select:text; -webkit-user-select:text; -webkit-touch-callout:default;
}

/* Custom fast-scroll thumb: a wide, always-grabbable pill pinned to the right
   edge of the groups scroller. The 22px box is the touch target; the visible bar
   inside stays slim and elegant, thickening while dragged. */
#groupsScrollbar {
  position:fixed; z-index:40; width:22px;
  display:none; align-items:stretch; justify-content:flex-end;
  padding-right:2px; box-sizing:border-box;
  touch-action:none; cursor:grab; -webkit-tap-highlight-color:transparent;
  /* Overlay behaviour: hidden and inert until scrolling/dragging reveals it,
     then it fades back out on its own (see js/groups-scrollbar.js). */
  opacity:0; pointer-events:none;
  transition:opacity .35s ease;
}
#groupsScrollbar.visible  { opacity:1; pointer-events:auto; }
#groupsScrollbar.dragging { cursor:grabbing; transition:none; }
#groupsScrollbar .gsb-bar {
  width:5px; border-radius:999px; background:var(--muted); opacity:.55;
  transition:opacity .18s ease, width .18s ease;
}
#groupsScrollbar:hover .gsb-bar    { opacity:.8; }
#groupsScrollbar.dragging .gsb-bar { width:7px; opacity:1; }

#groupsScrollbarBubble {
  position:fixed; z-index:41; display:none; transform:translateY(-50%);
  background:var(--surf2); color:var(--txt); border:1px solid var(--bdr);
  font-size:.82rem; font-weight:700; padding:6px 12px; border-radius:12px;
  box-shadow:0 8px 24px rgba(0,0,0,.28); pointer-events:none; white-space:nowrap;
}

/* search bar */
.search-bar-wrap {
  display:none; align-items:center; gap:8px;
  background:var(--surf); border:1px solid var(--bdr); border-radius:12px;
  padding:0 10px; height:36px; flex-shrink:0;
}
.search-bar-wrap.open { display:flex; }
.search-bar-wrap input {
  flex:1; background:none; border:none; outline:none;
  font:inherit; font-size:.82rem; color:var(--txt);
  -webkit-text-fill-color:var(--txt);
}
.search-bar-wrap input::placeholder { color:var(--faint); opacity:.5; }
#searchClear { display:grid; place-items:center; color:var(--faint); padding:0; }

.hsk-group { display:flex; flex-direction:column; gap:8px; }
.hsk-group-header {
  display:flex; align-items:center; justify-content:space-between;
}
.hsk-group-label {
  display:flex; align-items:center; gap:8px;
}
.hsk-group-label .badge {
  padding:4px 12px; border-radius:999px;
  font-size:.65rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase;
}
.hsk-group-label .count { font-size:.72rem; color:var(--faint); }
.hsk-prog-bar {
  height:4px; background:var(--bdr); border-radius:2px; overflow:hidden; flex:1; max-width:80px;
}
.hsk-prog-fill { height:100%; border-radius:2px; transition:width 400ms ease; }
.hsk-1-fill { background:#d89e32; }
.hsk-2-fill { background:#3a9aaf; }
.hsk-3-fill { background:#c8612a; }
.hsk-4-fill { background:#c04050; }
.hsk-5-fill { background:#6890c8; }
.hsk-6-fill { background:#9e6e9c; }
.hsk-7-fill { background:#4a4e56; }
.hsk-full-bar {
  height:5px; background:var(--bdr); border-radius:3px; overflow:hidden; width:100%;
}
.hsk-full-bar-fill { height:100%; border-radius:3px; transition:width 400ms ease; }

.char-grid-wrap {
  overflow:hidden;
  transition: max-height 320ms cubic-bezier(0.4,0,0.2,1), opacity 240ms ease, margin-top 320ms ease;
  /* Expanded steady state is uncapped so very tall grids (e.g. HSK7 with 5000+
     tiles) are never clipped. A pixel height is pinned by JS only for the
     duration of the open/close transition; see animateGridWrap() in ui.js. */
  max-height: none;
  opacity: 1;
  margin-top: 0;
}
.char-grid-wrap.collapsed {
  max-height: 0 !important;
  opacity: 0;
  margin-top: -4px;
}
.char-grid {
  display:grid;
  grid-template-columns: repeat(6, 1fr);
  gap:8px;
  padding-bottom:8px;
}
.char-grid-wrap { container-type:inline-size; }
@container (min-width:380px){ .char-grid { grid-template-columns: repeat(7, 1fr); } }
@container (min-width:440px){ .char-grid { grid-template-columns: repeat(8, 1fr); } }
@container (min-width:520px){ .char-grid { grid-template-columns: repeat(9, 1fr); } }
@container (min-width:600px){ .char-grid { grid-template-columns: repeat(10, 1fr); } }
@container (min-width:680px){ .char-grid { grid-template-columns: repeat(11, 1fr); } }
@container (min-width:760px){ .char-grid { grid-template-columns: repeat(12, 1fr); } }
.hsk-group-header {
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  position: sticky;
  top: 0;
  z-index: 2;
  background: var(--bg);
  padding: 6px 0;
  margin: -6px 0;
}
.hsk-group-header:active > * { opacity: .65; }
.chevron {
  width:16px; height:16px; color:var(--faint); flex-shrink:0;
  transition: transform 280ms cubic-bezier(0.4,0,0.2,1);
}
.chevron.open { transform: rotate(180deg); }
.char-tile {
  aspect-ratio:1;
  display:flex; flex-direction:column; align-items:center; justify-content:center;
  border-radius:14px;
  background:var(--surf); border:1px solid var(--bdr);
  position:relative; cursor:pointer;
  transition: transform 120ms ease, background 120ms ease;
  user-select:none; -webkit-user-select:none;
  content-visibility: auto;
  /* `auto` lets the browser remember each tile's real rendered size once it has
     been on-screen, so off-screen tiles (skipped via content-visibility) keep an
     accurate height and the scrollbar/scroll position stays stable. 60px is the
     first-paint fallback. */
  contain-intrinsic-size: auto 60px;
  /* Let the 1fr grid tracks shrink below a tile's min-content. Without this the
     longest pinyin (e.g. "chuáng") pins every column wide and the whole grid
     overflows / clips on the right — affecting radicals, components and characters
     alike (word tiles escape it via their own word-break/overflow rules). */
  min-width:0; overflow:hidden;
}
/* Virtualized grids (big levels) mount only a screenful of tiles via
   virtual-grid.js, so content-visibility would only get in the way: buffered
   tiles must report their real height for the windowing maths to line up. */
.char-grid.vgrid > .char-tile {
  content-visibility: visible;   /* visible ⇒ never size-contained, so no intrinsic-size needed */
}
/* In a window the physically-first child changes as you scroll, so the
   `:first-child` label padding must not apply — every label has to keep the same
   height the windowing maths measured once, or the window would drift. */
.char-grid.vgrid > .char-grid-label { padding-top: 8px; }
.char-tile:active { transform:scale(.93); }
.char-tile .tc {
  font-family:var(--cjk);
  font-size:26px; line-height:1; color:var(--txt);
  -webkit-text-fill-color:var(--txt);
}
.char-tile .tp { font-size:.68rem; color:var(--faint); margin-top:2px; }
.groups-scroll.hide-pinyin .char-tile .tp { display:none; }
/* Word tiles hold multi-character words + longer pinyin, so let the content wrap
   and fit the tile width instead of overflowing (being clipped) the square. */
.char-tile.word-tile { padding:2px 4px; overflow:hidden; }
.char-tile.word-tile .tc { max-width:100%; text-align:center; line-height:1.1; word-break:break-word; }
.char-tile.word-tile .tp { max-width:100%; text-align:center; line-height:1.15; font-size:.6rem; word-break:break-word; }
.char-tile.known {
  background:var(--green-bg); border-color:rgba(104,191,138,.3);
}
.char-tile.known .tc { color:var(--green); -webkit-text-fill-color:var(--green); }
.char-tile.known .tp { color:var(--green); opacity:.6; }
.char-tile.repaso {
  background:var(--blue-bg); border-color:rgba(100,160,230,.3);
}
.char-tile.repaso .tc { color:var(--blue); -webkit-text-fill-color:var(--blue); }
.char-tile.repaso .tp { color:var(--blue); opacity:.6; }
.char-tile .toggle-btn { display:none; }
.groups-section-title {
  display:flex; align-items:flex-end; justify-content:space-between; gap:10px;
  font-size:.68rem; font-weight:700; letter-spacing:.08em; text-transform:uppercase;
  color:var(--faint); margin:16px 2px 3px;
}
.groups-section-title .groups-legend { padding:0; flex-shrink:0; }
.groups-section-title .groups-legend-item {
  text-transform:none; font-weight:400; letter-spacing:normal;
}
.groups-section-title .groups-legend-dot { opacity:.45; }
.groups-section-title:first-child { margin-top:2px; }
.groups-section-sub {
  display:block; font-size:.64rem; font-weight:400; letter-spacing:0;
  text-transform:none; color:var(--faint); opacity:.7; margin-top:2px;
}
.char-grid-label {
  grid-column: 1 / -1;
  font-size: .58rem; font-weight: 700; letter-spacing: .07em; text-transform: uppercase;
  color: var(--faint); padding: 8px 2px 2px;
}
.char-grid-label:first-child { padding-top: 2px; }
.char-tile.empty { opacity:.4; }
.char-tile.empty .tc { color:var(--faint); -webkit-text-fill-color:var(--faint); }
.char-tile.empty .tp { color:var(--faint); }
.radical-tile.active { background:rgba(62,156,143,.22); border-color:rgba(62,156,143,.5); }
.radical-tile.active .tc { color:#3e9c8f; -webkit-text-fill-color:#3e9c8f; }
.radical-tile.active .tp { color:#3e9c8f; opacity:.7; }
.char-grid.radical-filtered .radical-tile:not(.active) { display:none; }
.component-tile.active { background:rgba(120,120,130,.22); border-color:rgba(120,120,130,.5); }
.component-tile.active .tc { color:#7e7e88; -webkit-text-fill-color:#7e7e88; }
.component-tile.active .tp { color:#7e7e88; opacity:.7; }
.char-grid.component-filtered .component-tile:not(.active) { display:none; }

/* char detail modal */
.modal-backdrop {
  position:fixed; inset:0; z-index:100;
  background:rgba(0,0,0,.5); backdrop-filter:blur(4px);
  display:none; align-items:flex-end; justify-content:center;
  padding:0;
}
.modal-backdrop.open { display:flex; }
.modal {
  width:100%; max-width:480px;
  background:var(--surf2); border:1px solid var(--bdr);
  border-radius:24px 24px 0 0;
  box-shadow:var(--sh);
  padding:20px 20px;
  padding-bottom:max(28px, var(--safe-b));
  display:flex; flex-direction:column; gap:14px;
  max-height:88dvh; overflow-y:auto; -webkit-overflow-scrolling:touch;
  animation:slideUp 220ms cubic-bezier(0.16,1,0.3,1);
}
.modal::-webkit-scrollbar { display:none; }
#modal { overscroll-behavior:contain; }
@keyframes slideUp { from{transform:translateY(100%)} to{transform:translateY(0)} }

#reset-backdrop,
#delacct-backdrop,
#review-backdrop {
  opacity: 0;
  pointer-events: none;
  transition: opacity 200ms ease;
}
#reset-backdrop.open,
#delacct-backdrop.open,
#review-backdrop.open {
  opacity: 1;
  pointer-events: all;
}
/* ── SETTINGS & ACCOUNT PANELS (right-side push drawers, mirror the recents drawer) ── */
/* --app-gutter is the empty space beside the centred .root (max-width 860px) on
   wide/desktop screens, so the drawers hug the app's right edge instead of the
   far edge of the window. On phones the gutter is 0 (unchanged behaviour). */
:root { --settings-w: min(85%, 320px); --app-gutter: max(0px, calc((100vw - 860px) / 2)); }

body.settings-open .root,
body.account-open .root {
  transform: translateX(calc(-1 * var(--settings-w)));
  border-radius: 22px;
  overflow: hidden;
}
body.settings-dragging .root,
body.account-dragging .root { transition: none; }
body.settings-dragging .root, body.settings-open .root,
body.account-dragging .root, body.account-open .root { border-radius: 22px; overflow: hidden; }

.settings-panel,
.account-panel {
  position: fixed; top: 0; right: var(--app-gutter); bottom: 0; z-index: 1;
  width: calc(var(--settings-w) + 30px);
  display: flex; flex-direction: column;
  background: var(--surf);
  padding-top: max(14px, var(--safe-t));
  padding-right: max(10px, var(--safe-r));
  padding-bottom: max(10px, var(--safe-b));
  padding-left: 44px;
  visibility: hidden;
}
body.settings-open .settings-panel,
body.settings-dragging .settings-panel { visibility: visible; }
body.account-open .account-panel,
body.account-dragging .account-panel { visibility: visible; }

.settings-scrim,
.account-scrim {
  position: fixed; top: 0; bottom: 0; left: 0; right: calc(var(--settings-w) + var(--app-gutter));
  z-index: 3; pointer-events: none;
}
body.settings-open .settings-scrim { pointer-events: auto; }
body.account-open .account-scrim { pointer-events: auto; }

.settings-panel-head {
  display: flex; align-items: center; justify-content: space-between;
  padding: 6px 0 16px;
  font-size: .95rem; font-weight: 700; color: var(--txt);
}
.settings-panel-body {
  flex: 1; min-height: 0; overflow-y: auto;
  display: flex; flex-direction: column; gap: 6px;
  scrollbar-width: none;
}
.settings-panel-body::-webkit-scrollbar { display: none; }

.settings-item {
  display:flex; align-items:center; gap:12px;
  width:100%; padding:13px 14px; border-radius:14px;
  background:var(--surf); border:1px solid var(--bdr);
  color:var(--txt); font-size:.85rem; font-weight:600;
  cursor:pointer; text-align:left; text-decoration:none;
}
.settings-item:active { background:var(--surf2); }
.settings-item > span { flex:1; }
.settings-item > svg:first-child { color:var(--muted); flex-shrink:0; }
#settingsLearnBtn > svg:first-child { color:#b04030; }   /* slightly dark red */
.settings-item .settings-chevron { color:var(--faint); flex-shrink:0; }
.settings-item > .settings-item-value { flex:0 0 auto; color:var(--muted); font-weight:600; }
/* Review modal — star rating + comment */
.review-stars { display:flex; justify-content:center; gap:8px; touch-action:none; }
.review-star {
  padding:2px; background:none; border:none; cursor:pointer;
  color:var(--bdr); -webkit-tap-highlight-color:transparent; touch-action:none; transition:transform .1s;
}
.review-star svg { width:34px; height:34px; display:block; fill:currentColor; }
.review-star.on { color:#f5b301; }
.review-star:active { transform:scale(.88); }
.review-comment {
  width:100%; box-sizing:border-box; resize:none;
  background:var(--bg); border:1px solid var(--bdr); border-radius:12px;
  padding:10px 12px; font:inherit; font-size:.82rem; color:var(--txt); outline:none;
}
.review-comment:focus { border-color:var(--blue); }

/* Small legal links pinned to the bottom of the Settings panel (not main items). */
.settings-panel-foot {
  display:flex; align-items:center; justify-content:flex-end; gap:12px;
  padding:14px 2px max(6px, var(--safe-b)); margin-top:6px;
  font-size:.7rem; color:var(--faint);
}
.settings-panel-foot a { color:var(--faint); text-decoration:none; }
.settings-panel-foot a:active { opacity:.6; }
.settings-panel-foot .sep { opacity:.5; }
@media (hover:hover) and (pointer:fine) { .settings-panel-foot a:hover { color:var(--muted); text-decoration:underline; } }
/* Language toggle — compact pill sized to the active language only; EN shows its
   flag on the left, ES on the right, and a tap flips between them. */
.lang-toggle {
  height:28px; box-sizing:border-box;
  display:inline-flex; align-items:center;
  padding:0 9px; border-radius:999px;
  background:var(--surf); border:1px solid var(--bdr); cursor:pointer;
  color:var(--txt); font-size:.72rem; font-weight:700; letter-spacing:.04em;
  transition:border-color .15s;
}
.lang-toggle:active { opacity:.6; }
.lang-toggle .lang-seg { display:inline-flex; align-items:center; gap:5px; }
.lang-toggle .lang-seg:not(.active) { display:none; }
.lang-toggle .lang-flag {
  width:18px; height:18px; flex-shrink:0; border-radius:50%;
  box-shadow:0 0 0 1px rgba(0,0,0,.12); display:block;
}

/* Matrix Rain effect overlay */
.matrix-overlay {
  position: fixed; inset: 0; z-index: 50;
  background: #000;
  opacity: 0; visibility: hidden;
  transition: opacity 200ms ease;
}
.matrix-overlay.open { opacity: 1; visibility: visible; }
.matrix-overlay iframe {
  width: 100%; height: 100%; border: 0; display: block;
}
.modal-handle { width:36px; height:4px; border-radius:2px; background:var(--bdr); margin:0 auto 10px; }
.modal #modal-content .info-row { margin-top:-14px; }
/* Every modal opens with a head row (type badge, optional back button); it tucks
   up under the drag handle and the info grid sits just below it. */
.modal #modal-content .modal-head { display:flex; align-items:center; gap:8px; margin:-8px 0 8px; min-height:26px; }
.modal #modal-content .modal-head .modal-back { margin:0; }
.modal #modal-content .modal-head + .info-row { margin-top:0; }
.modal #modal-content .words-title { margin-top:6px; margin-bottom:-4px; }
.modal #modal-content .words-list { margin-top:6px; }

/* --tc / --tc-bg = the current modal's entity-type accent (set per data-type),
   so the badge and the glyph cell announce "you are in a word / char / …". */
#modal-content[data-type="char"]      { --tc:var(--char);      --tc-bg:var(--char-bg); }
#modal-content[data-type="word"]      { --tc:var(--word);      --tc-bg:var(--word-bg); }
#modal-content[data-type="radical"]   { --tc:var(--radical);   --tc-bg:var(--radical-bg); }
#modal-content[data-type="component"] { --tc:var(--component); --tc-bg:var(--component-bg); }
.modal-type {
  margin-left:auto; display:inline-flex; align-items:center; gap:5px;
  padding:3px 10px; border-radius:999px;
  font-size:.6rem; font-weight:800; letter-spacing:.09em; text-transform:uppercase;
  color:var(--tc); background:var(--tc-bg);
  border:1px solid color-mix(in srgb, var(--tc) 30%, transparent);
}
.modal-type .mt-dot { width:7px; height:7px; border-radius:50%; background:var(--tc); }
/* Status indicator in the modal head — a self-contained SVG icon per state
   (empty circle / green check disc / blue refresh disc). `color` drives the disc
   fill and the outline; the glyph centres itself inside the viewBox, so it stays
   perfectly centred at any screen zoom. Tapping cycles the status. */
.modal-status {
  width:16px; height:16px; padding:0; border:none; background:transparent;
  -webkit-appearance:none; appearance:none; -webkit-tap-highlight-color:transparent;
  display:inline-flex; cursor:pointer; flex-shrink:0; transition: transform 120ms ease;
  color:var(--muted);
}
.modal-status svg { width:100%; height:100%; display:block; }
.modal-status:active { transform:scale(.85); }
.modal-status[data-status="know"]   { color:#18b85a; }
.modal-status[data-status="review"] { color:#2f7bf0; }

/* HSK filter pills */
.hsk-filter-row { display:grid; grid-template-columns:repeat(7,1fr); gap:4px; }
.hsk-filter-pill {
  padding:6px 1px; border-radius:999px;
  font-size:clamp(.56rem, 2.6vw, .72rem); font-weight:700;
  letter-spacing:.01em; text-transform:uppercase; cursor:pointer; text-align:center;
  opacity:.35; transition: opacity 150ms ease, transform 120ms ease;
  border: 1px solid transparent; width:100%;
  white-space:nowrap;
}
.hsk-filter-pill.active { opacity:1; }
.hsk-filter-pill:active { transform:scale(.94); }
.filter-hint { font-size:.66rem; color:var(--faint); margin-top:-4px; }
.status-filter-row { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; }
.status-filter-pill {
  padding:6px 4px; border-radius:999px; font-size:.66rem; font-weight:700;
  letter-spacing:.06em; text-transform:uppercase; cursor:pointer; text-align:center;
  opacity:.35; transition: opacity 150ms ease, transform 120ms ease;
  border: 1px solid transparent; width:100%;
}
.status-filter-pill.active { opacity:1; }
.status-filter-pill:active { transform:scale(.94); }
.status-left   { background:rgba(143,164,176,.15); color:var(--muted); border-color:rgba(143,164,176,.3); }
.status-know   { background:var(--green-bg); color:var(--green); border-color:rgba(104,191,138,.28); }
.status-review { background:var(--blue-bg); color:var(--blue); border-color:rgba(100,160,230,.3); }
.groups-filter-row { display:grid; grid-template-columns:repeat(2,1fr); gap:8px; }
.groups-pill { background:var(--surf); color:var(--muted); border-color:var(--bdr); }
.profile-section { display:flex; flex-direction:column; gap:8px; }
/* Profile spacing system: 16px between sections, 8px rhythm inside each, and
   hints hug the control they describe (negative margin on .filter-hint). */
#profile-scroll { gap:16px; }

/* ══════════════════════════════
   SCREEN — TRANSLATOR
══════════════════════════════ */
.translator-scroll {
  flex:1; min-height:0;
  overflow-y:auto; -webkit-overflow-scrolling:touch; touch-action:pan-y;
  overscroll-behavior-y:contain;   /* suppress the browser's native pull-to-refresh */
  display:flex; flex-direction:column;
  /* Clear the floating composer (--composer-h synced from JS) so the newest
     message can scroll fully into view above the pill instead of behind it. */
  padding-bottom:calc(var(--composer-h, 60px) + 12px);
  position:relative;               /* anchor the pull-to-refresh indicator */
}
/* Holds the results; translates down with the pull (iOS-style) so the content
   physically follows the gesture, then snaps back with a soft spring. */
.tr-content { display:flex; flex-direction:column; gap:12px; }
/* Chat-style stack: each translation is a message; a divider separates them. */
.tr-conversation { display:flex; flex-direction:column; gap:12px; }
/* Pull-to-clear: the messages glide up and fade before being removed, instead of
   vanishing abruptly (see discardChat in translator.js). */
.tr-conversation.clearing {
  transition:transform .3s cubic-bezier(.4,0,.2,1), opacity .3s ease;
  transform:translateY(-28px); opacity:0;
}

/* "New chat" archive animation: a body-level clone of the messages, presented as a
   clean elevated card, that recedes into the Recents icon (see archiveToRecents in
   translator.js). Fixed so the scroller can't clip it; collapses from its top edge
   toward the icon. */
.tr-archive-fly {
  position:fixed; z-index:200; pointer-events:none; transform-origin:50% 0;
  box-sizing:border-box; padding:12px; max-height:44vh; overflow:hidden;
  border-radius:20px; border:1px solid var(--bdr);
  background:var(--surf2);   /* solid card — no backdrop blur, smoother + more stable */
  box-shadow:0 24px 60px rgba(0,0,0,.30), 0 2px 8px rgba(0,0,0,.12);
  /* motion is driven by the Web Animations API in translator.js */
}
/* Long chats: cap the card and fade its bottom edge, like a preview thumbnail. */
.tr-archive-fly.clipped {
  -webkit-mask-image:linear-gradient(to bottom, #000 66%, transparent);
          mask-image:linear-gradient(to bottom, #000 66%, transparent);
}
/* The Recents icon springs as the card lands in it. */
@keyframes tr-recents-pulse {
  0%   { transform:scale(1); }
  35%  { transform:scale(1.22); }
  60%  { transform:scale(.96); }
  100% { transform:scale(1); }
}
.tr-recents-pulse { animation:tr-recents-pulse .52s cubic-bezier(.34,1.56,.64,1); }
.tr-message { display:flex; flex-direction:column; gap:12px; }
.tr-message + .tr-message { border-top:1px solid var(--bdr); padding-top:12px; }

/* "Thinking" placeholder shown in an edited slot while its translation loads. */
.tr-thinking { display:flex; gap:6px; padding:4px 2px; }
.tr-thinking span {
  width:7px; height:7px; border-radius:50%;
  background:var(--muted); opacity:.35;
  animation:tr-think 1.2s infinite ease-in-out;
}
.tr-thinking span:nth-child(2) { animation-delay:.16s; }
.tr-thinking span:nth-child(3) { animation-delay:.32s; }
@keyframes tr-think {
  0%, 70%, 100% { opacity:.3; transform:translateY(0); }
  35%           { opacity:1; transform:translateY(-3px); }
}

/* ── Shared pull-to-refresh (translator results, groups filters, …) ── */
/* Content wrapper that follows the pull and snaps back with a soft spring. */
.pull-follow:not(.dragging) { transition:transform .34s cubic-bezier(.22,1,.36,1); }
/* The disc: an iOS segmented spinner that slides down as the user drags, then
   spins briefly while the refresh action runs. */
.pull-disc {
  position:absolute; top:6px; left:50%; margin-left:-16px;
  width:32px; height:32px;
  display:grid; place-items:center; border-radius:50%;
  background:var(--surf2); border:1px solid var(--bdr); color:var(--muted);
  box-shadow:var(--sh);
  opacity:0; transform:translateY(-48px);
  z-index:6; pointer-events:none;
}
.pull-disc:not(.dragging) { transition:opacity .2s ease, transform .28s cubic-bezier(.34,1.56,.64,1); }
/* Optional caption under the disc (e.g. "Pull to clear" → "Release to clear"). */
.pull-label {
  position:absolute; top:-16px; left:0; right:0;
  text-align:center; font-size:.7rem; font-weight:400;
  letter-spacing:.02em; color:var(--faint);
  opacity:0; pointer-events:none; z-index:6;
}
.pull-label.ready { color:var(--green); }
/* Translator pull is a destructive "clear", so its caption arms in red. */
#trPullLabel.ready { color:var(--red); }
.pull-label:not(.dragging) { transition:opacity .2s ease, transform .28s cubic-bezier(.34,1.56,.64,1); }
/* Exit-up (pull-to-clear): the wheel + caption slide up and fade, in sync with the
   cleared content. A plain ease-out (no spring overshoot) reads as "lifting away". */
.pull-disc.exiting, .pull-label.exiting {
  transition:transform .34s cubic-bezier(.4,0,.2,1), opacity .3s ease;
}
.pull-disc .pull-spinner { width:18px; height:18px; }
.pull-disc .pull-spinner line { opacity:0; }   /* bars revealed progressively by JS */
.pull-disc.ready { color:var(--green); border-color:var(--green); }
/* Translator pull is a destructive "clear", so its wheel arms in red. On release
   it spins in the default grey, like groups. */
#trPull.ready { color:var(--red); border-color:var(--red); }
/* iOS-style ratchet: 12 discrete steps so the bright bar chases around. */
.pull-disc.spinning .pull-spinner { animation:pull-spin .85s steps(12,end) infinite; }
@keyframes pull-spin { to { transform:rotate(360deg); } }
/* The conversation uses a custom overlay scrollbar (built in translator.js) that
   stops at the composer instead of running behind the floating pill — so the native
   scrollbar is hidden. */
.translator-scroll { scrollbar-width:none; }
.translator-scroll::-webkit-scrollbar { width:0; height:0; }

/* Custom draggable scrollbar (iOS-style capsule). Positioned in the screen, spanning
   from the scroll's top down to the composer — never behind it (see layoutScrollbar). */
.tr-scrollbar {
  position:absolute; z-index:4; width:4px;
  right:max(16px, var(--safe-r));
  pointer-events:none;
  opacity:0; transition:opacity .3s ease;
}
.tr-scrollbar.visible { opacity:1; }
.tr-scrollbar-thumb {
  position:absolute; left:0; right:0; top:0; will-change:transform;
  border-radius:999px; pointer-events:auto; cursor:grab;
  background-color:color-mix(in srgb, var(--muted) 55%, transparent);
  transition:background-color .15s ease;
}
.tr-scrollbar-thumb:hover { background-color:color-mix(in srgb, var(--muted) 72%, transparent); }
.tr-scrollbar-thumb:active { cursor:grabbing; background-color:color-mix(in srgb, var(--muted) 85%, transparent); }

/* Bottom composer (ChatGPT-style): floats over the conversation so its exterior is
   transparent and messages scroll behind / around the pill. Positioned within the
   .screen (an absolute ancestor); its insets mirror the screen's own padding so the
   pill lines up with the messages. pointer-events:none lets taps in the transparent
   exterior reach the messages behind it — only the pill (and edit banner) catch them. */
.tr-composer {
  position:absolute; z-index:5;
  left:max(16px, var(--safe-l));
  right:max(16px, var(--safe-r));
  bottom:8px;
  pointer-events:none;
}
.tr-input-field, .tr-edit-banner { pointer-events:auto; }
/* Composer: one row while the text is short (language · textarea · mic/send). Once
   the text nears the controls, JS adds .multiline and it becomes two rows — textarea
   full-width on top, controls below — so writing can continue across the whole line
   (see updateComposerMode in translator.js). A grid with named areas keeps the
   language button on the left in BOTH layouts. */
.tr-input-field {
  position:relative; display:grid; gap:6px; align-items:center;
  grid-template-columns:auto 1fr auto;
  grid-template-areas:"lang input tools";
  /* Floating pill. Opaque fallback for browsers without backdrop-filter; the glass
     variant below overrides it. A soft lift shadow + bright top rim read as floating. */
  background:var(--surf2);
  border:1px solid var(--bdr);
  border-radius:24px;
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, #fff 22%, transparent),
    0 6px 24px -10px rgba(0,0,0,.28);
  padding:6px 8px; --sb:0px;
  /* No will-change:transform here — promoting the pill to its own composited layer
     breaks backdrop-filter (the backdrop isn't captured), hiding the glass effect. */
  transform-origin:50% 100%;
}
/* Liquid-glass pill: a strongly translucent fill over a blurred+saturated backdrop,
   so the messages scrolling behind it show through frosted (iOS-style). The fill is
   kept low (like the tab bar's glass) so the content actually reads through it. */
@supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
  .tr-input-field {
    background:color-mix(in srgb, var(--surf2) 55%, transparent);
    -webkit-backdrop-filter:blur(22px) saturate(180%);
    backdrop-filter:blur(22px) saturate(180%);
    border-color:color-mix(in srgb, var(--bdr) 65%, transparent);
  }
}
.tr-input-field.multiline {
  grid-template-areas:
    "input input input"
    "lang  .     tools";
}
.tr-lang-add   { grid-area:lang; }
.tr-input      { grid-area:input; }
.tr-input-tools { grid-area:tools; display:flex; align-items:center; gap:6px; }

/* Tap effect (ChatGPT-style): a gentle breath (WAAPI, in translator.js) + a soft
   luminous ripple from the tapped point. The ripple layer clips to the rounded field
   (separate so the language popover, which opens above, isn't clipped); the content
   sits above it. Literal rgba (not color-mix) so older Safari renders the glow. */
.tr-ripple-layer {
  position:absolute; inset:0; z-index:0; overflow:hidden;
  border-radius:inherit; pointer-events:none;
}
.tr-input-field > .tr-lang-add,
.tr-input-field > .tr-input,
.tr-input-field > .tr-input-tools { position:relative; z-index:1; }
.tr-ripple {
  position:absolute; border-radius:50%; pointer-events:none;
  transform:translate(-50%,-50%) scale(.35);
  background:radial-gradient(circle, rgba(130,175,235,.22), rgba(130,175,235,.05) 38%, transparent 62%);
  animation:tr-ripple .9s cubic-bezier(.16,1,.3,1) forwards;
}
@keyframes tr-ripple {
  0%   { transform:translate(-50%,-50%) scale(.35); opacity:0; }
  28%  { opacity:.22; }
  100% { transform:translate(-50%,-50%) scale(1);  opacity:0; }
}
/* Grow while held (press-and-hold), like the bottom tab bar. */
.tr-input-tools .tr-btn:active:not(:disabled) { transform:scale(1.18); }

/* Language-code button on the left of the composer (no circle): shows the current
   output language (zh / en / es) and opens the picker popover. Plain theme-ink
   colour — near-black in light, near-white in dark. The 34px box is just a
   comfortable tap target — no background or border. */
.tr-lang-add {
  flex:0 0 auto; height:34px; padding:0 6px;
  display:flex; align-items:center; justify-content:center; cursor:pointer;
  background:none; border:0;
  color:var(--txt); -webkit-text-fill-color:var(--txt);
  font-size:.92rem; font-weight:700; letter-spacing:.02em; text-transform:lowercase;
  transition:transform 200ms cubic-bezier(.34,1.56,.64,1), opacity 160ms ease;
}
.tr-lang-add:hover { opacity:.65; }
.tr-lang-add:active { transform:scale(.9); }
.tr-lang-add.open { opacity:.65; }

/* Popover with the language options (built in translator.js, appended to body). */
.tr-lang-menu {
  position:absolute; left:0; bottom:calc(100% + 8px); z-index:130; min-width:132px;
  background:var(--surf2); border:1px solid var(--bdr); border-radius:14px;
  box-shadow:var(--sh); padding:5px;
  opacity:0; transform:scale(.92); transform-origin:bottom left;
  pointer-events:none; transition:opacity .14s ease, transform .14s ease;
  user-select:none; -webkit-user-select:none; -webkit-touch-callout:none;
}
.tr-lang-menu.open { opacity:1; transform:scale(1); pointer-events:auto; }
.tr-lang-opt {
  display:flex; align-items:center; width:100%;
  padding:9px 11px; border-radius:9px;
  font-size:.86rem; font-weight:600; cursor:pointer;
  color:var(--txt); -webkit-text-fill-color:var(--txt);
}
.tr-lang-opt:hover { background:var(--surf); }
.tr-lang-opt.active {
  background:var(--surf);
  color:var(--txt); -webkit-text-fill-color:var(--txt); font-weight:700;
}

/* Send — ChatGPT-style solid circle: brand green with a white arrow; muted grey
   while the input is empty (disabled). */
#trGo {
  position:relative;
  background:var(--green); border-color:transparent;
  color:#fff; -webkit-text-fill-color:#fff;
}
#trGo:disabled {
  opacity:1;   /* always full flat green, even while empty */
  background:var(--green); border-color:transparent;
  color:#fff; -webkit-text-fill-color:#fff;
}
@media (hover:hover) and (pointer:fine) {
  #trGo:hover:not(:disabled) { background:color-mix(in srgb, var(--green) 86%, #000); }
}
/* Send button: spin like a loader while translating, then snap back.
   The ring spins on its OWN compositor layer (translateZ + will-change) so it
   stays smooth on mobile even while the main thread is busy translating. */
#trGo.tr-loading {
  background:transparent; border-color:transparent;
  color:transparent; -webkit-text-fill-color:transparent;
}
#trGo.tr-loading svg { display:none; }
#trGo.tr-loading::after {
  content:""; position:absolute; inset:-1px;
  border-radius:50%;
  border:3px solid color-mix(in srgb, var(--green) 22%, transparent);
  border-left-color:var(--green);
  animation:tr-spin-gpu .7s linear infinite;
  will-change:transform;
}
@keyframes tr-spin-gpu {
  from { transform:translateZ(0) rotate(0deg); }
  to   { transform:translateZ(0) rotate(360deg); }
}
/* Modern gradient border: a masked ring drawn around the text box. The mask
   punches out the centre so only the 1.5px edge shows. It fades in on focus and
   the gradient angle spins continuously (animatable via the @property angle). */
@property --tr-angle {
  syntax: '<angle>'; initial-value: 0deg; inherits: false;
}
.tr-input-field::before {
  /* inset:-1px so the ring sits on the pill's true outer edge (over the 1px border),
     concentric with the rounded corners — otherwise it floats 1px inside the border
     and reads as misaligned against the subtle border + top rim. */
  content:''; position:absolute; inset:-1px; z-index:1; pointer-events:none;
  border-radius:inherit; padding:1px;
  background:linear-gradient(var(--tr-angle), var(--blue), var(--green), var(--blue));
  -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite:xor; mask-composite:exclude;
  opacity:0; transition:opacity 150ms ease;
}
.tr-input-field:focus-within::before {
  opacity:1; animation:tr-border-spin 8s linear infinite;
}
@keyframes tr-border-spin { from { --tr-angle:0deg; } to { --tr-angle:360deg; } }
.tr-input {
  /* starts as a single line and grows upward with content (see autoGrow in
     translator.js) up to --tr-max-lines, the em-based maths adapting to zoom. */
  --tr-max-lines:6;
  min-width:0; width:100%; box-sizing:border-box;
  min-height:28px; resize:none; overflow-y:auto;
  max-height:calc(var(--tr-max-lines) * 1.45em + 12px);
  background:transparent; border:none; border-radius:0;
  color:var(--txt); -webkit-text-fill-color:var(--txt);
  font:inherit; font-size:1.02rem; line-height:1.45;
  padding:4px 6px; outline:none;
}
.tr-input::placeholder { color:var(--faint); opacity:.6; white-space:nowrap; }
/* Only genuinely small screens (below any iPhone) shrink the font so the
   placeholder still fits one row; the line-height scales with it, staying centred. */
@media (max-width:370px) {
  .tr-input { font-size:clamp(.72rem, 3.4vw, 1.02rem); }
}

.tr-btn {
  width:36px; height:36px; padding:0; border-radius:50%;
  display:flex; align-items:center; justify-content:center;
  background:color-mix(in srgb, var(--green) 16%, transparent);
  border:1px solid color-mix(in srgb, var(--green) 35%, transparent);
  color:var(--green); -webkit-text-fill-color:var(--green);
  cursor:pointer;
  transition:transform 140ms cubic-bezier(.34,1.56,.64,1), background 160ms ease, color 160ms ease;
}
.tr-btn svg { width:17px; height:17px; }
.tr-btn:hover { background:color-mix(in srgb, var(--green) 26%, transparent); }
.tr-btn:active {
  transform:scale(.9);
  background:var(--green);
  color:#fff; -webkit-text-fill-color:#fff;
  border-color:var(--green);
}
.tr-btn:disabled {
  opacity:.45; cursor:default; transform:none;
  background:color-mix(in srgb, var(--green) 12%, transparent);
  color:var(--green); -webkit-text-fill-color:var(--green);
}

/* Mic — borderless blue icon (matches the language button and the recording state);
   the send button keeps its filled circle as the primary action. Every interactive
   state is pinned so .tr-btn's green fill can't leak in / stick on touch. */
.tr-mic,
.tr-mic:hover,
.tr-mic:active,
.tr-mic:focus {
  position:relative;
  background:none; border-color:transparent;
  color:var(--blue); -webkit-text-fill-color:var(--blue);
}
@media (hover:hover) and (pointer:fine) {
  .tr-mic:hover { background:color-mix(in srgb, var(--blue) 16%, transparent); }
}
/* Slightly larger than the other tr-btn icons so the mic reads as its own affordance. */
.tr-mic svg { width:19px; height:19px; }

/* Idle = mic icon. Recording = animated audio-wave equaliser. */
.tr-mic .audio-wave { display:none; align-items:center; gap:2px; height:16px; }
.tr-mic.listening svg { display:none; }
.tr-mic.listening .audio-wave { display:flex; }
.tr-mic .audio-wave span {
  display:block; width:2px; height:100%;
  background:currentColor; border-radius:50px;
  animation:audio-wave 1.2s ease-in-out infinite;
}
.tr-mic .audio-wave span:nth-child(1) { animation-duration:.8s;  height:40%; }
.tr-mic .audio-wave span:nth-child(2) { animation-duration:1.1s; height:70%; }
.tr-mic .audio-wave span:nth-child(3) { animation-duration:1.3s; height:100%; }
.tr-mic .audio-wave span:nth-child(4) { animation-duration:.9s;  height:60%; }
.tr-mic .audio-wave span:nth-child(5) { animation-duration:1s;   height:50%; }
@keyframes audio-wave {
  0%, 100% { transform:scaleY(.5); }
  50%      { transform:scaleY(1); }
}

/* Recording state — no fill (fully transparent), blue bars, halo kept below. */
.tr-mic.listening {
  overflow:visible;
  background:transparent;
  border-color:transparent;
  color:var(--blue); -webkit-text-fill-color:var(--blue);   /* bars use currentColor */
  animation:mic-throb 0.8s ease-in-out infinite alternate;
}
@keyframes mic-throb {
  from { transform:scale(1); }
  to   { transform:scale(1.12); }
}
/* Rotating multicolour glow halo around the recording button. */
.tr-mic.listening::before {
  content:""; position:absolute; inset:2px; z-index:5; pointer-events:none;
  border-radius:50%;
  box-shadow:
    1px -2px 4px 0 color-mix(in srgb, var(--blue) 70%, #fff),
    2px -1px 4px 0 color-mix(in srgb, var(--blue) 85%, transparent),
    -2px -2px 5px 0 color-mix(in srgb, var(--blue) 55%, #6fd0ec),
    2px  1px 4px 0 var(--blue),
    1px  1px 5px 0 color-mix(in srgb, var(--blue) 60%, transparent);
  animation:mic-glow-rotate 1.5s linear infinite;
}
@keyframes mic-glow-rotate {
  from { transform:rotate(0deg); }
  to   { transform:rotate(360deg); }
}

/* Mic transcribing state — hides the icon and shows a spinner */
.tr-mic.transcribing { cursor:default; }
.tr-mic.transcribing svg { display:none; }
.tr-mic.transcribing::after {
  content:""; width:15px; height:15px; border-radius:50%;
  border:2px solid color-mix(in srgb, var(--blue) 30%, transparent);
  border-top-color:var(--blue);
  animation:tr-spin 0.7s linear infinite;
}
@keyframes tr-spin { to { transform:rotate(360deg); } }

.tr-section { display:flex; flex-direction:column; gap:8px; }

/* Hanzi result header: title + listen/clear actions on the right. */
/* Copy / listen sit just below the translation (ChatGPT-style), a touch smaller
   than the composer's action buttons, with a slight left margin. */
.tr-zh-actions { display:flex; gap:6px; margin-top:2px; margin-left:2px; }
.tr-zh-actions .tr-btn { width:26px; height:26px; }
.tr-zh-actions .tr-btn svg { width:14px; height:14px; }

/* Listen button — small, borderless (ChatGPT-style): a bare muted icon that only
   shows a faint background on hover. */
.tr-speak { width:26px; height:26px; }
.tr-speak svg { width:14px; height:14px; }
/* Pin every interactive state to the neutral ghost look, so .tr-btn's green
   hover/active fills can't leak in and stick on touch (where :hover/:active don't
   clear). The "on" fill comes ONLY from the JS-toggled .playing class, so it always
   clears when the audio ends. Desktop gets a subtle hover, scoped to fine pointers. */
.tr-speak,
.tr-speak:hover,
.tr-speak:active,
.tr-speak:focus {
  background:none; border-color:transparent;
  color:var(--muted); -webkit-text-fill-color:var(--muted);
}
.tr-speak.playing,
.tr-speak.playing:hover,
.tr-speak.playing:active {
  background:var(--muted); border-color:var(--muted);
  color:var(--surf2); -webkit-text-fill-color:var(--surf2);
}
@media (hover:hover) and (pointer:fine) {
  .tr-speak:hover { background:color-mix(in srgb, var(--muted) 18%, transparent); }
}

/* Copy button (next to the Hanzi result title) — grey variant.
   It carries the .tr-btn class for layout, but .tr-btn's green hover/active
   fills would otherwise leak in (and stick on touch, where :hover/:active don't
   clear). So every interactive state is pinned to the same neutral grey here;
   the green appears ONLY via the JS-toggled .copied class. Desktop adds a subtle
   hover affordance, scoped to fine pointers so touch never shows a stuck state. */
.tr-copy {
  width:26px; height:26px; position:relative;
  --tr-copy-bg:transparent;
}
.tr-copy,
.tr-copy:hover,
.tr-copy:active,
.tr-copy:focus {
  background:var(--tr-copy-bg);
  border-color:transparent;
  color:var(--muted); -webkit-text-fill-color:var(--muted);
}
/* Both icons stacked; cross-fade + scale between them on copy. */
.tr-copy svg {
  width:14px; height:14px;
  position:absolute; top:50%; left:50%;
  transition:opacity 200ms ease, transform 260ms cubic-bezier(.34,1.56,.64,1);
}
.tr-copy .ic-copy   { opacity:1; transform:translate(-50%,-50%) scale(1); }
.tr-copy .ic-copied { opacity:0; transform:translate(-50%,-50%) scale(.5); }
/* Copied confirmation — green check, no fill. Driven only by the JS class so it
   can never get stuck on a sticky :hover/:active. */
.tr-copy.copied,
.tr-copy.copied:hover,
.tr-copy.copied:active,
.tr-copy.copied:focus { color:var(--green); -webkit-text-fill-color:var(--green); }
.tr-copy.copied .ic-copy   { opacity:0; transform:translate(-50%,-50%) scale(.5); }
.tr-copy.copied .ic-copied { opacity:1; transform:translate(-50%,-50%) scale(1); }
@media (hover:hover) and (pointer:fine) {
  .tr-copy:hover { --tr-copy-bg:color-mix(in srgb, var(--muted) 18%, transparent); }
}

/* The user's input as a ChatGPT-style bubble, aligned to the right. */
.tr-user-msg { display:flex; justify-content:flex-end; position:relative; }
.tr-bubble {
  max-width:85%;
  background:var(--surf2); border:1px solid var(--bdr); border-radius:18px;
  padding:8px 14px;
  font-size:1rem; line-height:1.5; color:var(--txt);
  white-space:pre-wrap; word-break:break-word;
  cursor:pointer; -webkit-tap-highlight-color:transparent;
}

/* Copy / Edit popover raised below the sent bubble (built in translator.js). */
.tr-copy-pop {
  position:absolute; top:calc(100% + 6px); right:6px; z-index:6;
  display:flex; align-items:stretch; gap:2px;
  background:var(--surf2); border:1px solid var(--bdr); border-radius:11px;
  padding:3px; white-space:nowrap;
  opacity:0; transform:translateY(-4px) scale(.96);
  transition:opacity .14s ease, transform .14s ease;
  user-select:none; -webkit-user-select:none; -webkit-touch-callout:none;
}
.tr-copy-pop.show { opacity:1; transform:translateY(0) scale(1); }
.tr-pop-btn {
  display:flex; align-items:center; gap:6px;
  background:none; border:none; color:var(--txt);
  font-size:.78rem; font-weight:600; white-space:nowrap;
  padding:5px 11px; border-radius:8px; cursor:pointer;
}
.tr-pop-btn svg { width:14px; height:14px; flex-shrink:0; }
.tr-pop-btn:active { background:color-mix(in srgb, var(--muted) 20%, transparent); transform:scale(.97); }
.tr-pop-btn.copied { color:var(--green); }
.tr-pop-btn.danger { color:var(--red); }
.tr-pop-btn.danger:active { background:color-mix(in srgb, var(--red) 18%, transparent); }
.tr-pop-btn.icon-only { padding:5px 9px; }
.tr-pop-sep { width:1px; background:var(--bdr); margin:4px 0; flex-shrink:0; }

/* The message currently being edited: a fine iOS-style green hairline on its bubble. */
.tr-message.editing .tr-bubble {
  border-color:color-mix(in srgb, var(--green) 60%, transparent);
  box-shadow:0 0 0 .5px color-mix(in srgb, var(--green) 45%, transparent);
}
/* Edit-mode banner pinned above the composer — iOS Messages "edit" styling:
   a soft tinted card with a rounded accent bar and a tappable Cancel pill. */
.tr-edit-banner {
  display:flex; align-items:center; gap:10px;
  padding:8px 8px 8px 11px; margin-bottom:8px;
  background:color-mix(in srgb, var(--green) 8%, var(--surf2));
  border:1px solid color-mix(in srgb, var(--green) 16%, transparent);
  border-radius:13px; font-size:.78rem;
}
.tr-edit-banner::before {
  content:''; align-self:stretch; width:3px; margin:1px 0;
  border-radius:2px; background:var(--green); flex-shrink:0;
}
.js-editing-label { flex:1; font-weight:600; color:var(--txt); letter-spacing:-.01em; }
.tr-edit-cancel {
  display:flex; align-items:center; justify-content:center; flex-shrink:0;
  width:24px; height:24px; padding:0; border:none; border-radius:50%;
  background:color-mix(in srgb, var(--muted) 16%, transparent);
  color:var(--muted); cursor:pointer; -webkit-tap-highlight-color:transparent;
}
.tr-edit-cancel svg { width:13px; height:13px; }
.tr-edit-cancel:active { background:color-mix(in srgb, var(--muted) 28%, transparent); transform:scale(.94); }
.tr-zh-line {
  font-family:var(--cjk);
  font-size:1rem; line-height:1.5; color:var(--txt);   /* match the sent bubble (.tr-bubble) */
  padding:0 2px;
}

.tr-result { display:flex; flex-wrap:wrap; gap:8px; }
.tr-token {
  position:relative;
  background:var(--surf); border:1px solid var(--bdr); border-radius:12px;
  padding:8px 10px; text-align:center; min-width:46px;
  cursor:pointer; user-select:none; -webkit-touch-callout:none;
  transition:transform 120ms ease, border-color 150ms ease, background 150ms ease;
}
/* Hover/press feedback only on fine pointers, so a tap never leaves a stuck green
   border or scale on touch (the tapped word's state comes from .selected/.speaking). */
@media (hover:hover) and (pointer:fine) {
  .tr-token:not(.punct):hover { border-color:color-mix(in srgb, var(--green) 45%, var(--bdr)); }
  .tr-token:not(.punct):active { transform:scale(.94); }
}
/* Animated gradient ring while the word is playing (same effect as the input). */
.tr-token::before {
  content:''; position:absolute; inset:0; z-index:1; pointer-events:none;
  border-radius:inherit; padding:1px;
  background:linear-gradient(var(--tr-angle), var(--blue), var(--green), var(--blue));
  -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite:xor; mask-composite:exclude;
  opacity:0; transition:opacity 150ms ease;
}
.tr-token.speaking {
  background:color-mix(in srgb, var(--green) 12%, var(--surf));
}
.tr-token.speaking::before { opacity:1; animation:tr-border-spin 8s linear infinite; }
/* The selected word keeps a static gradient border (same as the input box).
   Drop the grey base border so only the thin gradient ring shows. */
.tr-token.selected { border-color:transparent; }
.tr-token.selected::before { opacity:1; }
.tr-token .py {
  display:block; font-size:.68rem; color:var(--green);
  margin-bottom:4px; white-space:nowrap;
}
.tr-token .zh {
  display:block; font-size:1.4rem; line-height:1.2;
  font-family:var(--cjk);
  color:var(--txt); -webkit-text-fill-color:var(--txt);
}
/* Tokenized header: title + the show-all-meanings eye toggle. */
.tr-eye {
  width:26px; height:26px; display:grid; place-items:center; flex:0 0 auto;
  border-radius:50%; background:none; border:1px solid transparent;
  color:var(--muted); -webkit-text-fill-color:var(--muted); cursor:pointer; padding:0;
}
.tr-eye svg { width:14px; height:14px; }
@media (hover:hover) and (pointer:fine) {
  .tr-eye:hover { background:color-mix(in srgb, var(--muted) 18%, transparent); }
}
.tr-eye:active { opacity:.6; }
.tr-eye .ic-eye { display:none; }
.tr-eye.showing .ic-eye { display:block; }
.tr-eye.showing .ic-eye-off { display:none; }

/* English gloss: a tap-to-reveal flashcard — collapsed until its token is
   revealed (per-word tap, or all at once via the eye), growing/shrinking the
   token block as it appears/disappears. */
.tr-token .en {
  display:block; font-size:.66rem; color:var(--faint);
  white-space:nowrap; font-style:italic;
  /* max-width:0 keeps the hidden gloss from widening the token — it only
     contributes to the box size once revealed. */
  max-height:0; max-width:0; opacity:0; margin-top:0; overflow:hidden;
  transition:max-height .18s ease, opacity .18s ease, margin-top .18s ease;
}
.tr-token.revealed .en { max-height:2em; max-width:none; opacity:1; margin-top:4px; }
.tr-token.punct {
  background:transparent; border:none; color:var(--faint);
  min-width:0; padding:8px 2px; cursor:default;
}
.tr-token.punct .py { display:none; }

.tr-empty { font-size:.78rem; color:var(--faint); line-height:1.6; padding:8px 2px; }
.tr-empty.err { color:var(--red); }

/* ── RECENTS DRAWER (ChatGPT-style push: content slides aside, panel stays flat) ── */
:root { --recents-w: min(82%, 340px); }

/* The app content shifts right to reveal the panel; its corners round off. */
.root {
  position:relative; z-index:2;
  background:var(--bg);            /* opaque so it always covers the panel behind */
  transition:transform .3s cubic-bezier(.4,0,.2,1), border-radius .3s ease;
  will-change:transform;
}
body.recents-open .root { transform:translateX(var(--recents-w)); border-radius:22px; overflow:hidden; }
body.recents-dragging .root { transition:none; }
body.recents-dragging .root,
body.recents-open .root { border-radius:22px; overflow:hidden; }

/* Flat panel pinned to the left, sitting behind the content. */
.tr-history-panel {
  position:fixed; top:0; left:var(--app-gutter); bottom:0; z-index:1;
  /* +30px underlaps the content's rounded corners so no gap shows behind them */
  width:calc(var(--recents-w) + 30px);
  display:flex; flex-direction:column;
  background:var(--surf);
  padding-top:max(14px, var(--safe-t));
  padding-left:max(10px, var(--safe-l));
  visibility:hidden;   /* only revealed while dragging / open */
}
body.recents-open .tr-history-panel,
body.recents-dragging .tr-history-panel { visibility:visible; }
/* Transparent catcher over the shifted content — tap to close. */
.tr-history-scrim {
  position:fixed; top:0; bottom:0; right:0; left:calc(var(--recents-w) + var(--app-gutter));
  z-index:3; pointer-events:none;
}
body.recents-open .tr-history-scrim { pointer-events:auto; }
.tr-history-head {
  display:flex; align-items:center; justify-content:space-between;
  /* extra right padding keeps the × clear of the 30px content underlap */
  padding:6px 44px 12px 6px; font-size:.95rem; font-weight:700;
}
.tr-history-list {
  flex:1; min-height:0; overflow-y:auto;
  display:flex; flex-direction:column; gap:6px;
  /* extra right padding keeps the delete buttons clear of the content underlap */
  padding:0 40px 12px 8px;
  scrollbar-width:thin; scrollbar-color:var(--bdr) transparent;
}
.tr-history-empty { font-size:.78rem; color:var(--faint); line-height:1.6; padding:10px 6px; }

/* Context menu (ChatGPT-style) for a history item. */
.tr-menu {
  position:fixed; z-index:130; min-width:150px;
  background:var(--surf2); border:1px solid var(--bdr); border-radius:14px;
  box-shadow:var(--sh); padding:5px;
  opacity:0; transform:scale(.92); transform-origin:top left;
  pointer-events:none; transition:opacity .14s ease, transform .14s ease;
  user-select:none; -webkit-user-select:none; -webkit-touch-callout:none;
}
.tr-menu.open { opacity:1; transform:scale(1); pointer-events:auto; }
.tr-menu button {
  display:flex; align-items:center; gap:10px; width:100%;
  padding:9px 11px; border-radius:9px;
  font-size:.84rem; font-weight:600;
}
.tr-menu button svg { width:17px; height:17px; }
.tr-menu-pin, .tr-menu-rename { color:var(--txt); -webkit-text-fill-color:var(--txt); }
.tr-menu-pin:hover, .tr-menu-pin:active,
.tr-menu-rename:hover, .tr-menu-rename:active { background:var(--surf); }
.tr-menu-del { color:var(--red); -webkit-text-fill-color:var(--red); }
.tr-menu-del:hover, .tr-menu-del:active { background:var(--red-bg); }
/* Inline title editor shown while renaming a history item. */
.tr-history-rename {
  flex:1; min-width:0; margin:2px 0;
  padding:9px 10px; border-radius:12px;
  background:var(--surf); border:1px solid color-mix(in srgb, var(--blue) 45%, var(--bdr));
  color:var(--txt); font-size:.82rem; font-weight:600;
  outline:none; -webkit-appearance:none; appearance:none;
}
/* Pin indicator on a pinned history item. */
.tr-history-pin { width:11px; height:11px; margin-right:4px; vertical-align:-1px; color:var(--faint); }
.tr-history-item {
  display:flex; align-items:stretch; gap:4px;
  border-radius:12px; transition:background 140ms ease, transform 140ms ease;
  user-select:none; -webkit-user-select:none; -webkit-touch-callout:none;
}
.tr-history-item:hover { background:var(--surf2); }
.tr-history-item.pressing { transform:scale(.97); background:var(--surf2); }
.tr-history-item.selected { background:color-mix(in srgb, var(--txt) 12%, transparent); }
/* Section headers separating Pinned / Recents. */
.tr-history-section {
  display:flex; align-items:center; justify-content:space-between;
  font-size:.95rem; font-weight:700; color:var(--txt);
  padding:12px 10px 6px;
}
.tr-history-section:first-child { padding-top:2px; }
.tr-section-clear {
  font-size:.65rem; font-weight:600; color:var(--red);
  padding:3px 9px; border-radius:20px; border:none;
  background:var(--red-bg); cursor:pointer; line-height:1.4;
  opacity:.6; transition:opacity 150ms;
}
.tr-section-clear:hover, .tr-section-clear:active { opacity:1; }
.tr-history-open {
  flex:1; min-width:0; text-align:left; padding:9px 10px;
  display:flex; flex-direction:column; gap:2px; border-radius:12px;
  -webkit-touch-callout:none; user-select:none;
}
.tr-history-src {
  font-size:.82rem; color:var(--txt); font-weight:600;
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.tr-history-zh {
  font-size:.72rem; color:var(--faint);
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
  font-family:var(--cjk);
}
.tr-history-del {
  flex:0 0 auto; width:30px; align-self:center;
  display:flex; align-items:center; justify-content:center;
  color:var(--faint); border-radius:8px; opacity:.6;
}
.tr-history-del svg { width:14px; height:14px; }
.tr-history-del:hover { color:var(--red); opacity:1; }

/* ── PWA INSTALL BANNER + iOS SHEET ── */
.pwa-banner {
  position:fixed; left:50%; z-index:140;
  bottom:calc(64px + var(--safe-b));
  transform:translate(-50%, 140%);
  width:min(94vw, 420px);
  display:flex; align-items:center; gap:12px;
  padding:10px 12px;
  background:var(--surf2); border:1px solid var(--bdr); border-radius:16px;
  box-shadow:var(--sh);
  opacity:0; pointer-events:none;
  transition:transform .32s cubic-bezier(.34,1.2,.4,1), opacity .25s ease;
}
.pwa-banner.show { transform:translate(-50%, 0); opacity:1; pointer-events:auto; }
.pwa-banner-icon {
  width:38px; height:38px; flex-shrink:0; border-radius:10px;
  object-fit:cover; display:block;
}
.pwa-banner-text { display:flex; flex-direction:column; line-height:1.25; min-width:0; flex:1; }
.pwa-banner-text strong { font-size:.84rem; color:var(--txt); }
.pwa-banner-text span { font-size:.66rem; color:var(--faint); }
.pwa-banner-install {
  flex-shrink:0; padding:8px 16px; border-radius:999px;
  background:var(--blue); color:#fff; -webkit-text-fill-color:#fff;
  font-size:.78rem; font-weight:700; cursor:pointer;
}
.pwa-banner-install:active { transform:scale(.95); }
.pwa-banner-close {
  flex-shrink:0; width:28px; height:28px; display:grid; place-items:center;
  color:var(--faint); border-radius:50%;
}
.pwa-banner-close svg { width:15px; height:15px; }

.pwa-sheet-backdrop {
  position:fixed; inset:0; z-index:150;
  background:rgba(0,0,0,.45);
  display:flex; align-items:flex-end; justify-content:center;
  opacity:0; transition:opacity .25s ease;
}
.pwa-sheet-backdrop.show { opacity:1; }
.pwa-sheet {
  width:100%; max-width:460px;
  background:var(--surf); border-radius:20px 20px 0 0;
  padding:20px 20px calc(20px + var(--safe-b));
  box-shadow:var(--sh);
  transform:translateY(100%); transition:transform .3s cubic-bezier(.4,0,.2,1);
}
.pwa-sheet-backdrop.show .pwa-sheet { transform:translateY(0); }
.pwa-sheet-title { font-size:1rem; font-weight:700; color:var(--txt); margin-bottom:14px; text-align:center; }
.pwa-sheet-steps { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:14px; counter-reset:s; }
.pwa-sheet-steps li {
  display:flex; align-items:center; gap:10px;
  font-size:.85rem; color:var(--txt);
}
.pwa-sheet-steps li::before {
  counter-increment:s; content:counter(s);
  flex-shrink:0; width:22px; height:22px; border-radius:50%;
  background:var(--blue-bg); color:var(--blue);
  display:grid; place-items:center; font-size:.7rem; font-weight:700;
}
.pwa-sheet-steps li span { flex:1; }
.pwa-sheet-steps li svg { width:20px; height:20px; flex-shrink:0; color:var(--blue); }
.pwa-sheet-note { font-size:.85rem; color:var(--txt); line-height:1.6; margin:0; }
.pwa-sheet-ok {
  margin-top:18px; width:100%; padding:12px; border-radius:12px;
  background:var(--blue); color:#fff; -webkit-text-fill-color:#fff;
  font-size:.85rem; font-weight:700; cursor:pointer;
}

/* ── SWIPE HINT LABELS (under the deck) ──
   A quiet, permanent reminder of the swipe outcomes — faint colour-coding,
   no pills, so it never competes with the card. */
.deck-hints { display:flex; justify-content:space-between; padding:0 4px; }
.deck-hint { font-size:.6rem; font-weight:400; letter-spacing:.01em; color:var(--faint); }

/* ── FIRST-RUN CARD DEMO (coach.js) ──
   The real top card nudges itself left → right → up once, revealing the same
   tint/chip overlays a real drag shows, so the gesture is learned by watching. */
.card.demo-swipe          { animation:demo-swipe-card 3.1s ease-in-out both; }
.card.demo-swipe .tint-l,
.card.demo-swipe .chip-l  { animation:demo-l 3.1s ease-in-out both; }
.card.demo-swipe .tint-r,
.card.demo-swipe .chip-r  { animation:demo-r 3.1s ease-in-out both; }
.card.demo-swipe .tint-u,
.card.demo-swipe .chip-u  { animation:demo-u 3.1s ease-in-out both; }

@keyframes demo-swipe-card {
  0%,10%   { transform:none; opacity:1; }
  22%      { transform:translateX(-112px) rotate(-6deg); opacity:.72; }
  36%,45%  { transform:none; opacity:1; }
  57%      { transform:translateX(112px) rotate(6deg); opacity:.72; }
  71%,80%  { transform:none; opacity:1; }
  90%      { transform:translateY(-108px); opacity:.72; }
  100%     { transform:none; opacity:1; }
}
@keyframes demo-l { 0%,13%{opacity:0} 22%{opacity:.85} 33%,100%{opacity:0} }
@keyframes demo-r { 0%,48%{opacity:0} 57%{opacity:.85} 68%,100%{opacity:0} }
@keyframes demo-u { 0%,82%{opacity:0} 90%{opacity:.85} 100%{opacity:0} }

/* Groups: lightweight long-press tip, floats above the tab bar. */
.coach-tip {
  position:fixed; left:50%; bottom:calc(72px + var(--safe-b)); z-index:160;
  transform:translate(-50%, 12px);
  display:flex; align-items:center; gap:9px;
  max-width:calc(100% - 32px);
  padding:10px 10px 10px 14px; border-radius:14px;
  background:var(--surf); border:1px solid var(--bdr); box-shadow:var(--sh);
  opacity:0; pointer-events:none; transition:opacity .25s ease, transform .25s ease;
}
.coach-tip.show { opacity:1; transform:translate(-50%, 0); pointer-events:auto; }
.coach-tip > svg { width:20px; height:20px; flex-shrink:0; color:var(--blue); }
.coach-tip span { font-size:.78rem; font-weight:600; color:var(--txt); line-height:1.3; }
.coach-tip-x { flex-shrink:0; color:var(--faint); display:grid; place-items:center; width:22px; height:22px; }
.coach-tip-x svg { width:14px; height:14px; }

/* ── LEARN SHUAZI GUIDED TOUR (tour.js) ── */
/* Dimming layer: the even-odd clip-path punches a hole over the target, so the
   dim area still swallows stray taps while the highlighted element beneath the
   hole stays interactive. */
.tour-overlay {
  position:fixed; inset:0; z-index:200;
  background:rgba(8,10,12,.62);
  opacity:0; transition:opacity .28s ease, clip-path .32s ease, background-color .28s ease;
  -webkit-tap-highlight-color:transparent;
}
.tour-overlay.show { opacity:1; }
/* Steps that showcase a live animation instead of a spotlight: no dim, and let
   taps reach the app underneath so the card stays swipeable. */
.tour-overlay.no-dim { background:transparent; pointer-events:none; }
/* Steps that invite opening the detail modal (z-index 100): sink the tour below
   it so the modal covers it and stays fully usable. */
.tour-overlay.under-modal { z-index:90; }
.tour-ring.under-modal    { z-index:91; }
.tour-card.under-modal    { z-index:95; }

/* Accent ring drawn around the spotlight hole. Purely decorative (no pointer
   events) so it never steals taps from the highlighted element. */
.tour-ring {
  position:fixed; z-index:201; display:none; pointer-events:none;
  box-shadow:0 0 0 1px color-mix(in srgb, var(--blue) 90%, transparent),
             0 0 0 4px color-mix(in srgb, var(--blue) 14%, transparent),
             0 4px 16px color-mix(in srgb, var(--blue) 20%, transparent);
  opacity:0; transition:opacity .28s ease, left .3s ease, top .3s ease, width .3s ease, height .3s ease;
}
.tour-ring.show { opacity:1; }

.tour-card {
  position:fixed; z-index:202;
  left:50%; transform:translateX(-50%) translateY(10px);
  width:min(420px, calc(100% - 28px));
  max-height:min(58vh, 460px); overflow-y:auto; scrollbar-width:none;
  touch-action:pan-y; overscroll-behavior:contain;
  display:flex; flex-direction:column; gap:9px;
  padding:16px 16px 14px; border-radius:20px;
  background:var(--surf); border:1px solid var(--bdr); box-shadow:var(--sh);
  opacity:0; pointer-events:none; transition:opacity .28s ease, transform .28s ease;
  user-select:none; -webkit-user-select:none; -webkit-touch-callout:none;
}
.tour-card::-webkit-scrollbar { display:none; }
.tour-card.show { opacity:1; pointer-events:auto; transform:translateX(-50%) translateY(0); }
.tour-card.pos-bottom { bottom:calc(20px + var(--safe-b)); top:auto; }
/* Clear the ~58px tab bar so a bottom-pinned card doesn't cover a spotlight there. */
.tour-card.pos-bottom.lift { bottom:calc(74px + var(--safe-b)); }
.tour-card.pos-top    { top:calc(20px + var(--safe-t)); bottom:auto; }
.tour-card.pos-center { top:50%; bottom:auto; transform:translate(-50%, calc(-50% + 10px)); }
.tour-card.pos-center.show { transform:translate(-50%, -50%); }

.tour-card-top { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.tour-section {
  font-size:.62rem; font-weight:800; letter-spacing:.08em; text-transform:uppercase;
  color:var(--blue); background:var(--blue-bg);
  padding:4px 10px; border-radius:999px;
}
.tour-skip {
  font-size:.72rem; font-weight:600; color:var(--faint);
  padding:4px 6px; background:none; border:none; cursor:pointer;
}
.tour-skip:active { opacity:.6; }
/* Fixed height reserves the image's space so the card doesn't grow (and shift)
   when the picture finishes loading. */
.tour-img { display:block; width:100%; height:200px; object-fit:contain; border-radius:14px; margin:2px 0 4px; }
.tour-title { font-size:1rem; font-weight:800; color:var(--txt); letter-spacing:-.01em; }
.tour-body { font-size:.82rem; line-height:1.55; color:var(--muted); }
.tour-body b { color:var(--txt); font-weight:700; }
.tour-body ul { margin:8px 0; padding-left:18px; display:flex; flex-direction:column; gap:4px; }
.tour-body li { line-height:1.5; }

.tour-nav { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:4px; }
/* One dot per section: hollow = upcoming, filled = completed, larger = current. */
.tour-dots { display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
.tour-dot {
  width:7px; height:7px; border-radius:50%;
  background:transparent; box-shadow:inset 0 0 0 1.5px var(--bdr);
  transition:background .2s, box-shadow .2s, transform .2s;
}
.tour-dot.seen   { background:color-mix(in srgb, var(--blue) 45%, transparent); box-shadow:none; }
.tour-dot.active { background:var(--blue); box-shadow:0 0 0 3px var(--blue-bg); transform:scale(1.15); }
.tour-count { margin-right:auto; font-size:.62rem; font-weight:500; color:var(--muted); opacity:.6; letter-spacing:.02em; font-variant-numeric:tabular-nums; }
.tour-actions { display:flex; align-items:center; gap:8px; flex-shrink:0; }
.tour-back, .tour-next {
  font-size:.8rem; font-weight:700; cursor:pointer;
  padding:8px 16px; border-radius:999px; border:1px solid var(--bdr);
}
.tour-back { background:var(--surf); color:var(--muted); }
.tour-back:active { background:var(--surf2); }
.tour-next { background:var(--blue); color:#fff; border-color:var(--blue); }
.tour-next:active { opacity:.85; }

@media (prefers-reduced-motion: reduce) {
  .tour-overlay, .tour-ring, .tour-card { transition:opacity .2s ease; }
}

/* ── STROKE ORDER (stroke-order.js) ── */
/* The character in the modal is a button that opens the stroke animation, with a
   small pencil affordance pinned to the cell's bottom-right corner. */
.char-cell { position:relative; }
.char-stroke-btn { cursor:pointer; line-height:1; padding:0 22px 0 0; }
.char-stroke-btn:active { opacity:.6; }
.stroke-open-btn {
  position:absolute; right:5px; bottom:5px;
  width:22px; height:22px; display:grid; place-items:center;
  color:var(--tc, var(--blue)); opacity:.8; cursor:pointer;
}
.stroke-open-btn svg { width:13px; height:13px; }
.stroke-open-btn:active { transform:scale(.9); }

.stroke-backdrop {
  position:fixed; inset:0; z-index:210;
  background:rgba(0,0,0,.42);
  backdrop-filter:blur(7px); -webkit-backdrop-filter:blur(7px);
  display:flex; align-items:center; justify-content:center; padding:16px;
  opacity:0; transition:opacity .2s ease;
}
.stroke-backdrop.show { opacity:1; }
.stroke-sheet {
  position:relative; width:100%; max-width:440px; text-align:center;
  background:var(--surf); border-radius:22px; box-shadow:var(--sh);
  padding:18px 20px;   /* centred sheet — no safe-area pad, which made the bottom oversized */
  transform:translateY(10px) scale(.98); transition:transform .25s cubic-bezier(.4,0,.2,1);
}
.stroke-backdrop.show .stroke-sheet { transform:translateY(0) scale(1); }
.stroke-sheet:focus { outline:none; }
.stroke-targets { display:flex; flex-wrap:wrap; gap:12px; justify-content:center; align-items:center; min-height:120px; }
.stroke-target {
  border-radius:14px; background:var(--surf2); border:1px solid var(--bdr);
  cursor:pointer; display:grid; place-items:center; overflow:hidden;
}
.stroke-missing {
  font-family:var(--cjk); font-size:2.8rem; color:var(--faint);
  width:100%; height:100%; display:grid; place-items:center;
}
.stroke-error { color:var(--faint); font-size:.85rem; line-height:1.5; padding:26px 8px; }
.stroke-actions { margin-top:14px; display:flex; justify-content:center; gap:8px; }
/* Small arrows in the bottom row for stepping through a word's characters. */
.stroke-nav {
  width:26px; height:26px; border-radius:50%;
  display:grid; place-items:center; cursor:pointer;
  color:var(--faint); background:transparent; border:1px solid var(--bdr);
  transition:opacity .15s, transform .1s;
}
.stroke-nav svg { width:14px; height:14px; }
.stroke-nav.stroke-prev { margin-right:6px; }
.stroke-nav.stroke-next { margin-left:6px; }
.stroke-nav:active { transform:scale(.9); }
.stroke-nav:disabled { opacity:.3; cursor:default; }
.stroke-nav[hidden] { display:none; }
.stroke-btn {
  width:30px; height:30px; border-radius:50%;
  display:grid; place-items:center; cursor:pointer;
  color:var(--faint); background:transparent; border:1px solid var(--bdr);
  transition:color .15s, border-color .15s, background .15s, transform .1s;
}
.stroke-btn svg { width:14px; height:14px; }
.stroke-btn:active { transform:scale(.94); }
.stroke-btn:disabled { opacity:.4; cursor:default; }
.stroke-grid.active {
  color:var(--blue); background:var(--blue-bg);
  border-color:color-mix(in srgb, var(--blue) 45%, var(--bdr));
}
/* The radical toggle matches the colour its strokes take when highlighted. */
.stroke-radical.active {
  color:var(--radical); background:var(--radical-bg);
  border-color:color-mix(in srgb, var(--radical) 45%, var(--bdr));
}
/* Field grid (米字格) behind the glyph — a background image so it stays under the
   HanziWriter strokes and over the tile surface. Neutral grey reads on both themes. */
.stroke-target.grid-on {
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' preserveAspectRatio='none'%3E%3Cg stroke='%23808080' stroke-opacity='0.4' stroke-width='0.35' stroke-dasharray='2.5 2.5'%3E%3Cline x1='50' y1='0' x2='50' y2='100'/%3E%3Cline x1='0' y1='50' x2='100' y2='50'/%3E%3Cline x1='0' y1='0' x2='100' y2='100'/%3E%3Cline x1='100' y1='0' x2='0' y2='100'/%3E%3C/g%3E%3C/svg%3E");
  background-size:100% 100%; background-repeat:no-repeat;
}

/* ── PASSWORD RESET (password-reset.js) ── */
.pwreset-backdrop {
  position:fixed; inset:0; z-index:220;
  background:rgba(0,0,0,.5); backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px);
  display:flex; align-items:center; justify-content:center; padding:20px;
  opacity:0; transition:opacity .2s ease;
}
.pwreset-backdrop.show { opacity:1; }
.pwreset-sheet {
  width:100%; max-width:340px;
  background:var(--surf); border-radius:20px; box-shadow:var(--sh);
  padding:22px 20px calc(20px + var(--safe-b));
  display:flex; flex-direction:column; gap:12px;
  transform:translateY(10px) scale(.98); transition:transform .25s cubic-bezier(.4,0,.2,1);
}
.pwreset-backdrop.show .pwreset-sheet { transform:translateY(0) scale(1); }
.pwreset-title { font-size:1rem; font-weight:800; color:var(--txt); letter-spacing:-.01em; text-align:center; }
.pwreset-input {
  background:var(--bg); border:1px solid var(--bdr); border-radius:10px;
  padding:10px 12px; font:inherit; font-size:.85rem; color:var(--txt); outline:none;
  width:100%; box-sizing:border-box;
}
.pwreset-error { font-size:.7rem; color:#c04050; min-height:.85rem; text-align:center; }
.pwreset-save {
  width:100%; padding:11px; border-radius:12px;
  background:var(--blue); color:#fff; -webkit-text-fill-color:#fff;
  font-size:.85rem; font-weight:800; cursor:pointer;
}
.pwreset-save:disabled { opacity:.5; cursor:default; }
.pwreset-save:active { transform:scale(.98); }

/* ── SUPPORT BUTTON FILL EFFECT ── */
#upgradeBannerBtn {
  position:relative;
  overflow:hidden;
  background:transparent;
  border:1.5px solid var(--green);
  color:var(--green); -webkit-text-fill-color:var(--green);
  transition:color .4s ease, -webkit-text-fill-color .4s ease;
}
#upgradeBannerBtn::before {
  content:'';
  position:absolute; inset:0;
  background-image:url('./images/fill-splash.png');
  background-repeat:repeat-x;
  background-position:0 -100%;
  transition:background-position 1.5s ease;
  filter:hue-rotate(280deg);
  z-index:0;
}
#upgradeBannerBtn:hover::before,
#upgradeBannerBtn:active::before { background-position:center; }
#upgradeBannerBtn:hover,
#upgradeBannerBtn:active { color:#fff; -webkit-text-fill-color:#fff; }
#upgradeBannerBtn #bubbleCTAText { position:relative; z-index:1; }

::view-transition-old(root),
::view-transition-new(root) { animation-duration: 200ms; animation-timing-function: ease; }

@media (prefers-reduced-motion:reduce) {
  *,*::before,*::after { animation-duration:.01ms!important; transition-duration:.01ms!important; }
  ::view-transition-old(root), ::view-transition-new(root) { animation-duration: .01ms; }
}

/* ══════════════════════════════
   DESKTOP / POINTER REFINEMENTS
   Mobile-first app: the rules below only enhance large, mouse-driven screens
   and never change the touch/phone layout.
══════════════════════════════ */

/* Hover affordances — mirror the existing :active feedback for mouse users so
   interactive elements feel responsive with a cursor, not just on tap. */
@media (hover:hover) and (pointer:fine) {
  .icon-btn:hover { opacity:.7; }
  .tab:not(.active):hover { color:var(--muted); }
  .hsk-group-header:hover > * { opacity: .8; }
  .pill:hover { opacity:.85; }
  .char-tile:not(.known):not(.repaso):hover { background:var(--surf2); }
  .char-tile.known:hover  { background:color-mix(in srgb, var(--green) 22%, var(--green-bg)); }
  .char-tile.repaso:hover { background:color-mix(in srgb, var(--blue) 22%, var(--blue-bg)); }
  .settings-item:hover {
    background:var(--surf2);
    border-color:color-mix(in srgb, var(--txt) 16%, var(--bdr));
  }
  .lang-toggle:hover { border-color:color-mix(in srgb, var(--txt) 16%, var(--bdr)); }
  .hsk-filter-pill:not(.active):not([disabled]):hover,
  .status-filter-pill:not(.active):hover { opacity:.7; }
}

/* On wide screens the centred column becomes a floating, rounded "app card".
   The page backdrop is derived from the app's own surface tones so the framing
   blends with the UI instead of sitting on flat or off-palette colour. */
@media (min-width:900px) {
  body {
    align-items:center;
    background:
      radial-gradient(140% 100% at 50% 0%,
        color-mix(in srgb, var(--surf) 60%, var(--bg)),
        var(--bg) 72%);
  }
  .root {
    height:calc(100dvh - 48px);
    border-radius:26px;
    border:1px solid var(--bdr);
    box-shadow:var(--sh);
    overflow:hidden;
  }
  /* Keep the side drawers inside the card's vertical band and round them to
     match, so they read as companion cards revealed beside the app. */
  .settings-panel,
  .account-panel,
  .tr-history-panel {
    top:24px; bottom:24px;
    border-radius:26px;
    overflow:hidden;
  }
}
