// ─── template-format-editor.jsx ──────────────────────────────────────────────
// "Quiz · gaming" templates by format, part 1: the guided EMAIL editor, a full screen inside
// "Layout library" › "Quiz · gaming" › "Manage templates".
//
// Omar, 2026-11-04: "an Editor creates "Quiz · gaming" templates by format, without writing HTML";
// 2026-11-05: "[431] Part 1's plan approved" · "[432] … Hide "Your own HTML" as a choice for new
// templates for now (hide it, don't remove it)". Prototype 3 is the specification
// (wiki/findings/2026-11-04-quiz-gaming-template-formats.md §9–§12): its blocks in their fixed order,
// each switched on or off, its fields and limits, its samples, and beside them the page itself.
//
// What this screen owns, and what it does not:
//   · the form, the preview and the checks. The page is built by template-formats.js, the SAME code
//     for the preview and for what is saved, so the preview is the page a learner gets (plan D1);
//   · "Create template" / "Save changes" stay unavailable while a check fails or has not run yet:
//     a length that was never measured is not a length within the limit;
//   · unsaved work (plan D7): leaving the screen or the site asks first (the app's own question,
//     through `useScreenWork`), choosing a sample asks before replacing, and nothing closes on a click
//     outside, because there is no outside: the editor is the screen.
// The server keeps its own guarantees (routes/templates.ts): the page is validated like any template's,
// and the page and its form are saved together.
//
// Every top-level name starts with `Tf`: every screen file shares one scope in the browser
// (`feedback_the_in_browser_compiler_shares_helper_names_across_files`, plan D8).

// The preview shows the course player's area on a computer: 950 × 480, centred (measured 2026-11-04, finding §3).
// No other screen is offered (Omar, 2026-11-05: "The preview always shows the computer size."); the length check
// still measures every screen it covers, out of sight (`TfLengthProbes`).
const TF_AREA_WIDTH = 950;
const TF_AREA_HEIGHT = 480;

/**
 * A page as a blob: address the frame can open. Through `safeFrameSrc` and `sandbox="allow-scripts"` like every
 * template preview in the app (`resolveHtmlPreviewUrl`, html-templates.jsx): the page's scripts run, in an OPAQUE
 * origin, so they cannot reach this app. Each address is given back once its page is replaced.
 */
function useTfPageUrl(html) {
  const url = React.useMemo(() => {
    try { return URL.createObjectURL(new Blob([html], { type: 'text/html' })); } catch (_) { return ''; }
  }, [html]);
  React.useEffect(() => () => { try { if (url) URL.revokeObjectURL(url); } catch (_) { /* gone */ } }, [url]);
  return url;
}

/** The value, `ms` after it last changed: the preview and the length check follow typing, not every key. */
function useTfSettled(value, ms) {
  const [settled, setSettled] = React.useState(value);
  React.useEffect(() => {
    const t = setTimeout(() => setSettled(value), ms);
    return () => clearTimeout(t);
  }, [value, ms]);
  return settled;
}

/**
 * The guided email editor.
 *   template — `{ _new: true }` for a new one, or the registry entry being edited (its `formValues`)
 *   onSave   — called with `{ _new, serverId, name, html, format, formValues }`
 *   onClose  — leave the editor (asked first by the caller's screen work when there are changes)
 */
