// 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.

// The 15 languages with a bundled UI.xml template + flags in @dynamo/scorm-packager.
// Keep in sync with LANG_FLAGS/LANG_NAMES (data.jsx) and SUPPORTED_UI_LANGUAGES
// (scorm-packager) — the export honest-gate 422s any enabled language not in that set.
const LOC_SUPPORTED_LANGS = ['en','it','de','fr','es','jp','ar','hk','ch','id','ms','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 = {};

// ── Auto-enable language on the course record ───────────────────────────────
// After a translation run lands (or turns out to be already complete), the
// course's server-side `enabled_langs` must include the target language or the
// export stays single-language (no <languages enabled="true">, no Player
// language selector). This calls the gateway's idempotent enable endpoint so
// authors never touch the course row manually. Returns { enabledLangs, added }
// or null on any failure (non-fatal: translation itself already succeeded —
// the toast just won't claim the language was enabled).
async function ensureLanguageEnabled(courseId, lang) {
  try {
    if (!courseId || !lang) return null;
    const token = await window.dynamoGetAccessToken();
    const res = await fetch(`${GATEWAY_BASE}/v1/courses/${courseId}/languages`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
      body: JSON.stringify({ language: lang }),
    });
    if (!res.ok) return null;
    return await res.json();
  } catch (_) {
    return null;
  }
}

