// demo-app.jsx — the password-protected /demo experience.
// Built on the Cloud design's components (PipiPrototype-style architecture)
// instead of the old vanilla-JS Codex structure. Mobile-first.
//
// Wires:
//   • password gate (APP_PASSWORD via /api/login)
//   • SK / EN language toggle (Rodič settings, persisted in localStorage)
//   • real audio playback for the "Pipi a tajomstvo vily" showcase story
//   • voice/typed answer → /api/classify-choice, mapped to branches
//   • dev/transcript strip behind a Rodič toggle (kept — that's testing only,
//     not the child-facing "browser hears you" waveform we removed)

const { useEffect, useState, useCallback, useRef, useMemo } = React;

const DEMO_LANG_KEY = 'pipi.demo.lang';
const DEMO_VOICE_KEY = 'pipi.demo.voice';
const DEMO_DEV_KEY = 'pipi.demo.dev';
const DEMO_KIDS_KEY = 'pipi.demo.kids';
const DEMO_ACTIVE_KID_KEY = 'pipi.demo.activeKid';
const ONBOARDING_KEY = 'pipi.demo.onboarding';

// Unrecognized answers at a split before we stop asking and auto-advance down
// the manifest's fallbackChoiceId. 3 = the child has heard the question three
// times (bridge, then the reAsk and fallback prompts, which both re-state the
// options) and had three turns at the mic.
const MAX_ASK_ATTEMPTS = 3;

// ──────────────────────────────────────────────────────────────
// Supabase — auth + data plane. The browser talks to Supabase
// DIRECTLY (publishable key + RLS); this replaced the legacy
// /papi → Cloudflare Worker calls, which are gone (v49/v50).
// ──────────────────────────────────────────────────────────────
const sb = (typeof window !== 'undefined' && window.supabase && window.PIPI_SUPABASE_URL)
  ? window.supabase.createClient(window.PIPI_SUPABASE_URL, window.PIPI_SUPABASE_KEY)
  : null;

// First-party cookie sync (Safari/iOS standalone PWA). supabase-js keeps
// the session in localStorage, which iOS can evict for home-screen PWAs.
// We mirror the tokens into an httpOnly first-party cookie via
// /api/auth/callback (SameSite=Lax on our own origin) and restore from it
// on boot. Best-effort everywhere — the endpoint doesn't exist on the bare
// local static server and auth must keep working without it.
function syncSessionCookie(session) {
  try {
    fetch('/api/auth/callback', {
      method: 'POST', credentials: 'include',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(session
        ? { access_token: session.access_token, refresh_token: session.refresh_token }
        : { event: 'SIGNED_OUT' }),
    }).catch(() => {});
  } catch {}
}
if (sb) {
  sb.auth.onAuthStateChange((event, session) => {
    if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED' || event === 'USER_UPDATED') {
      sbRestorePromise = Promise.resolve(session || null); // keep sbUser() current
      syncSessionCookie(session);
    }
    if (event === 'SIGNED_OUT') {
      sbRestorePromise = Promise.resolve(null);
      syncSessionCookie(null);
    }
  });
}

// One-shot session restore. Prefers the localStorage session; falls back
// to the first-party cookie (GET /api/auth/callback refreshes the token
// server-side, rotates the cookie and hands us a fresh session to adopt).
let sbRestorePromise = null;
function sbSession() {
  if (!sb) return Promise.resolve(null);
  if (sbRestorePromise) return sbRestorePromise;
  sbRestorePromise = (async () => {
    try {
      const { data } = await sb.auth.getSession();
      if (data && data.session) return data.session;
      const res = await fetch('/api/auth/callback', { credentials: 'include' });
      if (!res.ok) return null;
      const body = await res.json().catch(() => null);
      if (!body || !body.session || !body.session.access_token) return null;
      const { data: adopted } = await sb.auth.setSession({
        access_token: body.session.access_token,
        refresh_token: body.session.refresh_token,
      });
      return (adopted && adopted.session) || null;
    } catch { return null; }
  })();
  return sbRestorePromise;
}

// Current signed-in auth user or null. Never throws.
async function sbUser() {
  const session = await sbSession();
  return (session && session.user) || null;
}

// Anonymized branch-pick log → story_plays. RLS allows anonymous inserts
// (user_id null) so a kid playing pre-account still counts; signed-in
// families log under their own user_id. Fire-and-forget — analytics must
// never block or break playback.
function logStoryPlay(storyId, sceneKey, choiceId, lang) {
  if (!sb) return;
  sbUser()
    .then((user) => sb.from('story_plays').insert({
      user_id: user ? user.id : null,
      story_id: String(storyId || 'unknown'),
      scene_key: String(sceneKey || 'unknown'),
      choice_id: choiceId == null ? null : String(choiceId),
      lang: lang || 'sk',
    }))
    .then((r) => { if (r && r.error) console.warn('story_plays:', r.error.message); })
    .catch(() => {});
}

// Custom-story round telemetry → story_plays, WITH the per-stage timings the
// streaming engine collects (time-to-first-audio, LLM/TTS stage breakdown).
// The `timings` jsonb column lands with migration 0008 — until it's applied
// the insert is retried without the column so telemetry never breaks rounds.
function logCustomRound(storyId, sceneKey, lang, timings) {
  if (!sb) return;
  sbUser()
    .then((user) => {
      const row = {
        user_id: user ? user.id : null,
        story_id: String(storyId || 'custom'),
        scene_key: String(sceneKey || 'custom_round'),
        choice_id: null,
        lang: lang || 'sk',
      };
      if (!timings) return sb.from('story_plays').insert(row);
      return sb.from('story_plays').insert({ ...row, timings }).then((r) => {
        if (r && r.error) return sb.from('story_plays').insert(row); // pre-0008 schema
        return r;
      });
    })
    .then((r) => { if (r && r.error) console.warn('story_plays(custom):', r.error.message); })
    .catch(() => {});
}

// Onboarding answers → the caller's own profiles row (RLS
// profiles_update_own). Replaces POST /papi/api/onboarding. Throws if not
// signed in so callers can keep their silent-catch behavior.
async function syncOnboardingToSupabase(o, complete) {
  const user = await sbUser();
  if (!sb || !user) throw new Error('not signed in');
  const patch = {
    display_name: String(o.display_name || '').trim() || null,
    kid_count: Math.max(1, +o.kid_count || 1),
    kid_ages: (Array.isArray(o.kid_ages) ? o.kid_ages : []).map((a) => +a || 5),
    how_heard: o.how_heard || null,
    how_heard_note: String(o.how_heard_note || '').trim() || null,
  };
  if (complete) patch.onboarding_completed_at = new Date().toISOString();
  const { error } = await sb.from('profiles').update(patch).eq('id', user.id);
  if (error) throw new Error(error.message);
}

// Kid profiles → profiles aggregate (kid_count / kid_ages, what the admin
// overview reads) + one row per child in `kids` (RLS kids_rw_own). Privacy:
// we never store names, so the avatar emoji doubles as kids.name (NOT NULL).
// Replace-all keeps it idempotent; nothing references kids.id yet.
async function syncKidsToSupabase(kids) {
  const user = await sbUser();
  if (!sb || !user) throw new Error('not signed in');
  await sb.from('profiles').update({
    kid_count: kids.length,
    kid_ages: kids.map((k) => +k.age || 5),
  }).eq('id', user.id);
  await sb.from('kids').delete().eq('user_id', user.id);
  if (kids.length) {
    await sb.from('kids').insert(kids.map((k) => ({
      user_id: user.id,
      name: k.avatar || '🐤',
      age: +k.age || 5,
    })));
  }
}

// Privacy-first kid profiles: a child is NEVER identified by name. Each
// profile carries only an age + an animal avatar so a kid can recognize
// "their" face without us ever storing who they are. The avatar palette is
// baby animals (the chick 🐤 is Pipi's own, offered first).
const KID_AVATARS = ['🐤', '🐰', '🦊', '🐻', '🐼', '🐸', '🐱', '🐶', '🦉', '🐢'];

// Build the kid list. Source of truth is `pipi.demo.kids` (rich objects).
// On first run we synthesize it from the onboarding answers (which store
// only kid_count + kid_ages), assigning an avatar per index.
function loadKids() {
  try {
    const raw = JSON.parse(localStorage.getItem(DEMO_KIDS_KEY) || 'null');
    if (Array.isArray(raw) && raw.length) {
      return raw.map((k, i) => ({
        id: k.id || `k${i}`,
        age: Number.isFinite(+k.age) ? +k.age : 5,
        avatar: k.avatar || KID_AVATARS[i % KID_AVATARS.length],
      }));
    }
  } catch {}
  // Fall back to onboarding answers.
  try {
    const o = JSON.parse(localStorage.getItem(ONBOARDING_KEY) || '{}');
    const ages = Array.isArray(o.kid_ages) && o.kid_ages.length ? o.kid_ages : [5];
    return ages.map((age, i) => ({ id: `k${i}`, age: +age || 5, avatar: KID_AVATARS[i % KID_AVATARS.length] }));
  } catch {}
  return [{ id: 'k0', age: 5, avatar: KID_AVATARS[0] }];
}

function loadActiveKidId(kids) {
  try {
    const id = localStorage.getItem(DEMO_ACTIVE_KID_KEY);
    if (id && kids.some((k) => k.id === id)) return id;
  } catch {}
  return kids[0] ? kids[0].id : 'k0';
}

// Persist the kid list locally AND keep the onboarding answers + the server
// profile in sync (so the admin overview still sees an accurate kid_count /
// ages). Best-effort: a failed network call never blocks the UI.
function persistKids(kids) {
  try { localStorage.setItem(DEMO_KIDS_KEY, JSON.stringify(kids)); } catch {}
  try {
    const o = JSON.parse(localStorage.getItem(ONBOARDING_KEY) || '{}');
    o.kid_count = kids.length;
    o.kid_ages = kids.map((k) => k.age);
    localStorage.setItem(ONBOARDING_KEY, JSON.stringify(o));
    // Direct browser → Supabase under RLS (was POST /papi/api/onboarding).
    // Not gated on o.complete: a signed-in family that skipped the wizard
    // still edits kids in Settings and must not lose them on device switch.
    // syncKidsToSupabase throws (caught) when signed out — a no-op then.
    syncKidsToSupabase(kids).catch(() => {});
  } catch {}
}

// ── Account adoption (sign-in / boot-with-session) ────────────────────
// Decides who wins between this device's localStorage family and the
// account's server rows, then makes both agree:
//   • server HAS family data  → hydrate local from server (multi-device,
//     and the shared-device case where another family used this browser).
//   • server empty + local belongs to a DIFFERENT previous account →
//     reset local defaults (never donate family A's kids to account B).
//   • server empty + local is anonymous → push the parked local state up
//     (the classic try-first-sign-up-later flow).
// Returns { changed } so callers know to re-read localStorage into state.
const LAST_USER_KEY = 'pipi.demo.lastUserId';
async function adoptFamilyStateForUser(user) {
  if (!sb || !user) return { changed: false };
  let lastUser = null;
  try { lastUser = localStorage.getItem(LAST_USER_KEY); } catch {}
  try { localStorage.setItem(LAST_USER_KEY, user.id); } catch {}
  const [kidsRes, profRes] = await Promise.all([
    sb.from('kids').select('name,age,created_at').eq('user_id', user.id).order('created_at', { ascending: true }),
    sb.from('profiles').select('kid_ages,onboarding_completed_at').eq('id', user.id).maybeSingle(),
  ]);
  const kidRows = (kidsRes && kidsRes.data) || [];
  const prof = (profRes && profRes.data) || null;
  const serverHasFamily = kidRows.length > 0 || !!(prof && prof.onboarding_completed_at);
  if (serverHasFamily) {
    const src = kidRows.length ? kidRows : ((prof && prof.kid_ages) || [5]).map((a) => ({ age: a }));
    const kids = src.map((k, i) => ({
      id: 'k' + i,
      age: Number.isFinite(+k.age) ? +k.age : 5,
      avatar: k.name || KID_AVATARS[i % KID_AVATARS.length],
    }));
    try { localStorage.setItem(DEMO_KIDS_KEY, JSON.stringify(kids)); } catch {}
    try { localStorage.removeItem(DEMO_ACTIVE_KID_KEY); } catch {}
    try {
      const o = JSON.parse(localStorage.getItem(ONBOARDING_KEY) || '{}');
      o.kid_count = kids.length;
      o.kid_ages = kids.map((k) => k.age);
      if (prof && prof.onboarding_completed_at) o.complete = true;
      localStorage.setItem(ONBOARDING_KEY, JSON.stringify(o));
    } catch {}
    return { changed: true };
  }
  if (lastUser && lastUser !== user.id) {
    try { localStorage.removeItem(DEMO_KIDS_KEY); localStorage.removeItem(DEMO_ACTIVE_KID_KEY); } catch {}
    try {
      const o = JSON.parse(localStorage.getItem(ONBOARDING_KEY) || '{}');
      delete o.kid_count; delete o.kid_ages; delete o.complete;
      localStorage.setItem(ONBOARDING_KEY, JSON.stringify(o));
    } catch {}
    return { changed: true };
  }
  try {
    const o = JSON.parse(localStorage.getItem(ONBOARDING_KEY) || '{}');
    await syncOnboardingToSupabase(o, !!o.complete);
    await syncKidsToSupabase(loadKids());
  } catch {}
  return { changed: false };
}

// ── Per-kid stores ────────────────────────────────────────────────────
// Favorites, resume position and daily listening time are all kept PER
// child, keyed by kid id, so switching profiles shows that child's own
// shelf / "continue" / time. Shape: { [kidId]: <value> }.
const DEMO_FAVS_BY_KID_KEY = 'pipi.demo.favoritesByKid';
const DEMO_RESUME_KEY = 'pipi.demo.resumeByKid';   // { kidId: { storyId, sceneKey, ts } }
const DEMO_TIME_KEY = 'pipi.demo.timeByKid';       // { kidId: { day: 'YYYY-MM-DD', sec } }
const DEMO_PIN_KEY = 'pipi.demo.parentPin';        // 4-digit string or ''
const DEMO_LIMIT_KEY = 'pipi.demo.dailyLimitMin';  // number (0 = no limit)
const DEMO_PAYWALL_KEY = 'pipi.demo.entitlement';  // { plan, trialStart }

// Freemium scaffold. The flagship cirkus (+ its 5-min test) stays free
// forever as the hook; everything else needs an active trial or the paid
// Family plan. Trial is 7 days from first open.
const FREE_STORY_IDS = new Set(['cirkus', 'cirkusTest']);
const TRIAL_DAYS = 7;

// cirkus-test is a 5-min DEVELOPER variant whose T1/T2/T3 splits reference 27
// clips that were never rendered — hidden from real users so they never hit
// broken playback. Re-enable for testing with ?dev=1 or localStorage
// pipi.devBooks='1'. (Data-integrity finding DI-1.)
const SHOW_DEV_BOOKS = (typeof window !== 'undefined') && (
  /[?&]dev=1(\b|&|$)/.test(window.location.search) ||
  (() => { try { return localStorage.getItem('pipi.devBooks') === '1'; } catch { return false; } })()
);

// ── Value-first onboarding + post-session flow keys ──────────────────
// FIRST_SESSION: set once the child finishes their first story; gates the
// one-time post-session screens (bedtime opt-in → deferred sign-up nudge).
// BEDTIME/QUIET: parent preferences captured AFTER first value, stored
// locally per existing patterns (no backend yet — preference capture).
const FIRST_SESSION_KEY = 'pipi.demo.firstSessionDone';
const DEMO_BEDTIME_KEY = 'pipi.demo.bedtimeReminder'; // { enabled, time: 'HH:MM' }
const DEMO_QUIET_KEY = 'pipi.demo.quietHours';        // { enabled }

// The ONE personalization question: age + one interest tap. Interests are
// app-local (no profiles column) — they only steer the first recommendation.
const INTERESTS = [
  { id: 'zvieratka',     label: 'Zvieratká',     emoji: '🐴' },
  { id: 'dobrodruzstvo', label: 'Dobrodružstvo', emoji: '🧭' },
  { id: 'magia',         label: 'Mágia',         emoji: '✨' },
  { id: 'auta',          label: 'Autá',          emoji: '🚗' },
  { id: 'princezne',     label: 'Princezné',     emoji: '👑' },
];

// "How did you hear about us" — moved OUT of onboarding (value-first;
// the question belongs after first value) into Settings → Účet under
// the collapsible "Pomôž nám rásť" card.
const HOW_HEARD_OPTIONS = [
  { id: 'friend',  label: 'Kamarát/kolega' },
  { id: 'social',  label: 'Sociálne siete' },
  { id: 'school',  label: 'Škola / škôlka' },
  { id: 'press',   label: 'Tlač / podcast' },
  { id: 'search',  label: 'Vyhľadávač (Google)' },
  { id: 'other',   label: 'Iné' },
];

// Age + one interest → the first story, with an honest one-line "why".
// All reasons are content-true (cirkus really has koníky, vylet really
// has a stratená opička…). zlodeji is the napínavé one — only from 6 up.
function recommendStory(age, interest) {
  let id = 'cirkus';
  let why = 'začína sa tam, kde to deti milujú — v manéži plnej svetiel';
  if (interest === 'zvieratka') {
    id = 'cirkus'; why = 'sú v ňom cirkusové koníky a celé zvieracie predstavenie';
  } else if (interest === 'dobrodruzstvo') {
    if ((+age || 5) >= 6) { id = 'zlodeji'; why = 'je napínavý ako nočná výprava — a dobre dopadne'; }
    else { id = 'vylet'; why = 'je to výprava s mapou, piknikom a stratenou opičkou'; }
  } else if (interest === 'magia') {
    id = 'cirkus'; why = 'manéž je plná trikov, svetiel a malých kúziel';
  } else if (interest === 'auta') {
    id = 'vylet'; why = 'je to cesta za dobrodružstvom — s mapou a plnou poľnou';
  } else if (interest === 'princezne') {
    id = 'cirkus'; why = 'povrazolezkyňa v trblietavých šatách je skoro princezná';
  }
  const story = STORIES.find((s) => s.id === id) || STORIES[0];
  return { story, why };
}

function loadJSON(key, fallback) {
  try {
    const v = JSON.parse(localStorage.getItem(key) || 'null');
    return v == null ? fallback : v;
  } catch { return fallback; }
}
function saveJSON(key, val) {
  try { localStorage.setItem(key, JSON.stringify(val)); } catch {}
}
function todayStr() {
  const d = new Date();
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}

const DEMO_STRINGS = {
  sk: {
    login_title: 'Vitaj v Ippy',
    login_sub: 'Ippy je zatiaľ súkromné demo pre pozvaných rodičov. Zadaj kód z pozvánky.',
    login_btn: 'Otvoriť Ippy',
    login_busy: 'Overujem…',
    login_hint_pre: 'Nemáš kód? Napíš nám na',
    login_err: 'Nesprávny kód. Skús to ešte raz.',
    parent_greeting: 'Rodičovský režim',
    parent_sub: 'PIN: ✔ overené',
    voice_label: 'Hlas rozprávača',
    quiet_t: 'Pokojný režim pred spaním',
    quiet_s: 'Tlmí hlasné scény po 19:30',
    voice_in_t: 'Voľba hlasom',
    voice_in_s: 'Dieťa môže odpovedať nahlas',
    dev_t: 'Transkript režim (pre testerov)',
    dev_s: 'Zobrazí debug pásik s rozpoznaným textom',
    lang_t: 'Jazyk',
    lang_s: 'Slovenčina alebo angličtina pre rozhranie',
    today_t: 'Dnes ste počúvali',
    today_min: 'min',
    today_of: 'z limitu 60 min',
    pin_t: 'Rodičovský PIN',
    pin_s: 'chráni nastavenia pred deťmi',
    home_hello: 'Ahoj!',
    home_sub: 'Pipi má pre teba nový príbeh.',
    home_bubble: 'Dnes ideme do zakliateho lesa. Pomôžeš mi?',
    home_continue: 'Pokračuj v počúvaní',
    home_shelf: 'Polica s príbehmi',
    home_all: 'všetky →',
    home_bedtime: 'Pred spaním',
    home_quiet_card_t: 'Tichý príbeh s mesiacom',
    home_quiet_card_s: '12 min · žiadne hlasné scény',
    home_play_btn: 'hraj',
    player_chapter: 'Kapitola',
    player_of: 'z',
    player_narrator: 'rozpráva rozprávač',
    player_try_choice: '▸ Skús voľbu',
    player_choice_in: 'voľba o 2 min',
    listening_title: 'Pipi počúva…',
    listening_q: '„Čo má teraz Pipi urobiť?“',
    listening_hint: 'Povedz odpoveď nahlas — alebo klikni na obrázok.',
    listening_done: 'Počul som ťa ✓ — vybrať',
    choice_q_open: 'Čo má Pipi urobiť?',
    choice_voice_hint: 'alebo povedz nahlas',
    // ── Onboarding (value-first) ──
    ob_skip: 'preskočiť teraz',
    ob_back: '← späť',
    ob_next: 'ďalej →',
    ob_welcome_title: 'Vitaj v Ippy',
    ob_welcome_body: 'Rozprávky, kde rozhoduje tvoje dieťa. Rozprávač sa spýta, dieťa odpovie nahlas — a príbeh pokračuje jeho cestou.',
    ob_welcome_no: 'Bez reklám. Bez nákupov v aplikácii. Nič nesledujeme.',
    ob_mic_title: 'Mikrofón počúva len chvíľu',
    ob_mic_body: 'Keď príde voľba, rozprávač sa spýta a mikrofón sa na pár sekúnd zapne. Čo dieťa povie, slúži len na výber cesty — prepis zahodíme hneď a nahrávky neukladáme.',
    ob_mic_names: 'Mená nezbierame. Profil dieťaťa je len vek a tvárička.',
    ob_mic_note: 'Mikrofón povolíš až pri prvom príbehu, priamo v prehrávači.',
    ob_mic_cta: 'rozumiem →',
    ob_age_title: 'Pre koho bude prvý príbeh?',
    ob_age_label: 'Vek dieťaťa',
    ob_age_note: 'Ďalšie deti pridáš neskôr v Nastavenia → Deti.',
    ob_interest_label: 'Čo ho teraz najviac baví?',
    ob_pick_cta: 'vybrať príbeh →',
    ob_rec_eyebrow: 'Náš tip na prvý príbeh',
    ob_rec_why: 'Prečo tento:',
    ob_rec_play: '▶ Pustiť príbeh',
    ob_rec_account: 'Môžeš začať počúvať hneď — bez účtu. Vytvoríš si ho neskôr, keď bude čo uložiť.',
    ob_rec_later: 'neskôr',
    // ── Trial-timeline paywall ──
    pw_title: 'Ako funguje skúška',
    pw_sub: '7 dní je všetko odomknuté. Bez platby, bez záväzku.',
    pw_today: 'Dnes',
    pw_today_s: 'Všetko odomknuté — všetky knihy aj vlastné rozprávky.',
    pw_day5: 'Deň 5',
    pw_day5_s: 'Pošleme ti pripomienku e-mailom, že skúška o dva dni skončí.',
    pw_day7: 'Deň 7',
    pw_day7_s: 'Koniec skúšky — vyber si plán, alebo nechaj tak.',
    pw_over: 'Skúška skončila. Prvá kniha ostáva zadarmo navždy.',
    pw_monthly: 'Mesačne',
    pw_yearly: 'Ročne',
    pw_m_price: '4,90 € / mesiac',
    pw_y_price: '49 € / rok',
    pw_y_note: '2 mesiace zadarmo',
    pw_cancel: 'Zrušiť môžeš kedykoľvek — počas skúšky sa nič neplatí.',
    pw_cta: 'Pokračovať na predplatné →',
    pw_have: 'Už máš predplatné? Spravovať →',
    pw_later: 'Neskôr',
    // ── Post-session: bedtime opt-in + deferred sign-up ──
    ps_title: 'A to je koniec dnešného príbehu.',
    ps_sub: 'Dve drobnosti pre pokojnejšie večery. Obe sú dobrovoľné.',
    ps_bed_t: 'Pripomienka na večerný príbeh',
    ps_bed_s: 'Jemné ťuknutie, keď je čas na rozprávku.',
    ps_quiet_t: 'Pokojný režim pred spaním',
    ps_quiet_s: 'Po čase pripomienky stlmíme hlasné scény.',
    ps_save: 'uložiť ✓',
    ps_skip: 'teraz nie',
    acct_title: 'Uložiť pokrok?',
    acct_body: 'Účet drží profily, obľúbené a pokrok na každom zariadení. Počúvanie funguje aj bez neho.',
    acct_cta: 'Vytvoriť účet',
    acct_later: 'Neskôr',
    // ── Settings → "Pomôž nám rásť" (how-did-you-hear, moved here) ──
    growth_t: 'Pomôž nám rásť',
    growth_q: 'Ako si sa o nás dozvedel/a?',
    growth_s: 'Jedna otázka, veľká pomoc.',
    growth_thanks: 'Ďakujeme. Naozaj to pomáha.',
    growth_other_ph: 'napríklad reklama na YouTube',
  },
  en: {
    login_title: 'Welcome to Ippy',
    login_sub: 'Ippy is a private demo for invited parents for now. Enter the code from your invite.',
    login_btn: 'Open Ippy',
    login_busy: 'Checking…',
    login_hint_pre: 'No code? Write to',
    login_err: 'Wrong code. Try again.',
    parent_greeting: 'Parent mode',
    parent_sub: 'PIN: ✔ verified',
    voice_label: 'Narrator voice',
    quiet_t: 'Quiet bedtime mode',
    quiet_s: 'Softens loud scenes after 7:30 pm',
    voice_in_t: 'Voice answers',
    voice_in_s: 'Child can answer out loud',
    dev_t: 'Transcript mode (testers only)',
    dev_s: 'Shows a debug strip with recognized text',
    lang_t: 'Language',
    lang_s: 'Slovak or English for the UI',
    today_t: 'Today you listened',
    today_min: 'min',
    today_of: 'of 60 min limit',
    pin_t: 'Parent PIN',
    pin_s: 'protects settings from kids',
    home_hello: 'Hi!',
    home_sub: 'Pipi has a new story for you.',
    home_bubble: 'Today we are off to the enchanted wood. Will you help me?',
    home_continue: 'Continue listening',
    home_shelf: 'Story shelf',
    home_all: 'all →',
    home_bedtime: 'Before bed',
    home_quiet_card_t: 'A quiet story with the moon',
    home_quiet_card_s: '12 min · no loud scenes',
    home_play_btn: 'play',
    player_chapter: 'Chapter',
    player_of: 'of',
    player_narrator: 'narrator is reading',
    player_try_choice: '▸ Try a choice',
    player_choice_in: 'choice in 2 min',
    listening_title: 'Pipi is listening…',
    listening_q: '"What should Pipi do now?"',
    listening_hint: 'Say it out loud — or tap a picture.',
    listening_done: 'Got it ✓ — choose',
    choice_q_open: 'What should Pipi do?',
    choice_voice_hint: 'or say it out loud',
    // ── Onboarding (value-first) ──
    ob_skip: 'skip for now',
    ob_back: '← back',
    ob_next: 'next →',
    ob_welcome_title: 'Welcome to Ippy',
    ob_welcome_body: 'Stories where your child decides. The narrator asks, the child answers out loud — and the story takes their path.',
    ob_welcome_no: 'No ads. No in-app purchases. Nothing tracked.',
    ob_mic_title: 'The mic listens only for a moment',
    ob_mic_body: 'At a choice, the narrator asks and the mic turns on for a few seconds. What the child says only picks the path — the transcript is discarded right away and recordings are never stored.',
    ob_mic_names: 'We collect no names. A child profile is just an age and a face.',
    ob_mic_note: 'You grant the mic at the first story, right in the player.',
    ob_mic_cta: 'got it →',
    ob_age_title: 'Who is the first story for?',
    ob_age_label: 'Child’s age',
    ob_age_note: 'Add more children later in Settings → Children.',
    ob_interest_label: 'What are they into right now?',
    ob_pick_cta: 'pick a story →',
    ob_rec_eyebrow: 'Our tip for the first story',
    ob_rec_why: 'Why this one:',
    ob_rec_play: '▶ Play the story',
    ob_rec_account: 'You can start listening right away — no account. Create one later, once there’s something to save.',
    ob_rec_later: 'later',
    // ── Trial-timeline paywall ──
    pw_title: 'How the trial works',
    pw_sub: 'Everything is unlocked for 7 days. No payment, no commitment.',
    pw_today: 'Today',
    pw_today_s: 'Everything unlocked — all books and custom stories.',
    pw_day5: 'Day 5',
    pw_day5_s: 'We email you a reminder that the trial ends in two days.',
    pw_day7: 'Day 7',
    pw_day7_s: 'Trial ends — pick a plan, or simply let it lapse.',
    pw_over: 'The trial is over. The first book stays free forever.',
    pw_monthly: 'Monthly',
    pw_yearly: 'Yearly',
    pw_m_price: '€4.90 / month',
    pw_y_price: '€49 / year',
    pw_y_note: '2 months free',
    pw_cancel: 'Cancel anytime — nothing is charged during the trial.',
    pw_cta: 'Continue to subscription →',
    pw_have: 'Already subscribed? Manage →',
    pw_later: 'Later',
    // ── Post-session: bedtime opt-in + deferred sign-up ──
    ps_title: 'And that’s the end of today’s story.',
    ps_sub: 'Two small things for calmer evenings. Both optional.',
    ps_bed_t: 'Bedtime story reminder',
    ps_bed_s: 'A gentle nudge when it’s story time.',
    ps_quiet_t: 'Quiet bedtime mode',
    ps_quiet_s: 'After the reminder time we soften loud scenes.',
    ps_save: 'save ✓',
    ps_skip: 'not now',
    acct_title: 'Save your progress?',
    acct_body: 'An account keeps profiles, favorites and progress on every device. Listening works without one too.',
    acct_cta: 'Create account',
    acct_later: 'Later',
    // ── Settings → "Help us grow" (how-did-you-hear, moved here) ──
    growth_t: 'Help us grow',
    growth_q: 'How did you hear about us?',
    growth_s: 'One question, a big help.',
    growth_thanks: 'Thank you. It really helps.',
    growth_other_ph: 'e.g. a YouTube ad',
  },
};
const DEFAULT_DEMO_LANG = 'en';
const demoT = (lang) => DEMO_STRINGS[lang] || DEMO_STRINGS[DEFAULT_DEMO_LANG];

// The single source of truth for "what language is this app speaking right now".
// It is deliberately a plain function over localStorage rather than a prop:
// the language is one global setting, but it is needed both by React render
// (via state) and by non-React code deep in the mic pipeline, which would
// otherwise have to thread a prop through three unrelated components. Read at
// call time, so it can never go stale against the UI.
//
// This matters beyond cosmetics: /api/transcribe locks Whisper to a language,
// and Whisper answers a wrong language with confident phonetic nonsense rather
// than an error — so a drifted value here degrades silently into an
// unclear → re-ask → child-gives-up loop.
function currentLang() {
  try {
    const v = localStorage.getItem(DEMO_LANG_KEY);
    if (v && DEMO_STRINGS[v]) return v;
  } catch {}
  return DEFAULT_DEMO_LANG;
}

function readInitialLang() { return currentLang(); }

// ──────────────────────────────────────────────────────────────
// Password gate
// ──────────────────────────────────────────────────────────────
function DemoLogin({ lang, onUnlock }) {
  const t = demoT(lang);
  const [pw, setPw] = useState('');
  const [err, setErr] = useState('');
  const [busy, setBusy] = useState(false);
  const ref = useRef(null);
  useEffect(() => { ref.current && ref.current.focus(); }, []);
  const submit = async (e) => {
    e.preventDefault();
    setBusy(true); setErr('');
    try {
      const res = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ password: pw }),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) { setErr(data.error || t.login_err); setBusy(false); return; }
      onUnlock();
    } catch {
      setErr(lang === 'en' ? 'Network error.' : 'Sieť zlyhala.'); setBusy(false);
    }
  };
  // Portal so the login card escapes the scaled iPhone-frame stage and
  // renders centered in the viewport at full size on mobile.
  return ReactDOM.createPortal(
    <div className="login-shell" style={{ alignItems: 'center', justifyContent: 'center' }}>
      <div className="login-card" style={{ textAlign: 'center' }}>
        <div style={{ display: 'flex', justifyContent: 'center', marginTop: -52, marginBottom: 4 }}>
          <div style={{ transform: 'rotate(-3deg)' }}><Pipi size={76}/></div>
        </div>
        <h2>{t.login_title}</h2>
        <p>{t.login_sub}</p>
        <form onSubmit={submit}>
          <input ref={ref} type="password" autoComplete="current-password"
            inputMode="text" autoCapitalize="off" autoCorrect="off" spellCheck="false"
            value={pw} onChange={(e) => setPw(e.target.value)}
            placeholder={lang === 'en' ? 'invite code' : 'kód z pozvánky'}
            style={{ textAlign: 'center', minHeight: 48 }}/>
          {err && <div className="err">{err}</div>}
          <button type="submit" disabled={busy} style={{ minHeight: 52, opacity: busy ? 0.55 : 1, boxShadow: busy ? 'none' : undefined }}>
            {busy ? t.login_busy : t.login_btn}
          </button>
        </form>
        <div className="hint">
          {t.login_hint_pre}{' '}
          <a href="mailto:info@heyippy.com">info@heyippy.com</a>
        </div>
      </div>
    </div>,
    document.body
  );
}

