// Surface — Localisation
//
// ONE place to translate the whole course (see §25.13):
//   · a persistent LEFT RAIL of languages — add / select / delete a language.
//     English (primary) is shown for REFERENCE ONLY and is NOT clickable —
//     it just tells the author English is the source everything translates
//     from. The FIRST action is to pick a target language.
//   · a content-type switcher (Modules / Roles / Subtitles / Assessment),
//     shown once a target language is selected.
//   · every content type uses the SAME pattern: a dropdown to pick the item,
//     a side-by-side source → target editor, an explicit Save, and one
//     "Run localisation" action (scoped to this item OR the whole course).
//     No per-field review chrome; no bespoke per-type layouts.

const LOC_SUPPORTED_LANGS = ['en','it','de','fr','es','jp','ar','hk','ch','id','th','tr','pt','nl'];

// ── Custom-language registry ─────────────────────────────────────────────────
// Authors aren't limited to LOC_SUPPORTED_LANGS — they can define a brand-new
// target language (name + flag + locale code + translation guidance). Those
// definitions live here so every renderer can resolve a custom code's name and
// flag the same way it resolves a built-in one (the static LANG_NAMES /
// LANG_FLAGS maps in data.jsx). The component writes this map during render
// (via useMemo) BEFORE its children read it, so resolution is always current.
const LOC_CUSTOM_LANGS = {};
const locName = (code) =>
  (LOC_CUSTOM_LANGS[code] && LOC_CUSTOM_LANGS[code].name) || LANG_NAMES[code] || (code || '').toUpperCase();
const locFlag = (code) =>
  (LOC_CUSTOM_LANGS[code] && LOC_CUSTOM_LANGS[code].flag) || LANG_FLAGS[code] || '🌐';
// Per-language translation guidance — the instruction the AI uses to run
// localisation INTO that specific language (register, dialect, spelling…).
const locNote = (code) => (LOC_CUSTOM_LANGS[code] && LOC_CUSTOM_LANGS[code].note) || '';
const locIsCustom = (code) => !!LOC_CUSTOM_LANGS[code];

// ── Auto-translate support ───────────────────────────────────────────────────
// The same 34 LocalizedString field NAMES used by surface-export.jsx's §4f
// coercion (`normalizeLayoutForSchema` → `LOCALIZED_FIELDS`). Kept as a local
// copy because that set is function-scoped in surface-export.jsx; the two must
// stay in lock-step — these are the fields whose `{ en }` value can be machine
// translated into a target language. `name` is deliberately excluded (plain
// string), as are `mobileText` / `htmlAlt`.
const LOC_STRING_FIELDS = new Set([
  'body','caption','correct','description','descriptionText','descriptionTitle',
  'dftiInfoLeftText','dftiInfoRightText','dftiInfoTitle','feedback','feedbackText',
  'image360CoverText','info','initialCoverText','interactionText','itemText',
  'itemTitle','label','leftColumnContent','note','objectAltText',
  'profileSetupDescription','profileSetupEmail','profileSetupFirstName',
  'profileSetupLastName','rightColumnContent','subTitle','tabText','tabTitle',
  'text','title','titleMain','titleSub','wrong',
]);
const locDeepClone = (o) =>
  (typeof structuredClone === 'function' ? structuredClone(o) : JSON.parse(JSON.stringify(o)));

// ── LocalizedString field walking (real Modules panel) ───────────────────────
// A field is localisable when it's a LocalizedString object (`{ en: … }`) AND
// its key is one of the schema-coerced text fields. This is the SAME predicate
// translateMissing uses, so every field the panel shows is exactly a field the
// Translate button can fill — and every field export coerces.
const isLocField = (k, v) =>
  v && typeof v === 'object' && !Array.isArray(v) && typeof v.en === 'string' && LOC_STRING_FIELDS.has(k);

// Walk a content tree (objects + arrays, any depth) collecting every
// localisable field with the PATH needed to set it back. path entries are
// object keys (strings) or array indices (numbers).
function collectLocFields(node, basePath, out) {
  if (Array.isArray(node)) {
    node.forEach((v, i) => collectLocFields(v, basePath.concat(i), out));
    return;
  }
  if (node && typeof node === 'object') {
    for (const k of Object.keys(node)) {
      const v = node[k];
      if (isLocField(k, v)) out.push({ path: basePath.concat(k), key: k, value: v });
      else if (v && typeof v === 'object') collectLocFields(v, basePath.concat(k), out);
    }
  }
}

// Immutably set one LocalizedString field's `lang` slot inside `root`, returning
// a NEW tree (the assembled content is never mutated). Other languages on that
// field — and every other field — are preserved (locSet keeps siblings).
function setLocAtPath(root, path, lang, str) {
  const clone = locDeepClone(root);
  let node = clone;
  for (let i = 0; i < path.length - 1; i++) node = node[path[i]];
  const last = path[path.length - 1];
  node[last] = locSet(node[last], lang, str);
  return clone;
}

const humanizeKey = (k) => String(k)
  .replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ')
  .replace(/^./, c => c.toUpperCase()).trim();
const singularize = (s) => s.replace(/ies$/i, 'y').replace(/s$/i, '');
// Build a readable label from a field path, e.g. ['tabs',1,'tabTitle'] →
// "Tab 2 \u203a Tab title".
function fieldLabel(path) {
  const crumbs = [];
  for (let i = 0; i < path.length - 1; i++) {
    const seg = path[i];
    if (typeof seg === 'number') {
      if (crumbs.length) crumbs[crumbs.length - 1] = singularize(crumbs[crumbs.length - 1]) + ' ' + (seg + 1);
    } else {
      crumbs.push(humanizeKey(seg));
    }
  }
  const label = humanizeKey(path[path.length - 1]);
  return crumbs.length ? crumbs.join(' \u203a ') + ' \u00b7 ' + label : label;
}

// ── Language catalogue ───────────────────────────────────────────────────────
// Powers the custom-language auto-suggestions and the dynamic flag lookup.
// The author types a name; we match against this catalogue to resolve the
// flag and the locale code automatically (the author never types a code).
// Free-typed names not in the catalogue still work — they fall back to 🌐 and
// a code derived from the name.
const LANG_CATALOGUE = [
  { name: 'Arabic', code: 'ar', flag: '🇸🇦' }, { name: 'Bengali', code: 'bn', flag: '🇧🇩' },
  { name: 'Bulgarian', code: 'bg', flag: '🇧🇬' }, { name: 'Catalan', code: 'ca', flag: '🇪🇸' },
  { name: 'Chinese (Simplified)', code: 'zh', flag: '🇨🇳' }, { name: 'Chinese (Traditional)', code: 'zht', flag: '🇭🇰' },
  { name: 'Croatian', code: 'hr', flag: '🇭🇷' }, { name: 'Czech', code: 'cs', flag: '🇨🇿' },
  { name: 'Danish', code: 'da', flag: '🇩🇰' }, { name: 'Dutch', code: 'nl', flag: '🇳🇱' },
  { name: 'English', code: 'en', flag: '🇬🇧' }, { name: 'Estonian', code: 'et', flag: '🇪🇪' },
  { name: 'Filipino', code: 'fil', flag: '🇵🇭' }, { name: 'Finnish', code: 'fi', flag: '🇫🇮' },
  { name: 'French', code: 'fr', flag: '🇫🇷' }, { name: 'German', code: 'de', flag: '🇩🇪' },
  { name: 'Greek', code: 'el', flag: '🇬🇷' }, { name: 'Hebrew', code: 'he', flag: '🇮🇱' },
  { name: 'Hindi', code: 'hi', flag: '🇮🇳' }, { name: 'Hungarian', code: 'hu', flag: '🇭🇺' },
  { name: 'Icelandic', code: 'is', flag: '🇮🇸' }, { name: 'Indonesian', code: 'id', flag: '🇮🇩' },
  { name: 'Irish', code: 'ga', flag: '🇮🇪' }, { name: 'Italian', code: 'it', flag: '🇮🇹' },
  { name: 'Japanese', code: 'ja', flag: '🇯🇵' }, { name: 'Korean', code: 'ko', flag: '🇰🇷' },
  { name: 'Latvian', code: 'lv', flag: '🇱🇻' }, { name: 'Lithuanian', code: 'lt', flag: '🇱🇹' },
  { name: 'Malay', code: 'ms', flag: '🇲🇾' }, { name: 'Norwegian', code: 'no', flag: '🇳🇴' },
  { name: 'Persian', code: 'fa', flag: '🇮🇷' }, { name: 'Polish', code: 'pl', flag: '🇵🇱' },
  { name: 'Portuguese', code: 'pt', flag: '🇵🇹' }, { name: 'Portuguese (Brazil)', code: 'ptbr', flag: '🇧🇷' },
  { name: 'Romanian', code: 'ro', flag: '🇷🇴' }, { name: 'Russian', code: 'ru', flag: '🇷🇺' },
  { name: 'Scottish Gaelic', code: 'gd', flag: '🏴󠁧󠁢󠁳󠁣󠁴󠁿' }, { name: 'Serbian', code: 'sr', flag: '🇷🇸' },
  { name: 'Slovak', code: 'sk', flag: '🇸🇰' }, { name: 'Slovenian', code: 'sl', flag: '🇸🇮' },
  { name: 'Spanish', code: 'es', flag: '🇪🇸' }, { name: 'Spanish (Latin America)', code: 'eslat', flag: '🇲🇽' },
  { name: 'Swahili', code: 'sw', flag: '🇰🇪' }, { name: 'Swedish', code: 'sv', flag: '🇸🇪' },
  { name: 'Tamil', code: 'ta', flag: '🇮🇳' }, { name: 'Thai', code: 'th', flag: '🇹🇭' },
  { name: 'Turkish', code: 'tr', flag: '🇹🇷' }, { name: 'Ukrainian', code: 'uk', flag: '🇺🇦' },
  { name: 'Urdu', code: 'ur', flag: '🇵🇰' }, { name: 'Vietnamese', code: 'vi', flag: '🇻🇳' },
  { name: 'Welsh', code: 'cy', flag: '🏴󠁧󠁢󠁷󠁬󠁳󠁿' }, { name: 'Afrikaans', code: 'af', flag: '🇿🇦' },
];
const catalogueMatch = (q) => {
  const n = (q || '').trim().toLowerCase();
  if (!n) return null;
  return LANG_CATALOGUE.find(e => e.name.toLowerCase() === n) || null;
};
const catalogueSuggest = (q, exclude = []) => {
  const n = (q || '').trim().toLowerCase();
  if (!n) return [];
  const ex = new Set(exclude);
  const starts = [], contains = [];
  LANG_CATALOGUE.forEach(e => {
    if (ex.has(e.code)) return;
    const ln = e.name.toLowerCase();
    if (ln.startsWith(n)) starts.push(e);
    else if (ln.includes(n)) contains.push(e);
  });
  return [...starts, ...contains].slice(0, 6);
};

