// Surface 7 — Course settings
//
// Fully bound to the app-level `courseSettings` slice (see data.jsx
// SAMPLE_COURSE_SETTINGS and app.jsx). Every control reads from `settings`
// and writes through `setSettings` via the local `set(path, value)` helper.
// Section order is fixed: Course basics → Cover → News → "New to company" →
// Gaming quiz flow → Screen toggles. Every panel but the last owns a screen AND
// the content that screen needs; Screen toggles is the leftovers, which is why
// Omar put it at the bottom on 2026-08-14.
//
// ── 2026-08-14: every panel is CLOSED on arrival ────────────────────────────
// Omar: *"Make the modules closed by default so that the admin have a clear
// view when he lands on this page."* Five panels open at once was a wall of
// controls with no shape; closed, the page is a list of the five things a
// course settles. This matches the Localisation surface, which made the same
// move on 2026-08-10 (`project_localisation_surface_contract`). `defaultOpen`
// is gone from every call site rather than flipped to `false`, so the prop's
// absence is the state.
//
// ── Two panels were REMOVED the same day ────────────────────────────────────
// "Organisations" and "Assessment flow" had both been reduced to POINTERS at
// the real screens (2026-08-12/13) after their controls turned out to write to
// slices nothing read. Omar: *"They are no longer needed and add only space."*
// He is right — a pointer earns its space while an author might still be
// looking for the feature where it used to be, and stops earning it once the
// left navigation is the obvious answer. The removals are recorded at the
// bottom of this file, where the deleted editors already are.
//
// Localisation note: the course TITLE is localised as of 2026-08-14 (the
// Welcome tab on the Localisation surface writes the other languages). This
// panel writes the default-language entry AND the `course` row together — it is
// the only screen that writes either (`feedback_one_rule_one_place`). `topic`
// stays single-language: it never reaches the package at all.

// The standard "New to company" wording, per language, as the boxes below show
// it. Read off the global the generated classic script declares
// (`newtocompany-defaults.generated.js`, loaded before this file in index.html)
// rather than imported, because apps/fe has no module system — and defended with
// an empty object so a missing script degrades to today's blank boxes instead of
// throwing on `NTC_DEFAULTS.title` and blanking the whole screen
// (`feedback_the_no_build_frontend_fails_silently`).
const NTC_DEFAULTS = (typeof NEW_TO_COMPANY_DEFAULTS !== 'undefined'
  && NEW_TO_COMPANY_DEFAULTS) || { title: {}, intro: {}, body: {}, footnote: {} };