// Counterpart of ensureLanguageEnabled: removing a language in the rail must
// also disable it on the course record, or a half-translated leftover keeps
// blocking every export at the server's translation-coverage gate. Returns
// { ok, refused } — `refused` when the server rejected the removal (e.g. the
// course's default language), so the rail can keep the language visible.
async function disableLanguageServer(courseId, lang) {
  try {
    if (!courseId || !lang) return { ok: false, refused: false };
    const token = await window.dynamoGetAccessToken();
    const res = await fetch(
      `${GATEWAY_BASE}/v1/courses/${courseId}/languages/${encodeURIComponent(lang)}`,
      { method: 'DELETE', headers: { authorization: `Bearer ${token}` } },
    );
    if (res.status === 400) {
      // The endpoint has TWO 400s: the default language (a legitimate refusal
      // the rail must respect) and a malformed language tag (a real error).
      // Reading only the status told the author the wrong reason.
      let message = '';
      try { message = ((await res.json()) || {}).message || ''; } catch { /* keep '' */ }
      if (/default language/i.test(message)) return { ok: false, refused: true };
      return { ok: false, refused: false, message };
    }
    return { ok: res.ok, refused: false };
  } catch (_) {
    return { ok: false, refused: false };
  }
}
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`.
// LOC_STRING_FIELDS + the translate-job walk live in src/loc-translate-core.js
// (a React-free classic script, node-unit-testable per the test-before-handoff
// rule) and arrive here as globals.
const locDeepClone = (o) =>
  (typeof structuredClone === 'function' ? structuredClone(o) : JSON.parse(JSON.stringify(o)));

// ── LocalizedString field walking (real Modules panel) ───────────────────────
// A field is localisable when the SHARED predicate says so — `isLocTranslatable
// Value` in loc-translate-core.js, the same one the Translate run and the rail's
// coverage count use. Plus the walk's other shape: a PLAIN STRING in a known
// localizable field, which is what the editors seed when the author adds a step
// / tab / hotspot / overlay.
//
// Both additions close real gaps (2026-07-28). The panel used to require
// `LOC_STRING_FIELDS.has(k)`, so dynamic tooltip slots were translated by the
// button but could never be seen or corrected here; and it used to require an
// `{ en }` object, so a freshly added step showed NO rows at all while the
// export coerced its plain strings and the coverage gate then demanded a
// translation for them. Writing back works either way: `locSet` upgrades a
// plain string to `{ en }`.
const isLocField = (k, v) => window.isLocTranslatableValue(k, v)
  || (typeof v === 'string' && !!v.trim() && 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".
// `from` skips the leading segments a surrounding group header already states,
// so a row inside the "Question 2" band reads "Answer B \u00b7 Feedback".
function fieldLabel(path, type, from = 0) {
  const crumbs = [];                       // [{ key, text }]
  for (let i = from; i < path.length - 1; i++) {
    const seg = path[i];
    if (typeof seg === 'number') {
      // Re-label the crumb just pushed as "<noun> <n>": that crumb IS the
      // container key, which is what tells us the right word for one item.
      if (crumbs.length) {
        const last = crumbs[crumbs.length - 1];
        last.text = locItemLabel(last.key, seg, type);
      }
    } else if (!LOC_SKIP_CRUMBS.has(seg)) {
      crumbs.push({ key: seg, text: LOC_CRUMB_LABEL[seg] || humanizeKey(seg) });
    }
  }
  const leaf = path[path.length - 1];
  // The leaf's own container: the segment before it, or the one before that when
  // the leaf sits directly inside an array item (questions[1].text).
  const parent = typeof path[path.length - 2] === 'number'
    ? path[path.length - 3] : path[path.length - 2];
  const label = (LOC_LEAF_BY_CONTAINER[parent] || {})[leaf]
    || LOC_CRUMB_LABEL[leaf] || humanizeKey(leaf);
  return crumbs.length
    ? crumbs.map(c => c.text).join(' \u203a ') + ' \u00b7 ' + label
    : label;
}

// \u2500\u2500 The translator's vocabulary \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
// A group header here must use the word the author saw in the LAYOUT EDITOR, or
// the two screens name the same thing differently: the editor's "+ Add step"
// writes `tabs`, its "Hotspots" block writes `items`, its "In-video overlays"
// block writes `interactions`. A field path is a data shape, not a vocabulary.
const LOC_ITEM_NOUN = {
  questions: 'Question', answers: 'Answer', itemOptions: 'Answer',
  options: 'Option', rows: 'Row', icons: 'Icon', items: 'Hotspot',
  interactions: 'Overlay', tabs: 'Tab',
};
// Same data key, different editor word per layout type.
const LOC_ITEM_NOUN_BY_TYPE = { sequence: { tabs: 'Step' } };
// Answer sets are lettered A/B/C \u2014 matching the answer cards in the Assessment
// and Quiz Gaming panels and the editor's own "Option A" row placeholders.
const LOC_LETTERED = new Set(['answers', 'itemOptions']);
// Crumbs that exist to shape the data, not to name anything a translator cares
// about: questions[2].content.image.items[1].itemText must read
// "Hotspot 2 - Item text", never "Content > Image > Item 2 - Item text".
const LOC_SKIP_CRUMBS = new Set(['content', 'image', 'video', 'html']);
// Crumbs whose humanised key is worse than the editor's own label.
const LOC_CRUMB_LABEL = {
  gamingStartScreen: 'Start screen', gamingEndScreens: 'End screens',
  win: 'Win screen', failure: 'Fail screen', feedbacks: 'Question feedback',
  // The editor's own label for it, and what it IS: the one message a question
  // shows once answered. "Feedback text" is the data key, not a translator's word.
  feedbackText: 'Question feedback',
  tooltipValues: 'Tooltip', blockingSection: 'Gate',
};
// Leaf labels that only make sense in context: `text` is the prompt on a
// question, the body on a step, the label on an answer. Keyed by the leaf's
// IMMEDIATE container so an answer's `text` is not called a question prompt.
const LOC_LEAF_BY_CONTAINER = {
  questions: { text: 'Question prompt' },
  interactions: { text: 'Prompt' },
};
const LOC_GROUP_ICON = {
  questions: 'ClipboardCheck', answers: 'Check', tabs: 'Layers', rows: 'Layers',
  icons: 'Sparkle', items: 'Pin', interactions: 'Film', options: 'ListChecks',
};
const locItemNoun = (containerKey, type) => {
  const byType = LOC_ITEM_NOUN_BY_TYPE[type] || {};
  return byType[containerKey] || LOC_ITEM_NOUN[containerKey]
    || singularize(humanizeKey(containerKey));
};
// "Question 3" / "Answer B" \u2014 one item of a container.
const locItemLabel = (containerKey, index, type) =>
  `${locItemNoun(containerKey, type)} ${LOC_LETTERED.has(containerKey)
    ? String.fromCharCode(65 + index) : index + 1}`;

// \u2500\u2500 Grouping \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
// Omar, 2026-07-28: "it is hard to understand where question 1 starts and where
// it ends". A flat list of pairs gives a translator no structure, so:
//   \u00b7 fields with NO array index in their path are screen-level -> one band at
//     the top (the title block, which is where the editor puts them too);
//   \u00b7 everything else is grouped by the FIRST array index in its path, so
//     questions[1].text and questions[1].answers[2].feedback share one
//     "Question 2" band;
//   \u00b7 a container whose items each carry a SINGLE field (icons, rows) gets one
//     band for the whole container \u2014 three "Icon N" bands holding one row each
//     is noise, not structure;
//   \u00b7 a band holding exactly one row renders as a bare row (see the panel): the
//     row's own label already says everything the band would.
// Order follows the content object's own key order, so it matches the editor.
function groupLocFields(rows, type) {
  const screen = [];
  const containers = [];                   // [{ key, items: Map(index -> rows) }]
  const byKey = new Map();
  rows.forEach(r => {
    const at = r.field.path.findIndex(seg => typeof seg === 'number');
    if (at < 0) { screen.push({ ...r, label: fieldLabel(r.field.path, type) }); return; }
    const containerKey = r.field.path[at - 1];
    if (!byKey.has(containerKey)) {
      const entry = { key: containerKey, items: new Map() };
      byKey.set(containerKey, entry); containers.push(entry);
    }
    const items = byKey.get(containerKey).items;
    const idx = r.field.path[at];
    if (!items.has(idx)) items.set(idx, []);
    items.get(idx).push({ ...r, label: fieldLabel(r.field.path, type, at + 1) });
  });

  const groups = [];
  if (screen.length) {
    groups.push({ key: 'screen', label: 'Title block', icon: 'Type',
      sub: (screen.find(r => r.source) || {}).source
        ? trunc(stripTagsForLabel(screen.find(r => r.source).source), 64) : '',
      rows: screen });
  }
  containers.forEach(c => {
    const entries = [...c.items.entries()].sort((a, b) => a[0] - b[0]);
    const perItem = entries.some(([, rs]) => rs.length > 1);
    if (perItem) {
      entries.forEach(([idx, rs]) => groups.push({
        key: `${c.key}[${idx}]`,
        label: locItemLabel(c.key, idx, type),
        icon: LOC_GROUP_ICON[c.key] || 'ListChecks',
        sub: rs[0] && rs[0].source ? trunc(stripTagsForLabel(rs[0].source), 64) : '',
        rows: rs,
      }));
    } else {
      // One band for the container; every row keeps its item qualifier.
      const flat = entries.flatMap(([idx, rs]) => rs.map(r => ({
        ...r, label: `${locItemLabel(c.key, idx, type)} \u00b7 ${r.label}`,
      })));
      groups.push({ key: c.key, label: humanizeKey(c.key),
        icon: LOC_GROUP_ICON[c.key] || 'ListChecks',
        sub: flat[0] && flat[0].source ? trunc(stripTagsForLabel(flat[0].source), 64) : '',
        rows: flat });
    }
  });
  return groups;
}

// ── 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: 'ch', flag: '🇨🇳' }, { name: 'Chinese (Traditional)', code: 'hk', 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: 'jp', 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;
    // Only suggest languages the exporter can actually build (the 15 with a
    // bundled UI.xml template + flags). Suggesting others would be a false
    // affordance — they translate fine but fail the export honest-gate.
    if (!LOC_SUPPORTED_LANGS.includes(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);
};

// Per-language progress for the LEFT RAIL, from the course's real content.
//
// `cov` is { total, filled } for that language — counted by countLocCoverage
// over the SAME field set the Translate button walks, so "Localised" means
// exactly "Translate has nothing left to do for this language". It replaces a
// hardcoded simulation (it → done, de → partial, everything else → pending)
// that told Omar his fully-translated Arabic was "Not started" while claiming
// Italian was done regardless of content (2026-07-28).
//
// `total === 0` is its own case: a course with no English text yet has nothing
// to translate, and calling that "Not started" blames the translator for a
// missing source.
function railProgress(cov) {
  if (!cov || !cov.total) return 'empty';
  if (!cov.filled) return 'pending';
  return cov.filled >= cov.total ? 'done' : 'partial';
}

function SurfaceLocalisation({ course, roles, layoutDrafts, onUpdateDrafts, onTranslateModuleTitle, onTranslateGroupTitle, settings, onUpdateSettings,
  pendingLanguages, onLanguageAdded, onLanguagesEnabled, onLanguageRemoved }) {
  // The SETTINGS value first, for the reason spelled out in
  // `surface-brand.jsx`'s `primaryLang`: both names describe one quantity, but
  // `metadata.defaultLanguage` is written the instant the author changes the
  // dropdown while `course.defaultLanguage` waits for the course to be reopened.
  // The two surfaces must pick the SAME slot as "the source", or this panel's
  // read-only left column shows a different language than Course settings just
  // wrote into (`feedback_one_rule_one_place`).
  const primary = settings?.metadata?.defaultLanguage || course.defaultLanguage;
  // The rail = languages the SERVER has enabled (course.languages) + languages
  // added here but not yet enabled (pendingLanguages, owned by app.jsx), minus
  // any removed in this session. All three live ABOVE this component, so leaving
  // the surface and coming back can no longer lose a language — which is the bug
  // being fixed (Omar, 2026-07-27: "once translated, the localisation language
  // disappear from the left column"). There is deliberately NO local mirror of
  // the list: the previous mirror was initialised once from `course.languages`
  // and was exactly what got thrown away on unmount.
  //
  // `removedHere` covers the demo course, whose language set is a frozen
  // constant the app cannot rewrite, and gives every course an instant response
  // before the server confirms the removal.
  const [removedHere, setRemovedHere] = React.useState([]);
  // Read inside async translation runs, where a state closure would be stale.
  const removedHereRef = React.useRef(removedHere);
  React.useEffect(() => { removedHereRef.current = removedHere; }, [removedHere]);
  const enabledKey = (course.languages || []).join(',');
  const pendingKey = (pendingLanguages || []).join(',');
  const langs = React.useMemo(() => {
    const seen = new Set();
    const out = [];
    for (const l of [...(course.languages || []), ...(pendingLanguages || [])]) {
      if (!seen.has(l) && !removedHere.includes(l)) { seen.add(l); out.push(l); }
    }
    return out;
  }, [enabledKey, pendingKey, removedHere.join(',')]);
  // A language is only "enabled for export" once the server says so. The rail
  // labels the difference rather than implying a pending language will ship.
  const isPending = React.useCallback(
    (code) => (pendingLanguages || []).includes(code) && !(course.languages || []).includes(code),
    [pendingKey, enabledKey]);
  // 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]);
  // With SEVERAL target languages the first action is to choose one. With
  // exactly one there is nothing to choose, so preselect it — re-picking the
  // only target every time you return to this surface is friction, not a
  // decision (Omar, 2026-07-27).
  const [working, setWorking] = React.useState(() => {
    const targetsAtMount = (course.languages || []).filter(l => l !== primary);
    return targetsAtMount.length === 1 ? targetsAtMount[0] : null;
  });
  // ── Welcome first, not Modules (Omar, 2026-08-14) ──────────────────────────
  // *"When I land on the Localisation screen, make sure that when a language is
  // clicked, the Welcome tab is the one to appear."*
  //
  // Both halves of that, because they are two different moments: the initial value
  // covers landing on the surface, and the effect covers picking a language after
  // arriving — with several targets there is no working language at mount, so the
  // initial value alone would leave whichever tab was last open.
  //
  // Welcome is the right first screen because it is the course's front matter —
  // title, cover sentence, News, New to company — the text a learner reads before
  // the first module, and the shortest list. Modules is the long tail.
  const [tab, setTab] = React.useState('welcome');
  React.useEffect(() => { if (working) setTab('welcome'); }, [working]);

  const addLang = (code, meta) => {
    if (meta) setCustomMeta(m => ({ ...m, [code]: meta }));
    // Deliberately NOT enabled on the course record here: enabling a language
    // with no translated content makes the export coverage gate 422-block every
    // build (see the `jobs.length === 0` branch in translateMissing). It becomes
    // a PENDING language — held by app.jsx so it survives navigation, and kept
    // out of everything that describes what the package will contain.
    setRemovedHere(prev => prev.filter(l => l !== code));
    onLanguageAdded?.(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}-alt${i > 2 ? i : ''}`; i += 1; }
    addLang(c, {
      name: clean,
      flag: match ? match.flag : '🌐',
      custom: true,
    });
  };
  const removeLang = (code) => {
    // Optimistic locally; the app's authoritative lists are updated only once
    // the server confirms, so a refusal needs no compensating rewrite.
    setRemovedHere(prev => (prev.includes(code) ? prev : [...prev, code]));
    setCustomMeta(m => { if (!m[code]) return m; const n = { ...m }; delete n[code]; return n; });
    if (working === code) setWorking(null);
    // Keep the course record in sync — otherwise a half-translated leftover
    // 422-blocks every export at the server's coverage gate (FE→ZIP rule).
    disableLanguageServer(window.dynamoCourseId, code).then(r => {
      if (r.ok) {
        onLanguageRemoved?.(code);
      } else if (r.refused) {
        // Server kept it (default language) — restore the rail entry.
        setRemovedHere(prev => prev.filter(l => l !== code));
        showToast(`${locName(code)} is the course's default language — it can't be removed.`);
      } else {
        // Not actually removed anywhere: put it back rather than show a rail
        // that disagrees with what will be exported.
        setRemovedHere(prev => prev.filter(l => l !== code));
        showToast(r.message
          ? `${locName(code)} could not be removed: ${r.message}`
          : `${locName(code)} could not be removed — the server couldn't be reached. It will still be exported.`);
      }
    });
  };

  const isTarget = !!working && working !== primary;
  // English (the primary) IS selectable now, and it shows exactly ONE panel:
  // Subtitles. Omar's reason, verbatim: "while for modules, the EN text can be
  // edited into the Course architecture areas, for the EN subtitles the only
  // place to check the EN subtitles would be into the 'Subtitles' section."
  // So every other tab would be a second, worse editor for text that already has
  // a home — hence one tab, not five, and no Translate button (there is nothing
  // to translate English INTO from here).
  const isSource = working === primary;
  const targets = langs.filter(l => l !== primary);

  // Everything the shared walk needs to assemble this course's translatable
  // content. `normalizeLayout` applies the same quiz-feedback migration the
  // export does, so no walker ever counts (or offers to translate) a key that
  // is about to be renamed on the way out.
  const locCtx = React.useMemo(() => ({
    course, drafts: layoutDrafts, settings,
    contentBase: window.layoutContentBase,
    normalizeLayout: window.migrateQuizFeedback,
    clone: locDeepClone,
  }), [course, layoutDrafts, settings]);

  // ── Real per-language coverage (drives the rail badge) ─────────────────────
  // One walk of the whole course, counting every field the Translate run would
  // touch. Same walk, same predicate, same emptiness rule — so the badge, the
  // panel counters and the button always agree.
  const coverage = React.useMemo(
    () => window.courseLocCoverage(locCtx, targets), [locCtx, targets.join(',')]);

  // ── Auto-translate the working target language ─────────────────────────
  const [translating, setTranslating] = React.useState(false);
  const [toast, setToast] = React.useState(null);
  // "Everything already has text" is the one press where the author expects
  // something to happen and nothing can: there is no empty field to fill. It is
  // not an error and must not be a dead end, so it becomes a question with the
  // action attached. { lang, count, justEnabled }.
  const [retranslateAsk, setRetranslateAsk] = React.useState(null);
  // The question names ONE language and offers to replace every field in it, so it
  // must not outlive the choice of language. The modal's overlay makes switching
  // hard but not impossible (the rail is still keyboard-reachable), and a dialog
  // that says "Italiano" while acting on Arabic is the worst kind of confirmation.
  React.useEffect(() => { setRetranslateAsk(null); }, [working]);
  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); }, []);

  // Adopt the server's enabled-language list after any successful enable call.
  const syncEnabledFromServer = React.useCallback((ensured) => {
    if (!ensured || !Array.isArray(ensured.enabledLangs) || !ensured.enabledLangs.length) return;
    // FILTER a response that raced a removal — don't discard it wholesale. Adopting
    // a removed language would put it back into the descriptor (and localStorage),
    // where the Build modal ticks it for a ZIP that won't contain it; but dropping
    // the whole response would also strand a language in the SAME response that was
    // legitimately enabled, leaving it pending forever. Filtering at the source is
    // what makes the correction survive a remount, since the rail re-derives from
    // the descriptor.
    const list = ensured.enabledLangs.filter(l => !removedHereRef.current.includes(l));
    if (!list.length) return;
    // The server row is authoritative about what is ENABLED; hand it straight to
    // the app, which graduates those languages out of `pendingLanguages`.
    // Deliberately does NOT touch `removedHere`: clearing it here let an enable
    // response that raced a removal resurrect a language the server had already
    // dropped, in the rail AND in the persisted descriptor.
    onLanguagesEnabled?.(list);
  }, [onLanguagesEnabled]);

  // 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). By default 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 by a plain press.
  //
  // `opts.overwrite` re-translates fields that ALREADY have target text. It is
  // never the default and never silent — it arrives only from the explicit
  // confirmation below, after the author has been told how many fields it
  // replaces and that hand-corrected text is among them.
  const translateMissing = React.useCallback(async (targetLang, opts) => {
    if (!targetLang || targetLang === primary || translating) return;
    const overwrite = !!(opts && opts.overwrite);
    const jobs = [];               // { ref, source }

    // ONE walk decides what a course has to translate — every layout's
    // assembled content, each module's title + one-line summary, each
    // module-group title, the gaming-flow labels when the flow is on, and each
    // ENABLED assessment phase. It lives in loc-translate-core.js (React-free,
    // unit-run in node by the test loop) and the rail's per-language status
    // walks the very same thing, which is what stops a fully-translated
    // language from being badged "Not started" (Omar, 2026-07-28).
    //
    // The walk detects `{ en }` objects by name or shape (catching
    // dynamic-keyed tooltipValues) AND heals plain-string values of known
    // localizable fields by wrapping them to `{ en }` in the clone, queueing
    // them like any other job. Returning true records the node as touched, so
    // the collector below says exactly what to persist and where.
    const touched = window.walkCourseTranslatables(locCtx, (node) => {
      const before = jobs.length;
      window.collectLocTranslateJobs(node, targetLang, jobs, opts);
      return jobs.length > before;
    });
    const touchedLayouts = touched.layouts;
    // Snapshot each touched layout BEFORE the fetch. The walk has already healed
    // any plain-string field to `{ en }`; the translations are written into these
    // same objects after the request returns, so the difference between this
    // snapshot and the post-fetch object is EXACTLY what the run produced — which is
    // all that may be written back.
    const preTranslate = {};
    Object.entries(touchedLayouts).forEach(([id, content]) => {
      preTranslate[id] = locDeepClone(content);
    });
    const touchedModules = touched.modules;
    const touchedGroups = touched.groups;
    const touchedDfti = touched.dfti;
    const touchedAssessments = touched.assessments;
    const touchedRoles = touched.roles;
    const touchedWelcome = touched.welcome;
    const touchedNews = touched.news;

    if (jobs.length === 0) {
      // Zero jobs means either "everything already translated" OR "there is
      // nothing to translate yet" (a from-zero course whose fields are still
      // blank). Only the former may enable the language — enabling on a blank
      // course makes the coverage gate 422-block every export until the
      // language is removed again. `total` comes from the SAME walk, so the two
      // cases can no longer disagree with the rail badge.
      const tally = window.courseLocCoverage(locCtx, [targetLang])[targetLang];
      if (!tally || !tally.total) {
        showToast('Nothing to translate yet — write your course content first.');
        return;
      }
      // Content already translated — still make sure the language is switched
      // on server-side (idempotent), so a re-click can never leave a fully
      // translated course exporting single-language.
      if (removedHereRef.current.includes(targetLang)) {
        // Removed while this run was in flight — enabling now would silently put
        // it back on the course record and into every future export.
        showToast(`${locName(targetLang)} was removed while the translation was running — it has not been enabled.`);
        return;
      }
      // The enable call is idempotent and is NOT what this press is about. It
      // used to sit unguarded — outside the try/catch below — so if it rejected,
      // the whole press produced nothing at all: no toast, no dialog. That is
      // the exact symptom Omar reported ("I click and nothing happens") arriving
      // from a different cause, and it would have been indistinguishable to him.
      // A failure to switch a language on must not swallow the answer about his
      // text.
      //
      // And it must show as BUSY while it runs. The button's spinner and its
      // `disabled` are keyed to `translating`, which used to be set only after this
      // whole branch returned — so on a fully-translated course (Omar's course by
      // definition: "all 102 Italiano fields already have text") the press made a
      // network call with nothing on screen changing and the button still clickable.
      // That is the same "I click and nothing happens" this branch exists to end,
      // and a second click fired a second enable call.
      let ensured = null;
      setTranslating(true);
      try {
        ensured = await ensureLanguageEnabled(window.dynamoCourseId, targetLang);
        // Push the server's authoritative list up to the app, so leaving this
        // surface and coming back doesn't re-initialise the rail from a course
        // object that still thinks the target language isn't enabled.
        syncEnabledFromServer(ensured);
      } catch (err) {
        showToast(`${locName(targetLang)} could not be switched on for export`
          + `${err && err.message ? ` — ${err.message}` : ''}. Your text is unchanged.`);
      } finally {
        setTranslating(false);
      }
      // "Already up to date" was a claim this code cannot make. Zero jobs means
      // every target already HAS text — not that the text still matches the
      // English. `collectLocTranslateJobs` queues a field only when the target is
      // empty (loc-translate-core.js: `!(have != null && String(have).trim())`),
      // deliberately, so a re-press can never destroy a translation someone
      // corrected by hand.
      //
      // Omar, 2026-07-30: he rewrote an English start-screen body, pressed
      // Translate to Italiano, and was told everything was current while the
      // Italian still translated the PREVIOUS English — carrying, among other
      // things, the two dead answer-count tokens. The button behaved exactly as
      // designed; the message described a different program.
      //
      // So say what happened — and OFFER THE ACTION rather than describing a
      // chore. The first version of this message told him to go and clear the
      // field by hand on another tab, which for a 102-field course is not a
      // path anyone would take (Omar, 2026-07-31: "the text on the Italian
      // field remain the same"). The skip rule itself stays: fill-empty is
      // still what the button does, and replacing existing text now needs a
      // second, deliberate yes.
      const already = (tally && tally.total) || 0;
      if (!overwrite) {
        setRetranslateAsk({ lang: targetLang, count: already,
          justEnabled: !!(ensured && ensured.added) });
      }
      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] = safeTranslated(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) {
        // Per-FIELD merge, through a function patch, so a run that took seconds
        // cannot revert an edit made while it was in flight. What used to happen
        // here was `onUpdateDrafts(id, content)` with the whole pre-await object:
        // the author's keystroke was overwritten the moment the translation landed
        // (`feedback_derived_state_read_before_merge`).
        Object.entries(touchedLayouts).forEach(([id, content]) => {
          const slots = translatedSlots(preTranslate[id], content, targetLang);
          if (!slots.length) return;
          onUpdateDrafts(id, (prev) => {
            const draft = prev || {};
            const type = draft.type || content.type;
            let next = assembleLayoutContent(type, draft, course.contentMode);
            slots.forEach((s) => { next = setLocAtPath(next, s.path, targetLang, s.value); });
            // Same rule as a keystroke (writeLocBranch): only the branches this run
            // actually changed leave here.
            const out = { ...draft, type };
            new Set(slots.map((s) => s.path[0])).forEach((k) => { out[k] = next[k]; });
            return out;
          });
        });
      }
      if (onTranslateModuleTitle) {
        // Each entry is a patch object ({ title?, summary? }) — app.jsx
        // shallow-merges it into moduleOverrides[mid].
        Object.entries(touchedModules).forEach(([mid, patch]) => onTranslateModuleTitle(mid, patch));
      }
      if (onTranslateGroupTitle) {
        Object.entries(touchedGroups).forEach(([gid, title]) => onTranslateGroupTitle(gid, title));
      }
      if (touchedDfti && onUpdateSettings) {
        onUpdateSettings({ dftiFlow: { ...settings.dftiFlow, ...touchedDfti } });
      }
      if (touchedAssessments && onUpdateSettings) {
        onUpdateSettings({ assessments: { ...settings.assessments, ...touchedAssessments } });
      }
      // Role names. `touchedRoles` is { [code]: LocalizedString }, so the whole
      // array is rebuilt with the translated labels merged in by code — never by
      // index, which would reassign names if a role were added or deleted while
      // the run was in flight.
      if (touchedRoles && onUpdateSettings) {
        onUpdateSettings({
          roles: (settings.roles || []).map((r) =>
            touchedRoles[r.code] ? { ...r, label: touchedRoles[r.code] } : r),
        });
      }
      // The Welcome fields — the course title and the cover sentence. Written
      // back to the SAME two paths the Welcome panel edits, so a course-wide
      // Translate and a keystroke land in the same slot
      // (`feedback_one_door_per_field_reuse_existing_style`).
      if (touchedWelcome && onUpdateSettings) {
        const patch = {};
        if (touchedWelcome.courseTitle) patch.courseTitle = touchedWelcome.courseTitle;
        if (touchedWelcome.coverSentence) {
          patch.cover = { ...settings.cover, sentence: touchedWelcome.coverSentence };
        }
        if (Object.keys(patch).length) onUpdateSettings(patch);
      }
      // The News screen's two fields, merged into the slice the panel writes.
      if (touchedNews && onUpdateSettings) {
        onUpdateSettings({ news: { ...settings.news, ...touchedNews } });
      }
      // Translation landed — enable the language on the course record so the
      // export emits <languages enabled="true"> and the Player shows its
      // language selector. Idempotent; no more manual enabled_langs SQL.
      if (removedHereRef.current.includes(targetLang)) {
        // Removed while this run was in flight — enabling now would silently put
        // it back on the course record and into every future export.
        showToast(`${locName(targetLang)} was removed while the translation was running — it has not been enabled.`);
        return;
      }
      const ensured = await ensureLanguageEnabled(window.dynamoCourseId, targetLang);
      // Push the server's authoritative list up to the app, so leaving this
      // surface and coming back doesn't re-initialise the rail from a course
      // object that still thinks the target language isn't enabled.
      syncEnabledFromServer(ensured);
      // "Filled" and "Replaced" are different events and the author has to be
      // able to tell them apart — one added text where there was none, the other
      // wrote over text that was already there.
      const verb = overwrite ? 'Replaced' : 'Filled';
      const what = `${jobs.length} field${jobs.length === 1 ? '' : 's'}`;
      showToast(ensured && ensured.added
        ? `${verb} ${what} in ${locName(targetLang)} — language enabled for export.`
        : `${verb} ${what} in ${locName(targetLang)}.`);
    } catch (err) {
      showToast(err && err.message ? err.message : 'Translation failed.');
    } finally {
      setTranslating(false);
    }
  }, [locCtx, onUpdateDrafts, onTranslateModuleTitle, onTranslateGroupTitle, settings, onUpdateSettings, primary, translating, showToast, syncEnabledFromServer]);

  // Translate ONE field on demand, replacing whatever the target holds. The
  // surgical counterpart to the course-wide button: the case that button cannot
  // serve is "I rewrote this one English sentence and its Italian is now a
  // translation of the old one", and replacing all 102 fields to fix one of them
  // is not a proportionate answer. Returns the translated string, or null having
  // already said why — the caller writes it through its own normal edit path, so
  // a retranslate persists exactly like a keystroke does.
  const translateText = React.useCallback(async (text, targetLang) => {
    const src = String(text == null ? '' : text);
    if (!src.trim()) { showToast('This field has no English text to translate.'); return null; }
    if (!targetLang || targetLang === primary) return null;
    try {
      const token = await window.dynamoGetAccessToken();
      // Same request shape as the course-wide run: one string instead of many, and
      // the raw stored value.
      //
      // What that means for a rich-text field, stated accurately because an earlier
      // version of this comment claimed the opposite: the gateway hard-codes
      // `format: "text"` (google-translate.ts) and its route accepts only
      // q/target/source, so tags in a source are translated AS PROSE — they are not
      // preserved. The reply is sanitised on the way in, which keeps junk styling
      // out of the package, but a field whose English carries deliberate markup
      // (a link, a bold run) is a known limitation of both this button and the
      // course-wide run, not something this path handles well.
      const res = await fetch(`${GATEWAY_BASE}/v1/translate`, {
        method: 'POST',
        headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
        body: JSON.stringify({ q: [src], target: targetLang, source: 'en' }),
      });
      if (res.status === 503) { showToast("Translation isn't configured yet."); return null; }
      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 null;
      }
      const data = await res.json();
      const out = data.translations || data.data || data.q || [];
      const t = out[0];
      if (t == null || !String(t).length) {
        showToast('The translation came back empty — the field was left as it was.');
        return null;
      }
      return safeTranslated(t);
    } catch (err) {
      showToast(err && err.message ? err.message : 'Translation failed.');
      return null;
    }
  }, [primary, showToast]);

  const TABS = [
    // FIRST, because it is the first thing a learner reads (Omar, 2026-08-14:
    // *"add for each new language … a new tab called Welcome"*). The tab order
    // is the course's own order.
    { id: 'welcome',     label: 'Welcome',     icon: 'Flag' },
    { 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: 'flex', flexDirection: 'column', height: '100%',
      background: 'var(--bg)', overflow: 'hidden' }}
      data-screen-label="Surface · Localisation">

      {/* The shared page header (2026-08-14). This surface had NO title bar at all —
          it opened straight onto the language rail — so it was the one screen that
          never said where you were. Above the rail rather than beside it, because
          the header names the SCREEN and the rail is one of its controls. */}
      <window.SurfaceHeader title="Localisation"
        description={<>Translate the course into every language it ships in. Pick a
          language on the left, then work through its tabs — a tab is done when its
          count reads full.</>} />

      <div style={{
        display: 'grid', gridTemplateColumns: '244px minmax(0, 1fr)',
        flex: 1, minHeight: 0, overflow: 'hidden',
      }}>

      <LocaleRail langs={langs} primary={primary} working={working}
        tab={tab} onSelect={setWorking} onAdd={addLang}
        onAddCustom={addCustomLang} onRemove={removeLang} isPending={isPending}
        coverage={coverage} busyLang={translating ? working : null} />

      <section style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }}>
        {isSource && (
          <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)' }}>
              <span style={{ display: 'inline-flex', alignItems: 'center', gap: 7,
                padding: '6px 13px', borderRadius: 'var(--radius)', fontSize: 12.5,
                fontWeight: 600, background: 'var(--surface)', color: 'var(--text)',
                boxShadow: '0 1px 2px rgba(0,0,0,.10)' }}>
                <I.Captions size={14} />Subtitles
              </span>
            </div>
            <span style={{ fontSize: 11.5, color: 'var(--text-muted)' }}>
              {locName(primary)} subtitles are generated and edited here. All other
              {' '}{locName(primary)} text is edited in <strong>Course architecture</strong>.
            </span>
            <div style={{ flex: 1 }} />
          </div>
        )}
        {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 }} />
            {/* NOT on the Subtitles tab. This run walks the course's own text
                fields; cue text lives inside a `.vtt` and its path is explicitly
                denied as a translate job (loc-translate-core.js LOC_MEDIA_FIELDS),
                so on that tab the button reported success while every field the
                author was looking at stayed empty — Omar, 2026-07-30. The Subtitles
                panel carries its own per-video Translate, which can actually do it.
                A control that cannot affect what is on screen must not be offered
                there (`feedback_no_false_affordance_toggles`). */}
            {tab !== 'subtitles' && (
              <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',
          position: 'relative' }}>
          {/* A course-wide run can take seconds — a "Replace all 102" is two Google
              round-trips — and the author has no other signal down here that one is
              running. Informational only: `pointerEvents: 'none'`, nothing disabled.
              An earlier version of this blocked the whole panel to stop the run
              overwriting a live keystroke; the run now merges per FIELD instead
              (see translatedSlots), which is the actual fix, and blocking was never
              a complete one — the app's left navigation is outside this surface, so
              the author could always leave and type elsewhere. */}
          {translating && (
            <div aria-live="polite" style={{
              position: 'absolute', top: 10, left: 0, right: 0, zIndex: 20,
              display: 'flex', justifyContent: 'center', pointerEvents: 'none' }}>
              <div style={{ display: 'inline-flex', alignItems: 'center', gap: 9,
                padding: '8px 14px', background: 'var(--surface)',
                border: '1px solid var(--border)', borderRadius: 999,
                boxShadow: 'var(--shadow-xl)', fontSize: 12, fontWeight: 500 }}>
                <I.Loader size={13} className="spin" />
                Translating into {locName(working)} — you can keep working.
              </div>
            </div>
          )}
          {!working && (
            <SelectLanguagePrompt targets={targets} onSelect={setWorking} />
          )}
          {isTarget && tab === 'welcome'    && <WelcomePanel working={working} primary={primary}
            course={course} settings={settings} onUpdateSettings={onUpdateSettings}
            onTranslateText={translateText} />}
          {isTarget && tab === 'modules'    && <ModulesPanel course={course} working={working} primary={primary}
            layoutDrafts={layoutDrafts} onUpdateDrafts={onUpdateDrafts}
            onTranslateModuleTitle={onTranslateModuleTitle} onTranslateText={translateText} />}
          {isTarget && tab === 'roles'      && <RolesPanel roles={roles} working={working} primary={primary}
              settings={settings} onUpdateSettings={onUpdateSettings}
              onTranslateText={translateText} />}
          {/* The ONE panel that renders for both the source and a target
              language — the same component, told which side it is on. Splitting
              it in two would give the cue editor two homes. */}
          {(isSource || (isTarget && tab === 'subtitles')) && (
            <SubtitlesPanel course={course} working={working} primary={primary}
              layoutDrafts={layoutDrafts} onUpdateDrafts={onUpdateDrafts} />
          )}
          {isTarget && tab === 'assessment' && <AssessmentPanel working={working} primary={primary}
            course={course} settings={settings} onUpdateSettings={onUpdateSettings}
            onTranslateText={translateText} />}
          {isTarget && tab === 'quiz-gaming' && <QuizGamingPanel course={course}
            layoutDrafts={layoutDrafts} working={working} primary={primary}
            onUpdateDrafts={onUpdateDrafts} onTranslateText={translateText} />}
        </div>
      </section>
      </div>

      {/* The "nothing to fill" press, turned into a choice. Deliberately a modal
          and not a second button in the toolbar: replacing every translation in a
          language is not something to put one stray click away, and the count is
          the number that makes the consequence concrete. */}
      {retranslateAsk && window.ConfirmDialog && (
        <window.ConfirmDialog icon="Languages" tone="warning"
          title={`Every ${locName(retranslateAsk.lang)} field already has text`}
          body={`Translate only fills fields that are empty, so there was nothing to fill`
            + `${retranslateAsk.justEnabled
              ? ` — and ${locName(retranslateAsk.lang)} is now switched on for export`
              : ''}. `
            + `If you rewrote some of the English, the ${locName(retranslateAsk.lang)} beside it `
            + `still translates the older version. Replacing all ${retranslateAsk.count} `
            + `field${retranslateAsk.count === 1 ? '' : 's'} translates the English as it stands `
            + `now — including any ${locName(retranslateAsk.lang)} you corrected by hand, which `
            + `will be overwritten. Where a field shows a Retranslate button — on the Modules `
            + `and Quiz Gaming tabs — you can refresh just that one instead.`}
          confirmLabel={`Replace all ${retranslateAsk.count}`}
          cancelLabel="Leave them as they are"
          onCancel={() => setRetranslateAsk(null)}
          onConfirm={() => {
            const ask = retranslateAsk;
            setRetranslateAsk(null);
            translateMissing(ask.lang, { overwrite: true });
          }} />
      )}

      {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, isPending, coverage, busyLang }) {
  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 selectable, and selecting it opens the Subtitles
          // panel on the English track. It has no delete (a course cannot lose
          // its source language) and no coverage dot (nothing translates INTO
          // English), so it keeps its own row shape rather than joining the
          // target list.
          //
          // It was a plain non-clickable <div> until 2026-07-30, which made
          // Omar's S3 requirement — "allow the admin to select the English flag,
          // and once it is selected it should show only the English subtitles for
          // the selected video" — structurally unreachable: English could not be
          // selected, and every panel was gated on the language being a TARGET,
          // so the pane would have rendered blank even if it could.
          if (isPrimary) {
            // RESTING APPEARANCE IS IDENTICAL TO A TARGET ROW, deliberately
            // (Omar, 2026-07-31): "remove the selection bg as it seems clicked
            // and since we need the client to understand that they can click, it
            // is better for the EN to have at the beginning the same status as
            // the other languages."
            //
            // It previously carried `--surface-inset` + a visible border at rest,
            // which reads as ALREADY SELECTED — so the one row a new author most
            // needs to discover is clickable was the one row that looked spent.
            // The affordance is the hover fill, which is what every target row
            // uses; the transparent border keeps the row from shifting by 2px
            // when it becomes the working language. `cursor: 'default'` matches
            // its siblings and the app's house style (148 sites to 6).
            //
            // The "Source language · subtitles" caption is gone with it: the
            // green Primary pill already says what this row is, and the pane it
            // opens says what it does.
            return (
              <div key={l}
                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"
                  title={`Open the ${locName(l)} subtitle editor`}
                  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>
                      <span className="pill accepted" style={{ fontSize: 9.5 }}>Primary</span>
                    </div>
                  </div>
                </button>
              </div>
            );
          }

          const cov = (coverage || {})[l];
          const st = railProgress(cov);
          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 }}
                    title={cov && cov.total
                      ? `${cov.filled} of ${cov.total} text fields translated into ${locName(l)}`
                      : 'This course has no English text to translate yet'}>
                    <LocStatusDot status={st} />{LOC_STATUS_LABEL[st]}
                    {st === 'partial' && (
                      <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10.5,
                        color: 'var(--text-faint)' }}>{cov.filled}/{cov.total}</span>
                    )}
                  </div>
                  {isPending?.(l) && (
                    <div style={{ fontSize: 10.5, color: 'var(--text-faint)', marginTop: 2 }}
                      title="Run a translation to enable this language for export.">
                      not in the package yet
                    </div>
                  )}
                </div>
                {!confirming && l !== busyLang && (
                  <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',
  empty: 'Nothing to translate yet' };
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 · 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>
  );
}

