// Dynamo Authoring — main app composition.

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "dark": false,
  "density": "regular",
  "rail": "expanded",
  "showAiBadges": true,
  "showValidationDot": true,
  "selectedLayoutType": "sequence",
  "viewAs": "author",
  "rolesStartBlank": false
}/*EDITMODE-END*/;

// The course descriptor the app is editing: { id, title, topic, defaultLang,
// enabledLangs, brand }. The seeded demo course keeps its narrative sample
// content; every other course is a real row from GET/POST /v1/courses and
// authors from a blank base. Persisted in localStorage so a refresh reopens
// the same course.
const LS_ACTIVE_COURSE = 'dynamo:active-course';
const DEMO_COURSE_DESCRIPTOR = {
  id: window.DEMO_COURSE_ID,
  title: 'Difficult Interactions',
  topic: 'Leadership · Conversational skills',
  defaultLang: 'en',
  enabledLangs: ['en', 'it'],
  brand: 'MOHG',
};
function loadActiveCourse() {
  try {
    const raw = localStorage.getItem(LS_ACTIVE_COURSE);
    if (!raw) return DEMO_COURSE_DESCRIPTOR;
    const c = JSON.parse(raw);
    return (c && typeof c.id === 'string') ? c : DEMO_COURSE_DESCRIPTOR;
  } catch { return DEMO_COURSE_DESCRIPTOR; }
}