// ──────────────────────────────────────────────────────────────
// Story graph — three-act, branching by intent kind
// Audio files reuse the existing prepared MP3s under /demo/audio/.
// ──────────────────────────────────────────────────────────────
const STORY_GRAPH = {
  start: {
    chapter: 1, total: 3,
    audio: '/demo/audio/audio_original.mp3',
    title: { sk: 'Ráno vo vile Vilôčke', en: 'Morning at Villa Villekulla' },
    text:  {
      sk: 'Pipi si obula dve rozdielne pančuchy a pozrela na Tomiho s Anikou: dnes si dobrodružstvo vyberieme spolu.',
      en: 'Pipi pulled on two different socks and looked at Tomi and Annika: today we pick our adventure together.',
    },
    question: {
      sk: 'Kam má Pipi ísť najprv?',
      en: 'Where should Pipi go first?',
    },
    choices: [
      { id: 'circus', label: { sk: 'Do cirkusu', en: 'To the circus' }, desc: { sk: 'Možno tam býva víla.', en: 'Maybe a fairy lives there.' }, color: 'green', icon: 'door' },
      { id: 'lake',   label: { sk: 'K jazeru',   en: 'To the lake'   }, desc: { sk: 'Vraj má mapu od kakaa.',  en: 'They say there is a cocoa-stained map.' }, color: 'blue',  icon: 'knock' },
      { id: 'kitten', label: { sk: 'Pomôcť mačiatku', en: 'Help the kitten' }, desc: { sk: 'Niečo mňauká za plotom.', en: 'Something is meowing behind the fence.' }, color: 'coral', icon: 'run' },
    ],
    // Maps choice id → next node + the ack audio + branch audio
    branches: {
      circus: { ack: '/demo/audio/ack_a.mp3', next: 'circus_act2' },
      lake:   { ack: '/demo/audio/ack_b.mp3', next: 'lake_act2' },
      kitten: { ack: '/demo/audio/ack_c.mp3', next: 'kitten_act2' },
    },
  },
  circus_act2: {
    chapter: 2, total: 3,
    audio: '/demo/audio/continuation_a.mp3',
    title: { sk: 'Šapitó plné smiechu', en: 'A circus full of laughter' },
    text:  {
      sk: 'V cirkuse sa malému artistovi zakotúľala loptička pod lavicu a Pipi spravila malé dobré rozhodnutie.',
      en: 'In the circus a little performer dropped his ball under the bench and Pipi made one small kind choice.',
    },
    // Scene-aware question: we're in a circus, asking HOW to help — not WHERE to go.
    question: {
      sk: 'Ako má Pipi pomôcť malému artistovi?',
      en: 'How should Pipi help the little performer?',
    },
    choices: [
      { id: 'cheer',  label: { sk: 'Povzbudiť ho',     en: 'Cheer him on'      }, desc: { sk: 'Aby sa nebál vystúpiť.',   en: 'So he is not afraid to perform.' }, color: 'yellow', icon: 'knock' },
      { id: 'trick',  label: { sk: 'Skúsiť trik s ním', en: 'Try the trick with him' }, desc: { sk: 'Spolu sa to zvládne ľahšie.', en: 'Easier when they do it together.' }, color: 'pink',   icon: 'door' },
      { id: 'gather', label: { sk: 'Zavolať kamarátov', en: 'Call the friends' }, desc: { sk: 'Aby ho videli a tlieskali.',  en: 'So they can watch and clap.' }, color: 'green',  icon: 'run' },
    ],
    branches: {
      cheer:  { next: 'circus_end' },
      trick:  { next: 'circus_end' },
      gather: { next: 'circus_end' },
    },
  },
  lake_act2: {
    chapter: 2, total: 3,
    audio: '/demo/audio/continuation_b.mp3',
    title: { sk: 'Mapa ku trblietavej vode', en: 'A map to the sparkling water' },
    text:  {
      sk: 'Pri jazere stretli mapu plnú machúľ od kakaa. Tomi chcel rovno, Anika čítať, Pipi aby sa nik nestratil.',
      en: 'At the lake they found a map covered in cocoa stains. Tomi wanted to charge, Annika to read it, Pipi wanted nobody to get lost.',
    },
    // Scene at a crossroads with a map — ask WHAT to try first.
    question: {
      sk: 'Čo má Pipi skúsiť najprv?',
      en: 'What should Pipi try first?',
    },
    choices: [
      { id: 'read',   label: { sk: 'Prečítať mapu spolu', en: 'Read the map together' }, desc: { sk: 'Aby sa nestratili.',   en: 'So nobody gets lost.' }, color: 'blue',   icon: 'door' },
      { id: 'picnic', label: { sk: 'Rozložiť piknik',     en: 'Lay out the picnic' }, desc: { sk: 'Najlepšie sa rozhoduje s plným bruchom.', en: 'Best decisions on a full belly.' }, color: 'yellow', icon: 'knock' },
      { id: 'raft',   label: { sk: 'Postaviť malú plť',   en: 'Build a tiny raft' }, desc: { sk: 'Skratka cez vodu.',  en: 'A shortcut across the water.' }, color: 'green',  icon: 'run' },
    ],
    branches: {
      read:   { next: 'lake_end' },
      picnic: { next: 'lake_end' },
      raft:   { next: 'lake_end' },
    },
  },
  kitten_act2: {
    chapter: 2, total: 3,
    audio: '/demo/audio/continuation_c.mp3',
    title: { sk: 'Tiché mňaukanie za plotom', en: 'A quiet meow behind the fence' },
    text:  {
      sk: 'Za plotom sedelo mačiatko s labkou v šnúrke. Pipi si kľakla, aby nebola priveľká, a zašepkala.',
      en: 'Behind the fence sat a kitten with its paw in a string. Pipi knelt, so she would not be too big, and whispered.',
    },
    // Scene with a kitten in trouble — ask WHAT to do, not WHERE to go.
    question: {
      sk: 'Čo má Pipi povedať mačiatku?',
      en: 'What should Pipi say to the kitten?',
    },
    choices: [
      { id: 'soft',   label: { sk: 'Niečo upokojujúce', en: 'Something soothing' }, desc: { sk: 'Aby sa mačiatko nebálo.', en: 'So the kitten is not scared.' }, color: 'lilac', icon: 'knock' },
      { id: 'untie', label: { sk: 'Že rozviaže šnúrku', en: 'That she will untie the string' }, desc: { sk: 'Krok za krokom, opatrne.', en: 'Step by step, gently.' }, color: 'green', icon: 'door' },
      { id: 'call',  label: { sk: 'Zavolá Aniku',       en: 'She will call Annika' }, desc: { sk: 'Dve ruky sú lepšie ako jedna.', en: 'Two hands are better than one.' }, color: 'coral', icon: 'run' },
    ],
    branches: {
      soft:  { next: 'kitten_end' },
      untie: { next: 'kitten_end' },
      call:  { next: 'kitten_end' },
    },
  },
  // Act III ends — each reuses the act-II audio as a placeholder finale until
  // real finale MP3s exist. Marking end: true closes the choice flow.
  circus_end: { chapter: 3, total: 3, audio: '/demo/audio/continuation_a.mp3',
    title: { sk: 'Pipi a malý artista', en: 'Pipi and the little performer' },
    text:  { sk: 'Najkrajší trik bola dobrota.', en: 'The best trick was kindness.' },
    end: true,
  },
  lake_end:   { chapter: 3, total: 3, audio: '/demo/audio/continuation_b.mp3',
    title: { sk: 'Cesta naspäť domov', en: 'The way back home' },
    text:  { sk: 'Dobrodružstvo bolo lepšie, keď ho zdieľali.', en: 'The adventure was better when shared.' },
    end: true,
  },
  kitten_end: { chapter: 3, total: 3, audio: '/demo/audio/continuation_c.mp3',
    title: { sk: 'Malé mňau, veľké srdce', en: 'A small meow, a big heart' },
    text:  { sk: 'Najväčšie dobrodružstvo bola pomoc tomu najmenšiemu.', en: 'The biggest adventure was helping the smallest.' },
    end: true,
  },

  // The 3 audiobook chains (cirkus / vylet / zlodeji) are now BUILT
  // DYNAMICALLY from /demo/manifests/{book}.json at runtime via
  // expandBookChain() below. Adding a new authored split = drop new
  // mp3s + update manifest JSON, no code change.
};

// Take a manifest (book id, title, intro/ending text, splits[]) and
// expand it into a chain of STORY_GRAPH scenes.
//
//   chapter 1 (id_book)       audio 0→split1, then ask, then play
//                              [ack + tail] → chapter 2
//   chapter 2 (id_split_2)    audio split1→split2, then ask...
//   ...
//   chapter N+1 (id_final)    audio splitN → end
function expandBookChain(id, title, intro, ending, manifest) {
  const scenes = {};
  if (!manifest || !manifest.splits || !manifest.splits.length) return scenes;
  const splits = manifest.splits;
  const total = splits.length + 1;
  const colors = ['green', 'yellow', 'blue', 'coral', 'lilac', 'pink'];
  const icons = ['door', 'knock', 'run'];
  // Propagate the book-level endAtSec to every scene so the progress
  // bar / display duration reads the shortened cap on EVERY scene of
  // a test variant — not only the final one. Without this, the player
  // shows "0:00 / 15:40" on cirkus-test's intermediate scenes despite
  // playback stopping at 5:00.
  const bookEndAt = manifest.endAtSec || null;
  splits.forEach((split, i) => {
    const sceneKey = i === 0 ? `${id}_book` : `${id}_split_${i + 1}`;
    const nextKey = i === splits.length - 1 ? `${id}_final` : `${id}_split_${i + 2}`;
    const prev = splits[i - 1];
    scenes[sceneKey] = {
      chapter: i + 1, total,
      audio: manifest.source,
      startAtSec: i === 0 ? 0 : prev.splitAtSec,
      splitAtSec: split.splitAtSec,
      endAtSec: bookEndAt,
      title: { sk: title, en: title },
      text: { sk: intro, en: intro },
      question: { sk: split.questionText, en: split.questionText },
      bridgeAudio: split.bridgeAudio,
      choices: split.choices.map((c, ci) => ({
        id: c.id,
        label: { sk: c.label, en: c.label },
        desc: { sk: (c.ackText || '').replace(/^[„"]|["“]$/g, ''),
                en: (c.ackText || '').replace(/^[„"]|["“]$/g, '') },
        color: colors[ci % colors.length],
        icon: icons[ci % icons.length],
      })),
      branches: Object.fromEntries(split.choices.map((c) => [c.id, {
        ack: c.ackAudio,
        tail: c.tailAudio,
        next: nextKey,
      }])),
      // Re-ask + fallback prompts (rendered in cloned voice). Defaults
      // exist in the manifest; if missing we degrade gracefully.
      reAskAfterSec: split.reAskAfterSec || 8,
      reAskAudio:    split.reAskAudio || null,
      reAskText:     split.reAskText || '',
      fallbackAudio: split.fallbackAudio || null,
      fallbackText:  split.fallbackText || '',
      // Safe default branch for a child who never answers recognizably.
      // null = loop the prompts forever (pre-v55 behavior).
      fallbackChoiceId: split.fallbackChoiceId || null,
    };
  });
  const last = splits[splits.length - 1];
  scenes[`${id}_final`] = {
    chapter: total, total,
    audio: manifest.source,
    startAtSec: last.splitAtSec,
    endAtSec: bookEndAt,
    title: { sk: title, en: title },
    text: { sk: ending, en: ending },
    end: true,
  };
  return scenes;
}

// Given the full storyGraph + a current scene key, return the ordered
// chain of scenes for the book the current key belongs to. Used by
// the player's chapter-dot timeline so it can show "you are at chapter
// 3 of 8" with markers for the splits in between. Keys like
// `cirkus_book` / `cirkus_split_3` / `cirkus_final` all share the same
// prefix; we group by that prefix and sort by chapter.
function getChainScenes(storyGraph, currentSceneKey) {
  if (!storyGraph || !currentSceneKey) return [];
  const showcaseKeys = ['start', 'circus_act2', 'lake_act2', 'kitten_act2', 'circus_end', 'lake_end', 'kitten_end'];
  if (showcaseKeys.includes(currentSceneKey)) return []; // showcase has its own non-linear graph
  // Strip trailing _book / _split_N / _final to find the book prefix.
  const m = currentSceneKey.match(/^([a-zA-Z]+?)(?:_book|_split_\d+|_final)$/);
  if (!m) return [];
  const prefix = m[1];
  const all = Object.entries(storyGraph)
    .filter(([k]) => k === `${prefix}_book` || k.startsWith(`${prefix}_split_`) || k === `${prefix}_final`)
    .map(([k, v]) => ({ key: k, ...v }))
    .sort((a, b) => (a.chapter || 0) - (b.chapter || 0));
  return all;
}

const BOOK_META = {
  // EN Pippi core — authored adaptations of our licensed narration,
  // masters TTS-generated so splits are exact by construction.
  circus: {
    title: 'Pippi Goes to the Circus',
    intro: 'A circus has come to town. Pippi has never heard of a circus, but she has a hatful of gold coins, and Tommy and Annika are coming along.',
    ending: 'Pippi came home with her friends, full of laughter. The best trick of all was noticing other people.',
  },
  outing: {
    title: 'Pippi Goes on an Outing',
    intro: 'A sunny September day, a big picnic basket, and Mr. Galan the monkey on Pippi\'s shoulder. The woods are calling.',
    ending: 'They came home tired and happy. An adventure is best when it is shared.',
  },
  burglars: {
    title: 'Pippi and the Burglars',
    intro: 'One dark autumn evening, two burglars come knocking at Villa Villekulla. They stop by Pippi\'s bed.',
    ending: 'The burglars left with a smile and a gold coin, not the suitcase. Courage was knowing how to stay calm.',
  },
  // First English book — public-domain source (Joseph Jacobs, 1890), narrated in
  // the licensed clone. Unlike the SK books its master is TTS-generated, so its
  // splitAtSec values are exact by construction rather than recovered by
  // alignment. See scripts/render-en-book.mjs.
  goldilocks: {
    title: 'Goldilocks and the Three Bears',
    intro: 'Three bears live in a snug little house deep in the wood. While their porridge cools, a girl with golden hair knocks at the door.',
    ending: 'Goldilocks said sorry, and the three bears forgave her. Asking first beats sneaking in, and sorry is a brave word.',
  },
  cirkus: {
    title: 'Pipi ide do cirkusu',
    intro: 'Pipi sa s Tomim a Anikou rozhodne ísť do cirkusu. Stretne tam koníky, povrazolezku aj siláka Adolfa.',
    ending: 'Pipi sa s priateľmi vrátila domov, plná smiechu. Najkrajší trik bola dobrota.',
  },
  vylet: {
    title: 'Pipi usporiada výlet',
    intro: 'Pipi, Tomi a Anika sa vyberú na výlet. Hľadajú mapu, piknik a stratenú opicu pána Galána.',
    ending: 'Pipi sa s priateľmi vrátila domov. Najkrajšie dobrodružstvo bolo to zdieľané.',
  },
  zlodeji: {
    title: 'Pipi navštívia zlodeji',
    intro: 'Jednu noc do vily Vilôčky prídu dvaja zlodeji. Pri Pipinej posteli sa zastavia.',
    ending: 'Zlodeji odišli s úsmevom, nie s peniazmi. Odvaha bola vedieť ostať pokojná.',
  },
  // 5-min testing variant of cirkus — same source MP3 but 6 splits in
  // the 0..300 s window for quick iteration on voice/classifier tweaks.
  cirkusTest: {
    title: 'Cirkus test (5 min)',
    intro: 'Skrátená verzia cirkusu na rýchle testovanie volieb a hlasu. Trvá päť minút.',
    ending: 'Koniec testovacej cesty. Otvor plnú verziu cirkusu z knižnice.',
  },
};

// ──────────────────────────────────────────────────────────────
// Custom screen wrappers — we replace HomeScreen.onOpen / PlayerScreen /
// ChoiceScreen with versions that drive a real audio element.
// ──────────────────────────────────────────────────────────────

function useAudio(src, { onEnd, startAtSec = 0, splitAtSec = null, endAtSec = null, onSplit = null } = {}) {
  const audioRef = useRef(null);
  const [playing, setPlaying] = useState(false);
  const [time, setTime] = useState(0);
  const [duration, setDuration] = useState(0);
  const splitFiredRef = useRef(false);
  // Stash the latest callbacks + splitAtSec in refs so the audio-element
  // setup effect only runs ONCE per mount. Previously this effect's deps
  // included `onSplit` / `onEnd`, which were fresh inline functions on
  // every parent render — so the <audio> element got destroyed and
  // recreated mid-playback, making play/pause flake and the story stop
  // whenever an unrelated state changed.
  const onSplitRef = useRef(onSplit);
  const onEndRef = useRef(onEnd);
  const splitAtSecRef = useRef(splitAtSec);
  const endAtSecRef = useRef(endAtSec);
  useEffect(() => { onSplitRef.current = onSplit; }, [onSplit]);
  useEffect(() => { onEndRef.current = onEnd; }, [onEnd]);
  useEffect(() => { splitAtSecRef.current = splitAtSec; }, [splitAtSec]);
  useEffect(() => { endAtSecRef.current = endAtSec; }, [endAtSec]);

  useEffect(() => {
    const a = new Audio();
    a.preload = 'auto';
    audioRef.current = a;
    const onTime = () => {
      setTime(a.currentTime);
      // Trigger the authored split point once per scene load. We pause
      // here so the child has a still moment before the question reads.
      const splitAt = splitAtSecRef.current;
      if (!splitFiredRef.current && splitAt != null && a.currentTime >= splitAt) {
        splitFiredRef.current = true;
        // Fade the master out over ~150 ms instead of a hard cut, THEN pause so
        // currentTime stops advancing; restore volume to 1 for the next resume.
        // The incoming bridge fades in over the same window → a crossfade at the
        // seam, not a click. (audit #3 — was a bare a.pause())
        const PA = (typeof window !== 'undefined') ? window.PipiAudio : null;
        if (PA && PA.duck) {
          PA.duck(a, 0, 150).then(() => { try { a.pause(); a.volume = 1; } catch (e) {} });
        } else {
          a.pause();
        }
        setPlaying(false);
        if (onSplitRef.current) onSplitRef.current();
        return;
      }
      // endAtSec — hard end for shortened variants (cirkus-test).
      const endAt = endAtSecRef.current;
      if (endAt != null && a.currentTime >= endAt) {
        a.pause();
        setPlaying(false);
        if (onEndRef.current) onEndRef.current();
      }
    };
    const onMeta = () => setDuration(a.duration || 0);
    const onEnded = () => { setPlaying(false); if (onEndRef.current) onEndRef.current(); };
    a.addEventListener('timeupdate', onTime);
    a.addEventListener('loadedmetadata', onMeta);
    a.addEventListener('ended', onEnded);
    return () => {
      a.pause();
      a.removeEventListener('timeupdate', onTime);
      a.removeEventListener('loadedmetadata', onMeta);
      a.removeEventListener('ended', onEnded);
      audioRef.current = null;
    };
    // intentionally empty deps — callbacks are stashed in refs above so
    // the <audio> element is created once and lives for the component's
    // lifetime. Src/start changes flow through the second effect below.
  }, []);

  useEffect(() => {
    const a = audioRef.current;
    if (!a) return;
    // Compare against a.src which is always absolute; resolve `src` to
    // absolute too so an absolute /demo/... path matches itself.
    const absSrc = src ? new URL(src, location.href).href : '';
    if (src && a.src !== absSrc) {
      a.src = src;
    }
    // Always re-arm the split detector when scene changes. The previous
    // bug: scene N paused at its splitAtSec which equals the next scene's
    // startAtSec, so |currentTime - startAtSec| was ~0 → no seek → no
    // splitFiredRef reset → next scene's splitAtSec never fired.
    if (src) {
      const wantTime = startAtSec || 0;
      if (Math.abs(a.currentTime - wantTime) > 0.3) {
        a.currentTime = wantTime;
        setTime(wantTime);
      }
      splitFiredRef.current = false;
    }
  }, [src, startAtSec, splitAtSec]);

  const toggle = useCallback(() => {
    const a = audioRef.current;
    if (!a || !src) return;
    if (a.paused) {
      a.play().then(() => setPlaying(true)).catch(() => setPlaying(false));
    } else {
      a.pause();
      setPlaying(false);
    }
  }, [src]);

  const stop = useCallback(() => {
    const a = audioRef.current;
    if (!a) return;
    a.pause();
    a.currentTime = 0;
    setPlaying(false);
    setTime(0);
  }, []);

  // Skip by N seconds (positive = forward, negative = back). Clamped to
  // [0, duration]. The audio element fires `timeupdate` so the React
  // `time` state updates on its own.
  const skip = useCallback((seconds) => {
    const a = audioRef.current;
    if (!a || !isFinite(a.duration)) return;
    const next = Math.max(0, Math.min(a.duration, a.currentTime + seconds));
    a.currentTime = next;
  }, []);

  // Media Session API — gives iOS/Android a lock-screen widget with
  // play / pause / ±5 s buttons that drive the same audio element, so
  // the story keeps narrating when the phone is locked and the user
  // can control it without unlocking. Browsers that lack MediaSession
  // simply skip the setup.
  useEffect(() => {
    if (typeof navigator === 'undefined' || !navigator.mediaSession) return;
    const ms = navigator.mediaSession;
    try {
      ms.setActionHandler('play',           () => { const a = audioRef.current; if (a) a.play().then(() => setPlaying(true)).catch(() => {}); });
      ms.setActionHandler('pause',          () => { const a = audioRef.current; if (a) { a.pause(); setPlaying(false); } });
      ms.setActionHandler('seekbackward',   () => skip(-5));
      ms.setActionHandler('seekforward',    () => skip(5));
      ms.setActionHandler('seekto',         (e) => { const a = audioRef.current; if (a && typeof e.seekTime === 'number') a.currentTime = e.seekTime; });
    } catch { /* some actions unsupported on some browsers */ }
    return () => {
      try {
        ['play','pause','seekbackward','seekforward','seekto'].forEach((n) => ms.setActionHandler(n, null));
      } catch {}
    };
  }, [skip]);

  // Keep the OS lock-screen widget in sync: play/pause state + scrubber
  // position. Without these iOS shows a stuck/blank state even though the
  // action handlers above work. setPositionState throws on bad numbers, so
  // it's fully guarded.
  React.useEffect(() => {
    if (typeof navigator === 'undefined' || !navigator.mediaSession) return;
    try { navigator.mediaSession.playbackState = playing ? 'playing' : 'paused'; } catch {}
    try {
      if (isFinite(duration) && duration > 0 && navigator.mediaSession.setPositionState) {
        navigator.mediaSession.setPositionState({
          duration,
          position: Math.max(0, Math.min(time || 0, duration)),
          playbackRate: 1,
        });
      }
    } catch {}
  }, [playing, time, duration]);

  return { playing, time, duration, toggle, stop, skip, audioRef, setPlaying };
}

// Pulsing-mic visual used as the player's centerpiece. Same idea as
// landing/HowVis2 — three concentric dashed rings + a solid mic disc.
// active=true switches the disc to PIPI.blue and starts the rings.
function PlayerPulsingMic({ active = false, size = 200 }) {
  const ringBorder = `${Math.round(size * 0.025)}px dashed ${PIPI.blue}`;
  return (
    <div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}>
      <style>{`
        @keyframes pipiPlayerListen {
          0%   { transform: scale(0.85); opacity: 0.85; }
          70%  { opacity: 0.18; }
          100% { transform: scale(1.55); opacity: 0; }
        }
      `}</style>
      {active && [0, 1, 2].map(i => (
        <div key={i} style={{
          position: 'absolute', inset: 0, borderRadius: '50%',
          border: ringBorder,
          animation: `pipiPlayerListen 1.9s cubic-bezier(.2,.7,.3,1) ${i * 0.45}s infinite`,
        }}/>
      ))}
      <div style={{
        width: size, height: size, borderRadius: '50%',
        background: active ? PIPI.blue : PIPI.paperLt,
        border: `4px solid ${PIPI.ink}`,
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        position: 'relative', zIndex: 1,
        boxShadow: '6px 8px 0 rgba(0,0,0,0.16)',
        transition: 'background .2s',
      }}>
        <IconMic size={Math.round(size * 0.5)}/>
      </div>
    </div>
  );
}

// ───────────────────────────────────────────────────────────────
// DevTesterPanel — collapsible bottom diagnostics card. Visible
// only when Parent → "Transkript režim (pre testerov)" is on.
// Surfaces every signal a tester needs to figure out WHY the mic
// or classifier is misbehaving on their device:
//   • Live amplitude waveform from the primed mic stream so the
//     tester can SEE that the browser is actually picking up
//     their voice
//   • Mic permission state (granted / denied / prompt / unavailable)
//     + the last error message from getUserMedia
//   • Last raw transcript + last classifier choice + confidence
//   • Scrollable so it works on both mobile and desktop
// ───────────────────────────────────────────────────────────────
function MicWaveform({ active = true, height = 48 }) {
  const canvasRef = React.useRef(null);
  const rafRef = React.useRef(0);
  React.useEffect(() => {
    if (!active) return;
    if (!micState.stream) return;
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    const AC = window.AudioContext || window.webkitAudioContext;
    if (!AC) return;
    const audioCtx = new AC();
    const source = audioCtx.createMediaStreamSource(micState.stream);
    const analyser = audioCtx.createAnalyser();
    analyser.fftSize = 1024;
    analyser.smoothingTimeConstant = 0.55;
    source.connect(analyser);
    const buf = new Float32Array(analyser.fftSize);
    const draw = () => {
      analyser.getFloatTimeDomainData(buf);
      const w = canvas.width;
      const h = canvas.height;
      ctx.clearRect(0, 0, w, h);
      ctx.fillStyle = '#FBF1D9';
      ctx.fillRect(0, 0, w, h);
      // RMS bar baseline
      let sum = 0;
      for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i];
      const rms = Math.sqrt(sum / buf.length);
      const peak = Math.min(1, rms * 8);
      // Bar visualizer along the canvas
      const N = 28;
      const barW = w / N - 2;
      for (let i = 0; i < N; i++) {
        const idx = Math.floor((i / N) * buf.length);
        const v = Math.abs(buf[idx] || 0);
        const bh = Math.max(2, Math.min(h - 4, v * h * 4));
        const x = i * (barW + 2) + 1;
        const y = (h - bh) / 2;
        ctx.fillStyle = peak > 0.15 ? '#E8526B' : '#7CC4E8';
        ctx.fillRect(x, y, barW, bh);
      }
      // RMS readout
      ctx.fillStyle = '#1A1A2E';
      ctx.font = '11px "JetBrains Mono", monospace';
      ctx.fillText(`rms ${rms.toFixed(3)}`, 6, 12);
      rafRef.current = requestAnimationFrame(draw);
    };
    draw();
    return () => {
      cancelAnimationFrame(rafRef.current);
      try { source.disconnect(); } catch {}
      try { audioCtx.close(); } catch {}
    };
  }, [active, micState.stream]);

  React.useEffect(() => {
    // Resize canvas to its rendered size for crisp drawing
    const canvas = canvasRef.current;
    if (!canvas) return;
    const cssWidth = canvas.clientWidth;
    canvas.width = cssWidth * (window.devicePixelRatio || 1);
    canvas.height = height * (window.devicePixelRatio || 1);
    canvas.style.height = height + 'px';
  }, [height]);

  return (
    <canvas ref={canvasRef} style={{
      width: '100%', height, display: 'block', borderRadius: 8,
      background: PIPI.paperLt, border: `1.5px solid ${PIPI.ink}`,
    }}/>
  );
}

function DevTesterPanel({ scene, time, duration, transcript, heardChoice, lastDebug, playbackPhase, listening }) {
  const [open, setOpen] = React.useState(true);
  const mic = useMicState();
  const stateLabel = ({
    prompt:      'čaká na povolenie',
    granted:     '✓ povolený',
    denied:      '✗ zamietnutý',
    unavailable: '✗ nedostupný',
    error:       '✗ chyba',
  })[mic.state] || mic.state;

  return (
    <div style={{
      margin: '14px 14px 24px', background: PIPI.paperLt,
      border: `2.5px dashed ${PIPI.inkSoft}`, borderRadius: 14,
      padding: '10px 12px',
      fontFamily: '"JetBrains Mono", monospace', fontSize: 11, color: PIPI.ink,
      maxHeight: 360, overflowY: 'auto',
    }}>
      <div onClick={() => setOpen((v) => !v)} style={{
        display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
        cursor: 'pointer', marginBottom: 6,
      }}>
        <span style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 20, color: PIPI.red }}>
          🔧 dev / tester panel
        </span>
        <span style={{ fontSize: 10, opacity: 0.6 }}>
          {open ? 'klikni pre skrytie' : 'klikni pre zobrazenie'}
        </span>
      </div>
      {open && (
        <>
          <div style={{ marginBottom: 6, display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }}>
            <span style={{ opacity: 0.6 }}>mikrofón:</span>
            <b style={{ color: mic.state === 'granted' ? PIPI.green : mic.state === 'denied' ? PIPI.red : PIPI.ink }}>
              {stateLabel}
            </b>
            {mic.error && <span style={{ opacity: 0.7 }}>· {mic.error.slice(0, 80)}</span>}
            {mic.state === 'prompt' && (
              <button onClick={() => primeMicStream()} style={{
                background: PIPI.yellow, border: `2px solid ${PIPI.ink}`, borderRadius: 999,
                padding: '2px 10px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 14,
                cursor: 'pointer',
              }}>povoliť teraz</button>
            )}
          </div>

          {mic.state === 'granted' && (
            <div style={{ marginBottom: 8 }}>
              <div style={{ opacity: 0.6, marginBottom: 4 }}>live amplituda (povedz „skús" do mikrofónu):</div>
              <MicWaveform active={true} height={56}/>
            </div>
          )}

          <div style={{ display: 'grid', gap: 2, marginBottom: 4 }}>
            <div><span style={{ opacity: 0.55 }}>scéna:</span> kapitola {scene && scene.chapter}/{scene && scene.total} · {time.toFixed(1)}s / {(duration || 0).toFixed(1)}s</div>
            <div><span style={{ opacity: 0.55 }}>fáza prehrávania:</span> <b>{playbackPhase}</b>{listening ? ' · listening' : ''}</div>
            {scene && scene.bridgeAudio && (
              <div><span style={{ opacity: 0.55 }}>bridge mp3:</span> {scene.bridgeAudio.split('/').pop()}</div>
            )}
            {transcript && (
              <div><span style={{ opacity: 0.55 }}>počul som:</span> „{transcript}"{heardChoice ? ' → ' + heardChoice : ''}</div>
            )}
            {lastDebug && (
              <div><span style={{ opacity: 0.55 }}>vetva:</span> „{lastDebug.heard}" → {lastDebug.choice}{lastDebug.confidence ? ` (${Math.round(lastDebug.confidence * 100)}%)` : ''}</div>
            )}
            <div style={{ opacity: 0.45, marginTop: 4 }}>
              user-agent: {typeof navigator !== 'undefined' && navigator.userAgent ? navigator.userAgent.slice(0, 120) : '?'}
            </div>
          </div>
        </>
      )}
    </div>
  );
}