// HTML → the words. Used for READ-ONLY display of a rich-text field's source, so
// a translator reads the sentence and not the browser's markup.
//
// Tag-stripping by regex is unsafe as a security measure and is not used as one:
// the value lands in a React text node, which escapes everything, and the real
// defence is `rich-text-sanitize.js` on the write path. This is purely about
// legibility. Entities are decoded after stripping so `&amp;` reads as `&`, and a
// block boundary becomes a space rather than running two sentences together.
function plainText(html) {
  if (html == null) return '';
  const s = String(html);
  if (s.indexOf('<') === -1 && s.indexOf('&') === -1) return s;   // fast path
  return s
    .replace(/<\s*(br|\/p|\/div|\/li|\/h[1-6])\s*\/?\s*>/gi, ' ')
    .replace(/<[^>]*>/g, '')
    .replace(/&nbsp;/gi, ' ')
    .replace(/&lt;/gi, '<').replace(/&gt;/gi, '>')
    .replace(/&quot;/gi, '"').replace(/&#39;/gi, "'")
    .replace(/&amp;/gi, '&')          // last, or it double-decodes
    .replace(/\s+/g, ' ')
    .trim();
}

// `onRetranslate` is OPTIONAL and its absence is meaningful: the button only
// appears where an edit actually persists. On a preview panel it would promise a
// translation the surface cannot keep (`feedback_no_false_affordance_toggles`),
// so RolesPanel deliberately passes nothing.
// Machine-translation output lands in storage and from there in the exported
// package, so it has to clear the same bar as text an author types. It did not:
// `sanitizeRichText` was called only from the editors, never on this write path, so
// a reply carrying `<span style="color: var(--text)">` — or an `<img onerror>` —
// went straight into the draft. PR #104 exists precisely to keep that out of a
// SCORM package, and a translator hands back whatever it was given
// (`feedback_apply_a_security_finding_everywhere_at_once`).
//
// Only when the value actually carries MARKUP. `sanitizeRichText` treats a string
// containing `&` as HTML and would escape "Sicurezza & privacy" to
// "Sicurezza &amp; privacy", so running it over every plain sentence would corrupt
// ordinary translations.
const safeTranslated = (s) => {
  const str = String(s);
  return (/<[a-z!/]/i.test(str) && window.sanitizeRichText)
    ? window.sanitizeRichText(str) : str;
};

// ── Shared · the per-field Retranslate control ────────────────────────────────
// ONE definition, used by every panel that offers it (Modules, Roles, Quiz Gaming,
// Assessment, Subtitles). It began life inline inside `PairField`, and when Omar
// asked for it on three more tabs on 2026-08-12 — "The 'Retranslate' button should
// be a feature to apply across all the other tabs such Subtitles, Assessment, Quiz
// Gaming" — copying those fourteen lines four times is exactly how four buttons
// quietly stop behaving alike. The same reasoning that produced the shared
// `DraftSaveButton` in components.jsx a day earlier
// (`feedback_a_test_double_that_reimplements_drifts`).
//
// It owns its own in-flight state, so a slow translation disables only the field
// being refreshed and every other row stays usable.
function RetranslateButton({ onRetranslate, compact }) {
  const [retranslating, setRetranslating] = React.useState(false);
  const run = async () => {
    if (retranslating || !onRetranslate) return;
    setRetranslating(true);
    try { await onRetranslate(); } finally { setRetranslating(false); }
  };
  return (
    <button className="btn sm ghost" onClick={run} disabled={retranslating}
      title="Translate this field's English again and replace what is here"
      style={{ height: compact ? 20 : 22, padding: '0 8px', fontSize: compact ? 10.5 : 11 }}>
      {retranslating
        ? <><I.Loader size={11} className="spin" />Translating…</>
        : <><I.RefreshCw size={11} />Retranslate</>}
    </button>
  );
}

function PairField({ label, source, target, badge, onChange, onRetranslate }) {
  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>
        {/* Rich-text fields (a gaming start/win/fail body, a column of prose)
            store HTML. Rendered as a text node it arrives as literal markup:
            Omar, 2026-07-30, "It contains html tag that are not known to the
            admin. It should only present the text."
            The sanitizer now drops the no-op declarations that produced most of
            it, but that only helps text SAVED from now on — every course already
            stored carries the spans, so the read side has to cope too.
            SOURCE ONLY. The target stays raw on purpose: this same PairField
            backs the Modules panel, where an edit PERSISTS, and showing a
            stripped value in an editable box means the first keystroke writes
            plain text over the author's real markup. A read-only column cannot
            lose anything. */}
        <p style={{ margin: 0, fontSize: 13, color: 'var(--text)', lineHeight: 1.5 }}>{plainText(locText(source))}</p>
      </div>
      {/* TARGET — editable, white bg + bordered field */}
      <div style={{ padding: '12px 14px', background: 'var(--surface)' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, minHeight: 20 }}>
          <FieldLabel inline>{label}</FieldLabel>
          <div style={{ flex: 1 }} />
          {onRetranslate && <RetranslateButton onRetranslate={onRetranslate} />}
        </div>
        <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>
  );
}

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, onTranslateText }) {
  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 — per-mode base defaults overlaid with
  // the saved draft (same as surface-export's per-layout build), then the same
  // quiz-feedback migration the export applies, so this panel never offers a
  // field that is about to be renamed on the way out. Recomputes when the draft
  // changes, so author edits round-trip through layoutDrafts.
  const content = React.useMemo(() => {
    if (!layout) return {};
    const draft = drafts[layout.id] || {};
    return assembleLayoutContent(draft.type || layout.type, draft, course.contentMode);
  }, [layout, drafts, course.contentMode]);

  // 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.
  //
  // A FUNCTION patch, not an object. `app.jsx`'s `updateDraft` hands the callback
  // the CURRENT draft for this layout — it accepts a function for exactly this
  // reason, added 2026-07-30, and its own header names the hazard: "a caller that
  // builds the whole content from a `layoutDrafts` prop captured before an await
  // ships a patch derived from stale state, silently reverting anything the author
  // changed on that layout meanwhile".
  //
  // The first attempt at this read the content through a REF instead. That kept the
  // content fresh but left `layout.id` captured in the click-time closure, so a
  // Retranslate resolving after a layout switch wrote the NEWLY selected layout's
  // whole content — `type` included — into the PREVIOUS layout's draft, converting
  // a quiz_gaming layout into a two_columns_text one. Freshness and identity have
  // to travel together, and the patch function is what makes that automatic: the id
  // is named in the call, the content comes from the reducer
  // (`feedback_a_fix_can_trade_one_loss_for_a_worse_one`).
  const editField = (path, str) => {
    if (!layout || !onUpdateDrafts) return;
    const declaredType = layout.type;
    onUpdateDrafts(layout.id, (prev) => writeLocBranch(
      prev, declaredType, course.contentMode, path, working, str));
  };
  // Module heading + one-line summary are top-level course LocalizedStrings,
  // persisted separately as a PATCH ({ title } / { summary }) — app.jsx spreads
  // the second argument into moduleOverrides[id], so handing it a bare
  // LocalizedString wrote language keys onto the module and threw the edit away
  // (fixed 2026-07-28: typing a module heading here did nothing).
  const editModule = (key, str) => {
    if (!module.id || !onTranslateModuleTitle) return;
    onTranslateModuleTitle(module.id, { [key]: locSet(module[key], working, str) });
  };

  // ── What is shown, and what is counted ─────────────────────────────────────
  // A field is SHOWN when it has source text OR an existing translation, and
  // COUNTED only when it has source text.
  //
  // Both halves are deliberate (Omar, 2026-07-28: "make sure to visualise the
  // fields that have been activated in the layouts"). An unauthored field is
  // not a translation task — there is nothing to translate, the export prunes
  // it, and listing it left four permanently unfillable rows in a 19-row count
  // that could never read better than 15/19. A field with a translation but no
  // source is still shown, badged: it is authored text, and hiding it would be
  // the one thing worse than showing an empty row.
  const buildRow = (field, source, target) => ({ field, source, target });
  const moduleRows = ['title', 'summary'].map(key => buildRow(
    { path: ['__module__', key], key },
    String(locRawSlot(module[key], primary) || (typeof module[key] === 'string' ? module[key] : '')),
    String(locRawSlot(module[key], working)),
  )).map(r => ({ ...r, label: r.field.key === 'title' ? 'Module heading' : 'Module summary' }))
    .filter(r => r.source || r.target);
  const layoutRows = fields.map(f => buildRow(f,
    String(locSourceOf(f.value, primary)), String(locRawSlot(f.value, working))))
    .filter(r => r.source || r.target);

  // Deliberately NOT memoised: it is derived from `moduleRows` / `layoutRows`,
  // which are themselves recomputed every render, so a dependency array here
  // could only ever go stale. The whole grouping is a few dozen array ops.
  // ── The module is a LEVEL, not one of this screen's bands ──────────────────
  // It used to be the first entry in `groups`, rendered by the same component with
  // the same chrome, inside the same list, in the same 10px gap as "Title block".
  // In a dense form UI, sameness of frame is the strongest hierarchy statement
  // available, and containment is read before words are — so it said "sibling",
  // and Omar read it exactly that way: "it seems that every layout has a module."
  // It is now hoisted out of `groups` entirely and rendered above a labelled level
  // break, so containment finally matches ownership.
  const groups = groupLocFields(layoutRows, content.type)
    .map(g => (g.key === 'screen' ? { ...g, alwaysBand: true } : g));

  const tally = (rows) => rows.reduce((acc, r) => ({
    total: acc.total + (r.source ? 1 : 0),
    filled: acc.filled + (r.source && r.target.trim() ? 1 : 0),
  }), { total: 0, filled: 0 });
  // THIS SCREEN's fields only. The module's two shared fields used to be counted
  // into every screen's ratio, so the number agreed with the wrong model — the
  // same two fields inflating all four screens of a module is precisely the
  // "every layout has a module" reading, corroborated numerically.
  const overall = tally(layoutRows);
  const moduleTally = tally(moduleRows);

  // ── Everything starts CLOSED (Omar, 2026-08-10) ────────────────────────────
  // "all the sections should be collapsed by default, including the Module. This
  // way the Admin can decide which layout to open."
  //
  // Held as EXPANDED rather than COLLAPSED so the default needs no knowledge of
  // `groups`: an empty set is all-closed whatever the screen contains, where the
  // old inverted set had to be recomputed against the group list to mean the same
  // thing.
  const [expanded, setExpanded] = React.useState(() => new Set());
  React.useEffect(() => { setExpanded(new Set()); }, [sel, working]);
  const toggle = (k) => setExpanded(s => {
    const n = new Set(s); n.has(k) ? n.delete(k) : n.add(k); return n; });
  const allOpen = groups.length > 0 && expanded.size === groups.length;
  const setAll = () => setExpanded(allOpen ? new Set() : new Set(groups.map(g => g.key)));

  // The module card keeps its own state, keyed to the MODULE — so switching
  // screens within a module leaves it exactly as it was. That persistence is the
  // strongest available proof of identity: open it on L1, switch to L3, and the
  // same words are still there in the same place. One object, demonstrated
  // instead of asserted. Switching to a different module is a different object,
  // so it closes.
  const [modOpen, setModOpen] = React.useState(false);
  React.useEffect(() => { setModOpen(false); }, [module.id, working]);
  const screenCount = (module.layouts || []).length;
  // The module's own heading, as its identity line.
  const moduleIdentity = moduleRows.length
    ? trunc(stripTagsForLabel(moduleRows.find(r => r.source)?.source || ''), 64)
    : '';

  // ONE PairField call, with the write path chosen first — a module heading is a
  // course-level PATCH, a layout field is a draft write. Two near-identical calls
  // were two places to remember a new prop, and the Retranslate button would have
  // reached only whichever one was edited (`feedback_fix_must_be_reachable_on_the_users_path`).
  const rowFor = (r) => {
    const apply = r.field.path[0] === '__module__'
      ? (v) => editModule(r.field.key, v)
      : (v) => editField(r.field.path, v);
    // The selected layout id is part of the key. Without it two layouts of the same
    // type produce identical path-derived keys, so React reuses the row instance —
    // and with it a Retranslate's `retranslating` flag, leaving a "Translating…"
    // spinner attached to a different layout's field after a switch.
    return <PairField key={`${layout ? layout.id : '-'}:${r.field.path.join('.')}`} label={r.label}
      source={r.source} target={r.target} badge={locSourceBadge(r)}
      onChange={apply}
      // No English, nothing to translate: the button is withheld rather than
      // shown and then failing.
      onRetranslate={r.source && onTranslateText
        ? async () => { const t = await onTranslateText(r.source, working); if (t != null) apply(t); }
        : null} />;
  };

  return (
    <>
      <div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 24px 0' }}>
        <ItemNav options={options} value={sel} onChange={setSel} />
        <div style={{ flex: 1 }} />
        {/* Every visible target value, so a keystroke anywhere invalidates a
            previous "Saved to the server". */}
        <LocSaveToServer dirtySignal={moduleRows.concat(layoutRows)
          .map(r => r.target).join('\u0000')} />
        <span style={{ width: 1, height: 22, background: 'var(--border)' }} />
        <span title={`${overall.filled} of ${overall.total} fields on this screen with English text have a ${locName(working)} translation. The module's shared text is counted separately, above.`}
          style={{ fontSize: 11.5, color: 'var(--text-muted)', fontFamily: 'var(--font-mono)',
            whiteSpace: 'nowrap' }}>{overall.filled} / {overall.total} on this screen</span>
      </div>
      <PanelBody>
        <PairHeaders primary={primary} working={working} />

        {/* ── The module level ─────────────────────────────────────────────────
            Above the break, in a frame nothing else on the panel wears: a 3px
            left stripe, a heavier outline, a neutral outlined icon, and a chip
            that counts the screens it reaches. The note stays, once, as
            confirmation — never as the thing carrying the meaning. */}
        {moduleRows.length > 0 && (
          <LocGroupSection
            domKey={`module-${module.id}`}
            level="shared" headingLevel={3} icon="Layers"
            label={module.id ? `Module ${module.id}` : 'Module'}
            chip={
              <span className="pill" style={{ fontSize: 9.5, height: 18, flexShrink: 0,
                display: 'inline-flex', alignItems: 'center', gap: 3 }}>
                <I.Link2 size={9} aria-hidden="true" />
                Shared · {screenCount} screen{screenCount === 1 ? '' : 's'}
              </span>
            }
            sub={moduleIdentity}
            note={`Edited once — every screen in ${module.id || 'this module'} shows it.`}
            title={`The module heading and summary. The same text on all ${screenCount} screen${screenCount === 1 ? '' : 's'} of ${module.id || 'this module'} — editing it here changes every one of them.`}
            status={<LocBandStatus filled={moduleTally.filled} total={moduleTally.total} />}
            count={moduleTally.total ? `${moduleTally.filled}/${moduleTally.total}` : null}
            open={modOpen} onToggle={() => setModOpen(o => !o)}>
            {moduleRows.map(rowFor)}
          </LocGroupSection>
        )}

        {/* The level break. Expand/Collapse all sits INSIDE it, because the
            control's position is itself a statement about its scope: it moves the
            screen's bands and never the module card. */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 4,
          padding: '2px 0' }}>
          <span style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.06em',
            textTransform: 'uppercase', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
            This screen{layout ? ` · ${module.id}.L${layout.n}` : ''}
          </span>
          <span style={{ flex: 1, height: 1, background: 'var(--border)' }} />
          {groups.length > 1 && (
            <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.length === 0 ? (
          <EmptyNote>
            This screen has no English text yet. Write it in
            the <strong>Course architecture</strong> editor first — there is nothing to
            translate until then.
          </EmptyNote>
        ) : groups.map(g => {
          // A band around a single row would only repeat the row's own label.
          if (g.rows.length === 1 && !g.alwaysBand) return rowFor(g.rows[0]);
          const t = tally(g.rows);
          return (
            <LocGroupSection key={g.key} domKey={g.key} headingLevel={4}
              icon={g.icon} label={g.label} sub={g.sub} note={g.note}
              status={<LocBandStatus filled={t.filled} total={t.total} />}
              // No `${g.rows.length}` fallback: an unlabelled "2" meaning "2 rows"
              // is indistinguishable from a count of translations, which is worse
              // than showing no number. `LocBandStatus` covers that state in words.
              count={t.total ? `${t.filled}/${t.total}` : null}
              title={`${t.filled} of ${t.total} fields with English text have a ${locName(working)} translation`}
              open={expanded.has(g.key)} onToggle={() => toggle(g.key)}>
              {g.rows.map(rowFor)}
            </LocGroupSection>
          );
        })}
      </PanelBody>
    </>
  );
}