function App() {
  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
  const [surface, setSurface] = React.useState('dynamo-home');
  const [orgId, setOrgId] = React.useState('mohg');
  const [activeCourse, setActiveCourse] = React.useState(loadActiveCourse);
  const isDemoCourse = activeCourse.id === window.DEMO_COURSE_ID;
  const [selectedModuleId, setSelectedModuleId] = React.useState('M2');
  const [selectedLayoutId, setSelectedLayoutId] = React.useState('M2-L3');
  const [moduleOrder, setModuleOrder] = React.useState(null); // null = use default ordering
  const [layoutOrders, setLayoutOrders] = React.useState({}); // { [moduleId]: layoutId[] }
  const [groupTitleOverrides, setGroupTitleOverrides] = React.useState({}); // { [groupId]: title }
  const [moduleOverrides, setModuleOverrides] = React.useState({}); // { [moduleId]: { title?, summary? } }
  const [layoutStatusOverrides, setLayoutStatusOverrides] = React.useState({}); // { [layoutId]: 'pending'|'proposed'|'accepted'|'issues' }
  const [layoutDrafts, setLayoutDrafts] = React.useState({}); // { [layoutId]: layoutDataObject }
  // `patch` is normally an object and is SHALLOW-MERGED over the layout's draft.
  //
  // A FUNCTION patch is also accepted, and it REPLACES the draft with whatever it
  // returns, after being handed the current one. Two things need that and cannot
  // be expressed by a merge (both found 2026-07-30 by an adversarial review of the
  // subtitle work):
  //
  //   · DELETING a field. An absent key in a merge patch cannot remove a present
  //     key from the draft, so "remove this video's subtitle track" reported
  //     success and changed nothing for a layout whose field sits at the root.
  //   · An ASYNC writer. A caller that builds the whole content from a `layoutDrafts`
  //     prop captured before an await (a Deepgram round trip is seconds) ships a
  //     patch derived from stale state, silently reverting anything the author
  //     changed on that layout meanwhile (`feedback_derived_state_read_before_merge`).
  const updateDraft = React.useCallback((layoutId, patch) =>
    setLayoutDrafts(d => ({
      ...d,
      [layoutId]: typeof patch === 'function'
        ? patch(d[layoutId] || {})
        : { ...(d[layoutId] || {}), ...patch },
    })), []);
  // Course-level settings slice — the single source of truth for the Course
  // settings surface (Surface 7 · SurfaceBrand). Lives in app state, not
  // localStorage, so edits persist across surface navigation but reset on
  // browser refresh (expected — see Course_settings_design audit #2).
  // Per-course default: sample settings for the demo course, blank settings
  // for a from-zero course. Kept in a ref so hydrateDraft (stable callback)
  // always resolves the CURRENT course's default.
  const defaultSettingsFor = (c) => (c.id === window.DEMO_COURSE_ID)
    ? window.SAMPLE_COURSE_SETTINGS
    : window.makeBlankCourseSettings(c);
  const activeCourseRef = React.useRef(activeCourse);
  activeCourseRef.current = activeCourse;
  const [courseSettings, setCourseSettings] = React.useState(() => defaultSettingsFor(activeCourse));
  const [addedModules, setAddedModules] = React.useState([]); // user-created modules
  const [addedGroups, setAddedGroups]   = React.useState([]); // user-created module groups
  const [addedLayouts, setAddedLayouts] = React.useState({}); // { [moduleId]: layout[] } — layouts appended to a module
  const [deletedLayoutIds, setDeletedLayoutIds] = React.useState([]); // hidden layouts
  const [deletedModuleIds, setDeletedModuleIds] = React.useState([]); // hidden modules
  const [deletedGroupIds, setDeletedGroupIds]   = React.useState([]); // hidden chapters (+ their modules)
  const [showValidation, setShowValidation] = React.useState(false);
  const [showCmdK, setShowCmdK] = React.useState(false);
  // Preview scope: `null` = closed, `{}` = the whole course, `{ moduleId, moduleTitle }` =
  // one module. A boolean could not express which module, and a second boolean beside it
  // would let both be true at once — a state the UI has no rendering for.
  const [preview, setPreview] = React.useState(null);

  const currentOrg = window.SAMPLE_ORGS.find(o => o.id === orgId) || window.SAMPLE_ORGS[0];
  // Build a per-org course shell so the breadcrumb shows org-appropriate brand.
  // Content base: the demo course keeps SAMPLE_COURSE (narrative samples);
  // every real course starts from an empty blank base (makeBlankCourse).
  const liveCourse = React.useMemo(() => {
    const base = isDemoCourse ? SAMPLE_COURSE : window.makeBlankCourse(activeCourse);
    const allModules = [...base.modules, ...addedModules];
    const orderedModules = moduleOrder
      ? [
          ...moduleOrder.map(id => allModules.find(m => m.id === id)).filter(Boolean),
          // append any new modules not yet reflected in the saved ordering
          ...allModules.filter(m => !moduleOrder.includes(m.id)),
        ]
      : allModules;
    // A module's EFFECTIVE chapter: the override (written when it is dragged
    // between chapters) wins over the base value. This must be resolved BEFORE
    // the deleted-chapter filter below — filtering on the base `m.group` while
    // rendering the override meant a module dragged OUT of a chapter was still
    // deleted with that chapter (and one dragged IN survived as an invisible
    // orphan). Both lost or stranded the module and all its layouts, silently
    // and irreversibly, and the confirm dialog counted the merged list so it
    // truthfully said "0 modules" while deleting one (fixed 2026-07-27).
    const effectiveGroup = (m) => {
      const ov = moduleOverrides[m.id];
      const g = ov && 'group' in ov ? ov.group : m.group;
      return g || null;
    };
    const modules = orderedModules
      // Drop deleted modules and any module whose CURRENT chapter was deleted.
      .filter(m => {
        if (deletedModuleIds.includes(m.id)) return false;
        const g = effectiveGroup(m);
        return !(g && deletedGroupIds.includes(g));
      })
      .map(m => {
        const ov = moduleOverrides[m.id];
        let merged = ov ? { ...m, ...ov } : m;
        // Never leave a module pointing at a deleted chapter: it would match no
        // bucket in the modules column and become invisible while still being
        // exported. Fall back to ungrouped.
        if (merged.group && deletedGroupIds.includes(merged.group)) {
          merged = { ...merged, group: null };
        }
        // Base layouts + user-added layouts, minus deleted ones.
        let layouts = [...merged.layouts, ...(addedLayouts[m.id] || [])]
          .filter(l => !deletedLayoutIds.includes(l.id));
        // Apply per-layout status overrides set by the layout editor.
        layouts = layouts.map(l =>
          layoutStatusOverrides[l.id] ? { ...l, status: layoutStatusOverrides[l.id] } : l);
        // Apply the saved drag order, appending any layouts not yet in it.
        const order = layoutOrders[m.id];
        if (order) {
          const ordered = order.map(id => layouts.find(l => l.id === id)).filter(Boolean);
          const rest = layouts.filter(l => !order.includes(l.id));
          layouts = [...ordered, ...rest];
        }
        // Renumber for display so L-numbers stay contiguous after add/delete.
        layouts = layouts.map((l, i) => ({ ...l, n: i + 1 }));
        return { ...merged, layouts };
      });
    const moduleGroups = [...(base.moduleGroups || []), ...addedGroups]
      .filter(g => !deletedGroupIds.includes(g.id))
      .map(g => ({ ...g, title: groupTitleOverrides[g.id] || g.title }));
    // The demo course's content base is the frozen SAMPLE_COURSE constant, whose
    // `languages` can never gain a new one. `makeBlankCourse` maps the descriptor's
    // enabledLangs for every real course, but the demo ignored it — so a language
    // enabled server-side vanished from the rail the moment the translation
    // succeeded (the graduation step drops it from `pendingLangs`, and nothing
    // downstream had it). Union the two for the demo course only.
    const languages = isDemoCourse
      ? [...new Set([...(base.languages || []), ...(activeCourse.enabledLangs || [])])]
      : base.languages;
    return { ...base, languages, contentMode: isDemoCourse ? 'sample' : 'blank',
      modules, moduleGroups, brand: currentOrg.name };
  }, [activeCourse, isDemoCourse, currentOrg, moduleOrder, layoutOrders, groupTitleOverrides, moduleOverrides, layoutStatusOverrides, addedModules, addedGroups, addedLayouts, deletedLayoutIds, deletedModuleIds, deletedGroupIds]);

  // Expose the live course id so the media-upload widgets (VideoMediaBlock,
  // CompanionSlot, BackgroundPicker, MediaSlot) can target the same course the
  // export surface uses — without threading a courseId prop through every
  // layout-editor body. Falls back to the seeded test-course UUID.
  window.dynamoCourseId = liveCourse.id || '7bf69e2d-51e7-4856-86bb-bb2b77b47216';
  // Latest merged course, readable from stable callbacks (duplicate-layout needs
  // to find a layout by id without re-creating its handler on every edit).
  const liveCourseRef = React.useRef(liveCourse);
  liveCourseRef.current = liveCourse;
  // Content mode for surfaces that can't take a course prop (legacy demo
  // panels, editor add-affordance seeds): 'sample' = demo course, 'blank' =
  // from-zero course. Same value as liveCourse.contentMode.
  window.dynamoContentMode = isDemoCourse ? 'sample' : 'blank';

  // Append a new module (and optionally a new group) created from the
  // "+ Add module" dialog. Auto-select so the user lands on it.
  const handleAddModule = React.useCallback((payload) => {
    let groupId = payload.groupId || null;
    // Titles are LocalizedStrings everywhere downstream (schema, translate
    // walk, export) — wrap the dialog's plain strings on creation, or every
    // from-zero export dies at PUT /draft with a 400.
    const loc = (v) => (v && typeof v === 'object') ? v : { en: v || '' };
    if (payload.newGroupTitle) {
      const newGroup = {
        id: 'g_new_' + (Date.now().toString(36)),
        title: loc(payload.newGroupTitle),
      };
      setAddedGroups(gs => [...gs, newGroup]);
      groupId = newGroup.id;
    }
    setAddedModules(ms => {
      const baseCount = isDemoCourse ? SAMPLE_COURSE.modules.length : 0;
      const n = baseCount + ms.length + 1;
      const id = 'M' + n;
      const layouts = (payload.layouts || []).map((l, i) => ({
        ...l, id: `${id}-L${i+1}`, n: i + 1,
      }));
      const totalSec = layouts.length * 90; // ~1.5 min/layout placeholder
      const mm = String(Math.floor(totalSec / 60)).padStart(2, '0');
      const ss = String(totalSec % 60).padStart(2, '0');
      const next = {
        id, n,
        title: loc(payload.title),
        group: groupId || undefined,
        summary: loc(payload.summary || ''),
        duration: layouts.length ? `${mm}:${ss}` : '00:00',
        mandatory: false,
        layouts,
      };
      // Defer the select so the new module is in liveCourse first.
      setTimeout(() => setSelectedModuleId(id), 0);
      // Q4 (Omar, 2026-08-09: "Everyone") — a newly added module is visible to
      // every role until someone excludes it. Nothing the author creates is
      // silently invisible.
      //
      // Deferred with the same setTimeout idiom as the selection above, so the
      // write happens OUTSIDE this updater rather than as a side effect inside
      // it. `withModuleVisibleToAllRoles` is idempotent, so even a double
      // invocation cannot produce a duplicate id.
      //
      // Applied eagerly at creation, never derived: a rule like "a module not
      // listed is visible" would silently re-admit a module the author had
      // deliberately excluded — the trap in
      // `feedback_derived_state_read_before_merge`. And a role with NO explicit
      // list is skipped by that helper, because appending would turn "sees
      // everything" into "sees only this one".
      setTimeout(() => setCourseSettings(s => {
        const roles = s.roles || [];
        if (!roles.length) return s;
        return { ...s, roles: window.withModuleVisibleToAllRoles(roles, id) };
      }), 0);
      return [...ms, next];
    });
  }, [isDemoCourse]);

  // How many layouts the author can actually SEE that are gaming quizzes.
  // ONE counter, read by both directions of the master-switch rule below, so the
  // two can never disagree about what "no gaming quizzes in the course" means.
  // It reads `liveCourse`, which has already dropped deleted layouts, deleted
  // modules and deleted chapters — so every way a gaming quiz can disappear is
  // covered by construction rather than by a list of delete handlers to keep in
  // step. It honours a draft's `type` override for the same reason.
  const gamingCount = React.useMemo(() => liveCourse.modules.reduce((acc, m) =>
    acc + (m.layouts || []).filter(l => {
      const t = (layoutDrafts[l.id] && layoutDrafts[l.id].type) || l.type;
      const ct = window.canonicalLayoutType ? window.canonicalLayoutType(t) : t;
      return ct === 'quiz_gaming';
    }).length, 0), [liveCourse, layoutDrafts]);

  // Append a new layout (of the chosen type) to a module. Auto-select it.
  const handleAddLayout = React.useCallback((moduleId, type) => {
    const newId = `${moduleId}-Lx${Date.now().toString(36)}`;
    const meta = (window.LAYOUT_TYPES || []).find(t => t.id === type);
    // `type` may be a display id (e.g. a hidden_items flat/360° dropdown id).
    // Store the CANONICAL type on the data (Fix 4) and seed the matching media
    // field so the variant is encoded by the URL, not the type string.
    const canon = window.canonicalLayoutType ? window.canonicalLayoutType(type) : type;
    const layout = {
      id: newId, type: canon, status: 'pending',
      summary: meta ? `New ${meta.label.toLowerCase()}` : 'New layout',
      ...(type === 'hidden_items_360_image' ? { image360Url: 'placeholder:panorama', items: [] }
        : canon === 'hidden_items' ? { imageUrl: 'placeholder:scene-new', items: [] } : {}),
    };
    setAddedLayouts(al => ({ ...al, [moduleId]: [...(al[moduleId] || []), layout] }));
    setSelectedModuleId(moduleId);
    setTimeout(() => setSelectedLayoutId(newId), 0);

    // Auto-enable the gaming-quiz flow when the FIRST quiz_gaming layout is
    // added (count 0 → 1). Adding one implies the author wants the flow on, so
    // flip the course-level master switch. The OFF direction lives in the effect
    // below and reads the SAME counter — see `gamingCount`.
    if (canon === 'quiz_gaming' && gamingCount === 0) {
      // Same-reference bail-out when already on → no spurious state churn on
      // a 2nd+ add (the count guard already prevents this, but keep it safe).
      setCourseSettings(s => (s.dftiFlow && s.dftiFlow.enabled)
        ? s
        : { ...s, dftiFlow: { ...s.dftiFlow, enabled: true } });
    }
  }, [gamingCount]);

  // Auto-DISABLE the gaming-quiz flow when the LAST gaming quiz goes away.
  //
  // Omar, 2026-07-30: "It is ok for the toggle to switches itself on when I add a
  // gaming quiz, but at the same time, it has to switches itself off when I delete
  // all of them as there are no more gaming quiz in the course."
  //
  // This REVERSES Quiz_layouts_options §13.4 Fix 4 ("removing the LAST quiz_gaming
  // preserves enable — the author may be mid-refactor"), on his explicit
  // instruction: the switch describes the course, not a preference. Leaving it on
  // with nothing using it put a blank profile form in front of every learner and
  // blocked the export with eight errors naming a screen he had never visited.
  //
  // Written as ONE effect on the count, NOT a line added to each of the three
  // delete handlers (layout / module / chapter). A rule spread across a hand-kept
  // list of call sites is the shape that has been wrong repeatedly here, and a
  // fourth way to remove a layout — added later by someone who never reads this —
  // would silently miss it. The invariant is "enabled ⇒ something uses it".
  //
  // It fires ONLY on a transition observed while editing. A count of zero seen on
  // a fresh load, or after switching to a different course, is not a deletion:
  // flipping a STORED setting because a page was opened would rewrite the author's
  // data with no author action behind it. `hydrateDraft` re-baselines explicitly,
  // and boot, course switch and snapshot-restore all go through it.
  //
  // The course id in the baseline is deliberately BELT-AND-BRACES, and measured as
  // such: with it removed, `gaming-toggle.harness.mjs switch-live` still passes
  // 13/13 even with the draft read slowed to 400 ms, because React commits the
  // whole hydration at once. It is kept so that auto-disable does not silently
  // depend on the unenforced invariant "every course switch goes through
  // hydrateDraft" — if a future path changes `activeCourse` without hydrating,
  // this still refuses to read it as a deletion.
  //
  // Pre-existing on-with-nothing state stays the author's to resolve, which is
  // what the export blocker in `surface-export.jsx` is for.
  const activeCourseId = activeCourse ? activeCourse.id : null;
  const gamingBaseline = React.useRef({ id: null, count: null });
  // Set by `hydrateDraft` to skip exactly one observation: the one where a whole
  // draft arrives at once. React batches those setters into a single commit, so
  // one skip is one hydration.
  const gamingRebaseline = React.useRef(false);
  React.useEffect(() => {
    const prev = gamingBaseline.current;
    gamingBaseline.current = { id: activeCourseId, count: gamingCount };
    if (gamingRebaseline.current) { gamingRebaseline.current = false; return; }
    if (prev.id !== activeCourseId || prev.count === null) return;
    if (prev.count > 0 && gamingCount === 0) {
      setCourseSettings(s => (s.dftiFlow && s.dftiFlow.enabled)
        ? { ...s, dftiFlow: { ...s.dftiFlow, enabled: false } }
        : s);
    }
  }, [gamingCount, activeCourseId]);

  const handleDeleteLayout = React.useCallback((layoutId) => {
    setDeletedLayoutIds(ids => ids.includes(layoutId) ? ids : [...ids, layoutId]);
  }, []);
  // The layout editor's kebab needs this too — its "Delete layout" confirm used
  // to just close the dialog, leaving the layout in place and in the export
  // (fixed 2026-07-27). Same handler the Course-architecture list already uses.
  window.dynamoDeleteLayout = handleDeleteLayout;

  // Duplicate a layout into a chosen module, INCLUDING its edited content.
  // The dialog previously just closed itself, so "Duplicate layout" silently did
  // nothing (fixed 2026-07-26). Copying the layout entry alone is not enough —
  // the authored fields live in `layoutDrafts` keyed by layout id, so the draft
  // is deep-copied under the new id or the duplicate would come out blank.
  const handleDuplicateLayout = React.useCallback((layoutId, targetModuleId) => {
    let source = null;
    for (const m of liveCourseRef.current.modules) {
      const found = (m.layouts || []).find(l => l.id === layoutId);
      if (found) { source = found; break; }
    }
    if (!source || !targetModuleId) return;
    const newId = `${targetModuleId}-Lx${Date.now().toString(36)}`;
    const copy = { ...source, id: newId, status: 'pending' };
    setAddedLayouts(al => ({
      ...al, [targetModuleId]: [...(al[targetModuleId] || []), copy],
    }));
    setLayoutDrafts(d => {
      const src = d[layoutId];
      if (!src) return d;
      // Structured deep copy so editing the duplicate can't mutate the original.
      let cloned;
      try { cloned = JSON.parse(JSON.stringify(src)); } catch { cloned = { ...src }; }
      // ★ A draft carries IDENTITY as well as content, so a verbatim clone is a layout
      // wearing the original's name. The editor seeds every draft with
      // `{ ...layoutContentBase(...), ...(layout || {}) }`
      // (`surface-layout-editor.jsx:91-94`), which folds the architecture entry's `id`,
      // `n`, `status` and `summary` into the stored content. Cloning that unchanged gave
      // the duplicate `id: "M1-L3"` — the SOURCE's id — and Omar saw the consequences on
      // 2026-08-13: the Module preview rendered "a flat color background", the EDITING
      // badge vanished, and the layout list read L1, L2, L3, L4, **L3**.
      //
      // `id` is STAMPED rather than deleted. Deleting it looks tidier and is worse: the
      // editor passes the draft itself as the right panel's `layout`
      // (`surface-layout-editor.jsx:186`), so `currentLayoutId` is `draft.id`, and an
      // absent id sends focus to the module's first layout — the same bug at a new
      // address.
      //
      // `n` and `status` are DELETED, not stamped, because the architecture entry is
      // their owner: `liveCourse` renumbers `n` by position (so the copy is L5, not a
      // second L3) and the entry already carries `status: 'pending'`. Deleting lets the
      // real values win the merge; setting them to `undefined` would not — an
      // `undefined` value still shadows during a spread
      // (`feedback_absence_and_emptiness_read_the_same`). `summary` is deliberately kept:
      // it is descriptive text the author wrote, and copying it is the point.
      delete cloned.n;
      delete cloned.status;
      return { ...d, [newId]: { ...cloned, id: newId } };
    });
    setSelectedModuleId(targetModuleId);
    setTimeout(() => setSelectedLayoutId(newId), 0);
  }, []);
  window.dynamoDuplicateLayout = handleDuplicateLayout;

  const handleDeleteModule = React.useCallback((moduleId) => {
    setDeletedModuleIds(ids => ids.includes(moduleId) ? ids : [...ids, moduleId]);
  }, []);

  const handleDeleteGroup = React.useCallback((groupId) => {
    setDeletedGroupIds(ids => ids.includes(groupId) ? ids : [...ids, groupId]);
    // Clear any module override still pointing at the deleted chapter, so no
    // module is left referencing a chapter that no longer exists. liveCourse
    // also defends against this, but clearing the stored value keeps the
    // persisted draft honest rather than relying on a render-time fallback.
    setModuleOverrides(o => {
      let changed = false;
      const next = { ...o };
      for (const [id, ov] of Object.entries(o)) {
        if (ov && ov.group === groupId) { next[id] = { ...ov, group: null }; changed = true; }
      }
      return changed ? next : o;
    });
  }, []);

  // Theme + density
  React.useEffect(() => {
    document.documentElement.classList.toggle('dark', !!t.dark);
    const d = { compact: 0.85, regular: 1, comfy: 1.15 }[t.density] || 1;
    document.documentElement.style.setProperty('--density', d);
  }, [t.dark, t.density]);

  // Server-side HTML template library — load once so the quiz picker, the
  // Manage-templates screen and the Localisation tooltip labels all resolve
  // custom (template://) templates. Fire-and-forget: built-ins work
  // regardless, and each surface re-reads the registries on render.
  React.useEffect(() => {
    if (typeof window.loadServerTemplates === 'function') {
      window.loadServerTemplates().catch(() => {});
    }
  }, []);

  // Keyboard shortcuts
  React.useEffect(() => {
    const h = (e) => {
      // ★ These call `setPreview`, NOT the `setShowPreview` that used to live here. The
      // Preview rework replaced `showPreview` (a boolean) with `preview` (null | {} |
      // {moduleId, moduleTitle}) and deleted the old declaration, but left four callers
      // behind — so ⌘P, ⌘⇧P and Escape threw a ReferenceError on every press and the
      // shortcuts silently did nothing, while ⌘P additionally ate the browser's own Print
      // dialog because `preventDefault()` runs before the throw. Found by an independent
      // review, not by me: renaming state is exactly the change that needs a grep for every
      // caller (`feedback_enumerate_every_caller`).
      if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); setShowCmdK(o => !o); }
      else if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'p' || e.key === 'P')) { e.preventDefault(); setPreview({}); }
      else if ((e.metaKey || e.ctrlKey) && (e.key === 'p' || e.key === 'P')) { e.preventDefault(); setPreview(p => (p ? null : {})); }
      else if ((e.metaKey || e.ctrlKey) && e.key === 'e') { e.preventDefault(); setSurface('export'); }
      // Escape deliberately does NOT close the preview here: PreviewModal owns its own
      // Escape handler (preview-modal.jsx:160). Two owners for one behaviour is how they
      // drift apart (`feedback_one_rule_one_place`).
      else if (e.key === 'Escape') { setShowCmdK(false); setShowValidation(false); }
    };
    window.addEventListener('keydown', h);
    return () => window.removeEventListener('keydown', h);
  }, []);

  const goToLayoutEditor = React.useCallback((id) => {
    if (id) setSelectedLayoutId(id);
    setSurface('layout');
  }, []);

  // ── Local draft persistence (IndexedDB, single record per course) ─────────
  // The "draft" is the aggregate of every author-edit slice above. We persist
  // that snapshot (debounced) and restore it on boot. Slice setters are
  // untouched — persistence wraps the state, it doesn't restructure it.
  // Keyed by the ACTIVE course: the demo course keeps its historical key (so
  // existing local drafts survive this change); real courses key by UUID.
  // usePersistedDraft re-boots whenever the key changes, restoring that
  // course's saved slices (or resetting to defaults via hydrate(null)).
  // ── Who is signed in, and may they write? ─────────────────────────────────
  // The gateway refuses every non-read request from a `reviewer`
  // (12_SECURITY_AND_COMPLIANCE §4.1, enforced in gateway auth/roles.ts). Ask
  // once at boot so the UI never presents a Save or a Build that is going to
  // come back 403 — a control that cannot work must not look like it can
  // (no-false-affordance-toggles).
  //
  // Anything other than a clear "you are a reviewer" is treated as "may write":
  // GET /me answers 404 when the identity has no app_user row, and the gateway
  // deliberately allows those writes (a caller with no row has no role). If this
  // request fails outright the server still decides, and its 403 message is what
  // the author sees.
  // Set when a save was refused because the server moved on. Cleared when the
  // author reloads or saves successfully.
  const [saveConflict, setSaveConflict] = React.useState(null);
  // Is the SERVER's copy current? 'saved' | 'local' | 'saving'.
  //
  // The header used to render a hardcoded "✓ Saved" (`chrome.jsx:123`), so it
  // claimed durability it could not know. It is the reason Omar lost an HR role
  // on 2026-08-11: the two screens that own roles never contacted the server, and
  // the header reassured him throughout. 'local' is honest and not alarming — the
  // debounced autosave really has stored the work, just only in this browser.
  const [serverSync, setServerSync] = React.useState('saved');
  const [me, setMe] = React.useState(null);
  // ── When the gateway says this account has no workspace here ──────────────
  //
  // `null` while unknown or fine; otherwise `{ kind, message, email }` and the
  // app renders a refusal screen INSTEAD of the editor (below, just before the
  // main return, where every hook has already run).
  //
  // This replaced a bare `if (!res.ok) return;`. That line predates Phase 2 and
  // was reasonable when the only thing /v1/me could fail with was a blip: the
  // author kept working, the browser's autosave kept the work, and the server
  // stayed the authority. It stopped being reasonable the moment the gateway
  // could answer "you are not a member of this workspace" — because swallowing
  // THAT answer puts a refused person inside a fully editable interface over a
  // gateway that refuses every route they can reach.
  //
  // The three statuses are handled separately because they are three different
  // situations, and only one of them is the person's own to fix:
  //   403 — permanent. Not a member here, or removed. Nothing to retry.
  //   503 — the gateway could not CHECK (decision D2: it refuses rather than
  //         guessing). Genuinely transient, so the screen offers Try again.
  //   401 — the token was rejected. Signing in again is the only exit.
  // Anything else, including a thrown fetch, keeps the old behaviour on
  // purpose: an unexplained failure still lets the author work locally rather
  // than locking them out of their own draft over a network hiccup.
  const [accessDenied, setAccessDenied] = React.useState(null);
  const [accessAttempt, setAccessAttempt] = React.useState(0);
  React.useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const token = await window.dynamoGetAccessToken();
        const res = await fetch(`${window.DYNAMO_ENV.gatewayBase}/v1/me`,
          { headers: { Authorization: 'Bearer ' + token } });
        if (!res.ok) {
          if (res.status === 403 || res.status === 503 || res.status === 401) {
            // The gateway's own sentence, never a paraphrase — it is the only
            // thing that knows WHICH refusal this is.
            //
            // ⚠️ BOTH halves, since 2026-08-21. The heading used to be chosen
            // here from a binary, so a PAUSED workspace was titled "You don't
            // have access to this workspace" over a body explaining it was
            // merely paused — the heading read as the person's fault and undid
            // the sentence written to prevent exactly that. A refusal now
            // brings its heading and its sentence together or neither.
            let message = '';
            let title = '';
            try {
              const envelope = (await res.json()) || {};
              message = envelope.message || '';
              title = envelope.title || '';
            } catch (e) { /* no envelope */ }
            let email = '';
            try {
              const client = await window.auth0Ready;
              const user = await client.getUser();
              email = (user && user.email) || '';
            } catch (e) { /* the screen simply omits it */ }
            if (!cancelled) {
              setAccessDenied({
                kind: res.status === 403 ? 'denied' : 'unavailable',
                // Empty when the gateway sent none; the splash then falls back
                // to its own wording, so an older gateway still renders.
                title,
                message: message || (res.status === 403
                  ? 'Your account does not have access to this workspace.'
                  : 'We could not check your workspace membership right now.'),
                email,
              });
            }
          }
          return;
        }
        const j = await res.json();
        if (!cancelled) { setAccessDenied(null); if (j && j.user) setMe(j.user); }
      } catch (e) { /* gateway unreachable — the server remains the authority */ }
    })();
    return () => { cancelled = true; };
  }, [accessAttempt]);
  // ── Read-only comes from CAPABILITIES, not from a role name ───────────────
  //
  // This used to be `me.role === 'reviewer'` — a denylist of one, in a file with
  // no typecheck that CI never mounts. The moment a fourth role existed that
  // check answered "not read-only" for a `language_reviewer`, so the UI would
  // have offered a fully editable course over a server that refuses almost every
  // save. The person at the keyboard would have found that out, not us.
  //
  // `GET /v1/me` now returns the capability set the gateway itself resolved, so
  // there is one answer rather than two that can drift
  // (`feedback_one_rule_one_place`). A `language_reviewer` is NOT read-only —
  // they really can save, within their languages — and the server decides
  // whether any particular save is legal.
  //
  // The fallback when `me` has not loaded, or an older gateway omits the field,
  // is the previous behaviour exactly: read-only only for a literal `reviewer`.
  // Defaulting to read-only instead would lock every author out of an editor
  // during a slow /v1/me.
  const caps = (me && Array.isArray(me.capabilities)) ? me.capabilities : null;
  const can = (c) => (caps ? caps.indexOf(c) !== -1 : null);
  const readOnly = caps
    ? !(can('course:write') || can('localisation:write'))
    : !!(me && me.role === 'reviewer');
  // Published for surfaces several layers down (the Build button in
  // surface-export.jsx) that receive no user prop.
  window.dynamoReadOnly = readOnly;
  // Published for the same reason, so a screen can hide a control it cannot use
  // rather than offering a button the server will refuse
  // (`feedback_no_false_affordance_toggles`).
  window.dynamoCan = (c) => (caps ? caps.indexOf(c) !== -1 : false);
  window.dynamoMe = me;

  const DRAFT_COURSE_ID = isDemoCourse ? 'mohg:difficult-interactions' : activeCourse.id;
  // Published so the SaveStatusBanner's "Restore this device's copy" button can
  // reach the right record; the banner renders above the course tree and has no
  // course prop.
  window.dynamoDraftCourseId = DRAFT_COURSE_ID;
  const draftAggregate = React.useMemo(() => ({
    moduleOrder, layoutOrders, groupTitleOverrides, moduleOverrides,
    layoutStatusOverrides, layoutDrafts, addedModules, addedGroups, addedLayouts,
    deletedLayoutIds, deletedModuleIds, deletedGroupIds, courseSettings,
    // Resume context so a refresh lands back on the layout being edited.
    selectedModuleId, selectedLayoutId, surface,
  }), [moduleOrder, layoutOrders, groupTitleOverrides, moduleOverrides,
    layoutStatusOverrides, layoutDrafts, addedModules, addedGroups, addedLayouts,
    deletedLayoutIds, deletedModuleIds, deletedGroupIds, courseSettings,
    selectedModuleId, selectedLayoutId, surface]);
  // Read through a ref by saveDraftToServer, which must send the LATEST
  // aggregate without taking it as a dependency: its identity is published as
  // window.dynamoSaveDraftToServer for the Save button several layers down, and
  // re-creating that on every keystroke would churn the whole editor tree.
  const draftAggregateRef = React.useRef(draftAggregate);
  draftAggregateRef.current = draftAggregate;

  // ── THE one definition of what a save sends ─────────────────────────────────
  // There are TWO callers of `PUT /courses/:id/draft`: the Save button (below)
  // and the pre-export save in surface-export.jsx. They must send the same body.
  // When they did not, the consequence was severe and silent: the export's save
  // omitted `authoringState`, so the new version stored NULL, `GET /draft` (which
  // reads the current version) reported no server copy, and the very next machine
  // to open the course got an EMPTY editor — i.e. building the package switched
  // off the durability fix at the end of every session. Caught by the adversarial
  // review, not by any test, because each caller was self-consistent.
  //
  // So the body is built in ONE place and both callers use it. `content` is the
  // lossy projection the export consumes; `authoringState` is the editing session
  // itself, which nothing can reconstruct from `content`.
  // ── What version does this browser think the server holds? ────────────────
  // Sent with every save as `expectedVersionId` so the gateway can refuse a save
  // that would overwrite somebody else's newer work (409). `undefined` means "not
  // known yet" and the field is OMITTED, which the server treats as
  // last-write-wins — the pre-existing behaviour, so a boot that never reached the
  // gateway still saves rather than failing shut.
  //
  // A ref, not state: the body builder is synchronous and must read the value at
  // the moment of the request, and re-rendering on every save would be noise.
  const serverVersionRef = React.useRef(undefined);

  // ── Recording the version a successful save produced ──────────────────────
  // THE one place that advances the marker, because there are TWO senders of
  // `PUT /draft` and only one of them used to do this. The export's pre-export
  // save sent `expectedVersionId` correctly and then threw the reply away, so
  // after ANY export the tab believed the server was still on the version it
  // had read at boot. The server had moved on. Every later save — a second
  // Build, the layout editor's Save, the Localisation Save — was refused 409
  // with "Someone else saved this course after you opened it", to a single
  // author working alone, whose only offered remedy was a reload that risks
  // their work. Unifying the BODY (above) was not enough: the response has to
  // be handled in one place too, or the two callers drift again.
  // Reported by Omar 2026-07-30 as "I got other issues that prevented me to
  // export the zip"; his history showed 9 builds, so the second was certain to
  // fail. `fe-draft-save-version.test.ts` fails if a `PUT /draft` success path
  // stops calling this.
  const recordSavedVersion = React.useCallback(async (versionId) => {
    if (!versionId) return;
    serverVersionRef.current = versionId;
    // Best effort: the marker is a boot optimisation, so failing to persist it
    // must never turn a successful save into a reported failure. The cost of a
    // miss is a boot that conservatively prefers the server copy.
    try {
      if (window.DraftPersistence && window.DraftPersistence.saveMeta) {
        await window.DraftPersistence.saveMeta(
          DRAFT_COURSE_ID, { serverVersionId: versionId });
      }
    } catch (e) { /* keep the save reported as the success it was */ }
  }, []);
  window.dynamoRecordSavedVersion = recordSavedVersion;

  window.dynamoDraftSaveBody = (patch) => {
    const build = window.toDraftContent || toDraftContent;
    const aggregate = patch
      ? { ...draftAggregateRef.current, ...patch }
      : draftAggregateRef.current;
    return {
      content: build(liveCourseRef.current, courseSettings, layoutDrafts),
      authoringState: aggregate,
      ...(serverVersionRef.current !== undefined
        ? { expectedVersionId: serverVersionRef.current }
        : {}),
    };
  };

  // ── Explicit SAVE to the server ─────────────────────────────────────────────
  // The debounced autosave writes to IndexedDB only — i.e. this browser. The
  // server copy was previously written just once, on export, so "saved" in the
  // UI did not mean "saved anywhere durable". The Save button in the layout
  // editor calls this so the promise it makes to the author is true: text edits
  // and uploaded media reach `PUT /v1/courses/:id/draft`.
  //
  // Returns { ok: true } or { ok: false, message } — the caller surfaces the
  // failure rather than showing a false "Saved".
  // `patch` is merged over the aggregate before it is sent — used by the caller
  // below to include the "everything is now saved" statuses IN the save, rather
  // than applying them a moment after it.
  const saveDraftToServer = React.useCallback(async (patch) => {
    // The seeded demo course is a real DB row, but the app treats it as a local
    // scratchpad. Returning ok here turned the pill green having sent nothing —
    // a false claim of persistence. Report it as NOT saved, with the reason.
    if (isDemoCourse) {
      return { ok: false, skipped: 'demo course',
        message: 'the demo course is local-only — create a course to save to the server' };
    }
    // A `reviewer` is read-only server-side (gateway auth/roles.ts). Say so here
    // rather than firing a request that comes back 403 — same shape as the demo
    // skip above, so every caller already handles it.
    if (readOnly) {
      return { ok: false, skipped: 'read-only role',
        message: 'your role is reviewer — you can open and read this course, but not save changes' };
    }
    setServerSync('saving');
    try {
      const token = await window.dynamoGetAccessToken();
      // `toDraftContent` is a top-level declaration in surface-export.jsx, which
      // loads before this file — reachable as a bare global either way.
      const body = JSON.stringify(window.dynamoDraftSaveBody(patch));
      const res = await fetch(
        `${window.DYNAMO_ENV.gatewayBase}/v1/courses/${liveCourse.id}/draft`,
        { method: 'PUT',
          headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
          body });
      if (res.ok) {
        // Record WHICH server version this browser's local copy now matches, so
        // the next boot can tell "my unsaved edits on top of that state" from
        // "the server moved on without me". Shared with the export's pre-export
        // save via `recordSavedVersion` — see its comment for what happened when
        // only this caller did it.
        try {
          const j = await res.json();
          if (j && j.versionId) await recordSavedVersion(j.versionId);
        } catch (e) { /* keep the save reported as the success it was */ }
        setSaveConflict(null);
        // The ONE place that earns the header's tick. Set here rather than in
        // recordSavedVersion, because that runs from the export's pre-export save
        // too and is best-effort — this is the actual 2xx.
        setServerSync('saved');
        return { ok: true };
      }
      let message = `HTTP ${res.status}`;
      let payload = null;
      try {
        payload = await res.json();
        // A schema rejection arrives as a raw Zod issue array. Turn it into a
        // sentence HERE — the one place every Save caller reads its message from,
        // so the layout editor, the Localisation panel and the Assessments panel
        // all show the same author-facing text (2026-07-29).
        if (payload && payload.message) {
          message = window.dynamoHumanizeSchemaFailure
            ? window.dynamoHumanizeSchemaFailure(payload.message)
            : payload.message;
        }
      } catch { /* keep the status */ }
      // 409 = somebody else saved after we last read. PARK this device's copy
      // before doing anything else, so "your changes are kept on this device" is
      // a fact and not a comforting sentence — the same capped park the boot
      // divergence path uses, with the same Restore button.
      if (res.status === 409) {
        let parked = false;
        try {
          if (window.DraftPersistence && window.DraftPersistence.saveSuperseded) {
            await window.DraftPersistence.saveSuperseded(
              DRAFT_COURSE_ID, draftAggregateRef.current);
            parked = true;
          }
        } catch (e) { parked = false; }
        // Adopt the server's id so the NEXT save is a normal save rather than a
        // second conflict against a version we already know about.
        //
        // ⚠️ THIS COMMENT DESCRIBED AN INTENTION, NOT THE CODE, FROM 2026-07-29
        // TO 2026-08-11. `theirs` was computed, put into React state for the
        // banner, and never read again — `serverVersionRef` was only ever
        // assigned on a successful save and at boot. So after ONE conflict the
        // tab re-sent the same stale id forever and every save 409'd until a
        // reload. `surface-export.jsx:1520` records the same class happening
        // before: "One export made the whole app unsaveable until the page was
        // reloaded." The line below is what the comment always claimed.
        //
        // Writing the ref is enough, and deliberately does NOT touch the local
        // copy: the author's work stays exactly as it is (parked above), and
        // their next Save now goes through as an ordinary save that supersedes
        // the other writer's version — which is the recovery the banner offers.
        const theirs = (payload && payload.details
          && payload.details.currentVersionId) || null;
        if (theirs) {
          serverVersionRef.current = theirs;
          // Persist the marker for the same reason `recordSavedVersion` does —
          // otherwise a reload re-reads the OLD id from IndexedDB and the tab
          // returns to the stuck state this fix exists to end.
          try {
            if (window.DraftPersistence && window.DraftPersistence.saveMeta) {
              await window.DraftPersistence.saveMeta(
                DRAFT_COURSE_ID, { serverVersionId: theirs });
            }
          } catch (e) { /* boot optimisation only; never fail the report */ }
        }
        setSaveConflict({ message, parked, theirs });
        setServerSync('local');
        return { ok: false, conflict: true, parked, message };
      }
      // Every remaining exit is a FAILED save, so the work is still only local.
      // Leaving 'saving' here would freeze the header mid-spinner; returning to
      // 'saved' would be the original lie.
      setServerSync('local');
      return { ok: false, message };
    } catch (e) {
      setServerSync('local');
      return { ok: false, message: String((e && e.message) || e) };
    }
  }, [isDemoCourse, readOnly, liveCourse, courseSettings, layoutDrafts, recordSavedVersion]);

  // Exposed globally because the Save button lives several layers down in the
  // layout-editor tree, which does not receive the course/settings/draft state.
  // Wrapped so a CONFIRMED write marks the whole course saved: the PUT sends
  // every layout, so flagging only the open one left its siblings reading
  // "Not saved" after their content was durably stored (fixed 2026-07-27).
  //
  // The status sweep is computed BEFORE the request and sent WITH it. Applying it
  // afterwards meant the authoring state stored on the server always said its own
  // layouts were unsaved — so a course reopened elsewhere showed every layout as
  // "Not saved" despite being exactly what the server holds. One rule, one place:
  // the same object is sent and set.
  window.dynamoSaveDraftToServer = React.useCallback(async () => {
    const swept = { ...layoutStatusOverrides };
    for (const m of liveCourseRef.current.modules) {
      for (const l of (m.layouts || [])) swept[l.id] = 'accepted';
    }
    const result = await saveDraftToServer({ layoutStatusOverrides: swept });
    if (result && result.ok && !result.skipped) {
      setLayoutStatusOverrides(o => {
        const next = { ...o };
        for (const m of liveCourseRef.current.modules) {
          for (const l of (m.layouts || [])) next[l.id] = 'accepted';
        }
        return next;
      });
    }
    return result;
  }, [saveDraftToServer, layoutStatusOverrides]);

  // Any content edit invalidates "Saved". Without this the pill stayed green
  // while the author kept typing, asserting persistence that had not happened.
  const markLayoutDirty = React.useCallback((layoutId) => {
    setLayoutStatusOverrides(o => (
      o[layoutId] === 'accepted' ? { ...o, [layoutId]: 'proposed' } : o));
  }, []);

  // Apply a restored snapshot (or null = reset to defaults) to every slice.
  // Stable callback: per-course defaults resolve through activeCourseRef so
  // a course switch always resets to the RIGHT course's defaults.
  const hydrateDraft = React.useCallback((c) => {
    const d = c || {};
    // A whole draft arriving is not the author deleting anything. Without this,
    // opening a course that was saved with the flow on and its last gaming quiz
    // already deleted would silently flip a stored setting on page load.
    gamingRebaseline.current = true;
    setModuleOrder(d.moduleOrder ?? null);
    setLayoutOrders(d.layoutOrders || {});
    setGroupTitleOverrides(d.groupTitleOverrides || {});
    setModuleOverrides(d.moduleOverrides || {});
    setLayoutStatusOverrides(d.layoutStatusOverrides || {});
    setLayoutDrafts(d.layoutDrafts || {});
    setAddedModules(d.addedModules || []);
    setAddedGroups(d.addedGroups || []);
    setAddedLayouts(d.addedLayouts || {});
    setDeletedLayoutIds(d.deletedLayoutIds || []);
    setDeletedModuleIds(d.deletedModuleIds || []);
    setDeletedGroupIds(d.deletedGroupIds || []);
    // MERGE over the defaults, don't replace them. The restored aggregate can now
    // come from the SERVER, where it is stored verbatim and validated only for
    // shape, so a partial `courseSettings` — written by an older client, or by a
    // save that predated a new slice — would leave required sub-objects missing.
    // Consumers read them positionally (`settings.metadata.defaultLanguage`), and
    // with no error boundary anywhere in this app a single undefined read blanks
    // the whole screen with no way back. Branch on shape, never on presence.
    const restoredSettings = (d.courseSettings && typeof d.courseSettings === 'object'
      && !Array.isArray(d.courseSettings)) ? d.courseSettings : null;
    const settingDefaults = defaultSettingsFor(activeCourseRef.current);
    setCourseSettings(restoredSettings
      ? { ...settingDefaults, ...restoredSettings }
      : settingDefaults);
    // Always set the selection — leaving the previous course's (or the boot
    // default 'M2') selection in place leaks dangling ids into other courses.
    setSelectedModuleId(d.selectedModuleId || null);
    setSelectedLayoutId(d.selectedLayoutId || null);
    // Only resume into a stable editing surface (never a transient flow screen).
    const SAFE = ['draft', 'layout', 'library', 'org-roles', 'content-mapping',
      'localisation', 'assessments', 'brand', 'export', 'components'];
    if (d.surface && SAFE.includes(d.surface)) setSurface(d.surface);
  }, []);

  // Open a course from the (real) course list or the create flow. Persists
  // the choice so a refresh reopens the same course; the persistence hook
  // re-boots on the key change and swaps in that course's local draft.
  const openCourse = React.useCallback((row) => {
    const descriptor = {
      id: row.id, title: row.title, topic: row.topic || '',
      defaultLang: row.defaultLang || 'en',
      enabledLangs: (row.enabledLangs && row.enabledLangs.length) ? row.enabledLangs : ['en'],
      brand: row.brand || '',
    };
    try { localStorage.setItem(LS_ACTIVE_COURSE, JSON.stringify(descriptor)); } catch { /* ignore */ }
    setActiveCourse(descriptor);
    setSelectedModuleId(null);
    setSelectedLayoutId(null);
    setSurface('draft');
  }, []);

  // A course was deleted from the home grid. Drop its local draft; if it was
  // the OPEN course, switch back to the sample course so the editor never
  // points at a now-missing course (its next PUT/export would 404).
  const handleCourseDeleted = React.useCallback((deleted) => {
    // forget() (not clearDraft) tombstones the id so the persisted-draft
    // flush-on-switch — which fires as we switch away below — can't rewrite
    // the record we just removed.
    if (window.DraftPersistence) {
      window.DraftPersistence.forget(deleted.id).catch(() => {});
    }
    if (deleted.id === activeCourseRef.current.id) {
      try { localStorage.removeItem(LS_ACTIVE_COURSE); } catch { /* ignore */ }
      setActiveCourse(DEMO_COURSE_DESCRIPTOR);
      setSelectedModuleId(null);
      setSelectedLayoutId(null);
    }
  }, []);

  // Languages the author has added in the Localisation rail but that the SERVER
  // has NOT enabled yet. Enabling early is not an option: the export coverage
  // gate 422-blocks every build for a language with no translated content, so
  // the server only learns about a language when a translation run lands.
  //
  // These are deliberately kept SEPARATE from `activeCourse.enabledLangs` and
  // deliberately NOT persisted. `enabledLangs` feeds the status-bar count, the
  // Build-options modal (which promises "Every enabled language is included in
  // the package") and export history — so writing an un-enabled language into it
  // made the app claim an English-only ZIP contained Italian. Pending languages
  // are real for translation WORK and invisible to everything about shipping.
  const [pendingLangs, setPendingLangs] = React.useState([]);
  React.useEffect(() => { setPendingLangs([]); }, [activeCourse.id]);

  const handleLanguageAdded = React.useCallback((code) => {
    setPendingLangs(prev => (prev.includes(code) ? prev : [...prev, code]));
  }, []);

  // A translation run landed and the server enabled the language: it graduates
  // from pending to genuinely enabled, and only now may it be persisted.
  const handleLanguagesEnabled = React.useCallback((list) => {
    if (!Array.isArray(list) || !list.length) return;
    setPendingLangs(prev => prev.filter(l => !list.includes(l)));
    setActiveCourse(prev => {
      if ((prev.enabledLangs || []).join(',') === list.join(',')) return prev;
      const next = { ...prev, enabledLangs: list };
      if (prev.id !== DEMO_COURSE_DESCRIPTOR.id) {
        try { localStorage.setItem(LS_ACTIVE_COURSE, JSON.stringify(next)); } catch { /* ignore */ }
      }
      return next;
    });
  }, []);

  // Confirmed removed server-side (or dropped while still pending).
  const handleLanguageRemoved = React.useCallback((code) => {
    setPendingLangs(prev => prev.filter(l => l !== code));
    setActiveCourse(prev => {
      const cur = prev.enabledLangs || [];
      if (!cur.includes(code)) return prev;
      const next = { ...prev, enabledLangs: cur.filter(l => l !== code) };
      if (prev.id !== DEMO_COURSE_DESCRIPTOR.id) {
        try { localStorage.setItem(LS_ACTIVE_COURSE, JSON.stringify(next)); } catch { /* ignore */ }
      }
      return next;
    });
  }, []);

  // Self-heal descriptor drift: title/topic/defaultLang/languages can change
  // server-side (PATCH /courses, language add/remove) without openCourse
  // running again. Re-fetch the row once per session so a stale localStorage
  // descriptor doesn't keep showing an old title or language set.
  React.useEffect(() => {
    if (isDemoCourse) return;
    let cancelled = false;
    (async () => {
      try {
        const token = await window.dynamoGetAccessToken();
        const res = await fetch(
          `${window.DYNAMO_ENV.gatewayBase}/v1/courses/${activeCourse.id}`,
          { headers: { Authorization: 'Bearer ' + token } });
        if (!res.ok || cancelled) return;
        const row = await res.json();
        const fresh = {
          id: row.id, title: row.title, topic: row.topic || '',
          defaultLang: row.defaultLang || 'en',
          enabledLangs: (row.enabledLangs && row.enabledLangs.length) ? row.enabledLangs : ['en'],
          brand: row.brand || '',
        };
        if (JSON.stringify(fresh) !== JSON.stringify(activeCourse)) {
          try { localStorage.setItem(LS_ACTIVE_COURSE, JSON.stringify(fresh)); } catch { /* ignore */ }
          if (!cancelled) setActiveCourse(fresh);
        }
      } catch { /* offline / auth hiccup — keep the cached descriptor */ }
    })();
    return () => { cancelled = true; };
  }, [activeCourse.id, isDemoCourse]);

  // Self-heal a DRAFT-LESS course. A course whose draft row is missing is
  // completely unusable: every `PUT /:id/draft` 404s ("course has no draft")
  // and every media upload fails at `upload-complete` with the same cause,
  // surfacing only as "Upload failed — try again". Courses created before the
  // 2026-07-26 fix are in exactly that state, because the FE's draft-creating
  // POST declared a JSON content-type with no body and got a 500.
  //
  // `POST /:id/draft` is idempotent enough for this: it 409s when a draft
  // already exists, which we treat as success. So one unconditional attempt
  // per opened course repairs the broken ones and is a no-op for healthy ones.
  React.useEffect(() => {
    if (isDemoCourse) return;
    let cancelled = false;
    window.dynamoDraftReady = false;
    (async () => {
      try {
        const token = await window.dynamoGetAccessToken();
        if (cancelled) return;
        // No body → no JSON content-type (see surface-new-course.jsx).
        const res = await fetch(
          `${window.DYNAMO_ENV.gatewayBase}/v1/courses/${activeCourse.id}/draft`,
          { method: 'POST', headers: { Authorization: 'Bearer ' + token } });
        // 201 = repaired, 409 = already had one. Anything else means the course
        // is still draft-less, so say so loudly instead of leaving every later
        // save and upload to fail with an unexplained error.
        if (!res.ok && res.status !== 409) {
          console.error('draft self-heal failed', res.status,
            '— saves and uploads will fail until this succeeds');
        } else if (!cancelled) {
          window.dynamoDraftReady = true;
        }
      } catch (e) {
        console.error('draft self-heal could not reach the gateway', e);
      }
    })();
    return () => { cancelled = true; };
  }, [activeCourse.id, isDemoCourse]);

  // ── Read the server's copy of this course when it opens ────────────────────
  // The other half of the durability fix. `PUT /draft` now stores the authoring
  // aggregate; this fetches it back, so a course opened on a different machine —
  // or in the same browser after clearing site data — comes back as it was
  // instead of appearing empty.
  //
  // Returns null (→ the hook keeps using the local copy) for the demo course,
  // for a course with no draft row yet (404 while the self-heal POST above is
  // still in flight), for a version written before this column existed, and for
  // any gateway or network failure. A course must remain editable offline.
  const loadRemoteDraft = React.useCallback(async (id) => {
    if (isDemoCourse) return null;
    const courseId = activeCourseRef.current && activeCourseRef.current.id;
    // `id` is the DRAFT key, which equals the course id for real courses. Guard
    // against ever asking the gateway for the demo course's local key.
    if (!courseId || id !== courseId) return null;
    // Aborted after 10s so a hung connection is dropped rather than merely
    // ignored. `usePersistedDraft` independently stops WAITING after 8s (boot
    // gates the local autosave, so it must always settle); this makes sure the
    // request itself does not sit open behind it.
    const ctl = typeof AbortController !== 'undefined' ? new AbortController() : null;
    const abortTimer = ctl ? setTimeout(() => ctl.abort(), 10000) : null;
    try {
      const token = await window.dynamoGetAccessToken();
      const res = await fetch(
        `${window.DYNAMO_ENV.gatewayBase}/v1/courses/${courseId}/draft`,
        { headers: { Authorization: 'Bearer ' + token },
          ...(ctl ? { signal: ctl.signal } : {}) });
      if (!res.ok) return null;
      const j = await res.json();
      const currentVersionId = (j && j.currentVersionId) || null;
      // From here on every save carries this, so a second person's save in
      // between is refused instead of silently overwritten.
      serverVersionRef.current = currentVersionId;
      return {
        authoringState: (j && j.authoringState) || null,
        currentVersionId,
      };
    } catch (e) {
      return null;
    } finally {
      if (abortTimer) clearTimeout(abortTimer);
    }
  }, [isDemoCourse]);

  const { bootStatus: draftBootStatus, saveBlocked: draftSaveBlocked, reset: resetDraft } =
    usePersistedDraft(DRAFT_COURSE_ID, draftAggregate, hydrateDraft,
      { loadRemote: loadRemoteDraft });

  // ── Mark the server copy stale as soon as the author changes anything ──────
  //
  // ARMED ONLY AFTER BOOT SETTLES, and that is the whole subtlety. Boot HYDRATES
  // the aggregate (from IndexedDB, or from the server copy it just adopted),
  // which changes its identity — so an unarmed version of this effect would
  // announce "saved on this device only" on a course nobody had touched yet.
  // Crying wolf is how an honest indicator gets ignored, which would reproduce
  // the very problem it exists to fix.
  //
  // After that, ANY aggregate change means the server is behind. Deliberately
  // identity-based rather than a deep compare: it runs on every keystroke, and a
  // false "unsaved" is safe while a false "saved" is the bug.
  const syncArmedRef = React.useRef(false);
  React.useEffect(() => {
    if (draftBootStatus && draftBootStatus !== 'loading') syncArmedRef.current = true;
  }, [draftBootStatus]);
  React.useEffect(() => {
    if (!syncArmedRef.current) return;
    // Don't stomp the spinner: a save in flight already sent this aggregate.
    setServerSync(s => (s === 'saving' ? s : 'local'));
  }, [draftAggregate]);

  const [showResetConfirm, setShowResetConfirm] = React.useState(false);

  const currentSurface = (() => {
    switch (surface) {
      case 'dynamo-home': return <SurfaceDynamoHome
                            onPickOrg={(id) => { setOrgId(id); setSurface('home'); }} />;
      case 'home':        return <SurfaceHome orgId={orgId}
                            activeCourseId={activeCourse.id}
                            onOpenCourse={openCourse}
                            onNewCourse={() => setSurface('new-course')}
                            onCourseDeleted={handleCourseDeleted}
                            onSwitchOrg={() => {}} />;
      case 'new-course':  return <SurfaceNewCourse
                            onCancel={() => setSurface('home')}
                            onCreated={openCourse} />;
      case 'generating':  return <SurfaceGenerating
                            courseName={liveCourse.title}
                            files={SAMPLE_SOURCES}
                            onCancel={() => setSurface('new-course')}
                            onDone={() => setSurface('draft')} />;
      case 'sources':     return <SurfaceSources sources={SAMPLE_SOURCES} />;
      case 'sources-empty': return <SurfaceSources sources={SAMPLE_SOURCES} isEmpty />;
      case 'draft':       return <SurfaceDraft course={liveCourse}
                            selectedModuleId={selectedModuleId} selectedLayoutId={selectedLayoutId}
                            onSelectModule={setSelectedModuleId} onSelectLayout={setSelectedLayoutId}
                            onReorderModules={(next) => {
                              setModuleOrder(next.map(m => m.id));
                              // A module drag can also MOVE the module into another
                              // chapter, and an id-order list cannot express that —
                              // so the chapter change used to be silently dropped and
                              // the module sprang back (fixed 2026-07-26). Persist each
                              // module's current group as an override; `group` is merged
                              // over the module in liveCourse, and rewriting the same
                              // value is a harmless no-op.
                              setModuleOverrides(o => {
                                const merged = { ...o };
                                for (const m of next) {
                                  merged[m.id] = { ...(o[m.id] || {}), group: m.group ?? null };
                                }
                                return merged;
                              });
                            }}
                            onReorderLayouts={(modId, nextLayouts) =>
                              setLayoutOrders(o => ({ ...o, [modId]: nextLayouts.map(l => l.id) }))}
                            onRenameGroup={(gid, title) =>
                              setGroupTitleOverrides(o => ({ ...o, [gid]: title }))}
                            onAddModule={handleAddModule}
                            onEditModule={(mid, patch) =>
                              setModuleOverrides(o => ({ ...o, [mid]: { ...(o[mid] || {}), ...patch } }))}
                            onAddLayout={handleAddLayout}
                            onDeleteLayout={handleDeleteLayout}
                            onDeleteModule={handleDeleteModule}
                            onDeleteGroup={handleDeleteGroup}
                            sources={SAMPLE_SOURCES}
                            /* Module preview. The title travels with the id purely so the
                               preview window can name what it is showing — the SERVER
                               decides the scope from the id alone, so a stale title can
                               never widen or narrow what is actually built. */
                            onPreviewModule={(numericId, mod) => setPreview({
                              moduleId: numericId,
                              // `locText` handles per-language objects AND legacy flat
                              // strings, which is what module titles can still be.
                              moduleTitle: locText(mod && mod.title, 'en') || null,
                            })}
                            onOpenLayoutEditor={goToLayoutEditor} />;
      case 'members':     return <SurfaceMembers />;
      case 'layout':      return <SurfaceLayoutEditor course={liveCourse}
                            key={selectedLayoutId}
                            selectedLayoutId={selectedLayoutId}
                            onSelectLayout={setSelectedLayoutId}
                            draft={layoutDrafts[selectedLayoutId]}
                            onChangeDraft={(d) => {
                              setLayoutDrafts(o => ({ ...o, [selectedLayoutId]: d }));
                              // Editing invalidates a previous "Saved".
                              markLayoutDirty(selectedLayoutId);
                            }}
                            allDrafts={layoutDrafts}
                            gamingQuizEnabled={!!courseSettings.dftiFlow.enabled}
                            dftiFlow={courseSettings.dftiFlow}
                            onChangeDftiFlow={(next) => setCourseSettings(s =>
                              ({ ...s, dftiFlow: typeof next === 'function' ? next(s.dftiFlow) : next }))}
                            onBack={() => setSurface('draft')}
                            onChangeLayoutStatus={(lid, st) =>
                              setLayoutStatusOverrides(o => ({ ...o, [lid]: st }))} />;
      case 'library':     return <SurfaceLayoutLibrary
                            viewAs={t.viewAs}
                            onChangeViewAs={(v) => setTweak('viewAs', v)} />;
      case 'roles':
      // Roles now come from the PERSISTED slice, not from SAMPLE_ROLES (Phase 2b).
      // `startBlank` no longer decides whether personas appear — nothing is
      // seeded for any course — so the tweak only survives as a preview of the
      // empty state.
      // falls through — 'roles' is an alias for 'org-roles': one surface, two route names.
      case 'org-roles':   return <SurfaceOrgRoles roles={courseSettings.roles || []}
                            onUpdateRoles={(next) => setCourseSettings(s => ({ ...s, roles: next }))}
                            // Phase 4b: organisations come from the PERSISTED slice
                            // too. They used to be local `useState` seeded from
                            // `course.brands`, so they died on navigation and never
                            // reached the server — and the role→organisation
                            // assignment was dropped with them.
                            brands={courseSettings.brands || []}
                            onUpdateBrands={(next) => setCourseSettings(s => ({ ...s, brands: next }))}
                            course={liveCourse}
                            defaultLang={courseSettings.metadata.defaultLanguage}
                            startBlank={!!t.rolesStartBlank} />;
      case 'content-mapping': return <SurfaceContentMapping roles={courseSettings.roles || []}
                            // Read-only here: the grid GROUPS by organisation but
                            // never edits one. The same slice Org & Roles writes —
                            // a second copy for reading is how two views of one
                            // fact start to disagree.
                            brands={courseSettings.brands || []}
                            course={liveCourse}
                            defaultLang={courseSettings.metadata.defaultLanguage}
                            // The SAME door org-roles uses above, deliberately: the grid
                            // writes `roles[].modules`, which is the same fact that screen
                            // owns. A second writer with its own setter is how two copies
                            // of one fact start to diverge.
                            onUpdateRoles={(next) => setCourseSettings(s => ({ ...s, roles: next }))}
                            // Phase 3b: the author's REAL questions, not
                            // `SAMPLE_ASSESSMENTS`. That fixture is why every tick on a
                            // question row used to bind demo content belonging to no
                            // course — the same defect already fixed for the Localisation
                            // surface below, whose comment records it. The grid adapts the
                            // slice's shape itself (`adaptAssessmentsForGrid`).
                            assessments={courseSettings.assessments || makeEmptyAssessments()}
                            // The same door Localisation uses, deliberately: a second
                            // setter for one slice is how two copies start to diverge.
                            onUpdateSettings={(patch) => setCourseSettings(prev => ({ ...prev, ...patch }))}
                            onNavigate={setSurface} />;
      // ONE element for all three routes. These were three character-identical
      // copies, and adding a prop to just one of them is how the language-
      // persistence fix came to be wired to `languages` — a case nothing
      // navigates to — while the sidebar, the ⌘K palette, the export-validation
      // jump and session resume all open `localisation`. Keep them collapsed.
      case 'localisation':
      case 'languages':
      // No `assessments` prop: the Localisation surface reads the REAL slice out
      // of `settings.assessments` (below). It used to be handed SAMPLE_ASSESSMENTS,
      // so its Assessment tab translated canned demo questions into nowhere.
      // Roles come from the persisted slice here too — its Roles tab used to
      // offer to translate SAMPLE_ROLES, i.e. three personas no course owned.
      // falls through — 'languages' is an alias for 'subtitles': one surface, two names.
      case 'subtitles':   return <SurfaceLocalisation course={liveCourse} roles={courseSettings.roles || []}
                            layoutDrafts={layoutDrafts}
                            onUpdateDrafts={updateDraft}
                            onTranslateModuleTitle={(mid, patch) => setModuleOverrides(o => ({ ...o, [mid]: { ...(o[mid] || {}), ...patch } }))}
                            onTranslateGroupTitle={(gid, title) => setGroupTitleOverrides(o => ({ ...o, [gid]: title }))}
                            settings={courseSettings}
                            pendingLanguages={pendingLangs}
                            onLanguageAdded={handleLanguageAdded}
                            onLanguagesEnabled={handleLanguagesEnabled}
                            onLanguageRemoved={handleLanguageRemoved}
                            onUpdateSettings={(patch) => setCourseSettings(prev => ({ ...prev, ...patch }))} />;
      case 'assessments': return <SurfaceAssessments course={liveCourse}
                            settings={courseSettings} setSettings={setCourseSettings}
                            // The AI generator reads the module's own text, and an
                            // author who just rewrote a screen without saving expects
                            // it to read THAT. `liveCourse` does not carry drafts —
                            // `toDraftContent` takes them separately — so they have
                            // to come through here or the generator writes questions
                            // about the previous version of the content.
                            layoutDrafts={layoutDrafts}
                            onNavigate={setSurface} />;
      // `onNavigate` was added 2026-08-12 for the Organisations pointer panel and
      // REMOVED 2026-08-14 with it (Omar: the two pointer panels "are no longer
      // needed and add only space"). Dropped from the call site rather than left
      // dangling: a prop nothing reads is the next reader's false lead.
      case 'brand':       return <SurfaceBrand course={liveCourse}
                            settings={courseSettings} setSettings={setCourseSettings} />;
      // `onOpenPreview` feeds BuildSuccessModal's "Open in Preview" button
      // (surface-export.jsx:2187). It used to call the deleted `setShowPreview`, so after a
      // successful build the button closed the success dialog and THEN threw — the author
      // lost the dialog and got no preview, with nothing on screen to explain it. A
      // course-wide preview is `{}`; see the state declaration at the top of this component.
      case 'export':      return <SurfaceExport course={liveCourse}
                            settings={courseSettings}
                            layoutDrafts={layoutDrafts}
                            onOpenPreview={() => setPreview({})}
                            onJump={(item) => {
                              window.dispatchEvent(new CustomEvent('dynamo:jump-to', { detail: item }));
                              if (item && item.layoutId) { setSelectedLayoutId(item.layoutId); setSurface('layout'); }
                              else if (item && item.surface && item.surface !== 'export') setSurface(item.surface);
                            }} />;
      case 'components':  return <SurfaceComponents />;
      default: return null;
    }
  })();

  const noChromeSurface = surface === 'home' || surface === 'dynamo-home' || surface === 'new-course' || surface === 'generating';

  // ── Refused by the gateway → the refusal IS the screen ────────────────────
  // Placed here, after every hook in this component has run, so React's rules
  // hold. Rendering the editor behind a dimmed banner was the alternative and
  // it is the wrong one: nothing on this screen works, and an interface that
  // still looks editable is an invitation to lose an hour's writing.
  if (accessDenied) {
    return (
      <WorkspaceAccessSplash
        kind={accessDenied.kind}
        title={accessDenied.title}
        message={accessDenied.message}
        email={accessDenied.email}
        onRetry={() => { setAccessDenied(null); setAccessAttempt(n => n + 1); }}
        onSignOut={() => window.dynamoSignOut()} />
    );
  }

  return (
    <LocDefaultLangContext.Provider value={courseSettings?.metadata?.defaultLanguage || SAMPLE_COURSE.defaultLanguage || 'en'}>
    {/* A flex column so every banner PUSHES the shell down instead of covering
        it. The grid below was `height: 100vh` with the banners floating over its
        first ~33px, which hid the header and the top of the left rail — and hid
        one banner behind another when two were up. */}
    <div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
    <ReadOnlyBanner readOnly={readOnly} />
    <SaveConflictBanner conflict={saveConflict}
      onDismiss={() => setSaveConflict(null)} />
    <SaveStatusBanner bootStatus={draftBootStatus} saveBlocked={draftSaveBlocked} />
    <div style={{
      display: 'grid',
      gridTemplateAreas: noChromeSurface
        ? '"header header" "main main"'
        : '"header header" "rail main" "status status"',
      gridTemplateColumns: (surface === 'home' || surface === 'dynamo-home') ? '0 1fr' : 'auto 1fr',
      gridTemplateRows: noChromeSurface ? '52px 1fr' : '52px 1fr 26px',
      // `flex: 1` rather than `height: 100vh`: the column above owns the height,
      // so the shell shrinks by exactly the banners' height. `minHeight: 0` is
      // load-bearing — without it a grid child refuses to shrink below its
      // content and the page scrolls instead.
      flex: '1 1 auto',
      minHeight: 0,
      background: 'var(--bg)',
    }}>
      <TopHeader course={liveCourse} currentSurface={surface}
        currentOrg={currentOrg}
        serverSync={isDemoCourse ? 'saved' : serverSync}
        onValidationClick={() => setShowValidation(o => !o)}
        /* onCmdK dropped 2026-08-13 with the header's magnifier button. The
           palette is opened by the ⌘K keydown handler below, not from here. */
        onPreview={() => setPreview(p => (p ? null : {}))}
        onLogoClick={() => setSurface('dynamo-home')}
        onSwitchOrg={(id) => { setOrgId(id); setSurface('home'); }} />
      {!noChromeSurface && (
        <LeftRail collapsed={t.rail === 'collapsed'} currentSurface={surface}
          onNavigate={setSurface}
          onToggleCollapse={() => setTweak('rail', t.rail === 'collapsed' ? 'expanded' : 'collapsed')} />
      )}
      <main style={{ gridArea: 'main', minWidth: 0, overflow: 'hidden', position: 'relative' }}>
        {/* Wrapped so a render-time throw costs the SCREEN, not the app: the
            header, rail and status banner keep working, so the author can switch
            surfaces, reload, or recover a parked copy. Authored state can now
            arrive from the server (stored verbatim, shape-checked only), so a blob
            written by a different client can throw on a machine that never
            authored it — and until now that meant a blank white page.
            `surfaceKey` resets it on navigation. */}
        {window.DraftErrorBoundary
          ? <window.DraftErrorBoundary surfaceKey={surface}>{currentSurface}</window.DraftErrorBoundary>
          : currentSurface}
      </main>
      {!noChromeSurface && (
        <StatusBar course={liveCourse}
          jobs={[{ id: 'j1' }, { id: 'j2' }]}
          onValidationClick={() => setShowValidation(o => !o)} />
      )}

      {showValidation && <ValidationDrawer course={liveCourse}
        onClose={() => setShowValidation(false)}
        onJump={(item) => { setShowValidation(false); setSurface(item.surface); }} />}
      {showCmdK && <CommandPalette onClose={() => setShowCmdK(false)}
        onNavigate={setSurface} onJumpLayout={goToLayoutEditor}
        course={liveCourse}
        onResetDraft={() => { setShowCmdK(false); setShowResetConfirm(true); }} />}

      {showResetConfirm && window.ConfirmDialog && (
        <window.ConfirmDialog icon="Trash" tone="danger"
          title="Reset draft?"
          body="Your saved work will be permanently deleted and the course returns to its starting content. This can't be undone."
          confirmLabel="Reset draft"
          onConfirm={() => { resetDraft(); setShowResetConfirm(false); }}
          onCancel={() => setShowResetConfirm(false)} />
      )}

      {/* `layoutDrafts` / `selectedLayoutId` were dropped 2026-08-13: the modal no longer
          renders layouts itself, it saves the draft and points an iframe at the real
          Player. Passing props it ignores would read as "the preview renders from these",
          which is exactly the wrong mental model now. */}
      {preview && <PreviewModal course={liveCourse}
        moduleId={preview.moduleId ?? null}
        moduleTitle={preview.moduleTitle ?? null}
        onClose={() => setPreview(null)} />}

      <DynamoTweaks t={t} setTweak={setTweak} setSurface={setSurface} />
    </div>
    </div>
    </LocDefaultLangContext.Provider>
  );
}