// ───────────────────────────────────────────────────────────────
// SceneProgress — per-scene timeline. ONE dot at the right edge
// (the next question / end of book). Pipi walks from the left edge
// to that dot as the audio plays. Once the child answers and the
// next scene begins, this component receives a new `scene` and
// resets: Pipi is back at the left, walking toward the next dot.
//
// Why per-scene instead of a whole-book chain:
//   • Variations have different total runtimes, so a global
//     timeline lies about progress.
//   • Each "stretch" between two questions is what actually
//     matters to a child — "how close am I to the next choice?"
//   • The dot doubles as the −5 s anchor: skipping back is
//     clamped to the start of the CURRENT scene, never escapes
//     into a chapter the child has already committed.
//
// The right-edge marker changes shape:
//   • Mid-book scene with splitAtSec  → ❓ question dot
//   • Final scene (scene.end === true) → 🌙 end-of-story dot
// ───────────────────────────────────────────────────────────────
function SceneProgress({ scene, time }) {
  if (!scene) return null;
  const sceneStart = scene.startAtSec || 0;
  const sceneEnd = scene.splitAtSec || scene.endAtSec || (sceneStart + 60);
  const sceneLen = Math.max(0.5, sceneEnd - sceneStart);
  const elapsed = Math.max(0, (time || 0) - sceneStart);
  const frac = Math.min(1, elapsed / sceneLen);
  const isEndScene = !!scene.end;

  // Hybrid layout: SVG with preserveAspectRatio="none" stretches the
  // wobbly LINE to fill the container width, but the chick + the
  // right-edge dot are HTML divs absolutely positioned over the line.
  // This keeps the chick legibly sized at every viewport width
  // instead of squishing horizontally on 375 px mobile.
  const W = 1000, H = 18;
  const padX = 30;
  const usable = W - padX * 2;
  const yMid = 9;

  return (
    <div style={{ padding: '22px 18px 8px' }}>
      <div style={{ position: 'relative', height: 48 }}>
        <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" width="100%" height={H * 2.4}
          style={{ position: 'absolute', top: '50%', left: 0, transform: 'translateY(-50%)', display: 'block' }}>
          {/* Wobbly background line spans the full width */}
          <path d={wobblyLinePath(padX, yMid, W - padX, yMid, { amp: 2.5, seed: 80, segs: 40 })}
                fill="none" stroke={PIPI.ink} strokeWidth={2.5} opacity={0.28}
                vectorEffect="non-scaling-stroke"/>
          {/* Travelled portion */}
          <path d={wobblyLinePath(padX, yMid, padX + usable * frac, yMid, { amp: 2.5, seed: 80, segs: Math.max(8, Math.round(40 * frac)) })}
                fill="none" stroke={PIPI.pink} strokeWidth={5} strokeLinecap="round"
                vectorEffect="non-scaling-stroke"/>
        </svg>
        {/* Right-edge dot (HTML-positioned so it doesn't squish) */}
        <div style={{
          position: 'absolute', top: '50%', right: 6, transform: 'translateY(-50%)',
          width: 30, height: 30, borderRadius: '50%',
          background: isEndScene ? PIPI.lilac : PIPI.yellow,
          border: `2.5px solid ${PIPI.ink}`,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 18, color: PIPI.ink,
          lineHeight: 1,
        }}>{isEndScene ? '★' : '?'}</div>
        {/* Pipi chick — HTML positioned at frac% from left edge */}
        <div style={{
          position: 'absolute', top: '50%',
          left: `calc(${(frac * 100).toFixed(2)}% - 17px + 18px - ${(frac * 36).toFixed(2)}px)`,
          transform: 'translateY(-50%)',
          width: 34, height: 30,
          transition: 'left 0.45s ease-out',
          pointerEvents: 'none',
        }}>
          <svg viewBox="0 0 34 30" width="34" height="30">
            <ellipse cx="17" cy="16" rx="14" ry="12" fill={PIPI.yellow} stroke={PIPI.ink} strokeWidth={2.5}/>
            <circle cx="12" cy="13" r="3" fill={PIPI.paperLt} stroke={PIPI.ink} strokeWidth={1.5}/>
            <circle cx="22" cy="13" r="3" fill={PIPI.paperLt} stroke={PIPI.ink} strokeWidth={1.5}/>
            <circle cx="12.5" cy="13" r="1.3" fill={PIPI.ink}/>
            <circle cx="22.5" cy="13" r="1.3" fill={PIPI.ink}/>
            <path d="M 14 19 L 9 21 L 14 23 Z" fill="#F2A65A" stroke={PIPI.ink} strokeWidth={1.4} strokeLinejoin="round"/>
          </svg>
        </div>
      </div>
    </div>
  );
}

// Legacy multi-dot ChapterPath kept for back-compat if any caller
// still imports it. Internally identical to the previous version.
function ChapterPath({ chainScenes, scene, time, duration }) {
  if (!chainScenes || chainScenes.length < 2) return null;
  const idx = chainScenes.findIndex((s) => (s.startAtSec || 0) === (scene.startAtSec || 0)
    && (s.splitAtSec || null) === (scene.splitAtSec || null));
  const N = chainScenes.length;

  // Dot positions weighted by ACTUAL splitAtSec of each chapter, not
  // by ordinal index. With evenly-spaced dots the mascot raced ahead
  // of the audio: in cirkus-test, dot 2 sat at 1/6 = 16.6 % of the
  // path even though split T1 fires at 28 s out of 300 s = 9.3 % of
  // playback. The user's report: "the chick arrived sooner than it
  // should have." Now each dot lives at startAtSec / total.
  const startTimes = chainScenes.map((s) => s.startAtSec || 0);
  const finalScene = chainScenes[N - 1];
  const lastSplit = chainScenes[N - 2] ? (chainScenes[N - 2].splitAtSec || startTimes[N - 1]) : startTimes[N - 1];
  // total = audio length of the whole book. For cirkus-test we cap at
  // endAtSec; otherwise we use the audio element's duration; otherwise
  // we fall back to last split + 90 s padding for the finale chapter.
  const total = finalScene.endAtSec
    || duration
    || Math.max(lastSplit + 90, startTimes[N - 1] + 60);
  const dotFractions = startTimes.map((s, i) => {
    if (i === N - 1) return 1.0;          // pin the last dot to the right edge
    return total > 0 ? Math.min(1, s / total) : i / (N - 1);
  });
  // Pipi rides actual time, not chapter index.
  const pipiFraction = total > 0
    ? Math.min(1, Math.max(0, (time || 0) / total))
    : 0;

  // The wobbly horizontal path lives in viewBox 0..1000 wide, 36 high
  // (shorter now that we removed labels — more room for the mic above).
  const W = 1000, H = 36;
  const padX = 30;
  const usable = W - padX * 2;
  const yMid = 18;
  const pipiX = padX + pipiFraction * usable;

  return (
    <div style={{ padding: '18px 18px 4px' }}>
      <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" width="100%" height={42}
        style={{ display: 'block', overflow: 'visible' }}>
        {/* Wobbly background line */}
        <path d={wobblyLinePath(padX, yMid, W - padX, yMid, { amp: 2.5, seed: 80, segs: 40 })}
              fill="none" stroke={PIPI.ink} strokeWidth={3} opacity={0.28}/>
        {/* Travelled portion in pink — clamped to Pipi's current position */}
        <path d={wobblyLinePath(padX, yMid, pipiX, yMid, { amp: 2.5, seed: 80, segs: Math.max(8, Math.round(40 * pipiFraction)) })}
              fill="none" stroke={PIPI.pink} strokeWidth={5} strokeLinecap="round"/>
        {/* Chapter dots — no labels per user request. Past = pink filled,
           current = yellow + larger, upcoming = cream. */}
        {chainScenes.map((s, i) => {
          const x = padX + dotFractions[i] * usable;
          const isPast = i < idx;
          const isCurrent = i === idx;
          const fill = isPast ? PIPI.pink : isCurrent ? PIPI.yellow : PIPI.paperLt;
          return (
            <g key={i} transform={`translate(${x} ${yMid})`}>
              <circle r={isCurrent ? 10 : 7} fill={fill} stroke={PIPI.ink} strokeWidth={2.5}/>
            </g>
          );
        })}
        {/* Pipi mini chick rides the line — true time-aligned position */}
        <g transform={`translate(${pipiX} ${yMid - 2})`} style={{ transition: 'transform 0.4s ease-out' }}>
          <ellipse cx="0" cy="-4" rx="12" ry="11" fill={PIPI.yellow} stroke={PIPI.ink} strokeWidth={2.5}/>
          <circle cx="-4" cy="-6" r="2.4" fill={PIPI.paperLt} stroke={PIPI.ink} strokeWidth={1.4}/>
          <circle cx="4" cy="-6" r="2.4" fill={PIPI.paperLt} stroke={PIPI.ink} strokeWidth={1.4}/>
          <circle cx="-3.5" cy="-6" r="1.1" fill={PIPI.ink}/>
          <circle cx="4.5" cy="-6" r="1.1" fill={PIPI.ink}/>
          <path d="M -2 -1 L -6 0 L -2 2 Z" fill="#F2A65A" stroke={PIPI.ink} strokeWidth={1.2} strokeLinejoin="round"/>
        </g>
      </svg>
    </div>
  );
}

// ───────────────────────────────────────────────────────────────
// −5 s back chip. Big curved arrow with explicit "−5" inside and
// "späť" caption underneath, so a 4-year-old can read what it does
// even before they tap. Replaces the previous tiny SVG glyph.
// ───────────────────────────────────────────────────────────────
function SkipBackChip({ lang, onClick, disabled = false }) {
  // Two-line caption under a hand-drawn loop arrow. The big "−5"
  // is the action; the small Slovak phrase under it makes the
  // intent unambiguous for a parent reading the UI for the first
  // time on mobile (where the icon alone might be ambiguous).
  return (
    <button onClick={onClick} disabled={disabled} style={{
      background: disabled ? PIPI.paperLt : PIPI.paper,
      border: `3px solid ${PIPI.ink}`, borderRadius: 20,
      padding: '8px 16px 8px 10px', cursor: disabled ? 'default' : 'pointer',
      opacity: disabled ? 0.4 : 1,
      display: 'flex', alignItems: 'center', gap: 10,
      boxShadow: disabled ? 'none' : '3px 4px 0 rgba(0,0,0,0.22)',
      transition: 'box-shadow 0.12s, transform 0.08s',
      WebkitTapHighlightColor: 'transparent',
      minHeight: 56,
    }}
      onMouseDown={(e) => { if (!disabled) e.currentTarget.style.transform = 'translate(1px, 2px)'; }}
      onMouseUp={(e) => { e.currentTarget.style.transform = ''; }}
      onMouseLeave={(e) => { e.currentTarget.style.transform = ''; }}
      aria-label={lang === 'en' ? 'Back 5 seconds' : 'Späť o 5 sekúnd'}>
      <svg viewBox="0 0 48 48" width={42} height={42} style={{ display: 'block', flexShrink: 0 }}>
        {/* Counter-clockwise arc, ~270° loop, leaves a clear arrow head */}
        <path d="M 38 14 Q 44 26 34 36 Q 20 44 10 30 Q 5 16 18 10 Q 30 6 38 14"
              fill="none" stroke={PIPI.ink} strokeWidth={3.5} strokeLinecap="round"/>
        {/* Arrowhead pointing down-left at the open end of the loop */}
        <path d="M 38 14 L 31 9 L 33 18 Z"
              fill={PIPI.ink} stroke={PIPI.ink} strokeWidth={2} strokeLinejoin="round"/>
      </svg>
      <div style={{
        display: 'flex', flexDirection: 'column', alignItems: 'flex-start', lineHeight: 1,
        fontFamily: '"Caveat", cursive', fontWeight: 700, color: PIPI.ink,
      }}>
        <span style={{ fontSize: 22, lineHeight: 1 }}>−5 sek.</span>
        <span style={{ fontSize: 13, fontFamily: '"Patrick Hand", cursive', fontWeight: 400, opacity: 0.78, marginTop: 3, lineHeight: 1 }}>
          {lang === 'en' ? 'go back' : 'späť'}
        </span>
      </div>
    </button>
  );
}

// New player. Story title + small description at top, pulsing mic in the
// center (active when listening for an answer), scrubbable Spotify-style
// timeline, ±5 s skip + play/pause. Heart is a working favorite toggle.
function DemoPlayerScreen({ story, scene, t, lang, onBack, onAskChoice, onChapterEnd,
                            devMode, lastDebug, isFavorite, onToggleFavorite,
                            listening = false, transcript = '', heardChoice = null,
                            onPickFromTranscript = null, onClearListening = null,
                            onSkipSplit = null,
                            playbackPhase = 'idle', phaseClipRef = null,
                            masterElRef = null,
                            consumeAutoStart = null,
                            chainScenes = [] }) {
  const audioSrc = scene.audio;
  // When the audio reaches scene.splitAtSec, we route to the choice
  // screen automatically — this is what makes the cirkus audiobook
  // interactive at minute 7. startAtSec lets a continuation node skip
  // straight to where the previous chapter ended.
  const onSplitReached = React.useCallback(() => {
    if (onAskChoice) onAskChoice();
  }, [onAskChoice]);
  const { playing, time, duration, toggle, skip, audioRef, setPlaying } = useAudio(audioSrc, {
    onEnd: onChapterEnd,
    startAtSec: scene.startAtSec || 0,
    splitAtSec: scene.splitAtSec || null,
    endAtSec: scene.endAtSec || null,
    onSplit: onSplitReached,
  });
  // For shortened variants (cirkus-test caps at 5 min) we display the
  // capped duration rather than the underlying MP3 length. Otherwise
  // the progress bar reads "0:00 / 15:40" when only the first 5 min
  // are actually playable.
  const displayDuration = scene.endAtSec ? Math.min(duration || scene.endAtSec, scene.endAtSec) : duration;
  const progress = displayDuration ? Math.min(1, time / displayDuration) : 0;

  // Hand the master <audio> element up to the parent so playOne can DUCK it
  // (volume ramp) at split boundaries instead of hard-cutting against clips.
  React.useEffect(() => {
    if (!masterElRef) return undefined;
    masterElRef.current = audioRef.current;
    return () => { if (masterElRef && masterElRef.current === audioRef.current) masterElRef.current = null; };
  }, [masterElRef, audioRef, audioSrc]);

  // Tell the OS who's playing so the lock-screen widget shows
  // "Pipi a tajomstvo …" instead of the bare URL. Updated on scene change.
  React.useEffect(() => {
    if (typeof navigator === 'undefined' || !navigator.mediaSession) return;
    try {
      const titleStr = (scene.title && (scene.title[lang] || scene.title.sk)) || (story && story.title) || 'Pipi';
      navigator.mediaSession.metadata = new window.MediaMetadata({
        title: titleStr,
        artist: 'rozpráva rozprávač',
        album: 'Pipi · ' + (lang === 'en' ? 'kids audiobook' : 'detská rozprávka'),
      });
    } catch {}
  }, [scene, story, lang]);

  // Warm the Web Audio DECODE cache for this scene's side-clips. Playback runs
  // through PipiAudio.play() (fetch + decodeAudioData buffer cache); the hidden
  // <audio preload="auto"> tags below only warm the unused HTMLMediaElement
  // cache, so without this every bridge/ack/tail COLD-decodes at the split —
  // the 100–300 ms silent gap the crossfade engine exists to remove. Fire on
  // scene mount so the bytes are decoded and ready before the split fires.
  React.useEffect(() => {
    if (!scene || typeof window === 'undefined' || !window.PipiAudio || !window.PipiAudio.preload) return;
    const urls = [scene.bridgeAudio, scene.reAskAudio, scene.fallbackAudio];
    (scene.choices || []).forEach((c) => { urls.push(c.ackAudio, c.tailAudio); });
    const wanted = urls.filter(Boolean);
    if (wanted.length) window.PipiAudio.preload(wanted);
  }, [scene]);

  // Auto-resume on scene transitions. After the very first user gesture
  // (the play button), the browser allows audio.play() across the same
  // page, so when we advance from cirkus_book → cirkus_split_2 (after
  // tail finishes) we kick playback off without making the user reach
  // for the play button again.
  //
  // The PRE-FIX bug the user reported: tail finished → scene advanced
  // → playback paused → user had to hit play → original recording then
  // continued at the wrong moment. Root cause: this effect was keyed
  // on audioSrc only. Inside one book, all scenes share the SAME mp3
  // (the original recording is one file, just played from different
  // startAtSec). So audioSrc never changed across scene transitions
  // and the auto-play never fired. Now we key on scene identity
  // (startAtSec + splitAtSec) instead, which DOES change per scene.
  // Wake Lock API — keeps the screen on while the story is playing.
  // Without it, Android Chrome / iOS Safari auto-lock the device
  // after a minute or two and the OS kills the mic stream. The
  // parent can't realistically keep tapping the screen alive.
  // Supported on iOS 16.4+ and Chrome Android 84+; on older browsers
  // we silently no-op so the story still plays without screen-on.
  // Phase-aware "is something playing" — declared HERE (before the wake-lock
  // effects that depend on it) to avoid a Temporal Dead Zone: effect dependency
  // arrays are evaluated eagerly at render, so playingNow must exist by then.
  const isClipPlaying = playbackPhase && playbackPhase !== 'idle' && playbackPhase !== 'listening';
  const clipPaused = (() => {
    if (!isClipPlaying || !phaseClipRef || !phaseClipRef.current) return false;
    return phaseClipRef.current.paused;
  })();
  const playingNow = isClipPlaying ? !clipPaused : playing;

  const wakeLockRef = React.useRef(null);
  React.useEffect(() => {
    let cancelled = false;
    async function acquire() {
      if (!playingNow && !listening) return;
      if (wakeLockRef.current) return;
      if (typeof navigator === 'undefined' || !navigator.wakeLock) return;
      try {
        const lock = await navigator.wakeLock.request('screen');
        if (cancelled) { try { lock.release(); } catch {} return; }
        wakeLockRef.current = lock;
        lock.addEventListener('release', () => { if (wakeLockRef.current === lock) wakeLockRef.current = null; });
      } catch { /* permission denied / not supported — ignore */ }
    }
    async function release() {
      if (wakeLockRef.current) {
        try { await wakeLockRef.current.release(); } catch {}
        wakeLockRef.current = null;
      }
    }
    if (playingNow || listening) acquire();
    else release();
    return () => { cancelled = true; release(); };
  }, [playingNow, listening]);

  // Re-acquire wake lock when the page comes back from being hidden
  // (tab switch / phone unlock). The browser auto-releases the lock
  // on visibility change; we need to re-request when visible again.
  React.useEffect(() => {
    function onVisible() {
      if (document.visibilityState === 'visible' && (playingNow || listening) && !wakeLockRef.current && navigator.wakeLock) {
        navigator.wakeLock.request('screen')
          .then((lock) => { wakeLockRef.current = lock; })
          .catch(() => {});
      }
    }
    document.addEventListener('visibilitychange', onVisible);
    return () => document.removeEventListener('visibilitychange', onVisible);
  }, [playingNow, listening]);

  const prevSceneRef = React.useRef({ src: audioSrc, startAt: scene.startAtSec, splitAt: scene.splitAtSec });
  React.useEffect(() => {
    const prev = prevSceneRef.current;
    const sceneChanged = prev.src !== audioSrc
      || prev.startAt !== (scene.startAtSec || 0)
      || prev.splitAt !== (scene.splitAtSec || null);
    if (!sceneChanged) return;
    prevSceneRef.current = { src: audioSrc, startAt: scene.startAtSec || 0, splitAt: scene.splitAtSec || null };
    const a = audioRef.current;
    if (!a) return;
    // The seek to startAtSec is eager (useAudio's src/startAtSec effect runs
    // in the same commit, before this one), so only a tick of delay is needed.
    // Was a blind 250ms (audit #11) — that gap was unmasked silence at the
    // divergence→continuation seam; onPicked now advances the scene ~180ms
    // before the tail ends, so this fade-in crossfades UP under the tail's
    // fade-out instead of starting after it.
    const id = setTimeout(() => {
      const PA = window.PipiAudio;
      const canRamp = PA && PA.available();
      if (canRamp) { try { a.volume = 0; } catch {} }
      a.play().then(() => {
        if (setPlaying) setPlaying(true);
        if (canRamp) PA.duck(a, 1, 260); else { try { a.volume = 1; } catch {} }
      }).catch(() => { try { a.volume = 1; } catch {} });
    }, 60);
    return () => clearTimeout(id);
  }, [audioSrc, scene.startAtSec, scene.splitAtSec, audioRef, setPlaying]);
  const mm = (s) => {
    if (!isFinite(s)) return '0:00';
    const m = Math.floor(s / 60), r = Math.floor(s % 60);
    return m + ':' + (r < 10 ? '0' : '') + r;
  };

  // Transport — phase aware. When a side clip is playing (bridge,
  // re-ask, fallback, tail) toggle + skip(-5) act on THAT clip; when
  // we're back to the main recording they act on the main element.
  // This was the source of the "I pressed pause during the question
  // and nothing happened" complaint. (isClipPlaying/clipPaused/playingNow
  // are derived earlier — before the wake-lock effects that depend on them.)
  const toggleWithRecap = React.useCallback(() => {
    // Prime the mic stream the first time the user taps play. This
    // call is INSIDE a real user-gesture handler so iOS Safari grants
    // the permission cleanly. Subsequent listening rounds reuse the
    // cached stream without re-prompting. Fire-and-forget — we don't
    // want to delay playback waiting for the mic.
    if (micState.state !== 'granted' && micState.state !== 'denied') {
      primeMicStream().catch(() => {});
    }
    // Unlock the Web Audio context INSIDE this gesture — iOS/Safari start it
    // suspended, and custom-story clips (PipiAudio) stay silent until it's
    // resumed from a real user tap. (Master recording uses HTMLAudio, which
    // a.play() below unlocks; this covers the Web Audio path.)
    try { if (window.PipiAudio && window.PipiAudio.unlock) window.PipiAudio.unlock(); } catch {}

    // 1) Side clip in flight → pause/resume the clip directly. Small recap on
    //    resume, but only if the clip has actually played a while — a 2 s recap
    //    on a 3 s bridge/reAsk would replay almost the whole thing (audit #16).
    if (isClipPlaying && phaseClipRef && phaseClipRef.current) {
      const c = phaseClipRef.current;
      if (c.paused) {
        try { if ((c.currentTime || 0) > 2.5) c.currentTime = Math.max(0, c.currentTime - 2); } catch {}
        c.play().catch(() => {});
      } else {
        c.pause();
      }
      return;
    }
    // 2) Otherwise, main recording — 2 s recap on resume, FLOORED at the chapter
    //    start (scene.startAtSec, matching skipBack5) so it can never rewind into
    //    the previous, already-committed chapter, and skipped if we only just
    //    entered the chapter (audit #16).
    const a = audioRef.current;
    if (!a) return;
    if (a.paused) {
      const floor = scene.startAtSec || 0;
      const cur = a.currentTime || 0;
      try { if (cur - floor > 2.5) a.currentTime = Math.max(floor, cur - 2); } catch {}
    }
    toggle();
  }, [audioRef, toggle, isClipPlaying, phaseClipRef, scene.startAtSec]);

  // −5 s skip, also phase-aware. Floors at scene.startAtSec so the
  // child can't escape the current chapter into already-committed
  // content. During a clip, the floor is 0 of the clip's own timeline.
  const skipBack5 = React.useCallback(() => {
    if (isClipPlaying && phaseClipRef && phaseClipRef.current) {
      const c = phaseClipRef.current;
      try { c.currentTime = Math.max(0, (c.currentTime || 0) - 5); } catch {}
      return;
    }
    const a = audioRef.current;
    if (!a || !isFinite(a.duration)) return;
    const floor = scene.startAtSec || 0;
    const next = Math.max(floor, (a.currentTime || 0) - 5);
    try { a.currentTime = next; } catch {}
  }, [audioRef, isClipPlaying, phaseClipRef, scene.startAtSec]);

  // One-shot autostart for the onboarding recommendation CTA ("Pustiť
  // príbeh" should start the story, not land on a paused player). The
  // parent hands us a CONSUMING flag-getter — true exactly once, right
  // after the wizard's play tap — so ordinary player opens never
  // auto-play. Transient user activation from that tap usually carries
  // into this mount effect; where a browser disagrees, .catch leaves
  // the normal big-play-button flow untouched. Appended LAST so the
  // existing hook order in this fragile component is unchanged.
  React.useEffect(() => {
    if (!consumeAutoStart || !consumeAutoStart()) return;
    const a = audioRef.current;
    if (!a || !a.paused) return;
    a.play().then(() => { if (setPlaying) setPlaying(true); }).catch(() => {});
    // mount-only by design — the flag is consumed on first check
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <div style={{ padding: '12px 16px 0', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
        <button onClick={onBack} style={{ background: 'transparent', border: 0, padding: 6, cursor: 'pointer' }}
          aria-label={lang === 'en' ? 'Back' : 'Späť'}>
          <IconBack size={30}/>
        </button>
        <div style={{
          background: PIPI.paperLt, border: `2.5px solid ${PIPI.ink}`, borderRadius: 999,
          padding: '4px 14px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 20,
          maxWidth: '60%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
        }}>
          {scene.title[lang] || scene.title.sk}
        </div>
        <button onClick={onToggleFavorite} style={{ background: 'transparent', border: 0, padding: 6, cursor: 'pointer' }}
          aria-label={isFavorite ? 'Odznačiť obľúbené' : 'Označiť ako obľúbené'}>
          <Heart size={28} fill={isFavorite ? PIPI.pink : 'transparent'}/>
        </button>
      </div>

      {/* Compact about-the-story strip */}
      <div style={{ padding: '12px 22px 0' }}>
        <div style={{
          background: PIPI.paperLt, border: `2.5px solid ${PIPI.ink}`, borderRadius: 18,
          padding: '10px 14px', fontSize: 15, lineHeight: 1.35, opacity: 0.9, textAlign: 'center',
        }}>
          {scene.text[lang] || scene.text.sk}
        </div>
      </div>

      {/* Centerpiece: pulsing mic. Smaller on mobile so the rest of
         the player isn't squished. matchMedia at module scope, not
         on every render, so this doesn't churn React state. */}
      <div style={{ padding: '14px 22px 0' }}>
        <PlayerPulsingMic active={listening}
          size={typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(max-width: 480px)').matches ? 150 : 200}/>
        <div aria-live="polite" aria-atomic="true" style={{ textAlign: 'center', marginTop: 14, minHeight: 28 }}>
          {listening ? (
            <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22, color: PIPI.red }}>
              {lang === 'en' ? '🎤 listening — what should Pipi do?' : '🎤 počúvam čo má Pipi urobiť'}
            </div>
          ) : transcript ? (
            <div style={{ fontSize: 16, opacity: 0.85 }}>
              <span style={{ opacity: 0.55, marginRight: 6 }}>{lang === 'en' ? 'heard:' : 'počul som:'}</span>
              <b>„{transcript}"</b>
            </div>
          ) : (playbackPhase === 'bridge' || playbackPhase === 'reAsk' || playbackPhase === 'fallback') && playingNow ? (
            <div style={{ fontSize: 14, opacity: 0.85, fontFamily: '"Caveat", cursive', fontWeight: 700, color: PIPI.lilac }}>
              {lang === 'en' ? '❓ narrator is asking' : '❓ rozprávač sa pýta'}
            </div>
          ) : playbackPhase === 'tail' && playingNow ? (
            <div style={{ fontSize: 14, opacity: 0.85, fontFamily: '"Caveat", cursive', fontWeight: 700, color: PIPI.green }}>
              {lang === 'en' ? '◆ continuing the story' : '◆ pokračuje príbeh'}
            </div>
          ) : playingNow ? (
            <div style={{ fontSize: 14, opacity: 0.7, fontFamily: '"Caveat", cursive', fontWeight: 700 }}>
              {lang === 'en' ? '◆ narrator is reading' : '◆ rozpráva rozprávač'}
            </div>
          ) : (
            <div style={{ fontSize: 14, opacity: 0.45 }}>
              {lang === 'en' ? 'paused' : 'pauza'}
            </div>
          )}
        </div>
      </div>

      {/* Hand-drawn chapter path. Replaces the old seconds-based bar
         because variations differ in length and progress only makes
         sense in CHAPTERS for the kid. Pipi's mini head sits on the
         current chapter; line segments between chapters show how far
         we are within the current one. */}
      {/* Per-scene timeline — single dot at the right edge marking
         the next question (or end-of-story for the final scene).
         Resets when scene advances. */}
      <SceneProgress scene={scene} time={time}/>

      {/* Hidden audio preloaders. Without these, `new Audio(src)` inside
         playOne() cold-starts a network request the moment a split
         fires — and on slow Android Chrome you get a 1–2 s gap of
         silence where the kid expects the narrator's question. We mount
         <audio preload="auto"> for every clip the current scene could
         play so the bytes are already in the browser cache by the time
         the split happens. */}
      <div aria-hidden="true" style={{ display: 'none' }}>
        {scene.bridgeAudio && <audio preload="auto" src={scene.bridgeAudio}/>}
        {scene.reAskAudio && <audio preload="auto" src={scene.reAskAudio}/>}
        {scene.fallbackAudio && <audio preload="auto" src={scene.fallbackAudio}/>}
        {Array.isArray(scene.choices) && scene.choices.map((c) => (
          <React.Fragment key={c.id}>
            {c.ackAudio  && <audio preload="auto" src={c.ackAudio}/>}
            {c.tailAudio && <audio preload="auto" src={c.tailAudio}/>}
          </React.Fragment>
        ))}
      </div>

      <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 18, padding: '18px 24px 0' }}>
        <SkipBackChip lang={lang} onClick={skipBack5} disabled={listening}/>
        <button onClick={toggleWithRecap} disabled={listening} style={{
          background: 'transparent', border: 0, padding: 0, cursor: listening ? 'default' : 'pointer',
          width: 96, height: 96, position: 'relative', opacity: listening ? 0.45 : 1,
        }} aria-label={playingNow ? (lang === 'en' ? 'Pause' : 'Pozastaviť') : (lang === 'en' ? 'Play' : 'Prehrať')}>
          <svg viewBox="0 0 96 96" width={96} height={96} style={{ position: 'absolute', inset: 0 }}>
            <path d={wobblyCirclePath(48, 50, 42, { amp: 2, seed: 90, segs: 22 })}
                  fill={PIPI.ink} opacity={0.18}/>
            <path d={wobblyCirclePath(48, 48, 42, { amp: 2, seed: 91, segs: 22 })}
                  fill={PIPI.yellow} stroke={PIPI.ink} strokeWidth={4}/>
          </svg>
          <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
            {playingNow ? <IconPause size={44}/> : <IconPlay size={44}/>}
          </div>
        </button>
      </div>

      {/* No bottom action button. Listening is automatic at every split
         point — no "Skús voľbu" / "Prestať počúvať" / "Vybrať cestu".
         The only way to leave the listening state is to actually answer
         (the mic auto-stops after ~5 s and the result is processed). */}
      {scene.end && (
        <div style={{ padding: '16px 22px 0', textAlign: 'center', fontSize: 16, opacity: 0.7 }}>
          {lang === 'en' ? 'End of this path. Open another story from the shelf.' : 'Koniec tejto cesty. Vyber inú rozprávku z police.'}
        </div>
      )}

      {/* Dev / tester panel — collapsible, live mic waveform, last
         transcript + classifier, user agent. Only shown when Rodič
         → "Transkript režim (pre testerov)" is on. */}
      {devMode && (
        <DevTesterPanel
          scene={scene} time={time} duration={duration}
          transcript={transcript} heardChoice={heardChoice}
          lastDebug={lastDebug} playbackPhase={playbackPhase}
          listening={listening}
        />
      )}

      <div style={{ flex: 1 }}/>
    </>
  );
}