function TfEmailEditor({ template, busy, error, onSave, onClose }) {
  const TF = window.DynamoTemplateFormats;
  const ConfirmDialog = window.ConfirmDialog;
  const isNew = !!template._new;
  const [name, setName] = React.useState(isNew ? '' : (template.name || ''));
  const [values, setValues] = React.useState(() => (isNew
    ? TF.normaliseEmail(TF.EMAIL_SAMPLES[TF.DEFAULT_SAMPLE].v)
    : TF.normaliseEmail(template.formValues)));
  const [open, setOpen] = React.useState(() => new Set(['header']));
  const [askSample, setAskSample] = React.useState(null);     // a sample key waiting for "Replace"
  const [pictureError, setPictureError] = React.useState(null);  // { key, message }
  // The sample the form last started from: "Start from a sample" shows it, it is saved with the template, and a
  // picture's "Sample" puts that sample's picture back. A template saved before 2026-11-05 names none (null).
  const [sampleKey, setSampleKey] = React.useState(isNew ? TF.DEFAULT_SAMPLE : TF.sampleOf(template.formValues));

  // ★ Unsaved changes, against what was opened. Read at the moment of leaving (`useScreenWork`), never a copy.
  // The sample's name is saved with the template, so choosing another one is a change too (review, 2026-11-05).
  const baseline = React.useRef(JSON.stringify({ name: isNew ? '' : (template.name || ''), values, sampleKey }));
  const dirty = JSON.stringify({ name, values, sampleKey }) !== baseline.current;
  window.useScreenWork('template-email-editor', 'the email template', () => dirty, isNew ? 'Create template' : 'Save changes');

  const setValue = React.useCallback((k, v) => setValues(prev => ({ ...prev, [k]: v })), []);

  // ── what would be saved, and its checks ──
  const pageHtml = React.useMemo(() => TF.buildTemplate('email', values), [values]);
  const pageBytes = TF.sizeBytes(pageHtml);
  const settledValues = useTfSettled(values, 450);
  const [measured, setMeasured] = React.useState({ build: -1, metrics: {} });
  const build = React.useRef(0);
  const probeBuild = React.useMemo(() => { build.current += 1; return build.current; }, [settledValues]);
  const lengthPending = measured.build !== probeBuild
    || TF.LIMIT_SCREENS.some(s => !measured.metrics[s.w]);
  // While the length is measured again, the last complete measurement stands: otherwise the checks vanished on every
  // pause in typing and came back, and the preview jumped with them (review, 2026-11-05). Saving waits either way.
  const lastLength = React.useRef({});
  if (!lengthPending) lastLength.current = measured.metrics;
  const problems = TF.problems(values, lengthPending ? lastLength.current : measured.metrics);
  if (!name.trim()) problems.unshift({ key: 'name', where: 'Name', text: 'Give the template a name, so authors can find it in “Pick from samples”.' });
  if (pageBytes > TF.MAX_PAGE_BYTES) {
    problems.push({ key: 'size', where: 'Too big', text: `the template is ${Math.round(pageBytes / 1024)} KB with its pictures; the limit is 256 KB. Please use smaller pictures.` });
  }
  const typing = settledValues !== values;
  const blocked = busy || problems.length > 0 || lengthPending || typing;

  const save = () => {
    if (blocked) return;
    onSave({
      _new: isNew, serverId: template.serverId, name: name.trim(),
      html: TF.buildTemplate('email', values), format: 'email', formValues: TF.formValuesFor(values, sampleKey),
    });
  };
  const leave = () => {
    if (!dirty || typeof window.dynamoConfirmLeave !== 'function') { onClose(); return; }
    window.dynamoConfirmLeave('screen').then(ok => { if (ok) onClose(); });
  };

  // ── the learner's places: inserted where the cursor was ──
  const lastFocus = React.useRef(null);   // { kind: 'input', el, set } | { kind: 'rich', el }
  const insertPlace = (place) => {
    const f = lastFocus.current;
    if (!f || !f.el || !document.body.contains(f.el)) return;
    if (f.kind === 'rich') {
      f.el.focus();
      document.execCommand('insertText', false, place);
      return;
    }
    const el = f.el;
    const s = el.selectionStart ?? el.value.length, e = el.selectionEnd ?? s;
    const next = el.value.slice(0, s) + place + el.value.slice(e);
    f.set(next);
    requestAnimationFrame(() => { try { el.focus(); el.setSelectionRange(s + place.length, s + place.length); } catch (_) { /* gone */ } });
  };
  // A field the learner's places can go into: the chips insert where its cursor was. `set` writes the new text.
  const track = (set) => ({ onFocus: (e) => { lastFocus.current = { kind: 'input', el: e.currentTarget, set }; } });

  const replaceWithSample = (key) => {
    setAskSample(null);
    setValues(TF.normaliseEmail(TF.sampleFor(key).v, key));
    setSampleKey(key);
    setPictureError(null);
  };

  const toggleBlock = (id) => setOpen(prev => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; });

  return (
    <div style={{ height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0, background: 'var(--bg)' }}
      data-screen-label="Surface · Layout library · Manage templates · Email template">
      {/* Top bar: back, where this is, and the two actions */}
      <div style={{
        display: 'flex', alignItems: 'center', gap: 10, padding: '14px 28px',
        borderBottom: '1px solid var(--border)', background: 'var(--surface)', minHeight: 56,
      }}>
        <button className="btn sm ghost" onClick={leave} title="Back to Manage templates" aria-label="Back to Manage templates">
          <I.ArrowLeft size={14} />
        </button>
        <nav style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, minWidth: 0 }}>
          <span style={{ color: 'var(--text-muted)' }}>Manage templates</span>
          <I.ChevronRight size={12} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
          <span style={{ fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
            {isNew ? 'New email template' : (template.name || 'Email template')}
          </span>
        </nav>
        <div style={{ flex: 1 }} />
        {dirty && <span style={{ fontSize: 11.5, color: 'var(--text-muted)' }}>Not saved yet</span>}
        <button className="btn sm ghost" onClick={leave}>Cancel</button>
        <button className="btn sm primary" onClick={save} disabled={blocked}
          title={problems.length && !busy ? 'Please change what the checks list before saving.' : undefined}>
          {busy ? 'Saving…' : (isNew ? 'Create template' : 'Save changes')}
        </button>
      </div>

      {error && (
        <div role="alert" style={{
          margin: '12px 28px 0', padding: '10px 14px', background: 'var(--error-bg, #fef2f2)',
          border: '1px solid var(--error-text)', borderRadius: 'var(--radius-md)',
          fontSize: 12.5, color: 'var(--error-text)', lineHeight: 1.5,
        }}>{error}</div>
      )}

      <div style={{ flex: 1, minHeight: 0, display: 'grid', gridTemplateColumns: 'minmax(360px, 460px) 1fr' }}>
        {/* ── the form ── */}
        <div style={{ overflow: 'auto', padding: '18px 20px 48px', borderRight: '1px solid var(--border)',
          display: 'flex', flexDirection: 'column', gap: 14 }} data-tf-form="">
          <TfLabeled label="Name" htmlFor="tf-name">
            <input id="tf-name" className="field" value={name} placeholder="For example: Bank security check"
              onChange={e => setName(e.target.value)}
              onFocus={() => { lastFocus.current = null; }} style={{ width: '100%' }} />
          </TfLabeled>

          <div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>
            Learner details: click to insert at the cursor. The course player fills them from the
            “Learner details form”. The preview shows them for a sample learner, Robin Ashdale.
            <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 6 }}>
              {TF.PLACES.map(p => (
                <button key={p} type="button" className="btn sm ghost" data-tf-place={p}
                  onMouseDown={e => { e.preventDefault(); insertPlace(p); }}
                  style={{ fontFamily: 'var(--font-mono)', fontSize: 11.5, border: '1px solid var(--border)' }}>{p}</button>
              ))}
            </div>
          </div>

          <TfLabeled label="Start from a sample" htmlFor="tf-sample">
            <select id="tf-sample" className="field" value={sampleKey || ''}
              onChange={e => { if (e.target.value) setAskSample(e.target.value); }} style={{ width: '100%' }}>
              <option value="">Choose a sample…</option>
              {Object.keys(TF.EMAIL_SAMPLES).map(k => (
                <option key={k} value={k}>{TF.EMAIL_SAMPLES[k].label}</option>
              ))}
            </select>
          </TfLabeled>

          <div style={{ fontSize: 11.5, color: 'var(--text-muted)', fontWeight: 600, letterSpacing: '.02em',
            textTransform: 'uppercase', marginTop: 4 }}>Blocks, top to bottom</div>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
            {TF.EMAIL_BLOCKS.map((b, i) => (
              <TfBlock key={b.id} n={i + 1} block={b} values={values} setValue={setValue}
                open={open.has(b.id)} onToggle={() => toggleBlock(b.id)}
                track={track} lastFocus={lastFocus} sampleKey={sampleKey}
                pictureError={pictureError} setPictureError={setPictureError} />
            ))}
          </div>
        </div>

        {/* ── the page, and the checks ── */}
        <div style={{ overflow: 'auto', padding: '18px 24px 48px', display: 'flex', flexDirection: 'column', gap: 14 }}>
          <TfChecks problems={problems} />
          <TfPreview values={values} />
        </div>
      </div>

      <TfLengthProbes values={settledValues} build={probeBuild} onMeasured={setMeasured} />

      {askSample && ConfirmDialog && (
        <ConfirmDialog icon="Info" tone="primary" iconTone="neutral"
          title={`Replace everything below with the “${TF.EMAIL_SAMPLES[askSample].label}” sample?`}
          body="Every block takes the sample's words, pictures and colours. The name stays as it is."
          confirmLabel="Replace"
          onCancel={() => setAskSample(null)}
          onConfirm={() => replaceWithSample(askSample)} />
      )}
    </div>
  );
}

