// Field widgets — small editable primitives every layout editor uses.
// All are CONTROLLED (value + onChange) so the layout editor's draft is the
// single source of truth and the PlayerPreview can mirror live.

// ─── ColorField ────────────────────────────────────────────────────────────
// Native color picker + hex input + a quick palette pop-out.
function ColorField({ label, value = '#000000', onChange, palette }) {
  const [open, setOpen] = React.useState(false);
  const hex = normaliseHex(value);
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 8,
      padding: '6px 10px', background: 'var(--surface)',
      border: '1px solid var(--border)', borderRadius: 'var(--radius-md)' }}>
      {label && <span style={{ fontSize: 12, color: 'var(--text)', flex: 1,
        lineHeight: 1.3 }}>{label}</span>}
      <div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 6 }}>
        <button type="button" onClick={() => setOpen(o => !o)}
          title="Open palette"
          style={{
            width: 22, height: 22, borderRadius: 4,
            background: value, border: '1px solid var(--border-strong)',
            cursor: 'default', padding: 0,
          }} />
        <input type="color" value={hex}
          onChange={e => onChange?.(e.target.value)}
          style={{ position: 'absolute', inset: 0, width: 22, height: 22,
            opacity: 0, cursor: 'default' }} />
        <input className="field" value={value}
          onChange={e => onChange?.(e.target.value)}
          style={{ width: 110, height: 24, fontFamily: 'var(--font-mono)',
            fontSize: 11 }} />
        {open && (
          <>
            <div onClick={() => setOpen(false)}
              style={{ position: 'fixed', inset: 0, zIndex: 30 }} />
            <div style={{
              position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 31,
              padding: 8, background: 'var(--surface)', border: '1px solid var(--border)',
              borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-lg)',
              display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 4, width: 240,
            }}>
              {(palette || DEFAULT_PALETTE).map(c => (
                <button key={c} type="button"
                  onClick={() => { onChange?.(c); setOpen(false); }}
                  title={c}
                  style={{
                    width: 24, height: 24, borderRadius: 4, background: c,
                    border: '1px solid var(--border-strong)', cursor: 'default',
                  }} />
              ))}
            </div>
          </>
        )}
      </div>
    </div>
  );
}

const DEFAULT_PALETTE = [
  '#000000','#ffffff','#0f172a','#1e293b','#475569','#94a3b8','#cccccc','#f1f5f9',
  '#dc2626','#ea580c','#d97706','#16a34a','#0e7490','#2563eb','#7c3aed','#db2777',
  '#fef3c7','#dbeafe','#dcfce7','#fee2e2','#fce7f3','#e0e7ff','#fed7aa','#bbf7d0',
  '#FFD166','#F26430','#D32F2F','#3b82f6',
];

function normaliseHex(v) {
  if (!v || typeof v !== 'string') return '#000000';
  if (v.startsWith('#')) {
    if (v.length === 4) return '#' + v.slice(1).split('').map(c => c+c).join('');
    if (v.length === 7) return v;
  }
  // Named colours / rgba — fall back to a neutral so the native picker doesn't
  // crash. The text input still shows the original value.
  return '#000000';
}

// ─── GateControl ───────────────────────────────────────────────────────────
// The blockingSection 3-state — true | false | (empty). Three-position segmented.
function GateControl({ value, onChange }) {
  const states = [
    { id: 'true',  label: 'Required', icon: 'Lock',
      hint: 'Learner must complete this screen before continuing' },
    { id: 'false', label: 'Optional', icon: 'ChevronDown',
      hint: 'Arrow always available; completion not required' },
    { id: '',      label: 'No gate',  icon: 'Minus',
      hint: 'No gate — the layout doesn\u2019t block; the learner can move on immediately' },
  ];
  return (
    <div style={{ display: 'flex', gap: 4, padding: 3,
      background: 'var(--surface-inset)', border: '1px solid var(--border)',
      borderRadius: 'var(--radius)', width: 'fit-content' }}>
      {states.map(s => {
        const sel = (value || '') === s.id;
        const Ic = I[s.icon] || I.Circle;
        return (
          <button key={s.id || 'empty'} onClick={() => onChange?.(s.id)}
            title={s.hint}
            style={{
              display: 'inline-flex', alignItems: 'center', gap: 5,
              padding: '5px 10px', fontSize: 11.5, fontFamily: 'inherit',
              cursor: 'default', borderRadius: 4, border: 0,
              background: sel ? 'var(--surface)' : 'transparent',
              color: sel ? 'var(--text)' : 'var(--text-muted)',
              fontWeight: sel ? 600 : 500,
              boxShadow: sel ? 'var(--shadow-sm)' : 'none',
            }}>
            <Ic size={11} />{s.label}
          </button>
        );
      })}
    </div>
  );
}

// ─── Toggle (controlled Switch) ────────────────────────────────────────────
function Toggle({ value, onChange }) {
  const v = !!value;
  return (
    <button type="button" onClick={() => onChange?.(!v)}
      style={{
        width: 32, height: 18, padding: 0, border: 0,
        background: v ? 'var(--accent)' : 'var(--border-strong)',
        borderRadius: 9, cursor: 'default', position: 'relative',
        transition: 'background 150ms',
      }}>
      <span style={{
        position: 'absolute', top: 2, left: v ? 16 : 2,
        width: 14, height: 14, borderRadius: '50%',
        background: '#fff', transition: 'left 150ms',
        boxShadow: 'var(--shadow-sm)',
      }} />
    </button>
  );
}

// ─── TextField ─────────────────────────────────────────────────────────────
function TextField({ value, onChange, placeholder, mono, style }) {
  return (
    <input className="field" value={value || ''}
      placeholder={placeholder}
      onChange={e => onChange?.(e.target.value)}
      style={{
        width: '100%', fontFamily: mono ? 'var(--font-mono)' : 'inherit',
        ...(style || {}),
      }} />
  );
}

// ─── NumberField ───────────────────────────────────────────────────────────
function NumberField({ value, onChange, min, max, suffix, style }) {
  return (
    <div style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
      <input className="field" type="number" value={value ?? ''}
        min={min} max={max}
        onChange={e => onChange?.(e.target.value === '' ? null : +e.target.value)}
        style={{ width: 70, fontFamily: 'var(--font-mono)', ...(style || {}) }} />
      {suffix && <span style={{ fontSize: 11.5, color: 'var(--text-muted)' }}>{suffix}</span>}
    </div>
  );
}

// ─── SegmentedControl ──────────────────────────────────────────────────────
function SegmentedControl({ value, onChange, options }) {
  // options: [{ id, label, icon? }]
  return (
    <div style={{ display: 'inline-flex', gap: 4, padding: 3,
      background: 'var(--surface-inset)', border: '1px solid var(--border)',
      borderRadius: 'var(--radius)' }}>
      {options.map(o => {
        const sel = value === o.id;
        const Ic = o.icon ? (I[o.icon] || null) : null;
        return (
          <button key={o.id} onClick={() => onChange?.(o.id)}
            style={{
              display: 'inline-flex', alignItems: 'center', gap: 5,
              padding: '5px 10px', fontSize: 11.5, fontFamily: 'inherit',
              cursor: 'default', borderRadius: 4, border: 0,
              background: sel ? 'var(--surface)' : 'transparent',
              color: sel ? 'var(--text)' : 'var(--text-muted)',
              fontWeight: sel ? 600 : 500,
              boxShadow: sel ? 'var(--shadow-sm)' : 'none',
            }}>
            {Ic && <Ic size={11} />}{o.label}
          </button>
        );
      })}
    </div>
  );
}