function SurfaceBrand({ course, settings, setSettings }) {
  const s = settings || window.SAMPLE_COURSE_SETTINGS;
  // Generic immutable path setter — reuses setAtPath from field-widgets.jsx.
  const set = React.useCallback((path, value) => {
    setSettings(prev => window.setAtPath(prev, path, value));
  }, [setSettings]);

  // ── Persist course metadata server-side (FE→ZIP rule) ────────────────────
  // The export bakes course.title into configJson / imsmanifest / the ZIP
  // filename straight from the DB row, so edits here must PATCH the course —
  // browser state alone never reaches the ZIP. Debounced; only fields that
  // changed since the last successful persist are sent. First render seeds
  // the baseline so sample values are never pushed unprompted.
  const persistedRef = React.useRef(null);
  const persistTimer = React.useRef(null);
  const metaTitle = (s.metadata && s.metadata.title && s.metadata.title.en) || '';
  const metaTopic = (s.metadata && s.metadata.topic) || '';
  const metaDefaultLang = (s.metadata && s.metadata.defaultLanguage) || '';

  // The current values, reachable from a callback with a STABLE identity. The
  // flush below is a dependency of the Save button; rebuilding it on every
  // keystroke would hand the button a new prop each render for no reason.
  const latestMeta = React.useRef(null);
  latestMeta.current = {
    title: metaTitle.trim(),
    topic: metaTopic.trim(),
    defaultLang: metaDefaultLang.trim().toLowerCase(),
  };

  // Send the pending row edits NOW and REPORT the outcome — the debounce timer's
  // deterministic counterpart, and what the Save button calls first.
  //
  // ★ It returns `{ok, message}` instead of swallowing failures into console.warn,
  // because the button makes a claim on its behalf. A refused `defaultLang` (not
  // yet enabled server-side) used to be a line in a console the author never
  // opens; now it is "Not saved: …" on the screen.
  const flushCourseMetadata = React.useCallback(async () => {
    clearTimeout(persistTimer.current);
    const current = latestMeta.current;
    // Nothing has been established as the baseline yet — first render. Seeding it
    // here (rather than sending) keeps the sample values from being pushed unprompted.
    if (persistedRef.current === null) { persistedRef.current = current; return { ok: true }; }
    const payload = {};
    for (const k of ['title', 'topic', 'defaultLang']) {
      if (current[k] && current[k] !== persistedRef.current[k]) payload[k] = current[k];
    }
    if (!Object.keys(payload).length) return { ok: true };
    try {
      if (typeof window.dynamoGetAccessToken !== 'function') {
        return { ok: false, message: 'not signed in' };
      }
      const token = await window.dynamoGetAccessToken();
      const res = await fetch(
        `${GATEWAY_BASE}/v1/courses/${window.dynamoCourseId}`,
        {
          method: 'PATCH',
          headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
          body: JSON.stringify(payload),
        },
      );
      if (res.ok) {
        persistedRef.current = { ...persistedRef.current, ...payload };
        return { ok: true };
      }
      let detail = `course details refused (${res.status})`;
      try {
        const body = await res.json();
        if (body && (body.message || body.error)) detail = body.message || body.error;
      } catch { /* non-JSON refusal — the status line is the whole story */ }
      return { ok: false, message: detail };
    } catch (err) {
      return { ok: false, message: (err && err.message) || 'course details could not be sent' };
    }
  }, []);

  React.useEffect(() => {
    if (persistedRef.current === null) { persistedRef.current = latestMeta.current; return; }
    clearTimeout(persistTimer.current);
    persistTimer.current = setTimeout(() => { void flushCourseMetadata(); }, 900);
    return () => clearTimeout(persistTimer.current);
  }, [metaTitle, metaTopic, metaDefaultLang, flushCourseMetadata]);

  // ★ FLUSH ON UNMOUNT, never cancel — and in an effect OF ITS OWN, which is the
  // whole point of the separation.
  //
  // The debounce effect above re-runs on every keystroke, and React runs a cleanup
  // BEFORE each re-run, not only at unmount. Putting the flush in that cleanup — as
  // the first version of this did — therefore fires a PATCH on every character typed
  // and defeats the debounce it is sitting inside. This effect depends only on
  // `flushCourseMetadata`, which is `useCallback(…, [])` and so never changes, so its
  // cleanup runs exactly once: at unmount.
  //
  // Why flush at all rather than cancel: the old cleanup was
  // `() => clearTimeout(persistTimer.current)`, which threw the edit away. Leave this
  // screen within 900 ms of the last keystroke and the PATCH never went — and it was
  // never retried either, because `persistedRef` re-seeds from the CURRENT value on
  // remount, so the change stops looking like a change. The title still reached the
  // draft (it is written to `courseTitle` too), which made the symptom narrow and
  // nasty: the header and the package showed the new title while the ZIP kept the
  // old one. A pending write is not a cancellable one
  // (`feedback_bound_anything_that_gates_a_write`).
  React.useEffect(() => () => { void flushCourseMetadata(); }, [flushCourseMetadata]);

  // The language the author writes the source text in.
  //
  // ★ THE SETTINGS VALUE FIRST, and the order is the whole point. These are two
  // names for one quantity, but they refresh at different speeds: the dropdown on
  // this very panel writes `metadata.defaultLanguage` immediately, while
  // `course.defaultLanguage` comes from `liveCourse`, a useMemo that does not
  // depend on `courseSettings` and only re-reads the row when the course is
  // opened (`app.jsx:103-172`, `:905-930`). Reading the stale one split this
  // panel against itself: change the default language to Italian, then type — the
  // Cover sentence landed in `.it` (its widget reads `LocDefaultLangContext`,
  // which IS fresh) while the Course title landed in `.en`, and the box shows no
  // language, so nothing on screen said so. Found by an independent test pass,
  // 2026-08-14, in code added the same day
  // (`feedback_a_local_mirror_of_props_goes_stale`).
  //
  // `LocDefaultLangContext` (`app.jsx:1233`) is fed from the settings value, so
  // preferring it here is what makes every field on this panel agree.
  const primaryLang = metaDefaultLang || course?.defaultLanguage || 'en';

  const flagMeta = window.FEATURE_FLAG_META || [];
  // Only author-facing screen toggles are shown. Technical / always-on flags
  // (accessibility widget, SCORM, analytics, xAPI, alt-text gate…) are hidden
  // from the UI entirely — they keep their configured defaults and are never
  // surfaced as toggles. `alwaysOn` flags (e.g. accessibility widget) stay on.
  //
  // ★ A flag is listed here ONLY once it reaches the package. The three that do
  // are marked `wired` in FEATURE_FLAG_META; `haveNewsScreen` left this list on
  // 2026-08-14 because it needs authored content and became its own panel. The
  // predicate is the invariant ("does this switch change the ZIP?") rather than
  // a list of names, so a new unwired flag cannot appear here by being added to
  // the table (`feedback_guard_the_invariant_not_the_list`).
  const visibleFlags = flagMeta.filter(f => f.wired && !f.alwaysOn);

  // Does this course actually HAVE a pre-assessment? The "new to company"
  // screen's only consequence is whether the learner is sent to it
  // (`components.js` NewToCompany.onNextClicked → `skip_pre_a`, read at
  // `scripts.js:7183-7186`), so without one the screen asks a question that
  // changes nothing. The panel says so rather than letting the author find out
  // by exporting (`feedback_honest_gates_over_standins`).
  const preAssessment = s.assessments?.pre;
  const hasPreAssessment = preAssessment?.enabled === true
    && (preAssessment.groups || []).length > 0;

  // Languages the author may pick as default. Cross-surface read: the
  // Localisation surface owns the enabled-languages list; until that slice is
  // shared we fall back to the course's declared languages.
  const enabledLangs = course?.languages || ['en'];

  return (
    <div style={{ height: '100%', background: 'var(--bg)', overflowY: 'auto' }}
         data-screen-label="Surface 7 · Course settings">
      {/* THE MISSING DOOR — Omar's report, 2026-08-14: "On the Course Settings, add
          a Save button. For consistency each section needs to have a Save button so
          that the Admin knows with certainty that the changes have been applied."
          He was right, and this was the LAST screen without one: Org & roles and
          Content mapping each got theirs on 2026-08-11 for the same reason.

          Everything on this screen except the three course-ROW fields lives in the
          draft aggregate, which reached the server only as a side effect of some
          OTHER screen's Save or of a Build — so a cover sentence, a News message or
          a toggle set here and nowhere else survived in exactly one browser.

          ONE button for the screen, not one per panel, and that is a deliberate
          reading of "each section". Every panel here writes into the same draft, so
          six buttons would be six controls performing one identical global save
          while appearing to save their own section — a granularity the data model
          does not have (`feedback_no_false_affordance_toggles`). One door, in the
          header, is also literally what the other surfaces do, which is the
          consistency he asked for.

          `alsoSave` is the part that makes the claim true: the course title,
          topic and default language are NOT in the draft, and without flushing
          their debounced PATCH the button would tick green over an unsent title
          (`feedback_verify_the_control_not_just_the_pipeline`). It runs BESIDE
          the draft save rather than in front of it — a refused metadata field
          must never stop the author's content reaching the server.

          `dirtySignal` is the whole settings object, so the button drops its
          "Saved to the server" the moment anything on any panel changes. */}
      {/* The shared page header (2026-08-14). This screen had a sentence and no
          TITLE — the only surface that never said its own name. */}
      <window.SurfaceHeader title="Course settings"
        description={<>Settings that apply to the whole course — the screens a learner
          meets before the first module, and the text on them. Translate that text in
          <strong> Localisation</strong>.</>}>
        <window.DraftSaveButton
          dirtySignal={JSON.stringify(settings)}
          alsoSave={flushCourseMetadata}
          title="Write these course settings to the server, so they survive a cleared browser and can be opened on another machine" />
      </window.SurfaceHeader>
      <div style={{ padding: '20px 28px 40px',
        display: 'flex', flexDirection: 'column', gap: 16 }}>

        {/* ── Course basics ─────────────────────────────────────────── */}
        <SettingsPanel title="Course basics">
          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
            <Field label="Course title"
              hint={`Shown in the course header, the browser tab and on the cover. Translate it on the Localisation surface — Welcome tab.`}>
              {/* ★ ONE keystroke, TWO destinations, and both are required.
                  · `metadata.title` → PATCHed onto the `course` ROW, which names
                    the ZIP, the imsmanifest and the courses list.
                  · `courseTitle[primary]` → the localised map that becomes
                    `<label_course_title>` in every UI.xml, and the SOURCE the
                    Localisation surface translates from.
                  Writing only the first is what made a fully-translated course
                  still show an English title on every screen (Omar, 2026-08-14).
                  Writing only the second would leave the ZIP named after the old
                  title. This is the only screen that writes either. */}
              <input className="field" value={s.metadata.title.en || ''}
                onChange={e => {
                  const v = e.target.value;
                  setSettings(prev => window.setAtPath(
                    window.setAtPath(prev, ['metadata', 'title', 'en'], v),
                    ['courseTitle', primaryLang], v));
                }}
                style={{ width: '100%' }} />
            </Field>
            <Field label="Topic / category">
              <input className="field" value={s.metadata.topic || ''}
                onChange={e => set(['metadata', 'topic'], e.target.value)}
                style={{ width: '100%' }} />
            </Field>
            <Field label="Default language"
              hint="Choose from the languages enabled on the Localisation surface.">
              <select className="field select-elegant" value={s.metadata.defaultLanguage}
                onChange={e => set(['metadata', 'defaultLanguage'], e.target.value)}
                style={{ width: '100%', paddingRight: 26 }}>
                {enabledLangs.map(l => (
                  <option key={l} value={l}>
                    {LANG_FLAGS[l] || ''} {LANG_NAMES[l] || l}
                  </option>
                ))}
              </select>
            </Field>
            <Field label="SCORM version">
              <div style={{ display: 'flex', gap: 8 }}>
                <RadioPill label="1.2" selected={s.metadata.scormVersion === '1.2'}
                  onClick={() => set(['metadata', 'scormVersion'], '1.2')} />
                <RadioPill label="2004 4th Ed · not available yet" disabled
                  disabledHint="The pinned Player runtime supports SCORM 1.2 only — 2004 will unlock when a 2004-capable runtime ships."
                  selected={false} onClick={() => {}} />
              </div>
            </Field>
          </div>
        </SettingsPanel>

        {/* ── Cover screen ──────────────────────────────────────────────────
            REAL as of 2026-08-13 — the PreviewNote that used to head this panel
            ("the cover design isn't part of the exported package yet") is gone
            because it stopped being true, not because it was inconvenient.

            Three controls were REMOVED rather than left looking editable, each
            because the Player takes that value from somewhere else and a second
            door onto one fact is what `feedback_one_rule_one_place` is about:

              · "Title overlay" → the cover's <h1> is `SL.UI.course_title`
                (scripts.js:3792) — the course title, the SAME string the course
                header (:3684) and the browser tab (:1749) render. Typing a
                different one here could only have meant changing the course
                title, so the field now SHOWS it and points at Course basics.
              · "CTA label" → the button reads `lable_startCourse` (:3796), which
                all 15 bundled UI templates already carry professionally
                translated. Omar, 2026-08-13: keep the built-in translation.
              · "CTA colour" → the Player has no setting for it; the colour is in
                the stylesheet. It was a dial wired to nothing, the same shape as
                the Assessment-flow toggles removed above
                (`feedback_no_false_affordance_toggles`). Omar's call the same
                day: drop it.

            What is left is exactly what the cover screen owns.

            ── 2026-08-14 — three more texts removed, all Omar's, all the same
            shape: a caption describing what the control already says.
              · The toggle's hint ("The exported course opens on this screen." /
                "…goes straight past it") — *"it doesn't add any value"*. The
                badge beside the panel title already reads on/off.
              · The read-only "Headline" box and the read-only "Button" box —
                *"Remove the following text and its field as it cannot be
                edited."* Both were there to explain where a value comes from,
                and a field an author cannot type into is a poor place to say it.
                The two facts survive where they are actionable: the course title
                carries its own hint in Course basics, and the built-in button
                label is now only mentioned in this comment.
            Same judgement as `feedback_a_preview_window_should_not_caption_itself`
            — and BOTH branches of the toggle hint went, not just the sentence he
            quoted, because a hint that appears only in the off state reads as a
            glitch. */}
        <SettingsPanel title="Cover screen" badge={s.cover?.enabled ? 'on' : 'off'}>
          {/* Under the switch it describes, not above the panel — Omar,
              2026-08-14, the same move the News panel got. */}
          <FieldRow label="Show the cover screen"
            hint={'The first screen a learner sees — a full-bleed image, the course '
              + 'title, one sentence, and the button that starts the course. It '
              + 'appears before the language and organisation screens.'}>
            <SettingToggle value={!!s.cover?.enabled}
              onChange={v => set(['cover', 'enabled'], v)} />
          </FieldRow>

          <div style={{ display: 'grid', gap: 12, marginTop: 12 }}>
            <FieldGrid columns={2}>
              {/* The SAME picker the gaming-quiz intro images use — a real
                  presigned upload that stores `asset://<id>`. The panel's old
                  `LogoSlot` wrote `placeholder:cover_… .jpg`, a made-up filename
                  that uploaded nothing and would fail the export gate on sight
                  (`feedback_placeholder_token_in_raw_field`). */}
              <BackgroundPicker imageOnly label="Cover image (16:9)"
                defaultFilename={s.cover?.coverBgImgUrl || ''}
                onImageChange={f => set(['cover', 'coverBgImgUrl'], f || '')} />
              <BackgroundPicker imageOnly label="Logo (optional)"
                defaultFilename={s.cover?.coverLogoImgUrl || ''}
                onImageChange={f => set(['cover', 'coverLogoImgUrl'], f || '')} />
            </FieldGrid>

            <ControlledLocalized label="Cover sentence" multiline
              placeholder="One line that sets up the course."
              value={s.cover?.sentence}
              onChange={t => set(['cover', 'sentence'], t)} />
          </div>
        </SettingsPanel>

        {/* ── News screen ───────────────────────────────────────────────────
            REAL as of 2026-08-14, and it had to be built as a CONTENT panel
            rather than the toggle it looked like.

            `<have_newsScreen>1</have_newsScreen>` on its own changes nothing:
            the Player also requires `newsObj.enabled`, which it reads from
            `content/<lang>/news.xml` (`js/scripts.js:6665`, `:7218`) — and the
            file every package already shipped was a STUB with no `<enabled>`
            element at all. So the switch alone would have been a dial wired to
            nothing (`feedback_no_false_affordance_toggles`); the headline and
            message are what make it real, and `emitNewsXml` writes them into one
            file per enabled language. */}
        <SettingsPanel title="News screen" badge={s.news?.enabled ? 'on' : 'off'}>
          {/* The explainer sits UNDER the switch it explains, as `hint`, not as a
              panel-level paragraph above it — Omar, 2026-08-14. Same shape the
              gaming-quiz row below already uses, so this is the panel's one
              caption style rather than a second one. */}
          <FieldRow label="Show the News screen"
            hint={'A single message shown once, after the language screen and before '
              + 'the modules — used for a standing notice about the course or the '
              + 'organisation.'}>
            <SettingToggle value={!!s.news?.enabled}
              onChange={v => set(['news', 'enabled'], v)} />
          </FieldRow>
          <div style={{ display: 'grid', gap: 12, marginTop: 12 }}>
            {/* "(optional)" removed on Omar's instruction, 2026-08-14. The
                placeholder below still says what leaving it empty does, so the
                affordance survives the label losing the word. */}
            <ControlledLocalized label="Headline"
              placeholder="Leave empty for a message with no heading."
              value={s.news?.title}
              onChange={t => set(['news', 'title'], t)} />
            <ControlledLocalized label="Message" multiline
              placeholder="What the learner needs to know before starting."
              value={s.news?.message}
              onChange={t => set(['news', 'message'], t)} />
          </div>
        </SettingsPanel>

        {/* ── New to company ────────────────────────────────────────────────
            THE SWITCH LIVES HERE (Omar, 2026-08-14) — it used to be one tile in
            the Screen toggles grid, two panels away from the boxes that supply its
            wording. Same judgement that moved the News screen out of that grid: a
            switch belongs next to the content it turns on
            (`feedback_functional_dependency_drives_placement`).

            ★ TWO BOXES, NOT FOUR — Omar, later the same day: *"Remove 'Opening
            question' and 'Closing note' fields and make sure that the text to use
            and to be localised is the one in the 'What each option means' … In case
            the text 'Is this the first time…' is part of another field make sure to
            combine them together with the rest of the text."*

            The runtime still renders four elements — an <h2> and three <p>s
            (`components.js:7353`) — so the fold happens in the TEMPLATE, not here:
            `gen-ui-language-assets.mjs` merges the lead-in and the closing note
            into the body block and empties their own elements, in all 15 languages.
            One author-facing Message, one tag, no merge logic at export time
            (`feedback_one_rule_one_place`).

            ★ IT OVERRIDES, IT DOES NOT FILL. Every template carries a
            professionally translated body, so a box the author never touches writes
            NOTHING and the learner reads the standard wording in their own language.

            ★ AND THE BOXES START FULL. They were empty with the standard text as a
            grey PLACEHOLDER, which vanished on the first keystroke — Omar: *"the
            right approach is to enable the admin to make small changes to the
            existing text."* `fallbacks` shows that text as the box's real, editable
            contents; it is derived from the packager's own templates, never retyped
            here (`newtocompany-defaults.generated.js`).

            ★ THE ENGLISH IS OMAR'S OWN COPY, and it lives in the TEMPLATE, not in a
            fallback. He rewrote both blocks on 2026-08-14; putting his wording in
            the box while the package shipped the reference wording would be a box
            that lies about the screen, so `TRANSLATION_CORRECTIONS.en` carries it
            and the box derives from there. The other 14 languages keep their own
            professional wording, which no longer says quite the same thing — a
            translation debt raised with him, not papered over.

            ONE-LANGUAGE PANEL, by the same rule as the rest of this screen: the
            source language here, every other language on the Welcome tab. */}
        <SettingsPanel title="New to company"
          badge={s.featureFlags?.haveNewToCompany ? 'on' : 'off'}>
          <FieldRow label="Show New to company"
            hint={'The wording on the screen that asks whether the learner is taking '
              + 'this course for the first time, or has previously completed the '
              + 'mandatory training.'}>
            <SettingToggle value={!!s.featureFlags?.haveNewToCompany}
              onChange={v => set(['featureFlags', 'haveNewToCompany'], v)} />
          </FieldRow>
          {/* The caveat travelled with the switch. It came from FlagGrid, where it
              was the only per-flag caveat; leaving it behind would have deleted a
              true warning at the moment the switch moved. Shown only while it is
              actually true of this course (`feedback_honest_gates_over_standins`). */}
          {!hasPreAssessment && (
            <div style={{ fontSize: 11.5, color: 'var(--warning-text)', lineHeight: 1.4,
              margin: '8px 2px 0' }}>
              This course has no pre-assessment yet, so both answers lead to the same
              next screen. Switch a pre-assessment on for the question to change anything.
            </div>
          )}
          {/* "Headline" and "Message", the same two labels the News panel above uses
              — deliberately, and it is why the Localisation surface groups its rows
              under panel headings: the pair is ambiguous on its own and unambiguous
              under a heading, which is the trade Omar asked for. */}
          <div style={{ display: 'grid', gap: 12, marginTop: 12 }}>
            <ControlledLocalized label="Headline"
              fallbacks={NTC_DEFAULTS.title}
              value={s.newToCompanyIntro?.title}
              onChange={t => set(['newToCompanyIntro', 'title'], t)} />
            <ControlledLocalized label="Message" multiline
              style={{ minHeight: 150 }}
              fallbacks={NTC_DEFAULTS.body}
              value={s.newToCompanyIntro?.body}
              onChange={t => set(['newToCompanyIntro', 'body'], t)} />
          </div>
        </SettingsPanel>

        {/* ── Gaming quiz flow ──────────────────────────────────────── */}
        <SettingsPanel title="Gaming quiz flow"
          badge={s.dftiFlow.enabled ? 'on' : 'off'}>
          <p style={{ margin: '0 0 12px', fontSize: 12.5, color: 'var(--text-muted)' }}>
            The gaming quiz wraps a quiz in a mini-game with start/end screens
            and a running correct/wrong counter. Enabling it here makes the
            <strong style={{ fontWeight: 600 }}> Quiz · gaming </strong>
            layout type available in the course. Each gaming quiz is configured
            inside its own layout editor.
          </p>
          <FieldRow label="Enable gaming quiz flow"
            hint={s.dftiFlow.enabled
              ? 'Authors can add Quiz · gaming layouts in Course architecture.'
              : 'Quiz · gaming is hidden from the layout-type switcher while this is off.'}>
            <SettingToggle value={s.dftiFlow.enabled}
              onChange={v => set(['dftiFlow', 'enabled'], v)} />
          </FieldRow>
        </SettingsPanel>

        {/* ── Screen toggles ─────────────────────────────────────── LAST ──
            REAL as of 2026-08-14 — the PreviewNote that headed this panel
            ("these switches aren't wired to the exported package yet") is gone
            because it stopped being true.

            LAST on the page, on Omar's instruction (2026-08-14), and the order
            now says something: every panel above owns a screen AND the content
            that screen needs, while this one is the leftovers — switches with no
            content behind them. It has been shrinking for exactly that reason:
            News left it on 2026-08-14 for its own panel, "New to company" left
            it the same day for its own, and both went because the switch alone
            was never the whole feature. */}
        <SettingsPanel title="Screen toggles" badge={String(visibleFlags.length)}>
          <FlagGrid flags={visibleFlags} settings={s} set={set} />
        </SettingsPanel>
      </div>
    </div>
  );
}