// A row carrying a translation but no source text: authored content whose
// English was cleared (or a field the editor seeds but never surfaces). Shown
// rather than hidden — with a badge, because there is nothing to translate
// from and it will not reach the package while the source is empty.
const locSourceBadge = (r) => (!r.source && r.target
  ? <span className="pill" style={{ fontSize: 9 }}
      title="This field has no English text, so it cannot be translated and will not be exported. Write the English in the Course architecture editor, or clear this translation.">
      No English source</span>
  : null);

// Server-side save for the Languages screen. Every edit here writes straight
// through to the local draft, but the debounced autosave only reaches
// IndexedDB — i.e. this browser. Until 2026-07-28 this surface said "Saved
// automatically" and had no server path at all, so a whole course could be
// translated and still be one cleared cache away from gone (the same reasoning
// as AssessmentSaveButton in surface-assessments.jsx).
// `dirtySignal` must change on every edit the panel can make. Without it the
// pill stayed on "Saved to the server" while the author kept typing, asserting
// persistence that had not happened — the same false claim that was fixed for
// the layout editor's pill on 2026-07-27 (`markLayoutDirty` in app.jsx), and
// reintroduced here until the 2026-07-28 review caught it.
// This WAS the reference implementation — the best of the two that existed — and
// is now a thin wrapper over the shared one extracted from it (`components.jsx`
// `DraftSaveButton`). Its behaviour is unchanged, including the `dirtySignal`
// reset and the honest "Saved on this device" idle label, both of which the
// shared component inherited verbatim.
function LocSaveToServer({ dirtySignal }) {
  return <window.DraftSaveButton dirtySignal={dirtySignal}
    title="Write these translations to the server, so they survive a cleared browser and reach the package" />;
}

// The Subtitles panel carries NO saved-state text.
//
// It briefly had a "Subtitles and the course are saved together" hint, added to
// explain why the two save buttons had become one. Omar removed it the same day:
// "the admin do not need to know what's happening in background." He is right —
// the hint existed to describe an implementation detail, and the fix for a
// confusing pair of buttons is one button, not a caption explaining the pair.
//
// Every other Localisation tab still shows `LocSaveToServer`, because there the
// author really does have to press Save: those tabs edit fields that live in the
// course. Subtitles is the one tab where saving happens as part of the action, so
// there is nothing to say and nothing to press.

// ── Panel · Roles ─────────────────────────────────────────────────────────────
// `role.label` is a LocalizedString since Phase 2b, so it must be read per
// language. Rendering the object itself throws "Objects are not valid as a React
// child" and blanks this whole screen — there is no error boundary anywhere in
// this app (`feedback_localizedstring_bound_to_plain_widget`).
function roleName(role, lang) {
  const l = role && role.label;
  if (!l || typeof l !== 'object') return '';
  return l[lang] || '';
}

// PERSISTS as of 2026-08-09. It used to keep every typed name in local
// `useTargets` state and show a `preview` badge, so the panel the Roles surface
// points authors at could not actually translate anything — while the export gate
// refused the build over exactly those missing names. A gate whose remedy does not
// exist is worse than no gate: Omar hit it with three blockers and nowhere to go.
//
// ── REBUILT 2026-08-12 in the Modules tab's shape, on Omar's diagnosis ────────
// Verbatim: "the best approach is the one within the 'Modules' tab. It provides
// only 'Translate to Italiano' button, there is a Save button and there are also
// the 'Retranslate' button for each field."
//
// Three defects were removed, and all three came from this panel keeping its own
// copy of state and its own controls:
//
//  1. THE STALE RENDER. The target boxes read a local `useTargets` mirror seeded
//     on `[working, list.length]`. The header's "Translate to Italiano" writes the
//     translations into `courseSettings.roles` — which changes neither dependency —
//     so the mirror never re-seeded and the screen kept showing empty boxes.
//     Leaving the tab and returning REMOUNTED the panel, which re-seeded it, which
//     is exactly why the translation appeared only then (Omar: "if I exit the Roles
//     tab and then I enter again, I can see the translation"). The boxes now read
//     `roles` directly, so there is no copy that can be stale — the same reason
//     ModulesPanel reads its content from the draft rather than mirroring it.
//
//  2. THE SECOND, FAKE TRANSLATE BUTTON. `LocaleToolbar` carried "Run
//     localisation" with a scope menu, and its `useRunState` was a 1.5-second
//     `setTimeout` that translated NOTHING. Omar: "When I click 'Run localisation'
//     and select 'Localise all roles name' nothing is displayed on the role field."
//     It is gone rather than wired, because the header's Translate already does
//     the real thing for every tab (`feedback_no_false_affordance_toggles`).
//
//  3. THE AMBIGUOUS SAVE. With two translate controls, neither obviously owned
//     the Save — "it is not clear to me to which translate button is connected the
//     Save button". One translate control and one `LocSaveToServer` — the shared
//     door every other tab uses — leaves nothing to guess.
function RolesPanel({ roles, working, primary, settings, onUpdateSettings, onTranslateText }) {
  const list = roles || [];

  // Write one role's name in the working language. Merged by CODE and only into
  // `label[working]`, so the source language and every other translation survive.
  //
  // Reads `settings.roles` rather than the `roles` prop for the merge base: they
  // are the same array (`app.jsx` passes `courseSettings.roles` to both), and the
  // settings slice is the one being written back.
  const persist = (code, str) => {
    if (!onUpdateSettings) return;
    onUpdateSettings({
      roles: (settings && settings.roles ? settings.roles : []).map((r) =>
        r.code === code
          ? { ...r, label: { ...(r.label && typeof r.label === 'object' ? r.label : {}), [working]: str } }
          : r),
    });
  };

  // Straight off the prop — no mirror, so a course-wide Translate shows up here
  // the moment it lands. `roleName` reads the RAW slot for the language asked
  // for, with no source-language fallback, so an untranslated name reads as empty
  // rather than as the English repeated back.
  const rows = list.map(r => ({
    code: r.code,
    source: roleName(r, primary),
    target: roleName(r, working),
  }));
  // Counted on rows that HAVE a source, matching every other panel's tally: a
  // role with no name in the source language is not a translation task.
  const total = rows.filter(r => r.source).length;
  const filled = rows.filter(r => r.source && r.target.trim()).length;

  return (
    <>
      <div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 24px 0' }}>
        <span style={{ fontSize: 12.5, color: 'var(--text-muted)' }}>
          {list.length} role name{list.length === 1 ? '' : 's'}
        </span>
        <div style={{ flex: 1 }} />
        {/* Every visible target value, so a keystroke anywhere invalidates a
            previous "Saved to the server". */}
        <LocSaveToServer dirtySignal={rows.map(r => r.target).join(' ')} />
        <span style={{ width: 1, height: 22, background: 'var(--border)' }} />
        <span title={`${filled} of ${total} role names with ${locName(primary)} text have a ${locName(working)} translation`}
          style={{ fontSize: 11.5, color: 'var(--text-muted)', fontFamily: 'var(--font-mono)',
            whiteSpace: 'nowrap' }}>{filled} / {total} translated</span>
      </div>
      <PanelBody>
        {list.length === 0 ? (
          <EmptyNote>No roles yet. Add roles on the Roles surface first.</EmptyNote>
        ) : (
          <>
            <PairHeaders primary={primary} working={working} />
            {rows.map(r => (
              <PairField key={r.code} label="Role name" source={r.source} target={r.target}
                onChange={v => persist(r.code, v)}
                // Same contract as the Modules tab: withheld when there is no
                // source text to translate FROM, and the result is written through
                // `persist` — the identical path a keystroke takes, so a
                // retranslate is saved exactly as an edit is.
                onRetranslate={r.source && onTranslateText
                  ? async () => {
                      const t = await onTranslateText(r.source, working);
                      if (t != null) persist(r.code, t);
                    }
                  : null} />
            ))}
          </>
        )}
      </PanelBody>
    </>
  );
}