// Simulated per-language progress for the LEFT RAIL only (not per-field).
// Custom languages are always "pending" until the author runs localisation.
function railProgress(lang) {
  if (locIsCustom(lang)) return 'pending';
  if (lang === 'it') return 'done';
  if (lang === 'de') return 'partial';
  return 'pending';
}

function SurfaceLocalisation({ course, roles, assessments, layoutDrafts, onUpdateDrafts, onTranslateModuleTitle, onTranslateGroupTitle }) {
  const primary = course.defaultLanguage;
  const [langs, setLangs] = React.useState(course.languages);
  // Author-defined languages beyond LOC_SUPPORTED_LANGS: code → {name,flag,note,custom}.
  const [customMeta, setCustomMeta] = React.useState(() => ({ ...(course.customLangs || {}) }));
  // Sync the shared registry DURING render so nested renderers resolve fresh.
  React.useMemo(() => { Object.assign(LOC_CUSTOM_LANGS, customMeta); }, [customMeta]);
  // No auto-selection — the first action is to choose a language.
  const [working, setWorking] = React.useState(null);
  const [tab, setTab] = React.useState('modules');

  const addLang = (code, meta) => {
    if (meta) setCustomMeta(m => ({ ...m, [code]: meta }));
    setLangs(ls => ls.includes(code) ? ls : [...ls, code]);
    setWorking(code);
  };
  // Define + add a brand-new target language. The author types a NAME; the
  // system resolves the flag and locale code from the catalogue (or derives a
  // code + 🌐 flag for a free-typed name). No code or guidance is entered here.
  const addCustomLang = ({ name }) => {
    const clean = (name || '').trim();
    if (!clean) return;
    const match = catalogueMatch(clean);
    const base = ((match ? match.code : clean).toLowerCase().replace(/[^a-z]/g, '').slice(0, 6)) || 'lang';
    const taken = new Set([...langs, ...LOC_SUPPORTED_LANGS, ...Object.keys(LOC_CUSTOM_LANGS)]);
    let c = base, i = 2;
    while (taken.has(c)) c = base + (i++);
    addLang(c, {
      name: clean,
      flag: match ? match.flag : '🌐',
      custom: true,
    });
  };
  const removeLang = (code) => {
    setLangs(ls => ls.filter(l => l !== code));
    setCustomMeta(m => { if (!m[code]) return m; const n = { ...m }; delete n[code]; return n; });
    if (working === code) setWorking(null);
  };

  const isTarget = !!working && working !== primary;
  const targets = langs.filter(l => l !== primary);

  // ── Auto-translate the working target language ─────────────────────────
  const [translating, setTranslating] = React.useState(false);
  const [toast, setToast] = React.useState(null);
  const toastTimer = React.useRef(null);
  const showToast = React.useCallback((msg) => {
    setToast(msg);
    if (toastTimer.current) clearTimeout(toastTimer.current);
    toastTimer.current = setTimeout(() => setToast(null), 3400);
  }, []);
  React.useEffect(() => () => { if (toastTimer.current) clearTimeout(toastTimer.current); }, []);

  // Fill the target language's MISSING text by calling the gateway's translate
  // proxy, then persist into the local course content (the same content the
  // export PUTs). Only fields whose value is already a LocalizedString `{ en }`
  // with non-empty source and a missing/empty target are translated — values
  // the author already filled are never overwritten.
  const translateMissing = React.useCallback(async (targetLang) => {
    if (!targetLang || targetLang === primary || translating) return;
    const drafts = layoutDrafts || {};
    const jobs = [];               // { ref, source }
    const touchedLayouts = {};     // layoutId → cloned content
    const touchedModules = {};     // moduleId → cloned title
    const touchedGroups = {};      // groupId → cloned title

    const walk = (node) => {
      if (Array.isArray(node)) { node.forEach(walk); return; }
      if (node && typeof node === 'object') {
        for (const k of Object.keys(node)) {
          const v = node[k];
          const isLocObj = v && typeof v === 'object' && !Array.isArray(v) && typeof v.en === 'string';
          // Detect a LocalizedString by SHAPE (an object whose values are all
          // strings) as well as by name — matching the gateway's coverage gate.
          // This catches dynamic-keyed sets like a quiz question's `tooltipValues`
          // (keys sender_domain / suspicious_link / …) that aren't in the fixed
          // LOC_STRING_FIELDS list. A plain-string field (e.g. TooltipParam.name)
          // is not an object, so the `name` collision can never trigger here.
          const isLocStr = isLocObj &&
            (LOC_STRING_FIELDS.has(k) || Object.values(v).every(x => typeof x === 'string'));
          if (isLocStr) {
            const have = v[targetLang];
            if (v.en.trim() && !(have != null && String(have).trim())) {
              jobs.push({ ref: v, source: v.en });
            }
          } else if (v && typeof v === 'object') {
            walk(v);
          }
        }
      }
    };

    (course.modules || []).forEach(m => {
      (m.layouts || []).forEach(l => {
        const draft = drafts[l.id] || {};
        const type = draft.type || l.type;
        const base = (window.LAYOUT_CONTENT_SAMPLES && window.LAYOUT_CONTENT_SAMPLES[type]) || {};
        const content = locDeepClone({ ...base, ...draft, type });
        const before = jobs.length;
        walk(content);
        if (jobs.length > before) touchedLayouts[l.id] = content;
      });
      // Module title is a top-level LocalizedString in the live course content.
      if (m.title && typeof m.title === 'object' && !Array.isArray(m.title)) {
        const title = locDeepClone(m.title);
        const before = jobs.length;
        walk({ title });
        if (jobs.length > before) touchedModules[m.id] = title;
      }
    });

    // Module-GROUP names are top-level LocalizedStrings too (exported as
    // moduleGroups[].name) and the gateway gates them — translate them here.
    (course.moduleGroups || []).forEach(g => {
      if (g.title && typeof g.title === 'object' && !Array.isArray(g.title)) {
        const title = locDeepClone(g.title);
        const before = jobs.length;
        walk({ title });
        if (jobs.length > before) touchedGroups[g.id] = title;
      }
    });

    if (jobs.length === 0) { showToast('Already up to date'); return; }

    setTranslating(true);
    try {
      const token = await window.dynamoGetAccessToken();
      const res = await fetch(`${GATEWAY_BASE}/v1/translate`, {
        method: 'POST',
        headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
        body: JSON.stringify({ q: jobs.map(j => j.source), target: targetLang, source: 'en' }),
      });
      if (res.status === 503) { showToast("Translation isn't configured yet."); return; }
      if (!res.ok) {
        let msg = `Translation failed (HTTP ${res.status})`;
        try { const e = await res.json(); if (e && (e.error || e.message)) msg = e.error || e.message; } catch (_) {}
        showToast(msg); return;
      }
      const data = await res.json();
      const out = data.translations || data.data || data.q || [];
      jobs.forEach((j, i) => { const t = out[i]; if (t != null && String(t).length) j.ref[targetLang] = t; });
      // Persist into local content via the SAME debounced draft-save edits use.
      // onUpdateDrafts(layoutId, fullLayout) shallow-merges the whole assembled
      // layout into layoutDrafts[layoutId] (so every field flows to export).
      if (onUpdateDrafts) {
        Object.entries(touchedLayouts).forEach(([id, content]) => onUpdateDrafts(id, content));
      }
      if (onTranslateModuleTitle) {
        Object.entries(touchedModules).forEach(([mid, title]) => onTranslateModuleTitle(mid, title));
      }
      if (onTranslateGroupTitle) {
        Object.entries(touchedGroups).forEach(([gid, title]) => onTranslateGroupTitle(gid, title));
      }
      showToast(`Filled ${jobs.length} field${jobs.length === 1 ? '' : 's'} into ${locName(targetLang)}.`);
    } catch (err) {
      showToast(err && err.message ? err.message : 'Translation failed.');
    } finally {
      setTranslating(false);
    }
  }, [course, layoutDrafts, onUpdateDrafts, onTranslateModuleTitle, onTranslateGroupTitle, primary, translating, showToast]);

  const TABS = [
    { id: 'modules',     label: 'Modules',     icon: 'Layers' },
    { id: 'roles',       label: 'Roles',       icon: 'Users' },
    { id: 'subtitles',   label: 'Subtitles',   icon: 'Captions' },
    { id: 'assessment',  label: 'Assessment',  icon: 'ClipboardCheck' },
    { id: 'quiz-gaming', label: 'Quiz Gaming', icon: 'Gamepad2' },
  ];

  return (
    <div style={{
      display: 'grid', gridTemplateColumns: '244px minmax(0, 1fr)',
      height: '100%', background: 'var(--bg)', overflow: 'hidden',
    }} data-screen-label="Surface · Localisation">

      <LocaleRail langs={langs} primary={primary} working={working}
        tab={tab} onSelect={setWorking} onAdd={addLang}
        onAddCustom={addCustomLang} onRemove={removeLang} />

      <section style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }}>
        {isTarget && (
          <div style={{
            display: 'flex', alignItems: 'center', gap: 14,
            padding: '12px 24px', borderBottom: '1px solid var(--border)',
            background: 'var(--surface)',
          }}>
            <div style={{ display: 'flex', gap: 3, background: 'var(--surface-inset)',
              padding: 3, borderRadius: 'var(--radius-md)' }}>
              {TABS.map(t => {
                const Ico = I[t.icon];
                const on = tab === t.id;
                return (
                  <button key={t.id} onClick={() => setTab(t.id)}
                    style={{
                      display: 'inline-flex', alignItems: 'center', gap: 7,
                      padding: '6px 13px', border: 0, borderRadius: 'var(--radius)',
                      cursor: 'default', fontFamily: 'inherit', fontSize: 12.5,
                      fontWeight: on ? 600 : 500,
                      background: on ? 'var(--surface)' : 'transparent',
                      color: on ? 'var(--text)' : 'var(--text-muted)',
                      boxShadow: on ? '0 1px 2px rgba(0,0,0,.10)' : 'none',
                    }}>
                    <Ico size={14} />{t.label}
                  </button>
                );
              })}
            </div>
            <div style={{ flex: 1 }} />
            <button className="btn sm primary" disabled={!isTarget || translating}
              onClick={() => translateMissing(working)}
              title={`Auto-fill missing ${locName(working)} text via translation`}>
              {translating
                ? <><I.Loader size={12} className="spin" />Translating…</>
                : <><I.Languages size={12} />Translate to {locName(working)}</>}
            </button>
            <span style={{ width: 1, height: 22, background: 'var(--border)' }} />
            <LangDirection primary={primary} working={working} />
          </div>
        )}

        <div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
          {!working && (
            <SelectLanguagePrompt targets={targets} onSelect={setWorking} />
          )}
          {isTarget && tab === 'modules'    && <ModulesPanel course={course} working={working} primary={primary}
            layoutDrafts={layoutDrafts} onUpdateDrafts={onUpdateDrafts}
            onTranslateModuleTitle={onTranslateModuleTitle} />}
          {isTarget && tab === 'roles'      && <RolesPanel roles={roles} working={working} primary={primary} />}
          {isTarget && tab === 'subtitles'  && <SubtitlesPanel course={course} working={working} primary={primary} />}
          {isTarget && tab === 'assessment' && <AssessmentPanel working={working} primary={primary}
            assessments={assessments || window.SAMPLE_ASSESSMENTS} course={course} />}
          {isTarget && tab === 'quiz-gaming' && <QuizGamingPanel course={course}
            working={working} primary={primary} />}
        </div>
      </section>

      {toast && (
        <div role="status" aria-live="polite" className="slide-in" style={{
          position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
          zIndex: 120, display: 'inline-flex', alignItems: 'center', gap: 9,
          padding: '10px 16px', background: 'var(--text-strong)', color: '#fff',
          borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-xl)',
          fontSize: 12.5, fontWeight: 500, maxWidth: 'min(520px, 90vw)' }}>
          <I.Globe size={14} style={{ flexShrink: 0, opacity: 0.85 }} />
          <span>{toast}</span>
        </div>
      )}
    </div>
  );
}