// ─── Screen-toggle grid ─────────────────────────────────────────────────────
//
// ★ The mono line under each label is the CONFIG TAG, not the FE key — fixed
// 2026-08-14. It read `{f.key}` and the tooltip said "Controls the <key> tag in
// config.xml", which was wrong for every flag whose two names differ:
// `sequentialModules` is `<sequential_modules>`, `haveNewToCompany` is
// `<have_newToCompany>`. An author reading a package would have gone looking for
// a tag that does not exist (`feedback_a_comment_is_not_a_contract`).
//
// `blockingByDefault` has NO tag — it is applied to each layout's
// `<blockingSection>` — so it shows nothing in that slot rather than a plausible
// invention.
// The `hasPreAssessment` prop and the per-flag `caveat` it fed are GONE
// (2026-08-14). `haveNewToCompany` was the only flag that ever produced one, and
// it left this grid for its own panel the same day — where the caveat went with
// it, rather than being deleted. Proven dead before removal: no other flag key
// was ever tested here, and no other call site passes the prop
// (`feedback_annotate_only_after_proving_dead`).
function FlagGrid({ flags, settings, set }) {
  return (
    <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
      {flags.map(f => {
        return (
        <div key={f.key} style={{
          display: 'flex', alignItems: 'center', justifyContent: 'space-between',
          gap: 10, padding: '8px 10px', background: 'var(--surface)',
          border: '1px solid var(--border)', borderRadius: 'var(--radius)',
        }}>
          <div style={{ minWidth: 0, flex: 1 }}>
            <div style={{ fontSize: 12.5, fontWeight: 500, lineHeight: 1.3 }}>{f.label}</div>
            <div style={{ display: 'flex', alignItems: 'center', gap: 5, marginTop: 2 }}>
              {f.tag && (
                <span style={{ fontSize: 10.5, color: 'var(--text-faint)',
                  fontFamily: 'var(--font-mono)' }}>{f.tag}</span>
              )}
              <InfoTip text={<>{f.tag
                ? <>Sets the <code style={{
                    fontFamily: 'var(--font-mono)', fontSize: 10.5,
                    background: 'rgba(255,255,255,.14)', padding: '0 3px', borderRadius: 3,
                  }}>&lt;{f.tag}&gt;</code> tag in config.xml. </>
                : null}{f.desc}</>} />
            </div>
          </div>
          <SettingToggle value={!!settings.featureFlags[f.key]}
            onChange={v => set(['featureFlags', f.key], v)} />
        </div>
        );
      })}
    </div>
  );
}