// ── Panel · Welcome (2026-08-14) ─────────────────────────────────────────────
//
// The two strings a learner meets before any module: the COURSE TITLE and the
// COVER SENTENCE. Omar asked for them as one tab because that is how they are
// read — together, on the way in — even though they live in different slices and
// obey different export rules.
//
// ★ Those rules differ, and the panel is honest about it rather than tidy:
//
//   · The TITLE is coverage-gated like any other authored text. It is what the
//     Player renders in the course header on every screen after the language
//     picker, so an untranslated one is the exact bug this tab was built for
//     (Omar: *"even if I select the Italian, everything is translated except for
//     the course title"*). If it is missing, the export refuses by name.
//   · The COVER SENTENCE is NOT gated, and cannot usefully be: the cover paints
//     before the learner has chosen a language, in `languageArray[0]` only
//     (`js/scripts.js:6749`, `:7400`), so its translations are never shown to
//     anybody. It is offered here because Omar asked for it and because
//     "not required" is not "not offered" — with the reason on the row, so
//     nobody spends an afternoon on text no learner can reach
//     (`feedback_a_gate_can_demand_unreachable_work`).
//
// Structurally a sibling of RolesPanel: source read-only on the left, target
// editable on the right, one write path shared with the course-wide Translate.
function WelcomePanel({ working, primary, course, settings, onUpdateSettings, onTranslateText }) {
  const s = settings || {};
  // The source title, in precedence order: the localised map's primary slot
  // (what Course settings writes), then the `course` row. The two agree unless
  // the course predates the slice, in which case the row is the only source
  // there has ever been.
  const titleSource = (s.courseTitle && s.courseTitle[primary])
    || (course && course.title) || '';
  const titleTarget = (s.courseTitle && s.courseTitle[working]) || '';
  // ── The cover sentence LEFT this tab (Omar, 2026-08-14) ────────────────────
  // *"remove the 'Cover screen' field as it is no needed."*
  //
  // He is right, and the reason was already written on the row it replaces: the
  // Player paints the cover in `languageArray[0]`, BEFORE the learner has chosen a
  // language, so a translation of it is never displayed. The field carried
  // `DEFAULT_LANGUAGE_ONLY_MARKER`, so the export never demanded one either — it
  // was optional work with no visible result.
  //
  // It is gone from the WALK as well (`loc-translate-core.js`), not just from the
  // screen. Leaving it walked would have left Translate filling a field with no
  // editor anywhere and the rail counting it toward "Localised" — the mirror image
  // of the defect `loc-walker-welcome.test.ts` was written for, where a field was
  // gated but not walked. Not gated, not walked, not counted, not shown: one state,
  // consistent. The ENGLISH still lives on Course settings, where it always did.
  // The News screen's two fields live here too — added 2026-08-14 after an
  // independent test pass found them GATED BY THE EXPORT AND EDITABLE NOWHERE.
  // They belong on this tab rather than one of their own for the same reason the
  // cover sentence does: everything here is what a learner reads before the
  // first module. Shown only when the screen is ON, matching the walk — a
  // switched-off News screen is stripped before the gate, so its text is not
  // required and offering it would be work for nobody.
  const newsOn = !!(s.news && s.news.enabled === true);
  const newsTitleSource = (s.news && s.news.title && s.news.title[primary]) || '';
  const newsTitleTarget = (s.news && s.news.title && s.news.title[working]) || '';
  const newsMsgSource = (s.news && s.news.message && s.news.message[primary]) || '';
  const newsMsgTarget = (s.news && s.news.message && s.news.message[working]) || '';

  // The "new to company" intro — four OVERRIDE fields, and the override is what
  // makes them different from everything else on this tab.
  //
  // ★ A ROW APPEARS ONLY WHERE THE AUTHOR ACTUALLY OVERRODE. All four blocks have
  // professional translations built into the 15 UI.xml templates, so a field left
  // alone in Course settings needs no translation here — it already has fifteen.
  // Showing four empty pairs would invite the author to hand-translate text that is
  // already translated, and worse, to replace good copy with machine output
  // (`feedback_a_gate_can_demand_unreachable_work` — do not ask for work that is
  // not needed). Type an English override and its row appears, because THEN the
  // export does require the other languages.
  // Which bands the author has UNFOLDED. Closed is the default (Omar, 2026-08-14:
  // *"Make all the other fields collapsed by default"*), matching Course settings,
  // where every panel also arrives shut. Storing the OPEN ones keeps the empty
  // object as the default state, so a group that appears later starts closed like
  // the rest without needing an entry seeded for it — the same reason the previous
  // version stored the closed ones when open was the default.
  const [openBands, setOpenBands] = React.useState({});

  const introOn = !!(s.featureFlags && s.featureFlags.haveNewToCompany === true);
  const intro = s.newToCompanyIntro || {};
  // TWO fields since 2026-08-14, and the labels are the Course settings panel's
  // OWN — `Headline` and `Message`, unprefixed. They collide with the News
  // screen's pair on purpose: the band heading above each row is what tells them
  // apart, which is exactly what Omar asked for ("the same heading used in the
  // Course Settings are the same in the Localisation"). Prefixing them
  // `Intro heading` / `News headline`, as this file did until today, is the thing
  // that made the two screens read as different vocabularies.
  const INTRO_FIELDS = [
    ['title', 'Headline'],
    ['body', 'Message'],
  ];
  const introRows = introOn
    ? INTRO_FIELDS
        .map(([key, label]) => ({
          key,
          label,
          source: (intro[key] && intro[key][primary]) || '',
          target: (intro[key] && intro[key][working]) || '',
        }))
        .filter(r => r.source)
    : [];

  // Merge into the working language ONLY — every other language and the source
  // survive, exactly as RolesPanel's `persist` does.
  const persistTitle = (str) => {
    if (!onUpdateSettings) return;
    onUpdateSettings({ courseTitle: { ...(s.courseTitle || {}), [working]: str } });
  };
  const persistNews = (key, str) => {
    if (!onUpdateSettings) return;
    onUpdateSettings({
      news: {
        ...(s.news || {}),
        [key]: { ...((s.news && s.news[key]) || {}), [working]: str },
      },
    });
  };

  const persistIntro = (key, str) => {
    if (!onUpdateSettings) return;
    onUpdateSettings({
      newToCompanyIntro: {
        ...(s.newToCompanyIntro || {}),
        [key]: { ...((s.newToCompanyIntro && s.newToCompanyIntro[key]) || {}), [working]: str },
      },
    });
  };

  // Counted on rows that HAVE a source — the same rule every other panel uses.
  // A cover sentence nobody wrote is not a translation task.
  const rows = [
    { source: titleSource, target: titleTarget },
    ...(newsOn
      ? [{ source: newsTitleSource, target: newsTitleTarget },
         { source: newsMsgSource, target: newsMsgTarget }]
      : []),
    ...introRows,
  ];
  const total = rows.filter(r => r.source).length;
  const filled = rows.filter(r => r.source && r.target.trim()).length;

  // ── GROUPED BY THE COURSE SETTINGS PANEL THAT OWNS THE TEXT ───────────────
  //
  // Omar, 2026-08-14: *"visualise the content so that it is clear which section
  // belong to the Course basics, which to New to company and which to News. At the
  // moment they all look combined together. Also make sure that the same heading
  // used in the Course Settings are the same in the Localisation so that the admin
  // can immediately connect the text used in the Course settings with those in the
  // localisation."*
  //
  // FOUR groups, not the three he named — the cover sentence is owned by the
  // `Cover screen` panel, not by `Course basics` (surface-brand.jsx:334 vs :241).
  // Filing it under Course basics would commit the very error being fixed: the
  // author goes looking for it there on the settings screen and does not find it.
  //
  // The bands are `LocGroupSection`, the SAME component the Modules tab uses, at
  // its default level — which is not a coincidence worth losing: `SettingsPanel`
  // renders `section.card`, and `.card` and a default `LocGroupSection` are already
  // the same box (1px `--border`, `--radius-md`, `--surface`). So the two screens
  // share the container, the chevron, the badge slot and the collapse behaviour,
  // not merely the heading text. No new component for a job an existing one does
  // (`feedback_one_rule_one_place`).
  //
  // OPEN by default, unlike Course settings — which arrives all-closed because it
  // holds dozens of controls. This tab holds at most six one-line rows and its
  // whole purpose is showing what is left to translate; four clicks before you can
  // see whether there is any work is the opposite of that.
  //
  // NOT `level="shared"`: that variant means "module level" on the Modules tab
  // (see its own comment), and borrowing the frame would borrow the meaning.
  //
  // Rows are built as DATA and rendered by one `.map`, replacing four hand-written
  // branches with four near-identical Retranslate closures. `r.source &&` stays
  // written at the call site even where the row list is already filtered:
  // `fe-loc-retranslate.test.ts` asserts the guard is visible there, and
  // satisfying it by construction somewhere else makes it unverifiable from here.
  const groups = [
    {
      key: 'basics', domKey: 'welcome-basics', icon: 'Tag', label: 'Course basics',
      title: 'Written in English on the Course settings screen, Course basics panel.',
      rows: [{
        key: 'title', label: 'Course title',
        source: titleSource, target: titleTarget, persist: persistTitle,
      }],
    },
    ...(newsOn ? [{
      key: 'news', domKey: 'welcome-news', icon: 'Bell', label: 'News screen',
      title: 'Written in English on the Course settings screen, News screen panel.',
      rows: [
        { key: 'title', label: 'Headline', source: newsTitleSource,
          target: newsTitleTarget, persist: (v) => persistNews('title', v) },
        { key: 'message', label: 'Message', source: newsMsgSource,
          target: newsMsgTarget, persist: (v) => persistNews('message', v) },
      ],
    }] : []),
    ...(introRows.length ? [{
      key: 'ntc', domKey: 'welcome-newtocompany', icon: 'User', label: 'New to company',
      title: 'Written in English on the Course settings screen, New to company panel.',
      rows: introRows.map(r => ({
        key: r.key, label: r.label, source: r.source, target: r.target,
        persist: (v) => persistIntro(r.key, v),
      })),
    }] : []),
  ];

  // ── What is NOT here, said once at the foot ───────────────────────────────
  // Two absent groups used to explain themselves with an `EmptyNote` in the MIDDLE
  // of the list, so notes about text that is absent were interleaved with the work
  // that is present. One trailing note instead: on a default course a per-group
  // treatment gives four frames around one live row.
  const absent = [];
  if (!newsOn) absent.push('News screen');
  if (introOn && introRows.length === 0) absent.push('New to company');

  return (
    <>
      <div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 24px 0' }}>
        {/* One fixed line. It used to enumerate the sections, which is now the
            band headings\u2019 job — a caption that repeats what is directly below it
            is the shape Omar has stripped twice on the neighbouring screen
            (`feedback_a_preview_window_should_not_caption_itself`). */}
        <span style={{ fontSize: 12.5, color: 'var(--text-muted)' }}>
          Everything a learner reads before the first module.
        </span>
        <div style={{ flex: 1 }} />
        <LocSaveToServer
          dirtySignal={`${titleTarget} ${newsTitleTarget} ${newsMsgTarget}`} />
        <span style={{ width: 1, height: 22, background: 'var(--border)' }} />
        <span title={`${filled} of ${total} welcome fields with ${locName(primary)} text have a ${locName(working)} translation`}
          style={{ fontSize: 11.5, color: 'var(--text-muted)', fontFamily: 'var(--font-mono)',
            whiteSpace: 'nowrap' }}>{filled} / {total} translated</span>
      </div>
      <PanelBody>
        {/* Once, above the bands. The band inset is symmetric (1px border + 12px
            padding each side), so the 1fr/1fr split inside a band still lands on
            the same centre line as these headers. */}
        <PairHeaders primary={primary} working={working} />
        {groups.map(g => {
          const gTotal = g.rows.filter(r => r.source).length;
          const gFilled = g.rows.filter(r => r.source && r.target.trim()).length;
          return (
            <LocGroupSection key={g.key} domKey={g.domKey} icon={g.icon} label={g.label}
              note={g.note} title={g.title} headingLevel={3}
              count={`${gFilled}/${gTotal}`}
              status={<LocBandStatus filled={gFilled} total={gTotal} />}
              open={!!openBands[g.key]}
              onToggle={() => setOpenBands(o => ({ ...o, [g.key]: !o[g.key] }))}>
              {g.rows.map(r => (
                <PairField key={r.key} label={r.label} source={r.source} target={r.target}
                  onChange={r.persist}
                  onRetranslate={r.source && onTranslateText
                    ? async () => {
                        const t = await onTranslateText(r.source, working);
                        if (t != null) r.persist(t);
                      }
                    : null} />
              ))}
            </LocGroupSection>
          );
        })}
        {absent.length > 0 && (
          <EmptyNote>
            {absent.length === 1
              ? <>One more screen can carry text: <strong>{absent[0]}</strong>.</>
              : <>Two more screens can carry text: <strong>{absent[0]}</strong> and{' '}
                 <strong>{absent[1]}</strong>.</>}
            {!newsOn && <> The News screen is switched off — switch it on in{' '}
              <strong>Course settings</strong> and its text appears here.</>}
            {introOn && introRows.length === 0 && <> <strong>New to company</strong> is
              on and already reads in {locName(working)}: its wording is built in and
              professionally translated into every language. It appears here only if
              you rewrite it in <strong>Course settings</strong>.</>}
          </EmptyNote>
        )}
      </PanelBody>
    </>
  );
}