// ─── ControlledLocalized — wraps LocalizedStringEditor with explicit value
// ─── (so the editor draft owns the data, not the widget's internal state).
function ControlledLocalized({ label, value, onChange, languages = ['en','it'], multiline,
  placeholder, style, fallbacks }) {
  // `value` is a LocalizedString — a per-language object { [lang]: string }
  // (a legacy flat string is auto-upgraded on first edit). The widget shows a
  // SINGLE input bound to the course's default language; editing writes only
  // that slot and preserves every other language. Translation visibility lives
  // on the Localisation surface, never here (no badges, no language picker).
  const lang = React.useContext(LocDefaultLangContext) || 'en';
  const str = locText(value, lang);
  const emit = (s) => onChange?.(locSet(value, lang, s));

  // ── `fallbacks` — a per-language map of text ALREADY on the learner's screen,
  // shown as the box's real, editable contents while the author has written
  // nothing (Omar, 2026-08-14: *"as soon as I try to edit the text, it
  // disappears … the right approach is to enable the admin to make small changes
  // to the existing text"*). A `placeholder` cannot do this — it is grey, it is
  // not selectable, and the first keystroke replaces it with one character.
  //
  // The absent-vs-empty rule lives in `locBoxText` (loc-translate-core.js), where
  // it is React-free and covered by a test that runs in CI — a decision this
  // subtle should not be provable only by looking at the screen. Nothing is
  // written until a keystroke, so an author who only LOOKS at a panel still
  // exports the professionally translated defaults in every language.
  //
  // Guarded, because this widget is on ~30 screens and a bare call would white
  // out all of them if the classic script above it ever failed to load. The
  // degrade is EXACTLY today's behaviour — the box shows what it resolved and no
  // fallback — rather than a second, subtly different copy of the rule
  // (`feedback_the_no_build_frontend_fails_silently`).
  const shown = typeof locBoxText === 'function'
    ? locBoxText(value, lang, str, fallbacks && fallbacks[lang])
    : str;

  return (
    <div>
      {label && <div style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text)',
        marginBottom: 6 }}>{label}</div>}
      {multiline
        ? <textarea className="field" value={shown} placeholder={placeholder}
            onChange={e => emit(e.target.value)}
            style={{ width: '100%', minHeight: 60, ...(style || {}) }} />
        : <input className="field" value={shown} placeholder={placeholder}
            onChange={e => emit(e.target.value)}
            style={{ width: '100%', ...(style || {}) }} />}
    </div>
  );
}

// ─── ControlledRich — wraps RichTextEditor controllably.
// ─── Defers to RichTextEditor for the real B/I/U/list/link behaviour;
// ─── duplicating the toolbar here means dead buttons.
// ─── `value` is a LocalizedString (per-language object of HTML strings, or a
// ─── legacy flat HTML string). Reads/writes the default-language slot exactly
// ─── like ControlledLocalized — RichTextEditor itself only ever sees a string.
function ControlledRich({ value, onChange, placeholder, minHeight = 96, tokens, tokenInsert }) {
  const lang = React.useContext(LocDefaultLangContext) || 'en';
  return (
    <RichTextEditor value={locText(value, lang)}
      onChange={(s) => onChange?.(locSet(value, lang, s))}
      placeholder={placeholder} minHeight={minHeight}
      tokens={tokens} tokenInsert={tokenInsert} />
  );
}

// ─── FieldGrid — a labeled-pair grid used for blocks of color/switch fields.
function FieldGrid({ children, columns = 2 }) {
  return (
    <div style={{ display: 'grid',
      gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, gap: 8 }}>
      {children}
    </div>
  );
}

// ─── LabeledControl — wraps any control with a small label row.
function LabeledControl({ label, hint, required, children }) {
  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
        <span style={{ fontSize: 11.5, color: 'var(--text-muted)', fontWeight: 600,
          letterSpacing: '.02em', textTransform: 'uppercase' }}>{label}</span>
        {required && <span style={{ fontSize: 10, color: 'var(--error-text)' }}>·  required</span>}
        {hint && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>· {hint}</span>}
      </div>
      {children}
    </div>
  );
}

// ─── SubBlock — a lighter EditorBlock-style header for nested sections inside
// ─── per-element editors (no full divider line; tighter rhythm). Carries an
// ─── optional inline `action` so colours and toggles can attach to the label.
function SubBlock({ label, action, hint, children }) {
  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
        <span style={{ fontSize: 11.5, color: 'var(--text-muted)', fontWeight: 600,
          letterSpacing: '.02em', textTransform: 'uppercase', whiteSpace: 'nowrap',
          flexShrink: 0 }}>{label}</span>
        {hint && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>· {hint}</span>}
        <div style={{ flex: 1 }} />
        {action}
      </div>
      {children}
    </div>
  );
}

// ─── EditorTabs — underline tab strip that organises a single editor body
// ─── into chunks (e.g. quiz_gaming → Questions / Scoring / Screens). This is
// ─── editor chrome, NOT a layout-variant toggle (§23 forbids those). Tabs:
// ─── [{ id, label, icon?, badge?, warn? }]. `warn` shows a red dot for a tab
// ─── that has a validation problem so the author notices it from any tab.
function EditorTabs({ tabs, active, onChange }) {
  return (
    <div role="tablist" style={{ display: 'flex', gap: 22,
      borderBottom: '1px solid var(--border)' }}>
      {tabs.map(t => {
        const sel = t.id === active;
        const Ic = t.icon ? (I[t.icon] || null) : null;
        return (
          <button key={t.id} role="tab" aria-selected={sel} onClick={() => onChange?.(t.id)}
            style={{
              position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 7,
              padding: '9px 1px', border: 0, background: 'transparent', cursor: 'default',
              fontFamily: 'inherit', fontSize: 13, fontWeight: sel ? 600 : 500,
              color: sel ? 'var(--text)' : 'var(--text-muted)',
              transition: 'color 120ms',
            }}>
            {Ic && <Ic size={14} style={{ color: sel ? 'var(--accent)' : 'var(--text-faint)' }} />}
            {t.label}
            {t.badge != null && (
              <span style={{ fontSize: 10.5, fontFamily: 'var(--font-mono)',
                color: 'var(--text-faint)', background: 'var(--surface-inset)',
                padding: '1px 5px', borderRadius: 3 }}>{t.badge}</span>
            )}
            {t.warn && <span title="Needs attention" style={{ width: 6, height: 6,
              borderRadius: '50%', background: 'var(--error)' }} />}
            {sel && <span style={{ position: 'absolute', left: 0, right: 0, bottom: -1,
              height: 2, background: 'var(--accent)', borderRadius: 2 }} />}
          </button>
        );
      })}
    </div>
  );
}

// ─── SettingRow — a single setting laid out as label (+ hint) on the left and
// ─── its control on the right. Clearer than cramming pill-toggles into a
// ─── flex-wrap row; used for boolean/threshold settings in the quiz editor.
function SettingRow({ label, hint, control, children }) {
  return (
    <div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: '2px 0' }}>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text)' }}>{label}</div>
        {hint && <div style={{ fontSize: 11.5, color: 'var(--text-muted)', marginTop: 1,
          lineHeight: 1.4 }}>{hint}</div>}
      </div>
      <div style={{ flex: '0 0 auto', paddingTop: 1 }}>{control || children}</div>
    </div>
  );
}