// ── Validation drawer ──────────────────────────────────────────────────────
function ValidationDrawer({ course, onClose, onJump }) {
  return (
    <>
      <div onClick={onClose} style={{
        position: 'fixed', inset: 0, background: 'rgba(15,23,42,0.3)', zIndex: 40,
        animation: 'fadeIn 150ms',
      }} />
      <aside className="slide-in" style={{
        position: 'fixed', top: 0, right: 0, bottom: 0,
        width: 420, background: 'var(--surface)',
        borderLeft: '1px solid var(--border)', zIndex: 41,
        display: 'flex', flexDirection: 'column',
        boxShadow: 'var(--shadow-xl)',
      }}>
        <header style={{ padding: '16px 18px', borderBottom: '1px solid var(--border)',
          display: 'flex', alignItems: 'center', gap: 10 }}>
          <I.ListChecks size={16} />
          <h2 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>Validation</h2>
          <span className="pill issues" style={{ fontSize: 11 }}>
            {course.validation.items.length}
          </span>
          <div style={{ flex: 1 }} />
          <button className="btn sm ghost" onClick={onClose}><I.X size={14} /></button>
        </header>
        <div style={{ flex: 1, overflowY: 'auto', padding: '14px 18px' }}>
          <p style={{ margin: '0 0 12px', fontSize: 12.5, color: 'var(--text-muted)' }}>
            All unresolved invariants across the course. Click any item to jump.
          </p>
          <div style={{ display: 'grid', gap: 8 }}>
            {course.validation.items.map(v => (
              <button key={v.id} onClick={() => onJump(v)}
                style={{
                  display: 'grid', gridTemplateColumns: 'auto 1fr auto',
                  gap: 10, alignItems: 'flex-start', textAlign: 'left',
                  padding: '10px 12px',
                  background: v.level === 'error' ? 'var(--error-bg)' : 'var(--surface)',
                  border: '1px solid', borderColor: v.level === 'error' ? 'var(--error)' : 'var(--border)',
                  borderRadius: 'var(--radius-md)', cursor: 'default',
                  fontFamily: 'inherit', color: 'var(--text)',
                }}>
                {v.level === 'error'
                  ? <I.AlertCircle size={14} style={{ color: 'var(--error-text)', marginTop: 1 }} />
                  : <I.AlertTriangle size={14} style={{ color: 'var(--warning)', marginTop: 1 }} />}
                <div>
                  <div style={{ fontSize: 12.5, lineHeight: 1.4 }}>{v.message}</div>
                  <div style={{ fontSize: 11, color: 'var(--text-faint)',
                    fontFamily: 'var(--font-mono)', marginTop: 4 }}>
                    {v.surface}{v.moduleId ? ` · ${v.moduleId}` : ''}{v.layoutId ? ` · ${v.layoutId}` : ''}
                  </div>
                </div>
                <I.ArrowRight size={13} style={{ color: 'var(--text-faint)' }} />
              </button>
            ))}
          </div>
        </div>
      </aside>
    </>
  );
}