function TfLabeled({ label, htmlFor, count, children }) {
  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 6, marginBottom: 4 }}>
        <label htmlFor={htmlFor} style={{ fontSize: 12, color: 'var(--text)', fontWeight: 600 }}>{label}</label>
        <div style={{ flex: 1 }} />
        {count}
      </div>
      {children}
    </div>
  );
}

/** "n / max", red when over: the limit is the editor saying so, never the field refusing a key ([412]). */
function TfCount({ n, max }) {
  if (!max) return null;
  const over = n > max;
  return (
    <span data-tf-count="" style={{ fontSize: 11, fontFamily: 'var(--font-mono)',
      color: over ? 'var(--error-text)' : 'var(--text-faint)', fontWeight: over ? 700 : 400 }}>{n} / {max}</span>
  );
}

function TfBlock({ n, block, values, setValue, open, onToggle, track, lastFocus, sampleKey, pictureError, setPictureError }) {
  const on = block.always || !!values[block.on];
  const hasFields = block.fields.length > 0;
  return (
    <div data-tf-block={block.id} style={{
      border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', background: 'var(--surface)',
      opacity: on ? 1 : 0.72,
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px' }}>
        <span style={{ width: 18, fontSize: 11, color: 'var(--text-faint)', fontFamily: 'var(--font-mono)' }}>{n}</span>
        {block.always ? (
          <input type="checkbox" checked disabled aria-label={`${block.name}: always on`} />
        ) : (
          <input type="checkbox" checked={on} aria-label={`${block.name}: on or off`}
            onChange={e => setValue(block.on, e.target.checked)}
            style={{ accentColor: 'var(--accent)' }} />
        )}
        <button type="button" onClick={hasFields ? onToggle : undefined} aria-expanded={hasFields ? open : undefined}
          style={{ flex: 1, textAlign: 'left', border: 0, background: 'transparent', padding: 0, fontFamily: 'inherit',
            fontSize: 13, fontWeight: 600, color: 'var(--text)', cursor: 'default',
            display: 'flex', alignItems: 'center', gap: 6 }}>
          <span style={{ flex: 1 }}>{block.name}</span>
          {hasFields && (open ? <I.ChevronDown size={13} /> : <I.ChevronRight size={13} />)}
        </button>
      </div>
      {hasFields && open && (
        <div style={{ padding: '4px 12px 12px', display: 'flex', flexDirection: 'column', gap: 10,
          borderTop: '1px solid var(--border)' }}>
          {block.note && <div style={{ fontSize: 11.5, color: 'var(--text-faint)', paddingTop: 6 }}>{block.note}</div>}
          {block.fields.filter(f => !f.if || f.if(values)).map(f => (
            <TfField key={f.k} field={f} values={values} setValue={setValue} track={track} lastFocus={lastFocus}
              sampleKey={sampleKey} pictureError={pictureError} setPictureError={setPictureError} />
          ))}
        </div>
      )}
    </div>
  );
}

function TfField({ field: f, values, setValue, track, lastFocus, sampleKey, pictureError, setPictureError }) {
  const TF = window.DynamoTemplateFormats;
  const id = 'tf-' + f.k;
  const v = values[f.k];
  const hint = f.hint ? <div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 4 }}>{f.hint}</div> : null;
  switch (f.t) {
    case 'text':
      return (
        <TfLabeled label={f.label} htmlFor={id} count={<TfCount n={String(v || '').length} max={f.max} />}>
          <input id={id} className="field" value={v || ''} onChange={e => setValue(f.k, e.target.value)}
            {...track(x => setValue(f.k, x))} style={{ width: '100%' }} />
          {hint}
        </TfLabeled>
      );
    case 'area':
      return (
        <TfLabeled label={f.label} htmlFor={id} count={<TfCount n={String(v || '').length} max={f.max} />}>
          <textarea id={id} className="field" value={v || ''} rows={3} onChange={e => setValue(f.k, e.target.value)}
            {...track(x => setValue(f.k, x))} style={{ width: '100%', resize: 'vertical', fontFamily: 'inherit' }} />
          {hint}
        </TfLabeled>
      );
    case 'tip':
      return (
        <TfLabeled label={f.label} htmlFor={id} count={<TfCount n={String(v || '').length} max={f.max} />}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
            <span aria-hidden="true" style={{ width: 18, height: 18, borderRadius: 4, background: '#1a1a1a', color: '#fff',
              fontSize: 11, fontWeight: 700, lineHeight: '18px', textAlign: 'center', flexShrink: 0 }}>?</span>
            <input id={id} className="field" value={v || ''} placeholder="Empty: no tooltip"
              onChange={e => setValue(f.k, e.target.value)} {...track(x => setValue(f.k, x))} style={{ width: '100%' }} />
          </div>
        </TfLabeled>
      );
    case 'check':
      return (
        <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}>
          <input type="checkbox" checked={!!v} onChange={e => setValue(f.k, e.target.checked)}
            style={{ accentColor: 'var(--accent)' }} />
          {f.label}
        </label>
      );
    case 'color':
      return window.ColorField
        ? <window.ColorField label={f.label} value={v || '#000000'} onChange={c => setValue(f.k, c)} />
        : null;
    case 'seg':
    case 'align': {
      const opts = f.t === 'align' ? [['left', 'Left'], ['center', 'Centred']] : f.opts;
      return (
        <div>
          <div style={{ fontSize: 12, color: 'var(--text)', fontWeight: 600, marginBottom: 4 }}>{f.label || 'Alignment'}</div>
          <window.SegmentedControl value={v || opts[0][0]} onChange={x => setValue(f.k, x)}
            options={opts.map(o => ({ id: o[0], label: o[1] }))} />
        </div>
      );
    }
    case 'img':
      return <TfPictureField field={f} value={v} setValue={setValue} sampleKey={sampleKey}
        error={pictureError && pictureError.key === f.k ? pictureError.message : null} setError={setPictureError} />;
    case 'rich':
      return (
        <TfLabeled label={f.label} count={<TfCount n={TF.textLen(v)} max={f.max} />}>
          <window.RichTextEditor value={v || ''} onChange={html => setValue(f.k, html)} minHeight={72}
            tips colours={TF.RICH_COLOURS} lists={!!f.bullets} underline={false}
            onEditorFocus={el => { lastFocus.current = { kind: 'rich', el }; }} />
          <div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 4 }}>
            Select words, then: bold, italic, a colour, a link, or a tooltip (the learner sees your text when pointing at them).
          </div>
        </TfLabeled>
      );
    case 'rows':
      return <TfRows field={f} rows={v || []} setValue={setValue} track={track} />;
    default:
      return null;
  }
}