// ── Left rail · languages (add / select / delete) ────────────────────────────
function LocaleRail({ langs, primary, working, tab, onSelect, onAdd, onAddCustom, onRemove }) {
  const [addOpen, setAddOpen] = React.useState(false);
  const [confirmId, setConfirmId] = React.useState(null);
  const available = LOC_SUPPORTED_LANGS.filter(c => !langs.includes(c));
  return (
    <section style={{ borderRight: '1px solid var(--border)',
      background: 'var(--surface)', display: 'flex', flexDirection: 'column',
      minHeight: 0 }}>
      <ColumnHeader title="Languages" count={langs.length}
        action={
          <div style={{ position: 'relative' }}>
            <button className="btn sm" onClick={() => setAddOpen(o => !o)}>
              <I.Plus size={12} />Add
            </button>
            {addOpen && (
              <LocaleAddMenu available={available} existing={langs}
                onPick={(c) => { onAdd(c); setAddOpen(false); }}
                onAddCustom={(meta) => { onAddCustom(meta); setAddOpen(false); }}
                onClose={() => setAddOpen(false)} />
            )}
          </div>
        } />
      <div style={{ padding: '8px 8px 12px', overflowY: 'auto', flex: 1 }}>
        {langs.map((l, i) => {
          const isPrimary = l === primary;
          const isWorking = l === working;

          // Primary (English) is REFERENCE ONLY — not selectable, no delete.
          // A muted inset background + default cursor signal "not clickable".
          if (isPrimary) {
            return (
              <div key={l} style={{
                display: 'flex', alignItems: 'center', gap: 10,
                padding: '9px 10px', marginBottom: 6, cursor: 'default',
                borderRadius: 'var(--radius)', background: 'var(--surface-inset)',
                border: '1px solid var(--border)',
              }}>
                <span style={{ fontSize: 21, lineHeight: 1, opacity: 0.85 }}>{locFlag(l)}</span>
                <div style={{ flex: 1, minWidth: 0 }}>
                  <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
                    <span style={{ fontSize: 13, fontWeight: 600 }}>{locName(l)}</span>
                    <span className="pill accepted" style={{ fontSize: 9.5 }}>Primary</span>
                  </div>
                  <div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>
                    Source language
                  </div>
                </div>
              </div>
            );
          }

          const st = railProgress(l);
          const confirming = confirmId === l;
          return (
            <div key={l}
              onMouseLeave={() => { if (confirming) setConfirmId(null); }}
              style={{
                border: '1px solid', borderColor: isWorking ? 'var(--accent-border)' : 'transparent',
                background: isWorking ? 'var(--accent-bg)' : 'transparent',
                borderRadius: 'var(--radius)', marginBottom: 4,
                overflow: 'hidden',
              }}
              onMouseOver={e => { if (!isWorking) e.currentTarget.style.background = 'var(--surface-inset)'; }}
              onMouseOut={e => { if (!isWorking) e.currentTarget.style.background = 'transparent'; }}>
              <button onClick={() => onSelect(l)} className="loc-lang-row"
                style={{
                  display: 'flex', alignItems: 'center', gap: 10, width: '100%',
                  padding: '9px 10px', textAlign: 'left', border: 0,
                  background: 'transparent', cursor: 'default',
                  fontFamily: 'inherit', color: 'var(--text)',
                }}>
                <span style={{ fontSize: 21, lineHeight: 1 }}>{locFlag(l)}</span>
                <div style={{ flex: 1, minWidth: 0 }}>
                  <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
                    <span style={{ fontSize: 13, fontWeight: 600 }}>{locName(l)}</span>
                    {locIsCustom(l) && (
                      <span className="pill" style={{ fontSize: 9 }} title="Author-defined language">Custom</span>
                    )}
                  </div>
                  <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1,
                    display: 'inline-flex', alignItems: 'center', gap: 4 }}>
                    <LocStatusDot status={st} />{LOC_STATUS_LABEL[st]}
                  </div>
                </div>
                {!confirming && (
                  <span className="loc-del" onClick={(e) => { e.stopPropagation(); setConfirmId(l); }}
                    title={`Delete ${locName(l)}`}
                    style={{
                      width: 24, height: 24, borderRadius: 5, flexShrink: 0,
                      display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                      color: 'var(--text-faint)',
                    }}>
                    <I.Trash size={12} />
                  </span>
                )}
              </button>
              {confirming && (
                <div style={{ display: 'flex', alignItems: 'center', gap: 6,
                  padding: '0 10px 9px', fontSize: 11.5 }}>
                  <span style={{ flex: 1, color: 'var(--text-muted)' }}>Delete language?</span>
                  <button className="btn sm danger" style={{ height: 24 }}
                    onClick={() => { onRemove(l); setConfirmId(null); }}>Delete</button>
                  <button className="btn sm ghost" style={{ height: 24 }}
                    onClick={() => setConfirmId(null)}>Cancel</button>
                </div>
              )}
            </div>
          );
        })}
        <button className="btn sm" onClick={() => setAddOpen(true)}
          style={{ width: '100%', marginTop: 4, height: 34, justifyContent: 'flex-start',
            borderStyle: 'dashed' }}>
          <I.Plus size={13} />Add language
        </button>
      </div>
    </section>
  );
}

const LOC_STATUS_LABEL = { done: 'Localised', partial: 'In progress', pending: 'Not started' };
function LocStatusDot({ status }) {
  if (status === 'done') return <I.Check size={11} style={{ color: 'var(--success)' }} />;
  if (status === 'partial') return <span style={{ width: 6, height: 6, borderRadius: '50%',
    background: 'var(--warning)', display: 'inline-block' }} />;
  return <span style={{ width: 6, height: 6, borderRadius: '50%',
    background: 'var(--text-faint)', display: 'inline-block' }} />;
}

function LocaleAddMenu({ available, existing, onPick, onAddCustom, onClose }) {
  const [mode, setMode] = React.useState('list'); // 'list' | 'custom'
  return (
    <>
      <div onClick={onClose} style={{ position: 'fixed', inset: 0, zIndex: 50 }} />
      <div className="slide-in" style={{
        position: 'absolute', top: 'calc(100% + 6px)', left: 0,
        width: 268, maxHeight: 420, overflowY: 'auto',
        background: 'var(--surface)', border: '1px solid var(--border)',
        borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-xl)',
        zIndex: 51, padding: 6,
      }}>
        {mode === 'list' ? (
          <>
            <div style={{ padding: '6px 8px', fontSize: 10.5, fontWeight: 600,
              color: 'var(--text-faint)', letterSpacing: '.06em', textTransform: 'uppercase' }}>
              Add a target language
            </div>
            {available.length === 0 && (
              <div style={{ padding: '8px', fontSize: 12, color: 'var(--text-muted)' }}>
                All suggested languages are already added — search for another below.
              </div>
            )}
            {available.map(c => (
              <button key={c} onClick={() => onPick(c)}
                style={{
                  display: 'flex', alignItems: 'center', gap: 10, width: '100%',
                  padding: '8px', textAlign: 'left', background: 'transparent',
                  border: 0, borderRadius: 'var(--radius)', cursor: 'default',
                  fontFamily: 'inherit', color: 'var(--text)',
                }}
                onMouseOver={e => e.currentTarget.style.background = 'var(--surface-inset)'}
                onMouseOut={e => e.currentTarget.style.background = 'transparent'}>
                <span style={{ fontSize: 18, lineHeight: 1 }}>{locFlag(c)}</span>
                <span style={{ flex: 1, fontSize: 13 }}>{locName(c)}</span>
                <span style={{ fontSize: 11, color: 'var(--text-faint)',
                  fontFamily: 'var(--font-mono)' }}>{c.toUpperCase()}</span>
              </button>
            ))}
            <div style={{ height: 1, background: 'var(--border)', margin: '6px 4px' }} />
            <button onClick={() => setMode('custom')}
              style={{
                display: 'flex', alignItems: 'center', gap: 9, width: '100%',
                padding: '8px', textAlign: 'left', background: 'transparent',
                border: 0, borderRadius: 'var(--radius)', cursor: 'default',
                fontFamily: 'inherit', color: 'var(--accent-text)', fontWeight: 500,
              }}
              onMouseOver={e => e.currentTarget.style.background = 'var(--surface-inset)'}
              onMouseOut={e => e.currentTarget.style.background = 'transparent'}>
              <I.Search size={14} />
              <span style={{ flex: 1, fontSize: 13 }}>Search for another language…</span>
            </button>
          </>
        ) : (
          <CustomLangForm existing={existing}
            onBack={() => setMode('list')} onSubmit={onAddCustom} />
        )}
      </div>
    </>
  );
}