// ──────────────────────────────────────────────────────────────
// Mic stream singleton — fixes the "iOS Safari never asks for
// mic permission" bug a tester hit on mobile. getUserMedia must
// be called inside a user gesture handler on iOS. The original
// chain was timeupdate → splitFired → askChoice → setPlayerListening
// → useSmartListening.useEffect → getUserMedia, which iOS treats
// as NON-gesture (the timeupdate event is not a user gesture).
//
// Fix: `primeMicStream()` is called from the play button click
// (a real gesture) and caches the resulting MediaStream globally.
// useSmartListening reuses that stream instead of asking again —
// so each subsequent listening round starts instantly with no
// permission prompt and no iOS gesture problem.
//
// micStateRef exposes the current state to the dev panel so we
// can show "granted / denied / prompt / unavailable" alongside
// the waveform.
const micState = {
  stream: null,         // MediaStream | null
  state: 'prompt',      // 'prompt' | 'granted' | 'denied' | 'unavailable' | 'error'
  error: '',
  listeners: new Set(),
};
function micNotify() { for (const fn of micState.listeners) { try { fn(); } catch {} } }
function useMicState() {
  const [, setT] = React.useState(0);
  React.useEffect(() => {
    const tick = () => setT((x) => x + 1);
    micState.listeners.add(tick);
    return () => micState.listeners.delete(tick);
  }, []);
  return micState;
}
async function primeMicStream() {
  if (micState.stream && micState.stream.active) return micState;
  if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
    micState.state = 'unavailable';
    micState.error = 'getUserMedia is not available in this browser';
    micNotify();
    return micState;
  }
  try {
    const s = await navigator.mediaDevices.getUserMedia({ audio: true });
    micState.stream = s;
    micState.state = 'granted';
    micState.error = '';
    micNotify();
    return micState;
  } catch (e) {
    const name = (e && e.name) || '';
    micState.state = (name === 'NotAllowedError' || name === 'SecurityError') ? 'denied' : 'error';
    micState.error = (e && e.message) || name || 'mic error';
    micNotify();
    return micState;
  }
}

// Always-listening mic for Smart-AI mode. Reuses the primed mic
// stream when available; falls back to a fresh getUserMedia call
// if priming hasn't happened yet (desktop dev / direct test).
function useSmartListening({ enabled, onResult, choices = null }) {
  // Keep the latest choices in a ref so the listening effect always sends the
  // CURRENT scene's choices to the classifier — without putting `choices` in
  // the effect deps (an inline array there would re-acquire the mic each render).
  const choicesRef = React.useRef(choices);
  choicesRef.current = choices;
  React.useEffect(() => {
    if (!enabled) return;
    let cancelled = false;
    let recorder = null;
    let stream = null;
    const chunks = [];
    const fail = (msg) => { if (!cancelled) onResult({ error: msg }); };

    (async () => {
      if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
        fail('Mikrofón nie je dostupný v tomto prehliadači.');
        return;
      }
      // Reuse the primed mic stream if play-button priming already
      // got the user's consent. Falls back to a fresh getUserMedia
      // call if priming never happened (e.g. desktop dev/test where
      // the user clicked Listen directly without hitting play first).
      let usingShared = false;
      if (micState.stream && micState.stream.active) {
        stream = micState.stream;
        usingShared = true;
      } else {
        try {
          stream = await navigator.mediaDevices.getUserMedia({ audio: true });
          micState.stream = stream;
          micState.state = 'granted';
          micNotify();
        } catch (e) {
          const nm = (e && e.name) || '';
          micState.state = (nm === 'NotAllowedError' || nm === 'SecurityError') ? 'denied' : 'error';
          micState.error = (e && e.message) || nm;
          micNotify();
          fail('Mikrofón nepovolený. Povol v nastaveniach prehliadača.');
          return;
        }
      }
      if (cancelled) {
        // Only stop the stream if it was NOT the shared singleton —
        // we want subsequent listening rounds to reuse the same one.
        if (!usingShared) stream.getTracks().forEach((trk) => trk.stop());
        return;
      }

      // Pick the first mime the browser actually supports. iOS Safari
      // does NOT support webm/opus — it only emits audio/mp4 (AAC).
      // Desktop Chrome/Firefox support webm/opus. We MUST forward the
      // real mime to the server, otherwise Whisper rejects the payload
      // because the file extension we save it under disagrees with the
      // bytes.
      const candidates = [
        'audio/webm;codecs=opus',
        'audio/webm',
        'audio/mp4;codecs=mp4a.40.2',
        'audio/mp4',
        'audio/aac',
        'audio/mpeg',
      ];
      const mime = candidates.find((c) => {
        try { return typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(c); } catch { return false; }
      }) || '';
      try {
        recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
      } catch (e) {
        fail('Nahrávač zlyhal: ' + (e && e.message ? e.message : 'unknown'));
        stream.getTracks().forEach((trk) => trk.stop());
        return;
      }
      recorder.ondataavailable = (ev) => { if (ev.data && ev.data.size > 0) chunks.push(ev.data); };
      recorder.onstop = async () => {
        // Only stop tracks we OWN. When we borrowed the primed shared singleton
        // (usingShared) it must stay alive for the session — stopping it here
        // killed the mic so the next listening round silently re-prompted/failed.
        if (!usingShared) stream.getTracks().forEach((trk) => trk.stop());
        if (cancelled) return;
        const blobMime = recorder.mimeType || mime || 'audio/webm';
        const blob = new Blob(chunks, { type: blobMime });
        // Anything under ~1.5 KB is silence / a stillborn chunk — skip
        // the round-trip entirely and tell the child to try again.
        if (blob.size < 1500) {
          fail('Nepočul som ťa, skús znova.');
          return;
        }
        try {
          // lang rides on the query string because the body is raw audio.
          const sttRes = await fetch('/api/transcribe?lang=' + encodeURIComponent(currentLang()), {
            method: 'POST',
            headers: { 'Content-Type': blobMime },
            body: blob,
          });
          if (!sttRes.ok) {
            const e = await sttRes.json().catch(() => ({}));
            fail(e.error || ('STT zlyhal (' + sttRes.status + ')'));
            return;
          }
          const sttData = await sttRes.json();
          const text = (sttData.text || '').trim();
          if (!text) { fail('Nepočul som ťa, skús znova.'); return; }
          // CRITICAL: send the current scene's choices so the classifier
          // matches against the right universe. Without these, the API
          // falls back to the legacy hardcoded showcase options and
          // returns `unclear` for every authored audiobook answer.
          const clsRes = await fetch('/api/classify-choice', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              answer: text,
              provider: 'ai',
              choices: Array.isArray(choicesRef.current) && choicesRef.current.length ? choicesRef.current : undefined,
            }),
          });
          const clsData = clsRes.ok ? await clsRes.json() : null;
          if (cancelled) return;
          onResult({ text, choice: clsData && clsData.choice, confidence: clsData && clsData.confidence });
        } catch (e) {
          fail('Sieť zlyhala: ' + (e && e.message ? e.message : 'unknown'));
        }
      };
      // Start with a timeslice so dataavailable fires periodically — that
      // way even if onstop races the final flush we still have chunks
      // from earlier in the recording.
      try { recorder.start(1000); } catch { try { recorder.start(); } catch {} }

      // Voice-activity detector. Goal: stop recording ~1 s after the
      // child stops speaking, instead of always waiting the full 5 s
      // hard cap. The user complained: "I already said what I wanted
      // and was still waiting." VAD makes the classifier feel instant.
      //
      // Implementation: tap the mic stream into an AnalyserNode, sample
      // RMS energy every 80 ms. State machine:
      //   waiting → heard speech (energy > ENTRY for SPEAK_MS) → SPEAKING
      //   SPEAKING → silence for SILENCE_MS → stop recorder early
      // We never stop in the first 600 ms (false-positive silence at
      // very start of recording) and we always hard-cap at HARD_MS.
      let audioCtx = null;
      let analyser = null;
      let sourceNode = null;
      let vadInterval = null;
      let stoppedEarly = false;
      try {
        const AC = window.AudioContext || window.webkitAudioContext;
        if (AC && stream) {
          audioCtx = new AC();
          sourceNode = audioCtx.createMediaStreamSource(stream);
          analyser = audioCtx.createAnalyser();
          analyser.fftSize = 1024;
          analyser.smoothingTimeConstant = 0.6;
          sourceNode.connect(analyser);
        }
      } catch { /* VAD optional — hard-cap still applies */ }

      const ENTRY = 0.018;      // RMS threshold to count as "speech started"
      const EXIT  = 0.012;      // below this for SILENCE_MS = "speech ended"
      const SPEAK_MS = 200;     // need this much continuous speech to arm
      const SILENCE_MS = 900;   // silence after speech ends → stop
      const HARD_MS = 5000;     // absolute cap
      const START_DEAD_MS = 500; // ignore VAD for first 500 ms (mic ramp-up)
      const t0 = Date.now();
      let speakStartAt = 0;
      let silenceStartAt = 0;
      let armed = false;
      if (analyser) {
        const buf = new Float32Array(analyser.fftSize);
        vadInterval = setInterval(() => {
          if (cancelled || !recorder || recorder.state !== 'recording') {
            try { clearInterval(vadInterval); } catch {}
            return;
          }
          const elapsed = Date.now() - t0;
          if (elapsed < START_DEAD_MS) return;
          analyser.getFloatTimeDomainData(buf);
          let sum = 0;
          for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i];
          const rms = Math.sqrt(sum / buf.length);
          if (!armed) {
            if (rms > ENTRY) {
              if (!speakStartAt) speakStartAt = Date.now();
              if (Date.now() - speakStartAt > SPEAK_MS) { armed = true; silenceStartAt = 0; }
            } else {
              speakStartAt = 0;
            }
          } else {
            if (rms < EXIT) {
              if (!silenceStartAt) silenceStartAt = Date.now();
              if (Date.now() - silenceStartAt > SILENCE_MS) {
                // Speech ended. Force final chunk flush, then stop.
                stoppedEarly = true;
                try { recorder.requestData(); } catch {}
                setTimeout(() => {
                  try { if (recorder && recorder.state === 'recording') recorder.stop(); } catch {}
                }, 80);
                try { clearInterval(vadInterval); } catch {}
              }
            } else {
              silenceStartAt = 0;
            }
          }
        }, 80);
      }

      // Hard cap — still required even with VAD in case the mic never
      // detects clear speech (background noise, classifier-only test).
      setTimeout(() => {
        if (stoppedEarly) return;
        try { if (recorder && recorder.state === 'recording') recorder.requestData(); } catch {}
      }, HARD_MS - 100);
      setTimeout(() => {
        if (stoppedEarly) return;
        try { if (recorder && recorder.state === 'recording') recorder.stop(); } catch {}
      }, HARD_MS);
    })();

    return () => {
      cancelled = true;
      try { if (recorder && recorder.state === 'recording') recorder.stop(); } catch {}
      // Only release the stream if we didn't borrow it from the
      // shared singleton — otherwise the next listening round
      // would have to re-prompt for permission.
      if (stream && stream !== micState.stream) {
        try { stream.getTracks().forEach((trk) => trk.stop()); } catch {}
      }
    };
  }, [enabled, onResult]);
}

// Choice screen that uses the scene's branches with i18n.
// Includes an inline "Pripravené | Smart AI" mode pill above the choices.
// Default mode: child taps the chosen branch directly.
// Smart AI mode: child speaks; we transcribe + classify and show the
// suggested branch — the child still confirms with a tap.
function DemoChoiceScreen({ scene, t, lang, onPick, onBack, voiceEnabled, choiceMode, onChoiceModeChange }) {
  const [smartState, setSmartState] = React.useState({ status: 'listening', text: '', choice: null, confidence: 0, error: '' });
  const isSmart = choiceMode === 'smart';

  // Reset listening state every time the smart mode is (re)activated.
  React.useEffect(() => {
    if (!isSmart) return;
    setSmartState({ status: 'listening', text: '', choice: null, confidence: 0, error: '' });
  }, [isSmart, scene]);

  const onSmartResult = React.useCallback((r) => {
    if (r.error) {
      setSmartState({ status: 'error', text: '', choice: null, confidence: 0, error: r.error });
      return;
    }
    setSmartState({ status: 'heard', text: r.text || '', choice: r.choice || null, confidence: r.confidence || 0, error: '' });
  }, []);

  // Project scene.choices into the classifier's expected shape:
  // { id, label, desc }. Keeps the API contract independent of the
  // i18n-shaped choice objects the demo uses internally.
  const sceneChoicesForClassifier = React.useMemo(() => {
    if (!scene || !Array.isArray(scene.choices)) return null;
    return scene.choices.map((c) => ({
      id: c.id,
      label: (c.label && (c.label.sk || c.label.en)) || '',
      desc:  (c.desc  && (c.desc.sk  || c.desc.en))  || '',
    }));
  }, [scene]);
  useSmartListening({
    enabled: isSmart && smartState.status === 'listening',
    onResult: onSmartResult,
    choices: sceneChoicesForClassifier,
  });

  // When the classifier maps to one of the legacy option_a/b/c keys AND
  // the scene's choices include matching ids (id ending in _a/_b/_c or
  // matching by index), we auto-highlight. Manual tap still required.
  const suggestedChoice = React.useMemo(() => {
    if (!smartState.choice || smartState.choice === 'unclear') return null;
    const map = { option_a: 0, option_b: 1, option_c: 2 };
    const idx = map[smartState.choice];
    if (idx == null) return null;
    return scene.choices[idx] || null;
  }, [smartState.choice, scene]);

  const palette = { green: PIPI.green, blue: PIPI.blue, coral: PIPI.coral, yellow: PIPI.yellow, lilac: PIPI.lilac, pink: PIPI.pink };
  const iconMap = { door: ChoiceIconDoor, run: ChoiceIconRun, knock: ChoiceIconKnock };
  // isSmart already declared at the top of this component when the
  // smart-listening hook is wired — don't re-declare here.
  const pillStyle = (on, accent) => ({
    background: on ? accent : 'transparent',
    border: `2.5px solid ${PIPI.ink}`, padding: '6px 14px', borderRadius: 999,
    cursor: 'pointer', fontFamily: '"Caveat", cursive', fontWeight: 700,
    fontSize: 18, color: PIPI.ink, lineHeight: 1.1,
    boxShadow: on ? `2px 3px 0 rgba(0,0,0,0.18)` : 'none',
    transition: 'background .12s, box-shadow .12s',
  });
  return (
    <>
      <div style={{ padding: '12px 18px 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
        <button onClick={onBack} style={{ background: 'transparent', border: 0, padding: 6, cursor: 'pointer' }}>
          <IconBack size={30}/>
        </button>
        <div style={{ display: 'flex', alignItems: 'center', gap: 6,
          background: PIPI.yellow, padding: '4px 12px', borderRadius: 18,
          border: `2.5px solid ${PIPI.ink}` }}>
          <Sparkle size={20}/>
          <span style={{ fontSize: 16 }}>{lang === 'en' ? 'choice' : 'voľba'}</span>
        </div>
        <div style={{ width: 30 }}/>
      </div>

      <div style={{ padding: '16px 24px 4px', textAlign: 'center' }}>
        <Pipi size={88}/>
      </div>

      <div style={{ padding: '0 24px', textAlign: 'center' }}>
        <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 28, lineHeight: 1.1 }}>
          {scene.question[lang] || scene.question.sk}
        </div>
      </div>

      {/* Mode toggle — Pripravené vs Smart AI vyberie. */}
      <div style={{ display: 'flex', justifyContent: 'center', gap: 8, padding: '10px 22px 0' }}>
        <button onClick={() => onChoiceModeChange('prepared')} style={pillStyle(!isSmart, PIPI.yellow)}>
          {lang === 'en' ? 'Prepared' : 'Pripravené'}
        </button>
        <button onClick={() => onChoiceModeChange('smart')} style={pillStyle(isSmart, PIPI.lilac)}>
          {lang === 'en' ? '✦ Smart AI picks' : '✦ Smart AI vyberie'}
        </button>
      </div>

      {isSmart && (
        <div style={{ padding: '10px 22px 0' }}>
          {smartState.status === 'listening' && (
            <div style={{
              display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
              background: PIPI.lilac, border: `3px solid ${PIPI.ink}`, borderRadius: 22,
              padding: '12px 16px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22,
            }}>
              <span style={{
                width: 12, height: 12, borderRadius: 6, background: PIPI.pink,
                animation: 'pipiBlink 0.9s ease-in-out infinite',
              }}/>
              Pipi počúva… povedz odpoveď
            </div>
          )}
          {smartState.status === 'heard' && (
            <div style={{
              background: PIPI.paperLt, border: `3px solid ${PIPI.ink}`, borderRadius: 18,
              padding: '12px 14px',
            }}>
              <div style={{ fontSize: 14, opacity: 0.65, marginBottom: 4 }}>počul som:</div>
              <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22, lineHeight: 1.2 }}>
                „{smartState.text}"
              </div>
              {suggestedChoice && (
                <div style={{ fontSize: 14, opacity: 0.75, marginTop: 8 }}>
                  navrhujem: <b>{suggestedChoice.label[lang] || suggestedChoice.label.sk}</b>
                  {smartState.confidence ? ` (istota ${Math.round(smartState.confidence * 100)}%)` : ''}
                </div>
              )}
            </div>
          )}
          {smartState.status === 'error' && (
            <div style={{
              background: PIPI.pinkLt, border: `3px solid ${PIPI.ink}`, borderRadius: 14,
              padding: '10px 14px', fontSize: 15,
            }}>{smartState.error} <button onClick={() => setSmartState({ status: 'listening', text: '', choice: null, confidence: 0, error: '' })}
              style={{
                marginLeft: 8, background: 'transparent', border: 0, textDecoration: 'underline',
                cursor: 'pointer', font: 'inherit',
              }}>skús znova</button></div>
          )}
          <style>{`@keyframes pipiBlink { 0%, 100% { opacity: 1 } 50% { opacity: 0.3 } }`}</style>
        </div>
      )}

      <div style={{ padding: '14px 22px 0', display: 'grid', gap: 12 }}>
        {scene.choices.map((c, i) => {
          const Icon = iconMap[c.icon] || ChoiceIconDoor;
          const isSuggested = suggestedChoice && suggestedChoice.id === c.id;
          return (
            <button key={c.id} onClick={() => onPick(c)} style={{
              display: 'flex', gap: 14, alignItems: 'center', textAlign: 'left',
              padding: 12,
              background: isSuggested ? PIPI.yellow : PIPI.paperLt,
              border: `3.5px solid ${PIPI.ink}`, borderRadius: 22, cursor: 'pointer',
              fontFamily: '"Patrick Hand", cursive', color: PIPI.ink,
              transform: `rotate(${i % 2 === 0 ? -0.4 : 0.5}deg)`,
              boxShadow: isSuggested ? '5px 6px 0 rgba(0,0,0,0.22)' : 'none',
              transition: 'background .15s, box-shadow .15s',
            }}>
              <div style={{ width: 64, height: 64, background: palette[c.color] || PIPI.yellow,
                border: `3px solid ${PIPI.ink}`, borderRadius: 16,
                display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
                <Icon/>
              </div>
              <div style={{ flex: 1 }}>
                <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 24, lineHeight: 1 }}>
                  {c.label[lang] || c.label.sk}
                </div>
                <div style={{ fontSize: 16, opacity: 0.75, marginTop: 2 }}>{c.desc[lang] || c.desc.sk}</div>
              </div>
              <div style={{
                width: 36, height: 36, borderRadius: 18,
                background: PIPI.yellow, border: `2.5px solid ${PIPI.ink}`,
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                fontSize: 22, transform: 'rotate(-90deg)',
              }}>→</div>
            </button>
          );
        })}
      </div>

      {voiceEnabled && (
        <div style={{ padding: '12px 22px 0', textAlign: 'center', fontSize: 15, opacity: 0.6 }}>
          {t.choice_voice_hint}: <i>"{scene.choices[0].label[lang] || scene.choices[0].label.sk}"</i>
        </div>
      )}

      <div style={{ flex: 1 }}/>
    </>
  );
}

// ──────────────────────────────────────────────────────────────
// Settings — four sections (Account / Children / Listening / Privacy).
// Replaces the old single-scroll "Rodič" screen. Each section is a
// small focused component so the screen stays readable. The active
// section is lifted to DemoApp so the library's "Manage children"
// link can deep-link straight to the Deti tab.
// ──────────────────────────────────────────────────────────────
function SettingsCardTitle({ children }) {
  return (
    <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22, lineHeight: 1, marginBottom: 8 }}>
      {children}
    </div>
  );
}

// — Account: Supabase email+password (autoconfirm, no verification mail) —
//   replaces the old Worker /papi/api/auth/me + sign-out. The session lives
//   in supabase-js localStorage AND is mirrored into a first-party httpOnly
//   cookie (/api/auth/callback) so iOS standalone PWAs survive eviction.
function SettingsAccount({ lang, plan = 'trial', trialDaysLeft = 0, entitled = true, onSubscribe, onFamilyChanged }) {
  const [state, setState] = React.useState({ loading: true, user: null });
  const [email, setEmail] = React.useState('');
  const [password, setPassword] = React.useState('');
  const [busy, setBusy] = React.useState(false);
  const [authErr, setAuthErr] = React.useState('');
  const refetch = React.useCallback(() => {
    sbUser()
      .then((user) => setState({ loading: false, user }))
      .catch(() => setState({ loading: false, user: null }));
  }, []);
  React.useEffect(() => { refetch(); }, [refetch]);
  // "Pomôž nám rásť" — the how-did-you-hear question, moved here from the
  // old onboarding step 3 (value-first re-sequencing: the question belongs
  // after first value, never before it). Collapsible; saves into the same
  // onboarding blob + best-effort Supabase profile sync as before.
  const [growthOpen, setGrowthOpen] = React.useState(false);
  const [growth, setGrowth] = React.useState(() => {
    try {
      const o = JSON.parse(localStorage.getItem(ONBOARDING_KEY) || '{}');
      return { how_heard: o.how_heard || '', how_heard_note: o.how_heard_note || '' };
    } catch { return { how_heard: '', how_heard_note: '' }; }
  });
  const saveGrowth = React.useCallback((patch) => {
    setGrowth((prev) => {
      const next = { ...prev, ...patch };
      try {
        const o = JSON.parse(localStorage.getItem(ONBOARDING_KEY) || '{}');
        o.how_heard = next.how_heard;
        o.how_heard_note = next.how_heard_note;
        localStorage.setItem(ONBOARDING_KEY, JSON.stringify(o));
        // Same write path as onboarding always used — profiles row under RLS.
        syncOnboardingToSupabase(o, !!o.complete).catch(() => {});
      } catch {}
      return next;
    });
  }, []);
  const signOut = () => {
    if (!sb) { setState({ loading: false, user: null }); return; }
    sb.auth.signOut().catch(() => {})
      .then(() => setState({ loading: false, user: null }));
  };
  const doAuth = async (mode) => {
    if (!sb) { setAuthErr(lang === 'en' ? 'Sign-in unavailable.' : 'Prihlásenie nie je dostupné.'); return; }
    const mail = email.trim();
    if (!mail || password.length < 6) {
      setAuthErr(lang === 'en' ? 'Enter email and a password (min 6 chars).' : 'Zadaj e-mail a heslo (aspoň 6 znakov).');
      return;
    }
    setBusy(true); setAuthErr('');
    try {
      const { data, error } = mode === 'sign-up'
        ? await sb.auth.signUp({ email: mail, password })
        : await sb.auth.signInWithPassword({ email: mail, password });
      if (error) throw new Error(error.message);
      setPassword('');
      setState({ loading: false, user: (data && data.user) || null });
      // Reconcile this device's family state with the account: pull the
      // server family down (multi-device / shared-device), or push the
      // parked anonymous local state up on a first sign-in — see
      // adoptFamilyStateForUser for the who-wins rules.
      const authedUser = (data && data.user) || null;
      if (authedUser) {
        adoptFamilyStateForUser(authedUser)
          .then((r) => { if (r && r.changed && onFamilyChanged) onFamilyChanged(); })
          .catch(() => {});
      }
    } catch (e) {
      setAuthErr((lang === 'en' ? 'Failed: ' : 'Nepodarilo sa: ') + e.message);
    } finally {
      setBusy(false);
    }
  };
  return (
    <>
      <ScribbleCard style={{ padding: '14px 16px' }}>
      <SettingsCardTitle>{lang === 'en' ? 'Account' : 'Účet'}</SettingsCardTitle>
      {state.loading ? (
        <div style={{ fontSize: 15, opacity: 0.6 }}>{lang === 'en' ? 'Checking…' : 'Overujem…'}</div>
      ) : state.user ? (
        <>
          <div style={{ display: 'flex', alignItems: 'center', gap: 11 }}>
            <span style={{
              width: 42, height: 42, borderRadius: '50%', background: PIPI.yellow,
              border: `2.5px solid ${PIPI.ink}`, display: 'flex', alignItems: 'center',
              justifyContent: 'center', fontSize: 20,
            }}>🐤</span>
            <div style={{ flex: 1, minWidth: 0 }}>
              <div style={{ fontFamily: '"Patrick Hand", cursive', fontSize: 18, lineHeight: 1.1, wordBreak: 'break-all' }}>
                {state.user.email}
              </div>
              <div style={{ fontSize: 13, opacity: 0.65 }}>{lang === 'en' ? 'Beta — free' : 'Beta — zadarmo'}</div>
            </div>
          </div>
          <DotDividerLocal/>
          <button onClick={signOut} style={{
            background: 'transparent', border: `2.5px solid ${PIPI.ink}`, borderRadius: 999,
            padding: '6px 16px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 17,
            color: PIPI.ink, cursor: 'pointer',
          }}>{lang === 'en' ? 'Sign out' : 'Odhlásiť sa'}</button>
        </>
      ) : (
        <>
          <div style={{ fontSize: 15, opacity: 0.75, lineHeight: 1.45 }}>
            {lang === 'en'
              ? 'Not signed in. Listening works without an account; an account keeps your family’s profiles on every device.'
              : 'Neprihlásený. Počúvanie funguje aj bez účtu; s účtom má rodina svoje profily na každom zariadení.'}
          </div>
          <form onSubmit={(e) => { e.preventDefault(); doAuth('sign-in'); }}
            style={{ marginTop: 12, display: 'grid', gap: 8 }}>
            <input type="email" autoComplete="email" inputMode="email" autoCapitalize="off"
              value={email} onChange={(e) => setEmail(e.target.value)}
              placeholder={lang === 'en' ? 'email' : 'e-mail'}
              style={{
                width: '100%', border: `2.5px solid ${PIPI.ink}`, borderRadius: 12,
                padding: '10px 12px', font: 'inherit', fontSize: 16,
                background: PIPI.paperLt, color: PIPI.ink, minHeight: 44,
              }}/>
            <input type="password" autoComplete="current-password"
              value={password} onChange={(e) => setPassword(e.target.value)}
              placeholder={lang === 'en' ? 'password (min 6 chars)' : 'heslo (aspoň 6 znakov)'}
              style={{
                width: '100%', border: `2.5px solid ${PIPI.ink}`, borderRadius: 12,
                padding: '10px 12px', font: 'inherit', fontSize: 16,
                background: PIPI.paperLt, color: PIPI.ink, minHeight: 44,
              }}/>
            {authErr && (
              <div style={{
                background: PIPI.pink, color: PIPI.paperLt, border: `2.5px solid ${PIPI.ink}`,
                borderRadius: 12, padding: '8px 12px', fontSize: 14, lineHeight: 1.35,
              }}>{authErr}</div>
            )}
            <div style={{ display: 'flex', gap: 8 }}>
              <button type="submit" disabled={busy} style={{
                flex: 1, background: PIPI.yellow, border: `2.5px solid ${PIPI.ink}`, borderRadius: 999,
                padding: '8px 16px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 18,
                color: PIPI.ink, cursor: busy ? 'default' : 'pointer', opacity: busy ? 0.6 : 1,
                boxShadow: '2px 3px 0 rgba(0,0,0,0.16)',
              }}>{busy ? (lang === 'en' ? 'Working…' : 'Pracujem…') : (lang === 'en' ? 'Sign in' : 'Prihlásiť sa')}</button>
              <button type="button" disabled={busy} onClick={() => doAuth('sign-up')} style={{
                flex: 1, background: 'transparent', border: `2.5px solid ${PIPI.ink}`, borderRadius: 999,
                padding: '8px 16px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 18,
                color: PIPI.ink, cursor: busy ? 'default' : 'pointer', opacity: busy ? 0.6 : 1,
              }}>{lang === 'en' ? 'Create account' : 'Vytvoriť účet'}</button>
            </div>
          </form>
        </>
      )}
      </ScribbleCard>

      <ScribbleCard color={entitled ? PIPI.green : PIPI.paperLt} style={{ padding: '14px 16px' }}>
        <SettingsCardTitle>{lang === 'en' ? 'Plan' : 'Plán'}</SettingsCardTitle>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
          <div style={{ fontFamily: '"Patrick Hand", cursive', fontSize: 18 }}>
            {plan === 'family'
              ? (lang === 'en' ? 'Pipi Family · active' : 'Pipi Family · aktívne')
              : trialDaysLeft > 0
                ? (lang === 'en' ? `Free trial · ${trialDaysLeft} day(s) left` : `Skúšobné · ${trialDaysLeft} dní zostáva`)
                : (lang === 'en' ? 'Free — 1 book' : 'Bezplatné — 1 kniha')}
          </div>
          {plan !== 'family' && (
            <button onClick={onSubscribe} style={{
              background: PIPI.yellow, border: `2.5px solid ${PIPI.ink}`, borderRadius: 999,
              padding: '6px 14px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 16,
              color: PIPI.ink, cursor: 'pointer', whiteSpace: 'nowrap',
            }}>{lang === 'en' ? 'Subscribe' : 'Predplatiť'}</button>
          )}
        </div>
      </ScribbleCard>

      <ScribbleCard style={{ padding: '14px 16px' }}>
        <button onClick={() => setGrowthOpen((v) => !v)} style={{
          width: '100%', background: 'transparent', border: 0, padding: 0, cursor: 'pointer',
          display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10,
          fontFamily: 'inherit', color: PIPI.ink, textAlign: 'left',
        }}>
          <div>
            <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22, lineHeight: 1 }}>
              {demoT(lang).growth_t}
            </div>
            <div style={{ fontSize: 14, opacity: 0.65, marginTop: 2 }}>
              {demoT(lang).growth_q} {demoT(lang).growth_s}
            </div>
          </div>
          <span style={{
            fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22,
            transform: growthOpen ? 'rotate(90deg)' : 'none', transition: 'transform .15s',
          }}>→</span>
        </button>
        {growthOpen && (
          <>
            <DotDividerLocal/>
            <div style={{ display: 'grid', gap: 8 }}>
              {HOW_HEARD_OPTIONS.map((opt) => (
                <label key={opt.id} style={{
                  display: 'flex', alignItems: 'center', gap: 10,
                  padding: '8px 12px', borderRadius: 12,
                  background: growth.how_heard === opt.id ? PIPI.yellow : PIPI.paperLt,
                  border: `2.5px solid ${PIPI.ink}`,
                  cursor: 'pointer', fontFamily: '"Patrick Hand", cursive', fontSize: 16,
                }}>
                  <input type="radio" name="how_heard_settings" value={opt.id}
                    checked={growth.how_heard === opt.id}
                    onChange={() => saveGrowth({ how_heard: opt.id })}
                    style={{ width: 18, height: 18 }}
                  />
                  {opt.label}
                </label>
              ))}
            </div>
            {growth.how_heard === 'other' && (
              <input
                type="text" value={growth.how_heard_note || ''}
                onChange={(e) => saveGrowth({ how_heard_note: e.target.value })}
                placeholder={demoT(lang).growth_other_ph}
                style={{
                  marginTop: 8, width: '100%', border: `2px solid ${PIPI.ink}`, borderRadius: 10,
                  padding: '8px 12px', fontFamily: 'inherit', fontSize: 15,
                  background: PIPI.paperLt, color: PIPI.ink, outline: 'none',
                }}
              />
            )}
            {growth.how_heard && (
              <div style={{ fontSize: 14, opacity: 0.65, marginTop: 8 }}>{demoT(lang).growth_thanks}</div>
            )}
          </>
        )}
      </ScribbleCard>
    </>
  );
}