// ─── HeaderSwatch — tiny inline colour swatch + hex, designed to slot into
// ─── an EditorBlock's `action` so the colour visually attaches to the
// ─── label of the field it styles (e.g. Title colour ↔ "Title block").
function HeaderSwatch({ value = '#000000', onChange, title }) {
  const [open, setOpen] = React.useState(false);
  const hex = normaliseHex(value);
  return (
    <div style={{ position: 'relative', display: 'inline-flex',
      alignItems: 'center', gap: 4 }}>
      <div style={{ position: 'relative', display: 'inline-flex' }}>
        <button type="button" onClick={() => setOpen(o => !o)}
          title={title || 'Pick colour'}
          style={{
            width: 18, height: 18, borderRadius: 3,
            background: value, border: '1px solid var(--border-strong)',
            padding: 0, cursor: 'default',
          }} />
        <input type="color" value={hex}
          onChange={e => onChange?.(e.target.value)}
          style={{ position: 'absolute', inset: 0, width: 18, height: 18,
            opacity: 0, cursor: 'default' }} />
      </div>
      <input className="field" value={value}
        onChange={e => onChange?.(e.target.value)}
        style={{ width: 86, height: 22, padding: '0 6px',
          fontFamily: 'var(--font-mono)', fontSize: 11 }} />
      {open && (
        <>
          <div onClick={() => setOpen(false)}
            style={{ position: 'fixed', inset: 0, zIndex: 30 }} />
          <div style={{
            position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 31,
            padding: 8, background: 'var(--surface)', border: '1px solid var(--border)',
            borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-lg)',
            display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 4, width: 240,
          }}>
            {DEFAULT_PALETTE.map(c => (
              <button key={c} type="button"
                onClick={() => { onChange?.(c); setOpen(false); }}
                title={c}
                style={{
                  width: 24, height: 24, borderRadius: 4, background: c,
                  border: '1px solid var(--border-strong)', cursor: 'default',
                }} />
            ))}
          </div>
        </>
      )}
    </div>
  );
}

// ─── HeaderSwatchSet — multiple HeaderSwatches in one action slot, each
// ─── with a tiny floating label so authors know which colour is which.
function HeaderSwatchSet({ swatches }) {
  return (
    <div style={{ display: 'inline-flex', alignItems: 'center', gap: 12 }}>
      {swatches.map((s, i) => (
        <div key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
          {s.label && <span style={{ fontSize: 10.5, color: 'var(--text-faint)',
            fontWeight: 500, letterSpacing: '.02em',
            textTransform: 'uppercase' }}>{s.label}</span>}
          <HeaderSwatch value={s.value} onChange={s.onChange} title={s.label} />
        </div>
      ))}
    </div>
  );
}

// ─── InlineColorRow — full-width row: label-left, swatch + hex right.
// ─── For free-standing colours (Content background, Hotspot icon colour…).
function InlineColorRow({ label, value, onChange }) {
  return (
    <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={{ flex: 1, fontSize: 12, color: 'var(--text)',
        lineHeight: 1.3 }}>{label}</span>
      <HeaderSwatch value={value} onChange={onChange} title={label} />
    </div>
  );
}

// ─── GateBlock — convenience: the blockingSection gate in one tidy block,
// ─── used by every editor. Per packages/schema BlockingSectionSchema the
// ─── field is a nested object { state, color?, textColor? } — the colours
// ─── live inside it, not as sibling top-level fields.
function GateBlock({ draft, setField }) {
  const gate = draft.blockingSection || {};
  const state = gate.state ?? '';
  const active = state === 'true' || state === 'false';
  const write = (patch) => setField('blockingSection', { ...gate, ...patch });
  return (
    <EditorBlock label="Progress gate"
      action={active ? <HeaderSwatchSet swatches={[
        { label: 'Bg', value: gate.color,
          onChange: v => write({ color: v }) },
        { label: 'Text', value: gate.textColor,
          onChange: v => write({ textColor: v }) },
      ]} /> : <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>
        blockingSection
      </span>}>
      <GateControl value={state}
        onChange={v => write({ state: v })} />
    </EditorBlock>
  );
}

// ─── ToolIcon — a single 30×30 icon button with a rich hover tooltip.
// ─── Used by VideoMediaBlock's toolbar. None preselected at rest; clicking
// ─── toggles the active state so the toolbar can surface contextual controls.
// ─── Tooltip is portalled to <body> so it escapes the VideoMediaBlock
// ─── card's `overflow:hidden` (the icons sit at the very bottom edge).
function ToolIcon({ icon: Ic, title, hint, active, disabled, onClick }) {
  const [hover, setHover] = React.useState(false);
  const btnRef = React.useRef(null);
  const [rect, setRect] = React.useState(null);
  React.useEffect(() => {
    if (hover && btnRef.current) setRect(btnRef.current.getBoundingClientRect());
  }, [hover]);
  return (
    <div
      onMouseEnter={() => !disabled && setHover(true)}
      onMouseLeave={() => setHover(false)}
      style={{ position: 'relative', display: 'inline-flex' }}>
      <button
        ref={btnRef}
        onClick={disabled ? undefined : onClick}
        disabled={disabled}
        aria-pressed={!!active}
        aria-label={title}
        style={{
          width: 30, height: 30, padding: 0,
          display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
          background: active ? 'var(--accent-bg)' : 'var(--surface)',
          color: active ? 'var(--accent-text)' : 'var(--text-muted)',
          border: '1px solid',
          borderColor: active ? 'var(--accent-border)' : 'var(--border-strong)',
          borderRadius: 'var(--radius)',
          cursor: 'default', fontFamily: 'inherit',
          opacity: disabled ? 0.45 : 1,
          transition: 'background 120ms, color 120ms, border-color 120ms',
        }}>
        <Ic size={14} />
      </button>
      {hover && !disabled && rect && ReactDOM.createPortal(
        <div role="tooltip" style={{
          position: 'fixed',
          top: rect.bottom + 8,
          left: rect.left,
          zIndex: 1000, width: 240,
          padding: '8px 10px',
          background: 'var(--text-strong)', color: '#fff',
          border: '1px solid rgba(255,255,255,.08)',
          borderRadius: 'var(--radius)',
          fontSize: 11.5, lineHeight: 1.45,
          boxShadow: '0 8px 20px rgba(15,23,42,.18)',
          pointerEvents: 'none',
        }}>
          <div style={{ fontWeight: 600, marginBottom: 2 }}>{title}</div>
          <div style={{ color: 'rgba(255,255,255,.78)' }}>{hint}</div>
          <div style={{
            position: 'absolute', top: -5, left: 10,
            width: 8, height: 8, transform: 'rotate(45deg)',
            background: 'var(--text-strong)',
            borderLeft: '1px solid rgba(255,255,255,.08)',
            borderTop: '1px solid rgba(255,255,255,.08)',
          }} />
        </div>,
        document.body
      )}
    </div>
  );
}

// The gateway's asset MIME allow-list for kind 'video' (services/gateway/src/routes/
// assets.ts). Shared by the file dialog's `accept` and the drop-zone check so the two
// cannot drift — a drop that bypasses `accept` is still rejected with a reason.
const VIDEO_MIME_ALLOWED = ['video/mp4', 'video/webm'];