// Add any language by NAME: type to see live suggestions (flag + name); the
// flag is resolved dynamically and the system associates the locale code — the
// author never types a code. Free-typed names not in the catalogue still add
// (with a 🌐 fallback flag).
function CustomLangForm({ existing, onBack, onSubmit }) {
  const [query, setQuery] = React.useState('');
  const suggestions = catalogueSuggest(query, existing);
  const match = catalogueMatch(query);
  const previewFlag = match ? match.flag : (query.trim() ? '🌐' : '');
  const submit = (name) => { const v = (name ?? query).trim(); if (v) onSubmit({ name: v }); };
  return (
    <div style={{ padding: '4px 4px 6px' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 4px 8px' }}>
        <button className="btn sm ghost" onClick={onBack}
          style={{ width: 24, height: 24, padding: 0, justifyContent: 'center' }}
          title="Back to language list">
          <I.ArrowLeft size={13} />
        </button>
        <span style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-faint)',
          letterSpacing: '.06em', textTransform: 'uppercase' }}>Add a language</span>
      </div>

      <div style={{ padding: '0 4px' }}>
        {/* Search field with a live, dynamically-resolved flag adornment */}
        <div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
          <span style={{ position: 'absolute', left: 9, fontSize: 16, lineHeight: 1,
            pointerEvents: 'none' }}>{previewFlag || ''}</span>
          <input className="field" value={query} onChange={e => setQuery(e.target.value)}
            autoFocus placeholder="Start typing a language…"
            onKeyDown={e => { if (e.key === 'Enter') submit(); }}
            style={{ width: '100%', paddingLeft: previewFlag ? 32 : 10 }} />
        </div>

        {/* Live suggestions */}
        {suggestions.length > 0 && (
          <div style={{ marginTop: 6, display: 'flex', flexDirection: 'column', gap: 1 }}>
            {suggestions.map(s => (
              <button key={s.code} onClick={() => submit(s.name)}
                style={{
                  display: 'flex', alignItems: 'center', gap: 10, width: '100%',
                  padding: '8px', textAlign: 'left', background: 'transparent',
                  border: 0, borderRadius: 'var(--radius)', cursor: 'default',
                  fontFamily: 'inherit', color: 'var(--text)',
                }}
                onMouseOver={e => e.currentTarget.style.background = 'var(--surface-inset)'}
                onMouseOut={e => e.currentTarget.style.background = 'transparent'}>
                <span style={{ fontSize: 18, lineHeight: 1 }}>{s.flag}</span>
                <span style={{ flex: 1, fontSize: 13 }}>{s.name}</span>
                <I.Plus size={13} style={{ color: 'var(--text-faint)' }} />
              </button>
            ))}
          </div>
        )}

        {/* Free-typed name with no catalogue match */}
        {query.trim() && !match && suggestions.length === 0 && (
          <button className="btn sm primary" onClick={() => submit()}
            style={{ width: '100%', marginTop: 8 }}>
            <I.Plus size={12} />Add “{query.trim()}”
          </button>
        )}
      </div>
    </div>
  );
}

// ── Entry states ──────────────────────────────────────────────────────────────
function SelectLanguagePrompt({ targets, onSelect }) {
  return (
    <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
      padding: '40px 24px' }}>
      <div style={{ textAlign: 'center', maxWidth: 460 }}>
        <span style={{ display: 'inline-flex', width: 46, height: 46, borderRadius: 11,
          alignItems: 'center', justifyContent: 'center', marginBottom: 14,
          background: 'var(--accent-bg)', color: 'var(--accent-text)' }}>
          <I.Globe size={22} />
        </span>
        <h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>Select a language to localise</h3>
        <p style={{ margin: '6px 0 18px', fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.5 }}>
          {targets.length
            ? 'Pick a language from the left, then localise the course module by module.'
            : 'Add a target language from the left to start localising.'}
        </p>
        {targets.length > 0 && (
          <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
            {targets.map(l => (
              <button key={l} className="btn" onClick={() => onSelect(l)}>
                <span style={{ fontSize: 16, lineHeight: 1 }}>{locFlag(l)}</span>{locName(l)}
              </button>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

// ── Shared · EN → target indicator ────────────────────────────────────────────
function LangDirection({ primary, working }) {
  return (
    <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
      <span style={{ fontSize: 16 }}>{locFlag(primary)}</span>
      <span style={{ color: 'var(--text-muted)' }}>{locName(primary)}</span>
      <I.ArrowRight size={14} style={{ color: 'var(--text-faint)' }} />
      <span style={{ fontSize: 16 }}>{locFlag(working)}</span>
      <span style={{ fontWeight: 600 }}>{locName(working)}</span>
    </span>
  );
}

// ── Shared · toolbar (item selector + Save + Run localisation) ────────────────
function LocaleToolbar({ children, working, runState, onRun, itemNoun, saveState, onSave }) {
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 12,
      padding: '12px 24px 0' }}>
      {children}
      <div style={{ flex: 1 }} />
      <SaveControl state={saveState} onSave={onSave} />
      <span style={{ width: 1, height: 22, background: 'var(--border)' }} />
      <RunLocalise state={runState} working={working} onRun={onRun} itemNoun={itemNoun} />
    </div>
  );
}

// Explicit save of the author's translation edits. `state` is one of
// 'clean' (nothing to save) / 'dirty' (unsaved edits) / 'saving' / 'saved'.
function SaveControl({ state, onSave }) {
  const dirty = state === 'dirty';
  return (
    <div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
      {state === 'saving'
        ? <span style={{ fontSize: 11.5, color: 'var(--text-faint)', display: 'inline-flex',
            alignItems: 'center', gap: 5 }}><I.Loader size={12} className="spin" />Saving…</span>
        : dirty
          ? <span style={{ fontSize: 11.5, color: 'var(--warning-text)', display: 'inline-flex',
              alignItems: 'center', gap: 5 }}>
              <span style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--warning)' }} />
              Unsaved changes</span>
          : <span style={{ fontSize: 11.5, color: 'var(--text-faint)', display: 'inline-flex',
              alignItems: 'center', gap: 5 }}><I.Check size={12} />All changes saved</span>}
      <button className="btn sm" onClick={onSave} disabled={!dirty}>
        <I.Save size={12} />Save
      </button>
    </div>
  );
}

// Single "Run localisation" action with a scope picker (this item / whole
// course). Same label + Sparkle icon on every tab — the menu only changes
// WHAT gets localised, never the verb.
function RunLocalise({ state, working, onRun, itemNoun = 'item' }) {
  const [open, setOpen] = React.useState(false);
  const label = state === 'running' ? 'Localising…'
    : state === 'done' ? 'Re-run localisation'
    : 'Run localisation';
  const itemLabel = itemNoun === 'roles' ? 'Localise all role names' : `Localise this ${itemNoun}`;
  const fire = (scope) => { setOpen(false); onRun(scope); };
  return (
    <div style={{ position: 'relative', display: 'inline-flex' }}>
      <button className="btn sm primary" onClick={() => fire('item')}
        disabled={state === 'running'}
        title={`Run AI translation into ${locName(working)}`}
        style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}>
        <I.Sparkle size={12} />{label}
      </button>
      <button className="btn sm primary" onClick={() => setOpen(o => !o)}
        disabled={state === 'running'} aria-label="Choose localisation scope"
        style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, padding: '0 6px',
          borderLeft: '1px solid rgba(255,255,255,.35)' }}>
        <I.ChevronDown size={13} />
      </button>
      {open && (
        <>
          <div onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 50 }} />
          <div className="slide-in" style={{
            position: 'absolute', top: 'calc(100% + 6px)', right: 0, width: 264,
            background: 'var(--surface)', border: '1px solid var(--border)',
            borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-xl)', zIndex: 51, padding: 6,
          }}>
            <div style={{ padding: '6px 8px', fontSize: 10.5, fontWeight: 600,
              color: 'var(--text-faint)', letterSpacing: '.06em', textTransform: 'uppercase' }}>
              Run localisation into {locName(working)}
            </div>
            <ScopeOption icon="Sparkle" title={itemLabel}
              sub="Just the item shown here" onClick={() => fire('item')} />
            <ScopeOption icon="Layers" title="Localise the whole course"
              sub="Every module, role, subtitle & question" onClick={() => fire('course')} />
          </div>
        </>
      )}
    </div>
  );
}

function ScopeOption({ icon, title, sub, onClick }) {
  const Ico = I[icon];
  return (
    <button onClick={onClick}
      style={{ display: 'flex', alignItems: 'flex-start', gap: 9, width: '100%',
        padding: '8px', textAlign: 'left', background: 'transparent', border: 0,
        borderRadius: 'var(--radius)', cursor: 'default', fontFamily: 'inherit', color: 'var(--text)' }}
      onMouseOver={e => e.currentTarget.style.background = 'var(--surface-inset)'}
      onMouseOut={e => e.currentTarget.style.background = 'transparent'}>
      <Ico size={14} style={{ color: 'var(--accent-text)', marginTop: 1, flexShrink: 0 }} />
      <span style={{ minWidth: 0 }}>
        <span style={{ display: 'block', fontSize: 12.5, fontWeight: 500 }}>{title}</span>
        <span style={{ display: 'block', fontSize: 11, color: 'var(--text-muted)' }}>{sub}</span>
      </span>
    </button>
  );
}

// ── Shared · dropdown item navigator (no arrows) ──────────────────────────────
// options: [{ group, items: [{ value, label }] }]
function ItemNav({ options, value, onChange }) {
  const flat = options.flatMap(g => g.items);
  const idx = flat.findIndex(o => o.value === value);
  return (
    <div style={{ display: 'flex', gap: 10, alignItems: 'center', minWidth: 0, flex: 1, maxWidth: 560 }}>
      <select className="field" value={value} onChange={e => onChange(e.target.value)}
        style={{ flex: 1, fontWeight: 500, minWidth: 0 }}>
        {options.map(g => (
          <optgroup key={g.group} label={g.group}>
            {g.items.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
          </optgroup>
        ))}
      </select>
      <span style={{ fontSize: 11.5, color: 'var(--text-faint)',
        fontFamily: 'var(--font-mono)', whiteSpace: 'nowrap' }}>{idx + 1} / {flat.length}</span>
    </div>
  );
}

// ── Shared · side-by-side field ───────────────────────────────────────────────
function PairHeaders({ primary, working, leftLabel = 'source (read-only)' }) {
  return (
    <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr',
      padding: '8px 0', background: 'var(--surface-inset)', borderRadius: 'var(--radius)' }}>
      <div style={{ padding: '0 14px', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)',
        letterSpacing: '.06em', textTransform: 'uppercase' }}>
        {locFlag(primary)} {locName(primary)} · {leftLabel}
      </div>
      <div style={{ padding: '0 14px', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)',
        letterSpacing: '.06em', textTransform: 'uppercase' }}>
        {locFlag(working)} {locName(working)} · editable
      </div>
    </div>
  );
}