// — Children: the management surface for the privacy-first profiles.
//   Add / remove kids, change each age, cycle the avatar, pick who is
//   active. Names are never asked for or stored.
function SettingsKids({ kids, setKids, activeKidId, setActiveKidId, lang }) {
  const MAX_KIDS = 6;
  const changeAge = (id, d) => setKids((prev) => prev.map((k) =>
    k.id === id ? { ...k, age: Math.max(1, Math.min(14, k.age + d)) } : k));
  const cycleAvatar = (id) => setKids((prev) => prev.map((k) => {
    if (k.id !== id) return k;
    const i = KID_AVATARS.indexOf(k.avatar);
    return { ...k, avatar: KID_AVATARS[(i + 1) % KID_AVATARS.length] };
  }));
  const removeKid = (id) => setKids((prev) => (prev.length <= 1 ? prev : prev.filter((k) => k.id !== id)));
  const addKid = () => setKids((prev) => {
    if (prev.length >= MAX_KIDS) return prev;
    const used = new Set(prev.map((k) => k.avatar));
    const avatar = KID_AVATARS.find((a) => !used.has(a)) || KID_AVATARS[prev.length % KID_AVATARS.length];
    return [...prev, { id: `k${Date.now()}`, age: 5, avatar }];
  });
  const stepBtn = {
    width: 34, height: 34, borderRadius: '50%', border: `2.5px solid ${PIPI.ink}`,
    background: PIPI.paperLt, fontSize: 20, lineHeight: 1, cursor: 'pointer', color: PIPI.ink,
    display: 'flex', alignItems: 'center', justifyContent: 'center',
  };
  return (
    <ScribbleCard style={{ padding: '14px 16px' }}>
      <SettingsCardTitle>{lang === 'en' ? 'Children' : 'Deti'}</SettingsCardTitle>
      <div style={{ fontSize: 14, opacity: 0.65, marginTop: -4, marginBottom: 10, lineHeight: 1.4 }}>
        {lang === 'en'
          ? 'No names — just an age and a face each child recognizes.'
          : 'Žiadne mená — len vek a tvár, ktorú dieťa spozná.'}
      </div>
      <div style={{ display: 'grid', gap: 10 }}>
        {kids.map((k) => {
          const on = k.id === activeKidId;
          return (
            <div key={k.id} style={{
              display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px',
              background: on ? PIPI.yellow : PIPI.paperLt, border: `2.5px solid ${PIPI.ink}`, borderRadius: 14,
            }}>
              <button onClick={() => cycleAvatar(k.id)} title={lang === 'en' ? 'Change face' : 'Zmeniť tvár'} style={{
                width: 46, height: 46, borderRadius: '50%', background: PIPI.paper,
                border: `2.5px solid ${PIPI.ink}`, fontSize: 24, cursor: 'pointer', flexShrink: 0,
              }}>{k.avatar}</button>
              <div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
                <button onClick={() => changeAge(k.id, -1)} style={stepBtn} aria-label="−">−</button>
                <span style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22, minWidth: 44, textAlign: 'center' }}>
                  {ageLabel(k.age, lang)}
                </span>
                <button onClick={() => changeAge(k.id, +1)} style={stepBtn} aria-label="+">+</button>
              </div>
              {on ? (
                <span style={{
                  fontFamily: '"Patrick Hand", cursive', fontSize: 13, opacity: 0.7,
                  border: `2px solid ${PIPI.ink}`, borderRadius: 999, padding: '2px 9px',
                }}>{lang === 'en' ? 'active' : 'aktívne'}</span>
              ) : (
                <button onClick={() => setActiveKidId(k.id)} style={{
                  fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 15, cursor: 'pointer',
                  background: 'transparent', border: `2px solid ${PIPI.ink}`, borderRadius: 999,
                  padding: '3px 11px', color: PIPI.ink,
                }}>{lang === 'en' ? 'use' : 'zvoliť'}</button>
              )}
              {kids.length > 1 && (
                <button onClick={() => removeKid(k.id)} aria-label={lang === 'en' ? 'Remove' : 'Odstrániť'} style={{
                  width: 30, height: 30, borderRadius: '50%', border: `2px solid ${PIPI.ink}`,
                  background: 'transparent', fontSize: 16, cursor: 'pointer', color: PIPI.ink, flexShrink: 0,
                }}>×</button>
              )}
            </div>
          );
        })}
      </div>
      {kids.length < MAX_KIDS && (
        <button onClick={addKid} style={{
          marginTop: 12, width: '100%', background: 'transparent', border: `2.5px dashed ${PIPI.ink}`,
          borderRadius: 14, padding: '9px 0', fontFamily: '"Caveat", cursive', fontWeight: 700,
          fontSize: 18, color: PIPI.ink, cursor: 'pointer',
        }}>{lang === 'en' ? '+ Add a child' : '+ Pridať dieťa'}</button>
      )}
    </ScribbleCard>
  );
}

// — Listening: the runtime toggles that used to live on the old screen,
//   plus real per-kid listening time + a daily limit.
function SettingsListening({ lang, voiceInput, setVoiceInput, choiceMode, setChoiceMode, devMode, setDevMode, onLangChange, t, dailyLimitMin = 0, setDailyLimitMin, todaySec = 0 }) {
  const presets = [0, 15, 30, 45, 60];
  const todayMin = Math.round((todaySec || 0) / 60);
  // Evening preferences — same storage the post-first-session opt-in
  // screen writes (DEMO_BEDTIME_KEY / DEMO_QUIET_KEY), so whatever the
  // parent chose there shows up here and stays editable.
  const [bedReminder, setBedReminder] = React.useState(() => loadJSON(DEMO_BEDTIME_KEY, { enabled: false, time: '19:30' }));
  const [quietHours, setQuietHours] = React.useState(() => loadJSON(DEMO_QUIET_KEY, { enabled: false }));
  const patchReminder = (p) => setBedReminder((prev) => { const n = { ...prev, ...p }; saveJSON(DEMO_BEDTIME_KEY, n); return n; });
  const setQuietOn = (v) => setQuietHours((prev) => { const n = { ...prev, enabled: !!v }; saveJSON(DEMO_QUIET_KEY, n); return n; });
  return (
    <>
      <ScribbleCard color={PIPI.blue} style={{ padding: '14px 16px' }}>
        <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
          <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22, lineHeight: 1 }}>
            {lang === 'en' ? 'Listened today' : 'Dnes počúval(a)'}
          </div>
          <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 30, lineHeight: 1 }}>
            {todayMin} <span style={{ fontSize: 16, opacity: 0.7 }}>min</span>
          </div>
        </div>
        <DotDividerLocal/>
        <div style={{ fontFamily: '"Patrick Hand", cursive', fontSize: 18, marginBottom: 8 }}>
          {lang === 'en' ? 'Daily limit' : 'Denný limit'}
        </div>
        <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
          {presets.map((m) => {
            const on = dailyLimitMin === m;
            return (
              <button key={m} onClick={() => setDailyLimitMin && setDailyLimitMin(m)} style={{
                flex: '1 0 auto', minWidth: 56, background: on ? PIPI.yellow : PIPI.paperLt,
                border: `2.5px solid ${PIPI.ink}`, borderRadius: 999, padding: '6px 10px',
                fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 16, color: PIPI.ink, cursor: 'pointer',
                boxShadow: on ? '2px 3px 0 rgba(0,0,0,0.16)' : 'none',
              }}>{m === 0 ? (lang === 'en' ? 'No limit' : 'Bez limitu') : `${m} min`}</button>
            );
          })}
        </div>
      </ScribbleCard>

      <ScribbleCard style={{ padding: '12px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22, lineHeight: 1 }}>
            {lang === 'en' ? 'Narrator voice' : 'Hlas rozprávača'}
          </div>
          <div style={{ fontSize: 14, opacity: 0.7, marginTop: 4 }}>
            {lang === 'en' ? 'The storyteller voice that reads every story.' : 'Rozprávkový hlas, ktorý číta všetky príbehy.'}
          </div>
        </div>
        <div style={{
          background: PIPI.yellow, border: `2.5px solid ${PIPI.ink}`, borderRadius: 999,
          padding: '4px 12px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 18,
        }}>Pipi</div>
      </ScribbleCard>

      <ScribbleCard style={{ padding: '12px 16px' }}>
        <ToggleRowLocal label={t.voice_in_t} sub={t.voice_in_s} on={voiceInput} onChange={setVoiceInput}/>
        <DotDividerLocal/>
        <ToggleRowLocal
          label={lang === 'en' ? 'English interface' : 'Anglické rozhranie'}
          sub={lang === 'en' ? 'Switch the app language to English' : 'Prepne jazyk aplikácie na angličtinu'}
          on={lang === 'en'} onChange={(v) => onLangChange(v ? 'en' : 'sk')}/>
        {SHOW_DEV_BOOKS && <DotDividerLocal/>}
        {/* Tester-only transcript strip — hidden from real parents; enable with ?dev=1 */}
        {SHOW_DEV_BOOKS && <ToggleRowLocal label={t.dev_t} sub={t.dev_s} on={devMode} onChange={setDevMode}/>}
      </ScribbleCard>

      <ScribbleCard style={{ padding: '12px 16px' }}>
        {/* Evening: bedtime reminder + quiet hours. Captured post-first-
           session; editable here. Preference capture only for now — the
           push channel / scene-ducking engine come later. */}
        <ToggleRowLocal label={t.ps_bed_t} sub={t.ps_bed_s}
          on={!!bedReminder.enabled} onChange={(v) => patchReminder({ enabled: !!v })}/>
        {bedReminder.enabled && (
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '2px 0 8px' }}>
            <span style={{ fontSize: 15, opacity: 0.7 }}>{lang === 'en' ? 'Time' : 'Čas'}:</span>
            <input type="time" value={bedReminder.time || '19:30'}
              onChange={(e) => patchReminder({ time: e.target.value || '19:30' })}
              style={{
                border: `2.5px solid ${PIPI.ink}`, borderRadius: 10, padding: '6px 10px',
                fontFamily: '"Patrick Hand", cursive', fontSize: 17, background: PIPI.paperLt, color: PIPI.ink,
              }}/>
          </div>
        )}
        <DotDividerLocal/>
        <ToggleRowLocal label={t.quiet_t} sub={t.ps_quiet_s}
          on={!!quietHours.enabled} onChange={setQuietOn}/>
      </ScribbleCard>
    </>
  );
}

// — Privacy: explains the name-free design, a functional parent PIN, and
//   lets a parent wipe local data.
function SettingsPrivacy({ lang, parentPin, setParentPin }) {
  const [draft, setDraft] = React.useState('');
  const [editing, setEditing] = React.useState(false);
  const onlyDigits = (s) => (s || '').replace(/\D/g, '').slice(0, 4);
  const savePin = () => {
    if (draft.length === 4) { setParentPin && setParentPin(draft); setDraft(''); setEditing(false); }
  };
  const exportData = () => {
    const keys = [ONBOARDING_KEY, DEMO_KIDS_KEY, DEMO_ACTIVE_KID_KEY, DEMO_FAVS_BY_KID_KEY,
      DEMO_RESUME_KEY, DEMO_TIME_KEY, DEMO_LIMIT_KEY, DEMO_PAYWALL_KEY];
    const data = {};
    keys.forEach((k) => { try { const v = localStorage.getItem(k); if (v != null) data[k] = JSON.parse(v); } catch { data[k] = localStorage.getItem(k); } });
    try {
      const blob = new Blob([JSON.stringify({ exportedAt: new Date().toISOString(), data }, null, 2)], { type: 'application/json' });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url; a.download = 'pipi-data.json'; document.body.appendChild(a); a.click();
      document.body.removeChild(a);
      setTimeout(() => URL.revokeObjectURL(url), 1000);
    } catch {}
  };
  const wipe = () => {
    const msg = lang === 'en'
      ? 'Clear all Pipi data saved on this device (profiles, favorites, settings)? This cannot be undone.'
      : 'Vymazať všetky údaje Pipi uložené na tomto zariadení (profily, obľúbené, nastavenia)? Toto sa nedá vrátiť.';
    if (!window.confirm(msg)) return;
    try {
      [DEMO_KIDS_KEY, DEMO_ACTIVE_KID_KEY, ONBOARDING_KEY, 'pipi.demo.favorites',
       'pipi.demo.choiceMode', DEMO_VOICE_KEY, DEMO_DEV_KEY].forEach((k) => localStorage.removeItem(k));
    } catch {}
    window.location.reload();
  };
  return (
    <>
      <ScribbleCard color={PIPI.blue} style={{ padding: '14px 16px' }}>
        <SettingsCardTitle>{lang === 'en' ? 'Privacy' : 'Súkromie'}</SettingsCardTitle>
        <div style={{ fontSize: 15, opacity: 0.85, lineHeight: 1.5 }}>
          {lang === 'en'
            ? 'We never ask for or store a child’s name. Profiles are only an age and a face. What a child says into the mic is used to pick a branch and is not kept.'
            : 'Nikdy sa nepýtame na meno dieťaťa ani ho neukladáme. Profil je len vek a tvár. To, čo dieťa povie do mikrofónu, slúži na výber vetvy a neuchovávame to.'}
        </div>
        <div style={{ marginTop: 12 }}>
          <a href="/privacy.html" style={{
            fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 17, color: PIPI.ink,
          }}>{lang === 'en' ? 'Read the privacy policy →' : 'Prečítať zásady súkromia →'}</a>
        </div>
      </ScribbleCard>

      <ScribbleCard style={{ padding: '14px 16px' }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10 }}>
          <div style={{ flex: 1, paddingRight: 8 }}>
            <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22, lineHeight: 1 }}>
              {lang === 'en' ? 'Parent PIN' : 'Rodičovský PIN'}
            </div>
            <div style={{ fontSize: 14, opacity: 0.65, marginTop: 2 }}>
              {lang === 'en' ? 'asks for a 4-digit PIN to open Settings' : 'pýta 4-miestny PIN pri vstupe do Nastavení'}
            </div>
          </div>
          <div style={{
            background: parentPin ? PIPI.green : PIPI.paperLt, border: `2px solid ${PIPI.ink}`, borderRadius: 999,
            padding: '3px 10px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 14,
          }}>{parentPin ? (lang === 'en' ? 'On' : 'Zapnutý') : (lang === 'en' ? 'Off' : 'Vypnutý')}</div>
        </div>

        {(editing || !parentPin) && (
          <>
            <DotDividerLocal/>
            <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
              <input
                value={draft} onChange={(e) => setDraft(onlyDigits(e.target.value))}
                inputMode="numeric" type="password" placeholder="• • • •" maxLength={4}
                style={{
                  flex: 1, minWidth: 0, border: `2.5px solid ${PIPI.ink}`, borderRadius: 12,
                  padding: '8px 12px', fontFamily: '"JetBrains Mono", monospace', fontSize: 18,
                  letterSpacing: 6, background: PIPI.paperLt, color: PIPI.ink, outline: 'none',
                }}/>
              <button onClick={savePin} disabled={draft.length !== 4} style={{
                background: draft.length === 4 ? PIPI.yellow : PIPI.paperLt, border: `2.5px solid ${PIPI.ink}`,
                borderRadius: 999, padding: '8px 16px', fontFamily: '"Caveat", cursive', fontWeight: 700,
                fontSize: 16, color: PIPI.ink, cursor: draft.length === 4 ? 'pointer' : 'default', opacity: draft.length === 4 ? 1 : 0.5,
              }}>{lang === 'en' ? 'Save' : 'Uložiť'}</button>
            </div>
          </>
        )}
        {parentPin && !editing && (
          <>
            <DotDividerLocal/>
            <div style={{ display: 'flex', gap: 8 }}>
              <button onClick={() => { setEditing(true); setDraft(''); }} style={{
                background: 'transparent', border: `2.5px solid ${PIPI.ink}`, borderRadius: 999,
                padding: '6px 14px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 16, color: PIPI.ink, cursor: 'pointer',
              }}>{lang === 'en' ? 'Change PIN' : 'Zmeniť PIN'}</button>
              <button onClick={() => { setParentPin && setParentPin(''); setDraft(''); setEditing(false); }} style={{
                background: 'transparent', border: `2.5px solid ${PIPI.pink}`, borderRadius: 999,
                padding: '6px 14px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 16, color: PIPI.pink, cursor: 'pointer',
              }}>{lang === 'en' ? 'Turn off' : 'Vypnúť'}</button>
            </div>
          </>
        )}
      </ScribbleCard>

      <ScribbleCard style={{ padding: '14px 16px' }}>
        <SettingsCardTitle>{lang === 'en' ? 'Data on this device' : 'Údaje na tomto zariadení'}</SettingsCardTitle>
        <div style={{ fontSize: 14, opacity: 0.7, lineHeight: 1.45, marginBottom: 12 }}>
          {lang === 'en'
            ? 'Profiles and settings are stored in this browser. You can clear them anytime.'
            : 'Profily a nastavenia sú uložené v tomto prehliadači. Môžeš ich kedykoľvek vymazať.'}
        </div>
        <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
          <button onClick={exportData} style={{
            background: 'transparent', border: `2.5px solid ${PIPI.ink}`, borderRadius: 999,
            padding: '7px 16px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 17,
            color: PIPI.ink, cursor: 'pointer',
          }}>{lang === 'en' ? 'Export my data' : 'Exportovať moje údaje'}</button>
          <button onClick={wipe} style={{
            background: 'transparent', border: `2.5px solid ${PIPI.pink}`, borderRadius: 999,
            padding: '7px 16px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 17,
            color: PIPI.pink, cursor: 'pointer',
          }}>{lang === 'en' ? 'Clear data on this device' : 'Vymazať údaje na tomto zariadení'}</button>
        </div>
      </ScribbleCard>
    </>
  );
}

function DemoParentScreen({
  onTab, lang, onLangChange, devMode, setDevMode, voiceInput, setVoiceInput,
  choiceMode, setChoiceMode, kids, setKids, activeKidId, setActiveKidId,
  section, setSection, t, showTabBar = true,
  dailyLimitMin, setDailyLimitMin, todaySec, parentPin, setParentPin,
  plan, trialDaysLeft, entitled, onSubscribe, onFamilyChanged,
}) {
  const tabs = [
    { id: 'account',   label: lang === 'en' ? 'Account'   : 'Účet' },
    { id: 'kids',      label: lang === 'en' ? 'Children'  : 'Deti' },
    { id: 'listening', label: lang === 'en' ? 'Listening' : 'Počúvanie' },
    { id: 'privacy',   label: lang === 'en' ? 'Privacy'   : 'Súkromie' },
  ];
  return (
    <>
      <AppHeader greeting={lang === 'en' ? 'Settings' : 'Nastavenia'}
        sub={lang === 'en' ? 'Account, children, listening & privacy' : 'Účet, deti, počúvanie a súkromie'}
        right={<div style={{ width: 56, height: 56, border: `3px dashed ${PIPI.ink}`,
          borderRadius: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
          fontFamily: '"Caveat", cursive', fontSize: 22 }}>
          🐤
        </div>}/>

      <div style={{ display: 'flex', gap: 6, padding: '2px 18px 8px', overflowX: 'auto' }}>
        {tabs.map((tb) => (
          <button key={tb.id} onClick={() => setSection(tb.id)} style={{
            flexShrink: 0, background: section === tb.id ? PIPI.yellow : 'transparent',
            border: `2.5px solid ${PIPI.ink}`, borderRadius: 999, padding: '5px 14px',
            fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 17, color: PIPI.ink,
            cursor: 'pointer', boxShadow: section === tb.id ? '2px 3px 0 rgba(0,0,0,0.16)' : 'none',
          }}>{tb.label}</button>
        ))}
      </div>

      <div style={{ padding: '4px 22px 22px', display: 'grid', gap: 14, overflowY: 'auto' }}>
        {section === 'account' && (
          <SettingsAccount lang={lang} plan={plan} trialDaysLeft={trialDaysLeft}
            entitled={entitled} onSubscribe={onSubscribe} onFamilyChanged={onFamilyChanged}/>
        )}
        {section === 'kids' && (
          <SettingsKids kids={kids} setKids={setKids} activeKidId={activeKidId}
            setActiveKidId={setActiveKidId} lang={lang}/>
        )}
        {section === 'listening' && (
          <SettingsListening lang={lang} voiceInput={voiceInput} setVoiceInput={setVoiceInput}
            choiceMode={choiceMode} setChoiceMode={setChoiceMode} devMode={devMode}
            setDevMode={setDevMode} onLangChange={onLangChange} t={t}
            dailyLimitMin={dailyLimitMin} setDailyLimitMin={setDailyLimitMin} todaySec={todaySec}/>
        )}
        {section === 'privacy' && <SettingsPrivacy lang={lang} parentPin={parentPin} setParentPin={setParentPin}/>}
      </div>

      <div style={{ flex: 1 }}/>
      {showTabBar && <ScribbleTabBar active="parent" onChange={onTab}/>}
    </>
  );
}

// ──────────────────────────────────────────────────────────────
// Desktop & tablet sidebar — replaces the bottom tab bar above
// the mobile breakpoint. Mobile (≤ 767 px) keeps the bottom tabs.
// The sidebar uses the same tab ids (home / library / search /
// parent) so onChange routes through DemoApp.onTab unchanged.
// ──────────────────────────────────────────────────────────────
function DesktopSidebar({ active, onChange, lang }) {
  const items = [
    { id: 'home',    label: lang === 'en' ? 'Home'     : 'Domov',    Icon: IconHome },
    { id: 'library', label: lang === 'en' ? 'Library'  : 'Knižnica', Icon: IconLibrary },
    { id: 'search',  label: lang === 'en' ? 'Search'   : 'Hľadať',   Icon: IconSearch },
    { id: 'parent',  label: lang === 'en' ? 'Settings' : 'Rodič',    Icon: IconParent },
  ];
  return (
    <aside style={{
      width: 240, flexShrink: 0, padding: '24px 18px',
      background: PIPI.paperLt, borderRight: `3px solid ${PIPI.ink}`,
      display: 'flex', flexDirection: 'column', gap: 8,
      position: 'sticky', top: 0, alignSelf: 'flex-start',
      minHeight: '100vh', maxHeight: '100vh', overflowY: 'auto',
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 8px 16px',
        borderBottom: `2px dashed ${PIPI.inkSoft}`, marginBottom: 6,
      }}>
        <PipiMini size={48}/>
        <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 30, lineHeight: 1 }}>
          Ippy
        </div>
      </div>
      {items.map((it) => {
        const isOn = active === it.id;
        return (
          <button key={it.id} onClick={() => onChange(it.id)} style={{
            display: 'flex', alignItems: 'center', gap: 12,
            padding: '12px 14px', borderRadius: 16, cursor: 'pointer',
            background: isOn ? PIPI.yellow : 'transparent',
            border: `2.5px solid ${isOn ? PIPI.ink : 'transparent'}`,
            boxShadow: isOn ? '3px 4px 0 rgba(0,0,0,0.18)' : 'none',
            fontFamily: '"Patrick Hand", cursive', color: PIPI.ink,
            fontSize: 19, textAlign: 'left', transition: 'background .12s, box-shadow .12s',
            WebkitTapHighlightColor: 'transparent',
          }}
          onMouseEnter={(e) => { if (!isOn) e.currentTarget.style.background = PIPI.paper; }}
          onMouseLeave={(e) => { if (!isOn) e.currentTarget.style.background = 'transparent'; }}>
            <span style={{ width: 28, height: 28, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
              <it.Icon size={26} active={isOn}/>
            </span>
            <span style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22, lineHeight: 1 }}>
              {it.label}
            </span>
          </button>
        );
      })}
      {/* Contact / support pill — placed RIGHT BELOW the main tabs so
         it's always in the viewport on every screen height. Previously
         this lived at the bottom of the sidebar with flex:1 spacer
         pushing it down off-screen on short laptops + cramped phones.
         Currently links to "/" (landing) until we ship a dedicated
         /contact route. */}
      <a href="/" style={{
        textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 10,
        padding: '12px 14px', borderRadius: 14,
        background: PIPI.paper, color: PIPI.ink,
        border: `2.5px solid ${PIPI.ink}`,
        boxShadow: '3px 4px 0 rgba(0,0,0,0.18)',
        fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 20,
        marginTop: 18,
        WebkitTapHighlightColor: 'transparent',
      }}
        onMouseEnter={(e) => { e.currentTarget.style.background = PIPI.yellow; }}
        onMouseLeave={(e) => { e.currentTarget.style.background = PIPI.paper; }}>
        <svg viewBox="0 0 32 32" width={22} height={22} style={{ flexShrink: 0 }}>
          {/* Hand-drawn envelope. Same wobbly ink line as the rest of the icon set. */}
          <path d="M 5 9 Q 16 8 27 9 Q 27 22 27 23 Q 16 24 5 23 Q 5 11 5 9 Z"
                fill={PIPI.paperLt} stroke={PIPI.ink} strokeWidth={2.5} strokeLinejoin="round"/>
          <path d="M 5 9 L 16 17 L 27 9" fill="none" stroke={PIPI.ink} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round"/>
        </svg>
        <span>Kontakt &amp; podpora</span>
      </a>
      <div style={{ flex: 1 }}/>
    </aside>
  );
}

// Hook: true ≥ 768 px viewport (tablet portrait + everything wider).
function useIsWide(minWidth = 768) {
  const [isWide, setIsWide] = React.useState(() =>
    typeof window !== 'undefined' && window.matchMedia
      ? window.matchMedia(`(min-width: ${minWidth}px)`).matches
      : false);
  React.useEffect(() => {
    if (typeof window === 'undefined' || !window.matchMedia) return;
    const mq = window.matchMedia(`(min-width: ${minWidth}px)`);
    const handler = (e) => setIsWide(e.matches);
    if (mq.addEventListener) mq.addEventListener('change', handler);
    else mq.addListener(handler);
    return () => {
      if (mq.removeEventListener) mq.removeEventListener('change', handler);
      else mq.removeListener(handler);
    };
  }, [minWidth]);
  return isWide;
}

function ToggleRowLocal({ label, sub, on, onChange }) {
  return (
    <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 0' }}>
      <div style={{ flex: 1, paddingRight: 12 }}>
        <div style={{ fontFamily: '"Patrick Hand", cursive', fontSize: 19, lineHeight: 1.1 }}>{label}</div>
        {sub && <div style={{ fontSize: 14, opacity: 0.65, marginTop: 1 }}>{sub}</div>}
      </div>
      <button onClick={() => onChange(!on)} role="switch" aria-checked={on} aria-label={label} style={{
        width: 64, height: 34, background: 'transparent', border: 0, cursor: 'pointer', padding: 0, position: 'relative',
      }}>
        <svg viewBox="0 0 64 34" width={64} height={34}>
          <path d={wobblyRectPath(2, 2, 60, 30, { r: 15, amp: 1, seed: on ? 300 : 301 })}
                fill={on ? PIPI.green : PIPI.paperDk} stroke={PIPI.ink} strokeWidth={2.5}/>
          <path d={wobblyCirclePath(on ? 48 : 16, 17, 11, { amp: 0.7, seed: 305, segs: 14 })}
                fill={PIPI.paperLt} stroke={PIPI.ink} strokeWidth={2.5}/>
        </svg>
      </button>
    </div>
  );
}

function DotDividerLocal() {
  return <div style={{ borderTop: `2px dotted ${PIPI.inkSoft}`, margin: '6px 0' }}/>;
}

// ──────────────────────────────────────────────────────────────
// Custom chain — vila 3-round flow (custom-story engine v2)
// Round N: play opener.mp3 → play question.mp3 → auto-listen (mic
//          via useSmartListening) → STREAM from /api/custom-story/stream
//          (instant templated bridge ≈1-2 s, then story chunks as the
//          LLM+TTS pipeline produces them; PipiCustomEngine queues them
//          through PipiAudio). The old /api/custom-story-chunked flow
//          (filler-masked, 8-15 s) is kept as the automatic fallback.
// Round 3 wrap: all 3 answers go into one wrap-up generation.
// A character picker (custom-characters.json) runs before round 1; the
// chosen hero's traits flavor the prompt server-side.
// ──────────────────────────────────────────────────────────────
const CUSTOM_FILLERS = [
  '/demo/audio/custom-fillers/v1/f1.mp3',
  '/demo/audio/custom-fillers/v1/f2.mp3',
  '/demo/audio/custom-fillers/v1/f3.mp3',
  '/demo/audio/custom-fillers/v1/f4.mp3',
  '/demo/audio/custom-fillers/v1/f5.mp3',
  '/demo/audio/custom-fillers/v1/f6.mp3',
];

