const { useState, useEffect, useLayoutEffect, useRef, useMemo } = React;


/* ------------------------------------------------------------------
   SCHEDULER
   Core: FSRS-6 (Free Spaced Repetition Scheduler), the algorithm Anki
   ships as its default. Memory state per item = Stability + Difficulty,
   retrievability decays on a trainable power curve. 21 default weights
   from the open-spaced-repetition project.

   Three layers on top, which FSRS and Duolingo both lack:
   1. Grades are measured, not self-reported. You type, so the grade
      comes from the answer and the time it took.
   2. Content-aware priors (KARL, EMNLP 2024): a card you have never
      seen inherits a memory prior from cards sharing its words.
   3. Pattern layer: every card is tagged with the grammar it uses,
      errors roll up to the pattern, and generated practice targets
      the weakest pattern instead of random vocabulary.
   Production and recognition are scheduled as separate memories.
------------------------------------------------------------------ */

const W = [0.212, 1.2931, 2.3065, 8.2956, 6.4133, 0.8334, 3.0194, 0.001, 1.8722, 0.1666,
  0.796, 1.4835, 0.0614, 0.2629, 1.6483, 0.6014, 1.8729, 0.5425, 0.0912, 0.0658, 0.1542];
const DEC = W[20];
const FAC = Math.pow(0.9, -1 / DEC) - 1;
const DAY = 86400000;
const clampD = (d) => Math.min(10, Math.max(1, d));

const retriev = (days, S) => Math.pow(1 + FAC * (days / Math.max(S, 0.01)), -DEC);
const interval = (r, S) => Math.max(0.02, (S / FAC) * (Math.pow(r, -1 / DEC) - 1));
const initS = (g) => Math.max(0.01, W[g - 1]);
const initD = (g) => clampD(W[4] - Math.exp(W[5] * (g - 1)) + 1);

const nextD = (D, g) => {
  const dd = -W[6] * (g - 3);
  const d1 = D + dd * ((10 - D) / 9);
  return clampD(W[7] * initD(4) + (1 - W[7]) * d1);
};

const sRecall = (D, S, R, g) => {
  const hard = g === 2 ? W[15] : 1;
  const easy = g === 4 ? W[16] : 1;
  const inc = Math.exp(W[8]) * (11 - D) * Math.pow(S, -W[9]) * (Math.exp(W[10] * (1 - R)) - 1) * hard * easy;
  return S * (1 + Math.max(0, inc));
};

const sForget = (D, S, R) =>
  Math.min(S, W[11] * Math.pow(D, -W[12]) * (Math.pow(S + 1, W[13]) - 1) * Math.exp(W[14] * (1 - R)));

const sSameDay = (S, g) => {
  const inc = Math.exp(W[17] * (g - 3 + W[18])) * Math.pow(S, -W[19]);
  return S * (g >= 3 ? Math.max(1, inc) : inc);
};

// one review -> new memory state
function review(state, grade, now, prior) {
  if (!state) {
    const S = prior && prior.s ? (initS(grade) + prior.s) / 2 : initS(grade);
    const D = prior && prior.d ? (initD(grade) + prior.d) / 2 : initD(grade);
    return { s: Math.max(0.01, S), d: clampD(D), t: now, r: 1, l: grade === 1 ? 1 : 0 };
  }
  const days = (now - state.t) / DAY;
  const R = retriev(days, state.s);
  const D = nextD(state.d, grade);
  let S;
  if (days < 1) S = sSameDay(state.s, grade);
  else if (grade === 1) S = sForget(state.d, state.s, R);
  else S = sRecall(state.d, state.s, R, grade);
  return { s: Math.max(0.01, S), d: D, t: now, r: (state.r || 0) + 1, l: (state.l || 0) + (grade === 1 ? 1 : 0) };
}

/* Criterion learning (Rawson & Dunlosky). Until an item has been recalled
   correctly on three separate days, its interval is capped, so the three
   successes land in three spaced sessions instead of one. Recalling once
   in each of three spaced sessions beats three times in one session by
   roughly a factor of two on one-week retention. */
/* Gap-filling on his own sentence cards: production, but with the
   sentence around it. Receptive practice does not transfer to speaking
   nearly as well as productive practice does, so the mix leans that way. */