// ── Panel · Subtitles ────────────────────────────────────────────────────────
// REAL generation / read / edit / persist against `subtitlesUrl`, the per-language
// media field OQ-081 introduced. Two modes, one component:
//
//   SOURCE  (English selected)  — one editable English column, plus the
//                                 "Generate from video" action. Omar's S3: this
//                                 is the ONLY place English subtitles exist, so
//                                 it is the only place they can be checked.
//   TARGET  (a language selected) — the stacked card kept as-is: English row
//                                 ABOVE the target row, English READ-ONLY. His
//                                 S4, and his correction: "I meant to say English
//                                 above Italian, not side by side."
//
// What it replaced: a complete fake. Local `useState`, a `setTimeout(700)` save,
// and ten canned "Code of Conduct" cues that every video in every course showed,
// with hardcoded `04:32` / `02:14` durations. The panel whose two-language layout
// motivated OQ-081 was not showing the course at all.
//
// Timings are English-owned and shared: a translated line is the same moment of
// the same video, so the target track is written with the source's start/end.
function SubtitlesPanel({ course, working, primary, layoutDrafts, onUpdateDrafts }) {
  const isSource = working === primary;
  const courseId = window.dynamoCourseId;
  const videos = React.useMemo(
    () => window.collectSubtitleVideos(course, layoutDrafts, primary),
    [course, layoutDrafts, primary]);
  const [sel, setSel] = React.useState(() => videos[0] && videos[0].id);
  const video = videos.find(v => v.id === sel) || videos[0];

  // The stored track for each side. STRICT per-language reads: the English track
  // must never stand in for a missing Italian one — that is the whole defect
  // OQ-081 fixed, and reading it back leniently here would re-introduce it in
  // the editor even though the export is correct.
  const sourceRef = video ? window.locUrl(video.subtitlesUrl, primary) : '';
  const targetRef = (video && !isSource) ? window.locUrl(video.subtitlesUrl, working) : '';

  const source = useCueTrack(sourceRef);
  const target = useCueTrack(isSource ? '' : targetRef);

  // Real length of the selected video, from the browser's own metadata parse.
  const probed = window.useVideoDuration(video ? video.videoRef : '');

  // ── Local edit buffer ──────────────────────────────────────────────────────
  // `dirty` gates Store. `seedKey` identifies WHICH slot+language the buffer
  // belongs to, so a change of video or language re-seeds from the loaded tracks.
  //
  // Unsaved edits are HELD per slot rather than dropped. Re-seeding
  // unconditionally meant typing a cue, switching video, and switching back
  // silently discarded the work — no prompt, no trace. A ref keyed by `seedKey`
  // keeps each slot's pending edits for as long as the panel is open, so the
  // dropdown stops being a delete button.
  const seedKey = `${video ? video.id : ''}|${sourceRef}|${targetRef}|${working}`;
  const paired = React.useMemo(
    () => window.pairCues(source.cues, target.cues),
    [source.cues, target.cues]);
  // Neither track is mid-request. NOTHING may be seeded or edited before this is
  // true: the source and target fetch in PARALLEL, so a target view rendered
  // editable rows (built from the source alone) while the Italian track was still
  // in flight. Typing there captured a buffer with empty target cells, the loaded
  // translation then lost to `held`, and Store would have written the blanks over
  // the author's stored words. The first version of this buffer traded discarding
  // UNSAVED edits for discarding SAVED ones.
  const tracksSettled = source.state !== 'loading' && target.state !== 'loading';
  const buffers = React.useRef(new Map());   // seedKey -> edited rows
  const [rows, setRows] = React.useState([]);
  const [dirty, setDirty] = React.useState(false);
  React.useEffect(() => {
    if (!tracksSettled) {
      // Clear rather than keep the PREVIOUS slot's rows on screen while the new
      // one loads — those rows belong to a different video.
      setRows([]);
      setDirty(false);
      return;
    }
    const held = buffers.current.get(seedKey);
    setRows(held || paired.rows);
    setDirty(!!held);
  }, [seedKey, paired.rows, tracksSettled]);

  const [busy, setBusy] = React.useState(null);   // null | 'generating' | 'saving'
  const [notice, setNotice] = React.useState(null); // { kind, text }
  // A notice describes the slot it was produced for, so it clears when the slot or
  // the language changes.
  //
  // Keyed on the SLOT, deliberately NOT on `seedKey`: `seedKey` contains the two
  // asset refs, and every action that produces a notice (generate, store, remove)
  // changes a ref — so this effect wiped each success message in the same commit
  // that set it. The author waited ~20 s for a transcription and got no
  // confirmation, and never saw "Save the course to keep them", which is the one
  // instruction that makes the work durable.
  const slotKey = `${video ? video.id : ''}|${working}`;
  React.useEffect(() => { setNotice(null); }, [slotKey]);

  // ── One press, both writes ─────────────────────────────────────────────────
  // Subtitle lines are not course fields — they are a FILE, and the course holds
  // only its address. So making them durable takes two writes: store the file,
  // then save the course that points at it. That was two buttons, "Store cues"
  // and "Save", and Omar read them the other way round on 2026-07-30: "I don't
  // know what Store cues means since there is also the Save button." Pressing
  // only Save would have silently discarded every line he typed — the lines live
  // in `rows` until stored, and nothing warned him. A trap that needs explaining
  // is a trap; the two writes are now one action.
  //
  // The course save CANNOT be fired straight after `writeRef`. `onUpdateDrafts`
  // is a React state update, and app.jsx builds the save body from its own
  // `layoutDrafts` render closure — so a save issued in the same tick sends the
  // draft from BEFORE the new subtitle ref and reports success. This latch waits
  // for observable proof the draft committed: the panel re-derives its video list
  // from the `layoutDrafts` prop, so `activeRef` equalling the ref we just wrote
  // means app.jsx is holding it.
  const [pendingSave, setPendingSave] = React.useState(null);
  const courseSaveRunning = React.useRef(false);
  const retryCourseSave = React.useCallback(async () => {
    const save = window.dynamoSaveDraftToServer;
    if (!save) return;
    setBusy('saving-course');
    try {
      const r = await save();
      setNotice(r && r.ok
        ? { kind: 'ok', text: 'Course saved.' }
        : { kind: 'error', retry: true,
            text: `The course was NOT saved: ${(r && r.message) || 'unknown reason'}` });
    } catch (e) {
      setNotice({ kind: 'error', retry: true,
        text: `The course was NOT saved: ${(e && e.message) || e}` });
    } finally { setBusy(null); }
  }, []);
  React.useEffect(() => {
    if (!pendingSave || courseSaveRunning.current) return;
    const landed =
      (pendingSave.lang === primary ? sourceRef : targetRef) === pendingSave.ref;
    if (!landed) return;
    courseSaveRunning.current = true;
    setPendingSave(null);
    const { text, slot } = pendingSave;
    (async () => {
      // Report against the slot the action was STARTED on. A save that resolves
      // after the author switched video must not stamp its notice onto the new
      // one — the notice would describe work done somewhere else.
      const stillHere = () => slot === `${video ? video.id : ''}|${working}`;
      setBusy('saving-course');
      try {
        const save = window.dynamoSaveDraftToServer;
        const r = save ? await save() : { ok: false, message: 'save unavailable' };
        if (!stillHere()) return;
        setNotice(r && r.ok
          ? { kind: 'ok', text: `${text} Course saved.` }
          : { kind: 'error', retry: true,
              text: `${text} But the course was NOT saved, so this will be lost on ` +
                `reload: ${(r && r.message) || 'unknown reason'}` });
      } catch (e) {
        if (stillHere()) {
          setNotice({ kind: 'error', retry: true,
            text: `${text} But the course was NOT saved, so this will be lost on ` +
              `reload: ${(e && e.message) || e}` });
        }
      } finally {
        courseSaveRunning.current = false;
        setBusy(null);
      }
    })();
  }, [pendingSave, sourceRef, targetRef, primary, video, working]);

  const editRow = (i, patch) => {
    setRows(rs => {
      const next = rs.map((r, j) => (j === i ? { ...r, ...patch } : r));
      buffers.current.set(seedKey, next);
      return next;
    });
    setDirty(true);
  };

  // ── Retranslate ONE cue ────────────────────────────────────────────────────
  // Added 2026-08-12: "The 'Retranslate' button should be a feature to apply
  // across all the other tabs such Subtitles, Assessment, Quiz Gaming."
  //
  // The video-wide Translate below deliberately fills only EMPTY lines, so once a
  // cue has any target text that button can never refresh it. That is the same
  // dead end the Modules tab's Retranslate exists to solve, and on this tab it
  // matters more: re-timing or rewording one English line is routine, and the
  // alternative is clearing the Italian by hand to make the bulk button notice it.
  //
  // Unlike the bulk run it REPLACES what is there — that is the whole point, and
  // it is per-cue, one deliberate click, on a row the author is looking at. It
  // writes through `editRow`, exactly as typing in the box does, so the edit lands
  // in the same buffer and the same Save writes it to the `.vtt`
  // (`feedback_one_rule_one_place`). It does NOT save by itself: the notice below
  // tells the author to press Save, and a button that silently uploaded a subtitle
  // file would be a second, hidden definition of saving on this panel.
  const retranslateCue = (i) => {
    const row = rows[i];
    if (!row || isSource || !String(row.en || '').trim()) return null;
    return async () => {
      setNotice(null);
      try {
        const out = await window.translateSubtitleTexts([row.en], working, primary);
        const t = out && out[0];
        if (t && String(t).trim()) {
          editRow(i, { target: String(t) });
          setNotice({ kind: 'ok', text:
            `Cue ${String(row.n).padStart(2, '0')} retranslated into ${locName(working)}. `
            + `Press Save to write it into the subtitle file.` });
        } else {
          setNotice({ kind: 'error', text:
            `The translation for cue ${String(row.n).padStart(2, '0')} came back empty — `
            + `the line was left as it was.` });
        }
      } catch (err) {
        setNotice({ kind: 'error', text: (err && err.message) || 'Translation failed.' });
      }
    };
  };

  /**
   * Write a subtitle ref for `lang` into the selected slot's layout draft.
   *
   * Uses the FUNCTION form of `onUpdateDrafts`, which hands back the CURRENT
   * draft and replaces it with the return value. Both properties are needed:
   *  · building the patch from the `layoutDrafts` prop instead reverted anything
   *    the author changed on that layout during the request (Deepgram takes
   *    seconds), because the prop was captured before the await;
   *  · and the object form SHALLOW-MERGES, so an absent `subtitlesUrl` could not
   *    delete a present one — "Remove" reported success and changed nothing for
   *    every layout whose field sits at the content root (fullscreen_video,
   *    small_video). Both found 2026-07-30 by an adversarial review.
   */
  const writeRef = (lang, ref) => {
    if (!video || !onUpdateDrafts) return;
    const { layoutId, layoutType, containerPath } = video;
    const contentMode = course.contentMode;
    onUpdateDrafts(layoutId, (current) => {
      const draft = current || {};
      const type = draft.type || layoutType;
      const base = window.layoutContentBase(type, contentMode);
      const content = { ...base, ...draft, type };
      return window.setSubtitleRef(content, containerPath, lang, ref);
    });
  };

  // ── Generate (English only) ────────────────────────────────────────────────
  const generate = async () => {
    if (!video || busy) return;
    setBusy('generating');
    setNotice(null);
    try {
      const out = await window.generateSubtitles(courseId, video.videoRef, primary);
      writeRef(primary, out.ref);
      setPendingSave({ lang: primary, ref: out.ref, slot: slotKey,
        text: `${out.cues.length} cue${out.cues.length === 1 ? '' : 's'} generated from the video.` });
    } catch (err) {
      setNotice({ kind: 'error', text: (err && err.message) || 'Generation failed.' });
    } finally {
      setBusy(null);
    }
  };

  // ── Save the edited cues ───────────────────────────────────────────────────
  // Stores a NEW .vtt and points this language at it. The gateway owns the file
  // format (numeric cue ids), so nothing here builds a `.vtt` byte.
  const saveCues = async () => {
    if (!video || busy || rows.length === 0) return;
    setBusy('saving');
    setNotice(null);
    try {
      const lang = isSource ? primary : working;
      const cues = rows.map(r => ({
        start: r.start, end: r.end,
        text: isSource ? r.en : r.target,
      }));
      const out = await window.saveSubtitleCues(courseId, cues, lang,
        `${video.id.replace(/[^A-Za-z0-9._-]+/g, '-')}.${lang}.vtt`);
      writeRef(lang, out.ref);
      setDirty(false);
      setPendingSave({ lang, ref: out.ref, slot: slotKey,
        text: `${locName(lang)} subtitles stored.` });
    } catch (err) {
      setNotice({ kind: 'error', text: (err && err.message) || 'Could not save the cues.' });
    } finally {
      setBusy(null);
    }
  };

  /** Detach this language's track. The other languages' tracks are untouched. */
  const removeTrack = () => {
    const lang = isSource ? primary : working;
    writeRef(lang, '');
    setPendingSave({ lang, ref: '', slot: slotKey,
      text: `${locName(lang)} track removed from this video.` });
  };

  // ── Translate this video's cues into the working language ──────────────────
  // The missing half of the feature. The header's course-wide "Translate to X"
  // cannot reach cue text: it lives inside the `.vtt`, and the file's path is
  // explicitly denied as a translate job, so the button reported success and left
  // these fields empty (Omar, 2026-07-30). Scoped to the video on screen, which
  // is how he described expecting it: "populates the Italian subtitle fields for
  // the current video".
  //
  // Only EMPTY target lines are filled. Overwriting a line the author already
  // wrote — or already corrected after a previous run — would make this button
  // destructive, and re-pressing it is exactly what someone does when they are
  // unsure whether it worked the first time.
  const translate = async () => {
    if (!video || busy || isSource || rows.length === 0) return;
    setBusy('translating');
    setNotice(null);
    try {
      const blank = (s) => !s || !String(s).trim();
      // '' for rows that already have target text: `translateSubtitleTexts` skips
      // blanks, so those positions cost no quota and come back untouched.
      const ask = rows.map(r => (blank(r.target) ? r.en : ''));
      const out = await window.translateSubtitleTexts(ask, working, primary);
      let filled = 0;
      const next = rows.map((r, i) => {
        const t = out[i];
        if (blank(r.target) && t && String(t).trim()) { filled++; return { ...r, target: String(t) }; }
        return r;
      });
      if (filled > 0) {
        buffers.current.set(seedKey, next);
        setRows(next);
        setDirty(true);
      }
      setNotice({ kind: 'ok', text: filled > 0
        ? `${filled} line${filled === 1 ? '' : 's'} translated into ${locName(working)}. ` +
          `Read them, fix anything that is off, then press Save.`
        : `Nothing to fill — every line already has ${locName(working)} text. ` +
          `Existing text is never overwritten.` });
    } catch (err) {
      setNotice({ kind: 'error', text: (err && err.message) || 'Translation failed.' });
    } finally {
      setBusy(null);
    }
  };

  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. Upload one in the <strong>Course
          architecture</strong> editor — subtitles are generated from the video
          file, so there is nothing to caption until then.
        </EmptyNote>
      </PanelBody>
    );
  }

  const activeLang = isSource ? primary : working;
  const activeRef = isSource ? sourceRef : targetRef;
  const canGenerate = isSource && video && video.uploaded;

  return (
    <>
      <div style={{ display: 'flex', alignItems: 'center', gap: 12,
        padding: '12px 24px 0', flexWrap: 'wrap' }}>
        <ItemNav options={options} value={video ? video.id : undefined} onChange={setSel} />
        <div style={{ flex: 1 }} />
        {isSource && (
          <button className="btn sm primary" onClick={generate}
            disabled={!canGenerate || !!busy}
            title={canGenerate
              ? `Transcribe the uploaded video and create ${locName(primary)} cues`
              : 'Generation reads the uploaded video file — this slot has no uploaded video'}>
            {busy === 'generating'
              ? <><I.Loader size={12} className="spin" />Generating…</>
              : <><I.Sparkle size={12} />{sourceRef ? 'Regenerate' : 'Generate'} from video</>}
          </button>
        )}
        {!isSource && (
          <button className="btn sm primary" onClick={translate}
            disabled={!!busy || rows.length === 0 || source.state !== 'ready'}
            title={source.state === 'ready'
              ? `Fill the empty ${locName(working)} lines from the ${locName(primary)} ones`
              : `There are no ${locName(primary)} cues to translate from yet`}>
            {busy === 'translating'
              ? <><I.Loader size={12} className="spin" />Translating…</>
              : <><I.Languages size={12} />Translate to {locName(working)}</>}
          </button>
        )}
        {/* ONE save. It writes the subtitle FILE and then the COURSE that points at
            it — see the pendingSave latch above. This used to be two buttons, and
            the one with the reassuring name ("Save") was the one that silently
            discarded the author's typed lines.
            Named plainly "Save" since 2026-08-12. Omar: "Keep only one name for the
            Save, do not use 'Save Subtitles'. It is important to have consistency."
            The qualifier existed only to tell it apart from that second button, and
            that competition has been gone since 2026-07-30 — the comment above
            already said so. What this particular Save also writes stays in the
            tooltip, where it informs without becoming a fourth name for the verb. */}
        <button className="btn sm" onClick={saveCues}
          disabled={!dirty || !!busy || rows.length === 0}
          title={dirty
            ? `Save these ${locName(activeLang)} cues into this video's subtitle file, and save the course`
            : 'No cue edits to save'}>
          {busy === 'saving' ? <><I.Loader size={12} className="spin" />Saving…</>
            : busy === 'saving-course' ? <><I.Loader size={12} className="spin" />Saving course…</>
            : <><I.Check size={12} />Save</>}
        </button>
        {/* Icon only, in the SAME style as every other destructive control here —
            `btn sm ghost danger`, 28×28, `I.Trash size={12}` — which is exactly what
            VideoMediaBlock's "Remove this video" already uses (Omar, 2026-07-30:
            "The rule to use existing style must be applied as I don't want to
            generate multiple styles that already exist"). The word "Remove" was
            only there to distinguish it from a second Save button; that competition
            no longer exists. */}
        {activeRef && (
          <button className="btn sm ghost danger" onClick={removeTrack} disabled={!!busy}
            title={`Remove the ${locName(activeLang)} track from this video`}
            style={{ width: 28, height: 28, padding: 0,
              justifyContent: 'center', gap: 0 }}>
            <I.Trash size={12} />
          </button>
        )}
      </div>

      <PanelBody>
        {/* What this slot is, and how long its video actually is. */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 12,
          color: 'var(--text-muted)', flexWrap: 'wrap' }}>
          <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
            <I.Captions size={13} />
            {rows.length} cue{rows.length === 1 ? '' : 's'}
          </span>
          <span style={{ color: 'var(--text-faint)' }}>·</span>
          <span title="Read from the uploaded file, not a sample constant">
            {probed.state === 'ready' ? window.formatDuration(probed.seconds)
              : probed.state === 'loading' ? '· · ·'
              : probed.state === 'unknown' ? 'sample video'
              : probed.state === 'error' ? 'length unavailable'
              : 'no video'}
          </span>
          {video && !video.uploaded && (
            <>
              <span style={{ color: 'var(--text-faint)' }}>·</span>
              <span style={{ color: 'var(--warning-text)' }}>
                this slot points at a sample path, not an uploaded file
              </span>
            </>
          )}
        </div>

        {notice && (
          <div role="status" style={{
            display: 'flex', alignItems: 'flex-start', gap: 8, padding: '9px 12px',
            fontSize: 12, borderRadius: 'var(--radius)',
            border: `1px solid ${notice.kind === 'error' ? 'var(--danger, var(--warning))' : 'var(--border)'}`,
            background: notice.kind === 'error' ? 'var(--danger-bg, var(--surface-inset))' : 'var(--surface-inset)',
            color: notice.kind === 'error' ? 'var(--danger-text, var(--error-text))' : 'var(--text-muted)',
          }}>
            {notice.kind === 'error' ? <I.AlertTriangle size={13} /> : <I.Check size={13} />}
            <span style={{ flex: 1 }}>{notice.text}</span>
            {/* The subtitle FILE is already written when a course save fails, so
                the only missing step is the course — offer exactly that, rather
                than making the author re-run a transcription to get back here. */}
            {notice.retry && (
              <button className="btn sm" onClick={retryCourseSave} disabled={!!busy}
                title="Try saving the course again">
                {busy === 'saving-course' ? 'Saving…' : 'Retry save'}
              </button>
            )}
          </div>
        )}

        {/* The two tracks disagree about the video. Every cue from BOTH is listed
            and a store keeps them all, so this is information, not a warning that
            work is about to be lost — an earlier version told the author to press
            Store, which truncated the longer track. */}
        {paired.countMismatch && (
          <div style={{ display: 'flex', alignItems: 'flex-start', gap: 8,
            padding: '9px 12px', fontSize: 12, borderRadius: 'var(--radius)',
            border: '1px solid var(--warning)', color: 'var(--warning-text)' }}>
            <I.AlertTriangle size={13} />
            <span>
              The {locName(working)} track has {paired.countMismatch.target} cues and
              {' '}{locName(primary)} has {paired.countMismatch.source}. All of them are
              listed below; the {Math.abs(paired.countMismatch.target - paired.countMismatch.source)}
              {' '}extra {paired.countMismatch.target > paired.countMismatch.source
                ? `${locName(working)} line(s) have no ${locName(primary)} counterpart and keep their own timing`
                : `${locName(primary)} line(s) are not translated yet`}.
              {' '}Nothing is dropped when you store.
            </span>
          </div>
        )}

        {/* The stored target file physically holds its own moments. If English was
            re-timed after the target was stored, this editor is showing timings the
            learner is not getting until the target is stored again. */}
        {paired.timingDrift && (
          <div style={{ display: 'flex', alignItems: 'flex-start', gap: 8,
            padding: '9px 12px', fontSize: 12, borderRadius: 'var(--radius)',
            border: '1px solid var(--warning)', color: 'var(--warning-text)' }}>
            <I.AlertTriangle size={13} />
            <span>
              The stored {locName(working)} file still uses older timings for cue
              {paired.timingDrift.length === 1 ? ' ' : 's '}
              {paired.timingDrift.slice(0, 8).join(', ')}
              {paired.timingDrift.length > 8 ? ` and ${paired.timingDrift.length - 8} more` : ''}.
              The times shown here are {locName(primary)}'s — press <strong>Save</strong> to
              write them into the {locName(working)} file.
            </span>
          </div>
        )}

        {(source.state === 'loading' || target.state === 'loading') && (
          <EmptyNote>Reading the subtitle file…</EmptyNote>
        )}

        {source.state === 'error' && (
          <EmptyNote>{locName(primary)} track: {source.message}</EmptyNote>
        )}
        {target.state === 'error' && (
          <EmptyNote>{locName(working)} track: {target.message}</EmptyNote>
        )}

        {/* No English track yet. Under a target language there is nothing to
            translate FROM, and the fix is in the English view — say which. */}
        {source.state === 'none' && (
          <EmptyNote>
            {isSource ? (
              video && video.uploaded
                ? <>This video has no {locName(primary)} subtitles yet. Use <strong>Generate
                    from video</strong> above to transcribe it.</>
                : <>This video has no {locName(primary)} subtitles, and generation needs an
                    uploaded video file. Upload the video in <strong>Course
                    architecture</strong> first.</>
            ) : (
              <>This video has no {locName(primary)} subtitles yet, so there is nothing to
                translate. Select <strong>{locName(primary)}</strong> in the language list
                and generate them first.</>
            )}
          </EmptyNote>
        )}

        {rows.length > 0 && rows.map((row, i) => (
          <SubtitleCue key={row.n} cue={row} primary={primary} working={working}
            sourceReadOnly={!isSource}
            showTarget={!isSource}
            onEditStart={v => { const s = window.parseCueTime(v); if (s !== null) editRow(i, { start: s }); }}
            onEditEnd={v => { const s = window.parseCueTime(v); if (s !== null) editRow(i, { end: s }); }}
            onEditEn={v => editRow(i, { en: v })}
            onEditTarget={v => editRow(i, { target: v })}
            onRetranslateTarget={retranslateCue(i)} />
        ))}
      </PanelBody>
    </>
  );
}

// Load the cues of one stored track. `''`/non-asset → state 'none' with no
// request. Every settle path sets a state, so a failed fetch cannot leave the
// panel stuck on "loading" (which would read as "this video has no subtitles").
function useCueTrack(ref) {
  const [entry, setEntry] = React.useState({ state: 'none', cues: [], message: '' });
  React.useEffect(() => {
    if (typeof ref !== 'string' || !ref.startsWith('asset://')) {
      setEntry({ state: 'none', cues: [], message: '' });
      return undefined;
    }
    let cancelled = false;
    setEntry({ state: 'loading', cues: [], message: '' });
    window.fetchSubtitleCues(ref).then(
      (out) => {
        if (cancelled) return;
        const cues = (out && Array.isArray(out.cues)) ? out.cues : [];
        setEntry(cues.length > 0
          ? { state: 'ready', cues, message: '' }
          // A stored file that parses to zero cues is not "no track" — the ref
          // exists. Report it, or the author sees an empty editor and no reason.
          : { state: 'error', cues: [], message: 'the stored file contains no cues' });
      },
      (err) => {
        if (!cancelled) {
          setEntry({ state: 'error', cues: [],
            message: (err && err.message) || 'could not be read' });
        }
      });
    return () => { cancelled = true; };
  }, [ref]);
  return entry;
}

