// PlayerPreview — renders the visual the Dynamo Player would produce for the
// currently-selected layout. The mapping is layout.type → one renderer per type.
// Field names match catalogue §1/§3. Visuals match the screenshots in
// raw/ux-references/. This is a player skin, not the editor — no XML on screen.

// PreviewInteractiveContext — true only inside the full-viewport PreviewModal.
// When true, renderers run their executable behaviour (quiz submit→feedback→
// end screen, in-video interaction firing). The editor's small Module preview
// leaves it false, so its behaviour is unchanged.
const PreviewInteractiveContext = React.createContext(false);
window.PreviewInteractiveContext = PreviewInteractiveContext;

// ── 1. The mock-image placeholder ───────────────────────────────────────────
// Layouts reference images by path; we stand in for them with subtly-striped
// SVGs labelled with what the image is supposed to be. No fake photography.
function MockImage({ url, alt, style = {}, label }) {
  // Solid colour overrides — picked to match the screenshot palettes.
  const palettes = {
    'scene-tense-room':    ['#1f2937','#0f172a'],
    'scene-volume':        ['#475569','#1e293b'],
    'scene-eyecontact':    ['#64748b','#334155'],
    'video-poster-office': ['#0f172a','#020617'],
    'video-priya':         ['#475569','#0f172a'],
    'video-silence':       ['#94a3b8','#475569'],
    'video-sarah-prep':    ['#475569','#1e293b'],
    'video-talk':          ['#3b3f4e','#1f2330'],
    'scene-office':        ['#65523a','#3b2f24'],
    'scene-coaching':      ['#7c2d12','#431407'],
    'scene-giving':        ['#1e3a8a','#172554'],
    'scene-receiving':     ['#14532d','#052e16'],
    'scene-divider':       ['#0f172a','#020617'],
    'scene-office-fire':   ['#94a3b8','#64748b'],
    'scene-doors-split':   ['#52525b','#27272a'],
    'scene-kitchen':       ['#f1f5f9','#cbd5e1'],
    'scene-cover-hospitality': ['#3F2A1A','#1a0f06'],
    'scene-m1':            ['#475569','#334155'],
    'scene-m2':            ['#1e3a8a','#1e293b'],
    'scene-m3':            ['#7c2d12','#431407'],
    'scene-m4':            ['#3F2A1A','#1f1109'],
    'object-centrifuge':   ['#94a3b8','#475569'],
    'logo-mohg':           ['#B8932D','#8a6f1f'],
    'icon-people':         ['#3F4E66','#2A3447'],
    'icon-mind':           ['#3F4E66','#2A3447'],
    'icon-gear':           ['#3F4E66','#2A3447'],
    'icon-headset':        ['#3F4E66','#2A3447'],
    'icon-correct':        ['#16a34a','#065f46'],
    'icon-wrong':          ['#dc2626','#7f1d1d'],
  };
  const key = (url || '').replace(/^placeholder:/, '');
  const colors = palettes[key] || ['#475569','#1e293b'];
  const stripeId = `s_${key.replace(/[^a-z0-9]/gi, '_')}_${Math.random().toString(36).slice(2,6)}`;

  return (
    <div style={{
      position: 'relative', overflow: 'hidden',
      background: `linear-gradient(135deg, ${colors[0]}, ${colors[1]})`,
      ...style,
    }} aria-label={alt || label}>
      <svg width="100%" height="100%" style={{ position: 'absolute', inset: 0, opacity: 0.18 }}>
        <defs>
          <pattern id={stripeId} width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
            <line x1="0" y1="0" x2="0" y2="8" stroke="white" strokeWidth="1" />
          </pattern>
        </defs>
        <rect width="100%" height="100%" fill={`url(#${stripeId})`} />
      </svg>
      {label && (
        <div style={{
          position: 'absolute', inset: 0, display: 'flex', alignItems: 'center',
          justifyContent: 'center', color: 'rgba(255,255,255,.6)',
          fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '.05em',
          textAlign: 'center', padding: '0 8px',
        }}>
          {label}
        </div>
      )}
    </div>
  );
}

// ── 2. Shared chrome: title bar + bottom "continue" cue ─────────────────────
function PlayerTitleBar({ titleMain, titleSub, titleTextColor = '#fff', titleBackgroundColor = 'transparent' }) {
  if (!titleMain && !titleSub) return null;
  return (
    <div style={{
      padding: '24px 48px 16px', textAlign: 'center',
      background: titleBackgroundColor, color: titleTextColor,
      flexShrink: 0,
    }}>
      {titleMain && <h2 style={{ margin: '0 0 6px', fontSize: 22, fontWeight: 500,
        color: titleTextColor, lineHeight: 1.2 }}>{titleMain}</h2>}
      {titleSub && <div style={{ fontSize: 14, fontWeight: 400, color: titleTextColor,
        opacity: 0.92, lineHeight: 1.5, maxWidth: 880, margin: '0 auto' }}
        dangerouslySetInnerHTML={{ __html: stripCdata(titleSub) }} />}
    </div>
  );
}

function PlayerBottomCue({ gate, color = 'rgba(255,255,255,.7)' }) {
  // Matches the "Click the arrow down to continue learning" footer in the
  // screenshots. gate.state=true → padlock; false → arrow; '' → no cue.
  // `gate` is the nested blockingSection object { state, color?, textColor? }
  // (packages/schema BlockingSectionSchema).
  const g = gate || {};
  const state = g.state ?? '';
  if (state === '' || state == null) return null;
  // gate.color styles the bar background; gate.textColor styles text + icon.
  const fg = g.textColor || color;
  const bg = g.color;
  return (
    <div style={{
      // position:relative + zIndex puts the cue ABOVE any absolutely-positioned
      // background siblings inside a renderer (e.g. fullscreen_text_and_image
      // paints a <MockImage> + dark overlay over its whole root; without a
      // stacking context the cue's bg/text colour edits were hidden behind
      // those layers even though the DOM updated correctly).
      position: 'relative', zIndex: 1,
      padding: '20px 0 16px', display: 'flex', flexDirection: 'column',
      alignItems: 'center', gap: 4, color: fg, flexShrink: 0,
      background: bg || 'transparent',
    }}>
      <div style={{ fontSize: 12 }}>Click the arrow down to continue learning</div>
      <div style={{
        width: 30, height: 30, borderRadius: '50%',
        border: `1.5px solid ${fg}`, display: 'inline-flex',
        alignItems: 'center', justifyContent: 'center',
      }}>
        {state === 'true'
          ? <I.Lock size={14} />
          : <I.ChevronDown size={16} />}
      </div>
    </div>
  );
}

function stripCdata(s) {
  if (typeof s !== 'string') return '';
  return s.replace(/^<!\[CDATA\[/, '').replace(/]]>$/, '');
}

// ── 3. The main dispatch ─────────────────────────────────────────────────────
function PlayerPreview({ layout, frame = 'default', lang = 'en' }) {
  // `frame` is one of: 'default' (the layout itself), or any of the framing-
  // screen keys: 'cover','language','brand','role','home','preAssessment','postAssessment'.
  if (frame && frame !== 'default') {
    return <FramingScreenPreview frame={frame} lang={lang} />;
  }
  if (!layout) {
    return <div style={{ padding: 48, color: '#888', textAlign: 'center' }}>No layout selected</div>;
  }
  const type = layout.type;
  const R = LAYOUT_RENDERERS[type] || FallbackRenderer;
  // Flatten every LocalizedString in the layout to its active-language string
  // ONCE here, so the per-type renderers read plain strings exactly as before
  // the migration — no renderer needs to know about per-language objects.
  const resolved = deepLocResolve(layout, lang);
  // Wrap in a component so hooks inside R run inside a real React component;
  // key={type} forces a fresh mount when the type changes, so hook order is stable.
  return <RendererWrapper key={type} renderer={R} layout={resolved} />;
}

function RendererWrapper({ renderer, layout }) {
  return renderer(layout);
}

function FallbackRenderer({ layout }) {
  return (
    <div style={{ padding: '40px 32px', color: '#94a3b8', textAlign: 'center' }}>
      <I.AlertCircle size={20} />
      <div style={{ marginTop: 12, fontSize: 13 }}>
        No player preview for type <code style={{ fontFamily: 'var(--font-mono)' }}>{layout.type}</code>
      </div>
    </div>
  );
}

// ── 4. Per-type renderers ───────────────────────────────────────────────────
// ── Step canvas shells (sequence) ────────────────────────────────────────
// The four sequence step kinds (text / video / question / feedback) share
// the same 16:9 canvas-with-image-background shell. Three of them
// (text / question / feedback) additionally float a white card near the
// bottom. Factored here so the kinds read as a cohesive family — see
// docs/video-media-patterns.md §16.
function StepCanvas({ imageUrl, fallbackLabel = 'No image',
  mediaLabel = 'Image', children }) {
  return (
    <div style={{ position: 'relative', width: '100%', aspectRatio: '16/9',
      background: '#0f172a', borderRadius: 8, overflow: 'hidden' }}>
      {imageUrl
        ? <MockImage url={imageUrl} label={mediaLabel}
            style={{ position: 'absolute', inset: 0 }} />
        : <div style={{ position: 'absolute', inset: 0,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            color: 'rgba(255,255,255,.4)', fontSize: 12,
            background: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)' }}>
            {fallbackLabel}
          </div>}
      {children}
    </div>
  );
}

function StepBottomCard({ children, style }) {
  return (
    <div style={{
      position: 'absolute', left: '7%', right: '7%', bottom: '9%',
      background: '#fff', color: '#0f172a',
      padding: '16px 20px', borderRadius: 8,
      boxShadow: '0 8px 24px rgba(0,0,0,.25)',
      maxHeight: '78%', overflow: 'hidden',
      ...style,
    }}>{children}</div>
  );
}

// Shared numbered step-dot strip — the canonical implementation lives here so
// every layout with a step/question progress row renders identical dots
// (centered, numbered, connector line, selected dot = dotSelectedColor). Used
// by `sequence` and `quiz_images`/`quiz_gaming`. Don't re-implement dots per
// layout — see docs/video-media-patterns.md §26 (reuse, don't reinvent).
function StepDots({ count, activeIdx, onSelect, selectedColor = '#FFD166',
  leadDot = null, trailDot = null }) {
  // `leadDot` / `trailDot` are optional marker dots (e.g. a Start screen at the
  // head, a Win/Fail screen at the tail of quiz_gaming). They render as an
  // outlined icon dot connected to the numbered strip by the same connector
  // line, so the screens read as bookends around the question sequence.
  const connector = { width: 20, height: 1, background: 'rgba(255,255,255,.4)' };
  const Marker = ({ spec }) => {
    const Icon = spec.icon ? I[spec.icon] : null;
    return (
      <div title={spec.title} style={{
        width: 28, height: 28, borderRadius: '50%',
        background: 'transparent', color: '#fff',
        border: '1.5px solid rgba(255,255,255,.7)',
        display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
        flex: '0 0 28px',
      }}>{Icon ? <Icon size={14} /> : null}</div>
    );
  };
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 0, justifyContent: 'center' }}>
      {leadDot && <><Marker spec={leadDot} /><div style={connector} /></>}
      {Array.from({ length: count }).map((_, i) => (
        <React.Fragment key={i}>
          {i > 0 && <div style={connector} />}
          <button onClick={() => onSelect?.(i)}
            style={{
              width: 28, height: 28, borderRadius: '50%',
              background: i === activeIdx ? selectedColor : '#fff',
              color: '#0f172a',
              border: i === activeIdx ? '2px solid #2563eb' : 'none',
              fontSize: 12, fontWeight: 600,
              display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
              cursor: 'default', fontFamily: 'inherit', flex: '0 0 28px',
            }}>{i + 1}</button>
        </React.Fragment>
      ))}
      {trailDot && <><div style={connector} /><Marker spec={trailDot} /></>}
    </div>
  );
}