function TfPictureField({ field: f, value, setValue, sampleKey, error, setError }) {
  const TF = window.DynamoTemplateFormats;
  const inputRef = React.useRef(null);
  const [busy, setBusy] = React.useState(false);
  const sample = sampleKey ? (TF.sampleFor(sampleKey).v[f.k] || '') : '';
  const onFile = (file) => {
    if (!file) return;
    setBusy(true);
    setError(null);
    TF.fitPicture(file, f.k)
      .then(url => { setValue(f.k, url); })
      .catch(e => { setError({ key: f.k, message: String((e && e.message) || e) }); })
      .finally(() => { setBusy(false); if (inputRef.current) inputRef.current.value = ''; });
  };
  return (
    <div data-tf-picture={f.k}>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 6, marginBottom: 4 }}>
        <span style={{ fontSize: 12, color: 'var(--text)', fontWeight: 600 }}>{f.label}</span>
        <div style={{ flex: 1 }} />
        <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{f.shape}</span>
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
        <div style={{ width: 96, height: 40, borderRadius: 4, border: '1px solid var(--border)', background: '#fff',
          display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', flexShrink: 0 }}>
          {value
            ? <img src={value} alt="" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
            : <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>None</span>}
        </div>
        <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
          <button type="button" className="btn sm" disabled={busy} onClick={() => inputRef.current && inputRef.current.click()}>
            {busy ? 'Preparing…' : 'Upload…'}
          </button>
          {sample && value !== sample && (
            <button type="button" className="btn sm ghost" onClick={() => { setError(null); setValue(f.k, sample); }}>Sample</button>
          )}
          {value && (
            <button type="button" className="btn sm ghost" onClick={() => { setError(null); setValue(f.k, ''); }}>Remove</button>
          )}
        </div>
        <input ref={inputRef} type="file" accept="image/*" hidden aria-label={`Upload ${f.label}`}
          onChange={e => onFile(e.target.files && e.target.files[0])} />
      </div>
      <div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 4 }}>
        Any picture is fitted to this shape and made smaller; it is never stretched.
      </div>
      {error && <div role="alert" style={{ fontSize: 12, color: 'var(--error-text)', marginTop: 4 }}>{error}</div>}
    </div>
  );
}