// ─── VideoMediaBlock — compact stack for the video-related slots.
// ─── Used by fullscreen_video, small_video, sequence/horizontal_tabs video
// ─── tabs, text_and_image video rows.
// ─── Layout:
// ─── • 132-px preview at top (status pill + filename strip + scrubber), with
// ─── the real uploaded video playable inline.
// ─── • Toolbar: an Upload icon whose controls open inline, plus a trash on the
// ─── far right once a video exists. Drag-and-drop works on the preview.
// ─── • A REAL poster control, opt-in per call site via `poster` (2026-07-31,
// ─── campaign Stage 2). It is the SAME door as the quiz question editor's —
// ─── `BackgroundPicker imageOnly` — so there is one upload path for the field
// ─── rather than a second implementation to drift from it. The control that used
// ─── to be here was a setTimeout MOCK writing a fake 'placeholder:still-…' into
// ─── videoThumbUrl — a RAW field — so the author's next Build 422'd on a poster
// ─── they never chose (removed 2026-07-28). BackgroundPicker only ever emits
// ─── `asset://<id>` or '' (editors.jsx:844,992), which is exactly what a RAW
// ─── field accepts, so that trap cannot reopen through this route.
// ───
// ─── OPT-IN, not opt-out, and deliberately so: a slot gets the control only where
// ─── the pinned Player is known to READ the field back. `HotspotDiscoveryEditor`
// ─── passes videoThumbUrl but must NOT set `poster` — see the note at that call
// ─── site. A missing control is a gap; a control the runtime ignores is a lie.
// ───
// ─── `subtitlesUrl` is NOT handled here. The per-language track slot added in
// ─── OQ-081 phase 2b was removed 2026-07-30 on Omar's instruction — subtitles are
// ─── authored only in Localisation, which can generate, translate and edit cues
// ─── rather than just accept a file. The field itself is untouched everywhere else.
// ─── transcriptUrl / hasTranscript are still accepted but unread; no caller
// ─── depends on this block writing them.
function VideoMediaBlock({ videoUrl, videoThumbUrl, transcriptUrl,
  duration: declaredDuration = '00:00', generatedBy, defaultMode, defaultPrompt,
  onChange, hasTranscript = true, poster = false, videoElRef,
  courseId, label, previewHeight = 132 }) {
  const cid = courseId || window.dynamoCourseId;
  // Callers pass a sample length string ("04:32") that describes the DEMO
  // course's fictional video. For a real upload, read the file's actual length
  // — showing 04:32 over a 20 s clip is what made the overlay timings
  // unusable (Omar, 2026-07-27).
  const probedDuration = window.useVideoDuration(videoUrl);
  const duration =
    probedDuration.state === 'ready' ? window.formatDuration(probedDuration.seconds)
    // A real uploaded file whose length we haven't read: show nothing rather
    // than the caller's sample constant. Falling back to `declaredDuration`
    // here reprinted "04:32" over a 20 s upload whenever the probe was still
    // in flight or had failed.
    : probedDuration.state === 'loading' ? '· · ·'
    : probedDuration.state === 'error' ? '—'
    : declaredDuration;
  const [activeAction, setActiveAction] = React.useState(null); // null | 'upload'
  // The AI-generation panel was removed earlier; `generatedBy` still drives the
  // AI badge on an already-generated video, so aiModel stays.
  const [aiModel] = React.useState(generatedBy || 'HeyGen');
  const [status, setStatus] = React.useState(videoUrl ? 'ready' : 'idle');
  const [uploadError, setUploadError] = React.useState('');
  const fileInputRef = React.useRef(null);
  // The live preview element, held in STATE rather than a ref so that "is there
  // a video to capture a frame from?" is a reactive question — the Capture frame
  // button enables itself the moment the player mounts and disables itself when
  // the video is removed. A ref would answer correctly but never re-render.
  const [videoEl, setVideoEl] = React.useState(null);
  // A caller may want the same element (the quiz question editor renders its
  // poster picker as a SIBLING of this block, not inside it, so it cannot reach
  // the player any other way). Kept in a ref box and read at call time: putting
  // `videoElRef` in useCallback's deps would give the merged ref a new identity
  // on every render whenever a caller passed an inline arrow, and React would
  // detach/reattach — null, element, null, element — on each one.
  const externalVideoRef = React.useRef(videoElRef);
  externalVideoRef.current = videoElRef;
  const attachVideoEl = React.useCallback((el) => {
    setVideoEl(el);
    const ext = externalVideoRef.current;
    if (typeof ext === 'function') ext(el);
    else if (ext) ext.current = el;
  }, []);
  // Resolve an asset:// video to a playable URL (blob from this session, else a
  // short-lived signed GET). Non-asset values pass through unchanged.
  const [playableVideoSrc, setPlayableVideoSrc] = React.useState(null);
  React.useEffect(() => {
    let cancelled = false;
    const v = typeof videoUrl === 'string' ? videoUrl : '';
    if (!v || v.startsWith('placeholder:')) { setPlayableVideoSrc(null); return undefined; }
    if (/^(data:|blob:|https?:)/.test(v)) { setPlayableVideoSrc(v); return undefined; }
    if (!v.startsWith('asset://') || !window.resolveAssetUrl) { setPlayableVideoSrc(null); return undefined; }
    setPlayableVideoSrc(null);
    window.resolveAssetUrl(v).then((u) => { if (!cancelled && u) setPlayableVideoSrc(u); })
      .catch(() => { /* keep the placeholder rather than a broken player */ });
    return () => { cancelled = true; };
  }, [videoUrl]);
  // asset:// refs are opaque — never render the bare UUID (see assetLabel).
  const filename = window.assetLabel ? window.assetLabel(videoUrl) : (videoUrl || '').split('/').pop();
  const isAiVideo = (videoUrl || '').includes('ai-vid') || (generatedBy && videoUrl);

  const triggerFile = () => fileInputRef.current?.click();
  // Real presigned upload → asset://<id> ref the layout draft stores.
  const uploadVideo = async (f) => {
    if (!f) return;
    setStatus('uploading');
    setActiveAction('upload');
    setUploadError('');
    try {
      const ref = await window.uploadAsset(cid, f, 'video');
      onChange?.('videoUrl', ref);
      setStatus('ready');
    } catch (err) {
      console.error('video upload failed', err);
      setStatus('error');
      setUploadError((err && err.message) || '');
    }
  };
  const handleFile = (e) => {
    const f = e.target.files?.[0];
    // Clear the input BEFORE uploading: without this, choosing the same filename
    // twice fires no `change` event, so re-picking a file after a delete or a failed
    // upload silently did nothing. CompanionSlot (the widget this replaced at the
    // quiz-question site) had the reset; this one did not.
    e.target.value = '';
    uploadVideo(f);
  };
  const handleDrop = (e) => {
    e.preventDefault(); e.stopPropagation();
    const f = e.dataTransfer.files?.[0];
    if (!f) return;
    // The file dialog is limited to mp4/webm by `accept`, but a DROP bypasses that
    // entirely — and the gateway's precise rejection never reaches the author, so a
    // dropped .mov used to read only "Upload failed — try again". Say why instead.
    if (f.type && !VIDEO_MIME_ALLOWED.includes(f.type)) {
      setStatus('error');
      setUploadError(`${f.type.split('/').pop()} isn't supported — use MP4 or WebM.`);
      return;
    }
    uploadVideo(f);
  };
  const handleClear = () => {
    setStatus('idle');
    setActiveAction(null);
    onChange?.('videoUrl', '');
  };
  // A "Capture poster" control used to sit here and was REMOVED 2026-07-28: it was
  // a setTimeout mock writing `placeholder:still-<ts>` into videoThumbUrl, a RAW
  // field (asset-inline.ts), so findUnresolvedMedia could not resolve the fake path
  // and the author's next Build failed with an UnresolvedMedia 422 naming a poster
  // they never chose — on all 8 sites this component is used. The replacement below
  // is a real upload through BackgroundPicker, which cannot emit that shape.
  //
  // Canvas capture is now BUILT (2026-08-01) and shares that same route: the
  // poster card's "Capture frame" button encodes the displayed frame and uploads
  // it through the identical `uploadAsset` call, so it too can only ever produce
  // an `asset://<id>`. The 2026-07-28 trap was never about capturing — it was
  // about writing a path that named no file, which neither door can now do.
  const toggleAction = (k) => setActiveAction(prev => (prev === k ? null : k));

  // ── Poster (videoThumbUrl) ────────────────────────────────────────────────
  // A `placeholder:` seed is NOT a real upload: showing it as the picker's current
  // file would make an author skip a real one. Same filter the quiz question
  // editor already uses (layout-editor-bodies.jsx:1650).
  const realPosterName = (u) =>
    (typeof u === 'string' && u && !u.startsWith('placeholder:')) ? u : undefined;
  // Render whenever a video exists OR a poster already does. The second clause is
  // what keeps the state exitable: deleting the video must not HIDE a poster that
  // is still in the draft, or the author has an entrance with no exit — the shape
  // of the 2026-07-28 placeholder trap. Deleting the video therefore deliberately
  // does NOT clear the poster either: an author replacing a file (delete → upload)
  // would lose it, and an orphan poster cannot ship anyway, because every schema
  // that carries one requires `videoUrl` beside it.
  const showPoster = poster && Boolean(videoUrl || videoThumbUrl);

  // Preview background depends on what's happening.
  const previewBg = status === 'generating'
    ? 'linear-gradient(135deg, var(--ai-bg) 0%, var(--surface-inset) 100%)'
    : videoUrl
      ? 'linear-gradient(135deg, #475569 0%, #0f172a 100%)'
      : 'var(--surface-inset)';

  // Status pill text — derived from the video's actual state, not from any
  // preselected toolbar action.
  const pillText = status === 'generating' ? 'Generating'
    : status === 'uploading' ? 'Uploading'
    : status === 'error' ? 'Upload failed'
    : !videoUrl ? 'No video'
    : isAiVideo ? 'AI-generated'
    : 'Uploaded';

  const videoCard = (
    <div className="card" style={{ overflow: 'hidden' }}>
      {/* Header — deliberately byte-identical in style to the poster card's
          (editors.jsx:736-741), because the two sit side by side in a 2-column grid
          and Omar spotted the mismatch: the video's title was OUTSIDE its card (a
          SubBlock label above it) while the poster's was INSIDE, so the two cards
          started at different heights. Same header inside both = aligned tops. */}
      {label && (
        <div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)',
          fontSize: 12, fontWeight: 500, color: 'var(--text-muted)',
          display: 'flex', alignItems: 'center', gap: 8 }}>
          <I.Film size={13} />{label}
        </div>
      )}
      {/* Compact preview — drag-drop always enabled */}
      <div
        onDragOver={(e) => { e.preventDefault(); }}
        onDrop={handleDrop}
        style={{
          height: previewHeight, position: 'relative', overflow: 'hidden',
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          background: previewBg,
        }}>
        {/* The REAL uploaded video. Without this the box was a flat gradient, so
            an author could not tell an uploaded video from an empty slot — the
            same "did my upload land?" gap that images had (fixed 2026-07-27). */}
        {/* `preload="metadata"` and no `crossOrigin` — both deliberate, and both
            are why "Capture frame" never reads THIS element's pixels: at this
            preload setting Chrome reports readyState 4 while drawImage is still
            a no-op (a silent all-black poster), and without crossOrigin the
            canvas taints after a reload. Neither is worth fixing here — the
            attributes that would fix them also decide whether the video LOADS AT
            ALL, and a preview that fails is worse than a capture that works
            harder. captureVideoFrame uses its own element; see asset-upload.jsx. */}
        {playableVideoSrc && (
          <video ref={attachVideoEl} src={playableVideoSrc} controls preload="metadata"
            style={{ position: 'absolute', inset: 0, width: '100%', height: '100%',
              objectFit: 'contain', background: '#000' }} />
        )}
        {/* Status pill (top-left) */}
        <span style={{
          position: 'absolute', top: 8, left: 10,
          padding: '2px 7px', fontSize: 10, fontWeight: 600,
          borderRadius: 3, letterSpacing: '.04em', textTransform: 'uppercase',
          background: 'rgba(15,23,42,.55)', color: '#fff',
          backdropFilter: 'blur(4px)', fontFamily: 'inherit',
          display: 'inline-flex', alignItems: 'center', gap: 4,
          // Painted over the real <video>; without this it swallows clicks on the
          // frame beneath (play/pause) in that band.
          pointerEvents: 'none',
        }}>{pillText}</span>

        {/* AI service badge (top-right, when applicable) */}
        {videoUrl && status === 'ready' && isAiVideo && (
          <span style={{ position: 'absolute', top: 8, right: 10 }}>
            <AiBadge label={generatedBy || aiModel} />
          </span>
        )}

        {/* Centre content — varies by state. The film glyph is a PLACEHOLDER: it
            only makes sense when there is no real frame to show, so it is
            suppressed once the actual <video> is mounted (Omar, 2026-07-28). */}
        {status === 'ready' && videoUrl && !playableVideoSrc && (
          <I.Film size={28} style={{ color: 'rgba(255,255,255,.45)' }} />
        )}
        {status === 'generating' && (
          <span style={{ color: 'rgba(255,255,255,.75)', fontSize: 12,
            display: 'inline-flex', alignItems: 'center', gap: 6 }}>
            <I.Sparkle size={13} />Generating with {aiModel}…
          </span>
        )}
        {status === 'uploading' && (
          <span style={{ color: 'rgba(255,255,255,.75)', fontSize: 12,
            display: 'inline-flex', alignItems: 'center', gap: 6 }}>
            <I.Upload size={13} />Uploading…
          </span>
        )}
        {status === 'error' && (
          <span style={{ color: 'rgba(255,255,255,.85)', fontSize: 12,
            display: 'inline-flex', alignItems: 'center', gap: 6 }}>
            <I.AlertTriangle size={13} />{uploadError || 'Upload failed — try again'}
          </span>
        )}
        {status === 'idle' && !videoUrl && (
          <span style={{ color: 'var(--text-muted)', fontSize: 12,
            display: 'inline-flex', alignItems: 'center', gap: 6 }}>
            <I.Upload size={13} />Drop a video here, or use Upload below (MP4 / WebM)
          </span>
        )}

        {/* REMOVED 2026-07-28 — a fake play glyph, a hardcoded 34%-filled scrubber
            and a duration label used to sit along the bottom. Once the real
            <video controls> element landed here, every one of those was drawn twice,
            the second copy inert (Omar: "remove the placeholder and just use the
            video player elements"). The real control bar carries play, seek and
            00:05 / 00:19. The probed `duration` still drives the length shown in the
            toolbar row below, which is where it is not competing with the player. */}
        {status === 'ready' && videoUrl && (
          <span style={{
            // 32, not 8: the status pill occupies the top-left corner. The preview
            // box is its own positioning context, so the header above does not shift
            // these coordinates.
            position: 'absolute', top: 32, left: 10, maxWidth: 260,
            fontSize: 11, color: 'rgba(255,255,255,.85)',
            fontFamily: 'var(--font-mono)',
            overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
            pointerEvents: 'none',
          }}>{filename}</span>
        )}
      </div>

      {/* Hidden file picker so triggerFile() works from the contextual Replace
          button without bloating the toolbar with an inline <input>. */}
      {/* mp4/webm only — the gateway's asset MIME allow-list for kind 'video'
          rejects everything else, and "video/*" let an author pick a .mov and
          watch the upload fail (the guard CompanionSlot already had). */}
      <input ref={fileInputRef} type="file" accept={VIDEO_MIME_ALLOWED.join(',')}
        hidden onChange={handleFile} />

      {/* Toolbar — the Upload icon (not preselected) + its contextual controls +
          an always-available trash once a video exists. */}
      <div style={{
        display: 'flex', alignItems: 'center', gap: 8,
        padding: 8, borderTop: '1px solid var(--border)',
        background: 'var(--surface-2)',
        position: 'relative',
      }}>
        <div style={{ display: 'inline-flex', gap: 6 }}>
          <ToolIcon icon={I.Upload}
            title={videoUrl ? 'Replace video' : 'Upload video'}
            hint={videoUrl
              ? 'Replace this video with one from your computer (MP4 / WebM).'
              : 'Choose a video from your computer (MP4 / WebM).'}
            active={activeAction === 'upload'}
            onClick={() => toggleAction('upload')} />
        </div>

        {/* Contextual controls for the active icon */}
        <div style={{ flex: 1, display: 'flex', alignItems: 'center',
          gap: 8, minWidth: 0 }}>
          {activeAction === 'upload' && (
            <>
              {/* Action first, next to the mode icons — same rule as the image
                  picker: no dead "No file selected" label with the control
                  stranded at the far right (Omar, 2026-07-26). */}
              <button className="btn sm" onClick={triggerFile}
                disabled={status === 'uploading'}>
                <I.Upload size={11} />{videoUrl ? 'Replace' : 'Choose file'}
              </button>
              <div className="truncate" style={{
                flex: 1, fontSize: 12,
                color: filename ? 'var(--text)' : 'var(--text-faint)',
                fontFamily: filename ? 'var(--font-mono)' : 'inherit',
              }}>{filename}</div>
            </>
          )}

        </div>

        {/* The file's REAL length, read from the uploaded file itself. It used to sit
            on the preview, where it competed with the player's own running time; here
            it does not. Keeping it visible matters: an editor that printed a sample
            "04:32" over a 20-second clip is what made in-video overlay timings
            unusable (Omar, 2026-07-27), so authors need the true figure at a glance. */}
        {videoUrl && status === 'ready' && (
          <span title="Actual length of the uploaded file"
            style={{ fontSize: 11, fontFamily: 'var(--font-mono)',
              color: 'var(--text-faint)', flex: '0 0 auto' }}>{duration}</span>
        )}

        {/* Trash — always available once a video exists */}
        {videoUrl && (
          <button className="btn sm ghost danger" title="Remove this video"
            onClick={handleClear}
            style={{ width: 28, height: 28, padding: 0,
              justifyContent: 'center', gap: 0 }}>
            <I.Trash size={12} />
          </button>
        )}
      </div>

      {/* NO subtitle slot here. Subtitles are handled ONLY in Localisation
          (Omar, 2026-07-30: "The only section to handle the Subtitles for EN and
          other languages will be the Localisation section").

          OQ-081 phase 2b had put a per-language caption slot in this block — the
          single place all nine video slots render through — which is why removing
          it here removes it from all nine at once. That decision is superseded:
          uploading a `.vtt` beside the video and generating/translating cues in
          Localisation were two doors to the same field, and the Localisation one
          can do strictly more (generate from the video, translate, edit cues).
          Two doors to one field is how they drift apart.

          `subtitlesUrl` is UNCHANGED in the schema, the export and the ZIP — only
          this control is gone. The data path is Localisation → `setSubtitleRef` →
          the layout draft, exactly as before. */}
    </div>
  );

  if (!showPoster) return videoCard;

  // Stacked, not side-by-side. The quiz question editor composes the same two
  // cards in a 2-column FieldGrid, but it owns a full-width inspector; this block
  // also renders inside narrow tab and row panels, where a second column would
  // squeeze both previews. Stacking reads the same at every width.
  //
  // `window.BackgroundPicker` rather than a bare reference: editors.jsx loads
  // before this file (index.html:317 vs :334) so the global exists by render time,
  // and going through window is the convention this file already uses for
  // cross-file helpers (window.uploadAsset, window.resolveAssetUrl).
  const Picker = window.BackgroundPicker;
  return (
    <div style={{ display: 'grid', gap: 8 }}>
      {videoCard}
      {Picker ? (
        <Picker imageOnly label="Video poster" previewHeight={96} courseId={cid}
          defaultFilename={realPosterName(videoThumbUrl)}
          captureReady={Boolean(videoEl)}
          onCaptureFrame={() => window.captureVideoFrame(videoEl, videoUrl)}
          onImageChange={(f) => onChange?.('videoThumbUrl', f || '')} />
      ) : null}
    </div>
  );
}