function PairField({ label, source, target, badge, onChange }) {
  return (
    <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr',
      background: 'var(--surface)', border: '1px solid var(--border)',
      borderRadius: 'var(--radius-md)', overflow: 'hidden' }}>
      {/* SOURCE — read-only, muted inset bg */}
      <div style={{ padding: '12px 14px', background: 'var(--surface-inset)',
        borderRight: '1px solid var(--border)' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
          <FieldLabel inline>{label}</FieldLabel>
          {badge}
        </div>
        <p style={{ margin: 0, fontSize: 13, color: 'var(--text)', lineHeight: 1.5 }}>{locText(source)}</p>
      </div>
      {/* TARGET — editable, white bg + bordered field */}
      <div style={{ padding: '12px 14px', background: 'var(--surface)' }}>
        <FieldLabel inline>{label}</FieldLabel>
        <textarea className="field" value={target}
          onChange={e => onChange(e.target.value)}
          placeholder="Not translated yet"
          style={{ width: '100%', minHeight: 48, marginTop: 6, fontSize: 13,
            lineHeight: 1.5, resize: 'vertical' }} />
      </div>
    </div>
  );
}

function FieldLabel({ children, inline }) {
  return (
    <div style={{ fontSize: 10.5, color: 'var(--text-faint)', letterSpacing: '.04em',
      textTransform: 'uppercase', marginBottom: inline ? 0 : 4, fontWeight: 600,
      whiteSpace: 'nowrap' }}>{children}</div>
  );
}

// Editable target values + an explicit save lifecycle. Returns
// [vals, setVal, saveState, save]. saveState ∈ clean|dirty|saving|saved.
function useTargets(seedFields, deps) {
  const seed = React.useMemo(() => {
    const m = {};
    seedFields.forEach((f) => { m[f.key] = { value: f.target }; });
    return m;
  }, deps);
  const [vals, setVals] = React.useState(seed);
  const [saveState, setSaveState] = React.useState('clean');
  React.useEffect(() => { setVals(seed); setSaveState('clean'); }, [seed]);
  const setVal = (k, patch) => {
    setVals(o => ({ ...o, [k]: { ...o[k], ...patch } }));
    setSaveState('dirty');
  };
  const save = () => {
    setSaveState('saving');
    setTimeout(() => setSaveState('saved'), 700);
  };
  return [vals, setVal, saveState, save];
}

function useRunState(deps) {
  const [state, setState] = React.useState('idle');
  React.useEffect(() => { setState('idle'); }, deps);
  const run = () => { setState('running'); setTimeout(() => setState('done'), 1500); };
  return [state, run];
}

function PanelBody({ children }) {
  return (
    <div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px 36px' }}>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>{children}</div>
    </div>
  );
}

function EmptyNote({ children }) {
  return (
    <div style={{ padding: '40px 24px', textAlign: 'center', color: 'var(--text-muted)',
      fontSize: 12.5, background: 'var(--surface)', border: '1px solid var(--border)',
      borderRadius: 'var(--radius-md)' }}>{children}</div>
  );
}

// ── Panel · Modules ───────────────────────────────────────────────────────────
// REAL read / edit / persist against the actual course content (the same merge
// the editors + export use: LAYOUT_CONTENT_SAMPLES[type] ← layoutDrafts[id]).
// Pick a layout; every LocalizedString field (nested, any depth) shows its
// English source (read-only) beside an editable target. Editing a field
// rebuilds the WHOLE assembled layout with locSet at the field's path and
// persists via onUpdateDrafts(layoutId, fullLayout); the module title persists
// via onTranslateModuleTitle. Both flow through the app's debounced draft-save,
// so every edit survives a tab switch / reload — and the Translate button (top
// toolbar) fills empties without overwriting what the author typed.
function ModulesPanel({ course, working, primary, layoutDrafts, onUpdateDrafts, onTranslateModuleTitle }) {
  const drafts = layoutDrafts || {};
  const flatLayouts = React.useMemo(
    () => course.modules.flatMap(m => m.layouts.map(l => ({ ...l, mod: m }))), [course]);
  const [sel, setSel] = React.useState(
    (flatLayouts.find(l => l.selected) || flatLayouts[0])?.id);
  const layout = flatLayouts.find(l => l.id === sel) || flatLayouts[0];
  const module = layout?.mod || {};

  const options = course.modules.map(m => ({
    group: `${m.id} · ${locText(m.title, primary)}`,
    items: m.layouts.map(l => ({ value: l.id, label: `${m.id}.L${l.n} — ${locText(l.summary, primary)}` })),
  }));

  // Assemble the layout's REAL content — sample defaults overlaid with the
  // saved draft (same as surface-export's per-layout build). Recomputes when
  // the draft changes, so author edits round-trip through layoutDrafts.
  const content = React.useMemo(() => {
    if (!layout) return {};
    const draft = drafts[layout.id] || {};
    const type = draft.type || layout.type;
    const base = (window.LAYOUT_CONTENT_SAMPLES && window.LAYOUT_CONTENT_SAMPLES[type]) || {};
    return { ...base, ...draft, type };
  }, [layout, drafts]);

  // Every localisable field in this layout, with its set-back path.
  const fields = React.useMemo(() => {
    const out = []; collectLocFields(content, [], out); return out;
  }, [content]);

  // Edit one layout field: rebuild the full layout with the new target and
  // persist. The whole assembled object is sent so every field flows to export.
  const editField = (path, str) => {
    if (!layout || !onUpdateDrafts) return;
    onUpdateDrafts(layout.id, setLocAtPath(content, path, working, str));
  };
  // Module title — a top-level course LocalizedString; persisted separately.
  const editModuleTitle = (str) => {
    if (!module.id || !onTranslateModuleTitle) return;
    onTranslateModuleTitle(module.id, locSet(module.title, working, str));
  };

  const filled = fields.filter(f => String(f.value[working] || '').trim()).length
    + (String((module.title || {})[working] || '').trim() ? 1 : 0);
  const total = fields.length + 1; // + module title

  return (
    <>
      <div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 24px 0' }}>
        <ItemNav options={options} value={sel} onChange={setSel} />
        <div style={{ flex: 1 }} />
        <span style={{ fontSize: 11.5, color: 'var(--text-faint)', display: 'inline-flex',
          alignItems: 'center', gap: 6, whiteSpace: 'nowrap' }}>
          <I.Check size={12} />Saved automatically
        </span>
        <span style={{ width: 1, height: 22, background: 'var(--border)' }} />
        <span style={{ fontSize: 11.5, color: 'var(--text-muted)', fontFamily: 'var(--font-mono)',
          whiteSpace: 'nowrap' }}>{filled} / {total} translated</span>
      </div>
      <PanelBody>
        <PairHeaders primary={primary} working={working} />

        <PairField label="Module heading" source={locText(module.title, primary)}
          target={locText(module.title, working)} onChange={editModuleTitle} />

        {fields.length === 0 ? (
          <EmptyNote>This layout has no translatable text fields.</EmptyNote>
        ) : (
          fields.map(f => (
            <PairField key={f.path.join('.')} label={fieldLabel(f.path)}
              source={locText(f.value, primary)} target={locText(f.value, working)}
              onChange={v => editField(f.path, v)} />
          ))
        )}
      </PanelBody>
    </>
  );
}

// ── Panel · Roles ─────────────────────────────────────────────────────────────
function RolesPanel({ roles, working, primary }) {
  const list = roles || [];
  const seedFields = list.map((r) => ({ key: r.code, target: '' }));
  const [vals, setVal, saveState, save] = useTargets(seedFields, [working, list.length]);
  const [runState, run] = useRunState([working]);

  return (
    <>
      <LocaleToolbar working={working} runState={runState} onRun={run}
        itemNoun="roles" saveState={saveState} onSave={save}>
        <span style={{ fontSize: 12.5, color: 'var(--text-muted)' }}>
          {list.length} role name{list.length === 1 ? '' : 's'}
        </span>
      </LocaleToolbar>
      <PanelBody>
        {list.length === 0 ? (
          <EmptyNote>No roles yet. Add roles on the Roles surface first.</EmptyNote>
        ) : (
          <>
            <PairHeaders primary={primary} working={working} />
            {list.map(r => (
              <PairField key={r.code} label="Role name" source={r.label}
                target={vals[r.code]?.value || ''}
                onChange={v => setVal(r.code, { value: v })} />
            ))}
          </>
        )}
      </PanelBody>
    </>
  );
}