// ─── The organisation editor that USED to be here is gone ────────────────────
//
// Removed 2026-08-12 (Phase 4c). This file carried a SECOND organisation editor:
// `OrgCard` + `OrgLogo` + `LogoVariantRow` + `PrimaryRadio`, writing to a
// `settings.organisations` slice with its own shape (`id`, `isPrimary`,
// `logoVariants`) and a logo "upload" that invented a filename
// (`onSet(`logo_${Date.now().toString(36)}.png`)`) and uploaded nothing.
//
// It was honestly badged as a preview, and that was enough while organisations
// were not real. They shipped to production on 2026-08-12, so a second editor is
// no longer a harmless mock — it is a place an author can spend an afternoon
// naming organisations that the real screen will never see, because the two
// slices share no field: the real one is `courseSettings.brands[]` keyed by
// `code` (`feedback_one_rule_one_place`).
//
// The section is now a POINTER to the real screen rather than a deletion. An
// author who has been using this panel needs to be told where the feature went;
// a silently vanished section reads as a regression.
//
// `logoVariants` is not being ported. The runtime has exactly TWO logo slots
// (`#eLHeaderLogo`, `#homeInfoCompanyLogo`) and the four-variant shape came from
// `<brand>`'s XML logo fields, which feed only COMMENTED-OUT pill code. Phase 4d
// adds ONE logo per organisation, filling both real slots — which is what the
// shipped BT package itself does for Openreach
// (`brand_BT/clientSpecific.css:197-199`).