function DemoCustomChain({ manifest, onExit, lang }) {
  const [round, setRound] = React.useState(0); // 0..2 active rounds, 3 = wrap, 4 = done
  const [status, setStatus] = React.useState('idle'); // idle | opener | question | listening | thinking | replying | wrap | done | error
  const [userAnswers, setUserAnswers] = React.useState([]); // strings
  const [aiResponses, setAiResponses] = React.useState([]); // [{bridge, chunks: [{text, audio}]}]
  const [errorMsg, setErrorMsg] = React.useState('');
  const [currentText, setCurrentText] = React.useState(''); // what's currently being narrated / shown
  const [isAudioPlaying, setIsAudioPlaying] = React.useState(false);
  const audioRef = React.useRef(null);

  // Character selection (custom-characters.json). If the manifest is missing
  // or empty we skip the picker entirely — exact legacy behavior.
  const [characters, setCharacters] = React.useState(null);
  const [activeChar, setActiveChar] = React.useState(null);
  const [charPicked, setCharPicked] = React.useState(false);
  const engineRef = React.useRef(null);          // active PipiCustomEngine handle
  const roundHistoryRef = React.useRef([]);      // [{question, direction, summary}] for prompt continuity
  const customStoryIdRef = React.useRef(null);   // threads rounds into one custom_stories row (post-0008)

  // Mount guard: if the parent leaves the custom story while a round is in
  // flight (streaming/chunked fallback), don't set state on the unmounted tree
  // and don't leave audio playing into a screen the user already left.
  const mountedRef = React.useRef(true);
  React.useEffect(() => () => {
    mountedRef.current = false;
    try { if (audioRef.current) audioRef.current.pause(); } catch {}
    try { if (engineRef.current && engineRef.current.stop) engineRef.current.stop(); } catch {}
  }, []);

  React.useEffect(() => {
    let alive = true;
    fetch('/manifests/custom-characters.json', { cache: 'no-store' })
      .then((r) => (r.ok ? r.json() : null))
      .catch(() => null)
      .then((d) => {
        if (!alive) return;
        const list = d && Array.isArray(d.characters) ? d.characters.filter((c) => c && c.id) : null;
        if (list && list.length) {
          setCharacters(list);
          setActiveChar(list.find((c) => c.default) || list[0]);
        } else {
          setCharPicked(true); // no characters manifest → straight into round 1
        }
      });
    return () => { alive = false; };
  }, []);

  // Sequential audio player — promise resolves when src finishes.
  // Tracks isAudioPlaying so the play/pause button under the mic
  // reflects the actual state, and pauses any previous clip cleanly.
  const playSrc = React.useCallback((src) => new Promise((resolve) => {
    if (!src) return resolve();
    if (audioRef.current) { audioRef.current.pause(); if (audioRef.current._cleanup) audioRef.current._cleanup(); }
    const a = new Audio(src);
    audioRef.current = a;
    const finish = () => { if (mountedRef.current) setIsAudioPlaying(false); resolve(); };
    const onPause = () => { if (mountedRef.current) setIsAudioPlaying(false); };
    const onPlay  = () => { if (mountedRef.current) setIsAudioPlaying(true); };
    a.addEventListener('ended', finish, { once: true });
    a.addEventListener('error', finish, { once: true });
    a.addEventListener('pause', onPause);
    a.addEventListener('play',  onPlay);
    // Clean up the persistent listeners when the next playSrc replaces this
    // element, so detached <audio> elements don't fire state setters later.
    a._cleanup = () => { a.removeEventListener('pause', onPause); a.removeEventListener('play', onPlay); };
    a.play().catch(finish);
  }), []);

  // Manual transport — works on whatever clip is currently loaded.
  // Disabled while we're talking to the AI (status 'thinking' / 'wrap')
  // because there's no audio to skip yet and we don't want a half-loaded
  // clip to be scrubbed before it starts. With the streaming engine the
  // narration starts DURING 'thinking' — onFirstAudio flips the status to
  // 'replying', which unlocks the transport, and the controls route to the
  // engine's PipiAudio handle instead of the opener/question <audio>.
  const isLocked = status === 'thinking' || status === 'wrap' || status === 'idle';
  const toggleAudio = React.useCallback(() => {
    if (isLocked) return;
    const eh = engineRef.current && engineRef.current.handle && engineRef.current.handle();
    if (eh && status === 'replying') {
      if (eh.paused) { eh.play(); setIsAudioPlaying(true); }
      else { eh.pause(); setIsAudioPlaying(false); }
      return;
    }
    const a = audioRef.current;
    if (!a) return;
    if (a.paused) a.play().catch(() => {});
    else a.pause();
  }, [isLocked, status]);
  const skipBy = React.useCallback((seconds) => {
    if (isLocked) return;
    const eh = engineRef.current && engineRef.current.handle && engineRef.current.handle();
    if (eh && status === 'replying') {
      try { eh.currentTime = Math.max(0, (eh.currentTime || 0) + seconds); } catch {}
      return;
    }
    const a = audioRef.current;
    if (!a || !isFinite(a.duration)) return;
    a.currentTime = Math.max(0, Math.min(a.duration, a.currentTime + seconds));
  }, [isLocked, status]);

  // Auto-advance: when round changes, play opener → question, then
  // arm listening. The listening hook is enabled by status === 'listening'.
  // Waits for the character pick (no-op when the picker is skipped).
  React.useEffect(() => {
    if (!manifest || !charPicked) return;
    if (round > 2) return; // handled by wrap effect
    let cancelled = false;
    const r = manifest.rounds[round];
    if (!r) return;
    (async () => {
      setStatus('opener');
      setCurrentText(r.openerText);
      await playSrc(r.openerAudio);
      if (cancelled) return;
      setStatus('question');
      setCurrentText(r.questionText);
      await playSrc(r.questionAudio);
      if (cancelled) return;
      setStatus('listening');
    })();
    return () => { cancelled = true; if (audioRef.current) audioRef.current.pause(); };
  }, [round, manifest, playSrc, charPicked]);

  // Build the request body both endpoints understand. `history` carries the
  // prior rounds so the LLM keeps continuity; `character` flavors the prompt.
  const buildPayload = React.useCallback((direction, sceneText, questionText, roundIndex) => ({
    direction,
    scene: sceneText,
    question: questionText,
    characters: [activeChar ? activeChar.name_sk : 'Pipi'],
    character: activeChar
      ? { id: activeChar.id, name: activeChar.name_sk, traits: activeChar.prompt_traits }
      : null,
    history: roundHistoryRef.current,
    customStoryId: customStoryIdRef.current,
    roundIndex,
    lang: currentLang(),
  }), [activeChar]);

  // v2: stream the round. PipiCustomEngine plays the bridge ~1-2 s in and the
  // chunks as they render; fillers only fire if nothing is audible by 1.2 s
  // (and crossfade out when the real audio lands). Resolves AFTER playback.
  const runStreamRound = React.useCallback(async (payload, sceneKeyLabel) => {
    const Engine = window.PipiCustomEngine;
    if (!Engine) throw new Error('custom-engine.js not loaded');
    const h = Engine.start({
      endpoint: '/api/custom-story/stream',
      payload,
      fillers: CUSTOM_FILLERS,
      fillerDelayMs: 1200,
      onFirstAudio: () => setStatus('replying'),
      onPlayState: (p) => setIsAudioPlaying(p),
    });
    engineRef.current = h;
    try {
      const r = await h.done;
      if (r && r.db && r.db.customStoryId) customStoryIdRef.current = r.db.customStoryId;
      if (!r.stopped) logCustomRound('vila-custom', sceneKeyLabel, lang, r.timings);
      return {
        bridge: r.bridgeText,
        intent: r.intent,
        safeReject: r.safeReject,
        stopped: r.stopped,
        chunks: (r.chunks || []).map((c) => ({ text: c.text })),
        timings: r.timings,
      };
    } finally {
      engineRef.current = null;
      setIsAudioPlaying(false);
    }
  }, [lang]);

  // v1 fallback: the old filler-masked chunked flow, byte-for-byte behavior.
  const runChunkedRound = React.useCallback(async (payload, sceneKeyLabel) => {
    const fillerSrc = CUSTOM_FILLERS[Math.floor(Math.random() * CUSTOM_FILLERS.length)];
    const fillerPromise = playSrc(fillerSrc);
    const aiFetchPromise = fetch('/api/custom-story-chunked', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        direction: payload.direction,
        scene: payload.scene,
        question: payload.question,
        characters: payload.characters,
      }),
    }).then(async (res) => {
      if (!res.ok) throw new Error('HTTP ' + res.status);
      return res.json();
    });
    // Wait for the filler to finish before the real response, exactly like
    // the original flow, so the fallback transition isn't abrupt.
    await fillerPromise;
    const data = await aiFetchPromise;
    setStatus('replying');
    if (data.bridgeAudio) await playSrc(data.bridgeAudio);
    for (const ch of data.chunks || []) {
      if (ch.audio) await playSrc(ch.audio);
    }
    logCustomRound('vila-custom', sceneKeyLabel + '_fallback', lang, null);
    return data;
  }, [playSrc, lang]);

  const onHeard = React.useCallback(async (r) => {
    if (r.error) { setStatus('error'); setErrorMsg(r.error); return; }
    const text = (r.text || '').trim();
    if (!text) { setStatus('error'); setErrorMsg('Nepočul som ťa. Skús znova.'); return; }
    setUserAnswers((prev) => [...prev, text]);
    setStatus('thinking');
    setCurrentText(`Pipi premýšľa o: „${text}"`);

    const sceneText = manifest.rounds[round].openerText;
    const questionText = manifest.rounds[round].questionText;
    const payload = buildPayload(text, sceneText, questionText, round);
    const sceneKeyLabel = `custom_round_${round + 1}`;
    const advance = (data) => {
      roundHistoryRef.current = roundHistoryRef.current.concat([{
        question: questionText,
        direction: text,
        summary: (data.chunks || []).map((c) => c.text).filter(Boolean).join(' ').slice(0, 240),
      }]).slice(-3);
      setAiResponses((prev) => [...prev, data]);
      // advance to next round (or wrap if we just finished round 3)
      if (round < 2) setRound((r2) => r2 + 1);
      else setRound(3); // triggers wrap effect below
    };
    try {
      const data = await runStreamRound(payload, sceneKeyLabel);
      if (data.stopped || !mountedRef.current) return; // navigated away mid-round
      advance(data);
    } catch (streamErr) {
      // Stream never produced audio → safe to regenerate via the old endpoint.
      try {
        const data = await runChunkedRound(payload, sceneKeyLabel);
        if (!mountedRef.current) return;
        advance(data);
      } catch (e) {
        if (mountedRef.current) { setStatus('error'); setErrorMsg('AI ticho. Skús znova.'); }
      }
    }
  }, [round, manifest, buildPayload, runStreamRound, runChunkedRound]);

  // Wrap-up: round = 3 — combine all 3 user answers, stream the ending
  // (falls back to the chunked endpoint exactly like a normal round).
  React.useEffect(() => {
    if (round !== 3 || !manifest) return;
    let cancelled = false;
    (async () => {
      setStatus('wrap');
      setCurrentText('Pipi plete koniec príbehu…');
      const wrapInstruction = (manifest.wrap && manifest.wrap.promptInstruction) || '';
      const combined = userAnswers.map((a, i) => `Kolo ${i + 1}: ${a}`).join(' | ');
      const payload = buildPayload(
        combined,
        wrapInstruction || 'Pipi spája všetky tri rozhodnutia dieťaťa do jedného milého konca s malým ponaučením.',
        'Ako sa to celé skončí?',
        3
      );
      try {
        const data = await runStreamRound(payload, 'custom_wrap');
        if (cancelled || data.stopped) return;
        setAiResponses((prev) => [...prev, data]);
        setStatus('done');
      } catch {
        if (cancelled) return; // don't start the fallback after unmount
        try {
          const data = await runChunkedRound(payload, 'custom_wrap');
          if (cancelled) return;
          setAiResponses((prev) => [...prev, data]);
          setStatus('done');
        } catch {
          if (!cancelled) { setStatus('error'); setErrorMsg('Záver sa nepodarilo upiecť.'); }
        }
      }
    })();
    return () => {
      cancelled = true;
      if (audioRef.current) audioRef.current.pause();
      if (engineRef.current) { try { engineRef.current.stop(); } catch {} }
    };
  }, [round, manifest, userAnswers, buildPayload, runStreamRound, runChunkedRound]);

  // Wire the mic — same hook the player uses. Only enabled when listening.
  useSmartListening({ enabled: status === 'listening', onResult: onHeard });

  // Cleanup audio on unmount — the opener/question element, the streaming
  // engine (aborts the fetch) and any Web Audio clip still in the air.
  React.useEffect(() => () => {
    if (audioRef.current) audioRef.current.pause();
    if (engineRef.current) { try { engineRef.current.stop(); } catch {} }
    try { if (window.PipiAudio) window.PipiAudio.stop(); } catch {}
  }, []);

  if (!manifest) {
    return (
      <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24, color: PIPI.ink }}>
        <div style={{ fontFamily: '"Caveat", cursive', fontSize: 22 }}>Načítavam vilu Vilôčku…</div>
      </div>
    );
  }

  // Character picker — one tap, then the vila flow starts. Selecting a chip
  // previews the hero's intro clip (best-effort; clips render via
  // scripts/render-character-intros.mjs and may not exist yet).
  if (!charPicked) {
    return (
      <>
        <div style={{ padding: '12px 16px 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
          <button onClick={onExit} style={{ background: 'transparent', border: 0, padding: 6, cursor: 'pointer' }} aria-label="Späť">
            <IconBack size={30}/>
          </button>
          <div style={{ background: PIPI.lilac, border: `2.5px solid ${PIPI.ink}`, borderRadius: 999, padding: '4px 12px',
            fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 18 }}>Tvoja rozprávka</div>
          <div style={{ width: 30 }}/>
        </div>
        <div style={{ padding: '18px 22px 0', textAlign: 'center' }}>
          <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 30, lineHeight: 1.1 }}>
            Kto bude hrdinom rozprávky?
          </div>
          {characters === null ? (
            <div style={{ marginTop: 22, fontFamily: '"Caveat", cursive', fontSize: 20, opacity: 0.7 }}>Hľadám hrdinov…</div>
          ) : (
            <div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, justifyContent: 'center', marginTop: 20 }}>
              {characters.map((c) => {
                const sel = activeChar && activeChar.id === c.id;
                return (
                  <button key={c.id}
                    onClick={() => {
                      setActiveChar(c);
                      if (c.intro_audio) { try { new Audio(c.intro_audio).play().catch(() => {}); } catch {} }
                    }}
                    style={{
                      width: 104, padding: '12px 6px 10px', cursor: 'pointer',
                      background: sel ? PIPI.yellow : PIPI.paperLt,
                      border: `3px solid ${PIPI.ink}`, borderRadius: 18,
                      boxShadow: sel ? '4px 5px 0 rgba(0,0,0,0.22)' : '2px 3px 0 rgba(0,0,0,0.12)',
                      fontFamily: '"Patrick Hand", cursive', color: PIPI.ink,
                    }}>
                    <div style={{ fontSize: 34, lineHeight: 1 }}>{c.emoji}</div>
                    <div style={{ fontSize: 16, marginTop: 6, lineHeight: 1.05 }}>{c.name_sk}</div>
                  </button>
                );
              })}
            </div>
          )}
          <button
            onClick={() => setCharPicked(true)}
            disabled={!activeChar}
            style={{
              marginTop: 26, background: PIPI.green, border: `3px solid ${PIPI.ink}`, borderRadius: 999,
              padding: '12px 30px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 24,
              cursor: activeChar ? 'pointer' : 'default', opacity: activeChar ? 1 : 0.5,
              color: PIPI.ink, boxShadow: '4px 5px 0 rgba(0,0,0,0.22)',
            }}>
            Začni rozprávku
          </button>
        </div>
      </>
    );
  }

  const roundLabel = status === 'done' ? 'Koniec' : status === 'wrap' ? 'Koniec' : `Kolo ${round + 1} z 3`;
  return (
    <>
      <div style={{ padding: '12px 16px 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
        <button onClick={onExit} style={{ background: 'transparent', border: 0, padding: 6, cursor: 'pointer' }} aria-label="Späť">
          <IconBack size={30}/>
        </button>
        <div style={{ background: PIPI.lilac, border: `2.5px solid ${PIPI.ink}`, borderRadius: 999, padding: '4px 12px',
          fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 18 }}>{roundLabel}</div>
        <div style={{ width: 30 }}/>
      </div>

      <div style={{ padding: '16px 22px 0', textAlign: 'center' }}>
        <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 28, lineHeight: 1.1 }}>
          Pipi a tajomstvo vily
        </div>
        <div style={{ fontSize: 14, opacity: 0.7, marginTop: 2 }}>
          custom rozprávka, ty si rozprávkár
        </div>
      </div>

      <div style={{ padding: '20px 22px 0', display: 'flex', justifyContent: 'center' }}>
        <PlayerPulsingMic active={status === 'listening'} size={170}/>
      </div>

      <div style={{ padding: '14px 22px 0', textAlign: 'center', minHeight: 60 }}>
        {status === 'opener' && (
          <div style={{ fontSize: 18, fontStyle: 'italic', opacity: 0.85 }}>{currentText}</div>
        )}
        {status === 'question' && (
          <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 24 }}>„{currentText}"</div>
        )}
        {status === 'listening' && (
          <>
            <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22, color: PIPI.red }}>počúvam… povedz čo má Pipi spraviť</div>
            <div style={{ marginTop: 10, fontSize: 13, opacity: 0.6 }}>
              napríklad: {(manifest.rounds[round].exampleAnswers || []).slice(0, 2).map((s) => `„${s}"`).join(' · ')}
            </div>
          </>
        )}
        {status === 'thinking' && (
          <>
            <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22, color: PIPI.lilac }}>
              ✦ generujem custom príbeh…
            </div>
            <div style={{ marginTop: 6, fontSize: 13, opacity: 0.6 }}>chvíľu strpenie, Pipi premýšľa</div>
          </>
        )}
        {status === 'wrap' && (
          <>
            <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22, color: PIPI.lilac }}>
              ✦ plietam koniec z tvojich volieb…
            </div>
            <div style={{ marginTop: 6, fontSize: 13, opacity: 0.6 }}>všetky tri rozhodnutia v jednom závere</div>
          </>
        )}
        {status === 'replying' && (
          <div style={{ fontSize: 15, opacity: 0.7 }}>Pipi rozpráva…</div>
        )}
        {status === 'error' && (
          <div style={{
            background: PIPI.pinkLt, border: `3px solid ${PIPI.ink}`, borderRadius: 14,
            padding: '10px 14px', display: 'inline-block', fontSize: 15,
          }}>{errorMsg} <button onClick={() => setStatus('listening')} style={{ background: 'transparent', border: 0, cursor: 'pointer', textDecoration: 'underline', font: 'inherit', marginLeft: 8 }}>skús znova</button></div>
        )}
        {status === 'done' && (
          <>
            <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 32, color: PIPI.red }}>Koniec rozprávky.</div>
            <div style={{ fontSize: 14, opacity: 0.7, marginTop: 6 }}>Tvoje rozhodnutia:</div>
            <div style={{ fontSize: 15, marginTop: 4, opacity: 0.85 }}>
              {userAnswers.map((a, i) => <div key={i}>{i + 1}. „{a}"</div>)}
            </div>
          </>
        )}
      </div>

      {/* Transport controls — only meaningful when audio is loaded
          and we're not waiting on the AI. Mirrors the player's
          ±5s + play/pause but operates on whatever clip is active
          (opener / question / AI reply chunk / wrap chunk). */}
      {status !== 'done' && status !== 'idle' && (
        <div style={{ padding: '20px 22px 0', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 26 }}>
          <button onClick={() => skipBy(-5)} disabled={isLocked} style={{
            background: 'transparent', border: 0, padding: 8,
            cursor: isLocked ? 'default' : 'pointer', opacity: isLocked ? 0.25 : 1,
          }} aria-label="−5 s"><IconSkipBack size={40}/></button>

          <button onClick={toggleAudio} disabled={isLocked} style={{
            width: 70, height: 70, borderRadius: '50%', border: `3.5px solid ${PIPI.ink}`,
            background: isLocked ? PIPI.paperLt : PIPI.yellow,
            cursor: isLocked ? 'default' : 'pointer',
            opacity: isLocked ? 0.55 : 1,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            boxShadow: '4px 5px 0 rgba(0,0,0,0.22)',
          }} aria-label={isAudioPlaying ? 'Pauza' : 'Hraj'}>
            {isAudioPlaying ? <IconPause size={30}/> : <IconPlay size={30}/>}
          </button>

          <button onClick={() => skipBy(5)} disabled={isLocked} style={{
            background: 'transparent', border: 0, padding: 8,
            cursor: isLocked ? 'default' : 'pointer', opacity: isLocked ? 0.25 : 1,
          }} aria-label="+5 s"><IconSkipFwd size={40}/></button>
        </div>
      )}

      {status === 'done' && (
        <div style={{ padding: '20px 22px 0' }}>
          <ScribbleButton color={PIPI.yellow} seed={210} fullWidth onClick={onExit}>
            ← Späť do knižnice
          </ScribbleButton>
        </div>
      )}

      <div style={{ flex: 1 }}/>
    </>
  );
}

// ──────────────────────────────────────────────────────────────
// Library — every story in one scroll, with a Prepared / Custom mode
// toggle and a heart on each card. "Custom" stories show a small badge
// — they can branch by speech. For others the toggle is for next time.
// ──────────────────────────────────────────────────────────────
// ──────────────────────────────────────────────────────────────
// KidSwitcher — the active-child face that lives in the header.
// Privacy-first: shows ONLY an avatar + age (e.g. "🐤 5r"), never a
// name. Tapping opens a small popover to switch between profiles or
// jump to the "Deti" settings section to manage them.
// ──────────────────────────────────────────────────────────────
function ageLabel(age, lang) { return `${age}${lang === 'en' ? 'y' : 'r'}`; }

function KidSwitcher({ kids, activeKidId, onSelect, onManage, lang }) {
  const [open, setOpen] = React.useState(false);
  const active = (kids || []).find((k) => k.id === activeKidId) || (kids || [])[0];
  if (!active) return null;
  return (
    <div style={{ position: 'relative' }}>
      <button
        onClick={() => setOpen((o) => !o)}
        aria-label={lang === 'en' ? 'Switch child' : 'Prepnúť dieťa'}
        style={{
          display: 'flex', alignItems: 'center', gap: 7, cursor: 'pointer',
          background: PIPI.paperLt, border: `2.5px solid ${PIPI.ink}`, borderRadius: 999,
          padding: '4px 10px 4px 5px', boxShadow: '2px 3px 0 rgba(0,0,0,0.15)',
        }}>
        <span style={{
          width: 38, height: 38, borderRadius: '50%', background: PIPI.yellow,
          border: `2px solid ${PIPI.ink}`, display: 'flex', alignItems: 'center',
          justifyContent: 'center', fontSize: 22, lineHeight: 1,
        }}>{active.avatar}</span>
        <span style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 19 }}>
          {ageLabel(active.age, lang)}
        </span>
        <span style={{ fontSize: 12, opacity: 0.6, marginLeft: -2 }}>▾</span>
      </button>
      {open && (
        <>
          <div onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 40 }}/>
          <div style={{
            position: 'absolute', top: 'calc(100% + 8px)', right: 0, zIndex: 41, minWidth: 190,
            background: PIPI.paper, border: `2.5px solid ${PIPI.ink}`, borderRadius: 16,
            boxShadow: '3px 5px 0 rgba(0,0,0,0.18)', padding: 7,
          }}>
            <div style={{ fontFamily: '"Patrick Hand", cursive', fontSize: 13, opacity: 0.6, padding: '2px 8px 6px' }}>
              {lang === 'en' ? 'Who is listening?' : 'Kto počúva?'}
            </div>
            {kids.map((k) => {
              const on = k.id === activeKidId;
              return (
                <button key={k.id} onClick={() => { onSelect(k.id); setOpen(false); }} style={{
                  display: 'flex', alignItems: 'center', gap: 9, width: '100%', cursor: 'pointer',
                  background: on ? PIPI.yellow : 'transparent', border: 0, borderRadius: 10,
                  padding: '7px 8px', textAlign: 'left', color: PIPI.ink,
                }}>
                  <span style={{
                    width: 32, height: 32, borderRadius: '50%', background: PIPI.paperLt,
                    border: `2px solid ${PIPI.ink}`, display: 'flex', alignItems: 'center',
                    justifyContent: 'center', fontSize: 18,
                  }}>{k.avatar}</span>
                  <span style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 18, flex: 1 }}>
                    {ageLabel(k.age, lang)}
                  </span>
                  {on && <span style={{ fontSize: 15 }}>✓</span>}
                </button>
              );
            })}
            {onManage && (
              <>
                <div style={{ borderTop: `2px dotted ${PIPI.inkSoft}`, margin: '5px 4px' }}/>
                <button onClick={() => { setOpen(false); onManage(); }} style={{
                  display: 'flex', alignItems: 'center', gap: 8, width: '100%', cursor: 'pointer',
                  background: 'transparent', border: 0, borderRadius: 10, padding: '7px 8px',
                  fontFamily: '"Patrick Hand", cursive', fontSize: 16, color: PIPI.ink, opacity: 0.8,
                }}>
                  <span style={{ fontSize: 16 }}>⚙</span>
                  {lang === 'en' ? 'Manage children' : 'Spravovať deti'}
                </button>
              </>
            )}
          </div>
        </>
      )}
    </div>
  );
}

function DemoLibraryScreen({ onOpen, onTab, choiceMode, onChoiceModeChange, favorites, onToggleFavorite, lang, showTabBar = true, kids, activeKidId, onSelectKid, onManageKids, isLocked }) {
  const [product, setProduct] = React.useState('variations'); // 'variations' | 'custom'
  const isSmart = choiceMode === 'smart';
  const pill = (on, accent) => ({
    background: on ? accent : 'transparent',
    border: `2.5px solid ${PIPI.ink}`, padding: '6px 14px', borderRadius: 999,
    cursor: 'pointer', fontFamily: '"Caveat", cursive', fontWeight: 700,
    fontSize: 18, color: PIPI.ink, lineHeight: 1.1,
    boxShadow: on ? `2px 3px 0 rgba(0,0,0,0.18)` : 'none',
  });
  const listed = visibleStories(lang).filter((s) => s.product === product);
  return (
    <>
      <div style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
        <AppHeader greeting={lang === 'en' ? 'Library' : 'Knižnica'}
          sub={lang === 'en' ? 'Pick a story type.' : 'Vyber si typ príbehu.'}
          right={kids && kids.length ? (
            <KidSwitcher kids={kids} activeKidId={activeKidId} onSelect={onSelectKid}
              onManage={onManageKids} lang={lang}/>
          ) : null}/>
        {/* Top toggle = two distinct products:
            S variáciami → 3 real audiobooks, child picks 1 of 3 at the
                           authored mid-story moment.
            Custom       → one interactive book where the child says
                           anything and the AI extends the story. */}
        <div style={{ display: 'flex', justifyContent: 'center', gap: 8, padding: '4px 22px 6px' }}>
          <button onClick={() => setProduct('variations')} style={pill(product === 'variations', PIPI.yellow)}>
            {lang === 'en' ? 'With choices' : 'S voľbami'}
          </button>
          <button onClick={() => setProduct('custom')} style={pill(product === 'custom', PIPI.lilac)}>
            {lang === 'en' ? '✦ Your own story' : '✦ Vlastný príbeh'}
          </button>
        </div>
        <div style={{ padding: '0 22px 8px', textAlign: 'center', fontSize: 13, opacity: 0.6, lineHeight: 1.4 }}>
          {product === 'variations'
            ? (lang === 'en'
                ? 'Story plays, pauses on a real moment, child picks one of three.'
                : 'Príbeh hrá, zastaví sa na momente, dieťa vyberie z troch.')
            : (lang === 'en'
                ? 'Story plays, the child says what should happen, the AI extends it.'
                : 'Príbeh hrá, dieťa povie čo má byť ďalej, AI dopíše.')}
        </div>
        {/* Tap/Speak toggle removed — mic always auto-starts when the
            narrator asks for a choice. Less to configure, less to
            misunderstand. */}
        <div style={{
          padding: '0 18px 18px', display: 'grid', gap: 14,
          gridTemplateColumns: 'repeat(auto-fill, minmax(min(100%, 320px), 1fr))',
        }}>
          {listed.length === 0 && (
            <div style={{ textAlign: 'center', opacity: 0.6, padding: '14px 0 24px' }}>
              {lang === 'en' ? 'Empty for now.' : 'Zatiaľ tu nič nie je.'}
            </div>
          )}
          {listed.map((s) => {
            const Art = s.art;
            const fav = favorites.has(s.id);
            const locked = isLocked ? isLocked(s) : false;
            return (
              <div key={s.id} style={{
                display: 'flex', gap: 12, alignItems: 'center', padding: 10,
                background: PIPI.paperLt, border: `3px solid ${PIPI.ink}`, borderRadius: 18,
                position: 'relative',
              }}>
                <div onClick={() => onOpen(s)} style={{
                  width: 78, height: 78, background: s.color, border: `2.5px solid ${PIPI.ink}`,
                  borderRadius: 14, display: 'flex', alignItems: 'center', justifyContent: 'center',
                  flexShrink: 0, cursor: 'pointer', position: 'relative',
                }}>
                  <Art size={68}/>
                  {locked && (
                    <div style={{
                      position: 'absolute', inset: 0, borderRadius: 12,
                      background: 'rgba(26,26,46,0.45)', display: 'flex',
                      alignItems: 'center', justifyContent: 'center', fontSize: 28,
                    }} aria-label={lang === 'en' ? 'Locked' : 'Zamknuté'}>🔒</div>
                  )}
                </div>
                <div style={{ flex: 1, minWidth: 0, cursor: 'pointer' }} onClick={() => onOpen(s)}>
                  <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
                    <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22, lineHeight: 1.1 }}>
                      {s.title}
                    </div>
                    {s.badge && <span style={{
                      background: PIPI.yellow, border: `2px solid ${PIPI.ink}`, borderRadius: 999,
                      padding: '1px 8px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 13,
                    }}>★ {s.badge}</span>}
                  </div>
                  <div style={{ fontSize: 14, opacity: 0.7, marginTop: 2 }}>
                    {s.age} · {s.len} · {s.mood}
                  </div>
                  {s.lesson && (
                    <div style={{ fontSize: 13, opacity: 0.65, marginTop: 2, fontStyle: 'italic' }}>
                      „{s.lesson}"
                    </div>
                  )}
                </div>
                <button onClick={() => onToggleFavorite(s.id)} style={{
                  background: 'transparent', border: 0, padding: 6, cursor: 'pointer', flexShrink: 0,
                }} aria-label={fav ? 'Odznačiť obľúbené' : 'Označiť obľúbené'}>
                  <Heart size={22} fill={fav ? PIPI.pink : 'transparent'}/>
                </button>
              </div>
            );
          })}
        </div>
      </div>
      {showTabBar && <ScribbleTabBar active="library" onChange={onTab}/>}
    </>
  );
}

function DemoSearchScreen({ onOpen, onTab, lang, showTabBar = true }) {
  const [q, setQ] = React.useState('');
  const searchable = visibleStories(lang);
  const filtered = q.trim()
    ? searchable.filter((s) => s.title.toLowerCase().includes(q.trim().toLowerCase())
        || s.mood.toLowerCase().includes(q.trim().toLowerCase()))
    : searchable;
  return (
    <>
      <div style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
        <AppHeader greeting={lang === 'en' ? 'Search' : 'Hľadať'}/>
        <div style={{ padding: '4px 18px 14px' }}>
          <input
            type="search" value={q} onChange={(e) => setQ(e.target.value)}
            placeholder={lang === 'en' ? 'title or mood…' : 'názov alebo nálada…'}
            style={{
              width: '100%', border: `3px solid ${PIPI.ink}`, borderRadius: 14,
              padding: '12px 14px', fontFamily: '"Patrick Hand", cursive', fontSize: 18,
              background: PIPI.paperLt, color: PIPI.ink, outline: 'none',
            }}
          />
        </div>
        <div style={{ padding: '0 18px 18px', display: 'grid', gap: 12 }}>
          {filtered.length === 0 && (
            <div style={{ textAlign: 'center', opacity: 0.6, padding: '20px 0' }}>
              {lang === 'en' ? 'Nothing matches yet.' : 'Nič sa zatiaľ nehodí.'}
            </div>
          )}
          {filtered.map((s) => {
            const Art = s.art;
            return (
              <div key={s.id} onClick={() => onOpen(s)} style={{
                display: 'flex', gap: 12, alignItems: 'center', padding: 10,
                background: PIPI.paperLt, border: `3px solid ${PIPI.ink}`, borderRadius: 18,
                cursor: 'pointer',
              }}>
                <div style={{
                  width: 60, height: 60, background: s.color, border: `2.5px solid ${PIPI.ink}`,
                  borderRadius: 14, display: 'flex', alignItems: 'center', justifyContent: 'center',
                  flexShrink: 0,
                }}>
                  <Art size={52}/>
                </div>
                <div style={{ flex: 1, minWidth: 0 }}>
                  <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 20, lineHeight: 1.1 }}>{s.title}</div>
                  <div style={{ fontSize: 13, opacity: 0.7 }}>{s.age} · {s.len} · {s.mood}</div>
                </div>
              </div>
            );
          })}
        </div>
      </div>
      {showTabBar && <ScribbleTabBar active="search" onChange={onTab}/>}
    </>
  );
}

// ──────────────────────────────────────────────────────────────
// Top-level demo app: routes between Home / Player / Listening / Choice / Parent
// ──────────────────────────────────────────────────────────────
// ──────────────────────────────────────────────────────────────
// OnboardingWizard — value-first first-run flow (Headspace shape,
// Duolingo order). Four short steps:
//
//   Step 1: warm welcome — what Ippy is (one idea, no form)
//   Step 2: mic-consent explainer — the existing privacy promise,
//           plainly; the OS mic prompt itself stays in the player
//   Step 3: the ONE personalization question — child age (existing
//           stepper) + one interest chip
//   Step 4: instant recommendation with a "why" + big play CTA that
//           starts the story right away
//
// What moved OUT (the teardown's P1-1): display name is gone,
// "how did you hear about us?" lives in Settings → Účet ("Pomôž nám
// rásť"). Account creation is DEFERRED — the wizard never asks;
// DemoApp shows the sign-up nudge after the first story session ends
// or when saving progress is attempted.
//
// Answers persist to localStorage on every tap + best-effort sync to
// the family's own Supabase profiles row (RLS). The logical completion
// point is unchanged: complete:true is set when the user leaves the
// recommendation screen (play or "neskôr"), which also arms the
// parked-onboarding push in SettingsAccount.doAuth for sign-in later.
// ──────────────────────────────────────────────────────────────
// ONBOARDING_KEY is declared once at the top of the file (shared with
// the kid-profile helpers).