// ── Panel · Subtitles ─────────────────────────────────────────────────────────
// Each cue is a stacked card: number · timing / English (editable) /
// target language (editable). Both languages are editable here — the
// transcription lives with the video, not with the module source text.
function SubtitlesPanel({ course, working, primary }) {
  const videos = React.useMemo(() => collectCourseVideos(course), [course]);
  const [sel, setSel] = React.useState(videos[0]?.id);
  const video = videos.find(v => v.id === sel) || videos[0];

  const baseCues = (SAMPLE_CUES_BY_VIDEO[sel] || SAMPLE_CUES_BY_VIDEO.default);
  const seedCues = React.useMemo(() => baseCues.map((c) => ({
    n: c.n, start: c.start, end: c.end,
    en: c.text,
    target: (SAMPLE_CUE_TRANSLATIONS[working] || {})[c.n] || '',
  })), [sel, working]);
  const [cues, setCues] = React.useState(seedCues);
  const [saveState, setSaveState] = React.useState('clean');
  React.useEffect(() => { setCues(seedCues); setSaveState('clean'); }, [seedCues]);
  const editCue = (i, patch) => {
    setCues(cs => cs.map((c, j) => j === i ? { ...c, ...patch } : c));
    setSaveState('dirty');
  };
  const save = () => { setSaveState('saving'); setTimeout(() => setSaveState('saved'), 700); };
  const [runState, run] = useRunState([sel, working]);

  const options = React.useMemo(() => {
    const byMod = {};
    videos.forEach(v => { (byMod[v.moduleId] = byMod[v.moduleId] || []).push(v); });
    return Object.entries(byMod).map(([mod, vs]) => ({
      group: mod, items: vs.map(v => ({ value: v.id, label: `${v.label} — ${v.subLabel}` })) }));
  }, [videos]);

  if (videos.length === 0) {
    return <PanelBody><EmptyNote>No videos in this course yet.</EmptyNote></PanelBody>;
  }

  return (
    <>
      <LocaleToolbar working={working} runState={runState} onRun={run}
        itemNoun="video" saveState={saveState} onSave={save}>
        <ItemNav options={options} value={sel} onChange={setSel} />
      </LocaleToolbar>
      <PanelBody>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12,
          color: 'var(--text-muted)' }}>
          <I.Captions size={13} />{cues.length} cues · {video?.duration}
        </div>
        {cues.map((c, i) => (
          <SubtitleCue key={c.n} cue={c} primary={primary} working={working}
            onEditStart={v => editCue(i, { start: v })}
            onEditEnd={v => editCue(i, { end: v })}
            onEditEn={v => editCue(i, { en: v })}
            onEditTarget={v => editCue(i, { target: v })} />
        ))}
      </PanelBody>
    </>
  );
}

function SubtitleCue({ cue, primary, working, onEditStart, onEditEnd, onEditEn, onEditTarget }) {
  return (
    <div style={{ background: 'var(--surface)', border: '1px solid var(--border)',
      borderRadius: 'var(--radius-md)', overflow: 'hidden' }}>
      {/* header: number + editable timing */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 10,
        padding: '8px 12px', background: 'var(--surface-inset)',
        borderBottom: '1px solid var(--border)' }}>
        <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700,
          color: 'var(--text-muted)', background: 'var(--surface)', padding: '2px 7px',
          borderRadius: 3 }}>{String(cue.n).padStart(2, '0')}</span>
        <div style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
          <TimeField value={cue.start} onChange={onEditStart} label="Start time" />
          <I.ArrowRight size={12} style={{ color: 'var(--text-faint)' }} />
          <TimeField value={cue.end} onChange={onEditEnd} label="End time" />
        </div>
      </div>
      {/* English (editable) */}
      <CueLangRow flag={locFlag(primary)} name={locName(primary)}
        value={cue.en} onChange={onEditEn} />
      <div style={{ height: 1, background: 'var(--border)' }} />
      {/* target (editable) */}
      <CueLangRow flag={locFlag(working)} name={locName(working)}
        value={cue.target} placeholder="Not translated yet" onChange={onEditTarget} />
    </div>
  );
}

// Editable timecode (mono, bordered field — matches the "editable" convention).
function TimeField({ value, onChange, label }) {
  return (
    <input className="field" value={value} onChange={e => onChange(e.target.value)}
      spellCheck={false} aria-label={label} title={label}
      style={{ height: 24, padding: '0 7px', width: 108, textAlign: 'center',
        fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-muted)' }} />
  );
}

function CueLangRow({ flag, name, value, placeholder, onChange, footer }) {
  return (
    <div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 12,
      padding: '10px 12px', alignItems: 'flex-start' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 7, paddingTop: 4 }}>
        <span style={{ fontSize: 15, lineHeight: 1 }}>{flag}</span>
        <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)' }}>{name}</span>
      </div>
      <div>
        <textarea className="field" value={value} placeholder={placeholder}
          onChange={e => onChange(e.target.value)}
          style={{ width: '100%', minHeight: 38, fontSize: 13, lineHeight: 1.5, resize: 'vertical' }} />
        {footer}
      </div>
    </div>
  );
}

// ── Panel · Assessment ──────────────────────────────────────────────────────
// Real-quiz shape with a clear visual hierarchy so the three roles of text
// never blur together:
//   · QUESTION — one prominent block (accent rule, large type).
//   · ANSWERS  — lettered cards; the correct one is tinted + badged.
//   · FEEDBACK — nested INSIDE each answer, indented + inset + smaller, so it
//     reads as "the reply to this answer", not a peer of the answer.
function AssessmentPanel({ working, primary, assessments, course }) {
  const A = assessments || { pre: { groups: [] }, post: { groups: [] } };
  // Sub-tabs: Pre-assessment questions · Post-assessment questions · the
  // shared pre/post result-screen text. Pre and post each navigate their own
  // questions, so the author switches assessment phase explicitly.
  const [sub, setSub] = React.useState('pre');
  const phases = {
    pre:  { id: 'pre',  title: 'Pre-assessment',  data: A.pre },
    post: { id: 'post', title: 'Post-assessment', data: A.post },
  };
  const TABS = [
    ['pre',     'Pre-assessment'],
    ['post',    'Post-assessment'],
    ['results', 'Result screens'],
  ];

  return (
    <>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 24px 0' }}>
        <div style={{ display: 'flex', gap: 3, background: 'var(--surface-inset)',
          padding: 3, borderRadius: 'var(--radius)' }}>
          {TABS.map(([id, label]) => {
            const on = sub === id;
            return (
              <button key={id} onClick={() => setSub(id)}
                style={{ padding: '5px 12px', border: 0, borderRadius: 4, cursor: 'default',
                  fontFamily: 'inherit', fontSize: 12, fontWeight: on ? 600 : 500,
                  background: on ? 'var(--surface)' : 'transparent',
                  color: on ? 'var(--text)' : 'var(--text-muted)',
                  boxShadow: on ? 'var(--shadow-sm)' : 'none' }}>{label}</button>
            );
          })}
        </div>
      </div>

      {sub === 'results'
        ? <ResultScreenLocalise primary={primary} working={working} />
        : <AssessmentQuestionsPanel key={sub} phase={phases[sub]}
            primary={primary} working={working} />}
    </>
  );
}

// One assessment phase's questions (pre OR post). A dropdown navigates this
// phase's questions; each renders the prompt + lettered answers with nested
// feedback as source → target pairs. Remounted (via key) on phase change so
// the selection resets cleanly.
function AssessmentQuestionsPanel({ phase, primary, working }) {
  const flatQs = (phase.data.groups || []).flatMap(g =>
    g.questions.map((qq, qi) => ({ ...qq, phase: phase.title, groupLabel: g.label, quiz: quizFor(qq.id, qi) })));
  const [sel, setSel] = React.useState(flatQs[0]?.id);
  const q = flatQs.find(x => x.id === sel) || flatQs[0];

  const options = [{
    group: phase.title,
    items: (phase.data.groups || []).flatMap(g => g.questions.map(qq => ({
      value: qq.id, label: `${g.label} — ${trunc(qq.prompt, 44)}` }))),
  }];

  // Build the localisable fields: prompt, then each answer + its feedback.
  const answers = q ? q.quiz.answers : [];
  const fields = [{ key: 'prompt', target: '' }];
  answers.forEach((a, i) => {
    fields.push({ key: `a${i}`, target: '' });
    fields.push({ key: `f${i}`, target: '' });
  });
  const [vals, setVal, saveState, save] = useTargets(fields, [sel, working]);
  const [runState, run] = useRunState([sel, working]);

  if (!q) {
    return <PanelBody><EmptyNote>No questions in this assessment yet.</EmptyNote></PanelBody>;
  }

  return (
    <>
      <LocaleToolbar working={working} runState={runState} onRun={run}
        itemNoun="question" saveState={saveState} onSave={save}>
        <ItemNav options={options} value={sel} onChange={setSel} />
      </LocaleToolbar>
      <PanelBody>
        <PairHeaders primary={primary} working={working} />

        <QPromptBlock source={q.prompt}
          target={vals.prompt?.value || ''}
          onChange={v => setVal('prompt', { value: v })} />

        <div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 -2px' }}>
          <span style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '.06em',
            textTransform: 'uppercase', color: 'var(--text-faint)', whiteSpace: 'nowrap',
            flexShrink: 0 }}>Answers &amp; feedback</span>
          <span style={{ flex: 1, height: 1, background: 'var(--border)' }} />
        </div>

        {q.quiz.answers.map((a, i) => (
          <AnswerCard key={i} index={i} correct={a.correct}
            ansSource={a.text} ansTarget={vals[`a${i}`]?.value || ''}
            onAns={v => setVal(`a${i}`, { value: v })}
            fbSource={a.feedback} fbTarget={vals[`f${i}`]?.value || ''}
            onFb={v => setVal(`f${i}`, { value: v })} />
        ))}
      </PanelBody>
    </>
  );
}

// ── Result-screen localisation — heading / body / acknowledgement per phase.
// Source (default-language) text comes from FRAMING_CONTENT_SAMPLES; the
// English copy is AUTHORED on the Assessments surface (§28) and translated
// here. Reuses the QPromptBlock-style source→target pair.
function ResultScreenLocalise({ primary, working }) {
  const RS = window.FRAMING_CONTENT_SAMPLES || {};
  const pre = RS.preAssessment || {}, post = RS.postAssessment || {};
  const rows = [
    { key: 'pre.heading', phase: 'Pre-assessment',  label: 'Heading', src: pre.resultTitle || 'Thank you' },
    { key: 'pre.body',    phase: 'Pre-assessment',  label: 'Body',    src: pre.resultBody || '', multiline: true },
    { key: 'post.heading',phase: 'Post-assessment', label: 'Pass heading', src: post.passTitle || 'Well done!' },
    { key: 'post.body',   phase: 'Post-assessment', label: 'Body',    src: post.passBody || '', multiline: true },
    { key: 'post.ack',    phase: 'Post-assessment', label: 'Acknowledgement clause', src: post.acknowledgement || '', multiline: true },
  ];
  const [vals, setVal, saveState, save] = useTargets(rows.map(r => ({ key: r.key, target: '' })), [working]);
  const [runState, run] = useRunState([working]);
  return (
    <>
      <LocaleToolbar working={working} runState={runState} onRun={run}
        itemNoun="result screen" saveState={saveState} onSave={save} />
      <PanelBody>
        <PairHeaders primary={primary} working={working} />
        {rows.map(r => (
          <div key={r.key} style={{ border: '1px solid var(--border)',
            borderRadius: 'var(--radius-md)', overflow: 'hidden' }}>
            <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 14px',
              background: 'var(--surface-2)', borderBottom: '1px solid var(--border)' }}>
              <span className="pill" style={{ fontSize: 9.5, height: 16, whiteSpace: 'nowrap', flex: '0 0 auto' }}>{r.phase}</span>
              <span style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{r.label}</span>
            </div>
            <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr' }}>
              <div style={{ padding: '11px 14px', background: 'var(--surface-inset)',
                borderRight: '1px solid var(--border)' }}>
                <p style={{ margin: 0, fontSize: 13, lineHeight: 1.45, color: 'var(--text)' }}>{locText(r.src, primary)}</p>
              </div>
              <div style={{ padding: '11px 14px', background: 'var(--surface)' }}>
                <textarea className="field" value={vals[r.key]?.value || ''}
                  onChange={e => setVal(r.key, { value: e.target.value })}
                  placeholder={`Translate the ${r.label.toLowerCase()}…`}
                  style={{ width: '100%', minHeight: r.multiline ? 60 : 38, fontSize: 13,
                    lineHeight: 1.45, resize: 'vertical' }} />
              </div>
            </div>
          </div>
        ))}
        <div style={{ display: 'flex', alignItems: 'flex-start', gap: 7, padding: '8px 10px',
          background: 'var(--surface-inset)', borderRadius: 'var(--radius)',
          fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.5 }}>
          <I.Info size={12} style={{ flex: '0 0 auto', marginTop: 1 }} />
          <span>Keep <span style={{ fontFamily: 'var(--font-mono)', color: 'var(--accent-text)' }}>{'{correct}'}</span> and{' '}
            <span style={{ fontFamily: 'var(--font-mono)', color: 'var(--accent-text)' }}>{'{total}'}</span>{' '}
            in the translated body — they're replaced with the learner's score at runtime.</span>
        </div>
      </PanelBody>
    </>
  );
}