// ─── CompanionSlot — a thin (~52px) inline slot. NO live JSX call site since
// ─── 2026-07-28 (the quiz question-video slot, its last user, moved to
// ─── VideoMediaBlock). Kept for the subtitle/poster slots a future layout may need.
// ─── BEFORE REUSING IT for a transcript: kind 'transcript' maps to assetKind
// ─── 'document', which is NOT a key of the gateway's KIND_MIME — the upload 400s
// ─── with '"kind" must be one of video, image, subtitle, object3d'.
// ─── One row: icon + label + filename or "Add" + tiny action button.
function CompanionSlot({ kind = 'image', label, value, onChange, courseId }) {
  const cid = courseId || window.dynamoCourseId;
  const Icon = kind === 'image' ? I.Image
    : kind === 'subs' ? I.Captions
    : kind === 'video' ? I.Film
    : I.FileText;
  const accept = kind === 'image' ? 'image/*'
    : kind === 'subs' ? '.vtt,.srt,text/vtt'
    // The gateway accepts only mp4/webm for kind 'video' (assets.ts MIME
    // allow-list); offering 'video/*' let the author pick a .mov and fail.
    : kind === 'video' ? 'video/mp4,video/webm'
    : '.txt,.md,text/plain';
  const has = !!value;
  const fileInputRef = React.useRef(null);
  const triggerFile = () => fileInputRef.current?.click();
  // Map the slot kind to the gateway asset kind (subtitles → 'subtitle',
  // poster/image → 'image', transcript → 'document').
  const assetKind = kind === 'subs' ? 'subtitle' : kind === 'image' ? 'image'
    : kind === 'video' ? 'video' : 'document';
  const handleFile = async (e) => {
    const f = e.target.files?.[0];
    e.target.value = ''; // allow re-selecting the same file
    if (!f) return;
    try {
      const ref = await window.uploadAsset(cid, f, assetKind);
      onChange?.(ref);
    } catch (err) {
      console.error('companion upload failed', err);
    }
  };
  return (
    <div style={{
      display: 'flex', alignItems: 'center', gap: 10,
      padding: '10px 12px', background: 'var(--surface)',
      minWidth: 0,
    }}>
      <input ref={fileInputRef} type="file" accept={accept} hidden onChange={handleFile} />
      <div style={{
        width: 28, height: 28, borderRadius: 4,
        background: has ? 'var(--accent-bg)' : 'var(--surface-inset)',
        display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
        color: has ? 'var(--accent-text)' : 'var(--text-muted)',
        flexShrink: 0,
      }}>
        <Icon size={13} />
      </div>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontSize: 11.5, color: 'var(--text-muted)', fontWeight: 500,
          letterSpacing: '.02em', textTransform: 'uppercase' }}>{label}</div>
        <div className="truncate" style={{ fontSize: 11.5,
          color: has ? 'var(--text)' : 'var(--text-faint)',
          fontFamily: has ? 'var(--font-mono)' : 'inherit' }}>
          {has ? (window.assetLabel ? window.assetLabel(value) : (value || '').replace('placeholder:', '').split('/').pop()) : 'Not set'}
        </div>
      </div>
      {has && (
        <button className="btn sm ghost danger" title="Remove"
          onClick={() => onChange?.('')}
          style={{ width: 24, height: 24, padding: 0 }}>
          <I.Trash size={11} />
        </button>
      )}
      <button className="btn sm ghost" title={has ? 'Replace' : 'Add'}
        onClick={triggerFile}
        style={{ width: 24, height: 24, padding: 0 }}>
        <I.Upload size={11} />
      </button>
    </div>
  );
}