function OnboardingWizard({ onDone, onSkip, onPlay }) {
  const t = demoT(currentLang()); // wizard renders outside the lang-state tree
  const [step, setStep] = useState(1);
  const [state, setState] = useState(() => {
    try { return Object.assign({ display_name: '', kid_count: 1, kid_ages: [5], interest: '', how_heard: '', how_heard_note: '' }, JSON.parse(localStorage.getItem(ONBOARDING_KEY) || '{}')); }
    catch { return { display_name: '', kid_count: 1, kid_ages: [5], interest: '', how_heard: '', how_heard_note: '' }; }
  });

  const persist = useCallback(async (patch, opts = {}) => {
    const next = { ...state, ...patch };
    setState(next);
    try { localStorage.setItem(ONBOARDING_KEY, JSON.stringify(next)); } catch {}
    // Best-effort sync to Supabase (was POST /papi/api/onboarding) —
    // writes straight onto the family's own profiles row under RLS.
    try {
      await syncOnboardingToSupabase(next, !!opts.complete);
    } catch { /* not signed in yet — localStorage carries the state */ }
  }, [state]);

  const age = (Array.isArray(state.kid_ages) && state.kid_ages[0]) || 5;
  const setAge = (a) => persist({ kid_count: 1, kid_ages: [Math.max(2, Math.min(12, a))] });
  const rec = recommendStory(age, state.interest);
  const RecArt = rec.story.art;

  // Leaves the wizard. The localStorage write inside persist() is
  // synchronous, so DemoApp's handlers (setKids(loadKids()) etc.) read
  // fresh answers; the Supabase sync continues in the background.
  const finish = (play) => {
    // complete:true must land IN the stored blob, not just in opts — the
    // parked-onboarding push (doAuth) and persistKids both read o.complete
    // from localStorage. Passing it only as an opt meant the flag was never
    // persisted and no user's family data ever armed for sync. (auth audit)
    persist({ complete: true }, { complete: true });
    if (play) { if (onPlay) onPlay(rec.story); else onDone(state); }
    else onDone(state);
  };

  const Step = ({ children }) => (
    <div style={{
      background: PIPI.paper, border: `4px solid ${PIPI.ink}`, borderRadius: 28,
      padding: '28px 24px 22px', maxWidth: 460, width: '100%',
      boxShadow: '10px 12px 0 rgba(0,0,0,0.18)',
    }}>{children}</div>
  );
  const Title = ({ children }) => (
    <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 36, lineHeight: 1, marginBottom: 4 }}>{children}</div>
  );
  const Subtitle = ({ children }) => (
    <div style={{ fontSize: 16, opacity: 0.78, marginBottom: 16, lineHeight: 1.35 }}>{children}</div>
  );
  const PrimaryBtn = ({ children, onClick, disabled }) => (
    <button onClick={onClick} disabled={disabled} style={{
      background: disabled ? PIPI.paperLt : PIPI.yellow,
      border: `3px solid ${PIPI.ink}`, borderRadius: 999,
      padding: '12px 22px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22,
      cursor: disabled ? 'default' : 'pointer', color: PIPI.ink,
      boxShadow: disabled ? 'none' : '4px 5px 0 rgba(0,0,0,0.18)',
      opacity: disabled ? 0.5 : 1,
    }}>{children}</button>
  );
  const BackLink = ({ to }) => (
    <button onClick={() => setStep(to)} style={{
      background: 'transparent', border: 0, cursor: 'pointer', fontSize: 16,
      color: PIPI.ink, opacity: 0.7, textDecoration: 'underline', fontFamily: '"Patrick Hand", cursive',
    }}>{t.ob_back}</button>
  );
  const SkipLink = () => (
    <button onClick={onSkip} style={{
      background: 'transparent', border: 0, padding: '8px 12px',
      cursor: 'pointer', fontFamily: '"Patrick Hand", cursive',
      fontSize: 14, color: PIPI.ink, opacity: 0.65, textDecoration: 'underline',
    }}>{t.ob_skip}</button>
  );
  const Dots = () => (
    <div style={{ display: 'flex', gap: 6, justifyContent: 'center', marginTop: 14 }}>
      {[1, 2, 3, 4].map((i) => (
        <span key={i} style={{
          width: 10, height: 10, borderRadius: '50%',
          background: i === step ? PIPI.ink : PIPI.paperLt,
          border: `2px solid ${PIPI.ink}`,
        }}/>
      ))}
    </div>
  );

  return ReactDOM.createPortal(
    <div style={{
      position: 'fixed', inset: 0, background: 'rgba(26,26,46,0.85)',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      padding: 20, zIndex: 9000, overflowY: 'auto',
      fontFamily: '"Patrick Hand", cursive', color: PIPI.ink,
    }}>
      <Step>
        <div style={{ display: 'flex', justifyContent: 'center', marginTop: -56, marginBottom: 6 }}>
          <div style={{ transform: 'rotate(-2deg)' }}><Pipi size={84}/></div>
        </div>

        {step === 1 && (
          <>
            <Title>{t.ob_welcome_title}</Title>
            <Subtitle>{t.ob_welcome_body}</Subtitle>
            <div style={{
              background: PIPI.paperLt, border: `2.5px solid ${PIPI.ink}`, borderRadius: 14,
              padding: '10px 14px', fontSize: 15, lineHeight: 1.4, opacity: 0.9,
            }}>{t.ob_welcome_no}</div>
            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
              <SkipLink/>
              <PrimaryBtn onClick={() => setStep(2)}>{t.ob_next}</PrimaryBtn>
            </div>
            <Dots/>
          </>
        )}

        {step === 2 && (
          <>
            <Title>{t.ob_mic_title}</Title>
            <Subtitle>{t.ob_mic_body}</Subtitle>
            <div style={{
              background: PIPI.paperLt, border: `2.5px solid ${PIPI.ink}`, borderRadius: 14,
              padding: '10px 14px', fontSize: 15, lineHeight: 1.4, opacity: 0.9,
            }}>{t.ob_mic_names}</div>
            <div style={{ fontSize: 14, opacity: 0.6, marginTop: 10, lineHeight: 1.4 }}>{t.ob_mic_note}</div>
            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
              <BackLink to={1}/>
              <PrimaryBtn onClick={() => setStep(3)}>{t.ob_mic_cta}</PrimaryBtn>
            </div>
            <Dots/>
          </>
        )}

        {step === 3 && (
          <>
            <Title>{t.ob_age_title}</Title>
            <Subtitle>{t.ob_age_note}</Subtitle>
            <div style={{
              display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
              background: PIPI.paperLt, border: `2px solid ${PIPI.ink}`, borderRadius: 12,
            }}>
              <span style={{ fontSize: 16, flex: 1 }}>{t.ob_age_label}:</span>
              <button onClick={() => setAge(age - 1)} style={{
                width: 32, height: 32, borderRadius: 8, border: `2px solid ${PIPI.ink}`,
                background: PIPI.paper, fontWeight: 700, cursor: 'pointer',
              }}>−</button>
              <span style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 26, minWidth: 50, textAlign: 'center' }}>
                {age} <span style={{ fontSize: 14, opacity: 0.7 }}>r</span>
              </span>
              <button onClick={() => setAge(age + 1)} style={{
                width: 32, height: 32, borderRadius: 8, border: `2px solid ${PIPI.ink}`,
                background: PIPI.paper, fontWeight: 700, cursor: 'pointer',
              }}>+</button>
            </div>
            <div style={{ fontSize: 16, margin: '14px 0 8px' }}>{t.ob_interest_label}</div>
            <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
              {INTERESTS.map((it) => {
                const on = state.interest === it.id;
                return (
                  <button key={it.id} onClick={() => persist({ interest: it.id })} style={{
                    background: on ? PIPI.yellow : PIPI.paperLt,
                    border: `2.5px solid ${PIPI.ink}`, borderRadius: 999,
                    padding: '7px 14px', fontFamily: '"Patrick Hand", cursive', fontSize: 16,
                    color: PIPI.ink, cursor: 'pointer',
                    boxShadow: on ? '2px 3px 0 rgba(0,0,0,0.16)' : 'none',
                  }}>{it.emoji} {it.label}</button>
                );
              })}
            </div>
            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 16 }}>
              <BackLink to={2}/>
              <PrimaryBtn disabled={!state.interest} onClick={() => setStep(4)}>{t.ob_pick_cta}</PrimaryBtn>
            </div>
            <Dots/>
          </>
        )}

        {step === 4 && (
          <>
            <div style={{ fontSize: 14, opacity: 0.6, textTransform: 'none', marginBottom: 2 }}>{t.ob_rec_eyebrow}</div>
            <Title>Pre {age}-ročné uši odporúčame:</Title>
            <div style={{
              display: 'flex', gap: 12, alignItems: 'center', padding: 12, marginTop: 10,
              background: PIPI.paperLt, border: `3px solid ${PIPI.ink}`, borderRadius: 18,
            }}>
              <div style={{
                width: 64, height: 64, background: rec.story.color, border: `2.5px solid ${PIPI.ink}`,
                borderRadius: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
              }}>
                <RecArt size={54}/>
              </div>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22, lineHeight: 1.05 }}>{rec.story.title}</div>
                <div style={{ fontSize: 13, opacity: 0.7 }}>{rec.story.age} · {rec.story.len} · {rec.story.mood}</div>
              </div>
            </div>
            <div style={{ fontSize: 15, lineHeight: 1.45, margin: '12px 2px 0', opacity: 0.85 }}>
              <b>{t.ob_rec_why}</b> {rec.why}.
            </div>
            <div style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}>
              <PrimaryBtn onClick={() => {
                // The OS mic prompt belongs to THIS gesture: the consent
                // explainer (step 2) just set it up, and priming here means
                // the first split's voice answer works even on iOS. Cached
                // stream is reused by the player (primeMicStream design).
                try { primeMicStream().catch(() => {}); } catch {}
                finish(true);
              }}>{t.ob_rec_play}</PrimaryBtn>
            </div>
            <div style={{ fontSize: 14, opacity: 0.65, textAlign: 'center', marginTop: 12, lineHeight: 1.4 }}>
              {t.ob_rec_account}
            </div>
            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 8 }}>
              <BackLink to={3}/>
              <button onClick={() => finish(false)} style={{
                background: 'transparent', border: 0, padding: '8px 12px', cursor: 'pointer',
                fontFamily: '"Patrick Hand", cursive', fontSize: 15, color: PIPI.ink,
                opacity: 0.65, textDecoration: 'underline',
              }}>{t.ob_rec_later}</button>
            </div>
            <Dots/>
          </>
        )}
      </Step>
    </div>,
    document.body
  );
}

// Bottom-sheet paywall — the transparent trial-timeline pattern
// (Headspace "How your trial works" / Blinkist honest paywall): what
// unlocks today, when we remind, when the trial ends; monthly/yearly
// toggle with the cennik prices; checkout lives on /predplatne.html.
// Shown at trial-relevant moments only (locked-book tap, the Plan card's
// "Predplatiť") — never as a hard wall during onboarding. "Neskôr" always
// closes it.
function PaywallSheet({ lang, trialDaysLeft, plan, onClose }) {
  const t = demoT(lang);
  const [interval, setIntervalSel] = React.useState('yearly'); // lead with annual
  const trialOver = plan !== 'family' && !(trialDaysLeft > 0);
  const steps = [
    { icon: '🔓', day: t.pw_today, sub: t.pw_today_s },
    { icon: '✉️', day: t.pw_day5,  sub: t.pw_day5_s },
    { icon: '🗓️', day: t.pw_day7,  sub: t.pw_day7_s },
  ];
  return (
    <div onClick={onClose} style={{
      position: 'fixed', inset: 0, zIndex: 60, background: 'rgba(26,26,46,0.55)',
      display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
    }}>
      <div onClick={(e) => e.stopPropagation()} style={{
        width: '100%', maxWidth: 440, background: PIPI.paper, maxHeight: '92dvh', overflowY: 'auto',
        borderTopLeftRadius: 24, borderTopRightRadius: 24, border: `3px solid ${PIPI.ink}`,
        borderBottom: 0, padding: '20px 22px 28px', boxShadow: '0 -6px 0 rgba(0,0,0,0.12)',
      }}>
        <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 28, textAlign: 'center', lineHeight: 1.05 }}>
          {t.pw_title}
        </div>
        <div style={{ fontSize: 15, opacity: 0.78, textAlign: 'center', marginTop: 6, lineHeight: 1.45 }}>
          {trialOver
            ? t.pw_over
            : plan === 'trial'
              ? (lang === 'en' ? `Your trial is running — ${trialDaysLeft} day(s) left.` : `Skúška beží — zostáva ${trialDaysLeft} ${trialDaysLeft === 1 ? 'deň' : trialDaysLeft < 5 ? 'dni' : 'dní'}.`)
              : t.pw_sub}
        </div>

        {/* The 3-step trial timeline */}
        <div style={{ margin: '16px 2px 4px', position: 'relative' }}>
          <div style={{
            position: 'absolute', left: 21, top: 14, bottom: 14, width: 0,
            borderLeft: `2.5px dotted ${PIPI.ink}`, opacity: 0.4,
          }}/>
          {steps.map((s, i) => (
            <div key={i} style={{ display: 'flex', gap: 12, alignItems: 'flex-start', padding: '7px 0', position: 'relative' }}>
              <div style={{
                width: 42, height: 42, borderRadius: '50%', background: i === 0 ? PIPI.yellow : PIPI.paperLt,
                border: `2.5px solid ${PIPI.ink}`, display: 'flex', alignItems: 'center',
                justifyContent: 'center', fontSize: 19, flexShrink: 0, zIndex: 1,
              }}>{s.icon}</div>
              <div style={{ flex: 1, minWidth: 0, paddingTop: 2 }}>
                <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 20, lineHeight: 1 }}>{s.day}</div>
                <div style={{ fontSize: 14, opacity: 0.75, lineHeight: 1.35, marginTop: 2 }}>{s.sub}</div>
              </div>
            </div>
          ))}
        </div>

        {/* Monthly / yearly toggle (cennik prices: 4,90 € / 49 €) */}
        <div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
          {[
            { id: 'monthly', label: t.pw_monthly, price: t.pw_m_price, note: '' },
            { id: 'yearly',  label: t.pw_yearly,  price: t.pw_y_price, note: t.pw_y_note },
          ].map((p) => {
            const on = interval === p.id;
            return (
              <button key={p.id} onClick={() => setIntervalSel(p.id)} style={{
                flex: 1, background: on ? PIPI.yellow : PIPI.paperLt,
                border: `2.5px solid ${PIPI.ink}`, borderRadius: 16, padding: '10px 8px',
                fontFamily: '"Patrick Hand", cursive', color: PIPI.ink, cursor: 'pointer',
                boxShadow: on ? '2px 3px 0 rgba(0,0,0,0.16)' : 'none', textAlign: 'center',
              }}>
                <div style={{ fontSize: 14, opacity: 0.7 }}>{p.label}</div>
                <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 21, lineHeight: 1.1 }}>{p.price}</div>
                {p.note ? <div style={{ fontSize: 12, opacity: 0.65 }}>{p.note}</div> : null}
              </button>
            );
          })}
        </div>

        <div style={{ fontSize: 14, opacity: 0.7, textAlign: 'center', marginTop: 10, lineHeight: 1.4 }}>
          {t.pw_cancel}
        </div>

        <button onClick={() => { window.location.href = `/predplatne.html?interval=${interval}`; }} style={{
          width: '100%', marginTop: 10, background: PIPI.yellow, border: `2.5px solid ${PIPI.ink}`, borderRadius: 999,
          padding: '12px 0', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 21,
          color: PIPI.ink, cursor: 'pointer', boxShadow: '2px 4px 0 rgba(0,0,0,0.18)',
        }}>{t.pw_cta}</button>
        <div style={{ textAlign: 'center', marginTop: 10 }}>
          <a href="/predplatne.html" style={{ fontSize: 14, color: PIPI.ink, opacity: 0.7 }}>{t.pw_have}</a>
        </div>
        <button onClick={onClose} style={{
          width: '100%', background: 'transparent', border: 0, marginTop: 8,
          fontFamily: '"Patrick Hand", cursive', fontSize: 16, color: PIPI.ink, opacity: 0.6, cursor: 'pointer',
        }}>{t.pw_later}</button>
      </div>
    </div>
  );
}

// ──────────────────────────────────────────────────────────────
// Post-first-session screens — shown ONCE, after the child's first
// story finishes (never during onboarding):
//   1) PostSessionSetup — bedtime-reminder time + quiet-hours opt-in,
//      each with a one-line why; persists to the same localStorage
//      keys SettingsListening edits (no backend).
//   2) AccountNudgeSheet — the deferred sign-up prompt. Also reused
//      when a signed-out parent saves progress (hearts a story).
// ──────────────────────────────────────────────────────────────
function PostSessionSetup({ lang, onDone }) {
  const t = demoT(lang);
  const [rem, setRem] = React.useState(() => loadJSON(DEMO_BEDTIME_KEY, { enabled: true, time: '19:30' }));
  const [quiet, setQuiet] = React.useState(() => loadJSON(DEMO_QUIET_KEY, { enabled: true }));
  const save = () => {
    saveJSON(DEMO_BEDTIME_KEY, rem);
    saveJSON(DEMO_QUIET_KEY, quiet);
    onDone();
  };
  return ReactDOM.createPortal(
    <div style={{
      position: 'fixed', inset: 0, background: 'rgba(26,26,46,0.85)',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      padding: 20, zIndex: 9000, overflowY: 'auto',
      fontFamily: '"Patrick Hand", cursive', color: PIPI.ink,
    }}>
      <div style={{
        background: PIPI.paper, border: `4px solid ${PIPI.ink}`, borderRadius: 28,
        padding: '24px 24px 20px', maxWidth: 460, width: '100%',
        boxShadow: '10px 12px 0 rgba(0,0,0,0.18)',
      }}>
        <div style={{ fontSize: 40, textAlign: 'center', lineHeight: 1, marginBottom: 6 }}>🌙</div>
        <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 30, lineHeight: 1.05, textAlign: 'center' }}>
          {t.ps_title}
        </div>
        <div style={{ fontSize: 15, opacity: 0.75, textAlign: 'center', margin: '8px 0 14px', lineHeight: 1.4 }}>
          {t.ps_sub}
        </div>
        <div style={{ background: PIPI.paperLt, border: `2.5px solid ${PIPI.ink}`, borderRadius: 16, padding: '8px 14px' }}>
          <ToggleRowLocal label={t.ps_bed_t} sub={t.ps_bed_s}
            on={!!rem.enabled} onChange={(v) => setRem((p) => ({ ...p, enabled: !!v }))}/>
          {rem.enabled && (
            <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '0 0 8px' }}>
              <span style={{ fontSize: 15, opacity: 0.7 }}>{lang === 'en' ? 'Time' : 'Čas'}:</span>
              <input type="time" value={rem.time || '19:30'}
                onChange={(e) => setRem((p) => ({ ...p, time: e.target.value || '19:30' }))}
                style={{
                  border: `2.5px solid ${PIPI.ink}`, borderRadius: 10, padding: '6px 10px',
                  fontFamily: '"Patrick Hand", cursive', fontSize: 17, background: PIPI.paper, color: PIPI.ink,
                }}/>
            </div>
          )}
          <DotDividerLocal/>
          <ToggleRowLocal label={t.ps_quiet_t} sub={t.ps_quiet_s}
            on={!!quiet.enabled} onChange={(v) => setQuiet((p) => ({ ...p, enabled: !!v }))}/>
        </div>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
          <button onClick={onDone} style={{
            background: 'transparent', border: 0, padding: '8px 12px', cursor: 'pointer',
            fontFamily: '"Patrick Hand", cursive', fontSize: 14, color: PIPI.ink,
            opacity: 0.65, textDecoration: 'underline',
          }}>{t.ps_skip}</button>
          <button onClick={save} style={{
            background: PIPI.yellow, border: `3px solid ${PIPI.ink}`, borderRadius: 999,
            padding: '12px 22px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 22,
            cursor: 'pointer', color: PIPI.ink, boxShadow: '4px 5px 0 rgba(0,0,0,0.18)',
          }}>{t.ps_save}</button>
        </div>
      </div>
    </div>,
    document.body
  );
}

// Deferred sign-up nudge (bottom sheet). Never blocks anything — the
// "create account" CTA just deep-links to Settings → Účet where the
// existing email+password form (and the parked-onboarding push on
// sign-in) lives.
function AccountNudgeSheet({ lang, onCreate, onLater }) {
  const t = demoT(lang);
  return (
    <div onClick={onLater} style={{
      position: 'fixed', inset: 0, zIndex: 70, background: 'rgba(26,26,46,0.55)',
      display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
      fontFamily: '"Patrick Hand", cursive', color: PIPI.ink,
    }}>
      <div onClick={(e) => e.stopPropagation()} style={{
        width: '100%', maxWidth: 440, background: PIPI.paper,
        borderTopLeftRadius: 24, borderTopRightRadius: 24, border: `3px solid ${PIPI.ink}`,
        borderBottom: 0, padding: '20px 22px 28px', boxShadow: '0 -6px 0 rgba(0,0,0,0.12)',
      }}>
        <div style={{ fontSize: 38, textAlign: 'center', lineHeight: 1, marginBottom: 4 }}>🐤</div>
        <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 28, textAlign: 'center', lineHeight: 1.05 }}>
          {t.acct_title}
        </div>
        <div style={{ fontSize: 15, opacity: 0.78, textAlign: 'center', marginTop: 8, lineHeight: 1.45 }}>
          {t.acct_body}
        </div>
        <button onClick={onCreate} style={{
          width: '100%', marginTop: 14, background: PIPI.yellow, border: `2.5px solid ${PIPI.ink}`, borderRadius: 999,
          padding: '12px 0', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 21,
          color: PIPI.ink, cursor: 'pointer', boxShadow: '2px 4px 0 rgba(0,0,0,0.18)',
        }}>{t.acct_cta}</button>
        <button onClick={onLater} style={{
          width: '100%', background: 'transparent', border: 0, marginTop: 10,
          fontFamily: '"Patrick Hand", cursive', fontSize: 16, color: PIPI.ink, opacity: 0.6, cursor: 'pointer',
        }}>{t.acct_later}</button>
      </div>
    </div>
  );
}

// 4-digit PIN keypad shown in place of Settings when a parent PIN is set
// and not yet entered this session. Kid-proofing, not real security.
function ParentPinGate({ lang, expected, onUnlock, onCancel, onTab, showTabBar = true }) {
  const [entered, setEntered] = React.useState('');
  const [err, setErr] = React.useState(false);
  const press = (d) => {
    setErr(false);
    setEntered((prev) => {
      if (prev.length >= 4) return prev;
      const next = prev + d;
      if (next.length === 4) {
        if (next === expected) setTimeout(onUnlock, 90);
        else setTimeout(() => { setErr(true); setEntered(''); }, 140);
      }
      return next;
    });
  };
  const del = () => { setErr(false); setEntered((p) => p.slice(0, -1)); };
  const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '', '0', '⌫'];
  return (
    <>
      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 24, gap: 16 }}>
        <div style={{ fontSize: 40, lineHeight: 1 }}>🔒</div>
        <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 26, textAlign: 'center' }}>
          {lang === 'en' ? 'Parent PIN' : 'Rodičovský PIN'}
        </div>
        <div style={{ display: 'flex', gap: 12 }}>
          {[0, 1, 2, 3].map((i) => (
            <div key={i} style={{
              width: 18, height: 18, borderRadius: '50%',
              border: `2.5px solid ${err ? PIPI.pink : PIPI.ink}`,
              background: i < entered.length ? (err ? PIPI.pink : PIPI.ink) : 'transparent',
            }}/>
          ))}
        </div>
        <div style={{ minHeight: 18, color: PIPI.pink, fontSize: 14 }}>
          {err ? (lang === 'en' ? 'Wrong PIN' : 'Nesprávny PIN') : ''}
        </div>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 64px)', gap: 12 }}>
          {keys.map((k, i) => (k === '' ? <div key={i}/> : (
            <button key={i} onClick={() => (k === '⌫' ? del() : press(k))} style={{
              width: 64, height: 64, borderRadius: '50%', border: `2.5px solid ${PIPI.ink}`,
              background: PIPI.paperLt, fontFamily: '"Caveat", cursive', fontWeight: 700,
              fontSize: 26, color: PIPI.ink, cursor: 'pointer',
            }}>{k}</button>
          )))}
        </div>
        <button onClick={onCancel} style={{
          background: 'transparent', border: 0, color: PIPI.ink, opacity: 0.6, fontSize: 15,
          cursor: 'pointer', textDecoration: 'underline',
        }}>{lang === 'en' ? 'Back' : 'Späť'}</button>
      </div>
      {showTabBar && <ScribbleTabBar active="parent" onChange={onTab}/>}
    </>
  );
}

// Shown in place of the player when a child has used up the parent-set
// daily listening limit. Gentle, not punishing; tabs still work.
function DailyLimitScreen({ lang, limitMin, onTab, showTabBar = true }) {
  return (
    <>
      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '24px 28px', textAlign: 'center', gap: 14 }}>
        <div style={{ fontSize: 64, lineHeight: 1 }}>😴</div>
        <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 30, lineHeight: 1.05 }}>
          {lang === 'en' ? 'Pipi needs a little rest' : 'Pipi si musí oddýchnuť'}
        </div>
        <div style={{ fontSize: 16, opacity: 0.75, maxWidth: 320, lineHeight: 1.45 }}>
          {lang === 'en'
            ? `Today's listening time (${limitMin} min) is up. Come back tomorrow for more stories!`
            : `Dnešný čas na počúvanie (${limitMin} min) je preč. Príď zajtra na ďalšie príbehy!`}
        </div>
        <button onClick={() => onTab && onTab('home')} style={{
          marginTop: 6, background: PIPI.yellow, border: `2.5px solid ${PIPI.ink}`, borderRadius: 999,
          padding: '9px 20px', fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 19,
          color: PIPI.ink, cursor: 'pointer', boxShadow: '2px 3px 0 rgba(0,0,0,0.16)',
        }}>{lang === 'en' ? 'Back home' : 'Späť domov'}</button>
        <div style={{ fontSize: 13, opacity: 0.5, marginTop: 4 }}>
          {lang === 'en' ? 'A parent can change the limit in Settings → Listening.' : 'Rodič môže limit zmeniť v Nastavenia → Počúvanie.'}
        </div>
      </div>
      {showTabBar && <ScribbleTabBar active="home" onChange={onTab}/>}
    </>
  );
}