// ── Panel · Quiz Gaming ───────────────────────────────────────────────────
// Surfaces every quiz_gaming row's localisable strings in primary → working
// pairs: Title block, optional Intro panel, Start / Win / Fail screens, and
// each question (prompt + answers + per-slot tooltip text). Tooltip slots
// resolve a human label from the chosen template's tooltipParams
// (window.tooltipParamsForHtmlUrl) so translators see "Sender domain" rather
// than {{sender_domain}}. Only groups that exist on the row are rendered.
// Collapsible section for the Quiz Gaming panel. A prominent header band — an
// accent-tinted stroke icon + bold, full-strength title + field count + chevron
// — keeps the long list scannable; the body collapses to tame page length.
function LocGroupSection({ icon, label, count, open, onToggle, children }) {
  const Ico = icon ? I[icon] : null;
  return (
    <div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-md)',
      overflow: 'hidden', background: 'var(--surface)' }}>
      <button onClick={onToggle} style={{
        display: 'flex', alignItems: 'center', gap: 10, width: '100%',
        padding: '10px 12px', border: 0, cursor: 'default', fontFamily: 'inherit',
        background: open ? 'var(--surface-inset)' : 'var(--surface)',
        borderBottom: open ? '1px solid var(--border)' : 0, textAlign: 'left' }}>
        <span style={{ width: 26, height: 26, borderRadius: 7, flexShrink: 0,
          display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
          background: 'var(--accent-bg)', color: 'var(--accent-text)' }}>
          {Ico && <Ico size={15} />}
        </span>
        <span style={{ fontSize: 13.5, fontWeight: 700, color: 'var(--text)', flex: 1 }}>{label}</span>
        {count != null && (
          <span style={{ fontSize: 11, color: 'var(--text-faint)', fontFamily: 'var(--font-mono)' }}>
            {count}
          </span>
        )}
        <I.ChevronDown size={16} style={{ color: 'var(--text-muted)', flexShrink: 0,
          transform: open ? 'none' : 'rotate(-90deg)', transition: 'transform .15s' }} />
      </button>
      {open && (
        <div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
          {children}
        </div>
      )}
    </div>
  );
}

// Build the grouped, localisable field list for a quiz_gaming row. Each field:
// { key, label, source, badge? }. Only present groups are emitted.
function buildQuizGamingGroups(row, primary) {
  if (!row) return [];
  const c = row.content || {};
  const txt = (v) => locText(v, primary);
  const groups = [];

  const titleFields = [];
  if (c.titleMain != null) titleFields.push({ key: 'titleMain', label: 'Title', source: txt(c.titleMain) });
  if (c.titleSub != null) titleFields.push({ key: 'titleSub', label: 'Subtitle', source: txt(c.titleSub) });
  if (titleFields.length) groups.push({ label: 'Title block', icon: 'Type', fields: titleFields });

  const introFields = [];
  if (c.introTitle != null) introFields.push({ key: 'introTitle', label: 'Intro title', source: txt(c.introTitle) });
  if (c.introText != null) introFields.push({ key: 'introText', label: 'Intro text', source: txt(c.introText) });
  if (introFields.length) groups.push({ label: 'Intro panel', icon: 'Info', fields: introFields });

  const start = c.gamingStartScreen || {};
  const startFields = [];
  if (start.title != null) startFields.push({ key: 'start.title', label: 'Start title', source: txt(start.title) });
  if (start.body != null) startFields.push({ key: 'start.body', label: 'Start text', source: txt(start.body) });
  if (startFields.length) groups.push({ label: 'Start screen', icon: 'Flag', fields: startFields });

  const ends = c.gamingEndScreens || {};
  if (ends.win) {
    const winFields = [];
    if (ends.win.title != null) winFields.push({ key: 'win.title', label: 'Win title', source: txt(ends.win.title) });
    if (ends.win.body != null) winFields.push({ key: 'win.body', label: 'Win text', source: txt(ends.win.body) });
    if (winFields.length) groups.push({ label: 'Win screen', icon: 'Trophy', fields: winFields });
  }
  if (ends.failure) {
    const failFields = [];
    if (ends.failure.title != null) failFields.push({ key: 'fail.title', label: 'Fail title', source: txt(ends.failure.title) });
    if (ends.failure.body != null) failFields.push({ key: 'fail.body', label: 'Fail text', source: txt(ends.failure.body) });
    if (failFields.length) groups.push({ label: 'Fail screen', icon: 'AlertCircle', fields: failFields });
  }

  (c.questions || []).forEach((q, qi) => {
    const fields = [{ key: `q${qi}.text`, label: 'Question', source: txt(q.text) }];
    (q.answers || []).forEach((a, ai) => {
      fields.push({ key: `q${qi}.a${ai}`, label: `Answer ${String.fromCharCode(65 + ai)}`,
        source: txt(a.text),
        badge: a.isCorrect
          ? <span className="pill accepted" style={{ fontSize: 9, height: 15 }}><I.Check size={9} />Correct</span>
          : null });
    });
    // Tooltip values — resolve each slot's human label from the template.
    const tv = (q.content && q.content.tooltipValues) || {};
    const params = (window.tooltipParamsForHtmlUrl && q.content && q.content.html)
      ? window.tooltipParamsForHtmlUrl(q.content.html.htmlUrl) : [];
    const byName = {};
    params.forEach(p => { byName[p.name] = p; });
    Object.keys(tv).forEach(nm => {
      const p = byName[nm];
      const lab = p ? (locText(p.label, primary) || locText(p.label, 'en')) : nm;
      fields.push({ key: `q${qi}.tt.${nm}`, label: `${lab} tooltip`, source: txt(tv[nm]),
        badge: <span className="pill" style={{ fontSize: 9, height: 15 }}><I.MessageSquare size={9} />Tooltip</span> });
    });
    groups.push({ label: `Question ${qi + 1}`, icon: 'ClipboardCheck', fields });
  });

  return groups;
}

function QuizGamingPanel({ course, working, primary }) {
  const rows = React.useMemo(() => {
    const out = [];
    (course.modules || []).forEach(m => {
      (m.layouts || []).forEach(l => {
        if (l.type === 'quiz_gaming') {
          const content = (window.getLayoutContent
            ? window.getLayoutContent(l.id, 'quiz_gaming') : {}) || {};
          out.push({ id: l.id, n: l.n, module: m, summary: l.summary, content });
        }
      });
    });
    return out;
  }, [course]);

  const [sel, setSel] = React.useState(rows[0]?.id);
  const row = rows.find(r => r.id === sel) || rows[0];

  const groups = React.useMemo(() => buildQuizGamingGroups(row, primary), [row, primary]);
  const flatFields = groups.flatMap(g => g.fields.map(f => ({ key: f.key, target: '' })));
  const [vals, setVal, saveState, save] = useTargets(flatFields, [sel, working]);
  const [runState, run] = useRunState([sel, working]);

  // Collapsible sections — open the first group by default; reset when the
  // selected layout (or working language) changes.
  const [open, setOpen] = React.useState(() => new Set([0]));
  React.useEffect(() => { setOpen(new Set([0])); }, [sel, working]);
  const toggle = (i) => setOpen(s => {
    const n = new Set(s); n.has(i) ? n.delete(i) : n.add(i); return n; });
  const allOpen = groups.length > 0 && open.size === groups.length;
  const setAll = () => setOpen(allOpen ? new Set() : new Set(groups.map((_, i) => i)));

  if (rows.length === 0) {
    return <PanelBody><EmptyNote>No Quiz · gaming layouts in this course yet. Add a
      Quiz · gaming layout to a module to localise its screens and questions here.</EmptyNote></PanelBody>;
  }

  const options = [{
    group: 'Quiz · gaming layouts',
    items: rows.map(r => ({ value: r.id,
      label: `${r.module.id}.L${r.n} — ${locText(r.summary, primary) || locText(r.content.titleMain, primary) || 'Gamified quiz'}` })),
  }];

  return (
    <>
      <LocaleToolbar working={working} runState={runState} onRun={run}
        itemNoun="quiz" saveState={saveState} onSave={save}>
        <ItemNav options={options} value={sel} onChange={setSel} />
      </LocaleToolbar>
      <PanelBody>
        <PairHeaders primary={primary} working={working} />
        <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: -2 }}>
          <button className="btn sm ghost" onClick={setAll}>
            <I.ChevronDown size={12} style={{ transform: allOpen ? 'none' : 'rotate(-90deg)',
              transition: 'transform .15s' }} />
            {allOpen ? 'Collapse all' : 'Expand all'}
          </button>
        </div>
        {groups.map((g, gi) => (
          <LocGroupSection key={gi} icon={g.icon} label={g.label} count={`${g.fields.length}`}
            open={open.has(gi)} onToggle={() => toggle(gi)}>
            {g.fields.map(f => (
              <PairField key={f.key} label={f.label} source={f.source} badge={f.badge}
                target={vals[f.key]?.value || ''}
                onChange={v => setVal(f.key, { value: v })} />
            ))}
          </LocGroupSection>
        ))}
      </PanelBody>
    </>
  );
}