// ─── LocalizedCompanionSlot was REMOVED 2026-07-30.
// ───
// ─── Its only caller was the per-language subtitle slot in VideoMediaBlock, and
// ─── that slot is gone — subtitles are authored solely in Localisation now
// ─── (Omar: "Make sure to remove unnecessary code on this page"). The widget
// ─── existed purely to keep a per-language RECORD out of CompanionSlot, which is
// ─── a correct single-value widget whose internals all assume a string; that
// ─── hazard is documented on CompanionSlot itself and in
// ─── `feedback_localizedstring_bound_to_plain_widget`. If a future per-language
// ─── media slot needs it, recover it from git rather than re-deriving it — the
// ─── subtleties (strict per-language read, deleting the key instead of writing
// ─── '') are the whole point of it.

// ─── InteractionEmptyState — collapsed call-to-action shown when the layout
// ─── has zero in-video interactions. The full timeline is only rendered once
// ─── the author adds one.
function InteractionEmptyState({ onAdd, videoUrl }) {
  const [open, setOpen] = React.useState(false);
  // The FIRST interaction used to be seeded at a hardcoded 60 s, which is past
  // the end of any short upload — invisible to the author and never fired by
  // the Player. Seed it against the video's real length instead; `null` means
  // sample media with no real file, where the caller's sample default stands.
  const probed = window.useVideoDuration(videoUrl);
  const realDuration = probed.state === 'ready' ? probed.seconds : null;
  // 'none' = no video in this slot yet (the sequence / horizontal-tabs video
  // tabs render this block without a videoUrl guard), so there is no length to
  // time an interaction against.
  const blocked = probed.state === 'loading' || probed.state === 'error'
    || probed.state === 'none';
  return (
    <div style={{
      padding: 16, background: 'var(--surface-inset)',
      border: '1px dashed var(--border-strong)', borderRadius: 'var(--radius-md)',
      display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10,
      textAlign: 'center',
    }}>
      <div style={{
        width: 36, height: 36, borderRadius: '50%',
        background: 'var(--surface)', border: '1px solid var(--border)',
        display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
        color: 'var(--text-muted)',
      }}>
        <I.Clock size={16} />
      </div>
      <div>
        <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', marginBottom: 2 }}>
          No in-video interactions
        </div>
        <p style={{ margin: 0, fontSize: 11.5, color: 'var(--text-muted)',
          lineHeight: 1.5, maxWidth: 360 }}>
          Pause the video at a moment and overlay a reveal, a question, or a
          mandatory checkpoint.
        </p>
      </div>
      {probed.state === 'loading' && (
        <div style={{ fontSize: 11.5, color: 'var(--text-muted)' }}>
          Reading the video’s length…
        </div>
      )}
      {probed.state === 'none' && (
        <div style={{ fontSize: 11.5, color: 'var(--text-muted)', maxWidth: 360 }}>
          Upload the video first — overlays are timed against its real length.
        </div>
      )}
      {probed.state === 'error' && (
        <div style={{ fontSize: 11.5, color: 'var(--text-muted)', maxWidth: 360 }}>
          This video’s length can’t be read, so an interaction can’t be timed
          against it. Re-upload the video, or reload the page to retry.
        </div>
      )}
      {!open ? (
        <button className="btn sm primary" onClick={() => setOpen(true)}
          disabled={blocked}>
          <I.Plus size={12} />Add in-video interaction
        </button>
      ) : (
        <div style={{ display: 'flex', gap: 4 }}>
          <button className="btn sm" onClick={() => onAdd('discover', realDuration)}>
            <I.Eye size={11} />Discover
          </button>
          <button className="btn sm" onClick={() => onAdd('question', realDuration)}>
            <I.AlertCircle size={11} />Question
          </button>
          <button className="btn sm" onClick={() => onAdd('mandatoryQuestion', realDuration)}>
            <I.Lock size={11} />Mandatory
          </button>
          <button className="btn sm ghost" onClick={() => setOpen(false)}>
            <I.X size={11} />
          </button>
        </div>
      )}
    </div>
  );
}