function TfRows({ field: f, rows, setValue, track }) {
  const set = (i, patch) => setValue(f.k, rows.map((r, j) => (j === i ? { ...r, ...patch } : r)));
  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 6, marginBottom: 4 }}>
        <span style={{ fontSize: 12, color: 'var(--text)', fontWeight: 600 }}>Rows (label · value)</span>
        <div style={{ flex: 1 }} />
        <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>up to {f.max}</span>
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
        {rows.map((r, i) => (
          <div key={i} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto auto', gap: 6, alignItems: 'center' }}>
            <input className="field" value={r.label} maxLength={40} aria-label={`Label ${i + 1}`}
              onChange={e => set(i, { label: e.target.value })} {...track(x => set(i, { label: x }))} />
            <input className="field" value={r.value} maxLength={50} aria-label={`Value ${i + 1}`}
              onChange={e => set(i, { value: e.target.value })} {...track(x => set(i, { value: x }))} />
            <label style={{ fontSize: 11.5, display: 'flex', alignItems: 'center', gap: 4 }}>
              <input type="checkbox" checked={!!r.red} onChange={e => set(i, { red: e.target.checked })} />red
            </label>
            <button type="button" className="btn sm ghost" disabled={rows.length <= 1}
              onClick={() => setValue(f.k, rows.filter((_, j) => j !== i))}>Remove</button>
          </div>
        ))}
      </div>
      <button type="button" className="btn sm" disabled={rows.length >= f.max} style={{ marginTop: 6 }}
        onClick={() => setValue(f.k, [...rows, { label: 'Label', value: 'Value', red: false }])}>Add a row</button>
    </div>
  );
}

