// Building a course from a document, while the author watches.
//
// ── ★ FIVE BARS, NOT ONE ────────────────────────────────────────────────────
//
// Omar, 2026-09-16: *"there should be a clear indicator for each phase i.e.
// review of the content, layout creation 1 out of 40, etc. It is a must that the
// user know exactly what is happening and which process is going, so make sure
// to split each activity with its own progress bar."*
//
// So all five activities are on screen at once, each with its own label, its own
// bar and its own count. A single merged bar would answer "how far through" and
// lose "what is happening", which is the half he asked for.
//
// ── ★ AND EVERY NUMBER IS A MEASUREMENT OR IT IS ABSENT ─────────────────────
//
// `ActivityBar` accepts `value` only as a real fraction and has no "fake it
// smoothly" mode — there is a test named after that. Two of the five activities
// genuinely cannot show a fraction: reading the document is one request with
// nothing countable inside it, and so is the save. They show elapsed time while
// they run and a REAL COUNT when they finish, which satisfies "the user knows
// exactly what is happening" without inventing a percentage
// (`feedback_a_plausible_placeholder_is_worse_than_an_obvious_one`).
//
//   Uploading    MEASURED — bytes, from the upload's own XHR progress
//   Reading      indeterminate, then "10,977 words · 29 headings"
//   Proposing    indeterminate, then "3 chapters · 12 modules · 41 screens"
//   Building     MEASURED — screen n of N, and module m of M, both from the outline
//   Saving       indeterminate
//
// The mock this replaces showed "Classifying content · confidence 0.91" and
// "Coverage 94%". Nothing measured either, so both are gone.
//
// ── WHY THE LOOP IS HERE AND NOT ON THE SERVER ──────────────────────────────
// There is no job queue in this product and no `202`/status endpoint. The one
// proven pattern for long multi-step AI work is a client-side loop, one request
// per unit (`surface-assessments.jsx`). Building within it is deliberate.

(function () {
  'use strict';

  /** A phase that is still going. Anything else is a resting state. */
  // ⚠️ A PHASE MISSING FROM THIS LIST PARKS THE RUN FOR EVER, SILENTLY. The
  //    effect returns before it reads the phase at all, so there is no error, no
  //    request and no progress — the shape Omar reported as *"progress appeared
  //    to stop"*. `titling` was added to the machine and not to this list, and
  //    every import stopped dead just before its save. Pinned by
  //    `fe-course-import-rules.test.ts`, which DERIVES the set of phases the
  //    machine handles from the file itself rather than repeating it
  //    (`feedback_guard_the_invariant_not_the_list`).
  const RUNNING = ['reading', 'outlining', 'building', 'covering', 'titling', 'saving', 'verifying', 'sealing'];
  const isRunning = (r) => !!r && RUNNING.indexOf(r.phase) !== -1;

  const GATEWAY = () => (window.DYNAMO_ENV || {}).gatewayBase;

  /**
   * How long each call may take before the run says so instead of sitting there.
   *
   * ── ⚠️ WHY A LIMIT EXISTS AT ALL (Omar's QA import, 2026-09-19) ────────────
   *
   * "During my import, progress appeared to stop. After waiting, I refreshed
   * the page and generation continued." A fetch with no limit turns a hung
   * request into exactly that: no error, no progress, no way to tell "slow"
   * from "dead" — and the author becomes the timeout. The limits are generous
   * because the long calls are genuinely long (the outline call also runs the
   * coverage repair, which is up to three further model rounds); they exist to
   * end the SILENCE, not to hurry the work. On expiry the run goes to `failed`
   * with a sentence saying Resume continues from where it stopped — which is
   * true, because `finishedModuleIds` is how the loop decides what is left.
   */
  const CALL_LIMIT_MS = {
    read: 3 * 60 * 1000,
    outline: 10 * 60 * 1000,
    build: 7 * 60 * 1000,
    coverage: 7 * 60 * 1000,
    // Rule 9's label writer. Short: it asks for a handful of words per tab, and
    // a course that cannot get them saves with its deterministic labels rather
    // than waiting.
    titles: 3 * 60 * 1000,
    verify: 3 * 60 * 1000,
  };

  /**
   * How many times a save is attempted before the run stops.
   *
   * ★ THREE, and a CONFLICT is not one of them. A refused save is retried only
   *   where retrying can help — a timeout, a dropped connection, a 5xx. A 409
   *   means somebody else's work is on the server and retrying is precisely the
   *   thing that must not happen.
   */
  const SAVE_ATTEMPTS = 3;

  /**
   * The rungs of the recovery ladder: `rebuild`, then `reclaim`. Each one can
   * fall through without saving, and each fall-through costs one re-ask.
   *
   * ⚠️ NAMED, because `VERIFY_REASKS = 2` sat exactly on the boundary with
   *    nothing to spare: a run that falls through BOTH rungs drives the counter
   *    to exactly 2, and the cap is checked before the increment. A third rung
   *    would make those runs seal non-terminally and lose their record,
   *    silently. Tying the two together is what stops that (independent review,
   *    round 3).
   */
  const LADDER_RUNGS = 2;

  /**
   * How many times the run may ask again WITHOUT having saved in between.
   *
   * ★ TWO IS ENOUGH for the ladder as written — one re-ask after a `rebuild`
   *   with nothing to rebuild, one after a `reclaim` that wrote nothing. The cap
   *   is here because a ladder that can be wrong once can be wrong twice, and an
   *   unbounded retry is not a design: an earlier version of this loop asked 199
   *   times and was still going (found by an independent review). Reaching it is
   *   reported, never silent.
   */
  const VERIFY_REASKS = LADDER_RUNGS;

  async function postJson(url, body, limitMs) {
    const token = await window.dynamoGetAccessToken();
    const controller = new AbortController();
    const limit = typeof limitMs === 'number' ? limitMs : 5 * 60 * 1000;
    const timer = setTimeout(() => controller.abort(), limit);
    let res;
    try {
      res = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
        body: JSON.stringify(body),
        signal: controller.signal,
      });
    } catch (err) {
      if (controller.signal.aborted) {
        const mins = Math.round(limit / 60000);
        const timeout = new Error(
          `the server did not answer within ${mins} minutes — ` +
          'nothing finished was lost, and Resume continues from where it stopped');
        // So the build phase can tell a TIMEOUT from a refusal. A refused
        // module is skipped and the run carries the other eleven; a timeout is
        // transient and the module is fine, so skipping it would turn a slow
        // minute into permanently empty screens with no way back.
        timeout.timedOut = true;
        throw timeout;
      }
      throw err;
    } finally {
      clearTimeout(timer);
    }
    if (!res.ok) {
      let msg = '';
      try { msg = (await res.json()).message || ''; } catch { /* not JSON */ }
      const err = new Error(msg || `HTTP ${res.status}`);
      err.status = res.status;
      throw err;
    }
    return res.json();
  }

  /** Add up what each call reported, skipping the ones that reported nothing. */
  function addTokens(a, b) {
    const out = Object.assign({}, a || {});
    ['promptTokens', 'outputTokens', 'totalTokens'].forEach((k) => {
      if (typeof (b || {})[k] === 'number') out[k] = (out[k] || 0) + b[k];
    });
    return out;
  }

  /**
   * The identity of ONE STEP of work, used to make the driving effect run
   * exactly once per step.
   *
   * ── ⚠️ THE DEFECT THIS IS THE FIX FOR (2026-09-18, Omar's first real run) ──
   *
   * It used to be `run.startedAt + ':' + run.phase`, inline. Every phase
   * transition changes `phase`, so that was right for four of the five steps —
   * and WRONG for the fifth, which is the only one that repeats. The building
   * step deliberately ends in `phase: 'building'` again, because one request per
   * module is what makes "module 7 of 12" a measurement rather than a
   * decoration. So after the first module the key was UNCHANGED, the effect
   * returned immediately, and the run stalled for ever: one module built, the
   * save never reached, nothing written.
   *
   * On Omar's 353-block document that cost an outline call and one build call —
   * both billed — and produced an empty course. The code's own comment said the
   * effect "re-runs on the new `finishedModuleIds`"; the key never mentioned
   * them. **The intent and the guard disagreed, and only the guard ran.**
   *
   * ★ So the key counts the work FINISHED, not the phase it is in. A step is
   *   identified by what has been completed, which is the thing that actually
   *   changes when a step completes.
   *
   * ⚠️ `resumedAt` is part of it too: Resume clears `drivingRef`, but a resume
   *    that lands on the same phase and count must still be a different step, or
   *    a run stopped and resumed inside one module would stall exactly as above.
   */
  /**
 * Ascending, unique, integers only.
 *
 * ⚠️ The manifest is re-sent on every autosave, so it has to stay small and
 *    stable: duplicates would grow it without bound across resumes, and a
 *    changing order would make every save a real diff.
 */
function dedupeInts(list) {
  var seen = {};
  var out = [];
  (list || []).forEach(function (v) {
    if (!Number.isInteger(v) || v < 0 || seen[v]) return;
    seen[v] = 1;
    out.push(v);
  });
  return out.sort(function (a, b) { return a - b; });
}