// Question media for the gaming quiz preview — image (with optional hotspot
// pins), video poster, or an HTML-document placeholder. Normalised from either
// `content.{image,video,html}` (editor shape) or the legacy top-level `image`.
function GamingQuestionMedia({ content }) {
  if (!content) return null;
  const wrap = { width: '100%', maxHeight: 230, position: 'relative', flex: '0 1 auto' };
  if (content.html) {
    // Embedded HTML document — render the real file in an iframe so the
    // preview shows the actual sample (a phishy email, fake login, etc.),
    // not a placeholder. Fixed height for the static preview; the live
    // Player does its own dynamic iframe sizing at runtime.
    if (content.html.htmlUrl) {
      return (
        <div style={{ ...wrap, maxHeight: 300, height: 300, background: '#fff',
          borderRadius: 8, overflow: 'hidden', border: '1px solid rgba(0,0,0,.12)' }}>
          <iframe src={content.html.htmlUrl} className="htmlIframe" title="Embedded HTML media"
            aria-label={content.html.htmlAlt || 'Embedded HTML document'}
            style={{ width: '100%', height: '100%', border: 0, display: 'block', background: '#fff' }} />
        </div>
      );
    }
    return (
      <div style={{ ...wrap, aspectRatio: '21/9', background: '#fff', color: '#0f172a',
        display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
        gap: 8, overflow: 'hidden' }}>
        <I.FileText size={36} style={{ opacity: .5 }} />
        <div style={{ fontSize: 12, fontFamily: 'var(--font-mono)', opacity: .6 }}>
          HTML document
        </div>
      </div>
    );
  }
  if (content.video) {
    return (
      <div style={{ ...wrap, aspectRatio: '21/9', background: '#000' }}>
        <MockImage url={content.video.videoThumbUrl} label="Video"
          style={{ position: 'absolute', inset: 0 }} />
        <div style={{ position: 'absolute', inset: 0, display: 'flex',
          alignItems: 'center', justifyContent: 'center' }}>
          <div style={{ width: 50, height: 50, borderRadius: '50%', background: 'rgba(255,255,255,.9)',
            color: '#0f172a', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
            <I.Play size={20} />
          </div>
        </div>
      </div>
    );
  }
  if (content.image) {
    return (
      <div style={{ ...wrap, aspectRatio: '21/9' }}>
        <MockImage url={content.image.imageUrl} label="Question image"
          style={{ position: 'absolute', inset: 0 }} />
      </div>
    );
  }
  return null;
}

// QuizDiscoveryImage — the question image for a quiz_images "Hotspots" question.
// The pins are elements to FIND in the image: tapping one opens a reveal panel
// (title + rich body + optional image, or a video poster), mirroring the
// hidden_items flat-pin treatment. The pins are NOT answers — the question is
// answered via the answer boxes rendered below the image (§quiz_images).
function QuizDiscoveryImage({ image = {}, layout = {} }) {
  const items = image.items || [];
  const [open, setOpen] = React.useState(-1);
  const active = open >= 0 ? items[open] : null;
  const pinColor = image.itemColor || '#FF80FF';
  return (
    <div style={{ width: '100%', position: 'relative', flex: '0 1 auto', display: 'flex',
      justifyContent: 'center' }}>
      <div style={{ position: 'relative', width: '100%', maxWidth: 760, aspectRatio: '16/9',
        maxHeight: 300 }}>
        <MockImage url={image.imageUrl} label="Question image"
          style={{ position: 'absolute', inset: 0 }} />
        {items.map((it, i) => {
          const [x, y] = (it.itemPosition || '50,50').split(',').map(Number);
          const isOpen = open === i;
          return (
            <button key={it.id || i} onClick={() => setOpen(isOpen ? -1 : i)}
              title={stripCdata(it.itemTitle || `Hotspot ${i + 1}`)}
              style={{
                position: 'absolute', left: `${x}%`, top: `${y}%`,
                transform: 'translate(-50%, -50%)',
                width: 32, height: 32, borderRadius: '50%',
                background: isOpen ? pinColor : 'rgba(255,255,255,0.92)',
                border: `2px solid ${pinColor}`, color: isOpen ? '#fff' : pinColor,
                fontSize: 18, fontWeight: 700, lineHeight: 1, cursor: 'default',
                display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                boxShadow: isOpen ? '0 0 0 3px rgba(255,255,255,.5)' : '0 1px 3px rgba(0,0,0,.4)',
                zIndex: isOpen ? 3 : 2, transition: 'background 120ms, box-shadow 120ms',
              }}>+</button>
          );
        })}
        {active && (
          <div style={{
            position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
            boxSizing: 'border-box', width: 'min(440px, 84%)',
            background: image.bgColor || '#fff', borderWidth: 2, borderStyle: 'solid',
            borderColor: image.borderColor || pinColor, padding: '18px 22px',
            color: image.textColor || '#0f172a', boxShadow: '0 8px 24px rgba(0,0,0,.3)',
          }}>
            <h4 style={{ margin: '0 0 8px', fontSize: 16 }}>{stripCdata(active.itemTitle || '')}</h4>
            {(active.kind || 'text') === 'video' ? (
              <div style={{ position: 'relative', width: '100%', aspectRatio: '16/9', background: '#000' }}>
                <MockImage url={active.itemVideoThumbUrl} label="Hotspot video"
                  style={{ position: 'absolute', inset: 0 }} />
                <div style={{ position: 'absolute', inset: 0, display: 'flex',
                  alignItems: 'center', justifyContent: 'center' }}>
                  <div style={{ width: 42, height: 42, borderRadius: '50%', background: 'rgba(255,255,255,.9)',
                    color: '#0f172a', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
                    <I.Play size={18} />
                  </div>
                </div>
              </div>
            ) : (
              <>
                {active.itemText && (
                  <p style={{ margin: 0, fontSize: 14, lineHeight: 1.5 }}
                    dangerouslySetInnerHTML={{ __html: stripCdata(active.itemText) }} />
                )}
                {active.itemImageUrl && !String(active.itemImageUrl).startsWith('placeholder:') && (
                  <div style={{ position: 'relative', width: '100%', aspectRatio: '16/9', marginTop: 10 }}>
                    <MockImage url={active.itemImageUrl} label="Hotspot image"
                      style={{ position: 'absolute', inset: 0 }} />
                  </div>
                )}
              </>
            )}
            <button onClick={() => setOpen(-1)} style={{
              position: 'absolute', top: 8, right: 8, width: 24, height: 24,
              background: 'transparent', border: 0, cursor: 'default', color: 'inherit',
            }}><I.X size={14} /></button>
          </div>
        )}
      </div>
    </div>
  );
}

// SimulatedVideoPlayer — a clock-driven stand-in for the real <video> (the
// prototype has no real video files). Used by fullscreen_video / small_video
// in the interactive PreviewModal. Fires in-video interactions at their
// timeStarts (parseFloat — fractional times honoured, per the runtime patch):
//   • discover → a pulsing "+" marker in its [timeStarts, timeEnds) window;
//     click to open its description panel.
//   • question / mandatoryQuestion → pauses the clock, shows the question
//     overlay; answering resumes (mandatory requires the correct option).
// The clock runs accelerated (sim-seconds per real-second) so interactions at
// t=42s+ are reachable quickly; the scrubber lets you jump to any moment.
function SimulatedVideoPlayer({ posterUrl, label = 'Video', interactions = [] }) {
  const ivs = (interactions || []).filter(iv => iv && iv.timeStarts != null);
  const maxEnd = ivs.reduce((m, iv) => {
    const te = iv.timeEnds != null ? parseFloat(iv.timeEnds) : (parseFloat(iv.timeStarts) || 0) + 5;
    return Math.max(m, te);
  }, 0);
  const duration = Math.max(90, Math.ceil(maxEnd + 8));
  const RATE = 6; // simulated seconds per real second

  const [t, setT] = React.useState(0);
  const [playing, setPlaying] = React.useState(false);
  const [answered, setAnswered] = React.useState({});   // id → { pick, correct }
  const [wrongPick, setWrongPick] = React.useState({});  // id → idx (mandatory retry)
  const [openDiscover, setOpenDiscover] = React.useState(null);
  const rafRef = React.useRef(0);
  const lastRef = React.useRef(0);

  const activeQuestion = ivs.find(iv => {
    const isQ = iv.type === 'question' || iv.type === 'mandatoryQuestion';
    return isQ && t >= (parseFloat(iv.timeStarts) || 0) && !answered[iv.id];
  });
  const activeDiscovers = ivs.filter(iv => {
    if (iv.type !== 'discover') return false;
    const ts = parseFloat(iv.timeStarts) || 0;
    const te = iv.timeEnds != null ? parseFloat(iv.timeEnds) : ts + 5;
    return t >= ts && t < te;
  });
  const paused = !!activeQuestion;

  React.useEffect(() => {
    if (!playing || paused) { cancelAnimationFrame(rafRef.current); return; }
    lastRef.current = performance.now();
    const tick = (now) => {
      const dt = (now - lastRef.current) / 1000;
      lastRef.current = now;
      setT(prev => {
        const next = Math.min(duration, prev + dt * RATE);
        if (next >= duration) setPlaying(false);
        return next;
      });
      rafRef.current = requestAnimationFrame(tick);
    };
    rafRef.current = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(rafRef.current);
  }, [playing, paused, duration]);

  const fmt = s => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
  const pct = Math.min(100, (t / duration) * 100);
  const scrub = (e) => {
    const r = e.currentTarget.getBoundingClientRect();
    setT(Math.max(0, Math.min(duration, ((e.clientX - r.left) / r.width) * duration)));
  };
  const answer = (iv, idx) => {
    const opts = iv.options || iv.answers || [];
    const correct = !!(opts[idx] && opts[idx].isCorrect);
    if (iv.type === 'mandatoryQuestion' && !correct) {
      setWrongPick(w => ({ ...w, [iv.id]: idx })); return;
    }
    setAnswered(a => ({ ...a, [iv.id]: { pick: idx, correct } }));
    setWrongPick(w => { const n = { ...w }; delete n[iv.id]; return n; });
    setPlaying(true);
  };
  const discoverIv = openDiscover ? ivs.find(i => i.id === openDiscover) : null;

  return (
    <div style={{ width: '100%', height: '100%', position: 'relative', background: '#000', overflow: 'hidden' }}>
      <MockImage url={posterUrl} label={label} style={{ position: 'absolute', inset: 0 }} />

      {/* Discover markers — the "+" pin with its floating label (interactionText)
          shown above the video while the interaction is active. The label is
          the on-canvas caption; the descriptionTitle/Text only appear once the
          pin is opened (the panel below). */}
      {activeDiscovers.map(iv => (
        <div key={iv.id} style={{ position: 'absolute', top: '38%', right: '14%',
          display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
          {iv.interactionText && (
            <span style={{ padding: '3px 10px', borderRadius: 999, fontSize: 12, fontWeight: 600,
              background: 'rgba(0,0,0,.7)', color: '#fff', whiteSpace: 'nowrap',
              pointerEvents: 'none' }}>{stripCdata(iv.interactionText)}</span>
          )}
          <button onClick={() => { setOpenDiscover(iv.id); setPlaying(false); }}
            title={iv.descriptionTitle || 'Discover'}
            style={{ width: 40, height: 40,
              borderRadius: '50%', border: `2px solid ${iv.itemColor || '#3b82f6'}`,
              background: 'rgba(255,255,255,.92)', color: iv.itemColor || '#3b82f6',
              fontSize: 22, fontWeight: 700, cursor: 'default',
              display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
              boxShadow: '0 0 0 6px rgba(59,130,246,.25)' }}>+</button>
        </div>
      ))}

      {/* Discover description panel */}
      {discoverIv && (
        <div style={{ position: 'absolute', right: '8%', top: '20%', width: 280, zIndex: 6,
          background: '#fff', color: '#0f172a', borderRadius: 8, padding: '16px 18px',
          boxShadow: '0 12px 32px rgba(0,0,0,.4)' }}>
          <div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
            <strong style={{ flex: 1, fontSize: 14 }}>{discoverIv.descriptionTitle || 'Discover'}</strong>
            <button onClick={() => setOpenDiscover(null)}
              style={{ border: 0, background: 'transparent', cursor: 'default', color: '#64748b' }}>
              <I.X size={14} />
            </button>
          </div>
          <p style={{ margin: '8px 0 0', fontSize: 13, lineHeight: 1.5, color: '#475569' }}>
            {discoverIv.descriptionText || ''}
          </p>
        </div>
      )}

      {/* Centre play / pause (hidden while a question is up) */}
      {!activeQuestion && (
        <button onClick={() => setPlaying(p => !p)}
          aria-label={playing ? 'Pause' : 'Play'}
          style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)',
            width: 64, height: 64, borderRadius: '50%', cursor: 'default',
            background: 'rgba(255,255,255,.9)', color: '#0f172a', border: 0,
            display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
          {playing ? <I.Pause size={26} /> : <I.Play size={28} />}
        </button>
      )}

      {/* Question / mandatory overlay */}
      {activeQuestion && (() => {
        const iv = activeQuestion;
        const opts = iv.options || iv.answers || [];
        const mand = iv.type === 'mandatoryQuestion';
        return (
          <div style={{ position: 'absolute', inset: 0, background: 'rgba(8,12,24,.82)',
            display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 32, zIndex: 7 }}>
            <div style={{ width: '100%', maxWidth: 560 }}>
              <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
                {mand ? <I.Lock size={14} style={{ color: '#fbbf24' }} />
                  : <I.AlertCircle size={14} style={{ color: '#60a5fa' }} />}
                <span style={{ fontSize: 11.5, color: 'rgba(255,255,255,.7)', textTransform: 'uppercase',
                  letterSpacing: '.06em', fontWeight: 600 }}>
                  {mand ? 'Mandatory question' : 'Question'} · {fmt(parseFloat(iv.timeStarts) || 0)}
                </span>
              </div>
              <p style={{ margin: '0 0 16px', fontSize: 18, color: '#fff', lineHeight: 1.4 }}>
                {stripCdata(iv.text || 'Question')}
              </p>
              <div style={{ display: 'grid', gap: 10 }}>
                {opts.map((o, i) => {
                  const wrong = wrongPick[iv.id] === i;
                  return (
                    <button key={i} onClick={() => answer(iv, i)}
                      style={{ textAlign: 'left', padding: '14px 18px', fontSize: 15, fontFamily: 'inherit',
                        background: wrong ? 'rgba(248,113,113,.25)' : 'rgba(255,255,255,.06)',
                        color: '#fff', border: `1px solid ${wrong ? '#f87171' : 'rgba(255,255,255,.5)'}`,
                        borderRadius: 6, cursor: 'default' }}>
                      {stripCdata(o.text || `Option ${i + 1}`)}
                    </button>
                  );
                })}
              </div>
              {mand && Object.prototype.hasOwnProperty.call(wrongPick, iv.id) && (
                <div style={{ marginTop: 12, fontSize: 13, color: '#fca5a5',
                  display: 'flex', alignItems: 'center', gap: 6 }}>
                  <I.X size={13} />Not quite — this one must be answered correctly to continue.
                </div>
              )}
            </div>
          </div>
        );
      })()}

      {/* Controls bar */}
      <div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '10px 16px',
        display: 'flex', alignItems: 'center', gap: 12, color: '#fff',
        background: 'linear-gradient(to top, rgba(0,0,0,.78), transparent)' }}>
        <button onClick={() => setPlaying(p => !p)} disabled={paused}
          style={{ border: 0, background: 'transparent', color: '#fff', cursor: 'default',
            opacity: paused ? 0.4 : 1, display: 'inline-flex' }}>
          {playing ? <I.Pause size={18} /> : <I.Play size={18} />}
        </button>
        <span style={{ fontSize: 12, fontFamily: 'var(--font-mono)' }}>{fmt(t)}</span>
        <div onClick={scrub} style={{ flex: 1, height: 6, background: 'rgba(255,255,255,.25)',
          borderRadius: 3, cursor: 'default', position: 'relative' }}>
          <div style={{ width: `${pct}%`, height: '100%', background: '#fff', borderRadius: 3 }} />
          {/* interaction tick marks */}
          {ivs.map(iv => {
            const left = ((parseFloat(iv.timeStarts) || 0) / duration) * 100;
            const c = iv.type === 'discover' ? '#60a5fa'
              : iv.type === 'mandatoryQuestion' ? '#fbbf24' : '#f472b6';
            return <div key={iv.id} title={iv.type} style={{ position: 'absolute',
              left: `${left}%`, top: -2, width: 3, height: 10, background: c, borderRadius: 2 }} />;
          })}
        </div>
        <span style={{ fontSize: 12, fontFamily: 'var(--font-mono)', opacity: .8 }}>{fmt(duration)}</span>
      </div>
    </div>
  );
}

// QuizFeedback — the post-submit feedback panel (coloured Correct/Incorrect
// header + feedback body) shown in the interactive quiz renderers.
function QuizFeedback({ correct, text }) {
  return (
    <div style={{ borderRadius: 8, overflow: 'hidden',
      border: `1px solid ${correct ? '#22c55e' : '#f87171'}` }}>
      <div style={{ padding: '8px 14px', fontWeight: 600, fontSize: 14, color: '#06210f',
        background: correct ? '#22c55e' : '#f87171',
        display: 'flex', alignItems: 'center', gap: 8 }}>
        {correct ? <I.Check size={15} /> : <I.X size={15} />}
        {correct ? 'Correct' : 'Incorrect'}
      </div>
      <div style={{ padding: '12px 14px', fontSize: 14, lineHeight: 1.5,
        background: 'rgba(255,255,255,.06)' }}
        dangerouslySetInnerHTML={{ __html: stripCdata(text) }} />
    </div>
  );
}

const LAYOUT_RENDERERS = {

  // 3.1 ─ fullscreen_text_and_image
  fullscreen_text_and_image(layout) {
    const useColor = layout.backgroundMode === 'color' || !layout.backgroundImageUrl;
    return (
      <div style={{ position: 'relative', height: '100%', overflow: 'hidden',
        display: 'flex', flexDirection: 'column',
        background: useColor ? (layout.backgroundColor || '#0f172a') : undefined }}>
        {!useColor && (
          <>
            <MockImage url={layout.backgroundImageUrl} label="Background"
              style={{ position: 'absolute', inset: 0 }} />
            <div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.35)' }} />
          </>
        )}
        <div style={{ position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column',
          alignItems: 'center', justifyContent: 'center', textAlign: 'center', padding: '48px',
          color: layout.titleTextColor || '#fff' }}>
          <h1 style={{ margin: '0 0 16px', fontSize: 40, fontWeight: 500, color: layout.titleTextColor || '#fff' }}>
            {layout.titleMain}
          </h1>
          <p style={{ margin: 0, fontSize: 16, color: layout.textColor || '#fff', maxWidth: 720, lineHeight: 1.5 }}
             dangerouslySetInnerHTML={{ __html: stripCdata(layout.text || '') }} />
        </div>
        <PlayerBottomCue gate={layout.blockingSection} color="rgba(255,255,255,.7)" />
      </div>
    );
  },

  // 3.2 ─ fullscreen_video
  fullscreen_video(layout) {
    const interactive = React.useContext(PreviewInteractiveContext);
    return (
      <div style={{ background: layout.contentBackgroundColor || '#000', height: '100%',
        display: 'flex', flexDirection: 'column' }}>
        <PlayerTitleBar {...layout} />
        <div style={{ flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0 48px' }}>
          <div style={{ width: '100%', maxWidth: 960, aspectRatio: '16/9', position: 'relative' }}>
            {interactive ? (
              <SimulatedVideoPlayer posterUrl={layout.videoThumbUrl} label="Video poster"
                interactions={layout.interactions} />
            ) : (
            <>
            <MockImage url={layout.videoThumbUrl} label="Video poster"
              style={{ position: 'absolute', inset: 0 }} />
            <div style={{ position: 'absolute', inset: 0, display: 'flex',
              alignItems: 'center', justifyContent: 'center' }}>
              <div style={{
                width: 64, height: 64, borderRadius: '50%',
                background: 'rgba(255,255,255,.9)', color: '#0f172a',
                display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
              }}><I.Play size={28} /></div>
            </div>
            {/* sub bar */}
            <div style={{ position: 'absolute', bottom: 0, left: 0, right: 0,
              padding: '8px 14px', color: 'rgba(255,255,255,.85)', fontSize: 11,
              display: 'flex', alignItems: 'center', gap: 8,
              background: 'linear-gradient(to top, rgba(0,0,0,.7), transparent)' }}>
              <I.Play size={11} /><span>00:00</span>
              <div style={{ flex: 1, height: 3, background: 'rgba(255,255,255,.25)', borderRadius: 2 }}>
                <div style={{ width: '0%', height: '100%', background: '#fff', borderRadius: 2 }} />
              </div>
              <span>04:32</span>
            </div>
            {/* In-video interactions */}
            {(layout.interactions || []).map((iv, i) => (
              <div key={i} style={{
                position: 'absolute', top: '40%', right: 80,
                display: 'flex', alignItems: 'center', gap: 6,
              }}>
                <div style={{ width: 24, height: 24, borderRadius: '50%',
                  border: `2px solid ${iv.itemColor || '#3b82f6'}`,
                  background: 'rgba(255,255,255,0.9)',
                  display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                  color: iv.itemColor || '#3b82f6', fontWeight: 700 }}>+</div>
              </div>
            ))}
            </>
            )}
          </div>
        </div>
        <PlayerBottomCue gate={layout.blockingSection} color={layout.blockingSection?.textColor || 'rgba(255,255,255,.7)'} />
      </div>
    );
  },

  // 3.3 ─ text_and_image (rows)
  text_and_image(layout) {
    return (
      <div style={{ background: '#ffffff', height: '100%', display: 'flex', flexDirection: 'column', overflow: 'auto' }}>
        <PlayerTitleBar {...layout} titleTextColor={layout.titleTextColor || '#0f172a'} />
        <div style={{ display: 'flex', flexDirection: 'column' }}>
          {(layout.rows || []).map((row, i) => (
            <div key={i} style={{
              display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0,
              background: row.backgroundColor || '#fff',
              minHeight: 240,
            }}>
              {row.imageOnTheLeft ? (
                <>
                  <MockImage url={row.imageUrl || row.videoThumbUrl} label={row.videoUrl ? 'Video' : 'Image'} style={{ aspectRatio: '4/3' }} />
                  <div style={{ display: 'flex', alignItems: 'center', padding: '24px 32px' }}>
                    <p style={{ margin: 0, fontSize: 16, lineHeight: 1.6, color: row.textColor || '#0f172a' }}
                       dangerouslySetInnerHTML={{ __html: stripCdata(row.text || '') }} />
                  </div>
                </>
              ) : (
                <>
                  <div style={{ display: 'flex', alignItems: 'center', padding: '24px 32px' }}>
                    <p style={{ margin: 0, fontSize: 16, lineHeight: 1.6, color: row.textColor || '#0f172a' }}
                       dangerouslySetInnerHTML={{ __html: stripCdata(row.text || '') }} />
                  </div>
                  <MockImage url={row.imageUrl || row.videoThumbUrl} label={row.videoUrl ? 'Video' : 'Image'} style={{ aspectRatio: '4/3' }} />
                </>
              )}
            </div>
          ))}
        </div>
        <PlayerBottomCue gate={layout.blockingSection} color="rgba(0,0,0,.5)" />
      </div>
    );
  },

  // 3.4 ─ small_video
  small_video(layout) {
    const interactive = React.useContext(PreviewInteractiveContext);
    return (
      <div style={{ background: layout.contentBackgroundColor || '#f1f5f9', height: '100%',
        display: 'flex', flexDirection: 'column' }}>
        <PlayerTitleBar {...layout} titleTextColor={layout.titleTextColor || '#0f172a'} />
        <div style={{ flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0 48px 24px' }}>
          <div style={{ width: '100%', maxWidth: 640, aspectRatio: '16/9', position: 'relative',
            background: '#000', boxShadow: '0 8px 24px rgba(0,0,0,.15)' }}>
            {interactive ? (
              <SimulatedVideoPlayer posterUrl={layout.videoThumbUrl} label="Video"
                interactions={layout.interactions} />
            ) : (
            <>
            <MockImage url={layout.videoThumbUrl} label="Video"
              style={{ position: 'absolute', inset: 0 }} />
            <div style={{ position: 'absolute', inset: 0, display: 'flex',
              alignItems: 'center', justifyContent: 'center' }}>
              <div style={{
                width: 56, height: 56, borderRadius: '50%',
                background: 'rgba(255,255,255,.9)', color: '#0f172a',
                display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
              }}><I.Play size={24} /></div>
            </div>
            </>
            )}
          </div>
        </div>
        <PlayerBottomCue gate={layout.blockingSection} color="rgba(0,0,0,.5)" />
      </div>
    );
  },

  // 3.5 ─ sequence
  sequence(layout) {
    const [activeIdx, setActiveIdx] = React.useState(0);
    const tabs = layout.tabs || [];
    /* The inspector (SequenceEditor3) dispatches `dynamo:seq-active`
       whenever its active step row changes. We mirror that here so the
       Module preview literally follows whatever step the author is
       editing — instead of the author having to click the small step
       dots inside the scaled-down preview frame. */
    React.useEffect(() => {
      const handler = (e) => {
        const idx = e?.detail?.idx;
        if (typeof idx === 'number' && idx >= 0) setActiveIdx(idx);
      };
      window.addEventListener('dynamo:seq-active', handler);
      return () => window.removeEventListener('dynamo:seq-active', handler);
    }, []);
    /* Guard the active idx against tabs being shortened (e.g. the
       author just deleted the currently-selected step and its pair). */
    const safeIdx = Math.min(activeIdx, Math.max(0, tabs.length - 1));
    const active = tabs[safeIdx] || {};
    return (
      <div style={{ background: layout.contentBackgroundColor || '#000', height: '100%',
        display: 'flex', flexDirection: 'column', color: '#fff' }}>
        <PlayerTitleBar {...layout} />
        <div style={{ flex: 1, minHeight: 0, overflow: 'hidden',
          display: 'flex', flexDirection: 'column',
          padding: '0 48px 24px' }}>
          {/* Tab content — fills the available vertical space; the step
              dots sit pinned below this row, so a long text body never
              pushes them out of view. */}
          <div style={{ flex: 1, minHeight: 0, display: 'flex',
            flexDirection: 'column', alignItems: 'center',
            justifyContent: 'center', overflow: 'hidden' }}>
            <div style={{ width: '100%', maxWidth: 880, display: 'grid', gap: 16 }}>
              {/* Video step — thumbnail fills the canvas with a centred
                  play button. Shares the same 16:9 shell as Text and
                  Feedback so the four step kinds read as siblings. */}
              {active.kind === 'video' && (
                <StepCanvas imageUrl={active.videoThumbUrl}
                  mediaLabel="Video" fallbackLabel="No video">
                  <div style={{ position: 'absolute', inset: 0, display: 'flex',
                    alignItems: 'center', justifyContent: 'center' }}>
                    <div style={{ width: 64, height: 64, borderRadius: '50%',
                      background: 'rgba(255,255,255,.92)', color: '#0f172a',
                      display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                      boxShadow: '0 8px 24px rgba(0,0,0,.35)' }}>
                      <I.Play size={26} />
                    </div>
                  </div>
                </StepCanvas>
              )}
              {/* Text + Image step — the image is the canvas, with a
                  white text card floating near the bottom (not flush).
                  Matches the authoring intent: image is the scene, text
                  rides on top of it as a caption-style overlay. */}
              {active.kind === 'text' && (
                <StepCanvas imageUrl={active.imageUrl}>
                  {(active.tabText || active.text) && (
                    <StepBottomCard style={{ lineHeight: 1.55, fontSize: 14, maxHeight: '52%' }}>
                      <div dangerouslySetInnerHTML={{
                        __html: stripCdata(active.tabText || active.text) }} />
                    </StepBottomCard>
                  )}
                </StepCanvas>
              )}
              {active.kind === 'question' && (
                <SequenceQuestionPreview tab={active} layout={layout}
                  stepCount={tabs.length} stepIdx={safeIdx} onSelectStep={setActiveIdx} />
              )}
              {active.kind === 'feedback' && (() => {
                /* Feedback resolves its body from the preceding Question
                   step's per-answer feedback. The preview lets the
                   author toggle between "correct" and "wrong" to see
                   both states — matches how the player picks one based
                   on the learner's answer. */
                let prevQ = null;
                for (let j = safeIdx - 1; j >= 0; j--) {
                  if (tabs[j].kind === 'question') { prevQ = tabs[j]; break; }
                }
                return <SequenceFeedbackPreview tab={active} prevQ={prevQ} layout={layout} />;
              })()}
            </div>
          </div>
          {/* Step dots — pinned below the content row so they're visible
              regardless of which step kind is selected (a tall text body
              used to push them off the bottom of the preview). The
              question step renders its OWN in-canvas footer (dots +
              Confirm) to match the canonical player visual, so the shared
              dots are suppressed for it to avoid a duplicate row. */}
          {active.kind !== 'question' && (
            <div style={{ paddingTop: 12, flex: '0 0 auto' }}>
              <StepDots count={tabs.length} activeIdx={safeIdx}
                onSelect={setActiveIdx} selectedColor={layout.dotSelectedColor || '#FFD166'} />
            </div>
          )}
        </div>
        <PlayerBottomCue gate={layout.blockingSection} color="rgba(255,255,255,.7)" />
      </div>
    );
  },

  // 3.6 ─ horizontal_tabs
  horizontal_tabs(layout) {
    const [active, setActive] = React.useState(1);
    const tabs = layout.tabs || [];
    const sel = tabs[active] || tabs[0] || {};
    return (
      <div style={{ background: '#fff', height: '100%', display: 'flex', flexDirection: 'column', overflow: 'auto' }}>
        <PlayerTitleBar {...layout} titleTextColor={layout.titleTextColor || '#0f172a'} />
        <div style={{ flex: 1, minHeight: 0, overflow: 'hidden', padding: '0 48px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
          {/* Tab strip with arrows */}
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, justifyContent: 'center' }}>
            <button style={{ width: 40, height: 56, background: layout.selectedBgColor || '#D32F2F',
              color: '#fff', border: 0, cursor: 'default',
              display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
              <I.ChevronLeft size={18} />
            </button>
            {tabs.map((t, i) => (
              <button key={i} onClick={() => setActive(i)}
                style={{
                  flex: 1, maxWidth: 200, padding: '14px 18px',
                  background: i === active ? (layout.selectedBgColor || '#D32F2F') : '#f1f5f9',
                  color: i === active ? (layout.selectedTextColor || '#fff') : (layout.tabTextColor || '#475569'),
                  border: 0, fontSize: 14, fontWeight: 500, fontFamily: 'inherit', cursor: 'default',
                  whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden',
                }}>{t.tabTitle}</button>
            ))}
            <button style={{ width: 40, height: 56, background: layout.selectedBgColor || '#D32F2F',
              color: '#fff', border: 0, cursor: 'default',
              display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
              <I.ChevronRight size={18} />
            </button>
          </div>
          {/* Body */}
          <div>
            <p style={{ margin: '0 0 16px', fontSize: 15, color: '#0f172a', lineHeight: 1.6 }}
               dangerouslySetInnerHTML={{ __html: stripCdata(sel.tabText || '') }} />
            {(sel.imageUrl || sel.videoThumbUrl) && (
              <MockImage url={sel.imageUrl || sel.videoThumbUrl} label={sel.videoUrl ? 'Video' : 'Image'}
                style={{ width: '100%', aspectRatio: '16/9' }} />
            )}
          </div>
        </div>
        <PlayerBottomCue gate={layout.blockingSection} color="rgba(0,0,0,.6)" />
      </div>
    );
  },

  // 3.7 ─ vertical_tabs
  vertical_tabs(layout) {
    const [active, setActive] = React.useState(0);
    const tabs = layout.tabs || [];
    const sel = tabs[active] || {};
    const bg = layout.contentBackgroundColor || '#B8932D';
    return (
      <div style={{ background: bg, height: '100%', display: 'flex', flexDirection: 'column' }}>
        <PlayerTitleBar {...layout} titleTextColor={layout.titleTextColor || '#0f172a'} />
        <div style={{ flex: 1, minHeight: 0, overflow: 'hidden', padding: '0 64px 32px', display: 'grid',
          gridTemplateColumns: '260px 1fr', gap: 24 }}>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
            {tabs.map((t, i) => (
              <button key={i} onClick={() => setActive(i)}
                style={{
                  padding: '14px 16px', fontSize: 14, textAlign: 'left',
                  background: i === active ? '#3F4E66' : '#ffffff',
                  color: i === active ? '#ffffff' : '#0f172a',
                  border: 0, fontFamily: 'inherit', cursor: 'default',
                }}>{t.tabTitle}</button>
            ))}
          </div>
          <div style={{ background: '#ffffff', padding: '24px 32px' }}>
            <h3 style={{ margin: '0 0 12px', fontSize: 18, fontWeight: 500, color: '#0f172a' }}>
              {sel.tabTitle}
            </h3>
            <div style={{ fontSize: 15, lineHeight: 1.6, color: '#0f172a' }}
              dangerouslySetInnerHTML={{ __html: stripCdata(sel.tabText || '') }} />
          </div>
        </div>
        <PlayerBottomCue gate={layout.blockingSection} color="rgba(0,0,0,.6)" />
      </div>
    );
  },

  // 3.8 ─ icons_discover
  icons_discover(layout) {
    const [active, setActive] = React.useState(3);
    const icons = layout.icons || [];
    const sel = icons[active] || icons[0] || {};
    const bg = layout.contentBackgroundColor || '#3F4E66';
    return (
      <div style={{ background: bg, height: '100%', display: 'flex', flexDirection: 'column', color: '#fff' }}>
        <PlayerTitleBar {...layout} />
        <div style={{ flex: 1, minHeight: 0, overflow: 'hidden', padding: '0 64px 32px', display: 'grid',
          gridTemplateColumns: '1fr 1fr', gap: 32, alignItems: 'center' }}>
          <div style={{ fontSize: 15, lineHeight: 1.6, color: '#fff' }}
            dangerouslySetInnerHTML={{ __html: stripCdata(sel.text || '') }} />
          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
            {icons.map((ic, i) => (
              <button key={i} onClick={() => setActive(i)}
                style={{
                  aspectRatio: '2/1', padding: 16,
                  background: i === active ? (layout.selectedBgColor || '#B8932D') : 'transparent',
                  border: `1px solid ${i === active ? (layout.selectedBgColor || '#B8932D') : '#ffffff'}`,
                  color: '#ffffff', cursor: 'default',
                  display: 'flex', alignItems: 'center', justifyContent: 'center',
                  fontFamily: 'inherit',
                }}>
                <IconGlyph variant={i} />
              </button>
            ))}
          </div>
        </div>
        <PlayerBottomCue gate={layout.blockingSection} color="rgba(255,255,255,.7)" />
      </div>
    );
  },

  // 3.9 ─ hidden_items (flat + 360° variants share one renderer)
  //
  // Type was split into `hidden_items_flat_image` and
  // `hidden_items_360_image` at the dropdown level; the renderer keeps
  // a single implementation that picks the image source from whichever
  // field the editor wrote. `hidden_items` remains as a legacy alias.
  hidden_items(layout) {
    /* hidden_items is a single canonical type (Fix 4); the flat vs 360° variant
       is encoded by which media URL is populated — never by `layout.type`. */
    const is360 = Boolean(layout.image360Url);
    const items = layout.items || [];
    const [activeIdx, setActiveIdx] = React.useState(-1);
    const interactive = React.useContext(PreviewInteractiveContext);
    // Per-hotspot mini-quiz state, retained while this layout stays mounted so
    // reopening a hotspot whose quiz was answered restores its state (Fix 6 ·
    // matches the runtime's ignoreProgress:false patch). Navigating to another
    // layout unmounts the renderer → state resets (Fix 2 / scenario 10).
    const [quiz, setQuiz] = React.useState({}); // { [idx]: { pick, submitted } }
    const active = activeIdx >= 0 ? items[activeIdx] : null;
    const bg = layout.contentBackgroundColor || '#5514B4';
    return (
      <div style={{ background: bg, height: '100%', display: 'flex', flexDirection: 'column' }}>
        <PlayerTitleBar {...layout} />
        <div style={{ flex: 1, minHeight: 0, overflow: 'hidden', position: 'relative', padding: '0 48px 24px',
          display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
          <div style={{ position: 'relative', width: '100%', maxWidth: 960, aspectRatio: '16/9' }}>
            <MockImage url={is360 ? layout.image360Url : layout.imageUrl}
              label={is360 ? '360° panorama' : 'Background'}
              style={{ position: 'absolute', inset: 0 }} />
            {items.map((it, i) => {
              const [x, y] = (it.itemPosition || '50,50').split(',').map(Number);
              return (
                <button key={i} onClick={() => setActiveIdx(activeIdx === i ? -1 : i)}
                  style={{
                    position: 'absolute', left: `${x}%`, top: `${y}%`, transform: 'translate(-50%, -50%)',
                    width: 32, height: 32, borderRadius: '50%',
                    background: 'rgba(255,255,255,0.9)',
                    border: `2px solid ${layout.itemColor || '#FF80FF'}`,
                    color: layout.itemColor || '#FF80FF',
                    fontSize: 18, fontWeight: 600, lineHeight: 1, cursor: 'default',
                    display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                  }}>+</button>
              );
            })}
            {active && (
              <div style={{
                position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
                boxSizing: 'border-box',
                width: 'min(480px, 80%)',
                background: layout.bgColor || '#fff',
                borderWidth: 2, borderStyle: 'solid',
                borderColor: layout.borderColor || '#FF80FF',
                padding: '20px 24px', color: layout.textColor || '#0f172a',
                boxShadow: '0 8px 24px rgba(0,0,0,.3)',
              }}>
                <h4 style={{ margin: '0 0 8px', fontSize: 17 }}>{active.itemTitle}</h4>
                <p style={{ margin: 0, fontSize: 14, lineHeight: 1.5 }}
                  dangerouslySetInnerHTML={{ __html: stripCdata(active.itemText) }} />
                {(() => {
                  const opts = active.answers || active.options;
                  if (!opts || !opts.length) return null;
                  const st = quiz[activeIdx] || { pick: -1, submitted: false };
                  const setSt = (patch) => setQuiz(qz => ({
                    ...qz, [activeIdx]: { ...(qz[activeIdx] || { pick: -1, submitted: false }), ...patch } }));
                  const picked = opts[st.pick];
                  const correct = !!(picked && picked.isCorrect);
                  return (
                    <div style={{ marginTop: 12, display: 'grid', gap: 8 }}>
                      {opts.map((o, oi) => {
                        const sel = oi === st.pick;
                        const reveal = interactive && st.submitted;
                        const ok = reveal && o.isCorrect;
                        const bad = reveal && sel && !o.isCorrect;
                        return (
                          <button key={oi}
                            onClick={() => { if (interactive && st.submitted) return; setSt({ pick: oi }); }}
                            style={{ display: 'flex', alignItems: 'center', gap: 8, textAlign: 'left',
                              padding: '9px 12px', fontSize: 13.5, fontFamily: 'inherit', cursor: 'default',
                              background: ok ? 'rgba(34,197,94,.16)' : bad ? 'rgba(248,113,113,.16)'
                                : sel ? 'rgba(85,20,180,.1)' : 'transparent',
                              border: `1px solid ${ok ? '#22c55e' : bad ? '#f87171' : sel ? (layout.borderColor || '#FF80FF') : 'var(--border-strong)'}`,
                              borderRadius: 6, color: 'inherit' }}>
                            <span style={{ flex: 1 }}>{stripCdata(o.text || `Option ${oi + 1}`)}</span>
                            {ok && <I.Check size={14} style={{ color: '#16a34a' }} />}
                            {bad && <I.X size={14} style={{ color: '#dc2626' }} />}
                          </button>
                        );
                      })}
                      {interactive && !st.submitted && (
                        <button onClick={() => { if (st.pick >= 0) setSt({ submitted: true }); }}
                          style={{ justifySelf: 'start', padding: '7px 18px', fontSize: 13,
                            background: st.pick >= 0 ? (layout.borderColor || '#FF80FF') : 'var(--surface-inset)',
                            color: st.pick >= 0 ? '#fff' : 'var(--text-faint)',
                            border: 0, borderRadius: 6, cursor: 'default', fontFamily: 'inherit' }}>
                          Submit
                        </button>
                      )}
                      {interactive && st.submitted && (
                        <div style={{ fontSize: 12.5, lineHeight: 1.45,
                          color: correct ? '#15803d' : '#b91c1c',
                          display: 'flex', gap: 6, alignItems: 'flex-start' }}>
                          {correct ? <I.Check size={13} style={{ marginTop: 2 }} /> : <I.X size={13} style={{ marginTop: 2 }} />}
                          <span>{picked?.feedback || (correct ? 'Correct.' : 'Not quite.')}</span>
                        </div>
                      )}
                    </div>
                  );
                })()}
                <button onClick={() => setActiveIdx(-1)} style={{
                  position: 'absolute', top: 8, right: 8, width: 24, height: 24,
                  background: 'transparent', border: 0, cursor: 'default', color: 'inherit',
                }}><I.X size={14} /></button>
              </div>
            )}
          </div>
        </div>
        <PlayerBottomCue gate={layout.blockingSection} color="rgba(255,255,255,.7)" />
      </div>
    );
  },

  // 3.10 ─ quiz_images
  // Shares the SAME on-screen layout as quiz_gaming (§26) — a top counter row,
  // a divider, GamingQuestionMedia, and left-aligned answer boxes in a single
  // row — MINUS the two gaming-only families: the Scoring chrome (the running
  // correct/wrong score header) and the Screens chrome (the Start/Win/Fail step
  // bookend dots + the win/fail end screen). quiz_images is a plain question
  // sequence with feedback, not tied to completion, so the counter reads
  // "Question X of N" (no gaming "Challenge"/"DFTI" vocabulary — §23.2).
  // In the editor's Module preview the answers only highlight (interactive
  // context false). Inside the full-viewport PreviewModal (context true) the
  // Submit button advances to a feedback view and per-question answer state is
  // retained so revisiting a question via the dots restores its saved state.
  quiz_images(layout) {
    const interactive = React.useContext(PreviewInteractiveContext);
    const [qIdx, setQIdx] = React.useState(0);
    const [state, setState] = React.useState({}); // { [i]: { pick, picks, submitted } }
    const questions = layout.questions || [];
    const count = questions.length;
    const safeQIdx = Math.min(qIdx, Math.max(0, count - 1));
    const q = questions[safeQIdx] || questions[0] || {};
    const content = q.content
      || (q.image ? { image: q.image }
        : q.video ? { video: q.video }
        : q.html ? { html: q.html } : {});
    // Hotspot questions show the question image with DISCOVERY pins (tap to
    // reveal a context panel — they are NOT answers). Every question, hotspot
    // or not, is answered via the answer boxes below (q.answers). §quiz_images.
    const hotspotItems = (content.image && Array.isArray(content.image.items)
      && content.image.items.length) ? content.image.items : null;
    const isHotspot = !!hotspotItems;
    const answers = q.answers || [];
    const correctCount = answers.filter(a => a.isCorrect).length;
    const multi = correctCount >= 2;
    const accent = layout.dotSelectedColor || '#F26430';
    const cur = state[safeQIdx] || { pick: -1, picks: [], submitted: false };
    const isLast = safeQIdx === count - 1;
    const setCur = (patch) => setState(s => ({
      ...s, [safeQIdx]: { ...(s[safeQIdx] || { pick: -1, picks: [], submitted: false }), ...patch } }));
    const onAnswer = (i) => {
      if (interactive && cur.submitted) return;
      if (multi) {
        const has = (cur.picks || []).includes(i);
        setCur({ picks: has ? cur.picks.filter(p => p !== i) : [...(cur.picks || []), i] });
      } else {
        setCur({ pick: i });
      }
    };
    // Evaluate the answer + derive feedback. Multi-correct: right only when every
    // correct pin is picked and no wrong one is; feedback is the shared pair.
    // Single-correct: the picked answer's own feedback (§27).
    let isCorrect, feedbackText, canSubmit;
    if (multi) {
      const picks = cur.picks || [];
      const correctIdx = answers.map((a, i) => (a.isCorrect ? i : -1)).filter(i => i >= 0);
      isCorrect = correctIdx.length > 0 && correctIdx.every(i => picks.includes(i))
        && picks.every(i => answers[i] && answers[i].isCorrect);
      feedbackText = isCorrect ? (q.feedbacks?.correct || 'Correct.') : (q.feedbacks?.wrong || 'Not quite.');
      canSubmit = picks.length > 0;
    } else {
      const picked = answers[cur.pick];
      isCorrect = !!(picked && picked.isCorrect);
      feedbackText = picked?.feedback || (isCorrect ? 'Correct.' : 'Not quite.');
      canSubmit = cur.pick >= 0;
    }
    const onSubmit = () => { if (!canSubmit) return; if (interactive) setCur({ submitted: true }); };
    const onNext = () => { if (!isLast) setQIdx(safeQIdx + 1); };

    return (
      <div style={{ background: layout.contentBackgroundColor || '#000', color: layout.contentTextColor || '#fff',
        height: '100%', display: 'flex', flexDirection: 'column' }}>
        <PlayerTitleBar {...layout} />
        <div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex',
          flexDirection: 'column', padding: '0 48px 16px' }}>
          {/* No top "Question X of N" counter — that counter row is gaming-only
              (quiz_gaming). quiz_images keeps just the divider as a separator
              between the player title and the question body. */}
          <div style={{ height: 1, background: 'rgba(255,255,255,.35)', margin: '4px 0 18px' }} />
          <div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column',
            gap: 16, overflow: 'hidden' }}>
            <p style={{ margin: 0, fontSize: 16, lineHeight: 1.45 }}>
              {stripCdata(q.text || '')}
            </p>
            {isHotspot ? (
              <QuizDiscoveryImage key={safeQIdx} image={content.image} layout={layout} />
            ) : (
              <GamingQuestionMedia content={content} />
            )}
            <div style={{ display: 'grid',
              gridTemplateColumns: `repeat(${Math.max(answers.length, 1)}, 1fr)`, gap: 16 }}>
              {answers.map((a, i) => {
                const sel = multi ? (cur.picks || []).includes(i) : i === cur.pick;
                const submitted = interactive && cur.submitted;
                const revealCorrect = submitted && a.isCorrect;
                const revealWrong = submitted && sel && !a.isCorrect;
                const bg = revealCorrect ? 'rgba(34,197,94,.25)'
                  : revealWrong ? 'rgba(248,113,113,.25)'
                  : sel ? (layout.selectedBgColor || accent) : 'transparent';
                const bd = revealCorrect ? '#22c55e' : revealWrong ? '#f87171' : 'rgba(255,255,255,.55)';
                return (
                  <button key={i} onClick={() => onAnswer(i)}
                    style={{ display: 'flex', alignItems: 'center', gap: 8, textAlign: 'left',
                      padding: '16px 20px', fontSize: 15, fontFamily: 'inherit', lineHeight: 1.3,
                      background: bg,
                      color: sel && !submitted ? (layout.selectedTextColor || '#fff') : '#fff',
                      border: `1px solid ${bd}`, cursor: 'default' }}>
                    <span style={{ flex: 1 }}>{stripCdata(a.text || '')}</span>
                    {revealCorrect && <I.Check size={16} style={{ color: '#22c55e' }} />}
                    {revealWrong && <I.X size={16} style={{ color: '#f87171' }} />}
                  </button>
                );
              })}
            </div>
            {interactive && cur.submitted && (
              <QuizFeedback correct={isCorrect} text={feedbackText} />
            )}
          </div>
          {/* Step strip — plain numbered dots, NO Start/Win/Fail bookends
              (those are Screens, exclusive to quiz_gaming). */}
          <div style={{ position: 'relative', flex: '0 0 auto', paddingTop: 14 }}>
            <StepDots count={count} activeIdx={safeQIdx}
              onSelect={(i) => setQIdx(i)}
              selectedColor={accent} />
            {(() => {
              const showNext = interactive && cur.submitted && !isLast;
              const enabled = showNext || (!cur.submitted && canSubmit) || (!interactive && canSubmit);
              const label = showNext ? 'Next question' : 'Submit';
              const action = showNext ? onNext : onSubmit;
              if (interactive && cur.submitted && isLast) return null;
              return (
                <button onClick={enabled ? action : undefined}
                  style={{
                    position: 'absolute', right: 0, top: '50%', transform: 'translateY(-50%)',
                    padding: '10px 36px', fontSize: 14,
                    background: enabled ? '#fff' : 'rgba(255,255,255,.2)',
                    color: enabled ? '#0f172a' : 'rgba(255,255,255,.6)',
                    border: '1px solid rgba(255,255,255,.5)', cursor: 'default', fontFamily: 'inherit',
                  }}>{label}</button>
              );
            })()}
          </div>
        </div>
        <PlayerBottomCue gate={layout.blockingSection} color="rgba(255,255,255,.7)" />
      </div>
    );
  },

  // 3.10b ─ quiz_gaming · the gamified quiz. Distinct runtime chrome from the
  // plain quiz_images carousel: a "Challenge X of N" counter (top-left, accent),
  // a running correct/wrong score header (top-right, icon + number), a divider,
  // left-aligned answer boxes laid out in a single row, and a step strip that
  // gains a Start dot (Flag) at the head and a Win/Fail dot (Trophy) at the tail
  // to stand in for the start + end screens. This mirrors the live player; it is
  // NOT the quiz_images carousel. Author-facing copy never says "DFTI" (§23.2).
  quiz_gaming(layout) {
    const interactive = React.useContext(PreviewInteractiveContext);
    const [qIdx, setQIdx] = React.useState(0);
    const [state, setState] = React.useState({}); // { [i]: { pick, submitted } }
    const [ended, setEnded] = React.useState(false);
    const questions = layout.questions || [];
    const count = questions.length;
    const safeQIdx = Math.min(qIdx, Math.max(0, count - 1));
    const q = questions[safeQIdx] || questions[0] || {};
    const content = q.content
      || (q.image ? { image: q.image }
        : q.video ? { video: q.video }
        : q.html ? { html: q.html } : {});
    const answers = q.answers || [];
    const header = layout.gamingHeader || {};
    const accent = layout.dotSelectedColor || '#F26430';
    const showMarks = !!layout.showCorrectWrongAnswers;
    const hasStart = !!layout.gamingStartScreen;
    const endScreensObj = layout.gamingEndScreens || {};
    const hasEnd = !!(endScreensObj.win || endScreensObj.failure);
    const needed = layout.correctAnswersNeeded ?? count;

    const cur = state[safeQIdx] || { pick: -1, submitted: false };
    const isLast = safeQIdx === count - 1;
    const picked = answers[cur.pick];
    const isCorrect = !!(picked && picked.isCorrect);
    const setCur = (patch) => setState(s => ({
      ...s, [safeQIdx]: { ...(s[safeQIdx] || { pick: -1, submitted: false }), ...patch } }));
    // Live score across all answered questions.
    const score = questions.reduce((acc, qq, i) => {
      const st = state[i];
      if (st && st.submitted) {
        const a = (qq.answers || [])[st.pick];
        if (a && a.isCorrect) acc.correct++; else acc.wrong++;
      }
      return acc;
    }, { correct: 0, wrong: 0 });

    const onAnswer = (i) => { if (interactive && cur.submitted) return; setCur({ pick: i }); };
    const onSubmit = () => {
      if (cur.pick < 0) return;
      if (!interactive) return;
      setCur({ submitted: true });
      if (isLast && hasEnd) setEnded(true);
    };
    const onNext = () => { if (!isLast) setQIdx(safeQIdx + 1); };

    // ── End screen (win / fail) ──────────────────────────────────────────
    if (interactive && ended) {
      const win = score.correct >= needed;
      // gamingEndScreens is a { win, failure } object (GamingEndScreensSchema).
      const screen = (win ? endScreensObj.win : endScreensObj.failure)
        || endScreensObj.win || endScreensObj.failure || {};
      const body = (screen.body || '')
        .replace('{totalAnswers}', count).replace('{neededAnswers}', needed)
        .replace('{wrongAnswers}', score.wrong).replace('{correctAnswers}', score.correct);
      return (
        <div style={{ position: 'relative', height: '100%', overflow: 'hidden',
          background: layout.contentBackgroundColor || '#0b1020', color: '#fff',
          display: 'flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center' }}>
          <MockImage url={screen.bgImgUrl} label="End screen"
            style={{ position: 'absolute', inset: 0 }} />
          <div style={{ position: 'absolute', inset: 0,
            background: win ? 'rgba(6,33,15,.7)' : 'rgba(40,10,12,.7)' }} />
          <div style={{ position: 'relative', padding: 48, maxWidth: 600 }}>
            <div style={{ width: 72, height: 72, borderRadius: '50%', margin: '0 auto 20px',
              display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
              background: win ? '#22c55e' : '#f87171', color: '#06210f' }}>
              {win ? <I.Trophy size={34} /> : <I.AlertTriangle size={34} />}
            </div>
            <h1 style={{ margin: '0 0 12px', fontSize: 36, fontWeight: 600 }}>
              {screen.title || (win ? 'You did it!' : 'Not quite')}
            </h1>
            <p style={{ margin: '0 0 24px', fontSize: 16, lineHeight: 1.55, opacity: .92 }}>{body}</p>
            <div style={{ display: 'inline-flex', gap: 18, marginBottom: 24,
              fontSize: 15, fontVariantNumeric: 'tabular-nums' }}>
              <span style={{ color: '#22c55e' }}>✓ {score.correct} correct</span>
              <span style={{ color: '#f87171' }}>✗ {score.wrong} wrong</span>
              <span style={{ opacity: .7 }}>· need {needed} to pass</span>
            </div>
            <div>
              <button onClick={() => { setState({}); setQIdx(0); setEnded(false); }}
                style={{ padding: '10px 24px', fontSize: 14, background: '#fff', color: '#0f172a',
                  border: 0, cursor: 'default', fontFamily: 'inherit', fontWeight: 600 }}>
                <I.RefreshCw size={13} style={{ marginRight: 6, verticalAlign: '-2px' }} />Play again
              </button>
            </div>
          </div>
        </div>
      );
    }

    return (
      <div style={{ background: layout.contentBackgroundColor || '#0b1020',
        color: layout.contentTextColor || '#fff', height: '100%',
        position: 'relative',
        display: 'flex', flexDirection: 'column' }}>
        {/* Editor module-preview only (non-interactive). The start/end screens
            and the live score counter are rendered by the runtime player, not
            simulated here — the badge tells the author the gaming chrome is
            runtime-rendered so they don't expect a live mini-game in the canvas.
            The full preview modal (interactive) DOES simulate them, so hide the
            badge there. Copy never says "DFTI" (§23.2). */}
        {!interactive && (
          <div style={{ position: 'absolute', top: 10, right: 12, zIndex: 5,
            display: 'inline-flex', alignItems: 'center', gap: 6,
            padding: '4px 10px', borderRadius: 999, fontSize: 11, fontWeight: 600,
            letterSpacing: .2, background: 'rgba(0,0,0,.55)', color: 'rgba(255,255,255,.92)',
            border: '1px solid rgba(255,255,255,.28)', backdropFilter: 'blur(2px)' }}>
            <I.Trophy size={12} />Gamified · runtime-rendered
          </div>
        )}
        <PlayerTitleBar titleMain={layout.titleMain} titleSub={layout.titleSub}
          titleTextColor={layout.titleTextColor} />
        <div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex',
          flexDirection: 'column', padding: '0 48px 16px' }}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
            gap: 16, paddingBottom: 10 }}>
            <span style={{ fontSize: 15, fontWeight: 600, color: accent }}>
              Challenge {safeQIdx + 1} of {count}
            </span>
            {header.enabled && (
              <div style={{ display: 'flex', alignItems: 'center', gap: 22 }}>
                {[
                  { u: header.imgUrlCorrect, n: interactive ? score.correct : 0, ring: 'rgba(34,197,94,.9)' },
                  { u: header.imgUrlWrong,   n: interactive ? score.wrong : 0, ring: 'rgba(248,113,113,.9)' },
                ].map((it, i) => (
                  <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
                    <MockImage url={it.u} style={{ width: 30, height: 30, borderRadius: '50%',
                      flex: '0 0 30px', boxShadow: `inset 0 0 0 2px ${it.ring}` }} />
                    <span style={{ fontSize: 20, fontWeight: 600,
                      fontVariantNumeric: 'tabular-nums' }}>{it.n}</span>
                  </div>
                ))}
              </div>
            )}
          </div>
          <div style={{ height: 1, background: 'rgba(255,255,255,.35)', marginBottom: 18 }} />
          <div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column',
            gap: 16, overflow: 'hidden' }}>
            <p style={{ margin: 0, fontSize: 16, lineHeight: 1.45 }}>
              {stripCdata(q.text || '')}
            </p>
            <GamingQuestionMedia content={content} />
            <div style={{ display: 'grid',
              gridTemplateColumns: `repeat(${Math.max(answers.length, 1)}, 1fr)`, gap: 16 }}>
              {answers.map((a, i) => {
                const sel = i === cur.pick;
                const submitted = interactive && cur.submitted;
                const ok = (submitted && a.isCorrect) || (showMarks && sel && a.isCorrect && !interactive);
                const bad = (submitted && sel && !a.isCorrect) || (showMarks && sel && !a.isCorrect && !interactive);
                const bg = submitted
                  ? (a.isCorrect ? 'rgba(34,197,94,.25)' : sel ? 'rgba(248,113,113,.25)' : 'transparent')
                  : sel ? (layout.selectedBgColor || accent) : 'transparent';
                const bd = ok ? '#22c55e' : bad ? '#f87171' : 'rgba(255,255,255,.55)';
                return (
                  <button key={i} onClick={() => onAnswer(i)}
                    style={{ display: 'flex', alignItems: 'center', gap: 8, textAlign: 'left',
                      padding: '16px 20px', fontSize: 15, fontFamily: 'inherit', lineHeight: 1.3,
                      background: bg,
                      color: sel && !submitted ? (layout.selectedTextColor || '#fff') : '#fff',
                      border: `1px solid ${bd}`, cursor: 'default' }}>
                    <span style={{ flex: 1 }}>{stripCdata(a.text || '')}</span>
                    {ok && <I.Check size={16} style={{ color: '#22c55e' }} />}
                    {bad && <I.X size={16} style={{ color: '#f87171' }} />}
                  </button>
                );
              })}
            </div>
            {interactive && cur.submitted && (
              <QuizFeedback correct={isCorrect}
                text={picked?.feedback || (isCorrect ? 'Correct.' : 'Not quite.')} />
            )}
          </div>
          <div style={{ position: 'relative', flex: '0 0 auto', paddingTop: 14 }}>
            <StepDots count={count} activeIdx={safeQIdx}
              onSelect={(i) => setQIdx(i)}
              selectedColor={accent}
              leadDot={hasStart ? { icon: 'Flag', title: 'Start screen' } : null}
              trailDot={hasEnd ? { icon: 'Trophy', title: 'Win / Fail screen' } : null} />
            {(() => {
              const showNext = interactive && cur.submitted && !isLast;
              const showFinish = interactive && cur.submitted && isLast;
              const enabled = showNext || (!cur.submitted && cur.pick >= 0);
              const label = showNext ? 'Next question' : showFinish ? 'See result' : 'Submit';
              const action = showNext ? onNext : showFinish ? () => setEnded(true) : onSubmit;
              const on = enabled || (showFinish && hasEnd);
              return (
                <button onClick={on ? action : undefined}
                  style={{ position: 'absolute', right: 0, top: '50%',
                    transform: 'translateY(-50%)', padding: '10px 36px', fontSize: 14,
                    background: on ? '#fff' : 'rgba(255,255,255,.18)',
                    color: on ? '#0f172a' : 'rgba(255,255,255,.55)',
                    border: '1px solid rgba(255,255,255,.5)', cursor: 'default',
                    fontFamily: 'inherit' }}>{label}</button>
              );
            })()}
          </div>
        </div>
        <PlayerBottomCue gate={layout.blockingSection} color="rgba(255,255,255,.7)" />
      </div>
    );
  },

  // 3.11 ─ object_viewer
  object_viewer(layout) {
    // The .glb model is rendered transparently — `<backgroundColor>` is
    // the only scene tone the Player paints, so authors editing the
    // colour expect it to flood the entire viewport including the area
    // behind the model. Don't use a MockImage with a gradient bg here;
    // it would mask `backgroundColor` and lie about what the runtime
    // produces (see video-media-patterns.md §22.8 · object_viewer).
    const bg = layout.backgroundColor || '#0f172a';
    const items = layout.items || [];
    // Same click-to-open reveal behaviour as hidden_items — tap a pin
    // to open its panel, tap the same pin (or the X) to close. The
    // panel reflects what the hotspot inspector writes: title, body,
    // and the per-hotspot kind (text / video / question) so the
    // preview is a faithful proxy for what the Player will show.
    const [activeIdx, setActiveIdx] = React.useState(-1);
    const active = activeIdx >= 0 ? items[activeIdx] : null;

    // Project hotspots into the preview using the same yaw / pitch the
    // editor's placer mocks at rest, so a pin's position in the editor
    // matches its position in the preview without orbit drift.
    const yawRad = (30 * Math.PI) / 180;
    const pitchSin = Math.sin((-15 * Math.PI) / 180) * 0.3;
    const project = (p) => {
      const dx = (p.x ?? 0.5) - 0.5;
      const dy = (p.y ?? 0.5) - 0.5;
      const dz = (p.z ?? 0.5) - 0.5;
      const sx = 0.5 + (dx * Math.cos(yawRad) - dz * Math.sin(yawRad));
      const sy = 0.5 + dy + dz * pitchSin;
      const visible = (dx * Math.sin(yawRad) + dz * Math.cos(yawRad)) > -0.6;
      return { sx, sy, visible };
    };
    const modelLabel = (layout.objectUrl?.split('/').pop()) || '3D model';
    return (
      <div style={{ background: bg, color: '#fff',
        height: '100%', display: 'flex', flexDirection: 'column' }}>
        <PlayerTitleBar {...layout} />
        <div style={{ flex: 1, minHeight: 0, overflow: 'hidden', position: 'relative',
          display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
          {/* Model placeholder — transparent box so backgroundColor shows
              through. Centered glyph + filename for orientation. */}
          <div style={{
            width: '52%', aspectRatio: '4/3', position: 'relative',
            display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
            color: 'rgba(255,255,255,.55)', gap: 10,
          }}>
            <I.Cube size={56} />
            <div style={{ fontSize: 12, fontFamily: 'var(--font-mono)' }}>{modelLabel}</div>
          </div>
          {/* Start overlay — hidden once a hotspot panel is open so the
              reveal isn't fighting for the same vertical space. */}
          {!active && (
            <div style={{ position: 'absolute', top: '34%', left: '50%',
              transform: 'translate(-50%, -50%)', textAlign: 'center', color: '#fff',
              pointerEvents: 'none' }}>
              <div style={{ width: 60, height: 60, borderRadius: '50%', border: '2px solid #fff',
                display: 'inline-flex', alignItems: 'center', justifyContent: 'center', marginBottom: 8,
                fontSize: 11, fontFamily: 'var(--font-mono)' }}>360°</div>
              <div style={{ fontSize: 13 }}>{layout.initialCoverText || 'Drag to explore'}</div>
            </div>
          )}
          {/* Hotspots at the author's actual positions — clickable. */}
          {items.map((it, i) => {
            const [x, y, z] = (it.itemPosition || '0.5,0.5,0.5').split(',').map(Number);
            const pr = project({ x, y, z });
            if (!pr.visible) return null;
            return (
              <button key={i}
                onClick={() => setActiveIdx(activeIdx === i ? -1 : i)}
                style={{
                  position: 'absolute', left: `${pr.sx * 100}%`, top: `${pr.sy * 100}%`,
                  transform: 'translate(-50%, -50%)',
                  width: 32, height: 32, borderRadius: '50%',
                  background: 'rgba(255,255,255,0.9)',
                  border: `2px solid ${layout.itemColor || '#D32F2F'}`,
                  color: layout.itemColor || '#D32F2F',
                  fontSize: 18, fontWeight: 600, lineHeight: 1, cursor: 'default',
                  fontFamily: 'inherit',
                  display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                  zIndex: 2,
                }}>+</button>
            );
          })}
          {/* Reveal panel — same chrome / longhand-border / closer X as
              hidden_items so the two layouts feel like one family. */}
          {active && (
            <div style={{
              position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
              boxSizing: 'border-box',
              width: 'min(480px, 80%)',
              background: layout.bgColor || '#fff',
              borderWidth: 2, borderStyle: 'solid',
              borderColor: layout.borderColor || '#D32F2F',
              padding: '20px 24px', color: layout.textColor || '#0f172a',
              boxShadow: '0 8px 24px rgba(0,0,0,.3)',
              zIndex: 3,
            }}>
              <h4 style={{ margin: '0 0 8px', fontSize: 17 }}>{active.itemTitle}</h4>
              {/* Body varies by kind: text + image / video / question */}
              {(active.kind || 'text') === 'text' && (
                <>
                  {active.itemImageUrl && (
                    <MockImage url={active.itemImageUrl} label="Hotspot image"
                      style={{ width: '100%', aspectRatio: '16/9', marginBottom: 10 }} />
                  )}
                  <div style={{ margin: 0, fontSize: 14, lineHeight: 1.5 }}
                    dangerouslySetInnerHTML={{ __html: stripCdata(active.itemText || '') }} />
                </>
              )}
              {active.kind === 'video' && (
                <MockImage url={active.itemVideoThumbUrl} label="Hotspot video"
                  style={{ width: '100%', aspectRatio: '16/9' }} />
              )}
              {active.kind === 'question' && (
                <>
                  <p style={{ margin: '0 0 10px', fontSize: 14, lineHeight: 1.5 }}>
                    {stripCdata(active.itemQuestion || '')}
                  </p>
                  <div style={{ display: 'grid', gap: 6 }}>
                    {(active.itemOptions || []).map((o, j) => (
                      <div key={j} style={{
                        padding: '8px 10px', fontSize: 13,
                        border: '1px solid', borderColor: 'currentColor',
                        opacity: 0.85,
                      }}>{stripCdata(o.text || '')}</div>
                    ))}
                  </div>
                </>
              )}
              <button onClick={() => setActiveIdx(-1)} style={{
                position: 'absolute', top: 8, right: 8, width: 24, height: 24,
                background: 'transparent', border: 0, cursor: 'default', color: 'inherit',
              }}><I.X size={14} /></button>
            </div>
          )}
        </div>
        <PlayerBottomCue gate={layout.blockingSection} color="rgba(255,255,255,.7)" />
      </div>
    );
  },

  // 3.12 ─ two_columns_text
  two_columns_text(layout) {
    return (
      <div style={{ background: '#000', height: '100%', display: 'flex', flexDirection: 'column' }}>
        <PlayerTitleBar {...layout} />
        <div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0 }}>
          <div style={{ background: layout.leftColumnBackgroundColor || '#5514B4',
            color: layout.leftColumnTextColor || '#fff', padding: '36px 48px',
            display: 'flex', flexDirection: 'column', justifyContent: 'center', fontSize: 14, lineHeight: 1.6 }}
            dangerouslySetInnerHTML={{ __html: stripCdata(layout.leftColumnContent || '') }} />
          <div style={{ background: layout.rightColumnBackgroundColor || '#3D0D85',
            color: layout.rightColumnTextColor || '#fff', padding: '36px 48px',
            display: 'flex', flexDirection: 'column', justifyContent: 'center', fontSize: 14, lineHeight: 1.6 }}
            dangerouslySetInnerHTML={{ __html: stripCdata(layout.rightColumnContent || '') }} />
        </div>
        <PlayerBottomCue gate={layout.blockingSection} color="rgba(255,255,255,.7)" />
      </div>
    );
  },

  // 3.13 ─ mandatoryQuestion (standalone)
  mandatoryQuestion(layout) {
    const [pick, setPick] = React.useState(-1);
    return (
      <div style={{ background: '#f8fafc', height: '100%', display: 'flex', flexDirection: 'column' }}>
        <PlayerTitleBar {...layout} titleTextColor={layout.titleTextColor || '#0f172a'} />
        <div style={{ flex: 1, minHeight: 0, overflow: 'hidden', padding: '0 48px 24px', display: 'flex', flexDirection: 'column',
          alignItems: 'center', justifyContent: 'center', gap: 16 }}>
          <div style={{ width: '100%', maxWidth: 720, background: layout.bgColor || '#fff',
            color: layout.textColor || '#0f172a', padding: '24px 28px', boxShadow: '0 6px 18px rgba(0,0,0,.06)' }}>
            <p style={{ margin: '0 0 18px', fontSize: 16, fontWeight: 500 }}>
              {stripCdata(layout.text || '')}
            </p>
            <div style={{ display: 'grid', gap: 8 }}>
              {(layout.options || []).map((o, i) => (
                <button key={i} onClick={() => setPick(i)}
                  style={{
                    padding: '12px 16px', textAlign: 'left',
                    background: i === pick ? '#0f172a' : '#f1f5f9',
                    color: i === pick ? '#fff' : '#0f172a',
                    border: '1px solid #cbd5e1', fontSize: 14, fontFamily: 'inherit', cursor: 'default',
                  }}>{stripCdata(o.text || '')}</button>
              ))}
            </div>
            {pick >= 0 && (
              <div style={{ marginTop: 14, padding: '12px 14px',
                background: (layout.options || [])[pick]?.isCorrect ? '#ecfdf5' : '#fff1f2',
                color: (layout.options || [])[pick]?.isCorrect ? '#047857' : '#be123c',
                fontSize: 13, lineHeight: 1.5,
                borderLeft: '3px solid currentColor' }}>
                {(layout.options || [])[pick]?.isCorrect
                  ? (layout.feedbacks?.correct || 'Correct.')
                  : (layout.feedbacks?.wrong || 'Try again.')}
              </div>
            )}
          </div>
        </div>
        <PlayerBottomCue gate={layout.blockingSection} color="rgba(0,0,0,.5)" />
      </div>
    );
  },

  // 3.14 ─ discover removed as a standalone layout — see layout-editor-bodies.jsx
  // for the rationale. The in-video `discover` interaction (rendered by the
  // video layouts) is the only surviving form.

  // Aliases for the hidden_items post-split type ids — both share the
  // renderer above.
  hidden_items_flat_image(layout) { return LAYOUT_RENDERERS.hidden_items(layout); },
  hidden_items_360_image(layout)  { return LAYOUT_RENDERERS.hidden_items(layout); },

  // 3.15 ─ question (standalone, non-blocking)
  question(layout) {
    const [pick, setPick] = React.useState(-1);
    return (
      <div style={{ background: '#f8fafc', height: '100%', display: 'flex', flexDirection: 'column' }}>
        <PlayerTitleBar {...layout} titleTextColor={layout.titleTextColor || '#0f172a'} />
        <div style={{ flex: 1, minHeight: 0, overflow: 'hidden', padding: '0 48px 24px', display: 'flex', flexDirection: 'column',
          alignItems: 'center', justifyContent: 'center', gap: 16 }}>
          <div style={{ width: '100%', maxWidth: 640, background: layout.bgColor || '#fff',
            color: layout.textColor || '#0f172a', padding: '20px 24px',
            boxShadow: '0 4px 12px rgba(0,0,0,.06)' }}>
            <p style={{ margin: '0 0 14px', fontSize: 15, fontWeight: 500 }}>
              {stripCdata(layout.text || '')}
            </p>
            <div style={{ display: 'grid', gap: 6 }}>
              {(layout.answers || []).map((a, i) => (
                <label key={i} style={{
                  display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
                  background: i === pick ? '#eff6ff' : '#fff', border: '1px solid #e2e8f0',
                  cursor: 'default',
                }} onClick={() => setPick(i)}>
                  <input type="radio" name="q" checked={i === pick} readOnly />
                  <span style={{ fontSize: 13 }}>{stripCdata(a.text || '')}</span>
                </label>
              ))}
            </div>
          </div>
        </div>
        <PlayerBottomCue gate={layout.blockingSection} color="rgba(0,0,0,.5)" />
      </div>
    );
  },

  // 3.16 ─ title (section divider)
  title(layout) {
    return (
      <div style={{ position: 'relative', height: '100%', overflow: 'hidden',
        background: layout.titleBackgroundColor || '#0f172a',
        display: 'flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center' }}>
        <div style={{ position: 'relative', color: layout.titleTextColor || '#fff', padding: 48 }}>
          <h1 style={{ margin: '0 0 12px', fontSize: 48, fontWeight: 500 }}>{layout.titleMain}</h1>
          {layout.titleSub && (
            <p style={{ margin: 0, fontSize: 18, opacity: 0.85 }}>{stripCdata(layout.titleSub)}</p>
          )}
        </div>
      </div>
    );
  },
};

// Helpers
function IconGlyph({ variant = 0 }) {
  // Outline-only stand-ins for the icons_discover tile icons.
  return (
    <svg width="44" height="44" viewBox="0 0 44 44" fill="none" stroke="currentColor" strokeWidth="1.5">
      {variant === 0 && <><path d="M22 8c4 0 7 3 7 7 0 4-3 7-7 7s-7-3-7-7 3-7 7-7z"/><path d="M12 36c0-5 4-9 10-9s10 4 10 9"/></>}
      {variant === 1 && <><circle cx="22" cy="14" r="6"/><path d="M14 36c0-5 4-9 8-9s8 4 8 9"/><circle cx="32" cy="12" r="3"/></>}
      {variant === 2 && <><circle cx="22" cy="22" r="10"/><path d="m18 22 3 3 5-6"/></>}
      {variant === 3 && <><circle cx="20" cy="22" r="8"/><path d="M30 14v8M30 22v8"/><rect x="28" y="20" width="4" height="6"/></>}
    </svg>
  );
}

function SequenceQuestionPreview({ tab, layout, stepCount = 1, stepIdx = 0, onSelectStep }) {
  // Single-choice (≤ 1 correct) selects exactly one option; multi-choice
  // (≥ 2 correct) toggles membership in a Set and telegraphs "pick several"
  // with a ✓ glyph on chosen cards (§27 preview parity).
  const options = tab.options || [];
  const correctCount = options.filter(o => o.isCorrect).length;
  const multi = correctCount >= 2;
  const [pick, setPick] = React.useState(-1);
  const [picks, setPicks] = React.useState(() => new Set());
  const toggle = (i) => {
    if (multi) {
      setPicks(prev => {
        const next = new Set(prev);
        next.has(i) ? next.delete(i) : next.add(i);
        return next;
      });
    } else {
      setPick(i);
    }
  };
  const isSel = (i) => multi ? picks.has(i) : i === pick;
  const hasSelection = multi ? picks.size > 0 : pick >= 0;
  const text = tab.text || '';
  const n = options.length;

  /* Canonical sequence-question player layout (see ux-references
     _Quiz_Layouts/01_sequence_question*): the image FILLS the canvas;
     a semi-transparent question banner floats at lower-middle; the
     answer cards sit as separate WHITE blocks just below the banner
     (NOT inside it); and a footer overlay (step dots + Confirm) is
     pinned to the bottom of the canvas. This deliberately departs from
     the StepBottomCard pattern used by the text / feedback steps. */

  // <questionShort> (2 answers) → two narrow cards, centred, not spanning
  // the banner width. <question> / <questionMultiple> (3+) → full-width row.
  const isShort = n === 2;
  const cardWidth = isShort ? '22%' : `${Math.max(20, Math.floor(74 / Math.max(n, 1)))}%`;
  const bannerBg = layout.bgColor || 'rgba(15,23,42,0.78)';
  const bannerText = layout.textColor || '#ffffff';

  return (
    <StepCanvas imageUrl={tab.imageUrl}>
      {/* Banner + answer cards share one absolutely-positioned column so
          the gap between them matches the gap BETWEEN the answer cards
          (a fixed 10px) regardless of banner height — not a
          canvas-percentage that drifts as the canvas scales. The group
          floats in the lower half of the canvas. */}
      <div style={{
        position: 'absolute', left: '7.5%', right: '7.5%', bottom: '18%',
        display: 'flex', flexDirection: 'column', gap: 10,
      }}>
        {/* Question banner — semi-transparent, floats over the image. */}
        <div style={{
          background: bannerBg, color: bannerText,
          borderRadius: 4, padding: '14px 18px',
          textAlign: 'center', fontSize: 15, fontWeight: 500, lineHeight: 1.4,
          boxShadow: '0 6px 20px rgba(0,0,0,.25)',
        }}
          dangerouslySetInnerHTML={{ __html: stripCdata(text) }} />

        {/* Answer cards — separate white blocks just BELOW the banner. */}
        <div style={{
          display: 'flex', justifyContent: 'center', gap: 10, flexWrap: 'wrap',
        }}>
          {options.map((a, i) => (
            <button key={i} onClick={() => toggle(i)}
              style={{
                position: 'relative',
                flex: isShort ? `0 0 ${cardWidth}` : `1 1 ${cardWidth}`,
                maxWidth: isShort ? cardWidth : undefined,
                minHeight: 40, padding: '11px 14px',
                display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                textAlign: 'center', fontSize: 13, fontWeight: 500, lineHeight: 1.3,
                fontFamily: 'inherit', borderRadius: 4,
                border: '1px solid ' + (isSel(i) ? 'transparent' : '#e2e8f0'),
                background: isSel(i) ? (layout.btnBgColor || '#0f172a') : '#ffffff',
                color: isSel(i) ? (layout.btnTextColor || '#fff') : '#0f172a',
                cursor: 'default', boxShadow: '0 4px 14px rgba(0,0,0,.18)',
              }}>
              {multi && isSel(i) && (
                <span style={{ position: 'absolute', top: 5, right: 6, display: 'inline-flex' }}>
                  <I.Check size={13} strokeWidth={3} />
                </span>
              )}
              {stripCdata(a.text || '')}
            </button>
          ))}
        </div>
      </div>

      {/* Footer overlay — step dots centred, Confirm pill right-aligned,
          both pinned to the bottom of the canvas. */}
      <div style={{ position: 'absolute', left: 0, right: 0, bottom: '4%' }}>
        <div style={{ display: 'flex', justifyContent: 'center' }}>
          <StepDots count={stepCount} activeIdx={stepIdx}
            onSelect={onSelectStep}
            selectedColor={layout.dotSelectedColor || '#FFD166'} />
        </div>
        <span style={{
          position: 'absolute', right: '7.5%', top: '50%', transform: 'translateY(-50%)',
          padding: '7px 20px', borderRadius: 999, fontSize: 12.5, fontWeight: 600,
          background: hasSelection ? (layout.btnBgColor || '#0f172a') : 'rgba(226,232,240,.85)',
          color: hasSelection ? (layout.btnTextColor || '#fff') : '#94a3b8',
        }}>Confirm</span>
      </div>
    </StepCanvas>
  );
}

// Feedback preview — visually identical to the text+image step (image
// background + a white text card floating near the bottom). The only
// difference is that the card's body is drawn dynamically from the
// preceding Question step's per-answer feedback — not from a field on
// this step. A small "Correct" / "Wrong" pill on the card identifies
// which answer's feedback is being shown; clicking it flips the view
// so the author can preview both states.
function SequenceFeedbackPreview({ tab, prevQ, layout }) {
  const opts = prevQ?.options || [];
  const correctCount = opts.filter(o => o.isCorrect).length;
  const multi = correctCount >= 2;
  const correctIdx = opts.findIndex(o => o.isCorrect);
  const wrongIdx   = opts.findIndex(o => !o.isCorrect);
  const has = correctIdx >= 0 || wrongIdx >= 0;
  const [mode, setMode] = React.useState(correctIdx >= 0 ? 'correct' : 'wrong');
  const flip = () => {
    if (mode === 'correct' && wrongIdx >= 0) setMode('wrong');
    else if (mode === 'wrong' && correctIdx >= 0) setMode('correct');
  };
  // Multi-correct questions (<questionMultiple>) carry one shared
  // correct / incorrect pair on the question, NOT per-answer feedback —
  // so the body is sourced from prevQ.feedbacks. Single-correct keeps the
  // existing per-answer behaviour (§27).
  let body;
  if (multi) {
    const f = prevQ?.feedbacks || {};
    body = ((mode === 'correct' ? f.correct : f.wrong) || '').trim();
  } else {
    const shown = mode === 'correct' ? opts[correctIdx] : opts[wrongIdx];
    body = (shown?.feedback || '').trim();
  }
  const emptyMsg = multi
    ? 'Add Correct / Incorrect feedback on the paired question step — it has two or more correct answers, so feedback is shared rather than per-answer.'
    : (has ? 'Add per-answer feedback on the paired question step.'
           : 'Feedback copy is taken from the paired Question step\'s answers — add a Question step above to populate this card.');
  return (
    <StepCanvas imageUrl={tab.imageUrl}>
      <StepBottomCard style={{ maxHeight: '60%' }}>
        {has && (
          <button onClick={flip}
            title="Toggle which answer's feedback is shown"
            style={{
              display: 'inline-flex', alignItems: 'center', gap: 4,
              fontSize: 10.5, fontWeight: 600, letterSpacing: '.04em',
              textTransform: 'uppercase',
              padding: '2px 8px 3px', borderRadius: 999, marginBottom: 8,
              background: mode === 'correct' ? '#ecfdf5' : '#fff1f2',
              color:      mode === 'correct' ? '#047857' : '#be123c',
              border: 0, fontFamily: 'inherit', cursor: 'default',
            }}>
            {mode === 'correct'
              ? <><I.Check size={11} />Correct</>
              : <><I.X size={11} />Try again</>}
          </button>
        )}
        <div style={{ fontSize: 14, lineHeight: 1.55 }}
          dangerouslySetInnerHTML={{ __html: stripCdata(body || emptyMsg) }} />
      </StepBottomCard>
    </StepCanvas>
  );
}

// ── 5. Framing screens (§4.6) ───────────────────────────────────────────────
function FramingScreenPreview({ frame, lang = 'en' }) {
  const R = FRAMING_RENDERERS[frame];
  if (!R) return <FallbackRenderer layout={{ type: frame }} />;
  return <FramingWrapper key={frame} renderer={R} data={FRAMING_CONTENT_SAMPLES[frame]} lang={lang} />;
}

function FramingWrapper({ renderer, data, lang = 'en' }) {
  // Flatten every LocalizedString in the framing-screen data to its active
  // language so the renderers read plain strings (matches RendererWrapper).
  return renderer(deepLocResolve(data, lang));
}

const FRAMING_RENDERERS = {

  cover(d) {
    return (
      <div style={{ position: 'relative', height: '100%', overflow: 'hidden' }}>
        <MockImage url={d.backgroundImageUrl} style={{ position: 'absolute', inset: 0 }} />
        <div style={{ position: 'absolute', inset: 0,
          background: 'linear-gradient(120deg, rgba(0,0,0,0.7) 30%, rgba(0,0,0,0) 60%)' }} />
        <div style={{ position: 'relative', height: '100%', display: 'flex',
          flexDirection: 'column', justifyContent: 'flex-start',
          padding: '60px 80px', color: '#fff', maxWidth: 720 }}>
          <div style={{ width: 56, height: 56, marginBottom: 24,
            border: '1px solid rgba(255,255,255,.4)', borderRadius: 4 }}>
            <MockImage url={d.brandLogoUrl} label="Logo" style={{ width: '100%', height: '100%' }} />
          </div>
          <h1 style={{ margin: '0 0 16px', fontSize: 42, fontWeight: 500 }}>{d.titleMain}</h1>
          <p style={{ margin: '0 0 36px', fontSize: 16, lineHeight: 1.55, maxWidth: 480 }}>
            {d.introText}
          </p>
          <button style={{
            padding: '14px 36px', alignSelf: 'flex-start',
            background: d.ctaColor, color: d.ctaTextColor,
            border: 0, fontSize: 14, fontFamily: 'inherit', cursor: 'default',
          }}>{d.ctaLabel}</button>
        </div>
      </div>
    );
  },

  language(d) {
    return (
      <div style={{ height: '100%', background: '#f8fafc', padding: '64px 80px',
        display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 32 }}>
        <h2 style={{ margin: 0, fontSize: 22, fontWeight: 500, color: '#0f172a' }}>
          Select your language
        </h2>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 16,
          maxWidth: 720, width: '100%' }}>
          {d.enabled.map(l => (
            <button key={l} style={{
              padding: '20px 16px', background: '#ffffff', border: '1px solid #e2e8f0',
              display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
              cursor: 'default', fontFamily: 'inherit',
            }}>
              <span style={{ fontSize: 36, lineHeight: 1 }}>{LANG_FLAGS[l]}</span>
              <span style={{ fontSize: 13, color: '#0f172a' }}>{LANG_NAMES[l]}</span>
            </button>
          ))}
        </div>
      </div>
    );
  },

  brand(d) {
    const [pick, setPick] = React.useState(0);
    return (
      <div style={{ height: '100%', background: '#f8fafc', padding: '64px 80px',
        display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 32 }}>
        <div style={{ textAlign: 'center' }}>
          <h2 style={{ margin: '0 0 8px', fontSize: 22, fontWeight: 500, color: '#0f172a' }}>
            Compliance training
          </h2>
          <p style={{ margin: 0, fontSize: 15, color: '#475569', maxWidth: 640, lineHeight: 1.55 }}>
            Everyone has a responsibility to follow the rules.
          </p>
        </div>
        <div style={{ width: '100%', background: '#f1f5f9', padding: '36px', display: 'flex',
          flexDirection: 'column', gap: 18 }}>
          <div style={{ fontSize: 15, color: '#0f172a' }}>{d.promptText}</div>
          <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
            {d.brands.map((b, i) => (
              <button key={b.code} onClick={() => setPick(i)} style={{
                width: 160, height: 80, fontFamily: 'inherit', cursor: 'default',
                background: i === pick ? '#D32F2F' : '#ffffff',
                color: i === pick ? '#fff' : '#0f172a',
                border: `1px solid ${i === pick ? '#D32F2F' : '#cbd5e1'}`,
                fontSize: 14,
              }}>{b.label}</button>
            ))}
          </div>
        </div>
        <div style={{ display: 'flex', gap: 12, width: '100%', justifyContent: 'space-between', maxWidth: 720 }}>
          <button style={{ padding: '12px 28px', background: '#0f172a', color: '#fff',
            border: 0, fontSize: 14, fontFamily: 'inherit', cursor: 'default' }}>Previous</button>
          <button style={{ padding: '12px 28px', background: '#0f172a', color: '#fff',
            border: 0, fontSize: 14, fontFamily: 'inherit', cursor: 'default' }}>Next</button>
        </div>
      </div>
    );
  },

  role(d) {
    const [pick, setPick] = React.useState(0);
    return (
      <div style={{ height: '100%', background: '#fff',
        display: 'flex', flexDirection: 'column' }}>
        <div style={{ padding: '32px 80px 0' }}>
          <h2 style={{ margin: 0, textAlign: 'right', fontSize: 22, fontWeight: 500, color: '#0f172a' }}>
            {d.titleMain}
          </h2>
        </div>
        <div style={{ flex: 1, margin: '32px 80px', background: '#f1f5f9', padding: '32px',
          display: 'flex', flexDirection: 'column', gap: 18 }}>
          <h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, color: '#0f172a' }}>{d.promptText}</h3>
          <p style={{ margin: 0, fontSize: 14, color: '#475569' }}>{d.helperText}</p>
          <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
            {d.roles.map((r, i) => (
              <button key={r.code} onClick={() => setPick(i)} style={{
                width: 160, padding: '16px 12px',
                background: i === pick ? d.selectedBgColor : '#ffffff',
                color: i === pick ? d.selectedTextColor : '#0f172a',
                border: `1px solid ${i === pick ? d.selectedBgColor : '#cbd5e1'}`,
                fontSize: 12.5, lineHeight: 1.35, textAlign: 'center', cursor: 'default',
                fontFamily: 'inherit',
              }}>{r.label}</button>
            ))}
          </div>
        </div>
        <div style={{ display: 'flex', justifyContent: 'space-between', padding: '0 80px 32px' }}>
          <button style={{ padding: '12px 28px', background: '#0f172a', color: '#fff',
            border: 0, fontSize: 14, fontFamily: 'inherit', cursor: 'default' }}>Previous</button>
          <button style={{ padding: '12px 28px', background: '#0f172a', color: '#fff',
            border: 0, fontSize: 14, fontFamily: 'inherit', cursor: 'default' }}>Start Learning</button>
        </div>
      </div>
    );
  },

  home(d) {
    const stateGlyph = {
      done: { icon: <I.Check size={16} />, color: '#10b981' },
      open: { icon: <I.ChevronRight size={16} />, color: '#0f172a' },
      locked: { icon: <I.Lock size={14} />, color: '#94a3b8' },
    };
    return (
      <div style={{ height: '100%', background: '#fff', overflow: 'auto' }}>
        <div style={{ padding: '24px 80px', display: 'flex', alignItems: 'center', gap: 24,
          borderBottom: '1px solid #f1f5f9' }}>
          <div style={{ width: 80, height: 60 }}>
            <MockImage url={d.brandLogoUrl} style={{ width: '100%', height: '100%' }} />
          </div>
          <h2 style={{ margin: 0, marginLeft: 'auto', fontSize: 24, fontWeight: 400, color: '#0f172a' }}>
            {d.titleMain}
          </h2>
        </div>
        <div style={{ padding: '24px 80px', display: 'flex', flexDirection: 'column', gap: 16 }}>
          <p style={{ margin: 0, fontSize: 14, color: '#475569', lineHeight: 1.5, maxWidth: 720 }}>
            {d.introText}
          </p>
          <button style={{
            padding: '14px 24px', background: '#0f172a', color: '#fff', border: 0,
            fontSize: 14, fontFamily: 'inherit', cursor: 'default',
          }}>{d.postAssessmentLabel}</button>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
            {d.modules.map((m, i) => {
              const sg = stateGlyph[m.state] || stateGlyph.open;
              return (
                <div key={m.id} style={{
                  display: 'grid', gridTemplateColumns: '140px 1fr auto 1px auto',
                  gap: 18, alignItems: 'center', padding: '12px 16px',
                  border: '1px solid #e2e8f0',
                }}>
                  <MockImage url={m.thumbBg} label="Thumb" style={{ width: 140, aspectRatio: '16/10' }} />
                  <div>
                    <div style={{ fontSize: 11, color: '#64748b', marginBottom: 4 }}>Module | {m.duration}</div>
                    <div style={{ fontSize: 16, fontWeight: 500, color: '#0f172a', marginBottom: 4 }}>
                      {m.n}. {m.title}
                    </div>
                    <div style={{ fontSize: 13, color: '#64748b', lineHeight: 1.45 }}>{m.summary}</div>
                  </div>
                  <span style={{ fontSize: 22, color: '#a78bfa', fontWeight: 300 }}>{(i+1)*2}</span>
                  <div style={{ width: 1, height: 60, background: '#e2e8f0' }} />
                  <div style={{ width: 36, height: 36, borderRadius: '50%',
                    border: `2px solid ${sg.color}`, color: sg.color,
                    display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
                    {sg.icon}
                  </div>
                </div>
              );
            })}
          </div>
        </div>
      </div>
    );
  },

  preAssessment(d) {
    const [pick, setPick] = React.useState(-1);
    const letters = ['A', 'B', 'C', 'D'];
    return (
      <div style={{ position: 'relative', height: '100%', overflow: 'hidden', background: '#0f172a' }}>
        <MockImage url={d.backgroundImageUrl} style={{ position: 'absolute', inset: 0, opacity: 0.6 }} />
        {/* Top progress */}
        <div style={{ position: 'relative', display: 'flex', alignItems: 'center',
          padding: '20px 40px', gap: 16, color: '#fff' }}>
          <span style={{ fontSize: 16, fontWeight: 500 }}>{d.titleMain}</span>
          <div style={{ flex: 1 }} />
          <div style={{ width: 200, height: 6, background: 'rgba(255,255,255,.3)' }}>
            <div style={{ width: '10%', height: '100%', background: '#fff' }} />
          </div>
          <span style={{ fontSize: 13, fontFamily: 'var(--font-mono)' }}>1/10</span>
        </div>
        <div style={{ position: 'relative', padding: '0 80px 24px' }}>
          {/* Question banner */}
          <div style={{ background: '#3F4E66', color: '#fff', padding: '24px 32px',
            fontSize: 15, lineHeight: 1.55, marginBottom: 32, minHeight: 80 }}>
            {d.sampleQuestion.text}
          </div>
          {/* Answers */}
          <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
            {d.sampleQuestion.answers.map((a, i) => (
              <button key={i} onClick={() => setPick(i)} style={{
                display: 'flex', alignItems: 'center', gap: 24, padding: '16px 22px',
                background: i === pick ? '#0f172a' : '#ffffff',
                color: i === pick ? '#fff' : '#0f172a',
                border: 0, textAlign: 'left', fontFamily: 'inherit', fontSize: 14, cursor: 'default',
                boxShadow: '0 2px 4px rgba(0,0,0,.1)',
              }}>
                <span style={{ color: '#D32F2F', fontWeight: 700, fontSize: 16 }}>{letters[i]}.</span>
                <span>{a.text}</span>
              </button>
            ))}
          </div>
          {/* Submit */}
          <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 24 }}>
            <button style={{
              padding: '12px 60px',
              background: pick >= 0 ? '#0f172a' : '#94a3b8',
              color: '#fff', border: 0, fontSize: 14, cursor: 'default', fontFamily: 'inherit',
            }}>Submit</button>
          </div>
        </div>
      </div>
    );
  },

  postAssessment(d) {
    return (
      <div style={{ height: '100%', background: '#fff', display: 'flex',
        flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 64 }}>
        <div style={{ width: 88, height: 88, borderRadius: '50%', border: '4px solid #10b981',
          color: '#10b981', display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
          marginBottom: 24 }}>
          <I.Check size={36} />
        </div>
        <h2 style={{ margin: '0 0 12px', fontSize: 28, fontWeight: 500, color: '#0f172a' }}>{d.passTitle}</h2>
        <p style={{ margin: '0 0 24px', fontSize: 15, color: '#475569',
          maxWidth: 480, textAlign: 'center', lineHeight: 1.55 }}>{d.passBody}</p>
        <div style={{ maxWidth: 480, background: '#f1f5f9', padding: 16, marginBottom: 16,
          fontSize: 13, color: '#475569', lineHeight: 1.5 }}>
          <I.AlertCircle size={12} /> {d.acknowledgement}
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#3b82f6' }}>
          <I.Link size={11} /><span>{d.policyLinkUrl}</span>
        </div>
        <button style={{
          marginTop: 28, padding: '14px 36px',
          background: '#0f172a', color: '#fff', border: 0, fontSize: 14, cursor: 'default', fontFamily: 'inherit',
        }}>Finish</button>
      </div>
    );
  },
};

Object.assign(window, {
  PlayerPreview, MockImage, PlayerTitleBar, PlayerBottomCue,
  LAYOUT_RENDERERS, FRAMING_RENDERERS,
});