// ─── InlineTimeInput — compact MM : SS : mmm header variant.
// ─── Used inside an interaction-inspector header where timing colocates
// ─── with the type label, not the focal control. Three small monospaced
// ─── cells separated by colons; no min/sec/ms caps, no inset wrapper. The
// ─── full-size TimeInput remains for layouts where time IS the focal point.
function InlineTimeInput({ value = 0, onChange, max = 9999 }) {
  const totalMs = Math.max(0, Math.round((+value || 0) * 1000));
  const mm = Math.floor(totalMs / 60000);
  const ss = Math.floor((totalMs % 60000) / 1000);
  const ms = totalMs % 1000;
  const update = (m, s, milli) => {
    const next = Math.min(max, Math.max(0, m * 60 + s + milli / 1000));
    onChange?.(next);
  };
  const cell = {
    width: 42, height: 30, padding: '0 4px',
    fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 500,
    textAlign: 'center',
    border: '1px solid var(--border-strong)', borderRadius: 'var(--radius)',
    background: 'var(--surface)', color: 'var(--text)',
    appearance: 'textfield', MozAppearance: 'textfield',
  };
  const wide = { ...cell, width: 54 };
  const sep = {
    fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 500,
    color: 'var(--text-faint)', padding: '0 4px',
  };
  return (
    <div style={{ display: 'inline-flex', alignItems: 'center' }}>
      <style>{`
        .om-inlinetime-cell::-webkit-outer-spin-button,
        .om-inlinetime-cell::-webkit-inner-spin-button {
          -webkit-appearance: none; margin: 0;
        }
      `}</style>
      <input className="om-inlinetime-cell" style={cell} type="number" min="0" max="59"
        value={String(mm).padStart(2, '0')}
        onChange={e => update(Math.min(59, Math.max(0, +e.target.value || 0)), ss, ms)} />
      <span style={sep}>:</span>
      <input className="om-inlinetime-cell" style={cell} type="number" min="0" max="59"
        value={String(ss).padStart(2, '0')}
        onChange={e => update(mm, Math.min(59, Math.max(0, +e.target.value || 0)), ms)} />
      <span style={sep}>:</span>
      <input className="om-inlinetime-cell" style={wide} type="number" min="0" max="999"
        value={String(ms).padStart(3, '0')}
        onChange={e => update(mm, ss, Math.min(999, Math.max(0, +e.target.value || 0)))} />
    </div>
  );
}