function DemoApp() {
  const [lang, setLang] = useState(readInitialLang);
  const t = demoT(lang);
  const [unlocked, setUnlocked] = useState(null);
  const [screen, setScreen] = useState('home');
  const [sceneKey, setSceneKey] = useState('start');
  const [story, setStory] = useState(STORIES[0]);

  // Layout decision flips at 768 px:
  //   ≤ 767 px (mobile)         → bare fullscreen, bottom tab bar in screens
  //   ≥ 768 px (tablet+desktop) → left sidebar nav, no PhoneShell, screens
  //                               render without their own tab bar
  // The old iPhone-mockup mode is dropped — the design lives at full width
  // with the sidebar instead, so the experience reads as a real app on
  // desktop instead of a tiny phone-shaped widget.
  const isWide = useIsWide(768);
  const [voice, setVoice] = useState(() => {
    try { return localStorage.getItem(DEMO_VOICE_KEY) || 'marek'; } catch { return 'marek'; }
  });
  const [voiceInput, setVoiceInput] = useState(true);
  const [bedtime, setBedtime] = useState(true);
  const [devMode, setDevMode] = useState(() => {
    try { return localStorage.getItem(DEMO_DEV_KEY) === '1'; } catch { return false; }
  });
  const [lastDebug, setLastDebug] = useState(null);
  const DEMO_CHOICE_KEY = 'pipi.demo.choiceMode';
  const [choiceMode, setChoiceMode] = useState(() => {
    try { return localStorage.getItem(DEMO_CHOICE_KEY) === 'smart' ? 'smart' : 'prepared'; } catch { return 'prepared'; }
  });
  // Kid profiles (privacy-first: age + avatar only, never a name). setKids
  // routes every change through persistKids() so localStorage + the server
  // profile stay in sync. activeKidId picks whose face shows in the header.
  const [kids, setKidsState] = useState(loadKids);
  const [activeKidId, setActiveKidId] = useState(() => loadActiveKidId(loadKids()));
  const setKids = useCallback((updater) => {
    setKidsState((prev) => {
      const next = typeof updater === 'function' ? updater(prev) : updater;
      persistKids(next);
      return next;
    });
  }, []);
  // Re-read localStorage into state WITHOUT persisting (persistKids would
  // push right back up and could clobber a fresher server pull). Used after
  // adoptFamilyStateForUser rewrites localStorage on sign-in / boot.
  const refreshKidsFromLocal = useCallback(() => {
    const fresh = loadKids();
    setKidsState(fresh);
    setActiveKidId(loadActiveKidId(fresh));
  }, []);
  // Boot-with-session: a returning signed-in user may have changed the
  // family on another device — reconcile once per app load.
  React.useEffect(() => {
    let cancelled = false;
    sbUser().then((u) => {
      if (!u || cancelled) return;
      adoptFamilyStateForUser(u)
        .then((r) => { if (!cancelled && r && r.changed) refreshKidsFromLocal(); })
        .catch(() => {});
    }).catch(() => {});
    return () => { cancelled = true; };
  }, [refreshKidsFromLocal]);
  const activeKid = kids.find((k) => k.id === activeKidId) || kids[0];

  // Per-kid favorites. Stored as { kidId: [storyId,...] }; the visible
  // `favorites` Set is derived for the active kid. One-time migration from
  // the old global 'pipi.demo.favorites' seeds the active kid so nobody
  // loses their hearts on upgrade.
  const [favsByKid, setFavsByKid] = useState(() => {
    const map = loadJSON(DEMO_FAVS_BY_KID_KEY, null);
    if (map) return map;
    let legacy = [];
    try { legacy = JSON.parse(localStorage.getItem('pipi.demo.favorites') || '[]'); } catch {}
    return legacy.length ? { [loadActiveKidId(loadKids())]: legacy } : {};
  });
  useEffect(() => { saveJSON(DEMO_FAVS_BY_KID_KEY, favsByKid); }, [favsByKid]);
  const favorites = React.useMemo(() => new Set(favsByKid[activeKidId] || []), [favsByKid, activeKidId]);
  const toggleFavorite = useCallback((id) => {
    setFavsByKid((prev) => {
      const cur = new Set(prev[activeKidId] || []);
      if (cur.has(id)) cur.delete(id); else cur.add(id);
      return { ...prev, [activeKidId]: [...cur] };
    });
    // Saving progress while signed out → the deferred sign-up nudge
    // (max once per session; the heart itself always works locally).
    sbUser().then((u) => {
      if (!u && !accountNudgedRef.current) {
        accountNudgedRef.current = true;
        setShowAccountNudge(true);
      }
    }).catch(() => {});
  }, [activeKidId]);

  // Per-kid resume: the last { storyId, sceneKey } the child was on, so the
  // home "Pokračuj v počúvaní" card reopens exactly there for THIS profile.
  const [resumeByKid, setResumeByKid] = useState(() => loadJSON(DEMO_RESUME_KEY, {}));
  useEffect(() => { saveJSON(DEMO_RESUME_KEY, resumeByKid); }, [resumeByKid]);

  // Per-kid daily listening time + optional daily limit. We count seconds
  // while the player screen is open and the tab is visible (a robust proxy
  // for active listening — no fragile audio-element instrumentation).
  const [timeByKid, setTimeByKid] = useState(() => loadJSON(DEMO_TIME_KEY, {}));
  useEffect(() => { saveJSON(DEMO_TIME_KEY, timeByKid); }, [timeByKid]);
  const [dailyLimitMin, setDailyLimitMin] = useState(() => Number(loadJSON(DEMO_LIMIT_KEY, 0)) || 0);
  useEffect(() => { saveJSON(DEMO_LIMIT_KEY, dailyLimitMin); }, [dailyLimitMin]);
  const todaySec = (() => { const t = timeByKid[activeKidId]; return t && t.day === todayStr() ? (t.sec || 0) : 0; })();
  const limitReached = dailyLimitMin > 0 && todaySec >= dailyLimitMin * 60;

  // Parent PIN — optional 4-digit lock on the Settings screen. Empty = off.
  // Unlock is per-session and re-locks whenever the parent leaves Settings.
  const [parentPin, setParentPinState] = useState(() => String(loadJSON(DEMO_PIN_KEY, '') || ''));
  const setParentPin = useCallback((pin) => { setParentPinState(pin); saveJSON(DEMO_PIN_KEY, pin); }, []);
  const [parentUnlocked, setParentUnlocked] = useState(false);

  // Entitlement / paywall scaffold. plan: 'trial' | 'family' | 'none'.
  // On first open we start a 7-day free trial. While the trial is live (or
  // on the paid Family plan) everything is unlocked; afterwards only the
  // free books open and the rest show a paywall.
  const [entitlement, setEntitlement] = useState(() => {
    const e = loadJSON(DEMO_PAYWALL_KEY, null);
    if (e && e.plan) return e;
    const fresh = { plan: 'trial', trialStart: Date.now() };
    saveJSON(DEMO_PAYWALL_KEY, fresh);
    return fresh;
  });
  useEffect(() => { saveJSON(DEMO_PAYWALL_KEY, entitlement); }, [entitlement]);
  const trialMsLeft = entitlement.plan === 'trial' ? (entitlement.trialStart + TRIAL_DAYS * 86400000 - Date.now()) : 0;
  const trialActive = entitlement.plan === 'trial' && trialMsLeft > 0;
  const entitled = entitlement.plan === 'family' || trialActive;
  const trialDaysLeft = Math.max(0, Math.ceil(trialMsLeft / 86400000));
  const isLocked = useCallback((s) => !!s && !entitled && !FREE_STORY_IDS.has(s.id), [entitled]);
  const [showPaywall, setShowPaywall] = useState(false);
  // ── Stripe seam ──────────────────────────────────────────────────────
  // "Predplatiť" (Plan card) + locked-book taps open the trial-timeline
  // PaywallSheet; the sheet's CTA hands off to /predplatne.html, where the
  // real Stripe Checkout (api/stripe/checkout.js) lives.
  const startCheckout = useCallback(() => { setShowPaywall(true); }, []);

  // ── Post-first-session flow + deferred account creation ─────────────
  // The wizard never asks for an account. The sign-up nudge fires at two
  // moments only (teardown P1-1): (a) once, right after the FIRST story
  // session ends — preceded by the bedtime/quiet opt-in screen — and
  // (b) when a signed-out parent tries to save progress (hearts a story).
  const [postSession, setPostSession] = useState(null); // null | 'notify' | 'account'
  const [showAccountNudge, setShowAccountNudge] = useState(false);
  const accountNudgedRef = React.useRef(false); // favorite-nudge max once per session
  const postAcctRef = React.useRef(false);      // append account step after 'notify'?
  const pendingAutoplayRef = React.useRef(false);
  // Consuming getter for DemoPlayerScreen's one-shot autostart (set by the
  // onboarding "Pustiť príbeh" tap, cleared on first read).
  const consumeAutoStart = React.useCallback(() => {
    const v = pendingAutoplayRef.current;
    pendingAutoplayRef.current = false;
    return v;
  }, []);
  const maybeEndFirstSession = React.useCallback(() => {
    let first = false;
    try {
      first = localStorage.getItem(FIRST_SESSION_KEY) !== '1';
      if (first) localStorage.setItem(FIRST_SESSION_KEY, '1');
    } catch {}
    if (!first) return;
    sbUser()
      .then((u) => { postAcctRef.current = !u; setPostSession('notify'); })
      .catch(() => { postAcctRef.current = true; setPostSession('notify'); });
  }, []);

  // Which settings section is open (account | kids | listening | privacy).
  const [settingsSection, setSettingsSection] = useState('listening');
  useEffect(() => { try { localStorage.setItem(DEMO_ACTIVE_KID_KEY, activeKidId); } catch {} }, [activeKidId]);
  // If the active kid was removed, fall back to the first remaining profile.
  useEffect(() => {
    if (kids.length && !kids.some((k) => k.id === activeKidId)) setActiveKidId(kids[0].id);
  }, [kids, activeKidId]);

  // Load the 3 audiobook manifests + the vila-custom manifest once on
  // mount. expandBookChain() turns each into a chain of STORY_GRAPH
  // scenes; the result is merged with the base STORY_GRAPH and
  // memoized so screens read a single object.
  const [bookManifests, setBookManifests] = useState({
    circus: null, outing: null, burglars: null, goldilocks: null, cirkus: null, vylet: null, zlodeji: null, cirkusTest: null, vilaCustom: null,
  });
  useEffect(() => {
    const load = (name, key) => fetch(`/demo/manifests/${name}.json`, { cache: 'no-store' })
      .then((r) => (r.ok ? r.json() : null))
      .catch(() => null)
      .then((d) => setBookManifests((prev) => ({ ...prev, [key]: d })));
    load('circus', 'circus');
    load('outing', 'outing');
    load('burglars', 'burglars');
    load('goldilocks', 'goldilocks');
    load('cirkus', 'cirkus');
    load('vylet', 'vylet');
    load('zlodeji', 'zlodeji');
    if (SHOW_DEV_BOOKS) load('cirkus-test', 'cirkusTest');
    load('vila-custom', 'vilaCustom');
  }, []);
  const storyGraph = React.useMemo(() => ({
    ...STORY_GRAPH,
    ...expandBookChain('circus',     BOOK_META.circus.title,     BOOK_META.circus.intro,     BOOK_META.circus.ending,     bookManifests.circus),
    ...expandBookChain('outing',     BOOK_META.outing.title,     BOOK_META.outing.intro,     BOOK_META.outing.ending,     bookManifests.outing),
    ...expandBookChain('burglars',   BOOK_META.burglars.title,   BOOK_META.burglars.intro,   BOOK_META.burglars.ending,   bookManifests.burglars),
    ...expandBookChain('goldilocks', BOOK_META.goldilocks.title, BOOK_META.goldilocks.intro, BOOK_META.goldilocks.ending, bookManifests.goldilocks),
    ...expandBookChain('cirkus',     BOOK_META.cirkus.title,     BOOK_META.cirkus.intro,     BOOK_META.cirkus.ending,     bookManifests.cirkus),
    ...expandBookChain('vylet',      BOOK_META.vylet.title,      BOOK_META.vylet.intro,      BOOK_META.vylet.ending,      bookManifests.vylet),
    ...expandBookChain('zlodeji',    BOOK_META.zlodeji.title,    BOOK_META.zlodeji.intro,    BOOK_META.zlodeji.ending,    bookManifests.zlodeji),
    ...(SHOW_DEV_BOOKS ? expandBookChain('cirkusTest', BOOK_META.cirkusTest.title, BOOK_META.cirkusTest.intro, BOOK_META.cirkusTest.ending, bookManifests.cirkusTest) : {}),
  }), [bookManifests]);
  // persist toggles
  useEffect(() => { try { localStorage.setItem(DEMO_LANG_KEY, lang); } catch {} }, [lang]);
  useEffect(() => { try { localStorage.setItem(DEMO_VOICE_KEY, voice); } catch {} }, [voice]);
  useEffect(() => { try { localStorage.setItem(DEMO_DEV_KEY, devMode ? '1' : '0'); } catch {} }, [devMode]);
  useEffect(() => { try { localStorage.setItem(DEMO_CHOICE_KEY, choiceMode); } catch {} }, [choiceMode]);

  // Probe whether the demo needs a password.
  useEffect(() => {
    (async () => {
      try {
        const res = await fetch('/api/login', { method: 'GET' });
        const data = await res.json().catch(() => ({}));
        if (!data.required) { setUnlocked(true); return; }
        if (data.authenticated) { setUnlocked(true); return; }
        setUnlocked(false);
      } catch {
        // local static preview / endpoint missing — allow through.
        setUnlocked(true);
      }
    })();
  }, []);

  // hide boot screen once we know
  useEffect(() => {
    if (unlocked !== null) {
      const boot = document.getElementById('booting');
      if (boot) boot.remove();
      if (typeof window.pipiFit === 'function') {
        setTimeout(window.pipiFit, 20);
        setTimeout(window.pipiFit, 300);
      }
    }
  }, [unlocked]);

  // When the user switches tabs we want to leave the player cleanly:
  // stop the mic, pause anything we were currently playing (re-ask /
  // fallback / ack / tail), and bump pickTokenRef so any in-flight
  // ack-tail loop bails out instead of continuing while the user is
  // browsing the library. Without this the demo would keep recording
  // mic audio + play voice-over over the next screen.
  const onTab = useCallback((tab) => {
    setPlayerListening(false);
    setPlayerTranscript('');
    setPlayerHeardChoice(null);
    try { if (inflightAudioRef.current) inflightAudioRef.current.pause(); } catch {}
    inflightAudioRef.current = null;
    try { if (window.PipiAudio) window.PipiAudio.stop(); } catch {} // Web Audio side clip too
    phaseClipRef.current = null; // cross-engine: never leave a stale transport handle (audit #20)
    askPhaseRef.current = { attempts: 0, busy: false };
    pickTokenRef.current++;
    if (tab === 'parent') setScreen('parent');
    else if (tab === 'library') setScreen('library');
    else if (tab === 'search') setScreen('search');
    else setScreen('home');
  }, []);

  const openStory = useCallback((s) => {
    if (isLocked(s)) { setShowPaywall(true); return; }
    setStory(s);
    // Each story can specify its own STORY_GRAPH entry point via
    // `rootScene`; default is the interactive showcase ('start').
    if (s.product === 'custom') {
      setScreen('custom_chain');
      return;
    }
    // Use the rootScene id directly — don't gate on storyGraph having
    // it yet (manifests load async). When the manifest resolves the
    // useMemo recomputes and the scene becomes available.
    setSceneKey(s.rootScene || 'start');
    setScreen('player');
  }, [isLocked]);

  // Open a story directly at a given scene (used by the "Pokračuj" card).
  const openStoryAt = useCallback((s, key) => {
    if (isLocked(s)) { setShowPaywall(true); return; }
    setStory(s);
    if (s.product === 'custom') { setScreen('custom_chain'); return; }
    setSceneKey(key || s.rootScene || 'start');
    setScreen('player');
  }, [isLocked]);

  // Save the active kid's resume point whenever they're in the player on a
  // real book scene. Scene-level (not second-level) — robust and enough to
  // drop the child back where they were.
  useEffect(() => {
    if (screen === 'player' && story && sceneKey && story.product !== 'custom' && sceneKey !== 'start') {
      setResumeByKid((prev) => ({ ...prev, [activeKidId]: { storyId: story.id, sceneKey, ts: Date.now() } }));
    }
  }, [screen, story, sceneKey, activeKidId]);

  // Re-lock the parent settings whenever we leave that screen.
  useEffect(() => { if (screen !== 'parent') setParentUnlocked(false); }, [screen]);

  // Count a second per second of player time for the active kid (skips
  // hidden tab / non-player screens). Rolls over at midnight.
  useEffect(() => {
    if (screen !== 'player') return undefined;
    const id = setInterval(() => {
      if (typeof document !== 'undefined' && document.hidden) return;
      setTimeByKid((prev) => {
        const day = todayStr();
        const cur = prev[activeKidId];
        const sec = (cur && cur.day === day ? cur.sec : 0) + 1;
        return { ...prev, [activeKidId]: { day, sec } };
      });
    }, 1000);
    return () => clearInterval(id);
  }, [screen, activeKidId]);

  // Resolve the active kid's resume into a card the home screen can show.
  const activeResume = React.useMemo(() => {
    const r = resumeByKid[activeKidId];
    if (!r) return null;
    const s = STORIES.find((x) => x.id === r.storyId);
    if (!s) return null;
    let progress = 0.35;
    try {
      const chain = getChainScenes(storyGraph, r.sceneKey) || [];
      const idx = chain.findIndex((c) => c.key === r.sceneKey);
      if (chain.length && idx >= 0) progress = Math.min(0.98, (idx + 1) / (chain.length + 1));
    } catch {}
    return { story: s, sceneKey: r.sceneKey, progress };
  }, [resumeByKid, activeKidId, storyGraph]);

  const [playerListening, setPlayerListening] = useState(false);
  const [playerTranscript, setPlayerTranscript] = useState('');
  const [playerHeardChoice, setPlayerHeardChoice] = useState(null);

  // Track which prompt is currently "in the air" so the mic only opens
  // AFTER the narrator finishes asking, and we know whether to play a
  // re-ask or fallback next time the listener returns empty / unclear.
  const askPhaseRef = React.useRef({ attempts: 0, busy: false });
  const inflightAudioRef = React.useRef(null);

  // playbackPhase = which clip is currently audible. The player's
  // transport (pause + −5 s) checks this so:
  //   - pause/play during 'bridge' / 'reAsk' / 'fallback' / 'tail'
  //     toggles THAT clip, not the silent main recording
  //   - skip(-5) during a clip rewinds THAT clip, not the main track
  //   - when nothing is in 'phase' the controls fall back to the main
  //     audio element (managed by useAudio).
  // The user reported: hitting -5 s while the narrator is asking a
  // question used to do nothing visible (skip went to the paused main)
  // and the question kept playing. Now -5 s during a question
  // re-plays the last 5 s of the question.
  const [playbackPhase, setPlaybackPhase] = useState('idle'); // idle | bridge | reAsk | fallback | tail | listening
  const phaseClipRef = React.useRef(null);
  // Registered by DemoPlayerScreen — the master recording's <audio> element,
  // so playOne can volume-DUCK it at split boundaries instead of letting the
  // side clip hard-cut over it (audio-engine.js, "the gradient" fix).
  const masterElRef = React.useRef(null);
  const phaseTokenRef = React.useRef(0);
  // Pick token bumped whenever we navigate away — any in-flight bridge/ack/
  // tail sequence checks it after each clip and bails out, so the user doesn't
  // hear leftover voice-over (or get the mic armed) after leaving the player.
  const pickTokenRef = React.useRef(0);
  // opts (audit #11/#18): { nearEndSec, onNearEnd } — onNearEnd fires once,
  // ~nearEndSec before the clip ends, on both engine paths.
  const playOne = React.useCallback((src, phase = 'tail', opts = {}) => new Promise((resolve) => {
    if (!src) { setPlaybackPhase('idle'); return resolve(); }
    const PA = (typeof window !== 'undefined') ? window.PipiAudio : null;
    if (PA && PA.available()) {
      // Web Audio path — decoded buffer + gain fades, crossfades out whatever
      // clip was playing. Same Promise contract as the HTMLAudio path below:
      // resolves when the clip ends (or is stopped/replaced).
      try { PA.unlock(); } catch {}
      if (inflightAudioRef.current) { try { inflightAudioRef.current.pause(); } catch {} inflightAudioRef.current = null; }
      const token = ++phaseTokenRef.current;
      phaseClipRef.current = PA.handle();
      setPlaybackPhase(phase);
      // The master is already faded out + paused at the split (useAudio onTime,
      // audit #3), so don't double-duck here; finish() restores it for the resume.
      const finish = () => {
        if (phaseTokenRef.current === token) {
          phaseClipRef.current = null;
          setPlaybackPhase('idle');
          const m = masterElRef.current;
          if (m) PA.duck(m, 1, 200); // restore before the master resumes
        }
        resolve();
      };
      PA.play(src, { fadeIn: 0.1, fadeOut: 0.1, nearEndSec: opts.nearEndSec, onNearEnd: opts.onNearEnd }).then(finish).catch(finish);
      return;
    }
    // Legacy HTMLAudio path (no Web Audio) — behavior unchanged, plus a
    // timeupdate-based onNearEnd so both engines honor the same contract.
    try {
      if (inflightAudioRef.current) { try { inflightAudioRef.current.pause(); } catch {} }
      const a = new Audio(src);
      inflightAudioRef.current = a;
      phaseClipRef.current = a;
      setPlaybackPhase(phase);
      let nearEndFired = false;
      if (typeof opts.onNearEnd === 'function') {
        const lead = Math.max(0.05, opts.nearEndSec || 0.15);
        a.addEventListener('timeupdate', function onT() {
          if (nearEndFired) { a.removeEventListener('timeupdate', onT); return; }
          if (a.duration && a.duration - a.currentTime <= lead) {
            nearEndFired = true;
            a.removeEventListener('timeupdate', onT);
            try { opts.onNearEnd(); } catch {}
          }
        });
      }
      const finish = () => {
        if (phaseClipRef.current === a) {
          phaseClipRef.current = null;
          setPlaybackPhase('idle');
        }
        resolve();
      };
      a.addEventListener('ended', finish, { once: true });
      a.addEventListener('error', finish, { once: true });
      a.play().catch(finish);
    } catch { setPlaybackPhase('idle'); resolve(); }
  }), []);

  // Mic ALWAYS auto-starts when the narrator asks a choice. The Parent
  // voiceInput toggle is honored only as a hard kill switch (e.g.
  // listening with kids in public). Default = always auto-listen.
  //
  // Full flow on a split:
  //   1. play bridgeAudio (the narrator question MP3)
  //   2. open mic — useSmartListening records ~5 s
  //   3. on result:
  //        - error / silence → play reAskAudio → record again (attempt 2)
  //        - unclear        → play fallbackAudio → record again
  //        - clear choice   → onHeardChoice handles it (ack + tail + advance)
  //   4. after MAX_ASK_ATTEMPTS failures → auto-advance down the manifest's
  //      fallbackChoiceId (see onListenResult). A missing bridge clip is the
  //      only path to the manual choice screen.
  const askChoice = useCallback(async () => {
    const scene = storyGraph[sceneKey];
    if (!scene) return;
    setPlayerTranscript('');
    setPlayerHeardChoice(null);
    if (voiceInput === false) { setScreen('choice'); return; }
    if (askPhaseRef.current.busy) return;
    askPhaseRef.current = { attempts: 0, busy: true };
    // With PipiAudio, a replaced/stopped clip RESOLVES its promise (the old
    // HTMLAudio path left it hanging) — so re-check the navigation token
    // before arming the mic, or a tab switch mid-bridge would open the mic
    // on the library screen.
    // Missing bridge clip → don't open a silent mic (the child hears nothing,
    // then a reAsk loop). Fall back to the tappable choice screen. (audit #12)
    if (!scene.bridgeAudio) {
      askPhaseRef.current.busy = false;
      setScreen('choice');
      return;
    }
    const navToken = pickTokenRef.current;
    // Arm the mic just BEFORE the question ends (audit #18): a quiet rising
    // earcon marks "your turn" and the recorder's ramp-up + VAD dead time run
    // under the bridge's last beat instead of adding to the silence after it.
    await playOne(scene.bridgeAudio, 'bridge', {
      nearEndSec: 0.35,
      onNearEnd: () => {
        if (pickTokenRef.current !== navToken) return;
        try { if (window.PipiAudio && window.PipiAudio.earcon) window.PipiAudio.earcon(); } catch {}
        setPlayerListening(true);
      },
    });
    askPhaseRef.current.busy = false;
    if (pickTokenRef.current !== navToken) return;
    setPlayerListening(true); // idempotent — normally armed at nearEnd already
  }, [storyGraph, sceneKey, voiceInput, playOne]);

  // The smart-listening hook is shared with ChoiceScreen; here we run it
  // for the player. After listening returns we decide whether to:
  //   - auto-pick the matched choice (clear hit)
  //   - play re-ask (silence) and listen again
  //   - play fallback (unclear) and listen again
  //   - give up after 2 failed attempts and show the manual choice cards
  const onListenResult = React.useCallback(async (r) => {
    setPlayerListening(false);
    const scene = storyGraph[sceneKey];
    if (!scene) return;
    const heardText = (r && r.text) || '';
    const heardChoice = (r && r.choice) || 'unclear';
    setPlayerTranscript(heardText);
    setPlayerHeardChoice(heardChoice);

    // Clear hit → auto-pick. matchedChoice must be one of the scene's
    // own ids; the classifier sometimes returns option_a/b/c which we
    // map to the first/second/third choice as a fallback alias.
    const idToChoice = {};
    for (const c of (scene.choices || [])) idToChoice[c.id] = c;
    const aliases = {
      option_a: scene.choices && scene.choices[0],
      option_b: scene.choices && scene.choices[1],
      option_c: scene.choices && scene.choices[2],
    };
    const matched = idToChoice[heardChoice] || aliases[heardChoice];
    if (matched) {
      askPhaseRef.current = { attempts: 0, busy: false };
      onPicked(matched);
      return;
    }

    // Silence or unclear → re-ask or fallback. Still "mic-first": we never
    // bounce the child to a tappable choice screen, because that breaks the
    // audiobook trance.
    //   silence  →  reAskAudio   ("prepáč, nepočul som dobre…")
    //   unclear  →  fallbackAudio ("nerozumiem, chceš aby Pipi…")
    // The two MP3s must each re-state the 3 options out loud — that's
    // a content responsibility on the render side, not a runtime one.
    //
    // But looping those prompts forever is its own trap: a 1-5-year-old who
    // cannot pronounce an option never escapes, and the story is simply over
    // for them (path audit, cirkus #15 / vylet #7 / zlodeji #12). So after
    // MAX_ASK_ATTEMPTS we auto-advance down the manifest's fallbackChoiceId —
    // a branch chosen per split for the cleanest dovetail into the master's
    // resume. The story continues in the narrator's voice, which serves the
    // mic-first intent better than an endless prompt loop does.
    // Books with no fallbackChoiceId keep the old loop-forever behavior.
    const phase = askPhaseRef.current;
    phase.attempts += 1;
    if (phase.attempts >= MAX_ASK_ATTEMPTS && scene.fallbackChoiceId) {
      const dflt = (scene.choices || []).find((c) => c.id === scene.fallbackChoiceId);
      if (dflt) {
        console.warn(`[pipi] ${phase.attempts} unrecognized answers → auto-advancing down '${dflt.id}'`);
        askPhaseRef.current = { attempts: 0, busy: false };
        onPicked(dflt);
        return;
      }
    }
    const isSilence = !heardText || (r && r.skipped === 'silence');
    const promptUrl = isSilence ? scene.reAskAudio : scene.fallbackAudio;
    phase.busy = true;
    const navToken = pickTokenRef.current;
    // Same pre-arm + earcon as askChoice (audit #18) — the re-ask is a
    // question too, and the retry is where the "is it my turn?" doubt hurts most.
    await playOne(promptUrl, isSilence ? 'reAsk' : 'fallback', {
      nearEndSec: 0.35,
      onNearEnd: () => {
        if (pickTokenRef.current !== navToken) return;
        try { if (window.PipiAudio && window.PipiAudio.earcon) window.PipiAudio.earcon(); } catch {}
        setPlayerListening(true);
      },
    });
    phase.busy = false;
    if (pickTokenRef.current !== navToken) return; // navigated away mid-prompt
    setPlayerListening(true);
  }, [storyGraph, sceneKey, playOne]);

  // Same projection for the player's mic — feed the current scene's
  // own choices to the classifier so it can return a real branch id
  // instead of `unclear`.
  const playerSceneChoices = React.useMemo(() => {
    const cur = storyGraph[sceneKey];
    if (!cur || !Array.isArray(cur.choices)) return null;
    return cur.choices.map((c) => ({
      id: c.id,
      label: (c.label && (c.label.sk || c.label.en)) || '',
      desc:  (c.desc  && (c.desc.sk  || c.desc.en))  || '',
    }));
  }, [storyGraph, sceneKey]);
  useSmartListening({
    enabled: playerListening,
    onResult: onListenResult,
    choices: playerSceneChoices,
  });

  // After a pick we play [ack, tail] (if either is present) sequentially
  // then advance to the next scene. The user sits on the player screen
  // and sees the listening rings dim while the narrator confirms +
  // tells the short divergence; then the original recording resumes
  // from the next scene's startAtSec. Listening state is cleared so
  // the mic doesn't re-open mid-ack.
  // (pickTokenRef is declared next to phaseClipRef above — it now also guards
  // askChoice/onListenResult, not just the ack/tail chain here.)
  const onPicked = useCallback(async (choice) => {
    const scene = storyGraph[sceneKey];
    const branch = scene.branches && scene.branches[choice.id];
    setPlayerListening(false);
    setPlayerTranscript('');
    setPlayerHeardChoice(null);
    setScreen('player');
    if (!branch) return;
    const myToken = ++pickTokenRef.current;
    setLastDebug({ heard: choice.label[lang] || choice.label.sk, choice: choice.id, confidence: 1.0 });
    // Anonymized analytics → Supabase story_plays (fire-and-forget).
    logStoryPlay(story.id, sceneKey, choice.id, lang);
    // Skip ack playback entirely. The ack was a short confirmation line
    // ("Pipi začne skákať do kaluže.") which broke the storytelling
    // illusion — once a child picked a branch they wanted the STORY to
    // continue immediately, not hear a meta confirmation first. The ack
    // text is still authored in the manifest (and the MP3 is still
    // rendered) so we can bring it back per-branch if we ever need it,
    // but it's not part of the runtime flow anymore.
    if (branch.tail) {
      if (pickTokenRef.current !== myToken) return;
      // Advance the scene as the tail enters its last beat (audit #11): the
      // scene-resume effect then starts the master fade-in UNDER the tail's
      // fade-out — a crossfade at the divergence→continuation seam instead of
      // fade-out → silence → fade-in. The post-await setSceneKey below stays
      // as a same-value no-op safety net (and the only advance path when the
      // near-end callback couldn't fire).
      await playOne(branch.tail, 'tail', {
        nearEndSec: 0.18,
        onNearEnd: () => { if (pickTokenRef.current === myToken) setSceneKey(branch.next); },
      });
    }
    if (pickTokenRef.current !== myToken) return;
    setSceneKey(branch.next);
  }, [sceneKey, lang, playOne, storyGraph, story]);

  // The sidebar reflects the current top-level destination. Player /
  // listening / choice / custom_chain all live inside the "library" or
  // "home" flow, so we keep the previously-clicked top-level tab
  // highlighted while the user is inside a story. activeTab keeps that
  // state separate from `screen` (which can be the player view).
  // IMPORTANT: declare these hooks BEFORE the unlocked-gate early returns
  // — otherwise React sees a different hook count between the locked
  // and unlocked renders ("Rendered more hooks than during the previous
  // render" crash).
  const [activeTab, setActiveTab] = React.useState('home');
  const onTabHighlight = React.useCallback((tab) => { setActiveTab(tab); onTab(tab); }, [onTab]);

  // Onboarding wizard — show on first visit. We treat completion as
  // either "user finished the wizard" (localStorage flag) OR "user
  // explicitly skipped this session" (in-memory only). Skip resets on
  // page reload, so the wizard re-prompts gently next time without
  // being annoying mid-session.
  const [onboardingSkippedThisSession, setOnboardingSkippedThisSession] = React.useState(false);
  const [onboardingDone, setOnboardingDone] = React.useState(() => {
    try {
      const o = JSON.parse(localStorage.getItem('pipi.demo.onboarding') || '{}');
      return !!o.complete;
    } catch { return false; }
  });

  if (unlocked === null) return null;
  if (unlocked === false) {
    return <DemoLogin lang={lang} onUnlock={() => setUnlocked(true)}/>;
  }

  // If the user opened a book whose manifest is still loading, scene is
  // undefined. Show a small loader instead of falling back to vila.
  const scene = storyGraph[sceneKey];
  const sceneLoading = !scene && sceneKey && sceneKey !== 'start';

  const screenBody = (
    <>
      {screen === 'home' && (
        <HomeScreen onOpen={openStory} onTab={onTabHighlight} showTabBar={!isWide} t={t} lang={lang}
          resume={activeResume} onResume={() => activeResume && openStoryAt(activeResume.story, activeResume.sceneKey)}/>
      )}
      {screen === 'player' && limitReached && (
        <DailyLimitScreen lang={lang} limitMin={dailyLimitMin} onTab={onTabHighlight} showTabBar={!isWide}/>
      )}
      {screen === 'player' && !limitReached && sceneLoading && (
        <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
          <div style={{ fontFamily: '"Caveat", cursive', fontWeight: 700, fontSize: 24, color: PIPI.ink, opacity: 0.7 }}>
            Pip sa naťahuje…
          </div>
        </div>
      )}
      {screen === 'player' && !limitReached && !sceneLoading && (
        <DemoPlayerScreen
          story={story} scene={scene || storyGraph.start} t={t} lang={lang}
          onBack={() => onTab('home')}
          onAskChoice={askChoice}
          onChapterEnd={() => {
            // First story finished (end-scene audio ran out) → the one-time
            // post-session flow: bedtime opt-in, then the sign-up nudge.
            if (scene && scene.end) maybeEndFirstSession();
          }}
          consumeAutoStart={consumeAutoStart}
          devMode={devMode}
          lastDebug={lastDebug}
          isFavorite={favorites.has(story.id)}
          onToggleFavorite={() => toggleFavorite(story.id)}
          listening={playerListening}
          transcript={playerTranscript}
          heardChoice={playerHeardChoice}
          playbackPhase={playbackPhase}
          phaseClipRef={phaseClipRef}
          masterElRef={masterElRef}
          chainScenes={getChainScenes(storyGraph, sceneKey)}
          onPickFromTranscript={() => {
            // Route to the choice screen so the child can confirm with a tap.
            // The suggested branch will be highlighted there.
            setScreen('choice');
            setPlayerListening(false);
          }}
          onClearListening={() => {
            setPlayerListening(false);
            setPlayerTranscript('');
            setPlayerHeardChoice(null);
          }}
          onSkipSplit={() => {
            // Child scrubbed past a choice — auto-pick the first
            // branch so the story continues without making them stop
            // to decide. Plays ack + tail, then advances.
            const cur = storyGraph[sceneKey];
            if (!cur || !cur.choices || !cur.choices.length) return;
            onPicked(cur.choices[0]);
          }}
        />
      )}
      {screen === 'library' && (
        <DemoLibraryScreen
          onOpen={openStory} onTab={onTabHighlight}
          choiceMode={choiceMode} onChoiceModeChange={setChoiceMode}
          favorites={favorites} onToggleFavorite={toggleFavorite}
          lang={lang} showTabBar={!isWide}
          kids={kids} activeKidId={activeKidId} onSelectKid={setActiveKidId}
          onManageKids={() => { setSettingsSection('kids'); onTabHighlight('parent'); }}
          isLocked={isLocked}
        />
      )}
      {screen === 'search' && (
        <DemoSearchScreen
          onOpen={openStory} onTab={onTabHighlight} lang={lang} showTabBar={!isWide}
        />
      )}
      {screen === 'custom_chain' && (
        <DemoCustomChain
          manifest={bookManifests.vilaCustom}
          onExit={() => { setScreen('home'); }}
          lang={lang}
        />
      )}
      {screen === 'listening' && (
        <ListeningScreen
          story={story}
          onCancel={() => setScreen('player')}
          onHeard={() => setScreen('choice')}
        />
      )}
      {screen === 'choice' && (
        <DemoChoiceScreen
          scene={scene} t={t} lang={lang}
          onPick={onPicked}
          onBack={() => setScreen('player')}
          voiceEnabled={voiceInput}
          choiceMode={choiceMode}
          onChoiceModeChange={setChoiceMode}
        />
      )}
      {screen === 'parent' && parentPin && !parentUnlocked && (
        <ParentPinGate lang={lang} expected={parentPin} onTab={onTabHighlight}
          onUnlock={() => setParentUnlocked(true)} onCancel={() => onTabHighlight('home')}
          showTabBar={!isWide}/>
      )}
      {screen === 'parent' && (!parentPin || parentUnlocked) && (
        <DemoParentScreen
          onTab={onTabHighlight} lang={lang} onLangChange={setLang}
          devMode={devMode} setDevMode={setDevMode}
          voiceInput={voiceInput} setVoiceInput={setVoiceInput}
          choiceMode={choiceMode} setChoiceMode={setChoiceMode}
          kids={kids} setKids={setKids} activeKidId={activeKidId} setActiveKidId={setActiveKidId}
          section={settingsSection} setSection={setSettingsSection}
          dailyLimitMin={dailyLimitMin} setDailyLimitMin={setDailyLimitMin} todaySec={todaySec}
          parentPin={parentPin} setParentPin={setParentPin}
          plan={entitlement.plan} trialDaysLeft={trialDaysLeft} entitled={entitled} onSubscribe={startCheckout}
          onFamilyChanged={refreshKidsFromLocal}
          t={t} showTabBar={!isWide}
        />
      )}
    </>
  );

  const showOnboarding = !onboardingDone && !onboardingSkippedThisSession;
  const wizard = showOnboarding ? (
    <OnboardingWizard
      onDone={() => {
        // "Neskôr" path — onboarding is complete, no story started. Rebuild
        // the kid list from the fresh answers (age from the ONE question);
        // persistKids keeps localStorage + the Supabase profile in sync at
        // the same logical completion point as before.
        setOnboardingDone(true);
        setKids(loadKids());
      }}
      onSkip={() => setOnboardingSkippedThisSession(true)}
      onPlay={(s) => {
        // Recommendation CTA — complete onboarding and start the story
        // immediately (one-shot autostart inside the player).
        setOnboardingDone(true);
        setKids(loadKids());
        pendingAutoplayRef.current = true;
        openStory(s);
      }}
    />
  ) : null;
  const paywall = showPaywall ? (
    <PaywallSheet lang={lang} trialDaysLeft={trialDaysLeft} plan={entitlement.plan}
      onClose={() => setShowPaywall(false)}/>
  ) : null;
  // Post-first-session overlays: bedtime/quiet opt-in, then (signed-out
  // only) the deferred sign-up nudge. showAccountNudge is the
  // save-progress variant of the same sheet.
  const postFlow = postSession === 'notify' ? (
    <PostSessionSetup lang={lang}
      onDone={() => setPostSession(postAcctRef.current ? 'account' : null)}/>
  ) : (postSession === 'account' || showAccountNudge) ? (
    <AccountNudgeSheet lang={lang}
      onCreate={() => {
        setPostSession(null); setShowAccountNudge(false);
        setSettingsSection('account');
        onTabHighlight('parent'); // PIN gate (if set) still applies — it's a parent action
      }}
      onLater={() => { setPostSession(null); setShowAccountNudge(false); }}/>
  ) : null;

  if (isWide) {
    // Desktop / tablet layout — sidebar + content column. The content
    // column is capped at 760 px so the design retains its phone-app
    // proportions inside the wider browser window (cards don't stretch
    // to 1600 px). The page background fills the rest.
    return (
      <div style={{
        display: 'flex', minHeight: '100vh', background: PIPI.paper,
        fontFamily: '"Patrick Hand", cursive', color: PIPI.ink,
      }}>
        <DesktopSidebar active={activeTab} onChange={onTabHighlight} lang={lang}/>
        <main style={{ flex: 1, minWidth: 0, overflowX: 'hidden' }}>
          <div style={{
            maxWidth: 760, margin: '0 auto', minHeight: '100vh',
            display: 'flex', flexDirection: 'column',
            background: PIPI.paper,
            borderLeft: `1.5px dashed ${PIPI.inkSoft}33`,
            borderRight: `1.5px dashed ${PIPI.inkSoft}33`,
          }}>
            {screenBody}
          </div>
        </main>
        {wizard}
        {paywall}
        {postFlow}
      </div>
    );
  }

  return (
    <>
      <PhoneShell bare={true}>
        {screenBody}
      </PhoneShell>
      {wizard}
      {paywall}
      {postFlow}
    </>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<DemoApp/>);
