<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>VerseDocs Template Studio</title>
<style>
  /* The HTML `hidden` attribute only hides an element because the browser's own
     stylesheet says [hidden] { display: none }. Any author rule that sets display
     beats it, and this file has four: .layout, .modal-backdrop, .gallery-list and
     .count are all display:flex/inline-block. The result was that `hidden` did
     nothing: the New Template modal was visible on load and could not be closed,
     because every close handler just sets .hidden = true. This rule restores the
     intended behaviour for every element, including any added later. */
  [hidden] { display: none !important; }

  :root {
    --navy: #1E3A5F;
    --navy-dark: #16293F;
    --navy-tint: #EAF0F6;
    --ink: #1B1E24;
    --ink-soft: #5B6472;
    --border: #E1E5EA;
    --surface: #FFFFFF;
    --bg: #F5F7FA;
    --good: #157F3C;
    --good-bg: #E6F6EB;
    --bad: #B3261E;
    --bad-bg: #FCEAE9;
    --warn: #8A5B00;
    --warn-bg: #FFF3DE;
    --radius: 8px;
    --shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 1px 3px rgba(16, 24, 40, 0.08);
  }
  * { box-sizing: border-box; }
  html, body { height: 100%; }
  body {
    margin: 0;
    font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, Arial, sans-serif;
    color: var(--ink);
    background: var(--bg);
    font-size: 13px;
    line-height: 1.45;
  }
  button { font-family: inherit; }
  h1, h2, h3 { margin: 0; font-weight: 600; }
  svg.icon { width: 16px; height: 16px; fill: currentColor; flex: none; vertical-align: -3px; }

  /* ---------- Shell ---------- */
  .app { display: flex; flex-direction: column; height: 100vh; min-height: 560px; }
  .topbar {
    background: var(--navy); color: #fff; padding: 10px 18px;
    display: flex; align-items: center; justify-content: space-between; flex: none;
    box-shadow: var(--shadow);
  }
  .brand { display: flex; align-items: center; gap: 10px; font-size: 15px; font-weight: 600; letter-spacing: .1px; }
  .brand svg { width: 20px; height: 20px; fill: #fff; }
  .brand .sub { font-weight: 400; opacity: .8; font-size: 12px; margin-left: 4px; }
  .btn {
    appearance: none; border: 1px solid transparent; border-radius: 6px; padding: 7px 13px;
    font-size: 12.5px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 6px;
    transition: background .12s ease, border-color .12s ease, color .12s ease;
  }
  .btn-primary { background: #fff; color: var(--navy); }
  .btn-primary:hover { background: #EDEFF2; }
  .btn-ghost { background: transparent; color: #fff; border-color: rgba(255,255,255,.5); }
  .btn-ghost:hover { background: rgba(255,255,255,.12); }
  .btn-solid { background: var(--navy); color: #fff; }
  .btn-solid:hover { background: var(--navy-dark); }
  .btn-solid:disabled, .btn-outline:disabled, .btn-primary:disabled { opacity: .45; cursor: default; }
  .btn-outline { background: #fff; color: var(--ink); border-color: var(--border); }
  .btn-outline:hover { background: #F2F4F7; }
  .btn-danger-outline { background: #fff; color: var(--bad); border-color: #F0C6C3; }
  .btn-danger-outline:hover { background: var(--bad-bg); }
  .btn-sm { padding: 4px 9px; font-size: 11.5px; }

  .layout { flex: 1; display: flex; min-height: 0; }

  /* ---------- Gallery (left) ---------- */
  .gallery { width: 280px; flex: none; background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; }
  .gallery-header { padding: 12px; border-bottom: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px; }
  .search-box { position: relative; }
  .search-box svg { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); width: 14px; height: 14px; color: var(--ink-soft); fill: currentColor; }
  .search-box input { width: 100%; padding: 7px 9px 7px 28px; border: 1px solid var(--border); border-radius: 6px; font-size: 12.5px; }
  .gallery-list { flex: 1; overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 8px; }
  .tpl-card {
    border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 11px; cursor: pointer; background: #fff;
    transition: border-color .12s ease, box-shadow .12s ease; position: relative;
  }
  .tpl-card:hover { border-color: #B9C6D6; box-shadow: var(--shadow); }
  .tpl-card.selected { border-color: var(--navy); box-shadow: 0 0 0 1px var(--navy); background: var(--navy-tint); }
  .tpl-card .name { font-weight: 600; font-size: 12.8px; margin-bottom: 3px; padding-right: 46px; word-break: break-word; }
  .tpl-card .meta { color: var(--ink-soft); font-size: 11.3px; display: flex; flex-wrap: wrap; gap: 4px 8px; }
  .tpl-card .type-chip { text-transform: uppercase; font-weight: 700; letter-spacing: .03em; }
  .status-dots { position: absolute; top: 12px; right: 11px; display: flex; align-items: center; gap: 5px; }
  .status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--good); flex: none; vertical-align: middle; }
  .status-dot.inactive { background: #B8BEC8; }
  .status-dot.incomplete { background: var(--warn); }
  .incomplete-note { color: var(--warn); font-weight: 600; }
  .incomplete-flag { display: inline-flex; align-items: center; gap: 6px; color: var(--warn); font-weight: 600; }

  /* ---------- Workspace (middle) ---------- */
  .workspace { flex: 1; min-width: 0; overflow-y: auto; padding: 22px 26px; }
  .editor-header { margin-bottom: 16px; }
  .editor-title { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
  .editor-title h1 { font-size: 19px; }
  .badge { font-size: 11px; font-weight: 700; padding: 2px 9px; border-radius: 20px; letter-spacing: .02em; }
  .badge.active { background: var(--good-bg); color: var(--good); }
  .badge.inactive { background: #EEF0F3; color: var(--ink-soft); }
  .editor-meta { color: var(--ink-soft); font-size: 12.3px; margin-top: 4px; display: flex; gap: 14px; flex-wrap: wrap; }
  .editor-meta code { background: #EEF1F5; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; }
  .editor-actions { margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap; }
  .file-label { display: inline-flex; }

  .tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-top: 18px; }
  .tab {
    appearance: none; background: none; border: none; border-bottom: 2px solid transparent; padding: 9px 4px;
    margin-right: 20px; font-size: 12.8px; font-weight: 600; color: var(--ink-soft); cursor: pointer;
  }
  .tab:hover { color: var(--ink); }
  .tab.active { color: var(--navy); border-bottom-color: var(--navy); }
  .tab .count { display: inline-block; margin-left: 5px; background: var(--bad); color: #fff; border-radius: 20px; font-size: 10.5px; font-weight: 700; padding: 0 6px; }

  .panel { display: none; padding-top: 16px; }
  .panel.active { display: block; }

  /* ---------- Field browser ---------- */
  .field-toolbar { display: flex; gap: 8px; margin-bottom: 12px; }
  .field-group { margin-bottom: 16px; }
  .field-group-title { font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--ink-soft); margin: 0 0 6px 2px; display: flex; align-items: center; gap: 6px; }
  .field-group-title .rel-tag { background: var(--navy-tint); color: var(--navy); border-radius: 4px; padding: 1px 6px; font-size: 10px; text-transform: none; letter-spacing: 0; }
  .field-list { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: #fff; }
  .field-row { display: flex; align-items: center; justify-content: space-between; padding: 7px 11px; border-bottom: 1px solid var(--border); cursor: pointer; gap: 10px; }
  .field-row:last-child { border-bottom: none; }
  .field-row:hover { background: #F7F9FB; }
  .field-row .fname { font-weight: 600; font-size: 12.3px; }
  .field-row .flabel { color: var(--ink-soft); font-size: 11.3px; margin-left: 7px; }
  .field-row .token { font-family: Consolas, "Courier New", monospace; font-size: 11.3px; color: var(--navy); background: var(--navy-tint); padding: 2px 7px; border-radius: 4px; white-space: nowrap; }

  /* ---------- Validation ---------- */
  .validate-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
  .summary-banner { border-radius: var(--radius); padding: 12px 14px; font-size: 12.8px; margin-bottom: 14px; display: flex; align-items: center; gap: 9px; }
  .summary-banner.ok { background: var(--good-bg); color: var(--good); }
  .summary-banner.problem { background: var(--bad-bg); color: var(--bad); }
  .summary-banner.neutral { background: #EEF1F5; color: var(--ink-soft); }
  .issue-list { display: flex; flex-direction: column; gap: 8px; }
  .issue-card { border-radius: var(--radius); border: 1px solid var(--border); padding: 10px 12px; background: #fff; }
  .issue-card.unknown { border-color: #F0C6C3; background: var(--bad-bg); }
  .issue-card .row1 { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
  .issue-card .badtoken { font-family: Consolas, "Courier New", monospace; font-weight: 700; color: var(--bad); font-size: 12.6px; }
  .issue-card.known .badtoken { color: var(--good); font-weight: 600; }
  .issue-card .occurrences { color: var(--ink-soft); font-size: 11px; }
  .issue-card .suggestion { margin-top: 6px; font-size: 12px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
  .issue-card .suggestion .stoken { font-family: Consolas, "Courier New", monospace; background: #fff; border: 1px solid var(--border); padding: 1px 6px; border-radius: 4px; }
  .issue-card .no-suggestion { margin-top: 6px; font-size: 11.8px; color: var(--warn); }

  /* ---------- Sample data ---------- */
  .sd-toolbar { display: flex; gap: 8px; margin-bottom: 10px; }
  textarea.code {
    width: 100%; min-height: 320px; font-family: Consolas, "Courier New", monospace; font-size: 12.3px;
    border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; resize: vertical; tab-size: 2;
    background: #fff;
  }
  textarea.code.error { border-color: var(--bad); }
  .field-error { color: var(--bad); font-size: 12px; margin-top: 7px; background: var(--bad-bg); border-radius: 6px; padding: 8px 10px; }
  .field-ok { color: var(--good); font-size: 12px; margin-top: 7px; }

  /* ---------- Rich text editor (Edit tab) ---------- */
  .editor-toolbar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 10px; }
  .editor-toolbar .sep { width: 1px; height: 20px; background: var(--border); margin: 0 2px; flex: none; }
  .editor-toolbar .btn b, .editor-toolbar .btn i, .editor-toolbar .btn u { font-style: normal; text-decoration: none; }
  .editor-toolbar .btn i { font-style: italic; }
  .editor-toolbar .btn u { text-decoration: underline; }

  /* ---------- Edit tab layout: editor column + persistent field panel ----------
     The field browser used to be its own tab; it is now a side panel that stays
     visible while writing, so a field click can insert straight into the editor
     instead of only copying a token to the clipboard. Stacks below the editor
     under 900px so the panel never crushes the writing surface -- the workspace
     column here is already squeezed by the 280px gallery and (down to 1100px
     total window width) the 400px preview pane, so 900px is comfortably inside
     "narrow" for this middle column specifically, not just the whole window. */
  .edit-layout { display: flex; gap: 18px; align-items: flex-start; }
  .edit-layout .editor-col { flex: 1; min-width: 0; }
  .field-panel {
    width: 280px; flex: none; border: 1px solid var(--border); border-radius: var(--radius);
    background: #fff; padding: 12px; max-height: 70vh; overflow-y: auto;
  }
  .field-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
  .field-panel-header h3 { font-size: 12.2px; text-transform: uppercase; letter-spacing: .04em; color: var(--ink-soft); }
  .field-panel .field-toolbar { margin-bottom: 10px; }
  .field-panel .field-row { flex-wrap: wrap; row-gap: 2px; padding: 6px 9px; }
  .field-panel .field-row .token { white-space: normal; word-break: break-all; }
  @media (max-width: 900px) {
    .edit-layout { flex-direction: column; }
    .field-panel { width: 100%; max-height: 280px; }
  }

  .editor-surface {
    border: 1px solid var(--border); border-radius: var(--radius); min-height: 360px; max-height: 60vh;
    overflow-y: auto; padding: 16px 20px; background: #fff; font-size: 13.5px; line-height: 1.55;
  }
  .editor-surface:focus { outline: 2px solid var(--navy); outline-offset: -1px; }
  .editor-surface h1 { font-size: 22px; margin: 0 0 10px; }
  .editor-surface h2 { font-size: 18px; margin: 14px 0 8px; }
  .editor-surface h3 { font-size: 15px; margin: 12px 0 6px; }
  .editor-surface p { margin: 0 0 10px; }
  .editor-surface ul, .editor-surface ol { margin: 0 0 10px; padding-left: 22px; }
  .editor-surface table { border-collapse: collapse; margin: 10px 0; }
  .editor-surface table td, .editor-surface table th { border: 1px solid var(--border); padding: 4px 8px; min-width: 24px; }
  .editor-surface img { max-width: 100%; }

  /* ---------- Settings ---------- */
  .settings-form { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; max-width: 640px; }
  .settings-form .full { grid-column: 1 / -1; }
  .field-item label { display: block; font-size: 11.6px; font-weight: 600; color: var(--ink-soft); margin-bottom: 4px; }
  .field-item input, .field-item select, .field-item textarea {
    width: 100%; padding: 7px 9px; border: 1px solid var(--border); border-radius: 6px; font-size: 12.6px; font-family: inherit;
  }
  .field-item textarea { min-height: 60px; resize: vertical; }
  .field-hint { color: var(--ink-soft); font-size: 11.6px; margin-top: 4px; }

  /* ---------- Bound table picker: searchable combobox ----------
     Replaces a plain <select> of every table in the environment, which does
     not scale to a real tenant's table count. Deliberately does not set
     `display` on .combo-list -- that element carries `hidden` while closed,
     and this file already lost a shipped release once to a rule that beat
     the [hidden] guard above (see the note at the top of this stylesheet). */
  .combo { position: relative; }
  .combo-input {
    width: 100%; padding: 7px 9px; border: 1px solid var(--border); border-radius: 6px; font-size: 12.6px; font-family: inherit;
  }
  .combo-input:disabled { background: #F2F4F7; color: var(--ink-soft); }
  /* The options list and the "Show all tables" control live in one absolutely
     positioned panel. They used to be siblings, with the list floating over a
     footer that sat in normal flow below it, which meant the open list covered
     the toggle: the escape hatch was unclickable at exactly the moment someone
     needed it, having just looked at the short recommended list. Only the list
     scrolls; the footer stays pinned under it. */
  .combo-panel {
    position: absolute; top: 100%; left: 0; right: 0; margin: 4px 0 0;
    background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
    box-shadow: var(--shadow); z-index: 30;
  }
  .combo-list { margin: 0; padding: 4px; list-style: none; max-height: 240px; overflow-y: auto; }
  .combo-panel-footer {
    display: flex; align-items: center; justify-content: space-between; gap: 8px;
    padding: 6px 9px; border-top: 1px solid var(--border);
  }
  .combo-option { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; padding: 6px 8px; border-radius: 5px; cursor: pointer; font-size: 12.5px; }
  .combo-option.active, .combo-option:hover { background: var(--navy-tint); }
  .combo-option-label { font-weight: 600; }
  .combo-option-logical { color: var(--ink-soft); font-size: 11.2px; font-family: Consolas, "Courier New", monospace; white-space: nowrap; }
  .combo-empty { padding: 8px; color: var(--ink-soft); font-size: 12px; }
  .combo-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 5px; }
  .combo-status { color: var(--ink-soft); font-size: 11px; }
  .combo-toggle {
    appearance: none; background: none; border: none; color: var(--navy); font-size: 11.3px; font-weight: 600;
    cursor: pointer; padding: 2px 0; text-decoration: underline;
  }
  .combo-toggle:hover { color: var(--navy-dark); }

  /* ---------- Empty / loading / error states ---------- */
  .empty-state { color: var(--ink-soft); font-size: 12.6px; padding: 18px 14px; text-align: center; }
  .empty-state.large { padding: 90px 30px; font-size: 14px; }
  .empty-state svg { width: 30px; height: 30px; fill: #C4CBD5; margin-bottom: 8px; }
  .loading-state { padding: 18px; color: var(--ink-soft); font-size: 12.6px; text-align: center; }
  .spinner { display: inline-block; width: 13px; height: 13px; border: 2px solid #D8DEE6; border-top-color: var(--navy); border-radius: 50%; animation: spin .7s linear infinite; vertical-align: -2px; margin-right: 6px; }
  /* Same spinner, recolored for use inside a solid navy button (btn-solid) where the default
     navy-on-light-grey ring has no contrast against the button's own navy background. */
  .spinner.on-dark { border-color: rgba(255,255,255,.4); border-top-color: #fff; }
  @keyframes spin { to { transform: rotate(360deg); } }
  .error-banner { background: var(--bad-bg); border: 1px solid #F0C6C3; color: var(--bad); border-radius: var(--radius); padding: 12px 14px; font-size: 12.6px; }
  .error-banner .code { font-family: Consolas, monospace; font-weight: 700; }
  .fatal-error { max-width: 560px; margin: 60px auto; }

  /* ---------- Preview (right) ---------- */
  .preview { width: 400px; flex: none; background: var(--surface); border-left: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; }
  .preview-header { padding: 12px 14px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
  .preview-header h2 { font-size: 13px; }
  .preview-body { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; }
  .preview-frame-wrap { flex: 1; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; min-height: 340px; background: #fff; }
  .preview-frame-wrap iframe, .preview-frame-wrap embed { width: 100%; height: 100%; border: none; min-height: 340px; }
  .preview-meta { margin-top: 10px; font-size: 11.3px; color: var(--ink-soft); display: flex; flex-wrap: wrap; gap: 4px 12px; }
  .preview-download { margin-top: 12px; }

  /* ---------- Modal ---------- */
  .modal-backdrop { position: fixed; inset: 0; background: rgba(20, 26, 34, .45); display: flex; align-items: center; justify-content: center; z-index: 50; }
  .modal { background: #fff; border-radius: 10px; width: 460px; max-width: calc(100vw - 40px); box-shadow: 0 20px 50px rgba(0,0,0,.25); }
  .modal-header { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
  .modal-header h3 { font-size: 15px; }
  .modal-close { background: none; border: none; cursor: pointer; color: var(--ink-soft); font-size: 18px; line-height: 1; padding: 4px; }
  .modal-body { padding: 18px 20px; display: flex; flex-direction: column; gap: 12px; max-height: 60vh; overflow-y: auto; }
  .modal-footer { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; }

  .toast { position: fixed; bottom: 22px; right: 22px; background: var(--navy); color: #fff; padding: 9px 15px; border-radius: 6px; font-size: 12.5px; box-shadow: var(--shadow); z-index: 100; opacity: 0; transform: translateY(6px); transition: opacity .15s ease, transform .15s ease; pointer-events: none; }
  .toast.show { opacity: 1; transform: translateY(0); }

  /* Below 1100px the preview used to be display:none -- no toggle, no hint,
     nothing. A 1280x800 laptop at 125% scaling is under that threshold, so
     Live Preview simply was not there and the author had no way to know the
     feature existed. It now stacks under the editor instead of disappearing:
     narrower than the side-by-side view, but present and reachable. */
  @media (max-width: 1100px) {
    .layout { flex-direction: column; }
    .preview {
      width: auto;
      flex: none;
      border-left: none;
      border-top: 1px solid var(--border);
      min-height: 320px;
    }
  }
</style>
</head>
<body>
<div class="app" id="app">

  <header class="topbar">
    <div class="brand">
      <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.25 2h7.09c.4 0 .78.16 1.06.44l4.66 4.66c.28.28.44.66.44 1.06v11.59A2.25 2.25 0 0 1 17.25 22H6.25A2.25 2.25 0 0 1 4 19.75V4.25A2.25 2.25 0 0 1 6.25 2zm0 1.5a.75.75 0 0 0-.75.75v15.5c0 .41.34.75.75.75h11a.75.75 0 0 0 .75-.75V9h-3.75A1.75 1.75 0 0 1 12.5 7.25V3.5H6.25zM14 4.31v2.94c0 .14.11.25.25.25h2.94L14 4.31z"/><path d="M8.28 11.2a.75.75 0 0 1 .96.45l1.79 4.72 1.79-4.72a.75.75 0 1 1 1.4.53l-2.49 6.58a.75.75 0 0 1-1.4 0L7.84 12.2a.75.75 0 0 1 .44-1z"/></svg>
      Template Studio <span class="sub">VerseDocs</span>
    </div>
    <div>
      <button class="btn btn-primary" id="newTemplateBtn" type="button">+ New Template</button>
    </div>
  </header>

  <div class="layout" id="layout" hidden>
    <aside class="gallery">
      <div class="gallery-header">
        <div class="search-box">
          <svg viewBox="0 0 24 24"><path d="M10.5 3a7.5 7.5 0 1 0 4.66 13.4l4.72 4.72a1 1 0 0 0 1.42-1.42l-4.72-4.72A7.5 7.5 0 0 0 10.5 3zm-5.5 7.5a5.5 5.5 0 1 1 11 0 5.5 5.5 0 0 1-11 0z"/></svg>
          <input type="search" id="gallerySearch" placeholder="Search templates…" />
        </div>
      </div>
      <div id="galleryLoading" class="loading-state"><span class="spinner"></span>Loading templates…</div>
      <div id="galleryList" class="gallery-list" hidden></div>
      <div id="galleryEmpty" class="empty-state" hidden>
        <svg viewBox="0 0 24 24"><path d="M6.25 2h7.09c.4 0 .78.16 1.06.44l4.66 4.66c.28.28.44.66.44 1.06v11.59A2.25 2.25 0 0 1 17.25 22H6.25A2.25 2.25 0 0 1 4 19.75V4.25A2.25 2.25 0 0 1 6.25 2z"/></svg>
        <div>No templates yet.<br />Create one to get started.</div>
      </div>
      <div id="galleryNoMatch" class="empty-state" hidden>No templates match your search.</div>
    </aside>

    <main class="workspace">
      <div id="noSelection" class="empty-state large">
        <svg viewBox="0 0 24 24" style="width:38px;height:38px;margin:0 auto 10px;display:block;"><path d="M6.25 2h7.09c.4 0 .78.16 1.06.44l4.66 4.66c.28.28.44.66.44 1.06v11.59A2.25 2.25 0 0 1 17.25 22H6.25A2.25 2.25 0 0 1 4 19.75V4.25A2.25 2.25 0 0 1 6.25 2z"/></svg>
        Select a template on the left, or create a new one, to start editing.
      </div>

      <div id="editor" hidden>
        <div class="editor-header">
          <div class="editor-title">
            <h1 id="tplName"></h1>
            <span id="tplBadge" class="badge"></span>
          </div>
          <div class="editor-meta">
            <span>Bound table: <code id="tplBoundTable"></code></span>
            <span>Type: <code id="tplType"></code></span>
            <span>Version: <code id="tplVersion"></code></span>
            <span id="tplIncompleteFlag" class="incomplete-flag" hidden>
              <span class="status-dot incomplete"></span>
              <span id="tplIncompleteText"></span>
            </span>
          </div>
          <div class="editor-actions">
            <label class="btn btn-outline file-label">
              <svg class="icon" viewBox="0 0 24 24"><path d="M12 3a1 1 0 0 1 1 1v10.59l2.3-2.3a1 1 0 1 1 1.4 1.42l-4 4a1 1 0 0 1-1.4 0l-4-4a1 1 0 1 1 1.4-1.42l2.3 2.3V4a1 1 0 0 1 1-1zM5 18a1 1 0 0 1 1 1v.5c0 .28.22.5.5.5h11a.5.5 0 0 0 .5-.5V19a1 1 0 1 1 2 0v.5A2.5 2.5 0 0 1 17.5 22h-11A2.5 2.5 0 0 1 4 19.5V19a1 1 0 0 1 1-1z"/></svg>
              Replace file
              <input type="file" id="fileInput" accept=".docx,.xlsx,.pptx" hidden />
            </label>
            <button class="btn btn-outline" id="toggleActiveBtn" type="button">Deactivate</button>
          </div>
          <div id="pendingFileNote" class="field-ok" hidden></div>
        </div>

        <nav class="tabs">
          <button class="tab active" data-tab="edit" type="button">Edit</button>
          <button class="tab" data-tab="validate" type="button">Validate<span class="count" id="validateCount" hidden></span></button>
          <button class="tab" data-tab="sampledata" type="button">Sample Data</button>
          <button class="tab" data-tab="settings" type="button">Settings</button>
        </nav>

        <div class="tab-panels">
          <!-- EDIT (rich text editor, writes vdocs_editorsource + regenerates vdocs_templatefile).
               The field browser that used to be its own "Fields" tab now lives in here as a
               persistent side panel (#fieldPanel below) so a field is always one click away while
               writing. It still builds state.knownFields exactly as before -- see loadFields() --
               vdocs_ValidateTemplateTokens checks the Validate tab's tokens against that list, and
               that has nothing to do with which tab happens to be open. -->
          <section class="panel active" data-panel="edit">
            <div id="editNoSource" class="empty-state" hidden>
              <p>This template doesn't have editor-authored content saved yet, so this tab stays out of the way rather than guessing at your formatting.</p>
              <p>That usually means it was uploaded from Word. Converting an existing Word file into this editor's HTML and back would lose formatting, so instead: open the current file in Word, make your changes there, and use "Replace file" above to upload the updated version.</p>
            </div>
            <div id="editSurface" hidden>
              <div class="editor-toolbar" id="editToolbar">
                <button class="btn btn-outline btn-sm" data-cmd="formatBlock" data-value="h1" type="button" title="Heading 1">H1</button>
                <button class="btn btn-outline btn-sm" data-cmd="formatBlock" data-value="h2" type="button" title="Heading 2">H2</button>
                <button class="btn btn-outline btn-sm" data-cmd="formatBlock" data-value="h3" type="button" title="Heading 3">H3</button>
                <button class="btn btn-outline btn-sm" data-cmd="formatBlock" data-value="p" type="button" title="Paragraph">&para;</button>
                <span class="sep"></span>
                <button class="btn btn-outline btn-sm" data-cmd="bold" type="button" title="Bold"><b>B</b></button>
                <button class="btn btn-outline btn-sm" data-cmd="italic" type="button" title="Italic"><i>I</i></button>
                <button class="btn btn-outline btn-sm" data-cmd="underline" type="button" title="Underline"><u>U</u></button>
                <span class="sep"></span>
                <button class="btn btn-outline btn-sm" data-cmd="insertUnorderedList" type="button" title="Bulleted list">&bull; List</button>
                <button class="btn btn-outline btn-sm" data-cmd="insertOrderedList" type="button" title="Numbered list">1. List</button>
                <span class="sep"></span>
                <button class="btn btn-outline btn-sm" data-cmd="insertTable" type="button" title="Insert table">Insert table</button>
                <button class="btn btn-outline btn-sm" data-cmd="insertImage" type="button" title="Insert image">Insert image</button>
                <input type="file" id="editImageInput" accept="image/png,image/jpeg,image/gif,image/webp" hidden />
                <span class="sep"></span>
                <button class="btn btn-outline btn-sm" id="insertTokenBtn" type="button" title="Jump to the field panel">Insert token</button>
              </div>
              <div id="editLoading" class="loading-state" hidden><span class="spinner"></span>Loading saved content…</div>
              <div class="edit-layout">
                <div class="editor-col">
                  <div class="editor-surface" id="editorSurface" contenteditable="true" spellcheck="true"></div>
                  <div id="editorSaveError" class="error-banner" hidden style="margin-top:10px;"></div>
                  <div style="margin-top:14px;">
                    <button class="btn btn-solid" id="saveEditorBtn" type="button">Save</button>
                    <span id="editorSaveFeedback" class="field-ok" hidden></span>
                  </div>
                </div>
                <aside class="field-panel" id="fieldPanel" aria-label="Fields: click one to insert its token">
                  <div class="field-panel-header">
                    <h3>Fields</h3>
                    <button class="btn btn-outline btn-sm" id="reloadFieldsBtn" type="button">Refresh</button>
                  </div>
                  <div class="field-toolbar">
                    <div class="search-box" style="flex:1;">
                      <svg viewBox="0 0 24 24"><path d="M10.5 3a7.5 7.5 0 1 0 4.66 13.4l4.72 4.72a1 1 0 0 0 1.42-1.42l-4.72-4.72A7.5 7.5 0 0 0 10.5 3zm-5.5 7.5a5.5 5.5 0 1 1 11 0 5.5 5.5 0 0 1-11 0z"/></svg>
                      <input type="search" id="fieldSearch" placeholder="Search fields…" />
                    </div>
                  </div>
                  <div id="fieldsLoading" class="loading-state"><span class="spinner"></span>Loading fields from Dataverse…</div>
                  <div id="fieldsError" class="error-banner" hidden></div>
                  <div id="fieldsBody"></div>
                </aside>
              </div>
            </div>
          </section>

          <!-- VALIDATE -->
          <section class="panel" data-panel="validate">
            <div class="validate-toolbar">
              <button class="btn btn-solid" id="runValidationBtn" type="button">Validate tokens</button>
              <span id="validateStatus" class="loading-state" style="padding:0;" hidden></span>
            </div>
            <div id="validateBody">
              <div class="empty-state">Click "Validate tokens" to parse every {{token}} in this template's file and check it against the bound table's real fields.</div>
            </div>
          </section>

          <!-- SAMPLE DATA -->
          <section class="panel" data-panel="sampledata">
            <div class="sd-toolbar">
              <button class="btn btn-outline btn-sm" id="formatJsonBtn" type="button">Format JSON</button>
              <button class="btn btn-solid btn-sm" id="saveSampleDataBtn" type="button">Save</button>
            </div>
            <textarea class="code" id="sampleDataText" spellcheck="false" placeholder="{ }"></textarea>
            <div id="sampleDataFeedback"></div>
          </section>

          <!-- SETTINGS -->
          <section class="panel" data-panel="settings">
            <div class="settings-form">
              <div class="field-item full"><label>Name</label><input type="text" id="setName" /></div>
              <div class="field-item full"><label>Description</label><textarea id="setDescription"></textarea></div>
              <div class="field-item" id="setBoundTableWrap"><label id="setBoundTableLabel">Bound table</label><div id="setBoundTableMount"></div></div>
              <div class="field-item">
                <label>Template type</label>
                <select id="setTemplateType"><option value="docx">docx</option><option value="xlsx">xlsx</option><option value="pptx">pptx</option></select>
              </div>
              <div class="field-item"><label>Version</label><input type="text" id="setVersion" placeholder="e.g. 1.0.0" /></div>
              <div class="field-item"><label>Language</label><input type="text" id="setLanguage" placeholder="e.g. en-US" /></div>
              <div class="field-item full"><label>Binding manifest (FetchXML)</label><textarea id="setFetchXml" style="min-height:120px;font-family:Consolas,monospace;font-size:11.6px;"></textarea></div>
            </div>
            <div style="margin-top:14px;">
              <button class="btn btn-solid" id="saveSettingsBtn" type="button">Save changes</button>
              <span id="settingsFeedback" class="field-ok" hidden></span>
            </div>
          </section>
        </div>
      </div>
    </main>

    <aside class="preview">
      <div class="preview-header">
        <h2>Live Preview</h2>
        <button class="btn btn-solid btn-sm" id="refreshPreviewBtn" type="button">Generate</button>
      </div>
      <div class="preview-body" id="previewBody">
        <div class="empty-state">Select a template, then click Generate to render it against the Sample Data tab's JSON.</div>
      </div>
    </aside>
  </div>

  <div id="fatalError" class="fatal-error" hidden></div>
</div>

<!-- New template modal -->
<div class="modal-backdrop" id="newModalBackdrop" hidden>
  <div class="modal">
    <div class="modal-header">
      <h3>New template</h3>
      <button class="modal-close" id="newModalClose" type="button">&times;</button>
    </div>
    <div class="modal-body">
      <div class="field-item"><label>Name</label><input type="text" id="newName" placeholder="e.g. Account Welcome Letter" /></div>
      <div class="field-item" id="newBoundTableWrap"><label id="newBoundTableLabel">Bound table</label><div id="newBoundTableMount"></div></div>
      <div class="field-item">
        <label>Template type</label>
        <select id="newTemplateType"><option value="docx">docx (Word)</option><option value="xlsx">xlsx (Excel)</option><option value="pptx">pptx (PowerPoint)</option></select>
      </div>
      <div class="field-item"><label>Version</label><input type="text" id="newVersion" placeholder="1.0.0" /></div>
      <div class="field-item">
        <label>Starting point (optional, can be added later from Settings)</label>
        <select id="newStarter">
          <option value="upload">Upload my own file</option>
          <option value="blank">Blank starter (generated)</option>
          <option value="letter">Letter starter (generated)</option>
          <option value="invoice">Invoice starter (generated)</option>
          <option value="quote">Quote starter (generated)</option>
        </select>
      </div>
      <div class="field-item" id="newFileWrap">
        <label>Template file (optional)</label>
        <input type="file" id="newFile" accept=".docx,.xlsx,.pptx" />
      </div>
      <div id="newStarterNote" class="field-ok" hidden></div>
      <div class="field-item" id="newLineItemsWrap" hidden>
        <label id="newLineItemsLabel">Where do line items come from?</label>
        <div id="newLineItemsMount"></div>
        <div class="field-hint">Optional. Pick one of the bound table's related tables to source the line-items loop from real rows; leave as "None" to keep supplying line items yourself via <code>Data</code>.</div>
      </div>
      <div id="newModalError" class="error-banner" hidden></div>
    </div>
    <div class="modal-footer">
      <button class="btn btn-outline" id="newModalCancel" type="button">Cancel</button>
      <button class="btn btn-solid" id="newModalCreate" type="button">Create template</button>
    </div>
  </div>
</div>

<div class="toast" id="toast"></div>

<script>
(function () {
  "use strict";

  // ======================================================================
  // 0. Xrm bootstrap -- same "look in self/parent/top, fail with a clear
  // message rather than a blank page" pattern as licensing.htm /
  // generateandattach.htm.
  // ======================================================================
  function findXrm() {
    if (typeof Xrm !== "undefined" && Xrm.WebApi) { return Xrm; }
    try { if (window.parent && window.parent.Xrm && window.parent.Xrm.WebApi) { return window.parent.Xrm; } } catch (e) { /* cross-frame blocked */ }
    try { if (window.top && window.top.Xrm && window.top.Xrm.WebApi) { return window.top.Xrm; } } catch (e) { /* ignore */ }
    return null;
  }

  var xrm = findXrm();

  function showFatal(message) {
    document.getElementById("layout").hidden = true;
    var el = document.getElementById("fatalError");
    el.hidden = false;
    el.innerHTML = '<div class="error-banner">' + escapeHtml(message) + "</div>";
  }

  if (!xrm) {
    showFatal("This page must be opened from inside a model-driven app (it calls Dataverse via the client API, which is only injected inside the app shell).");
    return;
  }

  function clientUrl() {
    try { return xrm.Utility.getGlobalContext().getClientUrl(); } catch (e) { return ""; }
  }

  var API_BASE = clientUrl() + "/api/data/v9.2/";
  var ENTITY_LOGICAL_NAME = "vdocs_template";
  var templateEntitySetName = "vdocs_templates"; // corrected at startup via EntityDefinitions, see resolveEntitySetName()

  function webApiFetch(path, opts) {
    opts = opts || {};
    var headers = Object.assign({
      "OData-MaxVersion": "4.0",
      "OData-Version": "4.0",
      "Accept": "application/json",
    }, opts.headers || {});
    return fetch(API_BASE + path, {
      method: opts.method || "GET",
      headers: headers,
      credentials: "same-origin",
      body: opts.body,
    }).then(function (r) {
      if (!r.ok) {
        return r.text().then(function (t) { throw new Error("Request failed (" + r.status + "): " + t); });
      }
      return r;
    });
  }

  function resolveEntitySetName() {
    return webApiFetch("EntityDefinitions(LogicalName='" + ENTITY_LOGICAL_NAME + "')?$select=EntitySetName")
      .then(function (r) { return r.json(); })
      .then(function (body) { if (body.EntitySetName) { templateEntitySetName = body.EntitySetName; } })
      .catch(function () { /* keep the guessed default */ });
  }

  // ======================================================================
  // Small shared utilities
  // ======================================================================
  function escapeHtml(s) {
    return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
      return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
    });
  }

  function toast(message) {
    var el = document.getElementById("toast");
    el.textContent = message;
    el.classList.add("show");
    clearTimeout(toast._t);
    toast._t = setTimeout(function () { el.classList.remove("show"); }, 1600);
  }

  /** Reusable in-progress state for a button driving a multi-step async action. Create Template
   * and Save (Edit tab) both make more than one network round trip -- template scaffold, row
   * create, one or two file uploads for Create Template; HTML-to-docx conversion, two file
   * uploads for Save -- and previously showed nothing while that ran beyond the button going
   * disabled, which reads as broken on an ordinary connection, not just a slow one. Settings
   * "Save changes" gets the same treatment for consistency even though it is usually one call.
   * Disables the button and swaps its label for a CSS spinner (the same .spinner class used
   * everywhere else on this page -- no animated GIF, no external asset) plus text the caller
   * updates as the operation moves through its phases (busy.setPhase("Uploading...")); busy.done()
   * restores the exact original label and enabled state, called from both the success and the
   * failure branch of every caller below so a failed call never leaves a button stuck disabled. */
  function startBusy(btn) {
    var originalHtml = btn.innerHTML;
    var originalMinWidth = btn.style.minWidth;
    btn.style.minWidth = btn.offsetWidth + "px"; // keep the button's width steady as the label text changes length
    btn.disabled = true;

    function setPhase(text) {
      btn.innerHTML = '<span class="spinner on-dark"></span>' + escapeHtml(text);
    }

    function done() {
      btn.disabled = false;
      btn.innerHTML = originalHtml;
      btn.style.minWidth = originalMinWidth;
    }

    return { setPhase: setPhase, done: done };
  }

  /** A template is "incomplete" -- not yet usable by any vdocs_* action -- when it has no
   * uploaded file, or no binding manifest FetchXML (TemplateRowPipeline.ResolveEffectiveFetchXml
   * throws FETCHXML_INVALID for the latter). Shared by the gallery card and the editor header so
   * both agree on the same definition. Takes either a gallery-row projection or the full selected
   * record; both include vdocs_templatefile_name and vdocs_bindingmanifest. */
  function isTemplateIncomplete(t) {
    var hasFile = !!t.vdocs_templatefile_name;
    var hasManifest = !!(t.vdocs_bindingmanifest && t.vdocs_bindingmanifest.trim());
    return !hasFile || !hasManifest;
  }

  function copyToClipboard(text) {
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(text).catch(function () { fallbackCopy(text); });
    } else {
      fallbackCopy(text);
    }
    toast("Copied " + text);
  }

  function fallbackCopy(text) {
    var ta = document.createElement("textarea");
    ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
    document.body.appendChild(ta); ta.select();
    try { document.execCommand("copy"); } catch (e) { /* ignore */ }
    document.body.removeChild(ta);
  }

  function arrayBufferToBase64(buf) {
    var bytes = new Uint8Array(buf);
    var chunkSize = 0x8000;
    var chunks = [];
    for (var i = 0; i < bytes.length; i += chunkSize) {
      chunks.push(String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize)));
    }
    return btoa(chunks.join(""));
  }

  function base64ToUint8Array(base64) {
    var byteChars = atob(base64);
    var byteNumbers = new Array(byteChars.length);
    for (var i = 0; i < byteChars.length; i++) { byteNumbers[i] = byteChars.charCodeAt(i); }
    return new Uint8Array(byteNumbers);
  }

  function base64ToBlob(base64, contentType) {
    return new Blob([base64ToUint8Array(base64)], { type: contentType });
  }

  function fileToBase64(file) {
    return file.arrayBuffer().then(arrayBufferToBase64);
  }

  // TextEncoder is used instead of the classic escape/unescape(encodeURIComponent(...)) trick:
  // it's broadly supported in every browser this web resource can run in (it's always hosted
  // inside a model-driven app's own modern browser shell) and handles multi-byte characters
  // (accented names, curly quotes pasted from Word, etc.) in the editor's HTML correctly.
  function stringToUint8Array(str) {
    return new TextEncoder().encode(str);
  }

  function stringToBase64(str) {
    return arrayBufferToBase64(stringToUint8Array(str).buffer);
  }

  /** PATCHes raw bytes into a File column on a vdocs_template row -- the one upload primitive
   * shared by "Replace file" (uploadPendingFile), the New Template modal's starter/upload flow,
   * and the Edit tab's Save (which writes both vdocs_templatefile and vdocs_editorsource). */
  function uploadFileColumnBytes(templateId, columnName, bytes, fileName) {
    return webApiFetch(templateEntitySetName + "(" + templateId + ")/" + columnName, {
      method: "PATCH",
      headers: { "Content-Type": "application/octet-stream", "x-ms-file-name": fileName },
      body: bytes,
    });
  }

  function executeAction(actionName, parameters, paramTypes) {
    var request = Object.assign({}, parameters);
    request.getMetadata = function () {
      return { boundParameter: null, parameterTypes: paramTypes, operationType: 0, operationName: actionName };
    };
    return xrm.WebApi.online.execute(request).then(function (response) { return response.json(); });
  }

  var STRING_PARAM = { typeName: "Edm.String", structuralProperty: 1 };

  function apiErrorMessage(error) {
    if (!error) { return "Unknown error."; }
    var raw = error.message || String(error);
    // Custom API errors surface as JSON {"code":"...","message":"..."} inside the message text.
    var match = raw.match(/\{[\s\S]*\}/);
    if (match) {
      try {
        var parsed = JSON.parse(match[0]);
        if (parsed.code || parsed.message) {
          return { code: parsed.code, message: parsed.message || raw };
        }
      } catch (e) { /* not JSON, fall through */ }
    }
    return { code: null, message: raw };
  }

  function renderErrorBanner(container, error) {
    var e = apiErrorMessage(error);
    container.innerHTML = '<div class="error-banner">' +
      (e.code ? '<span class="code">' + escapeHtml(e.code) + "</span>: " : "") +
      escapeHtml(e.message) + "</div>";
  }

  // ======================================================================
  // 1. State
  // ======================================================================
  var state = {
    templates: [],       // gallery rows
    selectedId: null,
    selected: null,       // full record of selected template
    pendingFile: null,    // a locally-chosen File not yet uploaded
    knownFields: null,    // computed once fields are loaded: string[] of normalized paths, e.g. "account.name"
    fieldGroups: null,    // for rendering
    tableOptions: null,    // cached once fetched: the recommended (filtered) [{logicalName, label, displayLabel}], see loadTableList()
    tableOptionsAll: null, // same shape, unfiltered -- backs the table picker's "Show all tables" escape hatch
    editorLoaded: false,  // whether the Edit tab's stored vdocs_editorsource HTML has been fetched for state.selected
    lineItemsRelationshipsByTable: {}, // cache: bound table logical name (lowercased) -> loadLineItemsRelationships() promise
    lineItemsInitializedForBoundTable: null, // which bound table the New template modal's line-items picker was last built for, see updateLineItemsUi()
  };

  // ======================================================================
  // 1a. Bound table picker -- a searchable, keyboard-operable combobox over
  // EntityDefinitions, used by both the Settings tab and the New template
  // modal so "Bound table" is never hand-typed free text (the exact typo
  // this Studio otherwise exists to catch). Replaces a plain <select> of
  // every table in the environment, which does not scale: a real tenant
  // can easily have 800-900 EntityDefinitions rows, most of them platform
  // plumbing nobody would ever generate a document from.
  //
  // TWO TIERS, both gated by the same hard floor (passesHardFloor, below):
  //   - "all"         everything that clears the hard floor. Reachable via
  //                    "Show all tables", and also what the search box
  //                    searches the instant there is a query -- see
  //                    tablePickerSource() -- so typing an exact logical
  //                    name always finds it, recommended or not.
  //   - "recommended" the short, default-visible list: hard floor PLUS at
  //                    least one positive "someone would template this"
  //                    signal, see isRecommendedTable().
  //
  // Hard floor (verified live against a real dev environment with 897
  // EntityDefinitions rows; see the commit message for the raw counts):
  //   IsIntersect              true  -- many-to-many join table
  //   IsPrivate                true  -- hidden from customization entirely
  //   IsLogicalEntity          true  -- pseudo table, not a real row source
  //   IsValidForAdvancedFind  false  -- Dataverse's own "not for end-user
  //                                     querying" signal
  //   IsImportable            false  -- no import path exists for this
  //                                     table's rows; catches plumbing
  //                                     IsValidForAdvancedFind misses, e.g.
  //                                     sdkmessage, workflow, plugintype,
  //                                     fieldsecurityprofile, publisher,
  //                                     mailbox, tracelog
  // This drops 897 tables to 493. IsActivityParty was tried and REJECTED
  // for the floor: it reads like "this is activity-party plumbing", but it
  // is actually a capability flag ("this table CAN be an activity's
  // To/From/Cc target"), and filtering on it removed account and contact.
  //
  // Recommended = hard floor AND (signal 1 OR signal 2 OR signal 3):
  //   1. IsManaged = false (this environment's OWN unmanaged tables).
  //   2. Logical name is in CURATED_COMMON_TABLES, a fixed allowlist of
  //      standard tables people actually generate documents from: account,
  //      contact, lead, opportunity, quote, salesorder, invoice, incident
  //      (case), appointment, email, task, phonecall, product, pricelevel,
  //      campaign, contract, competitor, letter, knowledgearticle.
  //   3. Logical name already appears in vdocs_boundtable on an existing
  //      vdocs_template row in this environment (one extra GET, see
  //      loadBoundTablesInUse()) -- if someone already built a template
  //      against a table, it obviously belongs in the recommended list,
  //      and this is what makes the list reflect how THIS org actually
  //      uses the product rather than a generic guess. Degrades to signals
  //      1+2 only if that query fails, rather than breaking the picker.
  //
  // Signal 1 is IsManaged = false, NOT IsCustomEntity = true. Measured live
  // against a real 897-row dev environment, IsCustomEntity is close to
  // useless as a "the tenant's own table" signal in a modern org: 451 of
  // the 493 hard-floor tables are IsCustomEntity=true, and 449 of those are
  // Microsoft's own first-party feature tables (aaduser, adx_externalidentity,
  // adx_invitation, agentfeeditem, agenthubgoal, agenticscenario, aicopilot,
  // and hundreds more from Copilot Studio, AI Builder, Power Pages, Power
  // Automate, ALM tooling) that merely happen to be flagged "custom" because
  // they are not baked into the base platform schema -- not because the
  // tenant created them. Unioned with signals 2 and 3, IsCustomEntity=true
  // produced 459 recommended tables in that environment: barely different
  // from the 493-table hard floor, and nowhere near a usable "short list".
  //
  // IsManaged = false is different in kind, not degree: it identifies rows
  // that live in this environment's own default (unmanaged) solution layer,
  // which is exactly where a maker's own hand-built tables land. In that
  // same live environment it identifies exactly 2 tables after the hard
  // floor -- vdocs_template and vdocs_usage, this solution's own tables --
  // and the full union (signals 1+2+3) is 10 recommended tables: account,
  // appointment, contact, email, knowledgearticle, letter, phonecall, task,
  // vdocs_template, vdocs_usage.
  //
  // The honest limitation: a customer who ships their OWN tables inside
  // their OWN managed solution (a common pattern for anything built by a
  // partner/ISV, or exported from a dev environment and imported as managed)
  // will see IsManaged=true on those tables too, so signal 1 will not surface
  // them either. They stay reachable -- "Show all tables" and full-list
  // search both still reach every hard-floor-passing table regardless of
  // this predicate -- and the moment someone actually builds a first
  // template against one, signal 3 picks it up and promotes it into the
  // recommended list from then on. This is a real gap, not a solved
  // problem: it is acceptable only because nothing is ever unreachable, and
  // recommended self-corrects to real usage once there is any.
  //
  // Nothing here is unreachable: "Show all tables" always reaches the full
  // hard-floor-passing list, and the search box searches that full list
  // regardless of the toggle, so an exact logical name is always findable.
  // ======================================================================
  var TABLE_PICKER_SELECT = "LogicalName,DisplayName,IsPrivate,IsIntersect,IsLogicalEntity,IsValidForAdvancedFind,IsImportable,IsManaged";

  var CURATED_COMMON_TABLES = [
    "account", "contact", "lead", "opportunity", "quote", "salesorder", "invoice",
    "incident", "appointment", "email", "task", "phonecall", "product", "pricelevel",
    "campaign", "contract", "competitor", "letter", "knowledgearticle",
  ];
  var CURATED_COMMON_TABLES_SET = {};
  CURATED_COMMON_TABLES.forEach(function (n) { CURATED_COMMON_TABLES_SET[n] = true; });

  function tableOptionFrom(e) {
    var label = (e.DisplayName && e.DisplayName.UserLocalizedLabel && e.DisplayName.UserLocalizedLabel.Label) || e.LogicalName;
    return { logicalName: e.LogicalName, label: label, displayLabel: label + " (" + e.LogicalName + ")" };
  }

  function byDisplayLabel(a, b) {
    var x = a.displayLabel.toLowerCase(), y = b.displayLabel.toLowerCase();
    return x < y ? -1 : (x > y ? 1 : 0);
  }

  function passesHardFloor(e) {
    return e.IsIntersect !== true && e.IsPrivate !== true && e.IsLogicalEntity !== true &&
      e.IsValidForAdvancedFind !== false && e.IsImportable !== false;
  }

  function isRecommendedTable(e, boundInUseSet) {
    return e.IsManaged === false ||
      CURATED_COMMON_TABLES_SET.hasOwnProperty(e.LogicalName) ||
      boundInUseSet.hasOwnProperty(e.LogicalName);
  }

  /** One cheap GET for the distinct vdocs_boundtable values already in use in this environment
   * (signal 3 above). Degrades to an empty set -- NOT a broken picker -- if the query fails, e.g.
   * templateEntitySetName hasn't resolved yet or the call is otherwise rejected. */
  function loadBoundTablesInUse() {
    return webApiFetch(templateEntitySetName + "?$select=vdocs_boundtable")
      .then(function (r) { return r.json(); })
      .then(function (body) {
        var set = {};
        (body.value || []).forEach(function (row) {
          if (row.vdocs_boundtable) { set[row.vdocs_boundtable] = true; }
        });
        return set;
      })
      .catch(function () { return {}; });
  }

  /** Fetches once and caches both the recommended (short) and full (hard-floor-only) table lists. */
  function loadTableList() {
    if (state.tableOptions && state.tableOptionsAll) {
      return Promise.resolve({ filtered: state.tableOptions, all: state.tableOptionsAll });
    }
    return Promise.all([
      webApiFetch("EntityDefinitions?$select=" + TABLE_PICKER_SELECT).then(function (r) { return r.json(); }),
      loadBoundTablesInUse(),
    ]).then(function (results) {
      var body = results[0];
      var boundInUse = results[1];
      var raw = (body.value || []).filter(passesHardFloor);
      var all = raw.map(tableOptionFrom).sort(byDisplayLabel);
      var filtered = raw.filter(function (e) { return isRecommendedTable(e, boundInUse); }).map(tableOptionFrom).sort(byDisplayLabel);
      state.tableOptions = filtered;
      state.tableOptionsAll = all;
      return { filtered: filtered, all: all };
    });
  }

  function findTableOption(list, logicalName) {
    for (var i = 0; i < list.length; i++) {
      if (list[i].logicalName === logicalName) { return list[i]; }
    }
    return null;
  }

  function showBoundTableFetchError(wrapId, error) {
    var wrap = document.getElementById(wrapId);
    var note = document.createElement("div");
    var e = apiErrorMessage(error);
    note.className = "error-banner";
    note.style.marginTop = "6px";
    note.innerHTML = "Could not load the table list" + (e.message ? " (" + escapeHtml(e.message) + ")" : "") +
      ". Falling back to free text entry.";
    wrap.appendChild(note);
  }

  /** Swaps the picker for a plain text <input>, same id as the hidden value input, so callers
   * reading .value keep working unchanged. Used when the metadata fetch fails -- a picker that
   * fails closed is worse than the text box it replaces. */
  function useFallbackTextInput(prefix, currentValue) {
    var mount = document.getElementById(prefix + "Mount");
    mount.innerHTML = '<input type="text" id="' + prefix + '" value="' + escapeHtml(currentValue || "") + '" placeholder="e.g. account" />';
  }

  // Per-instance transient state for each live picker (keyed by prefix, e.g. "setBoundTable"),
  // holding what a <select>'s own browser-native combobox behavior would otherwise track for free:
  // the current filtered result set, which one is keyboard-highlighted, and whether "Show all
  // tables" is toggled on. Reset from scratch by initBoundTableSelect() every time a picker opens.
  var tablePickerState = {};

  // The {filtered, all} option lists backing each live picker, keyed by the same prefix as
  // tablePickerState -- e.g. pickerListsByPrefix.newBoundTable is every table option, and
  // pickerListsByPrefix.newLineItems (added by initRelationshipSelect, below) is one relationship
  // option per real one-to-many relationship of whichever bound table is currently selected in the
  // New template modal. Both "tiers" (filtered/all) are the SAME list for a relationship picker --
  // there is no "recommended" subset, unlike the bound-table picker -- but keeping the shape
  // identical is what lets tablePickerSource/renderTablePickerOptions/setTablePickerSelection stay
  // shared, unmodified, across both picker kinds (task constraint: "the searchable combobox idiom
  // already used for the bound-table picker ... do not introduce a second idiom").
  var pickerListsByPrefix = {};

  function currentTableLists(prefix) {
    return pickerListsByPrefix[prefix] || { filtered: [], all: [] };
  }

  /** Browsing with an empty box shows the short recommended list (or the full list once "Show all
   * tables" is toggled on) -- but the moment there is a query, search always reaches the FULL
   * hard-floor-passing list regardless of that toggle. A table being outside the recommended set
   * is a reason to hide it while browsing, not a reason someone typing its exact logical name
   * should fail to find it. */
  function tablePickerSource(prefix, hasQuery) {
    var lists = currentTableLists(prefix);
    if (hasQuery) { return lists.all; }
    return tablePickerState[prefix].showAll ? lists.all : lists.filtered;
  }

  function renderTablePickerOptions(prefix, query) {
    var s = tablePickerState[prefix];
    var q = (query || "").trim().toLowerCase();
    var source = tablePickerSource(prefix, !!q);
    var results = !q ? source : source.filter(function (t) {
      return t.label.toLowerCase().indexOf(q) >= 0 || t.logicalName.toLowerCase().indexOf(q) >= 0;
    });
    s.results = results;
    s.activeIndex = results.length ? 0 : -1;

    var listbox = document.getElementById(prefix + "Listbox");
    if (results.length === 0) {
      listbox.innerHTML = '<li class="combo-empty" role="presentation">No matching tables' +
        (q || s.showAll ? "." : ". Try \"Show all tables\".") + "</li>";
    } else {
      listbox.innerHTML = results.map(function (t, i) {
        return '<li id="' + prefix + 'Opt' + i + '" role="option" data-logical-name="' + escapeHtml(t.logicalName) + '" ' +
          'class="combo-option' + (i === 0 ? " active" : "") + '" aria-selected="' + (t.logicalName === s.selectedValue ? "true" : "false") + '">' +
          '<span class="combo-option-label">' + escapeHtml(t.label) + '</span>' +
          '<span class="combo-option-logical">' + escapeHtml(t.logicalName) + '</span></li>';
      }).join("");
    }
    updateTablePickerActiveDescendant(prefix);
  }

  function updateTablePickerActiveDescendant(prefix) {
    var s = tablePickerState[prefix];
    var search = document.getElementById(prefix + "Search");
    var listbox = document.getElementById(prefix + "Listbox");
    Array.prototype.forEach.call(listbox.querySelectorAll(".combo-option"), function (li, i) {
      li.classList.toggle("active", i === s.activeIndex);
    });
    if (s.activeIndex >= 0) {
      search.setAttribute("aria-activedescendant", prefix + "Opt" + s.activeIndex);
      var activeLi = document.getElementById(prefix + "Opt" + s.activeIndex);
      if (activeLi && activeLi.scrollIntoView) { activeLi.scrollIntoView({ block: "nearest" }); }
    } else {
      search.removeAttribute("aria-activedescendant");
    }
  }

  /* The panel mirrors the listbox's hidden state exactly. The listbox stays the
     single source of truth for "is the list open" so every existing
     listbox.hidden check keeps working unchanged; the panel just carries the
     border, shadow, and pinned footer around it. */
  function openTablePickerList(prefix) {
    document.getElementById(prefix + "Listbox").hidden = false;
    document.getElementById(prefix + "Panel").hidden = false;
    document.getElementById(prefix + "Search").setAttribute("aria-expanded", "true");
  }

  /** Closes the list. When revert is true (blur, Escape) any typed-but-not-committed text is
   * dropped in favor of the last real selection, so the field can never silently drift to
   * freeform garbage the way a mistyped free-text fallback could. */
  function closeTablePickerList(prefix, revert) {
    var listbox = document.getElementById(prefix + "Listbox");
    if (listbox.hidden) { return; }
    listbox.hidden = true;
    document.getElementById(prefix + "Panel").hidden = true;
    document.getElementById(prefix + "Search").setAttribute("aria-expanded", "false");
    document.getElementById(prefix + "Search").removeAttribute("aria-activedescendant");
    if (revert) { setTablePickerSelection(prefix, tablePickerState[prefix].selectedValue); }
  }

  function setTablePickerSelection(prefix, logicalName) {
    var s = tablePickerState[prefix];
    s.selectedValue = logicalName || "";
    document.getElementById(prefix).value = s.selectedValue;
    var lists = currentTableLists(prefix);
    var match = findTableOption(lists.all, s.selectedValue) || findTableOption(lists.filtered, s.selectedValue);
    var search = document.getElementById(prefix + "Search");
    if (!s.selectedValue) {
      search.value = "";
    } else if (match) {
      search.value = match.displayLabel;
    } else {
      search.value = s.selectedValue + " (not found in this environment)";
    }
  }

  function commitTablePickerSelection(prefix, logicalName) {
    setTablePickerSelection(prefix, logicalName);
    closeTablePickerList(prefix, false);
    document.getElementById(prefix + "Search").focus();
    // The New template modal's line-items relationship picker (section 4, below) lists the BOUND
    // table's own relationships, so a fresh bound-table pick invalidates whatever it was showing.
    if (prefix === "newBoundTable") { onNewBoundTableChanged(); }
  }

  function handleTablePickerKeydown(prefix, ev) {
    var s = tablePickerState[prefix];
    var listbox = document.getElementById(prefix + "Listbox");
    if (ev.key === "ArrowDown") {
      ev.preventDefault();
      if (listbox.hidden) {
        renderTablePickerOptions(prefix, document.getElementById(prefix + "Search").value);
        openTablePickerList(prefix);
        return;
      }
      if (s.results.length) { s.activeIndex = (s.activeIndex + 1) % s.results.length; updateTablePickerActiveDescendant(prefix); }
    } else if (ev.key === "ArrowUp") {
      ev.preventDefault();
      if (!listbox.hidden && s.results.length) { s.activeIndex = (s.activeIndex - 1 + s.results.length) % s.results.length; updateTablePickerActiveDescendant(prefix); }
    } else if (ev.key === "Enter") {
      if (!listbox.hidden && s.activeIndex >= 0 && s.results[s.activeIndex]) {
        ev.preventDefault();
        commitTablePickerSelection(prefix, s.results[s.activeIndex].logicalName);
      }
    } else if (ev.key === "Escape") {
      if (!listbox.hidden) { ev.preventDefault(); closeTablePickerList(prefix, true); }
    }
  }

  function wireTablePicker(prefix) {
    var search = document.getElementById(prefix + "Search");
    var listbox = document.getElementById(prefix + "Listbox");
    var toggle = document.getElementById(prefix + "Toggle");

    search.addEventListener("input", function () { renderTablePickerOptions(prefix, search.value); openTablePickerList(prefix); });
    search.addEventListener("focus", function () { renderTablePickerOptions(prefix, search.value); openTablePickerList(prefix); });
    search.addEventListener("keydown", function (ev) { handleTablePickerKeydown(prefix, ev); });
    // Delayed so a mousedown on a listbox option (below) still lands before the list closes.
    search.addEventListener("blur", function () { setTimeout(function () { closeTablePickerList(prefix, true); }, 120); });

    // Same "don't let a click steal the focus/selection I actually want" concern as the editor
    // toolbar and its own mousedown guard further down this file.
    listbox.addEventListener("mousedown", function (ev) { ev.preventDefault(); });
    listbox.addEventListener("click", function (ev) {
      var item = ev.target.closest ? ev.target.closest("li[data-logical-name]") : null;
      if (!item) { return; }
      commitTablePickerSelection(prefix, item.getAttribute("data-logical-name"));
    });

    toggle.addEventListener("click", function () {
      var s = tablePickerState[prefix];
      s.showAll = !s.showAll;
      toggle.textContent = s.showAll ? "Show recommended tables only" : "Show all tables";
      toggle.setAttribute("aria-pressed", s.showAll ? "true" : "false");
      renderTablePickerOptions(prefix, search.value);
      openTablePickerList(prefix);
      search.focus();
    });
  }

  /** Rebuilds the picker at id prefix `prefix` (e.g. "setBoundTable") from scratch inside
   * `<prefix>Mount` -- so retrying after a previous failure works -- and wires it up once the
   * (cached) table list resolves; falls back to free text on failure. The hidden input keeps the
   * original id so every existing `document.getElementById(prefix).value` read (Settings save,
   * New template create) keeps working unchanged. */
  function initBoundTableSelect(prefix, selectedValue) {
    var wrap = document.getElementById(prefix + "Wrap");
    Array.prototype.forEach.call(wrap.querySelectorAll(".error-banner"), function (n) { n.parentNode.removeChild(n); });

    tablePickerState[prefix] = { showAll: false, results: [], activeIndex: -1, selectedValue: selectedValue || "" };

    var mount = document.getElementById(prefix + "Mount");
    mount.innerHTML =
      '<input type="hidden" id="' + prefix + '" value="' + escapeHtml(selectedValue || "") + '" />' +
      '<div class="combo">' +
        '<input type="text" id="' + prefix + 'Search" class="combo-input" role="combobox" aria-autocomplete="list" ' +
          'aria-expanded="false" aria-controls="' + prefix + 'Listbox" aria-haspopup="listbox" aria-labelledby="' + prefix + 'Label" ' +
          'autocomplete="off" disabled placeholder="Loading tables…" />' +
        '<div class="combo-panel" id="' + prefix + 'Panel" hidden>' +
          '<ul class="combo-list" id="' + prefix + 'Listbox" role="listbox" hidden></ul>' +
          '<div class="combo-panel-footer">' +
            '<span class="combo-status" id="' + prefix + 'Status"></span>' +
            '<button type="button" class="combo-toggle" id="' + prefix + 'Toggle" hidden>Show all tables</button>' +
          "</div>" +
        "</div>" +
      "</div>";

    loadTableList().then(function (lists) {
      pickerListsByPrefix[prefix] = lists;
      var search = document.getElementById(prefix + "Search");
      search.disabled = false;
      search.placeholder = "Search tables by name…";
      wireTablePicker(prefix);
      setTablePickerSelection(prefix, selectedValue || "");
      var toggle = document.getElementById(prefix + "Toggle");
      toggle.hidden = false;
      var removed = lists.all.length - lists.filtered.length;
      document.getElementById(prefix + "Status").textContent = removed > 0
        ? lists.filtered.length + " of " + lists.all.length + " tables shown"
        : lists.all.length + " table" + (lists.all.length === 1 ? "" : "s");
    }, function (error) {
      useFallbackTextInput(prefix, selectedValue);
      showBoundTableFetchError(prefix + "Wrap", error);
    });
  }

  // ======================================================================
  // 1b. Line-items relationship picker -- lets the New template modal's
  // invoice/quote starters (vdocs_TemplateScaffold) source their line-items
  // loop from a REAL child table instead of always needing caller-supplied
  // data (task: "a line-items relationship picker"). Only shown for those
  // two starter styles -- see updateLineItemsUi in section 4, below.
  //
  // Reuses the exact same searchable-combobox plumbing as the bound-table
  // picker above (tablePickerState / pickerListsByPrefix / wireTablePicker /
  // renderTablePickerOptions / setTablePickerSelection /
  // commitTablePickerSelection), unmodified -- see pickerListsByPrefix's own
  // comment for why one generic {filtered, all} shape covers both picker
  // kinds even though this one has no "recommended" tier of its own.
  //
  // Options are the bound table's real one-to-many relationships
  // (EntityDefinitions OneToManyRelationships), filtered to child tables
  // that pass the SAME hard floor the bound-table picker itself enforces
  // (reusing loadTableList's cached "all" list as the filter, rather than
  // re-deriving it) -- without that filter this list is dominated by
  // Dataverse system plumbing that nobody would ever pick as a line-items
  // source. Verified live against this project's own dev environment:
  // `account` alone has 50 one-to-many relationships, and 18 of them
  // (asyncoperation, bulkdeletefailure, principalobjectattributeaccess,
  // duplicaterecord, syncerror, mailboxtrackingfolder, and others) fail the
  // hard floor and are exactly the ones nobody would want in this list; the
  // remaining ~30 (contact, task, phonecall, appointment, email, ...) are
  // real relationships someone might genuinely template line items from.
  // A long remaining list (a big standard table like account can still show
  // 30) is exactly what the searchable-combobox idiom -- not a plain
  // <select> -- is for: type "contact" and the list narrows immediately,
  // same as it already does for the 493-table bound-table picker.
  // ======================================================================
  var LINE_ITEMS_NONE_VALUE = "";

  function lineItemsRelationshipOptionFrom(rel, childLabel) {
    var navProp = rel.ReferencedEntityNavigationPropertyName || rel.SchemaName;
    return {
      logicalName: rel.SchemaName, // reused generically as this picker's "value" -- see pickerListsByPrefix's comment
      label: childLabel,
      displayLabel: childLabel + " (" + navProp + ")",
      childEntity: rel.ReferencingEntity,
      fromAttribute: rel.ReferencingAttribute,
      toAttribute: rel.ReferencedAttribute,
    };
  }

  /** One-to-many relationships of `boundTable`, filtered to child tables that pass the bound-table
   * picker's own hard floor (passesHardFloor) and shaped/sorted like every other picker's option
   * list. Cached per bound table (state.lineItemsRelationshipsByTable) since flipping the New
   * template modal's starter style back and forth must not re-fetch every time. */
  function loadLineItemsRelationships(boundTable) {
    if (!boundTable) { return Promise.resolve({ filtered: [], all: [] }); }
    var key = boundTable.toLowerCase();
    if (state.lineItemsRelationshipsByTable.hasOwnProperty(key)) { return state.lineItemsRelationshipsByTable[key]; }

    var promise = Promise.all([
      webApiFetch(
        "EntityDefinitions(LogicalName='" + boundTable + "')/OneToManyRelationships" +
        "?$select=SchemaName,ReferencingEntity,ReferencingAttribute,ReferencedAttribute,ReferencedEntityNavigationPropertyName"
      ).then(function (r) { return r.json(); }),
      loadTableList(),
    ]).then(function (results) {
      var rels = results[0].value || [];
      var byLogicalName = {};
      results[1].all.forEach(function (t) { byLogicalName[t.logicalName] = t; });

      var options = rels
        .filter(function (r) {
          return r.ReferencingAttribute && r.ReferencedAttribute && byLogicalName.hasOwnProperty(r.ReferencingEntity);
        })
        .map(function (r) { return lineItemsRelationshipOptionFrom(r, byLogicalName[r.ReferencingEntity].label); })
        .sort(byDisplayLabel);
      return { filtered: options, all: options };
    });

    state.lineItemsRelationshipsByTable[key] = promise;
    return promise;
  }

  /** Rebuilds the line-items relationship picker at id prefix `prefix` (currently only
   * "newLineItems") from scratch for `boundTable` -- same overall shape as
   * initBoundTableSelect, minus the "Show all tables" tier (there is only ever one tier here, so
   * the toggle button stays hidden) and with no free-text fallback (this field is optional: on
   * fetch failure the picker just stays disabled with "None" already selected, exactly like never
   * having opened it -- worse than a picker that fails closed would be a picker that lets you type
   * a relationship schema name freehand and silently gets it wrong). */
  function initRelationshipSelect(prefix, boundTable, selectedValue) {
    tablePickerState[prefix] = { showAll: false, results: [], activeIndex: -1, selectedValue: selectedValue || "" };

    var mount = document.getElementById(prefix + "Mount");
    mount.innerHTML =
      '<input type="hidden" id="' + prefix + '" value="' + escapeHtml(selectedValue || "") + '" />' +
      '<div class="combo">' +
        '<input type="text" id="' + prefix + 'Search" class="combo-input" role="combobox" aria-autocomplete="list" ' +
          'aria-expanded="false" aria-controls="' + prefix + 'Listbox" aria-haspopup="listbox" aria-labelledby="' + prefix + 'Label" ' +
          'autocomplete="off" disabled placeholder="Loading relationships…" />' +
        '<div class="combo-panel" id="' + prefix + 'Panel" hidden>' +
          '<ul class="combo-list" id="' + prefix + 'Listbox" role="listbox" hidden></ul>' +
          '<div class="combo-panel-footer">' +
            '<span class="combo-status" id="' + prefix + 'Status"></span>' +
            '<button type="button" class="combo-toggle" id="' + prefix + 'Toggle" hidden>Show all tables</button>' +
          "</div>" +
        "</div>" +
      "</div>";

    if (!boundTable) {
      document.getElementById(prefix + "Search").placeholder = "Pick a bound table first";
      return;
    }

    loadLineItemsRelationships(boundTable).then(function (lists) {
      pickerListsByPrefix[prefix] = lists;
      var search = document.getElementById(prefix + "Search");
      search.disabled = false;
      search.placeholder = lists.all.length
        ? "None -- search relationships by table name…"
        : "No related tables found for this bound table";
      wireTablePicker(prefix);
      setTablePickerSelection(prefix, selectedValue || "");
      document.getElementById(prefix + "Status").textContent =
        lists.all.length + " relationship" + (lists.all.length === 1 ? "" : "s") + " found";
    }, function (error) {
      var search = document.getElementById(prefix + "Search");
      search.placeholder = "Could not load relationships -- line items will stay caller-supplied";
      var e = apiErrorMessage(error);
      var note = document.createElement("div");
      note.className = "error-banner";
      note.style.marginTop = "6px";
      note.textContent = "Could not load relationships for \"" + boundTable + "\"" + (e.message ? " (" + e.message + ")" : "") + ".";
      document.getElementById(prefix + "Wrap").appendChild(note);
    });
  }

  /** The selected relationship's full option object (childEntity/fromAttribute/toAttribute), or
   * null when "None" (LINE_ITEMS_NONE_VALUE) is selected or nothing has loaded yet. */
  function selectedLineItemsRelationship() {
    var el = document.getElementById("newLineItems");
    if (!el || !el.value) { return null; }
    var lists = pickerListsByPrefix.newLineItems;
    return (lists && findTableOption(lists.all, el.value)) || null;
  }

  // ======================================================================
  // 2. Gallery
  // ======================================================================
  function loadGallery() {
    document.getElementById("galleryLoading").hidden = false;
    document.getElementById("galleryList").hidden = true;
    document.getElementById("galleryEmpty").hidden = true;

    // vdocs_templatefile_name and vdocs_bindingmanifest are both included here (not just
    // statecode) so the gallery can flag a template as incomplete without a second round trip.
    // A File column comes back null via $select when empty and holds the uploaded file's name
    // (a non-empty string) once populated -- confirmed live against DEV: an empty
    // vdocs_templatefile/vdocs_templatefile_name both serialize as JSON null, never omitted or
    // an empty string, so a plain falsy check is reliable. See isTemplateIncomplete().
    return xrm.WebApi.retrieveMultipleRecords(
      ENTITY_LOGICAL_NAME,
      "?$select=vdocs_name,vdocs_boundtable,vdocs_templatetype,vdocs_version,statecode,vdocs_templatefile_name,vdocs_bindingmanifest&$orderby=vdocs_name asc"
    ).then(function (result) {
      state.templates = result.entities || [];
      renderGallery();
    }, function (error) {
      document.getElementById("galleryLoading").hidden = true;
      document.getElementById("galleryEmpty").hidden = false;
      document.getElementById("galleryEmpty").innerHTML = '<div class="error-banner">Could not load templates: ' + escapeHtml(error && error.message) + "</div>";
    });
  }

  function renderGallery() {
    var loading = document.getElementById("galleryLoading");
    var list = document.getElementById("galleryList");
    var empty = document.getElementById("galleryEmpty");
    var noMatch = document.getElementById("galleryNoMatch");
    loading.hidden = true;

    if (state.templates.length === 0) {
      list.hidden = true; empty.hidden = false; noMatch.hidden = true;
      return;
    }
    empty.hidden = true;

    var q = (document.getElementById("gallerySearch").value || "").trim().toLowerCase();
    var visible = state.templates.filter(function (t) {
      if (!q) { return true; }
      return (t.vdocs_name || "").toLowerCase().indexOf(q) >= 0 ||
             (t.vdocs_boundtable || "").toLowerCase().indexOf(q) >= 0;
    });

    if (visible.length === 0) {
      list.hidden = true; noMatch.hidden = false;
      return;
    }
    noMatch.hidden = true; list.hidden = false;

    list.innerHTML = visible.map(function (t) {
      var id = t[ENTITY_LOGICAL_NAME + "id"];
      var active = t.statecode === 0;
      var incomplete = isTemplateIncomplete(t);
      var selectedCls = id === state.selectedId ? " selected" : "";
      return '<div class="tpl-card' + selectedCls + '" data-id="' + id + '">' +
        '<span class="status-dots">' +
          (incomplete ? '<span class="status-dot incomplete" title="Incomplete: missing template file and/or binding manifest"></span>' : "") +
          '<span class="status-dot' + (active ? "" : " inactive") + '" title="' + (active ? "Active" : "Inactive") + '"></span>' +
        "</span>" +
        '<div class="name">' + escapeHtml(t.vdocs_name || "(untitled)") + "</div>" +
        '<div class="meta">' +
          '<span class="type-chip">' + escapeHtml(t.vdocs_templatetype || "?") + "</span>" +
          "<span>" + escapeHtml(t.vdocs_boundtable || "no table") + "</span>" +
          (t.vdocs_version ? "<span>v" + escapeHtml(t.vdocs_version) + "</span>" : "") +
          (incomplete ? '<span class="incomplete-note">Incomplete</span>' : "") +
        "</div></div>";
    }).join("");

    Array.prototype.forEach.call(list.querySelectorAll(".tpl-card"), function (card) {
      card.addEventListener("click", function () { selectTemplate(card.getAttribute("data-id")); });
    });
  }

  document.getElementById("gallerySearch").addEventListener("input", renderGallery);

  // ======================================================================
  // 3. Selecting / loading a template into the editor
  // ======================================================================
  function selectTemplate(id) {
    state.selectedId = id;
    state.pendingFile = null;
    state.knownFields = null;
    state.fieldGroups = null;
    state.editorLoaded = false;
    document.getElementById("pendingFileNote").hidden = true;
    document.getElementById("fileInput").value = "";
    renderGallery();

    document.getElementById("noSelection").hidden = true;
    document.getElementById("editor").hidden = false;
    resetPreview();
    resetValidation();

    return xrm.WebApi.retrieveRecord(ENTITY_LOGICAL_NAME, id,
      "?$select=vdocs_name,vdocs_description,vdocs_boundtable,vdocs_templatetype,vdocs_version,vdocs_language,vdocs_sampledata,vdocs_bindingmanifest,vdocs_templatefile_name,vdocs_editorsource_name,statecode,statuscode"
    ).then(function (record) {
      state.selected = record;
      renderEditorHeader();
      renderSettingsForm();
      document.getElementById("sampleDataText").value = record.vdocs_sampledata || "{\n  \n}";
      document.getElementById("sampleDataFeedback").innerHTML = "";
      loadFields();
      if (currentActiveTabName() === "edit") { renderEditTab(); }
    }, function (error) {
      toast("Could not load template: " + (error && error.message));
    });
  }

  function currentActiveTabName() {
    var el = document.querySelector(".tab.active");
    return el ? el.getAttribute("data-tab") : null;
  }

  function renderEditorHeader() {
    var t = state.selected;
    document.getElementById("tplName").textContent = t.vdocs_name || "(untitled)";
    var badge = document.getElementById("tplBadge");
    var active = t.statecode === 0;
    badge.textContent = active ? "Active" : "Inactive";
    badge.className = "badge " + (active ? "active" : "inactive");
    document.getElementById("tplBoundTable").textContent = t.vdocs_boundtable || "Not set";
    document.getElementById("tplType").textContent = t.vdocs_templatetype || "Not set";
    document.getElementById("tplVersion").textContent = t.vdocs_version || "Not set";
    document.getElementById("toggleActiveBtn").textContent = active ? "Deactivate" : "Activate";

    var hasFile = !!t.vdocs_templatefile_name;
    var hasManifest = !!(t.vdocs_bindingmanifest && t.vdocs_bindingmanifest.trim());
    var flag = document.getElementById("tplIncompleteFlag");
    if (!hasFile || !hasManifest) {
      var missing = [];
      if (!hasFile) { missing.push("no template file"); }
      if (!hasManifest) { missing.push("no binding manifest"); }
      flag.hidden = false;
      document.getElementById("tplIncompleteText").textContent = "Incomplete: " + missing.join(", ");
    } else {
      flag.hidden = true;
    }
  }

  function renderSettingsForm() {
    var t = state.selected;
    document.getElementById("setName").value = t.vdocs_name || "";
    document.getElementById("setDescription").value = t.vdocs_description || "";
    initBoundTableSelect("setBoundTable", t.vdocs_boundtable || "");
    document.getElementById("setTemplateType").value = t.vdocs_templatetype || "docx";
    document.getElementById("setVersion").value = t.vdocs_version || "";
    document.getElementById("setLanguage").value = t.vdocs_language || "";
    document.getElementById("setFetchXml").value = t.vdocs_bindingmanifest || "";
  }

  document.getElementById("toggleActiveBtn").addEventListener("click", function () {
    var active = state.selected.statecode === 0;
    var next = { statecode: active ? 1 : 0, statuscode: active ? 2 : 1 };
    xrm.WebApi.updateRecord(ENTITY_LOGICAL_NAME, state.selectedId, next).then(function () {
      state.selected.statecode = next.statecode;
      var row = state.templates.filter(function (r) { return r[ENTITY_LOGICAL_NAME + "id"] === state.selectedId; })[0];
      if (row) { row.statecode = next.statecode; }
      renderEditorHeader();
      renderGallery();
      toast(active ? "Template deactivated" : "Template activated");
    }, function (error) {
      toast("Could not change status: " + (error && error.message));
    });
  });

  // ---- Replace file ----
  document.getElementById("fileInput").addEventListener("change", function (ev) {
    var file = ev.target.files[0];
    if (!file) { return; }
    state.pendingFile = file;
    var note = document.getElementById("pendingFileNote");
    note.hidden = false;
    note.className = "field-ok";
    note.innerHTML = '<span class="spinner"></span>Uploading ' + escapeHtml(file.name) + "…";
    uploadPendingFile(file);
  });

  function uploadPendingFile(file) {
    var note = document.getElementById("pendingFileNote");
    return file.arrayBuffer().then(function (buf) {
      return uploadFileColumnBytes(state.selectedId, "vdocs_templatefile", new Uint8Array(buf), file.name);
    }).then(function () {
      toast("Template file updated");
      state.selected.vdocs_templatefile_name = file.name;
      var row = state.templates.filter(function (r) { return r[ENTITY_LOGICAL_NAME + "id"] === state.selectedId; })[0];
      if (row) { row.vdocs_templatefile_name = file.name; }
      renderEditorHeader();
      renderGallery();
      resetValidation();
      resetPreview();
      note.className = "field-ok";
      note.textContent = "Selected " + file.name + ". It will be used for Validate/Preview immediately. Click Settings → Save changes, or run Validate/Preview, to keep using it; use the file input again to upload it to the saved record.";
    }, function (error) {
      note.className = "error-banner";
      note.textContent = "File upload failed: " + error.message;
      toast("File upload failed: " + error.message);
    });
  }

  // ---- Settings save ----
  document.getElementById("saveSettingsBtn").addEventListener("click", function () {
    var payload = {
      vdocs_name: document.getElementById("setName").value,
      vdocs_description: document.getElementById("setDescription").value,
      vdocs_boundtable: document.getElementById("setBoundTable").value,
      vdocs_templatetype: document.getElementById("setTemplateType").value,
      vdocs_version: document.getElementById("setVersion").value,
      vdocs_language: document.getElementById("setLanguage").value,
      vdocs_bindingmanifest: document.getElementById("setFetchXml").value,
    };
    var busy = startBusy(document.getElementById("saveSettingsBtn"));
    busy.setPhase("Saving…");
    xrm.WebApi.updateRecord(ENTITY_LOGICAL_NAME, state.selectedId, payload).then(function () {
      busy.done();
      Object.assign(state.selected, payload);
      renderEditorHeader();
      loadGallery().then(function () { renderGallery(); });
      var fb = document.getElementById("settingsFeedback");
      fb.hidden = false; fb.textContent = "Saved.";
      setTimeout(function () { fb.hidden = true; }, 2500);
      state.knownFields = null;
      loadFields();
    }, function (error) {
      busy.done();
      toast("Save failed: " + (error && error.message));
    });
  });

  // ======================================================================
  // 4. New template modal
  // ======================================================================
  var newModal = document.getElementById("newModalBackdrop");
  var STARTER_UPLOAD = "upload";
  var STARTER_LABELS = { blank: "blank", letter: "letter", invoice: "invoice", quote: "quote" };

  // Only these two starter styles have a line-items table to source (HtmlScaffoldBuilder's
  // BuildInvoiceOrQuote) -- "blank"/"letter" never show the relationship picker at all (task: "do
  // not clutter blank/letter").
  var LINE_ITEMS_STARTER_STYLES = { invoice: true, quote: true };

  /** Shows/hides and (re)builds the "Where do line items come from?" picker for the current
   * starter style + bound table. Skips rebuilding the picker (and so preserves whatever the author
   * already picked) when it was already built for this exact bound table -- only a real bound-table
   * change (onNewBoundTableChanged) or a fresh modal open forces a rebuild, so flipping the starter
   * dropdown between "invoice" and "quote" never silently resets the selection. */
  function updateLineItemsUi() {
    var starter = document.getElementById("newStarter").value;
    var wrap = document.getElementById("newLineItemsWrap");
    var showsLineItems = LINE_ITEMS_STARTER_STYLES.hasOwnProperty(starter);
    wrap.hidden = !showsLineItems;
    if (!showsLineItems) { return; }

    var boundTable = document.getElementById("newBoundTable").value.trim();
    if (state.lineItemsInitializedForBoundTable !== boundTable) {
      state.lineItemsInitializedForBoundTable = boundTable;
      initRelationshipSelect("newLineItems", boundTable, LINE_ITEMS_NONE_VALUE);
    }
  }

  /** Bound table changed in the New template modal -- whatever the line-items picker showed no
   * longer applies (it lists the OLD bound table's relationships), so force updateLineItemsUi to
   * rebuild it from scratch next time it runs, rather than trusting the stale cache key. */
  function onNewBoundTableChanged() {
    state.lineItemsInitializedForBoundTable = null;
    updateLineItemsUi();
  }

  /** Starter styles (worked example 16) only ever produce a .docx, so picking one pins the
   * Template type dropdown to docx instead of leaving it free to disagree with what will
   * actually be generated. Switching back to "Upload my own file" frees it again. */
  function updateStarterUi() {
    var starter = document.getElementById("newStarter").value;
    var isUpload = starter === STARTER_UPLOAD;
    document.getElementById("newFileWrap").hidden = !isUpload;
    var typeSelect = document.getElementById("newTemplateType");
    var note = document.getElementById("newStarterNote");
    if (isUpload) {
      typeSelect.disabled = false;
      note.hidden = true;
    } else {
      typeSelect.value = "docx";
      typeSelect.disabled = true;
      note.hidden = false;
      note.textContent = "A " + STARTER_LABELS[starter] + " starter .docx will be generated for the bound table above and used as this template's file.";
    }
    updateLineItemsUi();
  }
  document.getElementById("newStarter").addEventListener("change", updateStarterUi);

  document.getElementById("newTemplateBtn").addEventListener("click", function () {
    document.getElementById("newName").value = "";
    state.lineItemsInitializedForBoundTable = null;
    initBoundTableSelect("newBoundTable", "");
    document.getElementById("newTemplateType").value = "docx";
    document.getElementById("newVersion").value = "1.0.0";
    document.getElementById("newFile").value = "";
    document.getElementById("newStarter").value = STARTER_UPLOAD;
    updateStarterUi();
    document.getElementById("newModalError").hidden = true;
    newModal.hidden = false;
  });
  document.getElementById("newModalClose").addEventListener("click", function () { newModal.hidden = true; });
  document.getElementById("newModalCancel").addEventListener("click", function () { newModal.hidden = true; });

  // A new template's vdocs_bindingmanifest is set to this default so the row is usable by
  // TemplateRowPipeline.ResolveEffectiveFetchXml / FetchXmlRowResolver.Resolve the moment it's
  // created (no filter/condition needed: the target row comes from service.Retrieve(id), not
  // from the FetchXML). boundTable is validated against Dataverse's own logical-name grammar
  // before interpolation -- defense in depth, since useFallbackTextInput() can turn the bound
  // table picker into free text if the EntityDefinitions fetch fails, so the value is not always
  // constrained to the dropdown's own real logical names.
  var LOGICAL_NAME_RE = /^[a-z][a-z0-9_]*$/i;

  /** `lineItemsRelationship`, when given (an object from selectedLineItemsRelationship(): {
   * childEntity, fromAttribute, toAttribute }), adds the matching <link-entity> so
   * vdocs_TemplateFromRow/GenerateAndAttach/TemplateBatch actually pull the child rows the
   * scaffolded starter's {{#each}} loop expects (task PART 3, CRITICAL -- a generated template
   * whose loop tokens resolve to nothing is exactly the failure this feature exists to remove).
   * alias="lineitems" MUST match HtmlScaffoldBuilder.LineItemsAlias exactly: that is the literal
   * string the Core scaffolder's own {{#each account.lineitems}} token is built from
   * (docs/TEMPLATE-SYNTAX.md section 12.2's <parent>.<alias> shape) -- FetchXmlRowResolver.Resolve
   * (already proven against a real two-hop relationship, see that class's own doc comment) walks
   * this <link-entity> exactly like any other. link-type="outer" documents the intent (a bound row
   * with zero matching child rows should still resolve -- as an empty array, TEMPLATE-SYNTAX.md
   * section 12.2's "0 elements" case -- not be excluded); FetchXmlRowResolver never executes this
   * manifest as a literal join (it walks the parsed XML and queries each hop separately, see that
   * class's own "Nested link-entities" comment), so this attribute is inert today but correct. */
  function buildDefaultBindingManifest(boundTable, lineItemsRelationship) {
    if (!LOGICAL_NAME_RE.test(boundTable)) { return null; }
    var linkEntity = "";
    if (lineItemsRelationship) {
      linkEntity =
        "\n    <link-entity name=\"" + escapeHtml(lineItemsRelationship.childEntity) + "\" " +
        "from=\"" + escapeHtml(lineItemsRelationship.fromAttribute) + "\" " +
        "to=\"" + escapeHtml(lineItemsRelationship.toAttribute) + "\" " +
        "alias=\"lineitems\" link-type=\"outer\">\n      <all-attributes />\n    </link-entity>";
    }
    return "<fetch>\n  <entity name=\"" + boundTable + "\">\n    <all-attributes />" + linkEntity + "\n  </entity>\n</fetch>";
  }

  document.getElementById("newModalCreate").addEventListener("click", function () {
    var name = document.getElementById("newName").value.trim();
    var boundTable = document.getElementById("newBoundTable").value.trim();
    var templateType = document.getElementById("newTemplateType").value;
    var version = document.getElementById("newVersion").value.trim();
    var starter = document.getElementById("newStarter").value;
    var file = starter === STARTER_UPLOAD ? document.getElementById("newFile").files[0] : null;
    // Only meaningful for invoice/quote (LINE_ITEMS_STARTER_STYLES) -- selectedLineItemsRelationship()
    // reads #newLineItems, which stays hidden/uninitialized for every other style, so gating on
    // starter here rather than trusting the field's own hidden-ness keeps this correct even if the
    // modal's DOM state is ever stale.
    var lineItemsRelationship = LINE_ITEMS_STARTER_STYLES.hasOwnProperty(starter) ? selectedLineItemsRelationship() : null;
    var errEl = document.getElementById("newModalError");

    if (!name || !boundTable) {
      errEl.hidden = false;
      errEl.innerHTML = '<div class="error-banner">Name and bound table are required.</div>';
      return;
    }

    var defaultManifest = buildDefaultBindingManifest(boundTable, lineItemsRelationship);
    if (!defaultManifest) {
      errEl.hidden = false;
      errEl.innerHTML = '<div class="error-banner">Bound table must be a valid Dataverse logical name (letters, numbers, and underscores, starting with a letter).</div>';
      return;
    }

    var createBtn = document.getElementById("newModalCreate");
    var busy = startBusy(createBtn);
    errEl.hidden = true;

    // A starter style (worked example 16) generates the .docx via vdocs_TemplateScaffold before
    // the row even exists; "Upload my own file" keeps the plain local-file path, which stays
    // genuinely optional exactly as before (commit 0ab6d76) -- picking no file at all still
    // creates a usable, if incomplete, row.
    var scaffoldPromise;
    if (starter === STARTER_UPLOAD) {
      busy.setPhase("Creating…");
      scaffoldPromise = Promise.resolve(null);
    } else {
      busy.setPhase("Generating starter…");
      var scaffoldOptions = { style: starter, boundTable: boundTable };
      if (lineItemsRelationship) { scaffoldOptions.lineItemsChildEntity = lineItemsRelationship.childEntity; }
      scaffoldPromise = executeAction("vdocs_TemplateScaffold", {
        Options: JSON.stringify(scaffoldOptions),
      }, { Options: STRING_PARAM });
    }

    scaffoldPromise.then(function (scaffolded) {
      busy.setPhase("Creating template…");
      return xrm.WebApi.createRecord(ENTITY_LOGICAL_NAME, {
        vdocs_name: name,
        vdocs_boundtable: boundTable,
        vdocs_templatetype: scaffolded ? "docx" : templateType,
        vdocs_version: version,
        vdocs_sampledata: "{\n  \n}",
        vdocs_bindingmanifest: defaultManifest,
      }).then(function (created) {
        if (scaffolded) {
          // vdocs_TemplateScaffold returns both the generated .docx (OutFileContent) and
          // the same content as HTML (OutEditorHtml) -- store both, exactly like the Edit
          // tab's own Save does (uploadFileColumnBytes(... "vdocs_editorsource" ...) below),
          // so the new starter opens straight into the Edit tab instead of only existing
          // inside a .docx you'd have to download and open in Word.
          busy.setPhase("Uploading file…");
          return Promise.all([
            uploadFileColumnBytes(created.id, "vdocs_templatefile", base64ToUint8Array(scaffolded.OutFileContent), scaffolded.OutFileName),
            uploadFileColumnBytes(created.id, "vdocs_editorsource", stringToUint8Array(scaffolded.OutEditorHtml), (name || "template") + ".html"),
          ]).then(function () { return created.id; });
        }
        if (!file) { return created.id; }
        busy.setPhase("Uploading file…");
        return file.arrayBuffer().then(function (buf) {
          return uploadFileColumnBytes(created.id, "vdocs_templatefile", new Uint8Array(buf), file.name);
        }).then(function () { return created.id; });
      });
    }).then(function (id) {
      busy.done();
      newModal.hidden = true;
      toast("Template created");
      return loadGallery().then(function () { selectTemplate(id); });
    }, function (error) {
      busy.done();
      var e = apiErrorMessage(error);
      errEl.hidden = false;
      errEl.innerHTML = '<div class="error-banner">Could not create template: ' + escapeHtml(e.message) + "</div>";
    });
  });

  // ======================================================================
  // 5. Tabs
  // ======================================================================
  Array.prototype.forEach.call(document.querySelectorAll(".tab"), function (tab) {
    tab.addEventListener("click", function () {
      Array.prototype.forEach.call(document.querySelectorAll(".tab"), function (t) { t.classList.remove("active"); });
      Array.prototype.forEach.call(document.querySelectorAll(".panel"), function (p) { p.classList.remove("active"); });
      tab.classList.add("active");
      document.querySelector('.panel[data-panel="' + tab.getAttribute("data-tab") + '"]').classList.add("active");
      if (tab.getAttribute("data-tab") === "edit") { renderEditTab(); }
    });
  });

  // ======================================================================
  // 6. Field browser -- EntityDefinitions metadata for the bound table plus
  // every related entity reachable via the template's FetchXML link-entities
  // (task requirement #2). Also builds state.knownFields, the normalized
  // path list vdocs_ValidateTemplateTokens checks tokens against.
  // ======================================================================
  function loadFields() {
    var loading = document.getElementById("fieldsLoading");
    var errorEl = document.getElementById("fieldsError");
    var body = document.getElementById("fieldsBody");
    loading.hidden = false; errorEl.hidden = true; body.innerHTML = "";

    var boundTable = state.selected.vdocs_boundtable;
    if (!boundTable) {
      loading.hidden = true;
      errorEl.hidden = false;
      errorEl.innerHTML = "This template has no Bound Table set yet. Set one in the Settings tab to browse its fields.";
      return;
    }

    var relations = parseRelatedEntities(state.selected.vdocs_bindingmanifest || "");
    var toFetch = [{ alias: null, entity: boundTable, path: [boundTable] }]
      .concat(relations.map(function (r) { return { alias: r.alias, entity: r.entity, path: [boundTable].concat(r.aliasPath) }; }));

    Promise.all(toFetch.map(function (t) {
      return fetchAttributes(t.entity).then(function (attrs) { return { def: t, attrs: attrs }; })
        .catch(function (error) { return { def: t, error: error }; });
    })).then(function (groups) {
      loading.hidden = true;
      state.fieldGroups = groups;
      state.knownFields = [];
      groups.forEach(function (g) {
        if (g.attrs) {
          g.attrs.forEach(function (a) { state.knownFields.push(g.def.path.join(".") + "." + a.LogicalName); });
        }
      });
      renderFields();
    });
  }

  function fetchAttributes(entityLogicalName) {
    return webApiFetch(
      "EntityDefinitions(LogicalName='" + entityLogicalName + "')/Attributes" +
      "?$select=LogicalName,DisplayName,AttributeType&$filter=IsValidForRead eq true"
    ).then(function (r) { return r.json(); }).then(function (body) {
      return (body.value || [])
        .filter(function (a) { return a.AttributeType !== "Virtual"; })
        .map(function (a) {
          var label = (a.DisplayName && a.DisplayName.UserLocalizedLabel && a.DisplayName.UserLocalizedLabel.Label) || a.LogicalName;
          return { LogicalName: a.LogicalName, Label: label, Type: a.AttributeType };
        })
        .sort(function (x, y) { return x.LogicalName < y.LogicalName ? -1 : 1; });
    });
  }

  /** Walks <link-entity> elements (recursively, arbitrary depth) in a FetchXML string, returning {entity, alias, aliasPath}. */
  function parseRelatedEntities(fetchXml) {
    if (!fetchXml || !fetchXml.trim()) { return []; }
    var out = [];
    try {
      var doc = new DOMParser().parseFromString(fetchXml, "text/xml");
      if (doc.querySelector("parsererror")) { return []; }
      var root = doc.querySelector("entity");
      if (!root) { return []; }
      walk(root, []);
    } catch (e) { /* malformed FetchXML -- field browser just shows the root table */ }
    return out;

    function walk(el, ancestorPath) {
      Array.prototype.forEach.call(el.children, function (child) {
        if (child.tagName === "link-entity") {
          var entity = child.getAttribute("name");
          var alias = child.getAttribute("alias") || entity;
          var aliasPath = ancestorPath.concat([alias]);
          if (entity) { out.push({ entity: entity, alias: alias, aliasPath: aliasPath }); }
          walk(child, aliasPath);
        }
      });
    }
  }

  function renderFields() {
    var body = document.getElementById("fieldsBody");
    var q = (document.getElementById("fieldSearch").value || "").trim().toLowerCase();

    var html = state.fieldGroups.map(function (g) {
      if (g.error) {
        return '<div class="field-group"><div class="field-group-title">' + escapeHtml(g.def.entity) +
          '</div><div class="error-banner">Could not load metadata for "' + escapeHtml(g.def.entity) + '": ' + escapeHtml(g.error.message) + "</div></div>";
      }
      var rows = g.attrs.filter(function (a) {
        if (!q) { return true; }
        return a.LogicalName.toLowerCase().indexOf(q) >= 0 || a.Label.toLowerCase().indexOf(q) >= 0;
      });
      if (rows.length === 0) { return ""; }

      var pathPrefix = g.def.path.join(".");
      var titleLabel = g.def.alias ? g.def.entity + " related entity" : g.def.entity + " (bound table)";
      var tagHtml = g.def.alias ? '<span class="rel-tag">via ' + escapeHtml(g.def.alias) + "</span>" : "";

      var rowsHtml = rows.map(function (a) {
        var token = "{{" + pathPrefix + "." + a.LogicalName + "}}";
        return '<div class="field-row" data-token="' + escapeHtml(token) + '" role="button" tabindex="0" ' +
          'title="Insert this token at the cursor">' +
          '<div><span class="fname">' + escapeHtml(a.LogicalName) + '</span><span class="flabel">' + escapeHtml(a.Label) + "</span></div>" +
          '<span class="token">' + escapeHtml(token) + "</span></div>";
      }).join("");

      return '<div class="field-group"><div class="field-group-title">' + escapeHtml(titleLabel) + tagHtml + '</div><div class="field-list">' + rowsHtml + "</div></div>";
    }).join("");

    body.innerHTML = html || '<div class="empty-state">No fields match your search.</div>';

    Array.prototype.forEach.call(body.querySelectorAll(".field-row"), function (row) {
      row.addEventListener("click", function () { insertFieldToken(row.getAttribute("data-token")); });
      // role="button" on a <div> gets no native Enter/Space activation, unlike a real <button>;
      // a plain <button> was avoided here because it can't hold the row's flex layout (name+label
      // on one side, token on the other) as cleanly as the existing .field-row div does elsewhere
      // in this file (the old Fields tab used the same div+role=button-less pattern with a mouse
      // only -- this is the fix, not a repeat of that gap).
      row.addEventListener("keydown", function (ev) {
        if (ev.key === "Enter" || ev.key === " " || ev.key === "Spacebar") {
          ev.preventDefault();
          insertFieldToken(row.getAttribute("data-token"));
        }
      });
    });
  }

  /** Clicking (or Enter/Space-activating) a field row inserts its token at the editor's cursor --
   * this is the same primitive the old "Insert token" dropdown used (insertTokenAtCaret, defined
   * in the Edit tab section below; function declarations are hoisted, and by the time a user can
   * actually click a row the whole script has already run once top to bottom, so editorEl is long
   * since assigned). Falls back to copying the token when there's no editor to insert into (the
   * template has no editor-authored source yet -- see renderEditTab), matching what a field click
   * did before this panel existed. */
  function insertFieldToken(token) {
    if (canInsertIntoEditor()) {
      insertTokenAtCaret(token);
      toast("Inserted " + token);
    } else {
      copyToClipboard(token);
    }
  }

  function canInsertIntoEditor() {
    var surface = document.getElementById("editSurface");
    return !!surface && !surface.hidden && editorEl.contentEditable === "true";
  }

  // A field row is a <div>, not a native form control, so a plain click would not normally steal
  // focus away from the editor -- but preventing default on mousedown here is the same defensive
  // move as the editor toolbar's own guard: it guarantees the caret the user left in the editor is
  // still exactly there when insertTokenAtCaret runs, on every engine, rather than relying on that
  // incidental non-focusable-element behavior.
  document.getElementById("fieldPanel").addEventListener("mousedown", function (ev) {
    if (ev.target.closest && ev.target.closest(".field-row")) { ev.preventDefault(); }
  });

  document.getElementById("fieldSearch").addEventListener("input", renderFields);
  document.getElementById("reloadFieldsBtn").addEventListener("click", loadFields);

  // ======================================================================
  // 7. Token validation -- the highest-value feature. Downloads (or uses
  // the pending un-saved) template bytes, calls vdocs_ValidateTemplateTokens
  // with the known-field list the field browser already resolved, and
  // renders unknown fields by name with a "did you mean...?" suggestion.
  // ======================================================================
  function resetValidation() {
    document.getElementById("validateBody").innerHTML = '<div class="empty-state">Click "Validate tokens" to parse every {{token}} in this template\'s file and check it against the bound table\'s real fields.</div>';
    document.getElementById("validateCount").hidden = true;
  }

  function getTemplateFileBase64() {
    if (state.pendingFile) { return fileToBase64(state.pendingFile); }
    return webApiFetch(templateEntitySetName + "(" + state.selectedId + ")/vdocs_templatefile/$value")
      .then(function (r) { return r.arrayBuffer(); })
      .then(arrayBufferToBase64);
  }

  document.getElementById("runValidationBtn").addEventListener("click", function () {
    var statusEl = document.getElementById("validateStatus");
    var bodyEl = document.getElementById("validateBody");
    statusEl.hidden = false; statusEl.innerHTML = '<span class="spinner"></span>Validating…';
    bodyEl.innerHTML = "";

    var ready = state.knownFields ? Promise.resolve() : new Promise(function (resolve) {
      var check = setInterval(function () { if (state.knownFields) { clearInterval(check); resolve(); } }, 150);
    });

    ready.then(getTemplateFileBase64).then(function (base64) {
      return executeAction("vdocs_ValidateTemplateTokens", {
        FileName: (state.selected.vdocs_name || "template") + "." + state.selected.vdocs_templatetype,
        FileContent: base64,
        Options: JSON.stringify({ templateType: state.selected.vdocs_templatetype, knownFields: state.knownFields || [] }),
      }, { FileName: STRING_PARAM, FileContent: STRING_PARAM, Options: STRING_PARAM });
    }).then(function (body) {
      statusEl.hidden = true;
      renderValidationReport(JSON.parse(body.Result));
    }, function (error) {
      statusEl.hidden = true;
      renderErrorBanner(bodyEl, error);
    });
  });

  function renderValidationReport(report) {
    var bodyEl = document.getElementById("validateBody");
    var countEl = document.getElementById("validateCount");

    if (report.unknownFieldCount > 0) {
      countEl.hidden = false; countEl.textContent = report.unknownFieldCount;
    } else {
      countEl.hidden = true;
    }

    if (report.totalTokenCount === 0) {
      bodyEl.innerHTML = '<div class="empty-state">No {{tokens}} were found in this template file.</div>';
      return;
    }

    var bannerCls = report.unknownFieldCount > 0 ? "problem" : "ok";
    var bannerText = report.unknownFieldCount > 0
      ? report.unknownFieldCount + " of " + report.distinctFieldCount + " field" + (report.distinctFieldCount === 1 ? "" : "s") + " referenced in this template " + (report.unknownFieldCount === 1 ? "does" : "do") + " not match the bound table's schema."
      : "All " + report.distinctFieldCount + " field" + (report.distinctFieldCount === 1 ? "" : "s") + " referenced in this template match the schema.";

    var fields = report.fields.slice().sort(function (a, b) {
      if (a.isKnown !== b.isKnown) { return a.isKnown ? 1 : -1; }
      return a.path < b.path ? -1 : 1;
    });

    var cardsHtml = fields.map(function (f) {
      var cls = f.isKnown ? "known" : "unknown";
      var icon = f.isKnown
        ? '<svg class="icon" style="fill:var(--good)" viewBox="0 0 24 24"><path d="M9 16.2 4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4z"/></svg>'
        : '<svg class="icon" style="fill:var(--bad)" viewBox="0 0 24 24"><path d="M12 2 1 21h22zm0 5.5 6.9 11.9H5.1zM11 10h2v5h-2zm0 6h2v2h-2z"/></svg>';
      var suggestionHtml = "";
      if (!f.isKnown) {
        suggestionHtml = f.suggestion
          ? '<div class="suggestion">Did you mean <span class="stoken">{{' + escapeHtml(f.suggestion) + '}}</span>? ' +
            '<button class="btn btn-outline btn-sm copy-suggestion" data-token="{{' + escapeHtml(f.suggestion) + '}}">Copy fix</button></div>'
          : '<div class="no-suggestion">No close match found in the known field list. Check the bound table/related entity, or that this field actually exists.</div>';
      }
      return '<div class="issue-card ' + cls + '">' +
        '<div class="row1"><div>' + icon + ' <span class="badtoken">{{' + escapeHtml(f.path) + '}}</span></div>' +
        '<span class="occurrences">' + f.occurrenceCount + " use" + (f.occurrenceCount === 1 ? "" : "s") + "</span></div>" +
        suggestionHtml + "</div>";
    }).join("");

    var imagesHtml = report.imageNames && report.imageNames.length
      ? '<div style="margin-top:16px;font-size:11.8px;color:var(--ink-soft);">Image tokens referenced (not schema-checked): ' +
        report.imageNames.map(function (n) { return '<code>' + escapeHtml(n) + '</code>'; }).join(", ") + "</div>"
      : "";

    bodyEl.innerHTML =
      '<div class="summary-banner ' + bannerCls + '">' + escapeHtml(bannerText) + "</div>" +
      '<div class="issue-list">' + cardsHtml + "</div>" + imagesHtml;

    Array.prototype.forEach.call(bodyEl.querySelectorAll(".copy-suggestion"), function (btn) {
      btn.addEventListener("click", function () { copyToClipboard(btn.getAttribute("data-token")); });
    });
  }

  // ======================================================================
  // 8. Sample data editor
  // ======================================================================
  document.getElementById("formatJsonBtn").addEventListener("click", function () {
    var ta = document.getElementById("sampleDataText");
    try {
      ta.value = JSON.stringify(JSON.parse(ta.value), null, 2);
      ta.classList.remove("error");
      document.getElementById("sampleDataFeedback").innerHTML = "";
    } catch (e) {
      showSampleDataError(e.message);
    }
  });

  function showSampleDataError(message) {
    var ta = document.getElementById("sampleDataText");
    ta.classList.add("error");
    document.getElementById("sampleDataFeedback").innerHTML = '<div class="field-error">Invalid JSON: ' + escapeHtml(message) + "</div>";
  }

  document.getElementById("saveSampleDataBtn").addEventListener("click", function () {
    var ta = document.getElementById("sampleDataText");
    try {
      JSON.parse(ta.value);
    } catch (e) {
      showSampleDataError(e.message);
      return;
    }
    ta.classList.remove("error");
    xrm.WebApi.updateRecord(ENTITY_LOGICAL_NAME, state.selectedId, { vdocs_sampledata: ta.value }).then(function () {
      state.selected.vdocs_sampledata = ta.value;
      document.getElementById("sampleDataFeedback").innerHTML = '<div class="field-ok">Saved.</div>';
      setTimeout(function () { document.getElementById("sampleDataFeedback").innerHTML = ""; }, 2000);
    }, function (error) {
      toast("Save failed: " + (error && error.message));
    });
  });

  // ======================================================================
  // 9. Live preview
  // ======================================================================
  function resetPreview() {
    document.getElementById("previewBody").innerHTML = '<div class="empty-state">Click Generate to render this template against the Sample Data tab\'s JSON.</div>';
  }

  var TEMPLATE_ACTION_BY_TYPE = { docx: "vdocs_TemplateDocx", xlsx: "vdocs_TemplateXlsx", pptx: "vdocs_TemplatePptx" };

  document.getElementById("refreshPreviewBtn").addEventListener("click", function () {
    if (!state.selected) { return; }
    var previewBody = document.getElementById("previewBody");
    var sampleDataRaw = document.getElementById("sampleDataText").value;

    var data;
    try {
      data = JSON.parse(sampleDataRaw);
    } catch (e) {
      previewBody.innerHTML = '<div class="error-banner">Sample Data JSON is invalid: ' + escapeHtml(e.message) + ". Fix it on the Sample Data tab first.</div>";
      return;
    }

    previewBody.innerHTML = '<div class="loading-state"><span class="spinner"></span>Rendering…</div>';

    var templateType = state.selected.vdocs_templatetype;
    var actionName = TEMPLATE_ACTION_BY_TYPE[templateType];
    var wantsPdf = templateType === "docx";

    getTemplateFileBase64().then(function (base64) {
      return executeAction(actionName, {
        FileName: (state.selected.vdocs_name || "template") + "." + templateType,
        FileContent: base64,
        Data: JSON.stringify(data),
        Options: JSON.stringify(wantsPdf ? { outputFormat: "pdf" } : {}),
      }, { FileName: STRING_PARAM, FileContent: STRING_PARAM, Data: STRING_PARAM, Options: STRING_PARAM });
    }).then(function (body) {
      var meta = body.Meta ? JSON.parse(body.Meta) : {};
      var blob = base64ToBlob(body.OutFileContent, body.OutContentType);
      var url = URL.createObjectURL(blob);

      if (wantsPdf && body.OutContentType === "application/pdf") {
        previewBody.innerHTML =
          '<div class="preview-frame-wrap"><iframe src="' + url + '" title="Document preview"></iframe></div>' +
          '<div class="preview-meta">' +
            (meta.pages ? "<span>" + meta.pages + " page" + (meta.pages === 1 ? "" : "s") + "</span>" : "") +
            (meta.engine ? "<span>engine: " + escapeHtml(meta.engine) + "</span>" : "") +
            (meta.durationMs != null ? "<span>" + meta.durationMs + " ms</span>" : "") +
          "</div>";
      } else {
        previewBody.innerHTML =
          '<div class="empty-state">PDF inline preview is available for Word (docx) templates today. This ' + escapeHtml(templateType) +
          ' template rendered successfully. Download it to view.</div>' +
          '<div class="preview-download"><a class="btn btn-outline" href="' + url + '" download="' + escapeHtml(body.OutFileName) + '">Download ' + escapeHtml(body.OutFileName) + "</a></div>" +
          '<div class="preview-meta">' + (meta.durationMs != null ? "<span>" + meta.durationMs + " ms</span>" : "") + "</div>";
      }
    }, function (error) {
      renderErrorBanner(previewBody, error);
    });
  });

  // ======================================================================
  // 10. Edit tab -- a contenteditable rich text editor that IS the source of
  // truth for a template's content (see the file-level design note above).
  // Save serializes the editor's HTML, calls vdocs_HtmlToDocx (worked
  // example 17) to regenerate vdocs_templatefile, and stores the HTML
  // itself in the vdocs_editorsource File column so a later edit never has
  // to reverse-engineer HTML from a .docx (which would destroy formatting).
  // A template with no stored vdocs_editorsource (uploaded from Word -- a
  // starter picked from the New Template modal always has one, vdocs_TemplateScaffold
  // returns editor HTML now too) is never opened here -- see renderEditTab.
  // ======================================================================
  var editorEl = document.getElementById("editorSurface");

  // The allow-list mirrors VerseDocs.Core.EmailMarkup.HtmlSanitizeOperation's own defaults plus
  // the same widening vdocs_HtmlToDocx opts into (style/width/height, data:image/* on img src,
  // never data:image/svg+xml) -- see HtmlToDocxPlugin's doc comment. Keeping this list in the
  // client too means what the toolbar can produce, what a paste gets reduced to, and what the
  // server will actually accept are all the same shape, so nothing here can produce markup that
  // HTML_TO_DOCX_CONVERSION_FAILED or, worse, that silently converts to something unexpected.
  var EDITOR_ALLOWED_TAGS = {
    "p": 1, "br": 1, "b": 1, "strong": 1, "i": 1, "em": 1, "u": 1, "a": 1, "ul": 1, "ol": 1, "li": 1,
    "h1": 1, "h2": 1, "h3": 1, "h4": 1, "h5": 1, "h6": 1, "blockquote": 1, "pre": 1, "code": 1,
    "span": 1, "div": 1, "table": 1, "thead": 1, "tbody": 1, "tr": 1, "td": 1, "th": 1, "img": 1, "hr": 1,
  };
  var EDITOR_ALLOWED_ATTRS = { "href": 1, "src": 1, "alt": 1, "title": 1, "class": 1, "style": 1, "width": 1, "height": 1 };

  function isDangerousUri(value, allowDataImage) {
    if (!value) { return false; }
    var trimmed = value.replace(/^\s+/, "");
    if (/^javascript:/i.test(trimmed)) { return true; }
    if (/^data:/i.test(trimmed)) {
      var isAllowedImage = allowDataImage && /^data:image\//i.test(trimmed) && !/^data:image\/svg\+xml/i.test(trimmed);
      return !isAllowedImage;
    }
    return false;
  }

  function sanitizeEditorAttributes(node) {
    var isImgSrc = node.tagName.toLowerCase() === "img";
    var attrs = [];
    Array.prototype.forEach.call(node.attributes, function (a) { attrs.push(a); });
    attrs.forEach(function (attr) {
      var name = attr.name.toLowerCase();
      if (!EDITOR_ALLOWED_ATTRS[name]) { node.removeAttribute(attr.name); return; }
      if ((name === "href" || name === "src") && isDangerousUri(attr.value, isImgSrc && name === "src")) {
        node.removeAttribute(attr.name);
      }
    });
  }

  function sanitizeEditorChildren(parentNode) {
    var children = [];
    Array.prototype.forEach.call(parentNode.childNodes, function (n) { children.push(n); });
    children.forEach(function (node) {
      if (node.nodeType === 8) { parentNode.removeChild(node); return; } // comment (Word paste conditional comments etc.)
      if (node.nodeType === 3) { return; } // text node, keep as-is
      if (node.nodeType !== 1) { parentNode.removeChild(node); return; } // anything else, drop
      var tag = node.tagName.toLowerCase();
      if (!EDITOR_ALLOWED_TAGS[tag]) {
        // Same rule as the server sanitizer: an unlisted element is removed entirely (not
        // unwrapped), so its content only survives if it was already inside an allowed ancestor.
        parentNode.removeChild(node);
        return;
      }
      sanitizeEditorAttributes(node);
      sanitizeEditorChildren(node);
    });
  }

  /** Reduces arbitrary HTML (a paste from Word, or the editor's own saved/loaded content) down to
   * exactly the tag/attribute set the toolbar can produce and vdocs_HtmlToDocx accepts. Used on
   * paste, and again defensively when loading previously-stored vdocs_editorsource content and
   * right before Save -- see this section's doc comment. */
  function sanitizeEditorHtml(html) {
    var container = document.createElement("div");
    container.innerHTML = html;
    sanitizeEditorChildren(container);
    return container.innerHTML;
  }

  // ---- Caret/selection memory --------------------------------------------------------------
  // A toolbar button click never loses the editor's selection on its own: editToolbar's mousedown
  // handler below calls preventDefault(), which stops the browser's default "move focus to the
  // thing I clicked" behavior, so the contenteditable never blurs for a plain formatting command.
  // window.prompt() and the native file-picker dialog DO steal focus (they're outside the page
  // entirely), so anything that opens one saves the Range first and restores it right before
  // inserting -- otherwise the table/image would land wherever the browser happens to leave the
  // caret afterward instead of where the user actually clicked.
  var savedEditorRange = null;

  function saveEditorSelection() {
    var sel = window.getSelection();
    if (sel && sel.rangeCount > 0 && editorEl.contains(sel.anchorNode)) {
      savedEditorRange = sel.getRangeAt(0).cloneRange();
    }
  }

  function restoreEditorSelection() {
    editorEl.focus();
    var sel = window.getSelection();
    sel.removeAllRanges();
    if (savedEditorRange) {
      sel.addRange(savedEditorRange);
      return;
    }
    var range = document.createRange();
    range.selectNodeContents(editorEl);
    range.collapse(false);
    sel.addRange(range);
  }

  // document.execCommand is deprecated, but it remains the only broadly-supported API for driving
  // contenteditable formatting commands (bold/italic/lists/heading blocks/HTML insertion) without
  // a third-party editor library, which CLAUDE.md and this task both rule out. Used throughout
  // this section deliberately, not by oversight.
  function insertHtmlAtCaret(html) {
    document.execCommand("insertHTML", false, html);
  }

  function insertTokenAtCaret(token) {
    editorEl.focus();
    if (document.queryCommandSupported && document.queryCommandSupported("insertText")) {
      document.execCommand("insertText", false, token);
    } else {
      insertHtmlAtCaret(escapeHtml(token));
    }
  }

  function handleInsertTable() {
    saveEditorSelection();
    var rowsStr = window.prompt("How many rows?", "2");
    if (rowsStr === null) { return; }
    var colsStr = window.prompt("How many columns?", "2");
    if (colsStr === null) { return; }
    var rows = parseInt(rowsStr, 10);
    var cols = parseInt(colsStr, 10);
    if (!(rows >= 1 && rows <= 50) || !(cols >= 1 && cols <= 20)) {
      toast("Enter a valid row and column count (1-50 rows, 1-20 columns).");
      return;
    }
    var html = "<table><tbody>";
    for (var r = 0; r < rows; r++) {
      html += "<tr>";
      for (var c = 0; c < cols; c++) { html += "<td>&nbsp;</td>"; }
      html += "</tr>";
    }
    html += "</tbody></table><p><br></p>";
    restoreEditorSelection();
    insertHtmlAtCaret(html);
  }

  document.getElementById("editImageInput").addEventListener("change", function (ev) {
    var file = ev.target.files[0];
    ev.target.value = "";
    if (!file) { return; }
    if (file.type.indexOf("image/") !== 0 || file.type === "image/svg+xml") {
      toast("Choose a PNG, JPEG, GIF, or WEBP image (SVG isn't supported).");
      return;
    }
    var reader = new FileReader();
    reader.onload = function () {
      restoreEditorSelection();
      insertHtmlAtCaret('<img src="' + reader.result + '" alt="" />');
    };
    reader.onerror = function () { toast("Could not read that image file."); };
    reader.readAsDataURL(file);
  });

  // See the "Caret/selection memory" note above: preventing mousedown's default action anywhere in
  // the toolbar is what keeps a plain click from stealing focus (and the selection) away from the
  // editor surface in the first place. Unconditional, not just on the <button> element itself,
  // because a couple of buttons' own labels are formatted with the very tag their command applies
  // (Bold's label is a <b>) -- a click landing on that inner element still needs the same
  // preventDefault, and closest() in the click handler below already walks back up to the button.
  document.getElementById("editToolbar").addEventListener("mousedown", function (ev) {
    ev.preventDefault();
  });
  // Delegated so the buttons don't each need their own listener; every button's own behavior
  // (formatBlock/bold/italic/.../insertTable/insertImage) is dispatched from here by data-cmd.
  document.getElementById("editToolbar").addEventListener("click", function (ev) {
    var btn = ev.target.closest ? ev.target.closest("button[data-cmd]") : null;
    if (!btn) { return; }
    editorEl.focus();
    var cmd = btn.getAttribute("data-cmd");
    if (cmd === "formatBlock") {
      document.execCommand("formatBlock", false, "<" + btn.getAttribute("data-value") + ">");
    } else if (cmd === "insertTable") {
      handleInsertTable();
    } else if (cmd === "insertImage") {
      saveEditorSelection();
      document.getElementById("editImageInput").click();
    } else {
      document.execCommand(cmd, false, null);
    }
  });

  // ---- Insert token: there used to be a second, separate dropdown token picker here, duplicating
  // the Fields tab's own field list. It's gone -- the field panel (#fieldPanel, section 6 above,
  // built from the SAME state.fieldGroups this button used to re-render into a dropdown) is always
  // visible now, so "Insert token" just sends focus there instead of shipping the field browser
  // twice. See insertFieldToken() in section 6 for the actual insert-at-cursor behavior. ----
  document.getElementById("insertTokenBtn").addEventListener("click", function () {
    var panelSearch = document.getElementById("fieldSearch");
    if (panelSearch) { panelSearch.focus(); panelSearch.select(); }
  });

  // ---- Paste: a user WILL paste from Word, so pasted markup is reduced to the same allowed set
  // the toolbar itself produces, instead of letting Word's own bloated HTML (mso- styles, XML
  // namespaces, conditional comments) through to the converter unpredictably. ----
  editorEl.addEventListener("paste", function (ev) {
    ev.preventDefault();
    var clipboardData = ev.clipboardData || window.clipboardData;
    var pastedHtml = clipboardData ? clipboardData.getData("text/html") : "";
    if (pastedHtml) {
      insertHtmlAtCaret(sanitizeEditorHtml(pastedHtml));
      return;
    }
    var pastedText = clipboardData ? clipboardData.getData("text/plain") : "";
    insertHtmlAtCaret(escapeHtml(pastedText).replace(/\n/g, "<br>"));
  });

  function loadEditorSource() {
    var errorEl = document.getElementById("editorSaveError");
    var loadingEl = document.getElementById("editLoading");
    errorEl.hidden = true;
    editorEl.innerHTML = "";
    editorEl.contentEditable = "false";
    document.getElementById("saveEditorBtn").disabled = true;
    loadingEl.hidden = false;

    webApiFetch(templateEntitySetName + "(" + state.selectedId + ")/vdocs_editorsource/$value")
      .then(function (r) { return r.text(); })
      .then(function (text) {
        loadingEl.hidden = true;
        state.editorLoaded = true;
        editorEl.contentEditable = "true";
        document.getElementById("saveEditorBtn").disabled = false;
        // Defense in depth: re-sanitize even our own previously-saved content, in case the file
        // was ever written by something other than this tab (e.g. a direct Web API PATCH).
        editorEl.innerHTML = sanitizeEditorHtml(text);
        document.execCommand("defaultParagraphSeparator", false, "p");
      }, function (error) {
        loadingEl.hidden = true;
        renderErrorBanner(errorEl, error);
        errorEl.hidden = false;
      });
  }

  /** Called whenever the Edit tab becomes active (tab click, or re-selecting a template while the
   * Edit tab is already open). A template with no stored vdocs_editorsource never shows the
   * editor -- see this section's doc comment for why that's not just an inconvenience to route
   * around: reverse-engineering HTML from its .docx would destroy formatting no matter how it's
   * done, so the honest answer is "edit it in Word", not a silent, lossy conversion. */
  function renderEditTab() {
    var hasSource = !!(state.selected && state.selected.vdocs_editorsource_name);
    document.getElementById("editNoSource").hidden = hasSource;
    document.getElementById("editSurface").hidden = !hasSource;
    if (!hasSource) { return; }
    if (!state.editorLoaded) { loadEditorSource(); }
  }

  document.getElementById("saveEditorBtn").addEventListener("click", function () {
    var btn = document.getElementById("saveEditorBtn");
    var feedback = document.getElementById("editorSaveFeedback");
    var errorEl = document.getElementById("editorSaveError");
    var templateId = state.selectedId;
    var sanitized = sanitizeEditorHtml(editorEl.innerHTML);
    var htmlFileName = (state.selected.vdocs_name || "template") + ".html";

    feedback.hidden = true;
    errorEl.hidden = true;
    var busy = startBusy(btn);
    busy.setPhase("Converting…");

    executeAction("vdocs_HtmlToDocx", {
      FileName: htmlFileName,
      FileContent: stringToBase64(sanitized),
    }, { FileName: STRING_PARAM, FileContent: STRING_PARAM }).then(function (converted) {
      busy.setPhase("Uploading…");
      return Promise.all([
        uploadFileColumnBytes(templateId, "vdocs_templatefile", base64ToUint8Array(converted.OutFileContent), converted.OutFileName),
        uploadFileColumnBytes(templateId, "vdocs_editorsource", stringToUint8Array(sanitized), htmlFileName),
      ]).then(function () { return converted; });
    }).then(function (converted) {
      busy.done();
      state.selected.vdocs_templatefile_name = converted.OutFileName;
      state.selected.vdocs_editorsource_name = htmlFileName;
      var row = state.templates.filter(function (r) { return r[ENTITY_LOGICAL_NAME + "id"] === templateId; })[0];
      if (row) {
        row.vdocs_templatefile_name = converted.OutFileName;
        row.vdocs_editorsource_name = htmlFileName;
      }
      renderEditorHeader();
      renderGallery();
      resetValidation();
      resetPreview();
      feedback.hidden = false;
      feedback.textContent = "Saved. The template file was regenerated from this editor's content.";
      setTimeout(function () { feedback.hidden = true; }, 3000);
      toast("Template saved");
    }, function (error) {
      busy.done();
      renderErrorBanner(errorEl, error);
      errorEl.hidden = false;
    });
  });

  // ======================================================================
  // Boot
  // ======================================================================
  resolveEntitySetName().then(function () {
    document.getElementById("layout").hidden = false;
    loadGallery();
  });
})();
</script>
</body>
</html>
