// Per-layout-type editor bodies — one function per layout type, each consuming
// { draft, setField, setPath, languages } and rendering its full §3.N table.
//
// Each editor is small and declarative: it surfaces every row in the catalogue's
// Editor controls table, in order, using shared field widgets from
// field-widgets.jsx + the existing media/rich-text editors.
//
// Repeating structures (rows, tabs, items, questions, icons, interactions) are
// rendered with the local <ArrayPicker> + per-element forms below.

// ─── stripTagsForLabel — collapse rich-text HTML into a plain-text snippet ─
// Row headers in ArrayPicker show body text as a one-line label. The body
// editor stores formatted HTML (e.g. "<b>foo</b>"), and dumping raw HTML into
// the heading leaks tags the author never typed. Strip + decode entities so
// the label reads as plain text.
function stripTagsForLabel(s) {
  if (!s) return '';
  // Drop tags, then resolve entities by round-tripping through a textarea.
  const noTags = String(s).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
  const ta = document.createElement('textarea');
  ta.innerHTML = noTags;
  return ta.value;
}

// ─── Title block (shared by most editors) ──────────────────────────────────
// Catalogue §1.2 — Title / Subtitle / Title colour / Title background.
// Colours appear as compact HeaderSwatches in the section header so they
// visually attach to the field they style (no separate full-width colour row).
function TitleBlock({ draft, setField, hasTitle, hasSubtitle, hasTitleColor,
  hasTitleBackground, titleRequired, titleColorRequired }) {
  if (!hasTitle && !hasSubtitle && !hasTitleColor && !hasTitleBackground) return null;
  const swatches = [];
  if (hasTitleColor)      swatches.push({ label: 'Text',
    value: draft.titleTextColor,       onChange: v => setField('titleTextColor', v) });
  if (hasTitleBackground) swatches.push({ label: 'Bg',
    value: draft.titleBackgroundColor, onChange: v => setField('titleBackgroundColor', v) });

  return (
    <EditorBlock label="Title block"
      action={swatches.length ? <HeaderSwatchSet swatches={swatches} /> : null}>
      <div style={{ display: 'grid', gap: 10 }}>
        {hasTitle && (
          <ControlledLocalized value={draft.titleMain}
            onChange={v => setField('titleMain', v)} />
        )}
        {hasSubtitle && (
          <ControlledLocalized value={draft.titleSub} multiline
            onChange={v => setField('titleSub', v)} />
        )}
      </div>
    </EditorBlock>
  );
}