/** What must change before saving; nothing at all when nothing must (Omar's QA check, 2026-11-05). */
function TfChecks({ problems }) {
  if (!problems.length) return null;
  return (
    <div data-tf-checks="" role="status" style={{
      padding: '12px 14px', borderRadius: 'var(--radius-md)', fontSize: 12.5, lineHeight: 1.5,
      background: 'var(--surface)', border: '1px solid var(--error-text)',
    }}>
      <strong>Please change {problems.length === 1 ? 'this' : 'these'} before saving:</strong>
      <ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
        {problems.map((p, i) => <li key={p.key + i}>{p.where ? <><strong>{p.where}</strong>: </> : null}{p.text}</li>)}
      </ul>
    </div>
  );
}

/** The page, as the course player shows it on a computer: inside its area, and nothing else around it. */
function TfPreview({ values }) {
  const TF = window.DynamoTemplateFormats;
  const settled = useTfSettled(values, 220);
  const boxRef = React.useRef(null);
  const [boxW, setBoxW] = React.useState(0);
  React.useEffect(() => {
    const el = boxRef.current;
    if (!el) return undefined;
    const measure = () => setBoxW(el.clientWidth || 0);
    measure();
    const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null;
    if (ro) ro.observe(el);
    return () => { if (ro) ro.disconnect(); };
  }, []);
  const scale = boxW ? Math.min(1, boxW / TF_AREA_WIDTH) : 1;
  const page = React.useMemo(() => TF.buildTemplate('email', settled, { mode: 'preview', metricsId: 'main' }), [settled]);
  const pageUrl = useTfPageUrl(page);
  // Keep the learner's place while the page is rebuilt: the frame reports its scroll (it is in an opaque origin,
  // so this screen cannot read it), and gets it back once the new page has loaded.
  const frameRef = React.useRef(null);
  const lastTop = React.useRef(0);
  React.useEffect(() => {
    const onMessage = (e) => {
      const m = e.data || {};
      if (m.kind === 'tpl-scroll' && frameRef.current && e.source === frameRef.current.contentWindow) lastTop.current = +m.top || 0;
    };
    window.addEventListener('message', onMessage);
    return () => window.removeEventListener('message', onMessage);
  }, []);
  const restoreScroll = () => {
    try { frameRef.current.contentWindow.postMessage({ kind: 'scroll-to', top: lastTop.current }, '*'); } catch (_) { /* gone */ }
  };
  return (
    <div data-tf-preview="">
      <div ref={boxRef} style={{ width: '100%' }}>
        <div style={{ width: Math.round(TF_AREA_WIDTH * scale), margin: '0 auto' }}>
          <div style={{ width: Math.round(TF_AREA_WIDTH * scale), height: Math.round(TF_AREA_HEIGHT * scale), overflow: 'hidden',
            outline: '1px dashed var(--border-strong)', background: '#eef0f3' }}>
            <iframe ref={frameRef} title="The template, as the learner sees it" onLoad={restoreScroll}
              src={window.safeFrameSrc(pageUrl)} sandbox="allow-scripts"
              /* allow-scripts WITHOUT allow-same-origin: the page runs its own tooltips and date in an OPAQUE
                 origin, so it cannot reach this app. Never add allow-same-origin here. */
              style={{ width: TF_AREA_WIDTH, height: TF_AREA_HEIGHT, border: 0, display: 'block',
                transform: `scale(${scale})`, transformOrigin: 'top left', background: '#eef0f3' }} />
          </div>
        </div>
      </div>
    </div>
  );
}