// One cue, as a stacked card: number · timing / source row / target row.
//
// The card SHAPE is unchanged — Omar corrected an earlier reading of his
// screenshot with "my bad, I meant to say English above Italian, not side by
// side", so this must not become the two-column PairField grid the Modules panel
// uses. What is new is `sourceReadOnly`: under a target language the English row
// borrows the read-only treatment used by Modules / Roles / Assessment (a
// non-interactive block on `--surface-inset`), which is his S4 — "the English
// fields when another language is selected should not be editable. Same concept
// that already exists in the other Modules, Assessment, etc."
//
// Timing is editable only in the source view, because the timings are shared: a
// translated line is the same moment of the same video, so letting a translator
// nudge them would silently re-time English too.
function SubtitleCue({ cue, primary, working, sourceReadOnly, showTarget,
  onEditStart, onEditEnd, onEditEn, onEditTarget, onRetranslateTarget }) {
  return (
    <div style={{ background: 'var(--surface)', border: '1px solid var(--border)',
      borderRadius: 'var(--radius-md)', overflow: 'hidden' }}>
      {/* header: number + 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 seconds={cue.start} onChange={onEditStart} label="Start time"
            readOnly={sourceReadOnly} />
          <I.ArrowRight size={12} style={{ color: 'var(--text-faint)' }} />
          <TimeField seconds={cue.end} onChange={onEditEnd} label="End time"
            readOnly={sourceReadOnly} />
        </div>
        {sourceReadOnly && (
          <span style={{ fontSize: 10.5, color: 'var(--text-faint)' }}>
            timing follows {locName(primary)}
          </span>
        )}
      </div>
      {/* source language — editable in the source view, read-only under a target */}
      <CueLangRow flag={locFlag(primary)} name={locName(primary)}
        value={cue.en} onChange={onEditEn} readOnly={sourceReadOnly} />
      {showTarget && <>
        <div style={{ height: 1, background: 'var(--border)' }} />
        <CueLangRow flag={locFlag(working)} name={locName(working)}
          value={cue.target} placeholder="Not translated yet" onChange={onEditTarget}
          onRetranslate={onRetranslateTarget} />
      </>}
    </div>
  );
}

// Timecode field. Holds the author's KEYSTROKES while focused and commits
// seconds on blur — binding it straight to a formatted number meant every
// character was reformatted mid-typing, so the field fought back on the second
// digit and a partially-typed value could never exist.
function TimeField({ seconds, onChange, label, readOnly }) {
  const formatted = window.formatCueTime(seconds);
  const [draft, setDraft] = React.useState(null);
  const shown = draft === null ? formatted : draft;
  const valid = draft === null || window.parseCueTime(draft) !== null;
  if (readOnly) {
    return (
      <span aria-label={label} title={`${label} — set in the source language`}
        style={{ display: 'inline-block', height: 24, lineHeight: '24px',
          padding: '0 7px', width: 108, textAlign: 'center',
          fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-muted)',
          background: 'var(--surface-inset)', borderRadius: 'var(--radius-sm, 3px)' }}>
        {formatted}
      </span>
    );
  }
  return (
    <input className="field" value={shown}
      onChange={e => setDraft(e.target.value)}
      onBlur={() => {
        if (draft !== null && window.parseCueTime(draft) !== null) onChange(draft);
        setDraft(null);
      }}
      onKeyDown={e => {
        if (e.key === 'Enter') e.currentTarget.blur();
        if (e.key === 'Escape') { setDraft(null); e.currentTarget.blur(); }
      }}
      spellCheck={false} aria-label={label}
      title={`${label} — mm:ss.mmm`}
      style={{ height: 24, padding: '0 7px', width: 108, textAlign: 'center',
        fontFamily: 'var(--font-mono)', fontSize: 11,
        color: valid ? 'var(--text-muted)' : 'var(--danger-text, var(--error-text))',
        borderColor: valid ? undefined : 'var(--danger, var(--warning))' }} />
  );
}

// One language's cue text. `readOnly` renders the source treatment used across
// the hub — a non-interactive block on `--surface-inset` — rather than a disabled
// textarea, so it reads as reference material and not as a field that is broken.
function CueLangRow({ flag, name, value, placeholder, onChange, footer, readOnly, onRetranslate }) {
  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>
        {readOnly && (
          <span style={{ fontSize: 9.5, color: 'var(--text-faint)' }}>read-only</span>
        )}
      </div>
      <div>
        {/* Only on an EDITABLE row — the source column under a target language is
            read-only, and a Retranslate there would offer to translate English into
            English. */}
        {!readOnly && onRetranslate && (
          <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 5 }}>
            <RetranslateButton onRetranslate={onRetranslate} compact />
          </div>
        )}
        {readOnly ? (
          <p style={{ margin: 0, padding: '8px 10px', minHeight: 38,
            background: 'var(--surface-inset)', borderRadius: 'var(--radius)',
            fontSize: 13, lineHeight: 1.5,
            color: value ? 'var(--text)' : 'var(--text-faint)',
            fontStyle: value ? 'normal' : 'italic' }}>
            {value || 'No text for this cue'}
          </p>
        ) : (
          <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 read / edit / persist against `settings.assessments` — the same slice the
// Assessments surface authors, `buildCourseSettings` maps and the exporter emits.
// It used to render `SAMPLE_ASSESSMENTS` with answers pulled from an unrelated
// `SAMPLE_QUIZ` bank behind a `setTimeout` "Saved" pill; now that assessment text
// really ships, that would have let an author translate canned demo questions,
// see "Saved", and lose every word. Both fixtures are gone.
//
// Visual hierarchy is unchanged — the three roles of text must never blur:
//   · 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.
//
// There is no Save button: every keystroke writes straight through to
// `courseSettings` via onUpdateSettings, exactly like the Modules panel writes
// through layoutDrafts. The "Result screens" sub-tab is gone too — that wording
// comes from the Player's bundled per-language UI.xml, is not authorable in this
// version, and the tab was editing a frozen constant into nowhere (the
// Assessments surface says the same thing in `AssessmentResultScreenNote`).
// One language's RAW slot, with NO fallback. `locText` falls back to the source
// language when the target is missing — right for display, wrong for asking
// "has this been translated yet?", and wrong in a target BOX: an English
// sentence sitting in the Italian column reads as a translation nobody has to
// do. Every target field on this surface reads through here, so the boxes and
// the "N / M translated" counter always tell the same story (2026-07-28).
// ONE assembly for this surface, and it MUST match what the export builds per
// layout — `{...base, ...draft, type}` then `migrateQuizFeedback`
// (surface-export.jsx: the per-layout map, then `normalizeLayoutForSchema`). Both
// panels now WRITE this object back, so assembling it differently here would
// persist a shape the export never sees (`feedback_one_rule_one_place`).
// Which `lang` slots did a translate run actually produce? Compares the tree the
// run mutated against a snapshot taken before the fetch, and returns
// `{ path, value }` for every slot whose target text changed.
//
// This is what lets the run persist as a per-FIELD merge instead of replacing a
// whole layout. Replacing the whole layout meant anything the author changed during
// the request was silently discarded when it landed — a real loss, proven by probe,
// and a long "Replace all" run makes the window seconds wide. Blocking the UI while
// a run is in flight looked like a cheaper fix and is not a fix: the app's own left
// navigation sits outside this surface, so the author can simply go to Course
// architecture and type there.
const translatedSlots = (before, after, lang) => {
  const fields = [];
  collectLocFields(after, [], fields);
  const out = [];
  fields.forEach((f) => {
    const now = f.value && f.value[lang];
    if (now == null || !String(now).length) return;
    let b = before;
    for (let i = 0; i < f.path.length && b != null; i += 1) b = b[f.path[i]];
    const was = b && b[lang];
    if (String(was == null ? '' : was) !== String(now)) out.push({ path: f.path, value: now });
  });
  return out;
};

const assembleLayoutContent = (type, draft, contentMode) =>
  window.migrateQuizFeedback({
    ...window.layoutContentBase(type, contentMode), ...(draft || {}), type });

// Set one localised slot and return a draft patch carrying ONLY the branch it
// touched (plus `type`), merged over whatever the draft already held.
//
// The path has to be resolved against the ASSEMBLED content — a field the author has
// never edited exists only in the per-mode base, so setting it on the bare draft
// would walk into `undefined`. But writing the assembled object BACK is too much: it
// materialises every base default as author data. A single keystroke on a
// never-opened gaming layout wrote 16 keys and three empty `{en:''}` husks, and ran
// the quiz-feedback migration over `questions` on an edit that had nothing to do
// with a question — which permanently drops a legacy `feedbacks.wrong` value. The
// draft is a PATCH over the base at export time, so the untouched branches are
// already accounted for and must be left alone.
const writeLocBranch = (prev, declaredType, contentMode, path, lang, str) => {
  const draft = prev || {};
  const type = draft.type || declaredType;
  const next = setLocAtPath(
    assembleLayoutContent(type, draft, contentMode), path, lang, str);
  const branch = path[0];
  return { ...draft, type, [branch]: next[branch] };
};

const locRawSlot = (value, lang) =>
  (value && typeof value === 'object' && !Array.isArray(value) && typeof value[lang] === 'string')
    ? value[lang] : '';
// The SOURCE text of a field, with no fallback either: a plain string IS its own
// source (that is how the editors seed a fresh step / tab / hotspot), otherwise
// it is the source language's own slot.
const locSourceOf = (value, primary) => (typeof value === 'string'
  ? value
  : (locRawSlot(value, primary) || locRawSlot(value, 'en')));

const LOC_ASSESSMENT_EMPTY = {
  randomiseGroupOrder: true,
  pre: { enabled: false, groups: [] },
  post: { enabled: false, groups: [] },
};

function AssessmentPanel({ working, primary, course, settings, onUpdateSettings, onTranslateText }) {
  // Pre and post each navigate their own questions, so the author switches
  // assessment phase explicitly.
  const [sub, setSub] = React.useState('pre');
  const assessments = (settings && settings.assessments) || LOC_ASSESSMENT_EMPTY;
  const TABS = [
    ['pre',  'Pre-assessment'],
    ['post', 'Post-assessment'],
  ];

  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>

      <AssessmentQuestionsPanel key={sub} which={sub}
        phaseTitle={sub === 'pre' ? 'Pre-assessment' : 'Post-assessment'}
        assessments={assessments} course={course}
        primary={primary} working={working} onUpdateSettings={onUpdateSettings}
        onTranslateText={onTranslateText} />
    </>
  );
}

// One assessment phase's questions (pre OR post), read from the live slice.
// A dropdown navigates this phase's questions; each renders the prompt,
// its lettered answers with the per-answer note, and the two question-level
// feedback lines as source → target pairs. Remounted (via key) on phase change
// so the selection resets cleanly.
//
// Every edit funnels through `writeQuestion`, which rebuilds the whole
// assessments object immutably (new phase → new groups array → new questions
// array → new question) and hands it to onUpdateSettings. Nothing is mutated in
// place: React would not re-render, and this app has been bitten by that before.
// `locSet` is what writes the string, so the other languages on the same field
// — and the author's English source — always survive.
function AssessmentQuestionsPanel({ which, phaseTitle, assessments, course, primary, working, onUpdateSettings, onTranslateText }) {
  const phase = (assessments && assessments[which]) || { enabled: false, groups: [] };
  const groups = phase.groups || [];
  const modules = (course && course.modules) || [];

  // Group → module title. A group whose module was deleted keeps its raw id
  // rather than showing a blank dash, so the orphan is recognisable here too.
  const moduleLabel = (moduleId) => {
    const m = modules.find(mm => mm.id === moduleId);
    const t = m ? String(locText(m.title, primary) || '').trim() : '';
    return t || String(moduleId || 'Unassigned');
  };

  // Flatten to a navigable list, carrying the group/question indices the
  // write path needs. `uid` is the FE's stable React key and dropdown value.
  const flatQs = [];
  groups.forEach((g, gi) => (g.questions || []).forEach((q, qi) => {
    flatQs.push({ q, gi, qi, uid: q.uid || `${which}-g${gi}-q${qi}` });
  }));

  const [sel, setSel] = React.useState(flatQs[0] ? flatQs[0].uid : null);
  const cur = flatQs.find(x => x.uid === sel) || flatQs[0];

  // ── the single writer ─────────────────────────────────────────────────────
  const writeQuestion = (gi, qi, fn) => {
    if (!onUpdateSettings) return;
    const base = assessments || LOC_ASSESSMENT_EMPTY;
    const p = base[which] || { enabled: false, groups: [] };
    const nextGroups = (p.groups || []).map((g, i) => (
      i !== gi ? g : { ...g, questions: (g.questions || []).map((q, j) => (j === qi ? fn(q) : q)) }
    ));
    onUpdateSettings({ assessments: { ...base, [which]: { ...p, groups: nextGroups } } });
  };
  const setPrompt = (str) =>
    writeQuestion(cur.gi, cur.qi, q => ({ ...q, prompt: locSet(q.prompt, working, str) }));
  const setQField = (key, str) =>
    writeQuestion(cur.gi, cur.qi, q => ({ ...q, [key]: locSet(q[key], working, str) }));
  const setAnswerField = (ai, key, str) =>
    writeQuestion(cur.gi, cur.qi, q => ({
      ...q,
      answers: (q.answers || []).map((a, k) => (k === ai ? { ...a, [key]: locSet(a[key], working, str) } : a)),
    }));

  // Per-field Retranslate, added 2026-08-12 on Omar's instruction to spread it
  // beyond Modules. Built as ONE factory rather than five inline closures: every
  // field on this panel needs the same three steps (refuse when there is no source,
  // call the shared translate helper, write through the field's OWN setter), and
  // five copies of that is five places to forget the guard.
  //
  // `apply` is the very same function the field's `onChange` uses, so a
  // retranslate persists exactly as a keystroke does — the rule the Modules tab
  // established (`feedback_fix_must_be_reachable_on_the_users_path`).
  const retranslate = (source, apply) => (source && onTranslateText
    ? async () => {
        const t = await onTranslateText(source, working);
        if (t != null) apply(t);
      }
    : null);

  // ── honest empty states ───────────────────────────────────────────────────
  // A phase that is off never reaches the server, and a phase with no questions
  // has nothing to translate. Rendering an editor over either would be an editor
  // over nothing.
  const nothingToShow =
    phase.enabled !== true ? 'off'
      : !groups.length || !flatQs.length ? 'empty'
        : null;

  if (nothingToShow) {
    return (
      <PanelBody>
        <EmptyNote>
          {nothingToShow === 'off'
            ? <>The {phaseTitle.toLowerCase()} is switched off for this course, so it has no
                questions to translate. Turn it on and add questions on the <strong>Assessments</strong> screen.</>
            : <>The {phaseTitle.toLowerCase()} has no questions yet. Add them on
                the <strong>Assessments</strong> screen and they will appear here for translation.</>}
        </EmptyNote>
      </PanelBody>
    );
  }

  const options = [{
    group: phaseTitle,
    items: flatQs.map(x => ({
      value: x.uid,
      label: `${moduleLabel(groups[x.gi].moduleId)} — ${trunc(String(locText(x.q.prompt, primary) || 'Untitled question'), 44)}`,
    })),
  }];

  const q = cur.q;
  const answers = q.answers || [];
  // SOURCE-empty fields are skipped: there is nothing to translate, and an empty
  // pair reads as "the author forgot" rather than "this field doesn't exist yet".
  const promptSrc = String(locText(q.prompt, primary) || '');
  const correctSrc = String(locText(q.correctFeedback, primary) || '');
  const wrongSrc = String(locText(q.wrongFeedback, primary) || '');
  const visibleAnswers = answers
    .map((a, i) => ({ a, i,
      ansSrc: String(locText(a.text, primary) || ''),
      fbSrc: String(locText(a.wrongFeedback, primary) || '') }))
    .filter(x => x.ansSrc || x.fbSrc);

  // Translation progress over exactly the fields shown, counted on the RAW
  // slot — never through locText, whose source-language fallback would report
  // every untranslated field as done.
  let total = 0, filled = 0;
  const tally = (src, val) => {
    if (!src) return;
    total += 1;
    if (locRawSlot(val, working).trim()) filled += 1;
  };
  tally(promptSrc, q.prompt);
  visibleAnswers.forEach(x => { tally(x.ansSrc, x.a.text); tally(x.fbSrc, x.a.wrongFeedback); });
  tally(correctSrc, q.correctFeedback);
  tally(wrongSrc, q.wrongFeedback);

  return (
    <>
      <div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 24px 0' }}>
        <ItemNav options={options} value={cur.uid} onChange={setSel} />
        <div style={{ flex: 1 }} />
        {/* The write into courseSettings is immediate, but it only reaches
            IndexedDB until something PUTs the draft — so the honest control is
            the same server Save the Modules panel and the Assessments surface
            carry, not a pill claiming durability (2026-07-28). */}
        <LocSaveToServer dirtySignal={[locRawSlot(q.prompt, working),
          locRawSlot(q.correctFeedback, working), locRawSlot(q.wrongFeedback, working),
          ...answers.map(a => locRawSlot(a.text, working) + '|' + locRawSlot(a.wrongFeedback, working)),
        ].join('\u0000')} />
        <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} />

        {total === 0 ? (
          <EmptyNote>
            This question has no text in {locName(primary)} yet. Write it on
            the <strong>Assessments</strong> screen first — there is nothing to translate until then.
          </EmptyNote>
        ) : (<>
          {!!promptSrc && (
            <QPromptBlock source={promptSrc} target={locRawSlot(q.prompt, working)}
              onChange={setPrompt}
              onRetranslate={retranslate(promptSrc, setPrompt)} />
          )}

          {visibleAnswers.length > 0 && (
            <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>
          )}

          {visibleAnswers.map(({ a, i, ansSrc, fbSrc }) => (
            <AnswerCard key={a.uid || i} index={i} correct={a.correct === true}
              ansSource={ansSrc} ansTarget={locRawSlot(a.text, working)}
              onAns={v => setAnswerField(i, 'text', v)}
              showFeedback={!!fbSrc}
              fbSource={fbSrc} fbTarget={locRawSlot(a.wrongFeedback, working)}
              onFb={v => setAnswerField(i, 'wrongFeedback', v)}
              onRetranslateAns={retranslate(ansSrc, v => setAnswerField(i, 'text', v))}
              onRetranslateFb={retranslate(fbSrc, v => setAnswerField(i, 'wrongFeedback', v))} />
          ))}

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

          {!!correctSrc && (
            <QPromptBlock label="Feedback after a correct answer" icon="CheckCircle" emphasis={false}
              placeholder="Translate the correct-answer feedback…"
              source={correctSrc} target={locRawSlot(q.correctFeedback, working)}
              onChange={v => setQField('correctFeedback', v)}
              onRetranslate={retranslate(correctSrc, v => setQField('correctFeedback', v))} />
          )}
          {!!wrongSrc && (
            <QPromptBlock label="Feedback after a wrong answer" icon="AlertCircle" emphasis={false}
              placeholder="Translate the wrong-answer feedback…"
              source={wrongSrc} target={locRawSlot(q.wrongFeedback, working)}
              onChange={v => setQField('wrongFeedback', v)}
              onRetranslate={retranslate(wrongSrc, v => setQField('wrongFeedback', v))} />
          )}
        </>)}
      </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, sub, note, count, open, onToggle, children,
  level, headingLevel = 3, status, chip, title, domKey }) {
  // `I.List` was referenced by two call sites and does not exist in icons.jsx, so
  // those bands rendered an empty accent square — a fallback that cannot be wrong
  // costs nothing and stops the next unmapped container regressing it.
  const Ico = (icon && I[icon]) || I.ListChecks;
  // Group keys contain brackets (`questions[1]`), which are legal in an id but
  // hostile in a selector.
  const safe = String(domKey == null ? label : domKey).replace(/[^a-zA-Z0-9_-]/g, '-');
  const btnId = `loc-band-${safe}`;
  const bodyId = `${btnId}-body`;
  // ── `shared` is the MODULE level, not emphasis ─────────────────────────────
  // Omar, 2026-08-10: "each layout has show the same fields related to the module
  // … it seems that every layout has a module." The note already SAID it was
  // shared and he read it as per-layout, so the level has to be carried by the
  // frame rather than by a sentence. Everything here is on the BORDER channel
  // because `--surface-inset` is byte-identical to `--surface` in dark
  // (index.html:76) — a fill-based cue would simply not exist for half the day.
  const shared = level === 'shared';
  const H = `h${headingLevel}`;
  // Four border longhands below, rather than `border` plus a `borderLeft`
  // override. The shorthand-then-override form is correct CSS and renders
  // correctly in a browser, but it serialises unpredictably — jsdom drops the
  // shorthand entirely and reports only `border-left`, so the component harness
  // could not see the heavier outline at all. A style no automated test can read
  // is a style nothing guards.
  return (
    <div style={{
      borderTop: `1px solid var(${shared ? '--border-strong' : '--border'})`,
      borderRight: `1px solid var(${shared ? '--border-strong' : '--border'})`,
      borderBottom: `1px solid var(${shared ? '--border-strong' : '--border'})`,
      borderLeft: shared ? '3px solid var(--border-strong)' : '1px solid var(--border)',
      borderRadius: 'var(--radius-md)', overflow: 'hidden', background: 'var(--surface)' }}>
      <H style={{ margin: 0, fontSize: 'inherit', fontWeight: 'inherit' }}>
        <button id={btnId} className="focusable" onClick={onToggle} title={title}
          aria-expanded={!!open} aria-controls={bodyId} 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',
            // Neutral + outlined where every sibling is accent-filled: the icon
            // chip is the second-strongest carrier of "different level".
            background: shared ? 'var(--surface-inset)' : 'var(--accent-bg)',
            border: shared ? '1px solid var(--border-strong)' : 0,
            color: shared ? 'var(--text-muted)' : 'var(--accent-text)' }}>
            <Ico size={15} />
          </span>
          <span style={{ flex: 1, minWidth: 0 }}>
            <span style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
              <span className="truncate" style={{ fontSize: 13.5, fontWeight: 700,
                color: 'var(--text)' }}>{label}</span>
              {chip}
            </span>
            {/* The band's own first line of text — so "Question 2" says WHICH
                question without the translator opening it. Hidden when the band is
                OPEN, because the first row below then shows that exact sentence and
                printing it twice 12px apart is noise. */}
            {sub && !open ? (
              <span className="truncate" style={{ display: 'block', fontSize: 11.5,
                color: 'var(--text-muted)', fontWeight: 400 }}>{sub}</span>
            ) : null}
            {note ? (
              <span style={{ display: 'block', fontSize: 11, color: 'var(--text-faint)',
                fontWeight: 400 }}>{note}</span>
            ) : null}
          </span>
          {status}
          {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>
      </H>
      {open && (
        <div id={bodyId} role="region" aria-labelledby={btnId}
          style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
          {children}
        </div>
      )}
    </div>
  );
}