// ─── ArrayPicker ───────────────────────────────────────────────────────────
// A small list of items with select / add / remove / reorder. The per-element
// editor is rendered INLINE directly under the active row, sharing accent
// border with it so the connection between "which row I picked" and "which
// controls I'm now editing" is unmistakable. Clicking the active row again
// collapses it, so all rows can be tucked away at once.
function ArrayPicker({ label, items, idKey = 'id', renderRow, renderEditor,
  onAdd, onRemove, addLabel = 'Add', min = 0, reorderable = true,
  // Extra header content rendered to the left of the Add button — used by
  // editors that want to surface the list's scoping colour swatches in the
  // same row as the list label (see docs/video-media-patterns.md §14).
  headerExtras = null,
  // Optional: notify parent when the active row index changes. Used by
  // SequenceEditor3 to sync the right-column Module preview's selected
  // step with whichever step the author is currently editing — so the
  // preview literally follows the inspector. Fires on initial mount and
  // on every subsequent click.
  onActiveChange = null }) {
  // `null` = nothing open. Default to first row open on mount.
  const [activeIdx, setActiveIdx] = React.useState(0);
  // Fire onActiveChange on mount AND whenever activeIdx changes — the
  // preview listens to keep its own step pointer aligned. Including
  // items.length in deps means a freshly-added step (which auto-focuses
  // the new row) also re-fires the notification.
  React.useEffect(() => {
    onActiveChange?.(activeIdx);
  }, [activeIdx, onActiveChange, items.length]);
  React.useEffect(() => {
    if (activeIdx != null && activeIdx >= items.length) {
      setActiveIdx(items.length ? Math.max(0, items.length - 1) : null);
    }
  }, [items.length]);
  return (
    <EditorBlock label={label} count={items.length}
      action={(headerExtras || onAdd) && (
        <div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
          {headerExtras}
          {onAdd && (
            <button className="btn sm" onClick={onAdd}>
              <I.Plus size={12} />{addLabel}
            </button>
          )}
        </div>
      )}>
      <div style={{ display: 'grid', gap: 6 }}>
        {items.map((it, i) => {
          const isActive = i === activeIdx;
          const hasEditor = isActive && renderEditor;
          return (
            <div key={it[idKey] || i}
              style={{
                /* Active row + its inline editor share a single bordered
                   shell, so they read as one connected object. */
                background: isActive ? 'var(--accent-bg)' : 'var(--surface)',
                border: '1px solid',
                borderColor: isActive ? 'var(--accent-border)' : 'var(--border)',
                borderRadius: 'var(--radius)',
                /* Subtle outer halo on the active group reinforces the link */
                boxShadow: isActive
                  ? '0 0 0 3px color-mix(in srgb, var(--accent) 10%, transparent), var(--shadow-sm)'
                  : 'none',
                overflow: 'hidden',
                transition: 'box-shadow 150ms, border-color 150ms',
              }}>
              <div onClick={() => setActiveIdx(isActive ? null : i)}
                style={{
                  display: 'grid',
                  gridTemplateColumns: reorderable ? 'auto 1fr auto' : '1fr auto',
                  gap: 10,
                  alignItems: 'center', padding: '7px 10px',
                  fontSize: 12.5, cursor: 'default',
                  position: 'relative',
                }}>
                {/* Accent stripe down the left of the active row visually
                    "owns" the editor underneath. */}
                {isActive && (
                  <span aria-hidden style={{
                    position: 'absolute', left: 0, top: 0, bottom: hasEditor ? -1 : 0,
                    width: 3, background: 'var(--accent)',
                  }} />
                )}
                {reorderable && (
                  <I.GripVertical size={13} style={{
                    color: isActive ? 'var(--accent-text)' : 'var(--text-faint)',
                    marginLeft: isActive ? 4 : 0,
                  }} />
                )}
                <div style={{
                  minWidth: 0,
                  marginLeft: !reorderable && isActive ? 8 : 0,
                }}>{renderRow(it, i, isActive)}</div>
                {items.length > min && (
                  <button className="btn sm ghost danger" onClick={(e) => {
                    e.stopPropagation();
                    onRemove?.(i);
                  }}><I.Trash size={11} /></button>
                )}
              </div>
              {hasEditor && (
                <div style={{
                  /* Editor sits flush against the active row — separated by
                     a hairline divider in the accent tone, on a paler
                     surface so the controls themselves still pop. */
                  background: 'var(--surface)',
                  borderTop: '1px solid var(--accent-border)',
                  padding: 12,
                }}>
                  {renderEditor(items[activeIdx], activeIdx)}
                </div>
              )}
            </div>
          );
        })}
        {items.length === 0 && (
          <div style={{
            padding: '14px 12px', textAlign: 'center', fontSize: 12,
            color: 'var(--text-muted)', background: 'var(--surface)',
            border: '1px dashed var(--border-strong)', borderRadius: 'var(--radius-md)',
          }}>No {label.toLowerCase()} yet — click "{addLabel}" to start.</div>
        )}
      </div>
    </EditorBlock>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// 3.1 fullscreen_text_and_image
// ────────────────────────────────────────────────────────────────────────────
function FullscreenTextAndImageEditor3({ draft, setField }) {
  return (
    <>
      <TitleBlock draft={draft} setField={setField}
        hasTitle hasTitleColor titleRequired titleColorRequired />
      <EditorBlock label="Body text"
        action={<HeaderSwatchSet swatches={[
          { label: 'Text', value: draft.textColor,
            onChange: v => setField('textColor', v) },
        ]} />}>
        <ControlledRich value={draft.text} onChange={v => setField('text', v)}
          placeholder="A line of body copy that overlays the background…" />
      </EditorBlock>
      <EditorBlock label="Background">
        <BackgroundPicker
          defaultColor={draft.backgroundColor || '#0f172a'}
          defaultFilename={draft.backgroundImageUrl?.replace('placeholder:', '')}
          defaultMode={draft.backgroundMode}
          onColorChange={v => setField('backgroundColor', v)}
          onModeChange={m => setField('backgroundMode', m)}
          onImageChange={f => setField('backgroundImageUrl',
            f ? ((f.startsWith('placeholder:') || f.startsWith('asset://')) ? f : 'placeholder:' + f) : '')} />
      </EditorBlock>
      <GateBlock draft={draft} setField={setField} />
    </>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// 3.2 fullscreen_video
// ────────────────────────────────────────────────────────────────────────────
function FullscreenVideoEditor3({ draft, setField, setPath }) {
  return (
    <>
      <TitleBlock draft={draft} setField={setField}
        hasTitle hasSubtitle hasTitleColor hasTitleBackground titleRequired titleColorRequired />
      <EditorBlock label="Video media">
        <VideoMediaBlock
          videoUrl={draft.videoUrl} videoThumbUrl={draft.videoThumbUrl}
          subtitlesUrl={draft.subtitlesUrl} transcriptUrl={draft.transcriptUrl}
          duration="04:32" generatedBy="HeyGen"
          onChange={(k, v) => setField(k, v)} />
      </EditorBlock>
      {/* In-video overlays only make sense once a video exists — surfacing
          an empty interactions panel above an empty video media slot just
          tells the author to do work they can't do yet. */}
      {draft.videoUrl ? (
        <EditorBlock label="In-video overlays"
          count={(draft.interactions || []).length}>
          {(draft.interactions || []).length === 0
            ? <InteractionEmptyState onAdd={(t) => setField('interactions', [
                makeNewInteraction(t, 60),
              ])} />
            : <InVideoInteractionTimeline
                videoUrl={draft.videoUrl} videoThumbUrl={draft.videoThumbUrl}
                interactions={draft.interactions}
                onChange={list => setField('interactions', list)} />}
        </EditorBlock>
      ) : null}
      <GateBlock draft={draft} setField={setField} />
    </>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// 3.3 text_and_image
// ────────────────────────────────────────────────────────────────────────────
function TextAndImageEditor3({ draft, setField, setPath }) {
  const rows = draft.rows || [];
  // Rows are a discriminated union on `kind` ('image' | 'video'), fixed at
  // creation via the "+ Add row" InlineKindPicker — no in-row variant switch
  // (consistent with §23's "split variants, don't toggle" rule). TextAndImageRow
  // schema (companion codebase PR).
  const addRowOfKind = (kind) => setField('rows', [
    ...rows,
    kind === 'video'
      ? { id: 'r' + Date.now().toString(36), kind: 'video', backgroundColor: '#ffffff',
          videoUrl: 'content/row-video.mp4', videoThumbUrl: 'placeholder:row-video',
          text: 'New row body text', textColor: '#0f172a',
          imageOnTheLeft: rows.length % 2 === 0 }
      : { id: 'r' + Date.now().toString(36), kind: 'image', backgroundColor: '#ffffff',
          imageUrl: 'placeholder:scene-new', text: 'New row body text', textColor: '#0f172a',
          imageOnTheLeft: rows.length % 2 === 0 },
  ]);
  const removeRow = i => setField('rows', rows.filter((_, j) => j !== i));
  return (
    <>
      <TitleBlock draft={draft} setField={setField}
        hasTitle hasSubtitle hasTitleColor hasTitleBackground titleRequired titleColorRequired />
      <ArrayPicker
        label="Rows" items={rows} idKey="id" min={1} reorderable={false}
        onRemove={removeRow}
        headerExtras={<InlineKindPicker addLabel="Add row" onPick={addRowOfKind}
          options={[
            { id: 'image', label: 'Image row', icon: 'Image' },
            { id: 'video', label: 'Video row', icon: 'Film' },
          ]} />}
        renderRow={(r, i) => {
          const kind = r.kind || (r.videoUrl ? 'video' : 'image');
          return (
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11,
              color: 'var(--text-faint)' }}>R{i + 1}</span>
            <KindBadge kind={kind} />
            <span className="truncate" style={{ flex: 1 }}>{stripTagsForLabel(locText(r.text)).slice(0, 60)}</span>
            <span style={{ fontSize: 10, color: 'var(--text-muted)',
              fontFamily: 'var(--font-mono)' }}>
              {r.imageOnTheLeft ? '◧ media L' : 'media R ◨'}
            </span>
          </div>
          );
        }}
        renderEditor={(r, i) => {
          const kind = r.kind || (r.videoUrl ? 'video' : 'image');
          return (
          <div style={{ display: 'grid', gap: 12 }}>
            {/* Layout toggle only — media kind is fixed at creation (no in-row
                switch). */}
            <div style={{ display: 'flex', alignItems: 'center', gap: 10,
              padding: '8px 12px', background: 'var(--surface)',
              border: '1px solid var(--border)', borderRadius: 'var(--radius-md)' }}>
              <span style={{ fontSize: 11.5, color: 'var(--text-muted)', fontWeight: 600,
                letterSpacing: '.02em', textTransform: 'uppercase' }}>Layout</span>
              <SegmentedControl value={r.imageOnTheLeft ? 'left' : 'right'}
                onChange={v => setPath(['rows', i, 'imageOnTheLeft'], v === 'left')}
                options={[
                  { id: 'left',  label: 'Media ◧ Text' },
                  { id: 'right', label: 'Text ◨ Media' },
                ]} />
              <div style={{ flex: 1 }} />
              <KindBadge kind={kind} />
            </div>
            {kind === 'image' ? (
              <BackgroundPicker imageOnly
                defaultFilename={r.imageUrl?.replace('placeholder:', '')}
                onImageChange={f => setPath(['rows', i, 'imageUrl'],
                  f ? ((f.startsWith('placeholder:') || f.startsWith('asset://')) ? f : 'placeholder:' + f) : '')} />
            ) : (
              <VideoMediaBlock
                videoUrl={r.videoUrl} videoThumbUrl={r.videoThumbUrl}
                subtitlesUrl={r.subtitlesUrl}
                hasTranscript={false}
                onChange={(k, v) => setPath(['rows', i, k], v)} />
            )}
            <SubBlock label="Row text"
              action={<HeaderSwatchSet swatches={[
                { label: 'Text', value: r.textColor,
                  onChange: v => setPath(['rows', i, 'textColor'], v) },
                { label: 'Bg', value: r.backgroundColor,
                  onChange: v => setPath(['rows', i, 'backgroundColor'], v) },
              ]} />}>
              <ControlledRich value={r.text}
                placeholder="Row body text — supports bold, italic, lists, links."
                onChange={v => setPath(['rows', i, 'text'], v)} />
            </SubBlock>
          </div>
          );
        }} />
      <GateBlock draft={draft} setField={setField} />
    </>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// 3.4 small_video
// ────────────────────────────────────────────────────────────────────────────
function SmallVideoEditor3({ draft, setField }) {
  return (
    <>
      <TitleBlock draft={draft} setField={setField}
        hasTitle hasSubtitle hasTitleColor hasTitleBackground />
      {/* Video media owns the "external background" colour now — the swatch
          that fills the area around the small video inside the layout. Used
          to live in a separate "Content background" EditorBlock above; folded
          into this block's header so the inspector reads as one Video card
          (see docs/video-media-patterns.md §11). */}
      <EditorBlock label="Video media"
        action={<HeaderSwatchSet swatches={[
          { label: 'External bg', value: draft.contentBackgroundColor,
            onChange: v => setField('contentBackgroundColor', v) },
        ]} />}>
        <VideoMediaBlock
          videoUrl={draft.videoUrl} videoThumbUrl={draft.videoThumbUrl}
          subtitlesUrl={draft.subtitlesUrl} transcriptUrl={draft.transcriptUrl}
          duration="02:14"
          onChange={(k, v) => setField(k, v)} />
      </EditorBlock>
      <EditorBlock label="In-video overlays"
        count={(draft.interactions || []).length}>
        {(draft.interactions || []).length === 0
          ? <InteractionEmptyState onAdd={(t) => setField('interactions', [
              makeNewInteraction(t, 60),
            ])} />
          : <InVideoInteractionTimeline
              videoUrl={draft.videoUrl} videoThumbUrl={draft.videoThumbUrl}
              duration={134}
              interactions={draft.interactions}
              onChange={list => setField('interactions', list)} />}
      </EditorBlock>
      <GateBlock draft={draft} setField={setField} />
    </>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// 3.5 sequence
// ────────────────────────────────────────────────────────────────────────────
function SequenceEditor3({ draft, setField, setPath }) {
  const tabs = draft.tabs || [];
  /* "+ Add step" opens an inline kind picker (Text and Image / Video /
     Question + paired Feedback). The kind is chosen at creation, not
     toggled inline after the fact. Feedback is intentionally NOT in
     this picker — it's auto-paired with Question. See
     docs/video-media-patterns.md §15. */
  const addTabOfKind = (kind) => {
    const stamp = Date.now().toString(36);
    const id = 't' + stamp;
    const titles = { text: 'New step', video: 'New video step',
      question: 'New question', feedback: 'Feedback' };
    const title = titles[kind] || 'New step';
    const base = { id, kind, tabTitle: title, mobileText: title };
    const preset = {
      text:     { ...base, tabText: 'Step body text.', imageUrl: 'placeholder:step-image' },
      video:    { ...base, videoUrl: '', videoThumbUrl: '', interactions: [] },
      /* Quiz step — same data shape as in-video Question interactions.
         The author edits text + options[]; the player renders both. The
         feedback step is auto-created right after (see below). */
      question: { ...base, text: 'The question to surface to the learner at this moment.',
        imageUrl: 'placeholder:question-image',
        options: [
          { text: 'Yes', isCorrect: true,  feedback: 'Correct — that\'s the right read.' },
          { text: 'No',  isCorrect: false, feedback: 'Not quite — revisit the prior step.' },
        ],
        /* Empty shared feedback — only renders if the author ticks a
           second correct answer (the question becomes <questionMultiple>). */
        feedbacks: { correct: '', wrong: '' },
        /* Cross-link so the player + preview can resolve the paired
           feedback step from the question (and vice-versa). */
        feedbackTabId: 'tf' + stamp },
      feedback: { ...base, forQuestionTabId: '', imageUrl: 'placeholder:feedback-image' },
    }[kind] || base;
    /* Question steps always create their paired Feedback step in the
       same write. The Feedback step has no editable feedback-text of
       its own — its body is drawn from the question's per-answer
       feedback at play-time. The author only chooses an image. */
    if (kind === 'question') {
      const fb = {
        id: 'tf' + stamp,
        kind: 'feedback',
        tabTitle: 'Feedback',
        mobileText: 'Feedback',
        forQuestionTabId: id,
        imageUrl: 'placeholder:feedback-image',
      };
      setField('tabs', [...tabs, preset, fb]);
    } else {
      setField('tabs', [...tabs, preset]);
    }
  };
  /* When a Question is removed, the auto-paired Feedback right after
     it is dropped too — they're a unit at the data level. Removing a
     Feedback also removes the Question that owns it — the inverse
     direction the user asked for. Both directions surface a small
     confirmation dialog so the cascade isn't a surprise. */
  const [pendingRemove, setPendingRemove] = React.useState(null);
  const findPairIndices = (i) => {
    const t = tabs[i];
    if (!t) return null;
    if (t.kind === 'question' && tabs[i + 1]?.kind === 'feedback') {
      return { question: i, feedback: i + 1 };
    }
    if (t.kind === 'feedback' && tabs[i - 1]?.kind === 'question') {
      return { question: i - 1, feedback: i };
    }
    return null;
  };
  const removeTab = i => {
    const pair = findPairIndices(i);
    if (pair) {
      // Pair — open the confirm dialog instead of removing immediately.
      setPendingRemove({ ...pair, triggered: tabs[i].kind });
    } else {
      setField('tabs', tabs.filter((_, j) => j !== i));
    }
  };
  const confirmRemovePair = () => {
    if (!pendingRemove) return;
    const { question, feedback } = pendingRemove;
    setField('tabs', tabs.filter((_, j) => j !== question && j !== feedback));
    setPendingRemove(null);
  };

  /* The Module preview on the right column owns its own activeIdx; we
     mirror our inspector-side activeIdx into it via a window event so
     clicking a step row literally drives what the preview shows. The
     event payload is just the index — there's only ever one sequence
     layout being edited at a time, so no scoping is needed. */
  const announceActive = React.useCallback((idx) => {
    window.dispatchEvent(new CustomEvent('dynamo:seq-active', { detail: { idx } }));
  }, []);

  return (
    <>
      <TitleBlock draft={draft} setField={setField}
        hasTitle hasSubtitle hasTitleColor hasTitleBackground titleRequired titleColorRequired />

      {/* Steps own the swatches that scope the steps strip — the External
          bg behind the content area and the Active step colour. Folded
          into the Steps header per docs/video-media-patterns.md §14 (was
          a separate "Content & accent colours" block above the list). */}
      <ArrayPicker
        label="Steps" items={tabs} idKey="id" min={1}
        onAdd={null} onRemove={removeTab}
        onActiveChange={announceActive}
        headerExtras={
          <div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
            <HeaderSwatchSet swatches={[
              { label: 'External bg', value: draft.contentBackgroundColor,
                onChange: v => setField('contentBackgroundColor', v) },
              { label: 'Step colour', value: draft.dotSelectedColor,
                onChange: v => setField('dotSelectedColor', v) },
            ]} />
            <InlineKindPicker addLabel="Add step" onPick={addTabOfKind}
              options={[
                { id: 'text',     label: 'Text and Image',      icon: 'FileText' },
                { id: 'video',    label: 'Video',               icon: 'Film' },
                { id: 'question', label: 'Question + Feedback', icon: 'AlertCircle' },
              ]} />
          </div>
        }
        renderRow={(t, i, focused) => {
          /* Feedback rows that immediately follow a Question render with
             a tied glyph (↳) and a faint indent stripe, so the author
             reads them as one paired unit — matches the "question
             auto-creates feedback" creation flow. */
          const prev = i > 0 ? tabs[i - 1] : null;
          const isPairedFeedback = t.kind === 'feedback' && prev?.kind === 'question';
          return (
            <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
              {isPairedFeedback && (
                <I.CornerDownRight size={11} style={{ color: 'var(--text-faint)',
                  marginRight: -2 }} />
              )}
              <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11,
                color: 'var(--text-faint)' }}>S{i + 1}</span>
              <KindBadge kind={t.kind} />
              <span className="truncate" style={{ flex: 1 }}>{locText(t.tabTitle) || locText(t.mobileText)}</span>
            </div>
          );
        }}
        renderEditor={(t, i) => (
          <div style={{ display: 'grid', gap: 12 }}>
            {/* Tab title — drives both the inspector row label AND the
                player's <mobileText> on small screens. The smartphone
                glyph to the right names that role without taking a row
                (§14.10). */}
            <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
              <div style={{ flex: 1, minWidth: 0 }}>
                <ControlledLocalized value={t.tabTitle}
                  onChange={v => {
                    setPath(['tabs', i, 'tabTitle'], v);
                    const flat = typeof v === 'string' ? v
                      : (v && typeof v === 'object' ? (v.en || Object.values(v)[0] || '') : '');
                    setPath(['tabs', i, 'mobileText'], flat);
                  }} />
              </div>
              <span
                title="Shown as the mobile label (<mobileText> in the player) on small screens. Required."
                style={{
                  display: 'inline-flex', alignItems: 'center', gap: 4,
                  fontSize: 10, letterSpacing: '.06em', textTransform: 'uppercase',
                  color: 'var(--text-faint)', fontWeight: 500,
                  whiteSpace: 'nowrap', flex: '0 0 auto',
                  cursor: 'help',
                }}>
                <I.Smartphone size={11} />Mobile label
              </span>
            </div>
            {/* Text and Image step — body text plus a step image. The kind
                literally implies both; the image isn't an "(optional)"
                afterthought (§14.7). */}
            {t.kind === 'text' && (
              <>
                <BackgroundPicker imageOnly
                  defaultFilename={t.imageUrl?.replace('placeholder:', '')}
                  onImageChange={f => setPath(['tabs', i, 'imageUrl'],
                    f ? ((f.startsWith('placeholder:') || f.startsWith('asset://')) ? f : 'placeholder:' + f) : '')} />
                <ControlledRich value={t.tabText || t.text}
                  placeholder="Body text — supports bold, italic, lists, links."
                  onChange={v => setPath(['tabs', i, 'tabText'], v)} />
              </>
            )}

            {t.kind === 'video' && (
              <>
                <VideoMediaBlock
                  videoUrl={t.videoUrl} videoThumbUrl={t.videoThumbUrl}
                  subtitlesUrl={t.subtitlesUrl}
                  hasTranscript={false}
                  duration="01:24"
                  onChange={(k, v) => setPath(['tabs', i, k], v)} />
                <SubBlock label="In-video overlay"
                  hint={`${(t.interactions || []).length} marker${(t.interactions || []).length === 1 ? '' : 's'}`}>
                  {(t.interactions || []).length === 0
                    ? <InteractionEmptyState onAdd={(tp) => setPath(['tabs', i, 'interactions'], [
                        makeNewInteraction(tp, 30),
                      ])} />
                    : <InVideoInteractionTimeline
                        videoUrl={t.videoUrl} videoThumbUrl={t.videoThumbUrl}
                        duration={84}
                        interactions={t.interactions}
                        onChange={list => setPath(['tabs', i, 'interactions'], list)} />}
                </SubBlock>
              </>
            )}

            {/* Question step — replicates the in-video Question inspector
                shape (sparkle + inline prompt + Generate, then question
                stem, then answer rows with per-answer feedback). Source
                for AI generation is the previous steps' content rather
                than video subtitles (§13). */}
            {t.kind === 'question' && (
              <>
                <SubBlock label="Question image">
                  <BackgroundPicker imageOnly
                    defaultFilename={t.imageUrl?.replace('placeholder:', '')}
                    onImageChange={f => setPath(['tabs', i, 'imageUrl'],
                      f ? ((f.startsWith('placeholder:') || f.startsWith('asset://')) ? f : 'placeholder:' + f) : '')} />
                </SubBlock>
                <QuestionEditorPanel
                  value={{ text: t.text, options: t.options || [] }}
                  onChange={v => setPath(['tabs', i], { ...t, text: v.text, options: v.options })}
                  instanceKey={t.id}
                  /* Question-styling colours (§3.5). The catalogue lists
                     them per-step, but the prototype's draft + preview
                     (preview-renderers sequence question banner/buttons)
                     read them at the layout level — one styling set for
                     all question steps. Surfaced here, in the question
                     panel header, so authors meet them in context. */
                  styleSwatches={[
                    { label: 'Bg',       value: draft.bgColor,
                      onChange: v => setField('bgColor', v) },
                    { label: 'Text',     value: draft.textColor,
                      onChange: v => setField('textColor', v) },
                    { label: 'Btn bg',   value: draft.btnBgColor,
                      onChange: v => setField('btnBgColor', v) },
                    { label: 'Btn text', value: draft.btnTextColor,
                      onChange: v => setField('btnTextColor', v) },
                  ]}
                  aiPlaceholder="Generate question and answers based on the previous tabs content"
                  textPlaceholder="The question to surface to the learner at this moment."
                  aiGenerate={() => new Promise(resolve => setTimeout(() => {
                    /* Mocked: in production the server would read the
                       prior steps in this sequence (text + transcripts)
                       and draft a question grounded in them. */
                    setPath(['tabs', i], {
                      ...t,
                      text: 'Which behaviour best captures the SBI sequence shown so far?',
                      options: [
                        { text: 'Naming the situation, then the behaviour, then the impact',
                          isCorrect: true,
                          feedback: 'Correct — that\'s the SBI order in three moves.' },
                        { text: 'Leading with the personal impact',
                          isCorrect: false,
                          feedback: 'Impact lands last in SBI — without the situation and behaviour first it reads as judgement.' },
                        { text: 'Softening the behaviour to keep things positive',
                          isCorrect: false,
                          feedback: 'SBI works because the behaviour is named precisely.' },
                      ],
                    });
                    resolve();
                  }, 900))} />
              </>
            )}

            {t.kind === 'feedback' && (() => {
              /* Feedback steps are auto-paired with the preceding
                 Question step. The body the learner sees is drawn from
                 the chosen answer's `feedback` field on that question —
                 the author only chooses an image and (implicitly)
                 inherits the right/wrong copy. So no "Feedback text"
                 field here, just the image + a note explaining the
                 link. */
              const prevQ = (() => {
                for (let j = i - 1; j >= 0; j--) if (tabs[j].kind === 'question') return tabs[j];
                return null;
              })();
              return (
                <div style={{ display: 'grid', gap: 12 }}>
                  <div style={{
                    display: 'flex', alignItems: 'flex-start', gap: 10,
                    padding: '10px 12px',
                    background: 'var(--ai-bg)', color: 'var(--ai-text)',
                    border: '1px solid color-mix(in srgb, var(--ai) 25%, transparent)',
                    borderRadius: 'var(--radius-md)',
                    fontSize: 12, lineHeight: 1.5,
                  }}>
                    <I.Link2 size={14} style={{ marginTop: 1, flex: '0 0 auto' }} />
                    <div>
                      <div style={{ fontWeight: 600, marginBottom: 2 }}>
                        Paired with{' '}
                        {prevQ
                          ? <span style={{ fontFamily: 'var(--font-mono)' }}>
                              S{tabs.indexOf(prevQ) + 1} · {locText(prevQ.tabTitle) || 'Question'}
                            </span>
                          : 'the preceding Question step'}
                      </div>
                      <div style={{ color: 'inherit', opacity: 0.85 }}>
                        Correct / wrong copy is taken at play-time from the answer the learner
                        picks on the question — edit it there. This step just provides the
                        image shown behind the feedback card.
                      </div>
                    </div>
                  </div>
                  <SubBlock label="Feedback image">
                    <BackgroundPicker imageOnly
                      defaultFilename={t.imageUrl?.replace('placeholder:', '')}
                      onImageChange={f => setPath(['tabs', i, 'imageUrl'],
                        f ? ((f.startsWith('placeholder:') || f.startsWith('asset://')) ? f : 'placeholder:' + f) : '')} />
                  </SubBlock>
                </div>
              );
            })()}
          </div>
        )} />

      <GateBlock draft={draft} setField={setField} />

      {pendingRemove && (() => {
        const triggered = pendingRemove.triggered;
        const otherKind = triggered === 'question' ? 'Feedback' : 'Question';
        const title = triggered === 'question'
          ? 'Delete this Question and its paired Feedback?'
          : 'Delete this Feedback and the Question it answers?';
        return (
          <ConfirmDialog
            icon="Link2"
            title={title}
            body={`Question and Feedback steps are linked — removing the ${triggered} also removes the ${otherKind.toLowerCase()} below it. The two only make sense as a pair: the feedback's copy is drawn from the question's per-answer feedback at play-time, so neither survives on its own.`}
            confirmLabel="Delete both"
            cancelLabel="Keep both"
            tone="danger"
            onConfirm={confirmRemovePair}
            onCancel={() => setPendingRemove(null)} />
        );
      })()}
    </>
  );
}

function KindBadge({ kind }) {
  const meta = {
    text:     { label: 'Text + Image', tint: 'var(--text-muted)' },
    image:    { label: 'Image',        tint: 'var(--text-muted)' },
    video:    { label: 'Video',        tint: 'var(--accent-text)' },
    question: { label: 'Question',     tint: 'var(--warning-text)' },
    feedback: { label: 'Feedback',     tint: 'var(--success-text)' },
  }[kind] || { label: kind, tint: 'var(--text-muted)' };
  return (
    <span style={{
      fontSize: 10, padding: '1px 6px', borderRadius: 3,
      background: 'var(--surface-inset)', color: meta.tint,
      fontFamily: 'var(--font-mono)', fontWeight: 600,
    }}>{meta.label}</span>
  );
}

/* ─── InlineKindPicker ─────────────────────────────────────────────────────
   "Add (X)" → click → reveals a row of "kind" buttons → click one to
   commit, click X to cancel. Used by both the sequence "+ Add step" and
   the hidden_items "+ Add hotspot" pickers — same shape, same chrome.
   See docs/video-media-patterns.md §15 (kind picker pattern).

   `options` is `[{ id, label, icon }]`. Caller wires `onPick(id)` to
   the seed-and-insert logic; the picker itself owns the open/close
   state so callers don't have to. */
function InlineKindPicker({ addLabel = 'Add', options, onPick, primary = false }) {
  const [open, setOpen] = React.useState(false);
  if (!open) {
    return (
      <button className={primary ? 'btn sm primary' : 'btn sm'} onClick={() => setOpen(true)}>
        <I.Plus size={12} />{addLabel}
      </button>
    );
  }
  return (
    <div style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
      {options.map((opt) => {
        const Icon = I[opt.icon] || I.Plus;
        return (
          <button key={opt.id} className="btn sm"
            onClick={() => { onPick(opt.id); setOpen(false); }}>
            <Icon size={11} />{opt.label}
          </button>
        );
      })}
      <button className="btn sm ghost" onClick={() => setOpen(false)}
        title="Cancel"><I.X size={12} /></button>
    </div>
  );
}

/* ─── QuestionEditorPanel ──────────────────────────────────────────────────
   The bordered card that wraps the Question editor — AI sparkle bar +
   QuizQuestionBody. Identical chrome between the sequence question
   step and the hidden_items question hotspot, so it lives here as a
   single component. The caller passes `value = { text, options }` and
   an `onChange(next)` that writes back to whichever field-pair owns
   the data on that surface. Passes `host="sequence-step"` so the body
   keeps its correct-toggle column + per-answer feedback (§27) — unlike
   the passive small_video `question` overlay (host="video"). */
function QuestionEditorPanel({ value, onChange, instanceKey,
  aiPlaceholder, aiGenerate, textPlaceholder,
  // Optional question-styling swatches (catalogue §3.5: bgColor /
  // textColor / btnBgColor / btnTextColor). Rendered as a
  // HeaderSwatchSet in the panel header — §14: swatches live in the
  // header of the block they scope. Only the sequence question step
  // passes these; the hidden_items / object_viewer hotspot callers
  // leave it unset so their chrome is unchanged.
  styleSwatches = null }) {
  return (
    <div style={{
      background: 'var(--surface)',
      border: '1px solid var(--border)',
      borderRadius: 'var(--radius-md)',
      padding: 18,
      display: 'flex', flexDirection: 'column', gap: 18,
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 14,
        flexWrap: 'wrap', minWidth: 0 }}>
        <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)' }}>
          Question
        </span>
        <AiGenerateBar
          placeholder={aiPlaceholder}
          onGenerate={aiGenerate}
          instanceKey={instanceKey} />
        {styleSwatches && (
          <div style={{ marginLeft: 'auto', flex: '0 0 auto' }}>
            <HeaderSwatchSet swatches={styleSwatches} />
          </div>
        )}
      </div>
      <QuizQuestionBody value={value} onChange={onChange}
        instanceKey={instanceKey} type="question" host="sequence-step"
        textPlaceholder={textPlaceholder} />
    </div>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// 3.6 horizontal_tabs
// ────────────────────────────────────────────────────────────────────────────
function HorizontalTabsEditor3({ draft, setField, setPath }) {
  const tabs = draft.tabs || [];
  const addTab = () => setField('tabs', [
    ...tabs, { id: 'h' + Date.now().toString(36),
      tabTitle: `Tab ${tabs.length + 1}`,
      tabText: 'Tab body content.', imageUrl: 'placeholder:tab-image' },
  ]);
  const removeTab = i => setField('tabs', tabs.filter((_, j) => j !== i));
  return (
    <>
      <TitleBlock draft={draft} setField={setField}
        hasTitle hasSubtitle hasTitleColor hasTitleBackground titleRequired titleColorRequired />
      {tabs.length < 3 && (
        <div style={{ padding: 8, background: 'var(--warning-bg)',
          borderRadius: 'var(--radius)', fontSize: 11.5, color: 'var(--warning-text)',
          display: 'flex', alignItems: 'center', gap: 6 }}>
          <I.AlertTriangle size={11} /> Horizontal tabs need at least 3 tabs.
        </div>
      )}
      <ArrayPicker
        label="Tabs (min. 3)" items={tabs} idKey="id" min={1}
        onAdd={addTab} onRemove={removeTab} addLabel="Add tab"
        headerExtras={<HeaderSwatchSet swatches={[
          { label: 'Sel bg',   value: draft.selectedBgColor,
            onChange: v => setField('selectedBgColor', v) },
          { label: 'Sel text', value: draft.selectedTextColor,
            onChange: v => setField('selectedTextColor', v) },
          { label: 'Text',     value: draft.tabTextColor,
            onChange: v => setField('tabTextColor', v) },
        ]} />}
        renderRow={(t, i) => (
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11,
              color: 'var(--text-faint)' }}>T{i + 1}</span>
            <span className="truncate" style={{ flex: 1 }}>{locText(t.tabTitle)}</span>
            <span style={{ fontSize: 10, color: 'var(--text-muted)' }}>
              {t.videoUrl ? 'video' : 'image'}
            </span>
          </div>
        )}
        renderEditor={(t, i) => (
          <div style={{ display: 'grid', gap: 12 }}>
            <ControlledLocalized value={t.tabTitle}
              onChange={v => setPath(['tabs', i, 'tabTitle'], v)} />
            <div style={{ display: 'flex', alignItems: 'center', gap: 10,
              padding: '8px 12px', background: 'var(--surface)',
              border: '1px solid var(--border)', borderRadius: 'var(--radius-md)' }}>
              <span style={{ fontSize: 11.5, color: 'var(--text-muted)', fontWeight: 600,
                letterSpacing: '.02em', textTransform: 'uppercase' }}>Media</span>
              <SegmentedControl value={t.videoUrl ? 'video' : 'image'}
                onChange={k => {
                  if (k === 'image') {
                    setPath(['tabs', i, 'videoUrl'], undefined);
                    setPath(['tabs', i, 'videoThumbUrl'], undefined);
                    setPath(['tabs', i, 'imageUrl'], t.imageUrl || 'placeholder:tab-image');
                  } else {
                    setPath(['tabs', i, 'imageUrl'], undefined);
                    setPath(['tabs', i, 'videoUrl'], t.videoUrl || 'content/tab.mp4');
                    setPath(['tabs', i, 'videoThumbUrl'], 'placeholder:tab-video');
                  }
                }}
                options={[
                  { id: 'image', label: 'Image', icon: 'Image' },
                  { id: 'video', label: 'Video', icon: 'Film' },
                ]} />
            </div>
            {!t.videoUrl
              ? <BackgroundPicker imageOnly
                  defaultFilename={t.imageUrl?.replace('placeholder:', '')}
                  onImageChange={f => setPath(['tabs', i, 'imageUrl'],
                    f ? ((f.startsWith('placeholder:') || f.startsWith('asset://')) ? f : 'placeholder:' + f) : '')} />
              : <>
                  <VideoMediaBlock
                    videoUrl={t.videoUrl} videoThumbUrl={t.videoThumbUrl}
                    subtitlesUrl={t.subtitlesUrl}
                    hasTranscript={false}
                    onChange={(k, v) => setPath(['tabs', i, k], v)} />
                  <SubBlock label="In-video overlay"
                    hint={`${(t.interactions || []).length} marker${(t.interactions || []).length === 1 ? '' : 's'}`}>
                    {(t.interactions || []).length === 0
                      ? <InteractionEmptyState onAdd={(tp) => setPath(['tabs', i, 'interactions'], [
                          makeNewInteraction(tp, 30),
                        ])} />
                      : <InVideoInteractionTimeline
                          videoUrl={t.videoUrl} videoThumbUrl={t.videoThumbUrl}
                          duration={84}
                          interactions={t.interactions}
                          onChange={list => setPath(['tabs', i, 'interactions'], list)} />}
                  </SubBlock>
                </>}
            <SubBlock label="Tab body">
              <ControlledRich value={t.tabText}
                onChange={v => setPath(['tabs', i, 'tabText'], v)}
                placeholder="Tab body content — supports bold, italic, lists, links." />
            </SubBlock>
          </div>
        )} />
      <GateBlock draft={draft} setField={setField} />
    </>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// 3.7 vertical_tabs
// ────────────────────────────────────────────────────────────────────────────
function VerticalTabsEditor3({ draft, setField, setPath }) {
  const tabs = draft.tabs || [];
  const addTab = () => setField('tabs', [
    ...tabs, { id: 'v' + Date.now().toString(36),
      tabTitle: `Tab ${tabs.length + 1}`, tabText: 'Tab body content.' },
  ]);
  const removeTab = i => setField('tabs', tabs.filter((_, j) => j !== i));
  return (
    <>
      <TitleBlock draft={draft} setField={setField}
        hasTitle hasSubtitle hasTitleColor titleRequired titleColorRequired />
      <EditorBlock label="Background image">
        <BackgroundPicker imageOnly
          defaultFilename={draft.backgroundImageUrl?.replace('placeholder:', '')}
          onImageChange={f => setField('backgroundImageUrl',
            f ? ((f.startsWith('placeholder:') || f.startsWith('asset://')) ? f : 'placeholder:' + f) : '')} />
      </EditorBlock>
      <ArrayPicker
        label="Tabs" items={tabs} idKey="id" min={1}
        onAdd={addTab} onRemove={removeTab} addLabel="Add tab"
        headerExtras={<HeaderSwatchSet swatches={[
          { label: 'External bg', value: draft.contentBackgroundColor,
            onChange: v => setField('contentBackgroundColor', v) },
        ]} />}
        renderRow={(t, i) => (
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11,
              color: 'var(--text-faint)' }}>T{i + 1}</span>
            <span className="truncate" style={{ flex: 1 }}>{locText(t.tabTitle)}</span>
          </div>
        )}
        renderEditor={(t, i) => (
          <div style={{ display: 'grid', gap: 12 }}>
            <ControlledLocalized value={t.tabTitle}
              onChange={v => setPath(['tabs', i, 'tabTitle'], v)} />
            <SubBlock label="Tab body">
              <ControlledRich value={t.tabText}
                onChange={v => setPath(['tabs', i, 'tabText'], v)} />
            </SubBlock>
          </div>
        )} />
      <GateBlock draft={draft} setField={setField} />
    </>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// 3.8 icons_discover
// ────────────────────────────────────────────────────────────────────────────
function IconsDiscoverEditor3({ draft, setField, setPath }) {
  const icons = draft.icons || [];
  const addIcon = () => setField('icons', [
    ...icons, { id: 'i' + Date.now().toString(36),
      imageUrl: 'placeholder:icon', text: 'Reveal text for the new icon.' },
  ]);
  const removeIcon = i => setField('icons', icons.filter((_, j) => j !== i));
  return (
    <>
      <TitleBlock draft={draft} setField={setField}
        hasTitle hasSubtitle hasTitleColor hasTitleBackground titleRequired titleColorRequired />
      <ArrayPicker
        label="Icons (3-9)" items={icons} idKey="id" min={3}
        onAdd={icons.length < 9 ? addIcon : null}
        onRemove={removeIcon} addLabel="Add icon"
        headerExtras={<HeaderSwatchSet swatches={[
          { label: 'External bg', value: draft.contentBackgroundColor,
            onChange: v => setField('contentBackgroundColor', v) },
          { label: 'Tile colour', value: draft.selectedBgColor,
            onChange: v => setField('selectedBgColor', v) },
        ]} />}
        renderRow={(it, i) => (
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <div style={{ width: 24, height: 24, borderRadius: 4,
              background: 'var(--ai-bg)', display: 'inline-flex',
              alignItems: 'center', justifyContent: 'center' }}>
              <I.Sparkle size={11} style={{ color: 'var(--ai-text)' }} />
            </div>
            <span className="truncate" style={{ flex: 1, fontSize: 12.5 }}>
              {stripTagsForLabel(locText(it.text)).slice(0, 60)}
            </span>
          </div>
        )}
        renderEditor={(it, i) => (
          <div style={{ display: 'grid', gap: 12 }}>
            <BackgroundPicker imageOnly
              defaultFilename={it.imageUrl?.replace('placeholder:', '')}
              onImageChange={f => setPath(['icons', i, 'imageUrl'],
                f ? ((f.startsWith('placeholder:') || f.startsWith('asset://')) ? f : 'placeholder:' + f) : '')} />
            <SubBlock label="Reveal text">
              <ControlledRich value={it.text}
                onChange={v => setPath(['icons', i, 'text'], v)}
                placeholder="What's revealed when the icon is clicked…" />
            </SubBlock>
          </div>
        )} />
      <GateBlock draft={draft} setField={setField} />
    </>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// 3.9 hidden_items
// ────────────────────────────────────────────────────────────────────────────
function HiddenItemsEditor3({ draft, setField, setPath }) {
  const items = draft.items || [];
  /* hidden_items is a single canonical type (Fix 4). The flat vs 360° variant
     is encoded by which media URL is populated — the SAME branch the Player
     runtime uses — never by `draft.type` (which is always 'hidden_items').
     The dropdown (LayoutTypeSwitcher) is display sugar; picking flat/360° seeds
     the matching media field. */
  const is360 = Boolean(draft.image360Url);
  const variant = is360 ? '360' : 'flat';
  const updPin = (i, patch) => setPath(['items', i], { ...items[i], ...patch });
  /* Hotspots come in three flavours: text-and-image (a reveal panel —
     the original behaviour), video (a short clip plays), and question
     (a one-question quiz fires when the pin is opened). The "+ Add
     hotspot" button opens the same `InlineKindPicker` chrome used for
     sequence steps; the kind seeds the right per-hotspot fields. See
     docs/video-media-patterns.md §15. */
  const addPinOfKind = (kind) => {
    const stamp = Date.now().toString(36);
    /* Cascade new pins off centre so a fresh hotspot never lands on top
       of the previous one — otherwise the new dot covers the old one
       and the author can't see what changed. Step ~5% in a small
       diagonal pattern that wraps every 6 pins. */
    const n = items.length;
    const step = 5;
    const cx = 50 + ((n % 6) - 3) * step;          // -15…+10
    const cy = 50 + (Math.floor(n / 6) % 4 - 1) * step; // -5…+10
    const base = { id: 'p' + stamp, kind,
      itemPosition: `${Math.round(cx)},${Math.round(cy)}`,
      itemTitle: 'New hotspot' };
    const seed = {
      text:     { ...base, itemText: 'Panel body…',
        itemImageUrl: 'placeholder:hotspot-image' },
      video:    { ...base, itemText: '',
        itemVideoUrl: 'content/hotspot-video.mp4',
        itemVideoThumbUrl: 'placeholder:hotspot-video' },
      question: { ...base, itemText: '',
        itemQuestion: 'The question shown when this hotspot is opened.',
        itemOptions: [
          { text: 'Yes', isCorrect: true,  feedback: 'Correct.' },
          { text: 'No',  isCorrect: false, feedback: 'Not quite — revisit the panorama.' },
        ] },
    }[kind] || base;
    setField('items', [...items, seed]);
    setSelPin(items.length);
  };
  const removePin = i => setField('items', items.filter((_, j) => j !== i));
  const [selPin, setSelPin] = React.useState(0);
  /* Migration: any legacy items without a `kind` field are treated as
     text-and-image (the original behaviour). Existing sample data
     written before kinds existed continues to render correctly. */
  const kindOf = (it) => it?.kind || 'text';
  const sel = items[selPin];
  const selKind = kindOf(sel);

  return (
    <>
      <TitleBlock draft={draft} setField={setField}
        hasTitle hasSubtitle hasTitleColor hasTitleBackground titleRequired titleColorRequired />
      {variant === 'flat'
        ? <EditorBlock label="Background image"><BackgroundPicker imageOnly
            defaultFilename={draft.imageUrl?.replace('placeholder:', '')}
            onImageChange={f => setField('imageUrl',
              f ? ((f.startsWith('placeholder:') || f.startsWith('asset://')) ? f : 'placeholder:' + f) : '')} /></EditorBlock>
        : <>
            <EditorBlock label="Panorama image"><BackgroundPicker imageOnly
              defaultFilename={draft.image360Url?.replace('placeholder:', '')}
              onImageChange={f => setField('image360Url',
                f ? ((f.startsWith('placeholder:') || f.startsWith('asset://')) ? f : 'placeholder:' + f) : '')} /></EditorBlock>
            <EditorBlock label={'"Drag to look" prompt'}>
              <TextField value={draft.image360CoverText}
                onChange={v => setField('image360CoverText', v)}
                placeholder="e.g. Drag to look around" /></EditorBlock>
          </>}
      <EditorBlock label="Hotspots" count={items.length}
        action={<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
          <HeaderSwatchSet swatches={[
            { label: 'External bg', value: draft.contentBackgroundColor,
              onChange: v => setField('contentBackgroundColor', v) },
            { label: 'Pin colour', value: draft.itemColor,
              onChange: v => setField('itemColor', v) },
          ]} />
          <InlineKindPicker addLabel="Add hotspot" onPick={addPinOfKind}
            options={[
              { id: 'text',     label: 'Text and Image', icon: 'FileText' },
              { id: 'video',    label: 'Video',          icon: 'Film' },
              { id: 'question', label: 'Question',       icon: 'AlertCircle' },
            ]} />
        </div>}>
        {variant === 'flat'
          ? <ImageHotspotPlacer imageUrl={draft.imageUrl} imageLabel="Background"
              pins={pinsFromItems(items)}
              onChange={pins => setField('items', itemsFromPins(pins, items))}
              selectedIndex={selPin} onSelect={setSelPin}
              colors={{ fill: draft.itemColor, stroke: draft.itemColor }} />
          : <PanoramaHotspotPlacer imageUrl={draft.image360Url}
              image360CoverText={draft.image360CoverText}
              pins={pinsFromItems(items)}
              onChange={pins => setField('items', itemsFromPins(pins, items))}
              selectedIndex={selPin} onSelect={setSelPin} />}
      </EditorBlock>
      {sel && (
        <>
        <EditorBlock label="Panel colours">
          <FieldGrid columns={3}>
            <InlineColorRow label="Panel background" value={draft.bgColor}
              onChange={v => setField('bgColor', v)} />
            <InlineColorRow label="Panel border" value={draft.borderColor}
              onChange={v => setField('borderColor', v)} />
            <InlineColorRow label="Panel text colour" value={draft.textColor}
              onChange={v => setField('textColor', v)} />
          </FieldGrid>
        </EditorBlock>
        <EditorBlock label={`Editing hotspot ${selPin + 1}`}
          action={
            <div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
              <KindBadge kind={selKind} />
              <button className="btn sm ghost danger"
                onClick={() => removePin(selPin)}><I.Trash size={11} />Delete</button>
            </div>
          }>
          <div style={{ display: 'grid', gap: 12 }}>
            <ControlledLocalized value={sel.itemTitle}
              onChange={v => updPin(selPin, { itemTitle: v })} />

            {selKind === 'text' && (
              <>
                <SubBlock label="Hotspot body">
                  <ControlledRich value={sel.itemText}
                    placeholder="What's revealed when this hotspot is opened…"
                    onChange={v => updPin(selPin, { itemText: v })} />
                </SubBlock>
                <SubBlock label="Hotspot image">
                  <BackgroundPicker imageOnly
                    defaultFilename={sel.itemImageUrl?.replace('placeholder:', '')}
                    onImageChange={f => updPin(selPin, { itemImageUrl:
                      f ? ((f.startsWith('placeholder:') || f.startsWith('asset://')) ? f : 'placeholder:' + f) : '' })} />
                </SubBlock>
              </>
            )}

            {selKind === 'video' && (
              <SubBlock label="Hotspot video">
                <VideoMediaBlock
                  videoUrl={sel.itemVideoUrl}
                  videoThumbUrl={sel.itemVideoThumbUrl}
                  subtitlesUrl={sel.itemSubtitlesUrl}
                  hasTranscript={false}
                  onChange={(k, v) => updPin(selPin, {
                    itemVideoUrl:       k === 'videoUrl'       ? v : sel.itemVideoUrl,
                    itemVideoThumbUrl:  k === 'videoThumbUrl'  ? v : sel.itemVideoThumbUrl,
                    itemSubtitlesUrl:   k === 'subtitlesUrl'   ? v : sel.itemSubtitlesUrl,
                  })} />
              </SubBlock>
            )}

            {selKind === 'question' && (
              <QuestionEditorPanel
                value={{ text: sel.itemQuestion, options: sel.itemOptions || [] }}
                onChange={v => updPin(selPin, {
                  itemQuestion: v.text, itemOptions: v.options })}
                instanceKey={sel.id}
                aiPlaceholder="Generate a question grounded in this hotspot's context"
                textPlaceholder="The question shown when this hotspot is opened."
                aiGenerate={() => new Promise(resolve => setTimeout(() => {
                  updPin(selPin, {
                    itemQuestion: 'Which calming signal is most visible at this hotspot?',
                    itemOptions: [
                      { text: 'Open posture and relaxed shoulders',
                        isCorrect: true,
                        feedback: 'Correct — that\'s the clearest non-verbal cue here.' },
                      { text: 'Crossed arms and averted gaze',
                        isCorrect: false,
                        feedback: 'That reads as closed body language — opposite of calming.' },
                      { text: 'A neutral expression with no eye contact',
                        isCorrect: false,
                        feedback: 'Neutral isn\'t the same as calming — eye contact would be a stronger signal.' },
                    ],
                  });
                  resolve();
                }, 900))} />
            )}
          </div>
        </EditorBlock>
        </>
      )}
      <GateBlock draft={draft} setField={setField} />
    </>
  );
}

function pinsFromItems(items) {
  return (items || []).map((it, i) => {
    const [x, y] = (it.itemPosition || '50,50').split(',').map(Number);
    // Carry the answer fields (isCorrect / feedback) onto the pin so the
    // placer round-trip is lossless and the inspector can read them. For
    // image-hotspot quiz questions each pin IS an answer (§quiz_images Fix 1).
    return { id: it.id || ('pin' + i), x, y, title: it.itemTitle,
      isCorrect: !!it.isCorrect, feedback: it.feedback };
  });
}
function itemsFromPins(pins, existing) {
  return pins.map((p, i) => {
    const prev = existing[i] || {};
    return {
      ...prev,
      id: p.id, itemPosition: `${Math.round(p.x)},${Math.round(p.y)}`,
      itemTitle: p.title || prev.itemTitle || `Hotspot ${i + 1}`,
      // Preserve answer fields across drag/reposition. A pin may carry them
      // (when written by the inspector); otherwise fall back to the prior item.
      isCorrect: p.isCorrect != null ? !!p.isCorrect : !!prev.isCorrect,
      feedback: p.feedback != null ? p.feedback : prev.feedback,
    };
  });
}
// Default placement for pins created by migrating text answers → hotspots: a
// single evenly-spaced row across the image (wraps to a second row past 4).
function spreadPinPos(k, n) {
  const cols = Math.min(Math.max(n, 1), 4);
  const x = n <= 1 ? 50 : Math.round(18 + (64 * (k % cols)) / Math.max(1, cols - 1));
  const y = 40 + Math.floor(k / cols) * 26;
  return `${x},${Math.min(y, 82)}`;
}

// ────────────────────────────────────────────────────────────────────────────
// 3.10 quiz_images
// ────────────────────────────────────────────────────────────────────────────
// Shared question-authoring blocks — the content side of both quiz variants
// (title + the reorderable question list with per-question media & answers).
function QuizContentBlocks({ draft, setField, setPath, allowHotspots = true, allowHtml = true }) {
  const questions = draft.questions || [];
  // Allowed question media kinds. `quiz_gaming` drops Hotspots (the gamified
  // quiz answers via the answer boxes, not by tapping image hotspots), so the
  // picker omits it there — see CLAUDE.md / docs §23.2. `quiz_images` drops
  // BOTH Hotspots and HTML — it offers Image / Video only.
  const questionKinds = [
    { id: 'image',          label: 'Image',    icon: 'Image' },
    ...(allowHotspots ? [{ id: 'image+hotspots', label: 'Hotspots', icon: 'Pin' }] : []),
    { id: 'video',          label: 'Video',    icon: 'Film' },
    ...(allowHtml ? [{ id: 'html', label: 'HTML', icon: 'FileText' }] : []),
  ];
  // Add a question of a chosen media kind — mirrors the in-video
  // "+ Add interaction" picker (editor-tools.jsx) and the sequence
  // "+ Add step" / hidden_items "+ Add hotspot" InlineKindPicker (§15).
  const addQOfKind = (kind) => setField('questions', [
    ...questions, { id: 'q' + Date.now().toString(36),
      text: 'New question.',
      content: emptyContentFor(kind),
      answers: [
        { id: '1', text: 'Correct answer', isCorrect: true },
        { id: '2', text: 'Wrong answer' },
      ],
      feedbackText: 'Explanation shown after answering.' },
  ]);
  const removeQ = i => setField('questions', questions.filter((_, j) => j !== i));
  return (
    <>
      <TitleBlock draft={draft} setField={setField}
        hasTitle hasSubtitle hasTitleColor hasTitleBackground titleRequired titleColorRequired />

      <ArrayPicker
        label="Questions" items={questions} idKey="id" min={0}
        addLabel="Add question"
        onRemove={removeQ}
        headerExtras={<>
          <HeaderSwatchSet swatches={[
            { label: 'External bg', value: draft.contentBackgroundColor,
              onChange: v => setField('contentBackgroundColor', v) },
            { label: 'Sel bg',  value: draft.selectedBgColor,
              onChange: v => setField('selectedBgColor', v) },
            { label: 'Sel text', value: draft.selectedTextColor,
              onChange: v => setField('selectedTextColor', v) },
            { label: 'Step',    value: draft.dotSelectedColor,
              onChange: v => setField('dotSelectedColor', v) },
          ]} />
          <InlineKindPicker addLabel="Add question" onPick={addQOfKind} primary
            options={questionKinds} />
        </>}
        renderRow={(q, i) => (
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11,
              color: 'var(--text-faint)' }}>Q{i + 1}</span>
            <span className="truncate" style={{ flex: 1 }}>{stripTagsForLabel(locText(q.text))}</span>
          </div>
        )}
        renderEditor={(q, i) => (
          <div style={{ display: 'grid', gap: 12 }}>
            <SubBlock label="Question prompt">
              <ControlledRich value={q.text}
                onChange={v => setPath(['questions', i, 'text'], v)} />
            </SubBlock>
            {/* No in-editor media-kind switcher — the kind is fixed when the
                author picks Image / Hotspots / Video from "+ Add question".
                Hotspot questions show the discovery-hotspot manager (the pins
                are elements to FIND in the image, not answers). EVERY kind
                authors its answers + feedback in the Answers block below. */}
            <QuestionMediaEditor q={q}
              onChange={c => setPath(['questions', i, 'content'], c)} />
            <SubBlock label="Answers">
              <AnswersEditor answers={q.answers}
                onChange={v => setPath(['questions', i, 'answers'], v)}
                feedbacks={q.feedbacks || {}}
                onFeedbacksChange={v => setPath(['questions', i, 'feedbacks'], v)} />
            </SubBlock>
          </div>
        )} />
    </>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// 3.10 quiz_images  ·  also drives quiz_gaming (shared content + extra tabs)
// ────────────────────────────────────────────────────────────────────────────
function QuizImagesEditor3({ draft, setField, setPath, courseValue, onCourseChange }) {
  const isGaming = draft.type === 'quiz_gaming';

  // Non-gaming quiz · a plain, short single page. No tab strip, no Game /
  // Profile-form tabs (those belong to quiz_gaming) — the page opens straight
  // on the Title block. The question-authoring body is the SAME shared
  // QuizContentBlocks the gaming variant's Layout tab uses, so the two stay
  // in lock-step (§26). quiz_images offers Image / Video / Hotspots —
  // only HTML is dropped here (allowHtml false).
  if (!isGaming) {
    return (
      <>
        <QuizContentBlocks draft={draft} setField={setField} setPath={setPath}
          allowHtml={false} />
        <GateBlock draft={draft} setField={setField} />
      </>
    );
  }

  // Gaming quiz · three tabs, each doing one job (§26 — one block, one job):
  //   • Layout       — the content side (title, colours, questions, gate).
  //   • Game         — the gaming wrapper: scoring rules, score counter, and
  //                    the start / win / fail screens.
  //   • Profile form — the COURSE-LEVEL shared profile-setup form (binds to
  //                    courseValue / onCourseChange, not the per-layout draft).
  // The wrapper is always-on for quiz_gaming — these are tabs, not an enable
  // toggle (§23 forbids in-editor variant toggles).
  const [tab, setTab] = React.useState('layout');
  const qCount = (draft.questions || []).length;
  const threshold = draft.correctAnswersNeeded;
  const thresholdBad = threshold == null || threshold < 1
    || (qCount > 0 && threshold > qCount);
  const tabs = [
    { id: 'layout',  label: 'Layout',       icon: 'ClipboardCheck', badge: qCount },
    { id: 'game',    label: 'Game',         icon: 'Trophy', warn: thresholdBad },
    { id: 'profile', label: 'Profile form', icon: 'User' },
  ];
  return (
    <>
      <EditorTabs tabs={tabs} active={tab} onChange={setTab} />
      {tab === 'layout' && (
        <>
          <QuizContentBlocks draft={draft} setField={setField} setPath={setPath}
            allowHotspots={false} />
          <GateBlock draft={draft} setField={setField} />
        </>
      )}
      {tab === 'game' && (
        <>
          <GamingScoringTab draft={draft} setField={setField} />
          <GamingScreensTab draft={draft} setField={setField} />
        </>
      )}
      {tab === 'profile' && (
        <GamingProfileFormTab value={courseValue} onChange={onCourseChange} />
      )}
    </>
  );
}

function mediaKindFromContent(c) {
  if (!c) return 'image';
  if (c.html) return 'html';
  if (c.video) return 'video';
  if (c.image?.items?.length) return 'image+hotspots';
  return 'image';
}
function emptyContentFor(kind, prev) {
  if (kind === 'image') return { image: { imageUrl: prev?.image?.imageUrl || 'placeholder:q' } };
  if (kind === 'image+hotspots') return {
    image: { imageUrl: prev?.image?.imageUrl || 'placeholder:q',
      itemColor: prev?.image?.itemColor || '#FF80FF',
      // Discovery hotspots — elements to FIND in the image, NOT answers. The
      // question's answers + feedback are authored separately (§quiz_images).
      items: prev?.image?.items || [
        { id: 'h1', kind: 'text', itemPosition: '34,46', itemTitle: 'Point of interest',
          itemText: 'What the learner discovers when they open this hotspot.',
          itemImageUrl: 'placeholder:hotspot-image' },
        { id: 'h2', kind: 'text', itemPosition: '66,58', itemTitle: 'Another detail',
          itemText: 'A second thing worth pointing out in the image.',
          itemImageUrl: 'placeholder:hotspot-image' },
      ] } };
  if (kind === 'video') return { video: { videoUrl: prev?.video?.videoUrl || 'content/q.mp4',
    videoThumbUrl: 'placeholder:q-video' } };
  if (kind === 'html') return { html: { htmlUrl: prev?.html?.htmlUrl || 'content/q.html',
    htmlAlt: prev?.html?.htmlAlt || '' } };
  return prev;
}
function answersToFourEditor(answers = []) {
  // Legacy helper — retained only so any stray caller doesn't break. The
  // AnswersEditor now takes the raw answers array directly.
  return {
    correct: answers.find(a => a.isCorrect)?.text || '',
    'wrong-1': answers.filter(a => !a.isCorrect)[0]?.text || '',
    'wrong-2': answers.filter(a => !a.isCorrect)[1]?.text || '',
  };
}

// Discovery-hotspot manager for a quiz_images "Hotspots" question. The pins are
// PURELY elements to be DISCOVERED in the image — NOT answers and NOT feedback.
// (The question's answers + feedback are authored separately in the Answers
// block below the media — see §quiz_images.) This mirrors the hidden_items
// (flat) hotspot manager (§26 — reuse, don't reinvent): a header carrying the
// External-bg / Pin-colour swatches + an "+ Add hotspot" InlineKindPicker, the
// ImageHotspotPlacer, a Panel-colours block and a per-hotspot inspector (title,
// rich body, hotspot image / video). All colour + pin data lives on
// `content.image` so the round-trip is lossless.
function HotspotDiscoveryEditor({ content, onChange }) {
  const img = content.image || {};
  const items = img.items || [];
  const [sel, setSel] = React.useState(0);
  const safeSel = Math.min(sel, Math.max(0, items.length - 1));
  const realName = (u) => (u && !u.startsWith('placeholder:')) ? u : undefined;
  const pin = (f) => f ? ((f.startsWith('placeholder:') || f.startsWith('asset://')) ? f : 'placeholder:' + f) : '';

  const setImg = (patch) => onChange({ ...content, image: { ...img, ...patch } });
  const setItems = (next) => setImg({ items: next });
  const updItem = (idx, patch) => setItems(items.map((it, k) => k === idx ? { ...it, ...patch } : it));
  const kindOf = (it) => it?.kind || 'text';

  const addPinOfKind = (kind) => {
    const stamp = Date.now().toString(36);
    const n = items.length;
    const cx = 50 + ((n % 6) - 3) * 5;
    const cy = 50 + (Math.floor(n / 6) % 4 - 1) * 5;
    const base = { id: 'p' + stamp, kind,
      itemPosition: `${Math.round(cx)},${Math.round(cy)}`, itemTitle: 'New hotspot' };
    const seed = {
      text:  { ...base, itemText: 'What the learner discovers when this hotspot is opened…',
        itemImageUrl: 'placeholder:hotspot-image' },
      video: { ...base, itemText: '', itemVideoUrl: 'content/hotspot-video.mp4',
        itemVideoThumbUrl: 'placeholder:hotspot-video' },
    }[kind] || base;
    setItems([...items, seed]);
    setSel(items.length);
  };
  const removePin = (idx) => {
    const next = items.filter((_, k) => k !== idx);
    setItems(next);
    setSel(s => Math.max(0, Math.min(s, next.length - 1)));
  };
  const cur = items[safeSel];
  const selKind = kindOf(cur);

  return (
    <div style={{ display: 'grid', gap: 14 }}>
      <SubBlock label="Question image">
        <BackgroundPicker imageOnly
          defaultFilename={realName(img.imageUrl)}
          onImageChange={f => setImg({ imageUrl: pin(f) })} />
      </SubBlock>

      <EditorBlock label="Hotspots" count={items.length}
        action={<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
          <HeaderSwatchSet swatches={[
            { label: 'External bg', value: img.externalBg,
              onChange: v => setImg({ externalBg: v }) },
            { label: 'Pin colour', value: img.itemColor,
              onChange: v => setImg({ itemColor: v }) },
          ]} />
          <InlineKindPicker addLabel="Add hotspot" onPick={addPinOfKind}
            options={[
              { id: 'text',  label: 'Text and Image', icon: 'FileText' },
              { id: 'video', label: 'Video',          icon: 'Film' },
            ]} />
        </div>}>
        <ImageHotspotPlacer imageUrl={img.imageUrl} imageLabel="Question image"
          pins={pinsFromItems(items)}
          onChange={pins => setItems(itemsFromPins(pins, items))}
          selectedIndex={safeSel} onSelect={setSel}
          colors={{ fill: img.itemColor || '#FF80FF', stroke: img.itemColor || '#FF80FF' }}
          height={240} />
      </EditorBlock>

      {items.length === 0 ? (
        <div style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '8px 12px',
          fontSize: 12, lineHeight: 1.45, color: 'var(--text-muted)',
          background: 'var(--surface-inset)', borderRadius: 'var(--radius-md)' }}>
          <I.Info size={14} style={{ flex: '0 0 14px' }} />
          <span>Use “+ Add hotspot” above to place a hotspot for learners to discover,
            then drag it where you want it. Hotspots reveal extra context —
            they are not the question's answers.</span>
        </div>
      ) : (
        <>
          <EditorBlock label="Panel colours">
            <FieldGrid columns={3}>
              <InlineColorRow label="Panel background" value={img.bgColor}
                onChange={v => setImg({ bgColor: v })} />
              <InlineColorRow label="Panel border" value={img.borderColor}
                onChange={v => setImg({ borderColor: v })} />
              <InlineColorRow label="Panel text colour" value={img.textColor}
                onChange={v => setImg({ textColor: v })} />
            </FieldGrid>
          </EditorBlock>

          {cur && (
            <EditorBlock label={`Editing hotspot ${safeSel + 1}`}
              action={
                <div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
                  <KindBadge kind={selKind} />
                  <button className="btn sm ghost danger"
                    onClick={() => removePin(safeSel)}><I.Trash size={11} />Delete</button>
                </div>
              }>
              <div style={{ display: 'grid', gap: 12 }}>
                <ControlledLocalized value={cur.itemTitle}
                  onChange={v => updItem(safeSel, { itemTitle: v })} />

                {selKind === 'text' && (
                  <>
                    <SubBlock label="Hotspot body">
                      <ControlledRich value={cur.itemText}
                        placeholder="What's revealed when this hotspot is opened…"
                        onChange={v => updItem(safeSel, { itemText: v })} />
                    </SubBlock>
                    <SubBlock label="Hotspot image">
                      <BackgroundPicker imageOnly
                        defaultFilename={realName(cur.itemImageUrl)}
                        onImageChange={f => updItem(safeSel, { itemImageUrl: pin(f) })} />
                    </SubBlock>
                  </>
                )}

                {selKind === 'video' && (
                  <SubBlock label="Hotspot video">
                    <VideoMediaBlock
                      videoUrl={cur.itemVideoUrl}
                      videoThumbUrl={cur.itemVideoThumbUrl}
                      subtitlesUrl={cur.itemSubtitlesUrl}
                      hasTranscript={false}
                      onChange={(k, v) => updItem(safeSel, {
                        itemVideoUrl:      k === 'videoUrl'      ? v : cur.itemVideoUrl,
                        itemVideoThumbUrl: k === 'videoThumbUrl' ? v : cur.itemVideoThumbUrl,
                        itemSubtitlesUrl:  k === 'subtitlesUrl'  ? v : cur.itemSubtitlesUrl,
                      })} />
                  </SubBlock>
                )}
              </div>
            </EditorBlock>
          )}
        </>
      )}
    </div>
  );
}

function QuestionMediaEditor({ q, onChange }) {
  const c = q.content || {};
  // Placeholder tokens (e.g. "placeholder:q") are NOT real uploads — don't
  // surface them as an "Uploaded" filename. Only pass a real filename so the
  // picker shows its idle/empty upload state until the author drops an image.
  const realName = (u) => (u && !u.startsWith('placeholder:')) ? u : undefined;
  if (c.image && !c.image.items) {
    return <BackgroundPicker imageOnly
      defaultFilename={realName(c.image.imageUrl)}
      onImageChange={f => onChange({ ...c, image: { ...c.image,
        imageUrl: f ? ((f.startsWith('placeholder:') || f.startsWith('asset://')) ? f : 'placeholder:' + f) : '' } })} />;
  }
  if (c.image && c.image.items) {
    return <HotspotDiscoveryEditor content={c} onChange={onChange} />;
  }
  if (c.video) {
    return (
      <FieldGrid columns={2}>
        <MediaSlot kind="video" label="Question video"
          bound={{ alt: c.video.videoUrl, previewBg: '#1e293b' }} />
        <BackgroundPicker imageOnly label="Video poster" previewHeight={168}
          defaultFilename={realName(c.video.videoThumbUrl)}
          onImageChange={f => onChange({ ...c, video: { ...c.video,
            videoThumbUrl: f ? ((f.startsWith('placeholder:') || f.startsWith('asset://')) ? f : 'placeholder:' + f) : '' } })} />
      </FieldGrid>
    );
  }
  if (c.html) {
    return <HtmlMediaEditor content={c} onChange={onChange} />;
  }
  return null;
}

// HTML-document question media. Scaffold for the OQ-077 spike: shows the
// currently-bound HTML file (friendly sample label when it matches the
// catalogue), a live iframe preview, a "Pick from samples" picker over the
// brand-free HTML_SAMPLES registry, and the screen-reader htmlAlt field.
// Upload (Option A) + AI image-to-HTML (Option B) are deliberately out of
// scope here — this is the surface the next iteration storyboards onto.
function HtmlMediaEditor({ content, onChange }) {
  const c = content;
  const samples = window.HTML_SAMPLES || [];
  const [picking, setPicking] = React.useState(false);
  const current = samples.find(s => s.htmlUrl === c.html.htmlUrl);
  const isPlaceholder = !c.html.htmlUrl || c.html.htmlUrl === 'content/q.html';

  const pick = (s) => {
    onChange({ ...c, html: { ...c.html, htmlUrl: s.htmlUrl,
      // Seed the alt from the sample only if the author hasn't written their own.
      htmlAlt: (c.html.htmlAlt && c.html.htmlAlt.trim()) ? c.html.htmlAlt : (s.htmlAlt || '') } });
    setPicking(false);
  };

  return (
    <div style={{
      padding: 12, background: 'var(--surface)', border: '1px dashed var(--border-strong)',
      borderRadius: 'var(--radius-md)', display: 'grid', gap: 10,
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
        <I.FileText size={14} style={{ color: 'var(--text-muted)' }} />
        <span style={{ fontSize: 12.5, fontWeight: 500 }}>HTML document</span>
        <span style={{ flex: 1 }} />
        <span title={"Personalisation tokens — the runtime replaces these when the question is shown:\n  {username} → the learner's first + last name\n  {user-address} → the learner's email address\nWrap them in elements with class \"username\" / \"usermail\" so the engine's helper can find them."}
          style={{ display: 'inline-flex', alignItems: 'center', gap: 4, cursor: 'default',
            fontSize: 11, color: 'var(--text-faint)' }}>
          <I.Info size={13} />Tokens
        </span>
      </div>

      {/* Current file + actions */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
        <div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 1 }}>
          <span style={{ fontSize: 12.5, fontWeight: 500, color: isPlaceholder ? 'var(--text-faint)' : 'var(--text)' }} className="truncate">
            {current ? current.label : (isPlaceholder ? 'No HTML file selected' : c.html.htmlUrl)}
          </span>
          {current && (
            <span style={{ fontSize: 11, color: 'var(--text-faint)', fontFamily: 'var(--font-mono)' }} className="truncate">
              {current.htmlUrl}
            </span>
          )}
        </div>
        <button className="btn sm" onClick={() => setPicking(true)}>
          <I.Grid size={12} />Pick from samples
        </button>
      </div>

      {/* Live preview */}
      {!isPlaceholder ? (
        <div style={{ height: 200, borderRadius: 'var(--radius-md)', overflow: 'hidden',
          border: '1px solid var(--border)', background: '#fff' }}>
          <iframe src={c.html.htmlUrl} title="HTML media preview"
            aria-label={c.html.htmlAlt || 'HTML document preview'}
            style={{ width: '100%', height: '100%', border: 0, display: 'block', background: '#fff' }} />
        </div>
      ) : (
        <div style={{ height: 96, borderRadius: 'var(--radius-md)', border: '1px dashed var(--border)',
          display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
          color: 'var(--text-faint)', fontSize: 12 }}>
          <I.FileText size={16} />Pick a sample to preview it here
        </div>
      )}

      {/* Path (advanced) + screen-reader description */}
      <TextField value={isPlaceholder ? '' : c.html.htmlUrl}
        onChange={v => onChange({ ...c, html: { ...c.html, htmlUrl: v } })}
        placeholder="Path to .html file" />
      <textarea className="field" value={c.html.htmlAlt || ''}
        onChange={e => onChange({ ...c, html: { ...c.html, htmlAlt: e.target.value } })}
        placeholder="HTML description (for screen readers) — what the document shows"
        style={{ minHeight: 56, fontSize: 12 }} />

      {/* Tooltip-text slots (Phase 2) — appears only when the chosen template
          exposes {{…}} tooltip placeholders. Resolved by htmlUrl against the
          HTML_TEMPLATES registry; filled values persist on content.tooltipValues
          (part of the draft, NOT a sidecar) and surface in the Localisation
          surface's Quiz Gaming tab. Reuses the shared TooltipParamsForm (§26). */}
      {(() => {
        const params = (window.tooltipParamsForHtmlUrl || (() => []))(c.html.htmlUrl);
        if (!params.length) return null;
        const TPF = window.TooltipParamsForm;
        return (
          <TPF params={params} values={c.tooltipValues || {}}
            onChange={(name, v) => onChange({ ...c,
              tooltipValues: { ...(c.tooltipValues || {}), [name]: v } })} />
        );
      })()}

      {picking && (
        <HtmlSamplePicker samples={samples} currentUrl={c.html.htmlUrl}
          onPick={pick} onClose={() => setPicking(false)} />
      )}
    </div>
  );
}

// Modal listing the brand-free HTML_SAMPLES so an author can swap the embedded
// document. Each row: friendly label, scenario category + complexity + cue
// count, and a thumbnail iframe of the real file.
function HtmlSamplePicker({ samples, currentUrl, onPick, onClose }) {
  React.useEffect(() => {
    const onKey = (e) => { if (e.key === 'Escape') onClose?.(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose]);
  const compTone = { low: 'var(--success)', med: 'var(--warning)', high: 'var(--error-text)' };
  return (
    <>
      <div onClick={onClose} style={{ position: 'fixed', inset: 0, zIndex: 80,
        background: 'color-mix(in oklab, var(--text) 35%, transparent)', backdropFilter: 'blur(2px)' }} />
      <div role="dialog" aria-label="Pick an HTML sample" style={{
        position: 'fixed', zIndex: 81, top: '50%', left: '50%', transform: 'translate(-50%,-50%)',
        width: 'min(680px, calc(100vw - 48px))', maxHeight: 'calc(100vh - 80px)',
        display: 'flex', flexDirection: 'column',
        background: 'var(--surface)', border: '1px solid var(--border)',
        borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-lg, 0 24px 60px rgba(0,0,0,.25))', overflow: 'hidden' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '14px 16px',
          borderBottom: '1px solid var(--border)' }}>
          <I.Grid size={15} />
          <span style={{ fontWeight: 600, fontSize: 14 }}>HTML samples</span>
          <span style={{ fontSize: 12, color: 'var(--text-faint)' }}>· {samples.length} brand-free awareness examples</span>
          <span style={{ flex: 1 }} />
          <button className="btn sm ghost" onClick={onClose}><I.X size={14} /></button>
        </div>
        <div style={{ overflowY: 'auto', padding: 12, display: 'grid', gap: 10 }}>
          {samples.map(s => {
            const sel = s.htmlUrl === currentUrl;
            return (
              <button key={s.id} onClick={() => onPick(s)} style={{
                display: 'grid', gridTemplateColumns: '132px 1fr', gap: 12, textAlign: 'left',
                padding: 8, borderRadius: 'var(--radius-md)', cursor: 'pointer',
                background: sel ? 'var(--accent-bg)' : 'var(--surface-2)',
                border: `1px solid ${sel ? 'var(--accent)' : 'var(--border)'}`, fontFamily: 'inherit' }}>
                <div style={{ height: 96, borderRadius: 6, overflow: 'hidden', background: '#fff',
                  border: '1px solid var(--border)', position: 'relative' }}>
                  <iframe src={s.htmlUrl} title={s.label} tabIndex={-1} aria-hidden="true"
                    style={{ width: 396, height: 288, border: 0, transformOrigin: 'top left',
                      transform: 'scale(.333)', pointerEvents: 'none', background: '#fff' }} />
                </div>
                <div style={{ display: 'flex', flexDirection: 'column', gap: 5, minWidth: 0 }}>
                  <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
                    <span style={{ fontWeight: 600, fontSize: 13 }}>{s.label}</span>
                    {sel && <I.Check size={13} style={{ color: 'var(--accent)' }} />}
                  </div>
                  <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
                    <span className="chip" style={{ fontFamily: 'var(--font-mono)', fontSize: 10.5 }}>{s.scenarioCategory}</span>
                    <span className="chip" style={{ fontSize: 10.5, color: compTone[s.complexity] || 'var(--text-muted)' }}>
                      {s.complexity} complexity
                    </span>
                    <span className="chip" style={{ fontSize: 10.5 }}>{s.tooltipCount} cues</span>
                  </div>
                  <p style={{ margin: 0, fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.4 }}>{s.notes}</p>
                </div>
              </button>
            );
          })}
        </div>
      </div>
    </>
  );
}

// Game tab · part 1 — Scoring. The rules that decide pass/fail and what passing
// means, plus the optional running score counter. The progress gate now lives
// on the Layout tab (it's content-side, not a gaming-wrapper concern).
function GamingScoringTab({ draft, setField }) {
  const header = draft.gamingHeader || {};
  const qCount = (draft.questions || []).length;
  const threshold = draft.correctAnswersNeeded;
  const thresholdError =
    threshold == null || threshold < 1 ? 'Needs at least 1 correct answer.'
    : (qCount > 0 && threshold > qCount)
      ? `Only ${qCount} question${qCount === 1 ? '' : 's'} — can't require ${threshold}.`
      : null;
  return (
    <>
      <EditorBlock label="Scoring rules">
        <div style={{ display: 'grid', gap: 14 }}>
          <SettingRow label="Count as course completion"
            hint="Passing this quiz marks the whole course complete."
            control={<Toggle value={!!draft.countAsPostAssessment}
              onChange={v => setField('countAsPostAssessment', v)} />} />
          <SettingRow label="Show correct / wrong icons"
            hint="Mark each answer with a tick or cross after it's chosen."
            control={<Toggle value={!!draft.showCorrectWrongAnswers}
              onChange={v => setField('showCorrectWrongAnswers', v)} />} />
          <div style={{ height: 1, background: 'var(--border-faint)' }} />
          <SettingRow label="Pass threshold"
            hint="How many questions the learner must get right to win."
            control={
              <div style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
                <NumberField value={draft.correctAnswersNeeded}
                  onChange={v => setField('correctAnswersNeeded', v)}
                  min={1} max={qCount || undefined} style={{ width: 56 }} />
                {qCount > 0 && (
                  <span style={{ fontSize: 12, color: 'var(--text-faint)' }}>of {qCount}</span>
                )}
              </div>
            } />
          {thresholdError && (
            <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: -8,
              fontSize: 11.5, color: 'var(--error-text)' }}>
              <I.AlertCircle size={12} />{thresholdError}
            </div>
          )}
        </div>
      </EditorBlock>

      <EditorBlock label="Score counter"
        action={<Toggle value={!!header.enabled}
          onChange={v => setField('gamingHeader', { ...header, enabled: v })} />}>
        {header.enabled ? (
          <>
            <p style={{ margin: '0 0 10px', fontSize: 12, color: 'var(--text-muted)' }}>
              A running correct / wrong tally shown at the top of the quiz.
            </p>
            <FieldGrid columns={2}>
              <BackgroundPicker imageOnly label="Correct icon"
                defaultFilename={header.imgUrlCorrect?.replace('placeholder:', '')}
                onImageChange={f => setField('gamingHeader', { ...header,
                  imgUrlCorrect: f ? ((f.startsWith('placeholder:') || f.startsWith('asset://')) ? f : 'placeholder:' + f) : '' })} />
              <BackgroundPicker imageOnly label="Wrong icon"
                defaultFilename={header.imgUrlWrong?.replace('placeholder:', '')}
                onImageChange={f => setField('gamingHeader', { ...header,
                  imgUrlWrong: f ? ((f.startsWith('placeholder:') || f.startsWith('asset://')) ? f : 'placeholder:' + f) : '' })} />
            </FieldGrid>
          </>
        ) : (
          <p style={{ margin: 0, fontSize: 12, color: 'var(--text-faint)' }}>
            Off — no running score counter is shown during the quiz.
          </p>
        )}
      </EditorBlock>
    </>
  );
}

// Game tab · part 2 — Screens. The start splash and the two end screens. A
// header callout explains retry behaviour (derived from "Count as course
// completion", §23.2 — no FORMAL/INFORMAL toggle) and the placeholders the
// bodies accept.

// Runtime-filled placeholders the start/end bodies accept. Passed to
// ControlledRich as protected `tokens` so they render as atomic, non-editable
// chips (insert/delete whole, never partially) — authors can't accidentally
// break "{totalAnswers}" by editing its characters. Storage stays the plain
// {key} string the Player expects (§22 token chips).
const GAMING_SCREEN_TOKENS = [
  { key: 'neededAnswers' },
  { key: 'totalAnswers' },
  { key: 'wrongAnswers' },
];
function GamingScreensTab({ draft, setField }) {
  const start = draft.gamingStartScreen || {};
  const ends = draft.gamingEndScreens || {};
  const endWin = ends.win || {};
  const endFail = ends.failure || {};
  const formal = !!draft.countAsPostAssessment;
  // `gamingEndScreens` is a { win, failure } object (packages/schema
  // GamingEndScreensSchema). The win/failure discriminator is XML emit-time
  // only — never a data field — so there's no `for` flag here.
  const updEnd = (key, patch) => {
    setField('gamingEndScreens', {
      ...ends,
      [key]: { ...(key === 'win' ? endWin : endFail), ...patch },
    });
  };
  return (
    <>
      <div style={{ display: 'flex', gap: 8, padding: '10px 12px', fontSize: 12,
        lineHeight: 1.5, background: 'var(--surface-inset)', borderRadius: 'var(--radius-md)',
        color: 'var(--text-muted)' }}>
        <I.Info size={14} style={{ flex: '0 0 14px', marginTop: 2, color: 'var(--text-faint)' }} />
        <span>
          {formal
            ? <>Because <strong>Count as course completion</strong> is on, learners
              who fail must retry until they reach the pass threshold.</>
            : <>Because <strong>Count as course completion</strong> is off, learners
              who fail can move on without retrying.</>}
          {' '}End-screen text accepts <code style={{ fontFamily: 'var(--font-mono)' }}>{'{neededAnswers}'}</code>,{' '}
          <code style={{ fontFamily: 'var(--font-mono)' }}>{'{totalAnswers}'}</code> and{' '}
          <code style={{ fontFamily: 'var(--font-mono)' }}>{'{wrongAnswers}'}</code>.
        </span>
      </div>
      <GamingScreenEditor label="Start screen" icon="Flag" screen={start}
        onChange={p => setField('gamingStartScreen', { ...start, ...p })} />
      <GamingScreenEditor label="Win screen" icon="Trophy" screen={endWin}
        onChange={p => updEnd('win', p)} />
      <GamingScreenEditor label="Fail screen" icon="RotateCcw" screen={endFail}
        onChange={p => updEnd('failure', p)} />
    </>
  );
}

// One collapsible start/end screen. Folded by default so all three don't sit
// open at once (an author opening the Screens tab can't otherwise tell at a
// glance how much there is to fill in). The header carries a kind icon, the
// screen title as a collapsed summary, its two colour swatches, and a chevron.
function GamingScreenEditor({ label, icon, screen, onChange }) {
  const [open, setOpen] = React.useState(false);
  const lang = React.useContext(LocDefaultLangContext) || 'en';
  const Icon = icon ? I[icon] : null;
  const summary = stripTagsForLabel(locText(screen.title) || locText(screen.body) || '') || 'Not set yet';
  return (
    <div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius-md)',
      background: 'var(--surface)', overflow: 'hidden', flexShrink: 0 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px' }}>
        <div onClick={() => setOpen(o => !o)}
          style={{ display: 'flex', alignItems: 'center', gap: 9, flex: 1, minWidth: 0,
            cursor: 'default' }}>
          {Icon && <Icon size={15} style={{ color: 'var(--text-muted)', flex: '0 0 15px' }} />}
          <span style={{ fontSize: 12.5, fontWeight: 600, flex: '0 0 auto' }}>{label}</span>
          {!open && (
            <span className="truncate" style={{ flex: 1, minWidth: 0, fontSize: 11.5,
              color: 'var(--text-faint)' }}>{summary}</span>
          )}
        </div>
        <HeaderSwatchSet swatches={[
          { label: 'Title', value: screen.titleColor, onChange: v => onChange({ titleColor: v }) },
          { label: 'Bg',    value: screen.bgColor,    onChange: v => onChange({ bgColor: v }) },
        ]} />
        <button onClick={() => setOpen(o => !o)} className="btn sm ghost"
          style={{ width: 26, height: 26, padding: 0, justifyContent: 'center', flex: '0 0 auto' }}>
          {open ? <I.ChevronUp size={14} /> : <I.ChevronDown size={14} />}
        </button>
      </div>
      {open && (
        <div style={{ padding: '0 12px 12px', display: 'grid', gap: 8 }}>
          <TextField value={locText(screen.title, lang)}
            onChange={v => onChange({ title: locSet(screen.title, lang, v) })}
            placeholder="Screen title" />
          <BackgroundPicker imageOnly label="Background image"
            defaultFilename={screen.bgImgUrl?.replace('placeholder:', '')}
            onImageChange={f => onChange({ bgImgUrl:
              f ? ((f.startsWith('placeholder:') || f.startsWith('asset://')) ? f : 'placeholder:' + f) : '' })} />
          <ControlledRich value={screen.body} onChange={v => onChange({ body: v })}
            tokens={GAMING_SCREEN_TOKENS}
            placeholder="Body text — use the token buttons for {neededAnswers}, {totalAnswers}, {wrongAnswers}" />
        </div>
      )}
    </div>
  );
}

// A self-contained titled card. Used to split the Profile form tab into two
// clearly independent modules — the learner-details form and the intro panel —
// so each reads as its own area rather than items in one long list. The short
// description under each title tells the author what that area controls.
// Collapsible: the title row is the toggle, with a chevron on the right, so an
// author can fold either area away once it's filled in. Open by default.
function ProfileAreaCard({ title, description, children, defaultOpen = true }) {
  const [open, setOpen] = React.useState(defaultOpen);
  return (
    <section style={{
      border: '1px solid var(--border)', borderRadius: 'var(--radius-md)',
      background: 'var(--surface)', padding: 16,
      display: 'grid', gap: open ? 14 : 0,
    }}>
      <button onClick={() => setOpen(o => !o)}
        style={{ display: 'flex', alignItems: 'flex-start', gap: 10, width: '100%',
          padding: 0, border: 0, background: 'transparent', cursor: 'default',
          textAlign: 'left', fontFamily: 'inherit', color: 'var(--text)' }}>
        <div style={{ flex: 1, minWidth: 0 }}>
          <h3 style={{ margin: 0, fontSize: 13, fontWeight: 600, color: 'var(--text)',
            letterSpacing: '-.005em' }}>{title}</h3>
          {description && (
            <p style={{ margin: '4px 0 0', fontSize: 11.5, lineHeight: 1.5,
              color: 'var(--text-muted)' }}>{description}</p>
          )}
        </div>
        {open ? <I.ChevronUp size={15} style={{ flex: '0 0 auto', marginTop: 2, color: 'var(--text-muted)' }} />
              : <I.ChevronDown size={15} style={{ flex: '0 0 auto', marginTop: 2, color: 'var(--text-muted)' }} />}
      </button>
      {open && children}
    </section>
  );
}

// Tab 3 · Profile form — the COURSE-LEVEL shared profile-setup form. Binds to
// the course `dftiFlow` slice (value / onChange), NOT the per-layout draft, so
// an edit here propagates to every quiz_gaming layout in the course. The
// gamified quiz collects three hardcoded learner inputs (first / last name +
// email) on a one-time setup screen; authors relabel those inputs and edit the
// surrounding intro copy/imagery, but can't add or remove fields — so there is
// deliberately no "+ Add field" affordance. (Author-facing copy never says
// "DFTI" — §23.2; the `dfti*` data keys are internal only.)
function GamingProfileFormTab({ value, onChange }) {
  const v = value || {};
  // Course slice may be unavailable in isolated/preview mounts — degrade to a
  // read-only notice rather than throwing.
  if (!onChange) {
    return (
      <EditorBlock label="Profile form">
        <p style={{ margin: 0, fontSize: 12, color: 'var(--text-faint)' }}>
          The shared profile-setup form isn't available in this context.
        </p>
      </EditorBlock>
    );
  }
  // Profile setup is mandatory — the gamified quiz always collects the three
  // learner inputs, so there is no on/off toggle. The areas below always render.
  // Localized fields are per-language objects ({ en: '…' }); ControlledLocalized
  // reads/writes the active-language slot, so we pass the raw object straight
  // through and store whatever it emits.
  const L = (k) => v[k];
  const setL = (k, val) => onChange({ ...v, [k]: val });
  const setImg = (k, f) => onChange({ ...v,
    [k]: f ? ((f.startsWith('placeholder:') || f.startsWith('asset://')) ? f : 'placeholder:' + f) : '' });
  const fileName = (u) => (u || '').replace('placeholder:', '');
  return (
    <>
      <EditorBlock label="Profile setup">
        <p style={{ margin: 0, fontSize: 12, color: 'var(--text-muted)' }}>
          Learners fill a one-time setup screen before the gamified quiz. The
          engine collects exactly three values — first name, last name and
          email — and reuses them across the html quiz questions.
        </p>
      </EditorBlock>

      <ProfileAreaCard title="Learner details form"
            description="The one-time screen learners complete before the quiz: the intro text, plus the labels for the three fixed fields (name and email).">
            <div style={{ display: 'grid', gap: 12 }}>
              <ControlledLocalized label="Form description" multiline
                value={L('profileSetupDescription')}
                onChange={t => setL('profileSetupDescription', t)} />
              <FieldGrid columns={3}>
                <ControlledLocalized label="First-name label"
                  value={L('profileSetupFirstName')}
                  onChange={t => setL('profileSetupFirstName', t)} />
                <ControlledLocalized label="Last-name label"
                  value={L('profileSetupLastName')}
                  onChange={t => setL('profileSetupLastName', t)} />
                <ControlledLocalized label="Email label"
                  value={L('profileSetupEmail')}
                  onChange={t => setL('profileSetupEmail', t)} />
              </FieldGrid>
              <p style={{ margin: 0, fontSize: 11.5, color: 'var(--text-faint)',
                display: 'flex', gap: 6, alignItems: 'center' }}>
                <I.Info size={12} />
                These are labels only — the three inputs are fixed. You can't add
                or remove fields.
              </p>
            </div>
          </ProfileAreaCard>

          <ProfileAreaCard title="Intro panel"
            description="The explainer shown beside the form — a headline and two captioned images that set the tone before learners enter their details.">
            <div style={{ display: 'grid', gap: 12 }}>
              <ControlledLocalized label="Headline"
                value={L('dftiInfoTitle')}
                onChange={t => setL('dftiInfoTitle', t)} />
              <FieldGrid columns={2}>
                <div style={{ display: 'grid', gap: 8 }}>
                  <BackgroundPicker imageOnly label="Left image"
                    defaultFilename={fileName(v.dftiLeftImg)}
                    onImageChange={f => setImg('dftiLeftImg', f)} />
                  <ControlledLocalized label="Left caption"
                    value={L('dftiInfoLeftText')}
                    onChange={t => setL('dftiInfoLeftText', t)} />
                </div>
                <div style={{ display: 'grid', gap: 8 }}>
                  <BackgroundPicker imageOnly label="Right image"
                    defaultFilename={fileName(v.dftiRightImg)}
                    onImageChange={f => setImg('dftiRightImg', f)} />
                  <ControlledLocalized label="Right caption"
                    value={L('dftiInfoRightText')}
                    onChange={t => setL('dftiInfoRightText', t)} />
                </div>
              </FieldGrid>
            </div>
          </ProfileAreaCard>
    </>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// 3.11 object_viewer
// ────────────────────────────────────────────────────────────────────────────
function ObjectViewerEditor3({ draft, setField, setPath }) {
  const items = draft.items || [];
  const [selPin, setSelPin] = React.useState(0);
  const updPin = (i, patch) => setPath(['items', i], { ...items[i], ...patch });
  const removePin = i => setField('items', items.filter((_, j) => j !== i));
  /* Hotspots come in three flavours, mirroring `hidden_items`: text +
     image (the reveal panel), video (a short clip plays), and question
     (a one-question quiz fires when the pin is opened). The "+ Add
     hotspot" button uses `InlineKindPicker` — same chrome as the
     sequence "+ Add step" and the hidden_items hotspot picker.
     See docs/video-media-patterns.md §15. */
  const addPinOfKind = (kind) => {
    /* Cascade so a new dot doesn't land on top of the previous one
       (docs/video-media-patterns.md §22.8). Coordinates are normalized
       0–1; step ~0.08 in a diagonal pattern that wraps every 6 pins.
       Z (depth) stays at 0.5 — depth-cascade isn't meaningful on a 3D
       model surface and would push pins behind geometry. */
    const n = items.length;
    const step = 0.08;
    const cx = 0.5 + ((n % 6) - 3) * step;
    const cy = 0.5 + (Math.floor(n / 6) % 4 - 1) * step;
    const stamp = Date.now().toString(36);
    const base = { id: 'o' + stamp, kind,
      itemPosition: `${cx.toFixed(2)},${cy.toFixed(2)},0.5`,
      itemTitle: 'New hotspot' };
    const seed = {
      text:     { ...base, itemText: 'Panel body…',
        itemImageUrl: 'placeholder:hotspot-image' },
      video:    { ...base, itemText: '',
        itemVideoUrl: 'content/hotspot-video.mp4',
        itemVideoThumbUrl: 'placeholder:hotspot-video' },
      question: { ...base, itemText: '',
        itemQuestion: 'The question shown when this hotspot is opened.',
        itemOptions: [
          { text: 'Yes', isCorrect: true,  feedback: 'Correct.' },
          { text: 'No',  isCorrect: false, feedback: 'Not quite — revisit the model.' },
        ] },
    }[kind] || base;
    setField('items', [...items, seed]);
    setSelPin(items.length);
  };
  /* Migration: any legacy items without a `kind` field are treated as
     text + image (the original behaviour). Existing sample data
     written before kinds existed continues to render correctly. */
  const kindOf = (it) => it?.kind || 'text';
  const sel = items[selPin];
  const selKind = sel ? kindOf(sel) : 'text';

  return (
    <>
      <TitleBlock draft={draft} setField={setField}
        hasTitle hasSubtitle hasTitleColor hasTitleBackground titleRequired titleColorRequired />
      {/* 3D model block — exposes ONLY what the Player consumes at the
         scene level: <objectUrl> + <backgroundColor> (the scene tone).
         v1 scope (§3.11, updated 2026-06-10): authoring accepts ONLY
         `.glb` — the runtime also plays .gltf / .mixed.glb, but v1
         authoring exposes just the common format. The background swatch
         lives in the block's action slot — same convention as the
         title-background swatch on TitleBlock — so the colour control
         aligns to the right of the block heading. See
         video-media-patterns.md §14. */}
      <EditorBlock label="3D model"
        action={<HeaderSwatchSet swatches={[
          { label: 'Background', value: draft.backgroundColor,
            onChange: v => setField('backgroundColor', v) },
        ]} />}>
        <MediaSlot kind="object3d" label="3D model file (.glb)"
          bound={draft.objectUrl ? {
            alt: draft.objectUrl.split('/').pop() || draft.objectUrl,
            previewBg: 'linear-gradient(135deg, #94a3b8, #475569)',
          } : null}
          onRemove={() => setField('objectUrl', '')}
          onReplace={(ref) => setField('objectUrl', ref)} />
      </EditorBlock>
      {/* 3D model description — <objectAltText>. REQUIRED (§3.11): the
         screen-reader description of the model. LocalizedString per the
         schema (`objectAltText: LocalizedStringSchema`) — the emitter
         writes per-language values (pick + cdata), so the editor must
         write a per-language object, never a flat string.
         ControlledLocalized mirrors the TitleBlock/titleMain pattern:
         single input bound to the course's default language; other
         languages are authored on the Localisation surface. */}
      <EditorBlock label="3D model description"
        action={<span style={{ fontSize: 10, letterSpacing: '.06em',
          textTransform: 'uppercase', fontWeight: 600,
          color: 'var(--error-text)' }}>Required</span>}>
        <div style={{ display: 'grid', gap: 6 }}>
          <ControlledLocalized multiline
            value={draft.objectAltText}
            onChange={v => setField('objectAltText', v)} />
          <p style={{ margin: 0, fontSize: 11, color: 'var(--text-faint)',
            lineHeight: 1.5 }}>
            Describe the model for screen readers — what it is, its key
            parts, and what the hotspots point at.
          </p>
        </div>
      </EditorBlock>
      {/* v1 scope (§3.11 callouts): backgroundImageUrl /
         objectPosterImgUrl / objectEnvImgUrl stay valid in the schema +
         Player but are NOT authored in v1 — the author picks the
         background COLOUR only. No editor blocks for them. */}
      {/* Start overlay — <initialCoverText>. Optional LocalizedString
         (`initialCoverText: LocalizedStringSchema.optional()`); same
         per-language write pattern as the description above. */}
      <EditorBlock label="Start overlay"
        action={<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>
          e.g. “Drag to explore”</span>}>
        <ControlledLocalized
          value={draft.initialCoverText}
          onChange={v => setField('initialCoverText', v)} />
      </EditorBlock>
      <EditorBlock label="Viewer behaviour">
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
          {[
            { k: 'autoRotate',               label: 'Auto-rotate' },
            { k: 'mobileArEnabled',          label: 'Enable AR on mobile' },
            { k: 'disableZoom',              label: 'Disable zoom' },
            { k: 'disablePan',               label: 'Disable pan' },
            { k: 'disableInteractionPrompt', label: 'Hide interact prompt' },
          ].map(({ k, label }) => {
            const on = !!draft[k];
            return (
              <button key={k} onClick={() => setField(k, !on)}
                style={{
                  display: 'inline-flex', alignItems: 'center', gap: 6,
                  padding: '5px 10px', borderRadius: 999, cursor: 'default',
                  background: on ? 'var(--accent-bg)' : 'var(--surface)',
                  border: '1px solid', borderColor: on ? 'var(--accent-border)' : 'var(--border)',
                  color: on ? 'var(--accent-text)' : 'var(--text-muted)',
                  fontSize: 11.5, fontFamily: 'inherit',
                  fontWeight: on ? 600 : 500,
                }}>
                {on ? <I.Check size={11} /> : <I.Plus size={11} />}
                {label}
              </button>
            );
          })}
        </div>
      </EditorBlock>
      {/* Hotspots block — header swatches scope the CANVAS (pin colour).
         The Panel-colours grid lives INSIDE this block, under the placer,
         because the reveal-panel colours are LAYOUT-LEVEL (one set shared
         by every hotspot's panel) — grouping them with the per-pin editor
         below misread as per-pin chrome. The "+ Add hotspot" button uses
         InlineKindPicker (§15) to mirror the hidden_items pattern:
         text + image / video / question. */}
      <EditorBlock label="Hotspots" count={items.length}
        action={<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
          <HeaderSwatchSet swatches={[
            { label: 'Pin colour', value: draft.itemColor,
              onChange: v => setField('itemColor', v) },
          ]} />
          <InlineKindPicker addLabel="Add hotspot" onPick={addPinOfKind}
            options={[
              { id: 'text',     label: 'Text and Image', icon: 'FileText' },
              { id: 'video',    label: 'Video',          icon: 'Film' },
              { id: 'question', label: 'Question',       icon: 'AlertCircle' },
            ]} />
        </div>}>
        <ThreeDHotspotPlacer objectUrl={draft.objectUrl}
          objectPosterImgUrl={draft.objectPosterImgUrl}
          backgroundColor={draft.backgroundColor}
          pins={items.map((it, i) => {
            const [x, y, z] = (it.itemPosition || '0,0,0').split(',').map(Number);
            return { id: it.id || ('p' + i), x, y, z, title: it.itemTitle };
          })}
          onChange={pins => setField('items', pins.map((p, i) => ({
            ...(items[i] || {}), id: p.id,
            itemPosition: `${p.x},${p.y},${p.z}`, itemTitle: p.title }))) }
          selectedIndex={selPin} onSelect={setSelPin} />
        <div style={{ marginTop: 12 }}>
          <SubBlock label="Panel colours" hint="Shared across all hotspots">
            <FieldGrid columns={3}>
              <InlineColorRow label="Panel background" value={draft.bgColor}
                onChange={v => setField('bgColor', v)} />
              <InlineColorRow label="Panel border" value={draft.borderColor}
                onChange={v => setField('borderColor', v)} />
              <InlineColorRow label="Panel text colour" value={draft.textColor}
                onChange={v => setField('textColor', v)} />
            </FieldGrid>
          </SubBlock>
        </div>
      </EditorBlock>
      {sel && (
        /* Per-pin editor — mirrors the ArrayPicker active-row shell
           (accent border + halo + left stripe + flush body on a paler
           surface) so "Editing hotspot N" reads as its own per-pin
           section, clearly broken from the shared Hotspots config
           above — the same vocabulary hidden_items / sequence use for
           their active-element editors. */
        <section>
          <div style={{
            background: 'var(--accent-bg)',
            border: '1px solid var(--accent-border)',
            borderRadius: 'var(--radius)',
            boxShadow: '0 0 0 3px color-mix(in srgb, var(--accent) 10%, transparent), var(--shadow-sm)',
            overflow: 'hidden', position: 'relative',
          }}>
            <span aria-hidden style={{
              position: 'absolute', left: 0, top: 0, bottom: 0,
              width: 3, background: 'var(--accent)', zIndex: 1,
            }} />
            <div style={{ display: 'flex', alignItems: 'center', gap: 8,
              padding: '7px 10px 7px 14px', fontSize: 12.5 }}>
              <span style={{ fontWeight: 600, color: 'var(--text)' }}>
                Editing hotspot {selPin + 1}
              </span>
              <div style={{ flex: 1 }} />
              <KindBadge kind={selKind} />
              <button className="btn sm ghost danger"
                onClick={() => removePin(selPin)}><I.Trash size={11} />Delete</button>
            </div>
            <div style={{ background: 'var(--surface)',
              borderTop: '1px solid var(--accent-border)', padding: 12,
              display: 'grid', gap: 12 }}>
            <ControlledLocalized value={sel.itemTitle}
              onChange={v => updPin(selPin, { itemTitle: v })} />

            {selKind === 'text' && (
              <>
                <SubBlock label="Hotspot body">
                  <ControlledRich value={sel.itemText}
                    placeholder="What's revealed when this hotspot is opened…"
                    onChange={v => updPin(selPin, { itemText: v })} />
                </SubBlock>
                <SubBlock label="Hotspot image">
                  <BackgroundPicker imageOnly
                    defaultFilename={sel.itemImageUrl?.replace('placeholder:', '')}
                    onImageChange={f => updPin(selPin, { itemImageUrl:
                      f ? ((f.startsWith('placeholder:') || f.startsWith('asset://')) ? f : 'placeholder:' + f) : '' })} />
                </SubBlock>
              </>
            )}

            {selKind === 'video' && (
              <SubBlock label="Hotspot video">
                <VideoMediaBlock
                  videoUrl={sel.itemVideoUrl}
                  videoThumbUrl={sel.itemVideoThumbUrl}
                  subtitlesUrl={sel.itemSubtitlesUrl}
                  hasTranscript={false}
                  onChange={(k, v) => updPin(selPin, {
                    itemVideoUrl:       k === 'videoUrl'       ? v : sel.itemVideoUrl,
                    itemVideoThumbUrl:  k === 'videoThumbUrl'  ? v : sel.itemVideoThumbUrl,
                    itemSubtitlesUrl:   k === 'subtitlesUrl'   ? v : sel.itemSubtitlesUrl,
                  })} />
              </SubBlock>
            )}

            {selKind === 'question' && (
              <QuestionEditorPanel
                value={{ text: sel.itemQuestion, options: sel.itemOptions || [] }}
                onChange={v => updPin(selPin, {
                  itemQuestion: v.text, itemOptions: v.options })}
                instanceKey={sel.id}
                aiPlaceholder="Generate a question grounded in this hotspot's context"
                textPlaceholder="The question shown when this hotspot is opened."
                aiGenerate={() => new Promise(resolve => setTimeout(() => {
                  updPin(selPin, {
                    itemQuestion: 'Which part of the model is highlighted here?',
                    itemOptions: [
                      { text: 'The correct component for this step',
                        isCorrect: true,
                        feedback: 'Correct — that\'s the part this hotspot targets.' },
                      { text: 'A neighbouring component',
                        isCorrect: false,
                        feedback: 'Close — that part sits nearby but isn\'t the target.' },
                    ],
                  });
                  resolve();
                }, 900))} />
            )}
            </div>
          </div>
        </section>
      )}
      <GateBlock draft={draft} setField={setField} />
    </>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// 3.12 two_columns_text
// ────────────────────────────────────────────────────────────────────────────
function TwoColumnsTextEditor3({ draft, setField }) {
  return (
    <>
      <TitleBlock draft={draft} setField={setField}
        hasTitle hasSubtitle hasTitleColor hasTitleBackground titleRequired titleColorRequired />
      <EditorBlock label="Left column"
        action={<HeaderSwatchSet swatches={[
          { label: 'Text', value: draft.leftColumnTextColor,
            onChange: v => setField('leftColumnTextColor', v) },
          { label: 'Bg',   value: draft.leftColumnBackgroundColor,
            onChange: v => setField('leftColumnBackgroundColor', v) },
        ]} />}>
        <ControlledRich value={draft.leftColumnContent}
          onChange={v => setField('leftColumnContent', v)}
          placeholder="Left column content — supports headings, lists, bold, italic, links" />
      </EditorBlock>
      <EditorBlock label="Right column"
        action={<HeaderSwatchSet swatches={[
          { label: 'Text', value: draft.rightColumnTextColor,
            onChange: v => setField('rightColumnTextColor', v) },
          { label: 'Bg',   value: draft.rightColumnBackgroundColor,
            onChange: v => setField('rightColumnBackgroundColor', v) },
        ]} />}>
        <ControlledRich value={draft.rightColumnContent}
          onChange={v => setField('rightColumnContent', v)}
          placeholder="Right column content" />
      </EditorBlock>
      <GateBlock draft={draft} setField={setField} />
    </>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// 3.13 mandatoryQuestion
// ────────────────────────────────────────────────────────────────────────────
function MandatoryQuestionEditor3({ draft, setField, setPath }) {
  const options = draft.options || [
    { id: '1', text: '', isCorrect: true },
    { id: '2', text: '' }, { id: '3', text: '' }, { id: '4', text: '' },
  ];
  const updOpt = (i, patch) => setPath(['options', i], { ...options[i], ...patch });
  const addOpt = () => setField('options', [
    ...options, { id: String(options.length + 1), text: '' },
  ]);
  const removeOpt = i => setField('options', options.filter((_, j) => j !== i));
  const feedbacks = draft.feedbacks || {};
  return (
    <>
      <TitleBlock draft={draft} setField={setField} hasTitle hasSubtitle titleRequired />
      <EditorBlock label="Runtime caveat"
        action={<I.AlertTriangle size={12} style={{ color: 'var(--warning)' }} />}>
        <p style={{ margin: 0, fontSize: 12, color: 'var(--warning-text)',
          background: 'var(--warning-bg)', padding: '8px 10px', borderRadius: 'var(--radius)' }}>
          Standalone <code>mandatoryQuestion</code> is not yet registered in the Player
          dispatcher (per `14-tag-to-runtime-map.md` §2) — it works only as an
          in-video interaction today. The editor surfaces it with a warning until
          the runtime is extended.
        </p>
      </EditorBlock>
      <EditorBlock label="Question prompt"
        action={<HeaderSwatchSet swatches={[
          { label: 'Bg',   value: draft.bgColor,   onChange: v => setField('bgColor', v) },
          { label: 'Text', value: draft.textColor, onChange: v => setField('textColor', v) },
        ]} />}>
        <ControlledRich value={draft.text}
          onChange={v => setField('text', v)} minHeight={64}
          placeholder="The question the learner must answer to proceed." />
      </EditorBlock>
      <EditorBlock label="Options" count={options.length}
        action={<button className="btn sm" onClick={addOpt}>
          <I.Plus size={12} />Add option</button>}>
        <div style={{ display: 'grid', gap: 6 }}>
          {options.map((o, i) => (
            <div key={o.id} style={{
              display: 'grid', gridTemplateColumns: 'auto 1fr auto auto', gap: 10,
              alignItems: 'center', padding: '7px 10px',
              background: o.isCorrect ? 'var(--success-bg)' : 'var(--surface)',
              border: '1px solid', borderColor: o.isCorrect ? 'var(--success)' : 'var(--border)',
              borderRadius: 'var(--radius)',
            }}>
              <I.GripVertical size={13} style={{ color: 'var(--text-faint)' }} />
              <TextField value={o.text} onChange={v => updOpt(i, { text: v })}
                placeholder={`Option ${i + 1}`} style={{ height: 28 }} />
              <label style={{ display: 'inline-flex', alignItems: 'center', gap: 5,
                fontSize: 11.5, color: 'var(--text-muted)' }}>
                <input type="checkbox" checked={!!o.isCorrect}
                  onChange={e => updOpt(i, { isCorrect: e.target.checked })} />
                Correct
              </label>
              {options.length > 1 && (
                <button className="btn sm ghost danger" onClick={() => removeOpt(i)}>
                  <I.Trash size={11} /></button>
              )}
            </div>
          ))}
        </div>
      </EditorBlock>
      <EditorBlock label="Feedback messages">
        <FieldGrid columns={2}>
          <SubBlock label="Correct feedback">
            <TextField value={feedbacks.correct}
              onChange={v => setField('feedbacks', { ...feedbacks, correct: v })}
              placeholder="Shown for a correct answer" />
          </SubBlock>
          <SubBlock label="Wrong feedback">
            <TextField value={feedbacks.wrong}
              onChange={v => setField('feedbacks', { ...feedbacks, wrong: v })}
              placeholder="Shown for a wrong answer" />
          </SubBlock>
        </FieldGrid>
      </EditorBlock>
      <GateBlock draft={draft} setField={setField} />
    </>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// 3.14 discover — REMOVED as a standalone layout.
//
// `discover` is now exclusively an in-video interaction (added via the
// "+ Add interaction" picker on a video layout — see editor-tools.jsx
// `addInteraction('discover')`). Standalone layout-level `discover`
// never made it past the Player dispatcher and overlapped completely
// with `hidden_items_flat_image`. Authors who want a tap-to-reveal
// non-video surface should use `hidden_items_flat_image`; authors who
// want a non-blocking reveal on top of a clip use the in-video
// `discover` interaction.
// ────────────────────────────────────────────────────────────────────────────

// ────────────────────────────────────────────────────────────────────────────
// 3.15 question (standalone)
// ────────────────────────────────────────────────────────────────────────────
function QuestionEditor3({ draft, setField, setPath }) {
  const answers = draft.answers || [
    { id: '1', text: '', isCorrect: true }, { id: '2', text: '' },
  ];
  return (
    <>
      <TitleBlock draft={draft} setField={setField} hasTitle titleRequired />
      <EditorBlock label="Runtime caveat">
        <p style={{ margin: 0, fontSize: 12, color: 'var(--warning-text)',
          background: 'var(--warning-bg)', padding: '8px 10px', borderRadius: 'var(--radius)' }}>
          Standalone <code>question</code> is not registered in the Player
          dispatcher. Use it as an in-video interaction or nest it inside a
          <code>questions</code> block instead.
        </p>
      </EditorBlock>
      <EditorBlock label="Question prompt"
        action={<HeaderSwatchSet swatches={[
          { label: 'Bg',   value: draft.bgColor,   onChange: v => setField('bgColor', v) },
          { label: 'Text', value: draft.textColor, onChange: v => setField('textColor', v) },
        ]} />}>
        <ControlledRich value={draft.text} onChange={v => setField('text', v)} />
      </EditorBlock>
      <EditorBlock label="Answers">
        <AnswersEditor answers={answers}
          onChange={v => setField('answers', v)}
          feedbacks={draft.feedbacks || {}}
          onFeedbacksChange={v => setField('feedbacks', v)} />
      </EditorBlock>
      <GateBlock draft={draft} setField={setField} />
    </>
  );
}

// ────────────────────────────────────────────────────────────────────────────
// 3.16 title (section divider)
// ────────────────────────────────────────────────────────────────────────────
function TitleEditor3({ draft, setField }) {
  return (
    <>
      <TitleBlock draft={draft} setField={setField}
        hasTitle hasSubtitle hasTitleColor titleRequired titleColorRequired />
      <EditorBlock label="Background color">
        <BackgroundPicker colorOnly
          defaultColor={draft.titleBackgroundColor || '#0f172a'}
          onColorChange={v => setField('titleBackgroundColor', v)} />
      </EditorBlock>
      {/* Background image — <backgroundImageUrl> (§3.16, opt). Sits after
         the background-colour control per the catalogue's row order. */}
      <EditorBlock label="Background image">
        <BackgroundPicker imageOnly
          defaultFilename={draft.backgroundImageUrl?.replace('placeholder:', '')}
          onImageChange={f => setField('backgroundImageUrl',
            f ? ((f.startsWith('placeholder:') || f.startsWith('asset://')) ? f : 'placeholder:' + f) : '')} />
      </EditorBlock>
      {/* No Progress gate: `title` is a section-divider slide — it doesn't
          block progression, so the blockingSection controls are omitted even
          though the shared title block formally allows them. */}
    </>
  );
}

// ─── Helper: build an interaction object with sensible defaults ───────────
function makeNewInteraction(type, atTime = 0) {
  return {
    id: 'iv_' + Date.now().toString(36),
    type, timeStarts: atTime,
    interactionText: type === 'discover' ? 'Reveal' : 'Your turn',
    ...(type === 'discover' ? { timeEnds: atTime + 12,
      descriptionTitle: 'New reveal', descriptionText: '' } : {}),
    ...(type !== 'discover' ? { options: [{ text: 'Option A', isCorrect: true },
      { text: 'Option B' }] } : {}),
  };
}

// ─── Dispatcher ────────────────────────────────────────────────────────────
function PerTypeEditor({ type, draft, setField, setPath, courseValue, onCourseChange }) {
  const Editor = LAYOUT_EDITORS_3[type];
  if (!Editor) {
    return (
      <EditorBlock label={`${type} editor`}>
        <div style={{ padding: 14, background: 'var(--surface-inset)',
          borderRadius: 'var(--radius-md)', color: 'var(--text-muted)', fontSize: 12.5 }}>
          No editor surface yet for type <code>{type}</code>.
        </div>
      </EditorBlock>
    );
  }
  return <Editor draft={draft} setField={setField} setPath={setPath}
    courseValue={courseValue} onCourseChange={onCourseChange} />;
}

const LAYOUT_EDITORS_3 = {
  fullscreen_text_and_image: FullscreenTextAndImageEditor3,
  fullscreen_video:           FullscreenVideoEditor3,
  text_and_image:             TextAndImageEditor3,
  small_video:                SmallVideoEditor3,
  sequence:                   SequenceEditor3,
  horizontal_tabs:            HorizontalTabsEditor3,
  vertical_tabs:              VerticalTabsEditor3,
  icons_discover:             IconsDiscoverEditor3,
  // Hidden items is ONE canonical type (Fix 4): the value at `layout.type`
  // is always `hidden_items`, and the flat vs 360° variant is derived from
  // which media URL is populated (`image360Url` → 360°, else flat) — never
  // from the type. `hidden_items` is therefore the live lookup key; the two
  // display ids are kept here only as defensive aliases so a stray display
  // id (e.g. from an old saved layout) still resolves to this editor.
  hidden_items:               HiddenItemsEditor3,
  hidden_items_flat_image:    HiddenItemsEditor3,
  hidden_items_360_image:     HiddenItemsEditor3,
  quiz_images:                QuizImagesEditor3,
  quiz_gaming:                QuizImagesEditor3,
  object_viewer:              ObjectViewerEditor3,
  two_columns_text:           TwoColumnsTextEditor3,
  mandatoryQuestion:          MandatoryQuestionEditor3,
  question:                   QuestionEditor3,
  title:                      TitleEditor3,
};

Object.assign(window, {
  PerTypeEditor, LAYOUT_EDITORS_3, TitleBlock, ArrayPicker,
});