// ── Command palette ────────────────────────────────────────────────────────
function CommandPalette({ onClose, onNavigate, onJumpLayout, onResetDraft, course }) {
  const [q, setQ] = React.useState('');
  // Jump entries come from the OPEN course (was hardcoded to the demo
  // course's M2-L3 / M1-L4 — dangling ids on a from-zero course).
  const jumpEntries = [];
  (course?.modules || []).some(m => (m.layouts || []).some(l => {
    jumpEntries.push({ kind: 'jump', icon: 'PenLine',
      label: `Open ${l.id} · ${locText(l.summary) || l.type}`,
      action: () => { onJumpLayout(l.id); onClose(); } });
    return jumpEntries.length >= 2;
  }));
  const commands = [
    { kind: 'nav', label: 'Go to Sources', icon: 'Upload', action: () => { onNavigate('sources'); onClose(); } },
    { kind: 'nav', label: 'Go to Course architecture', icon: 'Layers', action: () => { onNavigate('draft'); onClose(); } },
    { kind: 'nav', label: 'Go to Layout library', icon: 'Grid', action: () => { onNavigate('library'); onClose(); } },
    { kind: 'nav', label: 'Go to Organisations & roles', icon: 'Users', action: () => { onNavigate('org-roles'); onClose(); } },
    { kind: 'nav', label: 'Go to Content mapping', icon: 'Table', action: () => { onNavigate('content-mapping'); onClose(); } },
    { kind: 'nav', label: 'Go to Localisation', icon: 'Globe', action: () => { onNavigate('localisation'); onClose(); } },
    { kind: 'nav', label: 'Go to Assessments', icon: 'ClipboardCheck', action: () => { onNavigate('assessments'); onClose(); } },
    { kind: 'nav', label: 'Go to Course settings', icon: 'Palette', action: () => { onNavigate('brand'); onClose(); } },
    { kind: 'nav', label: 'Go to Export', icon: 'Package', action: () => { onNavigate('export'); onClose(); } },
    { kind: 'nav', label: 'Open Component library', icon: 'Grid', action: () => { onNavigate('components'); onClose(); } },
    // The "Re-roll / Accept suggestion" entries went with the AI accept-or-
    // regenerate workflow the editor no longer has (Omar, 2026-07-26). They were
    // label-only anyway — no `action` — so they did nothing when picked.
    { kind: 'action', label: 'Cycle layout type', icon: 'Tabs', shortcut: 'T' },
    ...jumpEntries,
    { kind: 'action', label: 'Reset local draft (clear saved work)', icon: 'Trash',
      action: () => { onResetDraft ? onResetDraft() : onClose(); } },
  ];
  const filtered = commands.filter(c => c.label.toLowerCase().includes(q.toLowerCase()));
  return (
    <>
      <div onClick={onClose} style={{
        position: 'fixed', inset: 0, background: 'rgba(15,23,42,0.4)',
        backdropFilter: 'blur(2px)', zIndex: 50,
      }} />
      <div className="slide-in" style={{
        position: 'fixed', top: '15vh', left: '50%', transform: 'translateX(-50%)',
        width: 'min(640px, 92vw)', background: 'var(--surface)',
        border: '1px solid var(--border)', borderRadius: 'var(--radius-lg)',
        boxShadow: 'var(--shadow-xl)', zIndex: 51,
        display: 'flex', flexDirection: 'column', maxHeight: 'min(560px, 70vh)',
        overflow: 'hidden',
      }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10,
          padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
          <I.Search size={16} style={{ color: 'var(--text-muted)' }} />
          <input value={q} autoFocus onChange={e => setQ(e.target.value)}
            placeholder="Jump to surface, layout, module · or run an action…"
            style={{ flex: 1, border: 0, outline: 'none', background: 'transparent',
              color: 'var(--text)', fontSize: 14, fontFamily: 'inherit' }} />
          <kbd>esc</kbd>
        </div>
        <div style={{ flex: 1, overflowY: 'auto', padding: '6px 8px 10px' }}>
          {filtered.map((c, i) => {
            const Icon = I[c.icon] || I.Hash;
            return (
              <button key={i} onClick={c.action}
                style={{
                  display: 'flex', alignItems: 'center', gap: 10, width: '100%',
                  padding: '8px 10px', textAlign: 'left',
                  background: i === 0 ? 'var(--surface-inset)' : 'transparent',
                  border: 0, borderRadius: 'var(--radius)', cursor: 'default',
                  fontFamily: 'inherit', color: 'var(--text)', fontSize: 13.5,
                }}
                onMouseOver={e => e.currentTarget.style.background = 'var(--surface-inset)'}
                onMouseOut={e => e.currentTarget.style.background = i === 0 ? 'var(--surface-inset)' : 'transparent'}>
                <Icon size={14} style={{ color: 'var(--text-muted)' }} />
                <span style={{ flex: 1 }}>{c.label}</span>
                <span style={{
                  fontSize: 10, padding: '2px 5px', borderRadius: 3,
                  background: c.kind === 'nav' ? 'var(--accent-bg)' : c.kind === 'jump' ? 'var(--ai-bg)' : 'var(--surface-inset)',
                  color: c.kind === 'nav' ? 'var(--accent-text)' : c.kind === 'jump' ? 'var(--ai-text)' : 'var(--text-muted)',
                  fontWeight: 600, letterSpacing: '.04em', textTransform: 'uppercase',
                }}>{c.kind}</span>
                {c.shortcut && <kbd>{c.shortcut}</kbd>}
              </button>
            );
          })}
        </div>
        <div style={{ padding: '8px 16px', borderTop: '1px solid var(--border)',
          fontSize: 11, color: 'var(--text-faint)',
          display: 'flex', alignItems: 'center', gap: 12, background: 'var(--surface-2)' }}>
          <span><kbd>↑↓</kbd> navigate</span>
          <span><kbd>↵</kbd> run</span>
          <span><kbd>esc</kbd> close</span>
          <div style={{ flex: 1 }} />
          <span>powered by <kbd>⌘K</kbd></span>
        </div>
      </div>
    </>
  );
}

// ── Preview pane ───────────────────────────────────────────────────────────
// Renders the actual selected layout (or one of the seven framing screens)
// at native 1280×720 and scales it to fit the slide-out width — so what you
// see here matches what the Dynamo Player will render at runtime.
function PreviewPane({ onClose, course, selectedLayoutId, layoutDrafts, surface }) {
  // Default to the framing screen that matches the current surface, if any.
  const surfaceToFraming = {
    'brand': 'cover', 'languages': 'language', 'roles': 'role',
    'assessments': 'preAssessment', 'home': 'home',
  };
  const [mode, setMode] = React.useState(
    surfaceToFraming[surface] ? `frame:${surfaceToFraming[surface]}` : 'layout');

  // Resolve the layout from the selectedLayoutId.
  let layout = null;
  (course?.modules || []).forEach(m => {
    (m.layouts || []).forEach(l => { if (l.id === selectedLayoutId) layout = l; });
  });
  const layoutType = layout?.type || 'sequence';
  const draft = layoutDrafts?.[selectedLayoutId];
  const content = window.layoutContentBase(layoutType, course?.contentMode);

  const isFrame = mode.startsWith('frame:');
  const frameKey = isFrame ? mode.slice(6) : null;
  const W = 1280, H = 720;

  return (
    <aside className="slide-in" style={{
      position: 'absolute', top: 0, right: 0, bottom: 0,
      width: 540, background: 'var(--surface)',
      borderLeft: '1px solid var(--border)',
      boxShadow: 'var(--shadow-xl)',
      display: 'flex', flexDirection: 'column', zIndex: 20,
    }}>
      <header style={{ padding: '12px 14px', borderBottom: '1px solid var(--border)',
        display: 'flex', alignItems: 'center', gap: 8 }}>
        <I.Eye size={14} />
        <h3 style={{ margin: 0, fontSize: 13, fontWeight: 600 }}>Live preview</h3>
        <div style={{ flex: 1 }} />
        <kbd>⌘P</kbd>
        <button className="btn sm ghost" onClick={onClose}><I.X size={13} /></button>
      </header>

      {/* Mode picker */}
      <div style={{ padding: '8px 14px', borderBottom: '1px solid var(--border)',
        display: 'flex', flexDirection: 'column', gap: 6,
        background: 'var(--surface-2)' }}>
        <div style={{ fontSize: 10.5, color: 'var(--text-faint)', fontWeight: 600,
          letterSpacing: '.06em', textTransform: 'uppercase' }}>The selected layout</div>
        <button onClick={() => setMode('layout')}
          style={{
            display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px',
            background: mode === 'layout' ? 'var(--accent-bg)' : 'transparent',
            color: mode === 'layout' ? 'var(--accent-text)' : 'var(--text)',
            border: '1px solid', borderColor: mode === 'layout' ? 'var(--accent-border)' : 'transparent',
            borderRadius: 'var(--radius)', cursor: 'default', fontFamily: 'inherit',
            fontSize: 12.5, textAlign: 'left',
          }}>
          <LayoutTypeChip type={layoutType} />
          <span className="truncate" style={{ flex: 1 }}>{selectedLayoutId || 'no layout selected'}</span>
        </button>
        <div style={{ fontSize: 10.5, color: 'var(--text-faint)', fontWeight: 600,
          letterSpacing: '.06em', textTransform: 'uppercase', marginTop: 6 }}>Course framing</div>
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4 }}>
          {[
            ['cover',           'Cover',         'Image'],
            ['language',        'Language',      'Globe'],
            ['brand',           'Business',      'Layers'],
            ['role',            'Role',          'Users'],
            ['home',            'Home',          'Grid'],
            ['preAssessment',   'Pre-assess.',   'ClipboardCheck'],
            ['postAssessment',  'Post-assess.',  'ClipboardCheck'],
          ].map(([k, label, ic]) => {
            const Ic = I[ic];
            const sel = frameKey === k;
            return (
              <button key={k} onClick={() => setMode(`frame:${k}`)}
                style={{
                  display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px',
                  fontSize: 11.5, fontFamily: 'inherit', cursor: 'default',
                  background: sel ? 'var(--accent-bg)' : 'var(--surface-inset)',
                  color: sel ? 'var(--accent-text)' : 'var(--text-muted)',
                  border: '1px solid', borderColor: sel ? 'var(--accent-border)' : 'transparent',
                  borderRadius: 'var(--radius-sm)', textAlign: 'left',
                }}>
                <Ic size={11} /><span style={{ flex: 1, whiteSpace: 'nowrap' }}>{label}</span>
              </button>
            );
          })}
        </div>
      </div>

      <div style={{ flex: 1, padding: 16, overflowY: 'auto', background: 'var(--bg-deep)' }}>
        <PlayerFrame width={W} height={H} maxWidth={508}>
          <PlayerPreview
            layout={isFrame ? null : { ...content, ...(layout || {}), ...(draft || {}), type: layoutType }}
            frame={isFrame ? frameKey : 'default'} />
        </PlayerFrame>
        <div style={{ marginTop: 10, fontSize: 11, color: 'var(--text-faint)',
          textAlign: 'center', fontFamily: 'var(--font-mono)' }}>
          {isFrame
            ? `framing-screen · ${frameKey}`
            : `${selectedLayoutId || ''} · ${layoutType}`}
        </div>
      </div>
    </aside>
  );
}