const STOPGAP = new Set(["di", "ke", "dari", "dan", "yang", "ini", "itu", "apa", "ya"]);
function gapOf(text, weakSet) {
  const words = text.split(/\s+/).filter(Boolean);
  if (words.length < 3) return null;
  const scored = words.map((w, i) => {
    const bare = w.toLowerCase().replace(/[.,!?;:"']/g, "");
    if (bare.length < 3 || STOPGAP.has(bare)) return { i, s: -1 };
    return { i, s: (weakSet && weakSet.has(canonWord(bare)) ? 10 : 0) + bare.length / 10 };
  }).filter((x) => x.s > 0);
  if (!scored.length) return null;
  scored.sort((a, b) => b.s - a.s);
  const pick = scored[0].i;
  return { answer: words[pick].replace(/[.,!?;:"']/g, ""), prompt: words.map((w, i) => (i === pick ? "_____" : w)).join(" ") };
}

const CAP = [1, 1, 2, 4];
const schedInt = (st, want) => {
  const raw = interval(want, st.s);
  const cr = st.cr || 0;
  return cr >= 3 ? raw : Math.min(raw, CAP[cr] || 1);
};

const dueIn = (state, want, now) => {
  if (!state) return -1;
  const days = (now - state.t) / DAY;
  return schedInt(state, want) - days;
};

const nowR = (state, now) => (state ? retriev((now - state.t) / DAY, state.s) : 0);

const showDelay = (d) => {
  if (d < 1 / 24) return "minutes";
  if (d < 1) return Math.round(d * 24) + "h";
  if (d < 30) return Math.round(d) + "d";
  if (d < 365) return Math.round(d / 30) + "mo";
  return (d / 365).toFixed(1) + "y";
};

/* ---------------- pattern layer ---------------- */

const STOP = new Set(["berapa", "besok", "bersih", "menu", "merah", "mereka", "meja"]);
const PATTERNS = [
  ["time markers", /\b(udah|sudah|belum|lagi|akan|tadi|nanti|besok|kemarin|lusa|sekarang|selalu|masih)\b/i],
  ["question words", /\b(apa|siapa|kenapa|mengapa|dimana|kemana|darimana|kapan|berapa|gimana|bagaimana)\b/i],
  ["place words", /\b(di|ke|dari|sini|sana|situ)\b/i],
  ["negation", /\b(tidak|nggak|gak|bukan|belum|jangan)\b/i],
  ["modals", /\b(mau|ingin|bisa|harus|boleh|suka)\b/i],
  ["affixed verbs", /\b(mem\w+|men\w+|meng\w+|meny\w+|ber\w+|\w{4,}kan)\b/i],
  ["numbers and time", /\b(satu|dua|tiga|empat|lima|enam|tujuh|delapan|sembilan|sepuluh|belas|puluh|ratus|ribu|juta|jam|hari|tahun|bulan|pagi|siang|sore|malam)\b/i],
  ["pronouns", /\b(saya|aku|kamu|anda|dia|kami|kita|mereka|nya)\b/i],
];

function tagsOf(text) {
  const out = [];
  for (const [name, re] of PATTERNS) {
    const m = text.match(re);
    if (m && !STOP.has(m[0].toLowerCase())) out.push(name);
  }
  return out.length ? out : ["core words"];
}

const tokens = (s) => s.toLowerCase().replace(/-/g, " ").replace(/[^a-z\s]/g, " ").split(/\s+/).filter((w) => w.length > 2);


/* ------------------------------------------------------------------
   EQUIVALENCE + DIFF
   saya and aku are the same word to a learner, so the checker treats
   them as one. Same for the casual forms he actually needs with his
   partner: gak, udah, gimana, ke mana.
------------------------------------------------------------------ */

const PAIRS = [
  ["di mana", "dimana"], ["ke mana", "kemana"], ["dari mana", "darimana"],
  ["apa kabar", "apakabar"], ["tidak tahu", "tidaktahu"],
];

const EQUIV = {
  // casual spellings of the same word
  temen: "teman", bener: "benar", males: "malas", laen: "lain", capek: "capai", ketemu: "bertemu",
  saya: "1sg", aku: "1sg", gue: "1sg", gua: "1sg", ku: "1sg",
  kamu: "2sg", anda: "2sg", kau: "2sg", mu: "2sg",
  tidak: "neg", gak: "neg", nggak: "neg", enggak: "neg", tak: "neg", ga: "neg",
  sudah: "perf", udah: "perf",
  bagaimana: "how", gimana: "how",
  kenapa: "why", mengapa: "why",
  mau: "want", ingin: "want",
  bisa: "can", dapat: "can",
  sekarang: "now",
  dimana: "where", kemana: "whereto", darimana: "wherefrom",
};

// casual counterpart of a formal word, for the "both work" hint
const CASUAL = { saya: "aku", tidak: "gak", sudah: "udah", bagaimana: "gimana", anda: "kamu", mengapa: "kenapa" };

const canonWord = (w) => EQUIV[w] || w;

// See deckAll's comment further down: fixes an English gloss that landed on
// a real but archaic word ("whereto"/"wherefrom") instead of ordinary
// modern wording, wherever it shows up, without needing a data migration.
const ARCHAIC_EN = [[/\bwhereto\b/gi, "where to"], [/\bwherefrom\b/gi, "where from"]];
const plainEnglish = (s) => ARCHAIC_EN.reduce((t, [re, rep]) => t.replace(re, rep), String(s || ""));

// the tags above are for matching only; on screen they become real words again
const SHOW = {
  casual: { "1sg": "aku", "2sg": "kamu", neg: "gak", perf: "udah", how: "gimana", why: "kenapa", want: "mau", can: "bisa",
    now: "sekarang", where: "di mana", whereto: "ke mana", wherefrom: "dari mana" },
  polite: { "1sg": "saya", "2sg": "Anda", neg: "tidak", perf: "sudah", how: "bagaimana", why: "mengapa", want: "mau", can: "bisa",
    now: "sekarang", where: "di mana", whereto: "ke mana", wherefrom: "dari mana" },
};
const showWord = (w, register) => (SHOW[register === "polite" ? "polite" : "casual"][w] || w);

// the model likes dashes; the user does not
const undash = (s) => (s || "").replace(/\s*[—–]\s*/g, ", ").replace(/\s+,/g, ",").trim();

function canonTokens(s) {
  let t = (s || "").toLowerCase().replace(/-/g, " ").replace(/[.,!?;:"']/g, " ").replace(/\s+/g, " ").trim();
  for (const [long, short] of PAIRS) t = t.split(long).join(short);
  return t.split(" ").filter(Boolean);
}

const canonKey = (s) => canonTokens(s).map(canonWord).join(" ");

// longest common subsequence over canonical forms, so synonyms never show up as errors
function diffWords(mine, correct) {
  const a = (mine || "").trim().replace(/-/g, " ").split(/\s+/).filter(Boolean);
  const b = (correct || "").trim().replace(/-/g, " ").split(/\s+/).filter(Boolean);
  const ca = a.map((w) => canonWord(w.toLowerCase().replace(/[.,!?;:"']/g, "")));
  const cb = b.map((w) => canonWord(w.toLowerCase().replace(/[.,!?;:"']/g, "")));
  const m = ca.length, n = cb.length;
  const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
  for (let i = m - 1; i >= 0; i--)
    for (let j = n - 1; j >= 0; j--)
      dp[i][j] = ca[i] === cb[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
  const left = [], right = [];
  let i = 0, j = 0;
  while (i < m && j < n) {
    if (ca[i] === cb[j]) { left.push({ t: a[i], ok: true }); right.push({ t: b[j], ok: true }); i++; j++; }
    else if (dp[i + 1][j] >= dp[i][j + 1]) { left.push({ t: a[i], ok: false }); i++; }
    else { right.push({ t: b[j], ok: false }); j++; }
  }
  while (i < m) { left.push({ t: a[i++], ok: false }); }
  while (j < n) { right.push({ t: b[j++], ok: false }); }
  return { left, right };
}

const bare = (t) => canonWord(String(t).toLowerCase().replace(/[.,!?;:"'()\[\]]/g, ""));

/* A word he never wrote is a gap in vocabulary.
   A word he did write but in the wrong place is grammar, not vocabulary. */
const ENGLISH = new Set(["a","an","the","is","are","was","were","do","does","did","to","of","in","on","at","for","with",
  "you","i","he","she","it","we","they","me","my","your","his","her","their","this","that","there","here","and","or","but",
  "not","no","yes","please","want","have","has","had","be","been","can","will","would","should","could","what","where",
  "when","why","who","how","which","some","any","from","about","again","very","really","just","now","today","tomorrow"]);

function classifyMiss(mine, correct) {
  const { right } = diffWords(mine, correct);
  const mineSet = new Set(canonTokens(mine).map(canonWord));
  const missing = [], misplaced = [];
  const seen = new Set();
  right.filter((x) => !x.ok).map((x) => bare(x.t)).filter((w) => w.length > 2 && !ENGLISH.has(w))
    .forEach((w) => {
      if (seen.has(w)) return;                    // reduplication counts once
      seen.add(w);
      (mineSet.has(w) ? misplaced : missing).push(w);
    });
  return { missing, misplaced };
}

// which words of the model answer he did not produce
function missedWords(mine, correct) {
  return classifyMiss(mine, correct).missing;
}

function hitWords(mine, correct) {
  const { right } = diffWords(mine, correct);
  return right.filter((x) => x.ok).map((x) => bare(x.t)).filter((w) => w.length > 2 && !ENGLISH.has(w));
}

// Turns a roleplay "slip" or "nearly" pair (his line vs. the fixed/better
// one) into the same missed/misplaced/hit word signals Sentence forge
// already produces via classifyMiss/hitWords, so free-form roleplay speech
// feeds weakWords/strongWords too, not only the per-pattern score.
function roleplayDiff(mine, correct) {
  const cls = classifyMiss(mine, correct);
  return { missed: cls.missing, misplaced: cls.misplaced, hit: hitWords(mine, correct) };
}

// A line the reviewer already marked as fully correct has no "wrong"
// counterpart to diff against, so credit is simpler: every deck word he
// actually used in it counts as a hit.
function goodLineHits(line, known) {
  return Array.from(new Set(canonTokens(line || "").map(canonWord).filter((w) => w.length > 2 && known.has(w))));
}

// A single rolling "how is it going right now" line, built from the last
// batch of answers across every exercise (data.recent, appended to in
// commit() below), so prompts can lean into difficulty when it is going
// well and pull back when it is not -- instead of a fixed CEFR label doing
// all the work regardless of how the last few sessions actually went.
function levelNote(recentRate) {
  if (!recentRate || recentRate.n < 10) return "";
  const pct = Math.round(recentRate.rate * 100);
  if (recentRate.rate >= 0.75)
    return ` He has been doing well lately, right on about ${pct}% of his last ${recentRate.n} answers across every exercise, so he can handle real difficulty.`;
  if (recentRate.rate >= 0.5)
    return ` He has been doing OK lately, right on about ${pct}% of his last ${recentRate.n} answers, so keep it approachable and add difficulty one step at a time.`;
  return ` He has been struggling lately, right on only about ${pct}% of his last ${recentRate.n} answers across every exercise, so keep this mostly built from what he already handles well and go easy on new difficulty.`;
}

// the model quotes the words it is talking about; make them stand out
// the model explains a whole sentence at once; keep only the sentences that
// actually mention this word, so each entry says something about itself
const aboutWord = (note, word) => {
  const t = undash(note || "");
  if (!t || !word) return t;
  const parts = t.split(/(?<=[.;!?])\s+/).filter(Boolean);
  const hit = parts.filter((p) => canonTokens(p).map(canonWord).includes(canonWord(word)));
  return hit.length ? hit.join(" ") : "";
};

const Note = ({ text }) => {
  const t = undash(text || "");
  if (!t) return null;
  // A real quote's opening ' is never glued to a letter (no word right
  // before it); a contraction's apostrophe (doesn't, it's) always is --
  // that's the difference this leans on. Without it, "doesn't" reads as
  // an opening quote and swallows everything up to the next stray '
  // as one bolded, mangled chunk.
  const parts = t.split(/((?<![a-zA-Z0-9])'[^']{1,40}'(?![a-zA-Z0-9])|"[^"]{1,40}")/g);
  return (
    <span>
      {parts.map((p, i) =>
        /^['"].*['"]$/.test(p)
          ? <b key={i} className="bh-q">{p.slice(1, -1)}</b>
          : <span key={i}>{p}</span>
      )}
    </span>
  );
};

const Diff = ({ parts, tone }) => (
  <span>
    {parts.map((p, i) => (
      <span key={i} className={p.ok ? "" : tone === "mine" ? "bh-bad" : "bh-fix"}>{p.t}{" "}</span>
    ))}
  </span>
);

/* ---------------- answer checking ---------------- */

// a hyphen in reduplication is a spelling choice, not grammar: teman-teman = teman teman
const norm = (s) => (s || "").toLowerCase().replace(/\(.*?\)/g, " ").replace(/-/g, " ")
  .replace(/[.,!?;:"']/g, " ").replace(/\s+/g, " ").trim();
const variants = (s) => norm(s).split(/\s*\/\s*/).map((x) => x.trim()).filter(Boolean);

const lev = (a, b) => {
  const m = a.length, n = b.length;
  if (Math.abs(m - n) > 2) return 9;
  let prev = Array.from({ length: n + 1 }, (_, i) => i);
  for (let i = 1; i <= m; i++) {
    const cur = [i];
    for (let j = 1; j <= n; j++)
      cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
    prev = cur;
  }
  return prev[n];
};

const HINT = /[([]([^)\]]+)[)\]]\s*$/;
const splitHint = (s) => {
  const m = (s || "").match(HINT);
  return m ? { main: s.slice(0, m.index).trim(), hint: m[1].trim() } : { main: (s || "").trim(), hint: "" };
};

const BLANK = /_{2,}|\.{3,}|…/;
const hasBlank = (s) => BLANK.test(s || "");

// "Saya tinggal di ___" accepts anything in the slot, and tolerates a typo in the fixed part
const near = (a, b) => a === b || (b.length > 3 && lev(a, b) <= 1);

const blankMatch = (mine, target, cueHasBlank) => {
  const segs = (target || "").split(BLANK)
    .map((s) => canonKey(s).split(" ").filter(Boolean))
    .filter((s) => s.length);
  const mt = canonKey(mine).split(" ").filter(Boolean);
  if (!mt.length) return false;
  if (!segs.length) return true;
  let pos = 0, fixed = 0;
  for (const seg of segs) {
    let found = -1;
    for (let i = pos; i + seg.length <= mt.length; i++) {
      if (seg.every((w, j) => near(mt[i + j], w))) { found = i; break; }
    }
    if (found < 0) return false;
    pos = found + seg.length;
    fixed += seg.length;
  }
  // if the question itself showed a blank, leaving it open is a correct mirror
  return cueHasBlank ? true : mt.length > fixed;
};

const judge = (mine, target, cueHasBlank) => {
  if (hasBlank(target)) return blankMatch(mine, target, cueHasBlank) ? "yes" : "no";
  const m = norm(mine);
  if (!m) return "no";
  const vs = variants(target);
  if (vs.includes(m)) return "yes";
  const mk = canonKey(mine);
  if (variants(target).some((v) => canonKey(v) === mk)) return "yes";
  for (const v of vs) {
    const d = lev(m, v);
    if (d <= 1 && v.length > 4) return "typo";
    if (d <= 2 && v.length > 10) return "typo";
  }
  return "no";
};

// measured grade: what you typed, and how long you took
function gradeOf(verdict, ms, len) {
  if (verdict === "no" || verdict === "skip") return 1;
  if (verdict === "typo") return 2;
  const budget = 2200 + len * 260;
  return ms < budget ? 4 : 3;
}

/* ---------------- deck import ---------------- */

function parseExport(text) {
  const out = [], seen = new Set();
  for (const raw of text.split(/\r?\n/)) {
    const line = raw.trim();
    if (!line || line.startsWith("#")) continue;
    let parts = line.split("\t");
    if (parts.length < 2) parts = line.split(";");
    if (parts.length < 2) parts = line.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/);
    if (parts.length < 2) continue;
    const strip = (s) => s.replace(/<br\s*\/?>/gi, " / ").replace(/<[^>]+>/g, "").replace(/^"|"$/g, "").replace(/\s+/g, " ").trim();
    const id = strip(parts[0]), en = strip(parts[1]);
    if (!id || !en) continue;
    const k = id.toLowerCase();
    if (seen.has(k)) continue;
    seen.add(k);
    out.push({ id, en, s: id.split(" ").length > 2 ? 1 : 0 });
  }
  return out;
}


/* ------------------------------------------------------------------
   IMPORT RECONCILIATION
   A new Anki export never wipes anything. Known typo fixes are
   re-applied automatically, and when a card's Indonesian text changed
   but its English side did not, its scheduling history moves across
   instead of starting over.
------------------------------------------------------------------ */

const SEED_FIXES = {
  "talong ulang": "Tolong ulangi",
  "karena saya harus kaman": "Karena saya harus makan",
  "masu lalu": "Masa lalu",
  "memekai": "Memakai",
  "libaran": "Liburan",
  "seratus puluh": "Seratus sepuluh",
  "saya maaf": "Maaf / Saya minta maaf",
  "saya pergi di indonesia tanggal dua november": "Saya pergi ke Indonesia tanggal dua November",
  "aku ingin (ke) punya rencana banyak dengan kamu": "Aku ingin punya banyak rencana dengan kamu",
  "tidur saya bagus": "Saya tidur nyenyak",
};
const SEED_EN_FIXES = {
  "darimana asal kamu?": "Where are you from?",
  "kamu kerja jam berapa hari ini?": "What time do you work today?",
  "tomat": "Tomato",
};

const glossKey = (s) => (s || "").toLowerCase().replace(/[^a-z0-9 ]/g, "").replace(/\s+/g, " ").trim();

function reconcile(parsed, oldVocab, data) {
  const fixes = { ...SEED_FIXES, ...(data.fixes || {}) };
  let fixed = 0;
  const cards = parsed.map((c) => {
    const hit = fixes[c.id.toLowerCase()];
    const enHit = SEED_EN_FIXES[c.id.toLowerCase()];
    if (hit || enHit) fixed++;
    return { ...c, id: hit || c.id, en: enHit || c.en };
  });

  const newIds = new Set(cards.map((c) => c.id.toLowerCase()));
  const oldIds = new Set((oldVocab || []).map((c) => c.id.toLowerCase()));
  const gone = (oldVocab || []).filter((c) => !newIds.has(c.id.toLowerCase()));
  const arrived = cards.filter((c) => !oldIds.has(c.id.toLowerCase()));

  // a card whose Indonesian was corrected keeps its history, matched on the English side
  const byGloss = new Map();
  gone.forEach((g) => byGloss.set(glossKey(g.en), g));
  const mem = { ...(data.mem || {}) };
  const hooks = { ...(data.hooks || {}) };
  const alts = { ...(data.alts || {}) };
  let moved = 0;
  arrived.forEach((a) => {
    const old = byGloss.get(glossKey(a.en));
    if (!old || old.id === a.id) return;
    ["en2id", "id2en", "cloze"].forEach((dir) => {
      const from = old.id + "|" + dir, to = a.id + "|" + dir;
      if (mem[from] && !mem[to]) { mem[to] = mem[from]; delete mem[from]; moved++; }
    });
    if (hooks[old.id] && !hooks[a.id]) { hooks[a.id] = hooks[old.id]; delete hooks[old.id]; }
    if (alts[old.id] && !alts[a.id]) { alts[a.id] = alts[old.id]; delete alts[old.id]; }
    byGloss.delete(glossKey(a.en));
  });

  return { cards, mem, hooks, alts, fixed, moved, added: arrived.length, dropped: gone.length };
}

/* ------------------------------------------------------------------
   BACKUP
   Progress lives with one published artifact. Moving to another one
   would start from zero, so the whole state can be carried across by
   hand. Restoring merges rather than replaces: for every card the more
   recent review wins, counters add up, so an older backup can never
   undo newer progress.
------------------------------------------------------------------ */

function mergeState(a, b) {
  const out = JSON.parse(JSON.stringify(a));
  out.mem = { ...(a.mem || {}) };
  for (const k in b.mem || {}) {
    const mine = out.mem[k], theirs = b.mem[k];
    if (!mine || (theirs.t || 0) > (mine.t || 0)) out.mem[k] = theirs;
  }
  const addCounts = (x = {}, y = {}) => {
    const r = { ...x };
    for (const k in y) r[k] = { c: (r[k]?.c || 0) + (y[k].c || 0), w: (r[k]?.w || 0) + (y[k].w || 0) };
    return r;
  };
  out.pat = addCounts(a.pat, b.pat);
  out.words = addCounts(a.words, b.words);
  out.hooks = { ...(b.hooks || {}), ...(a.hooks || {}) };
  out.alts = { ...(a.alts || {}) };
  for (const k in b.alts || {}) out.alts[k] = Array.from(new Set([...(out.alts[k] || []), ...(b.alts[k] || [])])).slice(-8);
  out.days = Array.from(new Set([...(a.days || []), ...(b.days || [])])).sort().slice(-180);
  out.totals = {
    asked: Math.max(a.totals?.asked || 0, b.totals?.asked || 0),
    right: Math.max(a.totals?.right || 0, b.totals?.right || 0),
  };
  out.cal = {
    n: Math.max(a.cal?.n || 0, b.cal?.n || 0),
    pred: Math.max(a.cal?.pred || 0, b.cal?.pred || 0),
    hit: Math.max(a.cal?.hit || 0, b.cal?.hit || 0),
  };
  out.log = { ...(b.log || {}), ...(a.log || {}) };

  // everything else that is worth keeping, and was quietly lost before
  const byId = new Map();
  [...(b.mine || []), ...(a.mine || [])].forEach((c) => c && c.id && byId.set(c.id.toLowerCase(), c));
  out.mine = Array.from(byId.values());
  out.dropped = Array.from(new Set([...(b.dropped || []), ...(a.dropped || [])]));
  out.fixes = { ...(b.fixes || {}), ...(a.fixes || {}) };
  out.seen = Array.from(new Set([...(b.seen || []), ...(a.seen || [])])).slice(-60);
  out.hints = Math.max(a.hints || 0, b.hints || 0);
  out.conf = { ...(b.conf || {}) };
  for (const k in a.conf || {}) out.conf[k] = Array.from(new Set([...(out.conf[k] || []), ...(a.conf[k] || [])])).slice(-6);
  ["want", "register", "theme", "recog"].forEach((k) => { if (a[k] !== undefined) out[k] = a[k]; else if (b[k] !== undefined) out[k] = b[k]; });
  return out;
}

/* ---------------- API ----------------
   Self-hosted on Vercel: no artifact-host capabilities here, so both the
   AI call and the storage go through this app's own two serverless
   functions instead. /api/ask holds the Anthropic API key server-side and
   proxies to it. /api/storage holds a small Redis-backed store, namespaced
   per logged-in account, so progress is the same on every device. Both are
   gated by a session cookie (see Root/AuthScreen near the bottom of this
   file) that the browser sends automatically -- no header to attach here.
   ask() keeps its exact signature and behavior; every call site elsewhere
   in this file is untouched. */

// Set once Root knows who is logged in, purely so the localStorage cache
// below can be namespaced per account -- two people sharing one browser
// should never see each other's cached copy while a network request is
// in flight.
let sessionUsername = null;
function setSessionUsername(u) { sessionUsername = u; }

function onAuthFailure() {
  // The session cookie is gone or expired server-side; clear it here too
  // (harmless if it's already gone) and reload so Root re-checks and shows
  // the login screen.
  fetch("/api/auth/logout", { method: "POST" }).catch(() => {}).finally(() => {
    if (typeof location !== "undefined") location.reload();
  });
}

async function ask(system, messages, ms = 20000) {
  const ctl = typeof AbortController !== "undefined" ? new AbortController() : null;
  const timer = setTimeout(() => ctl && ctl.abort(), ms);
  try {
    const r = await fetch("/api/ask", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ system, messages }),
      signal: ctl ? ctl.signal : undefined,
    });
    if (r.status === 401) { onAuthFailure(); throw new Error("Not logged in."); }
    const d = await r.json();
    if (!r.ok) throw new Error((d && d.error) || "Anfrage fehlgeschlagen.");
    return (d.text || "").trim();
  } finally {
    clearTimeout(timer);
  }
}

// The learner's vocabulary list is by far the largest, and least often
// changing, part of what we send Claude: Sentence forge and Roleplay both
// interpolate it into their prompt on every single call. Wrapping it in its
// own cache_control block, with this exact wording used nowhere else, lets
// Anthropic's prompt cache reuse it across calls, and across those two
// features, instead of reprocessing the same few thousand tokens of words
// every single time. It costs a little more the first time it is written
// (still cheap), then a small fraction of the normal price on every hit,
// for as long as it keeps getting reused within the cache's lifetime. If
// the deck changes, the text changes too, so it simply writes a fresh
// entry rather than ever answering from a stale list.
function vocabSystemBlock(list) {
  return { type: "text", text: "The learner's vocabulary (Indonesian = English):\n\n" + list, cache_control: { type: "ephemeral" } };
}

if (typeof window !== "undefined" && !window.storage) {
  window.storage = {
    async get(key) {
      const cacheKey = "bh_cache_" + (sessionUsername || "anon") + "_" + key;
      try {
        const r = await fetch("/api/storage?key=" + encodeURIComponent(key));
        if (r.status === 401) { onAuthFailure(); return null; }
        const d = await r.json();
        if (d && typeof d.value === "string") {
          try { localStorage.setItem(cacheKey, d.value); } catch (e) {}
          return { value: d.value };
        }
        // server has nothing for this key yet: fall back to (and seed
        // the server from) this browser's own cached copy, if any
        try {
          const local = localStorage.getItem(cacheKey);
          if (local !== null) {
            fetch("/api/storage", {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify({ key, value: local }),
            }).catch(() => {});
            return { value: local };
          }
        } catch (e) {}
        return null;
      } catch (e) {
        try {
          const v = localStorage.getItem(cacheKey);
          return v === null ? null : { value: v };
        } catch (e2) {
          return null;
        }
      }
    },
    async set(key, value) {
      const cacheKey = "bh_cache_" + (sessionUsername || "anon") + "_" + key;
      try { localStorage.setItem(cacheKey, value); } catch (e) {}
      try {
        const r = await fetch("/api/storage", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ key, value }),
        });
        if (r.status === 401) { onAuthFailure(); return false; }
        const d = await r.json();
        return !!(d && d.ok);
      } catch (e) {
        return false;
      }
    },
  };
}

const parseJSON = (t) => {
  const clean = t.replace(/```json|```/g, "").trim();
  try { return JSON.parse(clean); } catch {}
  const m = clean.match(/[[{][\s\S]*[\]}]/);
  if (m) { try { return JSON.parse(m[0]); } catch {} }
  return null;
};

const wordlist = (v) => v.map((x) => `${x.id} = ${x.en}`).join("\n");

// read the Indonesian out loud, only ever after the answer is in

// every word he has ever had on a card, for spotting anything foreign in a model answer
const deckWords = (v) => {
  const set = new Set();
  v.forEach((c) => canonTokens(c.id).forEach((w) => set.add(canonWord(w))));
  return set;
};
const outsideDeck = (sentence, known) =>
  Array.from(new Set(canonTokens(sentence).map(canonWord)))
    .filter((w) => w.length > 1 && !known.has(w) && !/^\d+$/.test(w));

/* ---------------- session builder ---------------- */

const ROUND = 10;

function buildSession(vocab, mem, want, now, size = ROUND, weak = [], recog = 0.15, conf = {}, words = {}, nudged = {}) {
  const weakSet = new Set(weak.map((w) => w.k || w));
  const index = vocab.map((c, i) => ({ ...c, i, tags: tagsOf(c.id), tok: tokens(c.id),
    long: Math.min(...c.id.split("/").map((v) => v.trim().split(/\s+/).filter(Boolean).length)) >= 4 && !hasBlank(c.id) }));
  const known = index.filter((c) => mem[c.id + "|en2id"] || mem[c.id + "|id2en"]);

  const prior = (card) => {
    const near = known.filter((k) => k.tok.some((t) => card.tok.includes(t)));
    const ds = near.map((k) => (mem[k.id + "|en2id"] || mem[k.id + "|id2en"]).d);
    // A card's first exposure in Drill is not necessarily this word's first
    // exposure anywhere: Sentence forge and Roleplay may already have real
    // production evidence for it (data.words, the same accuracy signal
    // weakWords/strongWords read). A word he already produces cleanly gets
    // a kinder starting difficulty than the generic blind guess; one he
    // keeps fumbling gets a harsher one -- only once there's enough
    // evidence to trust it (same bar as weakWords/strongWords: 3+ attempts).
    card.tok.forEach((t) => {
      const w = words[canonWord(t)];
      if (!w) return;
      const total = (w.c || 0) + (w.w || 0) + (w.o || 0) + (w.h || 0);
      if (total < 3) return;
      const rate = ((w.w || 0) + 0.5 * (w.o || 0) + 0.4 * (w.h || 0)) / total;
      ds.push(1 + rate * 9); // same 1..10 scale as FSRS difficulty
    });
    if (!ds.length) return null;
    return { d: ds.reduce((a, b) => a + b, 0) / ds.length, s: 0 };
  };

  const pool = [];
  for (const card of index) {
    const weakHit = card.tok.some((t) => weakSet.has(canonWord(t))) ? 1.6 : 0;
    // whole sentences are practised as gap-fills and in the forge, not typed out word for word
    for (const dir of card.long ? ["id2en"] : ["en2id", "id2en"]) {
      const st = mem[card.id + "|" + dir];
      if (st) {
        const left = dueIn(st, want, now);
        // A miss on this word in Sentence forge/Roleplay (see nudgeDue in
        // Home) flags it here without touching st.s/st.d at all -- FSRS's
        // own difficulty/stability stay exactly what Drill itself measured;
        // this only pulls the *next session's* selection forward, so the
        // real measurement still happens cleanly, just sooner.
        const isNudged = !!nudged[card.id + "|" + dir];
        if (left <= 0.02 || isNudged) pool.push({ card, dir, st, kind: (st.cr || 0) < 3 ? "learning" : "due", score: -left + (dir === "en2id" ? 0.3 : 0) + ((st.cr || 0) < 3 ? 2 : 0) + weakHit + (isNudged ? 1.2 : 0) });
      } else if (dir === "id2en" && card.long) {
        pool.push({ card, dir, st: null, kind: "new", prior: prior(card), score: 0.3 + weakHit });
      } else if (dir === "en2id") {
        const p = prior(card);
        pool.push({ card, dir, st: null, kind: "new", prior: p, score: (p ? 0.5 : 0.1) + weakHit });
      } else {
        // the recognition side only opens once production has taken hold
        const prod = mem[card.id + "|en2id"];
        if (prod && prod.r >= 2) pool.push({ card, dir, st: null, kind: "new", prior: { d: prod.d, s: 0 }, score: 0.25 + weakHit });
      }
    }
    if (card.s === 1) {
      const st = mem[card.id + "|cloze"];
      const gap = gapOf(card.id, weakSet);
      const prod = mem[card.id + "|en2id"];
      if (gap && st) {
        const left = dueIn(st, want, now);
        const isNudged = !!nudged[card.id + "|cloze"];
        if (left <= 0.02 || isNudged) pool.push({ card, dir: "cloze", gap, st, kind: (st.cr || 0) < 3 ? "learning" : "due", score: -left + 0.5 + ((st.cr || 0) < 3 ? 2 : 0) + weakHit + (isNudged ? 1.2 : 0) });
      } else if (gap && (card.long || (prod && (prod.cr || 0) >= 2))) {
        pool.push({ card, dir: "cloze", gap, st: null, kind: "new", prior: prod ? { d: prod.d, s: 0 } : prior(card), score: 0.8 + weakHit });
      }
    }
  }

  const due = pool.filter((p) => p.kind === "due" || p.kind === "learning").sort((a, b) => b.score - a.score);
  const fresh = pool.filter((p) => p.kind === "new").sort((a, b) => b.score - a.score);
  const capped = [];
  let clozeCount = 0, recogCount = 0;
  const recogMax = Math.max(1, Math.round(size * recog));
  for (const p of due) {
    if (p.dir === "cloze") { if (clozeCount >= 2) continue; clozeCount++; }
    if (p.dir === "id2en") { if (recogCount >= recogMax) continue; recogCount++; }
    capped.push(p);
    if (capped.length >= Math.max(size - 3, Math.ceil(size * 0.7))) break;
  }
  const rest = [];
  for (const p of fresh) {
    if (rest.length + capped.length >= size) break;
    if (p.dir === "cloze") { if (clozeCount >= 2) continue; clozeCount++; }
    if (p.dir === "id2en") { if (recogCount >= recogMax) continue; recogCount++; }
    rest.push(p);
  }
  let mix = [...capped, ...rest];

  /* Items that look alike interfere with each other. While either of a
     confused pair is still settling, they never share a round. Once both
     are solid, the contrast is worth drilling, so they are allowed back
     together. */
  const solid = (id) => {
    const st = mem[id + "|en2id"];
    return st && (st.cr || 0) >= 3;
  };
  /* Interleaving beats blocking, and similar items interfere with each other,
     so at most two cards per round may hang on the same weak word. Once both
     sides of a pair are solid the contrast is worth practising, so the limit
     only applies while something is still settling. */
  const weakUse = {};
  mix = mix.filter((p) => {
    const hit = p.card.tok.map(canonWord).find((t) => weakSet.has(t));
    if (!hit) return true;
    weakUse[hit] = (weakUse[hit] || 0) + 1;
    return weakUse[hit] <= 2;
  });

  const taken = new Set();
  mix = mix.filter((p) => {
    const id = p.card.id;
    const partners = conf[id] || [];
    const clash = partners.some((o) => taken.has(o) && !(solid(id) && solid(o)));
    if (clash) return false;
    taken.add(id);
    return true;
  });

  // extra rounds: if nothing is due, bring the closest items forward rather than stopping
  if (mix.length < size) {
    const chosen = new Set(mix.map((p) => p.card.id + "|" + p.dir));
    const early = [];
    for (const card of index) {
      for (const dir of card.long ? ["id2en"] : ["en2id", "id2en"]) {
        const st = mem[card.id + "|" + dir];
        if (!st) continue;
        const k = card.id + "|" + dir;
        if (chosen.has(k)) continue;
        early.push({ card, dir, st, kind: "early", left: dueIn(st, want, now) });
      }
    }
    early.sort((a, b) => a.left - b.left);
    for (const p of early) {
      if (mix.length >= size) break;
      mix.push(p);
    }
  }

  // interleave: neither the same pattern nor the same weak word back to back
  const weakOf = (p) => p.card.tok.map(canonWord).find((t) => weakSet.has(t)) || null;
  const out = [];
  const left = [...mix];
  while (left.length) {
    const prev = out.length ? out[out.length - 1] : null;
    const prevTag = prev ? prev.card.tags[0] : null;
    const prevWeak = prev ? weakOf(prev) : null;
    let k = left.findIndex((x) => x.card.tags[0] !== prevTag && (!prevWeak || weakOf(x) !== prevWeak));
    if (k < 0) k = left.findIndex((x) => x.card.tags[0] !== prevTag);
    if (k < 0) k = 0;
    out.push(left.splice(k, 1)[0]);
  }
  return out.slice(0, size);
}

/* ---------------- styling ---------------- */

const CSS = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800&family=Fredoka:wght@500;600&display=swap');

.bh{
  --ink:#1A1B1F; --muted:#5E6472; --faint:#686C77; --paper:#FFFFFF; --canvas:#EDEEF2; --hair:#E0E1E7; --line:#C9CBD4;
  --act:#E2F4EE; --act-line:#A5DCCA; --act-ink:#0A6349; --solid:#0D7F63; --solid-dark:#0A6650; --dot:#17BFA0;
  --yes:#0F7A55; --yes-bg:#E9F7F0; --no:#C22E4A; --no-bg:#FCEEF1;
  --violet:#6C5CE7; --coral:#C63A62; --teal:#0D7F63; --sun:#2B6FE0; --sun-bg:#E2F4EE; --sun-ink:#0A6349;
  --violet-soft:#EFEDFE; --violet-line:#D6D1FA; --violet-ink:#3F35A8; --coral-soft:#FBEAEF; --coral-line:#EFC0CE; --coral-ink:#9C2A4B;
  --teal-soft:#E3F4EC; --teal-line:#BCE3D3; --teal-ink:#0A6349; --sun-soft:#E6EFFC; --sun-line:#C2D8F7;
  --bad-bg:#F8D2DA; --bad-ink:#8E1F36; --fix-bg:#CBEFDF; --fix-ink:#0B6B4F; --why-bg:rgba(255,255,255,.8);
  --mine-bg:#1A1B1F; --bw:0.5px;
  /* Type + spacing scale (Refactoring UI style: fixed steps, not ad hoc
     values). Not yet applied to every existing rule, adopt it as
     components get touched rather than in one risky blanket pass. */
  --fs-1:0.75rem; --fs-2:0.875rem; --fs-3:1rem; --fs-4:1.125rem; --fs-5:1.25rem; --fs-6:1.5rem; --fs-7:1.875rem; --fs-8:2.25rem; --fs-9:3rem;
  --sp-1:4px; --sp-2:8px; --sp-3:12px; --sp-4:16px; --sp-5:24px; --sp-6:32px; --sp-7:48px; --sp-8:64px;
  font-family:'Nunito',system-ui,sans-serif; color:var(--ink); background:var(--canvas);
  min-height:100%; box-sizing:border-box;
  padding:calc(20px + env(safe-area-inset-top)) calc(18px + env(safe-area-inset-right)) calc(40px + env(safe-area-inset-bottom)) calc(18px + env(safe-area-inset-left));
  font-size:1rem; line-height:1.5;
  -webkit-font-smoothing:antialiased; color-scheme:light;
}
.bh[data-theme="dark"]{
  --ink:#F1F2F6; --muted:#A3A9B7; --faint:#7F8698; --paper:#1C1F27; --canvas:#111318; --hair:#2A2E39; --line:#3A3F4D;
  --act:#14302A; --act-line:#2E6455; --act-ink:#8FE3CB; --solid:#19A183; --solid-dark:#14856C; --dot:#3DDABE;
  --yes:#5FD3A5; --yes-bg:#15302A; --no:#FF7F92; --no-bg:#3A2028;
  --violet:#7C6DFF; --coral:#F0698D; --teal:#19A183; --sun:#5C9BFF; --sun-bg:#14302A; --sun-ink:#8FE3CB;
  --violet-soft:#232238; --violet-line:#39366B; --violet-ink:#BDB6FF; --coral-soft:#2E1B22; --coral-line:#553040; --coral-ink:#F7B6C8;
  --teal-soft:#152B26; --teal-line:#27473E; --teal-ink:#7FE0BC; --sun-soft:#182338; --sun-line:#2C4166;
  --bad-bg:#5A2431; --bad-ink:#FFB3C0; --fix-bg:#1F4A3B; --fix-ink:#9FEBCB; --why-bg:rgba(255,255,255,.06);
  --mine-bg:#3B3F4C; --bw:1px; color-scheme:dark;
}
.bh *{box-sizing:border-box}
.bh-wrap{max-width:560px;margin:0 auto;width:100%}
@media (prefers-reduced-motion:no-preference){.bh-screen{animation:screenIn .22s ease-out}}
@keyframes screenIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
@media (min-width:700px){
  .bh{padding:calc(32px + env(safe-area-inset-top)) calc(24px + env(safe-area-inset-right)) calc(56px + env(safe-area-inset-bottom)) calc(24px + env(safe-area-inset-left))}
  .bh-heroin{padding:26px}
  .bh-headline{font-size:2.25rem}
  .bh-cue{font-size:clamp(2.125rem,3.4vw,2.875rem)}
}
.bh button{font-family:inherit;cursor:pointer;color:inherit;background:transparent;border:0;padding:0;-webkit-appearance:none;appearance:none}
.bh button:focus-visible,.bh input:focus-visible,.bh textarea:focus-visible{outline:2px solid var(--solid);outline-offset:2px}
.bh input,.bh textarea{font-family:inherit}
.serif{font-family:'Fredoka',system-ui,sans-serif;font-weight:600;letter-spacing:-0.01em}

.bh-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px}
.bh-logo{font-size:0.9375rem;font-weight:500;letter-spacing:-0.01em;display:flex;align-items:center;gap:9px}
.bh-mark{width:22px;height:22px;border-radius:6px;overflow:hidden;flex:none;border:var(--bw) solid var(--line)}
.bh-mark span{display:block;height:50%}
.bh-mark span:first-child{background:#CE1126}
.bh-mark span:last-child{background:#F4F4F4}
.bh-dotmark{font-style:normal;color:var(--solid);font-weight:800}
.bh-ver{font-size:0.625rem;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--faint);margin-left:2px}
.bh-topright{display:flex;align-items:center;gap:10px}
.bh-gear{color:var(--faint);display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%}
.bh-gear:active{background:var(--hair)}
.bh-streak{font-size:0.8125rem;color:var(--muted);font-weight:500}
.bh-streak[data-warn="1"]{color:var(--no)}

.bh-week{display:flex;gap:6px;margin-bottom:22px}
.bh-day{flex:1;text-align:center}
.bh-daybox{height:36px;border-radius:14px;background:var(--paper);border:var(--bw) solid var(--hair);display:flex;align-items:center;justify-content:center;color:var(--sun-ink)}
.bh-day[data-s="done"] .bh-daybox{background:var(--sun-bg);border-color:var(--sun-bg);color:var(--sun-ink)}
.bh-day[data-s="today"] .bh-daybox{border:1.5px dashed var(--solid);background:var(--paper)}
.bh-day[data-s="todaydone"] .bh-daybox{background:var(--solid);border-color:var(--solid);color:#fff}
@media (prefers-reduced-motion:no-preference){.bh-daybox svg{animation:pop .28s cubic-bezier(.3,1.4,.5,1)}}
@keyframes pop{from{transform:scale(.4);opacity:0}to{transform:scale(1);opacity:1}}
.bh-daylab{font-size:0.6875rem;color:var(--faint);margin-top:5px;font-weight:400}
.bh-day[data-s="today"] .bh-daylab,.bh-day[data-s="todaydone"] .bh-daylab{color:var(--act-ink);font-weight:500}

.bh-card{background:var(--paper);border:var(--bw) solid var(--hair);border-radius:18px;padding:20px 18px;margin-bottom:14px}
.bh-hero{background:var(--paper);border:var(--bw) solid var(--hair);border-radius:20px;overflow:hidden;margin-bottom:14px}
.bh-heroin{padding:18px}
.bh-num1{background:var(--act);color:var(--act-ink)}
.bh-alsolab{font-size:0.6875rem;letter-spacing:.07em;text-transform:uppercase;color:var(--faint);font-weight:600;margin:2px 0 8px}
.bh-two{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:18px}
.bh-twocard[data-tone="teal"] .bh-twoico{color:var(--solid)}
.bh-twocard[data-tone="teal"] .bh-twoico{color:var(--solid)}
.bh .bh-twocard{background:transparent;border:1.5px dashed var(--line);border-radius:18px;padding:15px;text-align:left;display:flex;flex-direction:column}
.bh-twocard:active{background:var(--canvas)}
.bh-twocard[data-tone="violet"] .bh-twoico{color:var(--violet)}
.bh-twocard[data-tone="coral"] .bh-twoico{color:var(--coral)}
.bh-twoname{font-size:0.9688rem;font-weight:600;letter-spacing:-0.015em;margin-top:9px;color:var(--muted)}
.bh-twometa{font-size:0.7812rem;color:var(--faint);margin-top:2px}
.bh-week[data-small="1"]{gap:5px;margin-bottom:16px}
.bh-week[data-small="1"] .bh-daybox{height:26px;border-radius:8px}
.bh-week[data-small="1"] .bh-daylab{font-size:0.625rem;margin-top:4px}
/* aspect-ratio (not a fixed height) keeps this matched to the SVG's own
   340x104 viewBox at any card width, so preserveAspectRatio's "slice"
   never has to crop the top of the illustration to fill the box -- the
   old fixed-height rules (104px on phones, 150px from 700px up) drifted
   out of sync with the actual rendered card width and clipped the sun. */
.bh-scene{display:block;width:100%;aspect-ratio:340/104;-webkit-mask-image:radial-gradient(120% 100% at 50% 0,#000 82%,transparent 100%);mask-image:radial-gradient(120% 100% at 50% 0,#000 82%,transparent 100%)}
.bh{--sky:#E8F5F0;--hill1:#A8DCCB;--hill2:#7DC9B2;--terr1:#52B999;--terr2:#31A07F;--terr3:#1C8A6B;--terr4:#12765C;--dot:#F2A93B}
.bh [data-mood="dawn"]{--sky:#F4F3E4;--hill1:#C6DCC2;--hill2:#9CCBB2;--terr1:#6FBCA0;--terr2:#47A586;--terr3:#2A8E70;--terr4:#19785E;--dot:#F2A93B}
.bh [data-mood="sea"]{--sky:#E4F2F6;--hill1:#A6D5DF;--hill2:#78BFD0;--terr1:#4FA7BE;--terr2:#2F8DA6;--terr3:#1E748C;--terr4:#145E74;--dot:#F2A93B}
.bh [data-mood="dusk"]{--sky:#EAF2F1;--hill1:#B5D2CB;--hill2:#8EBCB2;--terr1:#66A497;--terr2:#458C7E;--terr3:#2C7365;--terr4:#1B5D51;--dot:#F2A93B}
.bh [data-mood="night"]{--sky:#DEEDED;--hill1:#A3C8CA;--hill2:#7CB0B4;--terr1:#5A989D;--terr2:#41818A;--terr3:#2F6D77;--terr4:#215A64;--dot:#F2A93B}
.bh[data-theme="dark"]{--sky:#12211D;--hill1:#1C3A32;--hill2:#245046;--terr1:#1E5F4E;--terr2:#18715B;--terr3:#12866A;--terr4:#0E6E58;--dot:#E8A83C}
.bh[data-theme="dark"] [data-mood="dawn"]{--sky:#1B2119;--hill1:#25392C;--hill2:#2D4B3A;--terr1:#2F5F48;--terr2:#276D55;--terr3:#1F7A5F;--terr4:#17624C;--dot:#E8A83C}
.bh[data-theme="dark"] [data-mood="sea"]{--sky:#10242B;--hill1:#173942;--hill2:#1D4A55;--terr1:#215C69;--terr2:#1A4F5C;--terr3:#14414C;--terr4:#0F343D;--dot:#E8A83C}
.bh[data-theme="dark"] [data-mood="dusk"]{--sky:#13211F;--hill1:#1D3733;--hill2:#264842;--terr1:#2A5A50;--terr2:#224C44;--terr3:#1B3E38;--terr4:#14302B;--dot:#E8A83C}
.bh[data-theme="dark"] [data-mood="night"]{--sky:#0F1F21;--hill1:#183234;--hill2:#1F4245;--terr1:#245155;--terr2:#1D4448;--terr3:#16383B;--terr4:#102B2E;--dot:#E8A83C}
.bh-step1{display:flex;align-items:center;gap:10px;margin-bottom:14px}
.bh-step1n{font-size:1.0625rem;font-weight:700;letter-spacing:-0.015em}
.bh-step1w,.bh-cad{margin-left:auto;flex:none;font-size:0.75rem;font-weight:700;padding:5px 11px;border-radius:999px;background:var(--act);color:var(--act-ink)}
.bh-row2[data-tone="teal"] .bh-cad{background:var(--violet-soft);color:var(--violet-ink)}
.bh-row2[data-tone="coral"] .bh-cad{background:var(--coral-soft);color:var(--coral-ink)}
.bh-headline{font-family:'Fredoka',system-ui,sans-serif;font-weight:600;letter-spacing:-0.01em;font-size:2rem;line-height:1.2;margin:0 0 8px;font-weight:800;letter-spacing:-0.03em}
.bh-sayso{font-size:0.875rem;color:var(--muted);margin:2px 0 18px;line-height:1.55;max-width:40ch}
.bh .bh-cta{width:100%;height:54px;background:var(--solid);color:#fff;border:2px solid var(--solid-dark);border-radius:14px;font-size:1.0312rem;font-weight:600;letter-spacing:.01em}
.bh .bh-cta{transition:transform .08s,background .12s}
.bh .bh-cta:active{background:var(--solid-dark);transform:scale(.99)}

.bh-rows{display:flex;flex-direction:column;gap:10px;margin-bottom:18px}
.bh .bh-row2{display:flex;gap:14px;align-items:center;padding:18px 16px;width:100%;text-align:left;
  background:var(--paper);border:1.5px solid var(--step-line,var(--hair));border-radius:20px;transition:transform .08s}
.bh .bh-row2:active{transform:scale(.985)}
.bh .bh-row2[data-tone="teal"]{--step-line:var(--violet-line)}
.bh .bh-row2[data-tone="coral"]{--step-line:var(--coral-line)}
.bh .bh-row2:active{background:var(--canvas)}
.bh .bh-row2[data-tone="teal"] .bh-num{background:var(--violet-soft);color:var(--violet-ink)}
.bh .bh-row2[data-tone="coral"] .bh-num{background:var(--coral-soft);color:var(--coral-ink)}
.bh-num{width:38px;height:38px;flex:none;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:1rem;font-weight:700}
.bh-rname{font-size:1.0625rem;font-weight:700;letter-spacing:-0.015em}
.bh-rwhen{font-size:0.8438rem;color:var(--muted);margin-top:3px}
.bh-rdone{font-size:0.8125rem;color:var(--yes);font-weight:500}
.bh-chev{color:var(--faint);flex:none}

.bh-tabs{display:flex;margin-top:2px;padding-top:12px;border-top:var(--bw) solid var(--hair)}
.bh .bh-tab{flex:1;display:flex;flex-direction:column;align-items:center;gap:7px;padding:14px 2px 8px;position:relative;min-height:70px}
.bh .bh-tab:active{opacity:.6}
.bh-tabico{display:flex;align-items:center;justify-content:center;color:var(--muted)}
.bh .bh-tab[data-tone="violet"] .bh-tabico{color:var(--violet)}
.bh .bh-tab[data-tone="coral"] .bh-tabico{color:var(--coral)}
.bh .bh-tab[data-tone="teal"] .bh-tabico{color:var(--teal)}
.bh .bh-tab[data-tone="sun"] .bh-tabico{color:var(--sun-ink)}
.bh-tabname{font-size:0.7812rem;font-weight:500;color:var(--muted);letter-spacing:0}
.bh .bh-tab[data-alert="1"] .bh-tabname{color:var(--coral-ink);font-weight:600}
.bh-tabmeta{display:none}
.bh .bh-tab[data-alert="1"]::after{content:'';position:absolute;top:9px;right:calc(50% - 20px);width:9px;height:9px;border-radius:50%;background:var(--coral)}
.bh-foot{display:flex;gap:18px;margin-top:22px;justify-content:center}
.bh .bh-foot button{font-size:0.8125rem;color:var(--faint);font-weight:400}
.bh-help{margin-top:18px;font-size:0.9062rem;color:var(--muted);line-height:1.75;background:var(--paper);border:var(--bw) solid var(--hair);border-radius:18px;padding:18px}
.bh-help b{color:var(--ink);font-weight:500}

.bh-tourdim{position:fixed;inset:0;z-index:59;background:transparent}
.bh-tourhole{position:fixed;z-index:60;border-radius:16px;pointer-events:none;box-shadow:0 0 0 9999px rgba(10,12,16,.62),0 0 0 3px rgba(255,255,255,.9);transition:top .25s ease,left .25s ease,width .25s ease,height .25s ease}
.bh-tourcard{position:fixed;left:16px;right:16px;z-index:61;background:var(--paper);border:var(--bw) solid var(--hair);border-radius:16px;padding:16px;box-shadow:0 10px 30px rgba(0,0,0,.28);transition:top .25s ease;max-width:528px;margin:0 auto}
.bh-tourtext{font-size:0.9062rem;color:var(--ink);line-height:1.5;margin-bottom:14px}
.bh-touract{display:flex;align-items:center;justify-content:space-between;gap:10px}
.bh .bh-tourskip{font-size:0.8438rem;color:var(--faint);font-weight:500;padding:6px 4px}
.bh .bh-tournext{font-size:0.875rem;font-weight:600;color:#fff;background:var(--solid);border:1.5px solid var(--solid-dark);border-radius:12px;padding:9px 18px}
.bh-tourdots{display:flex;gap:5px}
.bh-tourdots span{width:6px;height:6px;border-radius:50%;background:var(--hair);display:inline-block}
.bh-tourdots span[data-on="1"]{background:var(--solid)}

.bh .bh-back{display:inline-flex;align-items:center;gap:4px;font-size:0.875rem;font-weight:500;color:var(--act-ink);min-height:44px;margin:-8px 0 8px -6px;padding:0 10px 0 6px;border-radius:14px}
.bh .bh-back:active{background:var(--hair)}
.bh-rule{display:block;width:104px;height:8px;margin:2px 0 14px}
.bh-title{font-family:'Fredoka',system-ui,sans-serif;font-size:1.75rem;margin:0 0 6px;line-height:1.15;font-weight:600;letter-spacing:-0.01em}
.bh-lede{font-size:var(--fs-2);color:var(--muted);margin-bottom:var(--sp-5);max-width:42ch;line-height:1.55}

.bh-bar{height:5px;background:var(--hair);border-radius:999px;overflow:hidden;margin-bottom:8px}
.bh-ticks{display:flex;gap:4px;margin-bottom:8px}
.bh-tick{flex:1;height:6px;border-radius:999px;background:var(--hair)}
.bh-tick[data-s="yes"]{background:var(--solid)}
.bh-tick[data-s="no"]{background:var(--no);opacity:.55}
.bh-tick[data-s="new"]{background:var(--dot)}
.bh-tick[data-s="now"]{background:var(--act-line)}
.bh-bar i{display:block;height:100%;background:var(--teal);border-radius:999px;transition:width .3s ease}
.bh-barlab{display:flex;justify-content:space-between;font-size:0.7188rem;color:var(--muted);font-weight:500;margin-bottom:20px}
.bh-stage{background:var(--paper);border:var(--bw) solid var(--hair);border-radius:18px;padding:26px 20px;margin-bottom:16px}
.bh-ask{font-size:0.6875rem;letter-spacing:.07em;text-transform:uppercase;color:var(--faint);font-weight:600;margin-bottom:12px;display:flex;justify-content:space-between;gap:12px}
.bh-ask em{font-style:normal;color:var(--muted);font-weight:500}
.bh .bh-hintbtn{font-size:0.7188rem;font-weight:600;letter-spacing:0;text-transform:none;color:var(--act-ink);
  background:var(--act);border-radius:999px;padding:4px 11px}
.bh-hintbox{margin-top:16px;padding-top:14px;border-top:var(--bw) solid var(--hair)}
.bh-hintlab{display:block;font-size:0.6875rem;letter-spacing:.06em;text-transform:uppercase;color:var(--faint);font-weight:600;margin-bottom:8px}
.bh-cue{font-family:'Fredoka',system-ui,sans-serif;font-size:clamp(1.875rem,9vw,2.75rem);line-height:1.1;font-weight:600;letter-spacing:-0.015em}
@media (prefers-reduced-motion:no-preference){.bh-cue{animation:rise .2s ease-out}}
@keyframes rise{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}
.bh-gapen{font-size:0.9062rem;color:var(--muted);font-weight:500;margin-top:12px}
.bh-gapen b{color:var(--ink);font-weight:600}
.bh-gap{display:inline-block;min-width:2.2em;text-align:center;background:var(--act);color:var(--act-ink);
  border:2px dashed var(--act-line);border-radius:10px;padding:0 .35em;margin:0 .12em}
.bh-do{font-size:0.8438rem;color:var(--muted);font-weight:400;margin-bottom:8px}
.bh-field{width:100%;background:var(--paper);border:var(--bw) solid var(--line);border-radius:14px;color:var(--ink);padding:15px 14px;font-size:1.0625rem;outline:none}
.bh-field::placeholder{color:var(--faint)}
.bh-field:focus{border-color:var(--ink)}
.bh-acts{display:flex;gap:10px;margin-top:12px}
.bh .bh-go{flex:1;height:50px;background:var(--solid);color:#fff;border:2px solid var(--solid-dark);border-radius:14px;font-size:0.9688rem;font-weight:600}
.bh .bh-go:disabled{background:var(--hair);color:var(--faint);border-color:var(--line)}
.bh .bh-go:active:not(:disabled){background:var(--solid-dark)}
.bh .bh-alt{height:50px;border:1px solid var(--line);color:var(--ink);font-size:0.9062rem;font-weight:400;padding:0 18px;border-radius:14px;background:var(--paper)}
.bh .bh-alt:active{background:var(--canvas)}

.bh-judge{border-radius:18px;padding:18px;margin-bottom:14px;border:var(--bw) solid}
.bh-right{background:var(--yes-bg);border-color:transparent}
.bh-wrong{background:var(--no-bg);border-color:transparent}
.bh-act{background:var(--act);border-color:transparent}
.bh-verdict{font-size:0.9062rem;font-weight:700;margin-bottom:12px;display:flex;justify-content:space-between;gap:12px;align-items:baseline}
.bh-right .bh-verdict b{color:var(--yes)}.bh-wrong .bh-verdict b{color:var(--no)}.bh-act .bh-verdict b{color:var(--act-ink)}
.bh-act .bh-why{background:var(--why-bg)}
.bh-verdict span{color:var(--muted);font-weight:500;font-size:0.7812rem}
.bh-praise{font-size:0.9375rem;font-weight:500;color:var(--yes);margin-top:8px}
.bh-q{font-weight:700;color:var(--ink)}
.bh-why{font-size:0.875rem;color:var(--muted);margin-top:12px;line-height:1.6;background:var(--why-bg);border-radius:14px;padding:12px 14px}
.bh-block{margin-bottom:12px}
.bh-blab{font-size:0.6875rem;letter-spacing:.06em;text-transform:uppercase;font-weight:600;color:var(--faint);margin-bottom:6px}
.bh-sent{font-size:1.3125rem;font-weight:700;letter-spacing:-0.02em;line-height:1.4}
.bh-bad{background:var(--bad-bg);color:var(--bad-ink);border-radius:6px;padding:1px 6px;text-decoration:line-through;text-decoration-thickness:2px;margin-right:2px;display:inline-block}
.bh-fix{background:var(--fix-bg);color:var(--fix-ink);border-radius:6px;padding:1px 6px;margin-right:2px;display:inline-block}

.bh-result{text-align:center;padding:6px 0 4px}
.bh-score{font-family:'Fredoka',system-ui,sans-serif;font-size:2.25rem;margin:8px 0 2px;font-weight:600;letter-spacing:-0.01em}
.bh-scorenote{font-size:0.9375rem;color:var(--muted);margin-bottom:14px}
.bh-diag{font-size:0.9375rem;line-height:1.6;color:var(--act-ink);background:var(--act);border-radius:14px;
  padding:14px 16px;margin:0 auto 22px;max-width:44ch;text-align:left}
.bh-miss{background:var(--paper);border:var(--bw) solid var(--hair);border-radius:14px;padding:14px 16px;margin-bottom:8px}
.bh-miss b{font-weight:700;font-size:1rem}
.bh-miss small{display:block;color:var(--muted);font-size:0.8125rem;margin-top:3px;line-height:1.5}
.bh-sub{font-size:0.8125rem;color:var(--muted);font-weight:400;margin:0 0 10px}

.bh-switch{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:20px}
.bh .bh-switch button{background:var(--paper);border:var(--bw) solid var(--line);border-radius:999px;padding:11px;font-size:0.875rem;color:var(--muted);font-weight:400}
.bh .bh-switch button[data-on="1"]{background:var(--ink);color:var(--canvas);border-color:var(--ink)}

.bh-thread{display:flex;flex-direction:column;gap:12px;margin-bottom:18px}
.bh-bubble{max-width:84%;padding:12px 16px;font-size:1rem;line-height:1.5;border-radius:18px}
.bh-from{background:var(--paper);border:var(--bw) solid var(--hair);border-bottom-left-radius:6px;align-self:flex-start}
.bh-mine{background:var(--mine-bg);color:var(--ink);border-bottom-right-radius:6px;align-self:flex-end}

.bh .bh-tile{display:flex;gap:14px;align-items:center;text-align:left;width:100%;background:var(--paper);border:var(--bw) solid var(--hair);border-radius:18px;padding:14px 16px;margin-bottom:10px}
.bh-tdesc{font-size:0.8125rem;color:var(--muted);margin-top:2px}
.bh-tname{font-weight:500;font-size:0.9688rem}
.bh-ico{width:38px;height:38px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex:none}
.bh-badge{font-size:0.75rem;font-weight:500;color:var(--act-ink);background:var(--act);border-radius:999px;padding:4px 10px}
.bh-badge[data-quiet="1"]{background:var(--canvas);color:var(--muted)}

.bh-panel{background:var(--paper);border:var(--bw) solid var(--hair);border-radius:18px;padding:var(--sp-4);margin-bottom:var(--sp-3)}
.bh-plabel{font-size:var(--fs-2);font-weight:700;color:var(--ink);margin-bottom:var(--sp-3)}
.bh-row{display:flex;justify-content:space-between;gap:14px;align-items:center;padding:10px 0;border-top:var(--bw) solid var(--hair);font-size:0.9375rem}
.bh-row:first-of-type{border-top:none;padding-top:0}
.bh .bh-prow{display:grid;grid-template-columns:1fr auto;gap:4px 12px;width:100%;text-align:left;padding:12px 0;border-top:var(--bw) solid var(--hair)}
.bh-prow:first-of-type{border-top:none}
.bh-prow[data-thin="1"]{opacity:.5}
.bh-pname{font-size:0.9375rem;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.bh-pnum{font-size:0.8125rem;color:var(--muted);white-space:nowrap}
.bh-pnum b{color:var(--ink);font-weight:700}
.bh-thin{font-style:italic}
.bh-trend{font-size:0.6875rem;font-weight:700;border-radius:999px;padding:3px 8px}
.bh-trend[data-dir="up"]{background:var(--yes-bg);color:var(--yes)}
.bh-trend[data-dir="down"]{background:var(--no-bg);color:var(--no)}
.bh-prow .bh-meter{grid-column:1 / -1;max-width:none}
.bh-meter{height:8px;background:var(--canvas);border-radius:999px;flex:1;max-width:110px;overflow:hidden}
.bh-meter i{display:block;height:100%;background:var(--no);border-radius:999px;opacity:.75}
.bh-count{font-size:0.8125rem;color:var(--muted);font-weight:500;flex:none;min-width:32px;text-align:right}
.bh-fc{display:flex;align-items:flex-end;gap:4px;height:64px;margin-bottom:8px}
.bh-stagebar{display:flex;height:14px;border-radius:4px;overflow:hidden;background:var(--hair);margin-bottom:14px}
.bh-stagebar i{display:block;height:100%;min-width:10px;flex-shrink:1;transition:width .4s ease,opacity .15s ease;cursor:pointer}
.bh-stagebar i[data-tone="fresh"]{background:var(--line)}
.bh-stagebar i[data-tone="settling"]{background:var(--act-line)}
.bh-stagebar i[data-tone="holding"]{background:var(--teal-line)}
.bh-stagebar i[data-tone="solid"]{background:var(--solid)}
.bh-stagebar i[data-active]{opacity:.6}
.bh-stattiles{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-2)}
/* .bh button (below) resets padding/background/border with equal-or-higher
   specificity on plain .bh-stattile, so every rule here is prefixed with
   .bh to win the cascade -- same convention as .bh .bh-go / .bh .bh-alt. */
.bh .bh-stattile{display:block;width:100%;text-align:left;border:none;font-family:inherit;cursor:pointer;border-radius:14px;padding:14px 16px;background:var(--canvas);outline:2px solid transparent;outline-offset:3px;transition:outline-color .15s ease,filter .15s ease}
.bh .bh-stattile b{display:block;font-size:var(--fs-6);line-height:1.15}
.bh .bh-stattile span{display:block;font-size:var(--fs-1);color:var(--muted);margin-top:var(--sp-1);font-weight:500}
.bh .bh-stattile[data-active]{outline-color:var(--line);filter:brightness(0.97)}
.bh .bh-stattile[data-tone="settling"]{background:var(--act)}
.bh .bh-stattile[data-tone="settling"] b{color:var(--act-ink)}
.bh .bh-stattile[data-tone="settling"] span{color:var(--act-ink);opacity:.75}
.bh .bh-stattile[data-tone="settling"][data-active]{outline-color:var(--act-line)}
.bh .bh-stattile[data-tone="holding"]{background:var(--teal-soft)}
.bh .bh-stattile[data-tone="holding"] b{color:var(--teal-ink)}
.bh .bh-stattile[data-tone="holding"] span{color:var(--teal-ink);opacity:.75}
.bh .bh-stattile[data-tone="holding"][data-active]{outline-color:var(--teal-line)}
.bh .bh-stattile[data-tone="solid"]{background:var(--solid)}
.bh .bh-stattile[data-tone="solid"] b,.bh .bh-stattile[data-tone="solid"] span{color:#fff}
.bh .bh-stattile[data-tone="solid"] span{opacity:.85}
.bh .bh-stattile[data-tone="solid"][data-active]{outline-color:rgba(255,255,255,.85);filter:brightness(1.05)}
.bh-pnum-sub{color:var(--faint);font-weight:500}
.bh-fc div{flex:1;border-radius:3px;min-height:3px}
.bh-fclab{display:flex;justify-content:space-between;font-size:0.6875rem;color:var(--faint);font-weight:500}
.bh-blank{color:var(--muted);font-size:0.9375rem;line-height:1.7;padding:20px;max-width:42ch;background:var(--paper);border:var(--bw) dashed var(--line);border-radius:18px}
.bh-fail{color:var(--no);font-size:0.875rem;font-weight:500;margin-top:12px}
.bh-micwrap{display:flex;flex-direction:column;align-items:center;gap:14px;padding:34px 0 14px}
.bh .bh-mic{width:88px;height:88px;border-radius:50%;background:var(--solid);color:#fff;border:2px solid var(--solid-dark);display:flex;align-items:center;justify-content:center;font-size:1.875rem}
.bh-mic[data-live="1"]{background:var(--no);border-color:var(--no)}
@media (prefers-reduced-motion:no-preference){.bh-mic[data-live="1"]{animation:bh-pulse 1.6s ease-in-out infinite}}
@keyframes bh-pulse{50%{box-shadow:0 0 0 14px var(--no-bg)}}
.bh-micstate{font-size:0.875rem;color:var(--muted);font-weight:500}
.bh-load{color:var(--muted);font-size:0.9375rem;font-weight:500}
@media (prefers-reduced-motion:no-preference){.bh-load{animation:pulse 1.2s ease-in-out infinite}}
@keyframes pulse{50%{opacity:.4}}
.bh-note{font-size:0.875rem;color:var(--muted);line-height:1.7}
.bh-slider{width:100%;accent-color:var(--act-ink);margin:4px 0 12px}
.bh-chips{display:flex;flex-wrap:wrap;gap:8px}
.bh-newbox{margin-top:12px;background:var(--act);border-radius:14px;padding:13px 14px}
.bh-newlab{font-size:0.6875rem;letter-spacing:.06em;text-transform:uppercase;font-weight:700;color:var(--act-ink);margin-bottom:9px}
.bh .bh-newchip{font-size:0.8125rem;font-weight:600;border-radius:999px;padding:7px 13px;background:var(--paper);color:var(--act-ink);border:1.5px solid var(--act-line)}
.bh .bh-newchip[data-state="busy"]{opacity:.6}
.bh .bh-lessoncard{display:block;width:100%;text-align:left;background:var(--act);border:1.5px solid var(--act-line);
  border-radius:20px;padding:18px;margin-bottom:18px}
.bh-lessoncard:active{filter:brightness(.97)}
.bh-lessontop{display:flex;align-items:center;gap:10px;margin-bottom:12px}
.bh-lessonico{width:34px;height:34px;flex:none;border-radius:50%;background:var(--solid);color:#fff;display:flex;align-items:center;justify-content:center}
.bh-lessonlab{font-size:0.6875rem;letter-spacing:.06em;text-transform:uppercase;font-weight:700;color:var(--act-ink)}
.bh-lessonpct{margin-left:auto;font-size:0.8125rem;font-weight:700;color:var(--act-ink);background:var(--paper);border-radius:999px;padding:4px 10px}
.bh-lessonname{display:block;font-family:'Fredoka',system-ui,sans-serif;font-size:1.5625rem;font-weight:600;letter-spacing:-0.01em;margin:0 0 6px;color:var(--ink)}
.bh-lessonmeta{display:block;font-size:0.8438rem;color:var(--act-ink);line-height:1.5;opacity:.85}
.bh-lessongo{display:inline-flex;align-items:center;gap:5px;margin-top:14px;font-size:0.875rem;font-weight:700;color:var(--solid)}
.bh-wcard{background:var(--paper);border:var(--bw) solid var(--hair);border-radius:18px;padding:16px;margin-bottom:12px}
.bh-whead{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
.bh-wword{font-family:'Fredoka',system-ui,sans-serif;font-size:1.375rem;font-weight:600;letter-spacing:-0.01em}
.bh-wcount{font-size:0.75rem;color:var(--muted);flex:none}
.bh-wmean{font-size:0.875rem;color:var(--muted);margin-top:2px}
.bh-wkind{margin-top:10px;font-size:0.7812rem;font-weight:600;border-radius:999px;padding:5px 11px;display:inline-block}
.bh-wkind[data-kind="order"]{background:var(--violet-soft);color:var(--violet-ink)}
.bh-wkind[data-kind="word"]{background:var(--sun-soft);color:var(--sun-ink)}
.bh-wpair{margin-top:14px;display:flex;flex-direction:column;gap:8px}
.bh-wline{display:flex;gap:9px;align-items:flex-start}
.bh-wline .bh-sent{font-size:1rem;font-weight:500;line-height:1.5}
.bh-wtag{flex:none;margin-top:3px;font-size:0.6875rem;font-weight:700;border-radius:7px;padding:3px 7px}
.bh-wtagno{background:var(--bad-bg);color:var(--bad-ink)}
.bh-wtagyes{background:var(--fix-bg);color:var(--fix-ink)}
.bh-chip{font-size:0.7812rem;font-weight:500;border-radius:999px;padding:6px 12px;background:var(--canvas);color:var(--muted)}
.bh-dot{width:10px;height:10px;border-radius:50%;flex:none}

.bh-histnow{display:flex;flex-direction:column;gap:2px;margin-bottom:14px}
.bh-histnow b{font-size:var(--fs-8);line-height:1.1;letter-spacing:-0.01em}
.bh-histnow span{font-size:0.8125rem;color:var(--muted)}
.bh-histwrap{position:relative;padding-top:34px;touch-action:pan-y}
.bh-histsvg{display:block;width:100%;height:90px}
.bh-histtip{position:absolute;top:0;transform:translateX(-50%);background:var(--ink);color:var(--paper);
  border-radius:10px;padding:5px 9px;white-space:nowrap;pointer-events:none;
  display:flex;flex-direction:column;align-items:center;gap:0}
.bh-histtip b{font-size:0.8125rem;line-height:1.3}
.bh-histtip span{font-size:0.625rem;font-weight:500;opacity:.75;color:var(--paper)}
.bh-histtoggle{margin-top:10px;font-size:0.8125rem;font-weight:600;color:var(--act-ink)}
`;

const localDay = (d = new Date()) => {
  const y = d.getFullYear(), m = String(d.getMonth() + 1).padStart(2, "0"), dd = String(d.getDate()).padStart(2, "0");
  return `${y}-${m}-${dd}`;
};
const today = () => localDay();
// Compact "23 Sep" label for a stored "YYYY-MM-DD" -- used only by the
// history trend chart in Memory, never for anything scheduling reads.
const fmtHistDate = (d) => {
  try { return new Date(d + "T00:00:00").toLocaleDateString(undefined, { day: "numeric", month: "short" }); }
  catch (e) { return d; }
};
const EMPTY = { mem: {}, pat: {}, errors: [], days: [], totals: { asked: 0, right: 0 }, want: 0.9, cal: { n: 0, pred: 0, hit: 0 }, hooks: {}, words: {}, register: "casual", alts: {}, mine: [], conf: {}, seen: [] };

function App({ user, onLogout }) {
  const [view, setView] = useState("home");
  const [data, setData] = useState(EMPTY);
  const [vocab, setVocab] = useState([]);
  const [ready, setReady] = useState(false);
  const [saveErr, setSaveErr] = useState(false);
  const [lessonOf, setLessonOf] = useState(null);
  const [sysDark, setSysDark] = useState(false);
  useEffect(() => {
    try {
      const mq = window.matchMedia("(prefers-color-scheme: dark)");
      const on = () => setSysDark(mq.matches);
      on();
      mq.addEventListener ? mq.addEventListener("change", on) : mq.addListener(on);
      return () => { mq.removeEventListener ? mq.removeEventListener("change", on) : mq.removeListener(on); };
    } catch (e) {}
  }, []);
  const theme = data.theme === "dark" ? "dark" : data.theme === "auto" ? (sysDark ? "dark" : "light") : "light";
  const live = useRef(EMPTY);

  useEffect(() => {
    (async () => {
      // Fetch the deck FIRST: every account gets its "vocab" key seeded at
      // signup time (the admin's real deck, or a small starter set for
      // everyone else -- see api/auth/signup.js), so this always has
      // something by the time the word-cleanup below needs it.
      let loadedVocab = [];
      try {
        const vRes = await window.storage.get("vocab");
        if (vRes && vRes.value) {
          const p = JSON.parse(vRes.value);
          if (Array.isArray(p) && p.length) {
            loadedVocab = p;
            setVocab(p);
          }
        }
      } catch (e) {}
      try {
        const r = await window.storage.get("v2");
        if (r && r.value) {
          const loaded = { ...EMPTY, ...JSON.parse(r.value) };
          // saves from before the criterion counter existed: infer it from the review count
          Object.values(loaded.mem || {}).forEach((st) => {
            if (st && st.cr === undefined) st.cr = Math.min(3, st.r || 0);
          });
          // older versions let English and fragments into the word statistics.
          // A word only survives if it appears somewhere in his actual deck.
          if (loaded.words) {
            const inDeck = new Set();
            [...loadedVocab, ...(loaded.mine || [])].forEach((c) => canonTokens(c.id).forEach((t) => inDeck.add(canonWord(t))));
            const clean = {};
            Object.entries(loaded.words).forEach(([k, v]) => {
              const w = bare(k);
              if (w.length <= 2 || ENGLISH.has(w) || !/^[a-z]+$/.test(w) || !inDeck.has(w)) return;
              const t = clean[w] || { c: 0, w: 0, o: 0, h: 0, from: {} };
              t.c += v.c || 0; t.w += v.w || 0; t.o += v.o || 0; t.h += v.h || 0;
              t.from = { ...(t.from || {}), ...(v.from || {}) };
              t.last = v.last || t.last;
              clean[w] = t;
            });
            loaded.words = clean;
          }
          live.current = loaded;
          setData(loaded);
        }
      } catch (e) {}
      setReady(true);
    })();
  }, []);

  const persist = (next) => {
    live.current = next;
    setData(next);
    Promise.resolve()
      .then(() => window.storage.set("v2", JSON.stringify(next)))
      .then((r) => setSaveErr(!r))
      .catch(() => setSaveErr(true));
  };

  // one safe way to change stored state, always from the live copy
  const update = (fn) => {
    const next = JSON.parse(JSON.stringify(live.current));
    fn(next);
    persist(next);
  };

  const saveVocab = (next) => {
    setVocab(next);
    Promise.resolve()
      .then(() => window.storage.set("vocab", JSON.stringify(next)))
      .then((r) => setSaveErr(!r))
      .catch(() => setSaveErr(true));
  };

  // one graded answer -> scheduler update + pattern update
  const commit = ({ id, dir, grade, tags, entry, mode, fixLast, skipErrorPop }) => {
    const next = JSON.parse(JSON.stringify(live.current));
    const now = Date.now();
    if (fixLast) {
      // the app judged it wrong and it was not: take that verdict back.
      // A first-ever guess never pushed an error entry to begin with
      // (see settle(): entry is null when first is true), so there is
      // nothing of that kind to pop here -- doing it anyway would delete
      // someone else's real mistake instead.
      if (!skipErrorPop) next.errors = (next.errors || []).slice(1);
      if (next.totals.asked > 0) next.totals.asked--;
      next.recent = (next.recent || []).slice(0, -1);
      (tags || []).forEach((t) => {
        const p = next.pat[t];
        if (p && p.w > 0) p.w--;
      });
    }
    if (mode) {
      next.log = next.log || {};
      const d0 = today();
      next.log[d0] = next.log[d0] || {};
      next.log[d0][mode] = (next.log[d0][mode] || 0) + 1;
      next.log = Object.fromEntries(Object.entries(next.log).slice(-14));
    }
    if (id) {
      const key = id + "|" + dir;
      // Whatever nudgeDue flagged, this real Drill review is the actual
      // measurement it was asking for -- clear it so the card goes back to
      // FSRS's own schedule instead of resurfacing again next session.
      if (next.nudge && next.nudge[key]) { next.nudge = { ...next.nudge }; delete next.nudge[key]; }
      const prev = next.mem[key];
      if (prev) {
        next.cal = next.cal || { n: 0, pred: 0, hit: 0 };
        next.cal.n++;
        next.cal.pred += nowR(prev, now);
        if (grade > 1) next.cal.hit++;
      }
      const st = review(prev, grade, now, entry && entry.prior);
      const day = today();
      st.cr = (prev && prev.cr) || 0;
      st.cd = prev && prev.cd;
      if (grade > 1 && st.cd !== day && st.cr < 3) { st.cr++; st.cd = day; }
      if (grade === 1) st.cr = Math.max(0, st.cr - 1);
      next.mem[key] = st;
    }
    if (entry && entry.missed) {
      next.words = next.words || {};
      entry.missed.forEach((wd) => {
        const r = next.words[wd] || { c: 0, w: 0, o: 0, from: {} };
        r.w++;
        r.from = r.from || {};
        r.from[mode || entry.mode || "drill"] = (r.from[mode || entry.mode || "drill"] || 0) + 1;
        r.last = { q: entry.q || "", mine: entry.mine || "", correct: entry.correct || "", note: entry.note || "", tag: (tags || [])[0] || "", kind: "word" };
        next.words[wd] = r;
      });
    }
    if (entry && entry.hinted) {
      next.words = next.words || {};
      entry.hinted.forEach((wd) => {
        const r = next.words[wd] || { c: 0, w: 0, o: 0, h: 0, from: {} };
        r.h = (r.h || 0) + 1;
        next.words[wd] = r;
      });
      next.hints = (next.hints || 0) + 1;
      const d0 = today();
      next.log = next.log || {};
      next.log[d0] = next.log[d0] || {};
      next.log[d0].hints = (next.log[d0].hints || 0) + 1;
    }
    if (entry && entry.misplaced) {
      next.words = next.words || {};
      entry.misplaced.forEach((wd) => {
        const r = next.words[wd] || { c: 0, w: 0, o: 0, from: {} };
        r.o = (r.o || 0) + 1;
        r.from = r.from || {};
        r.from[mode || entry.mode || "drill"] = (r.from[mode || entry.mode || "drill"] || 0) + 1;
        r.last = { q: entry.q || "", mine: entry.mine || "", correct: entry.correct || "", note: entry.note || "", tag: (tags || [])[0] || "", kind: "order" };
        next.words[wd] = r;
      });
    }
    if (entry && entry.hit) {
      next.words = next.words || {};
      entry.hit.forEach((wd) => {
        const r = next.words[wd] || { c: 0, w: 0, from: {} };
        r.c++;
        next.words[wd] = r;
      });
    }
    (tags || []).forEach((t) => {
      const p = next.pat[t] || { c: 0, w: 0 };
      grade === 1 ? p.w++ : p.c++;
      next.pat[t] = p;
      // a per day trail, so the screen can show whether it is getting better
      const d0 = today();
      next.patLog = next.patLog || {};
      next.patLog[d0] = next.patLog[d0] || {};
      const e = next.patLog[d0][t] || { c: 0, w: 0 };
      grade === 1 ? e.w++ : e.c++;
      next.patLog[d0][t] = e;
      next.patLog = Object.fromEntries(Object.entries(next.patLog).slice(-21));
    });
    next.totals.asked++;
    if (grade > 1) next.totals.right++;
    // A short rolling window (last 60 answers, any exercise), separate from
    // the lifetime totals above which barely move once there is real
    // history. This is what lets prompts react to how it's going *lately*
    // rather than to a number that stopped changing months ago.
    next.recent = [...(next.recent || []), grade].slice(-60);
    if (entry && !entry.silent) next.errors = [{ ...entry, prior: undefined, missed: undefined, misplaced: undefined, hit: undefined, ts: now }, ...next.errors].slice(0, 80);
    const d = today();
    if (!next.days.includes(d)) next.days = [...next.days, d].slice(-180);
    persist(next);
  };

  const streak = useMemo(() => {
    const set = new Set(data.days);
    let n = 0;
    const d = new Date();
    for (;;) {
      const k = localDay(d);
      if (set.has(k)) { n++; d.setDate(d.getDate() - 1); }
      else if (n === 0 && k === today()) { d.setDate(d.getDate() - 1); }
      else break;
    }
    return n;
  }, [data.days]);

  const weakWords = useMemo(() => {
    const rows = Object.entries(data.words || {})
      .map(([k, v]) => ({ k, ...v, score: (v.w || 0) + 0.5 * (v.o || 0) + 0.4 * (v.h || 0),
        rate: ((v.w || 0) + 0.5 * (v.o || 0) + 0.4 * (v.h || 0)) / Math.max(1, (v.c || 0) + (v.w || 0) + (v.o || 0) + (v.h || 0)) }))
      .filter((r) => r.score >= 2 && r.rate > 0.34)
      .sort((a, b) => b.score - a.score || b.rate - a.rate);
    return rows.slice(0, 12);
  }, [data.words]);

  const strongWords = useMemo(() =>
    Object.entries(data.words || {})
      .filter(([, v]) => v.c >= 3 && v.w === 0)
      .map(([k]) => k)
      .slice(0, 20), [data.words]);

  const weakest = useMemo(() => {
    const rows = Object.entries(data.pat)
      .map(([k, v]) => ({ k, ...v, rate: v.w / Math.max(1, v.c + v.w) }))
      .filter((r) => r.c + r.w >= 3)
      .sort((a, b) => b.rate - a.rate);
    return rows[0] && rows[0].rate > 0.15 ? rows[0].k : null;
  }, [data.pat]);

  // How the last stretch of answers went, across drill, sentence forge and
  // roleplay together -- see levelNote() and the weakest-count scaling in
  // Sentences, which both read this instead of a fixed level assumption.
  const recentRate = useMemo(() => {
    const r = data.recent || [];
    if (r.length < 10) return null;
    return { n: r.length, rate: r.reduce((s, g) => s + (g - 1) / 2, 0) / r.length };
  }, [data.recent]);

  // he typed another card from his own deck: those two are being confused
  const saveConfusion = (a, b) => {
    if (!a || !b || a === b) return;
    const next = JSON.parse(JSON.stringify(live.current));
    next.conf = next.conf || {};
    next.conf[a] = Array.from(new Set([...(next.conf[a] || []), b])).slice(-6);
    next.conf[b] = Array.from(new Set([...(next.conf[b] || []), a])).slice(-6);
    persist(next);
  };

  // what the forge has asked lately, so it stops repeating itself
  const rememberSeen = (list) => {
    const next = JSON.parse(JSON.stringify(live.current));
    next.seen = [...(next.seen || []), ...list].slice(-60);
    persist(next);
  };

  // a word the model used that he does not have yet
  // Generates the gloss+example for a new word WITHOUT saving it -- lets
  // Sentence forge show what a word means the moment it flags it as new,
  // instead of only after he commits to adding it.
  const previewWord = async (word, context) => {
    try {
      const t = await ask(
        "You add a single Indonesian word to a learner's deck. Reply with JSON only.",
        [{ role: "user", content: `The word "${word}" appeared in: "${context}". Give a short English meaning, and a natural example sentence at A2 level using it. Use only plain, modern, everyday English for the meaning and its translation, never an archaic or formal word like "whereto" or "wherefrom", write it as ordinary separate words instead, such as "where to" or "where from". JSON: {"en":"short meaning","example":"Indonesian sentence","exampleEn":"its English"}` }]
      );
      const j = parseJSON(t);
      return j && j.en ? j : null;
    } catch (e) { return null; }
  };

  // Persists an already-fetched preview (see previewWord above) -- no
  // second AI call, so showing the gloss up front never doubles the cost
  // of actually adding the word.
  const saveWord = (word, j) => {
    if (!j || !j.en) return;
    const next = JSON.parse(JSON.stringify(live.current));
    next.mine = next.mine || [];
    if (!next.mine.some((c) => c.id.toLowerCase() === word.toLowerCase())) next.mine.unshift({ id: word, en: j.en, s: 0 });
    if (j.example && j.exampleEn && !next.mine.some((c) => c.id === j.example)) {
      next.mine.unshift({ id: j.example, en: j.exampleEn, s: 1 });
    }
    persist(next);
  };

  const learnWord = async (word, context) => {
    const j = await previewWord(word, context);
    if (j) saveWord(word, j);
    return j;
  };

  const saveAlt = (id, phrase) => {
    const next = JSON.parse(JSON.stringify(live.current));
    next.alts = next.alts || {};
    const list = next.alts[id] || [];
    if (!list.includes(phrase)) list.push(phrase);
    next.alts[id] = list.slice(-8);
    persist(next);
  };

  const saveHook = (id, text) => {
    const next = JSON.parse(JSON.stringify(live.current));
    next.hooks = next.hooks || {};
    next.hooks[id] = text;
    persist(next);
  };

  const deckAll = useMemo(() => {
    const seen = new Set(vocab.map((c) => c.id.toLowerCase()));
    const dropped = new Set((data.dropped || []).map((s) => s.toLowerCase()));
    const own = (data.mine || []);
    const byId = new Map();
    [...vocab, ...own].forEach((c) => { if (!dropped.has(c.id.toLowerCase())) byId.set(c.id.toLowerCase(), c); });
    own.forEach((c) => { if (!dropped.has(c.id.toLowerCase())) byId.set(c.id.toLowerCase(), c); });
    // A card's English side is occasionally AI-generated (learnWord, or an
    // Anki import) and can land on a real but archaic word that reads as
    // broken English to a modern learner -- "whereto" for "kemana" is the
    // one that actually showed up in Drill. Cleaned up once here, at the
    // one place the whole deck assembles, so an already-saved card is
    // fixed on screen immediately too, no data migration needed.
    return Array.from(byId.values()).map((c) => (c.en ? { ...c, en: plainEnglish(c.en) } : c));
  }, [vocab, data.mine, data.dropped]);
  const deck = deckAll;

  // Once a day, quietly save one honest snapshot: the same deck-wide
  // recall average Memory already shows (nowR averaged across data.mem,
  // never a fabricated composite of unrelated signals), how many words
  // have hit the app's own "mastered" bar (cr>=3, i.e. correct on three
  // separate days -- the exact same test as the "holding"+"solid" stage
  // tiles in Memory, computed the same way here so the two numbers can
  // never quietly disagree), the current weakest pattern, and the
  // rolling recent rate. This is the only place data.history is
  // written; Memory just reads it to plot a real trend instead of only
  // "right now".
  useEffect(() => {
    if (!ready) return;
    const day = today();
    const hist = live.current.history || [];
    if (hist.length && hist[hist.length - 1].d === day) return;
    const keys = Object.keys(live.current.mem || {});
    if (!keys.length) return; // nothing scheduled yet -- nothing honest to snapshot
    const now = Date.now();
    const retention = keys.reduce((a, k) => a + nowR(live.current.mem[k], now), 0) / keys.length;
    let mastered = 0;
    deckAll.forEach((c) => {
      const st = live.current.mem[c.id + "|en2id"] || live.current.mem[c.id + "|id2en"];
      if (st && (st.cr || 0) >= 3) mastered++;
    });
    const next = JSON.parse(JSON.stringify(live.current));
    next.history = [...hist, {
      d: day, r: retention, m: mastered, n: deckAll.length,
      w: weakest || null, rr: recentRate ? recentRate.rate : null,
    }].slice(-180);
    persist(next);
  }, [ready, data.mem, deckAll.length, weakest, recentRate]);

  // A miss on a word in Sentence forge or Roleplay is real evidence that
  // its Drill card deserves another look, even if FSRS's own schedule
  // still has it due weeks out. This never touches that card's stored
  // difficulty/stability (see buildSession's `nudged` handling) -- it only
  // flags the card so the next session's selection pulls it forward; the
  // actual measurement still happens cleanly in Drill, same as always.
  // Only cards with an existing FSRS state get flagged: a direction never
  // reviewed yet has no "due date" to pull forward in the first place.
  const nudgeDue = (missedWords) => {
    if (!missedWords || !missedWords.length) return;
    const keys = new Set(missedWords);
    const hitCards = deckAll.filter((c) => canonTokens(c.id).map(canonWord).some((t) => keys.has(t)));
    if (!hitCards.length) return;
    const next = JSON.parse(JSON.stringify(live.current));
    next.nudge = next.nudge || {};
    let changed = false;
    hitCards.forEach((c) => {
      ["en2id", "id2en", "cloze"].forEach((dir) => {
        if (next.mem[c.id + "|" + dir] && !next.nudge[c.id + "|" + dir]) { next.nudge[c.id + "|" + dir] = true; changed = true; }
      });
    });
    if (changed) persist(next);
  };

  // a round built only from the words he keeps dropping
  const catchup = useMemo(() => {
    const keys = new Set((weakWords || []).map((w) => w.k));
    if (!keys.size) return [];
    const hits = deckAll.filter((c) => canonTokens(c.id).map(canonWord).some((t) => keys.has(t)));
    return hits.slice(0, 10).map((card) => ({
      card: { ...card, tags: tagsOf(card.id), tok: tokens(card.id) },
      dir: "en2id",
      st: data.mem[card.id + "|en2id"] || null,
      kind: data.mem[card.id + "|en2id"] ? "due" : "new",
    }));
  }, [weakWords, data.mem, deckAll]);

  const addCard = (card) => {
    const next = JSON.parse(JSON.stringify(live.current));
    next.mine = next.mine || [];
    if (!next.mine.some((c) => c.id.toLowerCase() === card.id.toLowerCase())) next.mine.unshift(card);
    persist(next);
  };

  const shared = { data, vocab: deck, commit, weakest, weakWords, strongWords, recentRate, saveHook, saveAlt, saveConfusion, rememberSeen, addCard, learnWord, previewWord, saveWord, nudgeDue, back: () => setView("home") };

  return (
    <div className="bh" data-theme={theme}>
      <style>{CSS}</style>
      <div className="bh-wrap">
        <div className="bh-top">
          <div className="bh-logo">
            <span className="bh-mark"><span /><span /></span>
            <span>bahasa indonesia<i className="bh-dotmark">.</i></span>
            <span className="bh-ver">v61</span>
          </div>
          <div className="bh-topright">
            <span className="bh-streak" data-warn={saveErr ? 1 : 0}>
              {saveErr ? "not saving" : ready ? (streak ? `${streak} day streak` : "start today") : "loading"}
            </span>
            <button className="bh-gear" onClick={() => setView("settings")} aria-label="Settings">
              <Icon name="gear" size={20} />
            </button>
          </div>
        </div>
        <div className="bh-screen" key={view}>
          {view === "home" && (
            !ready
              // Home's real numbers (streak, what's due, whether today is
              // done) aren't in yet -- showing Home itself here would mean
              // showing fabricated defaults ("Start here", an empty week)
              // as if they were real, for the brief moment before the
              // actual data lands and replaces them. This keeps the same
              // shape (so nothing jumps once the real Home takes over) but
              // says plainly that it is still loading, rather than showing
              // guessed content.
              ? <HomeSkeleton />
              : !data.onboarded
                ? <Onboarding
                    vocab={deck}
                    onStart={() => { update((n) => { n.onboarded = true; }); setView("drill"); }}
                    onSkip={() => update((n) => { n.onboarded = true; })}
                  />
                : <Home data={data} vocab={deck} go={setView} weakest={weakest} weakCount={(weakWords || []).length} update={update} ready={ready} />
          )}
          {view === "drill" && <Drill {...shared} />}
          {view === "sentences" && <Sentences {...shared} />}
          {view === "roleplay" && <Roleplay {...shared} />}
          {view === "memory" && <Memory {...shared} persist={persist} go={(v, p) => { if (p) setLessonOf(p); setView(v); }} />}
          {view === "settings" && <Settings data={data} vocab={deck} persist={persist} back={() => setView("home")} user={user} onLogout={onLogout} />}
          {view === "library" && <Library data={data} vocab={deck} update={update} back={() => setView("home")} />}
          {view === "weekly" && <Weekly data={data} vocab={deck} weakWords={weakWords} weakest={weakest} go={setView} back={() => setView("home")} />}
          {view === "lesson" && <Lesson data={data} pattern={lessonOf || weakest} persist={persist} go={setView} back={() => setView(lessonOf ? "memory" : "weekly")} />}
          {view === "catchup" && <Drill {...shared} preset={catchup} back={() => setView("weekly")} />}
          {view === "deck" && <Deck vocab={vocab} deck={deck} saveVocab={saveVocab} data={data} persist={persist} addCard={addCard} back={() => setView("home")} />}
        </div>
      </div>
    </div>
  );
}

// Placeholder shown in Home's spot for the brief moment before the
// account's real data has loaded. Same overall shape as the real Home
// (week strip + hero card) so nothing shifts around once it is replaced,
// but it never shows a made-up streak, due count or "Start here" -- those
// would look like real answers for a beat, which is exactly the flash of
// wrong information this replaces.
function HomeSkeleton() {
  return (
    <div>
      <Week days={[]} justDone={false} small />
      <div className="bh-hero">
        <Scene />
        <div className="bh-heroin">
          <div className="bh-headline serif">Loading…</div>
          <div className="bh-sayso">Fetching your progress.</div>
        </div>
      </div>
    </div>
  );
}

function Home({ data, vocab, go, weakest, weakCount, update, ready }) {
  const [peek, setPeek] = useState(null);
  const [help, setHelp] = useState(false);
  const now = Date.now();

  // Targets for the once-only coach-mark tour below -- a plain DOM ref per
  // element it points at, looked up by the same key the tour steps use.
  const exercisesRef = useRef(null);
  const cardsTabRef = useRef(null);
  const catchupTabRef = useRef(null);
  const progressTabRef = useRef(null);
  const addTabRef = useRef(null);
  // useMemo here (not a plain object literal) so this has the same identity
  // across Home's own re-renders -- otherwise Tour's effect below would see
  // a "new" targets object on every Home re-render and needlessly re-measure
  // and re-scroll, even mid-step.
  const tourTargets = useMemo(
    () => ({ exercises: exercisesRef, cards: cardsTabRef, catchup: catchupTabRef, progress: progressTabRef, add: addTabRef }),
    []
  );

  const due = useMemo(() => {
    let n = 0;
    for (const key in data.mem) if (dueIn(data.mem[key], data.want, now) <= 0) n++;
    return n;
  }, [data.mem, data.want]);

  const fresh = Object.keys(data.mem).length === 0;
  const log = (data.log && data.log[today()]) || {};
  const didDrill = (log.drill || 0) >= 5;
  const didSentences = (log.sentences || 0) >= 3;
  const didRoleplay = (() => {
    const l = data.log || {};
    for (let i = 0; i < 7; i++) {
      const d = new Date(); d.setDate(d.getDate() - i);
      if ((l[localDay(d)] || {}).roleplay) return true;     // once in seven days is enough
    }
    return false;
  })();

  const headline = fresh ? "Start here" : didDrill ? "Today is done" : due ? `${due} cards` : "Nothing due";
  const sayso = fresh
    ? "Ten cards, about four minutes. Then again tomorrow: the spacing is where the effect comes from."
    : didDrill ? "The drill is in. Anything below is optional, and tomorrow beats more today."
    : due ? "About four minutes, then you are finished for today."
    : "Nothing has come due yet. Try a set of sentences, or come back tomorrow.";
  /* The top slot is not reserved for the drill: once that is in, whatever
     is next moves up. Every exercise gets to be the main one. */
  const step = !didDrill && (due || fresh)
    ? { n: 1, name: "Drill", when: "every day", go: "drill", label: "Start",
        head: fresh ? "Start here" : `${due} due`,
        note: fresh ? "Ten cards, about four minutes. Then again tomorrow."
          : `${due} of your cards are ready for review. A round takes ten of them, about four minutes.` }
    : !didSentences
      ? { n: 2, name: "Sentence forge", when: "every other day", go: "sentences", label: "Build sentences",
          head: "Your turn to build", note: weakest ? `Eight sentences, weighted towards ${weakest}.` : "Eight sentences from the words you already have." }
      : !didRoleplay
        ? { n: 3, name: "Roleplay", when: "weekly", go: "roleplay", label: "Start a scene",
            head: "Try it for real", note: "One conversation in Indonesian. Mistakes come at the end, not during." }
        : { n: 1, name: "Drill", when: "every day", go: "drill", label: "One more round",
            head: "All done", note: "Everything for today is in. Coming back tomorrow beats doing more now." };

  return (
    <div>
      <Week days={data.days} justDone={didDrill} small />

      <div ref={exercisesRef}>
        <div className="bh-hero">
          <Scene />
          <div className="bh-heroin">
            <div className="bh-step1">
              <span className="bh-num bh-num1">{step.n}</span>
              <span className="bh-step1n">{step.name}</span>
              <span className="bh-step1w">{step.when}</span>
            </div>
            <div className="bh-headline serif">{step.head}</div>
            <div className="bh-sayso">{step.note}</div>
            <button className="bh-cta" onClick={() => go(step.go)}>{step.label}</button>
          </div>
        </div>

        <div className="bh-alsolab">Also available today</div>
        <div className="bh-two">
          {step.go !== "drill" && (
            <button className="bh-twocard" data-tone="teal" onClick={() => go("drill")}>
              <span className="bh-twoico"><Icon name="target" size={24} /></span>
              <span className="bh-twoname">Drill</span>
              <span className="bh-twometa">{didDrill ? "done today" : `${due} due`}</span>
            </button>
          )}
          {step.go !== "sentences" && (
            <button className="bh-twocard" data-tone="violet" onClick={() => go("sentences")}>
              <span className="bh-twoico"><Icon name="lines" size={24} /></span>
              <span className="bh-twoname">Sentences</span>
              <span className="bh-twometa">{didSentences ? "done today" : weakest ? `on ${weakest}` : "every other day"}</span>
            </button>
          )}
          {step.go !== "roleplay" && (
            <button className="bh-twocard" data-tone="coral" onClick={() => go("roleplay")}>
              <span className="bh-twoico"><Icon name="speech" size={24} /></span>
              <span className="bh-twoname">Roleplay</span>
              <span className="bh-twometa">{didRoleplay ? "done this week" : "weekly"}</span>
            </button>
          )}
        </div>
      </div>

      <div className="bh-tabs">
        <button className="bh-tab" ref={cardsTabRef} onClick={() => go("library")}>
          <span className="bh-tabico"><Icon name="cards" size={23} /></span>
          <span className="bh-tabname">Cards</span>
        </button>
        <button className="bh-tab" ref={catchupTabRef} data-alert={weakCount > 0 ? 1 : 0} onClick={() => go("weekly")}>
          <span className="bh-tabico"><Icon name="target" size={23} /></span>
          <span className="bh-tabname">Catch up</span>
        </button>
        <button className="bh-tab" ref={progressTabRef} onClick={() => go("memory")}>
          <span className="bh-tabico"><Icon name="chart" size={23} /></span>
          <span className="bh-tabname">Progress</span>
        </button>
        <button className="bh-tab" ref={addTabRef} onClick={() => go("deck")}>
          <span className="bh-tabico"><Icon name="plus" size={23} /></span>
          <span className="bh-tabname">Add</span>
        </button>
      </div>

      {ready && !data.toured && (
        <Tour
          steps={TOUR_STEPS}
          targets={tourTargets}
          onDone={() => update((n) => { n.toured = true; })}
        />
      )}

      <div className="bh-foot">
        <button onClick={() => setPeek(vocab[Math.floor(Math.random() * vocab.length)])}>Random card</button>
        <button onClick={() => setHelp(!help)}>{help ? "Hide guide" : "How to use this"}</button>
      </div>

      {peek && (
        <div className="bh-panel" style={{ marginTop: 14 }}>
          <div className="bh-plabel">Just browsing, nothing is scored</div>
          <div className="bh-sent">{peek.id}</div>
          <div className="bh-note" style={{ marginTop: 6 }}>{peek.en}</div>
        </div>
      )}

      {help && (
        <div className="bh-help">
          <b>Daily.</b> One round, then stop. If it says nothing is due, believe it and close the app.<br /><br />
          <b>Two or three times a week.</b> Sentence forge after the drill. That is where single words turn into sentences.<br /><br />
          <b>Once a week.</b> Roleplay, best the evening before your lesson, so the mistakes are fresh.<br /><br />
          <b>Whenever you have new words.</b> Add them under Add words, or load a fresh Anki export there.<br /><br />
          Doing five rounds today does not replace five days. New words only settle once you have recalled them right on three
          separate days, so spreading it out is the whole point.
        </div>
      )}
    </div>
  );
}

// The five stops of the once-only home tour, in order. Each `key` must match
// a key in the `targets` object Home passes to <Tour />.
const TOUR_STEPS = [
  { key: "exercises", text: "Three exercises live here: Drill for fast recall, Sentence forge to build full sentences, and Roleplay for a real conversation. Whichever is due comes up top, the other two wait just below." },
  { key: "cards", text: "Cards: every word and sentence you have learned so far, browsable any time." },
  { key: "catchup", text: "Catch up: the words giving you the most trouble, grouped for a quick, focused review." },
  { key: "progress", text: "Progress: your stats and streaks over time." },
  { key: "add", text: "Add: type in a new word or phrase whenever you want to learn one." },
];

// A short, once-per-account coach-mark walkthrough: one real UI element
// highlighted at a time (a "spotlight" cut into a dimmed backdrop, done
// with the classic giant-box-shadow trick, no measuring library needed),
// with a small caption underneath. Shown the very first time Home appears
// with a "toured" flag not yet set -- see the "toured" flag next to
// "onboarded" above -- whether the learner just finished their first
// session or skipped straight here, and never again after that.
function Tour({ steps, targets, onDone }) {
  const [i, setI] = useState(0);
  const [box, setBox] = useState(null);

  // useLayoutEffect, not useEffect: it runs before the browser paints, so
  // the very first frame Home is shown in already has the dimmed backdrop
  // and the spotlight in place -- no beat where the plain, undimmed Home
  // flashes before the tour "pops in" on top of it. Scrolling is only ever
  // triggered (and only re-measured afterwards) when the target genuinely
  // is not fully on screen yet, which in practice is rare on this short a
  // page, so most steps never need it at all.
  useLayoutEffect(() => {
    const ref = targets[steps[i].key];
    const el = ref && ref.current;
    if (!el) { setI((n) => n + 1); return; } // that step's element is not on screen right now, skip it rather than getting stuck
    const measure = () => {
      const r = el.getBoundingClientRect();
      setBox({ top: r.top - 8, left: r.left - 8, width: r.width + 16, height: r.height + 16 });
    };
    measure();
    const r = el.getBoundingClientRect();
    const fullyVisible = r.top >= 0 && r.bottom <= window.innerHeight;
    window.addEventListener("resize", measure);
    if (fullyVisible) return () => window.removeEventListener("resize", measure);
    el.scrollIntoView({ block: "center", behavior: "smooth" });
    const t = setTimeout(measure, 280); // one correction, after the smooth scroll has settled
    return () => { clearTimeout(t); window.removeEventListener("resize", measure); };
  }, [i, steps, targets]);

  if (!box) return null;
  const last = i === steps.length - 1;
  const roomBelow = window.innerHeight - (box.top + box.height) > 170;
  const cardStyle = roomBelow ? { top: box.top + box.height + 14 } : { top: Math.max(14, box.top - 158) };

  return (
    <div>
      <div className="bh-tourdim" />
      <div className="bh-tourhole" style={box} />
      <div className="bh-tourcard" style={cardStyle}>
        <div className="bh-tourtext">{steps[i].text}</div>
        <div className="bh-touract">
          <button className="bh-tourskip" onClick={onDone}>Skip</button>
          <div className="bh-tourdots">{steps.map((_, d) => <span key={d} data-on={d === i ? 1 : 0} />)}</div>
          <button className="bh-tournext" onClick={() => (last ? onDone() : setI((n) => n + 1))}>{last ? "Got it" : "Next"}</button>
        </div>
      </div>
    </div>
  );
}


/* One set, one 24 grid, stroke 2, round caps, curves rather than straight
   edges so the icons share the hand of the illustrations and the roundness
   of the type. */
const UI_ICONS = {
  cards: ["M4 8q0-2 2-2h9q2 0 2 2v9q0 2-2 2H6q-2 0-2-2Z", "M8 5q1-1 2-1h8q2 0 2 2v9"],
  target: ["M12 3q9 0 9 9t-9 9q-9 0-9-9", "M12 8q4 0 4 4t-4 4q-4 0-4-4", "M12 12 21 4"],
  chart: ["M4 20q0-5 0-7", "M10 20q0-10 0-14", "M16 20q0-7 0-9", "M3 20q9 1 18 0"],
  plus: ["M12 5q0 7 0 14", "M5 12q7 0 14 0"],
  lines: ["M4 6q8-1 16 0", "M4 12q6-1 12 0", "M4 18q9-1 14 0"],
  speech: ["M5 17q-1-6 3-9 5-4 10 0 3 3 1 8-6 2-14 1Z", "M9 20q3 1 6 0"],
  back: ["M14 6q-4 3-6 6 2 3 6 6"],
  right: ["M10 6q4 3 6 6-2 3-6 6"],
  check: ["M5 12q3 1 5 5 3-8 9-11"],
  gear: ["M12 9q3 0 3 3t-3 3q-3 0-3-3t3-3", "M12 3q1 0 1 2 2 .5 3 2 2-1 2.5 0t-1 2.5q.5 1.5 0 3 1.5.5 1 2.5t-2.5 0q-1 1.5-3 2 0 2-1 2t-1-2q-2-.5-3-2-2 1-2.5 0t1-2.5q-.5-1.5 0-3-1.5-.5-1-2.5t2.5 0q1-1.5 3-2 0-2 1-2"],
};

const Icon = ({ name, size = 24, style }) => (
  <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
    strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={style} aria-hidden="true">
    {(UI_ICONS[name] || []).map((d, i) => <path key={i} d={d} />)}
  </svg>
);

const ICONS = {
  bowl: (
    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M3 11h18c0 4.5-4 8-9 8s-9-3.5-9-8Z" fill="#FF7A59" />
      <path d="M8 8c0-1.5 1-2 1-3.5M12 7.5C12 6 13 5.5 13 4M16 8c0-1.5 1-2 1-3.5" stroke="#D9A300" strokeWidth="1.8" strokeLinecap="round" />
    </svg>
  ),
  scooter: (
    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <circle cx="5.5" cy="17" r="3.2" fill="#12A47A" />
      <circle cx="18.5" cy="17" r="3.2" fill="#12A47A" />
      <path d="M5.5 17h7l3-8h2" stroke="#121117" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
      <path d="M15 9h4l-.5 8" stroke="#FFC531" strokeWidth="2" strokeLinecap="round" />
    </svg>
  ),
  fruit: (
    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M3 11h18l-2 9H5l-2-9Z" fill="#FFC531" />
      <circle cx="9" cy="8" r="3.4" fill="#FF7A59" />
      <circle cx="15.5" cy="8.5" r="2.8" fill="#12A47A" />
    </svg>
  ),
  house: (
    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M12 3 22 10H2L12 3Z" fill="#D92D4E" />
      <path d="M4.5 10.5h15V21h-15V10.5Z" fill="#6C5CE7" opacity=".85" />
      <rect x="10" y="14" width="4" height="7" rx="1" fill="#fff" />
    </svg>
  ),
  plant: (
    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M12 21V9" stroke="#0E8A66" strokeWidth="2" strokeLinecap="round" />
      <path d="M12 12C9 12 6.5 9.5 6.5 6c3.5 0 5.5 2.5 5.5 6Z" fill="#12A47A" />
      <path d="M12 14c3 0 5.5-2 5.5-5.5-3.5 0-5.5 2-5.5 5.5Z" fill="#FFC531" />
    </svg>
  ),
};

const TONE_BG = { bowl: "var(--canvas)", scooter: "var(--canvas)", fruit: "var(--canvas)", house: "var(--canvas)", plant: "var(--canvas)" };



const Tile = ({ ic, n, d, on, badge, quiet }) => (
  <button className="bh-tile" onClick={on}>
    {ic ? <div className="bh-ico" style={{ background: TONE_BG[ic] }}>{ICONS[ic]}</div> : <div />}
    <div>
      <div className="bh-tname">{n}</div>
      <div className="bh-tdesc">{d}</div>
    </div>
    {badge ? <div className="bh-badge" data-quiet={quiet ? 1 : 0}>{badge}</div> : <div />}
  </button>
);

const PRAISE = ["Bagus.", "Straight through.", "That one is sticking.", "Clean.", "Mantap."];

// a hand-drawn rule under every screen title, same line quality as the motifs
const Rule = () => (
  <svg className="bh-rule" viewBox="0 0 200 8" preserveAspectRatio="none" aria-hidden="true">
    <path d="M2 5 q26 -5 52 -1 q28 4 54 -2 q30 -6 56 1 q18 2 34 -1" fill="none" stroke="var(--solid)" strokeWidth="2.4" strokeLinecap="round" opacity=".55" />
  </svg>
);

// Shown once, the very first time a brand-new account opens the app (see
// the "onboarded" flag on the progress blob, set true the moment either
// button below is pressed, and set true at signup already for the admin
// account migrating in real history -- see api/auth/signup.js).
function Onboarding({ vocab, onStart, onSkip }) {
  const sample = (vocab || []).slice(0, 4);
  return (
    <div>
      <h2 className="bh-title serif">Welcome!</h2>
      <Rule />
      <div className="bh-lede">
        We've loaded a small set of starter cards for you, real beginner phrases to get you talking right away.
      </div>

      {sample.length > 0 && (
        <div className="bh-panel">
          <div className="bh-plabel">A few of your starter cards</div>
          {sample.map((c, i) => (
            <div key={i} className="bh-row" style={{ padding: "6px 0" }}>
              <span>{c.id}</span><span className="bh-count">{c.en}</span>
            </div>
          ))}
        </div>
      )}

      <div className="bh-panel">
        <div className="bh-plabel">How this works, in short</div>
        <div className="bh-note">
          You'll mostly be asked to produce Indonesian, not just recognise it, that's what actually sticks.
          Cards you get wrong come back sooner, cards you know well come back later.
          Everything saves automatically to your account, on every device.
        </div>
      </div>

      <div className="bh-acts" style={{ marginTop: 16, flexDirection: "column", gap: 10 }}>
        <button className="bh-go" style={{ width: "100%" }} onClick={onStart}>Start your first session</button>
        <button className="bh-alt" style={{ width: "100%" }} onClick={onSkip}>Skip, I'll explore on my own</button>
      </div>
    </div>
  );
}

const Back = ({ to = "Home", on }) => (
  <button className="bh-back" onClick={on}>
    <Icon name="back" size={20} />
    {to}
  </button>
);

const Chevron = () => <span className="bh-chev"><Icon name="right" size={20} /></span>;

/* The picture language: fourteen motifs from Indonesia, drawn with loose
   curves rather than straight edges, so it feels handmade rather than
   generated. Same palette everywhere, one light source, layered depth. */
const sun = (x, y, r) => <circle cx={x} cy={y} r={r} fill="var(--dot)" opacity=".9" />;
const birds = (x, y) => (
  <g stroke="var(--terr4)" strokeWidth="1.6" fill="none" opacity=".55" strokeLinecap="round">
    <path d={`M${x} ${y} q5 -4 10 0 q5 -4 10 0`} />
    <path d={`M${x + 26} ${y + 9} q4 -3 8 0 q4 -3 8 0`} />
  </g>
);

const MOTIFS = [
  <g key="a" data-mood="day"><rect width="340" height="104" fill="var(--sky)" />{sun(272, 30, 16)}
    <path d="M18 66 q26 -46 58 -44 q30 2 52 44 Z" fill="var(--hill2)" />
    <path d="M58 34 q16 -10 30 2 q-14 6 -30 -2 Z" fill="var(--paper)" opacity=".5" />
    <path d="M96 66 q34 -34 66 -30 q28 4 44 30 Z" fill="var(--hill1)" opacity=".8" />
    <path d="M0 66 q70 -9 150 -2 t190 -2 v12 H0 Z" fill="var(--terr1)" />
    <path d="M0 76 q60 -10 124 -1 t126 2 t90 -3 v12 H0 Z" fill="var(--terr2)" />
    <path d="M0 87 q84 -11 176 -1 t164 -1 v11 H0 Z" fill="var(--terr3)" />
    <path d="M0 97 q96 -9 190 0 t150 -2 v14 H0 Z" fill="var(--terr4)" />
    {birds(210, 26)}
  </g>,
  <g key="b" data-mood="sea"><rect width="340" height="104" fill="var(--sky)" />{sun(58, 28, 14)}
    <path d="M196 18 q6 26 2 50 q-22 -6 -34 -12 q14 -24 32 -38 Z" fill="var(--hill2)" />
    <path d="M204 26 q22 16 30 42 q-18 2 -30 0 q4 -22 0 -42 Z" fill="var(--hill1)" />
    <path d="M200 12 v58" stroke="var(--terr4)" strokeWidth="3" strokeLinecap="round" />
    <path d="M140 70 q60 10 118 0 q-14 16 -34 18 h-52 q-20 -4 -32 -18 Z" fill="var(--terr4)" />
    <path d="M0 84 q56 -10 112 -1 t114 1 t114 -3 v23 H0 Z" fill="var(--terr2)" />
    <path d="M0 94 q70 -9 146 0 t194 -3 v13 H0 Z" fill="var(--terr3)" />
    {birds(96, 30)}
  </g>,
  <g key="c" data-mood="day"><rect width="340" height="104" fill="var(--sky)" />{sun(172, 24, 13)}
    <path d="M100 98 q-4 -40 2 -54 q6 -14 16 -18 q6 22 4 72 Z" fill="var(--terr3)" />
    <path d="M240 98 q4 -40 -2 -54 q-6 -14 -16 -18 q-6 22 -4 72 Z" fill="var(--terr3)" />
    <path d="M106 58 q12 -3 22 0 M106 72 q12 -3 22 0 M216 58 q12 -3 22 0 M216 72 q12 -3 22 0" stroke="var(--sky)" strokeWidth="3" fill="none" opacity=".65" />
    <path d="M84 98 q30 -5 62 0 v8 H84 Z M194 98 q30 -5 62 0 v8 h-62 Z" fill="var(--terr4)" />
    <path d="M0 92 q78 -8 164 0 t176 -2 v18 H0 Z" fill="var(--terr1)" opacity=".55" />
  </g>,
  <g key="d" data-mood="night"><rect width="340" height="104" fill="var(--sky)" />
    <circle cx="276" cy="28" r="13" fill="var(--dot)" opacity=".9" />
    <circle cx="271" cy="25" r="11" fill="var(--sky)" />
    <path d="M72 104 q-6 -30 2 -52" stroke="var(--terr4)" strokeWidth="5" fill="none" strokeLinecap="round" />
    <path d="M74 52 q-30 -14 -40 6 M74 52 q30 -14 40 6 M74 52 q-14 -28 -36 -24 M74 52 q14 -28 36 -24" stroke="var(--terr2)" strokeWidth="5" fill="none" strokeLinecap="round" />
    <path d="M188 104 q4 -24 -2 -40" stroke="var(--terr4)" strokeWidth="4" fill="none" strokeLinecap="round" />
    <path d="M186 64 q-24 -11 -32 5 M186 64 q24 -11 32 5 M186 64 q-10 -22 -26 -19" stroke="var(--terr3)" strokeWidth="4" fill="none" strokeLinecap="round" />
    <path d="M0 96 q86 -8 174 0 t166 -2 v14 H0 Z" fill="var(--terr1)" opacity=".6" />
  </g>,
  <g key="e" data-mood="dusk"><rect width="340" height="104" fill="var(--sky)" />
    <path d="M56 52 q118 -12 198 2 q16 8 24 16 q-128 8 -246 0 q8 -10 24 -18 Z" fill="var(--terr3)" />
    <path d="M66 70 q90 8 176 0 q4 20 0 34 q-90 6 -176 0 q-4 -16 0 -34 Z" fill="var(--terr1)" opacity=".5" />
    <path d="M94 80 q28 -3 54 0 q3 14 0 24 q-28 3 -54 0 q-3 -12 0 -24 Z" fill="var(--sky)" />
    <path d="M178 80 q28 -3 54 0 q2 8 0 14 q-28 3 -54 0 q-2 -7 0 -14 Z" fill="var(--sky)" />
    <circle cx="292" cy="60" r="9" fill="var(--dot)" />
    <path d="M292 40 q2 6 0 11" stroke="var(--terr4)" strokeWidth="2" fill="none" />
    <path d="M0 98 q90 -5 180 0 t160 -2 v10 H0 Z" fill="var(--terr4)" opacity=".45" />
  </g>,
  <g key="f" data-mood="dawn"><rect width="340" height="104" fill="var(--sky)" />{sun(48, 28, 15)}
    {[108, 148, 188, 228, 268].map((x, i) => (
      <g key={i}>
        <path d={`M${x} 104 q${i % 2 ? 6 : -6} -28 ${i % 2 ? 3 : -3} -${50 + (i % 2) * 8}`} stroke="var(--terr4)" strokeWidth="3" fill="none" strokeLinecap="round" />
        <path d={`M${x + (i % 2 ? 3 : -3)} ${54 + (i % 2) * 8} q-16 -4 -20 -18 M${x + (i % 2 ? 3 : -3)} ${54 + (i % 2) * 8} q16 -4 20 -18`} stroke="var(--terr2)" strokeWidth="3" fill="none" strokeLinecap="round" />
      </g>
    ))}
    <path d="M0 94 q94 -8 188 0 t152 -2 v14 H0 Z" fill="var(--terr1)" opacity=".6" />
  </g>,
  <g key="g" data-mood="sea"><rect width="340" height="104" fill="var(--sky)" />{sun(252, 32, 16)}
    <path d="M50 74 q32 8 62 0 q-8 14 -22 16 h-20 q-14 -4 -20 -16 Z" fill="var(--terr4)" />
    <path d="M84 74 q0 -22 -4 -28 q16 12 28 28 Z" fill="var(--hill2)" />
    <path d="M158 80 q24 6 46 0 q-6 10 -16 12 h-14 q-10 -3 -16 -12 Z" fill="var(--terr3)" />
    <path d="M182 80 q0 -16 -2 -20 q12 8 20 20 Z" fill="var(--hill1)" />
    <path d="M0 88 q62 -9 124 -1 t110 2 t106 -4 v19 H0 Z" fill="var(--terr2)" />
    <path d="M0 98 q74 -8 152 0 t188 -3 v9 H0 Z" fill="var(--terr3)" />
    {birds(104, 28)}
  </g>,
  <g key="h" data-mood="day"><rect width="340" height="104" fill="var(--sky)" />{sun(280, 26, 14)}
    <path d="M0 70 q60 -22 118 -6 q46 12 92 -6 q48 -18 130 -2 v48 H0 Z" fill="var(--hill1)" opacity=".7" />
    <path d="M0 84 q76 -16 152 -2 q64 12 188 -6 v28 H0 Z" fill="var(--terr2)" />
    <path d="M0 96 q88 -10 176 0 t164 -4 v12 H0 Z" fill="var(--terr4)" />
    {birds(60, 32)}
  </g>,
  <g key="i" data-mood="dawn"><rect width="340" height="104" fill="var(--sky)" />
    <path d="M170 96 q-8 -30 0 -44 q10 -16 26 -16 q-4 26 -10 60 Z" fill="var(--terr3)" />
    <path d="M170 96 q8 -30 0 -44 q-10 -16 -26 -16 q4 26 10 60 Z" fill="var(--terr2)" />
    <circle cx="170" cy="34" r="11" fill="var(--dot)" />
    <path d="M120 70 q-18 6 -24 20 M220 70 q18 6 24 20" stroke="var(--terr1)" strokeWidth="4" fill="none" strokeLinecap="round" />
    <path d="M0 96 q92 -9 184 0 t156 -3 v14 H0 Z" fill="var(--terr4)" opacity=".5" />
  </g>,
  <g key="j" data-mood="day"><rect width="340" height="104" fill="var(--sky)" />{sun(62, 30, 15)}
    <path d="M132 104 q-4 -34 4 -48 q8 -14 22 -16" stroke="var(--terr4)" strokeWidth="4" fill="none" strokeLinecap="round" />
    <path d="M158 40 q22 -6 28 10 q-20 8 -28 -10 Z" fill="var(--terr2)" />
    <path d="M150 56 q24 -4 30 12 q-22 6 -30 -12 Z" fill="var(--terr3)" />
    <path d="M142 74 q24 -2 28 14 q-22 4 -28 -14 Z" fill="var(--terr1)" />
    <path d="M0 98 q88 -7 176 0 t164 -2 v12 H0 Z" fill="var(--terr4)" opacity=".45" />
  </g>,
  <g key="k" data-mood="dusk"><rect width="340" height="104" fill="var(--sky)" />{sun(268, 30, 15)}
    <path d="M24 104 q-2 -36 6 -50 q10 -18 28 -20 q-10 34 -14 70 Z" fill="var(--terr2)" />
    <path d="M300 104 q4 -30 -4 -44 q-8 -14 -22 -16 q8 28 12 60 Z" fill="var(--terr3)" />
    <path d="M96 96 q36 -10 76 -2 q40 8 76 0 q-6 12 -22 14 h-112 q-14 -4 -18 -12 Z" fill="var(--terr4)" />
    <path d="M0 94 q80 -8 168 0 t172 -2 v12 H0 Z" fill="var(--terr1)" opacity=".55" />
    {birds(150, 30)}
  </g>,
  <g key="l" data-mood="sea"><rect width="340" height="104" fill="var(--sky)" />
    <circle cx="170" cy="40" r="22" fill="var(--dot)" opacity=".85" />
    <path d="M120 72 q50 -14 100 0 q-18 16 -50 16 q-32 0 -50 -16 Z" fill="var(--terr3)" />
    <path d="M0 86 q66 -10 134 -1 t206 -3 v22 H0 Z" fill="var(--terr2)" />
    <path d="M0 98 q80 -8 164 0 t176 -3 v11 H0 Z" fill="var(--terr4)" />
  </g>,
  <g key="m" data-mood="dawn"><rect width="340" height="104" fill="var(--sky)" />{sun(50, 26, 13)}
    <path d="M104 104 q0 -40 8 -56 q8 -16 24 -18 q-12 36 -16 74 Z" fill="var(--terr2)" />
    <path d="M152 104 q-2 -30 4 -44 q6 -14 20 -16 q-10 28 -14 60 Z" fill="var(--terr3)" />
    <path d="M206 104 q2 -24 8 -36 q6 -12 18 -14 q-10 24 -14 50 Z" fill="var(--terr1)" />
    <path d="M0 100 q90 -6 180 0 t160 -2 v10 H0 Z" fill="var(--terr4)" opacity=".5" />
  </g>,
  <g key="n" data-mood="night"><rect width="340" height="104" fill="var(--sky)" />{sun(272, 34, 17)}
    <path d="M0 62 q52 -20 104 -4 q52 16 104 -4 q52 -20 132 0 v50 H0 Z" fill="var(--hill2)" opacity=".55" />
    <path d="M0 78 q66 -18 132 -2 q66 16 208 -8 v36 H0 Z" fill="var(--terr2)" />
    <path d="M0 94 q84 -10 170 0 t170 -4 v14 H0 Z" fill="var(--terr4)" />
    {birds(76, 30)}
  </g>,
];

const Scene = () => {
  const d = new Date();
  const i = Math.floor((d - new Date(d.getFullYear(), 0, 0)) / 864e5) % MOTIFS.length;
  return (
    <svg className="bh-scene" viewBox="0 0 340 104" preserveAspectRatio="xMidYMax slice" aria-hidden="true">
      {MOTIFS[i]}
    </svg>
  );
};

const WEEK = ["M", "T", "W", "T", "F", "S", "S"];
const Week = ({ days, justDone, small }) => {
  const set = new Set(days || []);
  const now = new Date();
  const dow = (now.getDay() + 6) % 7;                 // Monday = 0
  const monday = new Date(now); monday.setDate(now.getDate() - dow);
  return (
    <div className="bh-week" data-small={small ? 1 : 0}>
      {WEEK.map((l, i) => {
        const d = new Date(monday); d.setDate(monday.getDate() + i);
        const k = localDay(d);
        const isToday = i === dow;
        const done = set.has(k) || (isToday && justDone);
        const s = isToday ? (done ? "todaydone" : "today") : done ? "done" : "";
        return (
          <div key={i} className="bh-day" data-s={s}>
            <div className="bh-daybox">{done && <Icon name="check" size={15} />}</div>
            <div className="bh-daylab">{l}</div>
          </div>
        );
      })}
    </div>
  );
};

const Pips = ({ results, total, at }) => (
  <div>
    <div className="bh-ticks">
      {Array.from({ length: total }).map((_, i) => (
        <span key={i} className="bh-tick" data-s={results[i] ? (results[i].onlyNewWord ? "new" : results[i].ok ? "yes" : "no") : i === at ? "now" : ""} />
      ))}
    </div>
    <div className="bh-barlab">
      <span>{Math.min(at + 1, total)} of {total}</span>
      <span>{results.filter((r) => r.ok).length} right</span>
    </div>
  </div>
);

function Drill({ data, vocab, commit, saveHook, saveAlt, saveConfusion, weakWords, preset, back }) {
  const [queue, setQueue] = useState(() => (preset && preset.length ? preset : buildSession(vocab, data.mem, data.want, Date.now(), ROUND, weakWords || [], data.recog ?? 0.15, data.conf || {}, data.words || {}, data.nudge || {})));
  const [fixed, setFixed] = useState(false);
  const [results, setResults] = useState([]);
  const [val, setVal] = useState("");
  const [res, setRes] = useState(null);
  const [hook, setHook] = useState(null);
  const [hookBusy, setHookBusy] = useState(false);
  const [checking, setChecking] = useState(false);
  const [slow, setSlow] = useState(false);
  const shownAt = useRef(0);
  const [at, setAt] = useState(0);
  const ref = useRef(null);
  const t0 = useRef(Date.now());

  // Deliberately no autofocus here: re-focusing the input on every new card
  // reopens the iOS keyboard (and its accessory bar) each time, which reads
  // as a distracting flicker. Tapping in, like Sentence forge, is one extra
  // tap per card but keeps the keyboard calm between cards.
  useEffect(() => { t0.current = Date.now(); }, [at, res]);

  const next = () => { setRes(null); setVal(""); setHook(null); setFixed(false); setAt((n) => n + 1); };

  // Enter moves on, but only once the verdict has actually been on screen.
  // A held key or a double tap can no longer skip it.
  useEffect(() => {
    if (!res) return;
    const onKey = (e) => {
      if (e.key !== "Enter" || e.repeat) return;
      if (Date.now() - shownAt.current < 600) { e.preventDefault(); return; }
      e.preventDefault();
      next();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [res]);

  if (!queue.length)
    return (
      <div>
        <Back on={back} />
        <div className="bh-blank">Your whole deck is in memory and nothing is due. Run the sentence forge instead, or come back tomorrow.</div>
      </div>
    );

  if (at >= queue.length) return <Summary results={results} back={back} again={back} label="round" days={data.days} />;

  const item = queue[at];
  const cloze = item.dir === "cloze";
  const cue = cloze ? item.gap.prompt : item.dir === "en2id" ? item.card.en : item.card.id;
  const sol = cloze ? item.gap.answer : item.dir === "en2id" ? item.card.id : item.card.en;
  const first = !item.st;              // never seen: this turn is a pretest, guessing is the point
  const R = item.st ? Math.round(nowR(item.st, Date.now()) * 100) : null;
  const lapses = item.st ? item.st.l || 0 : 0;
  const saved = data.hooks && data.hooks[item.card.id];

  const settle = async (verdictIn) => {
    if (res || checking) return;              // one answer per card, one check at a time
    const ms = Date.now() - t0.current;
    let verdict = verdictIn;
    let altNote = "";

    // your own accepted phrasings from earlier rounds count immediately
    const known = (data.alts && data.alts[item.card.id]) || [];
    if (verdict === "no" && known.some((k) => canonKey(k) === canonKey(val))) verdict = "alt";

    // otherwise ask whether it is simply another correct way to say it
    if (verdict === "no" && val.trim().length > 0 && item.dir !== "id2en") {
      setChecking(true);
      setSlow(false);
      const slowTimer = setTimeout(() => setSlow(true), 4000);
      try {
        const t = await ask(
          "You judge whether a learner's Indonesian is an acceptable way to say the same thing. Reply with JSON only.",
          [{ role: "user", content:
            `Meaning to express: "${item.dir === "en2id" ? item.card.en : item.card.id}"\n` +
            `The flashcard's wording: "${cloze ? item.card.id : item.card.id}"\n` +
            `Expected here: "${sol}"\nLearner wrote: "${val.trim()}"\n\n` +
            "Accept it if it is natural Indonesian that expresses the same thing, even with different word order, a contraction like namamu for nama kamu, or a synonym. " +
            "aku/saya, kamu/Anda, gak/nggak/tidak, udah/sudah, gimana/bagaimana, mau/ingin, cuma/hanya are always interchangeable and never a mistake. " +
            "If his version is acceptable Indonesian, ok must be true even when another wording would be more idiomatic: put that nuance in the note instead. " +
            "Reject it only if it means something different or is not grammatical. " +
            "JSON: {\"ok\":true|false,\"note\":\"one short line in English: if ok, how it differs in nuance or register from the card; if not ok, what his version actually means\"}" }]
        );
        const j = parseJSON(t);
        if (j && j.ok) { verdict = "alt"; saveAlt(item.card.id, val.trim()); }
        if (j && j.note) altNote = j.note;
      } catch (e) {
        altNote = "Could not reach Claude just now, so this was judged on the card text alone.";
        verdict = verdictIn;
      } finally {
        clearTimeout(slowTimer);
        setChecking(false);
        setSlow(false);
      }
    }

    const grade = gradeOf(verdict === "alt" ? "yes" : verdict, ms, sol.length);
    const ok = grade > 1;
    const nextState = review(item.st, grade, Date.now(), item.prior);
    nextState.cr = item.st ? item.st.cr || 0 : 0;
    if (ok) nextState.cr = Math.min(3, nextState.cr + (item.st && item.st.cd === today() ? 0 : 1));
    shownAt.current = Date.now();
    setRes({ verdict, delay: schedInt(nextState, data.want), first, altNote: undash(altNote) });
    setFixed(false);
    const cls = ok ? null : classifyMiss(val.trim(), sol);
    const kind = ok ? null : verdict === "typo" ? "typo"
      : !val.trim() ? "vocab"
      : cls.misplaced.length && !cls.missing.length ? "order"
      : cls.missing.length ? "vocab" : "choice";
    setResults([...results, { ok, cue, mine: val.trim(), sol: verdict === "alt" ? val.trim() : sol, first, kind }]);
    setHook(null);
    commit({
      id: item.card.id, dir: item.dir, grade, mode: "drill",
      tags: first ? [] : item.card.tags,
      entry: ok || first ? null : {
        mode: "drill", q: cue, mine: val.trim(), correct: sol, prior: item.prior,
        // the reading direction has an English answer, which has no place in the word stats
        ...(item.dir === "id2en"
          ? { missing: [], misplaced: [], missed: [], hit: [] }
          : { ...classifyMiss(val.trim(), sol), missed: classifyMiss(val.trim(), sol).missing, hit: hitWords(val.trim(), sol) }),
      },
    });
    // a wrong answer that is another card from his deck: note the confusion
    if (!ok && val.trim()) {
      const k = canonKey(val);
      const hit = vocab.find((c) => canonKey(c.id) === k || canonKey(c.en) === k);
      if (hit && canonKey(hit.id) !== canonKey(sol)) saveConfusion(item.card.id, hit.id);
    }

    // missed items come back later in the same session, not immediately
    if (!ok && !first) {
      setQueue((q) => {
        const copy = [...q];
        const pos = at + 5 <= copy.length ? at + 5 : copy.length;   // never sooner than four items later
        copy.splice(pos, 0, { ...item, again: true });
        return copy;
      });
    }
  };

  const getHook = async () => {
    setHookBusy(true);
    try {
      const t = await ask(
        "You write keyword mnemonics for a German speaker learning Indonesian. One or two sentences, concrete and vivid, English. No preamble.",
        [{ role: "user", content: `Indonesian: "${item.card.id}" means "${item.card.en}". Give one keyword mnemonic that links the sound of the Indonesian to the meaning. Keep it under 25 words.` }]
      );
      if (t) { setHook(undash(t)); saveHook(item.card.id, undash(t)); }
    } catch (e) {}
    setHookBusy(false);
  };

  const wrong = res && (res.verdict === "no" || res.verdict === "skip");
  // if he typed another card from his own deck, say what that one means
  const mistaken = (() => {
    if (!wrong || !val.trim()) return null;
    const k = canonKey(val);
    const hit = vocab.find((c) => canonKey(c.id) === k || canonKey(c.en) === k);
    return hit && canonKey(hit.id) !== canonKey(sol) ? hit : null;
  })();


  return (
    <div>
      <Back on={back} />
      <h2 className="bh-title serif">Drill</h2>
      <Rule />
      <div className="bh-lede">Items the scheduler chose. Enter checks, Enter again moves on.</div>

      <Pips results={results} total={queue.length} at={at} />

      <div className="bh-stage">
        <div className="bh-ask">
          <span>{first ? "Guess first, then learn it" : cloze ? "Fill the gap" : item.dir === "en2id" ? "Say it in Indonesian" : "What does this mean"}</span>
          <em>{first ? "new" : item.again ? "again" : item.kind === "early" ? "ahead of schedule"
            : (data.conf && (data.conf[item.card.id] || []).length) ? "easy to mix up" : ""}</em>
        </div>
        <div className="bh-cue serif" key={at}>
          {cloze
            ? splitHint(cue).main.split(/(_{2,})/).map((p, i) =>
                /^_{2,}$/.test(p) ? <span key={i} className="bh-gap">?</span> : <span key={i}>{p}</span>)
            : splitHint(cue).main}
        </div>
        {splitHint(cue).hint && <div className="bh-gapen">{splitHint(cue).hint}</div>}
        {cloze && <div className="bh-gapen"><b>Means:</b> {splitHint(item.card.en).main}</div>}
      </div>

      {!res ? (
        <div>
          <div className="bh-do">{cloze ? "One Indonesian word is missing. Type only that word." : first ? "Guess, even if you have no idea, then press Enter." : "Type your answer, then press Enter."}</div>
          <input ref={ref} className="bh-field" value={val} placeholder={first ? "your guess" : "your answer"}
            autoCapitalize="none" autoCorrect="off" spellCheck="false"
            onChange={(e) => setVal(e.target.value)}
            onKeyDown={(e) => { if (e.key === "Enter" && !e.repeat && val.trim() && !checking) { e.preventDefault(); settle(judge(val, sol, hasBlank(cue))); } }} />
          <div className="bh-acts">
            <button className="bh-go" onClick={() => val.trim() && settle(judge(val, sol, hasBlank(cue)))} disabled={!val.trim() || checking}>{checking ? (slow ? "still checking" : "checking") : "Cek"}</button>
            <button className="bh-alt" onClick={() => settle("skip")}>No idea</button>
          </div>
        </div>
      ) : (
        <div>
          <div className={"bh-judge " + (wrong ? "bh-wrong" : "bh-right")}>
            <div className="bh-verdict">
              <b>{res.verdict === "yes" ? "Right" : res.verdict === "alt" ? "Also right" : res.verdict === "typo" ? "Counts, typo though"
                : res.first ? "Now you have seen it" : res.verdict === "skip" ? "Skipped" : "Not quite"}</b>
              <span>back in {showDelay(res.delay)}</span>
            </div>
            {res.verdict === "alt" && (
              <div className="bh-block">
                <div className="bh-blab">Your version, which works</div>
                <div className="bh-sent" style={{ color: "var(--yes)" }}>{val.trim()}</div>
              </div>
            )}
            {(res.verdict === "no" || res.verdict === "typo") && val.trim() && (
              <div className="bh-block">
                <div className="bh-blab">You wrote</div>
                <div className="bh-sent"><Diff parts={diffWords(val.trim(), sol).left} tone="mine" /></div>
              </div>
            )}
            <div className="bh-block">
              <div className="bh-blab">{res.verdict === "alt" ? "The card says" : wrong || res.verdict === "typo" ? "It should be" : "Answer"}</div>
              <div className="bh-sent"><Diff parts={diffWords(val.trim(), sol).right} tone="fix" /></div>
            </div>
            {res.verdict === "yes" && <div className="bh-praise">{PRAISE[at % PRAISE.length]}</div>}
            {res.altNote && <div className="bh-why"><Note text={res.altNote} /></div>}
            {wrong && !fixed && val.trim() && (
              <button className="bh-alt" style={{ marginTop: 10 }} onClick={() => {
                setFixed(true);
                saveAlt(item.card.id, val.trim());
                commit({ id: item.card.id, dir: item.dir, grade: 3, tags: first ? [] : item.card.tags, fixLast: true, skipErrorPop: first });
                setResults(results.map((r, i) => (i === results.length - 1 ? { ...r, ok: true, sol: val.trim() } : r)));
              }}>That was right</button>
            )}
            {fixed && <div className="bh-praise">Taken back. It counts as correct and I will accept it next time.</div>}
            {mistaken && (
              <div className="bh-why">
                <b>{mistaken.id}</b> is a card you have, it means "{mistaken.en}". Close, but not this one.
              </div>
            )}
            {(hook || saved) && <div className="bh-why">{hook || saved}</div>}
            {wrong && !first && !hook && !saved && lapses >= 2 && (
              <button className="bh-alt" style={{ marginTop: 12 }} onClick={getHook} disabled={hookBusy}>
                {hookBusy ? "thinking" : "Give me a hook for this one"}
              </button>
            )}
          </div>
          <button className="bh-go" style={{ width: "100%" }} onClick={next}>
            {at + 1 >= queue.length ? "See results" : "Next"}
          </button>
        </div>
      )}
    </div>
  );
}

const PRAISE_BIG = ["Mantap", "Bagus sekali", "Keren", "Sip", "Oke banget"];

function Summary({ results, back, again, label, days }) {
  const right = results.filter((r) => r.ok).length;
  const misses = results.filter((r) => !r.ok);
  const rate = results.length ? right / results.length : 0;
  const word = rate === 1 ? "Sempurna" : rate >= 0.7 ? PRAISE_BIG[right % PRAISE_BIG.length] : rate >= 0.4 ? "Lumayan" : "Besok lagi";
  const fresh = results.filter((r) => r.first).length;
  const repeats = results.filter((r) => !r.ok && !r.first).length;
  const kinds = results.filter((r) => !r.ok && r.kind).reduce((a, r) => ({ ...a, [r.kind]: (a[r.kind] || 0) + 1 }), {});
  const top = Object.entries(kinds).sort((a, b) => b[1] - a[1])[0];
  const KIND_LINE = {
    vocab: (n) => `${n} of them failed on a word you did not have. That is vocabulary, so the drill is the place to fix it.`,
    choice: (n) => `${n} times you reached for a real word that does not fit there. That is word choice, not memory.`,
    order: (n) => `In ${n} of them you had every word and put them in the wrong order. Your vocabulary is fine, the structure is not.`,
    affix: (n) => `${n} slipped on a prefix or suffix. The stems are there, the affixes are not.`,
    typo: (n) => `${n} were spelling slips. You knew them.`,
  };
  const diagnosis = top && KIND_LINE[top[0]] ? KIND_LINE[top[0]](top[1]) : null;

  const line =
    rate === 1 ? "Every single one. All of these move further out now."
    : rate >= 0.7 && fresh ? `Solid, and ${fresh} of them you had never seen before.`
    : rate >= 0.7 ? "Solid round. The ones you had come back later, the rest sooner."
    : rate >= 0.4 && repeats ? `${repeats} of these you had seen before, so they stay on short intervals.`
    : rate >= 0.4 ? "Middling. Half of this comes back within a day."
    : fresh >= results.length / 2 ? "Mostly new material, so a low score here means nothing yet."
    : "Rough one. This was material you had already seen, so it all comes back tomorrow.";
  return (
    <div>
      <Back on={back} />
      <div className="bh-result">
        <svg viewBox="0 0 120 60" style={{ width: 140, height: 70 }} aria-hidden="true">
          <circle cx="22" cy="30" r="9" fill="#12A47A" opacity=".22" />
          <circle cx="45" cy="22" r="6" fill="#F2B233" opacity=".45" />
          <circle cx="98" cy="30" r="9" fill="#FF7A59" opacity=".22" />
          <circle cx="75" cy="20" r="5" fill="#12A47A" opacity=".35" />
          <circle cx="60" cy="34" r="20" fill={rate >= 0.4 ? "var(--sun)" : "var(--hair)"} />
          <path d="M51 34 l6 6 l12 -14" fill="none" stroke={rate >= 0.4 ? "#412402" : "var(--faint)"} strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" />
        </svg>
        <div className="bh-score serif">{word}</div>
        <div className="bh-scorenote">{right} of {results.length} right. {line}</div>
        {diagnosis && <div className="bh-diag">{diagnosis}</div>}
      </div>

      <Week days={days} justDone={true} />

      {misses.length > 0 && <div className="bh-sub">Coming back soon</div>}
      {misses.map((m, i) => (
        <div key={i} className="bh-miss">
          <b>{m.sol}</b>
          <small>{m.cue}{m.mine ? ` · you had ${m.mine}` : " · skipped"}{m.why ? ` · ${undash(m.why)}` : ""}</small>
        </div>
      ))}

      <div className="bh-acts" style={{ marginTop: 18, flexDirection: "column" }}>
        <button className="bh-go" style={{ width: "100%" }} onClick={back}>Done for today</button>
        <button className="bh-alt" style={{ width: "100%" }} onClick={again}>Another {label}</button>
      </div>
    </div>
  );
}

function Sentences({ data, vocab, commit, weakest, weakWords, strongWords, recentRate, rememberSeen, learnWord, previewWord, saveWord, nudgeDue, back }) {
  const [batch, setBatch] = useState([]);
  const [at, setAt] = useState(0);
  const [results, setResults] = useState([]);
  const [val, setVal] = useState("");
  const [res, setRes] = useState(null);
  const [busy, setBusy] = useState(false);
  const [fail, setFail] = useState("");
  const list = useMemo(() => wordlist(vocab), [vocab]);
  const known = useMemo(() => deckWords(vocab), [vocab]);
  const [fixed, setFixed] = useState(false);
  const [newWords, setNewWords] = useState({});
  const [previews, setPreviews] = useState({});
  const [hint, setHint] = useState(false);
  const weakSet = useMemo(() => new Set((weakWords || []).map((w) => w.k)), [weakWords]);

  // The hint used to just show whatever word list the sentence-writing
  // call separately put in "focus" -- but that field was free text from
  // the same generation, and it could drift from the actual answer (a
  // hint word that never even appears in "it should be"). Deriving it
  // straight from the real answer makes that impossible: every hint
  // word is guaranteed to be a word he actually needs to write, and the
  // words he's weakest on are surfaced first.
  const hintWordsFor = (id) => {
    const toks = Array.from(new Set(canonTokens(id || "").map(canonWord))).filter((w) => w.length > 2 && known.has(w));
    const weak = toks.filter((w) => weakSet.has(w));
    const rest = toks.filter((w) => !weakSet.has(w));
    // toks are internal matching keys (aku/saya both become "1sg", sudah/udah
    // become "perf") so they must go back through showWord before they're
    // shown to him, the same way the rest of the app never lets him see
    // those keys directly.
    return [...weak, ...rest].slice(0, 3).map((w) => showWord(w, data.register));
  };

  const casual = (data.register || "casual") === "casual";
  const REGISTER = casual
    ? "Write the model answers in casual spoken Indonesian, the register he needs with his Indonesian partner: aku and kamu rather than saya and Anda, gak or nggak rather than tidak, udah rather than sudah, gimana rather than bagaimana."
    : "Write the model answers in standard polite Indonesian: saya, Anda, tidak, sudah.";

  const load = async () => {
    setBusy(true); setFail(""); setBatch([]); setResults([]); setAt(0); setRes(null); setVal(""); setHint(false); setNewWords({}); setPreviews({});
    try {
      const recent = (data.seen || []).slice(-40);
      const SETTINGS = ["at home", "at a warung", "on the phone", "at the market", "meeting friends",
        "travelling", "at work", "making plans", "in the evening", "asking for help", "in a shop", "on a bad day"];
      const pick = SETTINGS[Math.floor(Math.random() * SETTINGS.length)];
      const weakList = (weakWords || []).filter((w) => (w.w || 0) >= (w.o || 0)).map((w) => w.k).join(", ");
      const orderList = (weakWords || []).filter((w) => (w.o || 0) > (w.w || 0)).map((w) => w.k).join(", ");
      // How hard to lean on the weak pattern below. This used to scale all
      // the way down to one sentence when the recent rate was low, which
      // backfired: easing off the very pattern he's worst at let the rate
      // recover through avoidance rather than through actually getting
      // better at it, which then swung the count back up, which crashed the
      // rate again -- the difficulty was oscillating on a metric it was
      // itself allowed to distort, and never at any point trained the real
      // weakness. So this never drops the count below the floor: it only
      // ever holds steady or scales up. What the rate is allowed to ease
      // is the surrounding load, below (`strained`), never the confrontation
      // with the actual weak pattern itself.
      const weakestCount = !recentRate ? 4 : recentRate.rate >= 0.7 ? 5 : recentRate.rate >= 0.45 ? 4 : 3;
      const strained = !!recentRate && recentRate.rate < 0.45;
      // Same cached vocabulary block Roleplay uses (see vocabSystemBlock):
      // identical wording here means this can hit the same prompt-cache
      // entry a Roleplay session already warmed up, or vice versa.
      const t = await ask(
        [
          vocabSystemBlock(list),
          { type: "text", text: "You write translation drills for a German learner of Indonesian at A2 level." + levelNote(recentRate) + " Write the English cues in plain, modern, everyday English, never an archaic or formal word like \"whereto\" or \"wherefrom\", write it as ordinary separate words instead, such as \"where to\" or \"where from\". Never use dashes of any kind, use commas or full stops. Reply with JSON only, no preamble, no markdown." },
        ],
        [{ role: "user", content:
          REGISTER +
          (weakest ? `\n\nWeakest grammar pattern right now: ${weakest}. At least ${weakestCount} sentences must force it, spread across the set rather than clustered together.` : "") +
          (weakList ? `\n\nWords he keeps getting wrong inside sentences: ${weakList}. Work as many of these in as you can, one or two per sentence, in new contexts.` : "") +
          (orderList ? `\n\nWords he knows but keeps putting in the wrong position: ${orderList}. Build sentences where their position actually matters.` : "") +
          ((strongWords || []).length ? `\n\nWords he already produces reliably, fine as filler but do not make them the point: ${(strongWords || []).join(", ")}.` : "") +
          (strained ? `\n\nHe has been finding practice hard lately, so outside the sentences forcing ${weakest || "the weak pattern"} above, keep things short and built mostly from words and patterns he already handles well, and never stack the weak-word or word-order issues above into the same sentence as the weakest pattern, one hard thing at a time.` : "") +
          `\n\nThis set should be set ${pick}. Write 8 fresh English sentences for him to translate into Indonesian, different from anything obvious. Use only words from the vocabulary list, ${strained ? "keep difficulty gentle and even rather than escalating" : "difficulty rising"}, vary the pattern.` +
          (recent.length ? `\n\nDo not repeat or lightly reword any of these, he had them recently:\n${recent.join("\n")}` : "") +
          `\n\nSeed ${Math.floor(Math.random() * 100000)}. JSON array: [{\"en\":\"English sentence\",\"id\":\"Indonesian answer\"}]` }]
      );
      const j = parseJSON(t);
      if (!Array.isArray(j) || !j.length) throw new Error();
      setBatch(j.map((x) => ({ ...x, en: plainEnglish(undash(x.en)), id: undash(x.id) })));
      rememberSeen(j.map((x) => x.en).filter(Boolean));
    } catch (e) { setFail("The sentences did not come through. Try again."); }
    setBusy(false);
  };

  useEffect(() => { load(); }, []);

  // As soon as an answer is judged, quietly look up what any brand-new
  // word actually means -- so the "+ word" chip below can show its
  // meaning right away instead of only after he commits to adding it.
  // Declared before the "batch finished" early return below on purpose:
  // a hook after a conditional return fires on some renders and not
  // others, which breaks React's hook-call order the moment that
  // condition first turns true -- exactly when a set ends -- and takes
  // the whole screen down blank.
  useEffect(() => {
    const cur = batch[at];
    if (!res || !previewWord || !cur) return;
    outsideDeck(res.correct || cur.id, known).forEach((w) => {
      if (previews[w] !== undefined) return;
      setPreviews((s) => ({ ...s, [w]: "busy" }));
      previewWord(w, res.correct || cur.id).then((j) => {
        setPreviews((s) => ({ ...s, [w]: j || null }));
      });
    });
  }, [res]);

  if (batch.length && at >= batch.length) return <Summary results={results} back={back} again={load} label="set" days={data.days} />;

  const task = batch[at];

  const finish = (ok, correct, note, kind) => {
    const mine = val.trim();
    const hintWords = hint ? hintWordsFor(task.id) : [];
    setFixed(false);
    const cls = ok ? { missing: [], misplaced: [] } : classifyMiss(mine, correct);
    // A word he was never taught is not a mistake he could have avoided --
    // it's the app teaching him something new. Never blame his weak-word
    // or pattern stats for a word he had no way to know yet: strip it out
    // of the real "missed" signal, and if that new word is the *only*
    // thing wrong, treat the whole answer as a win worth celebrating
    // rather than a miss (see the "New word ahead" verdict below).
    const newInAnswer = new Set(outsideDeck(correct, known).map(bare));
    const missed = cls.missing.filter((w) => !newInAnswer.has(bare(w)));
    const misplaced = cls.misplaced;
    const onlyNewWord = !ok && cls.missing.length > 0 && cls.missing.every((w) => newInAnswer.has(bare(w))) && !misplaced.length;
    const hit = hitWords(mine, correct);
    const guessed = kind || (ok ? null : misplaced.length && !missed.length ? "order" : missed.length ? "vocab" : "choice");
    setRes({ ok, correct, note, mine, onlyNewWord });
    setResults([...results, { ok: ok || onlyNewWord, onlyNewWord, cue: task.en, mine, sol: correct, why: note, kind: guessed }]);
    commit({
      id: null, dir: null, grade: ok ? (hint ? 2 : 3) : onlyNewWord ? 2 : 1, mode: "sentences", tags: tagsOf(task.id),
      entry: { mode: "sentences", q: task.en, mine, correct, note: note || "", missed, misplaced, hit, hinted: hintWords, silent: ok || onlyNewWord },
    });
    if (nudgeDue) nudgeDue([...missed, ...misplaced]);
  };

  const check = async () => {
    if (!val.trim() || busy) return;
    if (judge(val, task.id) === "yes") { finish(true, task.id, "", null); return; }
    setBusy(true);
    try {
      const t = await ask("You correct Indonesian for a German learner. Direct and concise in English, but fair: say what he got right before what's wrong, never only the negative. Never use dashes of any kind, use commas or full stops. Reply with JSON only.",
        [{ role: "user", content:
          `Task: translate "${task.en}" into Indonesian.\nModel answer: "${task.id}"\nLearner wrote: "${val.trim()}"\n\n` +
          "Treat register variants as fully correct, never as mistakes: aku = saya, kamu = Anda, gak = nggak = tidak, udah = sudah, gimana = bagaimana, mau = ingin, cuma = hanya. " +
          "If his sentence is acceptable Indonesian for the task, ok must be true even when the model answer is phrased differently or sounds more idiomatic. Put that nuance in the note. " +
          (casual ? "He is learning for casual conversation with his Indonesian partner, so prefer the casual form in your corrected version. " : "") +
          "Accept equivalent phrasings and small typos as correct. " +
          "JSON: {\"ok\":true|false,\"correct\":\"best Indonesian version\",\"wrong\":[\"the exact words he got wrong\"]," +
          "\"kind\":\"one of vocab, choice, order, affix, typo: vocab if a word he needed is missing entirely, choice if he used a real word that is wrong here, order if the words are right but in the wrong position, affix if only a prefix or suffix is wrong, typo for a misspelling\"," +
          "\"note\":\"one or two short sentences, warm but plain: first name what he got right if most of it was right (say so explicitly, e.g. most of that was right, or the structure was spot on), then the actual mistake. If every wrong word is really just a typo (kind is typo, or the wrong word is one or two letters off from a word he clearly knows), say plainly that it was just a typo rather than describing it as a language mistake.\"}" }]);
      const j = parseJSON(t) || { ok: false, correct: task.id, note: "" };
      finish(!!j.ok, undash(j.correct || task.id), undash(j.note || ""), j.kind);
    } catch (e) {
      finish(false, task.id, "Could not reach Claude just now, so this was compared with the model answer only.");
    } finally { setBusy(false); }
  };

  return (
    <div>
      <Back on={back} />
      <h2 className="bh-title serif">Sentence forge</h2>
      <Rule />
      <div className="bh-lede">
        {weakest ? `Weighted towards ${weakest}, the pattern you miss most.` : `Built from your ${vocab.length} cards, nothing foreign inside.`}
      </div>

      {!task && <div className="bh-blank">{busy ? <span className="bh-load">writing sentences</span> : fail || "Nothing loaded."}</div>}

      {task && (
        <div>
          <Pips results={results} total={batch.length} at={at} />
          <div className="bh-stage">
            <div className="bh-ask">
              <span>Say it in Indonesian</span>
              {!hint && hintWordsFor(task.id).length > 0 && <button className="bh-hintbtn" onClick={() => setHint(true)}>Need a hint?</button>}
            </div>
            <div className="bh-cue serif" key={at}>{task.en}</div>
            {!res && outsideDeck(task.id, known).length > 0 && (
              <span className="bh-badge" style={{ marginTop: 10, display: "inline-block" }}>uses a new word</span>
            )}
            {hint && hintWordsFor(task.id).length > 0 && (
              <div className="bh-hintbox">
                <span className="bh-hintlab">Words that fit</span>
                <div className="bh-chips">
                  {hintWordsFor(task.id).map((w) => (
                    <span key={w} className="bh-chip">{w}</span>
                  ))}
                </div>
              </div>
            )}
          </div>

          {!res ? (
            <div>
              <div className="bh-do">Write the Indonesian, then press Enter.</div>
              <input className="bh-field" value={val} placeholder="your answer"
                autoCapitalize="none" autoCorrect="off" spellCheck="false"
                onChange={(e) => setVal(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.repeat) { e.preventDefault(); check(); } }} />
              <div className="bh-acts">
                <button className="bh-go" onClick={check} disabled={!val.trim() || busy}>{busy ? "checking" : "Cek"}</button>
                <button className="bh-alt" onClick={() => finish(false, task.id, "", "vocab")}>No idea</button>
              </div>
            </div>
          ) : (
            <div>
              <div className={"bh-judge " + (res.onlyNewWord ? "bh-act" : res.ok ? "bh-right" : "bh-wrong")}>
                <div className="bh-verdict"><b>{res.onlyNewWord ? "New word ahead" : res.ok ? (hint ? "Right, with the hint" : "Right") : "Not quite"}</b></div>
                {!res.ok && res.mine && (
                  <div className="bh-block">
                    <div className="bh-blab">You wrote</div>
                    <div className="bh-sent"><Diff parts={diffWords(res.mine, res.correct || task.id).left} tone="mine" /></div>
                  </div>
                )}
                <div className="bh-block">
                  <div className="bh-blab">{res.ok ? "Model answer" : "It should be"}</div>
                  <div className="bh-sent"><Diff parts={diffWords(res.mine || "", res.correct || task.id).right} tone="fix" /></div>
                </div>
                {res.onlyNewWord ? (
                  <div className="bh-why">
                    Everything else was exactly right. {outsideDeck(res.correct || task.id, known).join(", ")} just isn't in your deck yet, add it below and it's yours next time.
                  </div>
                ) : (
                  res.note && <div className="bh-why"><Note text={res.note} /></div>
                )}
                {outsideDeck(res.correct || task.id, known).length > 0 && (
                  <div className="bh-newbox">
                    <div className="bh-newlab">New word{outsideDeck(res.correct || task.id, known).length > 1 ? "s" : ""} for you</div>
                    <div className="bh-chips">
                      {outsideDeck(res.correct || task.id, known).map((w) => {
                        const prev = previews[w];
                        return (
                          <button key={w} className="bh-newchip" data-state={newWords[w] ? "done" : prev === "busy" ? "busy" : "idle"}
                            disabled={!!newWords[w] || prev === "busy"}
                            onClick={async () => {
                              if (prev && prev !== "busy") {
                                setNewWords((s) => ({ ...s, [w]: `added: ${prev.en}` }));
                                saveWord(w, prev);
                                return;
                              }
                              setNewWords((s) => ({ ...s, [w]: "busy" }));
                              const j = await learnWord(w, res.correct || task.id);
                              setNewWords((s) => ({ ...s, [w]: j ? `added: ${j.en}` : "could not add" }));
                            }}>
                            {newWords[w]
                              ? `${w} · ${newWords[w]}`
                              : prev === "busy" || prev === undefined
                              ? `${w} …`
                              : prev
                              ? `+ ${w} (${prev.en})`
                              : `+ ${w}`}
                          </button>
                        );
                      })}
                    </div>
                  </div>
                )}
                {!res.ok && !res.onlyNewWord && !fixed && val.trim() && (
                  <button className="bh-alt" style={{ marginTop: 10 }} onClick={() => {
                    setFixed(true);
                    commit({ id: null, dir: null, grade: 3, mode: "sentences", tags: tagsOf(task.id), fixLast: true });
                    setResults(results.map((r, i) => (i === results.length - 1 ? { ...r, ok: true, sol: val.trim() } : r)));
                  }}>That was right</button>
                )}
                {fixed && <div className="bh-praise">Taken back, it counts as correct.</div>}
              </div>
              <button className="bh-go" style={{ width: "100%" }} onClick={() => { setRes(null); setVal(""); setFixed(false); setHint(false); setNewWords({}); setPreviews({}); setAt(at + 1); }}>
                {at + 1 >= batch.length ? "See results" : "Next"}
              </button>
            </div>
          )}
          {fail && <div className="bh-fail">{fail}</div>}
        </div>
      )}
    </div>
  );
}

const SCENES = [
  { k: "W", ic: "bowl", t: "Warung", d: "Order food, ask what is in it", s: "You run a warung in Yogyakarta. The learner wants to order." },
  { k: "G", ic: "scooter", t: "Grab ride", d: "Destination, traffic, small talk", s: "You are a Grab driver picking the learner up." },
  { k: "P", ic: "fruit", t: "Market", d: "Buy something, haggle a little", s: "You sell fruit at the pasar. The learner buys and haggles." },
  { k: "K", ic: "house", t: "Meeting the family", d: "Where you are from, work, food", s: "Your name is Tante Rina, the aunt of the learner's partner, meeting him for the first time. Introduce yourself by name first, then ask politely about one thing at a time: origin, work, age, food." },
  { k: "T", ic: "plant", t: "Neighbour", d: "Morning chat outside the house", s: "You are the neighbour running into the learner outside in the morning." },
];

function Roleplay({ vocab, commit, recentRate, nudgeDue, back }) {
  const [scene, setScene] = useState(null);
  const [msgs, setMsgs] = useState([]);
  const [val, setVal] = useState("");
  const [busy, setBusy] = useState(false);
  const [review2, setReview2] = useState(null);
  const [fail, setFail] = useState("");
  const [mode, setMode] = useState("text");
  const [speakScene, setSpeakScene] = useState(null);
  const list = useMemo(() => wordlist(vocab), [vocab]);
  const known = useMemo(() => deckWords(vocab), [vocab]);
  // A different random length each time, so scenes don't all wrap up at
  // exactly the same point -- set fresh in start() below.
  const targetTurnsRef = useRef(6);

  const turns = msgs.filter((m) => m.role === "user").length;
  useEffect(() => {
    if (scene && turns >= targetTurnsRef.current && !review2 && !busy) closeAndEvaluate();   // enough said, wrap it up
  }, [scene, turns, review2, busy]);

  // The vocabulary block is split out into its own cached content block (see
  // vocabSystemBlock above) so repeated turns in this same conversation,
  // and even a later Sentence forge session, can reuse it from Anthropic's
  // prompt cache instead of paying full price to reprocess it every time.
  const system = scene
    ? [
        vocabSystemBlock(list),
        { type: "text", text: scene.s + " Speak Indonesian only, at most two short sentences per turn, A2 level." + levelNote(recentRate) + " Prefer words from the vocabulary list, stay in character even if the learner makes mistakes or writes English, and never correct anything during the conversation." },
      ]
    : "";

  const start = async (sc) => {
    setScene(sc); setBusy(true); setReview2(null); setMsgs([]); setFail("");
    targetTurnsRef.current = 4 + Math.floor(Math.random() * 7); // 4 to 10 turns
    try {
      const t = await ask(sc.s + " Speak Indonesian only, at most two short sentences, A2 level." + levelNote(recentRate),
        [{ role: "user", content: "Open the conversation naturally: if your character would give their name, introduce yourself in one short line first, then ask exactly one simple, clearly-formed question. Never ask a question that does not make sense, such as asking your own name." }]);
      setMsgs([{ role: "assistant", content: t }]);
    } catch (e) {
      setFail("Could not start the scene. Check your connection and try again.");
      setScene(null);
    } finally { setBusy(false); }
  };

  const send = async () => {
    if (!val.trim() || busy) return;
    const hist = [...msgs, { role: "user", content: val.trim() }];
    setMsgs(hist); setVal(""); setBusy(true); setFail("");
    try {
      const t = await ask(system, hist);
      setMsgs([...hist, { role: "assistant", content: t }]);
    } catch (e) {
      setFail("That reply did not come through. Try sending again.");
    } finally { setBusy(false); }
  };

  // Takes an explicit message list rather than always reading `msgs` from
  // state, so closeAndEvaluate() below can hand it the just-added closing
  // line straight away -- setMsgs() alone wouldn't be visible here yet in
  // the same tick, since React batches the update.
  const evaluate = async (finalMsgs) => {
    if (busy) return;
    setBusy(true); setFail("");
    const list = finalMsgs || msgs;
    const script = list.map((m) => (m.role === "user" ? "LEARNER: " : "CHARACTER: ") + m.content).join("\n");
    let t = "";
    try {
      t = await ask("You review a roleplay for a German learner of Indonesian. Direct, concrete, in English. Never use dashes of any kind. Reply with JSON only.",
        [{ role: "user", content: script +
          "\n\nReview only the learner's lines. Treat aku/saya, gak/tidak, udah/sudah, cuma/hanya as fully correct. JSON: " +
          "{\"good\":[{\"line\":\"something he got right\",\"why\":\"what was good about it, one short line\"}]," +
          "\"nearly\":[{\"mine\":\"his line\",\"better\":\"how a native would say it\",\"why\":\"one line, understandable but not natural\"}]," +
          "\"slips\":[{\"mine\":\"his line\",\"correct\":\"fixed\",\"note\":\"one line why it is wrong\"}]," +
          "\"pattern\":\"the one thing to work on, one sentence\"}" }]);
    } catch (e) {
      setFail("The review did not come through. Try again.");
      setBusy(false);
      return;
    }
    const j = parseJSON(t) || { good: [], nearly: [], slips: [], pattern: "" };
    setReview2(j);
    commit({ id: null, dir: null, grade: 3, mode: "roleplay", tags: [] });
    // Good lines used to only bump the session-happened counter above.
    // They are also free evidence of which deck words and patterns he
    // already produces correctly on his own, unprompted -- the same kind
    // of positive signal Drill/Sentence forge give, which roleplay was
    // missing entirely (only its failures ever reached data.pat/data.words).
    (j.good || []).forEach((g) => {
      const hit = goodLineHits(g.line, known);
      commit({ id: null, dir: null, grade: 3, mode: "roleplay", tags: tagsOf(g.line || ""),
        entry: hit.length ? { mode: "roleplay", hit } : undefined });
    });
    // "Nearly" is understandable but not native -- a softer miss than a
    // slip, so it counts as a hint-level grade rather than a full wrong,
    // but still reports which words were the actual problem.
    (j.nearly || []).forEach((f) => {
      const { missed, misplaced, hit } = roleplayDiff(f.mine, f.better);
      commit({ id: null, dir: null, grade: 2, mode: "roleplay", tags: tagsOf(f.better || ""),
        entry: { mode: "roleplay", q: scene.t, mine: f.mine, correct: f.better, note: f.why || "", missed, misplaced, hit } });
      if (nudgeDue) nudgeDue([...missed, ...misplaced]);
    });
    (j.slips || []).forEach((f) => {
      const { missed, misplaced, hit } = roleplayDiff(f.mine, f.correct);
      commit({ id: null, dir: null, grade: 1, mode: "roleplay", tags: tagsOf(f.correct || ""),
        entry: { mode: "roleplay", q: scene.t, mine: f.mine, correct: f.correct, note: f.note || "", missed, misplaced, hit } });
      if (nudgeDue) nudgeDue([...missed, ...misplaced]);
    });
    setBusy(false);
  };

  // Instead of cutting the scene off the moment the turn count is reached,
  // ask the character for one real goodbye line first (a stage direction,
  // never shown as if the learner said it), then review the transcript
  // including that closing line. finalMsgs is threaded through explicitly
  // to evaluate() rather than relying on state, since setMsgs() here would
  // not be visible in `msgs` until the next render.
  const closeAndEvaluate = async () => {
    if (busy) return;
    setBusy(true); setFail("");
    try {
      const t = await ask(system,
        [...msgs, { role: "user", content: "(Stage direction, not spoken by the learner: bring this conversation to a natural close now, with a short, friendly goodbye in character.)" }]);
      const finalMsgs = [...msgs, { role: "assistant", content: t }];
      setMsgs(finalMsgs);
      await evaluate(finalMsgs);
    } catch (e) {
      setFail("Could not close the scene. Try again.");
      setBusy(false);
    }
  };

  if (speakScene)
    return <Speak scene={speakScene} back={() => setSpeakScene(null)} commit={commit} known={known} nudgeDue={nudgeDue} />;

  if (!scene)
    return (
      <div>
        <Back on={back} />
        <h2 className="bh-title serif">Roleplay</h2>
      <Rule />
        <div className="bh-lede">Indonesian only. Slips are collected, shown at the end, and fed back into the pattern scores.</div>
        <div className="bh-acts" style={{ marginBottom: 14 }}>
          <button className={mode === "text" ? "bh-go" : "bh-alt"} style={{ flex: 1 }} onClick={() => setMode("text")}>Type it</button>
          <button className={mode === "speak" ? "bh-go" : "bh-alt"} style={{ flex: 1 }} onClick={() => setMode("speak")}>Say it out loud</button>
        </div>
        <div>{SCENES.map((s) => <Tile key={s.k} ic={s.ic} n={s.t} d={s.d} on={() => (mode === "speak" ? setSpeakScene(s) : start(s))} />)}</div>
      </div>
    );

  const leave = () => { setScene(null); setMsgs([]); setReview2(null); };

  return (
    <div>
      <Back to="Scenes" on={leave} />
      <h2 className="bh-title serif">{scene.t}</h2>
      <div className="bh-lede">Answer in Indonesian, as best you can.</div>

      <div className="bh-thread">
        {msgs.map((m, i) => <div key={i} className={"bh-bubble " + (m.role === "user" ? "bh-mine" : "bh-from")}>{m.content}</div>)}
        {busy && <div className="bh-bubble bh-from"><span className="bh-load">typing</span></div>}
      </div>
      {fail && <div className="bh-fail">{fail}</div>}

      {!review2 && (
        <div>
          <div className="bh-do">Reply in Indonesian, then press Enter. The scene wraps up on its own after a few turns.</div>
          <input className="bh-field" value={val} placeholder="your reply"
            autoCapitalize="none" autoCorrect="off" spellCheck="false"
            onChange={(e) => setVal(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.repeat) { e.preventDefault(); send(); } }} />
          <div className="bh-acts">
            <button className="bh-go" onClick={send} disabled={!val.trim() || busy}>Send</button>
            <button className="bh-alt" onClick={evaluate} disabled={busy || msgs.length < 3}>End it now</button>
          </div>
        </div>
      )}

      {review2 && (
        <div>
          {(review2.good || []).length > 0 && (
            <div className="bh-judge bh-right">
              <div className="bh-verdict"><b>This worked</b></div>
              {(review2.good || []).map((g, i) => (
                <div key={i} className="bh-block">
                  <div className="bh-sent">{g.line}</div>
                  {g.why && <div className="bh-why"><Note text={g.why} /></div>}
                </div>
              ))}
            </div>
          )}

          {(review2.nearly || []).map((n, i) => (
            <div key={"n" + i} className="bh-judge" style={{ background: "var(--act)", borderColor: "var(--act-line)" }}>
              <div className="bh-verdict"><b style={{ color: "var(--act-ink)" }}>Understandable, not quite natural</b></div>
              <div className="bh-block"><div className="bh-blab">You said</div><div className="bh-sent">{n.mine}</div></div>
              <div className="bh-block"><div className="bh-blab">A native would say</div><div className="bh-sent">{n.better}</div></div>
              {n.why && <div className="bh-why"><Note text={n.why} /></div>}
            </div>
          ))}

          {(review2.slips || []).length === 0 && (review2.nearly || []).length === 0 && (review2.good || []).length === 0 && (
            <div className="bh-judge bh-right"><div className="bh-verdict"><b>Nothing to fix</b></div></div>
          )}
          {(review2.slips || []).map((f, i) => (
            <div key={i} className="bh-judge bh-wrong">
              <div className="bh-block"><div className="bh-blab">You said</div><div className="bh-sent" style={{ color: "var(--muted)", textDecoration: "line-through" }}>{f.mine}</div></div>
              <div className="bh-block"><div className="bh-blab">Better</div><div className="bh-sent">{f.correct}</div></div>
              {f.note && <div className="bh-why"><Note text={f.note} /></div>}
            </div>
          ))}
          {review2.pattern && <div className="bh-panel"><div className="bh-plabel">Work on this</div><div>{undash(review2.pattern)}</div></div>}
          <button className="bh-go" style={{ width: "100%" }} onClick={leave}>New scene</button>
        </div>
      )}
    </div>
  );
}

/* ---------------- Speak: live voice roleplay ----------------
   Same scenes and characters as the typed Roleplay above, but the reply
   is spoken instead of typed. Talks to Google's Gemini Live API directly
   from the browser over a WebSocket: our own /api/live-token mints a
   short-lived, constrained token (model + character + no tools baked in
   server-side, see that file's comments) so the long-lived Gemini key
   never reaches the browser, then the mic audio streams up and the
   model's voice streams back down on that one connection. No new backend
   dependency beyond the one token-minting call. */

function pcm16FromFloat32(float32, inRate, outRate) {
  let src = float32;
  if (inRate !== outRate) {
    const ratio = inRate / outRate;
    const outLen = Math.max(1, Math.round(float32.length / ratio));
    const resampled = new Float32Array(outLen);
    for (let i = 0; i < outLen; i++) {
      const at = i * ratio;
      const i0 = Math.floor(at), i1 = Math.min(i0 + 1, float32.length - 1);
      const frac = at - i0;
      resampled[i] = float32[i0] * (1 - frac) + float32[i1] * frac;
    }
    src = resampled;
  }
  const int16 = new Int16Array(src.length);
  for (let i = 0; i < src.length; i++) {
    const s = Math.max(-1, Math.min(1, src[i]));
    int16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
  }
  return int16;
}
function b64FromInt16(int16) {
  const bytes = new Uint8Array(int16.buffer);
  let bin = "";
  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
  return btoa(bin);
}
function int16FromB64(b64) {
  const bin = atob(b64);
  const bytes = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
  return new Int16Array(bytes.buffer);
}

const LIVE_IN_RATE = 16000;
const LIVE_OUT_RATE = 24000;

const LIVE_FADE_S = 0.006; // short fade at each audio chunk's edges, to stop the clicking between chunks
// A fixed session length felt mechanical (always the same number of turns).
// The model is never told a number now -- it decides for itself when the
// scene feels done, based on how the conversation actually develops. This
// range is only used to pick a fresh random safety-net threshold each
// session, for the rare case where it runs on much longer than that.
const END_AFTER_TURNS_MIN = 4;
const END_AFTER_TURNS_MAX = 10;

function Speak({ scene, back, commit, known, nudgeDue }) {
  const deckKnown = known || new Set();
  const [state, setState] = useState("idle"); // idle | connecting | live | error
  const [fail, setFail] = useState("");
  const [turns, setTurns] = useState([]); // [{ role: "user" | "model", text }]
  const [review, setReview] = useState(null);
  const [busy, setBusy] = useState(false); // grading the finished conversation
  const wsRef = useRef(null);
  const micCtxRef = useRef(null);
  const playCtxRef = useRef(null);
  const streamRef = useRef(null);
  const procRef = useRef(null);
  const nextPlayAt = useRef(0);
  const activeSrcs = useRef([]);
  const primedRef = useRef(false);
  const modelTurnsRef = useRef(0); // counts the character's completed replies, to decide when to auto-end
  const endAfterTurnsRef = useRef(END_AFTER_TURNS_MIN); // this session's random safety-net threshold, set fresh in start()
  const wrapupSentRef = useRef(false); // true once we've nudged the character to close, so we end on the *next* turn rather than mid-goodbye
  const turnsRef = useRef([]); // mirrors `turns`, so late callbacks (setTimeout, ws.onmessage) always see the latest transcript rather than the stale one from when they were created

  // Closes the mic/socket/audio without touching component state — used both
  // by a plain abandon (Back button, unmount) and as the first step of
  // finish() below, which goes on to grade the conversation.
  const teardown = () => {
    try { wsRef.current && wsRef.current.close(); } catch (e) {}
    wsRef.current = null;
    try { procRef.current && procRef.current.disconnect(); } catch (e) {}
    procRef.current = null;
    try { streamRef.current && streamRef.current.getTracks().forEach((t) => t.stop()); } catch (e) {}
    streamRef.current = null;
    try { micCtxRef.current && micCtxRef.current.close(); } catch (e) {}
    micCtxRef.current = null;
    activeSrcs.current.forEach((s) => { try { s.stop(); } catch (e) {} });
    activeSrcs.current = [];
    try { playCtxRef.current && playCtxRef.current.close(); } catch (e) {}
    playCtxRef.current = null;
    primedRef.current = false;
  };
  const stop = () => { teardown(); setState("idle"); };
  useEffect(() => () => teardown(), []);

  // Appends to the last bubble if the same speaker is still talking, otherwise
  // starts a new one — that's what turns the raw transcript stream into a
  // proper back-and-forth thread instead of one run-on paragraph.
  const say = (role, text) => {
    if (!text) return;
    const ts = turnsRef.current;
    const next = ts.length && ts[ts.length - 1].role === role
      ? [...ts.slice(0, -1), { role, text: ts[ts.length - 1].text + text }]
      : [...ts, { role, text }];
    turnsRef.current = next;
    setTurns(next);
  };

  // Same grading call the typed "Type it" mode uses, just fed the voice
  // transcript instead of the typed messages. Runs once the conversation is
  // over (either the turn count says the character has had its goodbye, or
  // the learner tapped stop by hand).
  const gradeAndFinish = async (said) => {
    const lines = said.filter((t) => t.role === "user");
    if (lines.length === 0) return; // nothing was actually said, nothing to review
    setBusy(true); setFail("");
    const script = said.map((t) => (t.role === "user" ? "LEARNER: " : "CHARACTER: ") + t.text).join("\n");
    try {
      const t = await ask("You review a roleplay for a German learner of Indonesian. Direct, concrete, in English. Never use dashes of any kind. Reply with JSON only.",
        [{ role: "user", content: script +
          "\n\nReview only the learner's lines. Treat aku/saya, gak/tidak, udah/sudah, cuma/hanya as fully correct. The learner's lines came from speech recognition, so ignore capitalization, punctuation, and small spelling slips that are clearly just transcription noise rather than language mistakes. JSON: " +
          "{\"good\":[{\"line\":\"something he got right\",\"why\":\"what was good about it, one short line\"}]," +
          "\"nearly\":[{\"mine\":\"his line\",\"better\":\"how a native would say it\",\"why\":\"one line, understandable but not natural\"}]," +
          "\"slips\":[{\"mine\":\"his line\",\"correct\":\"fixed\",\"note\":\"one line why it is wrong\"}]," +
          "\"pattern\":\"the one thing to work on, one sentence\"}" }]);
      const j = parseJSON(t) || { good: [], nearly: [], slips: [], pattern: "" };
      setReview(j);
      commit({ id: null, dir: null, grade: 3, mode: "roleplay", tags: [] });
      // Same word/pattern-level signals the typed Roleplay now records (see
      // its evaluate()): good lines credit the deck words he used correctly,
      // "nearly" is a softer miss than a slip, both report which words were
      // actually the problem instead of only feeding data.pat.
      (j.good || []).forEach((g) => {
        const hit = goodLineHits(g.line, deckKnown);
        commit({ id: null, dir: null, grade: 3, mode: "roleplay", tags: tagsOf(g.line || ""),
          entry: hit.length ? { mode: "roleplay", hit } : undefined });
      });
      (j.nearly || []).forEach((f) => {
        const { missed, misplaced, hit } = roleplayDiff(f.mine, f.better);
        commit({ id: null, dir: null, grade: 2, mode: "roleplay", tags: tagsOf(f.better || ""),
          entry: { mode: "roleplay", q: scene.t, mine: f.mine, correct: f.better, note: f.why || "", missed, misplaced, hit } });
        if (nudgeDue) nudgeDue([...missed, ...misplaced]);
      });
      (j.slips || []).forEach((f) => {
        const { missed, misplaced, hit } = roleplayDiff(f.mine, f.correct);
        commit({ id: null, dir: null, grade: 1, mode: "roleplay", tags: tagsOf(f.correct || ""),
          entry: { mode: "roleplay", q: scene.t, mine: f.mine, correct: f.correct, note: f.note || "", missed, misplaced, hit } });
        if (nudgeDue) nudgeDue([...missed, ...misplaced]);
      });
    } catch (e) {
      setFail("The review did not come through, but nice work practicing out loud.");
    } finally { setBusy(false); }
  };

  // Ends the live session (by hand, or because the character just said
  // goodbye) and hands the transcript over for review.
  const finish = () => {
    const said = turnsRef.current;
    teardown();
    setState("idle");
    gradeAndFinish(said);
  };

  const playChunk = (b64) => {
    const ctx = playCtxRef.current;
    if (!ctx) return;
    const int16 = int16FromB64(b64);
    const float32 = new Float32Array(int16.length);
    for (let i = 0; i < int16.length; i++) float32[i] = int16[i] / 32768;
    const buf = ctx.createBuffer(1, float32.length, LIVE_OUT_RATE);
    buf.getChannelData(0).set(float32);
    const src = ctx.createBufferSource();
    src.buffer = buf;
    // Each chunk arrives as a separate, independently-decoded buffer, so the
    // sample values at its start/end almost never line up with the chunk
    // played before or after it — that little jump is the clicking. A very
    // short fade in/out on every chunk's gain smooths those seams out
    // without being audible as a fade over a normal-length utterance.
    const gain = ctx.createGain();
    src.connect(gain); gain.connect(ctx.destination);
    const startAt = Math.max(ctx.currentTime, nextPlayAt.current);
    const dur = buf.duration;
    const fade = Math.min(LIVE_FADE_S, dur / 2);
    gain.gain.setValueAtTime(0, startAt);
    gain.gain.linearRampToValueAtTime(1, startAt + fade);
    gain.gain.setValueAtTime(1, Math.max(startAt + fade, startAt + dur - fade));
    gain.gain.linearRampToValueAtTime(0, startAt + dur);
    src.start(startAt);
    nextPlayAt.current = startAt + dur;
    activeSrcs.current.push(src);
    src.onended = () => { activeSrcs.current = activeSrcs.current.filter((s) => s !== src); };
  };

  const start = async () => {
    setState("connecting"); setFail(""); setTurns([]); turnsRef.current = []; setReview(null); modelTurnsRef.current = 0; wrapupSentRef.current = false;
    // No fixed turn count is told to the model any more -- it should judge
    // for itself when the scene feels done, based on how the conversation
    // actually goes and how much the learner has said, so the variability
    // comes across as organic rather than a hidden countdown. The random
    // number below is never mentioned to the model at all: it is purely our
    // own safety net for a session that runs unusually long, and it picks a
    // fresh value each time so that safety net itself doesn't land on the
    // same turn every session.
    endAfterTurnsRef.current = END_AFTER_TURNS_MIN + Math.floor(Math.random() * (END_AFTER_TURNS_MAX - END_AFTER_TURNS_MIN + 1));
    try {
      const system = scene.s +
        " Speak Indonesian only, at most two short sentences per turn, A2 level." +
        " Use only the simplest, most common everyday words a true beginner already knows (greetings, numbers, basic verbs like mau, ada, boleh, bisa, suka); avoid rare, formal, or literary vocabulary, and keep sentences short and guessable from context." +
        " You speak first: open the roleplay yourself before the learner says anything. If your character would give their name, introduce yourself in one short line first, then ask exactly one simple, clearly-formed question; never ask a question that does not make sense, such as asking your own name." +
        " Don't aim for a fixed number of exchanges: judge for yourself, from how the conversation is actually going and how engaged the learner seems, when the scene feels naturally complete, then bring it to a close yourself with a short, friendly goodbye in character and do not start a new topic after that. A quieter exchange can wrap up sooner; a livelier one can run on longer." +
        " Stay in character even if the learner makes mistakes or switches to English, and never correct anything during the conversation.";
      const r = await fetch("/api/live-token", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ system }),
      });
      if (r.status === 401) { onAuthFailure(); return; }
      const d = await r.json();
      if (!r.ok || !d.name) throw new Error((d && d.error) || "Could not start the voice session.");

      const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
      streamRef.current = stream;

      const ws = new WebSocket("wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContentConstrained?access_token=" + encodeURIComponent(d.name));
      wsRef.current = ws;

      ws.onopen = () => {
        // This setup message is meant to be fully overridden by the token
        // minted in api/live-token.js (that's the point of a constrained
        // token). In practice the transcription language still leaked
        // through as auto-detect from this client-side message alone
        // (showing up as e.g. Korean characters for the learner's actual
        // Indonesian speech), so languageCodes is repeated here too, to
        // match the server-side token exactly rather than relying on the
        // override.
        ws.send(JSON.stringify({
          setup: {
            model: d.model || "models/gemini-3.8-live",
            generationConfig: { responseModalities: ["AUDIO"], speechConfig: { languageCode: "id-ID" } },
            systemInstruction: { parts: [{ text: system }] },
            inputAudioTranscription: { languageCodes: ["id-ID"] },
            outputAudioTranscription: { languageCodes: ["id-ID"] },
          },
        }));

        const AC = window.AudioContext || window.webkitAudioContext;
        const micCtx = new AC();
        micCtxRef.current = micCtx;
        const playCtx = new AC({ sampleRate: LIVE_OUT_RATE });
        playCtxRef.current = playCtx;
        nextPlayAt.current = 0;

        const source = micCtx.createMediaStreamSource(stream);
        const proc = micCtx.createScriptProcessor(4096, 1, 1);
        procRef.current = proc;
        const mute = micCtx.createGain(); mute.gain.value = 0;
        source.connect(proc); proc.connect(mute); mute.connect(micCtx.destination);
        proc.onaudioprocess = (e) => {
          if (!wsRef.current || wsRef.current.readyState !== 1) return;
          const int16 = pcm16FromFloat32(e.inputBuffer.getChannelData(0), micCtx.sampleRate, LIVE_IN_RATE);
          wsRef.current.send(JSON.stringify({ realtimeInput: { audio: { data: b64FromInt16(int16), mimeType: "audio/pcm;rate=16000" } } }));
        };
        setState("live");
      };

      ws.onmessage = async (ev) => {
        let msg;
        try { msg = JSON.parse(typeof ev.data === "string" ? ev.data : await ev.data.text()); } catch (e) { return; }

        // Once the server confirms our setup, nudge it to speak first: a
        // short stage-direction sent as a normal text turn (never spoken
        // aloud, never transcribed, so it can't show up as a fake line from
        // the learner) rather than waiting silently for the mic.
        if (msg.setupComplete && !primedRef.current) {
          primedRef.current = true;
          ws.send(JSON.stringify({
            clientContent: {
              turns: [{ role: "user", parts: [{ text: "(Stage direction, not spoken by the learner: begin the roleplay now with your character's opening line, in Indonesian.)" }] }],
              turnComplete: true,
            },
          }));
        }

        const sc = msg.serverContent;
        if (!sc) return;
        if (sc.interrupted) {
          activeSrcs.current.forEach((s) => { try { s.stop(); } catch (e) {} });
          activeSrcs.current = [];
          nextPlayAt.current = playCtxRef.current ? playCtxRef.current.currentTime : 0;
        }
        (sc.modelTurn && sc.modelTurn.parts || []).forEach((p) => {
          if (p.inlineData && p.inlineData.data) playChunk(p.inlineData.data);
        });
        if (sc.inputTranscription && sc.inputTranscription.text) say("user", sc.inputTranscription.text);
        if (sc.outputTranscription && sc.outputTranscription.text) say("model", sc.outputTranscription.text);

        // turnComplete marks the end of one of the character's replies. The
        // model is never told a turn number, so it should normally close
        // the scene on its own; this is just the safety net for when it
        // runs on well past this session's random threshold. Rather than
        // cutting the audio off mid-conversation, the first time we cross
        // the threshold we nudge the character (a silent stage direction,
        // same trick used to prime it to speak first) to wrap up now, and
        // only actually end on the reply that follows -- so the learner
        // always hears a real goodbye rather than an abrupt stop.
        if (sc.turnComplete) {
          modelTurnsRef.current += 1;
          if (modelTurnsRef.current >= endAfterTurnsRef.current) {
            if (!wrapupSentRef.current) {
              wrapupSentRef.current = true;
              ws.send(JSON.stringify({
                clientContent: {
                  turns: [{ role: "user", parts: [{ text: "(Stage direction, not spoken by the learner: bring this conversation to a natural close now, with a short, friendly goodbye in character, in Indonesian.)" }] }],
                  turnComplete: true,
                },
              }));
            } else {
              const ctx = playCtxRef.current;
              const remain = ctx ? Math.max(0, nextPlayAt.current - ctx.currentTime) : 0;
              setTimeout(finish, remain * 1000 + 400);
            }
          }
        }
      };

      ws.onerror = () => setFail("The voice connection dropped. Try starting again.");
      ws.onclose = () => { if (wsRef.current) setState("idle"); };
    } catch (e) {
      setFail((e && e.message) || "Could not start the voice session.");
      setState("error");
      stop();
    }
  };

  return (
    <div>
      <Back to="Scenes" on={() => { teardown(); setState("idle"); back(); }} />
      <h2 className="bh-title serif">{scene.t}</h2>

      {!review && (
        <div>
          <div className="bh-lede">Talk it out loud, in Indonesian. The scene wraps up on its own after a few exchanges, or tap stop for your review any time.</div>

          <div className="bh-micwrap">
            <button className="bh-mic" data-live={state === "live" ? "1" : "0"} disabled={busy}
              onClick={() => (state === "live" || state === "connecting" ? finish() : start())}>
              {state === "live" ? "■" : "●"}
            </button>
            <div className="bh-micstate">
              {busy && "Reviewing your conversation…"}
              {!busy && state === "idle" && "Tap to start"}
              {!busy && state === "connecting" && "Connecting…"}
              {!busy && state === "live" && "Listening, tap to stop and get your review"}
              {!busy && state === "error" && "Tap to try again"}
            </div>
          </div>

          {turns.length > 0 && (
            <div className="bh-thread">
              {turns.map((t, i) => (
                <div key={i} className={"bh-bubble " + (t.role === "user" ? "bh-mine" : "bh-from")}>{t.text}</div>
              ))}
            </div>
          )}
          {fail && <div className="bh-fail">{fail}</div>}
        </div>
      )}

      {review && (
        <div>
          {(review.good || []).length > 0 && (
            <div className="bh-judge bh-right">
              <div className="bh-verdict"><b>This worked</b></div>
              {(review.good || []).map((g, i) => (
                <div key={i} className="bh-block">
                  <div className="bh-sent">{g.line}</div>
                  {g.why && <div className="bh-why"><Note text={g.why} /></div>}
                </div>
              ))}
            </div>
          )}

          {(review.nearly || []).map((n, i) => (
            <div key={"n" + i} className="bh-judge" style={{ background: "var(--act)", borderColor: "var(--act-line)" }}>
              <div className="bh-verdict"><b style={{ color: "var(--act-ink)" }}>Understandable, not quite natural</b></div>
              <div className="bh-block"><div className="bh-blab">You said</div><div className="bh-sent">{n.mine}</div></div>
              <div className="bh-block"><div className="bh-blab">A native would say</div><div className="bh-sent">{n.better}</div></div>
              {n.why && <div className="bh-why"><Note text={n.why} /></div>}
            </div>
          ))}

          {(review.slips || []).length === 0 && (review.nearly || []).length === 0 && (review.good || []).length === 0 && (
            <div className="bh-judge bh-right"><div className="bh-verdict"><b>Nothing to fix</b></div></div>
          )}
          {(review.slips || []).map((f, i) => (
            <div key={i} className="bh-judge bh-wrong">
              <div className="bh-block"><div className="bh-blab">You said</div><div className="bh-sent" style={{ color: "var(--muted)", textDecoration: "line-through" }}>{f.mine}</div></div>
              <div className="bh-block"><div className="bh-blab">Better</div><div className="bh-sent">{f.correct}</div></div>
              {f.note && <div className="bh-why"><Note text={f.note} /></div>}
            </div>
          ))}
          {review.pattern && <div className="bh-panel"><div className="bh-plabel">Work on this</div><div>{undash(review.pattern)}</div></div>}
          <button className="bh-go" style={{ width: "100%" }} onClick={back}>New scene</button>
        </div>
      )}
    </div>
  );
}

const STAGE_INFO = {
  fresh: {
    label: "Never seen",
    text: "Hasn't come up yet. The first time it does, its very first pace gets set from how well you get it then.",
  },
  settling: {
    label: "Settling in",
    text: "Answered right, but not yet on three separate days. Comes back every day or two on purpose, one correct day at a time, until it's earned a longer break.",
  },
  holding: {
    label: "Come back in days",
    text: "Past the settling-in stage. Next review lands somewhere between a few days and three weeks out, and grows a bit further each time you get it right.",
  },
  solid: {
    label: "Come back in weeks",
    text: "The gap has grown past three weeks, sometimes months. These are the ones you've properly got, only checked in on now and then.",
  },
};

// A small, honest line chart: one point per day of data.history's recall
// average. Single hue, no numeric y-axis -- the "words mastered" number
// next to it anchors the story, hover/drag reveals exact values, and
// "Show as list" gives a plain-table equivalent for anyone who'd rather
// not read a chart (or can't see color well).
function HistoryChart({ points }) {
  const [hover, setHover] = useState(null);
  const [table, setTable] = useState(false);
  const W = 300, H = 90, PAD = 7;
  const n = points.length;
  const xAt = (i) => (n <= 1 ? W / 2 : (i / (n - 1)) * W);
  const yAt = (r) => PAD + (1 - Math.max(0, Math.min(1, r))) * (H - PAD * 2);
  const path = points.map((p, i) => `${i ? "L" : "M"}${xAt(i).toFixed(1)},${yAt(p.r).toFixed(1)}`).join(" ");
  const area = `${path} L${xAt(n - 1).toFixed(1)},${H} L${xAt(0).toFixed(1)},${H} Z`;

  const onMove = (e) => {
    const rect = e.currentTarget.getBoundingClientRect();
    const cx = e.touches ? e.touches[0].clientX : e.clientX;
    const x = (cx - rect.left) / Math.max(1, rect.width);
    setHover(Math.max(0, Math.min(n - 1, Math.round(x * (n - 1)))));
  };

  if (table) {
    return (
      <div>
        {points.slice().reverse().map((p, i) => (
          <div key={i} className="bh-row">
            <span>{fmtHistDate(p.d)}</span>
            <span className="bh-count">{Math.round(p.r * 100)}%</span>
          </div>
        ))}
        <button type="button" className="bh-histtoggle" onClick={() => setTable(false)}>Show as chart</button>
      </div>
    );
  }

  const idx = hover != null ? hover : n - 1;
  const shown = points[idx];
  const tipPct = Math.max(8, Math.min(92, (xAt(idx) / W) * 100));
  return (
    <div>
      <div
        className="bh-histwrap"
        onMouseMove={onMove} onMouseLeave={() => setHover(null)}
        onTouchStart={onMove} onTouchMove={onMove} onTouchEnd={() => setHover(null)}
      >
        <svg className="bh-histsvg" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-hidden="true">
          <defs>
            <linearGradient id="bh-hist-fill" x1="0" y1="0" x2="0" y2="1">
              <stop offset="0%" stopColor="var(--solid)" stopOpacity="0.22" />
              <stop offset="100%" stopColor="var(--solid)" stopOpacity="0" />
            </linearGradient>
          </defs>
          <path d={area} fill="url(#bh-hist-fill)" stroke="none" />
          <path d={path} fill="none" stroke="var(--solid)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
          {hover != null && <line x1={xAt(hover)} x2={xAt(hover)} y1={PAD} y2={H} stroke="var(--hair)" strokeWidth="1" />}
          <circle cx={xAt(idx)} cy={yAt(shown.r)} r={hover != null ? 5 : 4} fill="var(--solid)" stroke="var(--paper)" strokeWidth="2" />
        </svg>
        <div className="bh-histtip" style={{ left: `${tipPct}%` }}>
          <b>{Math.round(shown.r * 100)}%</b><span>{fmtHistDate(shown.d)}</span>
        </div>
      </div>
      <div className="bh-fclab" style={{ marginTop: 6 }}>
        <span>{fmtHistDate(points[0].d)}</span>
        <span>{fmtHistDate(points[n - 1].d)}</span>
      </div>
      <button type="button" className="bh-histtoggle" onClick={() => setTable(true)}>Show as list</button>
    </div>
  );
}

function Memory({ data, vocab, weakWords, weakest, back, persist, go }) {
  const now = Date.now();
  const [stageSel, setStageSel] = useState(null);
  const toggleStage = (tone) => setStageSel((cur) => (cur === tone ? null : tone));


  const retention = (() => {
    const keys = Object.keys(data.mem);
    if (!keys.length) return 0;
    return keys.reduce((a, k) => a + nowR(data.mem[k], now), 0) / keys.length;
  })();

  const stages = (() => {
    const s = { fresh: 0, settling: 0, holding: 0, solid: 0 };
    const seen = new Set();
    vocab.forEach((c) => {
      const st = data.mem[c.id + "|en2id"] || data.mem[c.id + "|id2en"];
      seen.add(c.id);
      if (!st) { s.fresh++; return; }
      const iv = schedInt(st, data.want);
      if ((st.cr || 0) < 3) s.settling++;
      else if (iv < 21) s.holding++;
      else s.solid++;
    });
    return s;
  })();

  const activity = (() => {
    const out = [];
    const l = data.log || {};
    for (let i = 13; i >= 0; i--) {
      const d = new Date(); d.setDate(d.getDate() - i);
      const day = l[localDay(d)] || {};
      out.push((day.drill || 0) + (day.sentences || 0) + (day.roleplay || 0));
    }
    return out;
  })();
  const weekAnswers = activity.slice(-7).reduce((a, b) => a + b, 0);
  const hintsWeek = (() => {
    const l = data.log || {};
    let n = 0;
    for (let i = 0; i < 7; i++) { const d = new Date(); d.setDate(d.getDate() - i); n += (l[localDay(d)] || {}).hints || 0; }
    return n;
  })();
  const dueNow = Object.keys(data.mem).filter((k) => dueIn(data.mem[k], data.want, now) <= 0).length;

  const window7 = (from, to) => {
    const acc = {};
    const l = data.patLog || {};
    for (let i = from; i < to; i++) {
      const d = new Date(); d.setDate(d.getDate() - i);
      const day = l[localDay(d)] || {};
      Object.entries(day).forEach(([k, v]) => {
        const e = acc[k] || { c: 0, w: 0 };
        e.c += v.c || 0; e.w += v.w || 0;
        acc[k] = e;
      });
    }
    return acc;
  };
  const recent = window7(0, 7), earlier = window7(7, 14);

  const pats = Object.entries(data.pat)
    .map(([k, v]) => {
      const n = (v.c || 0) + (v.w || 0);
      const r = recent[k], e = earlier[k];
      const rRate = r && r.c + r.w >= 3 ? r.w / (r.c + r.w) : null;
      const eRate = e && e.c + e.w >= 3 ? e.w / (e.c + e.w) : null;
      const trend = rRate !== null && eRate !== null ? rRate - eRate : null;
      return { k, ...v, n, rate: (v.w || 0) / Math.max(1, n), thin: n < 5, trend };
    })
    .filter((p) => p.n >= 2)
    .sort((a, b) => (a.thin - b.thin) || (b.w - a.w) || (b.rate - a.rate));

  const hardest = Object.entries(data.mem)
    .map(([k, v]) => ({ k: k.split("|")[0], dir: k.split("|")[1], ...v }))
    .filter((h) => (h.l || 0) > 0 || (h.d || 0) >= 6)
    .sort((a, b) => (b.l || 0) - (a.l || 0) || (b.d || 0) - (a.d || 0))
    .slice(0, 8);


  return (
    <div>
      <Back on={back} />
      <h2 className="bh-title serif">Memory</h2>
      <Rule />
      <div className="bh-lede">
        {vocab.length} cards in your deck, {Object.keys(data.mem).length} of their directions being scheduled.
      </div>

      <div className="bh-panel">
        <div className="bh-plabel">How far along your {vocab.length} cards are</div>
        {/* One glance at where the whole deck stands: a stacked bar, left
            to right, from never touched to fully graduated. Same four
            stages and colors as the tiles below, just as a proportion of
            the deck instead of raw counts. Hover (mouse) or tap (touch)
            either the bar segment or its tile to see what that stage
            means and when its cards come back. */}
        <div className="bh-stagebar">
          {["fresh", "settling", "holding", "solid"].map((tone) => (
            <i
              key={tone}
              data-tone={tone}
              data-active={stageSel === tone ? "1" : undefined}
              style={{ width: `${(stages[tone] / Math.max(1, vocab.length)) * 100}%` }}
              onMouseEnter={() => setStageSel(tone)}
              onMouseLeave={() => setStageSel((cur) => (cur === tone ? null : cur))}
              onClick={() => toggleStage(tone)}
            />
          ))}
        </div>
        <div className="bh-stattiles">
          <button
            type="button"
            className="bh-stattile"
            data-tone="fresh"
            data-active={stageSel === "fresh" ? "1" : undefined}
            onMouseEnter={() => setStageSel("fresh")}
            onMouseLeave={() => setStageSel((cur) => (cur === "fresh" ? null : cur))}
            onClick={() => toggleStage("fresh")}
          >
            <b className="serif">{stages.fresh}</b><span>never seen</span>
          </button>
          <button
            type="button"
            className="bh-stattile"
            data-tone="settling"
            data-active={stageSel === "settling" ? "1" : undefined}
            onMouseEnter={() => setStageSel("settling")}
            onMouseLeave={() => setStageSel((cur) => (cur === "settling" ? null : cur))}
            onClick={() => toggleStage("settling")}
          >
            <b className="serif">{stages.settling}</b><span>settling in</span>
          </button>
          <button
            type="button"
            className="bh-stattile"
            data-tone="holding"
            data-active={stageSel === "holding" ? "1" : undefined}
            onMouseEnter={() => setStageSel("holding")}
            onMouseLeave={() => setStageSel((cur) => (cur === "holding" ? null : cur))}
            onClick={() => toggleStage("holding")}
          >
            <b className="serif">{stages.holding}</b><span>come back in days</span>
          </button>
          <button
            type="button"
            className="bh-stattile"
            data-tone="solid"
            data-active={stageSel === "solid" ? "1" : undefined}
            onMouseEnter={() => setStageSel("solid")}
            onMouseLeave={() => setStageSel((cur) => (cur === "solid" ? null : cur))}
            onClick={() => toggleStage("solid")}
          >
            <b className="serif">{stages.solid}</b><span>come back in weeks</span>
          </button>
        </div>
        <div className="bh-note" style={{ marginTop: 14, minHeight: 72 }}>
          {stageSel ? (
            <><b>{STAGE_INFO[stageSel].label}.</b> {STAGE_INFO[stageSel].text}</>
          ) : (
            <>Hover or tap a stage above to see what it means and when those cards come back.</>
          )}
        </div>
      </div>

      {(() => {
        const hist = data.history || [];
        const enough = hist.length >= 3;
        // Same cr>=3 bar as the "holding"+"solid" tiles above -- one
        // number for "mastered", never two that could quietly disagree.
        const masteredNow = stages.holding + stages.solid;
        const first = hist[0];
        if (!enough) {
          return (
            <div className="bh-panel">
              <div className="bh-plabel">Words mastered over time</div>
              <div className="bh-row"><span>Words mastered right now</span><span className="bh-count">{masteredNow}</span></div>
              <div className="bh-note" style={{ marginTop: 8 }}>
                Started tracking today. Check back in a few days to see how this grows.
              </div>
            </div>
          );
        }
        const rNow = retention, rThen = first.r;
        const steady = Math.abs(rNow - rThen) < 0.05;
        return (
          <div className="bh-panel">
            <div className="bh-plabel">Words mastered over time</div>
            <div className="bh-histnow">
              <b className="serif">{masteredNow}</b>
              <span>
                words mastered
                {masteredNow - first.m !== 0 && (
                  <> · <span className="bh-trend" data-dir={masteredNow - first.m > 0 ? "up" : "down"}>
                    {masteredNow - first.m > 0 ? "+" : ""}{masteredNow - first.m} since {fmtHistDate(first.d)}
                  </span></>
                )}
              </span>
            </div>
            <HistoryChart points={hist} />
            <div className="bh-note" style={{ marginTop: 12 }}>
              {steady
                ? <>And your recall held steady around {Math.round(rNow * 100)}% the whole time, so this growth isn't coming at the cost of forgetting older words.</>
                : rNow > rThen
                ? <>Your recall climbed too, from {Math.round(rThen * 100)}% to {Math.round(rNow * 100)}%, you're keeping up with reviews better than when tracking started.</>
                : <>Your recall dipped from {Math.round(rThen * 100)}% to {Math.round(rNow * 100)}% while that grew, a sign reviews might be piling up faster than you're clearing them.</>}
            </div>
            {weakest && first.w && weakest !== first.w && (
              <div className="bh-note" style={{ marginTop: 8 }}>
                Your weakest pattern has moved on since tracking started: it was <b>{first.w}</b>, now it's <b>{weakest}</b>.
              </div>
            )}
          </div>
        );
      })()}

      <div className="bh-panel">
        <div className="bh-plabel">Last two weeks</div>
        <div className="bh-fc">
          {activity.map((n, i) => (
            <div key={i} style={{ height: `${Math.max(4, (n / Math.max(1, ...activity)) * 100)}%`,
              background: n ? "var(--solid)" : "var(--hair)" }} title={`${n} answers`} />
          ))}
        </div>
        <div className="bh-fclab"><span>14 days ago</span><span style={{ textAlign: "right" }}>today</span></div>
        <div className="bh-row" style={{ marginTop: 12 }}>
          <span>Answers this week</span><span className="bh-count">{weekAnswers}</span>
        </div>
        <div className="bh-row">
          <span>Days practised</span><span className="bh-count">{data.days.length}</span>
        </div>
        <div className="bh-row">
          <span>Due right now</span><span className="bh-count">{dueNow}</span>
        </div>
        <div className="bh-row">
          <span>Hints used this week</span><span className="bh-count">{hintsWeek}</span>
        </div>
      </div>

      {pats.length > 0 && (
        <div className="bh-panel">
          <div className="bh-plabel">Patterns you miss most</div>
          {pats[0] && !pats[0].thin && (
            <div className="bh-diag" style={{ margin: "0 0 14px" }}>
              Your most expensive pattern is <b>{pats[0].k}</b>, {pats[0].w} mistakes out of {pats[0].n} tries.
            </div>
          )}
          {pats.map((p) => (
            <button key={p.k} className="bh-prow" data-thin={p.thin ? 1 : 0} onClick={() => go("lesson", p.k)}>
              <span className="bh-pname">
                {p.k}
                {p.trend !== null && Math.abs(p.trend) > 0.08 && (
                  <span className="bh-trend" data-dir={p.trend < 0 ? "up" : "down"}>
                    {p.trend < 0 ? "improving" : "getting worse"}
                  </span>
                )}
              </span>
              <span className="bh-pnum">
                {p.thin ? <span className="bh-thin">too few tries</span>
                  : <><b>{Math.round(p.rate * 100)}%</b> wrong <span className="bh-pnum-sub">({p.w} of {p.n})</span></>}
              </span>
              <div className="bh-meter"><i style={{ width: `${Math.round(p.rate * 100)}%` }} /></div>
            </button>
          ))}
          <div className="bh-note" style={{ marginTop: 12 }}>
            Ranked by how many mistakes each one actually cost you. The bold number is how often it goes wrong when it comes up. Tap one for a short lesson.
          </div>
        </div>
      )}

      {(weakWords || []).length > 0 && (
        <div className="bh-panel">
          <div className="bh-plabel">Words you drop inside sentences</div>
          <div className="bh-chips">
            {(() => {
              const maxW = Math.max(...weakWords.map((w) => w.w));
              return weakWords.map((w) => (
                <span key={w.k} className="bh-chip"
                  style={{ background: "var(--no-bg)", color: "var(--no)", opacity: 0.55 + 0.45 * (w.w / maxW) }}>
                  {showWord(w.k, data.register)} · {w.w}x
                </span>
              ));
            })()}
          </div>
          <div className="bh-note" style={{ marginTop: 14 }}>
            These come round more often in the drill now, and the sentence forge builds around them.
          </div>
        </div>
      )}

      {hardest.length > 0 && (
        <div className="bh-panel">
          <div className="bh-plabel">Hardest items right now</div>
          {hardest.map((h, i) => {
            const left = dueIn(h, data.want, now);
            return (
              <div key={i} className="bh-row">
                <span style={{ flex: 1, minWidth: 0, paddingRight: 10 }}>{h.k}</span>
                <span className="bh-count" style={{ minWidth: "auto" }}>
                  {h.l > 0 ? `wrong ${h.l}x` : "tricky"}
                  <span style={{ display: "block", color: "var(--faint)", fontWeight: 400 }}>
                    {left <= 0 ? "due now" : `back in ${showDelay(left)}`}
                  </span>
                </span>
              </div>
            );
          })}
          <div className="bh-note" style={{ marginTop: 12 }}>
            Sorted by how often you've actually gotten these wrong.
          </div>
        </div>
      )}

    </div>
  );
}

function AccountPanel({ user, onLogout }) {
  const [resetUser, setResetUser] = useState("");
  const [resetPass, setResetPass] = useState("");
  const [resetMsg, setResetMsg] = useState("");
  const [resetBusy, setResetBusy] = useState(false);

  const doReset = async () => {
    if (!resetUser.trim() || resetPass.length < 6) {
      setResetMsg("Enter a username and a new password (at least 6 characters).");
      return;
    }
    setResetBusy(true);
    setResetMsg("");
    try {
      const r = await fetch("/api/auth/reset", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ username: resetUser.trim(), newPassword: resetPass }),
      });
      const d = await r.json();
      if (!r.ok) throw new Error((d && d.error) || "Failed.");
      setResetMsg(`Password for ${resetUser.trim()} has been set.`);
      setResetPass("");
    } catch (e) {
      setResetMsg((e && e.message) || "Failed.");
    } finally {
      setResetBusy(false);
    }
  };

  return (
    <div className="bh-panel">
      <div className="bh-plabel">Account</div>
      <div className="bh-row"><span>Logged in as</span><span className="bh-count">{(user && user.username) || "–"}</span></div>
      {user && user.isAdmin && (
        <div className="bh-note" style={{ marginTop: 6 }}>Admin account, no daily limit on requests or conversations.</div>
      )}
      <div className="bh-acts" style={{ marginTop: 12 }}>
        <button className="bh-alt" style={{ flex: 1 }} onClick={onLogout}>Log out</button>
      </div>

      {user && user.isAdmin && (
        <div style={{ marginTop: 18, paddingTop: 14, borderTop: "1px solid var(--line)" }}>
          <div className="bh-plabel">Reset someone else's password</div>
          <input
            className="bh-field"
            style={{ marginBottom: 8 }}
            placeholder="Username"
            value={resetUser}
            onChange={(e) => setResetUser(e.target.value)}
          />
          <input
            className="bh-field"
            type="password"
            placeholder="New password"
            value={resetPass}
            onChange={(e) => setResetPass(e.target.value)}
          />
          <div className="bh-acts" style={{ marginTop: 10 }}>
            <button className="bh-go" style={{ flex: 1 }} disabled={resetBusy} onClick={doReset}>Reset password</button>
          </div>
          {resetMsg && <div className="bh-note" style={{ marginTop: 8 }}>{resetMsg}</div>}
        </div>
      )}
    </div>
  );
}

function Settings({ data, vocab, persist, back, user, onLogout }) {
  return (
    <div>
      <Back on={back} />
      <h2 className="bh-title serif">Settings</h2>
      <Rule />
      <div className="bh-lede">How the app behaves and how your progress is kept.</div>

      <AccountPanel user={user} onLogout={onLogout} />

      <div className="bh-panel">
        <div className="bh-plabel">Appearance</div>
        <div className="bh-switch" style={{ marginBottom: 10, gridTemplateColumns: "1fr 1fr 1fr" }}>
          <button data-on={data.theme === "auto" ? 1 : 0} onClick={() => persist({ ...data, theme: "auto" })}>Auto</button>
          <button data-on={!data.theme || data.theme === "light" ? 1 : 0} onClick={() => persist({ ...data, theme: "light" })}>Light</button>
          <button data-on={data.theme === "dark" ? 1 : 0} onClick={() => persist({ ...data, theme: "dark" })}>Dark</button>
        </div>
        <div className="bh-note">Light is the default. Auto follows your phone instead.</div>
      </div>

      <div className="bh-panel">
        <div className="bh-plabel">How much reading practice</div>
        <input className="bh-slider" type="range" min="0" max="0.5" step="0.05" value={data.recog ?? 0.15}
          onChange={(e) => persist({ ...data, recog: parseFloat(e.target.value) })} />
        <div className="bh-note">
          {Math.round((data.recog ?? 0.15) * 100)}% of each round is Indonesian to English. The rest is production, which is what
          carries over to speaking. Turn this up if you find yourself lost reading messages.
        </div>
      </div>

      <div className="bh-panel">
        <div className="bh-plabel">How you want to sound</div>
        <div className="bh-switch" style={{ marginBottom: 12 }}>
          <button data-on={(data.register || "casual") === "casual" ? 1 : 0} onClick={() => persist({ ...data, register: "casual" })}>Casual</button>
          <button data-on={data.register === "polite" ? 1 : 0} onClick={() => persist({ ...data, register: "polite" })}>Polite</button>
        </div>
        <div className="bh-note">
          Casual gives you aku, kamu, gak, udah, gimana, which is how you would talk to your partner and her friends.
          Polite gives you saya, Anda, tidak, sudah. Either way both count as correct when you answer, this only decides
          which version the app writes back at you.
        </div>
      </div>


      {data.cal && data.cal.n >= 10 && (
        <div className="bh-panel">
          <div className="bh-plabel">Is the model right about you</div>
          <div className="bh-row"><span>predicted recall</span><span className="bh-count">{Math.round((data.cal.pred / data.cal.n) * 100)}%</span></div>
          <div className="bh-row"><span>what actually happened</span><span className="bh-count">{Math.round((data.cal.hit / data.cal.n) * 100)}%</span></div>
          <div className="bh-note" style={{ marginTop: 12 }}>
            Measured over {data.cal.n} reviews. If the second number sits well below the first, the default parameters are too optimistic
            for you and the target above should come up. Well above, and you are reviewing more often than you need to.
          </div>
          <button className="bh-alt" style={{ marginTop: 12 }}
            onClick={() => { if (confirm("Reset this count and start measuring fresh from here?")) persist({ ...data, cal: { n: 0, pred: 0, hit: 0 } }); }}>
            Reset this count
          </button>
        </div>
      )}

      <div className="bh-panel">
        <div className="bh-plabel">Target recall {Math.round(data.want * 100)}%</div>
        <input className="bh-slider" type="range" min="0.8" max="0.95" step="0.01" value={data.want}
          onChange={(e) => persist({ ...data, want: parseFloat(e.target.value) })} />
        <div className="bh-note">
          Higher means cards come back sooner and you forget less, at the cost of more reviews. Going from 90 to 95 roughly doubles the
          workload for a few points of recall, so 85 to 90 is where most people sit.
        </div>
      </div>

      <div className="bh-panel">
        <div className="bh-plabel">What is running under this</div>
        <div className="bh-note">
          FSRS-6, the scheduler Anki ships by default, with its published parameters. Every item carries a stability and a difficulty,
          and the next review lands when predicted recall drops to the target above. On top of that:
          grades are measured from what you type and how long you took, so there is no self-rating to fool.
          A new item inherits difficulty from cards sharing its words instead of starting blind.
          Every card is tagged with the grammar it uses, and the sentence forge aims at whichever tag you miss most.
          A new item stays on short intervals until you have recalled it correctly on three separate days, which is worth
          far more than three correct answers inside one session. First contact with a card is a guess, not a presentation,
          because guessing wrong and then seeing the answer beats simply being shown it.
          And a missed item returns later in the same session rather than immediately.
          The mix leans towards production: English to Indonesian first, gap-filling inside your own sentences second,
          Indonesian to English last and only once the other direction holds. Producing a word is harder than recognising
          it, and it carries over to recognition, while the reverse barely happens.
        </div>
      </div>

      <Backup data={data} vocab={vocab} persist={persist} />

      <div className="bh-acts">
        <button className="bh-alt" style={{ flex: 1 }} onClick={() => { if (confirm("Delete all progress?")) persist(EMPTY); }}>Reset everything</button>
      </div>
    </div>
  );
}

function Backup({ data, vocab, persist }) {
  const [paste, setPaste] = useState("");
  const [msg, setMsg] = useState("");
  const [copyMsg, setCopyMsg] = useState("");
  const payload = useMemo(() => JSON.stringify({ v: 1, when: Date.now(), vocab, data }), [data, vocab]);

  // Manual tap-select-all-copy on a long single-line blob is unreliable on
  // phone browsers (selection and the copy gesture can grab less than the
  // whole field, silently). The Clipboard API copies the exact string in
  // one call, no selection involved, so it is the primary path here; the
  // textarea and its onFocus select() stay as a fallback for browsers
  // without clipboard permission.
  const copyPayload = async () => {
    setCopyMsg("");
    try {
      if (!navigator.clipboard || !navigator.clipboard.writeText) throw new Error();
      await navigator.clipboard.writeText(payload);
      setCopyMsg("Copied.");
    } catch (e) {
      setCopyMsg("Could not copy automatically. Tap into the box, select all, then copy by hand.");
    }
  };

  const pasteFromClipboard = async () => {
    setMsg("");
    try {
      if (!navigator.clipboard || !navigator.clipboard.readText) throw new Error();
      const t = await navigator.clipboard.readText();
      if (t) setPaste(t);
    } catch (e) {
      setMsg("Could not read the clipboard automatically. Paste into the box by hand.");
    }
  };

  const restore = () => {
    setMsg("");
    try {
      // defensive: strip anything a phone keyboard or copy step left
      // outside the braces, same tolerant read a JSON parser downstream would do
      let raw = paste.trim();
      const start = raw.indexOf("{");
      const end = raw.lastIndexOf("}");
      if (start >= 0 && end >= start) raw = raw.slice(start, end + 1);
      const inc = JSON.parse(raw);
      const incData = inc.data || inc;
      if (!incData || !incData.mem) throw new Error();
      const merged = mergeState(data, incData);
      persist(merged);
      const gained = Object.keys(merged.mem).length - Object.keys(data.mem || {}).length;
      setMsg(`Restored. ${Object.keys(merged.mem).length} memories now${gained > 0 ? `, ${gained} came from the backup` : ", nothing was lost"}.`);
      setPaste("");
    } catch (e) { setMsg("That does not look like a backup. Paste the whole block, from the first brace to the last."); }
  };

  return (
    <div className="bh-panel">
      <div className="bh-plabel">Your progress as text</div>
      <div className="bh-note" style={{ marginBottom: 12 }}>
        Progress belongs to this published app. Before you switch to a new version, tap Copy, then open the new one and
        tap Paste. Restoring merges, it never throws away newer progress. Copy and Paste beat selecting the text by
        hand, especially on a phone.
      </div>
      <textarea className="bh-field" style={{ height: 88, fontSize: 12, lineHeight: 1.4 }} readOnly value={payload}
        onFocus={(e) => e.target.select()} />
      <div className="bh-acts">
        <button className="bh-alt" onClick={copyPayload}>Copy</button>
      </div>
      {copyMsg && <div className="bh-why">{copyMsg}</div>}
      <div className="bh-plabel" style={{ marginTop: 18 }}>Restore from text</div>
      <textarea className="bh-field" style={{ height: 88, fontSize: 12 }} placeholder="paste a backup here"
        value={paste} onChange={(e) => setPaste(e.target.value)} />
      <div className="bh-acts">
        <button className="bh-alt" onClick={pasteFromClipboard}>Paste</button>
        <button className="bh-go" onClick={restore} disabled={!paste.trim()}>Restore and merge</button>
      </div>
      {msg && <div className="bh-why">{msg}</div>}
    </div>
  );
}

function Library({ data, vocab, update, back }) {
  const [q, setQ] = useState("");
  const [sort, setSort] = useState("worst");
  const [open, setOpen] = useState(null);
  const [draftId, setDraftId] = useState("");
  const [draftEn, setDraftEn] = useState("");
  const [limit, setLimit] = useState(120);
  const now = Date.now();

  const rows = vocab.map((c) => {
    const p = data.mem[c.id + "|en2id"], r = data.mem[c.id + "|id2en"], cl = data.mem[c.id + "|cloze"];
    const st = p || r || cl;
    const wrong = (p?.l || 0) + (r?.l || 0) + (cl?.l || 0);
    const own = (data.mine || []).some((m) => m.id === c.id);
    return { c, st, wrong, own, due: st ? dueIn(st, data.want, now) : 999, cr: st ? st.cr || 0 : -1 };
  });

  const needle = q.trim().toLowerCase();
  const filtered = rows
    .filter((r) => !needle || r.c.id.toLowerCase().includes(needle) || r.c.en.toLowerCase().includes(needle))
    .sort((a, b) =>
      sort === "worst" ? b.wrong - a.wrong || a.due - b.due
        : sort === "due" ? a.due - b.due
        : a.c.id.localeCompare(b.c.id));
  const shown = filtered.slice(0, limit);

  useEffect(() => setLimit(120), [q, sort]);

  const saveEdit = (card) => update((next) => {
    const idText = draftId.trim() || card.id, enText = draftEn.trim() || card.en;
    next.fixes = next.fixes || {};
    next.fixes[card.id.toLowerCase()] = idText;      // survives the next Anki import
    next.mine = (next.mine || []).filter((m) => m.id !== card.id);
    next.mine.unshift({ id: idText, en: enText, s: idText.split(/\s+/).length > 2 ? 1 : 0 });
    if (idText !== card.id) {
      ["en2id", "id2en", "cloze"].forEach((d) => {
        if (next.mem[card.id + "|" + d]) { next.mem[idText + "|" + d] = next.mem[card.id + "|" + d]; delete next.mem[card.id + "|" + d]; }
      });
    }
    setOpen(null);
  });

  const drop = (card) => update((next) => {
    next.mine = (next.mine || []).filter((m) => m.id !== card.id);
    next.dropped = Array.from(new Set([...(next.dropped || []), card.id]));
    ["en2id", "id2en", "cloze"].forEach((d) => delete next.mem[card.id + "|" + d]);
    setOpen(null);
  });

  return (
    <div>
      <Back on={back} />
      <h2 className="bh-title serif">Everything you have</h2>
      <Rule />
      <div className="bh-lede">{vocab.length} cards. Search, sort, fix what is broken.</div>

      <input className="bh-field" placeholder="search Indonesian or English" value={q} onChange={(e) => setQ(e.target.value)} />
      <div className="bh-switch" style={{ gridTemplateColumns: "1fr 1fr 1fr", marginTop: 10 }}>
        <button data-on={sort === "worst" ? 1 : 0} onClick={() => setSort("worst")}>Most missed</button>
        <button data-on={sort === "due" ? 1 : 0} onClick={() => setSort("due")}>Due next</button>
        <button data-on={sort === "az" ? 1 : 0} onClick={() => setSort("az")}>A to Z</button>
      </div>

      {shown.map((r) => (
        <div key={r.c.id} className="bh-miss" style={{ cursor: "pointer" }}
          onClick={() => { setOpen(open === r.c.id ? null : r.c.id); setDraftId(r.c.id); setDraftEn(r.c.en); }}>
          <b>{r.c.id}</b>
          <small>
            {r.c.en}
            {r.cr < 0 ? " · not started" : r.cr < 3 ? ` · settling in, day ${r.cr} of 3` : ` · back in ${showDelay(Math.max(0, r.due))}`}
            {r.wrong > 0 ? ` · missed ${r.wrong}x` : ""}
            {r.own ? " · yours" : ""}
          </small>
          {open === r.c.id && (
            <div onClick={(e) => e.stopPropagation()} style={{ marginTop: 12 }}>
              <input className="bh-field" value={draftId} onChange={(e) => setDraftId(e.target.value)} autoCapitalize="none" />
              <input className="bh-field" style={{ marginTop: 8 }} value={draftEn} onChange={(e) => setDraftEn(e.target.value)} />
              <div className="bh-acts">
                <button className="bh-go" onClick={() => saveEdit(r.c)}>Save</button>
                <button className="bh-alt" onClick={() => { if (confirm("Remove this card?")) drop(r.c); }}>Remove</button>
              </div>
              {(data.alts && data.alts[r.c.id] || []).length > 0 && (
                <div className="bh-note" style={{ marginTop: 10 }}>Also accepted: {(data.alts[r.c.id] || []).join(" · ")}</div>
              )}
            </div>
          )}
        </div>
      ))}
      {shown.length === 0 && <div className="bh-blank">Nothing matches that.</div>}
      {filtered.length > shown.length && (
        <button className="bh-alt" style={{ width: "100%", marginTop: 10 }} onClick={() => setLimit((n) => n + 120)}>
          Show more ({filtered.length - shown.length} left)
        </button>
      )}
    </div>
  );
}

const SRC_LABEL = { drill: "drill", sentences: "sentence forge", roleplay: "roleplay" };

/* When one grammar pattern keeps failing, drilling more of the same is the
   wrong answer. A short explanation with contrasting examples costs two
   minutes and fixes the rule rather than the single card. */
function Lesson({ data, pattern, persist, go, back }) {
  const cached = (data.lessons || {})[pattern];
  const [text, setText] = useState(cached || null);
  const [busy, setBusy] = useState(false);
  const [fail, setFail] = useState("");

  useEffect(() => {
    if (text || busy) return;
    let gone = false;
    (async () => {
      setBusy(true);
      try {
        const t = await ask(
          "You teach one Indonesian grammar point to a German learner at A2 level. Plain English, concrete, no preamble, no dashes of any kind. Reply with JSON only.",
          [{ role: "user", content:
            `He keeps getting the pattern "${pattern}" wrong. ` +
            (data.register === "polite" ? "He learns standard polite Indonesian. " : "He learns casual spoken Indonesian: aku, kamu, gak, udah. ") +
            'Give a two minute lesson. JSON: {"rule":"the rule in two short sentences","pairs":[{"right":"correct Indonesian","wrong":"a plausible wrong version","why":"one line"}],"watch":"the one thing to watch out for, one sentence"} with three pairs.' }]
        );
        const j = parseJSON(t);
        if (gone) return;
        if (!j || !j.rule) throw new Error();
        setText(j);
        const next = JSON.parse(JSON.stringify(data));
        next.lessons = { ...(next.lessons || {}), [pattern]: j };
        persist(next);
      } catch (e) { if (!gone) setFail("The lesson did not come through. Try again."); }
      if (!gone) setBusy(false);
    })();
    return () => { gone = true; };
  }, []);

  return (
    <div>
      <Back to="Catch up" on={back} />
      <h2 className="bh-title serif">{pattern}</h2>
      <Rule />
      <div className="bh-lede">This is the pattern you miss most. Two minutes on the rule, then straight into practice.</div>

      {busy && !text && <div className="bh-blank"><span className="bh-load">writing your lesson</span></div>}
      {fail && <div className="bh-fail">{fail}</div>}

      {text && (
        <div>
          <div className="bh-panel">
            <div className="bh-plabel">The rule</div>
            <div className="bh-note" style={{ fontSize: 15.5, color: "var(--ink)" }}><Note text={text.rule} /></div>
          </div>

          {(text.pairs || []).map((p, i) => (
            <div key={i} className="bh-wcard">
              <div className="bh-wline"><span className="bh-wtag bh-wtagyes">yes</span><span className="bh-sent">{p.right}</span></div>
              <div className="bh-wline" style={{ marginTop: 8 }}>
                <span className="bh-wtag bh-wtagno">no</span>
                <span className="bh-sent" style={{ color: "var(--muted)", textDecoration: "line-through" }}>{p.wrong}</span>
              </div>
              {p.why && <div className="bh-why"><Note text={p.why} /></div>}
            </div>
          ))}

          {text.watch && (
            <div className="bh-judge" style={{ background: "var(--act)", borderColor: "var(--act-line)" }}>
              <div className="bh-verdict"><b style={{ color: "var(--act-ink)" }}>Watch out for</b></div>
              <div className="bh-sent" style={{ fontSize: 17 }}><Note text={text.watch} /></div>
            </div>
          )}

          <button className="bh-go" style={{ width: "100%", marginTop: 10 }} onClick={() => go("sentences")}>
            Practise it now
          </button>
        </div>
      )}
    </div>
  );
}

function Weekly({ data, vocab, weakWords, weakest, go, back }) {
  const items = (weakWords || []).map((w) => {
    const rec = (data.words || {})[w.k] || {};
    const card = vocab.find((c) => canonTokens(c.id).map(canonWord).includes(w.k));
    const shown = card ? (canonTokens(card.id).find((t) => canonWord(t) === w.k) || showWord(w.k, data.register)) : showWord(w.k, data.register);
    return { ...w, shown, card, last: rec.last, from: rec.from || {}, right: rec.c || 0 };
  });

  return (
    <div>
      <Back on={back} />
      <h2 className="bh-title serif">Catch up</h2>
      <Rule />
      <div className="bh-lede">
        {items.length ? `${items.length} words keep slipping. Read them, then test yourself straight after.`
          : "Nothing has piled up yet."}
      </div>

      {weakest && (data.pat || {})[weakest] && ((data.pat[weakest].w || 0) + (data.pat[weakest].c || 0)) >= 6 &&
        (data.pat[weakest].w / Math.max(1, data.pat[weakest].w + data.pat[weakest].c)) > 0.4 && (
        <button className="bh-lessoncard" onClick={() => { go("lesson"); }}>
          <span className="bh-lessontop">
            <span className="bh-lessonico"><Icon name="target" size={22} /></span>
            <span className="bh-lessonlab">Keeps tripping you up</span>
            <span className="bh-lessonpct">{Math.round((data.pat[weakest].w / (data.pat[weakest].w + data.pat[weakest].c)) * 100)}%</span>
          </span>
          <span className="bh-lessonname">{weakest}</span>
          <span className="bh-lessonmeta">Two minutes on the rule beats drilling the same card again.</span>
          <span className="bh-lessongo">Take the lesson <Icon name="right" size={17} /></span>
        </button>
      )}

      {items.length === 0 && (
        <div className="bh-blank">Come back after a few more rounds. Words land here once you have dropped them twice.</div>
      )}

      {items.map((it) => (
        <div key={it.k} className="bh-wcard">
          <div className="bh-whead">
            <span className="bh-wword">{it.shown}</span>
            <span className="bh-wcount">{(it.w || 0) > 0 && `missed ${it.w}\u00d7`}{(it.w || 0) > 0 && (it.o || 0) > 0 && " · "}{(it.o || 0) > 0 && `misplaced ${it.o}\u00d7`}</span>
          </div>
          {it.card && <div className="bh-wmean">{it.card.en}</div>}
          <div className="bh-wkind" data-kind={(it.o || 0) > (it.w || 0) ? "order" : "word"}>
            {(it.o || 0) > (it.w || 0)
              ? "You know this word, it keeps landing in the wrong place"
              : (it.o || 0) > 0 ? "Sometimes missing, sometimes misplaced" : "This one does not come to you yet"}
          </div>

          {it.last && it.last.correct && (
            <div className="bh-wpair">
              {it.last.mine && (
                <div className="bh-wline">
                  <span className="bh-wtag bh-wtagno">you</span>
                  <span className="bh-sent"><Diff parts={diffWords(it.last.mine, it.last.correct).left} tone="mine" /></span>
                </div>
              )}
              <div className="bh-wline">
                <span className="bh-wtag bh-wtagyes">fix</span>
                <span className="bh-sent"><Diff parts={diffWords(it.last.mine || "", it.last.correct).right} tone="fix" /></span>
              </div>
            </div>
          )}

          {it.last && aboutWord(it.last.note, it.k) && (
            <div className="bh-why"><Note text={aboutWord(it.last.note, it.k)} /></div>
          )}

          <div className="bh-chips" style={{ marginTop: 12 }}>
            {Object.keys(it.from).map((k) => (
              <span key={k} className="bh-chip">{it.from[k]}&times; {SRC_LABEL[k] || k}</span>
            ))}
            {it.last && it.last.tag && <span className="bh-chip">pattern: {it.last.tag}</span>}
            {it.right > 0 && <span className="bh-chip">{it.right} right since</span>}
          </div>
        </div>
      ))}

      {items.length > 0 && (
        <button className="bh-go" style={{ width: "100%", marginTop: 10 }} onClick={() => go("catchup")}>
          Test me on these {items.length}
        </button>
      )}
    </div>
  );
}

function Deck({ vocab, deck, saveVocab, data, persist, addCard, back }) {
  const ref = useRef(null);
  const [done, setDone] = useState(null);
  const [fail, setFail] = useState("");
  const [id, setId] = useState("");
  const [en, setEn] = useState("");
  const [busy, setBusy] = useState(false);
  const [said, setSaid] = useState("");
  const [bulk, setBulk] = useState("");
  const [bulkBusy, setBulkBusy] = useState(false);
  const [bulkOut, setBulkOut] = useState(null);

  const addBulk = async () => {
    if (!bulk.trim()) return;
    setBulkBusy(true); setBulkOut(null);
    try {
      const t = await ask(
        "You turn a learner's raw notebook lines into Indonesian flashcards. Reply with JSON only, no preamble.",
        [{ role: "user", content:
          "Lines from his notebook, one item per line, in any mix of Indonesian, English and German, sometimes with a translation, sometimes without:\n\n" + bulk.trim() +
          "\n\n" + (data.register === "polite" ? "He wants standard polite Indonesian." : "He learns casual spoken Indonesian: aku, kamu, gak, udah.") +
          " For each line produce one card with an Indonesian side and an English side. Fill in whatever is missing, translate German into English, correct spelling mistakes in the Indonesian and mention them in note. Keep ___ blanks and bracketed hints as written. Skip lines that are not vocabulary." +
          " JSON: {\"cards\":[{\"id\":\"Indonesian\",\"en\":\"English\",\"note\":\"short or empty\"}]}" }]
      );
      const j = parseJSON(t);
      const cards = (j && j.cards) || [];
      cards.forEach((c) => { if (c.id && c.en) addCard({ id: c.id, en: c.en, s: c.id.split(/\s+/).length > 2 ? 1 : 0 }); });
      setBulkOut(cards);
      if (cards.length) setBulk("");
    } catch (e) { setBulkOut([]); }
    setBulkBusy(false);
  };

  const add = async () => {
    if (!id.trim() && !en.trim()) return;
    setBusy(true); setSaid("");
    let idText = id.trim(), enText = en.trim(), note = "";
    try {
      const t = await ask(
        "You maintain an Indonesian learner's flashcard deck. Reply with JSON only, no preamble.",
        [{ role: "user", content:
          `New card. Indonesian side: "${idText || "(empty)"}". English side: "${enText || "(empty)"}".\n\n` +
          (data.register === "polite" ? "He wants standard polite Indonesian. " : "He learns casual spoken Indonesian for talking with his Indonesian partner: aku, kamu, gak, udah. ") +
          "Fill in whichever side is empty. If the Indonesian he wrote has a mistake, correct it and say so in one short line. " +
          "Keep any ___ blanks and any hint in brackets exactly as he wrote them. " +
          "JSON: {\"id\":\"Indonesian\",\"en\":\"English\",\"note\":\"one short line, empty string if nothing to say\"}" }]
      );
      const j = parseJSON(t);
      if (j && j.id && j.en) { idText = j.id; enText = j.en; note = j.note || ""; }
    } catch (e) {}
    if (!idText || !enText) { setSaid("Could not complete that one, fill both sides yourself."); setBusy(false); return; }
    addCard({ id: idText, en: enText, s: idText.split(/\s+/).length > 2 ? 1 : 0 });
    setSaid(`Added: ${idText} · ${enText}${note ? ". " + undash(note) : ""}`);
    setId(""); setEn("");
    setBusy(false);
  };

  const handle = (file) => {
    if (!file) return;
    setFail(""); setDone(null);
    const r = new FileReader();
    r.onload = () => {
      const parsed = parseExport(String(r.result || ""));
      if (parsed.length < 5) { setFail("No cards found in there. The export needs two columns, Indonesian and English."); return; }
      const rec = reconcile(parsed, vocab, data);
      const old = new Set(vocab.map((v) => v.id.toLowerCase()));
      const fresh = rec.cards.filter((p) => !old.has(p.id.toLowerCase()));
      saveVocab(rec.cards);
      persist({ ...data, mem: rec.mem, hooks: rec.hooks, alts: rec.alts });
      setDone({ total: rec.cards.length, fresh, fixed: rec.fixed, moved: rec.moved, dropped: rec.dropped });
    };
    r.onerror = () => setFail("That file could not be read.");
    r.readAsText(file, "utf-8");
  };

  return (
    <div>
      <Back on={back} />
      <h2 className="bh-title serif">Your deck</h2>
      <Rule />
      <div className="bh-lede">
        In AnkiDroid: deck menu, Export, Notes in Plain Text. Import as often as you like.
        Nothing is deleted: your scheduling, your word history and your error log all survive, and the typo fixes
        we made are re-applied every time so a fresh export cannot drag the broken cards back in.
      </div>

      <input ref={ref} type="file" accept=".txt,.csv,.tsv,text/plain" style={{ display: "none" }}
        onChange={(e) => handle(e.target.files && e.target.files[0])} />

      <div className="bh-panel">
        <div className="bh-plabel">Add a card yourself</div>
        <div className="bh-note" style={{ marginBottom: 12 }}>
          Fill one side or both, I complete the rest and correct the Indonesian if it needs it.
          Use ___ for a slot you fill in freely, and put a note to yourself in brackets. Neither is ever tested:
          "Asal saya dari ___" with "I am from ___ (country)".
        </div>
        <input className="bh-field" value={id} placeholder="Indonesian" autoCapitalize="none" autoCorrect="off"
          onChange={(e) => setId(e.target.value)} />
        <input className="bh-field" style={{ marginTop: 8 }} value={en} placeholder="English"
          onChange={(e) => setEn(e.target.value)} onKeyDown={(e) => e.key === "Enter" && add()} />
        <div className="bh-acts">
          <button className="bh-go" onClick={add} disabled={busy || (!id.trim() && !en.trim())}>{busy ? "adding" : "Add card"}</button>
        </div>
        {said && <div className="bh-why">{said}</div>}
      </div>

      <div className="bh-panel">
        <div className="bh-plabel">Paste a page from your notebook</div>
        <div className="bh-note" style={{ marginBottom: 12 }}>
          One item per line, however you wrote it down. Indonesian only, Indonesian with a translation, or just the German word.
          I sort it into cards, fill in what is missing and fix spelling.
        </div>
        <textarea className="bh-field" style={{ height: 120, fontSize: 15 }} placeholder={"sebelum, before\nmasak\nich bin müde"}
          value={bulk} onChange={(e) => setBulk(e.target.value)} />
        <div className="bh-acts">
          <button className="bh-go" onClick={addBulk} disabled={bulkBusy || !bulk.trim()}>{bulkBusy ? "sorting" : "Make cards"}</button>
        </div>
        {bulkOut && (
          <div className="bh-why">
            {bulkOut.length === 0 ? "Nothing usable in there." : `${bulkOut.length} card${bulkOut.length > 1 ? "s" : ""} added.`}
            {bulkOut.slice(0, 12).map((c, i) => <div key={i} style={{ marginTop: 6 }}><b>{c.id}</b> · {c.en}{c.note ? ` · ${undash(c.note)}` : ""}</div>)}
          </div>
        )}
      </div>

      <div className="bh-stage">
        <div className="bh-ask"><span>Loaded right now</span></div>
        <div className="bh-cue serif">{deck.length} cards</div>
        {(data.mine || []).length > 0 && <div className="bh-gapen">{(data.mine || []).length} of them added here, safe from any import</div>}
      </div>

      <button className="bh-go" style={{ width: "100%" }} onClick={() => ref.current && ref.current.click()}>Choose file</button>
      {fail && <div className="bh-fail">{fail}</div>}

      {done && (
        <div className="bh-judge bh-right" style={{ marginTop: 16 }}>
          <div className="bh-verdict"><b>{done.total} cards in, {done.fresh.length} new</b></div>
          <div className="bh-note" style={{ marginBottom: 10 }}>
            {done.fixed > 0 && `${done.fixed} known typo${done.fixed > 1 ? "s" : ""} corrected again. `}
            {done.moved > 0 && `${done.moved} memor${done.moved > 1 ? "ies" : "y"} carried over to corrected cards. `}
            {done.dropped > 0 && `${done.dropped} card${done.dropped > 1 ? "s are" : " is"} no longer in your deck, their history is kept in case they come back. `}
            Nothing was overwritten.
          </div>
          {done.fresh.slice(0, 8).map((s, i) => <div key={i} style={{ fontSize: 15, padding: "3px 0" }}>{s.id} · {s.en}</div>)}
        </div>
      )}
    </div>
  );
}

// Accounts: username + self-chosen password, no email required (see
// api/auth/*.js). The session is an httpOnly cookie the browser sends on
// its own with every request; this screen just posts to /api/auth/login
// or /api/auth/signup and hands the resulting user up to Root. There is no
// self-service "forgot password" -- if someone forgets theirs, the admin
// resets it for them from Settings.
// Accounts: username + self-chosen password, no email required (see
// api/auth/*.js). The session is an httpOnly cookie the browser sends on
// its own with every request; this screen just posts to /api/auth/login
// or /api/auth/signup and hands the resulting user up to Root. There is no
// self-service "forgot password" -- if someone forgets theirs, the admin
// resets it for them from Settings.
function AuthScreen({ onAuthed }) {
  const [mode, setMode] = useState("login"); // "login" | "signup"
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");

  const isSignup = mode === "signup";

  const switchMode = (m) => {
    if (m === mode) return;
    setMode(m);
    setErr("");
  };

  const submit = async () => {
    if (!username.trim() || !password || busy) return;
    setBusy(true);
    setErr("");
    try {
      const r = await fetch(`/api/auth/${mode}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ username: username.trim(), password }),
      });
      const d = await r.json();
      if (!r.ok) throw new Error((d && d.error) || "Something went wrong.");
      onAuthed({ username: d.username, isAdmin: !!d.isAdmin });
    } catch (e) {
      setErr((e && e.message) || "Something went wrong.");
    } finally {
      setBusy(false);
    }
  };

  return (
    <div style={{ maxWidth: 360, width: "100%" }}>
      <div className="bh-logo" style={{ justifyContent: "center", marginBottom: 26 }}>
        <span className="bh-mark"><span /><span /></span>
        <span>bahasa indonesia<i className="bh-dotmark">.</i></span>
      </div>

      <div className="bh-panel" style={{ boxShadow: "0 18px 40px -18px rgba(0,0,0,.25)" }}>
        <div className="bh-switch" style={{ marginBottom: 22 }}>
          <button data-on={!isSignup ? 1 : 0} onClick={() => switchMode("login")}>Log in</button>
          <button data-on={isSignup ? 1 : 0} onClick={() => switchMode("signup")}>Sign up</button>
        </div>

        <h2 className="bh-title serif" style={{ fontSize: 23 }}>
          {isSignup ? "Create your account" : "Welcome back"}
        </h2>
        <Rule />
        <div className="bh-lede" style={{ marginBottom: 20 }}>
          {isSignup
            ? "A small set of starter cards is waiting for you, plus a place to keep everything you learn from here on."
            : "Log in to pick up right where you left off."}
        </div>

        <div className="bh-plabel">Username</div>
        <input
          className="bh-field"
          style={{ marginBottom: 14 }}
          autoFocus
          autoComplete="username"
          value={username}
          onChange={(e) => setUsername(e.target.value)}
          onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
          placeholder="username"
        />
        <div className="bh-plabel">Password</div>
        <input
          className="bh-field"
          type="password"
          autoComplete={isSignup ? "new-password" : "current-password"}
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
          placeholder={isSignup ? "at least 6 characters" : "password"}
        />
        {err && <div className="bh-fail" style={{ marginTop: 12 }}>{err}</div>}
        <div className="bh-acts" style={{ marginTop: 18 }}>
          <button className="bh-go" style={{ width: "100%" }} onClick={submit} disabled={busy || !username.trim() || !password}>
            {busy ? "…" : isSignup ? "Create account" : "Log in"}
          </button>
        </div>
      </div>
    </div>
  );
}

function Root() {
  const [user, setUser] = useState(null);
  const [checking, setChecking] = useState(true);
  const [sysDark, setSysDark] = useState(false);

  useEffect(() => {
    try {
      const mq = window.matchMedia("(prefers-color-scheme: dark)");
      const on = () => setSysDark(mq.matches);
      on();
      mq.addEventListener ? mq.addEventListener("change", on) : mq.addListener(on);
      return () => { mq.removeEventListener ? mq.removeEventListener("change", on) : mq.removeListener(on); };
    } catch (e) {}
  }, []);

  useEffect(() => {
    (async () => {
      try {
        const r = await fetch("/api/auth/me");
        const d = await r.json();
        if (d && d.username) {
          setSessionUsername(d.username);
          setUser({ username: d.username, isAdmin: !!d.isAdmin });
        }
      } catch (e) {}
      setChecking(false);
    })();
  }, []);

  const handleAuthed = (u) => {
    setSessionUsername(u.username);
    setUser(u);
  };

  const handleLogout = async () => {
    try { await fetch("/api/auth/logout", { method: "POST" }); } catch (e) {}
    setSessionUsername(null);
    setUser(null);
  };

  if (user) {
    return <App user={user} onLogout={handleLogout} />;
  }

  // Not logged in yet (or still checking): this branch mounts before App()
  // ever does, so it carries its own copy of the stylesheet -- otherwise
  // the login/signup screen would render with no styling at all.
  return (
    <div className="bh" data-theme={sysDark ? "dark" : "light"} style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: "100vh", padding: 24 }}>
      <style>{CSS}</style>
      {checking ? (
        <div className="bh-note">Loading…</div>
      ) : (
        <AuthScreen onAuthed={handleAuthed} />
      )}
    </div>
  );
}

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<Root />);