// ─── TimeInput — minutes : seconds . milliseconds, large enough to read.
// ─── Stores as a float (seconds with decimal milliseconds). Designed to be
// ─── the focal control inside an interaction inspector, so the digits read
// ─── like a stopwatch — generously sized, monospaced, with quiet unit
// ─── labels under each pair so the format never has to be guessed.
function TimeInput({ value = 0, onChange, max = 9999, compact }) {
  if (compact) return <InlineTimeInput value={value} onChange={onChange} max={max} />;
  const totalMs = Math.max(0, Math.round((+value || 0) * 1000));
  const mm = Math.floor(totalMs / 60000);
  const ss = Math.floor((totalMs % 60000) / 1000);
  const ms = totalMs % 1000;
  const update = (m, s, milli) => {
    const next = Math.min(max, Math.max(0, m * 60 + s + milli / 1000));
    onChange?.(next);
  };
  const cell = {
    width: 60, height: 40, padding: '0 6px',
    fontFamily: 'var(--font-mono)', fontSize: 18, fontWeight: 500,
    letterSpacing: '.01em', textAlign: 'center',
    border: '1px solid var(--border-strong)', borderRadius: 'var(--radius)',
    background: 'var(--surface)', color: 'var(--text)',
    /* Hide native number spinners so the cell reads as a clean digit slot. */
    appearance: 'textfield', MozAppearance: 'textfield',
  };
  const sep = {
    fontFamily: 'var(--font-mono)', fontSize: 18, fontWeight: 500,
    color: 'var(--text-faint)', padding: '0 2px', lineHeight: '40px',
  };
  const cap = {
    fontSize: 10, color: 'var(--text-faint)',
    letterSpacing: '.06em', textTransform: 'uppercase',
    textAlign: 'center', marginTop: 4, fontFamily: 'var(--font-mono)',
  };
  return (
    <div style={{
      display: 'inline-flex', alignItems: 'flex-start', gap: 2,
      padding: '8px 10px',
      background: 'var(--surface-inset)',
      border: '1px solid var(--border)',
      borderRadius: 'var(--radius-md)',
    }}>
      <style>{`
        .om-timeinput-cell::-webkit-outer-spin-button,
        .om-timeinput-cell::-webkit-inner-spin-button {
          -webkit-appearance: none; margin: 0;
        }
      `}</style>
      <div>
        <input className="om-timeinput-cell" style={cell} type="number" min="0" max="59"
          value={String(mm).padStart(2, '0')}
          onChange={e => update(Math.min(59, Math.max(0, +e.target.value || 0)), ss, ms)} />
        <div style={cap}>min</div>
      </div>
      <span style={sep}>:</span>
      <div>
        <input className="om-timeinput-cell" style={cell} type="number" min="0" max="59"
          value={String(ss).padStart(2, '0')}
          onChange={e => update(mm, Math.min(59, Math.max(0, +e.target.value || 0)), ms)} />
        <div style={cap}>sec</div>
      </div>
      <span style={sep}>.</span>
      <div>
        <input className="om-timeinput-cell" style={{ ...cell, width: 76 }}
          type="number" min="0" max="999"
          value={String(ms).padStart(3, '0')}
          onChange={e => update(mm, ss, Math.min(999, Math.max(0, +e.target.value || 0)))} />
        <div style={cap}>ms</div>
      </div>
    </div>
  );
}

// ─── Helpers exposed to the editor draft hook ───────────────────────────────
function setAtPath(obj, path, value) {
  // path: array of keys/indices. Returns a new object with value set at path.
  if (!path.length) return value;
  const [k, ...rest] = path;
  const isArr = Array.isArray(obj);
  const next = isArr ? obj.slice() : { ...(obj || {}) };
  next[k] = setAtPath(obj?.[k], rest, value);
  return next;
}

function getAtPath(obj, path) {
  let cur = obj;
  for (const k of path) {
    if (cur == null) return undefined;
    cur = cur[k];
  }
  return cur;
}

// ─── TooltipParamsForm — the Author's per-use tooltip-text form (Phase 2) ────
// Rendered below a chosen HTML template when that template exposes tooltip
// slots (`tooltipParams`). One row per slot: the slot's label + helper text,
// and a ControlledLocalized editor (the canonical LocalizedString widget — we
// reuse it, never reimplement; CLAUDE.md §26) bound to that slot's value.
// Shared by the live quiz HTML-media editor and the (legacy) HtmlTemplateSlot.
//   · params  : TooltipParam[]  — { name, label:LocalizedString, description:LocalizedString }
//   · values  : { [name]: LocalizedString }
//   · onChange : (name, localizedValue) => void
function TooltipParamsForm({ params, values = {}, onChange }) {
  const lang = React.useContext(LocDefaultLangContext) || 'en';
  if (!params || params.length === 0) return null;
  return (
    <div className="tooltip-params-form" style={{
      display: 'grid', gap: 12, padding: 12, marginTop: 2,
      background: 'var(--surface-inset)', border: '1px solid var(--border)',
      borderRadius: 'var(--radius-md)' }}>
      <div>
        <div className="section-header" style={{ display: 'flex', alignItems: 'center', gap: 7,
          fontSize: 12.5, fontWeight: 600, color: 'var(--text)' }}>
          <I.MessageSquare size={13} style={{ color: 'var(--text-muted)' }} />Tooltip text
        </div>
        <div className="section-help" style={{ fontSize: 11.5, color: 'var(--text-muted)',
          lineHeight: 1.5, marginTop: 4 }}>
          Fill in the tooltip text for each highlighted term. Each tooltip can be translated
          in the Localisation surface.
        </div>
      </div>
      {params.map(param => (
        <div key={param.name} className="param-row" style={{ display: 'grid', gap: 6 }}>
          <label style={{ display: 'grid', gap: 2 }}>
            <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text)' }}>
              {locText(param.label, lang) || locText(param.label, 'en')}
            </span>
            <span className="hint" style={{ fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.45 }}>
              {locText(param.description, lang) || locText(param.description, 'en')}
            </span>
          </label>
          <ControlledLocalized value={values[param.name] || {}} multiline
            onChange={v => onChange?.(param.name, v)} />
        </div>
      ))}
    </div>
  );
}

Object.assign(window, {
  ColorField, GateControl, Toggle, TextField, NumberField, SegmentedControl,
  ControlledLocalized, ControlledRich, FieldGrid, LabeledControl, GateBlock,
  VideoMediaBlock, CompanionSlot,
  InteractionEmptyState, TimeInput, InlineTimeInput,
  HeaderSwatch, HeaderSwatchSet, InlineColorRow, SubBlock, EditorTabs, SettingRow,
  setAtPath, getAtPath, DEFAULT_PALETTE, TooltipParamsForm,
});