// ─── …and the POINTER that replaced it is gone too (2026-08-14) ─────────────
//
// The block above describes a section that became a pointer at Organisations &
// roles. Omar removed both pointer panels — Organisations and Assessment flow —
// on 2026-08-14: *"They are no longer needed and add only space."*
//
// This is not a reversal of the 2026-08-12 judgement, it is its expiry date. A
// pointer earns its space while an author might still reach for the feature
// where it used to be; two days and a shipped Organisations screen later, the
// left navigation is the obvious answer and the panel is a heading with a
// sentence under it. What it was preventing — an author editing organisations in
// a place the export never read — cannot recur, because the editor it pointed
// away from no longer exists in this file.
//
// The Assessment-flow pointer went the same way, and had less to lose: its three
// toggles had already been deleted (they wrote to `courseSettings.assessmentFlow`,
// which no schema models and no emitter reads), so what remained was one sentence
// naming the Assessments screen.

// ─── LogoSlot is GONE (2026-08-13) ──────────────────────────────────────────
//
// It was the last caller of the placeholder-filename pattern: clicking it stored
// `placeholder:cover-image-16-9_m3x1p.png` — a filename it invented, for a file
// that was never uploaded. Harmless while the cover reached no package, and a
// data-loss trap the moment it did: `placeholder:` in a media field is exactly
// what the export gate 422s on (`feedback_placeholder_token_in_raw_field`), so an
// author who filled the panel would have been told their export was broken by a
// value they never typed.
//
// Its two remaining call sites (cover image, cover logo) now use
// `BackgroundPicker imageOnly`, the same real presigned upload the gaming-quiz
// intro images use — one door per field, reusing the existing style rather than
// keeping a second, weaker one alive.
//
// Proven dead before deletion, not assumed: no other `apps/fe` file and no test
// referenced it (`feedback_annotate_only_after_proving_dead`). The root `eslint .`
// pass would now flag it as unused anyway — the first thing that linter has
// caught since it was switched on.