function runStepKey(run) {
    if (!run) return '';
    return [
      run.startedAt,
      run.resumedAt || '',
      run.phase,
      (run.finishedModuleIds || []).length,
      // ★ A SAVE RETRY IS A NEW STEP. The effect runs once per key; without
      //   this, re-entering `saving` after a refused save would find its own
      //   key already in `drivingRef` and return, and the run would sit in
      //   `saving` for ever with nothing wrong on the wire. The verify round is
      //   here for the same reason: the run can ask again without saving first.
      run.saveAttempt || 0,
      run.verifyRound || 0,
    ].join(':');
  }

  /**
   * Drive one run.
   *
   * Everything it learns goes into `generationRun`, which is part of the draft
   * and therefore persisted — so a reload finds a run that stopped rather than a
   * course that half-built itself for no visible reason.
   *
   * @param opts.run        the current `generationRun`, or null
   * @param opts.setRun     updater for it
   * @param opts.courseId   the real course UUID
   * @param opts.ready      false while the draft is still loading — see below
   * @param opts.language   the course's authoring language
   * @param opts.applyOutline  (built) => void, called ONCE with modules/groups/drafts
   * @param opts.applyScreens  (drafts) => void, called per module
   * @param opts.save       () => Promise, the normal save path
   */
  function useGenerationRun(opts) {
    const { run, setRun, courseId, ready, language } = opts;
    // A run is driven by exactly one effect invocation. React can mount an
    // effect twice (StrictMode) and this one spends money, so the guard is a ref
    // keyed on the run rather than a boolean.
    const drivingRef = React.useRef(null);
    const stoppedRef = React.useRef(false);
    // The handlers change identity on every render (they close over state
    // setters); read them through a ref so the effect below depends only on the
    // run itself and cannot restart mid-build.
    const handlersRef = React.useRef(opts);
    handlersRef.current = opts;

    React.useEffect(() => {
      if (!isRunning(run)) return undefined;
      // ★ THE BOOT RACE. `hydrateDraft(null)` unconditionally resets every slice
      // when the draft finishes loading, so a run started before that would have
      // its results wiped a moment later — intermittently, and on screen it
      // would look exactly like "the AI produced nothing".
      if (!ready) return undefined;
      const key = runStepKey(run);
      if (drivingRef.current === key) return undefined;
      drivingRef.current = key;

      // ── ★ A PAID ANSWER IS APPLIED, NOT DISCARDED ─────────────────────────
      //
      // This used to be a `cancelled` flag set by the effect's cleanup — which
      // runs on EVERY change to the run object, including ones that do not
      // change the step (Dismiss, a `ready` flicker). A request in flight at
      // that moment completed, was billed, and was then thrown away; the fresh
      // effect invocation saw its own key already in `drivingRef` and returned;
      // and the run sat in `building` for ever with nothing wrong on the wire.
      // That is the stall Omar refreshed his way out of — the refresh worked
      // only because a remount resets `drivingRef`.
      //
      // So the test is OWNERSHIP, not cleanup: this invocation applies its
      // result as long as it is still the current driver for its step.
      // `drivingRef` moves on only when the step itself moves on (a new key) —
      // at which point a stale answer landing late must indeed be dropped, or
      // Resume's fresh request and the stalled one it replaced would both
      // write. Stop stays an explicit, user-meant discard.
      const bail = () => drivingRef.current !== key || stoppedRef.current;

      (async () => {
        const h = handlersRef.current;
        const base = `${GATEWAY()}/v1/courses/${courseId}/source`;
        try {
          // ── ② READING ───────────────────────────────────────────────────
          if (run.phase === 'reading') {
            const read = await postJson(`${base}/read`, { assetId: run.sourceAssetId }, CALL_LIMIT_MS.read);
            if (bail()) return;
            setRun((r) => Object.assign({}, r, {
              phase: 'outlining',
              stats: read.stats,
              readMs: Date.now() - Date.parse(r.startedAt),
            }));
            return;
          }

          // ── ③ PROPOSING ─────────────────────────────────────────────────
          if (run.phase === 'outlining') {
            const outline = await postJson(`${base}/outline`, {
              assetId: run.sourceAssetId,
              courseTitle: run.courseTitle || '',
            }, CALL_LIMIT_MS.outline);
            if (bail()) return;
            const built = window.outlineToCourse(outline, {
              makeModule: window.makeModule,
              createLayout: window.createLayout,
              layoutCreateDeps: window.layoutCreateDeps,
              blankMode: true,
              sourceName: run.sourceName,
              idSeed: String(Date.parse(run.startedAt)),
            });
            h.applyOutline(built);
            setRun((r) => Object.assign({}, r, {
              phase: built.plan.length ? 'building' : 'titling',
              counts: outline.counts,
              plan: built.plan,
              dropped: (outline.dropped || []).concat(
                built.skippedTypes.map((t) => `a "${t}" screen could not be created here`)),
              adjusted: outline.adjusted || [],
              note: outline.note || null,
              sourceTruncated: !!outline.sourceTruncated,
              outlineMs: outline.durationMs,
              tokens: addTokens(r.tokens, outline.tokens),
              finishedModuleIds: [],
              screensBuilt: 0,
              // ★ THE RUN'S RULES SNAPSHOT. Read once by the server, at the
              // outline, and carried from here to the end of the run — Omar:
              // "Each import should use one consistent set of rules from its
              // start." A save in the Console mid-run changes the NEXT import,
              // never this one, and it cannot change it because this object is
              // what the remaining requests send.
              rules: outline.rules || null,
              rulesSource: outline.rulesSource || null,
              // ★ THE RUN'S OWN ID, kept so the SECOND measurement can be filed
              //   against the first.
              //
              //   The recorder writes the plan-side row at the end of the
              //   outline call and hands its id back; until now nothing stored
              //   it, so the saved course and the run that produced it were two
              //   unrelated records. `/source/verify` reads this from the saved
              //   draft and files what the author actually got against the same
              //   run — which is what makes "41 planned, 70 saved" a single
              //   readable fact instead of two numbers nobody compared.
              importRunId: (outline.importRun && outline.importRun.runId) || null,
              rulesUnavailable: outline.rulesUnavailable || null,
              // What the rules could not fix without inventing content. Kept
              // apart from `dropped` (our shaping refusing the model) and from
              // `adjusted` (repairs that worked): a violation is a fact about
              // the DOCUMENT and it is the one that stops a run being called
              // complete.
              ruleViolations: outline.ruleViolations || [],
              ruleCheck: outline.ruleCheck || null,
              // ★ THE COVERAGE VERDICT NOW ARRIVES WITH THE OUTLINE.
              //
              // It used to be produced by the `covering` phase below, which is
              // driven from here — so a browser that did not reach that phase
              // simply never checked, and the course still called itself
              // finished. It is computed on the server now, where the document
              // and the plan both already are, and storing it here means the
              // `covering` phase's own guard (`!run.coverage`) skips a second,
              // paid pass rather than repeating one that has already happened.
              // Through the ONE normaliser both write sites share — the other
              // path stores counts, and a renderer facing two shapes under one
              // name is how "[object Object]" reached Omar's verdict
              // (`feedback_two_writers_need_one_response_handler`).
              coverage: window.coverageRecordOf(outline.coverage || null),
              complete: outline.complete === true,
              incompleteBecause: outline.incompleteBecause || [],
              spanAudit: outline.spanAudit || null,
            }));
            return;
          }

          // ── ④ BUILDING — one request per module ─────────────────────────
          if (run.phase === 'building') {
            const done = run.finishedModuleIds || [];
            // ★ KEYED ON THE CHUNK, falling back to the module for a run that
            //   was saved before a module could be more than one request. A key
            //   that is not unique per request would mark a whole module done
            //   after its first quarter (`feedback_position_is_not_identity`).
            const next = (run.plan || []).find((m) => done.indexOf(m.chunkId || m.moduleId) === -1);
            if (!next) {
              // ★ The second pass goes HERE, between the last module and the
              // save, and only when the rules ask for it and it has not already
              // run. `run.coverage` is part of the persisted run, so a reload
              // mid-course does not buy a second coverage call.
              const wantsCoverage =
                run.rules && run.rules.coverageCheck && !run.coverage;
              setRun((r) => Object.assign({}, r, {
                phase: wantsCoverage ? 'covering' : 'titling',
              }));
              return;
            }
            let written = null;
            let failure = null;
            try {
              const insistBlocks = (run.absentBlocks || []).filter((i) =>
                (next.screens || []).some((sc) => i >= sc.from && i <= sc.to));
              written = await postJson(`${base}/build`, {
                assetId: run.sourceAssetId,
                moduleTitle: next.moduleTitle,
                language: language || 'en',
                ...(insistBlocks.length ? { insistBlocks: insistBlocks } : {}),
                // The run's own snapshot, not whatever the Console holds now.
                ...(run.rules ? { rules: run.rules } : {}),
                screens: next.screens.map((s) => ({
                  id: s.id, type: s.type, title: s.title, from: s.from, to: s.to,
                })),
              }, CALL_LIMIT_MS.build);
            } catch (err) {
              // ⚠️ A TIMEOUT IS NOT A MODULE FAILURE. Skip-and-continue below
              // exists for a call the server ANSWERED with a refusal — the
              // module itself is the problem, and the other eleven should not
              // pay for it. A call nobody answered says nothing about the
              // module; swallowing it here would mark the module permanently
              // "could not be written" over a slow minute, with no way to ask
              // again. It goes to the outer catch instead: phase `failed`, the
              // timeout's own sentence on screen, and Resume re-asking for
              // exactly this module.
              if (err && err.timedOut) throw err;
              // ★ A module that fails takes ONLY ITSELF down. The module still
              // exists, named, with its screens on placeholders — and a
              // placeholder ships, so the course is still buildable. Failing the
              // whole run over one of twelve would throw away eleven modules of
              // the author's document.
              failure = String((err && err.message) || err);
            }
            if (bail()) return;

            if (written) {
              const byId = {};
              (written.screens || []).forEach((spec) => {
                if (spec && spec.id) byId[spec.id] = spec;
              });
              // ★ WALKED IN PLAN ORDER, not in the order the model answered in.
              // The alternating-image rule is about SUCCESSIVE occurrences, so
              // the count has to follow the module's own reading order — and a
              // model is free to return its screens in any order at all.
              const drafts = {};
              let imageScreens = 0;
              next.screens.forEach((planned) => {
                const spec = byId[planned.id];
                if (!spec) return;
                const opts = {};
                if (planned.type === 'text_and_image') {
                  // ★ PREFER THE SERVER'S ANSWER. It is computed in the rule
                  // engine, where it cannot be skipped by a browser that does
                  // not have the run's rules — which is exactly how the rule
                  // came to be ignored on Omar's Modern Slavery import. The
                  // local count remains only as a fallback for a plan built
                  // before the server started sending one.
                  if (typeof planned.imagePlacementIndex === 'number') {
                    opts.imagePlacementIndex = planned.imagePlacementIndex;
                  } else if (run.rules && run.rules.alternateImagePlacement) {
                    opts.imagePlacementIndex = imageScreens;
                  }
                  imageScreens += 1;
                }
                drafts[planned.id] = { type: planned.type, spec: spec, opts: opts };
              });
              h.applyScreens(drafts);
            }
            setRun((r) => Object.assign({}, r, {
              finishedModuleIds: (r.finishedModuleIds || []).concat([next.chunkId || next.moduleId]),
              screensBuilt: (r.screensBuilt || 0) +
                (written ? (written.returned || 0) : 0),
              // ★ The server measures how much of each chunk's source text the
              //   written screens carry, and sends it with every answer. This
              //   used to be discarded — the audit's phrase: "the honest
              //   figure exists; 0 occurrences of `retention` in the browser".
              //   Kept as an aggregate a person can read; the SAVED course
              //   gets its own full measure at the verify step.
              retention: (written && written.retention)
                ? {
                    chunks: (((r.retention || {}).chunks) || 0) + 1,
                    worstChunk: Math.min(
                      ((r.retention || {}).worstChunk) != null
                        ? r.retention.worstChunk : 1,
                      typeof written.retention.chunkFraction === 'number'
                        ? written.retention.chunkFraction : 1),
                    rewritten: (((r.retention || {}).rewritten) || 0) +
                      (written.retention.rewritten || 0),
                  }
                : (r.retention || null),
              dropped: (r.dropped || []).concat(
                (written && written.dropped) || []),
              skipped: failure
                ? (r.skipped || []).concat([{
                    moduleId: next.moduleId, title: next.moduleTitle, reason: failure,
                  }])
                : (r.skipped || []),
              tokens: addTokens(r.tokens, written && written.tokens),
              ruleViolations: (r.ruleViolations || []).concat(
                (written && written.ruleViolations) || []),
              // Same phase: the effect re-runs on the new `finishedModuleIds`
              // and picks up the next module. One request in flight at a time,
              // which is what makes "module 7 of 12" true rather than decorative.
              phase: 'building',
            }));
            return;
          }

          // ── ④b COVERING — the second pass over the whole document ───────
          //
          // ★ RUNS ONCE, AND THE GUARD IS `coverage` BEING SET rather than a
          //   flag meaning "I have been here". After it places screens the run
          //   goes back to `building` to write them, and `building` sends it
          //   here again when the plan is exhausted — so a guard on anything
          //   less durable would loop for ever on a course with one gap.
          if (run.phase === 'covering') {
            const plan = run.plan || [];
            if (!plan.length) {
              setRun((r) => Object.assign({}, r, { phase: 'titling' }));
              return;
            }
            let cov = null;
            let failure = null;
            try {
              cov = await postJson(`${base}/coverage`, {
                assetId: run.sourceAssetId,
                courseTitle: run.courseTitle || '',
                ...(run.rules ? { rules: run.rules } : {}),
                modules: plan.map((m) => ({
                  moduleId: m.moduleId,
                  moduleTitle: m.moduleTitle,
                  screens: m.screens.map((s) => ({
                    id: s.id, type: s.type, title: s.title, from: s.from, to: s.to,
                  })),
                })),
              }, CALL_LIMIT_MS.coverage);
            } catch (err) {
              // Same judgement as a failed module: the course is WORSE without
              // the repair, not broken. Losing every built module because the
              // second pass could not run would be the wrong trade.
              failure = String((err && err.message) || err);
            }
            if (bail()) return;

            let added = null;
            if (cov && (cov.recovered || []).length) {
              added = window.appendRecoveredScreens(cov.recovered, {
                plan: plan,
                createLayout: window.createLayout,
                layoutCreateDeps: window.layoutCreateDeps,
                sourceName: run.sourceName,
              });
              if (added) h.applyRecovered(added);
            }

            setRun((r) => Object.assign({}, r, {
              // Mark it done FIRST, so the `building` branch below cannot send
              // us back here after writing the recovered screens.
              coverage: cov
                ? window.coverageRecordOf(Object.assign({}, cov.coverage, {
                    recovered: cov.recovered || [],
                    unplaceable: cov.unplaceable || [],
                    summary: cov.summary,
                    recoveryError: cov.recoveryError,
                  }))
                : { failed: failure || 'the coverage check did not run' },
              // ★ The verdict travels WITH the numbers it describes. Before
              // this, `complete`/`incompleteBecause` kept the OUTLINE's answer
              // while `coverage` held this later pass's — which is how one
              // verdict told Omar "2 passages still on no screen" and "5 are
              // still on no screen" three lines apart.
              ...(cov && typeof cov.complete === 'boolean'
                ? { complete: cov.complete,
                    incompleteBecause: cov.incompleteBecause || [] }
                : {}),
              plan: added ? added.plan : plan,
              // ★ THE MODULES THAT GAINED A SCREEN GO BACK ON THE QUEUE. The
              //   build loop skips any module already in `finishedModuleIds`,
              //   so a recovered screen would otherwise sit there for ever with
              //   its placeholder text — a screen that exists to carry words
              //   the first pass dropped, carrying none.
              // ⚠️ `added.rebuild` names MODULES; the done-list holds CHUNK keys
              //    (`M3#2`). Matching on the prefix re-opens every request that
              //    belongs to a module that gained a screen — matching on
              //    equality would re-open none of them.
              finishedModuleIds: added
                ? (r.finishedModuleIds || []).filter((id) => !added.rebuild.some(
                    (m) => id === m || String(id).indexOf(m + '#') === 0))
                : (r.finishedModuleIds || []),
              dropped: (r.dropped || []).concat((cov && cov.dropped) || [])
                .concat(added ? added.skippedTypes.map(
                  (t) => `a recovered "${t}" screen could not be created here`) : []),
              tokens: addTokens(r.tokens, cov && cov.tokens),
              // The recheck REPLACES the earlier list: it ran over the course
              // including whatever was recovered, so it is the current truth
              // rather than an addition to a stale one.
              ruleViolations: (cov && cov.ruleViolations) || r.ruleViolations || [],
              ruleCheck: (cov && cov.ruleCheck) || r.ruleCheck || null,
              phase: added && added.added > 0 ? 'building' : 'titling',
            }));
            return;
          }

          // ── ④b TITLING — Rule 9 on the course this browser has BUILT ─────
          //
          // Omar, 2026-09-24: *"Rule 9 holds on the course as saved: apply it to
          // the browser-built course before the import's single save, not by a
          // second save."*
          //
          // ⚠️ IT SITS BEFORE **EVERY** SAVE, not only the first. The ladder can
          //    come back here — a rebuild round returns to `saving` — and a
          //    rebuilt screen is a fresh tab strip with fresh titles. A pass that
          //    ran once would hold for the first save and not for the one that
          //    actually shipped (`feedback_a_writer_that_is_safe_once_is_not_safe_twice`).
          //
          // ★ THIS BROWSER DECIDES NOTHING. It sends the tabs it built and writes
          //   back the edits it is given, verbatim. Whether a title is the
          //   document's own words, whether it may be shortened, and what it
          //   becomes are all settled on the server, which holds the document
          //   (`feedback_a_check_that_lives_in_the_client_is_optional`).
          //
          // ★ AND IT NEVER BLOCKS. A failed call leaves the titles exactly as
          //   they were — a worse course, not a broken one — and the run goes on
          //   to save. Losing every built module because a label could not be
          //   written would be the wrong trade.
          if (run.phase === 'titling') {
            // ★ The SAME pair the placers use, not a local copy of them.
            const readText = (v) => window.readLoc(v, language || 'en');
            const writeText = (shape, t) => window.writeLoc(shape, t, language || 'en');
            const drafts = h.layoutDrafts() || {};
            const spanOf = {};
            (run.plan || []).forEach((m) => (m.screens || []).forEach((s) => {
              spanOf[s.id] = { from: s.from, to: s.to, type: s.type };
            }));
            const tabScreens = Object.keys(drafts).map((layoutId) => {
              const d = drafts[layoutId] || {};
              const type = (spanOf[layoutId] && spanOf[layoutId].type) || d.type;
              if (type !== 'vertical_tabs' && type !== 'horizontal_tabs') return null;
              if (!Array.isArray(d.tabs) || !d.tabs.length) return null;
              const span = spanOf[layoutId] || {};
              return {
                layoutId: layoutId,
                type: type,
                screenTitle: readText(d.titleMain),
                screenSubtitle: readText(d.titleSub),
                ...(typeof span.from === 'number' ? { from: span.from, to: span.to } : {}),
                tabs: d.tabs.map((t) => ({
                  // ★ ITS OWN ID travels, and comes back on the edit. Position
                  //   is not identity: this browser reads its drafts, waits on
                  //   the call, then writes, and a strip that changed order in
                  //   between would take an index onto a different tab.
                  ...(t && typeof t.id === 'string' && t.id ? { id: t.id } : {}),
                  title: readText(t && t.tabTitle),
                  text: readText(t && (t.tabText !== undefined ? t.tabText : t.text)),
                })),
              };
            }).filter(Boolean);

            if (!tabScreens.length) {
              setRun((r) => Object.assign({}, r, { phase: 'saving' }));
              return;
            }

            let titling = null;
            let titlingError = null;
            try {
              titling = await postJson(`${base}/tab-titles`, {
                assetId: run.sourceAssetId,
                ...(run.rules ? { rules: run.rules } : {}),
                screens: tabScreens,
                // ★ The findings so far go UP, and what comes back is the list
                //   that survives this pass. The judgement is the server's; this
                //   page carries the sentences, it does not rule on them.
                findings: run.ruleViolations || [],
              }, CALL_LIMIT_MS.titles);
            } catch (err) {
              titlingError = String((err && err.message) || err);
            }
            if (bail()) return;

            // ── ★★ APPLIED TO THE LIVE DRAFTS, NOT TO THE SNAPSHOT ──────
            //
            // ⚠️ IT BUILT A PATCH FROM `drafts` — read BEFORE a call that can take
            //    minutes — and handed it over. `applyDrafts` merges by layoutId,
            //    so the whole of a touched screen went back as it was before the
            //    call: an author's edit to that screen vanished. And the body it
            //    wrote was composed from a tab body the route had capped at
            //    20,000 characters, so a longer one lost the remainder.
            //
            // ★ THE WRITE NOW HAPPENS INSIDE THE STATE UPDATER, against whatever
            //   the drafts ARE at that moment, and it only ever SETS a title and
            //   PREPENDS a heading. Nothing it does can remove a character the
            //   author has (`feedback_merge_into_a_users_work_with_an_allowlisted_diff`).
            //
            // ★ AND IDENTITY WITHOUT AN ID. No tab in any real course carries one
            //   — measured on all three saved courses, 0 of 64 — so the id path
            //   is a future convenience and the working path is this: the edit
            //   names the title it was DECIDED ABOUT, and it is applied only to a
            //   tab still carrying that exact title. A strip reordered, retitled
            //   or edited during the call therefore keeps the author's version
            //   rather than taking a label meant for a different tab
            //   (`feedback_position_is_not_identity`).
            let applied = 0;
            let skippedStale = 0;
            const edits = (titling && Array.isArray(titling.edits) ? titling.edits : []).filter(
              // ⚠️ `was` IS VALIDATED TOO — it is the field that carries IDENTITY,
              //    and the check below reads `now === (typeof e.was === 'string'
              //    ? e.was : now)`, which is `now === now` when it is missing: the
              //    edit then applies by index unconditionally, failing OPEN on the
              //    one guard that protects an author's edit
              //    (`feedback_a_required_field_needs_a_reader`).
              (e) => e && typeof e.layoutId === 'string' && typeof e.title === 'string'
                && typeof e.was === 'string'
                && Number.isInteger(e.tabIndex) && e.tabIndex >= 0,
            );
            if (edits.length) {
              h.applyDrafts((live) => {
                const next = Object.assign({}, live);
                edits.forEach((e) => {
                  const d = next[e.layoutId];
                  if (!d || !Array.isArray(d.tabs)) return;
                  // ① its own id, where the layout gives one;
                  let at = e.tabId
                    ? d.tabs.findIndex((t) => t && t.id === e.tabId)
                    : -1;
                  // ② otherwise the position, and ONLY while the tab there still
                  //    carries the title this was decided about. `was` is '' for a
                  //    tab that had none, which is itself a match condition.
                  if (at < 0 && !e.tabId) {
                    const there = d.tabs[e.tabIndex];
                    const now = there ? readText(there.tabTitle) : null;
                    if (there && now === e.was) at = e.tabIndex;
                  }
                  if (at < 0) { skippedStale += 1; return; }
                  const copy = JSON.parse(JSON.stringify(d));
                  const slot = copy.tabs[at];
                  if (!slot) { skippedStale += 1; return; }
                  slot.tabTitle = writeText(slot.tabTitle, e.title);
                  // Rule 9's remedy: the heading goes at the TOP of whatever the
                  // body is now — never a body this pass composed earlier.
                  if (typeof e.prependToBody === 'string' && e.prependToBody) {
                    const key = slot.tabText !== undefined ? 'tabText' : 'text';
                    const body = readText(slot[key]);
                    const esc = (t) => t.replace(/&/g, '&amp;')
                      .replace(/</g, '&lt;').replace(/>/g, '&gt;');
                    const opener = '<p>' + esc(e.prependToBody) + '</p>';
                    // Never twice: an idempotent write, because this phase can
                    // run again on its own output after a rebuild round.
                    if (body.indexOf(opener) !== 0) {
                      slot[key] = writeText(slot[key], body.trim() ? opener + body : opener);
                    }
                  }
                  next[e.layoutId] = copy;
                  applied += 1;
                });
                return next;
              });
            }

            setRun((r) => Object.assign({}, r, {
              phase: 'saving',
              titling: {
                ran: !!(titling && titling.ran),
                applied: applied,
                checked: (titling && titling.checked) || null,
                skippedStale: skippedStale,
                asked: (titling && titling.asked) || 0,
                writtenByModel: (titling && titling.writtenByModel) || 0,
                error: titlingError || (titling && titling.writeError) || null,
              },
              // Said out loud: these change what the author sees.
              adjusted: (r.adjusted || []).concat((titling && titling.adjusted) || []),
              // ── ★★ THE FINDINGS THIS PASS HAS MADE STALE ARE ALREADY GONE ──
              //
              // ⚠️ THE REPORT WAS RIGHT WHEN IT WAS WRITTEN AND WRONG BY THE TIME
              //    HE READ IT. Rule 9's findings are produced as each screen is
              //    generated; this pass runs afterwards over the built course and
              //    repairs them. Omar's two acceptance imports (54812f0e,
              //    9b3c6329, deployment 2ef10af) both saved a course with ZERO
              //    over-long, placeholder or blank tab buttons, and both reported
              //    eleven unmet rules — six naming a placeholder on a tab that
              //    reads GBCAT or ILO Toolkit.
              //
              // ★ THE SURVIVORS ARE THE SERVER'S ANSWER. The findings went up
              //   with the request and `carried` is what is left of them, judged
              //   on the final titles. This line decides nothing and names no
              //   rule — a client that knew which rule key to drop would hold a
              //   copy of the rule. When the call failed, was skipped, or could
              //   not judge them, `carried` is absent and every finding stays.
              // ⚠️ AND ONLY WHEN EVERY EDIT ACTUALLY LANDED. The server decided
              //    which screens are settled from the edits it INTENDS; this page
              //    then refuses any edit whose tab has moved underneath it — the
              //    guard above, which exists because that race was measured. Nothing
              //    reconciled the two, so a repair this page DECLINED could still
              //    have its finding withdrawn, and a tab still showing the layout
              //    editor's own placeholder label would ship with the report silent.
              //    Found by an independent review,
              //    2026-09-25; before the withdrawal existed he would have seen it.
              //
              // ★ `applied === edits.length`, NOT `skippedStale === 0`: one of the
              //   skip paths returns without counting itself, so the counter can read
              //   zero while an edit was dropped
              //   (`feedback_absence_and_emptiness_read_the_same`).
              //
              // ★ This names no rule and computes no verdict — "an edit I did not
              //   apply" is a fact about this page's own write, not a copy of a rule.
              //
              // ⚠️ AND ITS VALUE DEPENDS ON REACT'S EAGER-STATE PATH. `applied` is
              //    incremented INSIDE the updater handed to `applyDrafts`, and React
              //    runs a functional updater synchronously only via the eager-state
              //    bailout — skipped when another update is already queued on this
              //    fiber. Measured on the real React 18.3.1 this app loads: with a
              //    `setRun` pending, `applied` reads 0 and the findings are KEPT. That
              //    is the safe direction, and it is pinned by a test so nobody later
              //    "fixes" the intermittency into the unsafe one.
              // ⚠️ AGAINST THE SERVER'S COUNT, not the filtered one. `edits` above is
              //    what survived this page's validation; an edit dropped there shrinks
              //    BOTH sides, so the gate would pass while the server had counted that
              //    edit when it named the screen settled. Not reachable today — the
              //    server always sends all four fields — and the asymmetry is the bug,
              //    not the reachability (`feedback_guard_the_invariant_not_the_list`).
              //
              // ⓘ RESIDUAL, named rather than hidden: this reconciles the EDITS, not
              //   the SCREENS. When the server sends none, `0 === 0` passes, and a
              //   screen it named settled from the snapshot sent minutes earlier is
              //   still withdrawn. Closing that means the route echoing its final
              //   titles per settled screen for the page to compare as strings.
              ruleViolations: ((titling && Array.isArray(titling.carried)
                && applied === (Array.isArray(titling.edits) ? titling.edits.length : -1))
                ? titling.carried
                : (r.ruleViolations || []))
                .concat((titling && titling.findings) || []),
              tokens: addTokens(r.tokens, titling && titling.tokens),
            }));
            return;
          }

          // ── ⑤ SAVING ────────────────────────────────────────────────────
          //
          // ★★ THE RESULT IS READ. Until 2026-09-23 this line was `await
          //    h.save()` with the answer thrown away — and `saveDraftToServer`
          //    REPORTS a failure, it does not throw one. So a 409, a 403, a
          //    schema rejection, a read-only role or a dead network all left the
          //    run walking on to `verifying`, which then measured whatever
          //    version the server still held, and to "completed".
          //
          //    Measured on Omar's run 56a02fca (course d717ac7f, QA, deployment
          //    bee9254b): ONE draft_version was written, at 11:22:25.094Z; the
          //    rebuild that followed made twelve paid `course.build` calls
          //    between 11:22:31 and 11:23:35 and not one of their results ever
          //    reached the server. The run reported 79.4 % — a true measurement
          //    of a version it had already moved past
          //    (`feedback_absence_and_emptiness_read_the_same`).
          //
          // ★ AND THE VERSION IS REMEMBERED, because `ok: true` says a save
          //   happened, not which one. `verifying` below refuses to accept a
          //   measurement of any other version.
          if (run.phase === 'saving') {
            let saved = null;
            try {
              // ★ NEVER ADOPT THE OTHER WRITER'S VERSION. `app.jsx` does adopt it
              //   on 409 so an AUTHOR's next Save supersedes the winner — the
              //   remedy a person asked for by pressing Save. An import did not
              //   ask, and replaying a machine-made course over somebody's words
              //   is the loss, not the recovery.
              saved = await h.save({ noAdoptOnConflict: true });
            } catch (err) {
              saved = { ok: false, message: String((err && err.message) || err) };
            }
            if (bail()) return;
            if (!saved || !saved.ok) {
              const attempt = (run.saveAttempt || 0) + 1;
              // ⚠️ A CONFLICT IS NEVER RETRIED. It means another writer saved
              //    after this tab last read — a second tab, another person, or
              //    this author's own editing session. Retrying would replay a
              //    machine-made course over their words. Omar, this round:
              //    *"never overwrites an author's edits"*.
              const conflicted = !!(saved && saved.conflict);
              if (!conflicted && attempt < SAVE_ATTEMPTS) {
                setRun((r) => Object.assign({}, r, { saveAttempt: attempt }));
                return;
              }
              setRun((r) => Object.assign({}, r, {
                phase: 'failed',
                saveAttempt: attempt,
                error: conflicted
                  ? 'This course was saved somewhere else while the import was ' +
                    'running, so the import stopped rather than writing over that ' +
                    'work. Nothing of yours was overwritten. Reload the course to ' +
                    'see the other copy, then import again if you still want to.'
                  : 'The course could not be saved, so the import stopped rather ' +
                    'than reporting a result it had not stored' +
                    (saved && saved.message ? ' — ' + saved.message : '') + '.',
              }));
              return;
            }
            setRun((r) => Object.assign({}, r, {
              phase: 'verifying',
              saveAttempt: 0,
              savedVersionId: saved.versionId || null,
              savedAt: new Date().toISOString(),
            }));
            return;
          }

          // ── ⑥ VERIFYING — the SAVED course against the SOURCE ───────────
          //
          // The server reads the draft back from the database — not from this
          // browser — and measures how much of the document's text is in it.
          // Omar: "The verdict reflects substantive content present in the
          // saved course." Deterministic, no model, no credential; and a
          // verify that cannot run is REPORTED as not-run, never dressed as a
          // pass (`feedback_absence_and_emptiness_read_the_same`).
          if (run.phase === 'verifying') {
            let verify = null;
            try {
              verify = await postJson(`${base}/verify`, {
                assetId: run.sourceAssetId,
                // ★ WHERE THIS RUN IS ON ITS OWN LADDER. The server decides
                //   whether another verify is coming — and therefore which one
                //   writes the permanent record — and two of the steps below
                //   move on WITHOUT saving, so reading these off the saved
                //   draft would never see them change. A fact only this run
                //   holds, sent so the decision can stay on the server.
                ladder: {
                  retried: !!run.verifyRetried,
                  reclaimed: !!run.reclaimed,
                },
                // ★ So the server does not file a permanent measurement of a
                //   version this run is about to refuse. Append-only: a wrong
                //   row can never be replaced.
                ...(run.savedVersionId
                  ? { expectedVersionId: run.savedVersionId } : {}),
              }, CALL_LIMIT_MS.verify);
            } catch (err) {
              verify = { ran: false,
                reason: String((err && err.message) || err) };
            }
            if (bail()) return;

            // ── ★★ IS THIS A MEASUREMENT OF WHAT WE JUST SAVED? ───────────
            //
            // The verify reads the draft's CURRENT version. If that is not the
            // version this run's save produced, something else wrote in between
            // and the number describes a course this run did not make. Reporting
            // it would be the 79.4 % defect wearing a different coat: a true
            // measurement of the wrong thing
            // (`feedback_a_coverage_number_can_measure_the_plan_not_the_result`).
            //
            // ⚠️ Only when BOTH ids are known. A gateway that predates this
            //    field sends none, and failing shut there would break every
            //    import against an older server for no safety gained.
            // ★ AND WHETHER IT COULD RUN AT ALL IS RECORDED. The guard needs an
            //   id from BOTH sides; a gateway that predates the field sends
            //   none, and a 2xx whose body did not parse gives the save none
            //   either. Failing open there is right — failing open INVISIBLY is
            //   not, because "checked and matched" and "never checked" would
            //   render identically (`feedback_absence_and_emptiness_read_the_same`).
            const versionCheck = !verify || verify.ran === false
              ? 'not-run: the check itself did not run'
              : !run.savedVersionId
                ? 'not-run: the save did not report which version it created'
                : !verify.versionId
                  ? 'not-run: the server did not report which version it measured'
                  : verify.versionId === run.savedVersionId ? 'ran: same version'
                    : 'ran: a different version';
            if (verify && verify.ran !== false && run.savedVersionId &&
                verify.versionId && verify.versionId !== run.savedVersionId) {
              setRun((r) => Object.assign({}, r, {
                phase: 'failed',
                versionCheck: versionCheck,
                error: 'The import checked the course and found a newer copy than ' +
                  'the one it had just saved, so it stopped rather than reporting a ' +
                  'result measured on somebody else\'s version. Nothing was ' +
                  'overwritten. Reload the course to see what is there now.',
              }));
              return;
            }

            // ── ★ ONE REBUILD, AIMED BY THE MEASUREMENT ───────────────────
            //
            // Omar: "it must check the saved course against the source,
            // recover omissions where possible, and give an accurate final
            // verdict." The verify names the exact passages that are missing;
            // the chunks whose screens claim them are rebuilt once — the
            // writer is asked again about precisely what it dropped — then
            // saved and verified again. ONCE: `verifyRetried` stops a course
            // that cannot reach the target from paying for the same lesson
            // for ever, and the second verdict is then the honest one.
            // ★ THE SERVER'S OWN VERDICT, where it sends one. It is what decides
            //   which verify writes the permanent record, so the run must take
            //   the step that verdict names rather than a second opinion formed
            //   from the same numbers (`feedback_one_rule_one_place`). `next`
            //   is absent on an older gateway; then these conditions stand
            //   alone, exactly as before.
            const wantsRebuild = verify.next ? verify.next === 'rebuild'
              : (verify.complete !== true && !run.verifyRetried &&
                 (verify.absent || []).length > 0);
            const wantsReclaim = verify.next ? verify.next === 'reclaim'
              : (verify.complete !== true && run.verifyRetried && !run.reclaimed &&
                 (verify.placements || []).length > 0);

            if (verify.ran !== false && wantsRebuild && (verify.absent || []).length) {
              const absentIdx = verify.absent.map((a) => a.i);
              const rebuild = (run.plan || []).filter((m) =>
                (m.screens || []).some((sc) =>
                  absentIdx.some((i) => i >= sc.from && i <= sc.to)));
              if (rebuild.length) {
                setRun((r) => Object.assign({}, r, {
                  verifyRetried: true,
                  savedAt: null,
                  verify: null,
                  // The exact missing parts, BY INDEX — the build requests
                  // send the subset each screen claims, and the server
                  // resolves them to the document's own words. Naming the
                  // text turns "carry more" into "this sentence is missing".
                  absentBlocks: absentIdx.slice(0, 60),
                  finishedModuleIds: (r.finishedModuleIds || []).filter((id) =>
                    !rebuild.some((m) => (m.chunkId || m.moduleId) === id)),
                  phase: 'building',
                }));
                return;
              }
              // ⚠️ THE SERVER SAID REBUILD AND THIS RUN HAS NOTHING TO REBUILD —
              //    no module in its plan claims those passages. Falling through
              //    to `sealing` here would end the run on a verify the server
              //    called non-terminal, and the permanent record would never be
              //    written at all. So the run asks again with the flag set: the
              //    next answer cannot say `rebuild`, and the run ends on a
              //    terminal verify. Deterministic, no model, no money.
              if ((run.verifyRound || 0) >= VERIFY_REASKS) {
                setRun((r) => Object.assign({}, r, {
                  verify: verify, versionCheck: versionCheck, phase: 'sealing',
                  dropped: (r.dropped || []).concat([
                    'the check kept asking for work this course has nowhere to ' +
                    'put, so the import stopped asking — the result above stands, ' +
                    'and it was not filed against this run',
                  ]),
                }));
                return;
              }
              setRun((r) => Object.assign({}, r, {
                verifyRetried: true,
                verify: null,
                verifyRound: (r.verifyRound || 0) + 1,
                phase: 'verifying',
              }));
              return;
            }

            // ── ★ THE RECLAIM — verbatim, and NO model anywhere ───────────
            //
            // Three real runs measured the ceiling of asking the model again:
            // 73.6 → 82.2 → 83.9 %, the same paragraphs skipped even when the
            // retry named them word for word. So the passages come back as
            // arithmetic rather than as another request — arithmetic cannot
            // decline (`feedback_a_model_may_decline_so_a_guarantee_must_not_ask_one`).
            //
            // ★ AND THEY GO INSIDE THE SCREENS THAT ALREADY CLAIM THEM.
            //
            // ⚠️ THIS REPLACED `appendRecoveredScreens`. That built the missing
            //    passages as NEW screens at the end of the module, which put
            //    mid-document text after everything that followed it, made every
            //    recovered screen a `text_and_image` — nine in a row on Omar's
            //    own import, against a live rule permitting one — and created
            //    screens the run record had already been written without.
            //    Omar, 2026-09-21: *"Recovery must integrate missing text into
            //    existing screens … Recovery must not create additional screens."*
            //
            // ⚠️ NOTHING IS DECIDED HERE. Which screen, which slot, which
            //    position and whether it is possible at all were settled on the
            //    server, which holds the document and the saved draft in the
            //    same request. This branch writes what it is given and reports
            //    what it could not write
            //    (`feedback_a_check_that_lives_in_the_client_is_optional`).
            if (verify.ran !== false && wantsReclaim && (verify.placements || []).length) {
              const out = window.applyPlacements(
                verify.placements, h.layoutDrafts(), language || 'en');
              // ⚠️ A recovery that silently did nothing is the same trap in a
              //    different coat. If every placement was refused, the run says
              //    so in the list every other shortfall uses, and seals honestly
              //    rather than looking like a success.
              if (!out || !out.applied) {
                // Same reasoning as the rebuild fall-through above: `reclaimed`
                // is set, so the next answer is terminal and records.
                //
                // ⚠️ AND PAST THE CAP THIS RUN ENDS WITH NO PERMANENT RECORD,
                //    WHICH IT HAS TO SAY. The rebuild cap above says it; this
                //    branch used to reuse the ordinary "could not be written"
                //    sentence, so the one fact a reader needs was missing from
                //    the one path where the outcome is worse — a run reporting
                //    `done` with nothing filed against it
                //    (`feedback_absence_and_emptiness_read_the_same`). Found by
                //    an independent review, measured: 3 verifies, 2 saves, 0 rows.
                const capped = (run.verifyRound || 0) >= VERIFY_REASKS;
                setRun((r) => Object.assign({}, r, {
                  reclaimed: true,
                  ...(capped
                    ? { verify: verify, versionCheck: versionCheck, phase: 'sealing' }
                    : { verify: null, verifyRound: (r.verifyRound || 0) + 1,
                        phase: 'verifying' }),
                  // ★ SAID ONCE. This branch can run twice — once per re-ask —
                  //   and the same sentence twice reads as two separate losses.
                  dropped: (r.reclaimReported ? (r.dropped || []) : (r.dropped || []).concat([
                    'the missing passages could not be written into their screens' +
                    (out && (out.skipped || []).length
                      ? ' (' + out.skipped.join('; ') + ')' : '') +
                    ' — they are listed in the verdict instead',
                  ])).concat(capped ? [
                    'the check kept offering passages this course has nowhere to ' +
                    'put, so the import stopped asking — the result above stands, ' +
                    'and it was not filed against this run',
                  ] : []),
                  reclaimReported: true,
                }));
                return;
              }
              h.applyDrafts(out.drafts);
              setRun((r) => Object.assign({}, r, {
                reclaimed: true,
                recoveredPlacements: out.applied,
                // ★ WHY ANYTHING WAS NOT WRITTEN, IN THE LIST A PERSON READS.
                //
                // ⚠️ Only a COUNT of these survived, rendered nowhere, and the
                //    server's own `tailsRefused` was read by nothing at all —
                //    while its comment said "reported so a shortfall that stays
                //    is explained rather than silent". The one failure this
                //    feature has was the one nothing reported
                //    (`feedback_absence_and_emptiness_read_the_same`).
                dropped: (r.dropped || []).concat(
                  (out.skipped || []).map((why) => 'a passage was not written back — ' + why),
                  ((verify.tailsRefused || []).map((t) =>
                    'a passage the course carries only part of was left as it is — ' + t.why)),
                ),
                // ★ THE MANIFEST, from the server's own measurement of the
                //   saved course — not a tally of what this browser wrote. A
                //   LATER recovery uses it to tell "never placed" from "a
                //   person deleted this", which is the only way to honour
                //   "never restore content an author deliberately removed
                //   after import" without guessing.
                placedBlocks: dedupeInts(
                  (r.placedBlocks || []).concat(verify.placedBlocks || [], out.blocks || [])),
                skippedPlacements: (out.skipped || []).length,
                savedAt: null,
                verify: null,
                // Straight to the save: there is nothing to build — the text is
                // already on the screens.
                phase: 'titling',
              }));
              return;
            }

            setRun((r) => Object.assign({}, r, {
              verify: verify,
              versionCheck: versionCheck,
              // ★ Recorded even when nothing was recovered. A course whose
              //   first import was complete still needs a manifest, or the
              //   FIRST passage its author deletes comes straight back.
              placedBlocks: dedupeInts(
                (r.placedBlocks || []).concat(verify.placedBlocks || [])),
              // ★ The verdict is SEALED into the saved course: one more save,
              //   so the course reopened anywhere carries what this run
              //   measured — a verdict that lives only in one browser's
              //   storage is a check that lives in the client
              //   (`feedback_a_check_that_lives_in_the_client_is_optional`).
              phase: 'sealing',
            }));
            return;
          }

          // ── ⑦ SEALING — the verdict, persisted with the course ──────────
          //
          // ⚠️ THIS USED TO SWALLOW ITS FAILURE, on the reasoning that "the
          //    verdict is on screen either way". The verdict on screen lives in
          //    one browser; the verdict that matters is the one the course
          //    carries when it is reopened anywhere else. A seal that did not
          //    land leaves the saved course claiming the run state it had BEFORE
          //    the verdict — which is how a finished import reads as unfinished,
          //    and how a stale run record survives a good import.
          if (run.phase === 'sealing') {
            let sealed = null;
            try {
              sealed = await h.save({ noAdoptOnConflict: true });
            } catch (err) {
              sealed = { ok: false, message: String((err && err.message) || err) };
            }
            if (bail()) return;
            if (!sealed || !sealed.ok) {
              const attempt = (run.saveAttempt || 0) + 1;
              if (!(sealed && sealed.conflict) && attempt < SAVE_ATTEMPTS) {
                setRun((r) => Object.assign({}, r, { saveAttempt: attempt }));
                return;
              }
              setRun((r) => Object.assign({}, r, {
                phase: 'failed',
                saveAttempt: attempt,
                // ★ PRECISE ABOUT WHAT DID AND DID NOT HAPPEN. The course and
                //   whatever was recovered into it ARE on the server and the
                //   percentage on screen is a true measurement of them; what is
                //   missing is the record of that check. Saying "the import
                //   failed" flatly would be the opposite lie to the one this
                //   whole change removes.
                error: 'The course is saved and was checked against your document, ' +
                  'but the result of that check could not be stored with it' +
                  (sealed && sealed.message ? ' — ' + sealed.message : '') +
                  '. The percentage above is correct for the saved course; open ' +
                  'the course and press Save to store the result with it.',
              }));
              return;
            }
            setRun((r) => Object.assign({}, r, {
              phase: 'done',
              saveAttempt: 0,
              finishedAt: new Date().toISOString(),
            }));
          }
        } catch (err) {
          if (bail()) return;
          setRun((r) => Object.assign({}, r, {
            phase: 'failed',
            error: String((err && err.message) || err),
          }));
        }
      })();

      return undefined;
    }, [run, ready, courseId, language, setRun]);

    const stop = React.useCallback(() => {
      stoppedRef.current = true;
      setRun((r) => (r ? Object.assign({}, r, { phase: 'stopped' }) : r));
    }, [setRun]);

    const resume = React.useCallback(() => {
      stoppedRef.current = false;
      drivingRef.current = null;
      setRun((r) => {
        if (!r) return r;
        // Resume at the first phase that has not finished. `finishedModuleIds`
        // is what makes this safe: a module already written is never written
        // twice, so nothing double-charges and nothing overwrites a screen the
        // author has since edited.
        const phase = !r.stats ? 'reading'
          : !r.plan ? 'outlining'
            : (r.finishedModuleIds || []).length < (r.plan || []).length ? 'building'
              // Derived from what is PRESENT, like every other branch here: a
              // run with a plan fully built and no coverage record has not had
              // its second pass, whatever phase it happened to stop in.
              : (r.rules && r.rules.coverageCheck && !r.coverage) ? 'covering'
                // ★★ TITLING, NOT SAVING — and this branch was `'saving'`
                //    until an independent review EXECUTED the real `resume()`
                //    against a run parked in `titling` and watched it come back
                //    as `saving`.
                //
                // ⚠️ THE SAME SHAPE AS THE `RUNNING` DEFECT AT THE TOP OF THIS
                //    FILE, in the one place the forward path does not reach: a
                //    phase added to the machine and not to a list that decides
                //    where a run goes. Stop is clickable in every phase and
                //    Continue is the only way forward, so a run stopped or
                //    failed during titling resumed straight into the save —
                //    reporting "done" with the tab titles Rule 9 exists to fix
                //    still on the course, and nothing on screen saying the rule
                //    had not run (`feedback_guard_the_invariant_not_the_list`).
                //
                // ★ Derived from what is PRESENT, like every branch here: a run
                //   that has not saved has not had its titles settled either,
                //   whatever phase it stopped in. Re-running titling is cheap
                //   and safe — a title already within the limit is counted and
                //   not sent back, so only a tab that still needs one costs
                //   anything.
                : !r.savedAt ? 'titling'
                  : !r.verify ? 'verifying'
                    : 'sealing';
        return Object.assign({}, r, { phase: phase, error: null,
          // Resuming un-dismisses: the author asked for this work to continue,
          // so they get to watch it.
          dismissed: false,
          // ★ AND THE SAVE BUDGET STARTS AGAIN. Without this a run that stopped
          //   after three refused saves came back with the counter still at 3,
          //   so Continue got ONE attempt and failed with the same sentence
          //   immediately — which reads as the retry being broken. Found by an
          //   independent review, measured: 3 saves, Continue, 1 more, failed.
          saveAttempt: 0,
          // A new stamp, so the effect's one-shot key differs from the run that
          // stopped.
          startedAt: r.startedAt, resumedAt: new Date().toISOString() });
      });
    }, [setRun]);

    /**
     * Put the banner away — WITHOUT deleting how this course was made.
     *
     * ⚠️ THIS USED TO BE `setRun(null)`, and that was a real loss measured on
     * QA. `generationRun` is not the banner: it holds the source document's id,
     * the plan, the coverage verdict, what was left out and what each call
     * cost. Dismissing the banner erased all of it — both of Omar's Modern
     * Slavery courses lost their link to the document they were built from, on
     * a later save, with nothing to say so.
     *
     * The cost is not cosmetic. With no `sourceAssetId` the course can never be
     * re-checked against its source, a stopped run can never be resumed, and
     * the record of what the import spent is gone
     * (`feedback_silent_key_strip_hides_data_loss` — the same shape, by a
     * button rather than a schema).
     */
    const dismiss = React.useCallback(
      () => setRun((r) => (r ? Object.assign({}, r, { dismissed: true }) : r)),
      [setRun],
    );

    return { stop, resume, dismiss, running: isRunning(run) };
  }

  // ── The panel ─────────────────────────────────────────────────────────────

  const PHASE_ORDER = ['reading', 'outlining', 'building', 'covering', 'titling', 'saving', 'verifying', 'sealing'];
  const reached = (run, phase) =>
    run.phase === 'done' || PHASE_ORDER.indexOf(run.phase) > PHASE_ORDER.indexOf(phase);

  function Row({ label, detail, value, state }) {
    return (
      <div style={{ padding: '2px 0' }}>
        <window.ActivityBar
          label={label}
          {...(detail ? { detail } : {})}
          {...(typeof value === 'number' ? { value } : {})}
          state={state} />
      </div>
    );
  }

  /**
   * The run, as five activities.
   *
   * Rendered in the banner stack above the editor, so the author can keep
   * working on a screen that is already done while the rest arrive.
   */
  function GenerationRunBanner({ run, onStop, onResume, onDismiss }) {
    const running = isRunning(run);
    const elapsed = window.useElapsedSeconds(running);
    if (!run) return null;
    // Dismissed means "stop showing me this", never "forget this happened".
    // A run that is still going ignores it: a banner cannot be dismissed out
    // from under work in progress.
    if (run.dismissed && !running) return null;

    const stats = run.stats;
    const counts = run.counts;
    const plan = run.plan || [];
    const doneModules = (run.finishedModuleIds || []).length;
    const totalScreens = plan.reduce((n, m) => n + m.screens.length, 0);
    const built = run.screensBuilt || 0;
    // ⚠️ THE SAME KEY THE BUILD LOOP MARKS DONE WITH. A module is now several
    //    requests, so matching on `moduleId` here would name the wrong module
    //    from the second chunk onwards (`feedback_position_is_not_identity`).
    const current = plan.find(
      (m) => (run.finishedModuleIds || []).indexOf(m.chunkId || m.moduleId) === -1);
    // How many DISTINCT modules are finished — the number a person recognises,
    // where `doneModules` above counts requests.
    const moduleNames = plan.map((m) => m.moduleTitle);
    const totalModules = new Set(moduleNames).size;
    const doneModuleNames = new Set(plan
      .filter((m) => (run.finishedModuleIds || []).indexOf(m.chunkId || m.moduleId) !== -1)
      .map((m) => m.moduleTitle));
    const failedRun = run.phase === 'failed';
    const stopped = run.phase === 'stopped';
    const finished = run.phase === 'done';
    // A run PERSISTED before the one-shape normaliser shipped still carries the
    // server's raw arrays. Normalising here as well as at the write sites means
    // the renderer below reads exactly one shape whatever the draft holds —
    // idempotent, so a new record passes through untouched.
    const cov = run.coverage && !run.coverage.failed
      ? window.coverageRecordOf(run.coverage)
      : run.coverage;

    const nf = (n) => (typeof n === 'number' ? n.toLocaleString() : '');
    const secs = (ms) => (typeof ms === 'number' ? `${Math.max(1, Math.round(ms / 1000))}s` : '');

    return (
      <div style={{
        padding: '12px 16px', background: 'var(--surface)',
        borderBottom: '1px solid var(--border)',
        display: 'flex', flexDirection: 'column', gap: 8,
      }} data-generation-phase={run.phase}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
          <span style={{ fontSize: 13, fontWeight: 600, flex: 1 }}>
            {finished
              ? `Your course is built from ${run.sourceName}`
              : stopped
                ? 'Generating stopped'
                : failedRun
                  ? 'Generating could not finish'
                  : `Building your course from ${run.sourceName}`}
          </span>
          {running && (
            <span style={{ fontSize: 11.5, color: 'var(--text-muted)',
              fontVariantNumeric: 'tabular-nums' }}>
              {Math.floor(elapsed / 60)}m {String(elapsed % 60).padStart(2, '0')}s
            </span>
          )}
          {running && (
            <button className="btn sm ghost" onClick={onStop}>Stop</button>
          )}
          {(stopped || failedRun) && (
            <button className="btn sm" onClick={onResume}>Continue</button>
          )}
          {/* No handler, no button: on the create screen the run IS the
              content and the ways out are Open and Back — a Dismiss that did
              nothing would be a dead control (`feedback_no_false_affordance_toggles`). */}
          {(finished || stopped || failedRun) && !!onDismiss && (
            <button className="btn sm ghost" onClick={onDismiss}>Dismiss</button>
          )}
        </div>

        {/* ① Uploading — already finished before the editor opened, and its
            figure is the real byte count the upload reported. */}
        <Row label="Uploading the document" state="done"
          detail={`${window.formatBytes(run.sourceBytes)}${run.uploadMs ? ` · ${secs(run.uploadMs)}` : ''}`}
          value={1} />

        {/* ② Reading — ONE request, nothing countable inside it. No fraction is
            offered; the result line is real. */}
        <Row label="Reading the document"
          state={stats ? 'done' : run.phase === 'reading' ? 'running' : 'running'}
          detail={stats
            ? `${nf(stats.words)} words · ${nf(stats.headings)} heading${stats.headings === 1 ? '' : 's'}` +
              `${stats.tables ? ` · ${nf(stats.tables)} table${stats.tables === 1 ? '' : 's'}` : ''}` +
              `${run.readMs ? ` · ${secs(run.readMs)}` : ''}`
            : '—'}
          {...(stats ? { value: 1 } : {})} />

        {/* ③ Proposing — one model call. Same rule. */}
        {(stats || counts) && (
          <Row label="Proposing the structure"
            state={counts ? 'done' : 'running'}
            detail={counts
              ? `${counts.chapters} chapter${counts.chapters === 1 ? '' : 's'} · ` +
                `${counts.modules} module${counts.modules === 1 ? '' : 's'} · ` +
                `${counts.screens} screen${counts.screens === 1 ? '' : 's'}` +
                // ★ What the server ADDED to make the plan carry the document.
                //   Omar: "Progress must reflect actual processing, including
                //   verification and recovery." These two numbers are that
                //   recovery, and they are measurements of this run.
                `${cov && cov.splitForCapacity
                  ? ` · ${cov.splitForCapacity} split so nothing was cut` : ''}` +
                `${run.outlineMs ? ` · ${secs(run.outlineMs)}` : ''}`
              : '—'}
            {...(counts ? { value: 1 } : {})} />
        )}

        {/* ④ Checking nothing was left out.
            ★ IT SITS HERE BECAUSE THIS IS WHEN IT HAPPENS. The check used to be
            a step the browser drove after every module was built, which is why a
            browser that never reached it produced a course that called itself
            finished with 44 % of the document missing. It now runs on the server,
            inside the same request that proposes the structure — so the row
            belongs beside that one, and it appears for every run rather than for
            the runs that happened to get that far.

            One request, nothing countable inside it: no fraction is offered, and
            the result line is real. */}
        {(cov || run.phase === 'covering') && (
          <Row label="Checking nothing was left out"
            state={cov ? 'done' : 'running'}
            detail={cov
              ? (!cov.ran
                  ? (cov.reason || 'did not run')
                  : `${nf(cov.covered)} of ${nf(cov.blocks)} parts used` +
                    `${cov.recovered ? ` · ${cov.recovered} recovered` : ''}` +
                    `${cov.placedByPosition
                      ? ` (${cov.placedByPosition} placed by position)` : ''}` +
                    `${cov.gaps ? ` · ${cov.gaps} still missing` : ''}`)
              : '—'}
            {...(cov ? { value: 1 } : {})} />
        )}

        {/* ⑤ Building — the one activity with real units on both axes. */}
        {plan.length > 0 && (
          <div style={{ padding: '2px 0' }}>
            <window.ActivityBar
              label="Building the screens"
              detail={`screen ${Math.min(built, totalScreens)} of ${totalScreens}`}
              value={totalScreens ? Math.min(1, built / totalScreens) : 0}
              state={doneModules >= plan.length ? 'done' : 'running'} />
            <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>
              {doneModules >= plan.length
                ? `${totalModules} of ${totalModules} module${totalModules === 1 ? '' : 's'}`
                : `module ${Math.min(doneModuleNames.size + 1, totalModules)} of ${totalModules}` +
                  `${current ? ` — “${current.moduleTitle}”` : ''}`}
              {/* ★ The second writing pass, shown while it happens. A screen
                  that came back carrying too little of its passage is asked
                  again, and an author watching a bar that pauses deserves to
                  know why it paused. */}
              {run.retention && run.retention.rewritten
                ? ` · ${run.retention.rewritten} screen${run.retention.rewritten === 1 ? '' : 's'} rewritten to carry more of the document`
                : ''}
            </div>
          </div>
        )}

        {/* ⑤b Rule 9 — the tab buttons, settled before the course is saved.
            ⚠️ IT HAD NO ROW AT ALL, and that is not cosmetic. The call can take
            up to three minutes, and with no row for it every bar above reads
            "done" while the Saving row has not appeared yet — an import that
            looks frozen, with the Stop button sitting right there. That is the
            exact complaint `CALL_LIMIT_MS` was added for, and an independent
            review showed Stop during this phase used to skip Rule 9 entirely on
            Continue. Omar's own rule for this panel: *"there should be a clear
            indicator for each phase … It is a must that the user know exactly
            what is happening."*
            ★ And a titling call that FAILED was recorded and shown nowhere, so
            a course saved with its titles unfixed said so to no one
            (`feedback_absence_and_emptiness_read_the_same`). */}
        {(run.phase === 'titling' || (run.titling && reached(run, 'titling'))) && (
          <Row label="Shortening the tab buttons"
            state={run.titling ? 'done' : 'running'}
            {...(run.titling ? { value: 1 } : {})}
            detail={!run.titling ? '—'
              : run.titling.ran === false
                ? `did not run — ${run.titling.error || 'the limit is switched off in the Console'}`
                : `${run.titling.checked ? run.titling.checked.tabs : 0} tab` +
                  `${run.titling.checked && run.titling.checked.tabs === 1 ? '' : 's'} checked` +
                  `${run.titling.applied ? ` · ${run.titling.applied} shortened or given a title` : ' · none needed changing'}` +
                  `${run.titling.error ? ` · ⚠️ ${run.titling.error}` : ''}`} />
        )}

        {/* ⑥ Saving */}
        {(run.phase === 'saving' || run.phase === 'verifying' || finished) && (
          <Row label="Saving" state={run.savedAt || finished ? 'done' : 'running'}
            detail={run.savedAt || finished ? 'saved' : '—'}
            {...(run.savedAt || finished ? { value: 1 } : {})} />
        )}

        {/* ⑦ The SAVED course, measured against the document — the result-side
            half of "Checking nothing was left out". Row ④ above answers "did
            the PLAN claim everything?"; this answers Omar's actual question,
            "is my document in my course?", from the draft the server read back
            out of the database. The two are labelled apart on purpose
            (`feedback_a_coverage_number_can_measure_the_plan_not_the_result`). */}
        {(run.phase === 'verifying' || (finished && run.verify)) && (
          <Row label="Checking the saved course against the document"
            state={run.verify ? 'done' : 'running'}
            detail={run.verify
              ? (run.verify.ran === false
                  ? `could not run — ${run.verify.reason || 'no reason given'}`
                  : `${(run.verify.fraction * 100).toFixed(1)}% of the document's text ` +
                    `is in the saved course` +
                    `${run.verify.absent && run.verify.absent.length
                      ? ` · ${run.verify.absent.length} missing` : ''}` +
                    // ★ WHETHER THIS IS A MEASUREMENT OF THE VERSION THIS RUN
                    //   SAVED. The check needs an id from both sides and fails
                    //   OPEN without them, which is right — and invisible, which
                    //   is not: "checked and matched" and "never checked" would
                    //   otherwise read identically on screen
                    //   (`feedback_absence_and_emptiness_read_the_same`).
                    //   RENDERED, never computed here: the value is assigned in
                    //   one place, on the run.
                    `${run.versionCheck && run.versionCheck.indexOf('not-run') === 0
                      ? ' · could not confirm this is the copy the import saved'
                      : ''}`)
              : '—'}
            {...(run.verify ? { value: 1 } : {})} />
        )}

        {/* ★ THE VERDICT — and it is the server's, not the browser's.
            Omar: "An import must only be reported as complete after its required
            content checks have run successfully." `complete` and the reasons
            beneath it are computed where the document and the plan both are, so
            a browser cannot decide it has finished. An EMPTY reason list is the
            only thing that means complete. */}
        {(finished || stopped) && cov && (() => {
          // ⚠️ A STOPPED run never gets the reassuring heading, whatever the
          // span arithmetic says: its remaining screens are placeholders, and
          // "every part of the document is on a screen" over placeholder
          // screens is the exact sentence this box exists to prevent.
          //
          // ★ AND THE HEADING NEEDS BOTH HALVES. `run.complete` is the PLAN's
          //   verdict; `run.verify` is the SAVED COURSE's, measured by the
          //   server after the save. Each is computed server-side; this only
          //   requires them to agree before saying the reassuring sentence —
          //   a verify that found text missing, or that could not run, keeps
          //   the box honest whatever the plan claimed.
          const v = run.verify;
          const verifyOk = !v || (v.ran !== false && v.complete === true);
          const settled = run.complete && verifyOk && !stopped;
          return (
          <div style={{
            marginTop: 2, padding: '9px 11px', borderRadius: 'var(--radius)',
            border: '1px solid ' + (settled ? 'var(--border)' : 'var(--warning-border, #fde68a)'),
            background: settled ? 'var(--surface)' : 'var(--warning-surface, #fef3c7)',
            display: 'flex', flexDirection: 'column', gap: 4,
          }} data-testid="import-verdict">
            <div style={{ fontSize: 12.5, fontWeight: 600,
              color: settled ? 'var(--text)' : 'var(--warning-text, #92400e)' }}>
              {settled
                ? (v && v.ran !== false
                    ? `${(v.fraction * 100).toFixed(1)}% of the document's text is in the saved course.`
                    : 'Every part of the document is on a screen.')
                : 'This course is not complete yet.'}
            </div>
            {/* ★ THE RESULT-SIDE NUMBER LEADS. It is the answer to "is my
                document in my course?" — measured on the saved draft, with a
                control — where everything below it is about the plan. */}
            {!settled && v && v.ran !== false && (
              <div style={{ fontSize: 12, fontWeight: 600,
                color: 'var(--warning-text, #92400e)' }}>
                {`${(v.fraction * 100).toFixed(1)}% of the document's text is in the ` +
                 `saved course — the target is ${Math.round((v.target || 0.98) * 100)}%.`}
              </div>
            )}
            {v && v.ran === false && (
              <div style={{ fontSize: 12, color: 'var(--warning-text, #92400e)' }}>
                {`· the saved course could not be checked against the document: ` +
                 `${v.reason || 'no reason given'}`}
              </div>
            )}
            {v && v.ran !== false && (v.absent || []).length > 0 && (
              <div style={{ fontSize: 12, color: 'var(--warning-text, #92400e)' }}>
                <div style={{ fontWeight: 600 }}>Missing from the saved course:</div>
                {v.absent.slice(0, 8).map((a, i) => (
                  <div key={i}>
                    {`· “${a.excerpt}${a.chars > (a.excerpt || '').length ? '…' : ''}”` +
                     `${a.where ? ` · under “${a.where}”` : ''}` +
                     ` · ${a.chars} characters`}
                  </div>
                ))}
                {v.absent.length > 8 && (
                  <div>{`· and ${v.absent.length - 8} more`}</div>
                )}
              </div>
            )}
            {v && v.ran !== false && (v.excluded || []).length > 0 && (
              <div style={{ fontSize: 11.5, color: 'var(--text-faint)' }}>
                {`${v.excluded.length} navigation and quiz-structure lines ` +
                 `(“${(v.excluded[0] || {}).text}”…) are not counted ` +
                 'in the percentage — they become screen structure, not prose.'}
              </div>
            )}
            {!settled && (run.incompleteBecause || []).map((why, i) => (
              <div key={i} style={{ fontSize: 12, color: 'var(--warning-text, #92400e)' }}>
                · {why}
              </div>
            ))}
            {/* ★ WHAT, not just how many. Omar: the verdict must give "a
                readable count and description of anything missing" — and his
                screen showed "[object Object]" where these lines now are,
                because a count and a list shared one field name. Every line
                below was written by the server; this renders and counts
                nothing (`feedback_a_check_that_lives_in_the_client_is_optional`). */}
            {(cov.gapDetails || []).length > 0 && (
              <div style={{ fontSize: 12, color: 'var(--warning-text, #92400e)' }}>
                <div style={{ fontWeight: 600 }}>Still on no screen:</div>
                {cov.gapDetails.slice(0, 8).map((d, i) => <div key={i}>{`· ${d}`}</div>)}
                {cov.gapDetails.length > 8 && (
                  <div>{`· and ${cov.gapDetails.length - 8} more`}</div>
                )}
              </div>
            )}
            {(cov.unplaceableDetails || []).length > 0 && (
              <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
                <div style={{ fontWeight: 600 }}>
                  Left out on purpose — check each against the document:
                </div>
                {cov.unplaceableDetails.slice(0, 8).map((d, i) => <div key={i}>{`· ${d}`}</div>)}
                {cov.unplaceableDetails.length > 8 && (
                  <div>{`· and ${cov.unplaceableDetails.length - 8} more`}</div>
                )}
              </div>
            )}
            {cov.summary && (
              <div style={{ fontSize: 11.5, color: 'var(--text-faint)' }}>
                {cov.summary}
              </div>
            )}
          </div>
          );
        })()}

        {failedRun && run.error && (
          <div style={{ fontSize: 12, color: 'var(--error-text)' }}>{run.error}</div>
        )}

        {stopped && (
          <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
            {`Generating stopped. ${built} of ${totalScreens || '—'} screens were built — ` +
             'the rest are still placeholders. Continue picks up where it left off.'}
          </div>
        )}

        {/* ★ WHAT DID NOT WORK, said out loud. A short course and a thin document
            look identical on screen, so a run that lost something has to name it
            (`feedback_absence_and_emptiness_read_the_same`). */}
        {(finished || stopped) && (run.skipped || []).length > 0 && (
          <div style={{ fontSize: 12, color: 'var(--warning-text, var(--text-muted))' }}>
            {`${run.skipped.length} module${run.skipped.length === 1 ? '' : 's'} could not be written ` +
             `and ${run.skipped.length === 1 ? 'is' : 'are'} empty: ` +
             run.skipped.map((s) => `${s.title} (${s.reason})`).join('; ')}
          </div>
        )}
        {finished && run.sourceTruncated && (
          <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
            Only the first part of the document was used — it is longer than one
            course can be built from in a single pass.
          </div>
        )}
        {finished && run.note && (
          <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{run.note}</div>
        )}

        {/* The coverage sentence itself lives in the verdict box above — it
            rendered here a SECOND time until 2026-09-19, in a second voice with
            numbers from a different moment, followed by "[object Object]" where
            a count was expected. One verdict, one place. */}
        {(finished || stopped) && cov && cov.recoveryError && (
          <div style={{ fontSize: 12, color: 'var(--warning-text, var(--text-muted))' }}>
            {`The check for missing content could not finish: ${cov.recoveryError}. ` +
             'The course is complete as built; nothing was removed.'}
          </div>
        )}

        {/* ★ THE RULES THAT COULD NOT BE MET. Never silent, and never presented
            as a failure of the import: each one is a fact about the DOCUMENT,
            and the alternative to reporting it was inventing content to hide it. */}
        {(finished || stopped) && (run.ruleViolations || []).length > 0 && (
          <div style={{ fontSize: 12, color: 'var(--warning-text, var(--text-muted))' }}>
            <div style={{ fontWeight: 600, marginBottom: 2 }}>
              {`${run.ruleViolations.length} import rule${run.ruleViolations.length === 1 ? '' : 's'} ` +
               'could not be met without adding content the document does not contain:'}
            </div>
            {run.ruleViolations.slice(0, 8).map((v, i) => (
              <div key={i}>{`· ${v.where} — ${v.message}`}</div>
            ))}
            {run.ruleViolations.length > 8 && (
              <div>{`· and ${run.ruleViolations.length - 8} more`}</div>
            )}
          </div>
        )}
        {finished && run.rulesUnavailable && (
          <div style={{ fontSize: 12, color: 'var(--warning-text, var(--text-muted))' }}>
            {`The course import settings were not read for this course, so the standard ` +
             `rules were used instead — ${run.rulesUnavailable}.`}
          </div>
        )}
      </div>
    );
  }

  Object.assign(window, {
    useGenerationRun, GenerationRunBanner,
    generationRunIsRunning: isRunning,
    // Exported so the step key can be tested as the pure rule it is, rather
    // than only through a whole run (`feedback_a_written_invariant_needs_a_test`).
    generationRunStepKey: runStepKey,
  });
})();