// ── What a CLOSED band has to tell you ──────────────────────────────────────
// Collapsing everything by default costs discoverability, and a `4/6` ratio does
// not pay it back: comparing six ratios at 11px in `--text-faint` is arithmetic,
// not a glance. So the salience is inverted from the language rail's dot — there,
// faint grey for "not started" is right because that row is a language you have
// not begun; here, "nothing done" is exactly where the work is, and giving the
// highest-priority band the quietest treatment would invert the panel's job.
//
// A WORD, not a colour alone: `4 to translate` survives colour-blindness and
// survives a screenshot. `.pill.issues` carries `--warning-bg`/`--warning-text`,
// which are defined per theme, so it is theme-safe by construction.
function LocBandStatus({ filled, total }) {
  if (!total) {
    return <span className="pill" style={{ fontSize: 9.5, height: 18 }}>No English yet</span>;
  }
  if (filled >= total) {
    return <I.Check size={12} aria-hidden="true" style={{ color: 'var(--success)' }} />;
  }
  return (
    <span className="pill issues" style={{ fontSize: 9.5, height: 18, whiteSpace: 'nowrap' }}>
      {total - filled} to translate
    </span>
  );
}

// Build the grouped, localisable field list for a quiz_gaming row. Each field:
// { key, path, label, source, target, badge? }. Only present groups are emitted.
//
// `path` is what makes this panel writable. It is the set-back route into the
// assembled layout content — the same kind of path `collectLocFields` produces
// for the Modules panel — so an edit here goes through `setLocAtPath` and the
// app's draft-save exactly as an edit there does. Before it existed the panel had
// no way to name where a box's text belonged, which is why its Save was a
// 700 ms `setTimeout` and its toolbar said "Preview only — edits here aren't
// saved yet" (Omar, 2026-07-31: "Also on this page there is still this message").
// `working` is required, not optional: without it every field's target column
// came back as a hard-coded empty string, so this panel reported "Not translated
// yet" for text that was translated, stored and exporting correctly into the ZIP.
// Omar, 2026-07-30: "The localisation is correctly exported into the course ZIP
// but not visualised into the Localisation admin page."
//
// The target MUST read through `locRawSlot` — whose own comment two hundred lines
// up already says "Every target field on this surface reads through here" — and
// never through `locText`, which falls back to English when the slot is missing.
// An English sentence sitting in the Italian box reads as a translation nobody has
// to do, which is the opposite failure and just as misleading
// (`feedback_one_rule_one_place`: the invariant was written down and this one
// panel did not honour it).
function buildQuizGamingGroups(row, primary, working) {
  if (!row) return [];
  const c = row.content || {};
  const txt = (v) => locText(v, primary);
  // One place that pairs a field's source with its stored target AND its write
  // path, so no call site below can add a field and forget one of the three.
  // The React key is derived from the path rather than spelled a second time —
  // two names for one field is how they drift.
  const pair = (path, label, v, extra) => ({
    key: path.join('.'), path, label,
    source: txt(v), target: locRawSlot(v, working), ...(extra || {}),
  });
  const groups = [];

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

  // NO intro-panel group. `introTitle` / `introText` are fields of other layout
  // types; `QuizGamingContentSchema` is `.strict()` and has neither, and neither
  // the demo sample nor the blank skeleton for `quiz_gaming` defines them — so
  // these rows could only ever appear for a stray key, and only to offer a box
  // whose text the export drops before it reaches the ZIP
  // (`feedback_no_false_affordance_toggles`). Harmless while this panel was
  // read-only; not harmless now that a box here saves. Nothing is hidden by
  // dropping them: the Modules tab lists every LocalizedString the assembled
  // content actually carries, stray keys included.
  const start = c.gamingStartScreen || {};
  const startFields = [];
  if (start.title != null) startFields.push(pair(['gamingStartScreen', 'title'], 'Start title', start.title));
  if (start.body != null) startFields.push(pair(['gamingStartScreen', 'body'], 'Start text', 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(pair(['gamingEndScreens', 'win', 'title'], 'Win title', ends.win.title));
    if (ends.win.body != null) winFields.push(pair(['gamingEndScreens', 'win', 'body'], 'Win text', ends.win.body));
    if (winFields.length) groups.push({ label: 'Win screen', icon: 'Trophy', fields: winFields });
  }
  if (ends.failure) {
    const failFields = [];
    // `failure`, not `fail` — the schema's key. The old synthetic `fail.title`
    // was only ever a React key; a write path has to be the real one.
    if (ends.failure.title != null) failFields.push(pair(['gamingEndScreens', 'failure', 'title'], 'Fail title', ends.failure.title));
    if (ends.failure.body != null) failFields.push(pair(['gamingEndScreens', 'failure', 'body'], 'Fail text', ends.failure.body));
    if (failFields.length) groups.push({ label: 'Fail screen', icon: 'AlertCircle', fields: failFields });
  }

  (c.questions || []).forEach((q, qi) => {
    const fields = [pair(['questions', qi, 'text'], 'Question', q.text)];
    (q.answers || []).forEach((a, ai) => {
      const letter = String.fromCharCode(65 + ai);
      fields.push(pair(['questions', qi, 'answers', ai, 'text'], `Answer ${letter}`, a.text, {
        badge: a.isCorrect
          ? <span className="pill accepted" style={{ fontSize: 9, height: 15 }}><I.Check size={9} />Correct</span>
          : null }));
      // Per-answer feedback — a real `QuizAnswerSchema` LocalizedString the export
      // ships. It had no row here, and since the new "N / M translated" tally is
      // computed from THIS list, the tab could read 15 / 15 while the export's
      // coverage gate 422'd the build naming a path the tab never showed. Found
      // 2026-07-31; the general form is guarded by a test that walks the schema and
      // requires every LocalizedString to be paired or explicitly excluded, so the
      // next added field cannot repeat it
      // (`feedback_guard_the_invariant_not_the_list`).
      if (a.feedback != null) {
        fields.push(pair(['questions', qi, 'answers', ai, 'feedback'],
          `Answer ${letter} · feedback`, a.feedback));
      }
    });
    // The ONE message a multi-correct question shows once answered. Same story as
    // per-answer feedback above: schema field, exported, previously unlisted.
    if (q.feedbackText != null) {
      fields.push(pair(['questions', qi, 'feedbackText'],
        'Feedback after answering', q.feedbackText));
    }
    // 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(pair(['questions', qi, 'content', 'tooltipValues', nm], `${lab} tooltip`, 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;
}

// REAL read / edit / persist, as of 2026-07-31. What it replaced: `useTargets`, a
// local `useState` map with a 700 ms `setTimeout` for a save, and a toolbar pill
// reading "Preview only — edits here aren't saved yet". The pill was honest about
// the code, but the code was the problem: every field on this tab is a
// LocalizedString inside a layout draft that the Modules tab has been editing and
// persisting since 2026-07-28. The data was real, the export was real, and only
// this tab's write path was a stub — so an author who localised a gaming quiz on
// the tab named after it lost the work.
function QuizGamingPanel({ course, layoutDrafts, working, primary, onUpdateDrafts, onTranslateText }) {
  const rows = React.useMemo(() => {
    const out = [];
    const drafts = layoutDrafts || {};
    (course.modules || []).forEach(m => {
      (m.layouts || []).forEach(l => {
        const draft = drafts[l.id] || {};
        const type = draft.type || l.type;
        if (type === 'quiz_gaming') {
          // Per-mode base + draft merge + the quiz-feedback migration, i.e.
          // EXACTLY what ModulesPanel assembles and what surface-export builds
          // per layout. It matters more here than it did when this panel was
          // read-only: the object below is now written straight back, so
          // assembling it differently from the export would persist a shape the
          // export never sees (`feedback_one_rule_one_place`).
          const content = assembleLayoutContent(type, draft, course.contentMode);
          out.push({ id: l.id, n: l.n, module: m, summary: l.summary, content,
            declaredType: l.type });
        }
      });
    });
    return out;
  }, [course, layoutDrafts]);

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

  // Rebuilt whenever the draft changes, so the boxes show the stored text — an
  // author's keystroke, a course-wide Translate run landing while this panel is
  // open, and a Retranslate all arrive the same way. The old `useTargets` seed
  // needed a `storedFingerprint` in its dependency list to notice a write from
  // outside; reading the draft directly removes the question.
  const groups = React.useMemo(
    () => buildQuizGamingGroups(row, primary, working), [row, primary, working]);
  const allFields = groups.flatMap(g => g.fields);

  // Persist one field: rebuild the whole assembled layout with the new value at
  // the field's path, then hand it to the app's debounced draft-save — the same
  // two lines as ModulesPanel's `editField`, deliberately, because "what saving
  // means" must not have two definitions on one surface.
  // A FUNCTION patch, exactly as ModulesPanel does it and for the same reason: the
  // layout id is named in the call (captured with the row that produced the field),
  // while the content is assembled from whatever the reducer holds at apply time.
  // A ref-based version of this wrote a Retranslate result into the layout the
  // author had switched TO, replacing that layout's correct translation with a
  // translation of the previous layout's English.
  const editField = (path, str) => {
    if (!row || !onUpdateDrafts) return;
    const layoutId = row.id;
    const declaredType = row.declaredType || (row.content && row.content.type);
    onUpdateDrafts(layoutId, (prev) => writeLocBranch(
      prev, declaredType, course.contentMode, path, working, str));
  };

  // Same counting rule as the Modules tab: a field counts once its English has
  // text, and counts as done once the target's OWN slot has text.
  const tally = allFields.reduce((acc, f) => ({
    total: acc.total + (f.source ? 1 : 0),
    filled: acc.filled + (f.source && String(f.target || '').trim() ? 1 : 0),
  }), { total: 0, filled: 0 });

  // 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 (
    <>
      {/* The Modules tab's toolbar, not LocaleToolbar's preview pill: the same
          real server-save control and the same translated tally, because this tab
          now does the same thing. */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 24px 0' }}>
        <ItemNav options={options} value={sel} onChange={setSel} />
        <div style={{ flex: 1 }} />
        {/* Keyed per field, so two fields cannot swap text and read as unchanged:
            the signal has to move on every edit this panel can make, or the pill
            asserts a save that never happened. */}
        <LocSaveToServer dirtySignal={sel + '#' + allFields.map(f => f.key + '=' + f.target).join('|')} />
        <span style={{ width: 1, height: 22, background: 'var(--border)' }} />
        <span title={`${tally.filled} of ${tally.total} fields with English text have a ${locName(working)} translation`}
          style={{ fontSize: 11.5, color: 'var(--text-muted)', fontFamily: 'var(--font-mono)',
            whiteSpace: 'nowrap' }}>{tally.filled} / {tally.total} translated</span>
      </div>
      <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)}>
            {/* Layout id in each key — see ModulesPanel's rowFor: two gaming layouts
                produce identical path-derived keys, and a reused row carries a stuck
                "Translating…" spinner across the switch. */}
            {g.fields.map(f => (
              <PairField key={`${row.id}:${f.key}`} label={f.label} source={f.source}
                badge={f.badge}
                target={f.target || ''}
                onChange={v => editField(f.path, v)}
                onRetranslate={f.source && onTranslateText
                  ? async () => {
                      const t = await onTranslateText(f.source, working);
                      if (t != null) editField(f.path, t);
                    }
                  : null} />
            ))}
          </LocGroupSection>
        ))}
      </PanelBody>
    </>
  );
}

// QUESTION — the prominent block at the top of a question's localisation.
// `label` / `icon` / `emphasis` are optional so the same banded pair can carry
// the question-level feedback lines ("Feedback after a correct answer") at a
// calmer weight, without a second near-identical component.
function QPromptBlock({ source, target, onChange, label = 'Question',
  icon = 'ClipboardCheck', placeholder = 'Translate the question…', emphasis = true,
  onRetranslate }) {
  const Ico = I[icon] || I.ClipboardCheck;
  const size = emphasis ? 15 : 13;
  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%' }}>
        <Ico size={13} style={{ color: 'var(--accent-text)' }} />
        <span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '.06em',
          textTransform: 'uppercase', color: 'var(--accent-text)' }}>{label}</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: size, fontWeight: emphasis ? 600 : 500,
            lineHeight: 1.45 }}>{locText(source)}</p>
        </div>
        <div style={{ padding: '12px 14px', background: 'var(--surface)' }}>
          {onRetranslate && (
            <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 6 }}>
              <RetranslateButton onRetranslate={onRetranslate} />
            </div>
          )}
          <textarea className="field" value={target} onChange={e => onChange(e.target.value)}
            placeholder={placeholder}
            style={{ width: '100%', minHeight: emphasis ? 52 : 44, fontSize: size, fontWeight: 500,
              lineHeight: 1.45, resize: 'vertical' }} />
        </div>
      </div>
    </div>
  );
}

// ANSWER (top) + its FEEDBACK (nested, indented, inset, smaller).
// `showFeedback` hides the nested note row when the answer has no source note
// to translate — an empty pair reads as "the author forgot", not "this field
// doesn't exist yet".
function AnswerCard({ index, correct, ansSource, ansTarget, onAns, fbSource, fbTarget, onFb,
  showFeedback = true, onRetranslateAns, onRetranslateFb }) {
  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)' }}>
          {onRetranslateAns && (
            <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 5 }}>
              <RetranslateButton onRetranslate={onRetranslateAns} compact />
            </div>
          )}
          <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 */}
      {showFeedback && (
      <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)' }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, minHeight: 18 }}>
            <FieldLabel inline>Feedback</FieldLabel>
            <div style={{ flex: 1 }} />
            {onRetranslateFb && <RetranslateButton onRetranslate={onRetranslateFb} compact />}
          </div>
          <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; }

Object.assign(window, { SurfaceLocalisation });