// ─── InfoTip — small "i" with a hover tooltip ───────────────────────────────
function InfoTip({ text }) {
  const [show, setShow] = React.useState(false);
  return (
    <span style={{ position: 'relative', display: 'inline-flex', flexShrink: 0 }}
      onMouseEnter={() => setShow(true)} onMouseLeave={() => setShow(false)}>
      <I.Info size={13} style={{ color: 'var(--text-faint)' }} />
      {show && (
        <span style={{
          position: 'absolute', bottom: 'calc(100% + 7px)', left: '50%',
          transform: 'translateX(-50%)', width: 230, zIndex: 50,
          padding: '8px 10px', background: 'var(--text-strong)', color: '#fff',
          borderRadius: 'var(--radius)', fontSize: 11, lineHeight: 1.45,
          fontWeight: 400, textAlign: 'left', boxShadow: '0 8px 20px rgba(15,23,42,.22)',
          pointerEvents: 'none',
        }}>
          {text}
          <span style={{
            position: 'absolute', top: '100%', left: '50%', marginLeft: -4,
            width: 8, height: 8, transform: 'translateY(-50%) rotate(45deg)',
            background: 'var(--text-strong)',
          }} />
        </span>
      )}
    </span>
  );
}

// ─── SettingToggle — controlled Toggle with an optional disabled state ───────
// Wraps field-widgets' value-based Toggle. When disabled, the inner control is
// click-blocked but the wrapper still surfaces `disabledHint` as a tooltip.
function SettingToggle({ value, onChange, disabled, disabledHint }) {
  return (
    <span title={disabled ? disabledHint : undefined}
      style={{ display: 'inline-flex', opacity: disabled ? 0.45 : 1 }}>
      <span style={{ pointerEvents: disabled ? 'none' : 'auto' }}>
        <Toggle value={value} onChange={onChange} />
      </span>
    </span>
  );
}