/**
 * The "Too long" check: the page in a frame of each of the four widths, 480 px high, out of sight. Each frame
 * reports its own height; a report from an older version of the page, or from a frame that is not one of these,
 * is ignored, so the check can only ever describe the page that would be saved.
 */
function TfLengthProbes({ values, build, onMeasured }) {
  const TF = window.DynamoTemplateFormats;
  const frames = React.useRef({});
  React.useEffect(() => {
    const got = {};
    const onMessage = (e) => {
      const m = e.data || {};
      if (m.kind !== 'tpl-metrics' || typeof m.id !== 'string') return;
      const parts = m.id.split(':');
      if (parts[0] !== 'chk' || +parts[2] !== build) return;
      const w = +parts[1];
      const frame = frames.current[w];
      if (!frame || e.source !== frame.contentWindow) return;
      got[w] = { scrollH: +m.scrollH || 0, clientH: +m.clientH || 0 };
      onMeasured({ build, metrics: { ...got } });
    };
    window.addEventListener('message', onMessage);
    return () => window.removeEventListener('message', onMessage);
  }, [build]);
  return (
    <div aria-hidden="true" style={{ position: 'fixed', left: -12000, top: 0, width: 1, height: 1, overflow: 'hidden' }}>
      {TF.LIMIT_SCREENS.map(s => (
        <TfProbeFrame key={s.w + ':' + build} w={s.w} build={build} values={values}
          frameRef={el => { frames.current[s.w] = el; }} />
      ))}
    </div>
  );
}
function TfProbeFrame({ w, build, values, frameRef }) {
  const TF = window.DynamoTemplateFormats;
  const page = React.useMemo(() => TF.buildTemplate('email', values, { mode: 'preview', metricsId: 'chk:' + w + ':' + build }), [values, w, build]);
  const url = useTfPageUrl(page);
  return (
    <iframe ref={frameRef} title={`Length check, ${w} px`} data-tf-width={w} data-tf-build={build}
      tabIndex={-1} src={window.safeFrameSrc(url)} sandbox="allow-scripts"
      style={{ width: w, height: TF_AREA_HEIGHT, border: 0, position: 'absolute', left: 0, top: 0 }} />
  );
}

Object.assign(window, { TfEmailEditor });