// PlayerFrame — renders children inside a fixed 1280×720 box, scaled to fit
// the given maxWidth. This is the device-frame equivalent for the Dynamo Player.
function PlayerFrame({ children, width = 1280, height = 720, maxWidth = 480 }) {
  const scale = maxWidth / width;
  const scaledH = height * scale;
  return (
    <div style={{
      width: maxWidth, height: scaledH,
      background: '#000', borderRadius: 'var(--radius-md)',
      overflow: 'hidden', boxShadow: 'var(--shadow-lg)',
      position: 'relative', margin: '0 auto',
    }}>
      <div style={{
        width, height,
        transform: `scale(${scale})`, transformOrigin: 'top left',
        position: 'absolute', top: 0, left: 0,
      }}>
        {children}
      </div>
    </div>
  );
}

// ── Tweaks panel ───────────────────────────────────────────────────────────
function DynamoTweaks({ t, setTweak, setSurface }) {
  return (
    <TweaksPanel>
      <TweakSection label="Appearance" />
      <TweakToggle label="Dark mode" value={t.dark} onChange={(v) => setTweak('dark', v)} />
      <TweakRadio label="Density" value={t.density} options={['compact','regular','comfy']}
        onChange={(v) => setTweak('density', v)} />
      <TweakRadio label="Left rail" value={t.rail} options={['collapsed','expanded']}
        onChange={(v) => setTweak('rail', v)} />

      <TweakSection label="AI presence" />
      <TweakToggle label="Show AI badges" value={t.showAiBadges}
        onChange={(v) => setTweak('showAiBadges', v)} />
      <TweakToggle label="Show validation dots" value={t.showValidationDot}
        onChange={(v) => setTweak('showValidationDot', v)} />

      <TweakSection label="Persona" />
      <TweakRadio label="View as" value={t.viewAs || 'author'} options={['author','admin']}
        onChange={(v) => setTweak('viewAs', v)} />

      <TweakSection label="Roles surface" />
      <TweakToggle label="Start from blank (preview)" value={!!t.rolesStartBlank}
        onChange={(v) => { setTweak('rolesStartBlank', v); setSurface('roles'); }} />

      <TweakSection label="Jump to surface" />
      <TweakButton label="New course flow" onClick={() => setSurface('new-course')} />
      <TweakButton label="Generation in progress" onClick={() => setSurface('generating')} />
      <TweakButton label="Layout editor (M2-L3 sequence)" onClick={() => setSurface('layout')} />
      <TweakButton label="Layout library" onClick={() => setSurface('library')} />
      <TweakButton label="Component library" onClick={() => setSurface('components')} />
      <TweakButton label="Empty sources state" secondary onClick={() => setSurface('sources-empty')} />
    </TweaksPanel>
  );
}

// Splash gate (src/auth.jsx) — the editor only mounts once signed in.
ReactDOM.createRoot(document.getElementById('root')).render(<AuthGate><App /></AuthGate>);