// ─── Layout helpers ──────────────────────────────────────────────────────────
// `defaultOpen` is still honoured and no call site passes it any more (Omar,
// 2026-08-14: every panel closed on arrival). Kept rather than deleted because
// it is one boolean and removing it would make "open this one by default" a
// re-plumbing job the next time a panel earns it.
function SettingsPanel({ title, badge, defaultOpen, children }) {
  const [open, setOpen] = React.useState(!!defaultOpen);
  // `aria-expanded` / `aria-controls` added 2026-08-14, flagged by an independent
  // test pass. It was missing before and mattered less then: with the panels open
  // by default, a screen-reader user met the controls whatever the header
  // announced. Closing every panel — Omar's request the same day — makes the
  // header the ONLY route in, so an unannounced state is now five buttons with no
  // way to know they open anything. A change can turn a latent gap into a live
  // one (`feedback_a_dormant_error_branch_wakes_when_the_platform_changes`).
  // The Localisation surface's bands already carry both attributes; this brings
  // the two surfaces into line.
  const bodyId = `settings-panel-${String(title).toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
  return (
    <section className="card">
      <button onClick={() => setOpen(o => !o)}
        aria-expanded={open} aria-controls={bodyId}
        style={{
          display: 'flex', alignItems: 'center', gap: 10, width: '100%',
          padding: '14px 18px', border: 0, background: 'transparent',
          cursor: 'default', fontFamily: 'inherit', color: 'var(--text)', textAlign: 'left',
        }}>
        <h3 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>{title}</h3>
        {badge && <span className="pill" style={{ fontSize: 10.5 }}>{badge}</span>}
        <div style={{ flex: 1 }} />
        {open ? <I.ChevronUp size={15} /> : <I.ChevronDown size={15} />}
      </button>
      {open && (
        <div id={bodyId} style={{ padding: '0 18px 18px', borderTop: '1px solid var(--border)' }}>
          <div style={{ paddingTop: 16 }}>{children}</div>
        </div>
      )}
    </section>
  );
}

function Field({ label, hint, wide, children }) {
  return (
    <div style={{ gridColumn: wide ? 'span 2' : 'auto' }}>
      <div style={{ fontSize: 11.5, color: 'var(--text-muted)', marginBottom: 4, fontWeight: 500 }}>
        {label}
      </div>
      {children}
      {hint && <div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 4 }}>{hint}</div>}
    </div>
  );
}

function RadioPill({ label, selected, onClick, disabled, disabledHint }) {
  return (
    <button className="focusable" onClick={disabled ? undefined : onClick}
      disabled={disabled} title={disabled ? disabledHint : undefined} style={{
      padding: '4px 12px', height: 30, fontSize: 12.5,
      background: selected ? 'var(--accent-bg)' : 'var(--surface)',
      color: disabled ? 'var(--text-faint)' : selected ? 'var(--accent-text)' : 'var(--text-muted)',
      border: '1px solid', borderColor: selected ? 'var(--accent-border)' : 'var(--border-strong)',
      borderRadius: 'var(--radius)', cursor: 'default', fontFamily: 'inherit',
      fontWeight: selected ? 600 : 500, opacity: disabled ? 0.55 : 1,
    }}>{label}</button>
  );
}

// Amber one-liner for panels whose controls are not wired to the exported
// package yet (FE→ZIP rule: never let a dead control look functional).
function PreviewNote({ children }) {
  return (
    <p style={{ margin: '0 0 12px', fontSize: 12, color: 'var(--warning-text)',
      display: 'flex', alignItems: 'center', gap: 6 }}>
      <I.Info size={13} style={{ flexShrink: 0 }} />
      <span>{children}</span>
    </p>
  );
}

// PreviewNote is the shared honest-UI primitive ("this control does not reach
// the export yet, and here is why"). It is registered globally because callers
// live in files that load BEFORE this one (e.g. architecture-add-module.jsx at
// index.html:322 vs this file at :334) — a bare `PreviewNote` reference from
// there would be a ReferenceError, and with no error boundary anywhere in the
// app that blanks the whole screen. Always call it as `window.PreviewNote`.
Object.assign(window, { SurfaceBrand, PreviewNote });