// QUESTION — the prominent block at the top of a question's localisation.
function QPromptBlock({ source, target, onChange }) {
  return (
    <div style={{ border: '1px solid var(--border)', borderLeft: '3px solid var(--accent)',
      borderRadius: 'var(--radius-md)', overflow: 'hidden' }}>
      <div style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '7px 14px',
        background: 'var(--accent-bg)', borderBottom: '1px solid var(--border)', width: '100%' }}>
        <I.ClipboardCheck size={13} style={{ color: 'var(--accent-text)' }} />
        <span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '.06em',
          textTransform: 'uppercase', color: 'var(--accent-text)' }}>Question</span>
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr' }}>
        <div style={{ padding: '12px 14px', background: 'var(--surface-inset)',
          borderRight: '1px solid var(--border)' }}>
          <p style={{ margin: 0, fontSize: 15, fontWeight: 600, lineHeight: 1.45 }}>{locText(source)}</p>
        </div>
        <div style={{ padding: '12px 14px', background: 'var(--surface)' }}>
          <textarea className="field" value={target} onChange={e => onChange(e.target.value)}
            placeholder="Translate the question…"
            style={{ width: '100%', minHeight: 52, fontSize: 15, fontWeight: 500,
              lineHeight: 1.45, resize: 'vertical' }} />
        </div>
      </div>
    </div>
  );
}

// ANSWER (top) + its FEEDBACK (nested, indented, inset, smaller).
function AnswerCard({ index, correct, ansSource, ansTarget, onAns, fbSource, fbTarget, onFb }) {
  const letter = String.fromCharCode(65 + index);
  return (
    <div style={{ border: '1px solid', borderColor: correct ? 'var(--success)' : 'var(--border)',
      borderRadius: 'var(--radius-md)', overflow: 'hidden',
      boxShadow: correct ? '0 0 0 1px var(--success) inset' : 'none' }}>

      {/* Answer row */}
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr' }}>
        <div style={{ padding: '11px 12px', borderRight: '1px solid var(--border)',
          display: 'flex', gap: 9, alignItems: 'flex-start',
          background: correct ? 'var(--success-bg)' : 'var(--surface-inset)' }}>
          <span style={{ flexShrink: 0, width: 21, height: 21, borderRadius: 5,
            display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
            fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700,
            background: correct ? 'var(--success)' : 'var(--surface)',
            border: correct ? 0 : '1px solid var(--border)',
            color: correct ? '#fff' : 'var(--text-muted)' }}>{letter}</span>
          <div style={{ minWidth: 0, flex: 1 }}>
            <p style={{ margin: 0, fontSize: 13.5, fontWeight: 500, lineHeight: 1.4 }}>{ansSource}</p>
            {correct && (
              <span className="pill accepted" style={{ fontSize: 9, height: 15, marginTop: 5 }}>
                <I.Check size={9} />Correct answer</span>
            )}
          </div>
        </div>
        <div style={{ padding: '11px 12px', background: 'var(--surface)' }}>
          <textarea className="field" value={ansTarget} onChange={e => onAns(e.target.value)}
            placeholder={`Translate answer ${letter}…`}
            style={{ width: '100%', minHeight: 38, fontSize: 13.5, lineHeight: 1.4,
              resize: 'vertical' }} />
        </div>
      </div>

      {/* Feedback row — nested under the answer */}
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr',
        borderTop: '1px dashed var(--border-strong)' }}>
        <div style={{ padding: '9px 12px 9px 42px', borderRight: '1px solid var(--border)',
          background: 'var(--surface-inset)' }}>
          <span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, marginBottom: 3,
            fontSize: 10, fontWeight: 600, letterSpacing: '.04em', textTransform: 'uppercase',
            color: 'var(--text-faint)' }}>
            <I.CornerDownRight size={11} />Feedback
          </span>
          <p style={{ margin: 0, fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.45 }}>{fbSource}</p>
        </div>
        <div style={{ padding: '9px 12px', background: 'var(--surface)' }}>
          <FieldLabel inline>Feedback</FieldLabel>
          <textarea className="field" value={fbTarget} onChange={e => onFb(e.target.value)}
            placeholder="Translate feedback…"
            style={{ width: '100%', minHeight: 32, marginTop: 5, fontSize: 12,
              color: 'var(--text-muted)', lineHeight: 1.45, resize: 'vertical' }} />
        </div>
      </div>
    </div>
  );
}

function trunc(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; }

// Simulated quiz content (answers + feedback) per question id. Counts vary
// (2–4) to show that answer count is per-question. Unknown ids fall back to a
// generic 3-answer set so every question renders a real quiz.
const SAMPLE_QUIZ = {
  pq1: { answers: [
    { text: 'A subtle shift in tone or body language', correct: true,  feedback: 'Correct — micro-shifts in tone or posture are the earliest tell, well before words escalate.' },
    { text: 'Someone raises their voice',              correct: false, feedback: 'That\'s a later, louder signal. The goal is to catch it earlier.' },
    { text: 'A formal complaint is filed',             correct: false, feedback: 'By this point the moment to pre-empt has long passed.' },
    { text: 'The meeting runs over time',              correct: false, feedback: 'Time pressure can contribute, but it isn\'t itself a signal of tension.' },
  ] },
  pq2: { answers: [
    { text: 'Raising six months of saved-up grievances at once', correct: true,  feedback: 'Correct — stockpiling is holding issues back until they spill out together.' },
    { text: 'Giving feedback on the same day',                    correct: false, feedback: 'That\'s timely feedback — the opposite of stockpiling.' },
    { text: 'Asking a clarifying question',                       correct: false, feedback: 'Curiosity isn\'t stockpiling; it keeps the channel open.' },
  ] },
  pq3: { answers: [
    { text: 'Crossed arms → defensiveness', correct: true,  feedback: 'Correct — closed posture commonly signals a defensive stance.' },
    { text: 'Long pause → agreement',        correct: false, feedback: 'A pause more often signals hesitation or discomfort than agreement.' },
  ] },
  pq4: { answers: [
    { text: 'SBI — Situation, Behaviour, Impact', correct: true,  feedback: 'Correct — SBI is built for specific 1:1 feedback moments.' },
    { text: 'A company-wide email',               correct: false, feedback: 'Broadcasts don\'t fit a personal feedback conversation.' },
    { text: 'CLEAR contracting',                  correct: false, feedback: 'CLEAR suits coaching arcs more than a single feedback beat.' },
    { text: 'No framework — improvise',           correct: false, feedback: 'Improvising raises the odds the message lands poorly.' },
  ] },
  pq5: { answers: [
    { text: 'Coaching arc → CLEAR',          correct: true,  feedback: 'Correct — CLEAR scaffolds a longer coaching conversation.' },
    { text: 'One-off behaviour note → CLEAR', correct: false, feedback: 'For a single behaviour, SBI is the tighter fit.' },
    { text: 'Performance review → SBI only',  correct: false, feedback: 'Reviews usually blend both; SBI alone is too narrow.' },
  ] },
  pq6: { answers: [
    { text: 'Lower your own volume and slow the pace', correct: true,  feedback: 'Correct — regulating your own delivery lowers the temperature fastest.' },
    { text: 'Match their energy to show you care',     correct: false, feedback: 'Matching heat usually escalates rather than calms.' },
    { text: 'End the conversation immediately',         correct: false, feedback: 'Abruptly ending can read as dismissive; de-escalate first.' },
  ] },
  pq7: { answers: [
    { text: '"Let\'s come back to the goal we both share…"', correct: true,  feedback: 'Correct — re-grounding in shared purpose steadies the conversation.' },
    { text: '"You always do this."',                          correct: false, feedback: 'Absolutes provoke defensiveness and derail the talk.' },
  ] },
  poq1: { answers: [
    { text: 'A clipped, one-word reply', correct: true,  feedback: 'Correct — terse replies are an early withdrawal signal.' },
    { text: 'An open follow-up question', correct: false, feedback: 'That signals engagement, not tension.' },
    { text: 'A smile and a nod',          correct: false, feedback: 'Usually a sign of comfort rather than strain.' },
  ] },
  poq2: { answers: [
    { text: 'Tone → posture → word choice → silence', correct: true,  feedback: 'Correct — this ordering matches the module\'s escalation ladder.' },
    { text: 'Silence → smiling → laughter',            correct: false, feedback: 'These aren\'t ranked tension signals.' },
  ] },
  poq3: { answers: [
    { text: 'Contract, Listen, Explore, Action, Review', correct: true,  feedback: 'Correct — that\'s the CLEAR sequence applied end-to-end.' },
    { text: 'Criticise, Leave, End, Argue, Repeat',      correct: false, feedback: 'Not CLEAR — and not a constructive arc.' },
    { text: 'Situation, Behaviour, Impact',              correct: false, feedback: 'That\'s SBI, a different (shorter) framework.' },
  ] },
  poq4: { answers: [
    { text: 'CLEAR suits arcs; SBI suits single moments', correct: true,  feedback: 'Correct — scope is the key difference between them.' },
    { text: 'They are interchangeable in all cases',       correct: false, feedback: 'Their scope differs; pick by the moment you\'re in.' },
  ] },
  poq5: { answers: [
    { text: 'Acknowledge the feeling, then restate the goal', correct: true,  feedback: 'Correct — name-and-reframe is the strongest de-escalation move here.' },
    { text: 'Repeat your point more firmly',                  correct: false, feedback: 'Repetition under heat tends to escalate.' },
    { text: 'Threaten a consequence',                          correct: false, feedback: 'Threats spike the temperature and erode trust.' },
    { text: 'Go silent and wait it out',                       correct: false, feedback: 'Passive silence can read as disengagement.' },
  ] },
};

function quizFor(id, idx) {
  if (SAMPLE_QUIZ[id]) return SAMPLE_QUIZ[id];
  return { answers: [
    { text: 'The option that correctly applies the module\'s principle', correct: true,  feedback: 'Correct — this matches the guidance in the module.' },
    { text: 'A plausible but incomplete choice',                          correct: false, feedback: 'Close, but it misses a key step.' },
    { text: 'A common misconception',                                     correct: false, feedback: 'This is a frequent trap — revisit the module.' },
  ] };
}

Object.assign(window, { SurfaceLocalisation });
