// ═══════════════════════════════════════════════ LEON Word · LEON Presentation
//
// Two editors, one file, because they share the parts that matter: the LEON
// data engine (a field or a table that reads a live record instead of a number
// somebody typed), the asset picker (renders, supplier finishes, project
// photos — always BY REFERENCE), and the export path.
//
// The reason to write a proposal in here rather than in Word is that the
// document knows which job it belongs to. A contract value in a LEON field is
// the contract value; a contract value typed into Word is the contract value on
// the day it was typed. Everything below follows from that one difference.
//
// STORAGE is the hard constraint. localStorage on this machine holds about
// 13 MB for the WHOLE app. So:
//   · an image is a REFERENCE (render id, finish ref, project photo URL), never
//     a second copy of the bytes;
//   · an upload is downscaled before it is stored, and the editor shows what
//     the document weighs so nobody discovers the ceiling by hitting it;
//   · version history is BOUNDED (officeWordVersionLimit) and snapshots are taken
//     at decision points, not per keystroke.
//
// Not built, and said plainly where someone would look for it: live
// co-editing (there is no server), .docx / .pptx import or export (that needs a
// converter this browser does not have — SheetJS reads spreadsheets only), and
// AI writing. Nothing here pretends to do any of the three.

// ═══════════════════════════════ Shared theme ═════════════════════════════
//
// Office documents do not store colours. They store THEME SLOTS — twelve of
// them — and a style says `accent1` rather than `#8B5E34`. That one
// indirection is the entire reason "apply a theme" restyles a whole document
// instead of recolouring it element by element, and it is why both editors in
// this file read the same object, exactly as Word and PowerPoint read the same
// theme part out of the same package.
//
// makeOfficeTheme, OFFICE_THEME_OFFICE, WORD_STYLE_SET, WORD_PAGE_DEFAULT,
// SLIDE_LAYOUTS, SLIDE_FURNITURE and makeSlideMaster are the FIXED reference
// model in data.jsx, read from the blank Word.docx / Presentation.pptx the
// client supplied. Nothing here redefines any of them — and every one of them
// is read INSIDE a function rather than at module-evaluation time, because a
// const in a sibling script is in its temporal dead zone until that script has
// run and `typeof` on it throws rather than answering.
//
// To be plain about what this is NOT: matching Office's MODEL is not the same
// as reading or writing Office's FORMAT. There is still no .docx or .pptx
// import or export here, and both editors keep saying so.

const OFFICE_THEME_SLOTS = [
  { key: 'dk1', label: 'Text / Dark 1', hint: 'Body text. Office writes this one as the reader’s own black.' },
  { key: 'lt1', label: 'Background / Light 1', hint: 'The page or slide behind everything.' },
  { key: 'dk2', label: 'Text / Dark 2', hint: 'Secondary text — subtitles, minor headings.' },
  { key: 'lt2', label: 'Background / Light 2', hint: 'Panels, table shading, quiet fills.' },
  { key: 'accent1', label: 'Accent 1', hint: 'Headings and the first chart series.' },
  { key: 'accent2', label: 'Accent 2' },
  { key: 'accent3', label: 'Accent 3' },
  { key: 'accent4', label: 'Accent 4' },
  { key: 'accent5', label: 'Accent 5' },
  { key: 'accent6', label: 'Accent 6' },
  { key: 'hlink', label: 'Hyperlink' },
  { key: 'folHlink', label: 'Followed hyperlink' },
];
const OFFICE_THEME_SLOT_KEYS = OFFICE_THEME_SLOTS.map(s => s.key);

// Office's own convention: dk1 is the TEXT colour and lt1 the BACKGROUND, so a
// dark theme is expressed by swapping what those two hold — not by a `dark`
// flag that every drawing site then has to remember to check.
const OFFICE_THEME_LEON_DARK = {
  id: 'leon-dark', name: 'LEON Dark',
  majorFont: 'Century Gothic Leon', minorFont: 'Century Gothic Leon',
  dk1: '#F7F3EE', lt1: '#161311', dk2: '#C9BDB1', lt2: '#241E1A',
  accent1: '#B08968', accent2: '#8B5E34', accent3: '#8FA382',
  accent4: '#6FA0B0', accent5: '#C97B4E', accent6: '#A8937A',
  hlink: '#8FBECD', folHlink: '#C79BB4',
};

// Read at CALL time, never at module-evaluation time — see the note above.
// Built once and kept: makeOfficeTheme mints an id from uid() on every call,
// and a theme whose identity changed on every render would never compare equal
// to the id a document stores.
let officeThemeShipped = null;
function officeThemeBuiltins() {
  if (!officeThemeShipped) {
    let leon = null, office = null;
    try { leon = makeOfficeTheme({ id: 'leon' }); } catch (e) { leon = null; }
    try { office = (typeof OFFICE_THEME_OFFICE !== 'undefined') ? OFFICE_THEME_OFFICE : null; } catch (e) { office = null; }
    const list = [leon, OFFICE_THEME_LEON_DARK, office].filter(Boolean);
    if (!list.length) return [OFFICE_THEME_LEON_DARK];
    officeThemeShipped = list;
  }
  return officeThemeShipped;
}
// Everything a document can be set to: the shipped themes, plus whatever the
// team has duplicated and edited inside this document.
function officeThemeList(body) {
  const custom = ((body && body.themes) || []).filter(t => t && t.id);
  const builtin = officeThemeBuiltins().filter(b => !custom.some(c => c.id === b.id));
  return builtin.concat(custom);
}
function officeThemeOf(body) {
  const list = officeThemeList(body);
  return list.find(t => t.id === ((body && body.themeId) || 'leon')) || list[0] || OFFICE_THEME_LEON_DARK;
}
function officeThemeIsBuiltin(id) { return officeThemeBuiltins().some(t => t.id === id); }

// ── Colour arithmetic ─────────────────────────────────────────────────────
// Small on purpose. A theme declares twelve colours; a panel fill, a hairline
// rule and "which of black or white reads on this" are DERIVED from them, so
// that a theme stays twelve decisions rather than thirty.
function officeThemeHex(v) {
  let s = String(v || '').trim();
  if (s[0] !== '#') return null;
  s = s.slice(1);
  if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
  return /^[0-9a-fA-F]{6}$/.test(s) ? '#' + s.toLowerCase() : null;
}
function officeThemeRgb(v) {
  const h = officeThemeHex(v);
  if (!h) return [0, 0, 0];
  return [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
}
function officeThemeLum(v) {
  const [r, g, b] = officeThemeRgb(v);
  return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
}
function officeThemeMix(a, b, t) {
  const A = officeThemeRgb(a), B = officeThemeRgb(b);
  const f = Math.max(0, Math.min(1, t));
  const c = i => Math.round(A[i] + (B[i] - A[i]) * f).toString(16).padStart(2, '0');
  return '#' + c(0) + c(1) + c(2);
}
// What reads on top of a filled shape. Guessing white every time is how a
// section divider in a pale accent ends up unreadable on a projector.
function officeThemeOn(v, theme) {
  return officeThemeLum(v) > 0.58 ? (theme ? theme.dk1 : '#161311') : (theme ? theme.lt1 : '#ffffff');
}
// THE resolver. A value is either a slot name (follows the theme) or a literal
// (deliberately fixed). Both stay valid for ever — someone will always want one
// specific colour, and taking that away would just push them back into Word.
function officeThemeResolve(theme, value, fallback) {
  if (value === null || value === undefined || value === '') return fallback;
  const v = String(value);
  if (theme && OFFICE_THEME_SLOT_KEYS.includes(v)) return theme[v] || fallback;
  if (v[0] === '#' || v.slice(0, 3) === 'rgb') return v;
  return fallback;
}
function officeThemeIsSlot(value) { return OFFICE_THEME_SLOT_KEYS.includes(String(value)); }
// A theme names two faces; the stack behind each is the app's own, because a
// browser can only use a font that is actually present.
function officeThemeFontStack(theme, which) {
  const name = (which === 'major' ? (theme && theme.majorFont) : (theme && theme.minorFont)) || 'Century Gothic Leon';
  return '"' + name + '", "Century Gothic Leon", "Aptos", Poppins, "Segoe UI", sans-serif';
}

// A theme is a dozen strings, so a duplicate costs a document nothing.
function officeThemeDuplicate(theme, name) {
  const copy = Object.assign({}, theme, { id: uid('thm'), name: name || (theme.name + ' copy') });
  return copy;
}

// ── The theme editor, shared by both editors ──────────────────────────────
// Two fonts and twelve colours. Nothing more, because that is the whole of an
// Office theme's colour and font model and inventing a thirteenth slot would
// make our themes untranslatable back to it.
function OfficeThemeModal({ open, onClose, body, editable, onApplyTheme, onSaveThemes, note }) {
  const themes = officeThemeList(body);
  const [pick, setPick] = useState(null);
  const activeId = (body && body.themeId) || 'leon';
  const editing = pick ? themes.find(t => t.id === pick) : null;
  useEffect(() => { if (open) setPick(null); }, [open]);

  function writeTheme(id, fields) {
    const custom = ((body && body.themes) || []).slice();
    const at = custom.findIndex(t => t.id === id);
    if (at < 0) return;
    custom[at] = Object.assign({}, custom[at], fields);
    onSaveThemes(custom);
  }
  function duplicate(t) {
    const copy = officeThemeDuplicate(t, t.name + ' (edited)');
    onSaveThemes((((body && body.themes) || []).slice()).concat([copy]));
    setPick(copy.id);
  }
  function remove(t) {
    onSaveThemes(((body && body.themes) || []).filter(x => x.id !== t.id));
    setPick(null);
  }

  return (
    <Modal open={open} onClose={onClose} wide title="Theme">
      <p className="text-xs text-[var(--leon-black)]/55 mb-3">
        A theme is <strong>two fonts and twelve colours</strong> — the same twelve slots Word and PowerPoint store.
        Styles and elements reference a slot by name, so changing the theme changes everything that follows it and
        leaves alone everything somebody set by hand. Applying one is a single act, and a version is saved first so
        it can be undone.
      </p>
      <div className="grid sm:grid-cols-2 gap-2">
        {themes.map(t => (
          <div key={t.id}
            className={`border rounded-lg px-3 py-2.5 ${t.id === activeId ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)]'}`}>
            <div className="flex items-baseline justify-between gap-2">
              <span className="text-sm font-bold truncate">{t.name}</span>
              {t.id === activeId && <span className="text-[10px] uppercase tracking-wide text-[var(--leon-brown)] font-semibold shrink-0">In use</span>}
            </div>
            <div className="text-[10px] text-[var(--leon-black)]/45 truncate">{t.majorFont} · {t.minorFont}</div>
            <div className="flex gap-0.5 mt-1.5">
              {OFFICE_THEME_SLOT_KEYS.map(k => (
                <span key={k} title={k} className="w-4 h-4 rounded-sm border border-black/10" style={{ background: t[k] }} />
              ))}
            </div>
            {editable && (
              <div className="flex flex-wrap gap-2 mt-2 text-[11px]">
                {t.id !== activeId && <button className="underline hover:text-[var(--leon-brown)]" onClick={() => onApplyTheme(t.id)}>Apply</button>}
                {officeThemeIsBuiltin(t.id)
                  ? <button className="underline hover:text-[var(--leon-brown)]" onClick={() => duplicate(t)}>Duplicate &amp; edit</button>
                  : <>
                      <button className="underline hover:text-[var(--leon-brown)]" onClick={() => setPick(pick === t.id ? null : t.id)}>
                        {pick === t.id ? 'Close' : 'Edit'}
                      </button>
                      <button className="underline hover:text-[var(--leon-red)]" onClick={() => remove(t)}
                        disabled={t.id === activeId}>Delete</button>
                    </>}
              </div>
            )}
          </div>
        ))}
      </div>

      {editing && editable && (
        <div className="mt-4 border-t border-[var(--leon-line)] pt-3">
          <Field label="Theme name">
            <TextInput value={editing.name || ''} onChange={e => writeTheme(editing.id, { name: e.target.value })} />
          </Field>
          <div className="grid sm:grid-cols-2 gap-3 mt-3">
            <Field label="Major font (headings)" hint="Named only. A browser can use a face that is installed or shipped with the app; it cannot invent one.">
              <TextInput value={editing.majorFont || ''} onChange={e => writeTheme(editing.id, { majorFont: e.target.value })} />
            </Field>
            <Field label="Minor font (body)">
              <TextInput value={editing.minorFont || ''} onChange={e => writeTheme(editing.id, { minorFont: e.target.value })} />
            </Field>
          </div>
          <div className="grid sm:grid-cols-2 gap-2 mt-3">
            {OFFICE_THEME_SLOTS.map(slot => (
              <div key={slot.key} className="flex items-center gap-2 border border-[var(--leon-line)] rounded-lg px-2 py-1.5">
                <input type="color" value={officeThemeHex(editing[slot.key]) || '#000000'}
                  onChange={e => writeTheme(editing.id, { [slot.key]: e.target.value })}
                  className="w-7 h-7 rounded border border-[var(--leon-line)] bg-white shrink-0" />
                <div className="min-w-0 flex-1">
                  <div className="text-[11px] font-semibold truncate">{slot.label}</div>
                  <div className="text-[10px] text-[var(--leon-black)]/40 truncate">{slot.key}{slot.hint ? ' · ' + slot.hint : ''}</div>
                </div>
                <input value={editing[slot.key] || ''} onChange={e => writeTheme(editing.id, { [slot.key]: e.target.value })}
                  className="w-20 shrink-0 rounded border border-[var(--leon-line)] px-1 py-0.5 text-[11px] font-mono" />
              </div>
            ))}
          </div>
        </div>
      )}

      <div className="mt-4 text-[11px] text-[var(--leon-black)]/45 leading-relaxed border-t border-[var(--leon-line)] pt-3">
        {note}
        {' '}These are the same twelve slots an Office theme carries, so a look built here describes itself in Office’s
        own vocabulary. It is still <strong>not</strong> a .docx or .pptx theme file — the Hub matches Office’s model,
        it does not read or write Office’s format.
      </div>
    </Modal>
  );
}

// ── Page geometry ─────────────────────────────────────────────────────────
// Millimetres, because makeWordBody's margins are already in millimetres and a
// second unit in the same object is how a margin ends up ten times too wide.
const WORD_PAGE_SIZES = {
  Letter: { w: 215.9, h: 279.4, label: 'Letter · 8.5 × 11 in' },
  A4: { w: 210, h: 297, label: 'A4 · 210 × 297 mm' },
  Legal: { w: 215.9, h: 355.6, label: 'Legal · 8.5 × 14 in' },
};
const WORD_MM_PX = 3.7795;                        // CSS px per mm at 96 dpi
// LEON Office's home screen owns the version convention (newest first, capped,
// and it tells the user when one was dropped). These editors defer to it rather
// than keeping a second, differently-ordered history in the same array.
// Read at CALL time, never at module-evaluation time: a const declared in a
// sibling script is in its temporal dead zone until that script has run, and
// `typeof` on a TDZ binding throws rather than answering 'undefined' — the same
// trap that once blanked the whole app.
function officeWordVersionLimit() {
  try { return typeof OFFICE_VERSION_LIMIT !== 'undefined' ? OFFICE_VERSION_LIMIT : 12; } catch (e) { return 12; }
}
const WORD_UPLOAD_MAX_PX = 1100;                  // an uploaded image is downscaled to this
const WORD_DOC_SIZE_WARN = 400 * 1024;            // one document past this is worth a word

// ── Named styles ──────────────────────────────────────────────────────────
// The point of a style over ad-hoc formatting is that restyling the document
// is one action — so a block records WHICH style it is, and the look lives in
// the style, not on the block.
//
// The catalogue is WORD_STYLE_SET (data.jsx), which is Word's real built-in
// set: Title, Subtitle, Heading 1-9, Quote, Intense Quote, List Paragraph,
// Caption and the two CHARACTER styles. `kind` is the field that matters —
// Word separates PARAGRAPH styles (the whole block) from CHARACTER styles
// (a run inside one), and Intense Emphasis cannot be a paragraph style.
//
// This editor's first version had eight styles of its own, keyed by their
// display name ('Heading 1'). Old documents still carry those strings, so the
// name → id map below is applied AT LOOKUP TIME rather than by rewriting the
// blocks: nothing is migrated destructively, and a document written before
// this change opens with every style still attached.
const WORD_STYLE_ALIAS = {
  'Body': 'Normal', 'Normal': 'Normal', 'Title': 'Title', 'Subtitle': 'Subtitle',
  'Heading 1': 'Heading1', 'Heading 2': 'Heading2', 'Heading 3': 'Heading3', 'Heading 4': 'Heading4',
  'Heading 5': 'Heading5', 'Heading 6': 'Heading6', 'Heading 7': 'Heading7',
  'Heading 8': 'Heading8', 'Heading 9': 'Heading9',
  'Caption': 'Caption', 'Quote': 'Quote', 'Intense Quote': 'IntenseQuote',
  'List Paragraph': 'ListParagraph',
};
const WORD_HEADING_STYLE = {
  1: 'Heading1', 2: 'Heading2', 3: 'Heading3', 4: 'Heading4', 5: 'Heading5',
  6: 'Heading6', 7: 'Heading7', 8: 'Heading8', 9: 'Heading9',
};
// Which face a style asks the theme for. Word's own split: display type for the
// title and the headings, the reading face for everything else.
const WORD_MAJOR_STYLES = ['Title', 'Subtitle', 'Heading1', 'Heading2', 'Heading3', 'Heading4',
  'Heading5', 'Heading6', 'Heading7', 'Heading8', 'Heading9'];
function officeWordBuiltinStyles() {
  try { return (typeof WORD_STYLE_SET !== 'undefined' && WORD_STYLE_SET) ? WORD_STYLE_SET : []; } catch (e) { return []; }
}
function officeWordPageDefault() {
  try {
    if (typeof WORD_PAGE_DEFAULT !== 'undefined' && WORD_PAGE_DEFAULT) return WORD_PAGE_DEFAULT;
  } catch (e) { /* falls through to the literal below */ }
  return { pageSize: 'Letter', orientation: 'portrait',
    margins: { top: 25.4, right: 25.4, bottom: 25.4, left: 25.4 },
    headerDistance: 12.7, footerDistance: 12.7, gutter: 0 };
}
// A stored style reference, normalised. Accepts an id, one of the old display
// names, or nothing at all.
function officeWordStyleId(ref) {
  if (!ref) return 'Normal';
  const s = String(ref);
  if (WORD_STYLE_ALIAS[s]) return WORD_STYLE_ALIAS[s];
  return s;
}
// The live style list for one document: the built-ins with this document's
// edits merged over them, then the styles this document invented. A modified
// built-in keeps `builtin: true` and gains `modified: true`, which is what
// makes "reset" possible rather than "lost".
function officeWordStyleDefs(body) {
  const over = (body && body.styleOverrides) || {};
  const pick = def => {
    const o = over[def.id] || over[def.name];
    return o ? Object.assign({}, def, o, { modified: true }) : def;
  };
  const built = officeWordBuiltinStyles().map(pick);
  const custom = ((body && body.customStyles) || []).map(s => Object.assign({ kind: 'paragraph' }, s, { builtin: false }));
  return built.concat(custom);
}
function officeWordStyleDef(body, ref) {
  const id = officeWordStyleId(ref);
  const list = officeWordStyleDefs(body);
  return list.find(s => s.id === id) || list.find(s => s.id === 'Normal')
    || { id: 'Normal', name: 'Normal', kind: 'paragraph', sizePt: 12 };
}
function officeWordParagraphStyles(body) { return officeWordStyleDefs(body).filter(s => s.kind !== 'character'); }
function officeWordCharacterStyles(body) { return officeWordStyleDefs(body).filter(s => s.kind === 'character'); }

// One style definition turned into CSS, against the document's theme. Every
// colour goes through officeThemeResolve, so a style that says `accent1`
// follows the theme and one that says `#b83b3b` stays exactly that red.
// Word stores spacing and indent in points; so does this.
function officeWordStyleCss(def, theme) {
  const major = WORD_MAJOR_STYLES.includes(def.id);
  return {
    fontFamily: officeThemeFontStack(theme, def.font === 'major' ? 'major' : def.font === 'minor' ? 'minor' : (major ? 'major' : 'minor')),
    fontSize: (def.sizePt || 12) + 'pt',
    fontWeight: def.bold ? 700 : 400,
    fontStyle: def.italic ? 'italic' : 'normal',
    textDecoration: def.underline ? 'underline' : undefined,
    fontVariant: def.smallCaps ? 'small-caps' : undefined,
    color: officeThemeResolve(theme, def.color, theme.dk1),
    letterSpacing: def.letterSpacing ? def.letterSpacing + 'px' : undefined,
  };
}
// The character styles as real CSS rules, injected once per document. A run
// carries `class="wcs-<id>"` and nothing else, so the run FOLLOWS the style —
// which is the whole difference between a character style and pressing italic.
function officeWordCharacterCss(body, theme) {
  return officeWordCharacterStyles(body).map(def => {
    const css = officeWordStyleCss(def, theme);
    const parts = [];
    if (def.sizePt) parts.push('font-size: ' + def.sizePt + 'pt');
    parts.push('font-weight: ' + (def.bold ? 700 : 'inherit'));
    if (def.italic) parts.push('font-style: italic');
    if (def.underline) parts.push('text-decoration: underline');
    if (def.smallCaps) parts.push('font-variant: small-caps');
    parts.push('color: ' + css.color);
    return '.wcs-' + def.id + ' { ' + parts.join('; ') + '; }';
  }).join('\n');
}

const WORD_BLOCK_KINDS = [
  { type: 'paragraph', label: 'Paragraph', icon: '¶' },
  { type: 'heading', label: 'Heading', icon: 'H' },
  { type: 'bullet', label: 'Bulleted list', icon: '•' },
  { type: 'number', label: 'Numbered list', icon: '1.' },
  { type: 'quote', label: 'Quote', icon: '❝' },
  { type: 'table', label: 'Table', icon: '▦' },
  { type: 'image', label: 'Image', icon: '🖼️' },
  { type: 'data', label: 'LEON data table', icon: '🔗' },
  { type: 'toc', label: 'Table of contents', icon: '☰' },
  { type: 'divider', label: 'Divider', icon: '—' },
  { type: 'pagebreak', label: 'Page break', icon: '⤓' },
];

// ── Sanitising ────────────────────────────────────────────────────────────
// A block holds INLINE html, not a document blob: the model is still an ordered
// list of typed blocks, and the html only ever describes the run of text inside
// one of them. That is what makes bold/colour/superscript workable in a browser
// with no build step, while a table of contents, a data block or a page break
// stays a real object we can walk, search, export and refresh.
const WORD_OK_TAGS = { B: 1, STRONG: 1, I: 1, EM: 1, U: 1, S: 1, STRIKE: 1, SUB: 1, SUP: 1, SPAN: 1, BR: 1, FONT: 1, A: 1 };
const WORD_OK_STYLE = ['color', 'background-color', 'font-size', 'font-weight', 'font-style', 'text-decoration', 'vertical-align'];

function officeWordSafeStyle(value) {
  const keep = [];
  String(value || '').split(';').forEach(part => {
    const i = part.indexOf(':');
    if (i < 0) return;
    const prop = part.slice(0, i).trim().toLowerCase();
    const val = part.slice(i + 1).trim();
    // url() is the one thing a style attribute can use to reach the network.
    if (WORD_OK_STYLE.includes(prop) && val && !/url\s*\(/i.test(val)) keep.push(prop + ': ' + val);
  });
  return keep.join('; ');
}

function officeWordSanitize(html) {
  const box = document.createElement('div');
  box.innerHTML = String(html == null ? '' : html);
  const walk = node => {
    Array.prototype.slice.call(node.childNodes).forEach(child => {
      if (child.nodeType === 3) return;
      if (child.nodeType !== 1) { child.remove(); return; }
      if (!WORD_OK_TAGS[child.tagName]) {
        while (child.firstChild) node.insertBefore(child.firstChild, child);
        child.remove();
        return;
      }
      const names = Array.prototype.slice.call(child.attributes).map(a => a.name.toLowerCase());
      names.forEach(n => {
        const keep = n === 'style' || n === 'data-field' || n === 'data-src'
          || (n === 'class' && child.tagName === 'SPAN')
          || (child.tagName === 'FONT' && (n === 'color' || n === 'size'))
          || (child.tagName === 'A' && n === 'href');
        if (!keep) child.removeAttribute(n);
      });
      if (child.hasAttribute('style')) child.setAttribute('style', officeWordSafeStyle(child.getAttribute('style')));
      if (child.tagName === 'A') child.setAttribute('rel', 'noopener noreferrer');
      walk(child);
    });
  };
  walk(box);
  return box.innerHTML;
}

function officeWordPlain(html) {
  const box = document.createElement('div');
  box.innerHTML = String(html == null ? '' : html).replace(/<br\s*\/?>/gi, ' ');
  return (box.textContent || '').replace(/\s+/g, ' ').trim();
}
function officeWordEscape(s) {
  return String(s == null ? '' : s)
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function officeWordCountWords(text) {
  const t = String(text || '').trim();
  return t ? t.split(/\s+/).length : 0;
}

// ── Word-level diff, for the track-changes markup view ────────────────────
// Small on purpose. A block is a paragraph, not a chapter; past a few hundred
// words a word diff stops being readable anyway, and saying "this paragraph was
// rewritten" is more honest than a wall of alternating ins/del.
function officeWordDiffWords(before, after) {
  const a = String(before || '').split(/(\s+)/).filter(x => x !== '');
  const b = String(after || '').split(/(\s+)/).filter(x => x !== '');
  if (a.length > 400 || b.length > 400) return null;
  const n = a.length, m = b.length;
  const lcs = [];
  for (let i = 0; i <= n; i++) lcs.push(new Array(m + 1).fill(0));
  for (let i = n - 1; i >= 0; i--) {
    for (let j = m - 1; j >= 0; j--) {
      lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
    }
  }
  const out = [];
  let i = 0, j = 0;
  const push = (kind, text) => {
    const last = out[out.length - 1];
    if (last && last.kind === kind) last.text += text; else out.push({ kind, text });
  };
  while (i < n && j < m) {
    if (a[i] === b[j]) { push('same', a[i]); i++; j++; }
    else if (lcs[i + 1][j] >= lcs[i][j + 1]) { push('del', a[i]); i++; }
    else { push('ins', b[j]); j++; }
  }
  while (i < n) { push('del', a[i]); i++; }
  while (j < m) { push('ins', b[j]); j++; }
  return out;
}

// ── Body normalisation ────────────────────────────────────────────────────
// makeWordBody (data.jsx) is the FIXED shape and is never redefined here. The
// keys below are the editor's own additions, filled in on read so an older
// document opens without a migration pass and the factory stays untouched.
function officeWordBody(doc) {
  const b = (doc && doc.body) || {};
  const page = officeWordPageDefault();
  return {
    pageSize: b.pageSize || page.pageSize,
    orientation: b.orientation || page.orientation,
    margins: b.margins || Object.assign({}, page.margins),
    // Office's own half inch, in millimetres, and we simply did not have it.
    headerDistance: b.headerDistance === undefined ? page.headerDistance : b.headerDistance,
    footerDistance: b.footerDistance === undefined ? page.footerDistance : b.footerDistance,
    gutter: b.gutter === undefined ? (page.gutter || 0) : b.gutter,
    header: b.header || '',
    footer: b.footer || '',
    blocks: Array.isArray(b.blocks) ? b.blocks : [],
    // editor additions
    headerOn: b.headerOn !== false,
    footerOn: b.footerOn !== false,
    pageNumbers: b.pageNumbers === undefined ? true : !!b.pageNumbers,
    cover: b.cover || null,                 // { title, subtitle, meta[], imageRef }
    trackChanges: !!b.trackChanges,
    showMarkup: b.showMarkup === undefined ? true : !!b.showMarkup,
    changes: Array.isArray(b.changes) ? b.changes : [],
    // Style edits, keyed by style id. Older documents keyed them by display
    // name; officeWordStyleDefs reads both, so nothing had to be rewritten.
    styleOverrides: b.styleOverrides || {},
    customStyles: Array.isArray(b.customStyles) ? b.customStyles : [],
    // A document with no theme is a document written before themes existed:
    // it lands on LEON, which is the look it already had.
    themeId: b.themeId || 'leon',
    themes: Array.isArray(b.themes) ? b.themes : [],
    defaultFont: b.defaultFont || 'brand',
  };
}

function officeWordMakeBlock(type, data) {
  const base = { id: uid('wb'), type, style: 'Normal', align: 'left', indent: 0, lineSpacing: 1.45 };
  if (type === 'heading') { base.level = 1; base.style = 'Heading1'; base.html = ''; }
  else if (type === 'quote') { base.style = 'Quote'; base.html = ''; }
  else if (type === 'table') {
    base.headerRow = true; base.borders = 'all'; base.widths = null;
    base.rows = [
      [officeWordCell('Column', true), officeWordCell('Column', true), officeWordCell('Column', true)],
      [officeWordCell(''), officeWordCell(''), officeWordCell('')],
      [officeWordCell(''), officeWordCell(''), officeWordCell('')],
    ];
  } else if (type === 'image') {
    base.ref = null; base.caption = ''; base.width = 70; base.align = 'center';
  } else if (type === 'data') {
    base.dataKey = ''; base.args = {}; base.caption = ''; base.frozen = false; base.snapshot = null;
  } else if (type === 'toc') {
    base.levels = 3; base.entries = []; base.refreshedAt = null;
  } else if (type === 'pagebreak' || type === 'divider') {
    // nothing else to carry
  } else {
    base.html = '';
  }
  return Object.assign(base, data || {});
}
function officeWordCell(text, header) {
  return { id: uid('wc'), html: officeWordEscape(text || ''), bg: header ? '#f7f3ee' : '', colSpan: 1, rowSpan: 1, hidden: false };
}
const WORD_TEXT_BLOCKS = ['paragraph', 'heading', 'bullet', 'number', 'quote'];
function officeWordIsText(b) { return WORD_TEXT_BLOCKS.includes(b.type); }

// A document's weight, so the storage ceiling is visible rather than
// discovered. JSON length is close enough to the stored string to be useful.
// One snapshot, in the shape and order the Office home screen already uses.
// Returns the whole versions array, ready to hand to onChange.
function officeWordCaptureVersion(doc, note, by) {
  if (typeof officeHomeCaptureVersion === 'function') return officeHomeCaptureVersion(doc, note, by).versions;
  const n = (doc.versions || []).length ? Math.max.apply(null, doc.versions.map(v => v.n)) + 1 : 1;
  const list = [{ n, date: todayISO(), by: by || '', note: note || '', body: cloneDeep(doc.body) }].concat(doc.versions || []);
  return list.slice(0, officeWordVersionLimit());
}

function officeWordDocBytes(doc) {
  try { return JSON.stringify(doc).length; } catch (e) { return 0; }
}
function officeWordFmtBytes(n) {
  if (n < 1024) return n + ' B';
  if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
  return (n / 1024 / 1024).toFixed(1) + ' MB';
}

// An upload is downscaled BEFORE it is stored. Refusing a big file just sends
// the person off to resize it themselves; nobody should have to know what an
// image weighs to put one in a proposal.
function officeWordShrinkImage(file) {
  return new Promise(resolve => {
    const reader = new FileReader();
    reader.onload = () => {
      const img = new Image();
      img.onload = () => {
        const scale = Math.min(1, WORD_UPLOAD_MAX_PX / Math.max(img.width, img.height));
        const c = document.createElement('canvas');
        c.width = Math.max(1, Math.round(img.width * scale));
        c.height = Math.max(1, Math.round(img.height * scale));
        const g = c.getContext('2d');
        g.fillStyle = '#ffffff'; g.fillRect(0, 0, c.width, c.height);
        g.drawImage(img, 0, 0, c.width, c.height);
        let out;
        try { out = c.toDataURL('image/jpeg', 0.82); } catch (e) { out = reader.result; }
        resolve({ url: out.length < String(reader.result).length ? out : reader.result, w: c.width, h: c.height });
      };
      img.onerror = () => resolve({ url: reader.result, w: 0, h: 0 });
      img.src = reader.result;
    };
    reader.onerror = () => resolve(null);
    reader.readAsDataURL(file);
  });
}

// ═══════════════════════════════ LEON data engine ═════════════════════════
// The reason this software exists. A field reads a live record; it shows its
// own source; Refresh says what CHANGED before it overwrites anything; and
// "Convert to text" freezes it deliberately for a document that has been
// issued. Nothing here ever silently rewrites an issued document.

function officeWordScopeOf(ctx, doc) {
  const projects = ctx.projects || [];
  const project = doc && doc.projectId ? projects.find(p => p.id === doc.projectId) || null : null;
  const account = project ? (ctx.accounts || []).find(a => a.id === project.accountId) || null
    : (doc && doc.accountId ? (ctx.accounts || []).find(a => a.id === doc.accountId) || null : null);
  const vendor = doc && doc.vendorId ? (ctx.vendors || []).find(v => v.id === doc.vendorId) || null : null;
  const scope = project && doc && doc.scopeId ? (project.scopes || []).find(s => s.id === doc.scopeId) || null : null;
  return { ctx, doc, project, account, vendor, scope, company: ctx.companyProfile || {}, user: ctx.currentUser || null };
}

function officeWordContact(project, role) {
  const c = ((project || {}).contacts || {})[role] || {};
  return c.company || c.person || '';
}
function officeWordTeamName(ctx, project, role) {
  if (!project) return '';
  const dept = ctx.activeDepartment;
  let id = null;
  try { id = typeof teamMemberFor === 'function' ? teamMemberFor(project, role, dept) : null; } catch (e) { id = null; }
  if (!id) {
    const teams = project.teams || {};
    Object.keys(teams).forEach(d => { if (!id && teams[d] && teams[d][role]) id = teams[d][role]; });
  }
  const person = id ? (ctx.teamDirectory || []).find(p => p.id === id) : null;
  return person ? person.name : '';
}
function officeWordMoney(sc, n) {
  // Cost and value follow the same gate as everywhere else in the Hub. A field
  // that quietly prints a contract value to someone who cannot see financials
  // would be a hole punched through the permission model by a text document.
  if (!sc.ctx.canSeeFin) return '—';
  return fmtMoney(n || 0);
}

// Financial and commercial data is OFFERED only to people whose role can see
// it — not offered and then blanked. Showing "Contract value" to a coordinator
// and rendering it as "—" still tells them the number exists and clutters the
// picker with things they can never use. `fin: true` marks an item as
// financial; `officeFinOk` is the one place the rule is read.
function officeFinOk(item, sc) {
  if (!item || !item.fin) return true;
  return !!(sc && sc.ctx && sc.ctx.canSeeFin);
}

const WORD_FIELDS = [
  { key: 'project.name', label: 'Project name', group: 'Project',
    get: sc => (sc.project ? sc.project.name : ''), src: 'Project record' },
  { key: 'project.number', label: 'Project number', group: 'Project',
    get: sc => (sc.project ? sc.project.projectNumber : ''), src: 'Project record' },
  { key: 'project.address', label: 'Project address', group: 'Project',
    get: sc => (sc.project ? sc.project.address : ''), src: 'Project record' },
  { key: 'project.type', label: 'Project type', group: 'Project',
    get: sc => (sc.project ? sc.project.projectType : ''), src: 'Project record' },
  { key: 'project.status', label: 'Pipeline status', group: 'Project',
    get: sc => (sc.project ? sc.project.pipelineStatus : ''), src: 'Project record' },
  { key: 'project.department', label: 'Department', group: 'Project',
    get: sc => (sc.project ? (sc.project.companyDepartment || []).join(' / ') : ''), src: 'Project record' },
  { key: 'project.client', label: 'Client / account', group: 'Project',
    get: sc => (sc.account ? sc.account.name : ''), src: 'Account record' },
  { key: 'project.gc', label: 'General contractor', group: 'Project',
    get: sc => officeWordContact(sc.project, 'General Contractor'), src: 'Project contacts' },
  { key: 'project.architect', label: 'Architect', group: 'Project',
    get: sc => officeWordContact(sc.project, 'Architect'), src: 'Project contacts' },
  { key: 'project.designer', label: 'Designer', group: 'Project',
    get: sc => officeWordContact(sc.project, 'Designer'), src: 'Project contacts' },
  { key: 'project.owner', label: 'Owner', group: 'Project',
    get: sc => officeWordContact(sc.project, 'Owner'), src: 'Project contacts' },
  { key: 'project.coordinator', label: 'Project coordinator', group: 'Project',
    get: sc => officeWordTeamName(sc.ctx, sc.project, 'Project Coordinator'), src: 'Project team' },
  { key: 'project.salesperson', label: 'Sales person', group: 'Project',
    get: sc => officeWordTeamName(sc.ctx, sc.project, 'Sales Person'), src: 'Project team' },
  { key: 'project.scopeList', label: 'Scope list', group: 'Project',
    get: sc => (sc.project ? (sc.project.scopes || []).map(s => s.name).join(', ') : ''), src: 'Project scopes' },
  { key: 'project.scopeCount', label: 'Number of scopes', group: 'Project',
    get: sc => (sc.project ? String((sc.project.scopes || []).length) : ''), src: 'Project scopes' },
  { key: 'project.startDate', label: 'Earliest scope start', group: 'Dates',
    get: sc => {
      const all = ((sc.project || {}).scopes || []).map(s => (s.stages || [])[0]).filter(Boolean).map(s => s.plannedStart).filter(Boolean);
      return all.length ? fmtDate(all.sort()[0]) : '';
    }, src: 'Scope schedules' },
  { key: 'project.endDate', label: 'Latest scope completion', group: 'Dates',
    get: sc => {
      const all = [];
      ((sc.project || {}).scopes || []).forEach(s => (s.stages || []).forEach(st => { if (st.plannedDue) all.push(st.plannedDue); }));
      return all.length ? fmtDate(all.sort()[all.length - 1]) : '';
    }, src: 'Scope schedules' },
  { key: 'date.today', label: "Today's date", group: 'Dates',
    get: () => fmtDate(todayISO()), src: 'System date' },
  { key: 'money.contract', label: 'Original contract value', group: 'Money', fin: true,
    get: sc => officeWordMoney(sc, (sc.project || {}).originalContractValue), src: 'Project financials' },
  { key: 'money.revised', label: 'Revised contract value', group: 'Money', fin: true,
    get: sc => {
      if (!sc.project) return '';
      const v = typeof revisedContractValue === 'function' ? revisedContractValue(sc.project) : sc.project.originalContractValue;
      return officeWordMoney(sc, v);
    }, src: 'Contract + approved change orders' },
  { key: 'money.changeOrders', label: 'Approved change orders', group: 'Money', fin: true,
    get: sc => {
      if (!sc.project) return '';
      const v = typeof approvedChangeOrderTotal === 'function' ? approvedChangeOrderTotal(sc.project) : 0;
      return officeWordMoney(sc, v);
    }, src: 'Change orders (approved only)' },
  { key: 'scope.name', label: 'Scope name', group: 'Scope',
    get: sc => (sc.scope ? sc.scope.name : ''), src: 'Linked scope' },
  { key: 'scope.family', label: 'Scope family', group: 'Scope',
    get: sc => (sc.scope ? sc.scope.familyName : ''), src: 'Linked scope' },
  { key: 'scope.type', label: 'Scope type', group: 'Scope',
    get: sc => (sc.scope ? sc.scope.scopeType : ''), src: 'Linked scope' },
  { key: 'vendor.name', label: 'Vendor name', group: 'Vendor',
    get: sc => (sc.vendor ? sc.vendor.name : ''), src: 'Linked vendor' },
  { key: 'vendor.contact', label: 'Vendor contact', group: 'Vendor',
    get: sc => (sc.vendor ? (sc.vendor.contactPerson || '') : ''), src: 'Linked vendor' },
  { key: 'vendor.terms', label: 'Vendor payment terms', group: 'Vendor',
    get: sc => (sc.vendor ? (sc.vendor.defaultPaymentTerms || '') : ''), src: 'Linked vendor' },
  { key: 'po.latest', label: 'Latest PO number', group: 'Vendor',
    get: sc => {
      const pos = ((sc.project || {}).purchaseOrders || []).filter(p => !sc.vendor || p.vendorId === sc.vendor.id);
      const last = pos[pos.length - 1];
      return last ? (last.poNumber || '') : '';
    }, src: 'Purchase orders' },
  { key: 'company.name', label: 'Legal name', group: 'Company',
    get: sc => sc.company.name || '', src: 'Company Profile' },
  { key: 'company.tradeName', label: 'Trade name', group: 'Company',
    get: sc => sc.company.tradeName || '', src: 'Company Profile' },
  { key: 'company.address', label: 'Company address', group: 'Company',
    get: sc => [sc.company.addressLine1, sc.company.addressLine2].filter(Boolean).join(', '), src: 'Company Profile' },
  { key: 'company.phone', label: 'Company phone', group: 'Company',
    get: sc => sc.company.phone || '', src: 'Company Profile' },
  { key: 'company.email', label: 'Company email', group: 'Company',
    get: sc => sc.company.email || '', src: 'Company Profile' },
  { key: 'company.website', label: 'Company website', group: 'Company',
    get: sc => sc.company.website || '', src: 'Company Profile' },
  { key: 'company.ein', label: 'EIN', group: 'Company',
    get: sc => sc.company.ein || '', src: 'Company Profile' },
  { key: 'company.licenses', label: 'Licenses', group: 'Company',
    get: sc => sc.company.licenses || '', src: 'Company Profile' },
  { key: 'author.name', label: 'Author name', group: 'Author',
    get: sc => sc.ctx.currentUserName || '', src: 'Signed-in user' },
  { key: 'author.title', label: 'Author title', group: 'Author',
    get: sc => {
      const p = sc.user;
      // A title is a job; a security role is an access level. Never substitute
      // one for the other — the org chart makes the same distinction.
      return p && p.title && p.title.trim() ? p.title.trim() : '';
    }, src: 'Signed-in user' },
  { key: 'author.email', label: 'Author email', group: 'Author',
    get: sc => (sc.user ? sc.user.email || '' : ''), src: 'Signed-in user' },
  { key: 'doc.name', label: 'Document name', group: 'Document',
    get: sc => (sc.doc ? sc.doc.name : ''), src: 'This document' },
  { key: 'doc.revision', label: 'Document revision', group: 'Document',
    get: sc => (sc.doc ? String(sc.doc.revision || 0) : ''), src: 'This document' },
  { key: 'doc.status', label: 'Document status', group: 'Document',
    get: sc => (sc.doc ? sc.doc.status : ''), src: 'This document' },
];
const WORD_FIELD_BY_KEY = {};
WORD_FIELDS.forEach(f => { WORD_FIELD_BY_KEY[f.key] = f; });
const WORD_FIELD_GROUPS = WORD_FIELDS.reduce((acc, f) => {
  if (!acc.includes(f.group)) acc.push(f.group);
  return acc;
}, []);

function officeWordFieldValue(key, sc) {
  const f = WORD_FIELD_BY_KEY[key];
  if (!f) return { value: '', source: 'Unknown field', missing: true };
  let value = '';
  try { value = f.get(sc); } catch (e) { value = ''; }
  value = value === null || value === undefined ? '' : String(value);
  return { value, source: f.src, label: f.label, missing: !value };
}
// A field with nothing behind it yet reads as a gap, not as a blank — an empty
// space in a proposal is invisible until a client points at it.
function officeWordFieldHtml(key, sc) {
  const r = officeWordFieldValue(key, sc);
  const shown = r.value || '⟨' + (r.label || key) + '⟩';
  return '<span data-field="' + officeWordEscape(key) + '" class="wfield">' + officeWordEscape(shown) + '</span>';
}
function officeWordFieldToken(key) { return '<span data-field="' + key + '" class="wfield"></span>'; }

// Every field currently in the document, with what it says now and what the
// record says now. This is what Refresh SHOWS before it changes anything.
function officeWordScanFields(body, sc) {
  const out = [];
  (body.blocks || []).forEach(b => {
    const htmls = [];
    if (officeWordIsText(b)) htmls.push({ html: b.html, where: b });
    if (b.type === 'table') (b.rows || []).forEach(row => row.forEach(cell => htmls.push({ html: cell.html, where: cell })));
    if (b.type === 'image') htmls.push({ html: b.caption, where: b });
    htmls.forEach(h => {
      const box = document.createElement('div');
      box.innerHTML = String(h.html || '');
      Array.prototype.slice.call(box.querySelectorAll('[data-field]')).forEach(span => {
        const key = span.getAttribute('data-field');
        const live = officeWordFieldValue(key, sc);
        const shown = span.textContent || '';
        const next = live.value || '⟨' + (live.label || key) + '⟩';
        out.push({ blockId: b.id, key, label: live.label || key, source: live.source, shown, next, changed: shown !== next });
      });
    });
  });
  return out;
}
// Rewrites every field span in one html string to its live value.
function officeWordRefreshHtml(html, sc) {
  if (!html || String(html).indexOf('data-field') < 0) return html;
  const box = document.createElement('div');
  box.innerHTML = String(html);
  Array.prototype.slice.call(box.querySelectorAll('[data-field]')).forEach(span => {
    const live = officeWordFieldValue(span.getAttribute('data-field'), sc);
    span.textContent = live.value || '⟨' + (live.label || span.getAttribute('data-field')) + '⟩';
  });
  return box.innerHTML;
}
// Every field in a list of blocks, brought up to date in one pass. Used by
// Refresh and by a freshly applied template — a template's fields are written
// as empty tokens, so without this a new proposal would open with holes in it.
function officeWordRefreshBlocks(blocks, sc) {
  blocks.forEach(b => {
    if (officeWordIsText(b)) b.html = officeWordRefreshHtml(b.html, sc);
    if (b.type === 'image') b.caption = officeWordRefreshHtml(b.caption, sc);
    if (b.type === 'table') (b.rows || []).forEach(r => r.forEach(c => { c.html = officeWordRefreshHtml(c.html, sc); }));
  });
  return blocks;
}
// Convert to text: the span stops being a field and becomes ordinary words.
// This is the deliberate act that makes an ISSUED document stop moving.
function officeWordFreezeHtml(html) {
  if (!html || String(html).indexOf('data-field') < 0) return html;
  const box = document.createElement('div');
  box.innerHTML = String(html);
  Array.prototype.slice.call(box.querySelectorAll('[data-field]')).forEach(span => {
    const t = document.createTextNode(span.textContent || '');
    span.parentNode.replaceChild(t, span);
  });
  return box.innerHTML;
}

// ── LEON data BLOCKS — a live table, not a pasted one ─────────────────────
const WORD_DATA_BLOCKS = [
  {
    key: 'scopeSchedule', label: 'Scope schedule', needs: 'project',
    hint: 'Every scope, or one scope stage by stage, with planned dates and status.',
    args: [{ key: 'scopeId', label: 'Scope', kind: 'scope', allowAll: 'All scopes (summary)' }],
    build(sc, args) {
      const p = sc.project;
      if (!p) return null;
      const scopes = p.scopes || [];
      if (args.scopeId) {
        const s = scopes.find(x => x.id === args.scopeId);
        if (!s) return { columns: ['Stage'], rows: [], note: 'That scope is no longer on this project.' };
        return {
          columns: ['Stage', 'Planned start', 'Planned due', 'Status', 'Delay'],
          rows: (s.stages || []).map(st => [st.name, fmtDate(st.plannedStart), fmtDate(st.plannedDue), st.status,
            st.delayDays ? st.delayDays + ' d' : '—']),
          note: s.name + ' — ' + (s.familyName || ''),
        };
      }
      return {
        columns: ['Scope', 'Type', 'Department', 'Starts', 'Completes', 'Stages complete'],
        rows: scopes.map(s => {
          const st = s.stages || [];
          const done = st.filter(x => x.status === 'Complete').length;
          return [s.name, s.scopeType || '', s.department || '',
            fmtDate(st.length ? st[0].plannedStart : null),
            fmtDate(st.length ? st[st.length - 1].plannedDue : null),
            done + ' of ' + st.length];
        }),
      };
    },
  },
  {
    key: 'takeoffSummary', label: 'Take-off summary', needs: 'project',
    hint: 'The take-offs on this job — name, revision, scope and who prepared it.',
    args: [],
    build(sc) {
      const p = sc.project;
      if (!p) return null;
      const scopeName = id => ((p.scopes || []).find(s => s.id === id) || {}).name || '—';
      return {
        columns: ['Take-off', 'Rev', 'Scope', 'Prepared by', 'Date'],
        rows: (p.takeOffs || []).map(t => [t.name, String(t.revision || 1), scopeName(t.scopeId), t.preparedBy || '—', fmtDate(t.date)]),
      };
    },
  },
  {
    key: 'quoteLines', label: 'Quote lines', needs: 'project', fin: true,
    hint: 'A quote analysis, scope by scope. Sell values only — the cost build-up stays in the Sales Hub.',
    args: [{ key: 'analysisId', label: 'Quote analysis', kind: 'quoteAnalysis' }],
    build(sc, args) {
      const p = sc.project;
      if (!p) return null;
      const list = p.quoteAnalyses || [];
      const qa = list.find(x => x.id === args.analysisId) || list[list.length - 1];
      if (!qa) return { columns: ['Scope'], rows: [], note: 'No quote analysis on this job yet.' };
      const rows = [];
      (qa.sections || []).forEach(sec => {
        (sec.lines || []).forEach(l => {
          if (l.excluded) return;
          rows.push([sec.name || sec.scopeKey || '', l.description || l.itemTag || '',
            l.qty === null || l.qty === undefined ? '—' : String(l.qty), l.uom || '',
            sc.ctx.canSeeFin && l.sellOverride != null ? fmtMoney(l.sellOverride) : '—']);
        });
      });
      return {
        columns: ['Scope', 'Item', 'Qty', 'UoM', 'Sell'],
        rows,
        note: qa.name + ' · Rev ' + (qa.revision || 1) + ' · ' + (qa.status || 'Draft'),
      };
    },
  },
  {
    key: 'doorSchedule', label: 'Door schedule', needs: 'project',
    hint: 'The doors on this job, read live from LEON Doors.',
    args: [{ key: 'scopeId', label: 'Scope', kind: 'scope', allowAll: 'All scopes' }],
    build(sc, args) {
      const p = sc.project;
      if (!p) return null;
      const types = ((sc.ctx.doorLibrary || {}).types) || [];
      const doors = (p.doors || []).filter(d => !args.scopeId || d.scopeId === args.scopeId);
      return {
        columns: ['Mark', 'Location', 'Leaf size', 'Handing', 'Fire rating', 'Finish'],
        rows: doors.map(d => {
          const t = types.find(x => x.id === d.typeId) || null;
          const r = typeof resolveDoor === 'function' ? resolveDoor(d, t) : Object.assign({}, t || {}, d);
          const size = r.leafW && r.leafH ? fmtDim(r.leafW) + ' × ' + fmtDim(r.leafH) : '—';
          // A REQUESTED rating is not a certified one, and the schedule has to
          // keep saying so — this table is read by people who order doors.
          const rating = r.fireRating && r.fireRating !== 'None'
            ? r.fireRating + (r.ratingState === 'Certified' ? '' : ' (REQ)') : 'None';
          return [d.mark || '—', d.location || '—', size, r.handing || '—', rating,
            (r.finishRef && r.finishRef.name) || '—'];
        }),
      };
    },
  },
  {
    key: 'selections', label: 'Selections', needs: 'project',
    hint: 'What the client chose, by application area, with the supplier finish where one is set.',
    args: [{ key: 'scopeId', label: 'Scope', kind: 'scope', allowAll: 'All scopes' }],
    build(sc, args) {
      const p = sc.project;
      if (!p) return null;
      const lib = sc.ctx.scopeLibrary || [];
      const rows = [];
      (p.scopes || []).filter(s => !args.scopeId || s.id === args.scopeId).forEach(s => {
        const fam = lib.find(f => f.name === s.familyName);
        const cats = (fam && fam.categories) || [];
        const areas = [{ name: s.mainAreaName || s.name, selections: s.selections || {}, supplierFinishes: s.supplierFinishes || {} }]
          .concat((s.selectionAreas || []).map(a => ({ name: a.name, selections: a.selections || {}, supplierFinishes: a.supplierFinishes || {} })));
        areas.forEach(area => {
          cats.forEach(cat => {
            const optId = area.selections[cat.id];
            const opt = optId ? (cat.options || []).find(o => o.id === optId) : null;
            const fin = area.supplierFinishes[cat.id];
            if (!opt && !fin) return;
            rows.push([s.name, area.name, cat.name, opt ? opt.name : '—',
              fin ? fin.name + (fin.code ? ' · ' + fin.code : '') : '—']);
          });
        });
      });
      return { columns: ['Scope', 'Area', 'Category', 'Selection', 'Supplier finish'], rows };
    },
  },
  {
    key: 'procurement', label: 'Procurement status', needs: 'project',
    hint: 'Vendor estimates and purchase orders on this job. Amounts follow the usual financial permission.',
    args: [{ key: 'scopeId', label: 'Scope', kind: 'scope', allowAll: 'All scopes' }],
    build(sc, args) {
      const p = sc.project;
      if (!p) return null;
      const money = n => (sc.ctx.canSeeFin ? fmtMoney(n || 0) : '—');
      const rows = [];
      (p.vendorEstimates || []).filter(v => !args.scopeId || v.scopeId === args.scopeId).forEach(v => {
        rows.push(['Estimate', v.estimateNumber || '—', v.vendorName || '—', v.description || '', money(v.amount), v.status || '']);
      });
      (p.purchaseOrders || []).filter(v => !args.scopeId || v.scopeId === args.scopeId).forEach(po => {
        rows.push(['PO', po.poNumber || '—', po.vendorName || '—', po.description || '', money(po.amount), po.status || '']);
      });
      return { columns: ['Kind', 'Number', 'Vendor', 'Description', 'Amount', 'Status'], rows };
    },
  },
  {
    key: 'paymentTerms', label: 'Client payment terms', needs: 'project', fin: true,
    hint: 'The billing schedule as it stands on the job.',
    args: [],
    build(sc) {
      const p = sc.project;
      if (!p) return null;
      if (!sc.ctx.canSeeFin) return { columns: ['Term'], rows: [], note: 'Payment terms are financial data and are not shown at your permission level.' };
      const terms = typeof paymentTermsWithAmounts === 'function' ? paymentTermsWithAmounts(p) : (p.paymentTerms || []);
      return {
        columns: ['Term', 'Trigger', '%', 'Amount', 'Status'],
        rows: terms.map(t => [t.label || '', t.trigger || '', (t.pct || 0) + '%',
          fmtMoney(t.amount != null ? t.amount : 0), t.status || '']),
      };
    },
  },
];
const WORD_DATA_BY_KEY = {};
WORD_DATA_BLOCKS.forEach(d => { WORD_DATA_BY_KEY[d.key] = d; });

function officeWordBuildData(block, sc) {
  const def = WORD_DATA_BY_KEY[block.dataKey];
  if (!def) return { columns: [], rows: [], note: 'This data block refers to something that no longer exists.' };
  if (block.frozen && block.snapshot) return Object.assign({}, block.snapshot, { frozenNote: true });
  let built = null;
  try { built = def.build(sc, block.args || {}); } catch (e) { built = null; }
  if (!built) return { columns: [], rows: [], note: 'Link this document to a project to fill this table.' };
  return built;
}

// ═══════════════════════════════ Templates ════════════════════════════════
// Real LEON documents, drafted with real field references rather than
// [PROJECT NAME] placeholders — a template whose blanks are fields is filled
// the moment it is linked to a job, and stays filled when the job changes.

function officeWordP(html, style, extra) {
  return officeWordMakeBlock('paragraph', Object.assign({ html, style: officeWordStyleId(style || 'Normal') }, extra || {}));
}
function officeWordH(html, level) {
  return officeWordMakeBlock('heading', { html, level, style: WORD_HEADING_STYLE[level] });
}
function officeWordList(items, numbered) {
  return items.map(t => officeWordMakeBlock(numbered ? 'number' : 'bullet', { html: t }));
}
function officeWordFieldRow(label, key) {
  return [officeWordCell(label), { id: uid('wc'), html: officeWordFieldToken(key), bg: '', colSpan: 1, rowSpan: 1, hidden: false }];
}
function officeWordFactsTable(pairs) {
  return officeWordMakeBlock('table', {
    headerRow: false, borders: 'outer',
    rows: pairs.map(p => {
      const cells = officeWordFieldRow(p[0], p[1]);
      cells[0].bg = '#f7f3ee';
      return cells;
    }),
  });
}

const WORD_TEMPLATES = [
  {
    key: 'blank', label: 'Blank document', icon: '📄',
    blurb: 'Nothing but a title. Everything else is yours.',
    build: () => [officeWordP('', 'Title'), officeWordP('')],
  },
  {
    key: 'proposal', label: 'Proposal', icon: '🧾',
    blurb: 'Cover, scope narrative, live scope schedule, inclusions and exclusions, commercial terms.',
    build: () => [
      officeWordP(officeWordFieldToken('company.tradeName') + ' — Proposal', 'Title'),
      officeWordFactsTable([['Project', 'project.name'], ['Project number', 'project.number'],
        ['Address', 'project.address'], ['Client', 'project.client'], ['Date', 'date.today'],
        ['Prepared by', 'author.name']]),
      officeWordH('1. Introduction', 1),
      officeWordP('Thank you for the opportunity to price ' + officeWordFieldToken('project.name') +
        '. This proposal covers the scopes listed below, priced against the drawings and take-off issued to date.'),
      officeWordH('2. Scope of work', 1),
      officeWordP('The following scopes are included: ' + officeWordFieldToken('project.scopeList') + '.'),
      officeWordMakeBlock('data', { dataKey: 'scopeSchedule', args: {}, caption: 'Scopes and indicative programme' }),
      officeWordH('3. Inclusions', 1),
      ...officeWordList(['Supply of the materials described above.',
        'Shop drawings and submittals for approval.',
        'Delivery to the jobsite as scheduled.']),
      officeWordH('4. Exclusions', 1),
      ...officeWordList(['Any work not expressly described in this proposal.',
        'Permits, inspections and fees.',
        'Structural, mechanical, electrical or plumbing work.',
        'Site storage, hoisting and protection beyond our own materials.']),
      officeWordH('5. Commercial terms', 1),
      officeWordMakeBlock('data', { dataKey: 'paymentTerms', args: {}, caption: 'Payment schedule' }),
      officeWordP('Contract value: ' + officeWordFieldToken('money.contract') + '. Prices hold for 30 days from the date above.'),
      officeWordH('6. Acceptance', 1),
      officeWordP('Signed for and on behalf of the Client:'),
      officeWordP('______________________________     Date: ____________'),
      officeWordP(officeWordFieldToken('author.name') + ', ' + officeWordFieldToken('author.title') + ' · ' +
        officeWordFieldToken('company.name'), 'Caption'),
    ],
  },
  {
    key: 'contract', label: 'Contract', icon: '📜',
    blurb: 'Parties, scope, price, schedule, payment, change orders, warranty, signatures.',
    build: () => [
      officeWordP('Agreement for Interior Finishes Work', 'Title'),
      officeWordP('This Agreement is made on ' + officeWordFieldToken('date.today') + ' between ' +
        officeWordFieldToken('company.name') + ' of ' + officeWordFieldToken('company.address') +
        ' ("the Contractor") and ' + officeWordFieldToken('project.client') + ' ("the Client").'),
      officeWordH('1. The Work', 1),
      officeWordP('The Contractor shall carry out the following scopes at ' + officeWordFieldToken('project.address') +
        ' (project ' + officeWordFieldToken('project.number') + '): ' + officeWordFieldToken('project.scopeList') + '.'),
      officeWordH('2. Contract sum', 1),
      officeWordP('The Contract Sum is ' + officeWordFieldToken('money.contract') +
        ', exclusive of taxes, adjusted only by change order executed under clause 4.'),
      officeWordH('3. Programme', 1),
      officeWordMakeBlock('data', { dataKey: 'scopeSchedule', args: {}, caption: 'Contract programme by scope' }),
      officeWordH('4. Change orders', 1),
      officeWordP('No change to the Work is authorised until a written change order is signed by both parties. ' +
        'Approved change orders to date total ' + officeWordFieldToken('money.changeOrders') + '.'),
      officeWordH('5. Payment', 1),
      officeWordMakeBlock('data', { dataKey: 'paymentTerms', args: {}, caption: 'Payment schedule' }),
      officeWordH('6. Warranty', 1),
      officeWordP('The Contractor warrants the Work against defects in materials and workmanship for twelve (12) months ' +
        'from substantial completion.'),
      officeWordH('7. Signatures', 1),
      officeWordMakeBlock('table', {
        headerRow: true, borders: 'all',
        rows: [
          [officeWordCell('Contractor', true), officeWordCell('Client', true)],
          [officeWordCell('Name: '), officeWordCell('Name: ')],
          [officeWordCell('Signature: '), officeWordCell('Signature: ')],
          [officeWordCell('Date: '), officeWordCell('Date: ')],
        ],
      }),
    ],
  },
  {
    key: 'transmittal', label: 'Transmittal', icon: '📤',
    blurb: 'What is being sent, to whom, why, and what is expected back.',
    build: () => [
      officeWordP('Transmittal', 'Title'),
      officeWordFactsTable([['Project', 'project.name'], ['Project number', 'project.number'],
        ['To', 'project.gc'], ['From', 'author.name'], ['Date', 'date.today']]),
      officeWordH('Items transmitted', 2),
      officeWordMakeBlock('table', {
        headerRow: true, borders: 'all',
        rows: [
          [officeWordCell('Item', true), officeWordCell('Rev', true), officeWordCell('Copies', true), officeWordCell('Format', true)],
          [officeWordCell(''), officeWordCell(''), officeWordCell(''), officeWordCell('')],
          [officeWordCell(''), officeWordCell(''), officeWordCell(''), officeWordCell('')],
        ],
      }),
      officeWordH('Sent for', 2),
      ...officeWordList(['Approval', 'Your records', 'Review and comment', 'Construction']),
      officeWordH('Notes', 2),
      officeWordP(''),
    ],
  },
  {
    key: 'minutes', label: 'Meeting Minutes', icon: '🗒',
    blurb: 'Attendees, decisions, actions with owners and dates, next meeting.',
    build: () => [
      officeWordP('Meeting Minutes', 'Title'),
      officeWordFactsTable([['Project', 'project.name'], ['Project number', 'project.number'],
        ['Date', 'date.today'], ['Recorded by', 'author.name']]),
      officeWordH('Attendees', 2),
      officeWordP(''),
      officeWordH('Discussion and decisions', 2),
      ...officeWordList(['', '']),
      officeWordH('Actions', 2),
      officeWordMakeBlock('table', {
        headerRow: true, borders: 'all',
        rows: [
          [officeWordCell('#', true), officeWordCell('Action', true), officeWordCell('Owner', true), officeWordCell('Due', true)],
          [officeWordCell('1'), officeWordCell(''), officeWordCell(''), officeWordCell('')],
          [officeWordCell('2'), officeWordCell(''), officeWordCell(''), officeWordCell('')],
        ],
      }),
      officeWordH('Next meeting', 2),
      officeWordP(''),
    ],
  },
  {
    key: 'rfi', label: 'RFI', icon: '❓',
    blurb: 'One question, the reference it arises from, the answer needed and by when.',
    build: () => [
      officeWordP('Request for Information', 'Title'),
      officeWordFactsTable([['Project', 'project.name'], ['Project number', 'project.number'],
        ['To', 'project.architect'], ['From', 'author.name'], ['Date raised', 'date.today']]),
      officeWordH('Reference', 2),
      officeWordP('Drawing / specification reference: '),
      officeWordH('Question', 2),
      officeWordP(''),
      officeWordH('Our suggested resolution', 2),
      officeWordP(''),
      officeWordH('Impact if unanswered', 2),
      officeWordP('Schedule impact: ______ days. Cost impact: to be confirmed. Response required by: ____________.'),
      officeWordH('Response', 2),
      officeWordP('', 'Body', { indent: 1 }),
    ],
  },
  {
    key: 'coNarrative', label: 'Change Order Narrative', icon: '🔁',
    blurb: 'What changed, who asked, what it costs, what it does to the programme.',
    build: () => [
      officeWordP('Change Order Narrative', 'Title'),
      officeWordFactsTable([['Project', 'project.name'], ['Project number', 'project.number'],
        ['Client', 'project.client'], ['Date', 'date.today'], ['Prepared by', 'author.name']]),
      officeWordH('1. Description of the change', 1),
      officeWordP(''),
      officeWordH('2. Origin', 1),
      officeWordP('Requested by: ____________. Reference: ____________.'),
      officeWordH('3. Cost effect', 1),
      officeWordP('Original contract value: ' + officeWordFieldToken('money.contract') +
        '. Approved change orders to date: ' + officeWordFieldToken('money.changeOrders') +
        '. Revised contract value if approved: ' + officeWordFieldToken('money.revised') + '.'),
      officeWordH('4. Schedule effect', 1),
      officeWordMakeBlock('data', { dataKey: 'scopeSchedule', args: {}, caption: 'Programme as it stands before this change' }),
      officeWordH('5. Approval', 1),
      officeWordP('This narrative supports the change order raised in the Sales Hub. It does not itself change the contract value — ' +
        'the change order record does.', 'Caption'),
    ],
  },
  {
    key: 'warranty', label: 'Warranty Letter', icon: '🛡',
    blurb: 'What is warranted, for how long, from when, and what voids it.',
    build: () => [
      officeWordP('Warranty', 'Title'),
      officeWordP(officeWordFieldToken('company.name') + ' · ' + officeWordFieldToken('company.address'), 'Caption'),
      officeWordP('Date: ' + officeWordFieldToken('date.today')),
      officeWordP('To: ' + officeWordFieldToken('project.client')),
      officeWordP('Re: ' + officeWordFieldToken('project.name') + ' (' + officeWordFieldToken('project.number') + '), ' +
        officeWordFieldToken('project.address')),
      officeWordH('Scope warranted', 2),
      officeWordP(officeWordFieldToken('project.scopeList')),
      officeWordH('Term', 2),
      officeWordP('Twelve (12) months from the date of substantial completion recorded on the project closeout.'),
      officeWordH('What is covered', 2),
      ...officeWordList(['Defects in materials supplied by us.',
        'Defects in workmanship performed by us or our subcontractors.']),
      officeWordH('What is not covered', 2),
      ...officeWordList(['Damage from misuse, alteration or work by others.',
        'Normal wear, and normal movement of the building.',
        'Failure to maintain the finishes as specified.']),
      officeWordP(officeWordFieldToken('author.name') + ', ' + officeWordFieldToken('author.title'), 'Caption'),
    ],
  },
  {
    key: 'closeout', label: 'Closeout Letter', icon: '🏁',
    blurb: 'The job is finished: what was delivered, what is handed over, warranty start.',
    build: () => [
      officeWordP('Project Closeout', 'Title'),
      officeWordFactsTable([['Project', 'project.name'], ['Project number', 'project.number'],
        ['Client', 'project.client'], ['Address', 'project.address'], ['Date', 'date.today']]),
      officeWordH('1. Work completed', 1),
      officeWordP('The following scopes are complete and handed over: ' + officeWordFieldToken('project.scopeList') + '.'),
      officeWordMakeBlock('data', { dataKey: 'scopeSchedule', args: {}, caption: 'Final scope status' }),
      officeWordH('2. Selections as installed', 1),
      officeWordMakeBlock('data', { dataKey: 'selections', args: {}, caption: 'Finishes as selected and installed' }),
      officeWordH('3. Handover documents', 1),
      ...officeWordList(['Warranty letter', 'Care and maintenance instructions',
        'As-built / shop drawings', 'Attic stock and spare materials record']),
      officeWordH('4. Warranty', 1),
      officeWordP('The warranty period begins at substantial completion as recorded on the project closeout in the Hub.'),
      officeWordP('Yours sincerely, ' + officeWordFieldToken('author.name') + ' · ' + officeWordFieldToken('company.tradeName'), 'Caption'),
    ],
  },
];

// ═══════════════════════════════ Word — editing surface ═══════════════════

// contentEditable and React fight over the DOM: if the parent re-renders while
// someone is typing and React rewrites innerHTML, the caret jumps to the start.
// So this component OWNS its node — html goes in on mount and only ever again
// when the node is not focused and the value genuinely differs from what we
// last emitted.
// Which editable node the caret is in, and how to push its html back into the
// model. The toolbar needs both: a formatting button must not steal focus (it
// suppresses mousedown) and therefore never triggers the blur that normally
// commits, so it commits through here instead.
const officeWordFocus = { el: null, commit: null };

function OfficeWordRichText({ html, onCommit, editable, className, style, placeholder, onFocusBlock }) {
  const ref = useRef(null);
  const last = useRef(html || '');
  useEffect(() => {
    if (ref.current && ref.current.innerHTML !== (html || '')) {
      if (document.activeElement !== ref.current && (html || '') !== last.current) {
        ref.current.innerHTML = html || '';
        last.current = html || '';
      } else if (document.activeElement !== ref.current && !ref.current.innerHTML) {
        ref.current.innerHTML = html || '';
        last.current = html || '';
      }
    }
  }, [html]);
  useEffect(() => {
    if (ref.current && !ref.current.innerHTML) ref.current.innerHTML = html || '';
    // eslint-disable-next-line
  }, []);
  function commit() {
    if (!ref.current) return;
    const next = officeWordSanitize(ref.current.innerHTML);
    if (next === last.current) return;
    last.current = next;
    onCommit(next);
  }
  return (
    <div
      ref={ref}
      contentEditable={!!editable}
      suppressContentEditableWarning
      data-placeholder={placeholder || ''}
      spellCheck={true}
      onFocus={() => {
        officeWordFocus.el = ref.current;
        officeWordFocus.commit = commit;
        if (onFocusBlock) onFocusBlock();
      }}
      onBlur={() => { if (officeWordFocus.el === ref.current) officeWordFocus.el = null; commit(); }}
      onInput={() => { if (ref.current) ref.current.__dirty = true; }}
      onPaste={e => {
        // Pasting from Word or a browser drags in fonts, classes and nested
        // tables. Take the text; the block style is what decides the look.
        e.preventDefault();
        const text = (e.clipboardData || window.clipboardData).getData('text/plain');
        document.execCommand('insertText', false, text);
      }}
      className={`outline-none ${editable ? 'focus:bg-[var(--leon-cream)]/40 rounded-sm' : ''} ${className || ''}`}
      style={style}
    />
  );
}

// ── Formatting commands ───────────────────────────────────────────────────
function officeWordExec(cmd, value) {
  try {
    document.execCommand('styleWithCSS', false, true);
    document.execCommand(cmd, false, value === undefined ? null : value);
  } catch (e) { /* an unsupported command is a no-op, not a crash */ }
  if (officeWordFocus.commit) officeWordFocus.commit();
}
// execCommand has no "set the size in points" — only the seven legacy HTML
// sizes. The usual workaround: apply size 7, then rewrite those font tags into
// spans carrying the size that was actually asked for.
function officeWordSetFontSize(pt) {
  const el = officeWordFocus.el;
  try {
    document.execCommand('styleWithCSS', false, false);
    document.execCommand('fontSize', false, '7');
  } catch (e) { return; }
  if (el && el.querySelectorAll) {
    Array.prototype.slice.call(el.querySelectorAll('font[size="7"]')).forEach(f => {
      const s = document.createElement('span');
      s.style.fontSize = pt + 'pt';
      while (f.firstChild) s.appendChild(f.firstChild);
      f.parentNode.replaceChild(s, f);
    });
  }
  if (officeWordFocus.commit) officeWordFocus.commit();
}
function officeWordInsertHtml(html) {
  if (!officeWordFocus.el) return false;
  try { document.execCommand('insertHTML', false, html); } catch (e) { return false; }
  if (officeWordFocus.commit) officeWordFocus.commit();
  return true;
}
function officeWordSelectedText() {
  const sel = window.getSelection();
  return sel && sel.rangeCount ? String(sel.toString()) : '';
}

// ── Character styles, applied to a run ────────────────────────────────────
// This is the structural gap, not a cosmetic one: everything here was a
// PARAGRAPH style, so Intense Emphasis and Intense Reference — Word's two
// character styles, which apply to a selected run inside a paragraph — simply
// could not be expressed. A styled run carries nothing but a class, so it
// FOLLOWS its style definition: change the style and every run wearing it
// changes with it. That is the whole difference between a character style and
// pressing the italic button.
function officeWordSelectedHtml() {
  const sel = window.getSelection();
  if (!sel || !sel.rangeCount || sel.isCollapsed) return null;
  const box = document.createElement('div');
  box.appendChild(sel.getRangeAt(0).cloneContents());
  return box.innerHTML;
}
function officeWordStripCharStyles(html) {
  const box = document.createElement('div');
  box.innerHTML = String(html || '');
  Array.prototype.slice.call(box.querySelectorAll('span')).forEach(sp => {
    if (!/(^|\s)wcs-/.test(sp.className || '')) return;
    while (sp.firstChild) sp.parentNode.insertBefore(sp.firstChild, sp);
    sp.remove();
  });
  return box.innerHTML;
}
// Passing no id CLEARS the character style off the selection. Nesting one
// inside another is stripped first, because two competing run styles on one
// run is a question with no right answer.
function officeWordApplyCharStyle(id) {
  const html = officeWordSelectedHtml();
  if (html === null) return false;
  const inner = officeWordStripCharStyles(html);
  return officeWordInsertHtml(id ? '<span class="wcs-' + id + '">' + inner + '</span>' : inner);
}
// ── Update style from selection ───────────────────────────────────────────
// The single feature that makes styles worth using: format some text until it
// looks right, then push that formatting INTO the style so every paragraph
// wearing it follows. Read off the real rendered run rather than from what we
// think we applied — execCommand leaves several different shapes behind.
function officeWordRgbToHex(v) {
  const m = String(v || '').match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
  if (!m) return null;
  return '#' + [1, 2, 3].map(i => Number(m[i]).toString(16).padStart(2, '0')).join('');
}
function officeWordFormattingAtCaret() {
  const sel = window.getSelection();
  if (!sel || !sel.rangeCount) return null;
  let node = sel.getRangeAt(0).startContainer;
  if (node && node.nodeType === 3) node = node.parentNode;
  if (!node || node.nodeType !== 1 || !window.getComputedStyle) return null;
  const cs = window.getComputedStyle(node);
  const px = parseFloat(cs.fontSize) || 0;
  const deco = String(cs.textDecorationLine || cs.textDecoration || '');
  return {
    sizePt: px ? Math.round(px * 0.75 * 10) / 10 : undefined,
    bold: (parseInt(cs.fontWeight, 10) || 400) >= 600,
    italic: cs.fontStyle === 'italic',
    underline: deco.indexOf('underline') >= 0,
    smallCaps: String(cs.fontVariant || '').indexOf('small-caps') >= 0,
    color: officeWordRgbToHex(cs.color),
  };
}

// A toolbar button must not take the caret away from the text it is about to
// format, which is what preventDefault on mousedown is for.
function OfficeWordToolBtn({ children, title, onClick, active, disabled, wide }) {
  return (
    <button
      type="button" title={title} disabled={disabled}
      onMouseDown={e => e.preventDefault()}
      onClick={onClick}
      className={`h-7 ${wide ? 'px-2' : 'w-7'} shrink-0 inline-flex items-center justify-center rounded-md border text-[12px] leading-none transition-colors disabled:opacity-35
        ${active ? 'border-[var(--leon-brown)] bg-[var(--leon-brown)] text-white'
                 : 'border-[var(--leon-line)] bg-white text-[var(--leon-black)]/70 hover:border-[var(--leon-brown-light)] hover:text-[var(--leon-brown)]'}`}
    >{children}</button>
  );
}
function OfficeWordDivider() { return <span className="w-px h-5 bg-[var(--leon-line)] mx-0.5 shrink-0" />; }

const WORD_FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 24, 30, 36];
const WORD_COLORS = ['#161311', '#6b4a34', '#4a3325', '#b08968', '#3a7d44', '#b83b3b', '#c99a2e', '#5a6b7d', '#ffffff'];
const WORD_HIGHLIGHTS = ['#fff3b0', '#d7f0d8', '#ffd7d7', '#e2e8f5', '#f7f3ee'];

// ── Block chrome ──────────────────────────────────────────────────────────
function officeWordBlockStyleObj(body, block, theme) {
  const T = theme || officeThemeOf(body);
  const ref = block.style || (block.type === 'heading' ? WORD_HEADING_STYLE[block.level || 1] : 'Normal');
  const st = officeWordStyleDef(body, ref);
  const css = officeWordStyleCss(st, T);
  return Object.assign({}, css, {
    textAlign: block.align || st.align || 'left',
    lineHeight: block.lineSpacing || 1.45,
    // Word stores paragraph spacing and indent in POINTS, so these are pt.
    marginTop: (st.spaceBefore || 0) + 'pt',
    marginBottom: (st.spaceAfter || 0) + 'pt',
    // The style's own indent and the block's outdent/indent presses are
    // different things and both have to survive: a List Paragraph indented
    // twice is at the style's indent plus two levels.
    marginLeft: ((st.indent || 0) * (4 / 3) + (block.indent || 0) * 28) + 'px',
  });
}
// Intense Quote's rule. Kept out of the CSS object because a border belongs to
// the frame around the text, not to the type.
function officeWordBlockFrame(body, block, theme) {
  const st = officeWordStyleDef(body, block.style || (block.type === 'heading' ? WORD_HEADING_STYLE[block.level || 1] : 'Normal'));
  if (!st.ruled) return null;
  return { borderTop: '1px solid ' + officeThemeResolve(theme, st.color, theme.accent1),
    borderBottom: '1px solid ' + officeThemeResolve(theme, st.color, theme.accent1),
    paddingTop: '6pt', paddingBottom: '6pt' };
}

function OfficeWordDataTable({ built, borders, theme }) {
  if (!built) return null;
  const empty = !built.rows || !built.rows.length;
  // The rule under the header and the hairlines between rows are the theme's
  // text colour, thinned into the page — so a table reads on a dark theme as
  // well as on white instead of drawing brown lines on black.
  const T = theme || officeThemeBuiltins()[0];
  const head = T.dk1;
  const hair = officeThemeMix(T.lt1, T.dk1, 0.16);
  return (
    <div>
      {built.note && <div className="text-[11px] italic mb-1" style={{ color: T.accent1 }}>{built.note}</div>}
      {empty ? (
        <div className="text-xs italic text-[var(--leon-black)]/45 border border-dashed border-[var(--leon-line)] rounded px-3 py-3">
          Nothing to show yet — this table fills itself the moment the records exist.
        </div>
      ) : (
        <div className="overflow-x-auto">
          <table className="w-full text-[10.5pt]" style={{ borderCollapse: 'collapse' }}>
            <thead>
              <tr>{(built.columns || []).map((c, i) => (
                <th key={i} className="text-left font-bold px-2 py-1"
                  style={{ borderBottom: '1.4px solid ' + head, color: head, fontSize: '9.5pt', letterSpacing: '.4px' }}>{c}</th>
              ))}</tr>
            </thead>
            <tbody>
              {built.rows.map((r, ri) => (
                <tr key={ri}>{r.map((cell, ci) => (
                  <td key={ci} className="px-2 py-1 align-top"
                    style={{ borderBottom: borders === 'none' ? 'none' : '0.6px solid ' + hair }}>{cell}</td>
                ))}</tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </div>
  );
}

function officeWordTocEntries(blocks) {
  return blocks.filter(b => b.type === 'heading' && officeWordPlain(b.html))
    .map(b => ({ id: b.id, level: b.level || 1, text: officeWordPlain(b.html) }));
}

// ── One block ─────────────────────────────────────────────────────────────
function OfficeWordBlock({ body, block, blocks, index, sc, editable, selected, api, theme }) {
  const T = theme || officeThemeOf(body);
  const style = officeWordBlockStyleObj(body, block, T);
  const frame = officeWordBlockFrame(body, block, T);
  const change = (body.changes || []).find(c => c.blockId === block.id && c.status === 'open');
  const markup = body.showMarkup && change;

  function textBody() {
    if (markup && change.kind === 'edit') {
      const diff = officeWordDiffWords(officeWordPlain(change.before), officeWordPlain(change.after));
      if (diff) {
        return (
          <div style={style} className="whitespace-pre-wrap">
            {diff.map((d, i) => (
              <span key={i} className={d.kind === 'ins' ? 'bg-[#d7f0d8] underline decoration-[var(--leon-green)]'
                : d.kind === 'del' ? 'bg-[#ffd7d7] line-through text-[var(--leon-red)]' : ''}>{d.text}</span>
            ))}
          </div>
        );
      }
    }
    return (
      <OfficeWordRichText
        html={block.html}
        editable={editable}
        style={style}
        placeholder={block.type === 'heading' ? 'Heading' : 'Type here…'}
        className="min-h-[1.3em] whitespace-pre-wrap"
        onFocusBlock={() => api.focus(block.id)}
        onCommit={html => api.setHtml(block.id, html)}
      />
    );
  }

  let inner = null;
  if (block.type === 'paragraph' || block.type === 'heading') {
    // Intense Quote is a RULED paragraph in Word, so a paragraph carrying it
    // gets the rules — the style decides, not the block type.
    inner = frame ? <div style={frame}>{textBody()}</div> : textBody();
  } else if (block.type === 'quote') {
    inner = frame
      ? <div style={frame}>{textBody()}</div>
      : <div style={{ borderLeft: '4px solid ' + officeThemeResolve(T, 'accent2', T.accent2), paddingLeft: 16 }}>{textBody()}</div>;
  } else if (block.type === 'bullet' || block.type === 'number') {
    let n = 1;
    for (let i = index - 1; i >= 0; i--) {
      if (blocks[i].type === 'number') n++; else break;
    }
    inner = (
      <div className="flex gap-2" style={{ marginLeft: (block.indent || 0) * 28 + 'px' }}>
        <span className="shrink-0 select-none" style={{ fontSize: style.fontSize, lineHeight: style.lineHeight, color: officeThemeResolve(T, 'accent1', T.accent1) }}>
          {block.type === 'bullet' ? '•' : n + '.'}
        </span>
        <div className="flex-1">{textBody()}</div>
      </div>
    );
  } else if (block.type === 'divider') {
    inner = <hr className="border-0 border-t border-[var(--leon-line)] my-3" />;
  } else if (block.type === 'pagebreak') {
    inner = (
      <div className="my-3 flex items-center gap-2 text-[10px] uppercase tracking-widest text-[var(--leon-black)]/35 word-pagebreak">
        <span className="flex-1 border-t border-dashed border-[var(--leon-line)]" />
        Page break
        <span className="flex-1 border-t border-dashed border-[var(--leon-line)]" />
      </div>
    );
  } else if (block.type === 'toc') {
    const live = officeWordTocEntries(blocks);
    const stored = block.entries || [];
    const stale = JSON.stringify(live.map(e => e.level + e.text)) !== JSON.stringify(stored.map(e => e.level + e.text));
    inner = (
      <div>
        <div className="font-bold text-[13pt] mb-2 pb-1"
          style={{ borderBottom: '1px solid ' + T.dk1, color: officeThemeResolve(T, 'dk1', T.dk1),
                   fontFamily: officeThemeFontStack(T, 'major') }}>Contents</div>
        {(stored.length ? stored : live).map((e, i) => (
          <div key={i} className="flex gap-2 text-[10.5pt] py-0.5"
            style={{ marginLeft: (e.level - 1) * 18 + 'px', color: e.level === 1 ? T.dk1 : T.dk2 }}>
            <span className={e.level === 1 ? 'font-semibold' : ''}>{e.text}</span>
            <span className="flex-1 border-b border-dotted border-[var(--leon-line)] translate-y-[-4px]" />
          </div>
        ))}
        {!stored.length && !live.length && (
          <div className="text-xs italic text-[var(--leon-black)]/45">No headings yet. Style a paragraph as a heading and refresh.</div>
        )}
        {editable && (
          <div className="no-print mt-2 flex items-center gap-2">
            <Button size="sm" variant={stale ? 'primary' : 'outline'} onClick={() => api.set(block.id, { entries: live, refreshedAt: todayISO() })}>
              Refresh contents
            </Button>
            {stale && <span className="text-[11px] text-[var(--leon-red)]">Headings have changed since this was last built.</span>}
          </div>
        )}
      </div>
    );
  } else if (block.type === 'image') {
    const ref = block.ref;
    inner = (
      <figure style={{ textAlign: block.align || 'center', margin: 0 }}>
        {ref && ref.url ? (
          <img src={ref.url} alt={block.caption || (ref.name || '')}
            style={{ width: (block.width || 70) + '%', maxWidth: '100%', display: 'inline-block', borderRadius: 4 }} />
        ) : (
          <div className="border border-dashed border-[var(--leon-line)] rounded-lg py-10 text-xs text-[var(--leon-black)]/45">
            No image chosen yet.
          </div>
        )}
        <figcaption className="mt-1">
          <OfficeWordRichText
            html={block.caption} editable={editable}
            placeholder="Caption"
            style={officeWordBlockStyleObj(body, { style: 'Caption', align: block.align }, T)}
            onFocusBlock={() => api.focus(block.id)}
            onCommit={html => api.set(block.id, { caption: html })}
          />
        </figcaption>
        {ref && ref.kind !== 'upload' && (
          <div className="no-print text-[10px] text-[var(--leon-black)]/40 mt-0.5">
            Linked to {ref.kindLabel || ref.kind} · {ref.name || ref.id} — this document holds the reference, not a copy of the file.
          </div>
        )}
      </figure>
    );
  } else if (block.type === 'table') {
    inner = <OfficeWordTableBlock block={block} body={body} editable={editable} api={api} theme={T} />;
  } else if (block.type === 'data') {
    const def = WORD_DATA_BY_KEY[block.dataKey];
    const built = officeWordBuildData(block, sc);
    inner = (
      <div>
        {block.caption && <div className="font-bold text-[11pt] mb-1">{block.caption}</div>}
        <OfficeWordDataTable built={built} borders="all" theme={T} />
        <div className="no-print mt-1 flex flex-wrap items-center gap-2 text-[10px] text-[var(--leon-black)]/45">
          <span className={block.frozen ? 'text-[var(--leon-brown)] font-semibold' : ''}>
            {block.frozen ? '🔒 Frozen snapshot' : '🔗 Live'} · {def ? def.label : block.dataKey}
          </span>
          {block.frozen && block.snapshot && <span>taken {fmtDate(block.snapshot.at)}</span>}
          {editable && !block.frozen && (
            <button className="underline hover:text-[var(--leon-brown)]"
              onClick={() => api.set(block.id, { frozen: true, snapshot: Object.assign({}, built, { at: todayISO() }) })}>
              Convert to text (freeze)
            </button>
          )}
          {editable && block.frozen && (
            <button className="underline hover:text-[var(--leon-brown)]" onClick={() => api.set(block.id, { frozen: false })}>
              Go live again
            </button>
          )}
        </div>
      </div>
    );
  }

  return (
    <div
      className={`relative group ${selected && editable ? 'ring-1 ring-[var(--leon-brown-light)] rounded-sm' : ''} ${block.pendingDelete ? 'opacity-45 line-through' : ''}`}
      onMouseDown={() => api.focus(block.id)}
    >
      {editable && (
        <div className="no-print absolute -left-9 top-0 hidden group-hover:flex flex-col gap-0.5">
          <button title="Move up" onMouseDown={e => e.preventDefault()} onClick={() => api.move(block.id, -1)}
            className="w-6 h-5 rounded border border-[var(--leon-line)] bg-white text-[10px] text-[var(--leon-black)]/50 hover:text-[var(--leon-brown)]">▲</button>
          <button title="Move down" onMouseDown={e => e.preventDefault()} onClick={() => api.move(block.id, 1)}
            className="w-6 h-5 rounded border border-[var(--leon-line)] bg-white text-[10px] text-[var(--leon-black)]/50 hover:text-[var(--leon-brown)]">▼</button>
          <button title="Delete this block" onMouseDown={e => e.preventDefault()} onClick={() => api.remove(block.id)}
            className="w-6 h-5 rounded border border-[var(--leon-line)] bg-white text-[10px] text-[var(--leon-black)]/40 hover:text-[var(--leon-red)]">✕</button>
        </div>
      )}
      {block.pendingInsert && <span className="no-print absolute -right-6 top-0 text-[10px] text-[var(--leon-green)]" title="Inserted, not yet accepted">＋</span>}
      {inner}
    </div>
  );
}

// ── Tables ────────────────────────────────────────────────────────────────
function OfficeWordTableBlock({ block, body, editable, api, theme }) {
  const [cell, setCell] = useState(null);          // {r,c} for the merge controls
  const rows = block.rows || [];
  const T = theme || officeThemeOf(body);
  // A cell's own shading stays a literal — that is what shading IS in Word, a
  // decision about one cell. The RULES are the theme's, thinned into the page.
  const hair = officeThemeMix(T.lt1, T.dk1, 0.16);
  const hairSoft = officeThemeMix(T.lt1, T.dk1, 0.08);
  const headBg = officeThemeMix(T.lt1, T.dk1, 0.055);
  const border = block.borders === 'none' ? 'none'
    : block.borders === 'outer' ? '0.6px solid ' + hairSoft : '0.6px solid ' + hair;
  return (
    <div>
      <div className="overflow-x-auto">
        <table className="w-full" style={{ borderCollapse: 'collapse' }}>
          <tbody>
            {rows.map((row, r) => (
              <tr key={r}>
                {row.map((c, ci) => c.hidden ? null : (
                  <td key={c.id} colSpan={c.colSpan || 1} rowSpan={c.rowSpan || 1}
                    onMouseDown={() => setCell({ r, c: ci })}
                    className="align-top px-2 py-1"
                    style={{
                      border: block.borders === 'outer' ? undefined : border,
                      borderTop: block.borders === 'outer' && r === 0 ? border : undefined,
                      borderBottom: block.borders === 'outer' ? (r === rows.length - 1 ? border : '0.4px solid ' + hairSoft) : undefined,
                      background: c.bg || (block.headerRow && r === 0 ? headBg : 'transparent'),
                      color: T.dk1,
                      fontWeight: block.headerRow && r === 0 ? 700 : 400,
                      fontSize: '10.5pt',
                      outline: cell && cell.r === r && cell.c === ci && editable ? '2px solid var(--leon-brown-light)' : 'none',
                    }}>
                    <OfficeWordRichText
                      html={c.html} editable={editable}
                      onFocusBlock={() => { api.focus(block.id); setCell({ r, c: ci }); }}
                      onCommit={html => api.setCell(block.id, r, ci, { html })}
                      className="min-h-[1.2em]"
                    />
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
      {editable && (
        <div className="no-print mt-1.5 flex flex-wrap items-center gap-1.5 text-[11px]">
          <OfficeWordToolBtn wide title="Add a row below the selected cell"
            onClick={() => api.tableOp(block.id, 'addRow', cell)}>+ Row</OfficeWordToolBtn>
          <OfficeWordToolBtn wide title="Add a column to the right"
            onClick={() => api.tableOp(block.id, 'addCol', cell)}>+ Col</OfficeWordToolBtn>
          <OfficeWordToolBtn wide title="Remove the selected row" disabled={!cell}
            onClick={() => api.tableOp(block.id, 'delRow', cell)}>− Row</OfficeWordToolBtn>
          <OfficeWordToolBtn wide title="Remove the selected column" disabled={!cell}
            onClick={() => api.tableOp(block.id, 'delCol', cell)}>− Col</OfficeWordToolBtn>
          <OfficeWordDivider />
          <OfficeWordToolBtn wide title="Merge this cell with the one to its right" disabled={!cell}
            onClick={() => api.tableOp(block.id, 'mergeRight', cell)}>Merge →</OfficeWordToolBtn>
          <OfficeWordToolBtn wide title="Merge this cell with the one below" disabled={!cell}
            onClick={() => api.tableOp(block.id, 'mergeDown', cell)}>Merge ↓</OfficeWordToolBtn>
          <OfficeWordToolBtn wide title="Undo a merge on this cell" disabled={!cell}
            onClick={() => api.tableOp(block.id, 'unmerge', cell)}>Split</OfficeWordToolBtn>
          <OfficeWordDivider />
          <OfficeWordToolBtn wide active={block.headerRow} title="Treat the first row as a header"
            onClick={() => api.set(block.id, { headerRow: !block.headerRow })}>Header row</OfficeWordToolBtn>
          <select value={block.borders} onChange={e => api.set(block.id, { borders: e.target.value })}
            className="h-7 rounded-md border border-[var(--leon-line)] text-[11px] px-1">
            <option value="all">All borders</option>
            <option value="outer">Rules only</option>
            <option value="none">No borders</option>
          </select>
          {cell && (
            <>
              <OfficeWordDivider />
              <span className="text-[var(--leon-black)]/45">Shading</span>
              {['', '#f7f3ee', '#efe9e1', '#fff3b0', '#d7f0d8', '#ffd7d7'].map(bg => (
                <button key={bg || 'none'} onMouseDown={e => e.preventDefault()}
                  onClick={() => api.setCell(block.id, cell.r, cell.c, { bg })}
                  title={bg || 'No shading'}
                  className="w-5 h-5 rounded border border-[var(--leon-line)]"
                  style={{ background: bg || 'white' }} />
              ))}
            </>
          )}
        </div>
      )}
    </div>
  );
}

// ═══════════════════════════════ Asset picker ═════════════════════════════
// Shared by Word and Presentation. It returns a REFERENCE — a render id, a
// supplier finish ref, a project photo URL — never a second copy of the bytes.
// The one exception is an upload, which is downscaled first and labelled with
// its weight, because the document is what carries it.

function officeWordProjectImages(project) {
  if (!project) return [];
  const out = [];
  if (project.displayImageUrl) out.push({ url: project.displayImageUrl, name: 'Project image', from: 'Project' });
  const co = project.closeout || {};
  (co.photos || []).forEach(p => {
    const u = p.fileUrl || p.url;
    if (u) out.push({ url: u, name: p.caption || p.file || 'Finished photo', from: 'Closeout' });
  });
  (project.renderSets || []).forEach(set => {
    (set.revisions || []).forEach(rev => {
      (rev.images || []).forEach(img => {
        const u = typeof img === 'string' ? img : (img.url || img.fileUrl);
        if (u) out.push({ url: u, name: set.name + ' Rev ' + rev.revisionNumber, from: 'Render set' });
      });
    });
  });
  (project.jobsiteVisits || []).forEach(v => {
    (v.pictures || []).forEach(u => {
      const url = typeof u === 'string' ? u : (u && (u.url || u.fileUrl));
      if (url) out.push({ url, name: 'Site visit ' + fmtDate(v.date), from: 'Jobsite visit' });
    });
  });
  return out;
}

function OfficeWordAssetPicker({ open, onClose, onPick, sc, title }) {
  const [tab, setTab] = useState('render');
  const [q, setQ] = useState('');
  const [sup, setSup] = useState('');
  const [cat, setCat] = useState('');
  const [busy, setBusy] = useState(false);

  const renderItems = typeof RENDER_ITEMS !== 'undefined' ? RENDER_ITEMS : [];
  const renderBoards = typeof RENDER_BOARDS !== 'undefined' ? RENDER_BOARDS : [];
  const groups = useMemo(() => {
    try { return typeof supplierGroups === 'function' ? supplierGroups() : []; } catch (e) { return []; }
  }, []);
  const finishes = useMemo(() => {
    if (tab !== 'finish') return [];
    try { return searchSupplierFinishes(sup || null, cat || null, q, 60); } catch (e) { return []; }
  }, [tab, sup, cat, q]);
  const rItems = useMemo(() => {
    if (tab !== 'render') return [];
    const needle = q.trim().toLowerCase();
    const pool = renderItems.concat(renderBoards.map(b => Object.assign({}, b, { board: null, isBoard: true })));
    return pool.filter(r => !needle
      || String(r.name || '').toLowerCase().includes(needle)
      || String(r.cat || '').toLowerCase().includes(needle)
      || String(r.style || '').toLowerCase().includes(needle)
      || String(r.sub || '').toLowerCase().includes(needle)).slice(0, 80);
  }, [tab, q, renderItems, renderBoards]);
  const photos = useMemo(() => (tab === 'photo' ? officeWordProjectImages(sc.project) : []), [tab, sc.project]);

  async function upload(e) {
    const file = e.target.files && e.target.files[0];
    if (!file) return;
    setBusy(true);
    const shrunk = await officeWordShrinkImage(file);
    setBusy(false);
    if (!shrunk) return;
    onPick({ kind: 'upload', kindLabel: 'upload', url: shrunk.url, name: file.name, bytes: shrunk.url.length });
    onClose();
  }

  return (
    <Modal open={open} onClose={onClose} wide title={title || 'Choose an image'}>
      <Tabs
        active={tab} onChange={setTab}
        tabs={[
          { key: 'render', label: 'Render Library', icon: '🖼️' },
          { key: 'finish', label: 'Supplier finishes', icon: '🎨' },
          { key: 'photo', label: 'Project photos', icon: '📷' },
          { key: 'upload', label: 'Upload', icon: '⬆' },
        ]}
      />
      <div className="pt-3">
        {tab !== 'upload' && tab !== 'photo' && (
          <div className="flex flex-wrap gap-2 mb-3">
            <TextInput placeholder="Search…" value={q} onChange={e => setQ(e.target.value)} className="!w-56" />
            {tab === 'finish' && (
              <>
                <Select value={sup} onChange={e => { setSup(e.target.value); setCat(''); }} className="!w-48">
                  <option value="">Every supplier</option>
                  {groups.map(g => <option key={g.key} value={g.key}>{g.label}</option>)}
                </Select>
                <Select value={cat} onChange={e => setCat(e.target.value)} className="!w-48">
                  <option value="">Every construction</option>
                  {(groups.find(g => g.key === sup) || { cats: [] }).cats.map(c => (
                    <option key={c.cat} value={c.cat}>{c.cat} ({c.count})</option>
                  ))}
                </Select>
              </>
            )}
          </div>
        )}

        {tab === 'render' && (
          <div className="grid grid-cols-2 sm:grid-cols-4 gap-2 max-h-[46vh] overflow-y-auto">
            {rItems.map(r => (
              <button key={r.id} className="text-left border border-[var(--leon-line)] rounded-lg overflow-hidden hover:border-[var(--leon-brown)]"
                onClick={() => { onPick({ kind: r.isBoard ? 'board' : 'render', kindLabel: r.isBoard ? 'render board' : 'render', id: r.id, url: r.web || r.img, thumb: r.thumb, name: r.name, sub: r.sub || r.style || '' }); onClose(); }}>
                <img src={r.thumb || r.img || r.web} alt="" className="w-full h-24 object-cover bg-[var(--leon-cream)]" />
                <div className="px-2 py-1.5">
                  <div className="text-xs font-semibold truncate">{r.name}</div>
                  <div className="text-[10px] text-[var(--leon-black)]/45 truncate">{r.sub || r.style || r.cat}{r.isBoard ? ' · board' : ''}</div>
                </div>
              </button>
            ))}
            {!rItems.length && <EmptyState text="Nothing in the render library matches that." />}
          </div>
        )}

        {tab === 'finish' && (
          <div className="grid grid-cols-2 sm:grid-cols-4 gap-2 max-h-[46vh] overflow-y-auto">
            {finishes.map(f => (
              <button key={f.sup + ':' + f.id} className="text-left border border-[var(--leon-line)] rounded-lg overflow-hidden hover:border-[var(--leon-brown)]"
                onClick={() => {
                  const ref = makeSupplierFinishRef(f);
                  onPick({ kind: 'finish', kindLabel: 'supplier finish', id: f.id, sup: f.sup, url: f.img,
                    name: f.name, code: f.code, supLabel: f.supLabel, cat: f.cat, finishRef: ref });
                  onClose();
                }}>
                {f.img ? <img src={f.img} alt="" className="w-full h-24 object-cover bg-[var(--leon-cream)]" />
                  : <div className="w-full h-24 bg-[var(--leon-cream)] flex items-center justify-center text-[10px] text-[var(--leon-black)]/35">no swatch</div>}
                <div className="px-2 py-1.5">
                  <div className="text-xs font-semibold truncate">{f.name}</div>
                  <div className="text-[10px] text-[var(--leon-black)]/45 truncate">{f.code} · {f.supLabel}</div>
                </div>
              </button>
            ))}
            {!finishes.length && <EmptyState text="No finish matches that." />}
          </div>
        )}

        {tab === 'photo' && (
          <div className="grid grid-cols-2 sm:grid-cols-4 gap-2 max-h-[46vh] overflow-y-auto">
            {photos.map((p, i) => (
              <button key={i} className="text-left border border-[var(--leon-line)] rounded-lg overflow-hidden hover:border-[var(--leon-brown)]"
                onClick={() => { onPick({ kind: 'photo', kindLabel: 'project photo', url: p.url, name: p.name, from: p.from }); onClose(); }}>
                <img src={p.url} alt="" className="w-full h-24 object-cover bg-[var(--leon-cream)]" />
                <div className="px-2 py-1.5">
                  <div className="text-xs font-semibold truncate">{p.name}</div>
                  <div className="text-[10px] text-[var(--leon-black)]/45">{p.from}</div>
                </div>
              </button>
            ))}
            {!photos.length && <EmptyState text={sc.project ? 'This job has no photos filed yet.' : 'Link this document to a project to reach its photos.'} />}
          </div>
        )}

        {tab === 'upload' && (
          <div className="space-y-3">
            <input type="file" accept="image/*" onChange={upload} className="text-sm" />
            {busy && <div className="text-xs text-[var(--leon-brown)]">Downscaling…</div>}
            <div className="text-xs text-[var(--leon-black)]/55 leading-relaxed bg-[var(--leon-cream)] border border-[var(--leon-line)] rounded-lg p-3">
              An uploaded image is stored <strong>inside this document</strong> and counts against the app's storage.
              It is downscaled to {WORD_UPLOAD_MAX_PX}px on the long edge first. Anything that already exists in the Hub —
              a render, a supplier finish, a project photo — should be picked from the tabs above instead: those are
              references, and cost this document nothing.
            </div>
          </div>
        )}
      </div>
    </Modal>
  );
}

// ═══════════════════════════════ Word modals ══════════════════════════════

function OfficeWordFieldModal({ open, onClose, sc, onInsert }) {
  const allowed = WORD_FIELDS.filter(f => officeFinOk(f, sc));
  const groups = WORD_FIELD_GROUPS.filter(g => allowed.some(f => f.group === g));
  const [group, setGroup] = useState(groups[0] || 'Project');
  const list = allowed.filter(f => f.group === group);
  return (
    <Modal open={open} onClose={onClose} wide title="Insert a LEON field">
      <p className="text-xs text-[var(--leon-black)]/55 mb-3">
        A field reads the record every time the document is opened. It shows what the Hub says now, and Refresh
        tells you what changed before it rewrites anything.
      </p>
      <div className="flex flex-wrap gap-1 mb-3">
        {groups.map(g => (
          <button key={g} onClick={() => setGroup(g)}
            className={`px-2.5 py-1 rounded-md text-xs font-semibold border ${group === g
              ? 'bg-[var(--leon-brown)] text-white border-[var(--leon-brown)]'
              : 'border-[var(--leon-line)] text-[var(--leon-black)]/60 hover:border-[var(--leon-brown-light)]'}`}>{g}</button>
        ))}
      </div>
      <div className="max-h-[46vh] overflow-y-auto divide-y divide-[var(--leon-line)]">
        {list.map(f => {
          const v = officeWordFieldValue(f.key, sc);
          return (
            <button key={f.key} onClick={() => { onInsert(f.key); onClose(); }}
              className="w-full text-left py-2 px-1 hover:bg-[var(--leon-cream)] flex items-baseline justify-between gap-3">
              <span>
                <span className="text-sm font-semibold">{f.label}</span>
                <span className="block text-[11px] text-[var(--leon-black)]/45">Source: {f.src}</span>
              </span>
              <span className={`text-xs shrink-0 ${v.value ? 'text-[var(--leon-brown)]' : 'text-[var(--leon-black)]/35 italic'}`}>
                {v.value || 'nothing on record yet'}
              </span>
            </button>
          );
        })}
      </div>
    </Modal>
  );
}

function OfficeWordDataModal({ open, onClose, sc, onInsert }) {
  const [key, setKey] = useState('');
  const [args, setArgs] = useState({});
  const def = WORD_DATA_BY_KEY[key];
  const scopes = ((sc.project || {}).scopes) || [];
  const analyses = ((sc.project || {}).quoteAnalyses) || [];
  useEffect(() => { if (open) { setKey(''); setArgs({}); } }, [open]);
  const preview = def ? officeWordBuildData({ dataKey: key, args }, sc) : null;
  return (
    <Modal open={open} onClose={onClose} wide title="Insert a LEON data table"
      footer={<>
        <Button variant="ghost" onClick={onClose}>Cancel</Button>
        <Button disabled={!def} onClick={() => { onInsert(key, args, def.label); onClose(); }}>Insert</Button>
      </>}>
      {!sc.project && (
        <div className="text-xs bg-[var(--leon-cream)] border border-[var(--leon-line)] rounded-lg p-3 mb-3">
          This document is not linked to a project yet, so these tables have nothing to read. Insert one anyway and it
          fills itself the moment the document is linked.
        </div>
      )}
      <div className="space-y-2">
        {WORD_DATA_BLOCKS.filter(d => officeFinOk(d, sc)).map(d => (
          <button key={d.key} onClick={() => { setKey(d.key); setArgs({}); }}
            className={`w-full text-left border rounded-lg px-3 py-2 ${key === d.key ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)] hover:border-[var(--leon-brown-light)]'}`}>
            <div className="text-sm font-semibold">{d.label}</div>
            <div className="text-[11px] text-[var(--leon-black)]/50">{d.hint}</div>
          </button>
        ))}
      </div>
      {def && def.args.length > 0 && (
        <div className="mt-4 grid sm:grid-cols-2 gap-3">
          {def.args.map(a => (
            <Field key={a.key} label={a.label}>
              <Select value={args[a.key] || ''} onChange={e => setArgs(Object.assign({}, args, { [a.key]: e.target.value }))}>
                <option value="">{a.allowAll || 'Latest'}</option>
                {a.kind === 'scope' && scopes.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
                {a.kind === 'quoteAnalysis' && analyses.map(x => <option key={x.id} value={x.id}>{x.name} · Rev {x.revision}</option>)}
              </Select>
            </Field>
          ))}
        </div>
      )}
      {preview && (
        <div className="mt-4 border border-[var(--leon-line)] rounded-lg p-3 bg-white">
          <div className="text-[11px] uppercase tracking-wide font-semibold text-[var(--leon-black)]/45 mb-2">Preview</div>
          <OfficeWordDataTable built={preview} borders="all" />
        </div>
      )}
    </Modal>
  );
}

// A colour control that can say either "follow the theme" or "this exact
// colour". Both have to stay possible: a slot is what makes a theme change
// mean anything, and a literal is what someone reaches for when the brand book
// says one specific red.
function OfficeWordColorPicker({ value, theme, onChange, autoLabel }) {
  const isSlot = officeThemeIsSlot(value);
  const shown = officeThemeResolve(theme, value, theme.dk1);
  return (
    <div className="flex items-center gap-1.5">
      <span className="w-5 h-5 rounded border border-[var(--leon-line)] shrink-0" style={{ background: shown }} />
      <Select value={isSlot ? value : (value ? '__fixed' : '')} className="!py-1 text-xs"
        onChange={e => {
          const v = e.target.value;
          if (v === '__fixed') onChange(officeThemeHex(shown) || '#161311');
          else onChange(v || null);
        }}>
        <option value="">{autoLabel || 'Automatic (Text / Dark 1)'}</option>
        {OFFICE_THEME_SLOTS.map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
        <option value="__fixed">A fixed colour…</option>
      </Select>
      {!isSlot && value ? (
        <input type="color" value={officeThemeHex(value) || '#161311'} onChange={e => onChange(e.target.value)}
          className="w-8 h-7 rounded border border-[var(--leon-line)] bg-white shrink-0" />
      ) : null}
    </div>
  );
}

// ── The style manager ─────────────────────────────────────────────────────
// Word's whole built-in set is here, editable, and a document can add its own
// ("LEON Scope Heading"). A built-in that has been changed is marked and can be
// RESET to Word's definition rather than lost — which is why an edit lives in
// styleOverrides beside the definition instead of replacing it.
function OfficeWordStyleModal({ open, onClose, body, theme, editable, onSet, onUpdateFromSelection, startId }) {
  const defs = officeWordStyleDefs(body);
  const [pick, setPick] = useState(startId || 'Normal');
  const [msg, setMsg] = useState('');
  useEffect(() => { if (open) { setPick(startId || 'Normal'); setMsg(''); } }, [open, startId]);
  const def = defs.find(d => d.id === pick) || defs[0];
  if (!def) return null;

  function write(fields) {
    if (def.builtin) {
      const over = Object.assign({}, body.styleOverrides || {});
      over[def.id] = Object.assign({}, over[def.id] || over[def.name] || {}, fields);
      onSet({ styleOverrides: over });
    } else {
      const list = (body.customStyles || []).map(s => (s.id === def.id ? Object.assign({}, s, fields) : s));
      onSet({ customStyles: list });
    }
  }
  function resetBuiltin() {
    const over = Object.assign({}, body.styleOverrides || {});
    delete over[def.id]; delete over[def.name];
    onSet({ styleOverrides: over });
    setMsg(def.name + ' is back to Word’s own definition.');
  }
  function addStyle() {
    const s = { id: uid('wst'), name: 'New style', kind: 'paragraph', sizePt: 12 };
    onSet({ customStyles: (body.customStyles || []).concat([s]) });
    setPick(s.id);
  }
  function removeStyle() {
    onSet({ customStyles: (body.customStyles || []).filter(s => s.id !== def.id) });
    setPick('Normal');
    setMsg('Removed. Paragraphs that used it fall back to Normal — no text was touched.');
  }

  const preview = officeWordStyleCss(def, theme);
  return (
    <Modal open={open} onClose={onClose} wide title="Styles">
      <div className="grid sm:grid-cols-[190px_minmax(0,1fr)] gap-4">
        <div className="max-h-[52vh] overflow-y-auto pr-1 -mr-1">
          {['paragraph', 'character'].map(kind => (
            <div key={kind} className="mb-2">
              <div className="text-[10px] uppercase tracking-wide font-semibold text-[var(--leon-black)]/40 mb-1">
                {kind === 'paragraph' ? 'Paragraph styles' : 'Character styles'}
              </div>
              {defs.filter(d => (d.kind === 'character') === (kind === 'character')).map(d => (
                <button key={d.id} onClick={() => { setPick(d.id); setMsg(''); }}
                  className={`block w-full text-left px-2 py-1 rounded text-xs truncate ${d.id === pick ? 'bg-[var(--leon-brown)] text-white' : 'hover:bg-[var(--leon-cream)]'}`}>
                  {d.name}
                  {d.modified && <span className={`ml-1 text-[9px] ${d.id === pick ? 'opacity-80' : 'text-[var(--leon-brown)]'}`}>edited</span>}
                  {!d.builtin && <span className={`ml-1 text-[9px] ${d.id === pick ? 'opacity-80' : 'text-[var(--leon-black)]/40'}`}>yours</span>}
                </button>
              ))}
            </div>
          ))}
          {editable && <Button size="sm" variant="outline" className="w-full mt-1" onClick={addStyle}>+ New style</Button>}
        </div>

        <div className="min-w-0">
          <div className="border border-[var(--leon-line)] rounded-lg p-3 bg-white mb-3">
            <div className="text-[10px] uppercase tracking-wide font-semibold text-[var(--leon-black)]/40 mb-1">Preview</div>
            <div style={Object.assign({}, preview, {
              paddingLeft: (def.indent || 0) * (4 / 3) + 'px',
              borderTop: def.ruled ? '1px solid ' + officeThemeResolve(theme, def.color, theme.accent1) : undefined,
              borderBottom: def.ruled ? '1px solid ' + officeThemeResolve(theme, def.color, theme.accent1) : undefined,
              paddingTop: def.ruled ? 6 : 0, paddingBottom: def.ruled ? 6 : 0,
            })}>
              {def.kind === 'character' ? 'A run of text wearing ' + def.name : def.name + ' — the quick brown fox'}
            </div>
          </div>

          {!editable && <div className="text-xs text-[var(--leon-black)]/50">You can read the styles but not change them.</div>}

          {editable && (
            <div className="space-y-3">
              <div className="grid sm:grid-cols-2 gap-3">
                <Field label="Name">
                  <TextInput value={def.name || ''} disabled={def.builtin}
                    onChange={e => write({ name: e.target.value })} />
                </Field>
                <Field label="Applies to" hint={def.builtin ? 'Word decides this for a built-in.' : 'A character style applies to a selected run; a paragraph style to the whole block.'}>
                  <Select value={def.kind || 'paragraph'} disabled={def.builtin}
                    onChange={e => write({ kind: e.target.value })}>
                    <option value="paragraph">Paragraph</option>
                    <option value="character">Character (a run)</option>
                  </Select>
                </Field>
              </div>
              <div className="grid sm:grid-cols-3 gap-3">
                <Field label="Size (pt)">
                  <TextInput type="number" step="0.5" value={def.sizePt === undefined ? 12 : def.sizePt}
                    onChange={e => write({ sizePt: Number(e.target.value) || 0 })} />
                </Field>
                <Field label="Font">
                  <Select value={def.font || (WORD_MAJOR_STYLES.includes(def.id) ? 'major' : 'minor')}
                    onChange={e => write({ font: e.target.value })}>
                    <option value="major">Theme headings ({theme.majorFont})</option>
                    <option value="minor">Theme body ({theme.minorFont})</option>
                  </Select>
                </Field>
                <Field label="Alignment">
                  <Select value={def.align || 'left'} onChange={e => write({ align: e.target.value })}>
                    <option value="left">Left</option><option value="center">Centre</option>
                    <option value="right">Right</option><option value="justify">Justified</option>
                  </Select>
                </Field>
              </div>
              <div className="flex flex-wrap gap-4 text-sm">
                {[['bold', 'Bold'], ['italic', 'Italic'], ['underline', 'Underline'],
                  ['smallCaps', 'Small caps'], ['ruled', 'Rules above and below']].map(f => (
                  <label key={f[0]} className="flex items-center gap-1.5 text-xs">
                    <input type="checkbox" checked={!!def[f[0]]} onChange={e => write({ [f[0]]: e.target.checked })} /> {f[1]}
                  </label>
                ))}
              </div>
              <Field label="Colour" hint="Choose a theme slot and this style follows the theme; choose a fixed colour and it never moves.">
                <OfficeWordColorPicker value={def.color} theme={theme} onChange={v => write({ color: v })} />
              </Field>
              <div className="grid grid-cols-3 gap-3">
                <Field label="Space before (pt)"><TextInput type="number" value={def.spaceBefore || 0}
                  onChange={e => write({ spaceBefore: Number(e.target.value) || 0 })} /></Field>
                <Field label="Space after (pt)"><TextInput type="number" value={def.spaceAfter || 0}
                  onChange={e => write({ spaceAfter: Number(e.target.value) || 0 })} /></Field>
                <Field label="Indent (pt)"><TextInput type="number" value={def.indent || 0}
                  onChange={e => write({ indent: Number(e.target.value) || 0 })} /></Field>
              </div>

              <div className="flex flex-wrap gap-2 pt-2 border-t border-[var(--leon-line)]">
                <Button size="sm" variant="outline" onClick={() => {
                  const f = onUpdateFromSelection(def);
                  setMsg(f ? 'Updated ' + def.name + ' from the formatting where the caret is.'
                    : 'Put the caret in the text you formatted first — there is nothing to read otherwise.');
                }}>Update from selection</Button>
                {def.builtin && def.modified && <Button size="sm" variant="ghost" onClick={resetBuiltin}>Reset to Word’s definition</Button>}
                {!def.builtin && <Button size="sm" variant="ghost" onClick={removeStyle}>Delete this style</Button>}
              </div>
              {msg && <div className="text-[11px] text-[var(--leon-brown)] font-semibold">{msg}</div>}
              <div className="text-[11px] text-[var(--leon-black)]/45 leading-relaxed">
                <strong>Update from selection</strong> reads the formatting of the run the caret is in and writes it into
                the style, so every paragraph using that style follows. Formatting applied directly to a run stays on that
                run — Word behaves the same way. Clear it with ⌫ to let the style show through.
              </div>
            </div>
          )}
        </div>
      </div>
    </Modal>
  );
}

function OfficeWordPageSetupModal({ open, onClose, body, onSet }) {
  const m = body.margins || {};
  function setMargin(k, v) { onSet({ margins: Object.assign({}, m, { [k]: Number(v) || 0 }) }); }
  return (
    <Modal open={open} onClose={onClose} wide title="Page setup">
      <div className="grid sm:grid-cols-2 gap-3">
        <Field label="Page size">
          <Select value={body.pageSize} onChange={e => onSet({ pageSize: e.target.value })}>
            {Object.keys(WORD_PAGE_SIZES).map(k => <option key={k} value={k}>{WORD_PAGE_SIZES[k].label}</option>)}
          </Select>
        </Field>
        <Field label="Orientation">
          <Select value={body.orientation} onChange={e => onSet({ orientation: e.target.value })}>
            <option value="portrait">Portrait</option>
            <option value="landscape">Landscape</option>
          </Select>
        </Field>
      </div>
      <div className="grid grid-cols-4 gap-2 mt-3">
        {['top', 'right', 'bottom', 'left'].map(k => (
          <Field key={k} label={k[0].toUpperCase() + k.slice(1) + ' (mm)'}>
            <TextInput type="number" value={m[k] === undefined ? 25.4 : m[k]} onChange={e => setMargin(k, e.target.value)} />
          </Field>
        ))}
      </div>
      {/* Word measures the header and footer from the EDGE of the sheet, not
          from the text margin, and its default is half an inch. We had no such
          field at all, which is why a header could never sit where Word puts
          one. 12.7 mm is that half inch, taken from the reference document. */}
      <div className="grid grid-cols-3 gap-2 mt-3">
        <Field label="Header from edge (mm)" hint="Office default 12.7">
          <TextInput type="number" step="0.1" value={body.headerDistance === undefined ? 12.7 : body.headerDistance}
            onChange={e => onSet({ headerDistance: Number(e.target.value) || 0 })} />
        </Field>
        <Field label="Footer from edge (mm)" hint="Office default 12.7">
          <TextInput type="number" step="0.1" value={body.footerDistance === undefined ? 12.7 : body.footerDistance}
            onChange={e => onSet({ footerDistance: Number(e.target.value) || 0 })} />
        </Field>
        <Field label="Gutter (mm)" hint="Extra binding margin on the inside edge.">
          <TextInput type="number" step="0.1" value={body.gutter || 0}
            onChange={e => onSet({ gutter: Number(e.target.value) || 0 })} />
        </Field>
      </div>
      <div className="mt-4 space-y-3">
        <label className="flex items-center gap-2 text-sm">
          <input type="checkbox" checked={body.headerOn} onChange={e => onSet({ headerOn: e.target.checked })} /> Show a running header
        </label>
        <Field label="Header text" hint="Use {{page}} and {{pages}} for page numbers.">
          <TextInput value={body.header} onChange={e => onSet({ header: e.target.value })} placeholder="e.g. LEON Integra — Proposal" />
        </Field>
        <label className="flex items-center gap-2 text-sm">
          <input type="checkbox" checked={body.footerOn} onChange={e => onSet({ footerOn: e.target.checked })} /> Show a running footer
        </label>
        <Field label="Footer text">
          <TextInput value={body.footer} onChange={e => onSet({ footer: e.target.value })} placeholder="e.g. {{page}} of {{pages}}" />
        </Field>
        <div className="text-[11px] text-[var(--leon-black)]/45 leading-relaxed">
          On screen the header and footer are shown once, at the top and bottom of the document — the editor is a
          continuous page, not a paginated one. The printed and PDF output repeats the LEON letterhead on every sheet
          through the app's own print path.
        </div>
      </div>
      <div className="mt-4 border-t border-[var(--leon-line)] pt-3">
        <label className="flex items-center gap-2 text-sm mb-2">
          <input type="checkbox" checked={!!body.cover}
            onChange={e => onSet({ cover: e.target.checked ? { title: '', subtitle: '', showLogo: true } : null })} /> Cover page
        </label>
        {body.cover && (
          <div className="grid sm:grid-cols-2 gap-3">
            <Field label="Cover title"><TextInput value={body.cover.title || ''}
              onChange={e => onSet({ cover: Object.assign({}, body.cover, { title: e.target.value }) })} /></Field>
            <Field label="Cover subtitle"><TextInput value={body.cover.subtitle || ''}
              onChange={e => onSet({ cover: Object.assign({}, body.cover, { subtitle: e.target.value }) })} /></Field>
          </div>
        )}
      </div>
    </Modal>
  );
}

function OfficeWordFindModal({ open, onClose, body, onReplaceAll, editable }) {
  const [find, setFind] = useState('');
  const [repl, setRepl] = useState('');
  const [caseSensitive, setCaseSensitive] = useState(false);
  const hits = useMemo(() => {
    if (!find) return [];
    const out = [];
    (body.blocks || []).forEach(b => {
      const texts = [];
      if (officeWordIsText(b)) texts.push(officeWordPlain(b.html));
      if (b.type === 'table') (b.rows || []).forEach(r => r.forEach(c => texts.push(officeWordPlain(c.html))));
      if (b.type === 'image') texts.push(officeWordPlain(b.caption));
      texts.forEach(t => {
        const hay = caseSensitive ? t : t.toLowerCase();
        const needle = caseSensitive ? find : find.toLowerCase();
        let i = hay.indexOf(needle);
        while (i >= 0) {
          out.push({ blockId: b.id, excerpt: t.slice(Math.max(0, i - 30), i + needle.length + 30) });
          i = hay.indexOf(needle, i + needle.length);
        }
      });
    });
    return out;
  }, [find, caseSensitive, body.blocks]);
  return (
    <Modal open={open} onClose={onClose} wide title="Find and replace"
      footer={<>
        <Button variant="ghost" onClick={onClose}>Close</Button>
        <Button disabled={!editable || !find || !hits.length} onClick={() => { onReplaceAll(find, repl, caseSensitive); }}>
          Replace all ({hits.length})
        </Button>
      </>}>
      <div className="grid sm:grid-cols-2 gap-3">
        <Field label="Find"><TextInput value={find} onChange={e => setFind(e.target.value)} autoFocus /></Field>
        <Field label="Replace with"><TextInput value={repl} onChange={e => setRepl(e.target.value)} /></Field>
      </div>
      <label className="flex items-center gap-2 text-sm mt-2">
        <input type="checkbox" checked={caseSensitive} onChange={e => setCaseSensitive(e.target.checked)} /> Match case
      </label>
      <div className="mt-3 text-xs text-[var(--leon-black)]/55">{find ? hits.length + ' match' + (hits.length === 1 ? '' : 'es') : 'Type something to search for.'}</div>
      <div className="mt-2 max-h-52 overflow-y-auto space-y-1">
        {hits.slice(0, 60).map((h, i) => (
          <div key={i} className="text-[11px] border border-[var(--leon-line)] rounded px-2 py-1 bg-white">…{h.excerpt}…</div>
        ))}
      </div>
      <div className="mt-3 text-[11px] text-[var(--leon-black)]/45">
        Replace all rewrites the text of matching blocks. It never touches the value inside a LEON field — a field's
        text belongs to the record it reads, not to this document.
      </div>
    </Modal>
  );
}

function OfficeWordRefreshModal({ open, onClose, scan, onApply }) {
  const changed = scan.filter(s => s.changed);
  return (
    <Modal open={open} onClose={onClose} wide title="Refresh LEON fields"
      footer={<>
        <Button variant="ghost" onClick={onClose}>Cancel</Button>
        <Button disabled={!changed.length} onClick={() => { onApply(); onClose(); }}>
          Update {changed.length} field{changed.length === 1 ? '' : 's'}
        </Button>
      </>}>
      {!scan.length && <EmptyState text="This document has no LEON fields in it." />}
      {scan.length > 0 && !changed.length && (
        <div className="text-sm text-[var(--leon-green)] font-semibold">Every field already matches the record. Nothing to change.</div>
      )}
      {changed.length > 0 && (
        <>
          <p className="text-xs text-[var(--leon-black)]/55 mb-3">
            These are the differences between what the document says and what the Hub says <em>now</em>. Nothing is
            overwritten until you confirm — an issued document should never move under someone.
          </p>
          <div className="overflow-x-auto">
            <table className="w-full text-xs">
              <thead>
                <tr className="text-left border-b border-[var(--leon-black)]">
                  <th className="py-1 pr-2">Field</th><th className="py-1 pr-2">In the document</th><th className="py-1">Now says</th>
                </tr>
              </thead>
              <tbody>
                {changed.map((c, i) => (
                  <tr key={i} className="border-b border-[var(--leon-line)]">
                    <td className="py-1 pr-2 font-semibold">{c.label}<span className="block text-[10px] font-normal text-[var(--leon-black)]/40">{c.source}</span></td>
                    <td className="py-1 pr-2 text-[var(--leon-red)] line-through">{c.shown || '—'}</td>
                    <td className="py-1 text-[var(--leon-green)] font-semibold">{c.next || '—'}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        </>
      )}
    </Modal>
  );
}

function OfficeWordTemplateModal({ open, onClose, onApply, hasContent }) {
  return (
    <Modal open={open} onClose={onClose} wide title="Start from a LEON template">
      {hasContent && (
        <div className="text-xs bg-[#fff3b0]/50 border border-[var(--leon-line)] rounded-lg p-3 mb-3">
          This document already has content. Applying a template <strong>replaces</strong> it — a version is saved first,
          so the current draft can be brought back from Versions.
        </div>
      )}
      <div className="grid sm:grid-cols-2 gap-2">
        {WORD_TEMPLATES.map(t => (
          <button key={t.key} onClick={() => { onApply(t); onClose(); }}
            className="text-left border border-[var(--leon-line)] rounded-lg px-3 py-2.5 hover:border-[var(--leon-brown)] hover:bg-[var(--leon-cream)]">
            <div className="text-sm font-bold"><span className="mr-1.5">{t.icon}</span>{t.label}</div>
            <div className="text-[11px] text-[var(--leon-black)]/50 mt-0.5">{t.blurb}</div>
          </button>
        ))}
      </div>
    </Modal>
  );
}

// ── Plain-text export ─────────────────────────────────────────────────────
function officeWordToText(body, sc) {
  const lines = [];
  let n = 1;
  (body.blocks || []).forEach((b, i) => {
    if (b.type === 'heading') { lines.push(''); lines.push(officeWordPlain(b.html).toUpperCase()); lines.push(''); }
    else if (b.type === 'paragraph' || b.type === 'quote') lines.push(officeWordPlain(b.html));
    else if (b.type === 'bullet') lines.push('  • ' + officeWordPlain(b.html));
    else if (b.type === 'number') {
      const prev = (body.blocks[i - 1] || {}).type;
      if (prev !== 'number') n = 1;
      lines.push('  ' + n + '. ' + officeWordPlain(b.html)); n++;
    } else if (b.type === 'divider') lines.push('----------------------------------------');
    else if (b.type === 'pagebreak') lines.push('\f');
    else if (b.type === 'image') lines.push('[Image' + (b.ref && b.ref.name ? ': ' + b.ref.name : '') + ']' + (officeWordPlain(b.caption) ? ' ' + officeWordPlain(b.caption) : ''));
    else if (b.type === 'toc') { lines.push('CONTENTS'); (b.entries || []).forEach(e => lines.push('  '.repeat(e.level - 1) + e.text)); lines.push(''); }
    else if (b.type === 'table') {
      (b.rows || []).forEach(r => lines.push(r.filter(c => !c.hidden).map(c => officeWordPlain(c.html)).join('\t')));
      lines.push('');
    } else if (b.type === 'data') {
      const built = officeWordBuildData(b, sc);
      if (b.caption) lines.push(officeWordPlain(b.caption));
      lines.push((built.columns || []).join('\t'));
      (built.rows || []).forEach(r => lines.push(r.join('\t')));
      lines.push('');
    }
  });
  return lines.join('\n');
}
function officeWordDownloadText(name, text) {
  const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = safeFileName(name) + '.txt';
  document.body.appendChild(a); a.click();
  setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 500);
}

// ═══════════════════════════════ LEON WORD ════════════════════════════════
function OfficeWordEditor({ ctx, doc, onChange, editable }) {
  const body = officeWordBody(doc);
  const sc = officeWordScopeOf(ctx, doc);
  const canEditDoc = !!editable;
  const pageRef = useRef(null);
  const [focusId, setFocusId] = useState(null);
  const [panel, setPanel] = useState('outline');
  const [modal, setModal] = useState(null);
  const [imageFor, setImageFor] = useState(null);
  const [commentDraft, setCommentDraft] = useState(null);

  const theme = officeThemeOf(body);
  const paraStyles = officeWordParagraphStyles(body);
  const charStyles = officeWordCharacterStyles(body);
  const blocks = body.blocks;
  const geom = WORD_PAGE_SIZES[body.pageSize] || WORD_PAGE_SIZES.Letter;
  const pageW = (body.orientation === 'landscape' ? geom.h : geom.w) * WORD_MM_PX;
  const m = body.margins || {};

  // Every write goes through here. The object handed to onChange is a FULL
  // document with the changes merged in, so it works whether the caller treats
  // it as a patch to spread or as the replacement record.
  function emit(changes) {
    onChange(Object.assign({}, doc, changes, {
      modifiedDate: todayISO(), modifiedBy: ctx.currentUserName || doc.modifiedBy || '',
    }));
  }
  function setBody(partial) { emit({ body: Object.assign({}, body, partial) }); }
  function mutate(fn, extra) {
    const next = cloneDeep(blocks);
    fn(next);
    setBody(Object.assign({ blocks: next }, extra || {}));
  }
  // Snapshots are bounded and taken at decision points — applying a template,
  // refreshing every field, accepting every tracked change — not per keystroke.
  // One document with a snapshot per keystroke would eat the whole app's quota.
  function pushVersion(note) { return officeWordCaptureVersion(doc, note, ctx.currentUserName); }

  const api = {
    focus: id => setFocusId(id),
    set(id, fields) {
      mutate(next => {
        const b = next.find(x => x.id === id);
        if (b) Object.assign(b, fields);
      });
    },
    setCell(id, r, c, fields) {
      mutate(next => {
        const b = next.find(x => x.id === id);
        if (b && b.rows[r] && b.rows[r][c]) Object.assign(b.rows[r][c], fields);
      });
    },
    tableOp(id, op, cell) { mutate(next => officeWordTableOp(next.find(x => x.id === id), op, cell)); },
    move(id, dir) {
      mutate(next => {
        const i = next.findIndex(x => x.id === id);
        const j = i + dir;
        if (i < 0 || j < 0 || j >= next.length) return;
        const tmp = next[i]; next[i] = next[j]; next[j] = tmp;
      });
    },
  };

  // A tracked change lives on the BODY, so it travels with the document and
  // survives a reload; the pendingInsert / pendingDelete flag on the block is
  // only what the page draws. The record is stashed on the array being
  // committed so the blocks and the change list land in ONE state write —
  // two writes would let a re-render land between them.
  function officeWordRecordChange(list, blockId, kind, before, after, c) {
    const changes = cloneDeep(body.changes || []);
    const existing = changes.find(x => x.blockId === blockId && x.status === 'open' && x.kind === kind);
    if (existing) { existing.after = after; existing.date = todayISO(); }
    else {
      changes.push({
        id: uid('wch'), blockId, kind, before, after, status: 'open',
        author: c.currentUserName || '', date: todayISO(),
      });
    }
    list.__changes = changes;
  }
  function mutateTracked(fn) {
    const next = cloneDeep(blocks);
    fn(next);
    if (next.__changes) {
      const ch = next.__changes;
      delete next.__changes;
      setBody({ blocks: next, changes: ch });
    } else {
      setBody({ blocks: next });
    }
  }
  api.setHtml = (id, html) => mutateTracked(next => {
    const b = next.find(x => x.id === id);
    if (!b) return;
    const before = b.html || '';
    if (before === html) return;
    b.html = html;
    if (body.trackChanges) officeWordRecordChange(next, id, 'edit', before, html, ctx);
  });
  api.remove = id => {
    if (!body.trackChanges) {
      mutate(next => { const i = next.findIndex(x => x.id === id); if (i >= 0) next.splice(i, 1); });
      return;
    }
    mutateTracked(next => {
      const b = next.find(x => x.id === id);
      if (!b) return;
      if (b.pendingInsert) { next.splice(next.indexOf(b), 1); return; }
      b.pendingDelete = true;
      officeWordRecordChange(next, id, 'delete', officeWordPlain(b.html || ''), '', ctx);
    });
  };

  function insertBlock(block) {
    mutateTracked(next => {
      const i = focusId ? next.findIndex(x => x.id === focusId) : -1;
      if (body.trackChanges) {
        block.pendingInsert = true;
        officeWordRecordChange(next, block.id, 'insert', '', officeWordPlain(block.html || block.caption || ''), ctx);
      }
      if (i >= 0) next.splice(i + 1, 0, block); else next.push(block);
    });
    setFocusId(block.id);
  }
  function insertField(key) {
    const html = officeWordFieldHtml(key, sc);
    if (officeWordFocus.el && officeWordInsertHtml(html)) return;
    insertBlock(officeWordMakeBlock('paragraph', { html }));
  }

  // ── Field refresh ───────────────────────────────────────────────────────
  const scan = modal === 'refresh' ? officeWordScanFields(body, sc) : [];
  function applyRefresh() {
    const versions = pushVersion('Before refreshing LEON fields');
    const next = officeWordRefreshBlocks(cloneDeep(blocks), sc);
    emit({ body: Object.assign({}, body, { blocks: next }), versions });
  }
  function freezeAllFields() {
    const versions = pushVersion('Before converting fields to text');
    const next = cloneDeep(blocks);
    next.forEach(b => {
      if (officeWordIsText(b)) b.html = officeWordFreezeHtml(b.html);
      if (b.type === 'image') b.caption = officeWordFreezeHtml(b.caption);
      if (b.type === 'table') (b.rows || []).forEach(r => r.forEach(c => { c.html = officeWordFreezeHtml(c.html); }));
      if (b.type === 'data' && !b.frozen) { b.frozen = true; b.snapshot = Object.assign({}, officeWordBuildData(b, sc), { at: todayISO() }); }
    });
    emit({ body: Object.assign({}, body, { blocks: next }), versions });
  }

  function replaceAll(find, repl, caseSensitive) {
    const flags = caseSensitive ? 'g' : 'gi';
    const rx = new RegExp(find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags);
    const swap = html => {
      if (!html) return html;
      const box = document.createElement('div');
      box.innerHTML = html;
      const walk = node => {
        Array.prototype.slice.call(node.childNodes).forEach(child => {
          if (child.nodeType === 3) { child.textContent = child.textContent.replace(rx, repl); return; }
          // A field's text belongs to the record it reads, not to this document.
          if (child.nodeType === 1 && child.hasAttribute && child.hasAttribute('data-field')) return;
          if (child.nodeType === 1) walk(child);
        });
      };
      walk(box);
      return box.innerHTML;
    };
    mutate(next => {
      next.forEach(b => {
        if (officeWordIsText(b)) b.html = swap(b.html);
        if (b.type === 'image') b.caption = swap(b.caption);
        if (b.type === 'table') (b.rows || []).forEach(r => r.forEach(c => { c.html = swap(c.html); }));
      });
    });
  }

  // ── Track changes ───────────────────────────────────────────────────────
  function resolveChange(changeId, accept) {
    const ch = (body.changes || []).find(c => c.id === changeId);
    if (!ch) return;
    const nextBlocks = cloneDeep(blocks);
    const b = nextBlocks.find(x => x.id === ch.blockId);
    if (b) {
      if (ch.kind === 'edit' && !accept) b.html = ch.before;
      if (ch.kind === 'delete') { if (accept) nextBlocks.splice(nextBlocks.indexOf(b), 1); else delete b.pendingDelete; }
      if (ch.kind === 'insert' || b.pendingInsert) {
        if (accept) delete b.pendingInsert;
        else if (ch.kind === 'insert') nextBlocks.splice(nextBlocks.indexOf(b), 1);
      }
    }
    setBody({ blocks: nextBlocks, changes: (body.changes || []).filter(c => c.id !== changeId) });
  }
  function resolveAll(accept) {
    const versions = pushVersion(accept ? 'Before accepting all changes' : 'Before rejecting all changes');
    let nextBlocks = cloneDeep(blocks);
    (body.changes || []).forEach(ch => {
      const b = nextBlocks.find(x => x.id === ch.blockId);
      if (!b) return;
      if (ch.kind === 'edit' && !accept) b.html = ch.before;
      if (ch.kind === 'delete') { if (accept) nextBlocks = nextBlocks.filter(x => x.id !== b.id); else delete b.pendingDelete; }
    });
    nextBlocks.forEach(b => { delete b.pendingInsert; });
    emit({ body: Object.assign({}, body, { blocks: nextBlocks, changes: [] }), versions });
  }

  // ── Comments ────────────────────────────────────────────────────────────
  function addComment(text) {
    const quote = commentDraft && commentDraft.quote;
    const list = (doc.comments || []).slice();
    list.push({
      id: uid('wcm'), by: ctx.currentUserName || '', date: todayISO(), text,
      anchor: { blockId: (commentDraft && commentDraft.blockId) || focusId || null, quote: quote || '' },
      resolved: false, replies: [],
    });
    emit({ comments: list });
    setCommentDraft(null);
  }
  function replyComment(id, text) {
    const list = cloneDeep(doc.comments || []);
    const c = list.find(x => x.id === id);
    if (c) c.replies.push({ id: uid('wcr'), by: ctx.currentUserName || '', date: todayISO(), text });
    emit({ comments: list });
  }
  function toggleResolved(id) {
    const list = cloneDeep(doc.comments || []);
    const c = list.find(x => x.id === id);
    if (c) c.resolved = !c.resolved;
    emit({ comments: list });
  }

  // ── Theme ───────────────────────────────────────────────────────────────
  // One act, and undoable — the snapshot is taken BEFORE the theme lands, the
  // same rule the deck already followed. Nothing else has to be rewritten:
  // every style resolves its colour through the theme at render time, so the
  // whole document restyles simply because the theme it points at changed.
  function applyWordTheme(themeId) {
    const next = officeThemeList(body).find(t => t.id === themeId);
    if (!next) return;
    const versions = pushVersion('Before applying the ' + next.name + ' theme');
    emit({ body: Object.assign({}, body, { themeId }), versions });
  }
  // Push the formatting where the caret is into a style definition.
  function updateStyleFromSelection(def) {
    const f = officeWordFormattingAtCaret();
    if (!f) return false;
    const fields = { sizePt: f.sizePt, bold: f.bold, italic: f.italic, underline: f.underline, smallCaps: f.smallCaps };
    // A colour read off the screen is a literal, and saying so is the honest
    // thing: if it happens to equal a theme slot, keep the SLOT so the style
    // carries on following the theme instead of quietly freezing to a hex.
    const slot = OFFICE_THEME_SLOT_KEYS.find(k => officeThemeHex(theme[k]) === officeThemeHex(f.color));
    fields.color = slot || f.color || null;
    if (def.builtin) {
      const over = Object.assign({}, body.styleOverrides || {});
      over[def.id] = Object.assign({}, over[def.id] || over[def.name] || {}, fields);
      setBody({ styleOverrides: over });
    } else {
      setBody({ customStyles: (body.customStyles || []).map(s => (s.id === def.id ? Object.assign({}, s, fields) : s)) });
    }
    return true;
  }

  function applyTemplate(t) {
    const versions = (blocks || []).length ? pushVersion('Before applying the ' + t.label + ' template') : (doc.versions || []);
    // The template's fields are written as empty tokens; filling them here is
    // what makes a new proposal open complete rather than full of holes.
    emit({
      body: Object.assign({}, body, { blocks: officeWordRefreshBlocks(t.build(sc), sc), changes: [] }),
      versions,
      templateSource: t.key,
      name: doc.name && doc.name !== 'Untitled' ? doc.name : t.label,
    });
  }
  const words = useMemo(() => {
    let n = 0;
    (blocks || []).forEach(b => {
      if (officeWordIsText(b)) n += officeWordCountWords(officeWordPlain(b.html));
      if (b.type === 'image') n += officeWordCountWords(officeWordPlain(b.caption));
      if (b.type === 'table') (b.rows || []).forEach(r => r.forEach(c => { n += officeWordCountWords(officeWordPlain(c.html)); }));
    });
    return n;
  }, [blocks]);
  const bytes = officeWordDocBytes(doc);
  const openChanges = (body.changes || []).filter(c => c.status === 'open');
  const openComments = (doc.comments || []).filter(c => !c.resolved);
  const printLines = [
    sc.project ? sc.project.name + ' · ' + sc.project.projectNumber : null,
    sc.account ? sc.account.name : null,
    doc.status + (doc.revision ? ' · Rev ' + doc.revision : ''),
  ].filter(Boolean);

  return (
    <div className="space-y-3">
      {/* Scoped to this editor rather than added to styles.css, which belongs to
          the app shell. A field has to LOOK like a field on screen — otherwise
          nobody knows which numbers move on their own — and has to look like
          ordinary type on paper, where the distinction means nothing. */}
      {/* The character styles are emitted as REAL CSS rules from the live
          definitions, so a run wearing one follows both the style and the
          theme. Emitting them here rather than inlining the look on the run is
          exactly what makes it a style and not direct formatting. */}
      <style>{`
        .wfield { background: rgba(107,74,52,.09); border-bottom: 1px dotted var(--leon-brown-light); padding: 0 1px; border-radius: 2px; }
        [data-placeholder]:empty::before { content: attr(data-placeholder); color: rgba(22,19,17,.28); }
        @media print { .wfield { background: none; border-bottom: none; } }
        ${officeWordCharacterCss(body, theme)}
      `}</style>

      {/* ── Document bar ───────────────────────────────────────────────── */}
      <div className="no-print flex flex-wrap items-center gap-2 justify-between">
        <div className="flex items-center gap-2 min-w-0">
          <span className="text-lg">📄</span>
          <div className="min-w-0">
            <div className="font-bold text-sm truncate">{doc.name || 'Untitled'}</div>
            <div className="text-[11px] text-[var(--leon-black)]/45 truncate">
              {sc.project ? sc.project.name : 'Not linked to a project'} · {words} word{words === 1 ? '' : 's'} · {officeWordFmtBytes(bytes)}
              {bytes > WORD_DOC_SIZE_WARN && <span className="text-[var(--leon-red)] font-semibold"> · large — uploaded images are what weigh a document</span>}
            </div>
          </div>
        </div>
        <div className="flex flex-wrap items-center gap-1.5">
          {canEditDoc && <Button size="sm" variant="outline" onClick={() => setModal('template')}>Templates</Button>}
          <Button size="sm" variant="outline" onClick={() => setModal('styles')}>Styles</Button>
          <Button size="sm" variant="outline" onClick={() => setModal('theme')} title={'Theme: ' + theme.name}>Theme</Button>
          {canEditDoc && <Button size="sm" variant="outline" onClick={() => setModal('page')}>Page setup</Button>}
          <Button size="sm" variant="outline" onClick={() => setModal('find')}>Find</Button>
          <Button size="sm" variant="outline" onClick={() => setModal('refresh')}>Refresh fields</Button>
          <IconAction icon="🖨" title="Print this document"
            onClick={() => printRegion(pageRef.current, { title: doc.name, heading: doc.name, lines: printLines })} />
          <IconAction icon="📄" title="Download as a PDF"
            onClick={() => exportPdf(pageRef.current, { title: doc.name, heading: doc.name, lines: printLines })} />
          <IconAction icon="🅣" title="Download as plain text"
            onClick={() => officeWordDownloadText(doc.name, officeWordToText(body, sc))} />
        </div>
      </div>

      {/* ── The ribbon ─────────────────────────────────────────────────
          Word's own tab set, with the existing controls moved into it rather
          than rewritten. Every one of these still suppresses mousedown, which
          is what stops the toolbar stealing the selection it acts on. */}
      {canEditDoc && (
        <OfficeRibbon appKey="word" tabs={[
          { key: 'home', label: 'Home', groups: [
            { label: 'Styles', items: <>
              <select
                value={officeWordStyleId((blocks.find(b => b.id === focusId) || {}).style)}
                onMouseDown={e => e.stopPropagation()}
                onChange={e => {
                  const id = e.target.value;
                  const lvl = Object.keys(WORD_HEADING_STYLE).find(k => WORD_HEADING_STYLE[k] === id);
                  const cur = blocks.find(b => b.id === focusId) || {};
                  const fields = { style: id };
                  // Choosing a Heading style IS what makes a block a heading — that
                  // is what puts it in the outline and in the table of contents.
                  if (lvl) { fields.type = 'heading'; fields.level = Number(lvl); }
                  else if (cur.type === 'heading') fields.type = 'paragraph';
                  api.set(focusId, fields);
                }}
                disabled={!focusId}
                title="Paragraph style — applies to the whole block"
                className="h-7 rounded-md border border-[var(--leon-line)] text-xs px-1.5">
                {paraStyles.map(st => <option key={st.id} value={st.id}>{st.name}</option>)}
              </select>
              {/* Character styles — a RUN inside the block, which is a different
                  act from choosing a paragraph style. These are BUTTONS and not a
                  <select> for a concrete reason: a select has to take focus to
                  open, and taking focus away from the text is exactly what loses
                  the selection they act on. */}
              <span className="flex items-center gap-0.5" title="Character styles — select some text first">
                <span className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/40 px-0.5">Aa</span>
                {charStyles.map(cs => {
                  const css = officeWordStyleCss(cs, theme);
                  return (
                    <OfficeWordToolBtn key={cs.id} wide
                      title={'Character style: ' + cs.name + ' — applies to the selected run'}
                      onClick={() => officeWordApplyCharStyle(cs.id)}>
                      <span style={{ fontStyle: css.fontStyle, fontWeight: css.fontWeight, color: css.color, fontVariant: css.fontVariant }}>
                        {String(cs.name).replace(/^Intense /, '')}
                      </span>
                    </OfficeWordToolBtn>
                  );
                })}
                <OfficeWordToolBtn title="Take the character style off the selected run"
                  onClick={() => officeWordApplyCharStyle('')}>✕</OfficeWordToolBtn>
              </span>
            </> },
            { label: 'Font', items: <>
              <select onChange={e => { if (e.target.value) officeWordSetFontSize(Number(e.target.value)); e.target.value = ''; }}
                className="h-7 rounded-md border border-[var(--leon-line)] text-xs px-1.5" defaultValue="">
                <option value="">Size</option>
                {WORD_FONT_SIZES.map(sz => <option key={sz} value={sz}>{sz} pt</option>)}
              </select>
              <OfficeWordToolBtn title="Bold" onClick={() => officeWordExec('bold')}><b>B</b></OfficeWordToolBtn>
              <OfficeWordToolBtn title="Italic" onClick={() => officeWordExec('italic')}><i>I</i></OfficeWordToolBtn>
              <OfficeWordToolBtn title="Underline" onClick={() => officeWordExec('underline')}><u>U</u></OfficeWordToolBtn>
              <OfficeWordToolBtn title="Strikethrough" onClick={() => officeWordExec('strikeThrough')}><s>S</s></OfficeWordToolBtn>
              <OfficeWordToolBtn title="Superscript" onClick={() => officeWordExec('superscript')}>x²</OfficeWordToolBtn>
              <OfficeWordToolBtn title="Subscript" onClick={() => officeWordExec('subscript')}>x₂</OfficeWordToolBtn>
              <OfficeWordToolBtn title="Clear formatting" onClick={() => officeWordExec('removeFormat')}>⌫</OfficeWordToolBtn>
            </> },
            { label: 'Colour', items: <>
              <span className="flex items-center gap-0.5">
                {WORD_COLORS.map(c => (
                  <button key={c} title={'Text colour ' + c} onMouseDown={e => e.preventDefault()}
                    onClick={() => officeWordExec('foreColor', c)}
                    className="w-4 h-4 rounded-sm border border-[var(--leon-line)]" style={{ background: c }} />
                ))}
              </span>
              <span className="flex items-center gap-0.5 ml-1">
                {WORD_HIGHLIGHTS.map(c => (
                  <button key={c} title="Highlight" onMouseDown={e => e.preventDefault()}
                    onClick={() => officeWordExec('hiliteColor', c)}
                    className="w-4 h-4 rounded-sm border border-[var(--leon-line)]" style={{ background: c }} />
                ))}
                <button title="No highlight" onMouseDown={e => e.preventDefault()}
                  onClick={() => officeWordExec('hiliteColor', 'transparent')}
                  className="w-4 h-4 rounded-sm border border-[var(--leon-line)] bg-white text-[9px] leading-none">✕</button>
              </span>
            </> },
            { label: 'Paragraph', items: <>
              {[['left', '⯇'], ['center', '≡'], ['right', '⯈'], ['justify', '☰']].map(a => (
                <OfficeWordToolBtn key={a[0]} title={'Align ' + a[0]} disabled={!focusId}
                  active={(blocks.find(b => b.id === focusId) || {}).align === a[0]}
                  onClick={() => api.set(focusId, { align: a[0] })}>{a[1]}</OfficeWordToolBtn>
              ))}
              <select disabled={!focusId} value={String((blocks.find(b => b.id === focusId) || {}).lineSpacing || 1.45)}
                onChange={e => api.set(focusId, { lineSpacing: Number(e.target.value) })}
                className="h-7 rounded-md border border-[var(--leon-line)] text-xs px-1">
                {[1, 1.15, 1.45, 1.75, 2].map(sp => <option key={sp} value={sp}>{sp}×</option>)}
              </select>
              <OfficeWordToolBtn title="Decrease indent" disabled={!focusId}
                onClick={() => api.set(focusId, { indent: Math.max(0, ((blocks.find(b => b.id === focusId) || {}).indent || 0) - 1) })}>⇤</OfficeWordToolBtn>
              <OfficeWordToolBtn title="Increase indent" disabled={!focusId}
                onClick={() => api.set(focusId, { indent: Math.min(6, ((blocks.find(b => b.id === focusId) || {}).indent || 0) + 1) })}>⇥</OfficeWordToolBtn>
            </> },
          ] },
          { key: 'insert', label: 'Insert', groups: [
            { label: 'Blocks', items: <>
              {WORD_BLOCK_KINDS.map(k => (
                <OfficeWordToolBtn key={k.type} title={'Insert ' + k.label.toLowerCase()} wide
                  onClick={() => {
                    if (k.type === 'image') { const b = officeWordMakeBlock('image'); insertBlock(b); setImageFor(b.id); return; }
                    if (k.type === 'data') { setModal('data'); return; }
                    if (k.type === 'toc') { insertBlock(officeWordMakeBlock('toc', { entries: officeWordTocEntries(blocks) })); return; }
                    insertBlock(officeWordMakeBlock(k.type));
                  }}>{k.icon}</OfficeWordToolBtn>
              ))}
            </> },
            { label: 'LEON data', items: <>
              <OfficeWordToolBtn wide title="Insert a live LEON field" onClick={() => setModal('field')}>🔗 Field</OfficeWordToolBtn>
            </> },
          ] },
          { key: 'review', label: 'Review', groups: [
            { label: 'Comments', items: <>
              <OfficeWordToolBtn wide title="Comment on the selected text"
                onClick={() => { setCommentDraft({ blockId: focusId, quote: officeWordSelectedText() }); setPanel('comments'); }}>💬 Comment</OfficeWordToolBtn>
            </> },
            { label: 'Tracking', items: <>
              <OfficeWordToolBtn wide active={body.trackChanges} title="Record every edit with its author, so it can be accepted or rejected"
                onClick={() => setBody({ trackChanges: !body.trackChanges })}>
                {body.trackChanges ? 'Tracking' : 'Track changes'}
              </OfficeWordToolBtn>
            </> },
          ] },
        ]} />
      )}

      <div className="grid lg:grid-cols-[260px_minmax(0,1fr)] gap-4 items-start">
        {/* ── Side panel ──────────────────────────────────────────────── */}
        <div className="no-print order-2 lg:order-1">
          <Tabs active={panel} onChange={setPanel} tabs={[
            { key: 'outline', label: 'Outline' },
            { key: 'comments', label: 'Comments' + (openComments.length ? ' (' + openComments.length + ')' : '') },
            { key: 'changes', label: 'Changes' + (openChanges.length ? ' (' + openChanges.length + ')' : '') },
          ]} />
          <div className="pt-3 space-y-3 text-sm">
            {panel === 'outline' && (
              <>
                {officeWordTocEntries(blocks).map(e => (
                  <button key={e.id} onClick={() => setFocusId(e.id)}
                    className="block w-full text-left text-xs hover:text-[var(--leon-brown)] truncate"
                    style={{ paddingLeft: (e.level - 1) * 10, fontWeight: e.level === 1 ? 700 : 400 }}>{e.text}</button>
                ))}
                {!officeWordTocEntries(blocks).length && <EmptyState text="No headings yet." />}
                <div className="pt-2 border-t border-[var(--leon-line)] space-y-1.5">
                  <div className="text-[11px] uppercase tracking-wide font-semibold text-[var(--leon-black)]/45">LEON fields</div>
                  {canEditDoc && (
                    <>
                      <Button size="sm" variant="outline" className="w-full" onClick={() => setModal('refresh')}>Refresh and compare</Button>
                      <Button size="sm" variant="outline" className="w-full" onClick={freezeAllFields}>Convert every field to text</Button>
                      <div className="text-[10px] text-[var(--leon-black)]/45 leading-relaxed">
                        Freezing is for a document that has been issued: the numbers stop following the job.
                      </div>
                    </>
                  )}
                </div>
                <div className="pt-2 border-t border-[var(--leon-line)]">
                  <div className="text-[11px] uppercase tracking-wide font-semibold text-[var(--leon-black)]/45 mb-1">Versions</div>
                  {canEditDoc && <Button size="sm" variant="outline" className="w-full mb-1"
                    onClick={() => emit({ versions: pushVersion('Saved by ' + (ctx.currentUserName || '')) })}>Save a version</Button>}
                  <div className="text-[10px] text-[var(--leon-black)]/45 leading-relaxed">
                    {(doc.versions || []).length || 'No'} saved. The whole history, with a diff and Restore, is on the
                    document's <strong>Versions</strong> tab. A snapshot is also taken automatically before anything
                    destructive — applying a template, refreshing every field, accepting all changes. The last
                    {' ' + officeWordVersionLimit()} are kept; one per keystroke would fill the app's storage.
                  </div>
                </div>
                <OfficeWordLimitsNote />
              </>
            )}

            {panel === 'comments' && (
              <>
                {commentDraft && (
                  <div className="border border-[var(--leon-brown-light)] rounded-lg p-2 bg-[var(--leon-cream)]">
                    {commentDraft.quote
                      ? <div className="text-[11px] italic mb-1">“{commentDraft.quote.slice(0, 120)}”</div>
                      : <div className="text-[11px] text-[var(--leon-black)]/45 mb-1">On this block.</div>}
                    <OfficeWordQuickInput placeholder="Comment…" onSubmit={addComment} />
                    <button className="text-[11px] underline mt-1" onClick={() => setCommentDraft(null)}>Cancel</button>
                  </div>
                )}
                {(doc.comments || []).map(c => (
                  <div key={c.id} className={`border rounded-lg p-2 ${c.resolved ? 'border-[var(--leon-line)] opacity-60' : 'border-[var(--leon-brown-light)]'}`}>
                    <div className="text-[11px] font-semibold">{c.by} <span className="font-normal text-[var(--leon-black)]/45">{fmtDate(c.date)}</span></div>
                    {c.anchor && c.anchor.quote && <div className="text-[11px] italic text-[var(--leon-brown)] mb-1">“{c.anchor.quote.slice(0, 90)}”</div>}
                    <div className="text-xs whitespace-pre-wrap">{c.text}</div>
                    {(c.replies || []).map(r => (
                      <div key={r.id} className="mt-1.5 pl-2 border-l-2 border-[var(--leon-line)]">
                        <div className="text-[10px] font-semibold">{r.by} <span className="font-normal text-[var(--leon-black)]/45">{fmtDate(r.date)}</span></div>
                        <div className="text-[11px] whitespace-pre-wrap">{r.text}</div>
                      </div>
                    ))}
                    <div className="flex items-center gap-2 mt-1.5">
                      <button className="text-[11px] underline hover:text-[var(--leon-brown)]" onClick={() => toggleResolved(c.id)}>
                        {c.resolved ? 'Reopen' : 'Resolve'}
                      </button>
                      {c.anchor && c.anchor.blockId && (
                        <button className="text-[11px] underline hover:text-[var(--leon-brown)]" onClick={() => setFocusId(c.anchor.blockId)}>Go to</button>
                      )}
                    </div>
                    {!c.resolved && <OfficeWordQuickInput placeholder="Reply…" onSubmit={t => replyComment(c.id, t)} small />}
                  </div>
                ))}
                {!(doc.comments || []).length && !commentDraft && <EmptyState text="No comments. Select some text and press 💬." />}
              </>
            )}

            {panel === 'changes' && (
              <>
                <label className="flex items-center gap-2 text-xs">
                  <input type="checkbox" checked={body.showMarkup} onChange={e => setBody({ showMarkup: e.target.checked })} />
                  Show the markup on the page
                </label>
                {canEditDoc && openChanges.length > 0 && (
                  <div className="flex gap-1.5">
                    <Button size="sm" onClick={() => resolveAll(true)}>Accept all</Button>
                    <Button size="sm" variant="outline" onClick={() => resolveAll(false)}>Reject all</Button>
                  </div>
                )}
                {openChanges.map(c => (
                  <div key={c.id} className="border border-[var(--leon-line)] rounded-lg p-2">
                    <div className="text-[11px] font-semibold">
                      {c.kind === 'delete' ? 'Deleted' : c.kind === 'insert' ? 'Inserted' : 'Edited'} · {c.author} · {fmtDate(c.date)}
                    </div>
                    <div className="text-[11px] text-[var(--leon-red)] line-through truncate">{officeWordPlain(c.before) || '—'}</div>
                    <div className="text-[11px] text-[var(--leon-green)] truncate">{officeWordPlain(c.after) || '—'}</div>
                    {canEditDoc && (
                      <div className="flex gap-2 mt-1">
                        <button className="text-[11px] underline" onClick={() => resolveChange(c.id, true)}>Accept</button>
                        <button className="text-[11px] underline" onClick={() => resolveChange(c.id, false)}>Reject</button>
                        <button className="text-[11px] underline" onClick={() => setFocusId(c.blockId)}>Go to</button>
                      </div>
                    )}
                  </div>
                ))}
                {!openChanges.length && <EmptyState text={body.trackChanges ? 'Nothing recorded yet.' : 'Track changes is off.'} />}
              </>
            )}
          </div>
        </div>

        {/* ── The page ────────────────────────────────────────────────── */}
        <div className="order-1 lg:order-2 overflow-x-auto">
          {/* The page itself takes the theme's background and body face, so a
              theme change is visible on the paper and not only in the type.
              headerDistance / footerDistance are measured from the EDGE of the
              sheet the way Word measures them, which is why the header sits in
              the top margin rather than inside the text block. */}
          <div ref={pageRef} data-print-region
            className="border border-[var(--leon-line)] rounded-sm shadow-sm mx-auto"
            style={{ width: pageW, maxWidth: '100%',
              background: officeThemeResolve(theme, 'lt1', '#ffffff'),
              fontFamily: officeThemeFontStack(theme, 'minor'),
              paddingTop: (m.top || 25.4) * WORD_MM_PX, paddingBottom: (m.bottom || 25.4) * WORD_MM_PX,
              paddingLeft: ((m.left || 25.4) + (body.gutter || 0)) * WORD_MM_PX, paddingRight: (m.right || 25.4) * WORD_MM_PX }}>

            {body.headerOn && body.header && (
              <div className="text-[9pt] pb-1"
                style={{ color: theme.dk2, borderBottom: '1px solid ' + officeThemeMix(theme.lt1, theme.dk1, 0.14),
                  marginTop: -Math.max(0, ((m.top || 25.4) - (body.headerDistance === undefined ? 12.7 : body.headerDistance)) * WORD_MM_PX),
                  marginBottom: Math.max(8, ((m.top || 25.4) - (body.headerDistance === undefined ? 12.7 : body.headerDistance)) * WORD_MM_PX - 8) }}>
                {body.header.replace(/\{\{page\}\}/g, '1').replace(/\{\{pages\}\}/g, '1')}
              </div>
            )}

            {body.cover && (
              <div className="mb-10 pb-10 text-center" style={{ borderBottom: '1px solid ' + officeThemeMix(theme.lt1, theme.dk1, 0.14) }}>
                <img src="logo/leon-wordmark.svg" alt="LEON" className="h-10 mx-auto mb-4"
                  style={{ filter: officeThemeLum(theme.lt1) < 0.5 ? 'invert(1) brightness(1.6)' : undefined }} />
                <div className="text-3xl font-bold tracking-wide"
                  style={{ fontFamily: officeThemeFontStack(theme, 'major'), color: theme.dk1 }}>{body.cover.title || doc.name}</div>
                {body.cover.subtitle && <div className="text-base mt-2" style={{ color: theme.accent1 }}>{body.cover.subtitle}</div>}
                <div className="text-xs mt-6" style={{ color: theme.dk2 }}>
                  {sc.project ? sc.project.name + ' · ' + sc.project.projectNumber : ''}<br />
                  {sc.account ? sc.account.name : ''}<br />
                  {fmtDate(todayISO())}
                </div>
              </div>
            )}

            {blocks.map((b, i) => (
              <div key={b.id} className={b.type === 'heading' ? 'lp-section-title' : ''}>
                <OfficeWordBlock body={body} block={b} blocks={blocks} index={i} sc={sc} theme={theme}
                  editable={canEditDoc} selected={focusId === b.id} api={api} />
              </div>
            ))}

            {!blocks.length && (
              <div className="py-16 text-center">
                <div className="text-sm text-[var(--leon-black)]/45 mb-3">This document is empty.</div>
                {canEditDoc && <Button onClick={() => setModal('template')}>Start from a LEON template</Button>}
              </div>
            )}

            {body.footerOn && body.footer && (
              <div className="text-[9pt] pt-1"
                style={{ color: theme.dk2, borderTop: '1px solid ' + officeThemeMix(theme.lt1, theme.dk1, 0.14),
                  marginTop: Math.max(12, ((m.bottom || 25.4) - (body.footerDistance === undefined ? 12.7 : body.footerDistance)) * WORD_MM_PX),
                  marginBottom: -Math.max(0, ((m.bottom || 25.4) - (body.footerDistance === undefined ? 12.7 : body.footerDistance)) * WORD_MM_PX) }}>
                {body.footer.replace(/\{\{page\}\}/g, '1').replace(/\{\{pages\}\}/g, '1')}
              </div>
            )}
          </div>

          {canEditDoc && (
            <div className="no-print mt-2 flex flex-wrap gap-1.5 justify-center">
              <Button size="sm" variant="ghost" onClick={() => { setFocusId(null); insertBlock(officeWordMakeBlock('paragraph')); }}>+ Paragraph at the end</Button>
            </div>
          )}
        </div>
      </div>

      {/* ── Modals ─────────────────────────────────────────────────────── */}
      <OfficeWordTemplateModal open={modal === 'template'} onClose={() => setModal(null)}
        onApply={applyTemplate} hasContent={!!blocks.length} />
      <OfficeWordPageSetupModal open={modal === 'page'} onClose={() => setModal(null)} body={body} onSet={setBody} />
      <OfficeWordFindModal open={modal === 'find'} onClose={() => setModal(null)} body={body}
        editable={canEditDoc} onReplaceAll={(f, r, cs) => { replaceAll(f, r, cs); setModal(null); }} />
      <OfficeWordRefreshModal open={modal === 'refresh'} onClose={() => setModal(null)} scan={scan} onApply={applyRefresh} />
      <OfficeWordFieldModal open={modal === 'field'} onClose={() => setModal(null)} sc={sc} onInsert={insertField} />
      <OfficeWordDataModal open={modal === 'data'} onClose={() => setModal(null)} sc={sc}
        onInsert={(key, args, label) => insertBlock(officeWordMakeBlock('data', { dataKey: key, args, caption: label }))} />
      <OfficeWordAssetPicker open={!!imageFor} onClose={() => setImageFor(null)} sc={sc}
        onPick={ref => api.set(imageFor, { ref })} />
      <OfficeWordStyleModal open={modal === 'styles'} onClose={() => setModal(null)} body={body} theme={theme}
        editable={canEditDoc} onSet={setBody} onUpdateFromSelection={updateStyleFromSelection}
        startId={officeWordStyleId((blocks.find(b => b.id === focusId) || {}).style)} />
      <OfficeThemeModal open={modal === 'theme'} onClose={() => setModal(null)} body={body} editable={canEditDoc}
        onApplyTheme={id => applyWordTheme(id)} onSaveThemes={list => setBody({ themes: list })}
        note={'Every style in this document that names a slot follows the theme; a style set to a fixed colour does not.'} />
    </div>
  );
}

function OfficeWordQuickInput({ placeholder, onSubmit, small }) {
  const [v, setV] = useState('');
  return (
    <div className={`flex gap-1 ${small ? 'mt-1.5' : 'mt-1'}`}>
      <input value={v} onChange={e => setV(e.target.value)}
        onKeyDown={e => { if (e.key === 'Enter' && v.trim()) { onSubmit(v.trim()); setV(''); } }}
        placeholder={placeholder}
        className="flex-1 min-w-0 rounded-md border border-[var(--leon-line)] px-2 py-1 text-[11px]" />
      <button disabled={!v.trim()} onClick={() => { onSubmit(v.trim()); setV(''); }}
        className="px-2 rounded-md bg-[var(--leon-brown)] text-white text-[11px] disabled:opacity-40">Post</button>
    </div>
  );
}

// Said where someone would look for it, rather than left to be discovered.
function OfficeWordLimitsNote() {
  return (
    <div className="pt-2 border-t border-[var(--leon-line)] text-[10px] text-[var(--leon-black)]/45 leading-relaxed space-y-1">
      <div className="uppercase tracking-wide font-semibold text-[var(--leon-black)]/40">What this editor does not do</div>
      <div><strong>No live co-editing.</strong> There is no server behind the Hub — two people editing the same document at
        once would overwrite each other. Comments and tracked changes are how a document is worked on together here.</div>
      <div><strong>No .docx import or export.</strong> That needs a converter this browser does not have. Export is PDF or
        plain text; both are written from the real document, not from a picture of the screen. The styles and the theme
        follow Word's MODEL — its style set, its twelve theme slots — which is not the same as reading or writing its
        format, and no amount of matching the model makes a .docx openable here.</div>
      <div><strong>A font can only be a font that exists.</strong> A theme names two faces; the browser uses the one that
        is installed or shipped with the Hub and falls back through the stack otherwise. Naming Aptos does not fetch it.</div>
      <div><strong>No AI writing.</strong> Nothing here drafts, rewrites or summarises for you.</div>
      <div><strong>Spell-check is your browser's</strong>, on the text boxes above. The Hub does not ship a dictionary.</div>
    </div>
  );
}

// ── Table operations ──────────────────────────────────────────────────────
function officeWordTableOp(block, op, cell) {
  if (!block || !block.rows) return;
  const rows = block.rows;
  const width = rows[0] ? rows[0].length : 0;
  const r = cell ? cell.r : rows.length - 1;
  const c = cell ? cell.c : width - 1;
  if (op === 'addRow') rows.splice(r + 1, 0, new Array(width).fill(0).map(() => officeWordCell('')));
  else if (op === 'delRow' && rows.length > 1) rows.splice(r, 1);
  else if (op === 'addCol') rows.forEach(row => row.splice(c + 1, 0, officeWordCell('')));
  else if (op === 'delCol' && width > 1) rows.forEach(row => row.splice(c, 1));
  else if (op === 'mergeRight') {
    const a = rows[r] && rows[r][c];
    const b = rows[r] && rows[r][c + (a ? a.colSpan || 1 : 1)];
    if (a && b && !b.hidden) { a.colSpan = (a.colSpan || 1) + (b.colSpan || 1); b.hidden = true; }
  } else if (op === 'mergeDown') {
    const a = rows[r] && rows[r][c];
    const below = rows[r + (a ? a.rowSpan || 1 : 1)];
    const b = below && below[c];
    if (a && b && !b.hidden) { a.rowSpan = (a.rowSpan || 1) + (b.rowSpan || 1); b.hidden = true; }
  } else if (op === 'unmerge') {
    const a = rows[r] && rows[r][c];
    if (!a) return;
    for (let rr = r; rr < r + (a.rowSpan || 1); rr++) {
      for (let cc = c; cc < c + (a.colSpan || 1); cc++) {
        if (rows[rr] && rows[rr][cc] && !(rr === r && cc === c)) rows[rr][cc].hidden = false;
      }
    }
    a.colSpan = 1; a.rowSpan = 1;
  }
}

// ═══════════════════════════════ LEON PRESENTATION ════════════════════════
//
// makeSlidesBody() is { size, theme, slides } and is not redefined here. A
// slide holds ELEMENTS positioned in PERCENTAGES of the slide, never pixels:
// the same deck has to draw correctly in a 700px editor pane, full screen on a
// projector, and on a printed page. A pixel would be right in exactly one of
// those three.

const SLIDE_SIZES = { '16:9': 56.25, '4:3': 75 };   // padding-top %, i.e. h/w
const SLIDE_SNAP = 1.1;                              // % within which an edge snaps

const SLIDE_TRANSITIONS = [
  { key: 'none', label: 'None' },
  { key: 'fade', label: 'Fade' },
  { key: 'slide', label: 'Slide' },
];

// ── Layouts ───────────────────────────────────────────────────────────────
// SLIDE_LAYOUTS is PowerPoint's own eleven, in data.jsx, with its names and
// its type codes — and it is never redefined here. This editor used to carry
// nine layouts of its own invention; the map below is what lets a deck built
// against those nine open against these eleven without losing anything. Only
// the layout KEY is rewritten, and only at lookup: a slide's elements are
// never touched by a migration, because a layout is a starting point and the
// slide is the work.
const SLIDE_LEGACY_LAYOUT = {
  title: 'title',
  titleContent: 'obj',
  twoColumn: 'twoObj',
  imageFull: 'picTx',
  imageCaption: 'picTx',
  comparison: 'twoTxTwoObj',
  section: 'secHead',
  quote: 'secHead',
  blank: 'blank',
};
function officeSlidesLayoutList() {
  try { return (typeof SLIDE_LAYOUTS !== 'undefined' && SLIDE_LAYOUTS) ? SLIDE_LAYOUTS : []; } catch (e) { return []; }
}
function officeSlidesLayoutKey(key) {
  const k = String(key || 'obj');
  if (officeSlidesLayoutList().some(l => l.key === k)) return k;
  return SLIDE_LEGACY_LAYOUT[k] || 'blank';
}
function officeSlidesLayout(key) {
  const k = officeSlidesLayoutKey(key);
  return officeSlidesLayoutList().find(l => l.key === k) || { key: k, name: 'Blank', type: 'blank', ph: [] };
}
// An icon and, where it matters, a plain statement of what the layout is for.
// The two vertical layouts get one because a layout that lies about itself is
// worse than one that is missing.
const SLIDE_LAYOUT_META = {
  title:           { icon: '🅣', note: '' },
  obj:             { icon: '📃', note: '' },
  secHead:         { icon: '⏸', note: '' },
  twoObj:          { icon: '⫼', note: '' },
  twoTxTwoObj:     { icon: '⚖️', note: '' },
  titleOnly:       { icon: '🅷', note: '' },
  blank:           { icon: '▭', note: '' },
  objTx:           { icon: '🗂️', note: '' },
  picTx:           { icon: '🏞', note: '' },
  vertTx:          { icon: '↧', note: 'Vertical text. The body frame runs top-to-bottom, right-to-left — for CJK. Latin letters stay upright and read down the column; that is what vertical writing does to them, not a fault.' },
  vertTitleAndTx:  { icon: '⇊', note: 'Vertical title AND body, title on the right — the CJK reading order. Latin text in these frames reads down the column.' },
};

// ── Placeholders ──────────────────────────────────────────────────────────
// A layout OWNS a list of placeholders; a slide made from it gets those frames,
// positioned by the layout, showing prompt text until they are filled. Before
// this, every element on every slide was free-floating and two slides made from
// the same layout could not be relied on to line up — which is the whole reason
// PowerPoint has layouts at all.
//
// An empty placeholder is a prompt, not content: it shows while editing and
// renders NOTHING when presented, printed or thumbnailed.
const SLIDE_PH_SPECS = {
  title: [
    { ph: 'ctrTitle', prompt: 'Click to add title', x: 8, y: 33, w: 84, h: 16, size: 40, bold: true, role: 'title', align: 'center', valign: 'middle' },
    { ph: 'subTitle', prompt: 'Click to add subtitle', x: 14, y: 52, w: 72, h: 11, size: 18, role: 'subtitle', align: 'center' },
  ],
  obj: [
    { ph: 'title', prompt: 'Click to add title', x: 7, y: 8, w: 86, h: 12, size: 28, bold: true, role: 'title' },
    { ph: 'body', prompt: 'Click to add text', x: 7, y: 23, w: 86, h: 62, size: 16 },
  ],
  secHead: [
    { ph: 'title', prompt: 'Click to add section name', x: 8, y: 42, w: 84, h: 15, size: 34, bold: true, role: 'title' },
    { ph: 'body', prompt: 'Click to add text', x: 8, y: 59, w: 84, h: 12, size: 15, role: 'caption' },
  ],
  twoObj: [
    { ph: 'title', prompt: 'Click to add title', x: 7, y: 8, w: 86, h: 12, size: 28, bold: true, role: 'title' },
    { ph: 'body', prompt: 'Click to add text', x: 7, y: 23, w: 41, h: 62, size: 15 },
    { ph: 'body', prompt: 'Click to add text', x: 52, y: 23, w: 41, h: 62, size: 15 },
  ],
  twoTxTwoObj: [
    { ph: 'title', prompt: 'Click to add title', x: 7, y: 7, w: 86, h: 11, size: 26, bold: true, role: 'title' },
    { ph: 'body', prompt: 'Click to add heading', x: 7, y: 21, w: 41, h: 8, size: 18, bold: true, role: 'subtitle' },
    { ph: 'body', prompt: 'Click to add text', x: 7, y: 31, w: 41, h: 54, size: 14 },
    { ph: 'body', prompt: 'Click to add heading', x: 52, y: 21, w: 41, h: 8, size: 18, bold: true, role: 'subtitle' },
    { ph: 'body', prompt: 'Click to add text', x: 52, y: 31, w: 41, h: 54, size: 14 },
  ],
  titleOnly: [
    { ph: 'title', prompt: 'Click to add title', x: 7, y: 8, w: 86, h: 12, size: 28, bold: true, role: 'title' },
  ],
  blank: [],
  objTx: [
    { ph: 'title', prompt: 'Click to add title', x: 6, y: 8, w: 32, h: 13, size: 22, bold: true, role: 'title' },
    { ph: 'body', prompt: 'Click to add content', x: 41, y: 8, w: 53, h: 78, size: 15 },
    { ph: 'body', prompt: 'Click to add caption', x: 6, y: 23, w: 32, h: 63, size: 13, role: 'caption' },
  ],
  picTx: [
    { ph: 'title', prompt: 'Click to add title', x: 6, y: 8, w: 32, h: 13, size: 22, bold: true, role: 'title' },
    { ph: 'pic', kind: 'image', prompt: 'Click to add a picture', x: 41, y: 8, w: 53, h: 78, fit: 'cover' },
    { ph: 'body', prompt: 'Click to add caption', x: 6, y: 23, w: 32, h: 63, size: 13, role: 'caption' },
  ],
  vertTx: [
    { ph: 'title', prompt: 'Click to add title', x: 7, y: 8, w: 86, h: 12, size: 28, bold: true, role: 'title' },
    { ph: 'body', prompt: 'Click to add text', x: 7, y: 23, w: 86, h: 62, size: 16, vertical: true },
  ],
  vertTitleAndTx: [
    { ph: 'title', prompt: 'Click to add title', x: 73, y: 8, w: 20, h: 77, size: 28, bold: true, role: 'title', vertical: true },
    { ph: 'body', prompt: 'Click to add text', x: 7, y: 8, w: 63, h: 77, size: 16, vertical: true },
  ],
};
function officeSlidesPhSpecs(layoutKey) { return SLIDE_PH_SPECS[officeSlidesLayoutKey(layoutKey)] || []; }
// Is this placeholder still waiting to be filled? Text with nothing in it, or
// a picture frame with no picture.
function officeSlidesPhEmpty(el) {
  if (!el || !el.ph) return false;
  if (el.kind === 'image') return !(el.ref && el.ref.url);
  return !officeWordPlain(el.html);
}

// ── The theme, seen as a slide palette ────────────────────────────────────
// The canvas draws with semantic tokens — background, panel, rule, accent —
// and the theme carries Office's twelve slots. This is the one place the two
// meet. Deriving panel and rule rather than declaring them keeps a theme at
// twelve decisions instead of thirty, and means ANY theme, including one
// somebody invents this afternoon, produces a slide that reads.
function officeSlidesPalette(theme, master) {
  const m = master || {};
  const bg = officeThemeResolve(theme, m.background || 'lt1', theme.lt1);
  const ink = officeThemeResolve(theme, m.bodyColor || 'dk1', theme.dk1);
  const title = officeThemeResolve(theme, m.titleColor || 'dk2', theme.dk2);
  const accent = theme.accent1;
  return {
    key: theme.id, label: theme.name, dark: officeThemeLum(bg) < 0.5,
    bg,
    panel: officeThemeMix(bg, ink, 0.055),
    rule: officeThemeMix(bg, ink, 0.18),
    ink,
    title,
    muted: officeThemeMix(ink, bg, 0.38),
    accent,
    accent2: theme.accent2,
    onAccent: officeThemeOn(accent, theme),
    series: [theme.accent1, theme.accent2, theme.accent3, theme.accent4, theme.accent5, theme.accent6],
    font: officeThemeFontStack(theme, 'minor'),
    fontMajor: officeThemeFontStack(theme, 'major'),
  };
}

function officeSlidesEl(kind, data) {
  return Object.assign({
    id: uid('sel'), kind, x: 8, y: 20, w: 84, h: 20, rot: 0, z: 1,
    // themed: true means "this follows the deck's theme". The moment someone
    // sets a colour by hand it flips false, so a theme change never silently
    // undoes a deliberate choice. A colour that names a THEME SLOT is the
    // better answer again — it follows the theme without anything having to be
    // rewritten when the theme changes.
    themed: true,
  }, data || {});
}
function officeSlidesText(html, o) {
  return officeSlidesEl('text', Object.assign({
    html: html || '', size: 16, align: 'left', valign: 'top', bold: false, color: null, role: 'body',
  }, o || {}));
}
// One placeholder frame, from the layout's own spec.
function officeSlidesPhEl(spec, index) {
  const base = { ph: spec.ph, phIdx: index, prompt: spec.prompt || 'Click to add text',
    x: spec.x, y: spec.y, w: spec.w, h: spec.h, vertical: !!spec.vertical };
  if (spec.kind === 'image') {
    return officeSlidesEl('image', Object.assign({}, base, { ref: null, fit: spec.fit || 'cover' }));
  }
  return officeSlidesText('', Object.assign({}, base, {
    size: spec.size || 16, bold: !!spec.bold, role: spec.role || 'body',
    align: spec.align || 'left', valign: spec.valign || 'top',
  }));
}
function officeSlidesMakeSlide(layout, T, theme) {
  const key = officeSlidesLayoutKey(layout || 'obj');
  const s = { id: uid('sld'), layout: key, name: '', elements: [], notes: '', hidden: false,
    section: '', transition: 'none', bg: null, furniture: {} };
  s.elements = officeSlidesPhSpecs(key).map((spec, i) => officeSlidesPhEl(spec, i));
  // The title slide keeps the wordmark it always had — it is the one piece of
  // furniture that is LEON's rather than PowerPoint's.
  if (key === 'title') s.elements.push(officeSlidesEl('logo', { variant: 'wordmark', x: 8, y: 11, w: 22, h: 9 }));
  return s;
}
// Re-seat the placeholders on a slide against a layout: move the ones that
// exist, add the ones that are missing, and never remove anything a person put
// there. Changing a layout must not be able to lose work.
function officeSlidesApplyLayout(slide, key) {
  const k = officeSlidesLayoutKey(key);
  const specs = officeSlidesPhSpecs(k);
  const els = (slide.elements || []).slice();
  specs.forEach((spec, i) => {
    const at = els.find(e => e.ph === spec.ph && e.phIdx === i);
    if (at) {
      Object.assign(at, { x: spec.x, y: spec.y, w: spec.w, h: spec.h, vertical: !!spec.vertical, prompt: spec.prompt });
      if (at.kind === 'text') Object.assign(at, { size: spec.size || at.size, bold: !!spec.bold, role: spec.role || 'body', align: spec.align || 'left', valign: spec.valign || 'top' });
    } else {
      els.push(officeSlidesPhEl(spec, i));
    }
  });
  slide.layout = k;
  slide.elements = els;
  return slide;
}

// ── Slide furniture: date, footer, slide number ───────────────────────────
// SLIDE_FURNITURE (data.jsx) is the list every one of PowerPoint's eleven
// layouts carries, and we had no concept of it at all. It is drawn as CHROME
// rather than as three more elements on every slide: it belongs to the master,
// it is the same on every slide that inherits, and a deck with sixty slides
// should not carry a hundred and eighty draggable boxes to say so.
function officeSlidesFurnitureKeys() {
  try { return (typeof SLIDE_FURNITURE !== 'undefined' && SLIDE_FURNITURE) ? SLIDE_FURNITURE : ['date', 'footer', 'slideNumber']; }
  catch (e) { return ['date', 'footer', 'slideNumber']; }
}
function officeSlidesMaster(body) {
  const stored = (body && body.master) || {};
  let base = null;
  // A stable id, because makeSlideMaster mints a new one on every call and a
  // master that changed identity every render would never compare equal.
  try { base = makeSlideMaster({ id: stored.id || 'mstr-1' }); } catch (e) { base = null; }
  return Object.assign({
    id: stored.id || 'mstr-1', name: 'LEON Master', themeId: null,
    showDate: false, dateText: '', showFooter: false, footerText: '',
    showSlideNumber: true, showOnTitleSlide: false,
    background: 'lt1', titleColor: 'dk2', bodyColor: 'dk1',
  }, base || {}, stored);
}
// Inheritance, made explicit: undefined on the slide means "take the master's",
// and anything else means "set here". That is the same distinction LEON Casework
// draws between a derived part and an overridden one, and the UI says which.
const SLIDE_FURNITURE_FIELDS = {
  date: { show: 'showDate', text: 'dateText', label: 'Date' },
  footer: { show: 'showFooter', text: 'footerText', label: 'Footer' },
  slideNumber: { show: 'showSlideNumber', text: null, label: 'Slide number' },
};
function officeSlidesFurnitureValue(master, slide, kind, field) {
  const f = SLIDE_FURNITURE_FIELDS[kind];
  const key = field === 'text' ? f.text : f.show;
  if (!key) return undefined;
  const over = (slide && slide.furniture) || {};
  return over[key] === undefined ? master[key] : over[key];
}
function officeSlidesFurnitureIsSet(slide, kind, field) {
  const f = SLIDE_FURNITURE_FIELDS[kind];
  const key = field === 'text' ? f.text : f.show;
  if (!key) return false;
  return ((slide && slide.furniture) || {})[key] !== undefined;
}
// The title slide is the one place a footer and a page number look wrong, which
// is exactly why PowerPoint has "don't show on the title slide" as its own
// switch rather than making you clear three boxes.
function officeSlidesFurnitureOn(master, slide) {
  if (!slide) return false;
  if (officeSlidesLayoutKey(slide.layout) === 'title' && !master.showOnTitleSlide) return false;
  return true;
}
// Numbering. A hidden slide is not shown, so it is not counted and the numbers
// after it close up — which is the number the audience will actually be looking
// at. Reordering re-numbers by itself because the number is never stored.
function officeSlidesNumbers(slides) {
  const map = {};
  let n = 0;
  (slides || []).forEach(s => { if (!s.hidden) { n += 1; map[s.id] = n; } });
  return { map, total: n };
}
function officeSlidesResolveFurniture(master, slide, number) {
  const out = { date: null, footer: null, slideNumber: null };
  if (!officeSlidesFurnitureOn(master, slide)) return out;
  if (officeSlidesFurnitureValue(master, slide, 'date', 'show')) {
    const t = officeSlidesFurnitureValue(master, slide, 'date', 'text');
    out.date = t || fmtDate(todayISO());
  }
  if (officeSlidesFurnitureValue(master, slide, 'footer', 'show')) {
    out.footer = officeSlidesFurnitureValue(master, slide, 'footer', 'text') || '';
  }
  if (officeSlidesFurnitureValue(master, slide, 'slideNumber', 'show') && number) {
    out.slideNumber = String(number);
  }
  return out;
}

function officeSlidesBody(doc) {
  const b = (doc && doc.body) || {};
  // A deck written before themes existed carries the old named look. Map it
  // rather than dropping it: leon-corporate was the LEON palette on cream,
  // leon-light the same on white, leon-dark the dark one.
  const legacy = { 'leon-corporate': { themeId: 'leon', background: 'lt2' },
    'leon-light': { themeId: 'leon', background: 'lt1' },
    'leon-dark': { themeId: 'leon-dark', background: 'lt1' } }[b.theme] || null;
  const master = officeSlidesMaster(b);
  if (!b.master && legacy) master.background = legacy.background;
  return {
    size: b.size || '16:9',
    theme: b.theme || 'leon-corporate',        // kept so an old deck still reads
    themeId: b.themeId || (legacy ? legacy.themeId : 'leon'),
    themes: Array.isArray(b.themes) ? b.themes : [],
    master,
    slides: Array.isArray(b.slides) ? b.slides : [],
  };
}
// A colour set by hand wins, and a colour that names a THEME SLOT follows the
// theme. Otherwise the element takes the colour its ROLE has, which is what
// lets one theme change restyle a whole deck.
function officeSlidesRoleColor(el, T, theme) {
  if (el.color) return officeThemeResolve(theme, el.color, T.ink);
  if (el.role === 'title') return T.title;
  if (el.role === 'subtitle') return T.accent;
  if (el.role === 'caption') return T.muted;
  // Type sitting ON an accent panel. A role rather than a stored colour,
  // because which of black and white reads there is a fact about the theme.
  if (el.role === 'onAccent') return T.onAccent;
  return T.ink;
}
// Shape fills and strokes go through the same resolver, so 'accent1' follows
// the theme and '#b83b3b' stays that red for ever.
function officeSlidesFill(value, T, theme, fallback) {
  if (value === undefined || value === null) return fallback;
  if (value === '') return '';
  return officeThemeResolve(theme, value, fallback);
}

// ── Charts, drawn by hand ─────────────────────────────────────────────────
// No chart library, the same choice every Gantt in this app makes. A bar, a
// line and a pie are a handful of SVG primitives; a dependency is a whole
// vocabulary to keep in step with the brand palette.
function OfficeSlidesChart({ el, T, scale }) {
  const series = (el.series || []).filter(s => s && s.label !== undefined);
  const w = 300, h = 180, pad = 26;
  const max = Math.max(1, ...series.map(s => Number(s.value) || 0));
  // Chart series follow the theme's six accents, in order — which is exactly
  // what an Office chart does, and why a retheme recolours a chart too.
  const colors = T.series || [T.accent, T.accent2, '#3a7d44', '#c99a2e', '#b83b3b', '#5a6b7d'];
  let content = null;
  if (!series.length) {
    content = <text x={w / 2} y={h / 2} textAnchor="middle" fontSize="11" fill={T.muted}>No data on this chart yet</text>;
  } else if (el.chartType === 'pie') {
    const total = series.reduce((a, s) => a + (Number(s.value) || 0), 0) || 1;
    let angle = -Math.PI / 2;
    const cx = w / 2, cy = h / 2, r = Math.min(w, h) / 2 - 12;
    content = series.map((s, i) => {
      const frac = (Number(s.value) || 0) / total;
      const a2 = angle + frac * Math.PI * 2;
      const large = frac > 0.5 ? 1 : 0;
      const d = `M ${cx} ${cy} L ${cx + r * Math.cos(angle)} ${cy + r * Math.sin(angle)} A ${r} ${r} 0 ${large} 1 ${cx + r * Math.cos(a2)} ${cy + r * Math.sin(a2)} Z`;
      angle = a2;
      return <path key={i} d={d} fill={colors[i % colors.length]} stroke={T.bg} strokeWidth="1" />;
    });
  } else if (el.chartType === 'line') {
    const step = (w - pad * 2) / Math.max(1, series.length - 1);
    const pts = series.map((s, i) => [pad + i * step, h - pad - ((Number(s.value) || 0) / max) * (h - pad * 2)]);
    content = (
      <>
        <polyline points={pts.map(p => p.join(',')).join(' ')} fill="none" stroke={T.accent} strokeWidth="2.5" />
        {pts.map((p, i) => <circle key={i} cx={p[0]} cy={p[1]} r="3.5" fill={T.accent} />)}
      </>
    );
  } else {
    const bw = (w - pad * 2) / series.length;
    content = series.map((s, i) => {
      const bh = ((Number(s.value) || 0) / max) * (h - pad * 2);
      return <rect key={i} x={pad + i * bw + bw * 0.15} y={h - pad - bh} width={bw * 0.7} height={Math.max(1, bh)}
        fill={colors[i % colors.length]} rx="2" />;
    });
  }
  return (
    <div className="w-full h-full flex flex-col">
      {el.title && <div style={{ fontSize: 13 * scale, fontWeight: 700, color: T.ink, marginBottom: 2 * scale }}>{el.title}</div>}
      <svg viewBox={`0 0 ${w} ${h}`} className="flex-1 w-full" preserveAspectRatio="xMidYMid meet">
        {el.chartType !== 'pie' && <line x1={pad} y1={h - pad} x2={w - pad} y2={h - pad} stroke={T.rule} strokeWidth="1" />}
        {content}
        {el.chartType !== 'pie' && series.map((s, i) => {
          const bw = (w - pad * 2) / series.length;
          return <text key={i} x={pad + i * bw + bw / 2} y={h - pad + 12} textAnchor="middle" fontSize="8" fill={T.muted}>{String(s.label).slice(0, 12)}</text>;
        })}
      </svg>
      {el.chartType === 'pie' && (
        <div className="flex flex-wrap gap-x-2 gap-y-0.5 justify-center" style={{ fontSize: 8 * scale, color: T.muted }}>
          {series.map((s, i) => (
            <span key={i} className="inline-flex items-center gap-1">
              <span style={{ width: 6 * scale, height: 6 * scale, background: colors[i % colors.length], display: 'inline-block', borderRadius: 1 }} />
              {s.label}
            </span>
          ))}
        </div>
      )}
    </div>
  );
}

// ── One element on the canvas ─────────────────────────────────────────────
function OfficeSlidesElementView({ el, T, theme, scale, editable, selected, onCommit, onSelect, onStart }) {
  // An empty placeholder is a PROMPT, not content. It is drawn while the deck
  // is being edited and drawn by nothing else — not in present mode, not in a
  // thumbnail, not on paper. "Click to add title" belongs on a projector about
  // as much as a red squiggle does.
  const emptyPh = officeSlidesPhEmpty(el);
  if (emptyPh && !editable) return null;
  const common = {
    position: 'absolute',
    left: el.x + '%', top: el.y + '%', width: el.w + '%', height: el.h + '%',
    transform: el.rot ? `rotate(${el.rot}deg)` : undefined,
    zIndex: el.z || 1,
    // An element could carry an `opacity` and NOTHING read it, so every shape
    // painted solid. A scrim meant to sit at 12% over a photograph rendered as
    // an opaque black rectangle, which is why a break page looked like a dark
    // slab with a picture somewhere behind it. Applied on the wrapper so it
    // works for any kind — a faded picture is as reasonable as a faded shape.
    opacity: (el.opacity == null || el.opacity === 1) ? undefined : el.opacity,
  };
  let inner = null;
  if (el.kind === 'text') {
    const color = officeSlidesRoleColor(el, T, theme);
    const style = {
      fontSize: (el.size || 16) * scale + 'px',
      lineHeight: 1.28, color, fontWeight: el.bold ? 700 : 400,
      textAlign: el.align || 'left',
      fontFamily: el.role === 'title' ? T.fontMajor : T.font,
      letterSpacing: el.role === 'title' ? 0.4 * scale + 'px' : undefined,
      // The two vertical layouts are REAL vertical text, not a rotated box:
      // vertical-rl is the writing mode CJK actually uses, so the characters
      // stack down the column and the columns run right to left. Latin letters
      // stay upright and read downwards — which is what vertical writing does
      // to them, and is said on the layout rather than hidden.
      writingMode: el.vertical ? 'vertical-rl' : undefined,
      textOrientation: el.vertical ? 'mixed' : undefined,
      width: el.vertical ? undefined : '100%',
      height: el.vertical ? '100%' : undefined,
    };
    inner = (
      <div className="w-full h-full flex"
        style={{
          alignItems: el.vertical ? 'stretch' : (el.valign === 'middle' ? 'center' : el.valign === 'bottom' ? 'flex-end' : 'flex-start'),
          justifyContent: el.vertical ? 'flex-end' : undefined,
          overflow: 'hidden',
        }}>
        <OfficeWordRichText html={el.html} editable={editable} style={style}
          className={el.vertical ? 'whitespace-pre-wrap' : 'w-full whitespace-pre-wrap'}
          placeholder={el.ph ? (el.prompt || 'Click to add text') : 'Text'}
          onFocusBlock={() => onSelect(el.id)}
          onCommit={html => onCommit(el.id, { html })} />
      </div>
    );
  } else if (el.kind === 'image') {
    inner = el.ref && el.ref.url
      ? <img src={el.ref.url} alt="" className="w-full h-full" style={{ objectFit: el.fit || 'cover', borderRadius: el.radius ? el.radius + 'px' : 0 }} />
      : <div className="w-full h-full border border-dashed flex items-center justify-center text-center px-2"
          style={{ borderColor: T.rule, color: T.muted, fontSize: 11 * scale, fontFamily: T.font }}>
          {el.ph ? (el.prompt || 'Click to add a picture') : 'Pick an image'}
        </div>;
  } else if (el.kind === 'logo') {
    const src = el.variant === 'mark' ? 'logo/leon-mark.svg'
      : el.variant === 'official' ? 'logo/leon-official.svg' : 'logo/leon-wordmark.svg';
    inner = <img src={src} alt="LEON" className="w-full h-full" style={{ objectFit: 'contain', filter: T.dark && el.variant !== 'mark' ? 'invert(1) brightness(1.6)' : undefined }} />;
  } else if (el.kind === 'shape') {
    // A fill or a stroke may name a THEME SLOT, and then it follows the theme
    // with nothing rewritten; a hex stays that hex.
    const fill = officeSlidesFill(el.fill, T, theme, T.accent);
    const stroke = officeSlidesFill(el.stroke, T, theme, T.rule) || T.rule;
    if (el.shape === 'ellipse') {
      inner = <div className="w-full h-full" style={{ background: fill, border: el.strokeWidth ? `${el.strokeWidth}px solid ${stroke}` : 'none', borderRadius: '50%' }} />;
    } else if (el.shape === 'line' || el.shape === 'arrow') {
      const lineColor = officeSlidesFill(el.stroke, T, theme, T.accent) || T.accent;
      inner = (
        <svg viewBox="0 0 100 20" preserveAspectRatio="none" className="w-full h-full">
          <line x1="1" y1="10" x2={el.shape === 'arrow' ? 92 : 99} y2="10" stroke={lineColor} strokeWidth={el.strokeWidth || 2} vectorEffect="non-scaling-stroke" />
          {el.shape === 'arrow' && <polygon points="92,4 100,10 92,16" fill={lineColor} />}
        </svg>
      );
    } else {
      inner = <div className="w-full h-full" style={{ background: fill, border: el.strokeWidth ? `${el.strokeWidth}px solid ${stroke}` : 'none', borderRadius: (el.radius || 0) + 'px' }} />;
    }
  } else if (el.kind === 'table') {
    inner = (
      <div className="w-full h-full overflow-hidden">
        <table className="w-full" style={{ borderCollapse: 'collapse', fontSize: (el.size || 11) * scale + 'px', color: T.ink }}>
          <tbody>
            {(el.rows || []).map((row, r) => (
              <tr key={r}>
                {row.map((c, ci) => (
                  <td key={ci} className="px-1 py-0.5 align-top"
                    style={{
                      border: '0.5px solid ' + T.rule,
                      background: el.headerRow && r === 0 ? T.panel : 'transparent',
                      fontWeight: el.headerRow && r === 0 ? 700 : 400,
                    }}>{c}</td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    );
  } else if (el.kind === 'chart') {
    inner = <OfficeSlidesChart el={el} T={T} scale={scale} />;
  }

  return (
    <div style={Object.assign({}, common, emptyPh
      ? { outline: '1px dashed ' + T.rule, outlineOffset: 0 } : null)}
      onMouseDown={e => { if (editable) onStart(e, el.id, 'move'); }}
      className={selected && editable ? 'outline outline-2 outline-[var(--leon-brown)]' : ''}>
      {inner}
      {editable && selected && (
        <>
          {[['nw', 0, 0], ['ne', 100, 0], ['sw', 0, 100], ['se', 100, 100], ['n', 50, 0], ['s', 50, 100], ['w', 0, 50], ['e', 100, 50]].map(h => (
            <span key={h[0]}
              onMouseDown={e => { e.stopPropagation(); onStart(e, el.id, 'resize-' + h[0]); }}
              className="absolute bg-white border border-[var(--leon-brown)]"
              style={{ width: 8, height: 8, left: `calc(${h[1]}% - 4px)`, top: `calc(${h[2]}% - 4px)`, cursor: h[0] + '-resize', zIndex: 60 }} />
          ))}
          <span
            onMouseDown={e => { e.stopPropagation(); onStart(e, el.id, 'rotate'); }}
            title="Rotate"
            className="absolute rounded-full bg-white border border-[var(--leon-brown)] flex items-center justify-center text-[9px]"
            style={{ width: 14, height: 14, left: 'calc(50% - 7px)', top: -22, cursor: 'grab', zIndex: 60 }}>⟳</span>
        </>
      )}
    </div>
  );
}

// ── The canvas ────────────────────────────────────────────────────────────
// Drag, resize and rotate are done against a live "ghost" and only committed
// on mouse-up. Writing every mousemove into the document would put a hundred
// state writes (and a hundred localStorage saves) behind one drag.
function OfficeSlidesCanvas({ slide, T, theme, master, number, ratio, editable, selectedIds, onSelect, onApply, onPickImage, present }) {
  // A read-only render has nothing selected, and a caller that says so by
  // omitting the prop should not crash the tab. `selectedIds.includes` threw
  // from three places when the print holder rendered without it.
  selectedIds = selectedIds || [];
  onSelect = onSelect || function () {};
  onApply = onApply || function () {};
  const ref = useRef(null);
  const [w, setW] = useState(960);
  const [drag, setDrag] = useState(null);
  const [ghost, setGhost] = useState(null);
  const [guides, setGuides] = useState([]);

  useEffect(() => {
    function measure() {
      if (!ref.current) return;
      const got = ref.current.getBoundingClientRect().width;
      // 0 means it has not been laid out yet; keeping the last good width is
      // better than collapsing every element to nothing for a frame.
      if (got > 0) setW(got);
    }
    measure();
    // A WINDOW resize is not the only way this box changes size, and it was the
    // only thing being listened for. The canvas mounts before its own container
    // has laid out — in Present mode most of all, where the overlay appears in
    // the same frame — so the first measurement was 188px against a stage that
    // settled at 1189, and every element rendered at a sixth of its size until
    // something happened to resize the window. Type at 15pt drew at 2.9px.
    let ro = null;
    if (typeof ResizeObserver === 'function') {
      ro = new ResizeObserver(measure);
      if (ref.current) ro.observe(ref.current);
    }
    // A frame later as well, for browsers with no ResizeObserver and for the
    // case where the element is laid out but its ancestors are still animating.
    const raf = requestAnimationFrame(measure);
    const t = setTimeout(measure, 120);
    window.addEventListener('resize', measure);
    return () => {
      if (ro) ro.disconnect();
      cancelAnimationFrame(raf);
      clearTimeout(t);
      window.removeEventListener('resize', measure);
    };
  }, []);
  const scale = w / 960;                         // 960px is the reference deck width

  const elements = ((slide && slide.elements) || []).slice().sort((a, b) => (a.z || 1) - (b.z || 1));

  function start(e, id, mode) {
    if (!editable) return;
    // A click on text that is already selected should place the caret, not
    // start a drag — otherwise the text can never be edited.
    if (mode === 'move' && selectedIds.includes(id)) {
      const el = elements.find(x => x.id === id);
      if (el && el.kind === 'text') return;
    }
    e.preventDefault();
    const rect = ref.current.getBoundingClientRect();
    const ids = e.shiftKey ? (selectedIds.includes(id) ? selectedIds : selectedIds.concat([id])) : (selectedIds.includes(id) ? selectedIds : [id]);
    onSelect(ids);
    setDrag({
      mode, rect, x0: e.clientX, y0: e.clientY, ids,
      orig: ids.map(i => Object.assign({}, elements.find(x => x.id === i))),
    });
  }

  useEffect(() => {
    if (!drag) return;
    function move(e) {
      const dx = ((e.clientX - drag.x0) / drag.rect.width) * 100;
      const dy = ((e.clientY - drag.y0) / drag.rect.height) * 100;
      const next = {};
      const g = [];
      drag.orig.forEach(o => {
        if (drag.mode === 'move') {
          let nx = o.x + dx, ny = o.y + dy;
          if (drag.orig.length === 1) {
            const snapped = officeSlidesSnap(nx, ny, o, elements, g);
            nx = snapped.x; ny = snapped.y;
          }
          next[o.id] = { x: Math.round(nx * 10) / 10, y: Math.round(ny * 10) / 10 };
        } else if (drag.mode === 'rotate') {
          const cx = drag.rect.left + (o.x + o.w / 2) / 100 * drag.rect.width;
          const cy = drag.rect.top + (o.y + o.h / 2) / 100 * drag.rect.height;
          let deg = Math.atan2(e.clientY - cy, e.clientX - cx) * 180 / Math.PI + 90;
          if (e.shiftKey) deg = Math.round(deg / 15) * 15;
          next[o.id] = { rot: Math.round(deg) };
        } else {
          const dir = drag.mode.slice(7);
          let { x, y, w: ww, h: hh } = o;
          if (dir.includes('e')) ww = Math.max(3, o.w + dx);
          if (dir.includes('s')) hh = Math.max(3, o.h + dy);
          if (dir.includes('w')) { ww = Math.max(3, o.w - dx); x = o.x + (o.w - ww); }
          if (dir.includes('n')) { hh = Math.max(3, o.h - dy); y = o.y + (o.h - hh); }
          next[o.id] = { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10, w: Math.round(ww * 10) / 10, h: Math.round(hh * 10) / 10 };
        }
      });
      setGhost(next);
      setGuides(g);
    }
    function up() {
      if (ghost) onApply(ghost);
      setDrag(null); setGhost(null); setGuides([]);
    }
    window.addEventListener('mousemove', move);
    window.addEventListener('mouseup', up);
    return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
  }, [drag, ghost, elements]);

  return (
    <div className="relative w-full" ref={ref}>
      <div className="relative w-full overflow-hidden"
        style={{ paddingTop: (SLIDE_SIZES[ratio] || 56.25) + '%',
          // The master decides the background; a slide may set its own, and
          // either may name a theme slot. Nothing here is a stored hex unless
          // somebody deliberately chose one.
          background: officeSlidesFill(slide && slide.bg, T, theme, T.bg),
          fontFamily: T.font,
          borderRadius: present ? 0 : 4, border: present ? 'none' : '1px solid ' + T.rule }}
        onMouseDown={e => { if (editable && e.target === e.currentTarget.firstChild) onSelect([]); }}>
        <div className="absolute inset-0" onMouseDown={e => { if (editable && e.target === e.currentTarget) onSelect([]); }}>
          {slide && elements.map(el => {
            const gh = ghost && ghost[el.id];
            const drawn = gh ? Object.assign({}, el, gh) : el;
            return (
              <OfficeSlidesElementView key={el.id} el={drawn} T={T} theme={theme} scale={scale}
                editable={editable} selected={selectedIds.includes(el.id)}
                onSelect={id => onSelect([id])}
                onCommit={(id, f) => onApply({ [id]: f })}
                onStart={start} />
            );
          })}
          {/* Slide furniture — date, footer, slide number. Chrome, not three
              more draggable boxes on every slide: it belongs to the master, it
              is identical wherever it is inherited, and a sixty-slide deck
              should not carry a hundred and eighty elements to say so. */}
          {slide && master && (() => {
            const f = officeSlidesResolveFurniture(master, slide, number);
            if (!f.date && f.footer === null && !f.slideNumber) return null;
            const fs = Math.max(7, 11 * scale);
            const cell = { fontSize: fs + 'px', color: T.muted, fontFamily: T.font, lineHeight: 1.2 };
            return (
              <div className="absolute flex items-end gap-2 pointer-events-none"
                style={{ left: '6%', right: '6%', bottom: '3.5%', zIndex: 0 }}>
                <span style={Object.assign({ flex: '0 0 22%' }, cell)}>{f.date || ''}</span>
                <span style={Object.assign({ flex: '1 1 auto', textAlign: 'center' }, cell)}>{f.footer || ''}</span>
                <span style={Object.assign({ flex: '0 0 22%', textAlign: 'right' }, cell)}>{f.slideNumber || ''}</span>
              </div>
            );
          })()}
          {guides.map((g, i) => (
            <div key={i} className="absolute bg-[var(--leon-red)] pointer-events-none"
              style={g.axis === 'x'
                ? { left: g.at + '%', top: 0, bottom: 0, width: 1 }
                : { top: g.at + '%', left: 0, right: 0, height: 1 }} />
          ))}
          {!slide && <div className="absolute inset-0 flex items-center justify-center text-sm" style={{ color: T.muted }}>No slide selected.</div>}
        </div>
      </div>
      {editable && onPickImage && (
        <div className="no-print absolute inset-x-0 -bottom-7 text-[10px] text-[var(--leon-black)]/40 text-center">
          Drag to move · handles to resize · ⟳ to rotate (hold Shift for 15°) · Shift-click to select more than one
        </div>
      )}
    </div>
  );
}

// Snapping: a slide's own edges and centre, plus every other element's edges
// and centres. Guides are drawn for whatever actually caught.
function officeSlidesSnap(nx, ny, el, others, guidesOut) {
  const xt = [0, 50, 100];
  const yt = [0, 50, 100];
  others.forEach(o => {
    if (o.id === el.id) return;
    xt.push(o.x, o.x + o.w / 2, o.x + o.w);
    yt.push(o.y, o.y + o.h / 2, o.y + o.h);
  });
  const tryAxis = (v, size, targets, axis) => {
    const edges = [v, v + size / 2, v + size];
    for (let i = 0; i < edges.length; i++) {
      for (let t = 0; t < targets.length; t++) {
        if (Math.abs(edges[i] - targets[t]) <= SLIDE_SNAP) {
          guidesOut.push({ axis, at: targets[t] });
          return v + (targets[t] - edges[i]);
        }
      }
    }
    return v;
  };
  return { x: tryAxis(nx, el.w, xt, 'x'), y: tryAxis(ny, el.h, yt, 'y') };
}

// ── LEON content slides ───────────────────────────────────────────────────
// Built from real records. A "finish board" that needed its swatches typed in
// would be a picture of a finish board, not one.
function officeSlidesFinishBoard(sc, scopeId, T) {
  const project = sc.project;
  const scope = project ? (project.scopes || []).find(s => s.id === scopeId) : null;
  const s = officeSlidesMakeSlide('blank', T);
  s.name = scope ? scope.name + ' — finishes' : 'Material board';
  s.elements = [
    officeSlidesText(scope ? officeWordEscape(scope.name) + ' — Materials &amp; Finishes' : 'Materials &amp; Finishes',
      { x: 6, y: 6, w: 88, h: 9, size: 26, bold: true, role: 'title' }),
  ];
  if (!scope) return s;
  const lib = sc.ctx.scopeLibrary || [];
  const fam = lib.find(f => f.name === scope.familyName);
  const cats = (fam && fam.categories) || [];
  const picks = [];
  cats.forEach(cat => {
    const fin = (scope.supplierFinishes || {})[cat.id];
    if (fin) picks.push({ cat: cat.name, fin });
  });
  (scope.selectionAreas || []).forEach(area => {
    cats.forEach(cat => {
      const fin = (area.supplierFinishes || {})[cat.id];
      if (fin) picks.push({ cat: area.name + ' · ' + cat.name, fin });
    });
  });
  const cols = picks.length <= 3 ? 3 : picks.length <= 8 ? 4 : 5;
  const rows = Math.max(1, Math.ceil(picks.length / cols));
  const gx = 4, top = 20, availW = 100 - gx * 2, availH = 74;
  const cw = availW / cols, ch = availH / rows;
  picks.slice(0, cols * rows).forEach((p, i) => {
    const c = i % cols, r = Math.floor(i / cols);
    const x = gx + c * cw, y = top + r * ch;
    s.elements.push(officeSlidesEl('image', {
      x: x + 0.8, y: y, w: cw - 1.6, h: ch * 0.62, fit: 'cover', radius: 3,
      ref: { kind: 'finish', kindLabel: 'supplier finish', id: p.fin.id, sup: p.fin.source, url: p.fin.img, name: p.fin.name, code: p.fin.code },
    }));
    s.elements.push(officeSlidesText(
      '<b>' + officeWordEscape(p.fin.name) + '</b><br>' +
      officeWordEscape(p.cat) + (p.fin.code ? ' · ' + officeWordEscape(p.fin.code) : '') +
      (p.fin.supLabel ? '<br>' + officeWordEscape(p.fin.supLabel) : ''),
      { x: x + 0.8, y: y + ch * 0.64, w: cw - 1.6, h: ch * 0.34, size: 10, role: 'caption' }));
  });
  if (!picks.length) {
    s.elements.push(officeSlidesText('No supplier finishes are set on this scope yet — choose them in the Selection Hub and rebuild this slide.',
      { x: 6, y: 40, w: 88, h: 12, size: 15, role: 'caption' }));
  }
  return s;
}


// ── A quotation as a deck ─────────────────────────────────────────────────
// Built from the CLIENT document (`clientQuoteFromAnalysis`), never from the
// analysis. That whitelist is what decides a client may see the area, the
// description, the selection, the quantity and the price — and may not see
// cost, margin, commission, freight, the vendor or the supplier behind a
// finish. A deck assembled from the analysis would be a second, unguarded way
// out of the building for exactly the figures the whitelist exists to hold in.
//
// Slides: a cover, a scope each (with its selections as pictures, because that
// is what the client is choosing), a summary that adds up, then assumptions and
// exclusions where the job has them.
// The quotation deck, built from quoteDeckPlan (lib.jsx) — the slide sequence
// Leon's own issued quotation follows, rather than one slide per scope. The
// plan decides WHAT slides exist and in what order; the renderers below decide
// what each one looks like. Splitting it that way is what lets the order be
// read off a real deck and changed without touching any drawing code.
//
// It takes the CLIENT document, never the analysis. That whitelist is what
// keeps cost, margin, commission and the supplier behind a finish out of a
// client-facing file, and it fails closed.
function officeSlidesQuoteDeck(doc, opts) {
  const o = opts || {};
  const T = o.theme;
  // An EDITED deck wins over the generated one. What is stored is structure
  // only, so an edited deck still reprices itself from the live document.
  const resolved = (typeof quoteDeckResolve === 'function')
    ? quoteDeckResolve(doc, o, o.structure) : null;
  const plan = resolved ? resolved.plan
    : ((typeof quoteDeckPlan === 'function') ? quoteDeckPlan(doc, o) : []);
  const filled = o.pictures || {};
  const out = [];
  // Which output slide each plan step landed on, so the contents page can state
  // real page numbers. Terms expand to a dozen slides, so a step index is not a
  // page number and using one would print a contents page that is wrong by ten.
  const pageOf = [];
  let tocAt = -1;
  plan.forEach((step, i) => {
    pageOf[i] = out.length + 1;
    if (step.archetype === 'toc') tocAt = out.length;
    const ctx = { doc, T, o, step, index: i, filled };
    let slide = null;
    if (step.archetype === 'cover')         slide = officeSlidesQuoteCover(ctx);
    else if (step.archetype === 'about')    slide = officeSlidesQuoteAbout(ctx);
    else if (step.archetype === 'refs')     slide = officeSlidesQuoteRefs(ctx);
    else if (step.archetype === 'divider')  slide = officeSlidesQuoteDivider(ctx);
    else if (step.archetype === 'intent')   slide = officeSlidesQuoteIntent(ctx);
    else if (step.archetype === 'spec')     slide = officeSlidesQuoteSpec(ctx);
    else if (step.archetype === 'colors')   slide = officeSlidesQuoteColors(ctx);
    else if (step.archetype === 'gallery')  slide = officeSlidesQuoteGallery(ctx);
    else if (step.archetype === 'estimate') slide = officeSlidesQuoteEstimate(ctx);
    else if (step.archetype === 'install')  slide = officeSlidesQuoteInstall(ctx);
    else if (step.archetype === 'toc')      slide = officeSlidesQuoteToc(ctx);
    else if (step.archetype === 'plans')    slide = officeSlidesQuotePlans(ctx);
    else if (step.archetype === 'product')  slide = officeSlidesQuoteProduct(ctx);
    else if (step.archetype === 'breakdown') slide = officeSlidesQuoteBreakdown(ctx);
    else if (step.archetype === 'disclaim') slide = officeSlidesQuoteDisclaimer(ctx);
    else if (step.archetype === 'back')     slide = officeSlidesQuoteBack(ctx);
    else if (step.archetype === 'summary')  slide = officeSlidesQuoteSummary(ctx);
    else if (step.archetype === 'terms')    slide = officeSlidesQuoteTerms(ctx);
    if (Array.isArray(slide)) out.push.apply(out, slide);
    else if (slide) out.push(slide);
  });
  // Second pass: the contents page can only be written once the deck exists.
  // Only the slides worth listing — a gallery or a colour board is part of the
  // section above it, not an entry someone looks up.
  if (tocAt >= 0) {
    const LIST = { divider: 1, estimate: 1, install: 1, summary: 1, terms: 1, about: 1, refs: 1, plans: 1, breakdown: 1 };
    const entries = [];
    plan.forEach((step, i) => {
      if (!LIST[step.archetype]) return;
      const label = step.archetype === 'summary' ? 'Project bid summary'
        : step.archetype === 'terms' ? 'Terms and conditions'
        : step.archetype === 'about' ? 'About us'
        : step.archetype === 'refs' ? 'References'
        : step.archetype === 'plans' ? 'Project plans'
        : step.archetype === 'breakdown' ? (step.title || '') + ' — quantities'
        : step.archetype === 'estimate' ? (step.title || '') + ' — estimate'
        : step.archetype === 'install' ? (step.title || '')
        : (step.title || step.label);
      if (entries.some(e => e.label === label)) return;
      entries.push({ label, page: pageOf[i] });
    });
    const tocCtx = { doc, T, o: Object.assign({}, o, { _toc: entries }), step: plan.find(p => p.archetype === 'toc') || { archetype: 'toc' }, index: 0, filled };
    out[tocAt] = officeSlidesQuoteToc(tocCtx);
  }
  // Assumptions and exclusions, only where the job actually states any.
  if (doc.assumptions || doc.exclusions) {
    const s = officeSlidesMakeSlide('twoObj', T);
    s.name = 'Assumptions & exclusions';
    const title = s.elements.find(e => e.ph === 'title');
    if (title) title.html = 'What this price depends on';
    const bodies = s.elements.filter(e => e.ph === 'body');
    if (bodies[0]) bodies[0].html = '<b>Assumptions</b><br>' + officeWordEscape(doc.assumptions || 'None stated.').replace(/\n/g, '<br>');
    if (bodies[1]) bodies[1].html = '<b>Exclusions</b><br>' + officeWordEscape(doc.exclusions || 'None stated.').replace(/\n/g, '<br>');
    // Built from a layout rather than an archetype, so it misses the sweep the
    // renderers get — and a quotation page with no mark on it is the one the
    // client notices.
    officeSlidesQuoteBrand({ o }, s);
    out.push(s);
  }
  return out;
}

// The type scale, measured off Leon's three issued decks rather than chosen.
// A slide unit here IS a point (the canvas scales by w/960 against a 960pt
// slide), so these are the real sizes those decks use — and the reason the
// generated deck read as small and sparse beside them: its section titles were
// 22pt where theirs are 44, and its dividers 34 where theirs are 60.
//
// Measured counts, all three decks: dividers 60pt; section titles 44pt; the
// specification page 40pt with 9pt labels and 11pt values; colour codes 18pt;
// terms body 9pt; and 12pt captions almost everywhere else.
const QD = {
  coverTitle: 60, coverSub: 14,
  dividerTitle: 60,
  sectionTitle: 44,
  specTitle: 40,
  specLabel: 9, specValue: 11,
  colourCode: 18,
  tocTitle: 40, tocEntry: 12,
  aboutTitle: 60,
  bodyLg: 12, body: 11, small: 10, fine: 9,
  caption: 12,
  moneyKey: 12, moneyBig: 16,
};

// Fit a heading to its box.
//
// The measured sizes are real, but the decks they came from carry SHORT names
// on those slides — "QUARRY HILLS", "78 CRAFTS", "STONEWORK". Setting 60pt and
// walking away puts "Harborview Tower — Residences 40-52" straight off the
// right edge of the page, which is what it did.
//
// Century Gothic is a geometric face whose capitals average about 0.60 em and
// whose mixed case averages about 0.52. That is an approximation and it is the
// right kind: the canvas is not available when the deck is BUILT, so the choice
// is between an estimate that keeps type on the page and a fixed size that does
// not. It only ever steps DOWN from the measured size, never up, so a short
// title still gets the full 60pt the decks use.
function officeSlidesQuoteFit(text, size, widthPct, minSize) {
  const t = String(text || '');
  if (!t) return size;
  const caps = t === t.toUpperCase();
  // Measured on the shipped face: capitals average 0.58-0.60 em, mixed case
  // 0.47-0.53. The 0.94 is not padding for a bad estimate — a title also
  // carries letter-spacing, which the per-character average does not include,
  // and a heading that wraps costs far more than one set a point smaller.
  const em = caps ? 0.62 : 0.54;
  const boxPt = (widthPct / 100) * 960 * 0.94;
  const need = t.length * em;
  if (!need) return size;
  const fit = boxPt / need;
  return Math.max(minSize || 18, Math.min(size, Math.floor(fit)));
}

// Every heading on a quotation is set in CAPITALS — that is how all three
// issued decks read, and "Doors Design intent" beside "DOORS DESIGN INTENT" is
// the difference between a generated page and a composed one. The scope half is
// set in the brand brown so it carries the page; it names the accent SLOT
// rather than a hex, so it follows the deck's theme.
function officeSlidesQuoteCaps(t) { return String(t == null ? '' : t).toUpperCase(); }
function officeSlidesQuoteScopeHead(scope, what) {
  return '<b><span style="color:' + (QD_ACCENT || '#8B5E34') + '">'
    + officeWordEscape(officeSlidesQuoteCaps(scope)) + '</span>'
    + (what ? ' ' + officeWordEscape(officeSlidesQuoteCaps(what)) : '') + '</b>';
}
const QD_ACCENT = '#8B5E34';

// How tall a heading actually is, as a percentage of the page.
//
// A box is given a height and the type does not respect it — text overflows
// downward onto whatever is below. That is what put "DESIGN INTENT" at 44pt
// across two lines through the picture frames underneath it. Line height on the
// canvas is 1.28 and the page is 540pt, so N lines of S points occupy
// N * S * 1.28 / 540 of it. Content below a heading starts where this says it
// ends, rather than at a number typed once and left behind when the size
// changed.
function officeSlidesQuoteHeadH(lines, sizePt) {
  return Math.ceil((lines * sizePt * 1.28 / 540) * 100) + 1;
}

// The master a quotation deck is built on. WHITE, not the cream the legacy
// "leon-corporate" name maps to — a quotation is read on screen and printed,
// and every issued one is on white.
//
// The footer is furniture, drawn once by the master on every slide including
// the cover: the project, INTERIOR FINISHES ESTIMATE and the revision on the
// left, the date, and the page number. That is exactly what the issued decks
// carry, and having the master do it means it cannot differ from slide to
// slide the way a hand-drawn one does.
function quoteDeckMaster(doc, o) {
  const rev = 'REV ' + String(doc.revision == null ? 1 : doc.revision).padStart(2, '0');
  const base = (typeof makeSlideMaster === 'function') ? makeSlideMaster({ id: 'mstr-quote' }) : {};
  return Object.assign({}, base, {
    name: 'LEON Quotation',
    background: 'lt1',
    showFooter: true,
    footerText: 'LEON INTEGRA  I  ' + ((o && o.projectName) || doc.name || '') +
                '  I  INTERIOR FINISHES ESTIMATE  I  ' + rev,
    showDate: true,
    dateText: fmtDate(doc.date) || fmtDate(todayISO()),
    showSlideNumber: true,
    // The cover carries it too: a quotation's cover is page 1 and the client
    // reads the revision off it before anything else.
    showOnTitleSlide: true,
  });
}

// ─── shared bits ─────────────────────────────────────────────────────────────
function officeSlidesQuoteMoney(n) { return '$' + Math.round(qnum(n)).toLocaleString(); }
// A tax RATE is not a money figure and must not be rounded like one. Massa-
// chusetts is 6.25% and the old one-decimal rounding printed it as 6.3% — a
// quotation stating a rate the state does not charge. Up to two decimals,
// trailing zeros trimmed, so 6.25 stays 6.25 and 6 stays 6.
function officeSlidesQuotePct(fraction) {
  const v = Math.round(qnum(fraction) * 10000) / 100;
  return String(parseFloat(v.toFixed(2)));
}

// A picture SLOT. Unfilled it draws a dashed frame saying what belongs there
// and stays selectable in the editor, so the deck reads as "a picture is
// missing here" rather than quietly shipping a blank rectangle to a client.
function officeSlidesQuotePic(ctx, k, box, label) {
  const slot = ctx.step.archetype + ':' + ctx.index + ':' + k;
  const got = ctx.filled[slot];
  if (got && got.url) {
    // A CAPTION is optional and blank means blank. A picture that captions
    // itself with its own filename is worse than one that says nothing, so
    // nothing is what an empty caption draws — no label, and no space taken
    // from the picture for one.
    const cap = String(got.caption || '').trim();
    const picH = cap ? Math.max(6, box.h - 5) : box.h;
    const out = [officeSlidesEl('image', Object.assign({}, box, {
      h: picH,
      fit: box.fit || 'cover', radius: box.radius != null ? box.radius : 2,
      slot,
      ref: { kind: got.source || 'picture', kindLabel: 'picture', id: got.id || null, url: got.url, name: got.name || '' },
    }))];
    if (cap) {
      out.push(officeSlidesText(officeWordEscape(cap), {
        x: box.x, y: box.y + picH + 0.6, w: box.w, h: 4.4,
        size: QD.fine, align: box.w < 30 ? 'center' : 'left', role: 'caption',
      }));
    }
    return out;
  }
  return [
    officeSlidesEl('shape', Object.assign({}, box, {
      shape: 'rect', fill: 'lt2', stroke: 'dk2', strokeWidth: 1, dash: 'dash',
      radius: box.radius != null ? box.radius : 2, slot,
    })),
    // A full-bleed slot is a cover or a divider, and its title runs across the
    // MIDDLE of the page. Centring the placeholder there prints it through the
    // heading — so on those the prompt sits in the upper third, clear of the
    // band the title occupies.
    officeSlidesText('<b>+ Add picture</b><br>' + officeWordEscape(label || ''), {
      x: box.x, y: box.h >= 80 ? box.y + 16 : box.y + Math.max(2, box.h / 2 - 5),
      w: box.w, h: 10, size: 11, align: 'center', role: 'caption',
    }),
  ];
}
// The footer is the MASTER's job — date, footer text and page number are
// furniture PowerPoint has always drawn from the master, and drawing a second
// one per slide is how a deck ends up with two footers that disagree. See
// quoteDeckMaster below for what it says. What each slide adds is the LEON
// mark, which every issued quotation carries on every page.
function officeSlidesQuoteFooter(ctx, s) {
  return officeSlidesQuoteBrand(ctx, s);
}
function officeSlidesQuoteBrand(ctx, s) {
  if (ctx.o.noBrandMark) return s;
  s.elements.push(officeSlidesEl('logo', { variant: 'official', x: 86, y: 3.5, w: 9, h: 9 }));
  return s;
}
function officeSlidesQuoteBlank(ctx, name) {
  const s = officeSlidesMakeSlide('blank', ctx.T);
  s.name = name;
  s.elements = [];
  return s;
}

// N pictures in a box. The count comes from what has been placed rather than
// from the archetype, so a design intent page carrying one photograph and one
// carrying four are the same slide type laid out for what it holds.
function officeSlidesQuotePicGrid(ctx, box, label) {
  const n = (typeof quoteDeckSlotsFor === 'function')
    ? quoteDeckSlotsFor(ctx.step, ctx.index, ctx.filled)
    : (ctx.step.pictures || 0);
  if (n <= 0) return [];
  const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 6 ? 3 : 4;
  const rows = Math.ceil(n / cols);
  const gap = 2;
  const cw = (box.w - gap * (cols - 1)) / cols;
  const ch = (box.h - gap * (rows - 1)) / rows;
  const out = [];
  for (let i = 0; i < n; i++) {
    const c = i % cols, r = Math.floor(i / cols);
    out.push.apply(out, officeSlidesQuotePic(ctx, i, {
      x: box.x + c * (cw + gap), y: box.y + r * (ch + gap), w: cw, h: ch,
    }, label));
  }
  return out;
}

// ─── cover ───────────────────────────────────────────────────────────────────
function officeSlidesQuoteCover(ctx) {
  const d = ctx.doc, o = ctx.o, esc = officeWordEscape;
  const s = officeSlidesQuoteBlank(ctx, 'Cover');
  s.elements = [];
  // A title block, the way a drawing set carries one: the picture takes the
  // page and the facts sit in a panel down the right, each on its own labelled
  // line. Type over a photograph is always a compromise between legibility and
  // the picture; a panel beside it is neither compromised.
  const PANEL = 34;                     // the panel's width, right-hand side
  const px = 100 - PANEL;
  s.elements.push.apply(s.elements, officeSlidesQuotePic(ctx, 0, { x: 0, y: 0, w: px, h: 100, radius: 0 }, 'Cover image'));
  s.elements.push(officeSlidesEl('shape', { shape: 'rect', x: px, y: 0, w: PANEL, h: 100, fill: 'lt2' }));
  s.elements.push(officeSlidesEl('shape', { shape: 'rect', x: px, y: 0, w: 0.5, h: 100, fill: 'dk2', opacity: 0.5 }));

  const IX = px + 4, IW = PANEL - 8;
  // The mark is the panel's own header — twice the size it was and centred
  // across the panel's full inner width, so it reads as the letterhead of a
  // title block rather than a small badge tucked in a corner.
  // Wider than the text column and centred on the panel: the mark is the
  // letterhead of this title block, so it gets the room the panel can spare
  // rather than being fitted to the margin the type uses.
  const LW = PANEL - 4;
  s.elements.push(officeSlidesEl('logo', { variant: 'official', x: px + 2, y: 4, w: LW, h: 34 }));

  // The job's name, CENTRED and in the brand brown, sitting just above the
  // first rule so it reads as the heading of the block beneath it rather than
  // floating between the mark and the facts.
  const title = String(o.projectName || d.name || 'Quotation');
  const tSize = officeSlidesQuoteFit(officeSlidesQuoteCaps(title), 26, IW, 13);
  const tH = officeSlidesQuoteHeadH(2, tSize);
  s.elements.push(officeSlidesText(
    '<b><span style="color:' + QD_ACCENT + '">' + esc(officeSlidesQuoteCaps(title)) + '</span></b>',
    { x: IX, y: 48 - tH, w: IW, h: tH, role: 'title', size: tSize, align: 'center' }));

  // Each fact on its own line under its own label, which is what makes a title
  // block readable at a glance rather than a paragraph to parse.
  const facts = [
    ['CLIENT', o.clientName || ''],
    ['DOCUMENT', 'Interior Finishes Estimate'],
    ['REVISION', 'REV ' + String(d.revision == null ? 1 : d.revision).padStart(2, '0')],
    ['DATE', fmtDate(d.date) || ''],
    ['PREPARED BY', d.preparedBy || ''],
  ].filter(f => f[1]);
  // The facts are fitted to the panel rather than stacked at a fixed pitch:
  // five of them at 11 apiece ran PREPARED BY off the bottom of the page.
  // BOT stops above the master's footer, which the cover carries too — the
  // panel and the footer share the bottom of the page.
  const TOP = 49, BOT = 88;
  const pitch = Math.min(11, (BOT - TOP) / Math.max(1, facts.length));
  let y = TOP;
  facts.forEach(([k, v]) => {
    s.elements.push(officeSlidesEl('shape', { shape: 'rect', x: IX, y, w: IW, h: 0.25, fill: 'dk2', opacity: 0.25 }));
    s.elements.push(officeSlidesText(esc(k), { x: IX, y: y + 1.4, w: IW, h: 3.2, size: 7, role: 'caption' }));
    s.elements.push(officeSlidesText('<b>' + esc(v) + '</b>',
      { x: IX, y: y + 4.4, w: IW, h: Math.min(5, pitch - 4.6), size: QD.body, role: 'body' }));
    y += pitch;
  });
  return s;
}

// Kept only so nothing that referenced the old placeholder-layout cover breaks.
function officeSlidesQuoteCoverLegacy(ctx) {
  const d = ctx.doc, o = ctx.o;
  const s = officeSlidesMakeSlide('title', ctx.T);
  s.name = 'Cover';
  const ctr = s.elements.find(e => e.ph === 'ctrTitle');
  const sub = s.elements.find(e => e.ph === 'subTitle');
  if (ctr) ctr.html = officeWordEscape(o.projectName || d.name || 'Quotation');
  if (sub) {
    sub.html = [o.clientName ? officeWordEscape(o.clientName) : '',
      'INTERIOR FINISHES ESTIMATE',
      'REV ' + String(d.revision == null ? 1 : d.revision).padStart(2, '0') + ' &middot; ' + (fmtDate(d.date) || ''),
    ].filter(Boolean).join('<br>');
  }
  return s;
}

// ─── about us ────────────────────────────────────────────────────────────────
// Written from the Company Profile, which is a record the whole team already
// maintains — in the issued deck this was typed onto a slide, so it aged.
function officeSlidesQuoteAbout(ctx) {
  const c = ctx.step.company || {};
  const esc = officeWordEscape;
  const art = (typeof QUOTE_ART_ABOUT !== 'undefined') ? QUOTE_ART_ABOUT : null;
  const s = officeSlidesQuoteBlank(ctx, 'About us');

  // The building, full height down the right — their own page runs it as the
  // ground. Here it takes a third and the copy takes the rest, because five
  // paragraphs over a photograph is not readable and the photograph is worth
  // more than a texture behind text.
  const PIC = 34, px = 100 - PIC;
  const building = (ctx.o.aboutPicture) || (art && art.building) || null;
  if (building) {
    s.elements.push(officeSlidesEl('image', { x: px, y: 0, w: PIC, h: 100, fit: 'cover', radius: 0,
      ref: { kind: 'picture', kindLabel: 'picture', id: null, url: building, name: 'LEON' } }));
  } else {
    s.elements.push.apply(s.elements, officeSlidesQuotePic(ctx, 0, { x: px, y: 0, w: PIC, h: 100, radius: 0 }, 'About us'));
  }

  s.elements.push(officeSlidesText(officeSlidesQuoteScopeHead('About', 'Us'),
    { x: 6, y: 7, w: px - 12, h: officeSlidesQuoteHeadH(1, QD.sectionTitle), role: 'title', size: QD.sectionTitle }));

  // The company's own copy, verbatim from the issued decks. Sized to the room
  // it has rather than at a fixed point size — five paragraphs is a page, and a
  // sixth added later must not run off it.
  const paras = (ctx.o.aboutParagraphs && ctx.o.aboutParagraphs.length)
    ? ctx.o.aboutParagraphs
    : (art ? art.paragraphs : (c.about ? String(c.about).split(/\n+/) : []));
  const TOP = 22, BOT = 88, W = px - 12;
  if (paras.length) {
    const chars = paras.reduce((n, t) => n + t.length, 0);
    // ~ how many characters a line holds at a given size in this column, then
    // how tall that many lines are. Solved for the size that fills the box.
    let size = 11;
    for (let z = 11; z >= 6.5; z -= 0.25) {
      const perLine = (W / 100 * 960) / (z * 0.5);
      const lines = paras.reduce((n, t) => n + Math.max(1, Math.ceil(t.length / perLine)), 0);
      const tall = lines * z * 1.28 / 540 * 100 + paras.length * 1.6;
      if (tall <= BOT - TOP) { size = z; break; }
      size = z;
    }
    let y = TOP;
    const perLine = (W / 100 * 960) / (size * 0.5);
    paras.forEach((t, i) => {
      const lines = Math.max(1, Math.ceil(t.length / perLine));
      const h = lines * size * 1.28 / 540 * 100;
      s.elements.push(officeSlidesText(
        // The company's name in the brand brown the first time it appears.
        (i === 0 ? esc(t).replace(/^(Leon Integra)/,
          '<b><span style="color:' + QD_ACCENT + '">$1</span></b>') : esc(t)),
        { x: 6, y, w: W, h, size, role: 'body' }));
      y += h + 1.6;
    });
  }
  return officeSlidesQuoteFooter(ctx, s);
}

// ─── references ──────────────────────────────────────────────────────────────
// Read from the Finished Projects library — jobs actually published, with their
// own cover photo and location. Nothing is stored twice, so recaptioning a photo
// on the job updates what a future quotation shows. `hideClient` is honoured:
// some clients do not want their job shown by name.
function officeSlidesQuoteRefs(ctx) {
  const refs = ctx.step.references || [];
  const esc = officeWordEscape;
  const s = officeSlidesQuoteBlank(ctx, 'References');
  s.elements.push(officeSlidesText('<b>REFERENCES</b>', { x: 6, y: 5, w: 60, h: 12, size: QD.sectionTitle, role: 'title' }));
  refs.slice(0, 9).forEach((r, i) => {
    const c = i % 3, row = Math.floor(i / 3);
    const x = 6 + c * 30, y = 17 + row * 25;
    if (r.img) {
      s.elements.push(officeSlidesEl('image', { x, y, w: 27, h: 17, fit: 'cover', radius: 2,
        ref: { kind: 'project', kindLabel: 'project', id: r.id || null, url: r.img, name: r.name || '' } }));
    } else {
      s.elements.push(officeSlidesEl('shape', { shape: 'rect', x, y, w: 27, h: 17, fill: 'lt2', radius: 2 }));
    }
    s.elements.push(officeSlidesText('<b>' + esc(r.name || '') + '</b>' + (r.location ? '<br>' + esc(r.location) : ''),
      { x, y: y + 17.5, w: 27, h: 6, size: 9, role: 'caption' }));
  });
  return officeSlidesQuoteFooter(ctx, s);
}

// ─── table of contents ───────────────────────────────────────────────────────
// Generated from the deck it introduces, so it cannot disagree with it. In the
// issued decks this was a pasted picture of a typed list.
function officeSlidesQuoteToc(ctx) {
  const esc = officeWordEscape;
  const s = officeSlidesQuoteBlank(ctx, 'Table of contents');
  s.elements.push(officeSlidesText('<b>TABLE OF CONTENT</b>', { x: 6, y: 7, w: 60, h: 14, size: QD.tocTitle, role: 'title' }));
  const entries = (ctx.o._toc || []);
  let y = 20, col = 0;
  entries.slice(0, 32).forEach(e => {
    if (y > 84) { y = 20; col += 1; }
    const x = 8 + col * 45;
    s.elements.push(officeSlidesText(esc(officeSlidesQuoteCaps(e.label)), { x, y, w: 34, h: 5, size: QD.tocEntry, role: 'body' }));
    s.elements.push(officeSlidesText(String(e.page), { x: x + 34, y, w: 6, h: 5, size: QD.tocEntry, align: 'right', role: 'caption' }));
    y += 5.4;
  });
  return officeSlidesQuoteFooter(ctx, s);
}

// ─── project plans ───────────────────────────────────────────────────────────
function officeSlidesQuotePlans(ctx) {
  const s = officeSlidesQuoteBlank(ctx, 'Project plans');
  s.elements.push(officeSlidesText('<b>PROJECT PLANS</b>', { x: 6, y: 7, w: 60, h: 14, size: QD.sectionTitle, role: 'title' }));
  s.elements.push.apply(s.elements, officeSlidesQuotePicGrid(ctx, { x: 6, y: 19, w: 88, h: 66 }, 'Plan'));
  return officeSlidesQuoteFooter(ctx, s);
}

// ─── product detail ──────────────────────────────────────────────────────────
// The slide that says what the material IS — thickness, sizes, wear layer,
// grade. Every issued deck carries one per material scope, as a short list
// beside two or three photographs.
function officeSlidesQuoteProduct(ctx) {
  const esc = officeWordEscape;
  const step = ctx.step;
  const lines = (step.lines && step.lines.length ? step.lines
    : (ctx.o.productLines && ctx.o.productLines[step.scope]) || []);
  const s = officeSlidesQuoteBlank(ctx, (step.title || 'Product') + ' — detail');
  s.elements.push(officeSlidesText('<b>' + esc(String(step.title || '').toUpperCase()) + '</b>',
    { x: 6, y: 7, w: 48, h: 14, role: 'title',
      size: officeSlidesQuoteFit(String(step.title || '').toUpperCase(), QD.sectionTitle, 48, 18) }));
  let y = 22;
  (lines.length ? lines : ['Specification to follow.']).forEach(t => {
    s.elements.push(officeSlidesText(esc(String(t)), { x: 6, y, w: 40, h: 6, size: QD.bodyLg, role: 'body' }));
    y += 6.4;
  });
  s.elements.push.apply(s.elements, officeSlidesQuotePicGrid(ctx, { x: 50, y: 18, w: 44, h: 68 }, step.title));
  return officeSlidesQuoteFooter(ctx, s);
}

// ─── quantities by area ──────────────────────────────────────────────────────
// A 5.9 MB pasted spreadsheet picture in the issued decks, so it could not be
// checked and went stale. Built from the area rows the estimator already put in
// the line list.
function officeSlidesQuoteBreakdown(ctx) {
  const esc = officeWordEscape, money = officeSlidesQuoteMoney;
  const sec = ctx.step.section;
  const rows = (typeof quoteDeckAreaRows === 'function') ? quoteDeckAreaRows(sec) : [];
  const s = officeSlidesQuoteBlank(ctx, (ctx.step.title || '') + ' — quantities');
  s.elements.push(officeSlidesText('<b>' + esc(String(ctx.step.title || '').toUpperCase()) + '</b>',
    { x: 6, y: 7, w: 60, h: 14, role: 'title',
      size: officeSlidesQuoteFit(String(ctx.step.title || '').toUpperCase(), QD.sectionTitle, 60, 18) }));
  if (!rows.length) {
    s.elements.push(officeSlidesText('No areas are named on this scope yet — add area rows to the line list and they appear here.',
      { x: 6, y: 22, w: 80, h: 6, size: 11, role: 'caption' }));
    return officeSlidesQuoteFooter(ctx, s);
  }
  let y = 20;
  const head = (t, x, w, a) => s.elements.push(officeSlidesText('<b>' + t + '</b>',
    { x, y, w, h: 5, size: 9, align: a || 'left', role: 'caption' }));
  head('AREA', 6, 44); head('QTY', 52, 14, 'right'); head('UOM', 68, 10); head('PRICE', 80, 14, 'right');
  y += 6;
  rows.forEach(r => {
    s.elements.push(officeSlidesText(esc(r.area || ''), { x: 6, y, w: 44, h: 5, size: 11, role: 'body' }));
    s.elements.push(officeSlidesText(String(Math.round(r.qty * 100) / 100), { x: 52, y, w: 14, h: 5, size: 11, align: 'right', role: 'body' }));
    s.elements.push(officeSlidesText(esc(r.uom || ''), { x: 68, y, w: 10, h: 5, size: 11, role: 'body' }));
    s.elements.push(officeSlidesText(money(r.price), { x: 80, y, w: 14, h: 5, size: 11, align: 'right', role: 'body' }));
    y += 5.4;
  });
  const tot = rows.reduce((n, r) => n + r.price, 0);
  y += 2;
  s.elements.push(officeSlidesText('<b>Total</b>', { x: 6, y, w: 44, h: 5, size: 12, role: 'body' }));
  s.elements.push(officeSlidesText('<b>' + money(tot) + '</b>', { x: 80, y, w: 14, h: 5, size: 12, align: 'right', role: 'body' }));
  return officeSlidesQuoteFooter(ctx, s);
}

// ─── disclaimer ──────────────────────────────────────────────────────────────
function officeSlidesQuoteDisclaimer(ctx) {
  const esc = officeWordEscape;
  const s = officeSlidesQuoteBlank(ctx, (ctx.step.title ? ctx.step.title + ' — ' : '') + 'Disclaimer');
  s.elements.push(officeSlidesText('<b>Disclaimer</b>', { x: 6, y: 8, w: 60, h: 12, size: QD.specTitle, role: 'title' }));
  s.elements.push(officeSlidesText(esc(ctx.o.quantitiesDisclaimer || QUOTE_DECK_DISCLAIMER).replace(/\n/g, '<br>'),
    { x: 6, y: 22, w: 44, h: 40, size: 12, role: 'body' }));
  s.elements.push.apply(s.elements, officeSlidesQuotePic(ctx, 0, { x: 52, y: 18, w: 42, h: 56 }, ctx.step.title || 'Disclaimer'));
  return officeSlidesQuoteFooter(ctx, s);
}

// ─── back cover ──────────────────────────────────────────────────────────────
function officeSlidesQuoteBack(ctx) {
  const c = ctx.o.company || {};
  const esc = officeWordEscape;
  // No picture. The last page is the mark and the company, centred — a
  // photograph behind it is competing with the one thing it is there to say.
  const s = officeSlidesQuoteBlank(ctx, 'Back cover');
  s.elements.push(officeSlidesEl('logo', { variant: 'official', x: 36, y: 24, w: 28, h: 34 }));
  s.elements.push(officeSlidesText([
    c.name || 'LEON Integra',
    [c.addressLine1, c.addressLine2].filter(Boolean).join(', '),
    [c.phone, c.email, c.website].filter(Boolean).join('   |   '),
  ].filter(Boolean).map(esc).join('<br>'),
    { x: 6, y: 63, w: 88, h: 16, size: QD.bodyLg, align: 'center', role: 'body' }));
  // The brand mark is the page; the corner one would be a second copy of it.
  return s;
}

// ─── section divider ─────────────────────────────────────────────────────────
function officeSlidesQuoteDivider(ctx) {
  // Named apart from the slide it introduces: a divider called "Casework
  // Installation" sitting directly above a package slide of the same name is
  // two indistinguishable rows in the slide list.
  const s = officeSlidesQuoteBlank(ctx, ctx.step.title + ' — section');
  // A break page uses LEON's OWN standard artwork — the same kitchen behind
  // KITCHEN CASEWORK on every quotation they send, verified byte-identical
  // across all three decks supplied. It is not a slot to fill in per job, so it
  // is PLACED rather than prompted for. A picture chosen for this deck still
  // wins, for the job that genuinely needs a different one.
  const chosen = ctx.filled[ctx.step.archetype + ':' + ctx.index + ':0'];
  const std = (typeof quoteArtLookup === 'function') ? quoteArtLookup(ctx.o, ctx.step.title) : null;
  const url = (chosen && chosen.url) || std;
  if (url) {
    s.elements.push(officeSlidesEl('image', { x: 0, y: 0, w: 100, h: 100, fit: 'cover', radius: 0,
      slot: ctx.step.archetype + ':' + ctx.index + ':0',
      ref: { kind: 'picture', kindLabel: 'picture', id: null, url, name: ctx.step.title || '' } }));
    // The whole picture is shaded rather than a band across its middle: a band
    // cuts the image in half and puts the type over its busiest part. A light
    // wash over all of it, and the title along the FOOT, leaves the picture
    // readable as a picture and the words legible as words.
    // Light enough to keep the photograph a photograph. Two washes stack, so
    // 0.28 over 0.45 was ~60% black at the foot and the page read as a dark
    // rectangle with a picture somewhere behind it.
    s.elements.push(officeSlidesEl('shape', { shape: 'rect', x: 0, y: 0, w: 100, h: 100, fill: 'dk1', opacity: 0.12 }));
    s.elements.push(officeSlidesEl('shape', { shape: 'rect', x: 0, y: 58, w: 100, h: 42, fill: 'dk1', opacity: 0.38 }));
  } else {
    // No standard artwork for this title — an unusual scope name. A plain band
    // rather than a "+ Add picture" prompt, because a divider has no slot to
    // fill: the answer is to add artwork for it under Quote Settings.
    s.elements.push(officeSlidesEl('shape', { shape: 'rect', x: 0, y: 0, w: 100, h: 100, fill: 'dk2', radius: 0 }));
    // no picture, but the title is still white on a dark ground
  }
  const dSize = officeSlidesQuoteFit(officeSlidesQuoteCaps(ctx.step.title), QD.dividerTitle, 84, 24);
  const dH = officeSlidesQuoteHeadH(1, dSize);
  // A rule above the title, the width of the words — a small thing that stops a
  // heading floating on a photograph.
  s.elements.push(officeSlidesEl('shape', { shape: 'rect', x: 8, y: 88 - dH - 4, w: 14, h: 0.5, fill: 'lt1', opacity: 0.9 }));
  // WHITE, explicitly. `role: 'title'` resolves to the theme's ink — correct on
  // a white page and invisible on a darkened photograph, which is exactly how
  // this rendered: CASEWORK in near-black on a near-black wash.
  s.elements.push(officeSlidesText('<b>' + officeWordEscape(officeSlidesQuoteCaps(ctx.step.title)) + '</b>',
    { x: 8, y: 88 - dH, w: 84, h: dH, role: 'title', color: 'lt1', size: dSize }));
  return officeSlidesQuoteBrand(ctx, s);
}

// ─── design intent ───────────────────────────────────────────────────────────
function officeSlidesQuoteIntent(ctx) {
  const s = officeSlidesQuoteBlank(ctx, ctx.step.title + ' — design intent');
  const iSize = officeSlidesQuoteFit(officeSlidesQuoteCaps(ctx.step.title) + ' DESIGN INTENT', QD.sectionTitle, 88, 18);
  const iH = officeSlidesQuoteHeadH(1, iSize);
  s.elements.push(officeSlidesText(officeSlidesQuoteScopeHead(ctx.step.title, 'DESIGN INTENT'),
    { x: 6, y: 7, w: 88, h: iH, role: 'title', size: iSize }));
  // ONE picture: their design-intent pages carry a single image, not a pair.
  const iTop = 7 + iH + 3;
  s.elements.push.apply(s.elements, officeSlidesQuotePicGrid(ctx, { x: 6, y: iTop, w: 88, h: 86 - iTop }, 'Design intent'));
  return officeSlidesQuoteFooter(ctx, s);
}

// ─── specification ───────────────────────────────────────────────────────────
// The fields the quotation actually answers, stated ONCE per scope. They are
// answered on the AREA now, so a per-line list would repeat one answer forty
// times; where lines genuinely disagree the row says so instead of picking one.
function officeSlidesQuoteSpec(ctx) {
  const specs = ctx.step.specs || [];
  const extra = (ctx.o.extraSpecs && ctx.o.extraSpecs[ctx.step.scope]) || [];
  const rows = specs.concat(extra.map(e => ({ field: e.field, value: e.value, manual: true })));
  // The heading names the PART or AREA when the scope specifies more than one —
  // "Base Cabinet", "Bathroom" — which is what makes two specification pages
  // for one scope readable instead of confusing.
  const part = ctx.step.specTitle || '';
  const scope = ctx.step.title || '';
  // A hand-added page is usually titled for itself ("Accessories") and has no
  // second name to print underneath. Repeating it read as "Accessories —
  // Accessories", which is how a generated heading tells you it was generated.
  const sub = (part && scope && part.toLowerCase() !== scope.toLowerCase()) ? scope : '';
  const s = officeSlidesQuoteBlank(ctx, (part && !sub ? part : (scope + (part ? ' — ' + part : ''))) + ' — specification');
  const sSize = officeSlidesQuoteFit(String(part || scope).toUpperCase(), QD.specTitle, 46, 18);
  const sH = officeSlidesQuoteHeadH(sub ? 3 : 2, sSize);
  s.elements.push(officeSlidesText(
    '<b><span style="color:' + QD_ACCENT + '">' + officeWordEscape(officeSlidesQuoteCaps(part || scope)) + '</span></b><br>SPECIFICATION' +
    (sub ? '<br><span>' + officeWordEscape(officeSlidesQuoteCaps(sub)) + '</span>' : ''),
    { x: 6, y: 7, w: 46, h: sH, role: 'title', size: sSize }));
  // The specification's own pictures: SMALLER and more of them, each captioned
  // with the field it answers. One big picture said less than four labelled
  // ones — a client reads a finish board, and a board with no captions is a
  // pattern, not a specification.
  // EIGHT spots, 2 x 4 — enough for a scope's whole board on one page, which
  // is the point: a specification split across two pages is read as two
  // specifications. More than eight and the extras go on their own page, which
  // is the honest answer rather than shrinking them past reading.
  const pics = (ctx.step.specPics && ctx.step.specPics.length) ? ctx.step.specPics : [];
  if (pics.length) {
    const PW = 18.5, PH = 15, GX = 1.5, GY = 3;
    pics.slice(0, 8).forEach((pp, i) => {
      const col = i % 2, row = Math.floor(i / 2);
      const px = 55 + col * (PW + GX), py = 18 + row * (PH + GY + 3.6);
      s.elements.push(officeSlidesEl('image', { x: px, y: py, w: PW, h: PH, fit: 'cover', radius: 1,
        ref: { kind: 'finish', kindLabel: 'finish', id: null, url: pp.img, name: pp.name || '' } }));
      s.elements.push(officeSlidesText(
        '<b>' + officeWordEscape(officeSlidesQuoteCaps(pp.field || '')) + '</b>' +
        (pp.name ? '<br>' + officeWordEscape(pp.name) : ''),
        { x: px, y: py + PH + 0.5, w: PW, h: 6, size: 7, role: 'caption' }));
    });
    if (pics.length > 8) {
      s.elements.push(officeSlidesText('<i>' + (pics.length - 8) + ' further selection' +
        (pics.length - 8 === 1 ? '' : 's') + ' overleaf.</i>',
        { x: 55, y: 90, w: 39, h: 4, size: 7, role: 'caption' }));
    }
  } else {
    s.elements.push.apply(s.elements, officeSlidesQuotePicGrid(ctx, { x: 55, y: 20, w: 39, h: 62 }, ctx.step.title));
  }
  if (!rows.length) {
    s.elements.push(officeSlidesText(ctx.step.manual
      ? 'Add the lines for this page under Quotation deck &rarr; slides.'
      : 'No specification recorded for this scope yet.',
      { x: 6, y: 7 + sH + 2, w: 45, h: 6, size: QD.bodyLg, role: 'caption' }));
    return officeSlidesQuoteFooter(ctx, s);
  }
  let y = 7 + sH + 2;
  const step = rows.length > 14 ? 4.4 : 5.2;
  rows.forEach(r => {
    s.elements.push(officeSlidesText(officeWordEscape(String(r.field || '').toUpperCase()),
      { x: 6, y, w: 22, h: step, size: QD.specLabel, role: 'caption' }));
    s.elements.push(officeSlidesText('<b>' + officeWordEscape(r.value == null ? '' : String(r.value)) + '</b>' +
        (r.varies ? ' <span>(varies by area)</span>' : ''),
      { x: 28, y: y - 0.4, w: 24, h: step, size: QD.specValue, role: 'body' }));
    y += step;
  });
  return officeSlidesQuoteFooter(ctx, s);
}

// ─── colour options ──────────────────────────────────────────────────────────
// Generated from the supplier catalog, 5x2 as the issued deck lays them out.
// These were 60 pasted photographs weighing 486 MB; they are records now.
function officeSlidesQuoteColors(ctx) {
  const sw = ctx.step.swatches || [];
  const s = officeSlidesQuoteBlank(ctx, ctx.step.title + ' — colour options');
  s.elements.push(officeSlidesText('COLOR OPTIONS', { x: 6, y: 5, w: 60, h: 12, size: QD.sectionTitle, role: 'title' }));
  sw.slice(0, 10).forEach((f, i) => {
    const c = i % 5, r = Math.floor(i / 5);
    const x = 5 + c * 18.6, y = 22 + r * 34;
    s.elements.push(officeSlidesEl('image', {
      x, y, w: 15.6, h: 26, fit: 'cover', radius: 1,
      ref: { kind: 'finish', kindLabel: 'finish', id: null, url: f.img, name: f.name || f.code },
    }));
    s.elements.push(officeSlidesText('<b>' + officeWordEscape(f.code || f.name || '') + '</b>',
      { x, y: y + 26.5, w: 15.6, h: 6, size: QD.colourCode, align: 'center', role: 'body' }));
  });
  return officeSlidesQuoteFooter(ctx, s);
}

// ─── gallery ─────────────────────────────────────────────────────────────────
function officeSlidesQuoteGallery(ctx) {
  const s = officeSlidesQuoteBlank(ctx, ctx.step.title + ' — gallery');
  s.elements.push.apply(s.elements, officeSlidesQuotePicGrid(ctx, { x: 5, y: 8, w: 90, h: 82 }, ctx.step.title));
  return officeSlidesQuoteBrand(ctx, s);
}

// ─── the scope estimate ──────────────────────────────────────────────────────
// The slide that was a pasted picture. Price, shipping, tax and contract value
// all come from the client document, so they cannot drift from the quotation.
function officeSlidesQuoteEstimate(ctx) {
  const sec = ctx.step.section, d = ctx.doc, o = ctx.o;
  const money = officeSlidesQuoteMoney;
  const esc = officeWordEscape;
  const s = officeSlidesQuoteBlank(ctx, ctx.step.title + ' — estimate');
  const tSize = officeSlidesQuoteFit(officeSlidesQuoteCaps(ctx.step.title) + ' ESTIMATE', QD.sectionTitle, 88, 20);
  s.elements.push(officeSlidesText(officeSlidesQuoteScopeHead(ctx.step.title, 'ESTIMATE'),
    { x: 6, y: 6, w: 88, h: officeSlidesQuoteHeadH(1, tSize), role: 'title', size: tSize }));

  // NO picture on an estimate page. This is the page the client reads the
  // numbers off; a photograph on it is decoration competing with the figures.

  // WHAT IS BEING OFFERED, line by line — every column the quote analysis
  // carries except the ones that are ours: no cost, no margin, no freight, no
  // duty. Those never reach here anyway, because this is built from the client
  // document and that whitelist fails closed. The point of the table is that
  // the client can see what they are buying rather than a single figure.
  const rows = (sec.lines || []);
  const anyArea = rows.some(l => l.area);
  const anyUom = rows.some(l => l.uom);
  let y = 22;
  const C = anyArea
    ? { area: 6, desc: 24, qty: 60, uom: 70, price: 80 }
    : { area: null, desc: 6, qty: 60, uom: 70, price: 80 };
  // Column headers in the brand brown on a tinted band — a table of grey rows
  // under grey headers is what made this page read as a wall of figures.
  s.elements.push(officeSlidesEl('shape', { shape: 'rect', x: 5, y: y - 1, w: 90, h: 6, fill: 'lt2' }));
  const head = (t, x, w, al) => s.elements.push(officeSlidesText(
    '<b><span style="color:' + QD_ACCENT + '">' + t + '</span></b>',
    { x, y: y + 0.4, w, h: 5, size: QD.fine, align: al || 'left', role: 'caption' }));
  if (anyArea) head('AREA', C.area, 17);
  head('DESCRIPTION', C.desc, 34);
  head('QTY', C.qty, 9, 'right');
  if (anyUom) head('UOM', C.uom, 9);
  head('PRICE', C.price, 14, 'right');
  y += 6;

  const MAXY = 74;
  let shown = 0, hidden = 0;
  rows.forEach(l => {
    if (y > MAXY) { if (!l.rowKind || l.rowKind === 'item') hidden += 1; return; }
    if (l.rowKind === 'area') {
      y += 1;
      s.elements.push(officeSlidesText(
        '<b><span style="color:' + QD_ACCENT + '">' + esc(officeSlidesQuoteCaps(l.description || '')) + '</span></b>',
        { x: 6, y, w: 88, h: 5, size: QD.small, role: 'caption' }));
      y += 5.6;
      return;
    }
    if (l.rowKind === 'note') {
      s.elements.push(officeSlidesText('<i>' + esc(l.description || '') + '</i>',
        { x: 6, y, w: 88, h: 5, size: QD.fine, role: 'caption' }));
      y += 4.8;
      return;
    }
    if (anyArea) s.elements.push(officeSlidesText(esc(l.area || ''), { x: C.area, y, w: 17, h: 5, size: QD.body, role: 'body' }));
    s.elements.push(officeSlidesText(esc(l.description || ''), { x: C.desc, y, w: 34, h: 5, size: QD.body, role: 'body' }));
    s.elements.push(officeSlidesText(l.qty ? String(Math.round(qnum(l.qty) * 100) / 100) : '',
      { x: C.qty, y, w: 9, h: 5, size: QD.body, align: 'right', role: 'body' }));
    if (anyUom) s.elements.push(officeSlidesText(esc(l.uom || ''), { x: C.uom, y, w: 9, h: 5, size: QD.body, role: 'body' }));
    s.elements.push(officeSlidesText(l.price ? money(l.price) : '&mdash;',
      { x: C.price, y, w: 14, h: 5, size: QD.body, align: 'right', role: 'body',
        color: l.price ? 'dk2' : null }));
    y += 5.2; shown += 1;
  });
  // A page that quietly stops at the fold is worse than one that says so.
  if (hidden) {
    s.elements.push(officeSlidesText('<i>' + hidden + ' further line' + (hidden === 1 ? '' : 's') +
      ' continue on the following pages.</i>', { x: 6, y: Math.min(y, MAXY + 1), w: 88, h: 5, size: QD.fine, role: 'caption' }));
    y += 5;
  }

  // The foot of the page, laid out to a BUDGET rather than by stacking rows and
  // hoping. Before this the lead times ran to y=102 — off the paper — and the
  // disclaimer printed straight through CONTRACT VALUE and the master footer.
  const FOOT = 66;          // where the summary band starts
  const DISC = 86;          // the disclaimer's own line
  const LIMIT = 85;         // both columns must finish above the disclaimer
  s.elements.push(officeSlidesEl('shape', { shape: 'rect', x: 6, y: FOOT - 2, w: 88, h: 0.3, fill: 'dk2', opacity: 0.35 }));

  let my = FOOT;
  const row = (k, v, big) => {
    if (big) {
      // The one figure the client is looking for. A tinted band and brown type
      // so it is found without reading the three lines above it.
      s.elements.push(officeSlidesEl('shape', { shape: 'rect', x: 5, y: my - 1, w: 52, h: 7, fill: 'lt2' }));
    }
    s.elements.push(officeSlidesText(
      (big ? '<b><span style="color:' + QD_ACCENT + '">' + esc(k) + '</span></b>' : esc(k)),
      { x: 6, y: my, w: 28, h: 4.4, size: big ? QD.moneyBig : QD.moneyKey, role: 'body' }));
    s.elements.push(officeSlidesText(
      (big ? '<b><span style="color:' + QD_ACCENT + '">' + v + '</span></b>' : v),
      { x: 34, y: my, w: 22, h: 4.4, size: big ? QD.moneyBig : QD.moneyKey, align: 'right', role: 'body' }));
    my += big ? 6.6 : 4.4;
  };
  row('Price Estimate:', sec.unpriced ? 'Price to follow' : money(sec.subtotal));
  row('Shipping:', esc(o.shippingNote || 'Included'));
  if (qnum(d.taxRatePct)) row('Taxes:', money(sec.taxes));
  row('CONTRACT VALUE', sec.unpriced ? '&mdash;' : money(sec.contractValue), true);

  // Scope of supply and the lead times sit BESIDE the money, not under it, and
  // the lead times are ONE wrapped run rather than four rows — four rows ran to
  // y=95, through the disclaimer and into the master's own footer.
  let ly = FOOT;
  s.elements.push(officeSlidesText('<b><span style="color:' + QD_ACCENT + '">SCOPE OF SUPPLY</span></b>', { x: 60, y: ly, w: 34, h: 3.4, size: QD.fine, role: 'caption' }));
  s.elements.push(officeSlidesText(esc(sec.kind === 'combined' ? 'Supply & Install' : sec.kind === 'labor' ? 'Labor Only' : 'Supply Only'),
    { x: 60, y: ly + 3.6, w: 34, h: 4.2, size: QD.small, role: 'body' }));
  ly += 9;
  const lt = (typeof quoteDeckLeadTimes === 'function') ? quoteDeckLeadTimes(o.analysis || {}, sec) : null;
  if (lt && ly + 9 <= LIMIT) {
    const wk = n => qnum(n) + (qnum(n) === 1 ? ' wk' : ' wks');
    s.elements.push(officeSlidesText('<b><span style="color:' + QD_ACCENT + '">LEAD TIMES</span></b>', { x: 60, y: ly, w: 34, h: 3.4, size: QD.fine, role: 'caption' }));
    s.elements.push(officeSlidesText(
      ['Shop drawing ' + wk(lt.shopDrawingWeeks), 'Revisions ' + wk(lt.revisionWeeks),
       'Production ' + wk(lt.productionWeeks), 'Freight ' + wk(lt.freightWeeks)].join('  &middot;  '),
      { x: 60, y: ly + 3.6, w: 34, h: Math.min(8, LIMIT - ly - 3.6), size: 7.5, role: 'body' }));
  }

  // One line, clear of both columns above it and the master's footer below.
  s.elements.push(officeSlidesText('<i>' + esc(o.quantitiesDisclaimer || QUOTE_DECK_DISCLAIMER) + '</i>',
    { x: 6, y: DISC, w: 88, h: 4.2, size: 7, role: 'caption' }));
  return officeSlidesQuoteFooter(ctx, s);
}

// ─── the installation package ────────────────────────────────────────────────
function officeSlidesQuoteInstall(ctx) {
  const sec = ctx.step.section, d = ctx.doc, o = ctx.o;
  const money = officeSlidesQuoteMoney, esc = officeWordEscape;
  const s = officeSlidesQuoteBlank(ctx, ctx.step.title);
  s.elements.push(officeSlidesText('<b>' + esc(String(ctx.step.title || '').toUpperCase()) + ' PACKAGE</b>',
    { x: 6, y: 7, w: 56, h: 14, role: 'title',
      size: officeSlidesQuoteFit(String((ctx.step.title || '') + ' PACKAGE').toUpperCase(), QD.sectionTitle, 56, 18) }));
  s.elements.push.apply(s.elements, officeSlidesQuotePic(ctx, 0, { x: 62, y: 6, w: 32, h: 46 }, ctx.step.title));
  let y = 22;
  s.elements.push(officeSlidesText('<b>CONTRACT VALUE</b>', { x: 6, y, w: 30, h: 7, size: QD.moneyBig, role: 'body' }));
  s.elements.push(officeSlidesText('<b>' + (sec.unpriced ? 'Price to follow' : money(sec.contractValue)) + '</b>',
    { x: 36, y, w: 22, h: 7, size: QD.moneyBig, align: 'right', role: 'body' }));
  y += 12;
  const lt = (typeof quoteDeckLeadTimes === 'function') ? quoteDeckLeadTimes(o.analysis || {}, sec) : null;
  if (lt) {
    s.elements.push(officeSlidesText('<b>LEAD TIME</b>', { x: 6, y, w: 40, h: 5, size: QD.fine, role: 'caption' }));
    s.elements.push(officeSlidesText(qnum(lt.productionWeeks) + ' weeks from release',
      { x: 6, y: y + 6, w: 40, h: 6, size: QD.body, role: 'body' }));
  }
  return officeSlidesQuoteFooter(ctx, s);
}

// ─── the bid summary ─────────────────────────────────────────────────────────
// In the issued deck this was image156.emf — a 1.9 MB picture of a spreadsheet.
// It is the page the client decides on, and it could not be checked and went
// stale the moment a scope price moved. Here it adds itself up.
function officeSlidesQuoteSummary(ctx) {
  const d = ctx.doc, money = officeSlidesQuoteMoney, esc = officeWordEscape;
  const s = officeSlidesQuoteBlank(ctx, 'Estimate summary');
  s.elements.push(officeSlidesText('<b>ESTIMATE SUMMARY</b>', { x: 6, y: 7, w: 60, h: 14, size: QD.sectionTitle, role: 'title' }));
  // The rows are fitted to the room they have: eight scopes plus a tax line
  // plus the contract value plus the tariff note does not fit at a fixed pitch,
  // and a summary that overflows is the one page that must not.
  const rows = (d.sections || []);
  const footRows = 1 + (qnum(d.taxRatePct) ? 1 : 0) + 1;
  const room = 78 - 20 - (footRows * 5.6 + 4);
  const pitch = Math.max(3.6, Math.min(5.6, room / Math.max(1, rows.length)));
  let y = 20;
  rows.forEach(sec => {
    s.elements.push(officeSlidesText(esc(sec.name), { x: 8, y, w: 52, h: pitch, size: pitch < 5 ? QD.body : QD.bodyLg, role: 'body' }));
    s.elements.push(officeSlidesText(sec.unpriced ? 'Price to follow' : money(sec.subtotal),
      { x: 62, y, w: 30, h: pitch, size: pitch < 5 ? QD.body : QD.bodyLg, align: 'right', role: 'body' }));
    y += pitch;
  });
  y += 3;
  const foot = [['Subtotal', money(d.subtotal)]];
  if (qnum(d.taxRatePct)) foot.push(['Taxes (' + officeSlidesQuotePct(d.taxRatePct) + '%)', money(d.taxes)]);
  foot.push(['CONTRACT VALUE', money(d.contractValue)]);
  foot.forEach(([k, v], i) => {
    const last = i === foot.length - 1;
    s.elements.push(officeSlidesText((last ? '<b>' : '') + esc(k) + (last ? '</b>' : ''),
      { x: 8, y, w: 52, h: 6, size: last ? QD.moneyBig : QD.bodyLg, role: 'body' }));
    s.elements.push(officeSlidesText((last ? '<b>' : '') + v + (last ? '</b>' : ''),
      { x: 62, y, w: 30, h: 6, size: last ? QD.moneyBig : QD.bodyLg, align: 'right', role: 'body' }));
    y += last ? 8 : 5.6;
  });
  // An unpriced line makes a quotation INCOMPLETE rather than cheap.
  if (d.unpriced) {
    s.elements.push(officeSlidesText(
      d.unpriced + ' item' + (d.unpriced === 1 ? ' is' : 's are') + ' still to be priced, so this total is not yet complete.',
      { x: 8, y: Math.min(y + 2, 80), w: 84, h: 4.4, size: QD.fine, role: 'caption' }));
  }
  // Pinned above the master's footer rather than stacked after whatever came
  // before it — with tax and eight scopes the stacked version ran to y=97,
  // straight through the footer.
  s.elements.push(officeSlidesText(esc(ctx.o.tariffNote ||
    'Prices are based on tariff rates current at the date of this quotation and are subject to change in accordance with future government regulations or tariff adjustments.'),
    { x: 8, y: 86, w: 84, h: 4.4, size: 7, role: 'caption' }));
  return officeSlidesQuoteFooter(ctx, s);
}

// ─── terms & conditions ──────────────────────────────────────────────────────
// Paginated by measured height rather than a fixed clauses-per-slide, because
// clause 4 alone is nineteen blocks and clause 15 is one.
function officeSlidesQuoteTerms(ctx) {
  const terms = ctx.step.terms;
  const esc = officeWordEscape;
  const out = [];
  const H = 84;
  let s = null, y = 0;
  const startSlide = () => {
    s = officeSlidesQuoteBlank(ctx, 'Terms and conditions');
    s.elements.push(officeSlidesText('<b>TERMS AND CONDITIONS</b>', { x: 6, y: 5, w: 60, h: 10, size: QD.sectionTitle, role: 'title' }));
    y = 20;
    out.push(s);
  };
  startSlide();
  (terms.clauses || []).forEach(c => {
    const blocks = c.body || [];
    const need = 7 + blocks.reduce((n, b) => n + (b.t === 'h' ? 5.5 : Math.max(4.8, Math.ceil(b.s.length / 155) * 4.4)), 0);
    if (y + need > H && y > 18) startSlide();
    s.elements.push(officeSlidesText('<b>' + esc(c.n + '. ' + c.title) + '</b>',
      { x: 6, y, w: 88, h: 5, size: QD.bodyLg, role: 'body' }));
    y += 5.6;
    blocks.forEach(b => {
      if (y > H) { startSlide(); }
      // Height must follow the WRAP, not the character count alone: at 9pt an 845pt
      // column holds roughly 155 characters a line, so a 400-character clause is
      // three lines and reserving one is how terms overprint each other.
      const perLine = 155;
      const h = b.t === 'h' ? 5.5 : Math.max(4.8, Math.ceil(b.s.length / perLine) * 4.4);
      s.elements.push(officeSlidesText(
        b.t === 'h' ? '<b>' + esc(b.s) + '</b>' : b.t === 'li' ? '&bull;&nbsp; ' + esc(b.s) : esc(b.s),
        { x: b.t === 'li' ? 9 : 6, y, w: b.t === 'li' ? 85 : 88, h, size: QD.fine, role: 'caption' }));
      y += h;
    });
    y += 2;
  });
  out.forEach(sl => officeSlidesQuoteFooter(ctx, sl));
  return out;
}

function officeSlidesRenderSlide(refs, T, title) {
  const s = officeSlidesMakeSlide('blank', T);
  s.name = title || 'Renders';
  s.elements = [officeSlidesText(officeWordEscape(title || 'Renders'), { x: 6, y: 6, w: 88, h: 9, size: 26, bold: true, role: 'title' })];
  const n = Math.min(refs.length, 4);
  const cols = n <= 1 ? 1 : n === 2 ? 2 : 2;
  const rows = Math.ceil(n / cols);
  refs.slice(0, 4).forEach((r, i) => {
    const c = i % cols, rr = Math.floor(i / cols);
    const cw = 88 / cols, ch = 72 / rows;
    s.elements.push(officeSlidesEl('image', {
      x: 6 + c * cw + 1, y: 19 + rr * ch, w: cw - 2, h: ch - 6, fit: 'contain', ref: r,
    }));
    s.elements.push(officeSlidesText(officeWordEscape(r.name || ''), {
      x: 6 + c * cw + 1, y: 19 + rr * ch + ch - 6, w: cw - 2, h: 5, size: 11, align: 'center', role: 'caption',
    }));
  });
  return s;
}

function officeSlidesComparison(options, T, canSeeFin) {
  const s = officeSlidesMakeSlide('blank', T);
  s.name = 'Option comparison';
  s.elements = [officeSlidesText('Option comparison', { x: 6, y: 6, w: 88, h: 9, size: 26, bold: true, role: 'title' })];
  const n = Math.max(2, Math.min(3, options.length));
  const gap = 3, total = 88, cw = (total - gap * (n - 1)) / n;
  options.slice(0, n).forEach((o, i) => {
    const x = 6 + i * (cw + gap);
    // Slot names, not hexes: these panels follow whatever theme the deck ends up on.
    s.elements.push(officeSlidesEl('shape', { shape: 'rect', x, y: 19, w: cw, h: 70, fill: 'lt2', stroke: 'dk2', strokeWidth: 1, radius: 4 }));
    s.elements.push(officeSlidesText('<b>' + officeWordEscape(o.name || 'Option ' + (i + 1)) + '</b>',
      { x: x + 2, y: 22, w: cw - 4, h: 8, size: 19, role: 'subtitle' }));
    if (o.ref) s.elements.push(officeSlidesEl('image', { x: x + 2, y: 31, w: cw - 4, h: 26, fit: 'cover', radius: 3, ref: o.ref }));
    s.elements.push(officeSlidesText(
      (canSeeFin && o.price ? '<b>' + officeWordEscape(o.price) + '</b><br>' : '') +
      (o.lead ? 'Lead time: ' + officeWordEscape(o.lead) + '<br>' : '') +
      officeWordEscape(o.notes || ''),
      { x: x + 2, y: o.ref ? 59 : 32, w: cw - 4, h: 27, size: 13 }));
  });
  return s;
}

function officeSlidesTableEl(columns, rows, o) {
  const body = [columns].concat(rows.slice(0, 12));
  return officeSlidesEl('table', Object.assign({ rows: body, headerRow: true, size: 11, x: 6, y: 20, w: 88, h: 70 }, o || {}));
}

function officeSlidesProjectOverview(sc, T) {
  const p = sc.project;
  const s = officeSlidesMakeSlide('blank', T);
  s.name = 'Project overview';
  s.elements = [
    officeSlidesText(officeWordEscape(p ? p.name : 'Project overview'), { x: 6, y: 8, w: 88, h: 10, size: 30, bold: true, role: 'title' }),
    officeSlidesText(officeWordEscape(p ? (p.address || '') : ''), { x: 6, y: 19, w: 60, h: 6, size: 14, role: 'subtitle' }),
    officeSlidesEl('logo', { variant: 'wordmark', x: 78, y: 7, w: 16, h: 7 }),
  ];
  const rows = p ? [
    ['Project number', p.projectNumber || '—'],
    ['Client', sc.account ? sc.account.name : '—'],
    ['Type', p.projectType || '—'],
    ['Department', (p.companyDepartment || []).join(' / ') || '—'],
    ['Scopes', String((p.scopes || []).length)],
    ['Status', p.pipelineStatus || '—'],
  ] : [['—', 'Link this deck to a project']];
  if (p && sc.ctx.canSeeFin) rows.push(['Contract value', fmtMoney(p.originalContractValue || 0)]);
  s.elements.push(officeSlidesTableEl(['', ''], rows, { y: 28, h: 58, w: 56 }));
  if (p && p.displayImageUrl) {
    s.elements.push(officeSlidesEl('image', { x: 64, y: 28, w: 30, h: 58, fit: 'cover', radius: 4, ref: { kind: 'photo', kindLabel: 'project photo', url: p.displayImageUrl, name: 'Project image' } }));
  }
  return s;
}
function officeSlidesScopeSummary(sc, T) {
  const p = sc.project;
  const s = officeSlidesMakeSlide('blank', T);
  s.name = 'Scope summary';
  s.elements = [officeSlidesText('Scope of work', { x: 6, y: 7, w: 88, h: 9, size: 26, bold: true, role: 'title' })];
  const built = officeWordBuildData({ dataKey: 'scopeSchedule', args: {} }, sc);
  s.elements.push(officeSlidesTableEl(built.columns || [], built.rows || []));
  return s;
}
function officeSlidesSchedule(sc, T) {
  const p = sc.project;
  const s = officeSlidesMakeSlide('blank', T);
  s.name = 'Schedule';
  s.elements = [officeSlidesText('Programme', { x: 6, y: 7, w: 88, h: 9, size: 26, bold: true, role: 'title' })];
  const rows = [];
  ((p || {}).scopes || []).forEach(sc2 => {
    (sc2.stages || []).slice(0, 6).forEach(st => rows.push([sc2.name, st.name, fmtDate(st.plannedStart), fmtDate(st.plannedDue), st.status]));
  });
  s.elements.push(officeSlidesTableEl(['Scope', 'Stage', 'Start', 'Due', 'Status'], rows));
  return s;
}
function officeSlidesTeam(sc, T) {
  const p = sc.project;
  const s = officeSlidesMakeSlide('blank', T);
  s.name = 'Team';
  s.elements = [officeSlidesText('Your LEON team', { x: 6, y: 7, w: 88, h: 9, size: 26, bold: true, role: 'title' })];
  const dir = sc.ctx.teamDirectory || [];
  const rows = [];
  const teams = (p && p.teams) || {};
  Object.keys(teams).forEach(dept => {
    Object.keys(teams[dept] || {}).forEach(role => {
      const person = dir.find(x => x.id === teams[dept][role]);
      if (person) rows.push([dept, role, person.name, (person.title || '').trim() || '—']);
    });
  });
  s.elements.push(officeSlidesTableEl(['Department', 'Role', 'Name', 'Title'],
    rows.length ? rows : [['—', '—', 'Nobody assigned yet', '—']]));
  return s;
}
function officeSlidesBeforeAfter(sc, T) {
  const s = officeSlidesMakeSlide('blank', T);
  s.name = 'Before / after';
  const photos = officeWordProjectImages(sc.project);
  s.elements = [
    officeSlidesText('Before / after', { x: 6, y: 6, w: 88, h: 9, size: 26, bold: true, role: 'title' }),
    officeSlidesEl('image', { x: 6, y: 19, w: 43, h: 62, fit: 'cover', radius: 4, ref: photos[0] ? { kind: 'photo', kindLabel: 'project photo', url: photos[0].url, name: photos[0].name } : null }),
    officeSlidesEl('image', { x: 51, y: 19, w: 43, h: 62, fit: 'cover', radius: 4, ref: photos[1] ? { kind: 'photo', kindLabel: 'project photo', url: photos[1].url, name: photos[1].name } : null }),
    officeSlidesText('Before', { x: 6, y: 82, w: 43, h: 6, size: 13, align: 'center', role: 'caption' }),
    officeSlidesText('After', { x: 51, y: 82, w: 43, h: 6, size: 13, align: 'center', role: 'caption' }),
  ];
  return s;
}

// ── Content-slide chooser ─────────────────────────────────────────────────
function OfficeSlidesContentModal({ open, onClose, sc, T, onAdd }) {
  const [kind, setKind] = useState('overview');
  const [scopeId, setScopeId] = useState('');
  const [picked, setPicked] = useState([]);
  const [picking, setPicking] = useState(false);
  const [options, setOptions] = useState([{ name: 'Option A', price: '', lead: '', notes: '', ref: null },
    { name: 'Option B', price: '', lead: '', notes: '', ref: null }]);
  const [optionFor, setOptionFor] = useState(null);
  const scopes = ((sc.project || {}).scopes) || [];
  useEffect(() => { if (open) { setKind('overview'); setPicked([]); setScopeId(''); } }, [open]);

  const kinds = [
    { key: 'overview', label: 'Project overview', hint: 'The job as a fact sheet — number, client, type, scopes.' },
    { key: 'scopes', label: 'Scope summary', hint: 'Every scope with its programme, read from the schedule.' },
    { key: 'schedule', label: 'Schedule', hint: 'Stage by stage, with planned dates and status.' },
    { key: 'board', label: 'Material / finish board', hint: 'The supplier finishes chosen on a scope, laid out as swatches.' },
    { key: 'render', label: 'Render slide', hint: 'Pictures from the LEON render library.' },
    { key: 'comparison', label: 'Option comparison', fin: true, hint: 'Two or three options side by side with price and lead time.' },
    { key: 'team', label: 'Team', hint: 'Who is on this job, by department.' },
    { key: 'beforeAfter', label: 'Before / after', hint: 'Two project photos, side by side.' },
  ];

  function build() {
    if (kind === 'overview') return onAdd(officeSlidesProjectOverview(sc, T));
    if (kind === 'scopes') return onAdd(officeSlidesScopeSummary(sc, T));
    if (kind === 'schedule') return onAdd(officeSlidesSchedule(sc, T));
    if (kind === 'team') return onAdd(officeSlidesTeam(sc, T));
    if (kind === 'beforeAfter') return onAdd(officeSlidesBeforeAfter(sc, T));
    if (kind === 'board') return onAdd(officeSlidesFinishBoard(sc, scopeId, T));
    if (kind === 'render') return onAdd(officeSlidesRenderSlide(picked, T, 'Renders'));
    if (kind === 'comparison') return onAdd(officeSlidesComparison(options, T, sc.ctx.canSeeFin));
  }

  return (
    <>
      <Modal open={open} onClose={onClose} wide title="Add a LEON content slide"
        footer={<>
          <Button variant="ghost" onClick={onClose}>Cancel</Button>
          <Button onClick={() => { build(); onClose(); }}>Add slide</Button>
        </>}>
        {!sc.project && (
          <div className="text-xs bg-[var(--leon-cream)] border border-[var(--leon-line)] rounded-lg p-3 mb-3">
            This deck is not linked to a project, so the project slides will come out empty. Link it from the document's
            own details and rebuild them.
          </div>
        )}
        <div className="grid sm:grid-cols-2 gap-2">
          {kinds.filter(k => officeFinOk(k, sc)).map(k => (
            <button key={k.key} onClick={() => setKind(k.key)}
              className={`text-left border rounded-lg px-3 py-2 ${kind === k.key ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)] hover:border-[var(--leon-brown-light)]'}`}>
              <div className="text-sm font-semibold">{k.label}</div>
              <div className="text-[11px] text-[var(--leon-black)]/50">{k.hint}</div>
            </button>
          ))}
        </div>

        {kind === 'board' && (
          <Field label="Scope" className="mt-4">
            <Select value={scopeId} onChange={e => setScopeId(e.target.value)}>
              <option value="">Choose a scope…</option>
              {scopes.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
            </Select>
          </Field>
        )}

        {kind === 'render' && (
          <div className="mt-4">
            <div className="flex items-center gap-2 mb-2">
              <Button size="sm" variant="outline" onClick={() => setPicking(true)}>Pick a render</Button>
              <span className="text-xs text-[var(--leon-black)]/50">{picked.length} chosen · up to 4 fit on a slide</span>
            </div>
            <div className="flex flex-wrap gap-2">
              {picked.map((p, i) => (
                <span key={i} className="inline-flex items-center gap-1 border border-[var(--leon-line)] rounded px-2 py-1 text-[11px]">
                  {p.url && <img src={p.thumb || p.url} alt="" className="w-6 h-6 object-cover rounded" />}
                  {p.name}
                  <button className="text-[var(--leon-red)]" onClick={() => setPicked(picked.filter((_, j) => j !== i))}>✕</button>
                </span>
              ))}
            </div>
          </div>
        )}

        {kind === 'comparison' && (
          <div className="mt-4 space-y-3">
            {options.map((o, i) => (
              <div key={i} className="border border-[var(--leon-line)] rounded-lg p-2 grid sm:grid-cols-4 gap-2">
                <Field label="Name"><TextInput value={o.name} onChange={e => setOptions(options.map((x, j) => j === i ? Object.assign({}, x, { name: e.target.value }) : x))} /></Field>
                <Field label="Price" hint={sc.ctx.canSeeFin ? '' : 'Hidden at your permission level'}>
                  <TextInput value={o.price} onChange={e => setOptions(options.map((x, j) => j === i ? Object.assign({}, x, { price: e.target.value }) : x))} /></Field>
                <Field label="Lead time"><TextInput value={o.lead} onChange={e => setOptions(options.map((x, j) => j === i ? Object.assign({}, x, { lead: e.target.value }) : x))} /></Field>
                <Field label="Image">
                  <Button size="sm" variant="outline" onClick={() => setOptionFor(i)}>{o.ref ? 'Change' : 'Choose'}</Button>
                </Field>
              </div>
            ))}
            {options.length < 3 && <Button size="sm" variant="outline" onClick={() => setOptions(options.concat([{ name: 'Option C', price: '', lead: '', notes: '', ref: null }]))}>+ Third option</Button>}
          </div>
        )}
      </Modal>
      <OfficeWordAssetPicker open={picking} onClose={() => setPicking(false)} sc={sc}
        onPick={r => setPicked(picked.concat([r]))} title="Pick a render" />
      <OfficeWordAssetPicker open={optionFor !== null} onClose={() => setOptionFor(null)} sc={sc}
        onPick={r => setOptions(options.map((x, j) => j === optionFor ? Object.assign({}, x, { ref: r }) : x))} />
    </>
  );
}

// ── Present mode ──────────────────────────────────────────────────────────
function OfficeSlidesPresent({ body, T, theme, master, startIndex, onExit }) {
  const slides = (body.slides || []).filter(s => !s.hidden);
  // The number the audience sees is the slide's position among the ones that
  // are actually shown, which is why hidden slides are dropped BEFORE numbering.
  const numbers = officeSlidesNumbers(body.slides || []);
  const [i, setI] = useState(() => {
    const target = (body.slides || [])[startIndex];
    const at = slides.indexOf(target);
    return at >= 0 ? at : 0;
  });
  const [presenter, setPresenter] = useState(false);
  useEffect(() => {
    function key(e) {
      if (e.key === 'Escape') { onExit(); return; }
      if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); setI(x => Math.min(slides.length - 1, x + 1)); }
      if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); setI(x => Math.max(0, x - 1)); }
      if (e.key === 'Home') setI(0);
      if (e.key === 'End') setI(slides.length - 1);
      if (e.key === 'p' || e.key === 'P') setPresenter(v => !v);
      if (e.key === 'f' || e.key === 'F') {
        const el = document.documentElement;
        if (!document.fullscreenElement && el.requestFullscreen) el.requestFullscreen().catch(() => {});
        else if (document.exitFullscreen) document.exitFullscreen().catch(() => {});
      }
    }
    window.addEventListener('keydown', key);
    return () => window.removeEventListener('keydown', key);
  }, [slides.length, onExit]);

  const slide = slides[i];
  const next = slides[i + 1];
  const trans = (slide && slide.transition) || 'none';
  return (
    <div className="fixed inset-0 z-[60] bg-black flex flex-col no-print">
      <div className="flex-1 flex items-center justify-center p-3 min-h-0">
        {presenter ? (
          <div className="grid lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] gap-4 w-full max-w-[1500px]">
            <div key={slide && slide.id} className={trans === 'fade' ? 'slide-fade' : trans === 'slide' ? 'slide-in' : ''}>
              <OfficeSlidesCanvas slide={slide} T={T} theme={theme} master={master}
                number={slide ? numbers.map[slide.id] : null} ratio={body.size} editable={false} selectedIds={[]}
                onSelect={() => {}} onApply={() => {}} present />
            </div>
            <div className="text-white space-y-3 min-w-0">
              <div className="text-[11px] uppercase tracking-widest opacity-50">Next slide</div>
              {next ? (
                <div className="opacity-80"><OfficeSlidesCanvas slide={next} T={T} theme={theme} master={master}
                  number={numbers.map[next.id]} ratio={body.size} editable={false} selectedIds={[]} onSelect={() => {}} onApply={() => {}} present /></div>
              ) : <div className="text-xs opacity-50 border border-white/20 rounded p-4">End of deck</div>}
              <div className="text-[11px] uppercase tracking-widest opacity-50">Speaker notes</div>
              <div className="text-sm whitespace-pre-wrap max-h-64 overflow-y-auto">{(slide && slide.notes) || <span className="opacity-40">No notes on this slide.</span>}</div>
            </div>
          </div>
        ) : (
          <div className="w-full max-w-[1500px]" key={slide && slide.id}>
            <div className={trans === 'fade' ? 'slide-fade' : trans === 'slide' ? 'slide-in' : ''}>
              <OfficeSlidesCanvas slide={slide} T={T} theme={theme} master={master}
                number={slide ? numbers.map[slide.id] : null} ratio={body.size} editable={false} selectedIds={[]}
                onSelect={() => {}} onApply={() => {}} present />
            </div>
          </div>
        )}
      </div>
      <div className="flex items-center justify-between px-4 py-2 text-white/70 text-xs">
        <span>{i + 1} / {slides.length}{slide && slide.section ? ' · ' + slide.section : ''}</span>
        <span className="flex items-center gap-3">
          <span className="hidden sm:inline opacity-60">← → to move · P for presenter view · F for full screen · Esc to leave</span>
          <button onClick={() => setI(Math.max(0, i - 1))} className="px-2 py-1 rounded border border-white/25">Prev</button>
          <button onClick={() => setI(Math.min(slides.length - 1, i + 1))} className="px-2 py-1 rounded border border-white/25">Next</button>
          <button onClick={onExit} className="px-2 py-1 rounded border border-white/25">Exit</button>
        </span>
      </div>
      <style>{`
        .slide-fade { animation: officeSlidesFade .28s ease; }
        .slide-in { animation: officeSlidesIn .28s ease; }
        @keyframes officeSlidesFade { from { opacity: 0 } to { opacity: 1 } }
        @keyframes officeSlidesIn { from { opacity: 0; transform: translateX(3%) } to { opacity: 1; transform: none } }
      `}</style>
    </div>
  );
}

// ── The slide master ──────────────────────────────────────────────────────
// makeSlideMaster (data.jsx) is what every slide inherits: the theme, the
// furniture and the background. Without one, "change it on every slide" means
// opening every slide. A slide may still override any of the three furniture
// switches locally, and the inspector says which values are inherited and
// which are set here — the same distinction LEON Casework draws between a
// derived part and one somebody typed.
function OfficeSlidesMasterModal({ open, onClose, master, theme, editable, onSet, slideCount }) {
  const T = officeSlidesPalette(theme, master);
  return (
    <Modal open={open} onClose={onClose} wide title="Slide master">
      <p className="text-xs text-[var(--leon-black)]/55 mb-3">
        Everything here applies to all <strong>{slideCount}</strong> slide{slideCount === 1 ? '' : 's'} that have not
        overridden it. Change it once and the deck changes.
      </p>

      <div className="border border-[var(--leon-line)] rounded-lg overflow-hidden mb-4">
        <div className="p-4" style={{ background: T.bg, fontFamily: T.font }}>
          <div style={{ color: T.title, fontFamily: T.fontMajor, fontSize: 22, fontWeight: 700 }}>Title on the master</div>
          <div style={{ color: T.ink, fontSize: 13, marginTop: 4 }}>Body text on the master, in the theme’s reading face.</div>
          <div className="flex justify-between mt-4" style={{ color: T.muted, fontSize: 10 }}>
            <span>{master.showDate ? (master.dateText || fmtDate(todayISO())) : ''}</span>
            <span>{master.showFooter ? (master.footerText || 'Footer') : ''}</span>
            <span>{master.showSlideNumber ? '7' : ''}</span>
          </div>
        </div>
      </div>

      {!editable && <div className="text-xs text-[var(--leon-black)]/50">You can read the master but not change it.</div>}

      {editable && (
        <div className="space-y-4">
          <div className="grid sm:grid-cols-3 gap-3">
            <Field label="Background" hint="A theme slot, so it moves with the theme.">
              <Select value={master.background || 'lt1'} onChange={e => onSet({ background: e.target.value })}>
                {OFFICE_THEME_SLOTS.map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
              </Select>
            </Field>
            <Field label="Title colour">
              <Select value={master.titleColor || 'dk2'} onChange={e => onSet({ titleColor: e.target.value })}>
                {OFFICE_THEME_SLOTS.map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
              </Select>
            </Field>
            <Field label="Body colour">
              <Select value={master.bodyColor || 'dk1'} onChange={e => onSet({ bodyColor: e.target.value })}>
                {OFFICE_THEME_SLOTS.map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
              </Select>
            </Field>
          </div>

          <div className="border-t border-[var(--leon-line)] pt-3">
            <div className="text-[11px] uppercase tracking-wide font-semibold text-[var(--leon-black)]/45 mb-2">
              Slide furniture
            </div>
            <div className="space-y-2">
              <label className="flex items-center gap-2 text-sm">
                <input type="checkbox" checked={!!master.showDate} onChange={e => onSet({ showDate: e.target.checked })} /> Date
              </label>
              {master.showDate && (
                <Field label="Date text" hint="Leave blank to print today’s date. There is no auto-updating field here — a deck is presented on a day, and saying which day is a choice.">
                  <TextInput value={master.dateText || ''} onChange={e => onSet({ dateText: e.target.value })}
                    placeholder={fmtDate(todayISO())} />
                </Field>
              )}
              <label className="flex items-center gap-2 text-sm">
                <input type="checkbox" checked={!!master.showFooter} onChange={e => onSet({ showFooter: e.target.checked })} /> Footer
              </label>
              {master.showFooter && (
                <Field label="Footer text">
                  <TextInput value={master.footerText || ''} onChange={e => onSet({ footerText: e.target.value })}
                    placeholder="e.g. LEON Integra — confidential" />
                </Field>
              )}
              <label className="flex items-center gap-2 text-sm">
                <input type="checkbox" checked={!!master.showSlideNumber} onChange={e => onSet({ showSlideNumber: e.target.checked })} /> Slide number
              </label>
              <label className="flex items-center gap-2 text-sm">
                <input type="checkbox" checked={!!master.showOnTitleSlide} onChange={e => onSet({ showOnTitleSlide: e.target.checked })} /> Show furniture on the title slide
              </label>
            </div>
            <div className="text-[11px] text-[var(--leon-black)]/45 leading-relaxed mt-2">
              The slide number is the slide’s position among the ones that will actually be shown: <strong>hidden slides
              are not counted and the numbers after them close up</strong>, and reordering renumbers by itself because
              the number is never stored on a slide. Sections group the deck; they do not restart the count, because the
              audience is looking at one continuous run of slides.
            </div>
          </div>
        </div>
      )}
    </Modal>
  );
}

// ═══════════════════════════════ LEON PRESENTATION editor ═════════════════
function OfficeSlidesEditor({ ctx, doc, onChange, editable }) {
  const body = officeSlidesBody(doc);
  const sc = officeWordScopeOf(ctx, doc);
  // Three layers, and they are different things: the THEME is twelve colours
  // and two fonts, the MASTER says which of them the deck uses and what
  // furniture it carries, and T is the palette the canvas draws with, derived
  // from both.
  const theme = officeThemeOf(body);
  const master = body.master;
  const T = officeSlidesPalette(theme, master);
  const numbers = officeSlidesNumbers(body.slides || []);
  const canEditDoc = !!editable;
  const [idx, setIdx] = useState(0);
  const [sel, setSel] = useState([]);
  const [modal, setModal] = useState(null);
  const [imageFor, setImageFor] = useState(null);
  const [presenting, setPresenting] = useState(false);
  const [dragFrom, setDragFrom] = useState(null);
  const pdfRef = useRef(null);

  const slides = body.slides;
  const slide = slides[Math.min(idx, Math.max(0, slides.length - 1))] || null;
  const selected = slide ? (slide.elements || []).filter(e => sel.includes(e.id)) : [];
  const one = selected.length === 1 ? selected[0] : null;

  function emit(changes) {
    onChange(Object.assign({}, doc, changes, {
      modifiedDate: todayISO(), modifiedBy: ctx.currentUserName || doc.modifiedBy || '',
    }));
  }
  function setBody(partial) { emit({ body: Object.assign({}, body, partial) }); }
  function mutateSlides(fn) { const next = cloneDeep(slides); fn(next); setBody({ slides: next }); }
  function setSlide(fields) { mutateSlides(next => { if (next[idx]) Object.assign(next[idx], fields); }); }
  function applyEls(map) {
    mutateSlides(next => {
      const s = next[idx];
      if (!s) return;
      Object.keys(map).forEach(id => {
        const el = (s.elements || []).find(e => e.id === id);
        if (el) Object.assign(el, map[id]);
      });
    });
  }
  function addElement(el) {
    mutateSlides(next => {
      const s = next[idx];
      if (!s) return;
      el.z = ((s.elements || []).reduce((m, e) => Math.max(m, e.z || 1), 0)) + 1;
      s.elements.push(el);
    });
    setSel([el.id]);
  }
  function removeSelected() {
    mutateSlides(next => {
      const s = next[idx];
      if (s) s.elements = (s.elements || []).filter(e => !sel.includes(e.id));
    });
    setSel([]);
  }
  function addSlide(s) {
    mutateSlides(next => next.splice(idx + 1, 0, s));
    setIdx(idx + 1); setSel([]);
  }
  function pushVersion(note) { return officeWordCaptureVersion(doc, note, ctx.currentUserName); }
  // Applying a theme restyles the WHOLE deck — that is the point of a theme —
  // and it is ONE act with a version taken first, so it can be undone.
  //
  // Most of the work is done by not doing it: a colour that names a theme slot
  // resolves through the new theme with nothing rewritten. The pass below only
  // catches elements carrying a stored HEX that was itself derived from the old
  // theme (themed: true). An element whose colour somebody chose by hand has
  // themed: false and is left exactly alone — a theme change must never
  // silently undo a deliberate decision.
  function applyTheme(themeId) {
    const nextTheme = officeThemeList(body).find(t => t.id === themeId);
    if (!nextTheme) return;
    const nextT = officeSlidesPalette(nextTheme, master);
    const versions = pushVersion('Before applying the ' + nextTheme.name + ' theme');
    const next = cloneDeep(slides);
    next.forEach(s => {
      (s.elements || []).forEach(el => {
        if (!el.themed) return;
        if (el.kind === 'text' && el.color && !officeThemeIsSlot(el.color)) el.color = null;   // back to the role colour
        if (el.kind === 'shape') {
          if (el.fill && !officeThemeIsSlot(el.fill)) el.fill = officeSlidesLayoutKey(s.layout) === 'secHead' ? nextT.accent : nextT.panel;
          if (el.stroke && !officeThemeIsSlot(el.stroke)) el.stroke = nextT.rule;
        }
      });
      if (s.bg && !officeThemeIsSlot(s.bg)) s.bg = null;
    });
    // makeSlideMaster carries a themeId of its own; the deck's is authoritative
    // and the master's mirrors it, so the master never says one thing while the
    // deck draws another.
    emit({ body: Object.assign({}, body, { themeId, slides: next, master: Object.assign({}, master, { themeId }) }), versions });
  }
  function setMaster(fields) { setBody({ master: Object.assign({}, master, fields) }); }
  // A furniture switch on a slide: undefined means "inherit", anything else is
  // set here. Clearing it returns the slide to the master rather than freezing
  // whatever the master happened to say at the time.
  function setSlideFurniture(kind, field, value) {
    const f = SLIDE_FURNITURE_FIELDS[kind];
    const key = field === 'text' ? f.text : f.show;
    if (!key) return;
    mutateSlides(next => {
      const s = next[idx];
      if (!s) return;
      const fur = Object.assign({}, s.furniture || {});
      if (value === undefined) delete fur[key]; else fur[key] = value;
      s.furniture = fur;
    });
  }
  function zOrder(mode) {
    mutateSlides(next => {
      const s = next[idx];
      if (!s) return;
      const zs = (s.elements || []).map(e => e.z || 1);
      const top = Math.max.apply(null, zs.concat([1]));
      const bottom = Math.min.apply(null, zs.concat([1]));
      (s.elements || []).forEach(e => {
        if (!sel.includes(e.id)) return;
        if (mode === 'front') e.z = top + 1;
        else if (mode === 'back') e.z = bottom - 1;
        else if (mode === 'forward') e.z = (e.z || 1) + 1;
        else e.z = (e.z || 1) - 1;
      });
    });
  }
  function alignSelected(mode) {
    if (selected.length < 2) return;
    const minX = Math.min.apply(null, selected.map(e => e.x));
    const maxX = Math.max.apply(null, selected.map(e => e.x + e.w));
    const minY = Math.min.apply(null, selected.map(e => e.y));
    const maxY = Math.max.apply(null, selected.map(e => e.y + e.h));
    const map = {};
    if (mode === 'distH' || mode === 'distV') {
      const sorted = selected.slice().sort((a, b) => (mode === 'distH' ? a.x - b.x : a.y - b.y));
      const span = mode === 'distH' ? maxX - minX : maxY - minY;
      const totalSize = sorted.reduce((s, e) => s + (mode === 'distH' ? e.w : e.h), 0);
      const gap = (span - totalSize) / Math.max(1, sorted.length - 1);
      let cursor = mode === 'distH' ? minX : minY;
      sorted.forEach(e => {
        map[e.id] = mode === 'distH' ? { x: Math.round(cursor * 10) / 10 } : { y: Math.round(cursor * 10) / 10 };
        cursor += (mode === 'distH' ? e.w : e.h) + gap;
      });
    } else {
      selected.forEach(e => {
        if (mode === 'left') map[e.id] = { x: minX };
        else if (mode === 'right') map[e.id] = { x: maxX - e.w };
        else if (mode === 'centerH') map[e.id] = { x: (minX + maxX) / 2 - e.w / 2 };
        else if (mode === 'top') map[e.id] = { y: minY };
        else if (mode === 'bottom') map[e.id] = { y: maxY - e.h };
        else if (mode === 'centerV') map[e.id] = { y: (minY + maxY) / 2 - e.h / 2 };
      });
    }
    applyEls(map);
  }

  const printLines = [
    sc.project ? sc.project.name + ' · ' + sc.project.projectNumber : null,
    sc.account ? sc.account.name : null,
    slides.length + ' slide' + (slides.length === 1 ? '' : 's'),
  ].filter(Boolean);

  return (
    <div className="space-y-3">
      <div className="no-print flex flex-wrap items-center gap-2 justify-between">
        <div className="flex items-center gap-2 min-w-0">
          <span className="text-lg">🖼️</span>
          <div className="min-w-0">
            <div className="font-bold text-sm truncate">{doc.name || 'Untitled'}</div>
            <div className="text-[11px] text-[var(--leon-black)]/45 truncate">
              {slides.length} slide{slides.length === 1 ? '' : 's'} · {theme.name} · {body.size} · {officeWordFmtBytes(officeWordDocBytes(doc))}
            </div>
          </div>
        </div>
        <div className="flex flex-wrap items-center gap-1.5">
          <IconAction icon="🖨" title="Print the deck as a document"
            onClick={() => printRegion(pdfRef.current, { title: doc.name, heading: doc.name, lines: printLines })} />
          <IconAction icon="📄" title="Download the deck as a PDF"
            onClick={() => exportPdf(pdfRef.current, { title: doc.name, heading: doc.name, lines: printLines })} />
        </div>
      </div>

      {/* PowerPoint's tab set. Present stays pinned to the right of the tab
          strip rather than living inside a tab — it is the one command you
          reach for from wherever you happen to be, which is why PowerPoint
          gives it the F5 key and a permanent button of its own. */}
      <OfficeRibbon appKey="slides"
        right={<Button size="sm" variant="black" disabled={!slides.length} onClick={() => setPresenting(true)}>▶ Present</Button>}
        tabs={[
          { key: 'home', label: 'Home', groups: [
            { label: 'Slides', items: canEditDoc ? <>
              <Button size="sm" variant="outline" onClick={() => setModal('layout')}>+ Slide</Button>
            </> : <span className="text-[11px] text-[var(--leon-black)]/40">Read only</span> },
          ] },
          { key: 'insert', label: 'Insert', groups: [
            { label: 'From LEON', items: canEditDoc ? <>
              <Button size="sm" variant="outline" onClick={() => setModal('content')}>+ LEON slide</Button>
            </> : <span className="text-[11px] text-[var(--leon-black)]/40">Read only</span> },
          ] },
          { key: 'design', label: 'Design', groups: [
            { label: 'Theme', items: <>
              <Button size="sm" variant="outline" onClick={() => setModal('theme')} title={'Theme: ' + theme.name}>{theme.name}</Button>
              <Button size="sm" variant="outline" onClick={() => setModal('master')}>Master</Button>
            </> },
            { label: 'Slide size', items: canEditDoc ? <>
              <Select value={body.size} onChange={e => setBody({ size: e.target.value })} className="!w-auto !py-1 text-xs">
                <option value="16:9">16 : 9</option>
                <option value="4:3">4 : 3</option>
              </Select>
            </> : <span className="text-[11px] text-[var(--leon-black)]/40">{body.size}</span> },
          ] },
        ]} />

      <div className="grid lg:grid-cols-[180px_minmax(0,1fr)_270px] gap-4 items-start">
        {/* ── Sorter ──────────────────────────────────────────────────── */}
        <div className="no-print order-2 lg:order-1">
          <div className="text-[11px] uppercase tracking-wide font-semibold text-[var(--leon-black)]/45 mb-2">Slides</div>
          <div className="space-y-2 max-h-[70vh] overflow-y-auto pr-1">
            {slides.map((s, i) => (
              <div key={s.id}
                draggable={canEditDoc}
                onDragStart={() => setDragFrom(i)}
                onDragOver={e => e.preventDefault()}
                onDrop={() => {
                  if (dragFrom === null || dragFrom === i) return;
                  mutateSlides(next => { const [m] = next.splice(dragFrom, 1); next.splice(i, 0, m); });
                  setIdx(i); setDragFrom(null);
                }}
                onClick={() => { setIdx(i); setSel([]); }}
                className={`border rounded-lg overflow-hidden cursor-pointer ${i === idx ? 'border-[var(--leon-brown)] ring-1 ring-[var(--leon-brown-light)]' : 'border-[var(--leon-line)]'} ${s.hidden ? 'opacity-45' : ''}`}>
                <div className="pointer-events-none">
                  <OfficeSlidesCanvas slide={s} T={T} theme={theme} master={master} number={numbers.map[s.id]}
                    ratio={body.size} editable={false} selectedIds={[]}
                    onSelect={() => {}} onApply={() => {}} />
                </div>
                <div className="flex items-center justify-between px-1.5 py-1 text-[10px] bg-white">
                  <span className="truncate">
                    {numbers.map[s.id] || '–'}. {s.name || officeSlidesLayout(s.layout).name}
                    {s.section ? <span className="text-[var(--leon-black)]/40"> · {s.section}</span> : null}
                  </span>
                  {canEditDoc && (
                    <span className="flex gap-1 shrink-0">
                      <button title="Duplicate" onClick={e => { e.stopPropagation(); mutateSlides(next => { const c = cloneDeep(s); c.id = uid('sld'); (c.elements || []).forEach(el => { el.id = uid('sel'); }); next.splice(i + 1, 0, c); }); }}>⧉</button>
                      <button title={s.hidden ? 'Show in the presentation' : 'Hide from the presentation'}
                        onClick={e => { e.stopPropagation(); mutateSlides(next => { next[i].hidden = !next[i].hidden; }); }}>{s.hidden ? '🙈' : '👁'}</button>
                      <button title="Delete" onClick={e => { e.stopPropagation(); mutateSlides(next => next.splice(i, 1)); setIdx(Math.max(0, i - 1)); }}>✕</button>
                    </span>
                  )}
                </div>
              </div>
            ))}
            {!slides.length && (
              <div className="text-xs text-[var(--leon-black)]/45 italic">
                No slides yet.
                {canEditDoc && <Button size="sm" className="mt-2 w-full" onClick={() => setModal('layout')}>Add the first slide</Button>}
              </div>
            )}
          </div>
        </div>

        {/* ── Canvas ──────────────────────────────────────────────────── */}
        <div className="order-1 lg:order-2 min-w-0">
          {canEditDoc && slide && (
            <div className="no-print flex flex-wrap items-center gap-1 mb-2 border border-[var(--leon-line)] rounded-lg bg-white px-2 py-1.5">
              <OfficeWordToolBtn wide title="Text box" onClick={() => addElement(officeSlidesText('Text', { x: 12, y: 40, w: 40, h: 12 }))}>T</OfficeWordToolBtn>
              <OfficeWordToolBtn wide title="Image" onClick={() => { const el = officeSlidesEl('image', { x: 20, y: 25, w: 40, h: 40, fit: 'cover' }); addElement(el); setImageFor(el.id); }}>🖼</OfficeWordToolBtn>
              <OfficeWordToolBtn wide title="Rectangle" onClick={() => addElement(officeSlidesEl('shape', { shape: 'rect', x: 20, y: 30, w: 30, h: 20, fill: 'accent1', stroke: '', strokeWidth: 0, radius: 3 }))}>▭</OfficeWordToolBtn>
              <OfficeWordToolBtn wide title="Ellipse" onClick={() => addElement(officeSlidesEl('shape', { shape: 'ellipse', x: 25, y: 30, w: 24, h: 24, fill: 'accent2', stroke: '', strokeWidth: 0 }))}>◯</OfficeWordToolBtn>
              <OfficeWordToolBtn wide title="Line" onClick={() => addElement(officeSlidesEl('shape', { shape: 'line', x: 20, y: 50, w: 50, h: 4, stroke: 'accent1', strokeWidth: 2 }))}>／</OfficeWordToolBtn>
              <OfficeWordToolBtn wide title="Arrow" onClick={() => addElement(officeSlidesEl('shape', { shape: 'arrow', x: 20, y: 50, w: 50, h: 5, stroke: 'accent1', strokeWidth: 2 }))}>➜</OfficeWordToolBtn>
              <OfficeWordToolBtn wide title="Table" onClick={() => addElement(officeSlidesEl('table', { x: 12, y: 25, w: 70, h: 40, headerRow: true, size: 11, rows: [['Column', 'Column'], ['', ''], ['', '']] }))}>▦</OfficeWordToolBtn>
              <OfficeWordToolBtn wide title="Chart" onClick={() => addElement(officeSlidesEl('chart', { x: 20, y: 25, w: 58, h: 50, chartType: 'bar', title: 'Chart', series: [{ label: 'A', value: 3 }, { label: 'B', value: 5 }, { label: 'C', value: 2 }] }))}>📊</OfficeWordToolBtn>
              <OfficeWordToolBtn wide title="LEON logo" onClick={() => addElement(officeSlidesEl('logo', { variant: 'wordmark', x: 74, y: 84, w: 18, h: 8 }))}>LEON</OfficeWordToolBtn>
              <OfficeWordDivider />
              <OfficeWordToolBtn title="Bring to front" disabled={!selected.length} onClick={() => zOrder('front')}>⤒</OfficeWordToolBtn>
              <OfficeWordToolBtn title="Send to back" disabled={!selected.length} onClick={() => zOrder('back')}>⤓</OfficeWordToolBtn>
              <OfficeWordToolBtn title="Forward" disabled={!selected.length} onClick={() => zOrder('forward')}>↑</OfficeWordToolBtn>
              <OfficeWordToolBtn title="Backward" disabled={!selected.length} onClick={() => zOrder('backward')}>↓</OfficeWordToolBtn>
              <OfficeWordDivider />
              {[['left', '⯇'], ['centerH', '↔'], ['right', '⯈'], ['top', '⯅'], ['centerV', '↕'], ['bottom', '⯆'], ['distH', '⇹'], ['distV', '⇳']].map(a => (
                <OfficeWordToolBtn key={a[0]} title={'Align / distribute: ' + a[0]} disabled={selected.length < 2}
                  onClick={() => alignSelected(a[0])}>{a[1]}</OfficeWordToolBtn>
              ))}
              <OfficeWordDivider />
              <OfficeWordToolBtn title="Delete the selected element" disabled={!selected.length} onClick={removeSelected}>✕</OfficeWordToolBtn>
            </div>
          )}
          <OfficeSlidesCanvas slide={slide} T={T} theme={theme} master={master}
            number={slide ? numbers.map[slide.id] : null} ratio={body.size} editable={canEditDoc}
            selectedIds={sel} onSelect={setSel} onApply={applyEls} onPickImage />
          {slide && (
            <div className="no-print mt-8">
              <Field label="Speaker notes">
                <TextArea rows={3} value={slide.notes || ''} disabled={!canEditDoc}
                  onChange={e => setSlide({ notes: e.target.value })}
                  placeholder="What you will say over this slide. Shown in presenter view, never on the slide." />
              </Field>
            </div>
          )}
        </div>

        {/* ── Inspector ───────────────────────────────────────────────── */}
        <div className="no-print order-3 space-y-3 text-sm">
          {slide && canEditDoc && (
            <div className="border border-[var(--leon-line)] rounded-lg p-3 space-y-2 bg-white">
              <div className="text-[11px] uppercase tracking-wide font-semibold text-[var(--leon-black)]/45">This slide</div>
              <Field label="Name"><TextInput value={slide.name || ''} onChange={e => setSlide({ name: e.target.value })} /></Field>
              <Field label="Section" hint="Slides sharing a section are grouped in the sorter and in presenter view.">
                <TextInput value={slide.section || ''} onChange={e => setSlide({ section: e.target.value })} /></Field>
              <Field label="Transition">
                <Select value={slide.transition || 'none'} onChange={e => setSlide({ transition: e.target.value })}>
                  {SLIDE_TRANSITIONS.map(t => <option key={t.key} value={t.key}>{t.label}</option>)}
                </Select>
              </Field>
              <div className="text-[10px] text-[var(--leon-black)]/40">
                Three transitions only, on purpose. A construction deck does not need more, and heavy animation is what
                makes a deck look amateur.
              </div>

              {/* Layout. Changing it re-seats the placeholders this layout owns
                  and adds the ones it is missing; nothing already on the slide
                  is removed, because a layout is a starting point and the slide
                  is the work. */}
              <Field label="Layout">
                <Select value={officeSlidesLayoutKey(slide.layout)}
                  onChange={e => mutateSlides(next => { if (next[idx]) officeSlidesApplyLayout(next[idx], e.target.value); })}>
                  {officeSlidesLayoutList().map(l => <option key={l.key} value={l.key}>{l.name}</option>)}
                </Select>
              </Field>
              {(SLIDE_LAYOUT_META[officeSlidesLayoutKey(slide.layout)] || {}).note ? (
                <div className="text-[10px] text-[var(--leon-black)]/45 leading-relaxed">
                  {SLIDE_LAYOUT_META[officeSlidesLayoutKey(slide.layout)].note}
                </div>
              ) : null}
              <Button size="sm" variant="outline" className="w-full"
                title="Put back any placeholder this layout owns that is no longer on the slide. Nothing is removed."
                onClick={() => mutateSlides(next => { if (next[idx]) officeSlidesApplyLayout(next[idx], next[idx].layout); })}>
                Restore this layout’s placeholders
              </Button>
              {(() => {
                const empties = (slide.elements || []).filter(officeSlidesPhEmpty).length;
                return empties ? (
                  <div className="text-[10px] text-[var(--leon-black)]/45">
                    {empties} placeholder{empties === 1 ? '' : 's'} still empty. {empties === 1 ? 'It does' : 'They do'} not
                    appear when the deck is presented, printed or exported.
                  </div>
                ) : null;
              })()}
            </div>
          )}

          {/* Furniture on THIS slide, against the master. The badge says which
              values are inherited and which were set here, and ↺ hands one back
              to the master rather than freezing today's answer. */}
          {slide && canEditDoc && (
            <div className="border border-[var(--leon-line)] rounded-lg p-3 space-y-2 bg-white">
              <div className="flex items-center justify-between">
                <span className="text-[11px] uppercase tracking-wide font-semibold text-[var(--leon-black)]/45">Furniture</span>
                <button className="text-[11px] underline hover:text-[var(--leon-brown)]" onClick={() => setModal('master')}>Edit the master</button>
              </div>
              {!officeSlidesFurnitureOn(master, slide) && (
                <div className="text-[10px] text-[var(--leon-brown)]">
                  This is a title slide and the master is set not to show furniture on it.
                </div>
              )}
              {officeSlidesFurnitureKeys().map(kind => {
                const f = SLIDE_FURNITURE_FIELDS[kind];
                if (!f) return null;
                const on = officeSlidesFurnitureValue(master, slide, kind, 'show');
                const setHere = officeSlidesFurnitureIsSet(slide, kind, 'show');
                return (
                  <div key={kind} className="flex items-center gap-2">
                    <label className="flex items-center gap-1.5 text-xs flex-1 min-w-0">
                      <input type="checkbox" checked={!!on} onChange={e => setSlideFurniture(kind, 'show', e.target.checked)} />
                      <span className="truncate">{f.label}</span>
                    </label>
                    <span className={`text-[9px] uppercase tracking-wide shrink-0 ${setHere ? 'text-[var(--leon-brown)] font-semibold' : 'text-[var(--leon-black)]/35'}`}>
                      {setHere ? 'set here' : 'master'}
                    </span>
                    {setHere && (
                      <button title="Go back to what the master says" className="text-[11px] shrink-0"
                        onClick={() => setSlideFurniture(kind, 'show', undefined)}>↺</button>
                    )}
                  </div>
                );
              })}
              {officeSlidesFurnitureValue(master, slide, 'footer', 'show') && (
                <Field label="Footer on this slide"
                  hint={officeSlidesFurnitureIsSet(slide, 'footer', 'text') ? 'Set here — ↺ to use the master’s.' : 'Inherited from the master.'}>
                  <div className="flex gap-1.5">
                    <TextInput value={officeSlidesFurnitureValue(master, slide, 'footer', 'text') || ''}
                      onChange={e => setSlideFurniture('footer', 'text', e.target.value)} />
                    {officeSlidesFurnitureIsSet(slide, 'footer', 'text') && (
                      <button className="px-2 text-[11px]" title="Use the master’s footer"
                        onClick={() => setSlideFurniture('footer', 'text', undefined)}>↺</button>
                    )}
                  </div>
                </Field>
              )}
              <div className="text-[10px] text-[var(--leon-black)]/40 leading-relaxed">
                This slide is number <strong>{numbers.map[slide.id] || '—'}</strong> of {numbers.total} shown.
                Hidden slides are not counted.
              </div>
            </div>
          )}

          {one && canEditDoc && (
            <div className="border border-[var(--leon-line)] rounded-lg p-3 space-y-2 bg-white">
              <div className="text-[11px] uppercase tracking-wide font-semibold text-[var(--leon-black)]/45">
                {one.kind === 'text' ? 'Text box' : one.kind === 'image' ? 'Image' : one.kind === 'shape' ? 'Shape'
                  : one.kind === 'table' ? 'Table' : one.kind === 'chart' ? 'Chart' : 'Logo'}
              </div>
              <div className="grid grid-cols-4 gap-1.5">
                {['x', 'y', 'w', 'h'].map(k => (
                  <Field key={k} label={k.toUpperCase() + ' %'}>
                    <TextInput type="number" value={one[k]} onChange={e => applyEls({ [one.id]: { [k]: Number(e.target.value) } })} />
                  </Field>
                ))}
              </div>
              <Field label="Rotation °"><TextInput type="number" value={one.rot || 0}
                onChange={e => applyEls({ [one.id]: { rot: Number(e.target.value) } })} /></Field>

              {one.kind === 'text' && (
                <>
                  <div className="grid grid-cols-2 gap-1.5">
                    <Field label="Size"><TextInput type="number" value={one.size || 16}
                      onChange={e => applyEls({ [one.id]: { size: Number(e.target.value) } })} /></Field>
                    <Field label="Align">
                      <Select value={one.align || 'left'} onChange={e => applyEls({ [one.id]: { align: e.target.value } })}>
                        <option value="left">Left</option><option value="center">Centre</option><option value="right">Right</option>
                      </Select>
                    </Field>
                  </div>
                  <Field label="Vertical">
                    <Select value={one.valign || 'top'} onChange={e => applyEls({ [one.id]: { valign: e.target.value } })}>
                      <option value="top">Top</option><option value="middle">Middle</option><option value="bottom">Bottom</option>
                    </Select>
                  </Field>
                  <label className="flex items-center gap-2 text-xs">
                    <input type="checkbox" checked={!!one.bold} onChange={e => applyEls({ [one.id]: { bold: e.target.checked } })} /> Bold
                  </label>
                  <div>
                    <div className="text-[11px] font-semibold text-[var(--leon-black)]/60 mb-1">Colour</div>
                    {/* Theme slots first, and they are the answer that keeps
                        working: a slot follows the theme for ever. A fixed
                        colour below it never moves, which is sometimes exactly
                        what somebody wants. */}
                    <div className="flex flex-wrap gap-1 mb-1">
                      <button title="Follow the element’s role in the theme"
                        onClick={() => applyEls({ [one.id]: { color: null, themed: true } })}
                        className="w-5 h-5 rounded border border-[var(--leon-line)] bg-white text-[9px]">↺</button>
                      {OFFICE_THEME_SLOT_KEYS.slice(0, 10).map(k => (
                        <button key={k} title={'Theme slot: ' + k}
                          onClick={() => applyEls({ [one.id]: { color: k, themed: true } })}
                          className={`w-5 h-5 rounded border ${one.color === k ? 'border-[var(--leon-brown)] ring-1 ring-[var(--leon-brown)]' : 'border-[var(--leon-line)]'}`}
                          style={{ background: theme[k] }} />
                      ))}
                    </div>
                    <div className="flex flex-wrap gap-1">
                      {['#ffffff', '#161311', '#3a7d44', '#b83b3b', '#c99a2e', '#5a6b7d'].map(c => (
                        <button key={c} title={'A fixed colour: ' + c}
                          onClick={() => applyEls({ [one.id]: { color: c, themed: false } })}
                          className="w-5 h-5 rounded-full border border-[var(--leon-line)]" style={{ background: c }} />
                      ))}
                    </div>
                    <div className="text-[10px] text-[var(--leon-black)]/40 mt-1">
                      Squares follow the theme. Circles are fixed and never move.
                    </div>
                  </div>
                </>
              )}

              {one.kind === 'image' && (
                <>
                  <Button size="sm" variant="outline" className="w-full" onClick={() => setImageFor(one.id)}>
                    {one.ref ? 'Change image' : 'Choose an image'}
                  </Button>
                  <Field label="Fit">
                    <Select value={one.fit || 'cover'} onChange={e => applyEls({ [one.id]: { fit: e.target.value } })}>
                      <option value="cover">Fill the box (crop)</option>
                      <option value="contain">Fit inside the box</option>
                    </Select>
                  </Field>
                  {one.ref && one.ref.kind !== 'upload' && (
                    <div className="text-[10px] text-[var(--leon-black)]/45">
                      Linked to a {one.ref.kindLabel || one.ref.kind}: {one.ref.name}. The deck holds the reference, not a copy.
                    </div>
                  )}
                </>
              )}

              {one.kind === 'shape' && (
                <>
                  <Field label="Shape">
                    <Select value={one.shape} onChange={e => applyEls({ [one.id]: { shape: e.target.value } })}>
                      <option value="rect">Rectangle</option><option value="ellipse">Ellipse</option>
                      <option value="line">Line</option><option value="arrow">Arrow</option>
                    </Select>
                  </Field>
                  <div className="text-[11px] font-semibold text-[var(--leon-black)]/60">Fill</div>
                  <div className="flex flex-wrap gap-1 mb-1">
                    {OFFICE_THEME_SLOT_KEYS.slice(0, 10).map(k => (
                      <button key={k} title={'Theme slot: ' + k}
                        onClick={() => applyEls({ [one.id]: { fill: k, themed: true } })}
                        className={`w-5 h-5 rounded border ${one.fill === k ? 'border-[var(--leon-brown)] ring-1 ring-[var(--leon-brown)]' : 'border-[var(--leon-line)]'}`}
                        style={{ background: theme[k] }} />
                    ))}
                  </div>
                  <div className="flex flex-wrap gap-1">
                    {['#ffffff', '#161311', '#3a7d44', '#b83b3b', '#c99a2e', '#5a6b7d'].map(c => (
                      <button key={c} title={'A fixed colour: ' + c}
                        onClick={() => applyEls({ [one.id]: { fill: c, themed: false } })}
                        className="w-5 h-5 rounded-full border border-[var(--leon-line)]" style={{ background: c }} />
                    ))}
                  </div>
                  <div className="text-[10px] text-[var(--leon-black)]/40">Squares follow the theme; circles are fixed.</div>
                  <Field label="Border width"><TextInput type="number" min="0" value={one.strokeWidth || 0}
                    onChange={e => applyEls({ [one.id]: { strokeWidth: Number(e.target.value) } })} /></Field>
                </>
              )}

              {one.kind === 'logo' && (
                <Field label="Which mark">
                  <Select value={one.variant || 'wordmark'} onChange={e => applyEls({ [one.id]: { variant: e.target.value } })}>
                    <option value="wordmark">LEON wordmark</option>
                    <option value="mark">Lion mark</option>
                    <option value="official">Official lockup</option>
                  </Select>
                </Field>
              )}

              {one.kind === 'table' && <OfficeSlidesTableEditor el={one} onApply={f => applyEls({ [one.id]: f })} />}
              {one.kind === 'chart' && <OfficeSlidesChartEditor el={one} onApply={f => applyEls({ [one.id]: f })} />}
            </div>
          )}

          {selected.length > 1 && (
            <div className="border border-[var(--leon-line)] rounded-lg p-3 bg-white text-xs">
              {selected.length} elements selected. Use the align and distribute controls above the slide.
            </div>
          )}

          <div className="border border-[var(--leon-line)] rounded-lg p-3 bg-white">
            <div className="text-[11px] uppercase tracking-wide font-semibold text-[var(--leon-black)]/45 mb-1">Versions</div>
            {canEditDoc && <Button size="sm" variant="outline" className="w-full mb-1"
              onClick={() => emit({ versions: pushVersion('Saved by ' + (ctx.currentUserName || '')) })}>Save a version</Button>}
            <div className="text-[10px] text-[var(--leon-black)]/45 leading-relaxed">
              {(doc.versions || []).length || 'No'} saved. The full history and Restore are on the document's
              <strong> Versions</strong> tab. A snapshot is taken automatically before a theme is applied to the whole deck,
              because that is one act over every slide and has to be undoable.
            </div>
          </div>

          <OfficeSlidesLimitsNote />
        </div>
      </div>

      {/* The deck as a DOCUMENT, which is what the app's PDF writer can put
          real text into. Positioned off-screen rather than display:none so the
          clone the print path takes is complete. */}
      <div ref={pdfRef} data-print-region aria-hidden="true"
        style={{ position: 'absolute', left: -99999, top: 0, width: 900 }}>
        {slides.map((s, i) => {
          const fur = officeSlidesResolveFurniture(master, s, numbers.map[s.id]);
          return (
            <div key={s.id} style={{ marginBottom: 18 }}>
              <div className="lp-section-title">
                {'Slide ' + (numbers.map[s.id] || '–') + (s.name ? ' · ' + s.name : '') + (s.hidden ? ' · hidden' : '')}
              </div>
              {(s.elements || []).slice().sort((a, b) => a.y - b.y).map(el => {
                // An empty placeholder is a prompt on screen and nothing at
                // all on paper. "Click to add title" must never be printed.
                if (officeSlidesPhEmpty(el)) return null;
                if (el.kind === 'text') return <div key={el.id}>{officeWordPlain(el.html)}</div>;
                if (el.kind === 'image') return <div key={el.id}>{'[Image: ' + ((el.ref && el.ref.name) || 'not chosen') + ']'}</div>;
                if (el.kind === 'chart') return <div key={el.id}>{'[Chart: ' + (el.title || el.chartType) + ']'}</div>;
                if (el.kind === 'table') {
                  return (
                    <table key={el.id}><tbody>
                      {(el.rows || []).map((r, ri) => <tr key={ri}>{r.map((c, ci) => <td key={ci}>{c}</td>)}</tr>)}
                    </tbody></table>
                  );
                }
                return null;
              })}
              {(fur.date || fur.footer || fur.slideNumber)
                ? <div>{[fur.date, fur.footer, fur.slideNumber].filter(Boolean).join(' · ')}</div> : null}
              {s.notes ? <div>{'Notes: ' + s.notes}</div> : null}
            </div>
          );
        })}
      </div>

      {/* ── Modals ─────────────────────────────────────────────────────── */}
      {/* PowerPoint's own eleven, with its own names — SLIDE_LAYOUTS in
          data.jsx. A layout is its placeholder set, so the card says what the
          slide will actually arrive with. */}
      <Modal open={modal === 'layout'} onClose={() => setModal(null)} wide title="Add a slide">
        <div className="grid sm:grid-cols-3 gap-2">
          {officeSlidesLayoutList().map(l => {
            const meta = SLIDE_LAYOUT_META[l.key] || {};
            const ph = officeSlidesPhSpecs(l.key);
            return (
              <button key={l.key} onClick={() => { addSlide(officeSlidesMakeSlide(l.key, T, theme)); setModal(null); }}
                className="border border-[var(--leon-line)] rounded-lg px-3 py-3 text-center hover:border-[var(--leon-brown)] hover:bg-[var(--leon-cream)]">
                <div className="text-xl">{meta.icon || '▭'}</div>
                <div className="text-xs font-semibold mt-1">{l.name}</div>
                <div className="text-[10px] text-[var(--leon-black)]/45 mt-0.5">
                  {ph.length ? ph.length + ' placeholder' + (ph.length === 1 ? '' : 's') : 'nothing on it'}
                </div>
                {meta.note && <div className="text-[10px] text-[var(--leon-brown)] mt-1 leading-snug text-left">{meta.note}</div>}
              </button>
            );
          })}
        </div>
        <div className="mt-3 text-[11px] text-[var(--leon-black)]/45 leading-relaxed">
          A placeholder shows its prompt until it is filled and prints nothing while it is empty. Every layout carries the
          master’s date, footer and slide number — change those once on the master rather than on every slide.
        </div>
      </Modal>
      <OfficeSlidesContentModal open={modal === 'content'} onClose={() => setModal(null)} sc={sc} T={T}
        onAdd={s => addSlide(s)} />
      <OfficeWordAssetPicker open={!!imageFor} onClose={() => setImageFor(null)} sc={sc}
        onPick={ref => applyEls({ [imageFor]: { ref } })} />
      <OfficeThemeModal open={modal === 'theme'} onClose={() => setModal(null)} body={body} editable={canEditDoc}
        onApplyTheme={id => applyTheme(id)} onSaveThemes={list => setBody({ themes: list })}
        note={'Every element set to a theme slot follows the theme; one somebody coloured by hand does not.'} />
      <OfficeSlidesMasterModal open={modal === 'master'} onClose={() => setModal(null)} master={master} theme={theme}
        editable={canEditDoc} onSet={setMaster} slideCount={slides.length} />
      {presenting && <OfficeSlidesPresent body={body} T={T} theme={theme} master={master}
        startIndex={idx} onExit={() => setPresenting(false)} />}
    </div>
  );
}

function OfficeSlidesTableEditor({ el, onApply }) {
  const rows = el.rows || [];
  function setCell(r, c, v) {
    const next = cloneDeep(rows);
    next[r][c] = v;
    onApply({ rows: next });
  }
  return (
    <div className="space-y-1.5">
      <div className="flex gap-1">
        <Button size="sm" variant="outline" onClick={() => { const n = cloneDeep(rows); n.push(new Array(rows[0] ? rows[0].length : 2).fill('')); onApply({ rows: n }); }}>+ Row</Button>
        <Button size="sm" variant="outline" onClick={() => { const n = cloneDeep(rows); n.forEach(r => r.push('')); onApply({ rows: n }); }}>+ Col</Button>
        <Button size="sm" variant="outline" disabled={rows.length < 2} onClick={() => { const n = cloneDeep(rows); n.pop(); onApply({ rows: n }); }}>− Row</Button>
      </div>
      <label className="flex items-center gap-2 text-xs">
        <input type="checkbox" checked={!!el.headerRow} onChange={e => onApply({ headerRow: e.target.checked })} /> Header row
      </label>
      <div className="max-h-40 overflow-y-auto space-y-1">
        {rows.map((r, ri) => (
          <div key={ri} className="flex gap-1">
            {r.map((c, ci) => (
              <input key={ci} value={c} onChange={e => setCell(ri, ci, e.target.value)}
                className="flex-1 min-w-0 rounded border border-[var(--leon-line)] px-1 py-0.5 text-[11px]" />
            ))}
          </div>
        ))}
      </div>
    </div>
  );
}

function OfficeSlidesChartEditor({ el, onApply }) {
  const series = el.series || [];
  function set(i, k, v) {
    const next = cloneDeep(series);
    next[i][k] = k === 'value' ? Number(v) : v;
    onApply({ series: next });
  }
  return (
    <div className="space-y-1.5">
      <Field label="Chart type">
        <Select value={el.chartType || 'bar'} onChange={e => onApply({ chartType: e.target.value })}>
          <option value="bar">Bar</option><option value="line">Line</option><option value="pie">Pie</option>
        </Select>
      </Field>
      <Field label="Title"><TextInput value={el.title || ''} onChange={e => onApply({ title: e.target.value })} /></Field>
      <div className="space-y-1 max-h-40 overflow-y-auto">
        {series.map((s, i) => (
          <div key={i} className="flex gap-1">
            <input value={s.label} onChange={e => set(i, 'label', e.target.value)}
              className="flex-1 min-w-0 rounded border border-[var(--leon-line)] px-1 py-0.5 text-[11px]" />
            <input type="number" value={s.value} onChange={e => set(i, 'value', e.target.value)}
              className="w-16 rounded border border-[var(--leon-line)] px-1 py-0.5 text-[11px]" />
            <button className="text-[var(--leon-red)] text-[11px]"
              onClick={() => onApply({ series: series.filter((_, j) => j !== i) })}>✕</button>
          </div>
        ))}
      </div>
      <Button size="sm" variant="outline" onClick={() => onApply({ series: series.concat([{ label: 'New', value: 1 }]) })}>+ Point</Button>
      <div className="text-[10px] text-[var(--leon-black)]/40">
        Typed in, not connected. A chart that read a live LEON figure would belong in LEON Sheets, where the numbers live.
      </div>
    </div>
  );
}

function OfficeSlidesLimitsNote() {
  return (
    <div className="text-[10px] text-[var(--leon-black)]/45 leading-relaxed space-y-1 border border-[var(--leon-line)] rounded-lg p-3 bg-[var(--leon-cream)]">
      <div className="uppercase tracking-wide font-semibold text-[var(--leon-black)]/40">What this does not do</div>
      <div><strong>No live co-editing.</strong> There is no server behind the Hub; two people editing one deck at the same
        time would overwrite each other.</div>
      <div><strong>No .pptx import or export.</strong> That needs a converter the browser does not have. The eleven
        layouts, the placeholders, the furniture and the theme follow PowerPoint's MODEL — that is what makes a deck
        built here read as a normal deck to anyone who has used it — but matching the model is not reading the format,
        and a .pptx still cannot be opened or written here.</div>
      <div><strong>The two vertical layouts are real vertical text</strong>, set in the writing mode CJK uses: characters
        run down the column and columns run right to left. Latin letters stay upright and read downwards. That is what
        vertical writing does to them, and it is said on the layout rather than discovered.</div>
      <div><strong>A font is only a font that exists.</strong> A theme names two faces; the browser uses whichever is
        installed or shipped with the Hub and falls back through the stack. Naming a face does not fetch it.</div>
      <div><strong>PDF is a document, not screenshots.</strong> The export writes each slide's real text and tables through
        the app's own PDF writer, so it is selectable and searchable. It is deliberately not a picture of the slides —
        rasterising the screen is the thing that writer exists to stop doing. Present mode is where the deck is seen.</div>
      <div><strong>No AI.</strong> Nothing here writes or designs a slide for you.</div>
    </div>
  );
}

