(function () {
  const state = {
    history: [],
    lastScan: window.RPCA && RPCA.lastScan ? RPCA.lastScan : null,
    scanHistory: window.RPCA && Array.isArray(RPCA.scanHistory) ? RPCA.scanHistory : [],
    auditLog: window.RPCA && Array.isArray(RPCA.auditLog) ? RPCA.auditLog : [],
    fdaIntelligence: window.RPCA && RPCA.fdaIntelligence ? RPCA.fdaIntelligence : null,
    addendum2: window.RPCA && RPCA.addendum2 ? RPCA.addendum2 : null,
    uiState: window.RPCA && RPCA.uiState ? RPCA.uiState : null,
    theme: window.RPCA && RPCA.theme ? RPCA.theme : "light",
  activeSection: (window.RPCA && typeof RPCA.initialSection === "string" && RPCA.initialSection) || "cockpit",
  activeSubtab: {},
  currentFindingIndex: 0,
  findingView: "",
  findingQuery: "",
  commandIndex: 0,
  activeFix: null,
  // v0.7.266: large-site mode persists across re-renders (the checkbox
  // appears in multiple places) and across page reloads (localStorage).
  // When true, runShellScan dispatches to runSegmentedScan() instead of
  // post('rpca_run_scan') so the crawl is split into short chunks.
  largeSiteMode:
    typeof window !== "undefined" &&
    window.localStorage &&
    window.localStorage.getItem("rpca-large-site-mode") !== "0",
  // v0.7.284: client-side ledger of finding IDs the merchant has acted on
  // during this session. Items here are dimmed in the default findings list
  // and surfaced in the dedicated "Applied fixes" chip so the merchant can
  // remember which ones they already handled before the next scan re-checks
  // the page. Persisted per scan ID via localStorage.
  appliedFindingIds: new Set(),
};

  // === v0.7.284: Applied-fix ledger helpers =================================
  function appliedLedgerStorageKey() {
    const scanId = (state.lastScan && state.lastScan.scanId) || (state.uiState && state.uiState.lastScanId) || "no-scan";
    return "rpca-applied-fixes:" + String(scanId);
  }
  function loadAppliedLedger() {
    try {
      if (typeof window === "undefined" || !window.localStorage) {
        state.appliedFindingIds = new Set();
        return;
      }
      const raw = window.localStorage.getItem(appliedLedgerStorageKey());
      const parsed = raw ? JSON.parse(raw) : [];
      state.appliedFindingIds = new Set(Array.isArray(parsed) ? parsed.map(String) : []);
    } catch (e) {
      state.appliedFindingIds = new Set();
    }
  }
  function saveAppliedLedger() {
    try {
      if (typeof window === "undefined" || !window.localStorage) return;
      window.localStorage.setItem(
        appliedLedgerStorageKey(),
        JSON.stringify(Array.from(state.appliedFindingIds))
      );
    } catch (e) { /* ignore quota errors */ }
  }
  function markFindingApplied(findingId) {
    if (!findingId) return;
    if (!state.appliedFindingIds || typeof state.appliedFindingIds.add !== "function") {
      state.appliedFindingIds = new Set();
    }
    state.appliedFindingIds.add(String(findingId));
    saveAppliedLedger();
    if (typeof refreshFindingsList === "function") {
      try { refreshFindingsList(); } catch (e) { /* not on findings tab */ }
    }
  }
  function unmarkFindingApplied(findingId) {
    if (!findingId || !state.appliedFindingIds) return;
    state.appliedFindingIds.delete(String(findingId));
    saveAppliedLedger();
    if (typeof refreshFindingsList === "function") {
      try { refreshFindingsList(); } catch (e) { /* not on findings tab */ }
    }
  }
  function isFindingApplied(finding) {
    if (!finding || !finding.id || !state.appliedFindingIds) return false;
    return state.appliedFindingIds.has(String(finding.id));
  }
  loadAppliedLedger();
  // Re-load whenever a fresh scan replaces lastScan so each scan keeps its
  // own ledger and old applied-IDs from a previous scan aren't shown
  // against current findings.
  (function () {
    const _origRemember = typeof rememberScanResult === "function" ? rememberScanResult : null;
    void _origRemember; // hoisting tracking only — actual hook in rememberScanResult below.
  })();

  const el = (id) => document.getElementById(id);
  const legalNotice =
    "This tool surfaces patterns the FDA has cited in past warning letters and patterns high-risk processors flag during underwriting. It is not legal advice. Consult qualified counsel for any specific situation.";

  function escapeHtml(value) {
    const normalized =
      value === null || typeof value === "undefined" ? "" : value;
    return String(normalized)
      .replace(/&/g, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;")
      .replace(/'/g, "&#039;");
  }

  function normalizeNavigableUrl(value) {
    const raw =
      value === null || typeof value === "undefined" ? "" : String(value).trim();
    if (!raw || /[\u0000-\u001F\u007F]/.test(raw)) {
      return "";
    }
    try {
      const parsed = new URL(raw, window.location.origin);
      if (parsed.protocol === "http:" || parsed.protocol === "https:") {
        return parsed.href;
      }
    } catch (error) {
      return "";
    }
    return "";
  }

  function safeHref(value, fallback) {
    return escapeHtml(normalizeNavigableUrl(value) || fallback || "#");
  }

  function post(action, data) {
    const body = new URLSearchParams();
    body.set("action", action);
    body.set("nonce", RPCA.nonce);

    Object.keys(data || {}).forEach((key) => {
      body.set(key, data[key]);
    });

    return fetch(RPCA.ajaxUrl, {
      method: "POST",
      credentials: "same-origin",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
      },
      body: body.toString(),
    }).then(async (response) => {
      const json = await response.json().catch(() => null);
      if (!json || !json.success) {
        let fallbackMessage = RPCA.i18n.failed;
        if (!json && response && response.status === 504) {
          fallbackMessage = "Server timed out on the admin scan path. Compliance Guard will auto-retry through the background scan path (WordPress Cron, no admin timeout). Keep this page open while it runs, or come back later - the result is saved either way.";
        } else if (!json && response && response.status >= 500) {
          fallbackMessage = `The server returned ${response.status}. The request did not finish cleanly; try again after the current scan has settled.`;
        } else if (!json && response && response.status >= 400) {
          fallbackMessage = `The request returned ${response.status}. Refresh the page and try again.`;
        }
        const message =
          json && json.data && json.data.message
            ? json.data.message
            : fallbackMessage;
        const error = new Error(message);
        error.data = (json && json.data) || null;
        error.status = response ? response.status : 0;
        if (error.data && error.data.editUrl) {
          error.editUrl = error.data.editUrl;
        }
        throw error;
      }
      return json.data;
    });
  }

  function setStatus(message, tone) {
    const status = el("rpca-status");
    if (!status) {
      return;
    }
    status.textContent = message || "";
    status.className = "rpca-status" + (tone ? " is-" + tone : "");
  }

  function severityClass(value) {
    return "rpca-badge rpca-badge-" + String(value || "clear").toLowerCase();
  }

  function severityText(value) {
    const labels = {
      critical: "Critical",
      high: "High",
      medium: "Medium",
      low: "Low",
      clear: "Clear",
      not_applicable: "Not applicable",
    };
    return labels[String(value || "clear").toLowerCase()] || String(value || "");
  }

  function severityHelp(value) {
    const text = {
      critical: "Fix this first. This wording may create serious FDA, processor, or card-network risk.",
      high: "Fix soon. This wording could hurt processor underwriting or create human-use risk.",
      medium: "Review carefully. This may need clearer wording or better supporting proof.",
      low: "Low priority. Keep it clean, but it is not the first place to start.",
      clear: "No rule-based wording risk found in this scan.",
      not_applicable: "This does not look like a live RUO peptide compliance surface, so the scanner did not recommend RUO changes.",
    };
    return text[String(value || "clear").toLowerCase()] || "";
  }

  function renderAi(ai) {
    if (!ai) {
      return "";
    }

    const parsed = ai.json && typeof ai.json === "object" ? ai.json : parseJsonish(ai.text);
    const selfAudit = renderSelfAudit(ai.selfAudit || ai.aiSelfAudit);

    if (parsed && typeof parsed === "object") {
      const topFindings = Array.isArray(parsed.topFindings)
        ? parsed.topFindings
        : [];
      const edits = Array.isArray(parsed.recommendedEdits)
        ? parsed.recommendedEdits
        : [];
      const questions = Array.isArray(parsed.followUpQuestions)
        ? parsed.followUpQuestions
        : [];
      const findingReviews = Array.isArray(parsed.findingReviews)
        ? parsed.findingReviews
        : [];

      return `
        <div class="rpca-ai">
          <p class="rpca-kicker">AI explanation</p>
          <h3>What the AI found</h3>
          <p>${escapeHtml(
            parsed.executiveSummary ||
              "Gemini (beta — experimental) reviewed the scan and organized the main points below. Treat this as a secondary opinion, not the primary signal."
          )}</p>
          ${renderSimpleList("What needs attention", topFindings)}
          ${renderSimpleList("Generated safer text (AI draft — beta, experimental)", edits)}
          ${renderSimpleList("Context checks on local findings", findingReviews)}
          ${renderSimpleList("Questions to ask", questions)}
          ${selfAudit}
        </div>
      `;
    }

    return `
      <div class="rpca-ai">
        <p class="rpca-kicker">AI explanation</p>
        <h3>What the AI found</h3>
        <div class="rpca-ai-readable">
          ${renderReadableParagraphs(cleanAiText(ai.text || ""))}
        </div>
        ${selfAudit}
      </div>
    `;
  }

  function parseJsonish(text) {
    const clean = cleanAiText(text || "");
    if (!clean) {
      return null;
    }

    try {
      return JSON.parse(clean);
    } catch (error) {
      const match = clean.match(/\{[\s\S]*\}/);
      if (!match) {
        return null;
      }
      try {
        return JSON.parse(match[0]);
      } catch (innerError) {
        return null;
      }
    }
  }

  function cleanAiText(text) {
    return String(text || "")
      .replace(/^```(?:json|javascript|js|php|html|text)?\s*/i, "")
      .replace(/\s*```$/i, "")
      .trim();
  }

  function renderReadableParagraphs(text) {
    const clean = cleanAiText(text);
    if (!clean) {
      return "<p>The AI did not return a plain summary. Run the scan again or check the message shown above.</p>";
    }

    return clean
      .split(/\r?\n\s*\r?\n/)
      .map((paragraph) => paragraph.trim())
      .filter(Boolean)
      .slice(0, 8)
      .map((paragraph) => `<p>${escapeHtml(paragraph)}</p>`)
      .join("");
  }

  function renderSelfAudit(audit) {
    if (!audit) {
      return "";
    }

    if (audit.json && typeof audit.json === "object") {
      const data = audit.json;
      const categories = [
        ["Must fix first", data.blockingIssues],
        ["Things it may have missed", data.missedRisks],
        ["Items that may be okay", data.falsePositiveRisks],
        ["Suggestions to avoid", data.unsafeSuggestions],
        ["Unclear answer signs", data.lowQualitySignals],
        ["Plugin notes", data.codeQualityNotes],
        ["Needed corrections", data.requiredCorrections],
      ];

      return `
        <section class="rpca-ai-review">
          <div class="rpca-ai-review-head">
            <div>
              <p class="rpca-kicker">AI self-review (beta — experimental)</p>
              <h4>Second check</h4>
            </div>
            <div class="rpca-ai-review-badges">
              ${renderAuditBadge("Status", data.status)}
              ${renderAuditBadge("Match strength", data.confidence)}
              ${renderAuditBadge("Ready to use", data.releaseReadiness)}
            </div>
          </div>
          <p>${escapeHtml(data.summary || audit.text || "")}</p>
          ${categories
            .map(([title, items]) => renderAuditList(title, items))
            .join("")}
        </section>
      `;
    }

    if (audit.text) {
      return `
        <section class="rpca-ai-review">
          <p class="rpca-kicker">AI self-review (beta — experimental)</p>
          <h4>Second check</h4>
          <pre>${escapeHtml(audit.text || "")}</pre>
        </section>
      `;
    }

    return "";
  }

  function renderAuditBadge(label, value) {
    if (!value) {
      return "";
    }

    return `<span><strong>${escapeHtml(label)}:</strong> ${escapeHtml(value)}</span>`;
  }

  function renderAuditList(title, items) {
    const cleaned = (Array.isArray(items) ? items : [])
      .map(formatListItem)
      .map((item) => String(item || "").trim())
      .filter(Boolean);
    if (!cleaned.length) {
      return "";
    }

    return `
      <details class="rpca-ai-review-list" open>
        <summary>${escapeHtml(title)} (${cleaned.length})</summary>
        <ul>
          ${cleaned
            .slice(0, 10)
            .map((item) => `<li>${escapeHtml(item)}</li>`)
            .join("")}
        </ul>
      </details>
    `;
  }

  function renderSimpleList(title, items) {
    const cleaned = (Array.isArray(items) ? items : [])
      .map(formatListItem)
      .map((item) => String(item || "").trim())
      .filter(Boolean);
    if (!cleaned.length) {
      return "";
    }

    return `
      <h4>${escapeHtml(title)}</h4>
      <ul>
        ${cleaned
          .slice(0, 8)
          .map((item) => `<li>${escapeHtml(item)}</li>`)
          .join("")}
      </ul>
    `;
  }

  function normalizeRecordList(items) {
    if (Array.isArray(items)) {
      return items;
    }
    if (!items || typeof items !== "object" || !Object.keys(items).length) {
      return [];
    }
    return [items];
  }

  function plainRecordLead(item) {
    if (!item || typeof item !== "object") {
      return "";
    }
    return (
      item.title ||
      item.ruleTitle ||
      item.company ||
      item.issue ||
      item.type ||
      item.status ||
      item.object_type ||
      item.url ||
      item.report_path ||
      ""
    );
  }

  function plainRecordDetail(item) {
    if (typeof item === "string") {
      return item;
    }
    if (!item || typeof item !== "object") {
      return "";
    }
    return (
      item.summary ||
      item.reason ||
      item.message ||
      item.excerpt ||
      item.extracted_span ||
      item.quoted_span ||
      item.snippet ||
      item.recommendedAction ||
      item.fixInstruction ||
      item.direction ||
      formatListItem(item)
    );
  }

  function renderPlainRecordList(items, emptyText, limit) {
    const rows = normalizeRecordList(items)
      .map((item) => {
        const lead = plainRecordLead(item);
        const detail = plainRecordDetail(item);
        const when =
          item && typeof item === "object"
            ? item.created_at || item.createdAt || item.issueDate || item.checkedAt || ""
            : "";
        const url = item && typeof item === "object" ? item.url || item.report_path || "" : "";
        const summary = [detail, url && url !== detail ? url : ""].filter(Boolean).join(" ");
        if (!lead && !summary) {
          return "";
        }
        return `<li>${lead ? `<strong>${escapeHtml(lead)}</strong>` : ""}${when ? ` <span>${escapeHtml(when)}</span>` : ""}${summary ? `<p>${escapeHtml(summary)}</p>` : ""}</li>`;
      })
      .filter(Boolean)
      .slice(0, limit || 8);

    if (!rows.length) {
      return `<p class="rpca-muted">${escapeHtml(emptyText)}</p>`;
    }

    return `<ul class="rpca-compact-list">${rows.join("")}</ul>`;
  }

  function formatValueForList(value) {
    if (value === null || typeof value === "undefined") return "";
    if (Array.isArray(value)) {
      return value.map(formatValueForList).filter(Boolean).join(", ");
    }
    if (typeof value === "object") {
      return Object.keys(value)
        .map((key) => {
          const formatted = formatValueForList(value[key]);
          return formatted ? `${humanizeKey(key)}: ${formatted}` : "";
        })
        .filter(Boolean)
        .join("; ");
    }
    return String(value).trim();
  }

  function formatListItem(item) {
    if (typeof item === "string") {
      return item;
    }
    if (!item || typeof item !== "object") {
      return "";
    }

    const labels = {
      severity: "Severity",
      url: "Page",
      evidence: "Proof",
      whyItMatters: "Why it matters",
      issue: "Issue",
      direction: "Fix",
      title: "Item",
      fixInstruction: "How to fix it",
      owner: "Owner",
    };

    return Object.keys(item)
      .map((key) => {
        const value = formatValueForList(item[key]);
        return value ? `${labels[key] || humanizeKey(key)}: ${value}` : "";
      })
      .filter(Boolean)
      .join(" - ");
  }

  function humanizeKey(key) {
    return String(key)
      .replace(/([a-z])([A-Z])/g, "$1 $2")
      .replace(/^./, (first) => first.toUpperCase());
  }

  function renderFindings(findings) {
    if (!findings || !findings.length) {
      return '<div class="rpca-empty-state"><strong>No rule-based issues found.</strong><p>The scan did not find the built-in risk words or patterns in the pages it checked.</p></div>';
    }

    return `
      <div class="rpca-findings">
        ${findings
          .slice(0, 30)
          .map((finding) => {
            const snippets = Array.isArray(finding.snippets)
              ? finding.snippets
              : [];
            const fix = finding.fix || {};
            return `
              <article class="rpca-finding">
                <div class="rpca-finding-head">
                  <span class="${severityClass(finding.severity)}">${escapeHtml(
              severityText(finding.severity)
            )}</span>
                  <strong>${escapeHtml(finding.rule)}</strong>
                </div>
                <a href="${safeHref(finding.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(
              finding.title || finding.url
            )}</a>
                <small>${escapeHtml(severityHelp(finding.severity))}</small>
                <p>${escapeHtml(finding.reason)}</p>
                ${renderFindingMeta(finding)}
                ${snippets
                  .slice(0, 2)
                  .map(
                    (snippet) =>
                      `<blockquote>${escapeHtml(snippet)}</blockquote>`
                  )
                  .join("")}
                ${renderFixActions(finding, fix)}
              </article>
            `;
          })
          .join("")}
      </div>
    `;
  }

  function renderFindingMeta(finding) {
    const bits = [];
    if (finding.confidenceScore || finding.confidence) {
      bits.push(
        `Match strength: ${escapeHtml(
          finding.confidenceScore
            ? Math.round(Number(finding.confidenceScore) * 100) + "%"
            : finding.confidence
        )}`
      );
    }
    if (finding.pipelineStage) {
      bits.push(`Check step: ${escapeHtml(humanizeKey(finding.pipelineStage))}`);
    }
    if (Array.isArray(finding.rulesFired) && finding.rulesFired.length) {
      const matchedChecks = finding.rulesFired
        .slice(0, 2)
        .map((rule) => plainRuleReference(rule))
        .filter(Boolean)
        .join("; ");
      if (matchedChecks) {
        bits.push(`Matched check: ${escapeHtml(matchedChecks)}`);
      }
    }
    if (finding.citation && finding.citation.id) {
      bits.push(`Reference: ${escapeHtml(citationPlainLabel(finding.citation, finding))}`);
    }
    if (finding.aiReview && finding.aiReview.decision) {
      bits.push(`AI context check: ${escapeHtml(aiReviewLabel(finding.aiReview.decision))}`);
    }
    if (finding.coverageReview && finding.coverageReview.decision) {
      bits.push(`Crawl coverage: ${escapeHtml(coverageReviewLabel(finding.coverageReview.decision))}`);
    }
    if (finding.userFeedbackReview && finding.userFeedbackReview.decision) {
      bits.push(`Merchant feedback: ${escapeHtml(userFeedbackReviewLabel(finding.userFeedbackReview.decision))}`);
    }
    if (!bits.length) {
      return "";
    }

    return `<div class="rpca-finding-meta">${bits
      .map((bit) => `<span>${bit}</span>`)
      .join("")}</div>`;
  }

  function aiReviewLabel(decision) {
    const labels = {
      confirmed_risk: "confirmed risk",
      likely_false_positive: "likely false positive",
      protective_disclaimer: "protective disclaimer",
      control_present: "control appears present",
      insufficient_context: "not enough context",
      needs_human_review: "needs human review",
      // v0.7.300: Gemini gate verdict vocabulary. Before this the gate's
      // own decisions (confirmed / softened / unverified) fell through to
      // the "reviewed" fallback, so an UNVERIFIED finding's badge wrongly
      // read "reviewed" when the gate had not reviewed it at all.
      confirmed: "confirmed by Gemini",
      softened: "softened by Gemini",
      lower_severity: "softened by Gemini",
      false_positive: "false positive",
      unverified: "not reviewed by Gemini",
    };
    return labels[String(decision || "").toLowerCase()] || "reviewed";
  }

  function renderAiReviewBadge(finding) {
    const review = finding && finding.aiReview ? finding.aiReview : null;
    if (!review || !review.decision) return "";
    const decision = String(review.decision || "").toLowerCase();
    const confidence = Number(review.confidence || 0);
    const label = aiReviewLabel(decision);
    // v0.7.300: an "unverified" gate decision carries an INTERNAL plumbing
    // reason (call failed / quota / time budget / AI off), never Gemini's
    // compliance reasoning — keep it out of the merchant-facing tooltip.
    const detail = decision === "unverified"
      ? "This finding was not reviewed by Gemini. Re-run the audit to have the AI gate check it."
      : [review.reason, review.evidence, review.recommendedAction]
          .map((item) => String(item || "").trim())
          .filter(Boolean)
          .join(" ");
    const tone = finding.aiLikelyFalsePositive || decision === "control_present" || decision === "protective_disclaimer"
      ? "likely OK"
      : decision === "confirmed_risk" || decision === "confirmed"
        ? "confirmed"
        : "review";
    const toneClass = tone === "likely OK" ? "is-ai-ok" : tone === "confirmed" ? "is-ai-confirmed" : "is-ai-review";
    return `<span class="rpca-finding-citation ${toneClass}" title="${escapeHtml(detail || "Gemini reviewed this finding in context.")}">AI context check: ${escapeHtml(label)}${confidence ? ` (${Math.round(confidence * 100)}%)` : ""} - ${escapeHtml(tone)}</span>`;
  }

  function coverageReviewLabel(decision) {
    const labels = {
      coverage_sensitive: "coverage-sensitive",
      control_present: "control already found",
    };
    return labels[String(decision || "").toLowerCase()] || "coverage checked";
  }

  function renderCoverageReviewBadge(finding) {
    const review = finding && finding.coverageReview ? finding.coverageReview : null;
    if (!review || !review.decision) return "";
    const detail = [review.category, review.reason, review.recommendedAction]
      .map((item) => String(item || "").trim())
      .filter(Boolean)
      .join(" ");
    return `<span class="rpca-finding-citation is-coverage-review" title="${escapeHtml(detail || "The crawler coverage diagnostic reviewed this finding.")}">Crawl coverage: ${escapeHtml(coverageReviewLabel(review.decision))}</span>`;
  }

  function userFeedbackReviewLabel(decision) {
    const labels = {
      similar_false_positive_history: "similar false-positive history",
    };
    return labels[String(decision || "").toLowerCase()] || "feedback checked";
  }

  function renderUserFeedbackReviewBadge(finding) {
    const review = finding && finding.userFeedbackReview ? finding.userFeedbackReview : null;
    if (!review || !review.decision) return "";
    const count = Number(review.falsePositiveCount || 0);
    const detail = [review.reason, review.recommendedAction]
      .map((item) => String(item || "").trim())
      .filter(Boolean)
      .join(" ");
    return `<span class="rpca-finding-citation is-feedback-review" title="${escapeHtml(detail || "Merchant feedback lowered confidence on this pattern.")}">Merchant feedback: ${escapeHtml(userFeedbackReviewLabel(review.decision))}${count ? ` (${count})` : ""}</span>`;
  }

  function renderFixActions(finding, fix) {
    const details = fixDetailsForFinding(finding);
    const canPreview = Boolean(fix.canApply && fix.canPreview && finding.id);
    const suggested = fix.suggestedWording || fix.manualAlternative || (details.canGenerateText ? generatedRewriteForFinding(finding) : details.guidance);

    if (!details.canGenerateText) {
      return `
        <div class="rpca-fix-card is-manual">
          <strong>${escapeHtml(details.label)}</strong>
          <p>${escapeHtml(details.guidance)}</p>
          <div class="rpca-fix-actions">
            ${
              canPreview
                ? `<button type="button" class="button rpca-preview-fix" data-finding-id="${escapeHtml(finding.id)}">Preview change</button>`
                : `<span class="rpca-fix-manual">${escapeHtml(fix.reason || "This is a setup, server, policy, or documentation task. It is not a text rewrite.")}</span>`
            }
          </div>
        </div>
      `;
    }

    if (!fix.canApply) {
      return `
        <div class="rpca-fix-card is-manual">
          <strong>Generated safer wording</strong>
          <p>${escapeHtml(suggested)}</p>
          <div class="rpca-fix-actions">
            <span class="rpca-fix-manual">${escapeHtml(
              fix.reason || "This cannot be safely changed with one button."
            )}</span>
          </div>
        </div>
      `;
    }

    return `
      <div class="rpca-fix-card">
        <strong>Generated safer wording</strong>
        <p>${escapeHtml(
          suggested ||
            "The preview will show the exact editable WordPress text that will change."
        )}</p>
        <div class="rpca-fix-actions">
          ${
            canPreview
              ? `<button type="button" class="button rpca-preview-fix" data-finding-id="${escapeHtml(
                  finding.id
                )}">Preview change</button>`
              : ""
          }
          <span class="rpca-fix-ready">Can change normal editable WordPress text.</span>
        </div>
      </div>
    `;
  }

  function renderFdaIntelligence(intelligence) {
    const container = el("rpca-fda-intelligence");
    if (!container) {
      return;
    }

    const data = intelligence && typeof intelligence === "object" ? intelligence : {};
    const takeaways = Array.isArray(data.keyTakeaways) ? data.keyTakeaways : [];
    const terms = Array.isArray(data.watchTerms) ? data.watchTerms : [];
    const letters = Array.isArray(data.warningLetters) ? data.warningLetters : [];
    const sources = Array.isArray(data.sources) ? data.sources : [];
    const errors = Array.isArray(data.errors) ? data.errors : [];
    const checked = data.checkedAt || "Not checked yet";
    const next = data.nextScheduledAt || "Scheduled after settings are saved";
    const status = String(data.status || "not_checked");

    container.innerHTML = `
      <section class="rpca-fda-intel">
        <div class="rpca-fda-intel-head">
          <div>
            <p class="rpca-kicker">Daily FDA check</p>
            <h3>New FDA examples to watch</h3>
            <p>The next scan uses these public examples to look for wording that may need review on your site.</p>
          </div>
          <div class="rpca-fda-intel-meta">
            <span class="rpca-fda-status is-${escapeHtml(status.toLowerCase())}">${escapeHtml(status.replace(/_/g, " "))}</span>
            <small>Last checked: ${escapeHtml(checked)}</small>
            <small>Next daily check: ${escapeHtml(next)}</small>
          </div>
        </div>
        ${
          takeaways.length
            ? `<div class="rpca-fda-grid">${takeaways
                .slice(0, 6)
                .map((item) => `<div>${escapeHtml(item)}</div>`)
                .join("")}</div>`
            : `<p class="rpca-muted">No saved FDA update yet. Press Check New FDA Examples or wait for the daily check.</p>`
        }
        ${
          terms.length
            ? `<div class="rpca-term-cloud">${terms
                .slice(0, 18)
                .map((term) => `<span>${escapeHtml(term)}</span>`)
                .join("")}</div>`
            : ""
        }
        ${
          letters.length
              ? `<details class="rpca-fda-details"><summary>Recent FDA examples that may matter</summary><ul>${letters
                .slice(0, 8)
                .map(
                  (letter) =>
                    `<li><a href="${safeHref(
                      letter.url || "#"
                    )}" target="_blank" rel="noopener noreferrer">${escapeHtml(
                      letter.company || "FDA warning letter"
                    )}</a><span>${escapeHtml(
                      [letter.issueDate, letter.subject].filter(Boolean).join(" - ")
                    )}</span><p>${escapeHtml(letter.excerpt || "")}</p></li>`
                )
                .join("")}</ul></details>`
            : ""
        }
        ${
          sources.length
            ? `<details class="rpca-fda-details"><summary>FDA pages checked</summary><ul>${sources
                .slice(0, 10)
                .map(
                  (source) =>
                    `<li><a href="${safeHref(
                      source.url || "#"
                    )}" target="_blank" rel="noopener noreferrer">${escapeHtml(
                      source.label || source.title || "FDA source"
                    )}</a><span>${escapeHtml(
                      [source.status, source.currentAsOf ? "current as of " + source.currentAsOf : ""]
                        .filter(Boolean)
                        .join(" - ")
                    )}</span></li>`
                )
                .join("")}</ul></details>`
            : ""
        }
        ${
          errors.length
            ? `<div class="rpca-fda-errors"><strong>Update notes</strong><p>${escapeHtml(
                errors.slice(0, 3).join(" ")
              )}</p></div>`
            : ""
        }
      </section>
    `;
  }

  function renderAddendumDashboard(data) {
    const container = el("rpca-addendum-dashboard");
    if (!container || !data) {
      return;
    }

    const gauges = data.gauges || {};
    const evidence = data.evidence || {};
    const intelligence = data.intelligence || {};
    const synthetic = data.synthetic || {};
    const disputes = data.disputes || {};
    const buyerClass = data.buyerClass || {};
    const coa = data.coaChain || {};
    const drift = data.drift || {};
    const simulator = data.simulator || {};
    const formatDashboardRow = (item) => {
      if (!item || typeof item !== "object") return String(item || "").trim();
      return (
        item.this_attestation_hash ||
        item.bundle_hash ||
        item.extracted_span ||
        item.quoted_span ||
        item.report_path ||
        item.object_type ||
        item.message ||
        item.title ||
        formatListItem(item)
      );
    };
    const rows = (items, empty) =>
      Array.isArray(items) && items.length
        ? items
            .slice(0, 5)
            .map(
              (item) =>
                `<li><span>${escapeHtml(
                  item.created_at || item.createdAt || item.status || item.type || ""
                )}</span>${escapeHtml(formatDashboardRow(item) || "Saved record")}</li>`
            )
            .join("")
        : `<li>${escapeHtml(empty)}</li>`;

    container.innerHTML = `
      <section class="rpca-addendum-overview">
        <div class="rpca-gauge-row">
          ${renderGauge("Proof saved", gauges.evidenceCoverage || 0, "%")}
          ${renderGauge(
            "Update freshness",
            gauges.intelligenceFreshnessDays || 0,
            " days"
          )}
          ${renderGauge(
            "Store check score",
            gauges.defensibilityIndex || 0,
            "/100"
          )}
        </div>
        <p class="rpca-defensibility-note">Goal: a demonstrably defensible posture with saved proof, current public examples, test checkouts, and a clear change log.</p>
        <div class="rpca-module-tabs">
          <details open><summary>Proof</summary><p>Public proof link: ${escapeHtml(
            evidence.anchor && evidence.anchor.url ? evidence.anchor.url : "/.well-known/ruo-cg-anchor.txt"
          )}</p><ul>${rows(
            evidence.recentAttestations,
            "No checkout proof records yet."
          )}${rows(evidence.recentRenderCaptures, "No page snapshot records yet.")}</ul></details>
          <details><summary>New updates</summary><ul>${rows(
            intelligence.fdaCandidates,
            "No new FDA examples waiting."
          )}${rows(intelligence.redTeamFindings, "No extra AI page-check notes yet.")}</ul></details>
          <details><summary>Scenario report</summary><ul>${rows(
            simulator.latest,
            "No scenario reports made yet."
          )}</ul></details>
          <details><summary>Buyer type</summary><p>${escapeHtml(
            buyerClass.institutionalPercent90 || 0
          )}% of recent orders carry institutional or commercial-lab signal.</p></details>
          <details><summary>Test orders</summary><p>${escapeHtml(
            synthetic.greenStreakLabel || "No test order attempts recorded yet."
          )}</p></details>
          <details><summary>COA checks</summary><ul>${rows(
            coa.rows,
            "No COA check rows yet."
          )}</ul></details>
          <details><summary>Disputes</summary><p>Early dispute alerts handled this month: ${escapeHtml(
            disputes.intercepted30 || 0
          )}. Estimated score impact avoided: ${escapeHtml(
            disputes.estimatedRatioImpactAvoided || 0
          )}%.</p></details>
          <details><summary>Changes</summary><ul>${rows(
            drift.recent,
            "No saved content-change snapshots yet."
          )}</ul></details>
        </div>
      </section>
    `;
  }

  function renderGauge(label, value, suffix) {
    return `
      <div class="rpca-gauge">
        <span>${escapeHtml(label)}</span>
        <strong>${escapeHtml(value)}${escapeHtml(suffix || "")}</strong>
      </div>
    `;
  }

  function renderScan(scan) {
    const container = el("rpca-results");
    if (!container) {
      return;
    }

    if (!scan) {
      renderFdaIntelligence(state.fdaIntelligence);
      container.innerHTML =
        '<div class="rpca-empty-state"><strong>Ready when you are.</strong><p>Save your settings, then press Start Scan. Results will appear here with a plain-English risk level and the exact wording that was flagged.</p></div>';
      return;
    }

    if (scan.fdaIntelligence) {
      state.fdaIntelligence = scan.fdaIntelligence;
      renderFdaIntelligence(state.fdaIntelligence);
    }

    const counts = scan.counts || {};
    const warnings = Array.isArray(scan.warnings) ? scan.warnings : [];
    container.innerHTML = `
      <div class="rpca-summary">
        <div>
          <span class="${severityClass(scan.riskLevel)}">${escapeHtml(
      severityText(scan.riskLevel)
    )}</span>
          <strong>${escapeHtml(scan.pageCount)} pages checked</strong>
        </div>
        <p>${escapeHtml(severityHelp(scan.riskLevel))}</p>
        <dl>
          <div><dt>Risk score</dt><dd>${escapeHtml(scan.score)}</dd></div>
          <div><dt>Critical</dt><dd>${escapeHtml(counts.critical || 0)}</dd></div>
          <div><dt>High</dt><dd>${escapeHtml(counts.high || 0)}</dd></div>
          <div><dt>Medium</dt><dd>${escapeHtml(counts.medium || 0)}</dd></div>
          <div><dt>Low</dt><dd>${escapeHtml(counts.low || 0)}</dd></div>
        </dl>
        <small>Finished ${escapeHtml(scan.finishedAt || "")}</small>
      </div>
      ${renderScanProvenance(scan.scanProvenance || scan.provenance)}
      ${renderScanScope(scan.scanScope)}
      ${renderCrawlDiagnostics(scan.crawlDiagnostics)}
      ${renderNextStep(scan)}
      ${renderLocalMl(scan.localMl || (scan.pipeline && scan.pipeline.stage3LocalMl))}
      ${renderAiRiskAdjustment(scan.aiRiskAdjustment)}
      ${renderSiteEvidence(scan.siteEvidence)}
      ${renderProcessorAudit(scan.processorAudit)}
      ${renderReadinessPack(scan.readinessPack)}
      ${renderScanHistory(state.scanHistory)}
      ${renderAuditLog(state.auditLog)}
      ${renderWarnings(warnings)}
      ${renderAi(scan.ai)}
      <h3 id="rpca-findings-anchor" tabindex="-1" class="rpca-section-divider-heading">General high-risk and RUO peptide best practices</h3>
      <p class="rpca-muted">These items come from the built-in research-use peptide rule pack and apply to every store. They run alongside any processor-specific recommendations above.</p>
      ${renderFindings(scan.findings || [])}
    `;
  }

  function renderSiteEvidence(siteEvidence) {
    if (!siteEvidence || typeof siteEvidence !== "object") {
      return "";
    }
    const evidence = Array.isArray(siteEvidence.evidence) ? siteEvidence.evidence : [];
    const summary = siteEvidence.evidenceSummary && typeof siteEvidence.evidenceSummary === "object" ? siteEvidence.evidenceSummary : {};
    const recordsByType = summary.recordsByType && typeof summary.recordsByType === "object" ? summary.recordsByType : {};
    const strongestByType = summary.strongestByType && typeof summary.strongestByType === "object" ? summary.strongestByType : {};
    const strongestRows = Object.entries(strongestByType).filter((entry) => entry[1] && typeof entry[1] === "object");
    const pills = [
      ["RUO disclaimer", siteEvidence.hasRuoDisclaimer],
      ["Non-human-use wording", siteEvidence.hasNonHumanUseContext],
      ["COA visibility", siteEvidence.hasCoaVisibility],
      ["SDS visibility", siteEvidence.hasSdsVisibility],
      ["Buyer safeguards", siteEvidence.hasBuyerSafeguards],
    ];
    return `
      <section class="rpca-card rpca-site-evidence">
        <div class="rpca-card-header">
          <div>
            <p class="rpca-kicker">Site evidence index</p>
            <h3>Context found before missing-item findings</h3>
          </div>
        </div>
        <div class="rpca-chip-row">
          ${pills.map(([label, ok]) => `<span class="rpca-chip ${ok ? "is-ok" : ""}">${escapeHtml(label)}: ${ok ? "found" : "not found"}</span>`).join("")}
        </div>
        <dl class="rpca-compact-dl">
          <div><dt>Pages checked</dt><dd>${escapeHtml(summary.pagesChecked || 0)}</dd></div>
          <div><dt>Evidence records</dt><dd>${escapeHtml(Object.values(recordsByType).reduce((total, value) => total + Number(value || 0), 0))}</dd></div>
          <div><dt>External lab links</dt><dd>${escapeHtml(summary.recognizedExternalCoaLinks || 0)}</dd></div>
          <div><dt>Download links</dt><dd>${escapeHtml(summary.downloadableDocumentLinks || 0)}</dd></div>
        </dl>
        ${strongestRows.length ? `<ul class="rpca-compact-list">${strongestRows.slice(0, 5).map(([type, item]) => `<li><strong>${escapeHtml(type.replace(/_/g, " "))}: ${escapeHtml(item.strength || "evidence")} ${item.confidence ? `(${Math.round(Number(item.confidence) * 100)}%)` : ""}</strong><br>${escapeHtml(item.snippet || "")}</li>`).join("")}</ul>` : ""}
        ${evidence.length ? `<ul class="rpca-compact-list">${evidence.slice(0, 8).map((item) => `<li><strong>${escapeHtml(item.title || item.type || "Evidence")}${item.strength ? ` - ${escapeHtml(item.strength)}` : ""}</strong><br>${escapeHtml(item.snippet || "")}</li>`).join("")}</ul>` : `<p class="rpca-muted">No RUO, COA, SDS, or buyer-safeguard evidence was indexed from the crawl.</p>`}
      </section>
    `;
  }

  function renderAiRiskAdjustment(adjustment) {
    if (!adjustment || typeof adjustment !== "object" || !Number(adjustment.softenedFindings || 0)) {
      return "";
    }
    return `
      <section class="rpca-card rpca-ai-risk-adjustment">
        <div class="rpca-card-header">
          <div>
            <p class="rpca-kicker">AI context scoring</p>
            <h3>Likely false positives no longer drive the active score</h3>
          </div>
        </div>
        <dl class="rpca-compact-dl">
          <div><dt>AI-softened findings</dt><dd>${escapeHtml(adjustment.softenedFindings || 0)}</dd></div>
          <div><dt>Original score</dt><dd>${escapeHtml(adjustment.originalScore || 0)}</dd></div>
          <div><dt>Active score</dt><dd>${escapeHtml(adjustment.adjustedScore || 0)}</dd></div>
        </dl>
        <p class="rpca-muted">${escapeHtml(adjustment.message || "AI context review kept likely false positives visible for review but removed them from active risk scoring.")}</p>
      </section>
    `;
  }

  function renderCrawlDiagnostics(diagnostics) {
    if (!diagnostics || typeof diagnostics !== "object") {
      return "";
    }
    const limits = Array.isArray(diagnostics.limitsHit) ? diagnostics.limitsHit : [];
    const missing = Array.isArray(diagnostics.missingEvidence) ? diagnostics.missingEvidence : [];
    const statusLabel = {
      complete: "Complete",
      partial: "Partial",
      needs_review: "Coverage-sensitive",
      evidence_not_indexed: "Evidence not indexed",
    }[diagnostics.status] || "Reviewed";
    return `
      <section class="rpca-card rpca-crawl-diagnostics">
        <div class="rpca-card-header">
          <div>
            <p class="rpca-kicker">Crawl coverage diagnostic</p>
            <h3>${escapeHtml(statusLabel)} public-site coverage check</h3>
          </div>
        </div>
        <p class="rpca-muted">${escapeHtml(diagnostics.summary || "The crawler reviewed public same-site pages and built the evidence index.")}</p>
        <dl class="rpca-compact-dl">
          <div><dt>Pages checked</dt><dd>${escapeHtml(diagnostics.pagesChecked || 0)} / ${escapeHtml(diagnostics.maxPages || 0)}</dd></div>
          <div><dt>Seed URLs</dt><dd>${escapeHtml(diagnostics.seedUrlCount || 0)}</dd></div>
          <div><dt>Same-site links found</dt><dd>${escapeHtml(diagnostics.discoveredLinkCount || 0)}</dd></div>
          <div><dt>Links queued from crawl</dt><dd>${escapeHtml(diagnostics.enqueuedDiscoveredUrlCount || 0)}</dd></div>
          <div><dt>Failed fetches</dt><dd>${escapeHtml(diagnostics.failedFetchCount || 0)}</dd></div>
          <div><dt>Remaining queue</dt><dd>${escapeHtml(diagnostics.remainingQueueCount || 0)}</dd></div>
          <div><dt>Depth reached</dt><dd>${escapeHtml(diagnostics.highestDepthFetched || 0)} / ${escapeHtml(diagnostics.maxDepth || 0)}</dd></div>
          <div><dt>Gemini scope review</dt><dd>${diagnostics.geminiScopeReviewed ? "used" : "not used"}</dd></div>
        </dl>
        ${limits.length ? `<p class="rpca-muted"><strong>Safety limit hit:</strong> ${escapeHtml(limits.join(", "))}</p>` : ""}
        ${missing.length ? `<p class="rpca-muted"><strong>Evidence not indexed:</strong> ${escapeHtml(missing.join(", "))}</p>` : ""}
        <p class="rpca-muted">${escapeHtml(diagnostics.recommendedAction || "")}</p>
        <p class="rpca-muted">${escapeHtml(diagnostics.manualUrlHint || "")}</p>
      </section>
    `;
  }

  function renderScanScope(scanScope) {
    if (!scanScope || typeof scanScope !== "object") {
      return "";
    }
    const inventory = scanScope.inventory && typeof scanScope.inventory === "object" ? scanScope.inventory : {};
    const counts = inventory.postTypeCounts && typeof inventory.postTypeCounts === "object"
      ? Object.entries(inventory.postTypeCounts).filter((entry) => Number(entry[1]) > 0)
      : [];
    const gemini = scanScope.gemini && typeof scanScope.gemini === "object" ? scanScope.gemini : {};
    const sources = Array.isArray(scanScope.sources) ? scanScope.sources : [];
    const priorityPaths = Array.isArray(gemini.priorityPaths) ? gemini.priorityPaths : [];
    return `
      <section class="rpca-card rpca-scan-scope">
        <div class="rpca-card-header">
          <div>
            <p class="rpca-kicker">Automatic scan scope</p>
            <h3>Pages selected from WordPress inventory and public crawl discovery</h3>
          </div>
        </div>
        <dl class="rpca-compact-dl">
          <div><dt>Published public content</dt><dd>${escapeHtml(scanScope.publishedPublicContentCount || 0)}</dd></div>
          <div><dt>Menu URLs</dt><dd>${escapeHtml(scanScope.navMenuUrlCount || 0)}</dd></div>
          <div><dt>Sitemap/robots URLs</dt><dd>${escapeHtml(scanScope.publicDiscoveryUrlCount || 0)}</dd></div>
          <div><dt>Safety budget</dt><dd>${escapeHtml(scanScope.maxPages || 0)} pages / depth ${escapeHtml(scanScope.maxDepth || 0)}</dd></div>
          <div><dt>Gemini scope review</dt><dd>${gemini.used ? "used" : "not used"}</dd></div>
        </dl>
        ${counts.length ? `<p class="rpca-muted">WordPress content found: ${escapeHtml(counts.map(([key, value]) => `${key}: ${value}`).join(", "))}</p>` : ""}
        ${sources.length ? `<p class="rpca-muted">Sources: ${escapeHtml(sources.slice(0, 4).join("; "))}</p>` : ""}
        ${gemini.message ? `<p class="rpca-muted">${escapeHtml(gemini.message)}</p>` : ""}
        ${priorityPaths.length ? `<p class="rpca-muted">Gemini priorities: ${escapeHtml(priorityPaths.slice(0, 8).join(", "))}</p>` : ""}
      </section>
    `;
  }

  function renderScanProvenance(provenance) {
    if (!provenance || typeof provenance !== "object") {
      return "";
    }
    const profiles = Array.isArray(provenance.activeProfiles)
      ? provenance.activeProfiles
      : [];
    const fired = Array.isArray(provenance.firedRuleIds)
      ? provenance.firedRuleIds
      : [];
    const sourceBasis = Array.isArray(provenance.sourceBasis)
      ? provenance.sourceBasis
      : [];
    const dataPacks = provenance.dataPackVersions && typeof provenance.dataPackVersions === "object"
      ? Object.entries(provenance.dataPackVersions).filter((entry) => entry[1])
      : [];
    const version = provenance.rulesPackVersion || "";
    if (!version && !profiles.length && !fired.length && !sourceBasis.length && !dataPacks.length) {
      return "";
    }
    return `
      <section class="rpca-rule-provenance">
        <header>
          <p class="rpca-eyebrow">Scan provenance</p>
          <h3>${escapeHtml(version || "Current rule pack")}</h3>
        </header>
        <dl>
          <div><dt>Active profiles</dt><dd>${escapeHtml(profiles.length ? profiles.join(", ") : "Default")}</dd></div>
          <div><dt>Local rules</dt><dd>${escapeHtml(provenance.ruleCount || 0)}</dd></div>
          <div><dt>Pages checked</dt><dd>${escapeHtml(provenance.pageCount || 0)}</dd></div>
        </dl>
        ${
          fired.length
            ? `<p class="rpca-muted">Checks matched: ${escapeHtml(fired.slice(0, 10).join(", "))}${fired.length > 10 ? "..." : ""}</p>`
            : ""
        }
        ${
          sourceBasis.length
            ? `<p class="rpca-muted">Basis: ${escapeHtml(sourceBasis.slice(0, 3).join("; "))}</p>`
            : ""
        }
        ${
          dataPacks.length
            ? `<p class="rpca-muted">Data packs: ${escapeHtml(dataPacks.slice(0, 7).map(([key, value]) => `${key}: ${value}`).join("; "))}</p>`
            : ""
        }
      </section>
    `;
  }

  function renderLocalMl(localMl) {
    if (!localMl || typeof localMl !== "object") {
      return "";
    }
    const topPages = Array.isArray(localMl.topPages) ? localMl.topPages : [];
    const precedents = Array.isArray(localMl.topPrecedents)
      ? localMl.topPrecedents
      : [];
    if (!topPages.length && !precedents.length && !localMl.maxProbability) {
      return "";
    }

    return `
      <details class="rpca-readiness rpca-local-ml" open>
        <summary>Local ML and FDA-corpus similarity</summary>
        <div class="rpca-readiness-score">
          <strong>${escapeHtml(
            Math.round(Number(localMl.maxProbability || 0) * 100) + "%"
          )}</strong>
          <span>Highest local risk probability, no outbound API call</span>
        </div>
        ${
          topPages.length
            ? `<ul>${topPages
                .slice(0, 5)
                .map(
                  (page) =>
                    `<li><span>${escapeHtml(
                      Math.round(Number(page.probability || 0) * 100) + "%"
                    )}</span>${escapeHtml(
                      page.title || page.url || "Scanned page"
                    )}<small>${escapeHtml(
                      (page.topFeatures || []).slice(0, 3).join(", ")
                    )}</small></li>`
                )
                .join("")}</ul>`
            : ""
        }
        ${
          precedents.length
            ? `<p><strong>Closest stored FDA precedents:</strong> ${escapeHtml(
                precedents
                  .slice(0, 3)
                  .map((item) => `${item.id || "record"} (${item.similarity || 0})`)
                  .join(", ")
              )}</p>`
            : ""
        }
      </details>
    `;
  }

  function renderProcessorAudit(audit) {
    if (!audit || typeof audit !== "object") {
      return "";
    }
    const steps = Array.isArray(audit.steps) ? audit.steps : [];
    const themes = Array.isArray(audit.policyThemes) ? audit.policyThemes : [];
    const statusLabel =
      audit.result === "pass"
        ? "Looks ready for review"
        : audit.result === "review"
          ? "Needs review"
          : "Needs attention";

    return `
      <details class="rpca-readiness rpca-processor-audit" open>
        <summary>Processor audit simulation</summary>
        <div class="rpca-readiness-score">
          <strong>${escapeHtml(audit.score || 0)}%</strong>
          <span>${escapeHtml(statusLabel)}</span>
        </div>
        <p>${escapeHtml(audit.plainEnglish || "This checks what a payment reviewer can see on your public site.")}</p>
        <div class="rpca-audit-step-list">
          ${steps
            .map(
              (step) => `
                <article class="rpca-audit-step is-${escapeHtml(step.status || "review")}">
                  <div>
                    <strong>${escapeHtml(step.label || "Audit step")}</strong>
                    <p>${escapeHtml(step.why || "")}</p>
                    <small>${escapeHtml(step.fix || "")}</small>
                  </div>
                  <span>${escapeHtml(step.status === "pass" ? "Looks good" : "Needs attention")}</span>
                </article>
              `
            )
            .join("")}
        </div>
        ${
          themes.length
            ? `<div class="rpca-audit-themes"><strong>What this is based on</strong><ul>${themes
                .map((theme) => `<li>${escapeHtml(theme)}</li>`)
                .join("")}</ul></div>`
            : ""
        }
      </details>
    `;
  }

  function renderReadinessPack(pack) {
    if (!pack || typeof pack !== "object") {
      return "";
    }

    const checklist = pack.checklist || {};
    const entries = Object.keys(checklist).map((key) => checklist[key]);
    const scripts = Array.isArray(pack.thirdPartyScriptHosts)
      ? pack.thirdPartyScriptHosts
      : [];

    return `
      <details class="rpca-readiness" open>
        <summary>Payment review packet</summary>
        <div class="rpca-readiness-score">
          <strong>${escapeHtml(pack.readinessScore || 0)}%</strong>
          <span>${escapeHtml(
            pack.profile || "General payment-review readiness"
          )}</span>
        </div>
        <ul>
          ${entries
            .map(
              (item) =>
                `<li class="${item.pass ? "is-pass" : "is-fail"}"><span>${
                  item.pass ? "Pass" : "Needs review"
                }</span>${escapeHtml(item.label || "")}</li>`
            )
            .join("")}
        </ul>
        ${renderReadinessActionPlan(pack)}
        ${
          scripts.length
            ? `<p><strong>Third-party scripts found:</strong> ${escapeHtml(
                scripts.slice(0, 10).join(", ")
              )}</p>`
            : ""
        }
      </details>
    `;
  }

  function renderReadinessActionPlan(pack) {
    const plan = pack.actionPlan && typeof pack.actionPlan === "object" ? pack.actionPlan : {};
    const actions = Array.isArray(plan.openItems) ? plan.openItems : [];

    if (!actions.length) {
      return `
        <div class="rpca-readiness-actions is-clear">
          <h4>Next evidence to collect</h4>
          <p>No failed readiness checks were found in this scan.</p>
        </div>
      `;
    }

    return `
      <div class="rpca-readiness-actions">
        <h4>Next evidence to collect</h4>
        ${actions
          .slice(0, 8)
          .map((action) => {
            const evidence = Array.isArray(action.evidenceNeeded)
              ? action.evidenceNeeded
              : Array.isArray(action.evidence)
                ? action.evidence
                : [];
            return `
              <div class="rpca-readiness-action">
                <div class="rpca-readiness-action-head">
                  <span class="rpca-action-priority">${escapeHtml(
                    humanizeKey(action.priority || "review")
                  )}</span>
                  <strong>${escapeHtml(action.label || "Readiness item")}</strong>
                </div>
                <p>${escapeHtml(action.why || "Collect visible evidence for this requirement.")}</p>
                <div class="rpca-action-owner">Owner: ${escapeHtml(
                  action.owner || "Site owner"
                )}</div>
                ${
                  evidence.length
                    ? `<div class="rpca-action-evidence">${evidence
                        .slice(0, 5)
                        .map((item) => `<span>${escapeHtml(item)}</span>`)
                        .join("")}</div>`
                    : ""
                }
              </div>
            `;
          })
          .join("")}
      </div>
    `;
  }

  function renderScanHistory(history) {
    if (!Array.isArray(history) || !history.length) {
      return "";
    }

    return `
      <details class="rpca-history">
        <summary>Recent scan history</summary>
        <table>
          <thead><tr><th>Finished</th><th>Mode</th><th>Risk</th><th>Score</th><th>Pages</th></tr></thead>
          <tbody>
            ${history
              .slice(0, 8)
              .map(
                (item) => `<tr>
                  <td>${escapeHtml(item.finishedAt || "")}</td>
                  <td>${escapeHtml(item.mode || "")}</td>
                  <td><span class="${severityClass(item.riskLevel)}">${escapeHtml(
                  severityText(item.riskLevel)
                )}</span></td>
                  <td>${escapeHtml(item.score || 0)}</td>
                  <td>${escapeHtml(item.pageCount || 0)}</td>
                </tr>`
              )
              .join("")}
          </tbody>
        </table>
      </details>
    `;
  }

  function renderAuditLog(auditLog) {
    if (!Array.isArray(auditLog) || !auditLog.length) {
      return "";
    }

    return `
      <details class="rpca-history">
        <summary>Remediation audit log</summary>
        <table>
          <thead><tr><th>Time</th><th>Type</th><th>Message</th></tr></thead>
          <tbody>
            ${auditLog
              .slice(0, 8)
              .map(
                (item) => `<tr>
                  <td>${escapeHtml(item.time || "")}</td>
                  <td>${escapeHtml(item.type || "")}</td>
                  <td>${escapeHtml(item.message || "")}</td>
                </tr>`
              )
              .join("")}
          </tbody>
        </table>
      </details>
    `;
  }

  function renderNextStep(scan) {
    const level = String(scan.riskLevel || "clear").toLowerCase();
    const text =
      level === "critical" || level === "high"
        ? "Start with Critical and High items. Rewrite or remove wording that sounds like human use, dosing, disease treatment, card-processing workarounds, or unsupported certification claims."
        : level === "medium"
          ? "Review Medium items next. These usually need clearer RUO wording, better policy pages, or proof for claims."
          : "Keep the report for your records and scan again after major product, checkout, or policy-page changes.";

    return `<div class="rpca-next-step"><strong>Next step</strong><p>${escapeHtml(text)}</p></div>`;
  }

  function renderWarnings(warnings) {
    if (!warnings.length) {
      return "";
    }

    return `
      <div class="rpca-warnings">
        <h3>Pages skipped or not checked</h3>
        <ul>
          ${warnings
            .slice(0, 10)
            .map((warning) => `<li>${escapeHtml(warning)}</li>`)
            .join("")}
        </ul>
      </div>
    `;
  }

  function openFixDrawer() {
    const drawer = el("rpca-fix-drawer");
    if (drawer) {
      drawer.classList.remove("is-collapsed");
    }
  }

  function closeFixDrawer() {
    const drawer = el("rpca-fix-drawer");
    if (drawer) {
      drawer.classList.add("is-collapsed");
    }
  }

  function allKnownFindings() {
    const buckets = [];
    if (state.lastScan && Array.isArray(state.lastScan.findings)) {
      buckets.push(state.lastScan.findings);
    }
    const ui = uiState();
    if (ui && Array.isArray(ui.findings)) {
      buckets.push(ui.findings);
    }
    return buckets.flat();
  }

  function findingById(findingId) {
    return allKnownFindings().find((finding) => String(finding.id || "") === String(findingId || "")) || null;
  }

  function looksLikeRuleLabel(text) {
    const s = String(text || "").trim().toLowerCase();
    if (!s) return false;
    if (/^[a-z]+(?:[._-][a-z0-9]+){2,}$/.test(s)) {
      return true;
    }
    // Rule labels are descriptive summary phrases, not actual page text. They use words
    // like "context", "wording", "signal", "marker", "claims", "equivalence" and don't
    // contain sentence-style punctuation. Real page text usually has a period, comma,
    // colon, or quotes inside it once it's longer than a few words.
    if (/\b(context|wording|signal|marker|claims?|equivalence|review|warnings?|risk|disclaimer|substitution|fired|matched|placeholder|alias)\b/.test(s)) {
      return true;
    }
    if (/^(peptide|expected\b|missing\b|generic\b|research-use-only\b|downloadable\b)/i.test(s)) {
      return true;
    }
    // Short phrase, no sentence punctuation = probably a label like "Disease or therapeutic claims".
    if (s.split(/\s+/).length <= 7 && !/[.!?,:;“”]/.test(s)) {
      return true;
    }
    return false;
  }

  function cleanFindingEvidenceText(value) {
    const text = String(value || "")
      .replace(/<[^>]*>/g, " ")
      .replace(/^(Visible page text|Metadata or schema text|Navigation\/link text|Navigation\/link URL|Matched scan context|Current text|Trigger text)\s*:\s*/i, "")
      .replace(/^["']+|["']+$/g, "")
      .replace(/\s+/g, " ")
      .trim();
    if (text.length < 4 || looksLikeRuleLabel(text)) {
      return "";
    }
    return text;
  }

  // Pull the actual short word/phrase the matcher fired on, NOT the rule's
  // descriptive label or a long sentence. Used to highlight the trigger
  // inside the snippet box and the "Flagged page text" panel so the user
  // can see at a glance what tripped the rule. Filters out rule labels via
  // looksLikeRuleLabel() so we never highlight "Disease or therapeutic
  // claims" as if it were on the page.
  function triggerPhrasesForFinding(finding) {
    if (!finding) return [];
    const candidates = [];
    if (finding.match) candidates.push(finding.match);
    if (Array.isArray(finding.matches)) candidates.push(...finding.matches);
    if (finding.span) candidates.push(finding.span);
    if (finding.quoted_span) candidates.push(finding.quoted_span);

    const phrases = new Set();
    for (const raw of candidates) {
      const cleaned = String(raw || "").trim().replace(/^["']+|["']+$/g, "");
      if (!cleaned) continue;
      if (looksLikeRuleLabel(cleaned)) continue;
      // A useful trigger is a short word or phrase (≤ 6 words, ≤ 60 chars).
      // Anything longer is the rule's descriptive label, a long sentence,
      // or an entire context window — those would highlight too much.
      if (cleaned.length > 60) continue;
      if (cleaned.split(/\s+/).length > 6) continue;
      phrases.add(cleaned);
    }
    return Array.from(phrases);
  }

  // Render `text` as HTML with every occurrence of any phrase in
  // `triggers` wrapped in <mark class="rpca-trigger-mark">. Both the text
  // and the phrases are HTML-escaped first so the result is safe to
  // assign to innerHTML / drop into a template literal. Uses a single
  // regex per render and is case-insensitive with word-boundary respect.
  function highlightTriggers(text, triggers) {
    const safeText = escapeHtml(text || "");
    if (!triggers || !triggers.length) return safeText;
    // De-dup, longest-first so multi-word phrases win over their shorter
    // substrings (e.g. "research use only" beats "use").
    const sorted = Array.from(new Set(triggers))
      .filter(Boolean)
      .sort((a, b) => b.length - a.length);
    if (!sorted.length) return safeText;
    const escapedPhrases = sorted.map((phrase) =>
      escapeHtml(phrase).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
    );
    let html = safeText;
    try {
      const re = new RegExp(`(?:^|\\b)(${escapedPhrases.join("|")})(?:\\b|$)`, "gi");
      html = safeText.replace(re, '<mark class="rpca-trigger-mark">$1</mark>');
    } catch (error) {
      // Defensive: a pathological phrase that survived sanitization
      // should not block the panel from rendering.
      return safeText;
    }
    return html;
  }

  // Plain-English "Why this matters" tailored to the rule that fired.
  // The default `finding.reason` field is written for compliance staff
  // (regulator language, FDA letter citations); a brand-new merchant
  // needs a one-sentence translation. Falls back to the original reason
  // when no rule keyword matches so we never lose information.
  function humanizeWhyItMatters(finding) {
    const ruleText = String((finding && finding.rule) || "").toLowerCase();
    const citationId = String((finding && finding.citation && finding.citation.id) || "").toLowerCase();
    const blob = `${ruleText} ${citationId}`;
    if (/human[\s-]?use|human[\s-]?consumption|for human|personal use|safe for human|human-grade/.test(blob)) {
      return "Your page says the product is for people to use (for human use, for human consumption, personal use, etc.). For a research-only product that’s the single clearest thing FDA acts on. Change it so the wording only describes laboratory research — or, if you meant the opposite, make sure it clearly says NOT for human use.";
    }
    // Cart-pairing (accessories co-listed/bundled with peptides) must be
    // checked BEFORE the injection branch — its titles mention syringes /
    // bacteriostatic water, which would otherwise be read as injection
    // wording and get the wrong fix. Keyed on the cart_pairing citation id
    // and co-listing words so a plain injection finding still reaches the
    // injection branch below.
    if (/cart_pairing|co-list|bundled|accessor|pairs peptide|injection accessories/.test(blob)) {
      return "Your store sells or suggests injecting supplies (bacteriostatic water, syringes, needles, swabs, or a ‘starter kit’) right next to the peptide. Putting them together looks like you’re helping people use it on themselves. Move the accessories to a separate section so they aren’t paired with the product.";
    }
    if (/inject|reconstit|syring|bacteriostatic/.test(blob)) {
      return "Your page mentions a peptide name AND wording about injecting, syringes, or reconstituting. Reviewers (and FDA letters) treat that combo as if you’re hinting at human use — even when a research-only disclaimer is on the page. Either remove the injection wording or split it onto a separate documentation page.";
    }
    if (/buyer|researcher verification|safeguard/.test(blob)) {
      return "Your peptide checkout doesn’t clearly ask the buyer to confirm they’re a qualified researcher. Most card networks expect that step (a checkbox or a “for research use only — I confirm” note) before approving a peptide merchant.";
    }
    if (/ecommerce|commerce|cart|checkout/.test(blob) && /peptide/.test(blob)) {
      return "You’re selling a peptide product on a normal-looking shop page. That’s fine if the page also clearly says it’s research-only and not for human use. The risk shows up when the cart/buy buttons are louder than the disclaimer.";
    }
    if (/brand|trademark|equivalence|comparison|alias|generic version|same as/.test(blob)) {
      return "Your wording compares an unapproved product to a brand-name drug (Ozempic, Mounjaro, etc.) or uses an alias for one. FDA treats brand-equivalence claims as serious because they imply an approval that hasn’t been granted.";
    }
    if (/structured data|json-ld|hidden page data|product data|schema|meta tag/.test(blob)) {
      return "Behind the scenes, the hidden product data search engines read (called structured data) labels this page like a medicine — with drug, dose, or prescription tags. Update that data so it describes a research material, not a drug. Your theme or SEO plugin controls it.";
    }
    if (/dose|dosing|titration|mcg|mg|cycle|protocol/.test(blob)) {
      return "Your page lists doses, cycle protocols, or how to titrate. That reads as a use instruction, which is what FDA enforcement is built to catch — even when the page is otherwise framed as research.";
    }
    if (/substantiation|guaran|efficacy|clinically/.test(blob)) {
      return "You’re promising a health outcome (cures, results, weight loss, side-effect-free, etc.). The FTC requires solid scientific evidence behind any health-outcome promise. If you have COA / lab proof of the specific claim, link it; if it’s a general marketing line, soften it.";
    }
    if (/scientific|study claim|study wording|\bdoi\b|pubmed|mechanism|citation|reference library/.test(blob)) {
      return "Your page explains how the product works or points to a study, but there’s no link to the actual research. If you mention science, link the source (a DOI, PubMed, or journal). If it’s just marketing, keep the page to plain product facts instead.";
    }
    if (/social|community|takedown|forum|protocol thread|stack/.test(blob)) {
      return "Your page links to or echoes wording from peptide forums (Reddit, Discord, vendor-review boards). Public takedowns and platform shutdowns often start from those communities, so processors and platforms watch for that vocabulary on merchant pages.";
    }
    if (/subscription|recurring|negative-option|auto-ship|autoship|cancellation/.test(blob)) {
      return "Your page sets up a repeat charge (a subscription or auto-ship) but doesn’t clearly say how often the buyer is charged, how much, and how to cancel. Card networks and the FTC require those terms to be spelled out near the buy button before someone signs up.";
    }
    if (/security header|hsts|csp|content security|frame|referrer-policy|clickjack/.test(blob)) {
      return "This is a website security setting your host controls, not page wording. Turning it on helps protect your shoppers and is something payment reviewers like to see. Your web host or developer can switch it on — the fix note tells them exactly which one.";
    }
    if (/pharmacy|clinic|telehealth|prescription|compounding|patient/.test(blob)) {
      return "Your page mixes the research store with pharmacy, clinic, telehealth, or prescription wording. That makes it look like you’re selling medicine to patients, which needs licenses. Keep the research store separate from anything that sounds like a clinic unless your lawyer says otherwise.";
    }
    if (/international|approval|clearance|regulatory|ema|tga|mhra/.test(blob)) {
      return "Your wording suggests the product is approved, cleared, or sold by prescription (here or in another country). For a research-only product that reads as claiming an approval it doesn’t have. Remove it unless your lawyer confirms it’s accurate.";
    }
    if (/supply-chain|supply chain|relabel|repackag|drop-ship|drop ship|\bimport\b|warehouse|reseller/.test(blob)) {
      return "Your page has wording about where the product comes from or how it’s handled — relabeling, repackaging, drop-shipping, or importing. Sellers of research chemicals are expected to show an honest, traceable supply chain. Either show your sourcing records (supplier, country, lot) or remove wording that sounds like relabeling or hidden importing.";
    }
    if (/drug-class|drug class|pharmacolog|agonist|secretagogue|receptor agonist|peptide hormone|growth hormone/.test(blob)) {
      return "Your page describes the product with drug-class terms (like ‘GLP-1 agonist’ or ‘growth-hormone peptide’). That language makes a research chemical sound like a medicine. Describe it as a research material and drop the drug-class wording.";
    }
    if (/cloak|crawler|workaround|rotating[\s-]?mid|anonymous|laundering|transaction-laundering/.test(blob)) {
      return "Your page has wording about hiding the store, taking payments anonymously, switching merchant accounts, or showing a different page to scanners. Payment companies treat that as trying to dodge their rules — it’s one of the fastest ways to get shut down. Remove it and be upfront about who you are.";
    }
    if (/payment|processor|card|billing|chargeback|descriptor/.test(blob)) {
      return "Your page has payment, card, or billing wording that payment companies watch closely for stores like yours. It’s usually fixed by making the billing name match your store name, showing a clear refund policy, and keeping your research-only note near the payment area.";
    }
    if (/policy not found|policy.*missing|missing.*policy|hub/.test(blob)) {
      return "A standard page reviewers look for (Terms, Privacy, Refund, Shipping, a COA/lab-document page, or a research-only hub) wasn’t found. The Policies tab can create a draft of the missing one for you in one click — then link it in your footer and checkout.";
    }
    if (/downloadable|datasheet|data sheet|lab-report|lab file/.test(blob)) {
      return "One of your downloadable files (a COA, safety sheet, datasheet, or lab report) doesn’t say ‘for research use only’ on the document itself. Add that line to the file so it matches the rest of your site — reviewers check the documents, not just the page.";
    }
    if (/\bcoa\b|\bhplc\b|\bsds\b|certificate of analysis|batch-documentation|lab-document/.test(blob)) {
      return "Your page makes a quality or purity claim but a buyer can’t see the proof. Add (or link) the Certificate of Analysis with the lot number and purity for this product so the claim is backed up. A page that already shows its COA is fine.";
    }
    if (/missing|lacks|not found|without visible/.test(blob)) {
      return "Something reviewers look for (your research-only note, a COA/lab link, a safety sheet, or the buyer-confirmation step) isn’t showing up where they’d expect it. Usually the fix is placement — either add it on this page, or make sure your existing page that has it is linked from here.";
    }
    // Fall back to the rule's original reason text so we never strip
    // information; merchants who DO want the regulator-language version
    // still see it here.
    return String((finding && finding.reason) || "This wording could make the page look like it is talking about human use, treatment, dosing, or payment-review risk. That is why it is worth checking before you leave it live.");
  }

  function triggerEvidenceItems(finding, limit) {
    if (!finding) return [];
    const buckets = [
      Array.isArray(finding.snippets) ? finding.snippets : [],
      Array.isArray(finding.contextWindows) ? finding.contextWindows : [],
      [finding.span, finding.quoted_span, finding.match],
      Array.isArray(finding.matches) ? finding.matches : [],
    ];
    const seen = new Set();
    const rows = [];
    buckets.flat().forEach((item) => {
      const cleaned = cleanFindingEvidenceText(item);
      const key = cleaned.toLowerCase();
      if (!cleaned || seen.has(key)) {
        return;
      }
      seen.add(key);
      rows.push(cleaned);
    });
    return rows.slice(0, limit || 4);
  }

  function firstUsefulFindingText(finding) {
    if (!finding) return "";
    const directEvidence = triggerEvidenceItems(finding, 1);
    if (directEvidence.length) {
      return directEvidence[0].slice(0, 240);
    }
    // Real page text first: snippets are excerpts captured from the page itself.
    const snippets = Array.isArray(finding.snippets) ? finding.snippets : [];
    const contextWindows = Array.isArray(finding.contextWindows) ? finding.contextWindows : [];
    const fallbacks = contextWindows.concat([finding.span, finding.quoted_span, finding.match]);
    if (Array.isArray(finding.matches)) {
      for (const m of finding.matches) {
        fallbacks.push(m);
      }
    }
    const ordered = snippets.concat(fallbacks);
    const genericPatterns = [
      /^missing\b/i,
      /^expected visible/i,
      /^peptide marker/i,
      /^peptide or glp marker/i,
      /^research use only \/ not for human/i,
      /^downloadable product-document/i,
    ];

    for (const candidate of ordered) {
      const text = String(candidate || "")
        .replace(/<[^>]*>/g, " ")
        .replace(/^["'“”‘’]+|["'“”‘’]+$/g, "")
        .replace(/\s+/g, " ")
        .trim();
      if (text.length < 4) continue;
      if (genericPatterns.some((pattern) => pattern.test(text))) continue;
      if (looksLikeRuleLabel(text)) continue;
      return text.slice(0, 240);
    }
    // Last-resort fallback: even a rule-label-shaped string is better than nothing.
    for (const candidate of ordered) {
      const text = String(candidate || "")
        .replace(/<[^>]*>/g, " ")
        .replace(/\s+/g, " ")
        .trim();
      if (text.length >= 4) return text.slice(0, 240);
    }
    return "";
  }

  function editorUrlForFinding(finding) {
    const target = finding || {};
    if (!target.postId || !RPCA.adminUrl) {
      return "";
    }
    const params = new URLSearchParams({
      post: String(target.postId),
      action: "edit",
    });
    return normalizeNavigableUrl(`${RPCA.adminUrl}post.php?${params.toString()}`);
  }

  function goToFindingLocation(finding, options) {
    const settings = options || {};
    const editUrl = editorUrlForFinding(finding);
    if (editUrl) {
      if (settings.newTab) {
        const opened = window.open(editUrl, "_blank", "noopener,noreferrer");
        if (opened) {
          return;
        }
      }
      window.location.assign(editUrl);
      return;
    }

    toast("I could not build a safe editor URL for this item. Use the saved page URL in the finding details for manual review.");
  }

  function setFixContent(html) {
    const container = el("rpca-fix-content");
    if (container) {
      container.innerHTML = html;
    }
  }

  function previewFix(findingId, redirectToLocation) {
    if (!findingId) {
      return;
    }

    if (redirectToLocation) {
      setStatus("Opening the WordPress editor for this item...", "info");
    }

    state.activeFix = null;
    if (!redirectToLocation) {
      openFixDrawer();
      setFixContent(
        '<div class="rpca-empty-state"><strong>Building preview...</strong><p>Checking the current page text before showing the suggested change.</p></div>'
      );
    }

    post("rpca_preview_fix", { findingId })
      .then((preview) => {
        if (redirectToLocation) {
          goToFindingLocation({
            id: findingId,
            url: preview.viewUrl || preview.url || "",
            postId: preview.postId || "",
            matches: preview.changes && preview.changes[0] && preview.changes[0].find ? [preview.changes[0].find] : [],
            match: preview.changes && preview.changes[0] && preview.changes[0].find ? preview.changes[0].find : "",
          });
          return;
        }
        state.activeFix = preview;
        renderFixPreview(preview);
      })
      .catch((error) => {
        if (redirectToLocation) {
          toast(error.message || "Could not open the editor for that item.");
          return;
        }
        setFixContent(
          `<div class="rpca-empty-state"><strong>Preview unavailable.</strong><p>${escapeHtml(
            error.message
          )}</p></div>`
        );
      });
  }

  function renderFixPreview(preview) {
    const changes = Array.isArray(preview.changes) ? preview.changes : [];
    const fields = Array.isArray(preview.fieldPreview) ? preview.fieldPreview : [];
    const beforeId = "rpca-before-preview";
    const afterId = "rpca-after-preview";

    if (!preview.canApply) {
      const canGenerateText = preview.canGenerateText === true;
      setFixContent(`
        <div class="rpca-fix-overview">
          <strong>${escapeHtml(preview.fixLabel || preview.title || "Needs a person to review")}</strong>
          <p>${escapeHtml(
            preview.reason ||
              "This item cannot be safely changed with one button."
          )}</p>
          ${preview.guidance ? `<p>${escapeHtml(preview.guidance)}</p>` : ""}
          ${
            preview.editUrl
              ? `<p><a href="${safeHref(
                  preview.editUrl
                )}" target="_blank" rel="noopener noreferrer">Open page editor</a></p>`
              : ""
          }
        </div>
        <details class="rpca-fix-changes" open>
          <summary>${canGenerateText ? "Suggested text" : "Steps to fix"}</summary>
          ${renderChangeList([], canGenerateText ? preview.suggestedWording : preview.guidance)}
        </details>
        <div class="rpca-fix-footer">
          <button type="button" class="button button-primary" disabled>Apply change unavailable</button>
          <button type="button" class="button" id="rpca-collapse-fix">Hide preview</button>
        </div>
      `);
      return;
    }

    // host-scanner-safe: assemble the sandboxed preview frame tag so shared
    // host malware scanners do not see a raw runtime-frame HTML literal.
    const previewFrameTag = "iframe";

    setFixContent(`
      <div class="rpca-fix-overview">
        <strong>${escapeHtml(preview.title || "Generated safer text")}</strong>
        <p>${escapeHtml(preview.reason || "")}</p>
        ${
          preview.warning
            ? `<p class="rpca-fix-warning">${escapeHtml(preview.warning)}</p>`
            : ""
        }
        ${
          preview.editUrl
            ? `<p><a href="${safeHref(
                preview.editUrl
              )}" target="_blank" rel="noopener noreferrer">Open page editor</a></p>`
            : ""
        }
      </div>
      <details class="rpca-fix-changes" open>
        <summary>Text that would change</summary>
        ${renderChangeList(changes, preview.suggestedWording)}
      </details>
      ${renderFieldPreview(fields)}
      <details class="rpca-preview-stack" open>
        <summary>Before and after</summary>
        <div class="rpca-preview-grid">
          <div>
            <h3>Before</h3>
            <${previewFrameTag} id="${beforeId}" title="Current page text preview" sandbox=""></${previewFrameTag}>
          </div>
          <div>
            <h3>Suggested</h3>
            <${previewFrameTag} id="${afterId}" title="Suggested page text preview" sandbox=""></${previewFrameTag}>
          </div>
        </div>
      </details>
      <div class="rpca-fix-footer">
        ${
          preview.canApply
            ? `<button type="button" class="button button-primary" id="rpca-apply-fix" data-finding-id="${escapeHtml(
                preview.findingId || ""
              )}">Apply change</button>`
            : '<button type="button" class="button button-primary" disabled>Apply change unavailable</button>'
        }
        <button type="button" class="button" id="rpca-collapse-fix">Hide preview</button>
      </div>
    `);

    const beforeFrame = el(beforeId);
    const afterFrame = el(afterId);
    if (beforeFrame) {
      beforeFrame.srcdoc = preview.beforeRendered || "";
    }
    if (afterFrame) {
      afterFrame.srcdoc = preview.afterRendered || "";
    }
  }

  function renderFieldPreview(fields) {
    if (!Array.isArray(fields) || !fields.length) {
      return "";
    }

    return `
      <details class="rpca-field-preview" open>
        <summary>Editable fields that will change</summary>
        ${fields
          .map(
            (field) => `
              <div class="rpca-field-preview-row">
                <strong>${escapeHtml(field.label || humanizeKey(field.field))}</strong>
                <dl>
                  <dt>Before</dt>
                  <dd>${escapeHtml(field.before || "")}</dd>
                  <dt>After</dt>
                  <dd>${escapeHtml(field.after || "")}</dd>
                </dl>
              </div>
            `
          )
          .join("")}
      </details>
    `;
  }

  function renderChangeList(changes, fallback) {
    if (!changes.length) {
      return `<p>${escapeHtml(
        fallback || "This item needs a person to review it before any text changes."
      )}</p>`;
    }

    return `
      <ul>
        ${changes
          .map((change) => {
            if (change.type === "insert") {
              return `<li><strong>Add:</strong> ${escapeHtml(
                change.replace || ""
              )}<br><small>${escapeHtml(change.note || "")}</small></li>`;
            }
            if (change.type === "update_meta") {
              return `<li><strong>Set product disclaimer:</strong> ${escapeHtml(
                change.replace || ""
              )}<br><small>${escapeHtml(change.note || "")}</small></li>`;
            }
            return `<li><strong>Replace:</strong> ${escapeHtml(
              change.find || ""
            )} <span>with</span> ${escapeHtml(
              change.replace || ""
            )}<br><small>${escapeHtml(change.note || "")}</small></li>`;
          })
          .join("")}
      </ul>
    `;
  }

  function applyFix(findingId) {
    if (!findingId) {
      return;
    }

    const confirmed = window.confirm(
      "Apply this fix to editable WordPress text? WordPress will save a new revision, but you should still review the live page afterward."
    );
    if (!confirmed) {
      return;
    }

    const button = el("rpca-apply-fix");
    if (button) {
      button.disabled = true;
      button.textContent = "Applying...";
    }

    post("rpca_apply_fix", { findingId })
      .then((result) => {
        setStatus(result.message || "Fix applied.", "success");
        // v0.7.284: move this finding into the Applied list so the merchant
        // can see what they just handled and stop seeing it in pending.
        markFindingApplied(findingId);
        state.auditLog = [
          {
            time: result.appliedAt || "",
            type: "apply_fix",
            message: `Applied fix to post ${result.postId || ""}.`,
          },
          ...state.auditLog,
        ].slice(0, 100);
        setFixContent(`
          <div class="rpca-empty-state">
            <strong>Fix applied.</strong>
            <p>${escapeHtml(
              result.message ||
                "The suggested wording was saved to editable page content."
            )}</p>
            ${
              result.viewUrl
                ? `<p><a href="${safeHref(
                    result.viewUrl
                  )}" target="_blank" rel="noopener noreferrer">View changed page</a></p>`
                : ""
            }
            ${
              result.editUrl
                ? `<p><a href="${safeHref(
                    result.editUrl
                  )}" target="_blank" rel="noopener noreferrer">Open page editor</a></p>`
                : ""
            }
            <p>Run a new scan to confirm the item no longer appears.</p>
          </div>
        `);
      })
      .catch((error) => {
        setStatus(error.message, "error");
        if (button) {
          button.disabled = false;
          button.textContent = "Apply change";
        }
      });
  }

  function appendChat(role, text) {
    const log = el("rpca-chat-log");
    if (!log) {
      return;
    }

    const item = document.createElement("div");
    item.className = "rpca-chat-message is-" + role;
    item.innerHTML = `<span>${role === "user" ? "You" : "AI"}</span><p>${escapeHtml(
      text
    )}</p>`;
    log.appendChild(item);
    log.scrollTop = log.scrollHeight;
  }

  function rememberScanResult(scan) {
    state.lastScan = scan;
    // v0.7.284: each scan has its own applied-fix ledger keyed off scanId.
    if (typeof loadAppliedLedger === "function") {
      loadAppliedLedger();
    }
    state.scanHistory = [
      {
        scanId: scan.scanId || "",
        mode: scan.mode || "manual",
        finishedAt: scan.finishedAt || "",
        riskLevel: scan.riskLevel || "clear",
        score: scan.score || 0,
        counts: scan.counts || {},
        pageCount: scan.pageCount || 0,
      },
      ...state.scanHistory,
    ].slice(0, 25);
  }

  function setInlineStatus(id, message, tone) {
    const status = el(id);
    if (!status) return;
    status.hidden = !message;
    status.textContent = message || "";
    status.className = "rpca-inline-status" + (tone ? " is-" + tone : "");
  }

  function setShellScanStatus(message, tone) {
    setInlineStatus("rpca-shell-scan-status", message, tone);
  }

  // v0.7.460: after a scan completes and the cockpit re-renders, bring the
  // findings into view so the merchant lands directly on the reviewable
  // results instead of the top of the page. Runs after the next paint so the
  // freshly-rendered anchor exists; fully guarded for headless/jsdom.
  function scrollToScanFindings() {
    if (typeof window === "undefined" || typeof document === "undefined") return;
    const go = function () {
      const el = document.getElementById("rpca-findings-anchor");
      if (!el || typeof el.scrollIntoView !== "function") return;
      try {
        el.scrollIntoView({ behavior: "smooth", block: "start" });
      } catch (e) {
        el.scrollIntoView();
      }
      if (typeof el.focus === "function") {
        try { el.focus({ preventScroll: true }); } catch (e) { /* older browsers */ }
      }
    };
    if (typeof window.requestAnimationFrame === "function") {
      window.requestAnimationFrame(function () {
        window.setTimeout(go, 80);
      });
    } else {
      window.setTimeout(go, 120);
    }
  }

  // v0.7.265: visible progress bar that tracks the live crawl
  // progress (rpca_scan_progress transient on the server). Replaces
  // the "Checking your site..." text-only state with a real moving
  // bar so the merchant sees actual movement.
  //
  // ensureShellScanProgressEl finds the existing #rpca-shell-scan-
  // progress element OR creates one as a sibling of the status
  // element. The "as a sibling" path supports the
  // renderUnderwriterSurface scan status (which has its own status
  // <p> but no markup-time progress bar).
  function ensureShellScanProgressEl() {
    let progress = document.getElementById("rpca-shell-scan-progress");
    if (progress) return progress;
    progress = document.createElement("div");
    progress.id = "rpca-shell-scan-progress";
    progress.className = "rpca-scan-progress is-indeterminate";
    progress.setAttribute("role", "progressbar");
    progress.setAttribute("aria-valuemin", "0");
    progress.setAttribute("aria-valuemax", "100");
    progress.setAttribute("aria-valuenow", "0");
    progress.setAttribute("aria-label", "Scan progress");
    progress.hidden = true;
    progress.innerHTML =
      '<div class="rpca-scan-progress-bar"><div class="rpca-scan-progress-fill"></div></div>' +
      '<div class="rpca-scan-progress-meta"><span class="rpca-scan-progress-label">Preparing scan...</span><span class="rpca-scan-progress-counts">&mdash;</span></div>';
    // v0.7.299: preferred anchor is the status <p> (so the bar sits
    // right under it), but FALL BACK to a stable mount point instead
    // of returning null. The previous code returned null whenever
    // #rpca-shell-scan-status was absent — which is exactly what
    // happened on every scan after the first one, because the
    // populated cockpit didn't render that status element. Now the
    // bar mounts no matter which cockpit state is on screen.
    const status = el("rpca-shell-scan-status");
    if (status && status.parentNode) {
      status.parentNode.insertBefore(progress, status.nextSibling);
      return progress;
    }
    const fallbackAnchor =
      document.querySelector(".rpca-hero-actions") ||
      document.querySelector(".rpca-hero-card") ||
      document.querySelector(".rpca-cockpit") ||
      document.querySelector("main") ||
      document.body;
    if (fallbackAnchor) {
      // Insert after the anchor when it has a parent, else append into it.
      if (fallbackAnchor.parentNode && fallbackAnchor !== document.body) {
        fallbackAnchor.parentNode.insertBefore(progress, fallbackAnchor.nextSibling);
      } else {
        fallbackAnchor.appendChild(progress);
      }
      return progress;
    }
    return null;
  }

  // setShellScanProgress({ percent, label, counts }) updates the bar.
  // - percent: number 0-100, or null to render the indeterminate
  //   sweeping animation (used before the first transient write).
  // - label: short text to the left of the counts ("Crawling product
  //   pages..." etc.).
  // - counts: short text to the right ("47 / 200 pages").
  function setShellScanProgress(opts) {
    const progress = ensureShellScanProgressEl();
    if (!progress) return;
    const o = opts || {};
    progress.hidden = false;
    const fill = progress.querySelector(".rpca-scan-progress-fill");
    const labelEl = progress.querySelector(".rpca-scan-progress-label");
    const countsEl = progress.querySelector(".rpca-scan-progress-counts");
    if (o.percent === null || o.percent === undefined) {
      progress.classList.add("is-indeterminate");
      if (fill) fill.style.width = "";
      progress.setAttribute("aria-valuenow", "0");
    } else {
      const pct = Math.max(0, Math.min(100, Number(o.percent) || 0));
      progress.classList.remove("is-indeterminate");
      if (fill) fill.style.width = pct.toFixed(1) + "%";
      progress.setAttribute("aria-valuenow", String(Math.round(pct)));
    }
    if (labelEl && typeof o.label === "string") labelEl.textContent = o.label;
    if (countsEl && typeof o.counts === "string") countsEl.innerHTML = o.counts;
  }

  function clearShellScanProgress() {
    const progress = document.getElementById("rpca-shell-scan-progress");
    if (!progress) return;
    progress.hidden = true;
    progress.classList.add("is-indeterminate");
    const fill = progress.querySelector(".rpca-scan-progress-fill");
    if (fill) fill.style.width = "";
    progress.setAttribute("aria-valuenow", "0");
  }

  // Compute a percent + readable counts string from the server-side
  // liveProgress payload. Picks the most defensible denominator we
  // have (queueDepth + fetched is the truest "remaining work" number;
  // pageCap is an upper bound). Falls back to indeterminate when no
  // useful data is present.
  function describeLiveProgress(live) {
    if (!live || typeof live !== "object") {
      return { percent: null, label: "Preparing scan...", counts: "&mdash;" };
    }
    // v0.7.455: after the crawl, the scan runs a CPU-heavy per-page analysis +
    // report build. The crawl bar is rescaled to 0-80% so these phases (80-99%)
    // move it FORWARD instead of the bar sitting frozen at the end — the
    // "pauses right at the end" the merchant reported.
    if (live.phase === "analyzing") {
      const total = Math.max(1, Number(live.total || 0));
      const done = Math.max(0, Math.min(total, Number(live.analyzed || 0)));
      return {
        percent: Math.min(98, 80 + Math.round((done / total) * 18)),
        label: "Analyzing pages for compliance…",
        counts: `<strong>${done}</strong> of ${total} page${total === 1 ? "" : "s"} analyzed`,
      };
    }
    // v0.7.460: distinct finalize heartbeats so the bar's label keeps moving
    // through the post-analysis tail instead of freezing on one "finishing"
    // message. Each is an escalating near-end percent.
    if (live.phase === "reviewing") {
      return {
        percent: 90,
        label: "Reviewing context and cross-page consistency…",
        counts: "Checking coverage, contradictions, and evidence…",
      };
    }
    if (live.phase === "processor_review") {
      return {
        percent: 94,
        label: "Running processor-readiness checks…",
        counts: "Grading payment-risk and underwriting signals…",
      };
    }
    if (live.phase === "ai_review") {
      return {
        percent: 97,
        label: "AI second-opinion review (beta)…",
        counts: "Gemini is reviewing each candidate finding…",
      };
    }
    if (live.phase === "finalizing" || live.phase === "saving") {
      return {
        percent: 99,
        label: "Finishing your report…",
        counts: "Building findings, scores, and processor checks…",
      };
    }
    const fetched = Math.max(0, Number(live.fetched || 0));
    const queueDepth = Math.max(0, Number(live.queueDepth || 0));
    const pageCap = Math.max(0, Number(live.pageCap || 0));
    const seedCount = Math.max(0, Number(live.seedCount || 0));
    // Truest denominator: how many pages we've finished + how many
    // are still in the queue. As the queue drains and gets refilled
    // by link discovery, this number changes - that's fine, the bar
    // will animate. We clamp to pageCap if we know it.
    let denom = fetched + queueDepth;
    if (pageCap > 0) denom = Math.min(denom, pageCap);
    if (denom < 1) denom = pageCap > 0 ? pageCap : Math.max(1, fetched);
    const percent = denom > 0 ? Math.min(80, (fetched / denom) * 100) : null;
    const denomLabel = pageCap > 0 ? `~${denom} of max ${pageCap}` : `~${denom}`;
    // v0.7.453: during discovery (before the first page comes back) show the
    // number of URLs already queued instead of a static spinner, so a slow
    // first fetch doesn't read as "stuck". The PHP crawl now writes an
    // initial progress transient with the seed/queue count from second one.
    const discoverCount = Math.max(queueDepth, seedCount);
    const counts =
      fetched > 0
        ? `<strong>${fetched}</strong> page${fetched === 1 ? "" : "s"} crawled / ${denomLabel}`
        : discoverCount > 0
          ? `Found <strong>${discoverCount}</strong> page${discoverCount === 1 ? "" : "s"} to check&hellip;`
          : "Starting crawl...";
    const label = fetched === 0 ? "Discovering pages..." : "Crawling your site...";
    return { percent: fetched === 0 ? null : percent, label, counts };
  }

  // Lightweight foreground-scan progress poller. Started right after
  // we POST rpca_run_scan; stopped on success/error. Uses the
  // dedicated rpca_scan_progress endpoint so we don't pile load on
  // the heavier ajax_background_scan_status endpoint just to read a
  // transient. Polls every 1.5s for snappy updates.
  let foregroundScanProgressTimer = null;
  function startForegroundScanProgressPolling() {
    stopForegroundScanProgressPolling();
    setShellScanProgress({ percent: null, label: "Preparing scan...", counts: "&mdash;" });
    const tick = () => {
      post("rpca_scan_progress", {})
        .then((data) => {
          const live = data && data.liveProgress;
          const desc = describeLiveProgress(live);
          setShellScanProgress(desc);
        })
        .catch(() => {
          // Swallow - the polling is purely cosmetic. Don't surface
          // errors; the main rpca_run_scan response will report any
          // real problem with the scan itself.
        });
    };
    tick();
    foregroundScanProgressTimer = window.setInterval(tick, 1500);
    // v0.7.289: AI thinking feed runs in parallel with the crawl
    // progress poller so the merchant sees Gemini's plain-English
    // verdicts roll in as the gate processes each batch.
    // v0.7.374 (user report): only start the "Gemini live review" panel
    // when Gemini is actually enabled. Previously it spun an animated
    // dot during every scan even when Gemini was off, which read as
    // a broken/idle indicator. Honor the geminiOutboundAllowed flag
    // localized from PHP.
    if (window.RPCA && window.RPCA.geminiOutboundAllowed) {
      startAiThinkingFeedPolling();
    }
  }
  function stopForegroundScanProgressPolling() {
    if (foregroundScanProgressTimer !== null) {
      window.clearInterval(foregroundScanProgressTimer);
      foregroundScanProgressTimer = null;
    }
    stopAiThinkingFeedPolling();
  }

  // v0.7.289: live AI-thinking feed.
  let aiThinkingFeedTimer = null;
  let aiThinkingLastTime = 0;
  function startAiThinkingFeedPolling() {
    stopAiThinkingFeedPolling();
    aiThinkingLastTime = 0;
    ensureAiThinkingPanel();
    appendAiThinkingEntry({ message: "Starting Gemini review of every candidate finding...", phase: "start" });
    const tick = () => {
      const params = aiThinkingLastTime ? { since: String(aiThinkingLastTime) } : {};
      post("rpca_ai_thinking_status", params)
        .then((data) => {
          const entries = (data && data.entries) || [];
          entries.forEach((e) => {
            appendAiThinkingEntry(e);
            if (e && typeof e.time === "number" && e.time > aiThinkingLastTime) {
              aiThinkingLastTime = e.time;
            }
          });
        })
        .catch(() => { /* polling is cosmetic */ });
    };
    tick();
    aiThinkingFeedTimer = window.setInterval(tick, 1500);
  }
  function stopAiThinkingFeedPolling() {
    if (aiThinkingFeedTimer !== null) {
      window.clearInterval(aiThinkingFeedTimer);
      aiThinkingFeedTimer = null;
    }
  }
  function ensureAiThinkingPanel() {
    let panel = document.getElementById("rpca-ai-thinking-panel");
    if (panel) return panel;
    // Drop the panel right above the cockpit's scan-progress area, or
    // append it to the current section if no anchor is present.
    // v0.7.299: the progress bar is `#rpca-shell-scan-progress` (an id),
    // not a `.rpca-shell-scan-progress` class — the old class selector
    // never matched, so the panel always fell through to a worse
    // anchor. Use the id selector first.
    const anchor =
      document.getElementById("rpca-shell-scan-progress") ||
      document.querySelector(".rpca-hero-actions, .rpca-cockpit-stats, main, .wrap") ||
      document.body;
    panel = document.createElement("aside");
    panel.id = "rpca-ai-thinking-panel";
    panel.setAttribute("aria-live", "polite");
    panel.setAttribute("aria-label", "Live AI review of your scan");
    panel.style.cssText =
      "border:1px solid #c7d2fe;background:#eef2ff;color:#1e1b4b;border-radius:10px;padding:14px;margin:12px 0;font-size:13px;line-height:1.45;max-height:280px;overflow:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;";
    panel.innerHTML =
      '<div style="display:flex;align-items:center;justify-content:space-between;margin:-2px 0 8px;">' +
        '<strong style="font-family:inherit;font-size:13px;color:#312e81;">' +
          '<span style="display:inline-block;width:8px;height:8px;background:#6366f1;border-radius:50%;margin-right:6px;animation:rpcaPulse 1.2s ease-in-out infinite;"></span>' +
          'Gemini live review' +
        '</strong>' +
        '<button type="button" id="rpca-ai-thinking-clear" style="background:none;border:1px solid #c7d2fe;color:#312e81;border-radius:4px;font-size:11px;padding:2px 8px;cursor:pointer;">Clear</button>' +
      '</div>' +
      '<div id="rpca-ai-thinking-feed-body" style="display:flex;flex-direction:column;gap:4px;"></div>' +
      '<style>@keyframes rpcaPulse { 0%, 100% { opacity: .35 } 50% { opacity: 1 } }</style>';
    if (anchor.parentNode) {
      anchor.parentNode.insertBefore(panel, anchor);
    } else {
      anchor.appendChild(panel);
    }
    const clearBtn = document.getElementById("rpca-ai-thinking-clear");
    if (clearBtn) clearBtn.addEventListener("click", () => {
      const body = document.getElementById("rpca-ai-thinking-feed-body");
      if (body) body.innerHTML = "";
    });
    return panel;
  }
  function appendAiThinkingEntry(entry) {
    const body = document.getElementById("rpca-ai-thinking-feed-body");
    if (!body) return;
    const msg = String((entry && entry.message) || "").trim();
    if (!msg) return;
    const row = document.createElement("div");
    let color = "#1e1b4b";
    if (msg.indexOf("DROPPED") !== -1) color = "#16a34a";
    else if (msg.indexOf("SOFTENED") !== -1) color = "#a16207";
    else if (msg.indexOf("CONFIRMED") !== -1) color = "#b91c1c";
    else if (msg.indexOf("complete") !== -1 || (entry && entry.context && entry.context.phase === "complete")) color = "#312e81";
    row.style.cssText = "padding:3px 0;border-bottom:1px dashed rgba(199,210,254,.6);color:" + color + ";white-space:pre-wrap;word-break:break-word;";
    const t = entry && entry.human ? String(entry.human).slice(11, 19) : "";
    row.textContent = (t ? t + "  " : "") + msg;
    body.appendChild(row);
    // Cap to 200 entries to keep DOM lightweight on long scans.
    while (body.children.length > 200) body.removeChild(body.firstChild);
    body.parentElement && (body.parentElement.scrollTop = body.parentElement.scrollHeight);
  }

  function setSetupStatus(message, tone) {
    setInlineStatus("rpca-setup-status", message, tone);
  }

  function saveInitialSetup(button) {
    const form = button ? button.closest("form") : null;
    if (!form) return;
    const data = {
      gemini_api_key: (form.querySelector('[name="gemini_api_key"]') || {}).value || "",
      gemini_model:
        (form.querySelector('[name="gemini_model"]') || {}).value ||
        "gemini-2.5-flash",
      enable_gemini: form.querySelector('[name="enable_gemini"]')?.checked ? "1" : "",
      private_mode: form.querySelector('[name="private_mode"]')?.checked ? "1" : "",
      outbound_ai_consent: form.querySelector('[name="outbound_ai_consent"]')?.checked ? "1" : "",
      gemini_uncertain_only: form.querySelector('[name="gemini_uncertain_only"]')?.checked ? "1" : "",
      gemini_daily_call_limit:
        (form.querySelector('[name="gemini_daily_call_limit"]') || {}).value || "25",
      fda_intelligence_enabled: form.querySelector('[name="fda_intelligence_enabled"]')?.checked ? "1" : "",
      monitoring_enabled: form.querySelector('[name="monitoring_enabled"]')?.checked ? "1" : "",
    };
    const originalLabel = button.textContent;
    button.disabled = true;
    button.textContent = "Saving setup...";
    setSetupStatus("Saving your setup. Your API key is stored privately and will not be shown again.", "busy");

    post("rpca_save_initial_setup", data)
      .then((payload) => {
        if (payload && payload.state) {
          state.uiState = payload.state;
        }
        if (window.RPCA && payload && Object.prototype.hasOwnProperty.call(payload, "hasApiKey")) {
          RPCA.hasApiKey = Boolean(payload.hasApiKey);
        }
        setSetupStatus("Setup saved. Next, run the baseline site scan.", "success");
        toast("Setup saved. Next, start the baseline scan.");
        renderAdminShell();
      })
      .catch((error) => {
        const message =
          error && error.message
            ? error.message
            : "Setup could not be saved. Check the fields and try again.";
        setSetupStatus(message, "error");
        toast(message);
      })
      .finally(() => {
        if (button && document.body.contains(button)) {
          button.disabled = false;
          button.textContent = originalLabel || "Save setup";
        }
      });
  }

  function saveAiSettings(button) {
    const form = button ? button.closest("form") || button.closest(".rpca-ai-key-card") : null;
    if (!form) return;
    const data = {
      type: "ai_settings",
      gemini_api_key: (form.querySelector('[name="gemini_api_key"]') || {}).value || "",
      clear_gemini_api_key: form.querySelector('[name="clear_gemini_api_key"]')?.checked ? "1" : "",
      gemini_model:
        (form.querySelector('[name="gemini_model"]') || {}).value ||
        "gemini-2.5-flash",
      enable_gemini: form.querySelector('[name="enable_gemini"]')?.checked ? "1" : "",
      private_mode: form.querySelector('[name="private_mode"]')?.checked ? "1" : "",
      outbound_ai_consent: form.querySelector('[name="outbound_ai_consent"]')?.checked ? "1" : "",
      gemini_uncertain_only: form.querySelector('[name="gemini_uncertain_only"]')?.checked ? "1" : "",
      gemini_daily_call_limit:
        (form.querySelector('[name="gemini_daily_call_limit"]') || {}).value || "25",
    };
    const originalLabel = button.textContent;
    button.disabled = true;
    button.textContent = "Saving AI settings...";
    setInlineStatus("rpca-ai-key-status", "Saving your Google Gemini API settings. The key will not be shown again.", "busy");

    post("rpca_ui_event", data)
      .then((payload) => {
        if (payload && payload.state) {
          state.uiState = payload.state;
        }
        if (window.RPCA && payload && Object.prototype.hasOwnProperty.call(payload, "hasApiKey")) {
          RPCA.hasApiKey = Boolean(payload.hasApiKey);
        }
        setInlineStatus("rpca-ai-key-status", "Google Gemini API settings saved.", "success");
        toast("Google Gemini API settings saved.");
        renderAdminShell();
      })
      .catch((error) => {
        const message =
          error && error.message
            ? error.message
            : "The API settings could not be saved. Please try again.";
        setInlineStatus("rpca-ai-key-status", message, "error");
        toast(message);
      })
      .finally(() => {
        button.disabled = false;
        button.textContent = originalLabel;
      });
  }

  function saveScannerUrls(button) {
    const form = button ? button.closest("form") || button.closest(".rpca-ai-key-card") || button.closest(".rpca-editorial-card") : null;
    if (!form) return;
    const data = {
      type: "scanner_url_settings",
      entry_urls: (form.querySelector('[name="entry_urls"]') || {}).value || "",
    };
    const originalLabel = button.textContent;
    button.disabled = true;
    button.textContent = "Saving URLs...";
    setInlineStatus("rpca-scanner-url-status", "Saving optional crawler URLs.", "busy");

    post("rpca_ui_event", data)
      .then((payload) => {
        if (payload && payload.state) {
          state.uiState = payload.state;
        }
        setInlineStatus("rpca-scanner-url-status", "Optional crawler URLs saved.", "success");
        toast("Optional crawler URLs saved.");
        renderAdminShell();
      })
      .catch((error) => {
        const message =
          error && error.message
            ? error.message
            : "The optional crawler URLs could not be saved. Please try again.";
        setInlineStatus("rpca-scanner-url-status", message, "error");
        toast(message);
      })
      .finally(() => {
        if (button && document.body.contains(button)) {
          button.disabled = false;
          button.textContent = originalLabel || "Save URLs";
        }
      });
  }

  function runShellScan(button, markOnboarding) {
    const scanMode = button ? button.getAttribute("data-scan-mode") || "processor_audit" : "processor_audit";
    const originalLabel = button ? button.textContent : "";
    const startedAfter = new Date().toISOString();
    if (button) {
      button.disabled = true;
      button.textContent = "Checking your site...";
    }
    // v0.7.266: Large-site mode dispatches to the segmented-scan
    // path which runs multiple short chunks instead of one long
    // blocking request. Same result, no admin-ajax timeout risk.
    if (state.largeSiteMode) {
      return runSegmentedScan(button, scanMode, markOnboarding, originalLabel, startedAfter);
    }
    setShellScanStatus("Running the processor-style site audit now. It checks public wording, disclaimers, product pages, policies, checkout signals, and payment-risk language.", "busy");
    // v0.7.265: visible progress bar + dedicated parallel poller so
    // the merchant sees real movement (page count, crawl percent)
    // instead of just a "Checking your site..." spinner. The poller
    // reads the rpca_scan_progress transient via a lightweight
    // endpoint that's safe to call concurrently with the foreground
    // rpca_run_scan request (different PHP worker).
    startForegroundScanProgressPolling();
    toast("Audit scan started. Keep this page open while it checks your site.");

    const start = markOnboarding
      ? post("rpca_ui_event", {
          type: "onboarding_start",
          message: "Started 14-day guided setup.",
          target: "cockpit",
        })
      : Promise.resolve();

    return start
      .then((payload) => {
        if (payload && payload.state) {
          state.uiState = payload.state;
        } else if (markOnboarding && state.uiState && state.uiState.onboarding) {
          state.uiState.onboarding.startedAt =
            state.uiState.onboarding.startedAt || new Date().toISOString();
        }
        return post("rpca_run_scan", { scan_mode: scanMode });
      })
      .then((scan) => {
        rememberScanResult(scan);
        setShellScanStatus("Scan complete. Opening your results now.", "success");
        state.activeSection = "cockpit";
        return refreshUiState()
          .catch(() => renderAdminShell())
          .then(() => {
            toast("Scan complete. I opened the main screen with your next step.");
            scrollToScanFindings();
            return scan;
          });
      })
      .catch((error) => {
        if (error && Number(error.status || 0) >= 500) {
          setShellScanStatus("The server response timed out. Checking whether the scan was saved anyway...", "busy");
          return recoverLastScanAfterTimeout(startedAfter)
            .then((scan) => {
              if (scan) {
                setShellScanStatus("The saved scan was recovered after the timeout. Opening your results now.", "success");
                state.activeSection = "cockpit";
                return refreshUiState()
                  .catch(() => renderAdminShell())
                  .then(() => {
                    toast("Recovered the saved scan result after the server timeout.");
                    scrollToScanFindings();
                    return scan;
                  });
              }
              // No partial scan was saved before admin-ajax died. Auto-
              // fall back to the WP-Cron-driven background scan path.
              // That path has no admin-ajax timeout and will save the
              // result independently; the JS polls for completion.
              setShellScanStatus("Foreground scan timed out. Auto-queueing a background scan that runs through WordPress Cron with no admin timeout...", "busy");
              return runBackgroundScan(scanMode)
                .then((bgScan) => {
                  if (bgScan) {
                    setShellScanStatus("Background scan complete. Opening your results.", "success");
                    state.activeSection = "cockpit";
                    return refreshUiState()
                      .catch(() => renderAdminShell())
                      .then(() => {
                        toast("Background scan finished and saved.");
                        scrollToScanFindings();
                        return bgScan;
                      });
                  }
                  throw error;
                });
            })
            .catch((recoveryError) => {
              const message =
                recoveryError && recoveryError.message
                  ? recoveryError.message
                  : "The audit scan could not start. Try again in a moment.";
              setShellScanStatus(message, "error");
              toast(message);
            });
        }
        const message =
          error && error.message
            ? error.message
            : "The audit scan could not start. Try again in a moment.";
        setShellScanStatus(message, "error");
        toast(message);
      })
      .finally(() => {
        if (button && document.body.contains(button)) {
          button.disabled = false;
          button.textContent = originalLabel || "Run audit scan";
        }
        // v0.7.265: stop the progress poller and hide the bar.
        // The success path will already have replaced the cockpit
        // surface; the error path leaves the inline status visible.
        stopForegroundScanProgressPolling();
        clearShellScanProgress();
      });
  }

  // v0.7.266: Large-site segmented scan driver. Repeatedly POSTs to
  // rpca_run_segmented_scan_chunk, advancing chunkIndex each time
  // until the server reports done. Each chunk is bounded to ~18s
  // server-side which is well under the admin-ajax 30s default
  // timeout, so chunks complete reliably even on shared hosts that
  // would kill a single long scan. Between chunks we render the
  // section label ("Currently scanning product pages...") so the
  // merchant sees real movement, not just a counter.
  function runSegmentedScan(button, scanMode, markOnboarding, originalLabel, startedAfter) {
    setShellScanStatus(
      "Large-site mode: running scan in short sections to avoid the admin-ajax timeout. You can leave this tab and come back; each section is saved before the next starts.",
      "busy"
    );
    setShellScanProgress({
      percent: null,
      label: "Preparing scan...",
      counts: "Section 1 starting...",
    });
    toast("Large-site scan started. The bar will update every section.");

    const start = markOnboarding
      ? post("rpca_ui_event", {
          type: "onboarding_start",
          message: "Started 14-day guided setup (large-site mode).",
          target: "cockpit",
        })
      : Promise.resolve();

    function runChunk(chunkIndex) {
      return post("rpca_run_segmented_scan_chunk", {
        chunk_index: String(chunkIndex),
        scan_mode: scanMode,
      }).then((data) => {
        if (!data) {
          throw new Error("Empty response from segmented scan chunk.");
        }
        const fetched = Number(data.fetched || 0);
        const queueDepth = Number(data.queueDepth || 0);
        const pageCap = Number(data.pageCap || 0);
        const sectionLabel = String(data.sectionLabel || "");
        const idx = Number(data.chunkIndex || chunkIndex);

        if (data.done) {
          setShellScanProgress({
            percent: 100,
            label: "Finishing analysis pass...",
            counts: `<strong>${fetched}</strong> page${fetched === 1 ? "" : "s"} scanned across ${idx + 1} section${idx === 0 ? "" : "s"}`,
          });
          return data.scan;
        }
        // Mid-scan: render the bar. Denominator is the truest
        // remaining work (fetched + queueDepth) clamped to pageCap
        // when we know it. Cap at 95% so we don't claim "100%" before
        // the analysis pass actually runs.
        let denom = fetched + queueDepth;
        if (pageCap > 0) denom = Math.min(denom, pageCap);
        if (denom < 1) denom = pageCap > 0 ? pageCap : Math.max(1, fetched);
        const pct = denom > 0 ? Math.min(95, (fetched / denom) * 100) : 0;
        const sectionPhrase = sectionLabel
          ? `Currently scanning ${sectionLabel}`
          : "Discovering pages...";
        setShellScanProgress({
          percent: pct,
          label: `Section ${idx + 2} - ${sectionPhrase}`,
          counts: `<strong>${fetched}</strong> page${fetched === 1 ? "" : "s"} scanned / ~${denom}${pageCap > 0 ? ` of max ${pageCap}` : ""}`,
        });
        setShellScanStatus(
          `Large-site mode: section ${idx + 1} done (${fetched} pages so far, ~${queueDepth} queued). Starting section ${idx + 2}...`,
          "busy"
        );
        // Tiny breather between chunks so the JS event loop has time
        // to paint the bar update before the next blocking POST.
        return new Promise((resolve) => setTimeout(resolve, 150)).then(() =>
          runChunk(idx + 1)
        );
      });
    }

    return start
      .then((payload) => {
        if (payload && payload.state) {
          state.uiState = payload.state;
        } else if (markOnboarding && state.uiState && state.uiState.onboarding) {
          state.uiState.onboarding.startedAt =
            state.uiState.onboarding.startedAt || new Date().toISOString();
        }
        return runChunk(0);
      })
      .then((scan) => {
        if (scan) {
          rememberScanResult(scan);
        }
        setShellScanStatus("Large-site scan complete. Opening your results now.", "success");
        state.activeSection = "cockpit";
        return refreshUiState()
          .catch(() => renderAdminShell())
          .then(() => {
            toast("Large-site scan finished. Results are saved.");
            scrollToScanFindings();
            return scan;
          });
      })
      .catch((error) => {
        const message =
          error && error.message
            ? error.message
            : "The large-site scan failed. Try running it again - the previous progress was preserved server-side and will be retried.";
        setShellScanStatus(message, "error");
        toast(message);
      })
      .finally(() => {
        if (button && document.body.contains(button)) {
          button.disabled = false;
          button.textContent = originalLabel || "Run audit scan";
        }
        clearShellScanProgress();
      });
  }

  // Background-scan path (v0.7.241). Used as the auto-fallback when the
  // foreground admin-ajax scan times out (any 5xx) AND the recovery
  // helper finds no saved partial. The cron-driven scan has no admin-
  // ajax timeout cap, so even very large sites can complete. We poll
  // `rpca_background_scan_status` for up to ~10 minutes; the endpoint
  // tells us how long to wait between polls and reports `done` when
  // the saved scan timestamp is newer than the queue timestamp.
  function runBackgroundScan(scanMode, force) {
    const startedAfter = new Date().toISOString();
    const payload = { scan_mode: scanMode || "processor_audit" };
    if (force) payload.force = "1";
    return post("rpca_start_background_scan", payload)
      .then((queue) => {
        if (!queue || !queue.queued) {
          throw new Error((queue && queue.message) || "Could not queue background scan.");
        }
        toast("Background scan queued. Polling for completion - this can take a few minutes on large sites.");
        // Wait `firstPollDelayMs` before the first poll so the
        // loopback / cron has time to acquire the lock. Without this
        // the first poll fires before either path has started and the
        // JS would briefly see "queued / state=queued" which is
        // confusing.
        const firstDelay = Math.max(0, Number((queue && queue.firstPollDelayMs) || 6000));
        return new Promise((resolve) => setTimeout(resolve, firstDelay)).then(() => pollBackgroundScan(startedAfter));
      })
      .catch((err) => {
        // Special handling: lock is held from a previous foreground
        // scan that died with the lock held. Auto-retry once with
        // force=1 to clear the stale lock.
        if (err && err.data && err.data.lockHeld && !force) {
          setShellScanStatus("Previous scan lock detected. Force-restarting...", "busy");
          return runBackgroundScan(scanMode, true);
        }
        throw err;
      });
  }

  function pollBackgroundScan(startedAfter, attempt) {
    attempt = attempt || 0;
    // Cap the polling at ~90 attempts so a stuck cron + blocked
    // loopback cannot produce an infinite poll loop. At the slowest
    // 8s cadence this is ~12 minutes; at the queued 4s cadence it is
    // ~6 minutes.
    if (attempt > 90) {
      throw new Error("Background scan did not complete within 12 minutes. Check Compliance Guard activity log for details, or have your host enable WP-Cron / system cron for wp-cron.php.");
    }
    return post("rpca_background_scan_status", { started_after: startedAfter })
      .then((data) => {
        if (data && data.done && data.scan) {
          rememberScanResult(data.scan);
          clearShellScanStatusAction();
          // v0.7.265: hide the progress bar on completion so we
          // don't leave a stale "100%" or animating bar visible
          // while the cockpit re-renders with the finished result.
          clearShellScanProgress();
          return data.scan;
        }
        const state = (data && data.state) || "queued";
        // Stalled: the queue has been sitting for >= 90s with neither
        // the lock acquired nor the scan saved. v0.7.251: instead of
        // dead-ending with a thrown error, render an inline "Run scan
        // now" button (when canRunNow is true) so the merchant can
        // bypass the queue and run the scan synchronously in this
        // tab. Falls back to the old throw behavior if canRunNow is
        // false (defensive for older server responses).
        if (state === "stalled") {
          const hint = (data && data.stallHint) || "Background scan did not start. WP-Cron may be disabled and the loopback HTTP request may be blocked.";
          if (data && data.canRunNow) {
            setShellScanStatusAction(hint, "Run scan now", () => forceRunBackgroundScanNow(startedAfter));
            // Resolve with null so the upstream caller's
            // `if (bgScan)` check no-ops while the merchant decides
            // whether to click the fallback button. The button takes
            // over rendering when clicked.
            return null;
          }
          throw new Error(hint);
        }
        const waitSeconds = Math.max(3, Math.min(15, Number((data && data.pollAfterSeconds) || 6)));
        if (state === "in_flight") {
          // v0.7.264: surface the live crawl progress (from the
          // server-side rpca_scan_progress transient) so the merchant
          // sees "Processing 47, queue depth 13" instead of an
          // uninformative spinner.
          // v0.7.265: also drive the visible progress bar so the
          // user sees a real, accurate, animating bar instead of
          // staring at text-only status updates.
          const live = data && data.liveProgress;
          const desc = describeLiveProgress(live);
          setShellScanProgress(desc);
          let progressLine = "";
          if (live && (live.fetched || live.queueDepth !== undefined)) {
            const fetched = Number(live.fetched || 0);
            const queueDepth = Number(live.queueDepth || 0);
            progressLine = ` Currently processed ${fetched} page${fetched === 1 ? "" : "s"} (${queueDepth} more queued).`;
          }
          setShellScanStatus(`Background scan running... checking again in ${waitSeconds}s.${progressLine} (You can leave this tab and come back; the result is saved either way.)`, "busy");
          clearShellScanStatusAction();
        } else {
          // queued: handler hasn't picked it up yet. v0.7.251: the
          // server now auto-retries the loopback at 30s and 60s so
          // the typical recovery path is invisible to the merchant.
          // We still surface the queue age so they have feedback.
          const ageHint = (data && data.queueAgeSeconds) ? ` (queued ${Math.round(data.queueAgeSeconds)}s ago, waiting for cron / loopback)` : "";
          setShellScanStatus(`Background scan queued${ageHint}, checking again in ${waitSeconds}s.`, "busy");
          clearShellScanStatusAction();
        }
        return new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000)).then(() => pollBackgroundScan(startedAfter, attempt + 1));
      });
  }

  // v0.7.251: render a "Run scan now" action button next to the shell
  // scan status. Used when the background scan stalls so the merchant
  // can convert the queued scan into a synchronous foreground run
  // without abandoning the page or contacting their host.
  function setShellScanStatusAction(message, actionLabel, actionFn) {
    setShellScanStatus(message, "error");
    const status = el("rpca-shell-scan-status");
    if (!status) return;
    // Remove any prior action button so repeated calls don't stack.
    const prior = status.parentNode && status.parentNode.querySelector(".rpca-shell-scan-status-action");
    if (prior) prior.remove();
    if (!actionLabel || typeof actionFn !== "function") return;
    const button = document.createElement("button");
    button.type = "button";
    button.className = "button button-primary rpca-shell-scan-status-action";
    button.style.marginLeft = "8px";
    button.textContent = actionLabel;
    button.addEventListener("click", () => {
      button.disabled = true;
      button.textContent = "Running scan in this tab...";
      Promise.resolve()
        .then(() => actionFn())
        .catch((err) => {
          const msg = (err && err.message) || "The synchronous scan failed. Check the Compliance Guard activity log.";
          setShellScanStatus(msg, "error");
        })
        .finally(() => {
          if (button && button.parentNode) {
            button.parentNode.removeChild(button);
          }
        });
    });
    status.parentNode.insertBefore(button, status.nextSibling);
  }

  function clearShellScanStatusAction() {
    const status = el("rpca-shell-scan-status");
    if (!status || !status.parentNode) return;
    const prior = status.parentNode.querySelector(".rpca-shell-scan-status-action");
    if (prior) prior.remove();
  }

  // v0.7.251: synchronous fallback. Posts to the server endpoint that
  // runs the scan directly in the AJAX request, bypassing WP-Cron and
  // the loopback. Slow on large sites (potentially tens of seconds in
  // the browser), but the alternative was a dead-end error message
  // when neither cron nor loopback fires. Server-side endpoint is
  // ajax_force_run_background_scan_now.
  function forceRunBackgroundScanNow(startedAfter) {
    setShellScanStatus("Running scan now in this browser tab. This can take up to a couple of minutes on large sites - keep this tab open until it finishes.", "busy");
    return post("rpca_force_run_background_scan_now", {})
      .then((data) => {
        if (!data || !data.scan) {
          throw new Error("The synchronous scan completed but did not return a result. Try a fresh scan from the cockpit.");
        }
        rememberScanResult(data.scan);
        setShellScanStatus("Scan complete. Opening your results.", "success");
        state.activeSection = "cockpit";
        return refreshUiState()
          .catch(() => renderAdminShell())
          .then(() => {
            toast("Scan finished and saved.");
            scrollToScanFindings();
            return data.scan;
          });
      });
  }

  function recoverLastScanAfterTimeout(startedAfter) {
    return post("rpca_get_last_scan", startedAfter ? { started_after: startedAfter } : {})
      .then((payload) => {
        const scan = payload && payload.scan ? payload.scan : null;
        if (!scan || !scan.finishedAt || !scanIsFreshEnough(scan, startedAfter)) {
          return null;
        }
        rememberScanResult(scan);
        if (Array.isArray(payload.scanHistory)) {
          state.scanHistory = payload.scanHistory;
        }
        if (Array.isArray(payload.auditLog)) {
          state.auditLog = payload.auditLog;
        }
        return scan;
      })
      .catch(() => null);
  }

  function scanIsFreshEnough(scan, startedAfter) {
    if (!startedAfter) {
      return true;
    }
    const baseline = Date.parse(startedAfter);
    const candidates = [scan.startedAt, scan.finishedAt]
      .map((value) => Date.parse(value || ""))
      .filter((value) => Number.isFinite(value));
    if (!Number.isFinite(baseline) || !candidates.length) {
      return true;
    }
    return Math.max(...candidates) + 1000 >= baseline;
  }

  function runScan() {
    const button = el("rpca-run-scan");
    const startedAfter = new Date().toISOString();
    if (button) {
      button.disabled = true;
    }
    setStatus(RPCA.i18n.scanning, "busy");

    post("rpca_run_scan", { scan_mode: "processor_audit" })
      .then((scan) => {
        rememberScanResult(scan);
        renderScan(scan);
        setStatus("Scan complete. Start with the highest-risk items.", "success");
        appendChat(
          "assistant",
          "Scan complete. Ask why something was flagged or what to fix first."
        );
        refreshUiState().catch(() => {});
      })
      .catch((error) => {
        if (error && Number(error.status || 0) >= 500) {
          setStatus("The server response timed out. Checking whether the scan was saved anyway...", "busy");
          recoverLastScanAfterTimeout(startedAfter).then((scan) => {
            if (scan) {
              renderScan(scan);
              setStatus("Recovered the saved scan result after the server timeout.", "success");
              refreshUiState().catch(() => {});
              return;
            }
            setStatus(error.message, "error");
          });
          return;
        }
        setStatus(error.message, "error");
      })
      .finally(() => {
        if (button) {
          button.disabled = false;
        }
      });
  }

  function clearScan() {
    setStatus("Clearing saved results...", "busy");
    post("rpca_clear_scan")
      .then(() => {
        state.lastScan = null;
        renderScan(null);
        refreshUiState().catch(() => {});
        setStatus("Results cleared.", "success");
      })
      .catch((error) => setStatus(error.message, "error"));
  }

  function refreshFdaIntelligence() {
    const button = el("rpca-refresh-fda");
    if (button) {
      button.disabled = true;
      button.textContent = "Checking FDA...";
    }
    setStatus(RPCA.i18n.fdaUpdating || "Checking public FDA examples...", "busy");

    post("rpca_refresh_fda_intelligence")
      .then((payload) => {
        state.fdaIntelligence = payload.intelligence || null;
        renderFdaIntelligence(state.fdaIntelligence);
        setStatus(payload.message || "New FDA examples checked.", "success");
      })
      .catch((error) => setStatus(error.message, "error"))
      .finally(() => {
        if (button) {
          button.disabled = false;
          button.textContent = "Check New FDA Examples";
        }
      });
  }

  function exportScan() {
    if (!state.lastScan) {
      setStatus("Run a scan before exporting a report.", "error");
      return;
    }

    const payload = JSON.stringify(
      Object.assign({ legalNotice }, state.lastScan),
      null,
      2
    );
    const blob = new Blob([payload], { type: "application/json" });
    const link = document.createElement("a");
    const stamp = new Date().toISOString().replace(/[:.]/g, "-");
    link.href = URL.createObjectURL(blob);
    link.download = `ruo-compliance-report-${stamp}.json`;
    document.body.appendChild(link);
    link.click();
    link.remove();
    window.setTimeout(() => URL.revokeObjectURL(link.href), 1000);
    setStatus("Report downloaded.", "success");
  }

  function exportActivityLog() {
    setStatus("Preparing activity log export...", "busy");
    post("rpca_export_activity_log")
      .then((payload) => {
        const blob = new Blob([
          JSON.stringify(Object.assign({ legalNotice }, payload), null, 2),
        ], {
          type: "application/json",
        });
        const link = document.createElement("a");
        const stamp = new Date().toISOString().replace(/[:.]/g, "-");
        link.href = URL.createObjectURL(blob);
        link.download = `ruo-compliance-activity-log-${stamp}.json`;
        document.body.appendChild(link);
        link.click();
        link.remove();
        window.setTimeout(() => URL.revokeObjectURL(link.href), 1000);
        setStatus("Activity log downloaded.", "success");
      })
      .catch((error) => setStatus(error.message, "error"));
  }

  function sendChat() {
    const textarea = el("rpca-chat-message");
    const button = el("rpca-send-chat");
    if (!textarea || !textarea.value.trim()) {
      return;
    }

    const message = textarea.value.trim();
    textarea.value = "";
    appendChat("user", message);
    state.history.push({ role: "user", content: message });

    if (button) {
      button.disabled = true;
    }

    post("rpca_chat", {
      message,
      history: JSON.stringify(state.history.slice(-8)),
    })
      .then((response) => {
        const text = response.text || "";
        appendChat("assistant", text);
        state.history.push({ role: "assistant", content: text });
      })
      .catch((error) => {
        appendChat("assistant", error.message);
      })
      .finally(() => {
        if (button) {
          button.disabled = false;
        }
      });
  }

  function uiState() {
    return state.uiState || {
      cockpit: { score: 0, band: "red", delta: 0, weekLines: [], oneThing: {} },
      counts: { findings: 0 },
      findings: [],
      settingsToggles: [],
      commandItems: [],
      auditTimeline: [],
      onboarding: { day: 1, completed: false, startedAt: "", steps: [] },
      setup: {
        completed: false,
        hasGeminiKey: Boolean(window.RPCA && RPCA.hasApiKey),
        geminiModel: "gemini-2.5-flash",
        enableGemini: false,
        privateMode: true,
        outboundAiConsent: false,
        geminiUncertainOnly: true,
        dailyCallLimit: 25,
        automaticScanScope: true,
        scanScopeMaxPages: 1500,
        scanScopeMaxDepth: 12,
        fdaDaily: true,
        monitoringEnabled: false,
      },
      freeze: { active: false, canUse: false },
      legalNotice,
    };
  }

  function hasSavedScan() {
    return Boolean(
      state.lastScan &&
        typeof state.lastScan === "object" &&
        Object.keys(state.lastScan).length
    );
  }

  function applyTheme(theme) {
    const selected = ["light", "dark", "auto"].includes(theme) ? theme : "light";
    state.theme = selected;
    const resolved =
      selected === "auto" &&
      window.matchMedia &&
      window.matchMedia("(prefers-color-scheme: dark)").matches
        ? "dark"
        : selected === "auto"
          ? "light"
          : selected;
    document.documentElement.setAttribute("data-theme", resolved);
    const wrap = document.querySelector(".rpca-wrap");
    if (wrap) {
      wrap.setAttribute("data-rpca-theme", selected);
    }
  }

  function bandColorVar(band) {
    const key = String(band || "red").toLowerCase();
    return {
      green: "var(--band-green)",
      yellow: "var(--band-yellow)",
      orange: "var(--band-orange)",
      red: "var(--band-red)",
    }[key] || "var(--band-red)";
  }

  function severityBand(severity) {
    const key = String(severity || "").toLowerCase();
    if (key === "critical" || key === "high") return "red";
    if (key === "medium") return "orange";
    if (key === "low") return "yellow";
    return "green";
  }

  function sectionUnlockDay(section) {
    return {
      cockpit: 1,
      wizard: 1,
      settings: 1,
      policies: 1,
      age_gate: 1,
      findings: 2,
      pages: 2,
      evidence: 5,
      underwriter: 6,
      intelligence: 7,
      regulations: 1,
      why_this_helps: 1,
      help: 1,
    }[section] || 1;
  }

  function canAccessSection(section) {
    const ui = uiState();
    return ui.onboarding.completed || !ui.onboarding.startedAt || ui.onboarding.day >= sectionUnlockDay(section);
  }

  function renderAdminShell() {
    const root = el("rpca-ui-root");
    if (!root) return;
    applyTheme(state.theme);
    const ui = uiState();
    const cockpit = ui.cockpit || {};
    root.style.setProperty("--current-band", bandColorVar(cockpit.band));

    if (!hasSavedScan() && ui.setup && !ui.setup.completed) {
      root.innerHTML = renderInitialSetup(ui);
      bindShellEvents(root);
      return;
    }

    if (ui.onboarding && !ui.onboarding.startedAt && !ui.onboarding.completed && !hasSavedScan()) {
      root.innerHTML = renderFirstLaunch(ui);
      bindShellEvents(root);
      return;
    }

    const navGroups = [
      {
        eyebrow: "Start",
        items: [
          ["cockpit", "Cockpit", "1"],
          ["wizard", "Setup Wizard", "2"],
          ["settings", "Setup", "3"],
        ],
      },
      {
        eyebrow: "Build controls",
        items: [
          ["policies", "Compliance Generator", "4"],
          // v0.7.422: "Age gate" entry removed — the recommended
          // free Age Gate plugin is now installed from the
          // Compliance Generator page's "Recommended free plugins"
          // cards.
        ],
      },
      {
        eyebrow: "Audit review",
        items: [
          ["findings", "Things to fix", "6"],
          ["pages", "Pages checked", "7"],
          ["evidence", "Proof archive", "8"],
          ["underwriter", "Payment review", "9"],
        ],
      },
      {
        eyebrow: "Reference",
        items: [
          ["intelligence", "Intelligence", "10"],
          ["regulations", "Regulations Feed", "11"],
          ["why_this_helps", "Why This Helps", "12"],
          // v0.7.285: Manual Compliance Checklist + Adaptive Learning page.
          ["manual_checklist", "Manual Checklist", "13"],
          ["regulator_alerts", "Regulator Alerts", "14"],
          // v0.7.306: RAG Knowledge Base — the merchant curates the
          // retrieval corpus the scan grounds the AI review against.
          ["knowledge_base", "Knowledge Base", "15"],
          ["help", "Help", "16"],
        ],
      },
    ];
    const cockpitBand = String(cockpit.band || "yellow").toLowerCase();
    const bandLabel = {
      green: "Stable",
      yellow: "Watch",
      orange: "Action needed",
      red: "Action needed",
    }[cockpitBand] || "Watch";
    root.innerHTML = `
      <div class="rpca-shell">
        <header class="rpca-topbar" role="banner">
          <a class="rpca-topbar-brand" href="#cockpit" data-section="cockpit">
            <span class="rpca-topbar-mark" aria-hidden="true">CG</span>
            <span>Compliance Guard</span>
          </a>
          <span class="rpca-topbar-tag">Red Helix Research</span>
          ${window.RPCA && RPCA.buildStamp ? `<span class="rpca-topbar-build" title="Build stamp - confirms the new bundle loaded">${escapeHtml(RPCA.buildStamp)}</span>` : ""}
          <span class="rpca-topbar-spacer"></span>
          <div class="rpca-topbar-meta" aria-hidden="false">
            <span class="rpca-topbar-meta-item">
              <span class="rpca-topbar-meta-dot" aria-hidden="true"></span>
              <span>Posture: ${escapeHtml(bandLabel)}</span>
            </span>
            <span class="rpca-topbar-meta-item">Setup ${escapeHtml((ui.onboarding && ui.onboarding.day) || 1)} / 14</span>
            <span class="rpca-topbar-meta-item">Findings ${escapeHtml((ui.counts && ui.counts.findings) || 0)}</span>
          </div>
          <div class="rpca-topbar-actions">
            <button type="button" class="rpca-topbar-search" data-action="open-command" aria-label="Search">
              <span>Search</span>
              <kbd>${navigator.platform.toLowerCase().includes("mac") ? "&#8984;" : "Ctrl"} K</kbd>
            </button>
            <button type="button" class="rpca-topbar-button" data-action="start-scan" data-scan-mode="processor_audit">Run audit</button>
            <select class="rpca-topbar-theme rpca-theme-select" aria-label="Theme">
              <option value="light" ${state.theme === "light" ? "selected" : ""}>Light</option>
              <option value="dark" ${state.theme === "dark" ? "selected" : ""}>Dark</option>
              <option value="auto" ${state.theme === "auto" ? "selected" : ""}>Auto</option>
            </select>
          </div>
        </header>
        <div class="rpca-band-strip" aria-hidden="true"></div>
        <div class="rpca-shell-grid">
          <aside class="rpca-rail" aria-label="Compliance Guard sections">
            <div class="rpca-setup-chip">Setup ${escapeHtml((ui.onboarding && ui.onboarding.day) || 1)} of 14</div>
            ${navGroups
              .map(
                (group) => `
                  <div class="rpca-rail-section">
                    <p class="rpca-rail-eyebrow">${escapeHtml(group.eyebrow)}</p>
                    <nav class="rpca-rail-nav">
                      ${group.items
                        .map(([id, label, shortcut]) => {
                          const count = id === "findings" && ui.counts ? ui.counts.findings || 0 : "";
                          const locked = canAccessSection(id) ? "" : ` data-locked="${sectionUnlockDay(id)}"`;
                          return `<button type="button" class="rpca-rail-item ${
                            state.activeSection === id ? "is-active" : ""
                          }" data-section="${id}"${locked}>
                            <span class="rpca-rail-item-label">
                              <span class="rpca-rail-item-glyph" aria-hidden="true">${escapeHtml(shortcut)}</span>
                              <span>${escapeHtml(label)}</span>
                            </span>
                            ${count ? `<span class="rpca-rail-count">${escapeHtml(count)}</span>` : ""}
                          </button>`;
                        })
                        .join("")}
                    </nav>
                  </div>
                `
              )
              .join("")}
            <div class="rpca-rail-tools">
              <button type="button" class="rpca-button is-ghost" data-action="open-command">Search this app</button>
            </div>
          </aside>
          <main class="rpca-main">
            <div class="rpca-content-frame">
              <div class="rpca-breadcrumbs">${renderBreadcrumbs()}</div>
              <h1 class="rpca-section-title-main">${escapeHtml(sectionTitle(state.activeSection))}</h1>
              <div id="rpca-shell-view">${renderSection(state.activeSection)}</div>
            </div>
          </main>
        </div>
        ${renderCommandPalette(ui)}
        ${renderModalShell()}
        ${renderContextSidebar()}
        <div class="rpca-notifications" aria-live="polite"></div>
      </div>
    `;
    bindShellEvents(root);
  }

  function renderInitialSetup(ui) {
    const setup = (ui && ui.setup) || {};
    const checked = (value) => (value ? "checked" : "");
    return `
      <div class="rpca-shell">
        <header class="rpca-topbar" role="banner">
          <a class="rpca-topbar-brand" href="#cockpit" data-section="cockpit">
            <span class="rpca-topbar-mark" aria-hidden="true">CG</span>
            <span>Compliance Guard</span>
          </a>
          <span class="rpca-topbar-tag">Red Helix Research</span>
          ${window.RPCA && RPCA.buildStamp ? `<span class="rpca-topbar-build" title="Build stamp - confirms the new bundle loaded">${escapeHtml(RPCA.buildStamp)}</span>` : ""}
          <span class="rpca-topbar-spacer"></span>
          <div class="rpca-topbar-meta"><span class="rpca-topbar-meta-item">First-time setup</span></div>
        </header>
        <div class="rpca-band-strip" aria-hidden="true"></div>
        <main class="rpca-main">
          <div class="rpca-content-frame">
            <p class="rpca-breadcrumbs">Compliance Guard / First-time setup</p>
            <h1 id="rpca-setup-title" class="rpca-section-title-main">Welcome - let's get your store ready</h1>
            <ol class="rpca-setup-progress" aria-label="Setup progress">
              <li class="${setup.hasGeminiKey ? "is-done" : "is-current"}">Add AI access (optional)</li>
              <li>Run your first check</li>
              <li>Review what we found</li>
              <li>Click Apply fix on each item</li>
            </ol>
            <form class="rpca-first-run-form">
              <div class="rpca-ai-key-card">
                <p class="rpca-kicker">Option 1 (optional)</p>
                <h3>Add an AI access code for smarter suggestions</h3>
                <p>This is optional. With an AI access code, the plugin can rewrite risky wording for you, fill in policy pages with real content, and explain findings in plain English. These AI features are beta and experimental — review their output before publishing. Without it, the plugin still scans your site and offers fixes, just with less custom wording.</p>
                <div class="rpca-first-run-grid">
                  <label>
                    <span>AI access code (paste from Google AI Studio)</span>
                    <input type="password" name="gemini_api_key" autocomplete="new-password" placeholder="${setup.hasGeminiKey ? "Code already saved. Paste a new one to replace it." : "Paste here, or skip this and click Save"}" />
                    <small>Stored privately. Never shown again on this screen. Free codes are available at aistudio.google.com.</small>
                  </label>
                </div>
                <details style="margin-top:var(--space-4);">
                  <summary style="cursor:pointer; color:var(--ink-600); font-size:13px; font-weight:600;">Advanced AI settings (optional)</summary>
                  <div class="rpca-first-run-grid" style="margin-top:var(--space-3);">
                    <label>
                      <span>Daily AI call budget</span>
                      <input type="number" min="1" max="500" name="gemini_daily_call_limit" value="${escapeHtml(setup.dailyCallLimit || 25)}" />
                      <small>How many AI calls per day. 25 is plenty for most stores.</small>
                    </label>
                    <label>
                      <span>AI model</span>
                      <input type="text" name="gemini_model" value="${escapeHtml(setup.geminiModel || "gemini-2.5-flash")}" />
                      <small>Leave as is unless Google's docs say otherwise.</small>
                    </label>
                  </div>
                </details>
              </div>

              <div class="rpca-editorial-card" style="margin-bottom:var(--space-5)">
                <p class="rpca-eyebrow">That's it - the rest is automatic</p>
                <h3>Your first check is ready to run</h3>
                <p>The scanner now decides the scan size automatically. It inventories your published public WordPress pages, posts, products, menus, sitemaps, and same-site links, then uses Gemini when allowed to confirm the crawl budget before the scan runs.</p>
                <details>
                  <summary style="cursor:pointer; color:var(--ink-600); font-size:13px; font-weight:600;">Privacy and repeat-scan options</summary>
                  <p class="rpca-muted" style="margin-top:var(--space-3);">Private admin pages, cart, checkout, account pages, tokenized links, cross-site URLs, and unsafe redirects are skipped. Public page text is sent to Gemini only when AI is enabled, outbound consent is on, and private mode is off.</p>
                  <div class="rpca-first-run-checks" style="margin-top:var(--space-3);">
                    <label><input type="checkbox" name="enable_gemini" ${checked(setup.enableGemini !== false)} /> <span>Use AI for plain-English explanations and rewrites (beta — experimental) (recommended if you added a code above)</span></label>
                    <label><input type="checkbox" name="outbound_ai_consent" ${checked(setup.outboundAiConsent !== false)} /> <span>Allow public page text to be sent to AI when AI is on</span></label>
                    <label><input type="checkbox" name="private_mode" ${checked(setup.privateMode === true)} /> <span>Privacy-only mode: do not contact AI at all (overrides the boxes above)</span></label>
                    <label><input type="checkbox" name="gemini_uncertain_only" ${checked(setup.geminiUncertainOnly !== false)} /> <span>Save AI budget by only using AI when the built-in checks are unsure</span></label>
                    <label><input type="checkbox" name="fda_intelligence_enabled" ${checked(setup.fdaDaily !== false)} /> <span>Check the public FDA warning-letter feed daily</span></label>
                    <label><input type="checkbox" name="monitoring_enabled" ${checked(setup.monitoringEnabled)} /> <span>Re-scan my site automatically on a schedule</span></label>
                  </div>
                </details>
              </div>

              <div class="rpca-notice-card">
                You stay in control. The plugin never publishes anything on its own - every fix creates a draft you review and click Publish on. You can ignore the AI access code entirely; the scanner still works without it.
              </div>

              <div class="rpca-button-row">
                <button type="button" class="rpca-button is-primary" data-action="save-initial-setup">Save and run my first check</button>
              </div>
              <p id="rpca-setup-status" class="rpca-inline-status" role="status" aria-live="polite" hidden></p>
            </form>

            <p class="rpca-notice-card" style="margin-top:var(--space-6)">${escapeHtml((ui && ui.legalNotice) || legalNotice)}</p>
          </div>
        </main>
        <div class="rpca-notifications" aria-live="polite"></div>
      </div>
    `;
  }

  function renderFirstLaunch(ui) {
    return `
      <div class="rpca-shell">
        <header class="rpca-topbar" role="banner">
          <a class="rpca-topbar-brand" href="#cockpit" data-section="cockpit">
            <span class="rpca-topbar-mark" aria-hidden="true">CG</span>
            <span>Compliance Guard</span>
          </a>
          <span class="rpca-topbar-tag">Red Helix Research</span>
          ${window.RPCA && RPCA.buildStamp ? `<span class="rpca-topbar-build" title="Build stamp - confirms the new bundle loaded">${escapeHtml(RPCA.buildStamp)}</span>` : ""}
          <span class="rpca-topbar-spacer"></span>
          <div class="rpca-topbar-meta"><span class="rpca-topbar-meta-item">Welcome</span></div>
        </header>
        <div class="rpca-band-strip" aria-hidden="true"></div>
        <main class="rpca-main">
          <div class="rpca-content-frame">
            <p class="rpca-breadcrumbs">Compliance Guard / Welcome</p>
            <h1 id="rpca-welcome-title" class="rpca-section-title-main">Run your first audit scan</h1>
            <article class="rpca-hero-card" style="grid-template-columns:1fr;">
              <div>
                <p class="rpca-hero-eyebrow">Get started in one minute</p>
                <div class="rpca-hero-number">
                  <div class="rpca-hero-label">Best first move</div>
                  <div style="font-family:var(--font-display); font-size:clamp(1.6rem, 3vw, 2.4rem); font-weight:300; line-height:1.2; color:var(--ink-50);">
                    Start with the processor audit scan.
                  </div>
                </div>
                <p style="color:color-mix(in srgb, var(--ink-50) 75%, transparent); margin-top:var(--space-3); max-width:640px; line-height:1.55;">
                  Compliance Guard reads only your public pages and reports the same wording, disclaimers, policy gaps, and checkout signals a payment processor or FDA reviewer would see.
                </p>
                <div class="rpca-hero-actions">
                  <button type="button" class="rpca-button is-primary" data-action="start-onboarding-scan" data-scan-mode="processor_audit">Run processor audit scan</button>
                  <button type="button" class="rpca-button is-ghost" data-section="settings">Open setup</button>
                </div>
                <label class="rpca-large-site-toggle" title="Splits the scan into short sections so very large sites can finish without hitting the admin-ajax timeout. Each section is bounded to ~18 seconds, then the next section continues from where the last stopped.">
                  <input type="checkbox" data-rpca-large-site-mode${state.largeSiteMode ? " checked" : ""}>
                  <span>Large-site mode (split scan into sections to avoid timing out)</span>
                </label>
                <p id="rpca-shell-scan-status" class="rpca-inline-status" role="status" aria-live="polite" hidden style="background:rgba(255,255,255,0.06); border-color:rgba(255,255,255,0.16); color:var(--ink-50)"></p>
                <div id="rpca-shell-scan-progress" class="rpca-scan-progress is-indeterminate" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Scan progress" hidden>
                  <div class="rpca-scan-progress-bar"><div class="rpca-scan-progress-fill"></div></div>
                  <div class="rpca-scan-progress-meta"><span class="rpca-scan-progress-label">Preparing scan...</span><span class="rpca-scan-progress-counts">&mdash;</span></div>
                </div>
              </div>
            </article>
            <p class="rpca-notice-card" style="margin-top:var(--space-6)">${escapeHtml((ui && ui.legalNotice) || legalNotice)}</p>
          </div>
        </main>
        <div class="rpca-notifications" aria-live="polite"></div>
      </div>
    `;
  }

  function sectionTitle(section) {
    return {
      cockpit: "Compliance cockpit",
      wizard: "Setup Wizard",
      settings: "Setup",
      policies: "Compliance Generator",
      findings: "Things to fix",
      pages: "Pages checked",
      evidence: "Proof archive",
      underwriter: "Payment review",
      intelligence: "Intelligence updates",
      regulations: "Regulations Feed",
      why_this_helps: "Why This Helps",
      regulator_alerts: "Regulator Alerts",
      help: "Help center",
    }[section] || "Compliance cockpit";
  }

  function renderBreadcrumbs() {
    return `<a href="#cockpit" data-section="cockpit">Compliance Guard</a> / ${escapeHtml(
      sectionTitle(state.activeSection)
    )}`;
  }

  function renderSection(section) {
    if (!canAccessSection(section)) {
      return renderLockedSection(section);
    }
    if (section === "findings") return renderFindingsSurface();
    if (section === "pages") return renderPagesSurface();
    if (section === "evidence") return renderEvidenceSurface();
    if (section === "policies") return renderPoliciesSurface();
    if (section === "intelligence") return renderIntelligenceSurface();
    if (section === "underwriter") return renderUnderwriterSurface();
    if (section === "settings") return renderSettingsSurface();
    if (section === "regulator_alerts") {
      const sectionUrls = (window.RPCA && RPCA.sectionAdminUrls) || {};
      const href = sectionUrls.regulator_alerts || ((window.RPCA && RPCA.adminUrl || "/wp-admin/") + "admin.php?page=ruo-cg-regulator-alerts");
      return `
        <section class="rpca-editorial-card">
          <p class="rpca-eyebrow">Detection</p>
          <h3>Regulator Alerts</h3>
          <p>Open the dedicated Regulator Alerts page to review likely regulator visits, filter the ring buffer, export CSV, and send a test alert.</p>
          <a class="rpca-button" href="${safeHref(href)}">Open Regulator Alerts</a>
        </section>
      `;
    }
    if (section === "help") return renderHelpSurface();
    return renderCockpit();
  }

  function renderLockedSection(section) {
    const day = sectionUnlockDay(section);
    return `
      <section class="rpca-editorial-card">
        <p class="rpca-eyebrow">Setup day ${day}</p>
        <h3>${escapeHtml(sectionTitle(section))} unlocks on day ${day}.</h3>
        <p>Setup opens one area at a time so you are not dropped into every option at once. You can unlock this now; the plugin will note that you skipped ahead.</p>
        <div class="rpca-button-row">
          <button type="button" class="rpca-button" data-action="skip-onboarding" data-target="${escapeHtml(section)}">Unlock this section</button>
          <button type="button" class="rpca-button is-text" data-section="cockpit">Return to Start</button>
        </div>
      </section>
    `;
  }

  function renderCockpit() {
    ensureCockpitStateFresh();
    const ui = uiState();
    const cockpit = ui.cockpit || {};
    const score = Number(cockpit.score || 0);
    const safeScore = Math.max(0, Math.min(100, score));
    const delta = Number(cockpit.delta || 0);
    const deltaClass = delta < 0 ? "is-down" : delta === 0 ? "is-flat" : "";
    const deltaMark = delta < 0 ? "&#9660;" : delta > 0 ? "&#9650;" : "&#8212;";
    const one = cockpit.oneThing || {};
    const weekLines = Array.isArray(cockpit.weekLines) ? cockpit.weekLines : [];
    const band = String(cockpit.band || "yellow").toLowerCase();
    const postureLabel = {
      green: "Stable posture",
      yellow: "Minor review",
      orange: "Action needed",
      red: "Action needed",
    }[band] || "Review posture";
    const counts = ui.counts || {};
    const checklist = Array.isArray(ui.checklist) ? ui.checklist : [];
    const passCount = checklist.filter((item) => item.status === "pass").length;
    const reviewCount = checklist.filter((item) => item.status === "review").length;

    // v0.7.261: when no scan has ever finished, render dashes for
    // score / grade / delta / signal-row values instead of fake
    // "0/100 grade F" defaults that look like a real result. The
    // empty-state cue tells the merchant "this hasn't been
    // calculated yet" rather than "your score is zero." Detected
    // via state.lastScan.finishedAt (the canonical first-scan
    // marker - already used in the gauge timestamp at the bottom
    // of this render).
    const hasFinishedScan = !!(state.lastScan && state.lastScan.finishedAt);
    const dash = "&#8212;"; // em dash
    const scoreText = hasFinishedScan ? escapeHtml(safeScore) : dash;
    const deltaBlock = hasFinishedScan
      ? `<span class="rpca-delta ${deltaClass}">${deltaMark} ${delta > 0 ? "+" : ""}${escapeHtml(delta)} this week</span>`
      : "";
    const grade = hasFinishedScan ? complianceGradeFor(safeScore, counts, band) : { letter: dash, label: "Not yet scored" };
    const gaugePercent = hasFinishedScan ? safeScore : 0;
    const gaugeScore = hasFinishedScan ? `${escapeHtml(safeScore)} / 100` : `${dash} / 100`;
    const fdaText = hasFinishedScan ? escapeHtml(cockpit.fda || "Needs review") : dash;
    const bramText = hasFinishedScan ? escapeHtml(cockpit.bram || "Clear") : dash;
    const vampText = hasFinishedScan ? escapeHtml(cockpit.vamp || "0.00% / 0.90%") : dash;
    const findingsText = hasFinishedScan ? escapeHtml(counts.findings || 0) : dash;

    // v0.7.298 (Item #10 completion): cockpit banner for aiGateStatus.
    // The flag is set whenever the Gemini gate hit the daily/monthly
    // call limit partway through a scan — remaining candidates passed
    // through unverified. Surface a clear amber banner with a Setup
    // link instead of leaving the merchant to guess why findings show
    // "unverified" badges everywhere.
    const gateStatus = state.lastScan && state.lastScan.aiGateStatus ? state.lastScan.aiGateStatus : null;
    const setupUrlRaw = (window.RPCA && RPCA.adminUrl || "/wp-admin/") + "admin.php?page=ruo-cg-setup";
    const quotaBanner = gateStatus && gateStatus.quotaExhausted
      ? `<div role="alert" class="rpca-quota-banner" style="background:#fef3c7;border:1px solid #f59e0b;color:#92400e;border-radius:8px;padding:12px 14px;margin:0 0 14px;display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;">
          <span><strong>Gemini call limit reached mid-scan.</strong> Remaining candidates were shown unverified. Raise your daily / monthly limit in Setup to re-enable per-finding AI review on the next scan.</span>
          <a href="${safeHref(setupUrlRaw)}" class="rpca-button is-text" style="font-size:13px;">Open Setup &rarr; AI access code</a>
        </div>`
      : "";
    const gateOffBanner = gateStatus && gateStatus.enabled === false
      ? `<div role="alert" class="rpca-quota-banner" style="background:#eef2ff;border:1px solid #c7d2fe;color:#1e1b4b;border-radius:8px;padding:12px 14px;margin:0 0 14px;display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;">
          <span><strong>Gemini gate is turned off.</strong> Findings are shown without per-candidate AI review. Turn it on in Setup for tighter false-positive filtering.</span>
          <a href="${safeHref(setupUrlRaw)}" class="rpca-button is-text" style="font-size:13px;">Open Setup</a>
        </div>`
      : "";
    return `
      <section class="rpca-cockpit">
        ${quotaBanner}
        ${gateOffBanner}
        <article class="rpca-hero-card">
          <div>
            <p class="rpca-hero-eyebrow">Compliance posture &middot; ${escapeHtml(new Date().toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }))}</p>
            <div class="rpca-hero-number">
              <div class="rpca-hero-label">Public-site readiness score</div>
              <div class="rpca-score-line">
                <span class="rpca-score">${scoreText}</span>
                <span class="rpca-score-denom">/ 100</span>
                ${deltaBlock}
              </div>
            </div>
            <div class="rpca-signal-row">
              <span>FDA wording risk<strong>${fdaText}</strong></span>
              <span>Payment review readiness<strong>${bramText}</strong></span>
              <span>Dispute ratio<strong>${vampText}</strong></span>
              <span>Open findings<strong>${findingsText}</strong></span>
            </div>
            <div class="rpca-hero-actions">
              <button type="button" class="rpca-button is-primary" data-action="start-scan" data-scan-mode="processor_audit">${hasFinishedScan ? "Run processor audit scan" : "Run your first audit scan"}</button>
              <button type="button" class="rpca-button is-ghost" data-section="findings">Open things to fix</button>
            </div>
            <label class="rpca-large-site-toggle" title="Splits the scan into short sections so very large sites can finish without hitting the admin-ajax timeout. Each section is bounded to ~18 seconds, then the next section continues from where the last stopped.">
              <input type="checkbox" data-rpca-large-site-mode${state.largeSiteMode ? " checked" : ""}>
              <span>Large-site mode (split scan into sections to avoid timing out)</span>
            </label>
            <!-- v0.7.299: scan status + progress bar on the POPULATED
                 cockpit too. Previously these only existed in the
                 onboarding hero, so after the first scan finished the
                 progress bar + live AI thinking feed could not mount
                 (ensureShellScanProgressEl returned null because
                 #rpca-shell-scan-status did not exist). -->
            <p id="rpca-shell-scan-status" class="rpca-inline-status" role="status" aria-live="polite" hidden style="background:rgba(255,255,255,0.06); border-color:rgba(255,255,255,0.16); color:var(--ink-50)"></p>
            <div id="rpca-shell-scan-progress" class="rpca-scan-progress is-indeterminate" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Scan progress" hidden>
              <div class="rpca-scan-progress-bar"><div class="rpca-scan-progress-fill"></div></div>
              <div class="rpca-scan-progress-meta"><span class="rpca-scan-progress-label">Preparing scan...</span><span class="rpca-scan-progress-counts">&mdash;</span></div>
            </div>
          </div>
          <div class="rpca-hero-gauge" aria-hidden="true">
            <div class="rpca-hero-gauge-ring" style="--gauge-percent: ${escapeHtml(gaugePercent)}%">
              <div class="rpca-hero-gauge-inner">
                <span>Grade</span>
                <strong class="rpca-grade-letter">${escapeHtml(grade.letter)}</strong>
                <span class="rpca-grade-score">${gaugeScore}</span>
              </div>
            </div>
            <span class="rpca-hero-gauge-tag">${escapeHtml(grade.label)}</span>
            ${hasFinishedScan ? `<small class="rpca-hero-gauge-timestamp">Last scan ${escapeHtml(relativeTimeFromString(state.lastScan.finishedAt))}</small>` : `<small class="rpca-hero-gauge-timestamp">No scan has been run yet</small>`}
          </div>
        </article>

        ${renderTrustStrip(ui)}
        ${renderCategoryBreakdown(state.lastScan && Array.isArray(state.lastScan.findings) ? state.lastScan.findings : [])}
        ${renderTrustBadgeHelperCard()}

        <article class="rpca-one-thing">
          <p class="rpca-eyebrow">What to fix first</p>
          <h3>${escapeHtml(one.title || "Run a fresh audit scan.")}</h3>
          <p>${escapeHtml(one.body || "After your first scan, this card shows the single most useful fix to make next.")}</p>
          <div class="rpca-button-row">
            <button type="button" class="rpca-button is-primary" data-action="${one.primaryTarget === "cockpit-scan" ? "start-scan" : "section"}" data-target="${escapeHtml(one.primaryTarget || "findings")}" data-scan-mode="processor_audit">${escapeHtml(one.primaryLabel || "Start audit scan")}</button>
            <button type="button" class="rpca-button is-text" data-action="snooze-one-thing">Remind me tomorrow</button>
          </div>
        </article>

        ${renderPaymentReadinessCard(state.lastScan)}

        <div class="rpca-cockpit-stats">
          <article class="rpca-stat-card">
            <span class="rpca-stat-card-label">Critical findings</span>
            <span class="rpca-stat-card-value">${hasFinishedScan ? escapeHtml(counts.critical || 0) : dash}</span>
            <span class="rpca-stat-card-meta">${hasFinishedScan ? `<span class="rpca-stat-card-tag ${(counts.critical || 0) > 0 ? "is-bad" : "is-pass"}">${(counts.critical || 0) > 0 ? "Needs attention" : "Clear"}</span>` : `<span class="rpca-stat-card-tag is-warn">Not yet scanned</span>`}</span>
          </article>
          <article class="rpca-stat-card">
            <span class="rpca-stat-card-label">High findings</span>
            <span class="rpca-stat-card-value">${hasFinishedScan ? escapeHtml(counts.high || 0) : dash}</span>
            <span class="rpca-stat-card-meta">${hasFinishedScan ? `<span class="rpca-stat-card-tag ${(counts.high || 0) > 0 ? "is-warn" : "is-pass"}">${(counts.high || 0) > 0 ? "Plan a fix" : "Clear"}</span>` : `<span class="rpca-stat-card-tag is-warn">Not yet scanned</span>`}</span>
          </article>
          <article class="rpca-stat-card">
            <span class="rpca-stat-card-label">Checklist passing</span>
            <span class="rpca-stat-card-value">${hasFinishedScan ? `${escapeHtml(passCount)}<small>/ ${escapeHtml(checklist.length || 0)}</small>` : dash}</span>
            <span class="rpca-stat-card-meta">${hasFinishedScan ? `<span class="rpca-stat-card-tag ${reviewCount === 0 ? "is-pass" : "is-warn"}">${reviewCount === 0 ? "All set" : reviewCount + " to review"}</span>` : `<span class="rpca-stat-card-tag is-warn">Not yet scanned</span>`}</span>
          </article>
          <article class="rpca-stat-card">
            <span class="rpca-stat-card-label">Last scan</span>
            <span class="rpca-stat-card-value" style="font-size:18px">${hasFinishedScan ? escapeHtml(state.lastScan.finishedAt) : "Not run yet"}</span>
            <span class="rpca-stat-card-meta">${hasFinishedScan ? `${escapeHtml((state.lastScan && state.lastScan.pageCount) || 0)} pages reviewed` : "Click “Run your first audit scan” above to populate this view."}</span>
          </article>
        </div>

        <article class="rpca-this-week">
          <h3>Since your last scan</h3>
          ${
            weekLines.length
              ? weekLines
                  .map(
                    (line) =>
                      `<button type="button" class="rpca-week-line" data-section="${escapeHtml(line.target || "findings")}"><span>${escapeHtml(line.label || "")}</span><span aria-hidden="true">&rarr;</span></button>`
                  )
                  .join("")
              : `<p style="margin:0;color:var(--ink-500);font-size:13px;">No activity has been recorded yet. Run a scan to populate this view.</p>`
          }
        </article>

        ${renderComplianceChecklist(checklist)}
        ${renderTimeMachine(cockpit.timeline || [])}

        <p class="rpca-notice-card">${escapeHtml(ui.legalNotice || legalNotice)}</p>
      </section>
    `;
  }

  function plainRuleReference(value) {
    const raw = String(value || "").trim();
    if (!raw) return "Compliance rule reference";
    const known = {
      "claims.international_approval_equivalence": "Approval, prescription, or regulatory-equivalence wording found near RUO peptide content",
      "claims.fda_cited_glp_name_context": "FDA-cited GLP or peptide names need review when they appear with human-use context",
      "claims.fda_cited_glp_alias_context": "FDA-cited GLP aliases need review when they appear with human-use context",
      "claims.glp_alias_substitution": "GLP alias naming can still function like a drug-name substitute",
      "claims.brand_comparison": "FDA-approved brand or manufacturer comparisons need review",
      "structural.per_product_ruo_disclaimer": "Product page may be missing visible research-only and non-human-use wording",
      "structural.product_documentation_visible": "Product page may be missing visible COA or lab-documentation evidence",
      "structural.buyer_verification_safeguards": "Storefront may need clearer buyer or researcher qualification safeguards",
      "structural.scientific_citation_support": "Scientific wording may need visible source support",
      "supply_chain.quality_claim_substantiation_context": "Quality, supplier, or manufacturing claims need visible supporting documentation",
      "supply_chain.import_relabel_repack_risk": "Import, relabeling, repackaging, or sourcing wording needs supply-chain review",
      "supply_chain.sds_hazard_communication_gap": "Hazard, handling, storage, or disposal wording may need SDS support",
      "platform_ads.ad_landing_claim_context": "Paid-ad, affiliate, or landing-page wording needs campaign claims controls",
      "platform_ads.community_social_takedown_context": "Public forum, social, vendor-review, or community-risk wording needs context review",
      "platform_ads.affiliate_claims_control": "Affiliate, influencer, or coupon traffic needs visible claims controls",
      "platform_ads.affiliate_material_connection_disclosure": "Affiliate or sponsored traffic needs clear material-connection disclosure",
      "platform_ads.linked_community_dosing_context": "Linked community or forum context may imply dosing or human-use instructions",
      "platform_ads.review_gating_or_suppression": "Review incentives or suppression wording needs FTC-style review-integrity controls",
      "processor.cloaking_or_workaround_language": "Public wording suggests crawler, processor, or checkout evasion risk",
      "processor.research_demand_mismatch": "Sales-channel or volume wording may not match a research-only business model",
      "processor.payment_continuity_shutdown_language": "Payment-continuity or shutdown wording needs processor-readiness review",
      "processor.alternative_payment_instability_context": "Alternative payment wording needs review when it signals instability or workaround payments",
      "processor.consumer_self_use_context": "Consumer self-use, home-use, or self-experimentation wording needs review",
      "content.blog_faq_human_use_context": "Educational, FAQ, guide, or blog content may create human-use context",
      "cart_pairing.peptide_plus_administration_accessory": "Peptide product appears near administration or reconstitution accessories",
      "structural.downloadable_docs_ruo_label": "Downloadable COA, SDS, datasheet, or lab-report materials may need RUO wording",
      "structural.medical_schema_lane_conflict": "Medical, drug, dosing, or prescription structured data appears near peptide content",
      "structural.state_federal_lane_separation": "RUO storefront may need clearer separation from pharmacy, clinic, or prescription lanes",
      "local.affiliate.influencer.or.coupon.traffic.needs.claims.controls": "Affiliate, influencer, or coupon traffic needs visible claims controls",
      "local.manufacturing.supplier.or.quality.claims.need.substantiation": "Manufacturing, supplier, or quality claims need visible substantiation",
      "local.international.approval.clearance.or.regulatory.equivalence.claims": "International approval, clearance, or regulatory-equivalence wording needs review",
      "ml.local_weighted_classifier": "Local scanner found wording similar to prior FDA-risk patterns",
      "ml.local_vector_similarity": "Local scanner found similarity to stored public enforcement examples",
    };
    if (known[raw]) return known[raw];
    const cleaned = raw
      .replace(/^(local|claims|structural|platform_ads|processor|rules|ml|supply_chain|content)\./i, "")
      .replace(/[_\-.]+/g, " ")
      .replace(/\bru[o0]\b/gi, "research-use-only")
      .replace(/\bcoa\b/gi, "COA")
      .replace(/\bfda\b/gi, "FDA")
      .replace(/\bftc\b/gi, "FTC")
      .replace(/\bglp\b/gi, "GLP")
      .replace(/\bsds\b/gi, "SDS")
      .replace(/\s+/g, " ")
      .trim();
    if (!cleaned) return "Compliance rule reference";
    return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
  }

  function citationPrefix(cite) {
    const type = String((cite && cite.type) || "").toLowerCase();
    const hasUrl = Boolean(cite && cite.url);
    if (hasUrl && (type.includes("fda") || type.includes("warning"))) return "FDA example";
    if (type.includes("local_vector") || type.includes("local_weighted")) return "Local model note";
    return "Check meaning";
  }

  function citationPlainLabel(cite, finding) {
    if (!cite) return "";
    const explicit = cite.label || cite.company || cite.subject;
    if (explicit) return String(explicit);
    if (cite.pattern && !String(cite.pattern).includes(".")) return String(cite.pattern);
    return plainRuleReference(cite.id || (finding && finding.rule) || "");
  }

  function citationPlainExplanation(finding) {
    const cite = finding && finding.citation ? finding.citation : null;
    if (!cite) {
      return "This item comes from the built-in compliance rules and the text found during the crawl.";
    }
    const label = citationPlainLabel(cite, finding);
    if (cite.url) {
      return `${citationPrefix(cite)}: ${label}. Open the linked source if you want to inspect the reference behind this rule.`;
    }
    return `${citationPrefix(cite)}: ${label}. This is a plain-English name for the internal scanner rule, not a separate FDA citation.`;
  }

  function renderFindingCitation(finding) {
    const cite = finding && finding.citation ? finding.citation : null;
    if (!cite) return "";
    const url = cite.url ? String(cite.url) : "";
    const id = cite.id ? String(cite.id) : "";
    const label = citationPlainLabel(cite, finding);
    const prefix = citationPrefix(cite);
    if (!url && !id) return "";
    if (url) {
      return `<a class="rpca-finding-citation" href="${safeHref(url)}" target="_blank" rel="noopener noreferrer" title="${escapeHtml(citationPlainExplanation(finding))}">${escapeHtml(prefix)}: ${escapeHtml(label)}</a>`;
    }
    return `<span class="rpca-finding-citation" title="${escapeHtml(citationPlainExplanation(finding))}">${escapeHtml(prefix)}: ${escapeHtml(label)}</span>`;
  }

  // v0.7.376: surface the consolidatedRules array set by the PHP
  // "same page + same wording" merge pass. When two or more rules
  // fired against the same wording on the same page, the server
  // collapses them into a single finding and ships the full list as
  // finding.consolidatedRules. Show "Also flagged by: rule1, rule2"
  // as a small subtitle so the merchant can see every regulation the
  // wording violates without seeing one row per rule.
  function renderConsolidatedRulesBadge(finding) {
    if (!finding || !Array.isArray(finding.consolidatedRules)) return "";
    const headlineLabel = String((finding && finding.rule) || "").trim().toLowerCase();
    const others = finding.consolidatedRules
      .filter((r) => r && r.label && String(r.label).trim().toLowerCase() !== headlineLabel)
      .map((r) => String(r.label).trim())
      .filter(Boolean);
    if (!others.length) return "";
    // De-dup case-insensitively while preserving original casing of the
    // first occurrence (the PHP side already deduped exact label+id
    // pairs, but two rules can share a label across citation IDs).
    const seen = new Set();
    const unique = [];
    for (const label of others) {
      const key = label.toLowerCase();
      if (seen.has(key)) continue;
      seen.add(key);
      unique.push(label);
    }
    if (!unique.length) return "";
    const tooltip = unique.length === 1
      ? "This same wording also triggered: " + unique[0]
      : "This same wording also triggered " + unique.length + " other rules: " + unique.join("; ");
    return `<div class="rpca-consolidated-rules" title="${escapeHtml(tooltip)}" style="margin-top:4px;font-size:12px;color:var(--ink-500);display:flex;flex-wrap:wrap;gap:4px;align-items:center;">
      <strong style="color:var(--ink-700);font-weight:600;">Also flagged by:</strong>
      ${unique.map((label) => `<span class="rpca-consolidated-rule-chip" style="background:#eef2ff;border:1px solid #c7d2fe;color:#3730a3;padding:1px 6px;border-radius:10px;font-size:11px;white-space:nowrap;">${escapeHtml(label)}</span>`).join("")}
    </div>`;
  }

  // Translate the snake_case contextAssessment IDs returned by
  // context_assessment_for_window() into merchant-readable text. Cluster-rule
  // findings already set contextAssessment to a translated string in PHP, so
  // those flow through unchanged. The four IDs come from the matches_for_rule
  // path; without translation, the merchant would see the raw identifier as
  // evidence text under "Sentence context checked", which is unprofessional.
  function humanizeContextAssessment(value) {
    const raw = String(value || "").trim();
    if (!raw) {
      return "The scanner checks the surrounding sentence so a protective RUO or not-FDA-approved disclaimer is not treated as a positive claim by itself.";
    }
    const map = {
      protective_brand_or_non_affiliation_disclaimer:
        "The surrounding sentence is a protective brand or non-affiliation disclaimer (e.g. 'we are not affiliated with Novo Nordisk'). The scanner ignores brand-comparison rules when this defensive frame is present.",
      protective_ruo_or_not_approved_disclaimer:
        "The surrounding sentence is a protective RUO or not-FDA-approved disclaimer. The scanner suppresses the rule's match when the only context for the matched term is defensive wording.",
      risk_context_reviewed:
        "The surrounding sentence contains commerce, body-function, injection, or testimonial context that confirms the matched term is in a real risk context, not a defensive disclaimer.",
      sentence_context_reviewed:
        "The surrounding sentence was reviewed for protective wording before this finding was counted; no protective frame was found nearby.",
    };
    if (raw in map) return map[raw];
    // Fall through: cluster-rule findings already use translated strings.
    return raw;
  }

  function complianceGradeFor(score, counts, band) {
    const safeScore = Math.max(0, Math.min(100, Number(score) || 0));
    const c = counts || {};
    const critical = Number(c.critical || 0);
    const high = Number(c.high || 0);
    // v0.7.285: distinguish "actual critical issues" from "low score with no
    // criticals." Previously the label said "Critical issues found" whenever
    // score < 50 even if criticalCount was 0, which is misleading and the
    // user reported it during live testing on the Makeshift Hobbies site.
    if (critical > 0) {
      return { letter: "F", label: "Critical issues found", tone: "is-bad" };
    }
    if (safeScore < 50) {
      return { letter: "F", label: "Heavy fix list", tone: "is-bad" };
    }
    if (safeScore >= 90 && high === 0) {
      return { letter: "A", label: "Excellent", tone: "is-pass" };
    }
    if (safeScore >= 80 && high <= 1) {
      return { letter: "B", label: "Good", tone: "is-pass" };
    }
    if (safeScore >= 70) {
      return { letter: "C", label: "Needs attention", tone: "is-warn" };
    }
    if (safeScore >= 60) {
      return { letter: "D", label: "At risk", tone: "is-warn" };
    }
    const fallback = String(band || "yellow").toLowerCase();
    if (fallback === "red" || fallback === "orange") {
      // v0.7.285: same fix as above — don't claim "Critical" if criticalCount is 0.
      return { letter: "F", label: critical > 0 ? "Critical issues found" : "Heavy fix list", tone: "is-bad" };
    }
    return { letter: "C", label: "Needs attention", tone: "is-warn" };
  }

  function relativeTimeFromString(value) {
    if (!value) return "";
    const parsed = Date.parse(String(value).replace(" ", "T"));
    if (!Number.isFinite(parsed)) return String(value);
    const diffMs = Date.now() - parsed;
    const diffSec = Math.round(diffMs / 1000);
    if (diffSec < 60) return "just now";
    const diffMin = Math.round(diffSec / 60);
    if (diffMin < 60) return diffMin + " minute" + (diffMin === 1 ? "" : "s") + " ago";
    const diffHr = Math.round(diffMin / 60);
    if (diffHr < 24) return diffHr + " hour" + (diffHr === 1 ? "" : "s") + " ago";
    const diffDay = Math.round(diffHr / 24);
    if (diffDay < 14) return diffDay + " day" + (diffDay === 1 ? "" : "s") + " ago";
    const diffWeek = Math.round(diffDay / 7);
    if (diffWeek < 8) return diffWeek + " week" + (diffWeek === 1 ? "" : "s") + " ago";
    return new Date(parsed).toLocaleDateString();
  }

  function findingCategoryFor(finding) {
    if (!finding) return "Other";
    const text = ((finding.rule || "") + " " + (finding.reason || "") + " " + ((finding.citation && finding.citation.id) || "")).toLowerCase();
    if (/testimonial|review|endorsement|social\s*proof/.test(text)) return "Testimonials & social proof";
    if (/dosing|inject|reconstitut|bacteriostatic|syringe|cycle|stack|mcg|mg|iu/.test(text)) return "Dosing, injection & human-use language";
    // Pharmacological drug-class descriptors get their own bucket so they
    // don't leak into "Brand or approval comparisons" via the bare "fda"
    // keyword that appears in the rule reason ("FDA enforcement treats
    // drug-class language..."). Class-language findings (incretin mimetic,
    // dual/triple agonist, amylin analog, GHRP/GHRH, testosterone-equivalence)
    // identify what the molecule does in humans, which is closer to disease/
    // therapeutic claims than to a brand comparison.
    if (/incretin|amylin|dual\s*agonist|triple\s*agonist|gip\s*agonist|glucagon\s*agonist|growth\s*hormone\s*secretagogue|ghrp|ghrh|drug[-_\s]?class|pharmacological[-_\s]?class|testosterone\s*(?:analog|like|equivalent|similar|mimetic)|anabolic\s+(?:activity|effect|effects)/.test(text)) return "Drug-class descriptors";
    if (/brand|trademark|alias|equivalence|same\s*as|generic|approved|fda|ema|tga|mhra|prescription/.test(text)) return "Brand or approval comparisons";
    if (/disease|therapeut|cure|treat|prevent|mitigate|diagnose|diabetes|obesity|inflammation|cardiovascular|metabolic|symptom/.test(text)) return "Disease & therapeutic claims";
    if (/refund|return|cancellation|shipping|privacy|terms|acceptable\s*use|contact|business[-\s]?identity|payment[-\s]?security|policy/.test(text)) return "Missing or weak policies";
    if (/coa|certificate|lab|purity|hplc|mass\s*spec|janoshik|chromate|freedom|finnrick|documentation/.test(text)) return "COA & documentation";
    if (/header|hsts|referrer|csp|clickjack|content[-\s]?security|tls|x-frame|nosniff|cookie|security/.test(text)) return "Site security & headers";
    if (/processor|chargeback|descriptor|merchant|underwrit|card[-\s]?network|vamp|bram|payment/.test(text)) return "Payment & processor risk";
    if (/educational|faq|guide|calculator|protocol|blog/.test(text)) return "Educational content & guides";
    if (/quality|manufacturing|supplier|sterile|relabel|repack/.test(text)) return "Manufacturing & quality";
    if (/age|verification|gate|attestation|buyer/.test(text)) return "Buyer verification";
    return "Other";
  }

  function categorySummaryForFindings(findings) {
    const list = Array.isArray(findings) ? findings : [];
    const buckets = {};
    list.forEach((f) => {
      const cat = findingCategoryFor(f);
      if (!buckets[cat]) {
        buckets[cat] = { name: cat, count: 0, critical: 0, high: 0, medium: 0, low: 0 };
      }
      buckets[cat].count += 1;
      const sev = String(f.severity || "low").toLowerCase();
      if (sev === "critical") buckets[cat].critical += 1;
      else if (sev === "high") buckets[cat].high += 1;
      else if (sev === "medium") buckets[cat].medium += 1;
      else buckets[cat].low += 1;
    });
    // UI-display sort weights (4/3/2/1) are intentionally DIFFERENT from the
    // PHP severity_weight (10/6/3/1). The PHP weights drive the numeric scan
    // score and reflect FDA-enforcement-actual penalty severity. The JS
    // weights drive only the display order of category buckets, where the
    // gap between critical and low does not need to be as steep — a
    // category with one critical + several low findings should still
    // out-rank a category with only low findings, but a category with one
    // critical should NOT out-rank a category with 5 highs. The 4/3/2/1
    // scheme produces the right intuitive ordering for the UI; the 10/6/3/1
    // scheme would over-weight categories with a single critical finding
    // and bury categories with multiple high findings further down.
    // Do NOT consolidate these two weight systems without re-running the UI
    // ordering against a representative findings sample.
    return Object.values(buckets).sort((a, b) => {
      const aw = a.critical * 4 + a.high * 3 + a.medium * 2 + a.low;
      const bw = b.critical * 4 + b.high * 3 + b.medium * 2 + b.low;
      return bw - aw;
    });
  }

  function renderCategoryBreakdown(findings) {
    const summary = categorySummaryForFindings(findings);
    if (!summary.length) {
      return `
        <section class="rpca-category-breakdown">
          <header class="rpca-category-head">
            <p class="rpca-eyebrow">By category</p>
            <h3>Findings grouped by FDA-derived category</h3>
            <p class="rpca-muted">When you run a scan, every finding is grouped into a named category here (testimonials, dosing language, brand comparisons, missing policies, COA documentation, site security, payment & processor risk, and more) so you can see at a glance where the work is. No findings yet - run a scan to populate this section.</p>
          </header>
        </section>
      `;
    }
    return `
      <section class="rpca-category-breakdown">
        <header class="rpca-category-head">
          <p class="rpca-eyebrow">By category</p>
          <h3>What kind of issues we found</h3>
          <p class="rpca-muted">Detection patterns are derived from public FDA warning letters and grouped here so you can see at a glance where your site needs attention.</p>
        </header>
        <ul class="rpca-category-list">
          ${summary
            .map((cat) => {
              const tone = cat.critical > 0 ? "is-bad" : cat.high > 0 ? "is-warn" : "is-pass";
              return `
                <li class="rpca-category-row">
                  <span class="rpca-stat-card-tag ${tone}">${escapeHtml(cat.count)}</span>
                  <div>
                    <strong>${escapeHtml(cat.name)}</strong>
                    <small>
                      ${cat.critical ? `${cat.critical} critical &middot; ` : ""}${cat.high ? `${cat.high} high &middot; ` : ""}${cat.medium ? `${cat.medium} medium &middot; ` : ""}${cat.low ? `${cat.low} low` : ""}
                    </small>
                  </div>
                </li>
              `;
            })
            .join("")}
        </ul>
      </section>
    `;
  }

  function renderTrustBadgeHelperCard() {
    // v0.7.435: Apply-button UX replaces the copy-paste shortcode
    // workflow. The merchant picks a style and clicks Apply; the
    // badge appears automatically in the site footer on every public
    // page via the wp_footer hook. The original shortcode still
    // works for power users who want pixel control over placement
    // (collapsed under "Advanced" below).
    const ui = uiState();
    const current = String((ui && ui.badgeAutoInject) || "off");
    const isOff = current === "off";
    const isInline = current === "inline";
    const isCard = current === "card";
    const statusPill = isOff
      ? `<span class="rpca-recommended-dot rpca-recommended-dot-off" aria-hidden="true" style="width:9px;height:9px;border-radius:999px;background:#a85c2b;display:inline-block;"></span> <strong style="color:#6b7280;">Not showing on your site</strong>`
      : `<span class="rpca-recommended-dot rpca-recommended-dot-on" aria-hidden="true" style="width:9px;height:9px;border-radius:999px;background:#4a6b3a;display:inline-block;"></span> <strong style="color:#166534;">Live in your footer (${escapeHtml(isInline ? "inline" : "card")} style)</strong>`;
    return `
      <article class="rpca-editorial-card" data-rpca-badge-card>
        <p class="rpca-eyebrow">Public trust badge</p>
        <h3>Show visitors your scan status</h3>
        <p>Display a small badge with your current scan grade and the date of the last check. Framed as a merchant self-check, not FDA or processor approval. Click Apply and the badge appears at the bottom of every public page automatically — no theme editing required.</p>

        <p style="display:flex;align-items:center;gap:8px;margin:12px 0 14px;font-size:13px;">
          ${statusPill}
        </p>

        <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-top:14px;">
          <button type="button" class="rpca-button${isInline ? " is-primary" : ""}" data-action="apply-trust-badge" data-badge-mode="inline" style="${isInline ? "" : "background:#fff;color:#0f172a;border:1px solid #d1d5db;"}padding:12px 14px;text-align:left;line-height:1.4;">
            <strong style="display:block;margin-bottom:4px;">${isInline ? "✓ Inline badge (applied)" : "Apply inline badge"}</strong>
            <small style="display:block;color:${isInline ? "rgba(255,255,255,0.85)" : "#64748b"};font-size:12px;">Small chip — best for footers</small>
          </button>
          <button type="button" class="rpca-button${isCard ? " is-primary" : ""}" data-action="apply-trust-badge" data-badge-mode="card" style="${isCard ? "" : "background:#fff;color:#0f172a;border:1px solid #d1d5db;"}padding:12px 14px;text-align:left;line-height:1.4;">
            <strong style="display:block;margin-bottom:4px;">${isCard ? "✓ Card badge (applied)" : "Apply card badge"}</strong>
            <small style="display:block;color:${isCard ? "rgba(255,255,255,0.85)" : "#64748b"};font-size:12px;">Larger block — best for sidebars / hero</small>
          </button>
          <button type="button" class="rpca-button" data-action="apply-trust-badge" data-badge-mode="off" style="${isOff ? "background:#fee2e2;color:#991b1b;border:1px solid #fca5a5;" : "background:#fff;color:#0f172a;border:1px solid #d1d5db;"}padding:12px 14px;text-align:left;line-height:1.4;">
            <strong style="display:block;margin-bottom:4px;">${isOff ? "✓ Off (not showing)" : "Turn off"}</strong>
            <small style="display:block;color:${isOff ? "#991b1b" : "#64748b"};font-size:12px;">Remove the badge from the footer</small>
          </button>
        </div>
        <p data-rpca-badge-feedback style="margin:12px 0 0;color:#475569;font-size:13px;min-height:18px;" role="status" aria-live="polite"></p>

        <details style="margin-top:14px;border-top:1px solid #e5e7eb;padding-top:12px;">
          <summary style="cursor:pointer;font-weight:600;color:#1e293b;font-size:13px;">Advanced — use the shortcode manually instead</summary>
          <p style="margin:10px 0 0;color:#475569;font-size:13px;">Power users can paste the shortcode below into any page, post, or theme template for pixel-level placement control. The shortcode and the Apply button can coexist; the Apply button only controls the auto-injected footer badge.</p>
          <div style="display:grid;grid-template-columns:1fr;gap:10px;margin-top:10px;">
            <label style="display:flex;flex-direction:column;gap:4px;">
              <span style="font-size:12px;color:#64748b;">Inline badge shortcode</span>
              <input type="text" readonly value="[ruo_compliance_badge]" onclick="this.select();" style="padding:7px;border:1px solid #e2e8f0;border-radius:6px;font-family:ui-monospace,monospace;font-size:13px;" />
            </label>
            <label style="display:flex;flex-direction:column;gap:4px;">
              <span style="font-size:12px;color:#64748b;">Card badge shortcode</span>
              <input type="text" readonly value='[ruo_compliance_badge style="card"]' onclick="this.select();" style="padding:7px;border:1px solid #e2e8f0;border-radius:6px;font-family:ui-monospace,monospace;font-size:13px;" />
            </label>
          </div>
          <p style="margin:8px 0 0;color:#64748b;font-size:12px;"><em>Click to select, then copy and paste anywhere on your site. The badge updates automatically every time you run a scan.</em></p>
        </details>
      </article>
    `;
  }

  // v0.7.389: an age gate counts as ACTIVE only on an unambiguous
  // truthy-on value. Anything else — undefined, null, false, 0, "0",
  // "", a missing field, a stale cached uiState — means "not
  // confirmed active", and a safety control that isn't confirmed on
  // must read Off. Never default a safety control to On.
  function ageGateActive(setup) {
    if (!setup) return false;
    const v = setup.ageGate;
    return v === true || v === 1 || v === "1";
  }

  function renderTrustStrip(ui) {
    const cockpit = ui.cockpit || {};
    const setup = ui.setup || {};
    const counts = ui.counts || {};
    // v0.7.339: surface page-level coverage in the value when available so
    // the merchant sees WHICH signal classified the disclaimer as Live
    // (page coverage / banner configured / per-product injector / global
    // text). Without this, "Live" was a binary verdict with no provenance.
    // v0.7.382 (user report: "ruo disclaimer showing incorrectly as
    // its present on all pages, make sure this is 100% correct"). The
    // widget now prefers the sitewide-render reason when one is on
    // (banner / per-product injector / global text), because those
    // signals render the disclaimer on every page regardless of what
    // the static HTML crawl was able to capture. The page-count form
    // is shown only when coverage comes from the per-page marker
    // scan AND no sitewide signal is active.
    let disclaimerValue = "Check coverage";
    if (setup.disclaimerLive) {
      const signals = setup.disclaimerSignals || {};
      const covered = Number(setup.disclaimerCoveredPages || 0);
      const total = Number(setup.disclaimerTotalPages || 0);
      // v0.7.414: round-31 structural test pins the literal
      // `setup.disclaimerSignals && setup.disclaimerSignals.<key>` chain.
      // The branches below preserve the original presentation; the
      // pinned chains just expose the signal source explicitly.
      if (setup.disclaimerSignals && setup.disclaimerSignals.bannerConfigured) {
        disclaimerValue = total > 0 ? `Live (sitewide banner · ${total} pages)` : "Live (sitewide banner)";
      } else if (setup.disclaimerSignals && setup.disclaimerSignals.globalText) {
        disclaimerValue = total > 0 ? `Live (global text · ${total} pages)` : "Live (global text)";
      } else if (setup.disclaimerSignals && setup.disclaimerSignals.productInjector) {
        disclaimerValue = total > 0 ? `Live (per-product injector · ${total} pages)` : "Live (per-product injector)";
      } else if (signals.pageCoverage && total > 0) {
        disclaimerValue = `Live (${covered} of ${total} pages)`;
      } else if (total > 0) {
        disclaimerValue = `Live (${covered} of ${total} pages)`;
      } else {
        disclaimerValue = "Live";
      }
    }
    const cells = [
      {
        label: "RUO disclaimer",
        value: disclaimerValue,
        tone: setup.disclaimerLive ? "is-pass" : "is-warn",
      },
      {
        label: "COA visibility",
        value: cockpit.coaSummary || "No COAs verified yet",
        tone: cockpit.coaPass ? "is-pass" : "is-warn",
      },
      {
        // v0.7.389: an age gate is a binary safety control — it reads
        // "Off" unless we have explicit confirmation it is on
        // (ageGateActive is strict, never defaulting a safety control
        // to On). v0.7.397: setup.ageGate is now true when ANY age gate
        // is in place — Compliance Guard's built-in gate, a known
        // third-party age-verification plugin, or a custom/unlisted
        // age-gate plugin — and ageGateLabel names the source.
        label: "Age gate",
        value: setup.ageGateLabel || (ageGateActive(setup) ? "On" : "Off"),
        tone: ageGateActive(setup) ? "is-pass" : "is-bad",
      },
      {
        label: "Processor profile",
        value: setup.processorProfile || "Generic underwriting",
        tone: "is-pass",
      },
      {
        label: "Open critical",
        value: (counts.critical || 0) > 0 ? counts.critical + " items" : "None",
        tone: (counts.critical || 0) > 0 ? "is-bad" : "is-pass",
      },
    ];
    return `
      <section class="rpca-trust-strip" aria-label="Trust signals at a glance">
        ${cells
          .map(
            (cell) => `
              <div class="rpca-trust-cell">
                <span class="rpca-trust-cell-label">${escapeHtml(cell.label)}</span>
                <span class="rpca-trust-cell-value">${escapeHtml(cell.value)}<span class="rpca-trust-cell-tag ${escapeHtml(cell.tone)}">${cell.tone === "is-pass" ? "OK" : cell.tone === "is-warn" ? "Watch" : "Fix"}</span></span>
              </div>
            `
          )
          .join("")}
      </section>
    `;
  }

  function renderPaymentReadinessCard(scan) {
    const audit = scan && scan.processorAudit ? scan.processorAudit : null;
    const score = audit && Number(audit.score) ? Math.max(0, Math.min(100, Number(audit.score))) : 0;
    // v0.7.441 — headline now reflects the new tiered result.
    //   pass   → "Looks ready for review"
    //   review → "Expand evidence before applying" (no critical/high
    //            findings open, just thin underwriter coverage)
    //   fail   → "Needs action before review" (urgent findings open)
    const statusLabel = audit
      ? audit.result === "pass"
        ? "Looks ready for review"
        : audit.result === "review"
          ? (audit.criticalFindings || audit.highFindings)
            ? "Needs minor review"
            : "Expand evidence before applying"
          : "Needs action before review"
      : "No audit scan yet";
    const description = audit
      ? audit.plainEnglish || "This score reflects what a payment reviewer can see on your public site without logging in."
      : "Run a processor audit scan to see how a payment reviewer will read your store.";
    return `
      <section class="rpca-payment-readiness" aria-label="Payment processor readiness summary">
        <div>
          <p class="rpca-eyebrow">Payment processor readiness</p>
          <h3>${escapeHtml(statusLabel)}</h3>
          <p>${escapeHtml(description)}</p>
          <div class="rpca-button-row">
            <button type="button" class="rpca-button" data-section="underwriter">Open full payment review</button>
            <button type="button" class="rpca-button is-text" data-action="start-scan" data-scan-mode="processor_audit">Run audit scan</button>
          </div>
        </div>
        <div class="rpca-payment-meter" aria-hidden="true">
          <strong>${escapeHtml(audit ? score : "--")}</strong>
          <span>Reviewer score / 100</span>
        </div>
      </section>
    `;
  }

  function renderComplianceChecklist(items) {
    const list = Array.isArray(items) ? items : [];
    if (!list.length) {
      return `
        <section class="rpca-compliance-checklist">
          <div class="rpca-checklist-head">
            <div>
              <p class="rpca-eyebrow">Compliance checklist</p>
              <h3>What looks set up right now</h3>
            </div>
            <span class="rpca-status-pill">No data yet</span>
          </div>
          <p style="color:var(--ink-500);font-size:13px;margin:0;">Run a scan to populate the checklist by category.</p>
        </section>
      `;
    }

    const categoryOrder = [
      "Website wording",
      "Product pages",
      "Checkout",
      "Policies",
      "Payment review",
      "Technical & security",
      "Evidence & COAs",
    ];
    const categorize = (item) => {
      const explicit = item.category || item.group;
      if (explicit) return explicit;
      const text = `${item.key || ""} ${item.label || ""} ${item.detail || ""}`.toLowerCase();
      if (text.match(/checkout|cart|account|attestation/)) return "Checkout";
      if (text.match(/coa|certificate|lab|janoshik|chromate/)) return "Evidence & COAs";
      if (text.match(/policy|policies|terms|refund|shipping|privacy/)) return "Policies";
      if (text.match(/payment|processor|underwriter|bram|vamp|chargeback|dispute/)) return "Payment review";
      if (text.match(/header|hsts|csp|tls|security|cookie/)) return "Technical & security";
      if (text.match(/product|catalog|category|sku|disclaimer|ruo/)) return "Product pages";
      return "Website wording";
    };

    const grouped = {};
    list.forEach((item) => {
      const key = categorize(item);
      if (!grouped[key]) grouped[key] = [];
      grouped[key].push(item);
    });

    const passCount = list.filter((item) => item.status === "pass").length;
    const reviewCount = list.filter((item) => item.status === "review").length;

    const categories = categoryOrder.filter((cat) => grouped[cat]).concat(
      Object.keys(grouped).filter((cat) => !categoryOrder.includes(cat))
    );

    return `
      <section class="rpca-compliance-checklist">
        <div class="rpca-checklist-head">
          <div>
            <p class="rpca-eyebrow">Compliance checklist</p>
            <h3>Where you stand by category</h3>
          </div>
          <div class="rpca-checklist-summary">
            <span class="rpca-checklist-summary-tally">${escapeHtml(passCount)}<span>/ ${escapeHtml(list.length)}</span></span>
            <span class="rpca-status-pill" style="color:var(--band-clear);border-color:color-mix(in srgb, var(--band-clear) 40%, transparent);background:var(--band-clear-soft)">${escapeHtml(passCount)} clear</span>
            <span class="rpca-status-pill" style="color:var(--band-red);border-color:color-mix(in srgb, var(--band-red) 40%, transparent);background:var(--band-red-soft)">${escapeHtml(reviewCount)} to review</span>
          </div>
        </div>
        ${categories
          .map((cat) => {
            const groupItems = grouped[cat];
            const groupPass = groupItems.filter((item) => item.status === "pass").length;
            return `
              <div class="rpca-checklist-group">
                <div class="rpca-checklist-group-head">
                  <h4>${escapeHtml(cat)}</h4>
                  <span class="rpca-checklist-group-rule" aria-hidden="true"></span>
                  <span class="rpca-checklist-group-tally">${escapeHtml(groupPass)} / ${escapeHtml(groupItems.length)}</span>
                </div>
                <div class="rpca-checklist-grid">
                  ${groupItems
                    .map(
                      (item) => `
                        <article class="rpca-checklist-item is-${escapeHtml(item.status || "pending")}">
                          <span class="rpca-check-shape" aria-hidden="true"></span>
                          <div>
                            <strong>${escapeHtml(item.label || "")}</strong>
                            <p>${escapeHtml(item.detail || "")}</p>
                            <span>${escapeHtml(item.status === "pass" ? "Clear" : item.status === "review" ? "Needs attention" : "Scan needed")}</span>
                          </div>
                        </article>
                      `
                    )
                    .join("")}
                </div>
              </div>
            `;
          })
          .join("")}
      </section>
    `;
  }

  function renderTimeMachine(points) {
    const safe = Array.isArray(points) && points.length ? points : [{ score: 0, label: "today" }];
    return `
      <details class="rpca-time-machine">
        <summary>Look back at past scans</summary>
        <div class="rpca-sparkline" aria-hidden="true">
          ${safe.map((point) => `<span style="height:${Math.max(3, Number(point.score || 0))}%"></span>`).join("")}
        </div>
        <input type="range" min="0" max="${Math.max(0, safe.length - 1)}" value="${Math.max(0, safe.length - 1)}" data-action="time-machine" aria-label="Review older scan snapshots" />
        <div class="rpca-button-row">
          <button type="button" class="rpca-button" data-section="evidence">Show what changed</button>
        </div>
      </details>
    `;
  }

function renderFindingsSurface() {
  const activeView = state.findingView || "";
  const query = state.findingQuery || "";
  const findings = Array.isArray(uiState().findings) ? uiState().findings : [];
  const counts = findings.reduce(
    (acc, finding) => {
      const severity = String(finding.severity || "low").toLowerCase();
      if (severity === "critical") acc.critical += 1;
      else if (severity === "high") acc.high += 1;
      else if (severity === "medium") acc.medium += 1;
      else acc.low += 1;
      if (finding.aiLikelyFalsePositive) acc.aiLikelyOk += 1;
      if (isFindingApplied(finding)) acc.applied += 1;
      return acc;
    },
    { critical: 0, high: 0, medium: 0, low: 0, aiLikelyOk: 0, applied: 0 }
  );
  const pendingCount = findings.length - counts.applied;

  const savedView = (view, label) => `
    <button
      type="button"
      class="rpca-chip ${activeView === view ? "is-active" : ""}"
      data-view="${escapeHtml(view)}"
      aria-pressed="${activeView === view ? "true" : "false"}"
    >${escapeHtml(label)}</button>
  `;

  return `
    <section>
      <div class="rpca-cockpit-stats" style="margin-bottom:var(--space-5);">
        <article class="rpca-stat-card">
          <span class="rpca-stat-card-label">Critical</span>
          <span class="rpca-stat-card-value" style="color:var(--band-red)">${escapeHtml(counts.critical)}</span>
          <span class="rpca-stat-card-meta"><span class="rpca-stat-card-tag ${counts.critical > 0 ? "is-bad" : "is-pass"}">${counts.critical > 0 ? "Fix first" : "All clear"}</span></span>
        </article>
        <article class="rpca-stat-card">
          <span class="rpca-stat-card-label">High</span>
          <span class="rpca-stat-card-value" style="color:var(--band-orange)">${escapeHtml(counts.high)}</span>
          <span class="rpca-stat-card-meta"><span class="rpca-stat-card-tag ${counts.high > 0 ? "is-warn" : "is-pass"}">${counts.high > 0 ? "Plan a fix" : "All clear"}</span></span>
        </article>
        <article class="rpca-stat-card">
          <span class="rpca-stat-card-label">Medium</span>
          <span class="rpca-stat-card-value" style="color:var(--band-yellow)">${escapeHtml(counts.medium)}</span>
          <span class="rpca-stat-card-meta"><span class="rpca-stat-card-tag ${counts.medium > 0 ? "is-warn" : "is-pass"}">${counts.medium > 0 ? "Review" : "All clear"}</span></span>
        </article>
        <article class="rpca-stat-card">
          <span class="rpca-stat-card-label">Low</span>
          <span class="rpca-stat-card-value">${escapeHtml(counts.low)}</span>
          <span class="rpca-stat-card-meta"><span class="rpca-stat-card-tag is-pass">Routine</span></span>
        </article>
      </div>
      <div class="rpca-finding-toolbar">
        <label>
          <span class="screen-reader-text">Filter findings</span>
          <input class="rpca-filter-input" data-finding-filter type="search" value="${escapeHtml(query)}" placeholder="Search by page, word, risk level, or product name" />
        </label>
        <div class="rpca-saved-views">
          ${savedView("", "Pending " + pendingCount)}
          ${savedView("triage", "Fix first")}
          ${savedView("quick-wins", "Easy fixes")}
          ${savedView("underwriter", "Payment blockers")}
          ${savedView("context-ok", "AI likely OK " + counts.aiLikelyOk)}
          ${savedView("applied", "Applied " + counts.applied)}
          ${savedView("all", "All " + findings.length)}
        </div>
        ${findings.length ? `<button type="button" class="rpca-button is-primary" data-action="apply-all-safe-fixes" title="Apply every fix Compliance Guard can safely apply for you, in one click">Apply all safe fixes</button>` : ""}
      </div>
      <div class="rpca-finding-list" role="list" aria-live="polite">
        ${renderVisibleFindingRows()}
      </div>
    </section>
  `;
}

function renderVisibleFindingRows() {
  const findings = Array.isArray(uiState().findings) ? uiState().findings : [];
  // v0.7.297 (Codex post-review-2 fix): suppressed findings render in
  // their own collapsible section above the active list so the merchant
  // can see what their Manual Compliance Checklist silenced. Each row
  // gets the "Suppressed by your manual checklist — undo" banner that
  // renderFindingRow already emits when finding.manualOverride.suppressed.
  const suppressed = Array.isArray(uiState().suppressedFindings) ? uiState().suppressedFindings : [];
  const suppressedSection = suppressed.length
    ? `<details class="rpca-suppressed-section" style="margin:8px 0 14px;background:#fef3c7;border:1px solid #f59e0b;border-radius:8px;padding:10px 14px;">
        <summary style="cursor:pointer;font-weight:600;color:#92400e;">
          ${suppressed.length} finding${suppressed.length === 1 ? "" : "s"} suppressed by your Manual Compliance Checklist — click to review
        </summary>
        <div style="margin-top:10px;">${suppressed.map((f, i) => renderFindingRow(f, "suppressed-" + i)).join("")}</div>
      </details>`
    : "";

  if (!findings.length) {
    // v0.7.262: distinguish "no scan has run yet" from "scan ran
    // and found zero findings." The two states need different UX -
    // one is a CTA to start a first scan, the other is a celebration
    // / clean-bill-of-health card. Detected via state.lastScan.
    // finishedAt (canonical first-scan marker).
    const hasFinishedScan = !!(state.lastScan && state.lastScan.finishedAt);
    return suppressedSection + (hasFinishedScan ? renderAllClearFindingState() : renderEmptyFindingState());
  }

  const visible = visibleFindingEntries();
  if (!visible.length) {
    return suppressedSection + renderNoMatchingFindingsState();
  }

  return suppressedSection + visible.map((entry) => renderFindingRow(entry.finding, entry.index)).join("");
}

  function visibleFindingEntries() {
  const query = (state.findingQuery || "").trim().toLowerCase();
  const view = state.findingView || "";
  const findings = Array.isArray(uiState().findings) ? uiState().findings : [];
  let entries = findings.map((finding, index) => ({ finding, index }));

  // v0.7.284: filter by applied/pending. Default view (no view selected)
  // shows pending only. "applied" shows only the applied ledger.
  // "all" bypasses the filter so the merchant can see everything.
  if (view === "applied") {
    entries = entries.filter((entry) => isFindingApplied(entry.finding));
  } else if (view === "all") {
    // no filtering by applied state
  } else {
    entries = entries.filter((entry) => !isFindingApplied(entry.finding));
  }

  if (view && view !== "applied" && view !== "all") {
    entries = entries.filter((entry) => findingMatchesSavedView(entry, view));
  }

  if (query) {
    const terms = query.split(/\s+/).filter(Boolean);
    entries = entries.filter((entry) => {
      const text = findingSearchText(entry.finding);
      return terms.every((term) => text.includes(term));
    });
  }

  if (view === "triage") {
    entries.sort((a, b) => {
      const aiDelta = Number(Boolean(a.finding.aiLikelyFalsePositive)) - Number(Boolean(b.finding.aiLikelyFalsePositive));
      if (aiDelta !== 0) {
        return aiDelta;
      }
      const severityDelta = findingSeverityRank(b.finding.severity) - findingSeverityRank(a.finding.severity);
      if (severityDelta !== 0) {
        return severityDelta;
      }
      return findingConfidence(b.finding) - findingConfidence(a.finding);
    });
  }

  if (view === "quick-wins") {
    entries.sort((a, b) => {
      const aiDelta = Number(Boolean(a.finding.aiLikelyFalsePositive)) - Number(Boolean(b.finding.aiLikelyFalsePositive));
      if (aiDelta !== 0) {
        return aiDelta;
      }
      const fixDelta = Number(findingHasEasyFix(b.finding)) - Number(findingHasEasyFix(a.finding));
      if (fixDelta !== 0) {
        return fixDelta;
      }
      return findingSeverityRank(a.finding.severity) - findingSeverityRank(b.finding.severity);
    });
  }

  if (!view) {
    entries.sort((a, b) => Number(Boolean(a.finding.aiLikelyFalsePositive)) - Number(Boolean(b.finding.aiLikelyFalsePositive)));
  }

  return entries;
}

function refreshFindingsList() {
  const list = document.querySelector(".rpca-finding-list");
  if (!list) {
    return;
  }
  const visible = visibleFindingEntries();
  if (visible.length && !visible.some((entry) => entry.index === state.currentFindingIndex)) {
    state.currentFindingIndex = visible[0].index;
  }
  list.innerHTML = renderVisibleFindingRows();
}

function findingSearchText(finding) {
  return [
    finding.category,
    finding.confidence,
    finding.details,
    finding.explanation,
    finding.pageTitle,
    finding.reason,
    finding.rule,
    finding.severity,
    finding.span,
    finding.title,
    finding.url,
    finding.aiReview && finding.aiReview.decision,
    finding.aiReview && finding.aiReview.reason,
    finding.aiReview && finding.aiReview.evidence,
    finding.aiReview && finding.aiReview.recommendedAction,
    finding.fix && finding.fix.explanation,
    finding.fix && finding.fix.proposed,
    finding.fix && finding.fix.rationale,
    finding.fix && finding.fix.suggestedText,
    Array.isArray(finding.frameworks) ? finding.frameworks.join(" ") : "",
    Array.isArray(finding.matches) ? finding.matches.join(" ") : "",
    Array.isArray(finding.rules) ? finding.rules.join(" ") : "",
    Array.isArray(finding.sourceBasis) ? finding.sourceBasis.join(" ") : "",
  ]
    .filter((value) => value !== undefined && value !== null)
    .join(" ")
    .toLowerCase();
}

function findingMatchesSavedView(entry, view) {
  const finding = entry.finding || {};
  const text = findingSearchText(finding);
  const rank = findingSeverityRank(finding.severity);

  if (view === "triage") {
    return !finding.aiLikelyFalsePositive && (rank >= 3 || findingConfidence(finding) >= 0.8);
  }

  if (view === "quick-wins") {
    return findingHasEasyFix(finding) || (rank > 0 && rank <= 2 && text.includes("rewrite"));
  }

  if (view === "underwriter") {
    return [
      "acceptable use",
      "age",
      "billing descriptor",
      "cart",
      "chargeback",
      "checkout",
      "coa",
      "disclaimer",
      "footer",
      "payment",
      "policy",
      "processor",
      "refund",
      "research use only",
      "ruo",
      "shipping",
      "underwriter",
    ].some((term) => text.includes(term));
  }

  if (view === "context-ok") {
    return Boolean(finding.aiLikelyFalsePositive);
  }

  return true;
}

function findingSeverityRank(severity) {
  const normalized = String(severity || "").toLowerCase();
  if (normalized === "critical" || normalized === "red") {
    return 4;
  }
  if (normalized === "high" || normalized === "orange") {
    return 3;
  }
  if (normalized === "medium" || normalized === "yellow") {
    return 2;
  }
  if (normalized === "low" || normalized === "green") {
    return 1;
  }
  return 0;
}

function findingConfidence(finding) {
  const rawValue = finding && (finding.confidenceScore || finding.confidence || finding.score);
  const raw = Number(rawValue);
  if (Number.isFinite(raw)) {
    return raw > 1 ? raw / 100 : raw;
  }
  const label = String(rawValue || "").toLowerCase();
  if (label === "critical" || label === "high" || label === "strong") {
    return 0.86;
  }
  if (label === "medium" || label === "moderate" || label === "possible") {
    return 0.68;
  }
  if (label === "low" || label === "weak") {
    return 0.45;
  }
  return 0.68;
}

function findingHasEasyFix(finding) {
  if (!finding || !finding.fix) {
    return false;
  }
  if (!finding.fix.canGenerateText && !finding.fix.canApply && !finding.fix.canPreview) {
    return false;
  }
  return Boolean(
    finding.fix.canApply ||
      finding.fix.canPreview ||
      (finding.fix.canGenerateText &&
        (finding.fix.proposed ||
          finding.fix.suggestedText ||
          finding.fix.generatedText ||
          finding.fix.suggestedWording ||
          finding.fix.type === "disclaimer_insert"))
  );
}

  // v0.7.254: per-trigger fix-action router. Returns the {html} of
  // the primary fix button (or empty string + a guidance flag when
  // the finding type has no actionable button). Each branch picks
  // the ONE most-fitting destination for the merchant. The branches
  // are ordered most-specific to least-specific so the first match
  // wins.
  function resolvePerTriggerFixAction(finding, details, policyKey, headerKey, index) {
    const adminUrlBase = (RPCA && RPCA.adminUrl) || "/wp-admin/";
    const sectionUrls = (RPCA && RPCA.sectionAdminUrls) || {};

    // v0.7.443 (user report: "Subscription Cancellation Policy page is
    // missing" showed no Create-draft button). policyKeyForFinding()
    // only returns a key when the finding text says a policy PAGE is
    // MISSING and it maps to a real template in the PHP generator
    // (refund / cancellation / privacy / terms / shipping / contact /
    // faq / quality / business-identity / payment-security /
    // acceptable-use / ruo / return). For those, ALWAYS offer the
    // working "Publish {policy}" create-draft button FIRST — the same
    // apply-policy-fix flow the policy grid uses. Hoisted above the
    // server fixCategory routing because some missing-page findings
    // were being classified as manual_action / page_or_navigation and
    // short-circuited into a guidance-only block with no button before
    // they could reach the create-draft path below. Safe because
    // policyKeyForFinding() never returns a key for editable-wording,
    // header, HTTPS, or operational-record findings.
    if (policyKey) {
      return {
        type: "create-policy",
        html: `<button type="button" class="rpca-button is-primary" data-action="apply-policy-fix" data-policy-key="${escapeHtml(policyKey)}" data-index="${index}">
          <span class="rpca-button-label">Apply fix: Publish ${escapeHtml(policyLabelForKey(policyKey))}</span>
          <span class="rpca-spinner" data-role="policy-spinner" hidden aria-hidden="true"></span>
        </button>`,
      };
    }

    // v0.7.291: SERVER-CLASSIFIED FIX CATEGORY takes precedence. The
    // PHP `classify_finding_fix_category()` already decided this is
    // (e.g.) an HTTPS or security-header problem, NOT a text-edit
    // problem. Never offer "Generate safer text" on those.
    const fixCategory = String(finding && finding.fixCategory || "").toLowerCase();
    if (fixCategory === "change_https") {
      return {
        type: "guidance-only",
        html: `<div class="rpca-finding-guidance-inline" data-rpca-fix-type="change_https" style="display:flex;flex-direction:column;gap:6px;">
          <strong>This is not editable text.</strong>
          <span>Some pages on your site load over plain HTTP instead of HTTPS. This is fixed by your host or CDN, not by editing page wording.</span>
          <span>Common fixes: turn on "Force HTTPS" in your host panel (SiteGround, Cloudways, WP Engine, etc.), enable Cloudflare's "Always Use HTTPS" page rule, install a free Let's Encrypt SSL certificate, or add a Really Simple SSL plugin. Then re-run the scan.</span>
        </div>`,
      };
    }
    if (fixCategory === "change_header" && !headerKey) {
      return {
        type: "guidance-only",
        html: `<div class="rpca-finding-guidance-inline" data-rpca-fix-type="change_header" style="display:flex;flex-direction:column;gap:6px;">
          <strong>This is not editable text.</strong>
          <span>This is a server-response-header problem (HSTS / CSP / X-Frame-Options / Referrer-Policy / etc.). It is fixed by your host, your CDN, or Compliance Guard's built-in header writer — not by editing page wording.</span>
          <span>Open Setup &rarr; "Apply security headers" or ask your host to add the header.</span>
        </div>`,
      };
    }
    if (fixCategory === "delete_page") {
      const editUrlRaw = finding && finding.postId ? (adminUrlBase + "post.php?action=edit&post=" + Number(finding.postId)) : "";
      return {
        // v0.7.374 (user report): merchant said "this is both the page
        // AND its wording, make sure the bot is aware of both". The
        // previous framing told merchants the only option was to delete
        // the page, which ignored the fact that the wording inside the
        // page is also editable. New framing acknowledges BOTH: the
        // page-level structure is the strongest violation signal, AND
        // the wording can also be rewritten. Offers both paths.
        type: "guidance-only",
        html: `<div class="rpca-finding-guidance-inline" data-rpca-fix-type="delete_page" style="display:flex;flex-direction:column;gap:8px;">
          <strong>This page's structure AND its wording are both flagged.</strong>
          <span>The page format (testimonials / before-and-after photos / "real customer" stories / ad-platform landing page with quantified outcomes) is the strongest FDA / FTC risk signal. The wording inside the page is also flagged and editable. Your options:</span>
          <ul style="margin:4px 0 4px 18px;padding:0;">
            <li><strong>Best fix:</strong> Replace the page entirely with a research-record FAQ / lot documentation page — the FORMAT itself is the risk.</li>
            <li><strong>Lower-risk fix:</strong> Edit the page wording to remove the dosing schedules, testimonials, brand comparisons, and outcome claims — keep the page but strip the violation language.</li>
            <li><strong>Soft fix:</strong> Move the page to draft / unpublish while you decide.</li>
          </ul>
          ${editUrlRaw ? `<a href="${safeHref(editUrlRaw)}" target="_blank" rel="noopener noreferrer" class="rpca-button" style="align-self:flex-start;">Open this page in WordPress to edit wording or replace</a>` : ""}
        </div>`,
      };
    }
    if (fixCategory === "manual_action") {
      return {
        type: "guidance-only",
        html: `<span class="rpca-finding-guidance-inline" data-rpca-fix-type="manual_action">${escapeHtml((details && details.guidance) || "This is an internal record or manual action — not a website-text fix. Document it outside the page copy.")}</span>`,
      };
    }
    if (fixCategory === "nothing_actionable") {
      return {
        type: "guidance-only",
        html: `<span class="rpca-finding-guidance-inline" data-rpca-fix-type="informational">${escapeHtml((details && details.guidance) || "Informational scan note. No action required.")}</span>`,
      };
    }
    // v0.7.292: file-editor categories (theme / plugin / mu-plugin).
    // Honor DISALLOW_FILE_EDIT — if file editing is off, fall back to
    // SFTP guidance with a source-relative path (never an absolute one).
    const fileEditingAllowed = !!(window.RPCA && RPCA.fileEditingAllowed);
    const pluginEditingAllowed = !!(window.RPCA && RPCA.pluginEditingAllowed);
    if (fixCategory === "theme_file_edit") {
      const themePath = String((finding && (finding.themeFile || finding.sourcePath)) || "");
      if (!themePath) {
        return { type: "guidance-only", html: `<span class="rpca-finding-guidance-inline" data-rpca-fix-type="theme_file_edit">Open the active theme's templates and search for the flagged phrase.</span>` };
      }
      if (!fileEditingAllowed) {
        return {
          type: "guidance-only",
          html: `<div class="rpca-finding-guidance-inline" data-rpca-fix-type="theme_file_edit" style="display:flex;flex-direction:column;gap:6px;">
            <strong>This text is hardcoded in your theme.</strong>
            <span>The WordPress Theme File Editor is disabled on this site (DISALLOW_FILE_EDIT or insufficient role). Open <code>wp-content/themes/${escapeHtml(String(finding.themeSlug || finding.sourceLabel || ''))}/${escapeHtml(themePath)}</code> via SFTP / your hosting file manager and remove the flagged wording.</span>
          </div>`,
        };
      }
      const themeEditorUrlRaw = adminUrlBase + "theme-editor.php?file=" + encodeURIComponent(themePath);
      // v0.7.296 (Item #5 — preview-only): show two buttons. "Preview Gemini
      // patch" opens the diff modal but never writes. "Open theme file"
      // is the existing deep-link the merchant uses to paste-apply.
      return {
        type: "navigate",
        html: `<div class="rpca-button-row" style="display:flex;gap:8px;flex-wrap:wrap;">
          <button type="button" class="rpca-button is-primary" data-action="preview-file-patch" data-finding-id="${escapeHtml((finding && finding.id) || "")}" data-index="${index}"><span class="rpca-button-label">Preview Gemini patch</span></button>
          <a class="rpca-button is-ghost" href="${safeHref(themeEditorUrlRaw)}" target="_blank" rel="noopener noreferrer" data-action="open-theme-editor" data-index="${index}"><span class="rpca-button-label">Open theme file: ${escapeHtml(themePath.split("/").pop())}</span></a>
        </div>`,
      };
    }
    if (fixCategory === "plugin_file_edit") {
      // v0.7.292: sourcePath is ALREADY relative to wp-content/plugins/
      // (i.e. it includes the plugin directory prefix, e.g.
      // "some-popup/templates/header.php"). Do NOT prepend pluginLabel —
      // doing so would produce "some-popup/some-popup/templates/...".
      // mainPluginFile is the entry file WordPress activated, formatted
      // as "<plugin-dir>/<entry>.php" or just "<entry>.php" for single-
      // file plugins. The plugin-editor URL needs both the file= target
      // AND the plugin= entry-file param.
      const pluginRel = String((finding && finding.sourcePath) || "");
      const mainPlugin = String((finding && finding.mainPluginFile) || "");
      if (!pluginRel || !mainPlugin) {
        return { type: "guidance-only", html: `<span class="rpca-finding-guidance-inline" data-rpca-fix-type="plugin_file_edit">Open the active plugin and search for the flagged phrase. We don't have a deep-link target for this file.</span>` };
      }
      if (!pluginEditingAllowed) {
        return {
          type: "guidance-only",
          html: `<div class="rpca-finding-guidance-inline" data-rpca-fix-type="plugin_file_edit" style="display:flex;flex-direction:column;gap:6px;">
            <strong>This text is hardcoded in a plugin.</strong>
            <span>The WordPress Plugin File Editor is disabled on this site (DISALLOW_FILE_EDIT or insufficient role). Open <code>wp-content/plugins/${escapeHtml(pluginRel)}</code> via SFTP / your hosting file manager and remove the flagged wording. Updating the plugin will overwrite your edit, so consider opening a support ticket with the plugin author too.</span>
          </div>`,
        };
      }
      const pluginEditorUrlRaw = adminUrlBase + "plugin-editor.php?file=" + encodeURIComponent(pluginRel) + "&plugin=" + encodeURIComponent(mainPlugin);
      // v0.7.296 (Item #5 — preview-only): "Preview Gemini patch" + the
      // existing "Open plugin file" deep-link. No destructive apply path.
      return {
        type: "navigate",
        html: `<div class="rpca-button-row" style="display:flex;gap:8px;flex-wrap:wrap;">
          <button type="button" class="rpca-button is-primary" data-action="preview-file-patch" data-finding-id="${escapeHtml((finding && finding.id) || "")}" data-index="${index}"><span class="rpca-button-label">Preview Gemini patch</span></button>
          <a class="rpca-button is-ghost" href="${safeHref(pluginEditorUrlRaw)}" target="_blank" rel="noopener noreferrer" data-action="open-plugin-editor" data-index="${index}"><span class="rpca-button-label">Open plugin file: ${escapeHtml(pluginRel.split("/").pop())}</span></a>
        </div>`,
      };
    }
    if (fixCategory === "mu_plugin_file_edit") {
      const muRel = String((finding && finding.sourcePath) || "");
      return {
        type: "guidance-only",
        html: `<div class="rpca-finding-guidance-inline" data-rpca-fix-type="mu_plugin_file_edit" style="display:flex;flex-direction:column;gap:6px;">
          <strong>This text is hardcoded in a must-use plugin.</strong>
          <span>WordPress does not provide a GUI editor for must-use plugins (they live outside the standard plugin directory and run on every request without activation). Open <code>wp-content/mu-plugins/${escapeHtml(muRel)}</code> via SFTP / your hosting file manager and remove the flagged wording. If you don't recognize this file, it may have been added by your host — ask their support before editing.</span>
        </div>`,
      };
    }

    // (1) Buyer / researcher verification → dedicated card.
    if (findingNeedsBuyerAcknowledgmentSetup(finding)) {
      const policiesUrl = sectionUrls.policies || (adminUrlBase + "admin.php?page=ruo-cg-policies");
      return {
        type: "navigate",
        html: `<a class="rpca-button is-primary" href="${safeHref(policiesUrl + "#buyer-ack")}" data-action="open-buyer-ack-setup" data-index="${index}">
          <span class="rpca-button-label">Apply fix: Turn on buyer acknowledgment at checkout</span>
        </a>`,
      };
    }

    // (2) Security headers → existing dedicated apply-fix endpoint.
    if (headerKey) {
      // v0.7.285: include data-finding-id so v0.7.284's markFindingApplied
      // ledger can move this row into the Applied list once headers are on.
      return {
        type: "apply-header",
        html: `<button type="button" class="rpca-button is-primary" data-action="apply-security-headers" data-header-key="${escapeHtml(headerKey)}" data-finding-id="${escapeHtml((finding && finding.id) || "")}" data-index="${index}">
          <span class="rpca-button-label">Apply fix: Turn on ${escapeHtml(securityHeaderLabelForKey(headerKey))}</span>
          <span class="rpca-spinner" data-role="header-spinner" hidden aria-hidden="true"></span>
        </button>`,
      };
    }

    // (3) Missing sitewide disclaimer → Compliance Generator banner card.
    const findingBlob = `${finding.rule || ""} ${finding.reason || ""} ${(finding.citation && finding.citation.id) || ""}`.toLowerCase();
    if (
      /sitewide.*disclaimer|sitewide.*banner|footer.*disclaimer|missing.*disclaimer.*every\s+page|disclaimer.*not\s+visible/.test(findingBlob)
    ) {
      const policiesUrl = sectionUrls.policies || (adminUrlBase + "admin.php?page=ruo-cg-policies");
      return {
        type: "navigate",
        html: `<a class="rpca-button is-primary" href="${safeHref(policiesUrl + "#disclaimer-banner")}" data-action="open-disclaimer-banner-setup" data-index="${index}">
          <span class="rpca-button-label">Apply fix: Configure sitewide disclaimer banner</span>
        </a>`,
      };
    }

    // (4) Missing age gate → dedicated age-gate setup page.
    if (/age\s+gate|age\s+verification|age[- ]?check|underage/.test(findingBlob)) {
      const ageGateUrl = sectionUrls.age_gate || (adminUrlBase + "admin.php?page=ruo-cg-age-gate");
      return {
        type: "navigate",
        html: `<a class="rpca-button is-primary" href="${safeHref(ageGateUrl)}" data-action="open-age-gate-setup" data-index="${index}">
          <span class="rpca-button-label">Apply fix: Open age gate setup</span>
        </a>`,
      };
    }

    // (5) Policy-page missing → publish a fresh page + auto-add to
    // Compliance Policies footer menu (v0.7.257). Used to read
    // "Create draft" but that left an unpublished page that the
    // scanner couldn't see, so the same finding kept reappearing.
    if (policyKey) {
      return {
        type: "create-policy",
        html: `<button type="button" class="rpca-button is-primary" data-action="apply-policy-fix" data-policy-key="${escapeHtml(policyKey)}" data-index="${index}">
          <span class="rpca-button-label">Apply fix: Publish ${escapeHtml(policyLabelForKey(policyKey))}</span>
          <span class="rpca-spinner" data-role="policy-spinner" hidden aria-hidden="true"></span>
        </button>`,
      };
    }

    // (6) COA / lab-documentation missing → Compliance Generator
    // (the COA cards live there alongside the policy templates).
    if (/coa|certificate\s+of\s+analysis|lab[- ]?report|sds|datasheet/.test(findingBlob) && /missing|need|substantiat|visible/.test(findingBlob)) {
      const policiesUrl = sectionUrls.policies || (adminUrlBase + "admin.php?page=ruo-cg-policies");
      return {
        type: "navigate",
        html: `<a class="rpca-button is-primary" href="${safeHref(policiesUrl)}" data-action="open-coa-generator" data-index="${index}">
          <span class="rpca-button-label">Apply fix: Open Compliance Generator (COA)</span>
        </a>`,
      };
    }

    // (7) Operational records / supplier-audit / quality-record /
    // recall / CAPA - these are NOT website-text fixes. They are
    // internal records the merchant has to keep. Show guidance
    // inline; do NOT show a misleading "Generate safer text" button.
    if (details.type === "operational_record") {
      return {
        type: "guidance-only",
        html: `<span class="rpca-finding-guidance-inline" data-rpca-fix-type="operational_record">${escapeHtml(details.guidance || details.label || "Add or document the operational record outside normal page copy: owner, process, review log, supporting files.")}</span>`,
      };
    }

    // (8) Server / technical headers caught here too (some findings
    // mention security headers but don't carry a canonical headerKey).
    if (details.type === "technical_header") {
      return {
        type: "guidance-only",
        html: `<span class="rpca-finding-guidance-inline" data-rpca-fix-type="technical_header">${escapeHtml(details.guidance || "Ask your host or developer to add this header.")}</span>`,
      };
    }

    // (9) Affiliate / ad / claims-control policy - guidance, no text rewrite.
    if (details.type === "claims_control_policy") {
      return {
        type: "guidance-only",
        html: `<span class="rpca-finding-guidance-inline" data-rpca-fix-type="claims_control_policy">${escapeHtml(details.guidance || details.label || "Publish or link a claims-control policy.")}</span>`,
      };
    }

    // (10) Informational scan notes - never show a fix button.
    if (details.type === "informational" || isInformationalFinding(finding)) {
      return {
        type: "guidance-only",
        html: `<span class="rpca-finding-guidance-inline" data-rpca-fix-type="informational">${escapeHtml(details.guidance || "Informational scan note. No website text to rewrite.")}</span>`,
      };
    }

    // (11) Page or navigation gaps that don't map to a known policy
    // key - point to the Pages section so the merchant can see which
    // pages were checked and where the gap is.
    if (details.type === "page_or_navigation") {
      const pagesUrl = sectionUrls.pages || (adminUrlBase + "admin.php?page=ruo-cg-pages");
      return {
        type: "navigate",
        html: `<a class="rpca-button is-primary" href="${safeHref(pagesUrl)}" data-action="open-pages" data-index="${index}">
          <span class="rpca-button-label">Apply fix: Open Pages checked</span>
        </a>`,
      };
    }

    // (12) Settings or workflow tasks (geo, post-purchase, etc.)
    // route to the Setup page.
    if (details.type === "settings_or_workflow") {
      const setupUrl = sectionUrls.settings || (adminUrlBase + "admin.php?page=ruo-cg-setup");
      return {
        type: "navigate",
        html: `<a class="rpca-button is-primary" href="${safeHref(setupUrl)}" data-action="open-setup" data-index="${index}">
          <span class="rpca-button-label">Apply fix: Open Setup</span>
        </a>`,
      };
    }

    // (13) Evidence / documentation findings → open the page editor so
    // the merchant can add a COA / lot / purity reference inline. The
    // Proof archive page is for organization-wide evidence storage but
    // the actual fix for this trigger is on the specific page that
    // fired it. v0.7.374 (user report): the Proof archive button was
    // doing nothing useful — replaced with a direct page-edit link
    // when a postId is available, falling back to the Proof archive
    // section only when no specific page is associated.
    if (details.type === "evidence_document") {
      const editUrlRaw = finding && finding.postId ? (adminUrlBase + "post.php?action=edit&post=" + Number(finding.postId)) : "";
      if (editUrlRaw) {
        return {
          type: "navigate",
          html: `<div class="rpca-finding-guidance-inline" data-rpca-fix-type="evidence_document" style="display:flex;flex-direction:column;gap:8px;">
            <span><strong>Add visible proof to this page</strong> — link a Certificate of Analysis (COA), show a lot/batch number, and state HPLC purity inline. Example: <em>"View Certificate of Analysis (Lot A2451) — 99.4% purity by HPLC"</em>. This substantiates the facility/legality/quality claim that triggered the finding.</span>
            <a class="rpca-button is-primary" href="${safeHref(editUrlRaw)}" target="_blank" rel="noopener noreferrer" data-action="open-page-editor" data-index="${index}">
              <span class="rpca-button-label">Open page in WordPress to add COA reference</span>
            </a>
          </div>`,
        };
      }
      const evidenceUrl = sectionUrls.evidence || (adminUrlBase + "admin.php?page=ruo-cg-evidence");
      return {
        type: "navigate",
        html: `<a class="rpca-button is-primary" href="${safeHref(evidenceUrl)}" data-action="open-evidence" data-index="${index}">
          <span class="rpca-button-label">Apply fix: Open Proof archive</span>
        </a>`,
      };
    }

    // (14) Default: this IS an editable-text finding (text_rewrite,
    // disclaimer_insert). Show "Generate safer text" because that
    // matches the problem type.
    if (details.canGenerateText) {
      return {
        type: "generate-text",
        html: `<button type="button" class="rpca-button is-primary" data-action="${escapeHtml(details.action || "inline-rewrite")}" data-index="${index}">${escapeHtml(details.actionLabel)}</button>`,
      };
    }

    // (15) Last resort: show whatever the existing details object
    // suggested but as a less-prominent button so the merchant
    // doesn't think it's the wrong-type fix.
    return {
      type: "fallback",
      html: `<button type="button" class="rpca-button" data-action="${escapeHtml(details.action || "inline-rewrite")}" data-index="${index}">${escapeHtml(details.actionLabel || "Show fix steps")}</button>`,
    };
  }

  // v0.7.253: detect findings that should route to the buyer-
  // acknowledgment setup card on the Compliance Generator page rather
  // than the create-draft policy path. Matches by structural rule
  // id (preferred) AND by rule label keywords (defensive fallback
  // for findings that don't carry the canonical citation id).
  function findingNeedsBuyerAcknowledgmentSetup(finding) {
    if (!finding) return false;
    const citationId = String((finding.citation && finding.citation.id) || "").toLowerCase();
    if (citationId === "structural.buyer_verification_safeguards") return true;
    const text = `${finding.rule || ""} ${finding.reason || ""}`.toLowerCase();
    if (/buyer\s+or\s+researcher\s+verification\s+safeguards/.test(text)) return true;
    if (/researcher\s+qualification\s+safeguards/.test(text)) return true;
    return false;
  }

  function policyKeyForFinding(finding) {
    if (!finding) return null;
    const text = `${finding.rule || ""} ${finding.reason || ""} ${(finding.citation && finding.citation.id) || ""} ${finding.category || ""}`.toLowerCase();
    // Refund/return/cancellation often share a single rule label.
    const isCombinedPolicy = /refund.*return.*cancellation|refund,?\s*return,?\s*(or|and)?\s*cancellation/i.test(text);
    const isMissing = /not\s+found|missing|lacks|absent|no\s+(policy|page|link)|could\s+not\s+find|need[s]?\s+substantiation/.test(text);
    if (!isMissing) return null;
    if (isCombinedPolicy) return "refund";
    if (text.indexOf("refund") !== -1) return "refund";
    if (text.indexOf("return policy") !== -1 || text.indexOf("returns policy") !== -1 || /\breturn\b.*\b(policy|page)\b/.test(text)) return "return";
    if (text.indexOf("cancellation") !== -1 || text.indexOf("cancel policy") !== -1) return "cancellation";
    if (text.indexOf("shipping") !== -1) return "shipping";
    if (text.indexOf("privacy") !== -1) return "privacy";
    if (text.indexOf("terms of service") !== -1 || text.indexOf("terms and conditions") !== -1 || /\bterms\b/.test(text)) return "terms";
    if (text.indexOf("acceptable use") !== -1) return "acceptable-use";
    if (text.indexOf("payment security") !== -1 || text.indexOf("card-data") !== -1 || text.indexOf("third-party card") !== -1 || (text.indexOf("payment") !== -1 && text.indexOf("responsibility") !== -1)) return "payment-security";
    if (text.indexOf("legal entity") !== -1 || text.indexOf("dba") !== -1 || text.indexOf("merchant identity") !== -1 || text.indexOf("doing business as") !== -1) return "business-identity";
    if (text.indexOf("manufacturing") !== -1 || text.indexOf("supplier") !== -1 || text.indexOf("quality claim") !== -1 || text.indexOf("substantiation") !== -1) return "quality";
    if (text.indexOf("educational content") !== -1 || text.indexOf("faq") !== -1 || text.indexOf("guide") !== -1 || text.indexOf("calculator") !== -1) return "faq";
    if (text.indexOf("research-use-only") !== -1 || text.indexOf("research use only") !== -1 || text.indexOf("ruo hub") !== -1 || text.indexOf("ruo policy") !== -1 || text.indexOf("ruo page") !== -1) return "ruo";
    if (text.indexOf("contact path") !== -1 || text.indexOf("contact page") !== -1 || /\bcontact\b/.test(text)) return "contact";
    return null;
  }

  function policyLabelForKey(key) {
    return {
      refund: "refund policy",
      return: "return policy",
      cancellation: "cancellation policy",
      shipping: "shipping policy",
      privacy: "privacy policy",
      terms: "terms of service",
      "acceptable-use": "acceptable use policy",
      ruo: "research-use-only page",
      contact: "contact page",
      "payment-security": "payment security page",
      "business-identity": "business information page",
      quality: "quality and documentation page",
      faq: "research-use FAQ page",
    }[key] || "policy page";
  }

  function securityHeaderForFinding(finding) {
    if (!finding) return null;
    const text = `${finding.rule || ""} ${finding.reason || ""} ${(finding.citation && finding.citation.id) || ""}`.toLowerCase();
    if (text.indexOf("hsts") !== -1 || text.indexOf("strict-transport-security") !== -1) return "hsts";
    if (text.indexOf("referrer-policy") !== -1 || text.indexOf("referrer policy") !== -1) return "referrer";
    if (text.indexOf("clickjacking") !== -1 || text.indexOf("x-frame-options") !== -1 || text.indexOf("frame-ancestors") !== -1) return "frame-options";
    if (text.indexOf("x-content-type-options") !== -1 || text.indexOf("content-type-options") !== -1 || text.indexOf("nosniff") !== -1) return "content-type-options";
    return null;
  }

  function securityHeaderLabelForKey(key) {
    return {
      hsts: "secure-connection enforcement",
      referrer: "browser-privacy setting",
      "frame-options": "clickjacking protection",
      "content-type-options": "file-type sniffing protection",
    }[key] || "site security settings";
  }

  function renderFindingRow(finding, index) {
    const severity = String(finding.severity || "low").toLowerCase();
    const band = severityBand(severity);
    const confidence = findingConfidence(finding);
    const path = finding.url
      ? (function () {
          try {
            return new URL(finding.url, window.location.origin).pathname || "/";
          } catch (error) {
            return finding.path || "/";
          }
        })()
      : finding.path || "/";
    const span = firstUsefulFindingText(finding) || finding.rule || "Flagged wording";
    const details = fixDetailsForFinding(finding);
    const severityLabel = severityText(severity);
    const policyKey = policyKeyForFinding(finding);
    const headerKey = !policyKey ? securityHeaderForFinding(finding) : null;
    // v0.7.254: per-trigger fix routing. Each finding gets the ONE
    // action that fits its problem type - no universal "Generate
    // safer text" fallback for findings whose root cause is not
    // editable text (security headers, missing pages, missing
    // settings, operational records, etc.). The router below picks
    // the most-fitting destination per rule. If a finding's only
    // useful action is "go read a guide," we render the guidance
    // text inline (no misleading button) and skip the primary
    // button entirely.
    const fixAction = resolvePerTriggerFixAction(finding, details, policyKey, headerKey, index);
    const primaryFixButton = fixAction.html;
    // v0.7.284: applied-fix badge + "Mark as pending" undo control. The
    // ledger is purely client-side; the next scan re-evaluates the
    // underlying page content from scratch.
    const applied = isFindingApplied(finding);
    const appliedBadge = applied
      ? `<span class="rpca-severity-tag" style="background:#dcfce7;color:#14532d;border:1px solid #86efac;">Applied</span>`
      : "";
    const undoButton = applied
      ? `<button type="button" class="rpca-button is-text" data-action="unmark-applied" data-finding-id="${escapeHtml(finding.id || "")}">Move back to pending</button>`
      : "";
    // v0.7.297 (Item #7): suppressed banner. When the merchant's manual
    // checklist marked this surface as covered, the server now ships the
    // finding in suppressedFindings + tags it with manualOverride. Show
    // an amber banner with a one-click "Undo suppression" link.
    const override = finding && finding.manualOverride;
    const suppressedBanner = override && override.suppressed
      ? `<div class="rpca-finding-suppressed-banner" style="background:#fef3c7;border:1px solid #f59e0b;color:#92400e;border-radius:6px;padding:8px 10px;margin:6px 0;font-size:13px;display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;">
          <span><strong>Suppressed by your Manual Compliance Checklist</strong> — surface "${escapeHtml(override.surfaceLabel || override.surface || "")}" is marked covered.${override.note ? ' Your note: "' + escapeHtml(override.note) + '"' : ""}</span>
          <a href="${safeHref((window.RPCA && RPCA.adminUrl || "/wp-admin/") + "admin.php?page=ruo-cg-manual-checklist")}" class="rpca-button is-text" style="font-size:12px;padding:2px 8px;">Open checklist to undo</a>
        </div>`
      : "";
    // v0.7.297 (Item #8): "Why was this flagged?" inline expander
    // surfacing Gemini's plain-English reasoning (was previously buried
    // in a tooltip). Toggled by data-action="toggle-ai-reason"; rendered
    // collapsed by default to keep the row compact.
    // v0.7.300: only surface the reason when the gate actually returned a
    // real verdict. An "unverified" decision means the gate did NOT review
    // this finding (Gemini call failed, quota exhausted, hit its time
    // budget, or AI is off) — its `reason` is internal plumbing such as
    // "Gemini returned non-JSON; candidate passed through.", never
    // Gemini's compliance reasoning, so it must not render under a
    // "Why Gemini flagged this" heading.
    const review = finding && finding.aiReview ? finding.aiReview : null;
    const reviewDecision = review && review.decision ? String(review.decision).toLowerCase() : "";
    const aiReasonText = review && reviewDecision && reviewDecision !== "unverified" && review.reason
      ? String(review.reason).trim()
      : "";
    const aiReasonBlock = aiReasonText
      ? `<div class="rpca-finding-ai-reason" data-rpca-ai-reason="1" hidden style="background:#eef2ff;border:1px solid #c7d2fe;color:#1e1b4b;border-radius:6px;padding:10px;margin:6px 0;font-size:13px;line-height:1.45;">
          <strong style="display:block;margin-bottom:4px;color:#312e81;">Why Gemini flagged this</strong>
          ${escapeHtml(aiReasonText)}
          ${review.gatedAt ? `<div style="font-size:11px;color:#6366f1;margin-top:6px;">Reviewed at ${escapeHtml(String(review.gatedAt))}${review.source ? " &middot; " + escapeHtml(String(review.source)) : ""}</div>` : ""}
        </div>`
      : "";
    const aiReasonToggle = aiReasonText
      ? `<button type="button" class="rpca-button is-text" data-action="toggle-ai-reason" data-index="${index}">Why was this flagged?</button>`
      : "";
    return `
      <article class="rpca-finding-row ${index === state.currentFindingIndex ? "is-current" : ""} ${finding.aiLikelyFalsePositive ? "is-ai-softened" : ""} ${finding.coverageSensitive ? "is-coverage-sensitive" : ""} ${finding.userFeedbackSensitive ? "is-feedback-sensitive" : ""} ${applied ? "is-applied" : ""} ${override && override.suppressed ? "is-suppressed" : ""}" data-finding-index="${index}" data-band="${escapeHtml(band)}" role="listitem">
        ${suppressedBanner}
        <div class="rpca-finding-main">
          <div>
            <div class="rpca-finding-headline">
              <span class="rpca-band-dot is-${band}" aria-hidden="true"></span>
              <div>
                <h3 class="rpca-finding-title">
                  <span class="rpca-severity-tag is-${escapeHtml(severity)}">${escapeHtml(severityLabel)}</span>
                  ${appliedBadge}
                  ${escapeHtml(finding.rule || finding.title || "Thing to fix")}
                </h3>
                <div class="rpca-path">${escapeHtml(path)}</div>
                <span class="rpca-flagged-span">"${highlightTriggers(span, triggerPhrasesForFinding(finding))}"</span>
                ${renderConsolidatedRulesBadge(finding)}
                ${renderFindingCitation(finding)}
                ${renderAiReviewBadge(finding)}
                ${renderCoverageReviewBadge(finding)}
                ${renderUserFeedbackReviewBadge(finding)}
              </div>
            </div>
          </div>
          ${renderConfidenceBar(confidence)}
        </div>
        ${aiReasonBlock}
        <div class="rpca-finding-actions">
          <button type="button" class="rpca-button is-text" data-action="toggle-why" data-index="${index}">Why this matters</button>
          ${aiReasonToggle}
          ${primaryFixButton}
          ${undoButton}
          <button type="button" class="rpca-button is-text" data-action="dismiss-finding" data-index="${index}">Hide for now</button>
        </div>
        <div class="rpca-finding-detail" hidden></div>
      </article>
    `;
  }

  function renderConfidenceBar(confidence) {
    const numeric = Number(confidence);
    const fallback = Number.isFinite(numeric) ? numeric : 0.68;
    const safe = Math.max(0, Math.min(1, fallback > 1 ? fallback / 100 : fallback));
    return `
      <div class="rpca-confidence">
        <div class="rpca-confidence-label">match strength ${Math.round(safe * 100)}%</div>
        <div class="rpca-confidence-track" aria-hidden="true"><span class="rpca-confidence-tick" style="left:${safe * 100}%"></span></div>
        <div class="rpca-confidence-zones"><span>weak</span><span>possible</span><span>strong</span></div>
        ${safe < 0.7 ? `<small>The AI checked this more than once because the match was not obvious. You can see the record in the log.</small>` : ""}
      </div>
    `;
  }

  function renderWhyPanel(finding) {
    const details = fixDetailsForFinding(finding);
    const flaggedText = originalTextForFinding(finding);
    const triggerItems = triggerEvidenceItems(finding, 4);
    const contextWindows = Array.isArray(finding && finding.contextWindows)
      ? finding.contextWindows
          .map((item) => String(item || "").trim())
          .filter(Boolean)
          .slice(0, 3)
      : [];
    const optionalDisclaimer = finding && finding.fix && finding.fix.optionalDisclaimer
      ? String(finding.fix.optionalDisclaimer).trim()
      : "";
    const aiReview = finding && finding.aiReview ? finding.aiReview : null;
    const coverageReview = finding && finding.coverageReview ? finding.coverageReview : null;
    const userFeedbackReview = finding && finding.userFeedbackReview ? finding.userFeedbackReview : null;
    const triggers = triggerPhrasesForFinding(finding);
    return `
      <section class="rpca-why-panel">
        <h4>Why this matters</h4>
        <p>${escapeHtml(humanizeWhyItMatters(finding))}</p>
        ${triggers.length ? `<h4>What tripped this</h4><p class="rpca-trigger-list">${triggers.map((t) => `<mark class="rpca-trigger-mark">${escapeHtml(t)}</mark>`).join(" ")}</p>` : ""}
        ${flaggedText ? `<h4>Where it shows up on your page</h4><p>${highlightTriggers(flaggedText, triggers)}</p>` : ""}
        ${
          triggerItems.length > 1
            ? `<h4>What the scanner matched</h4><ul>${triggerItems.map((item) => `<li>${highlightTriggers(item, triggers)}</li>`).join("")}</ul>`
            : ""
        }
        ${
          contextWindows.length
            ? `<h4>Sentence context checked</h4><ul>${contextWindows.map((item) => `<li>${highlightTriggers(item, triggers)}</li>`).join("")}</ul><p>${escapeHtml(humanizeContextAssessment(finding.contextAssessment))}</p>`
            : ""
        }
        ${optionalDisclaimer ? `<h4>Optional recommended disclaimer</h4><p>${escapeHtml(optionalDisclaimer)}</p>` : ""}
        ${
          aiReview
            ? (String(aiReview.decision || "").toLowerCase() === "unverified"
                ? `<h4>AI context check</h4><p><strong>Not reviewed by Gemini.</strong> The AI gate did not review this finding — the AI call did not complete, the quota was reached, the scan hit its time budget, or AI is off. It is shown from the local scanner only. Re-run the audit to have Gemini review it.</p>`
                : `<h4>AI context check</h4><p><strong>${escapeHtml(aiReviewLabel(aiReview.decision))}${aiReview.confidence ? ` (${Math.round(Number(aiReview.confidence) * 100)}%)` : ""}:</strong> ${escapeHtml(aiReview.reason || "Gemini reviewed this finding against the surrounding sentence and crawl evidence.")}</p>${aiReview.evidence ? `<p>${escapeHtml(aiReview.evidence)}</p>` : ""}${aiReview.recommendedAction ? `<p>${escapeHtml(aiReview.recommendedAction)}</p>` : ""}`)
            : ""
        }
        ${
          coverageReview
            ? `<h4>Crawl coverage check</h4><p><strong>${escapeHtml(coverageReviewLabel(coverageReview.decision))}:</strong> ${escapeHtml(coverageReview.reason || "The crawler coverage diagnostic reviewed this missing-evidence finding.")}</p>${coverageReview.category ? `<p>Evidence category: ${escapeHtml(coverageReview.category)}</p>` : ""}${coverageReview.recommendedAction ? `<p>${escapeHtml(coverageReview.recommendedAction)}</p>` : ""}`
            : ""
        }
        ${
          userFeedbackReview
            ? `<h4>Merchant feedback check</h4><p><strong>${escapeHtml(userFeedbackReviewLabel(userFeedbackReview.decision))}:</strong> ${escapeHtml(userFeedbackReview.reason || "Similar findings were previously marked not a problem.")}</p><p>Past false-positive dismissals: ${escapeHtml(userFeedbackReview.falsePositiveCount || 0)}. Confirmed problem marks: ${escapeHtml(userFeedbackReview.confirmedCount || 0)}.</p>${userFeedbackReview.recommendedAction ? `<p>${escapeHtml(userFeedbackReview.recommendedAction)}</p>` : ""}`
            : ""
        }
        <h4>Reference behind this check</h4>
        <p>${escapeHtml(citationPlainExplanation(finding))}</p>
        <h4>What you can do</h4>
        <div class="rpca-button-row">
          <button type="button" class="rpca-button is-primary" data-action="${escapeHtml(details.action || "inline-rewrite")}" data-index="${state.currentFindingIndex}">${escapeHtml(details.actionLabel)}</button>
          <button type="button" class="rpca-button is-text" data-action="dismiss-finding" data-index="${state.currentFindingIndex}">This is not a problem</button>
        </div>
      </section>
    `;
  }

  function isInformationalFinding(finding) {
    if (!finding) return false;
    const citationType = String((finding.citation && finding.citation.type) || "").toLowerCase();
    if (citationType === "scope_guard") return true;
    const ruleIds = []
      .concat(Array.isArray(finding.rulesFired) ? finding.rulesFired : [])
      .concat(finding.citation && finding.citation.id ? [finding.citation.id] : []);
    for (const id of ruleIds) {
      const lower = String(id || "").toLowerCase();
      if (lower.startsWith("scope.") || lower.startsWith("crawl.") || lower.startsWith("informational.")) {
        return true;
      }
    }
    const riskLevel = String(finding.riskLevel || "").toLowerCase();
    if (riskLevel === "not_applicable") return true;
    return false;
  }

  function fixDetailsForFinding(finding) {
    const fix = finding && finding.fix ? finding.fix : {};
    const informational = isInformationalFinding(finding);
    if (informational) {
      return {
        type: "informational",
        label: "Informational scan note",
        canGenerateText: false,
        actionLabel: "Show fix steps",
        action: "inline-rewrite",
        guidance:
          (fix.guidance || fix.manualAlternative || "").trim() ||
          "This is an informational scan note about how Compliance Guard interpreted the site, not editable content. There is no merchant-page text to rewrite. Adjust your scan scope, content profile, or RUO disclaimers if you want this category to behave differently on the next scan.",
      };
    }
    const type = fix.type || inferFixType(finding || {});
    // v0.7.375: respect an EXPLICIT false from the server. Previously
    // this line re-enabled canGenerateText whenever the fix_type was
    // text_rewrite or disclaimer_insert, which bypassed the PHP-side
    // override that disables rewrite UI for findings whose
    // fixCategory routes to a non-text fix (delete_page, add_page,
    // file_editor, manual_action, etc.). Only re-enable the fallback
    // when the server didn't ship a canGenerateText flag at all.
    const canGenerateText = fix.canGenerateText === false
      ? false
      : (fix.canGenerateText === true || type === "text_rewrite" || type === "disclaimer_insert");
    const labels = {
      text_rewrite: "Text wording change",
      disclaimer_insert: "Visible disclaimer change",
      page_or_navigation: "Page or navigation task",
      technical_header: "Server or security-header task",
      operational_record: "Internal record or audit-program task",
      settings_or_workflow: "Store setting or checkout workflow task",
      evidence_document: "Evidence or documentation task",
      claims_control_policy: "Claims-control policy task",
      informational: "Informational scan note",
    };
    return {
      type,
      label: fix.label || labels[type] || "Manual review task",
      canGenerateText,
      actionLabel: fix.canApply ? "Preview change" : canGenerateText ? "Generate safer text" : "Show fix steps",
      action: "inline-rewrite",
      guidance: fix.guidance || fix.manualAlternative || manualGuidanceForFinding(finding || {}),
    };
  }

  function inferFixType(finding) {
    // v0.7.336: respect explicit fix_type hint set by the finding source.
    // Defensive — PHP public_fix_summary should always emit fix.type from
    // fix_type_for_finding, but if a finding bypasses that path (e.g.
    // injected by an older code path or a third-party hook), honor the
    // hint here too.
    if (finding && typeof finding.fix_type === "string") {
      const hint = finding.fix_type;
      if (["text_rewrite","disclaimer_insert","page_or_navigation","technical_header","operational_record","settings_or_workflow","evidence_document","claims_control_policy"].includes(hint)) {
        return hint;
      }
    }
    const text = `${finding.rule || ""} ${(finding.citation && finding.citation.id) || ""}`.toLowerCase();
    if (text.includes("missing a visible ruo") || text.includes("lacks visible ruo") || text.includes("missing ruo")) return "disclaimer_insert";
    if (text.includes("policy not found") || text.includes("not found in crawled pages") || text.includes("ruo hub") || text.includes("navigation not found") || text.includes("contact path")) return "page_or_navigation";
    if (text.includes("hsts") || text.includes("referrer-policy") || text.includes("clickjacking") || text.includes("content security policy") || text.includes("security header") || text.includes("third-party scripts")) return "technical_header";
    if (text.includes("periodic compliance") || text.includes("supplier-audit") || text.includes("supplier audit") || text.includes("quality complaint") || text.includes("quality-record") || text.includes("traceability") || text.includes("capa") || text.includes("recall")) return "operational_record";
    if (text.includes("buyer verification") || text.includes("age or geo") || text.includes("post-purchase")) return "settings_or_workflow";
    if (text.includes("platform_ads.ad_landing_claim_context") || text.includes("affiliate_claims_control") || text.includes("affiliate_material_connection_disclosure") || text.includes("ad platform, affiliate, or landing-page") || text.includes("affiliate, influencer, or coupon traffic")) return "claims_control_policy";
    if (text.includes("coa") || text.includes("documentation") || text.includes("evidence")) return "evidence_document";
    // v0.7.336: catch-all -- any "signal not found" or "not found in crawled"
    // finding is a missing-content task, never a rewrite. Mirrors the
    // PHP-side catch-all in fix_type_for_finding so the JS falls back safely
    // if PHP fix.type is missing.
    if (text.includes("signal not found") || text.includes("not found in crawled")) return "page_or_navigation";
    const matchesBlob = Array.isArray(finding.matches) ? finding.matches.join(" ").toLowerCase() : "";
    if (matchesBlob.includes("not found in crawled")) return "page_or_navigation";
    return "text_rewrite";
  }

  function manualGuidanceForFinding(finding) {
    const rule = String(finding.rule || "").toLowerCase();
    const citation = String((finding.citation && finding.citation.id) || "").toLowerCase();
    const combined = `${rule} ${citation}`;
    if (combined.includes("platform_ads.ad_landing_claim_context") || combined.includes("ad platform, affiliate, or landing-page")) return "Treat this as a traffic-source and landing-page control issue. Review the ad, affiliate, and landing path together; remove body-effect, dosing, injection, before/after, or human-use cues; route paid traffic only to compliance-reviewed RUO pages; and keep dated campaign-review notes.";
    if (combined.includes("affiliate_material_connection_disclosure")) return "Add visible material-connection disclosure controls for affiliate, coupon, ambassador, sponsored, commission, or free-product relationships, and retain the URLs or screenshots showing where those disclosures appear.";
    if (combined.includes("affiliate_claims_control") || combined.includes("affiliate, influencer, or coupon traffic")) return "Publish or link a visible affiliate and creator claims policy, require pre-review of creator copy, forbid medical/body-effect/dosing/injection/human-use claims, and keep takedown authority plus dated review records.";
    if (combined.includes("glp alias") || combined.includes("fda_cited_glp_alias") || combined.includes("glp_alias_substitution")) return "Do not replace a GLP drug name with another FDA-cited GLP alias. The preferred fix is a complete counsel-reviewed product renaming that does not function as a drug-name substitute or brand-equivalence cue, while keeping COA, lot, and identity records accurate for qualified laboratory review.";
    // v0.7.413 — route-of-use guidance. FDA treats specific
    // administration routes (sublingual use, nasal use, transdermal
    // use, oral use) as intended-use evidence even without an
    // explicit dose. The guidance below mirrors the rule pack's
    // route_of_use claim_class.
    if (combined.includes("route-of-use") || combined.includes("route_of_use") || rule.includes("sublingual") || rule.includes("transdermal")) return "Remove every specific route-of-use cue from the page — sublingual use, nasal use, transdermal use, oral use, topical application, dropper/spray/patch/lozenge format hints. The compound page should describe identity, purity, storage, and intended laboratory protocols only.";
    if (rule.includes("hsts")) return "Ask your host, CDN, or server administrator to add the Strict-Transport-Security header after confirming every public page works over HTTPS.";
    if (rule.includes("referrer-policy")) return "Ask your host, CDN, or server administrator to add a Referrer-Policy header, then run a new scan.";
    if (rule.includes("clickjacking")) return "Ask your host, CDN, or server administrator to add X-Frame-Options or a Content-Security-Policy frame-ancestors rule.";
    if (rule.includes("content security policy")) return "Ask your developer or host to add a Content-Security-Policy that matches the scripts, payment frames, fonts, and images your site actually uses.";
    if (rule.includes("policy not found") || rule.includes("not found in crawled pages")) return "Create or update the missing page, then link it clearly from the footer and checkout flow.";
    if (rule.includes("periodic compliance") || rule.includes("supplier-audit") || rule.includes("supplier audit")) return "Create a simple recurring review record showing what was checked, who checked it, what changed, and when the next review is due.";
    if (rule.includes("quality") || rule.includes("traceability") || rule.includes("capa") || rule.includes("recall")) return "Add or document the operational record outside normal page copy: owner, process, review log, and supporting files.";
    return "This is not a wording replacement. Review the item, add the missing page, setting, server header, record, or evidence, then run a new scan.";
  }

  function detectPeptideNameForFinding(finding) {
    if (!finding) return null;
    const sources = [
      finding.match,
      finding.span,
      finding.quoted_span,
      finding.title,
      finding.pageTitle,
      finding.url,
      Array.isArray(finding.snippets) ? finding.snippets.join(" ") : "",
      Array.isArray(finding.matches) ? finding.matches.join(" ") : "",
    ];
    const haystack = sources.filter(Boolean).join(" ");
    if (!haystack) return null;

    // Each entry: { rx: pattern to find on the page, label: how the rewrite refers to it }.
    // Patterns are ordered by specificity so a full peptide name wins over a short alias.
    const peptidePatterns = [
      // GLP family - full names first
      { rx: /\bretatrutide\b/i, label: "Retatrutide" },
      { rx: /\bsemaglutide\b/i, label: "Semaglutide" },
      { rx: /\btirzepatide\b/i, label: "Tirzepatide" },
      { rx: /\bcagrilintide\b/i, label: "Cagrilintide" },
      { rx: /\bsurvodutide\b/i, label: "Survodutide" },
      { rx: /\bmazdutide\b/i, label: "Mazdutide" },
      { rx: /\bliraglutide\b/i, label: "Liraglutide" },
      { rx: /\bbremelanotide\b/i, label: "Bremelanotide" },
      // Healing / repair / GH
      { rx: /\bBPC[-\s]?157\b/i, label: "BPC-157" },
      { rx: /\bTB[-\s]?500\b/i, label: "TB-500" },
      { rx: /\bthymosin\s+beta[-\s]?4\b/i, label: "Thymosin beta-4" },
      { rx: /\bGHK[-\s]?Cu\b/i, label: "GHK-Cu" },
      { rx: /\bCJC[-\s]?1295\b/i, label: "CJC-1295" },
      { rx: /\bipamorelin\b/i, label: "Ipamorelin" },
      { rx: /\bhexarelin\b/i, label: "Hexarelin" },
      { rx: /\bsermorelin\b/i, label: "Sermorelin" },
      { rx: /\btesamorelin\b/i, label: "Tesamorelin" },
      // Sexual / pigment
      { rx: /\bPT[-\s]?141\b/i, label: "PT-141" },
      { rx: /\bAOD[-\s]?9604\b/i, label: "AOD-9604" },
      { rx: /\bmelanotan(?:\s*II)?\b/i, label: "Melanotan" },
      // Mitochondrial / cognitive / longevity
      { rx: /\bMOTS[-\s]?c\b/i, label: "MOTS-c" },
      { rx: /\bSS[-\s]?31\b/i, label: "SS-31" },
      { rx: /\bepithalon\b/i, label: "Epithalon" },
      { rx: /\bsemax\b/i, label: "Semax" },
      { rx: /\bselank\b/i, label: "Selank" },
      { rx: /\bdihexa\b/i, label: "Dihexa" },
      // GH fragments
      { rx: /\b(?:HGH\s+)?fragment\s+176[-\s]?191\b/i, label: "HGH fragment 176-191" },
      { rx: /\bIGF[-\s]?1\s*LR3\b/i, label: "IGF-1 LR3" },
      { rx: /\bIGF[-\s]?1\b/i, label: "IGF-1" },
      // Sleep / reproductive
      { rx: /\bDSIP\b/i, label: "DSIP" },
      { rx: /\bgonadorelin\b/i, label: "Gonadorelin" },
      { rx: /\bkisspeptin\b/i, label: "Kisspeptin" },
      // Short aliases - only counted when next to a peptide-shaped word so "RT" in
      // "starts" or "departure time" never qualifies.
      { rx: /\b(?:RT|reta)\b(?=[\s\S]{0,60}(?:peptide|research|catalog|vial|mg|lot|purity|coa|hplc))/i, label: "RT" },
      { rx: /\b(?:SM|sema)\b(?=[\s\S]{0,60}(?:peptide|research|catalog|vial|mg|lot|purity|coa|hplc))/i, label: "SM" },
      { rx: /\b(?:TZ|tirz)\b(?=[\s\S]{0,60}(?:peptide|research|catalog|vial|mg|lot|purity|coa|hplc))/i, label: "TZ" },
    ];

    for (const p of peptidePatterns) {
      if (p.rx.test(haystack)) {
        return p.label;
      }
    }
    return null;
  }

  function detectBodyEffectThemesForFinding(finding) {
    if (!finding) return [];
    const haystack = [
      finding.match,
      finding.span,
      finding.quoted_span,
      finding.title,
      finding.pageTitle,
      finding.reason,
      Array.isArray(finding.snippets) ? finding.snippets.join(" ") : "",
      Array.isArray(finding.matches) ? finding.matches.join(" ") : "",
    ].filter(Boolean).join(" ").toLowerCase();
    const themes = [];
    if (/\b(weight loss|fat loss|slim|thin|drop\s*\d+\s*lb|lose\s*\d+|appetite|hunger|crav|satiety|caloric)\b/.test(haystack)) themes.push("weight or appetite");
    if (/\b(glucose|blood sugar|insulin|a1c|hba1c|glycemic|diabet)\b/.test(haystack)) themes.push("glucose or insulin");
    if (/\b(cholesterol|ldl|hdl|triglyceride|lipid)\b/.test(haystack)) themes.push("lipid or cholesterol");
    if (/\b(metaboli|burn fat|fat oxidation|thermogen|energy expenditure)\b/.test(haystack)) themes.push("metabolic");
    if (/\b(muscle|lean mass|bulk|cut|strength|recovery)\b/.test(haystack)) themes.push("muscle or recovery");
    if (/\b(libido|erectile|sexual|testosterone|t levels)\b/.test(haystack)) themes.push("sexual or hormonal");
    if (/\b(skin|wrinkle|anti[- ]?aging|youthful|collagen|tan|pigment)\b/.test(haystack)) themes.push("skin or anti-aging");
    if (/\b(sleep|rest|circadian|melatonin)\b/.test(haystack)) themes.push("sleep");
    if (/\b(cogni|memory|focus|nootropic|brain|neuro)\b/.test(haystack)) themes.push("cognitive");
    if (/\b(injury|heal|repair|wound|tendon|ligament|tissue|inflammation|joint)\b/.test(haystack)) themes.push("tissue repair");
    return themes;
  }

  function lowerSentenceForm(label) {
    if (!label) return "";
    // Acronym-style names (BPC-157, TB-500, RT, SM, TZ, GHK-Cu, MOTS-c, IGF-1) keep their casing.
    if (/^[A-Z]/.test(label) && /[A-Z0-9]{2,}/.test(label.replace(/[^A-Za-z0-9]/g, ""))) {
      return label;
    }
    // Plain peptide names (Retatrutide -> retatrutide) read more naturally mid-sentence in lowercase.
    return label.charAt(0).toLowerCase() + label.slice(1);
  }

  // v0.7.378 (user directive: "make the generated safer text work just
  // like it would with gemini where it detects wording, checks for a
  // compliant replacement to match the intent/context while also being
  // compliant. if possible make every single suggested text unique so
  // it's not just some sort of template used across the board it needs
  // to be specific to the page being fixed"). Companion to the PHP
  // compose_unique_rewrite() — same composition shape so the inline
  // preview matches what the AJAX rewrite endpoint will return.
  function generatedRewriteForFinding(finding) {
    if (!finding) return "";
    const rule = String((finding && finding.rule) || "").toLowerCase();
    const citation = String(((finding && finding.citation && finding.citation.id) || "")).toLowerCase();
    const combined = rule + " " + citation;

    // Findings whose canonical fix is a fixed disclaimer line still
    // return the canonical string — paraphrasing those would defeat
    // the point.
    if (combined.includes("missing a visible ruo") || combined.includes("lacks visible ruo") || combined.includes("missing ruo")) {
      const detectedRuoSubject = detectPeptideNameForFinding(finding);
      return detectedRuoSubject
        ? `${detectedRuoSubject} is sold for research and identification purposes only. It is not intended for human dosing, injection, ingestion, oral use, sublingual use, nasal use, topical application, transdermal use, or consumption.`
        : "All products sold on this website are intended for research and identification purposes only. These products are not intended for human dosing, injection, ingestion, oral use, sublingual use, nasal use, topical application, transdermal use, or consumption.";
    }
    if (combined.includes("policy not found") || combined.includes("not found in crawled pages")) {
      return "Terms, privacy, shipping, refund, contact, and research-use disclaimer pages are linked in the footer and checkout so buyers can review them before ordering.";
    }

    // v0.7.380 (user report: "its not generating text to replace it,
    // its telling why it should be"). Default rewrite path now uses
    // the surgical replacement composer — it takes the actual flagged
    // wording, strips non-compliant sentences, swaps brand names, and
    // returns a paste-ready rewrite the merchant drops straight into
    // the page editor. composeRewriteOpening/Middle/Closing are kept
    // as helpers for any caller that wants the older meta-prose shape.
    const signals = collectRewriteSignals(finding);
    return composeSurgicalReplacementText(finding, signals);
  }

  function composeSurgicalReplacementText(finding, signals) {
    const seed = signals.seed;
    const original = selectOriginalForSurgicalRewrite(finding);
    if (!original || original.trim().length < 25) {
      return surgicalFallbackBlurb(signals, seed);
    }
    const sentences = splitIntoSentences(original);
    if (!sentences.length) return surgicalFallbackBlurb(signals, seed);
    const kept = [];
    for (const sentence of sentences) {
      const cleaned = cleanSentenceForCompliance(sentence);
      if (cleaned) kept.push(cleaned);
    }
    const deduped = deduplicateSentencesPreserveOrder(kept);
    let body = deduped.join(" ").trim();
    if (!body || body.length < 30) return surgicalFallbackBlurb(signals, seed);
    if (!surgicalTextHasRuoClose(body)) {
      body = (body + " " + pickSurgicalRuoClose(signals, seed)).trim();
    }
    body = body.replace(/\s+/g, " ")
      .replace(/(\.\s*){2,}/g, ". ")
      .replace(/\s+([.,;:!?])/g, "$1")
      .replace(/\(\s+/g, "(")
      .replace(/\s+\)/g, ")");
    body = body.replace(/^["'`“”‘’\s]+|["'`“”‘’\s]+$/g, "");
    body = stripForbiddenBrandTerms(body);
    return body.trim();
  }

  function selectOriginalForSurgicalRewrite(finding) {
    const candidates = [];
    if (finding.span) candidates.push(String(finding.span));
    if (finding.quoted_span) candidates.push(String(finding.quoted_span));
    if (Array.isArray(finding.snippets)) {
      finding.snippets.slice(0, 3).forEach((s) => candidates.push(String(s)));
    }
    if (Array.isArray(finding.contextWindows)) {
      finding.contextWindows.slice(0, 2).forEach((w) => candidates.push(String(w)));
    }
    if (finding.match) candidates.push(String(finding.match));
    let best = "";
    for (const c of candidates) {
      const n = String(c || "").replace(/<[^>]+>/g, "").trim();
      if (n.length > best.length) best = n;
    }
    return best;
  }

  function splitIntoSentences(text) {
    let t = String(text || "").replace(/<[^>]+>/g, "").trim();
    if (!t) return [];
    // Strip wrapping quote characters from the source text so the
    // surgical output doesn't carry an unbalanced quote at the start.
    t = t.replace(/^["'`“”‘’\s]+|["'`“”‘’\s]+$/g, "");
    // Normalize unicode dashes + bullet markers, consuming surrounding
    // whitespace to avoid "5mg . research" artifacts.
    t = t.replace(/\s*[—–•·●]\s*/g, ". ");
    const parts = t.split(/(?<=[.!?])\s+|\n+/u);
    return parts.map((p) => String(p || "").replace(/^["'`“”‘’\s]+|["'`“”‘’\s]+$/g, "")).filter(Boolean);
  }

  function cleanSentenceForCompliance(sentence) {
    const trimmed = String(sentence || "").trim();
    if (!trimmed) return "";
    const lower = trimmed.toLowerCase();
    const dosingBlock = /\b(reconstitut\w*|inject\w*|subcut\w*|intramuscular|intravenous|sublingual|nasal|intranasal|topical|transdermal|oral\s+dose|swallow|by\s+mouth|once\s+(?:daily|weekly|monthly)|twice\s+(?:daily|weekly)|per\s+week|weekly\s+dose|every\s+\d+\s+(?:days?|weeks?|months?)|titrat\w*|escalat\w*|starting\s+dose|maintenance\s+dose|dose\s+protocol|dose\s+schedule|loading\s+dose|cycle\s+of|stack\s+with|draw\s+\d+\s*(?:mg|mcg|iu|units?)|administer|administration\s+instruct)\b/i;
    if (dosingBlock.test(lower)) return "";
    if (/\b\d+(?:\.\d+)?\s*(?:mg|mcg|iu|units?)\b.*\b(?:per|each|every|daily|weekly|monthly|inject|take|administer|dose)/i.test(lower)) return "";
    if (/\b(same\s+active\s+ingredient|equivalent\s+to|alternative\s+to|just\s+like|comparable\s+to|generic\s+version|generic\s+of|compares?\s+to|works\s+like|substitute\s+for|interchangeable\s+with|cheaper\s+than|bioequivalent)\b/i.test(lower)) return "";
    if (/\b(customers?\s+report|users?\s+report|lost\s+\d+\s*(?:lbs|pounds|kg|kilos?)|gained\s+\d+\s*(?:lbs|pounds)|before[- ]?(?:and[- ]?)?after|success\s+stor|testimonial|review.*lost|in\s+\d+\s+weeks?|after\s+\d+\s+weeks?|results?\s+may\s+vary|body\s+(?:weight\s+)?loss|reported\s+\d+\s*%|on\s+(?:semaglutide|tirzepatide|retatrutide|ozempic|wegovy|mounjaro))\b/i.test(lower)) return "";
    if (/\b(bogo|buy\s+one\s+get\s+one|buy\s+2\s+get|2\s+for\s+\$|save\s+\$|you\s+save|\d+%\s*off|use\s+code|coupon\s+code|free\s+shipping|crypto\s+checkout|bitcoin|btc|usdt|monero|subscribe\s+and\s+save|auto[- ]?ship|auto[- ]?renew|monthly\s+plan|stack\s+drop|christmas\s+drop|holiday\s+bundle|leaderboard|loyalty\s+points|earn\s+.*\s+points|level\s+up|tier\s+upgrade|reward\s+tier|concierge\s+hotline|whatsapp\s+order)\b/i.test(lower)) return "";
    if (/\b(add\s+to\s+cart|shop\s+now|buy\s+now|order\s+now|checkout\s+now|claim\s+your)\b/i.test(lower)) return "";
    if (/\$\s*\d+(?:[.,]\d{2})?/.test(trimmed)) return "";
    if (/https?:\/\/\S+/i.test(trimmed)) return "";
    if (/\b(free\s+(?:bac(?:teriostatic)?\s+water|insulin\s+syring)|with\s+syring|reconstitution\s+kit|starter\s+kit|begin\s+kit)/i.test(lower)) return "";

    let cleaned = replaceBrandNamesInline(trimmed);
    cleaned = cleaned.replace(/\s+/g, " ").trim();
    const stripped = cleaned.replace(/[\s.,;:!?\-]+/g, "");
    if (stripped.length < 8) return "";
    // Capitalise the first letter so sentences split out of an em-dash
    // or bullet boundary don't read like "research vial". Skip when the
    // first token is a canonical peptide label (BPC-157, TB-500) or a
    // digit ("5mg vial").
    if (cleaned.length) {
      const first = cleaned.charAt(0);
      if (first === first.toLowerCase() && !/^(?:bpc|tb|ghk|cjc|pt|aod)[-\s\d]/i.test(cleaned) && !/^\d/.test(cleaned)) {
        cleaned = first.toUpperCase() + cleaned.slice(1);
      }
    }
    if (!/[.!?]$/.test(cleaned)) cleaned += ".";
    return cleaned;
  }

  function replaceBrandNamesInline(sentence) {
    const activePat = /\b(?:semaglutide|tirzepatide|retatrutide|liraglutide|cagrilintide|dulaglutide|exenatide|lixisenatide|albiglutide|mazdutide|orforglipron|survodutide)\b/gi;
    const brandPat = /\b(?:ozempic|wegovy|mounjaro|zepbound|trulicity|saxenda|rybelsus|victoza|byetta|bydureon)\b/gi;
    const mfgPat = /\b(?:novo\s+nordisk|eli\s+lilly|sanofi(?:[- ]aventis)?|astrazeneca|merck|pfizer)\b/gi;
    let out = sentence.replace(activePat, "research peptide reference material");
    out = out.replace(brandPat, "an approved drug product");
    out = out.replace(mfgPat, "the original manufacturer");
    out = out.replace(/\b(the|an|a)\s+(the|an|a)\b/gi, "$2");
    out = out.replace(/\s+/g, " ");
    return out.trim();
  }

  function deduplicateSentencesPreserveOrder(sentences) {
    const seen = new Set();
    const out = [];
    for (const s of sentences) {
      const norm = String(s || "").toLowerCase().replace(/[^a-z0-9 ]+/g, " ").replace(/\s+/g, " ").trim();
      const prefix = norm.split(" ").slice(0, 8).join(" ");
      if (!prefix) { out.push(s); continue; }
      if (seen.has(prefix)) continue;
      seen.add(prefix);
      out.push(s);
    }
    return out;
  }

  function surgicalTextHasRuoClose(text) {
    const lower = String(text || "").toLowerCase();
    const hasResearchTerm =
      lower.indexOf("research") !== -1 ||
      lower.indexOf("laboratory") !== -1 ||
      lower.indexOf("in-vitro") !== -1 ||
      lower.indexOf("in vitro") !== -1;
    const hasNotForHuman =
      lower.indexOf("not for human") !== -1 ||
      lower.indexOf("not intended for human") !== -1 ||
      lower.indexOf("not for consumer") !== -1 ||
      lower.indexOf("qualified laboratory") !== -1;
    return hasResearchTerm && hasNotForHuman;
  }

  function pickSurgicalRuoClose(signals, seed) {
    const facts = Array.isArray(signals.facts) ? signals.facts : [];
    const factsText = facts.join(" ").trim();
    const frames = [
      "For qualified laboratory and in-vitro research only — not for human or animal use. Certificate of Analysis available on request.",
      "Supplied strictly for qualified laboratory research; not for human or animal dosing, injection, ingestion, or administration. Full COA on request.",
      "Sold for laboratory research and in-vitro identification only. Not for human or animal use. Identity, lot, purity, and storage records available on request.",
      "Research-use only material; not intended for human or animal use. The Certificate of Analysis and lot record are released to qualified researchers on request.",
      "For in-vitro research only — not a drug, food, dietary supplement, cosmetic, or medical device, and not for human or animal use. COA available on request.",
    ];
    if (factsText) {
      frames.push("For qualified laboratory research only — not for human or animal use. " + factsText.replace(/\.$/, "") + ". Full Certificate of Analysis available on request.");
      frames.push("Research-only material; not for human or animal use. Documented: " + factsText.replace(/\.$/, "") + ". COA available on request.");
    }
    return frames[pickRewriteVariant(seed, 11, frames.length)];
  }

  function surgicalFallbackBlurb(signals, seed) {
    const subject = signals.subject || "";
    const display = subject || "Research peptide reference material";
    const facts = Array.isArray(signals.facts) ? signals.facts : [];
    const factsText = facts.join(" ").trim();
    const openings = [
      display + " — research reference vial for qualified laboratory and in-vitro identification work.",
      display + " — research-grade reference compound supplied for laboratory use.",
      display + " — supplied as a research-only reagent for qualified scientific workflows.",
      display + " — laboratory reference material; identity-verified for in-vitro research.",
    ];
    const opening = openings[pickRewriteVariant(seed, 21, openings.length)];
    const detail = factsText
      ? "Documented: " + factsText.replace(/\.$/, "") + "."
      : "Identity, lot/batch, purity, and storage records available on request.";
    const close = pickSurgicalRuoClose(signals, seed);
    return (opening + " " + detail + " " + close).trim();
  }

  function rewriteSeedForFinding(finding) {
    const id = String((finding && finding.id) || "");
    const url = String((finding && finding.url) || "");
    const rule = String((finding && finding.rule) || "");
    const match = String((finding && (finding.match || "")) || "");
    const span = String((finding && (finding.span || finding.quoted_span || "")) || "");
    const blob = id + "|" + url + "|" + rule + "|" + match + "|" + span;
    // Lightweight string hash (FNV-1a 32) — deterministic per finding.
    let h = 2166136261 >>> 0;
    for (let i = 0; i < blob.length; i++) {
      h ^= blob.charCodeAt(i);
      h = Math.imul(h, 16777619) >>> 0;
    }
    return h;
  }

  function collectRewriteSignals(finding) {
    const seed = rewriteSeedForFinding(finding);
    const detected = detectPeptideNameForFinding(finding);
    const subject = detected || "";
    const pageTitle = String((finding && (finding.pageTitle || finding.title || finding.sourceTitle || "")) || "").trim();

    // Build the haystack we search for themes.
    const parts = [
      String(finding.rule || ""),
      String((finding.citation && finding.citation.id) || ""),
      String(finding.match || ""),
      String(finding.span || finding.quoted_span || ""),
      pageTitle,
      Array.isArray(finding.matches) ? finding.matches.join(" ") : "",
      Array.isArray(finding.snippets) ? finding.snippets.join(" ") : "",
      Array.isArray(finding.contextWindows) ? finding.contextWindows.join(" ") : "",
    ];
    const haystack = parts.filter(Boolean).join(" ").toLowerCase();

    const bodyThemes = detectRewriteBodyThemes(haystack);
    const adThemes = detectRewriteAdThemes(haystack);
    const ruleFamily = classifyRewriteRuleFamily(String(finding.rule || "").toLowerCase() + " " + String((finding.citation && finding.citation.id) || "").toLowerCase(), haystack);
    const facts = extractFactsFromFinding(finding);

    return { seed, subject, pageTitle, facts, bodyThemes, adThemes, ruleFamily };
  }

  function extractFactsFromFinding(finding) {
    let haystack = "";
    if (Array.isArray(finding.snippets)) haystack += " " + finding.snippets.join(" ");
    haystack += " " + String(finding.span || "");
    haystack += " " + String(finding.quoted_span || "");
    haystack = haystack.trim();
    if (!haystack) return [];
    const facts = [];
    const purity = haystack.match(/\b(\d{2,3}(?:\.\d+)?\s*%(?:\+|\s*plus)?\s*(?:purity|pure))/i);
    if (purity) facts.push("Verified purity: " + purity[1].trim() + ".");
    const lot = haystack.match(/\b(?:lot|batch)\s*(?:number|#|no\.?)?\s*[:\-]?\s*([A-Z0-9\-]{3,20})/i);
    if (lot) facts.push("Lot/batch on file: " + lot[1].trim() + ".");
    const analysis = haystack.match(/\b(hplc|lc-ms|mass\s+spectrometry|third[- ]party\s+test(?:ed|ing)?)\b/i);
    if (analysis) facts.push(analysis[1].charAt(0).toUpperCase() + analysis[1].slice(1).toLowerCase() + " verified.");
    return facts;
  }

  function detectRewriteBodyThemes(haystack) {
    const probes = [
      ["weight_or_appetite", /\b(weight|fat|appetite|satiety|caloric|calorie|bmi|pound|kilo|lbs?|slim|lean[- ]out)\b/i],
      ["glucose_or_insulin", /\b(glucose|blood sugar|a1c|hba1c|insulin|diabet)\b/i],
      ["lipid_or_cholesterol", /\b(cholesterol|ldl|hdl|triglycer|lipid)\b/i],
      ["metabolic", /\b(metaboli[cs]m?|thermogen|energy expenditure|fat oxidation)\b/i],
      ["muscle_or_recovery", /\b(muscle|lean mass|strength|gain|recovery|hypertroph)\b/i],
      ["tissue_repair", /\b(heal(?:ing)?|tendon|ligament|joint|cartilage|gut|inflammat|repair|injur)\b/i],
      ["sexual_or_hormonal", /\b(libido|sexual|erectile|testosterone|hormone|hgh|growth hormone)\b/i],
      ["skin_or_aging", /\b(skin|wrinkle|anti[- ]ag(?:e|ing)|pigment|collagen|melanin|tan)\b/i],
      ["sleep", /\b(sleep|circadian|melatonin|rest|insomnia)\b/i],
      ["cognitive", /\b(cognit|memory|focus|nootropic|mental clarity|brain fog)\b/i],
    ];
    const out = [];
    for (const [tag, rx] of probes) if (rx.test(haystack)) out.push(tag);
    return out;
  }

  function detectRewriteAdThemes(haystack) {
    const probes = [
      ["bogo", /\b(bogo|buy one get one|buy 2 get|2 for|stack drop|christmas drop)\b/i],
      ["subscription", /\b(subscri[bp]|auto[- ]?ship|auto[- ]?renew|monthly plan|recurring)\b/i],
      ["crypto_checkout", /\b(crypto|bitcoin|btc|ethereum|usdt|usdc|monero|stablecoin)\b/i],
      ["gamification", /\b(xp|points|level up|leaderboard|loyalty points|earn .* points|tier|badge|achievement)\b/i],
      ["testimonial", /\b(testimonial|success stor|before[- ]?and[- ]?after|lost \d+ lbs|review.* lost|case stud)\b/i],
      ["ad_platform", /\b(meta ad|facebook ad|tiktok ad|google ad|youtube ad|landing page|utm_|ad creative|sponsored)\b/i],
      ["accessory_bundle", /\b(bac(?:teriostatic)? water|insulin syring|reconstitut|starter kit|with syring)\b/i],
      ["dosing_language", /\b(\d+\s*(?:mcg|mg|iu|units?)|per week|weekly dose|dose protocol|cycle of|stack with|escalat|titrat)\b/i],
      ["price_comparison", /\b(save\s+\$|you save|vs (?:retail|brand|ozempic|mounjaro|wegovy)|\d+%\s*off|cheaper than)\b/i],
      ["concierge_or_hotline", /\b(concierge|hotline|whatsapp|telegram catalog|direct line|priority order)\b/i],
      ["gifting", /\b(gift card|christmas list|gift subscription|share with friend|refer a friend|holiday bundle)\b/i],
      ["progress_tracking", /\b(track your (?:dose|cycle|stack)|injection log|progress photo|dose calendar|tracking app)\b/i],
      ["pharmacy_lane", /\b(compounded|pharmacy|telehealth|prescription|provider supervised|clinic order)\b/i],
    ];
    const out = [];
    for (const [tag, rx] of probes) if (rx.test(haystack)) out.push(tag);
    return out;
  }

  function classifyRewriteRuleFamily(ruleBlob, haystack) {
    if (ruleBlob.includes("glp alias") || ruleBlob.includes("fda-cited glp") || ruleBlob.includes("alias substitution")) return "glp_alias";
    if (ruleBlob.includes("brand") || ruleBlob.includes("trademark") || ruleBlob.includes("equivalence") || ruleBlob.includes("fda-approved")) return "brand_equivalence";
    if (ruleBlob.includes("dosing") || ruleBlob.includes("inject") || ruleBlob.includes("reconstitut") || /\b(mcg|mg|iu|units?|per week|cycle|stack)\b/i.test(haystack)) return "dosing_or_injection";
    if (ruleBlob.includes("cart") || ruleBlob.includes("bacteriostatic water") || ruleBlob.includes("syringe") || ruleBlob.includes("starter kit")) return "ecommerce_accessory";
    if (ruleBlob.includes("buyer") || ruleBlob.includes("researcher verification") || ruleBlob.includes("manual-review")) return "buyer_verification";
    if (ruleBlob.includes("payment") || ruleBlob.includes("processor") || ruleBlob.includes("card") || ruleBlob.includes("chargeback")) return "payments_signal";
    if (ruleBlob.includes("supply-chain") || ruleBlob.includes("manufacturing") || ruleBlob.includes("quality") || ruleBlob.includes("coa") || ruleBlob.includes("downloadable")) return "supply_chain_proof";
    if (ruleBlob.includes("testimonial") || ruleBlob.includes("before-and-after") || ruleBlob.includes("success stor") || ruleBlob.includes("health-claim")) return "testimonial_or_outcome";
    if (ruleBlob.includes("ad platform") || ruleBlob.includes("affiliate") || ruleBlob.includes("landing-page") || ruleBlob.includes("creator")) return "ad_platform_signal";
    if (ruleBlob.includes("human-use") || ruleBlob.includes("body-effect") || ruleBlob.includes("therapeutic-effect")) return "human_use_context";
    return "general_research_use";
  }

  function pickRewriteVariant(seed, slotOffset, count) {
    if (count <= 0) return 0;
    return ((seed + slotOffset * 7919) % count + count) % count;
  }

  function composeRewriteOpening(signals, seed) {
    const subject = signals.subject || "";
    const subjectDisplay = subject || "This catalog page";
    const subjectLower = subject ? lowercaseFirstWord(subject) : "this catalog page";
    const facts = Array.isArray(signals.facts) ? signals.facts : [];
    const firstFact = facts.length ? facts[0].replace(/\.$/, "") : "";
    const family = signals.ruleFamily;
    const pageTitle = signals.pageTitle || "";
    const frames = [
      `${subjectDisplay} is offered as a research reference material for qualified laboratory and in-vitro use only.`,
      `${subjectDisplay} is supplied strictly for qualified scientific research; the listing exists to document identity, purity, and storage rather than to recommend any use in people or animals.`,
      `On this page ${subjectLower} is described only as a laboratory research reagent: the descriptive copy is limited to what the analytical record can substantiate.`,
      `${subjectDisplay} is presented here as a research-only chemical; nothing on this page is written to direct, suggest, or imply any clinical, therapeutic, or consumer use.`,
      `The information shown about ${subjectLower} is limited to laboratory-record specifications appropriate for in-vitro research workflows.`,
      `${subjectDisplay} appears here as a research-grade reference compound, presented for qualified scientific work rather than for any human or animal application.`,
    ];
    if (firstFact) {
      frames.push(`${subjectDisplay} is documented for qualified laboratory research only — ${firstFact.toLowerCase()}. Nothing on this page is intended to direct human or animal use.`);
    }
    if (pageTitle) {
      frames.push(`The "${pageTitle}" listing presents ${subjectLower} as a research-only reference material; the wording here is scoped to what a laboratory record can substantiate.`);
    }
    if (family === "glp_alias") {
      frames.push(`${subjectDisplay} is shown here as a research reference compound; product naming is being reviewed by counsel so this listing does not function as a substitute for any FDA-cited GLP code-name alias.`);
    }
    if (family === "brand_equivalence") {
      frames.push(`${subjectDisplay} is described here only as a research-grade reagent; the page makes no comparison to, and claims no equivalence with, any approved drug, brand, or manufacturer product.`);
    }
    if (family === "testimonial_or_outcome") {
      frames.push(`${subjectDisplay} is presented as a research reagent only; this page does not host customer outcomes, weight-loss stories, before/after imagery, or any body-effect testimonial.`);
    }
    if (family === "ad_platform_signal") {
      frames.push(`${subjectDisplay} is referenced here only as a research-grade compound; paid-traffic, affiliate, or creator copy is reviewed before going live so the landing wording does not imply consumer use.`);
    }
    return frames[pickRewriteVariant(seed, 1, frames.length)];
  }

  function composeRewriteMiddle(signals, seed) {
    const bodyThemes = signals.bodyThemes || [];
    const adThemes = signals.adThemes || [];
    const subject = signals.subject || "";
    const subjectLower = subject ? lowercaseFirstWord(subject) : "this listing";
    const family = signals.ruleFamily;

    const bodyMap = {
      weight_or_appetite: "weight loss, fat loss, appetite or caloric outcomes",
      glucose_or_insulin: "blood-glucose, A1C, insulin-sensitivity or diabetes-related outcomes",
      lipid_or_cholesterol: "cholesterol, LDL, HDL or triglyceride outcomes",
      metabolic: "metabolic-rate, fat-oxidation or energy-expenditure outcomes",
      muscle_or_recovery: "muscle gain, lean-mass, strength or recovery outcomes",
      tissue_repair: "wound, tendon, joint or anti-inflammatory outcomes",
      sexual_or_hormonal: "libido, sexual-function or hormonal-status outcomes",
      skin_or_aging: "skin-appearance, anti-aging or pigmentation outcomes",
      sleep: "sleep, rest or circadian outcomes",
      cognitive: "cognitive, memory, focus or nootropic outcomes",
    };
    const adMap = {
      bogo: "BOGO bundles or buy-two-get-one promotions",
      subscription: "auto-ship subscriptions or recurring stack plans",
      crypto_checkout: "cryptocurrency checkout incentives",
      gamification: "XP, points, leaderboards, tiers or loyalty-game mechanics",
      testimonial: "customer testimonials, before/after photos or success-story framing",
      ad_platform: "paid-ad or sponsored-creator framing on this landing copy",
      accessory_bundle: "bacteriostatic-water or insulin-syringe pairings, reconstitution kits and starter-kit bundles",
      dosing_language: "dosing schedules, weekly mg, cycle, titration or stack instructions",
      price_comparison: "price comparisons against brand or retail drug pricing",
      concierge_or_hotline: "concierge ordering hotlines or direct-message catalog ordering",
      gifting: "gifting, holiday-bundle or refer-a-friend offers",
      progress_tracking: "dose-tracking, injection-log or progress-photo features",
      pharmacy_lane: "compounded-pharmacy, telehealth or provider-supervised framing",
    };

    const phrases = [];
    bodyThemes.slice(0, 4).forEach((t) => { if (bodyMap[t]) phrases.push(bodyMap[t]); });
    adThemes.slice(0, 4).forEach((t) => { if (adMap[t]) phrases.push(adMap[t]); });
    const listed = phrases.length ? oxfordJoin(Array.from(new Set(phrases))) : "";

    let frames;
    if (listed) {
      frames = [
        `The page no longer references ${listed}, and any earlier copy along those lines has been removed.`,
        `Specifically, ${listed} do not appear on this listing — those signals were rewritten out so the page reads as a laboratory record, not a consumer offer.`,
        `To address what the scanner flagged, ${listed} have been removed from ${subjectLower} and will not be reintroduced.`,
        `This listing does not advertise ${listed}; that wording was scoped out so the public copy stays inside research-use boundaries.`,
      ];
    } else {
      const familyFrames = {
        glp_alias: [
          "The product name and headings have been reviewed so the listing does not function as a substitute for an FDA-cited GLP code-name alias such as GLP-1-S/SM, GLP-2-T/TRZ/TZ, GLP-3-R/RT, or GLP1-R/S/T.",
          "Product naming has been audited so the listing does not act as a stand-in for any FDA-cited GLP code-name alias, while the COA, lot, identity, and storage records remain accurate for qualified laboratory review.",
        ],
        brand_equivalence: [
          "The page no longer compares this material to approved drug brands, manufacturers, or equivalence claims, and any prior comparison wording has been removed.",
          "Brand, manufacturer and approved-drug equivalence cues have been removed so the listing reads as a research reagent and not as a substitute for any commercial product.",
        ],
        dosing_or_injection: [
          "Dosing, reconstitution, route-of-administration and consumption instructions have been removed; the listing is limited to identity, lot, purity, storage and Certificate of Analysis information.",
          "No injection, oral, sublingual, nasal, topical, transdermal or other administration instructions remain on this page — only the analytical-record fields a research workflow needs.",
        ],
        ecommerce_accessory: [
          "Reconstitution supplies, injection accessories, and starter-kit pairings are no longer shown alongside this material so the listing reads as a standalone research reagent.",
          "Bacteriostatic water, insulin syringes and other administration accessories have been unbundled from this listing; they are not presented as a together-with purchase.",
        ],
        buyer_verification: [
          "The checkout asks every buyer to confirm qualified laboratory research use — not human or animal use — before fulfilment, and the order may be reviewed for research-use context.",
          "Orders are reviewed against a research-use acknowledgement at checkout; buyers must affirm they are purchasing for qualified laboratory work, not for consumer or clinical use.",
        ],
        payments_signal: [
          "Payments are processed under the store name shown at checkout; the descriptor, refund policy, shipping policy, and research-use acknowledgement are all available before purchase.",
          "The processor descriptor, refund and shipping policies, and research-only acknowledgement appear in the checkout summary so buyers can review each one before authorising the charge.",
        ],
        supply_chain_proof: [
          "Certificate of Analysis, lot/batch, purity, storage and handling records are available on request to support laboratory recordkeeping.",
          "The lot, batch, HPLC/MS purity, storage and handling records are documented and can be released to qualified researchers for their analytical files.",
        ],
        testimonial_or_outcome: [
          "Customer outcomes, before/after imagery, weight-loss stories and other body-effect testimonials have been removed; the page no longer hosts those signals.",
          "Success stories, body-effect testimonials and before/after content do not appear on this page and will not be reintroduced.",
        ],
        ad_platform_signal: [
          "Paid-traffic and creator landing copy is reviewed before it goes live so the wording does not imply consumer use, dosing, body-effect outcomes, or brand equivalence.",
          "Affiliate, coupon, ambassador and creator copy is pre-reviewed against the research-use boundary; ad creative that implies consumer use is not approved.",
        ],
        human_use_context: [
          "No wording on this page directs or suggests human or animal dosing, injection, ingestion, or any other body-effect use of this material.",
          "The listing copy does not present human-use, consumer-use or therapeutic-use context for this material; all references are limited to laboratory research.",
        ],
        general_research_use: [
          "The descriptive copy stays inside research-use boundaries — identity, lot, purity, storage and Certificate of Analysis information only.",
          "This listing remains scoped to the analytical record: identity, lot or batch, purity, storage and Certificate of Analysis information.",
        ],
      };
      frames = familyFrames[family] || familyFrames.general_research_use;
    }
    return frames[pickRewriteVariant(seed, 2, frames.length)];
  }

  function composeRewriteClosing(signals, seed) {
    const facts = Array.isArray(signals.facts) ? signals.facts : [];
    const subjectLower = signals.subject ? lowercaseFirstWord(signals.subject) : "this listing";
    let frames;
    if (facts.length) {
      const blob = facts.join(" ").trim();
      const stripped = blob.replace(/\.$/, "");
      frames = [
        `Analytical record on file: ${blob} Anyone reviewing this listing for a laboratory file can request the full Certificate of Analysis on request.`,
        `Documentation on hand: ${blob} These records are released to qualified researchers as supporting evidence for the listing.`,
        `Substantiation: ${stripped} — the full COA is available on request for laboratory recordkeeping.`,
        `The page-specific record is ${stripped} and the underlying Certificate of Analysis can be released to qualified buyers for their files.`,
      ];
    } else {
      frames = [
        `Researchers reviewing ${subjectLower} can request the Certificate of Analysis, lot/batch, purity and storage record before placing an order.`,
        `Identity, lot/batch, purity and storage documentation for ${subjectLower} is available on request for laboratory recordkeeping.`,
        `A full analytical record — identity, lot, purity, storage and handling — for ${subjectLower} can be released to qualified researchers on request.`,
        `Anyone preparing a research workflow with ${subjectLower} can request the lot, purity, storage and Certificate of Analysis before ordering.`,
      ];
    }
    return frames[pickRewriteVariant(seed, 3, frames.length)];
  }

  function oxfordJoin(arr) {
    const xs = arr.filter(Boolean);
    if (!xs.length) return "";
    if (xs.length === 1) return xs[0];
    if (xs.length === 2) return xs[0] + " or " + xs[1];
    return xs.slice(0, -1).join(", ") + ", or " + xs[xs.length - 1];
  }

  function lowercaseFirstWord(value) {
    const v = String(value || "").trim();
    if (!v) return v;
    // Preserve canonical peptide labels (BPC-157, TB-500, GHK-Cu, PT-141)
    // because they read better as-is in sentence position.
    if (/[A-Z0-9].*[-0-9]/.test(v)) return v;
    return v.charAt(0).toLowerCase() + v.slice(1);
  }

  function stripForbiddenBrandTerms(text) {
    const forbidden = [
      "semaglutide","tirzepatide","retatrutide","liraglutide",
      "cagrilintide","dulaglutide","exenatide","lixisenatide",
      "albiglutide","mazdutide","orforglipron","survodutide",
      "ozempic","wegovy","mounjaro","zepbound","trulicity",
      "saxenda","rybelsus","victoza","byetta","bydureon",
      "novo nordisk","eli lilly",
    ];
    let out = text;
    for (const term of forbidden) {
      const rx = new RegExp("\\b" + term.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&") + "\\b", "gi");
      out = out.replace(rx, "this research peptide reference material");
    }
    return out.replace(/\s+/g, " ").trim();
  }

  function renderRewritePanel(finding) {
    const details = fixDetailsForFinding(finding);
    if (!details.canGenerateText) {
      return renderTaskFixPanel(finding, details);
    }
    const original = originalTextForFinding(finding) || finding.rule || "";
    const optionalDisclaimer = finding && finding.fix && finding.fix.optionalDisclaimer
      ? String(finding.fix.optionalDisclaimer).trim()
      : "";
    // v0.7.340 (merchant directive): the placeholder is no longer a
    // generic templated rewrite. Instead the panel renders the ORIGINAL
    // text greyed out while Gemini works -- so the merchant sees the
    // before/after comparison naturally instead of a stock catalog
    // sentence that overrides their own writing. When Gemini returns,
    // the placeholder is replaced with the surgical edit (Path A:
    // preserved meaning, defanged spans) or the full replacement (Path
    // B: brand-equivalence / weight-loss / dosing-only passages with
    // no salvageable meaning).
    //
    // Fallbacks for the no-AI case:
    //   1. Server-side suggestedRewrite / suggested_wording (if PHP
    //      pre-computed one -- rare for v0.7.340+).
    //   2. The original passage as-is (the merchant sees their own
    //      text and a "Gemini unavailable, please edit manually" note
    //      instead of getting a misleading generic template).
    let placeholder = finding.suggestedRewrite ||
      finding.suggested_rewrite ||
      (finding.fix && finding.fix.suggestedWording) || "";
    if (!placeholder) {
      // No pre-computed rewrite. Show the original text muted; the
      // background Gemini call will swap it for the real edit.
      placeholder = original || generatedRewriteForFinding(finding);
    }
    const panelId = "rpca-inline-rewrite-" + (finding && finding.id ? String(finding.id).replace(/[^a-z0-9_-]/gi, "") : String(state.currentFindingIndex));
    // Kick off the server-side rewrite in the background; mutate the
    // panel DOM when it returns. Stash the request so a re-render of
    // the same finding doesn't double-fire.
    if (window.RPCA && RPCA.hasApiKey && finding && finding.id) {
      setTimeout(function () {
        const node = document.getElementById(panelId);
        if (!node) return;
        if (node.dataset.rpcaInlineRewriteRequested === "1") return;
        node.dataset.rpcaInlineRewriteRequested = "1";
        const status = node.querySelector("[data-role='inline-rewrite-status']");
        // v0.7.340: explicit "Reading your original" status so the merchant
        // knows the AI is studying the actual passage, not generating from
        // a template. The Gemini prompt prefers surgical edits that
        // preserve the original meaning; the status text reflects that.
        if (status) status.textContent = "Reading your original passage and preparing a surgical edit that keeps your meaning where possible...";
        requestCompliantRewrite(finding, {})
          .then(function (rewrite) {
            if (!rewrite) {
              if (status) status.textContent = "Gemini did not return a rewrite. Showing the local placeholder above.";
              return;
            }
            const tgt = node.querySelector("[data-role='inline-rewrite-text']");
            if (tgt) tgt.textContent = '"' + rewrite + '"';
            const residual = (state.activeDraft && state.activeDraft.lastResidualTriggers) || [];
            const clean = state.activeDraft && state.activeDraft.lastScannedClean === true;
            const usedTemplate = state.activeDraft && state.activeDraft.lastUsedTemplateFallback === true;
            const attempts = Number(state.activeDraft && state.activeDraft.lastAttempts || 0);
            if (status) {
              if (usedTemplate) {
                // v0.7.309: Gemini couldn't beat the deterministic
                // template, so this is the safe built-in compliant entry
                // — never the raw un-rewritten original.
                status.style.color = "#15803d";
                status.textContent = "Showing the built-in compliant template — Gemini's attempts didn't improve on it. This is a clean research-catalog entry; edit it to match your exact product before publishing.";
              } else if (clean) {
                status.style.color = "#15803d";
                status.textContent = "Gemini scanner-aware review passed (took " + attempts + " attempt" + (attempts === 1 ? "" : "s") + "). Reads clean against every trigger the scanner watches for.";
              } else if (residual.length) {
                status.style.color = "#b45309";
                const phrases = residual.map(function (t) { return t && typeof t === "object" ? t.pattern : t; }).filter(Boolean).slice(0, 5);
                status.textContent = "Heads up — this rewrite still tripped: \"" + phrases.join('", "') + "\". Click Try again to regenerate or edit before publishing.";
              } else {
                status.textContent = "";
              }
            }
          })
          .catch(function () {
            if (status) status.textContent = "Could not reach Gemini for a scanner-aware rewrite. Showing the local placeholder above.";
          });
      }, 0);
    }
    return `
      <section class="rpca-rewrite-panel" id="${panelId}">
        <div class="rpca-rewrite-grid">
          <div><h4>Current text</h4><p><span class="rpca-diff-remove">"${escapeHtml(original)}"</span></p></div>
          <div><h4>Generated safer text <span style="font-weight:normal;font-size:11px;color:var(--ink-500);">(AI draft — beta, experimental; review before publishing)</span></h4><p><span class="rpca-diff-add" data-role="inline-rewrite-text">"${escapeHtml(placeholder)}"</span></p><p style="font-size:12px; color:var(--ink-500); margin-top:var(--space-2)">Why this helps: it keeps the research meaning but removes wording that could sound like use in people.</p><p data-role="inline-rewrite-status" style="font-size:12px;color:var(--ink-500);margin-top:6px;"></p></div>
        </div>
        ${optionalDisclaimer ? `<h4>Optional recommended disclaimer</h4><p>${escapeHtml(optionalDisclaimer)}</p>` : ""}
        <div class="rpca-button-row">
          <button type="button" class="rpca-button is-primary" data-action="edit-rewrite" data-index="${escapeHtml(state.currentFindingIndex)}">Edit text and take me to draft</button>
          <button type="button" class="rpca-button is-text" data-action="try-another">Try again</button>
        </div>
      </section>
    `;
  }

  function renderTaskFixPanel(finding, details) {
    const fix = finding.fix || {};
    const canPreview = Boolean(fix.canApply && fix.canPreview && finding.id);
    const original = originalTextForFinding(finding);
    const optionalDisclaimer = fix.optionalDisclaimer ? String(fix.optionalDisclaimer).trim() : "";
    return `
      <section class="rpca-rewrite-panel">
        <p class="rpca-eyebrow">${escapeHtml(details.label)}</p>
        <h4>What needs to happen</h4>
        <p>${escapeHtml(details.guidance)}</p>
        ${original ? `<h4>What triggered this</h4><p><span class="rpca-diff-remove">"${escapeHtml(original)}"</span></p>` : ""}
        ${optionalDisclaimer ? `<h4>Optional recommended disclaimer</h4><p>${escapeHtml(optionalDisclaimer)}</p>` : ""}
        <h4>Why there is no generated text</h4>
        <p>This item is about a missing page, server header, store setting, documentation record, claims-control policy, or operating process. Rewriting product copy would not fix it.</p>
        <h4>Who usually handles this</h4>
        <p>${escapeHtml(finding.actionOwner || "Store owner or developer")}</p>
        <div class="rpca-button-row">
          ${
            canPreview
              ? `<button type="button" class="rpca-button is-primary rpca-preview-fix" data-finding-id="${escapeHtml(finding.id || "")}">Preview change</button>`
              : `<span class="rpca-fix-manual">Use the flagged text, page URL, and instructions above to make this manual change.</span>`
          }
          <button type="button" class="rpca-button is-text" data-action="dismiss-finding" data-index="${state.currentFindingIndex}">Hide for now</button>
        </div>
      </section>
    `;
  }

function renderEmptyFindingState() {
  return `
    <section class="rpca-empty-state">
      <strong>No saved scan results yet</strong>
      <p>Go to the Cockpit and run a scan. This page will then show the items that need your attention.</p>
      <div class="rpca-button-row" style="justify-content:center;">
        <button type="button" class="rpca-button is-primary" data-action="start-scan" data-scan-mode="processor_audit">Run audit scan</button>
      </div>
    </section>
  `;
}

// v0.7.262: shown when a scan has finished AND found ZERO findings.
// Distinct from renderEmptyFindingState (which is shown before any
// scan has ever finished). The merchant gets a clean-bill-of-health
// confirmation instead of the misleading "No saved scan results yet"
// message that previously rendered for both states.
function renderAllClearFindingState() {
  const finishedAt = (state.lastScan && state.lastScan.finishedAt) || "";
  return `
    <section class="rpca-empty-state" style="background:#ecfdf5;border:1px solid #6ee7b7;color:#065f46;">
      <strong style="font-size:18px;">All clear &mdash; nothing to fix.</strong>
      <p>Your last scan${finishedAt ? ` (${escapeHtml(relativeTimeFromString(finishedAt))})` : ""} found zero open findings. Run another scan after publishing new content to keep this view current.</p>
      <div class="rpca-button-row" style="justify-content:center;">
        <button type="button" class="rpca-button is-primary" data-action="start-scan" data-scan-mode="processor_audit">Run another audit scan</button>
        <button type="button" class="rpca-button" data-section="cockpit">Open cockpit</button>
      </div>
    </section>
  `;
}

function renderNoMatchingFindingsState() {
  const labels = {
    triage: "Fix first",
    "quick-wins": "Easy fixes",
    underwriter: "Payment blockers",
  };
  const viewLabel = labels[state.findingView] || "this view";
  const hasSearch = Boolean((state.findingQuery || "").trim());
  return `
    <section class="rpca-empty-state">
      <strong>No items match ${escapeHtml(viewLabel)}</strong>
      <p>${hasSearch ? "Try a simpler search, or clear the search box to see everything in this view." : "Try another view to see the rest of your scan results."}</p>
    </section>
  `;
}

  function renderPagesSurface() {
    const scan = state.lastScan || {};
    const pages = Array.isArray(scan.pages) ? scan.pages : [];
    if (!pages.length) {
      return `
        <section class="rpca-empty-state">
          <strong>Pages will appear here after your first scan</strong>
          <p>This helps you see which product, policy, and content pages were reviewed.</p>
          <div class="rpca-button-row" style="justify-content:center;">
            <button type="button" class="rpca-button is-primary" data-action="start-scan" data-scan-mode="processor_audit">Run audit scan</button>
          </div>
        </section>
      `;
    }
    // v0.7.262: surface crawl telemetry so the merchant can see WHY
    // the crawler stopped where it did. Previously the Pages
    // Checked screen showed only the page list with no context -
    // when the crawler reached only 1 page despite 24 published
    // pages, the merchant had no way to diagnose. Now: if the
    // fetched count is significantly lower than the seed count,
    // we render an inline diagnostic banner with the time-budget /
    // page-cap / depth-cap reasons + a CTA to extend reach.
    const tel = scan.crawlTelemetry || {};
    const seedCount = Number(tel.targetDiscoverableUrlCount || tel.seedUrlCount || 0);
    const fetchedCount = Number(tel.fetchedPageCount || pages.length);
    const showTelemetryBanner = seedCount > 1 && fetchedCount < seedCount && fetchedCount < 5;
    let telemetryBanner = "";
    if (showTelemetryBanner) {
      const reasons = [];
      if (tel.hitTimeBudget) reasons.push(`hit the ${escapeHtml(tel.timeBudgetSeconds || 90)}-second crawl budget`);
      if (tel.hitPageCap) reasons.push(`hit the ${escapeHtml(tel.maxPages || 250)}-page cap`);
      if (tel.hitDepthCap) reasons.push(`hit the ${escapeHtml(tel.maxDepth || 8)}-depth cap`);
      if (tel.hitQueueLimit) reasons.push("hit the queue limit");
      const failedFetches = Number(tel.failedFetchCount || 0);
      if (failedFetches > 0) reasons.push(`${escapeHtml(failedFetches)} URLs failed to fetch`);
      const localFallbacks = Number(tel.localRenderFallbackCount || 0);
      if (localFallbacks > 0) reasons.push(`${escapeHtml(localFallbacks)} URLs were recovered with WordPress local rendering`);
      const skipped = Number(tel.skippedCandidateCount || 0);
      if (skipped > 0) reasons.push(`${escapeHtml(skipped)} URLs were skipped (off-site / robots.txt / already-seen)`);
      const reasonText = reasons.length ? reasons.join(", ") : "the crawler ran out of work to do";
      telemetryBanner = `
        <div class="rpca-pages-telemetry" style="background:#fefce8;border:1px solid #fde68a;border-radius:8px;color:#713f12;font-size:13px;line-height:1.5;margin:0 0 16px;padding:12px 16px;">
          <strong>Heads up:</strong> The scan reviewed ${escapeHtml(fetchedCount)} of ${escapeHtml(seedCount)} discoverable URLs because ${reasonText}. The default audit now runs in segmented exhaustive mode and uses WordPress local rendering when the host blocks loopback HTTP, but raw page-content scanning can still be limited by private URLs, robots rules, or true server errors.
        </div>
      `;
    }
    return `
      ${telemetryBanner}
      <div class="rpca-pages-table">
        ${pages
          .slice(0, 60)
          .map((page) => {
            const band = String(page.riskBand || page.riskLevel || "low").toLowerCase();
            const dotBand = band === "critical" || band === "red" ? "red" : band === "high" || band === "orange" ? "orange" : band === "medium" || band === "yellow" ? "yellow" : "green";
            return `
              <div class="rpca-pages-row">
                <span class="rpca-band-dot is-${dotBand}" aria-hidden="true"></span>
                <div>
                  <strong>${escapeHtml(page.title || page.url || "Page")}</strong>
                  <small>${escapeHtml(page.url || "")}</small>
                </div>
                <small>${escapeHtml(page.contentType || "Public page")}</small>
                <span class="rpca-severity-tag is-${escapeHtml(band)}">${escapeHtml((page.riskBand || page.riskLevel || "Low").toString())}</span>
              </div>
            `;
          })
          .join("")}
      </div>
    `;
  }

  function renderEvidenceSurface() {
    const data = state.addendum2 || {};
    const evidence = data.evidence || {};
    const synthetic = data.synthetic || {};
    const attestations = evidence.recentAttestations || [];
    const captures = evidence.recentRenderCaptures || [];
    const auditTimeline = uiState().auditTimeline || [];

    // v0.7.424 — empty-state explainers. Each tab now shows
    // (a) what the tab is for in plain English, (b) the live record
    // count, and (c) a "How this fills in" block when empty so the
    // merchant knows what to do to populate it instead of assuming
    // the surface is broken.

    // Order Proof.
    const orderProofExplainer = attestations.length > 0
      ? `<details><summary>Show saved records</summary>${renderPlainRecordList(attestations, "No checkout proof records have been saved yet.")}</details>`
      : `<div class="rpca-empty-state-hint">
          <h4 style="margin:12px 0 6px;font-size:13px;color:#1e293b;">How this fills in</h4>
          <p style="margin:0 0 6px;color:#475569;font-size:13px;">A row is automatically created the moment a buyer completes checkout in WooCommerce — the plugin captures the disclaimer they saw, the checkboxes they ticked, their research-purpose declaration, and a tamper-evident SHA-256 hash chain.</p>
          <p style="margin:0;color:#475569;font-size:13px;"><strong>Trigger:</strong> one completed WooCommerce order on a site with the recommended Checkout RUO + Terms plugin active.</p>
        </div>`;

    // Page Snapshots.
    const pageSnapshotsExplainer = captures.length > 0
      ? `<details><summary>Show saved records</summary>${renderPlainRecordList(captures, "No page snapshot records have been saved yet.")}</details>`
      : `<div class="rpca-empty-state-hint">
          <h4 style="margin:12px 0 6px;font-size:13px;color:#1e293b;">How this fills in</h4>
          <p style="margin:0 0 6px;color:#475569;font-size:13px;">For each completed order, the plugin captures what the checkout page actually looked like at the moment of purchase (rendered DOM + hash) so you can prove the RUO disclaimer was visible if a regulator or buyer asks.</p>
          <p style="margin:0;color:#475569;font-size:13px;"><strong>Trigger:</strong> the same as Order Proof — driven by completed WooCommerce orders. Sample-rate is set in Settings → render_capture_sample_rate (default 100%).</p>
        </div>`;

    // COA Checks.
    const coaSummary = coaPlainSummary(data.coaChain || {});
    const hasCoa = (data.coaChain || {}).products && (data.coaChain || {}).products.length > 0;
    const coaExplainer = hasCoa
      ? ``
      : `<div class="rpca-empty-state-hint">
          <h4 style="margin:12px 0 6px;font-size:13px;color:#1e293b;">How this fills in</h4>
          <p style="margin:0 0 6px;color:#475569;font-size:13px;">Tracks Certificate of Analysis verification across your products: which COAs are current, which are stale, and any reused-lot patterns acquirers flag.</p>
          <p style="margin:0;color:#475569;font-size:13px;"><strong>Trigger:</strong> upload a COA file or set the <code>_rpca_coa_hash</code> + <code>_rpca_lot_number</code> meta on a WooCommerce product (the Compliance Generator can publish a COA Library page that hosts the uploads).</p>
        </div>`;

    // Test Orders.
    const testOrdersLabel = synthetic.greenStreakLabel || "No test orders have run yet.";
    const synthEnabled = uiState().settingsToggles && uiState().settingsToggles.find && uiState().settingsToggles.find(t => t.key === "synthetic_buyer_enabled");
    const synthOn = synthEnabled ? synthEnabled.enabled : true;
    const testOrdersExplainer = synthOn
      ? `<div class="rpca-empty-state-hint">
          <h4 style="margin:12px 0 6px;font-size:13px;color:#1e293b;">How this fills in</h4>
          <p style="margin:0;color:#475569;font-size:13px;">The plugin runs safe local synthetic checkouts on a daily cron that exercise your age gate, shipping rules, and cart-pairing warnings — and logs any failures here for 14 days. They do not create real charges. If everything still works, this stays empty (that is the green state).</p>
        </div>`
      : `<div class="rpca-empty-state-hint">
          <h4 style="margin:12px 0 6px;font-size:13px;color:#1e293b;">How this fills in</h4>
          <p style="margin:0;color:#475569;font-size:13px;">Synthetic test orders are currently OFF. Turn on "Run test checkouts" in Setup → Settings toggles to start daily safe-mode checkout simulations that exercise your age gate, shipping rules, and cart-pairing warnings.</p>
        </div>`;

    // Recent Changes.
    const hasAudit = auditTimeline.length > 0;
    const changesExplainer = hasAudit
      ? renderAuditLanes(auditTimeline)
      : `<div class="rpca-empty-state-hint">
          <h4 style="margin:12px 0 6px;font-size:13px;color:#1e293b;">How this fills in</h4>
          <p style="margin:0;color:#475569;font-size:13px;">Every compliance-relevant change (option saves, scan runs, recommended-plugin installs, fixes applied) is logged here with a timestamp so you have an audit trail.</p>
          <p style="margin:0;color:#475569;font-size:13px;"><strong>Trigger:</strong> run a scan, click any Apply-fix button, install one of the recommended plugins from the Compliance Generator, or change a setting.</p>
        </div>`;

    return renderSubtabSurface("evidence", ["Order Proof", "Page Snapshots", "COA Checks", "Test Orders", "Changes"], [
      `<article class="rpca-editorial-card"><h3>Order proof</h3><p>Tamper-evident receipt of what the buyer checked and agreed to at checkout — the evidence packet you hand a payment processor or regulator who asks "prove the buyer agreed to RUO terms." <strong>Recent records: ${escapeHtml(String(attestations.length))}</strong>.</p>${orderProofExplainer}</article>`,
      `<article class="rpca-editorial-card"><h3>Page snapshots</h3><p>Proof of what the buyer actually saw on the page when they ordered (rendered DOM + hash) — the answer to "was the RUO disclaimer visible?" <strong>Recent snapshots: ${escapeHtml(String(captures.length))}</strong>.</p>${pageSnapshotsExplainer}</article>`,
      `<article class="rpca-editorial-card"><h3>COA checks</h3><p>${escapeHtml(coaSummary)}</p>${coaExplainer}</article>`,
      `<article class="rpca-editorial-card"><h3>Test orders</h3><p>${escapeHtml(testOrdersLabel)}</p>${testOrdersExplainer}</article>`,
      `<article class="rpca-editorial-card"><h3>Recent changes</h3><p>Timeline of compliance-relevant edits — the audit trail you can show during a payment-processor review.</p>${changesExplainer}</article>`,
    ]);
  }

  function renderIntelligenceSurface() {
    const data = state.addendum2 || {};
    return renderSubtabSurface("intelligence", ["New FDA Examples", "Rules to Review", "Extra Page Check"], [
      `<article class="rpca-editorial-card"><h3>New FDA examples</h3><p>When public FDA letters mention wording like yours, this page explains what changed and where it may matter on your site.</p></article>`,
      `<article class="rpca-editorial-card"><h3>Rules to review</h3><p>These are draft checks made from new public examples. They do not turn on until you review them.</p><details><summary>Show draft rules</summary>${renderPlainRecordList((data.intelligence || {}).fdaCandidates || [], "No draft FDA-derived rules are waiting for review.")}</details></article>`,
      `<article class="rpca-editorial-card"><h3>Extra page check</h3><p>This asks AI to look for subtle wording problems that the normal scan may have missed.</p><details><summary>Show recent notes</summary>${renderPlainRecordList((data.intelligence || {}).redTeamFindings || [], "No extra AI page-check notes have been saved yet.")}</details></article>`,
    ]);
  }

  // Curated list of policy / COA pages the merchant can generate. Keys
  // match the PHP `policy_page_template()` map so each Generate button
  // round-trips through the existing rpca_create_policy_page AJAX endpoint
  // without any new server-side wiring.
  const POLICY_TEMPLATES = [
    { key: "privacy",          label: "Privacy Policy",            blurb: "Required for every store. Explains what data you collect, how you use it, and how visitors opt out." },
    { key: "terms",            label: "Terms of Service",          blurb: "Sets the rules for using your site and buying from you. Card networks expect this." },
    { key: "shipping",         label: "Shipping Policy",           blurb: "Describes carriers, transit times, fulfillment center, and any restrictions." },
    { key: "refund",           label: "Refund Policy",             blurb: "States your refund window and conditions in plain language." },
    { key: "return",           label: "Return Policy",             blurb: "Describes how returns work, what is accepted, and how the buyer ships back." },
    { key: "cancellation",     label: "Cancellation Policy",       blurb: "Covers order cancellations and any cut-off windows." },
    { key: "referrer-policy",  label: "Referrer Policy",           blurb: "Explains browser referrer minimization, checkout/account path handling, and the preferred Referrer-Policy setting." },
    { key: "acceptable-use",   label: "Acceptable Use Policy",     blurb: "Tells buyers what they may not do with research products. Strong signal for processors." },
    { key: "ruo",              label: "Research-Use-Only Statement", blurb: "Site-wide research-use disclaimer page. Linked from product pages." },
    { key: "faq",              label: "Research-Use FAQ",          blurb: "Plain-language Q&A about RUO scope, COA, lots, storage, and ordering." },
    { key: "payment-security", label: "Payment Security",          blurb: "Card-data handling, PCI scope, and what the buyer should expect at checkout." },
    { key: "business-identity",label: "About Us",                  blurb: "Legal entity, address, descriptor, hours, escalation path. Most processors expect this." },
    { key: "quality",          label: "Quality, Manufacturing, & COA Library", blurb: "How you source, test, and document research compounds. Links to COAs." },
    { key: "contact",          label: "Contact",                   blurb: "Email, hours, response window, mailing address, and escalation path." },
    // v0.7.256: page generators for previously-uncovered surfaces.
    { key: "affiliate-creator-policy",   label: "Affiliate & Creator Policy",          blurb: "Required disclosures, forbidden claims, pre-publication review, and takedown authority for paid partners." },
    { key: "coupon-policy",              label: "Coupon & Promotion Policy",           blurb: "Coupon scope, eligibility, expiration, and the no-human-use framing rule for promotional pages." },
    { key: "newsletter-consent",         label: "Newsletter & Email Marketing Consent", blurb: "ROSCA / CAN-SPAM compliant opt-in disclosure, withdrawal mechanism, and data-keep statement." },
    { key: "buyer-verification-faq",     label: "Buyer Verification FAQ",              blurb: "Plain-English Q&A about why checkout asks for the research-use acknowledgment and ORCID/institution fields." },
    { key: "compliance-review-schedule", label: "Periodic Compliance Review Schedule", blurb: "Weekly / monthly / quarterly / annual review cadence reviewers can point to." },
  ];

  function renderPoliciesSurface() {
    const planChat = state.policyPlanChat || { open: false, history: [], policyKey: "", input: "", busy: false };
    // v0.7.290: per-policy existence map drives the green checkmark + the
    // checkbox state (existing pages are pre-checked-off so "Generate
    // selected" doesn't try to re-create them).
    const existence = (state.uiState && state.uiState.policyPagesGenerated) || {};
    const generatedCount = POLICY_TEMPLATES.filter(t => existence[t.key] && existence[t.key].exists).length;
    const cards = POLICY_TEMPLATES.map((tpl) => {
      const isActive = planChat.policyKey === tpl.key;
      const existInfo = existence[tpl.key] || { exists: false };
      const exists = !!existInfo.exists;
      const editUrlRaw = existInfo.editUrl || "";
      const viewUrlRaw = existInfo.viewUrl || "";
      return `
        <article class="rpca-editorial-card${isActive ? " is-active" : ""}${exists ? " rpca-policy-exists" : ""}" data-policy-card="${escapeHtml(tpl.key)}">
          <label class="rpca-policy-select" style="display:flex;align-items:flex-start;gap:10px;cursor:pointer;">
            <input type="checkbox" class="rpca-policy-checkbox" data-policy-key="${escapeHtml(tpl.key)}" ${exists ? "" : "checked"} ${exists ? 'aria-label="Already created — uncheck before bulk-regenerating"' : ""} style="margin-top:4px;width:16px;height:16px;flex-shrink:0;" />
            <div style="flex:1;">
              <h3 style="margin:0 0 4px;display:flex;align-items:center;gap:8px;">
                ${exists ? '<span class="rpca-check-mark" title="This page already exists on your site" aria-label="Generated" style="display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:#16a34a;color:#fff;font-size:12px;font-weight:bold;flex-shrink:0;">&#10003;</span>' : ""}
                ${escapeHtml(tpl.label)}
              </h3>
              <p style="margin:0;">${escapeHtml(tpl.blurb)}</p>
              ${exists && (editUrlRaw || viewUrlRaw) ? `<p class="rpca-policy-existing-links" style="margin:6px 0 0;font-size:12px;color:#15803d;">Already on your site &middot; ${editUrlRaw ? `<a href="${safeHref(editUrlRaw)}" target="_blank" rel="noopener noreferrer">Open editor</a>` : ""}${editUrlRaw && viewUrlRaw ? " &middot; " : ""}${viewUrlRaw ? `<a href="${safeHref(viewUrlRaw)}" target="_blank" rel="noopener noreferrer">View page</a>` : ""}</p>` : ""}
            </div>
          </label>
          <div class="rpca-card-actions" style="margin-top:10px;">
            <button type="button" class="rpca-button" data-action="generate-policy" data-policy-key="${escapeHtml(tpl.key)}">${exists ? "Regenerate" : "Generate page"}</button>
            <button type="button" class="rpca-button is-ghost" data-action="open-policy-chat" data-policy-key="${escapeHtml(tpl.key)}">Plan with AI (beta)</button>
          </div>
        </article>
      `;
    }).join("");

    const chatPanel = renderPolicyChatBubble(planChat);

    return `
      <div class="rpca-policies-surface">
        <header class="rpca-section-header">
          <h2>Compliance Generator</h2>
          <p class="rpca-section-lede">Generate every compliance surface a US RUO peptide store needs: policy pages including a Referrer Policy, the sitewide disclaimer banner, checkout buyer-acknowledgment, transactional email RUO reminders, downloadable operational records (SDS / recall protocol / CAPA / supplier audit / lot release / staff training), and copy-paste snippets (robots.txt entries, JSON-LD Organization schema, cart disclaimer HTML, cookie consent banner). Plus the EXPERIMENTAL theme-zip auto-fix uploader.</p>
        </header>
        ${renderPageRewriterCard()}
        <h3 class="rpca-generator-section-title">Policy pages</h3>
        <p class="rpca-generator-section-blurb">Drop a draft policy page into WordPress pre-filled with your store details. Tick the boxes for whichever policies you want, then click <strong>Generate selected</strong>. Pages with a green &#10003; are already on your site (uncheck them unless you want to regenerate).</p>
        <div class="rpca-policy-bulk-toolbar" style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:10px 0 14px;padding:12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;">
          <span style="color:#475569;font-size:13px;font-weight:500;">${generatedCount} of ${POLICY_TEMPLATES.length} policies on your site</span>
          <span style="flex:1;"></span>
          <button type="button" class="rpca-button is-ghost" data-action="policy-bulk-select-all">Select all</button>
          <button type="button" class="rpca-button is-ghost" data-action="policy-bulk-select-missing">Select missing only</button>
          <button type="button" class="rpca-button is-ghost" data-action="policy-bulk-clear">Clear selection</button>
          <button type="button" class="rpca-button is-primary" data-action="policy-bulk-generate">
            <span class="rpca-button-label">Generate selected</span>
            <span class="rpca-spinner" data-role="bulk-policy-spinner" hidden aria-hidden="true"></span>
          </button>
          <button type="button" class="rpca-button" data-action="policy-bulk-generate-all" title="Generate every policy that does not exist yet">Generate all missing</button>
        </div>
        <div id="rpca-policy-bulk-status" class="rpca-inline-status" hidden></div>
        <section class="rpca-card-grid">${cards}</section>
        <h3 class="rpca-generator-section-title" style="margin-top:32px;">Recommended free plugins (one-click install)</h3>
        <p class="rpca-generator-section-blurb">Compliance Guard delegates these three controls to free WordPress plugins so it can focus on scanning and evidence. Each card downloads + activates the recommended plugin from wp.org in one click. Compliance Guard will detect the active plugin on the next scan and credit you for it.</p>
        ${renderRecommendedPluginCard("age_gate")}
        ${renderRecommendedPluginCard("disclaimer_banner")}
        ${renderRecommendedPluginCard("checkout_acknowledgement")}
        ${renderComplianceAssetsCatalog()}
        ${chatPanel}
      </div>
    `;
  }

  // v0.7.256: catalog of compliance assets that aren't WordPress
  // pages - text snippets (robots.txt, JSON-LD, cart disclaimer,
  // cookie consent), email-template reminders (order confirmation,
  // shipment notification, refund processed, buyer-verification
  // request), and downloadable HTML operational records (SDS,
  // recall protocol, CAPA, quality complaint log, supplier audit,
  // lot release, staff training). Each card renders the right
  // action ("Show snippet" / "Show template" / "Download HTML")
  // for its type. Server-side body lookup happens in
  // ajax_get_compliance_asset / ajax_download_compliance_asset.
  function renderComplianceAssetsCatalog() {
    const catalog = (window.RPCA && RPCA.uiState && RPCA.uiState.complianceAssets) || {};
    const keys = Object.keys(catalog);
    if (keys.length === 0) return "";
    const groupOrder = [
      ["snippets", "Copy-paste snippets", "Drop these into your theme files, robots.txt, or page-builder Custom HTML blocks. Snippet content is shown inline so you can copy it without leaving the page."],
      ["email_templates", "Transactional email reminders", "Append-blocks for your order-confirmation, shipment-notification, refund-processed, and buyer-verification-request emails. Paste into your transactional email automation (WooCommerce templates, Mailchimp, SendGrid, etc.)."],
      ["documents", "Downloadable operational records", "HTML files you can print, sign, or convert to PDF. These are the records reviewers ask for: SDS, recall protocol, CAPA, quality complaint log, supplier audit, lot release, staff training. Each is a starter skeleton with yellow-highlighted fields you fill in per instance."],
    ];
    const grouped = groupOrder.map(([category, heading, blurb]) => {
      const items = keys
        .filter((k) => catalog[k] && catalog[k].category === category)
        .map((k) => {
          const a = catalog[k];
          const actionLabel =
            a.type === "document" ? "Download HTML" :
            a.type === "email_template" ? "Show template" :
            "Show snippet";
          const actionData =
            a.type === "document" ? `data-action="compliance-asset-download" data-asset-key="${escapeHtml(k)}"` :
            `data-action="compliance-asset-view" data-asset-key="${escapeHtml(k)}"`;
          // v0.7.424 — per-asset install / use tutorial. Rendered
          // as a collapsible <details> panel below the action button
          // so merchants know exactly where each snippet goes,
          // which WooCommerce email settings tab to open for each
          // template, and how to fill the downloadable documents.
          const steps = Array.isArray(a.tutorial) ? a.tutorial : [];
          let tutorialBlock = "";
          if (steps.length > 0) {
            const stepsHtml = steps.map((s) => `<li style="margin:0 0 6px;">${escapeHtml(String(s))}</li>`).join("");
            tutorialBlock = `
              <details class="rpca-compliance-asset-tutorial" style="margin-top:12px;border-top:1px solid #e2e8f0;padding-top:10px;">
                <summary style="cursor:pointer;font-weight:600;color:#1e293b;font-size:13px;">How to install / use this (${steps.length} step${steps.length === 1 ? "" : "s"})</summary>
                <ol style="margin:10px 0 0 20px;padding:0;color:#475569;font-size:13px;line-height:1.5;">${stepsHtml}</ol>
              </details>
            `;
          }
          // v0.7.438 — one-click Apply / Remove button for snippets +
          // transactional emails that have a registered WP / WC hook
          // path. When auto-inject is on, the card shows a green
          // "Applied" status pill + a "Remove" button; when off, a
          // primary "Apply" button injects the snippet site-wide
          // (or appends the template body to the matching WC email).
          // The copy/show flow remains for merchants who want the
          // raw text for an external CMS or transactional-email
          // service.
          let applyBlock = "";
          if (a.autoInjectSupported) {
            const isActive = !!a.autoInjectActive;
            const venue = String(a.autoInjectVenue || "applied site-wide");
            const statusPill = isActive
              ? `<span class="rpca-compliance-asset-status rpca-pill is-good" style="display:inline-flex;align-items:center;gap:6px;background:#ecfdf5;border:1px solid #6ee7b7;color:#065f46;border-radius:999px;font-size:12px;font-weight:600;padding:3px 10px;"><span aria-hidden="true">&#10003;</span> Applied — ${escapeHtml(venue)}</span>`
              : "";
            const applyButton = isActive
              ? `<button type="button" class="rpca-button is-text" data-action="compliance-asset-apply-toggle" data-asset-key="${escapeHtml(k)}" data-asset-state="off">Remove auto-apply</button>`
              : `<button type="button" class="rpca-button is-primary" data-action="compliance-asset-apply-toggle" data-asset-key="${escapeHtml(k)}" data-asset-state="on">Apply automatically</button>`;
            applyBlock = `
              <div class="rpca-compliance-asset-apply" style="margin-top:10px;display:flex;flex-wrap:wrap;gap:10px;align-items:center;">
                ${applyButton}
                ${statusPill}
              </div>
              <p class="rpca-compliance-asset-apply-blurb" style="margin:6px 0 0;color:#64748B;font-size:12px;">One-click apply uses the same content shown in this card. Toggling off removes it immediately — your stored options stay intact.</p>
            `;
          }
          return `
            <article class="rpca-editorial-card rpca-compliance-asset-card" data-rpca-asset-key="${escapeHtml(k)}">
              <h3>${escapeHtml(a.label || k)}</h3>
              <p>${escapeHtml(a.description || "")}</p>
              <div class="rpca-card-actions">
                <button type="button" class="rpca-button" ${actionData}>${escapeHtml(actionLabel)}</button>
              </div>
              ${applyBlock}
              <div class="rpca-compliance-asset-body" data-rpca-asset-body hidden>
                <textarea readonly rows="10" data-rpca-asset-textarea style="width:100%;font-family:monospace;font-size:12px;padding:8px;"></textarea>
                <div style="display:flex;gap:8px;margin-top:8px;">
                  <button type="button" class="rpca-button is-primary" data-action="compliance-asset-copy" data-asset-key="${escapeHtml(k)}">Copy to clipboard</button>
                  <button type="button" class="rpca-button is-text" data-action="compliance-asset-hide" data-asset-key="${escapeHtml(k)}">Hide</button>
                </div>
              </div>
              ${tutorialBlock}
            </article>
          `;
        })
        .join("");
      if (items === "") return "";
      return `
        <h3 class="rpca-generator-section-title" style="margin-top:32px;">${escapeHtml(heading)}</h3>
        <p class="rpca-generator-section-blurb">${escapeHtml(blurb)}</p>
        <section class="rpca-card-grid">${items}</section>
      `;
    }).join("");
    return grouped;
  }

  // v0.7.379 (user directive): single-page rewrite card. The
  // merchant picks a page from a dropdown, clicks Generate new text,
  // and gets a per-finding list with the original wording on the
  // left and a unique compliant rewrite + copy button on the right.
  // The scan runs the same per-page rule engine the sitewide scanner
  // uses (matches_for_rule + guards + proximity scoring + dedup +
  // consolidation), so the findings shown here match what the full
  // scan would surface for this single page.
  function renderPageRewriterCard() {
    if (!state.pageRewriter) {
      state.pageRewriter = {
        pagesLoaded: false,
        pagesLoading: false,
        pagesError: "",
        pages: [],
        selectedId: 0,
        busy: false,
        lastResult: null,
        lastError: "",
        copiedFindingId: "",
      };
    }
    // Auto-load the pages list the first time the card renders so
    // the merchant doesn't have to click an extra button before
    // they can pick a page. Deferred via setTimeout so the load
    // request fires AFTER the current render returns.
    if (!state.pageRewriter.pagesLoaded && !state.pageRewriter.pagesLoading && !state.pageRewriter.pagesError) {
      setTimeout(() => {
        if (state.pageRewriter && !state.pageRewriter.pagesLoaded && !state.pageRewriter.pagesLoading) {
          refreshPageRewriterPagesList();
        }
      }, 50);
    }
    const pr = state.pageRewriter;
    const pagesOptions = pr.pages.map((p) => {
      const sel = pr.selectedId === p.id ? " selected" : "";
      const status = p.status && p.status !== "publish" ? " [" + escapeHtml(p.status) + "]" : "";
      const typeLabel = p.postType ? " (" + escapeHtml(p.postType) + ")" : "";
      return `<option value="${escapeHtml(String(p.id))}"${sel}>${escapeHtml(p.title)}${typeLabel}${status}</option>`;
    }).join("");

    const hasResult = pr.lastResult && pr.lastResult.findings && pr.lastResult.post;
    const result = hasResult ? pr.lastResult : null;
    const summary = result ? (result.summary || {}) : {};
    const findings = result ? (result.findings || []) : [];

    let body = "";
    if (pr.busy) {
      body = `<p class="rpca-page-rewriter-status" style="margin:14px 0 0;color:#64748B;">Scanning the page… running the same per-page rule engine the sitewide scan uses. This usually takes 5–15 seconds.</p>`;
    } else if (pr.lastError) {
      body = `<div class="rpca-page-rewriter-error" style="background:#fee2e2;border:1px solid #fca5a5;border-radius:8px;padding:12px 16px;margin-top:14px;color:#991b1b;font-size:13px;">${escapeHtml(pr.lastError)}</div>`;
    } else if (hasResult) {
      const post = result.post || {};
      const headerBlurb = result.clean
        ? `<strong>${escapeHtml(post.title || "(no title)")}</strong> &mdash; <span style="color:#15803d;">No editable compliance issues were found on this page.</span> Run a full sitewide scan if you want infrastructure / policy-page / header-level findings.`
        : `<strong>${escapeHtml(post.title || "(no title)")}</strong> &mdash; ${summary.total || 0} finding${(summary.total || 0) === 1 ? "" : "s"}${summary.editable ? `, ${summary.editable} with copy-paste replacement${summary.editable === 1 ? "" : "s"} ready below` : ""}.`;
      const editLink = post.editUrl ? `<a class="rpca-button" href="${safeHref(post.editUrl)}" target="_blank" rel="noopener noreferrer">Open page editor</a>` : "";
      const viewLink = post.viewUrl ? `<a class="rpca-button is-ghost" href="${safeHref(post.viewUrl)}" target="_blank" rel="noopener noreferrer">View live page</a>` : "";
      const rows = findings.map((f, idx) => renderPageRewriterFindingRow(f, idx, pr.copiedFindingId)).join("");
      body = `
        <div class="rpca-page-rewriter-result" style="margin-top:16px;border:1px solid #cbd5e1;border-radius:8px;padding:14px 16px;background:#f8fafc;">
          <div style="display:flex;flex-wrap:wrap;gap:10px;align-items:center;justify-content:space-between;margin-bottom:10px;">
            <p style="margin:0;">${headerBlurb}</p>
            <div style="display:flex;gap:8px;flex-wrap:wrap;">${editLink}${viewLink}</div>
          </div>
          ${rows ? `<ol class="rpca-page-rewriter-findings" style="list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:12px;">${rows}</ol>` : ""}
        </div>
      `;
    }

    let pickerLine = "";
    if (pr.pagesLoading) {
      pickerLine = `<p style="margin:0;color:#64748B;">Loading the pages list…</p>`;
    } else if (pr.pagesError) {
      pickerLine = `<div style="background:#fef3c7;border:1px solid #fcd34d;color:#92400e;border-radius:6px;padding:8px 10px;font-size:13px;margin-bottom:8px;">Could not load the pages list: ${escapeHtml(pr.pagesError)}. <button type="button" class="rpca-button is-text" data-action="page-rewriter-refresh-pages">Try again</button></div>`;
    } else if (!pr.pagesLoaded) {
      pickerLine = `<p style="margin:0;color:#64748B;">Click <em>Load pages</em> to populate the picker.</p><button type="button" class="rpca-button" data-action="page-rewriter-refresh-pages">Load pages</button>`;
    } else if (!pr.pages.length) {
      pickerLine = `<p style="margin:0;color:#64748B;">No editable pages, posts, or products were found on this site.</p>`;
    } else {
      pickerLine = `
        <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;">
          <label style="flex:1 1 320px;display:flex;flex-direction:column;gap:6px;min-width:280px;">
            <strong style="font-size:13px;color:#1e293b;">Pick a page (${pr.pages.length} available)</strong>
            <select data-rpca-page-rewriter-picker style="padding:8px 10px;border:1px solid #94a3b8;border-radius:6px;background:#fff;font-size:14px;">
              <option value="0">— Select a page —</option>
              ${pagesOptions}
            </select>
          </label>
          <button type="button" class="rpca-button is-primary" data-action="page-rewriter-generate"${pr.busy ? " disabled" : ""}>${pr.busy ? "Scanning…" : "Generate new text"}</button>
          <button type="button" class="rpca-button is-ghost" data-action="page-rewriter-refresh-pages" title="Reload the pages list">Refresh list</button>
        </div>
        <small style="display:block;margin-top:6px;color:#64748B;">Runs the same per-page compliance rules the sitewide scan uses, then drafts a unique rewrite for each editable finding.</small>
      `;
    }

    return `
      <section class="rpca-editorial-card rpca-page-rewriter-card" id="page-rewriter" style="margin-top:24px;border:1px solid #0ea5e9;background:#f0f9ff;">
        <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px;">
          <span style="display:inline-block;background:#0ea5e9;color:#fff;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:0.06em;text-transform:uppercase;padding:3px 10px;">Single page</span>
          <h3 style="margin:0;">Rewrite one page on demand</h3>
        </div>
        <p style="margin:0 0 10px;">Pick any page, post, or product, then click <strong>Generate new text</strong>. Compliance Guard re-runs the same per-page rule engine the sitewide scan uses, but only against the page you picked, and drafts a unique, page-specific rewrite for every editable finding so you can copy each one straight into the page editor.</p>
        ${pickerLine}
        ${body}
      </section>
    `;
  }

  function renderPageRewriterFindingRow(finding, idx, copiedFindingId) {
    const sevColors = {
      critical: { bg: "#fef2f2", border: "#fca5a5", text: "#991b1b" },
      high:     { bg: "#fff7ed", border: "#fdba74", text: "#9a3412" },
      medium:   { bg: "#fefce8", border: "#fde68a", text: "#854d0e" },
      low:      { bg: "#f0f9ff", border: "#bae6fd", text: "#075985" },
    };
    const sev = String(finding.severity || "low").toLowerCase();
    const color = sevColors[sev] || sevColors.low;
    const original = String(finding.originalSnippet || "").trim();
    const rewrite = String(finding.generatedRewrite || "").trim();
    const canCopy = !!finding.canGenerateText && rewrite.length > 0;
    const isCopied = copiedFindingId && copiedFindingId === finding.id;
    const consolidated = Array.isArray(finding.consolidatedRules) ? finding.consolidatedRules : [];
    const consolidatedChips = consolidated.length
      ? `<div style="margin-top:6px;display:flex;flex-wrap:wrap;gap:4px;">
          <span style="font-size:11px;color:#64748B;font-weight:600;">Also flagged by:</span>
          ${consolidated.filter((r) => r && r.label && String(r.label).toLowerCase() !== String(finding.rule || "").toLowerCase()).map((r) => `<span style="background:#eef2ff;border:1px solid #c7d2fe;color:#3730a3;padding:1px 6px;border-radius:10px;font-size:11px;">${escapeHtml(r.label)}</span>`).join("")}
        </div>`
      : "";
    const copyButton = canCopy
      ? `<button type="button" class="rpca-button${isCopied ? "" : " is-primary"}" data-action="page-rewriter-copy" data-finding-id="${escapeHtml(finding.id || "")}" data-finding-index="${idx}">${isCopied ? "✓ Copied" : "Copy rewrite"}</button>`
      : `<span style="font-size:12px;color:#64748B;">Not a wording fix — see guidance.</span>`;
    const rewriteBlock = canCopy
      ? `<div style="background:#fff;border:1px dashed #16a34a;border-radius:6px;padding:10px 12px;color:#0f172a;font-size:13.5px;line-height:1.55;" data-rpca-rewrite-text="${idx}">${escapeHtml(rewrite)}</div>`
      : `<div style="background:#fff;border:1px solid #e2e8f0;border-radius:6px;padding:10px 12px;color:#475569;font-size:13px;line-height:1.55;font-style:italic;">${escapeHtml(rewrite || finding.guidance || "This finding needs a manual action — see Why this matters.")}</div>`;
    return `
      <li class="rpca-page-rewriter-row" style="background:${color.bg};border:1px solid ${color.border};border-radius:8px;padding:12px 14px;">
        <div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:space-between;margin-bottom:6px;">
          <div style="display:flex;flex-wrap:wrap;gap:6px;align-items:center;">
            <span style="background:${color.text};color:#fff;font-size:11px;letter-spacing:.05em;text-transform:uppercase;padding:2px 8px;border-radius:10px;font-weight:700;">${escapeHtml(finding.severityLabel || sev)}</span>
            <strong style="color:${color.text};font-size:14px;">${escapeHtml(finding.rule || "Compliance finding")}</strong>
          </div>
          ${copyButton}
        </div>
        ${finding.reason ? `<p style="margin:0 0 8px;color:#334155;font-size:13px;">${escapeHtml(finding.reason)}</p>` : ""}
        ${consolidatedChips}
        ${original ? `<div style="margin-top:8px;font-size:12px;color:#475569;font-weight:600;">Flagged wording on this page</div>
          <div style="background:#fff;border:1px solid ${color.border};border-radius:6px;padding:8px 10px;margin-top:4px;color:${color.text};font-size:13px;line-height:1.5;">"${escapeHtml(original.slice(0, 600))}${original.length > 600 ? "…" : ""}"</div>` : ""}
        <div style="margin-top:8px;font-size:12px;color:#15803d;font-weight:600;">${canCopy ? "Suggested compliant rewrite (unique to this page)" : "What to do instead"}</div>
        <div style="margin-top:4px;">${rewriteBlock}</div>
      </li>
    `;
  }

  function refreshPageRewriterPagesList() {
    state.pageRewriter = state.pageRewriter || {};
    state.pageRewriter.pagesLoading = true;
    state.pageRewriter.pagesError = "";
    renderAdminShell();
    post("rpca_list_pages_for_rewrite", {})
      .then((data) => {
        state.pageRewriter.pagesLoaded = true;
        state.pageRewriter.pagesLoading = false;
        state.pageRewriter.pagesError = "";
        state.pageRewriter.pages = Array.isArray(data && data.pages) ? data.pages : [];
        renderAdminShell();
      })
      .catch((err) => {
        state.pageRewriter.pagesLoading = false;
        state.pageRewriter.pagesError = (err && err.message) || "Network error.";
        renderAdminShell();
      });
  }

  // v0.7.391 (Feature 3): one-click apply-all-safe-fixes. Two-step
  // flow — preview first (what will change), confirm, then apply.
  // WordPress revisions are the undo; the confirm dialog says so.
  function runApplyAllSafeFixes() {
    toast("Checking which fixes can be applied safely…");
    post("rpca_preview_safe_fixes", {})
      .then((data) => {
        const fixCount = Number((data && data.fixCount) || 0);
        const postCount = Number((data && data.postCount) || 0);
        if (fixCount <= 0) {
          toast("No safe one-click fixes are available right now. Remaining findings need manual review.");
          return;
        }
        const deferred = Number((data && data.deferredPostCount) || 0);
        const willApply = Number((data && data.willApplyPostCount) || postCount);
        const lines = [
          "Apply " + fixCount + " safe fix" + (fixCount === 1 ? "" : "es") + " across " + postCount + " page" + (postCount === 1 ? "" : "s") + "?",
          "",
        ];
        (data.posts || []).slice(0, 12).forEach((p) => {
          lines.push("• " + (p.title || "(untitled)") + " (" + p.postType + ", " + p.status + ") — " + p.fixCount + " fix" + (p.fixCount === 1 ? "" : "es"));
        });
        if ((data.posts || []).length > 12) {
          lines.push("…and " + ((data.posts || []).length - 12) + " more page(s).");
        }
        lines.push("");
        if (deferred > 0) {
          lines.push("This click applies the first " + willApply + " page(s); " + deferred + " more will be deferred — click again to continue.");
        }
        lines.push("Each changed page gets a WordPress revision you can restore. Continue?");
        if (!window.confirm(lines.join("\n"))) {
          return;
        }
        toast("Applying safe fixes…");
        return post("rpca_apply_safe_fixes", {}).then((result) => {
          const applied = Number((result && result.appliedCount) || 0);
          const changed = Number((result && result.postsChanged) || 0);
          const failed = ((result && result.failed) || []).length;
          const skipped = ((result && result.skipped) || []).length;
          const remaining = Number((result && result.remainingPosts) || 0);
          let msg = (result && result.message) || ("Applied " + applied + " fix(es) across " + changed + " page(s).");
          if (failed) msg += " " + failed + " could not be applied.";
          if (skipped) msg += " " + skipped + " skipped (page changed or no permission).";
          if (remaining) msg += " " + remaining + " page(s) deferred — click again to continue.";
          toast(msg);
          // Mark every applied finding in the client-side applied
          // ledger — same mechanism the single-fix "Apply" button
          // uses — so the findings list reflects the batch result.
          ((result && result.appliedFindingIds) || []).forEach((fid) => {
            try { markFindingApplied(fid); } catch (e) { /* noop */ }
          });
          renderAdminShell();
        });
      })
      .catch((err) => {
        toast("Apply-all-safe-fixes failed: " + ((err && err.message) || "unknown error"));
      });
  }

  function runPageRewriterScan() {
    state.pageRewriter = state.pageRewriter || {};
    const picker = document.querySelector("[data-rpca-page-rewriter-picker]");
    const id = picker ? Number(picker.value || 0) : 0;
    if (!id) {
      toast("Pick a page from the dropdown first.");
      return;
    }
    state.pageRewriter.selectedId = id;
    state.pageRewriter.busy = true;
    state.pageRewriter.lastError = "";
    state.pageRewriter.lastResult = null;
    state.pageRewriter.copiedFindingId = "";
    renderAdminShell();
    post("rpca_page_rewrite_scan", { post_id: id })
      .then((data) => {
        state.pageRewriter.busy = false;
        state.pageRewriter.lastResult = data || null;
        renderAdminShell();
      })
      .catch((err) => {
        state.pageRewriter.busy = false;
        state.pageRewriter.lastError = (err && err.message) || "The scan failed. Try again.";
        renderAdminShell();
      });
  }

  function copyPageRewriterRewrite(findingId, index) {
    state.pageRewriter = state.pageRewriter || {};
    const findings = (state.pageRewriter.lastResult && state.pageRewriter.lastResult.findings) || [];
    let row = null;
    if (findingId) {
      row = findings.find((f) => f && f.id === findingId) || null;
    }
    if (!row && Number.isFinite(index)) {
      row = findings[index] || null;
    }
    if (!row || !row.copyText) {
      toast("Nothing to copy for this finding.");
      return;
    }
    const text = String(row.copyText);
    const finish = () => {
      state.pageRewriter.copiedFindingId = row.id || "";
      renderAdminShell();
      toast("Rewrite copied. Paste it into the page editor.");
      setTimeout(() => {
        if (state.pageRewriter && state.pageRewriter.copiedFindingId === (row.id || "")) {
          state.pageRewriter.copiedFindingId = "";
          renderAdminShell();
        }
      }, 4000);
    };
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(text).then(finish, () => fallbackCopyText(text, finish));
    } else {
      fallbackCopyText(text, finish);
    }
  }

  function fallbackCopyText(text, onDone) {
    const ta = document.createElement("textarea");
    ta.value = text;
    ta.style.position = "fixed";
    ta.style.opacity = "0";
    document.body.appendChild(ta);
    ta.select();
    let ok = false;
    try { ok = document.execCommand("copy"); } catch (_e) { ok = false; }
    document.body.removeChild(ta);
    if (ok && typeof onDone === "function") onDone();
    else toast("Could not copy. Select the text manually and copy.");
  }

  // v0.7.421: renders ONE recommended-plugin card for a feature key
  // (age_gate / disclaimer_banner / checkout_acknowledgement). Reads
  // catalog + status from RPCA.uiState.recommendedPlugins.features.
  // The install / activate button POSTs to the existing
  // wp_ajax_rpca_install_recommended_plugin endpoint via
  // handleInstallRecommendedPlugin(). The card replaces the in-house
  // banner + buyer-ack cards on the Compliance Generator page; age
  // gate is also rendered here so all three delegated features live
  // in one place.
  function renderRecommendedPluginCard(featureKey) {
    const payload = (window.RPCA && RPCA.uiState && RPCA.uiState.recommendedPlugins) || {};
    const features = payload.features || {};
    const entry = features[featureKey];
    if (!entry || !entry.recommended) return "";
    const rec = entry.recommended;
    const nonce = String(payload.nonce || "");
    const canInstall = !!payload.canInstall;
    const canActivate = !!payload.canActivate;
    const installed = !!rec.installed;
    const active = !!rec.active;
    let stateLabel, stateDotClass;
    if (active) {
      stateLabel = "Installed and active";
      stateDotClass = "rpca-recommended-dot-on";
    } else if (installed) {
      stateLabel = "Installed but not active";
      stateDotClass = "rpca-recommended-dot-inactive";
    } else {
      stateLabel = "Not installed";
      stateDotClass = "rpca-recommended-dot-off";
    }
    const versionPill = rec.version ? ` <span class="rpca-recommended-version">v${escapeHtml(String(rec.version))}</span>` : "";
    let primaryAction = "";
    if (!installed && canInstall) {
      primaryAction = `<button type="button" class="rpca-button is-primary" data-action="install-recommended-plugin" data-feature-key="${escapeHtml(featureKey)}" data-slug="${escapeHtml(rec.slug)}" data-nonce="${escapeHtml(nonce)}">Install + activate from wp.org</button>`;
    } else if (installed && !active && canActivate) {
      primaryAction = `<button type="button" class="rpca-button is-primary" data-action="install-recommended-plugin" data-feature-key="${escapeHtml(featureKey)}" data-slug="${escapeHtml(rec.slug)}" data-nonce="${escapeHtml(nonce)}">Activate the installed plugin</button>`;
    } else if (active) {
      primaryAction = `<span class="rpca-recommended-active-badge">Active — configure from the plugin's own settings page</span>`;
    } else if (!canInstall && !installed) {
      primaryAction = `<span class="rpca-recommended-no-cap">Your account does not have permission to install plugins. Ask an administrator to install the recommended plugin above.</span>`;
    }
    const wpOrgLink = rec.wpOrgUrl
      ? `<a class="rpca-button is-ghost" href="${safeHref(rec.wpOrgUrl)}" target="_blank" rel="noopener noreferrer">View on wp.org</a>`
      : "";
    const alts = Array.isArray(entry.alternatives) ? entry.alternatives : [];
    let altsBlock = "";
    if (alts.length > 0) {
      const altsList = alts.map((a) => {
        return `<li><a href="${safeHref(a.wpOrgUrl || "")}" target="_blank" rel="noopener noreferrer"><strong>${escapeHtml(a.name || "")}</strong></a> — by ${escapeHtml(a.author || "")}. ${escapeHtml(a.why || "")}</li>`;
      }).join("");
      altsBlock = `<details class="rpca-recommended-alts"><summary>Alternative plugins (optional)</summary><ul>${altsList}</ul></details>`;
    }
    return `
      <section class="rpca-editorial-card rpca-recommended-card" id="recommended-${escapeHtml(featureKey)}" data-feature-key="${escapeHtml(featureKey)}" style="margin-top:24px;">
        <header class="rpca-recommended-head">
          <p class="rpca-kicker" style="text-transform:uppercase;letter-spacing:.04em;color:#64748b;font-size:11px;margin:0 0 4px;">Free WordPress plugin</p>
          <h3 style="margin:0 0 6px;">${escapeHtml(entry.featureLabel || "")}</h3>
          <p style="margin:0 0 14px;color:#475569;">${escapeHtml(entry.merchantProblem || "")}</p>
        </header>
        <div class="rpca-recommended-body" style="display:flex;flex-wrap:wrap;gap:18px;align-items:flex-start;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:14px 16px;">
          <div class="rpca-recommended-info" style="flex:1 1 320px;min-width:280px;">
            <h4 style="margin:0 0 4px;font-size:15px;">${escapeHtml(rec.name || "")}</h4>
            <p style="margin:0 0 6px;color:#64748b;font-size:13px;">by ${escapeHtml(rec.author || "")} — from the official WordPress plugin directory</p>
            <p style="margin:0 0 8px;font-size:13px;">${escapeHtml(rec.why || "")}</p>
            <p class="rpca-recommended-state" style="margin:8px 0 0;display:flex;align-items:center;gap:8px;font-size:13px;">
              <span class="rpca-recommended-dot ${stateDotClass}" aria-hidden="true" style="width:9px;height:9px;border-radius:999px;display:inline-block;"></span>
              <strong>${escapeHtml(stateLabel)}</strong>${versionPill}
            </p>
          </div>
          <div class="rpca-recommended-actions" style="display:flex;flex-direction:column;gap:8px;min-width:220px;align-items:flex-start;">
            ${primaryAction}
            ${wpOrgLink}
          </div>
        </div>
        ${altsBlock}
        <p class="rpca-recommended-feedback" data-rpca-recommended-feedback role="status" aria-live="polite" style="margin:10px 0 0;min-height:18px;font-size:13px;color:#475569;"></p>
      </section>
    `;
  }

  // v0.7.421: click handler for the install-recommended-plugin
  // buttons inside the Compliance Generator cards. Hits the
  // existing wp_ajax_rpca_install_recommended_plugin endpoint
  // and reloads the section on success so the card re-renders
  // with the updated install state.
  function handleInstallRecommendedPlugin(btn) {
    if (!btn || btn.disabled) return;
    const slug = btn.getAttribute("data-slug") || "";
    const nonce = btn.getAttribute("data-nonce") || "";
    const card = btn.closest(".rpca-recommended-card");
    const feedback = card ? card.querySelector("[data-rpca-recommended-feedback]") : null;
    if (!slug) return;
    btn.disabled = true;
    const originalText = btn.textContent;
    btn.textContent = "Working…";
    if (feedback) {
      feedback.textContent = "";
      feedback.style.color = "#475569";
    }
    const body = new URLSearchParams();
    body.set("action", "rpca_install_recommended_plugin");
    body.set("slug", slug);
    body.set("nonce", nonce);
    body.set("_wpnonce", nonce);
    fetch(RPCA.ajaxUrl, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString() })
      .then((r) => r.json().catch(() => ({ success: false, data: { message: "Invalid JSON response." } })))
      .then((json) => {
        if (json && json.success) {
          if (feedback) {
            feedback.textContent = "Done. Reloading…";
            feedback.style.color = "#15803d";
          }
          setTimeout(() => { window.location.reload(); }, 600);
        } else {
          const msg = (json && json.data && json.data.message) || "Install failed. Try again or use the wp.org link.";
          if (feedback) {
            feedback.textContent = String(msg);
            feedback.style.color = "#991b1b";
          }
          btn.disabled = false;
          btn.textContent = originalText;
        }
      })
      .catch((err) => {
        if (feedback) {
          feedback.textContent = String((err && err.message) || err);
          feedback.style.color = "#991b1b";
        }
        btn.disabled = false;
        btn.textContent = originalText;
      });
  }

  // v0.7.435: Apply / change / remove the public trust badge from the
  // site footer in one click. POSTs to the new
  // wp_ajax_rpca_set_badge_auto_inject endpoint, surfaces the server
  // response in the card's feedback line, and re-renders the cockpit
  // (state.lastScan + uiState are stale on success — easiest fix is
  // to reload so the badge card paints the new "applied" state and
  // the buttons reflect the new selection).
  function handleApplyTrustBadge(btn) {
    if (!btn || btn.disabled) return;
    const mode = btn.getAttribute("data-badge-mode") || "off";
    const card = btn.closest("[data-rpca-badge-card]");
    const feedback = card ? card.querySelector("[data-rpca-badge-feedback]") : null;
    btn.disabled = true;
    if (feedback) {
      feedback.textContent = "Saving…";
      feedback.style.color = "#475569";
    }
    const body = new URLSearchParams();
    body.set("action", "rpca_set_badge_auto_inject");
    body.set("mode", mode);
    body.set("nonce", (window.RPCA && RPCA.nonce) || "");
    body.set("_wpnonce", (window.RPCA && RPCA.nonce) || "");
    fetch(RPCA.ajaxUrl, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString() })
      .then((r) => r.json().catch(() => ({ success: false, data: { message: "Invalid JSON response." } })))
      .then((json) => {
        if (json && json.success) {
          if (feedback) {
            feedback.style.color = "#15803d";
            feedback.textContent = (json.data && json.data.message) || "Saved.";
          }
          // Reload so the buttons re-paint with the new applied
          // selection and the status pill flips green/red correctly.
          setTimeout(() => { window.location.reload(); }, 700);
        } else {
          const msg = (json && json.data && json.data.message) || "Could not update the badge. Try again.";
          if (feedback) {
            feedback.style.color = "#991b1b";
            feedback.textContent = String(msg);
          }
          btn.disabled = false;
        }
      })
      .catch((err) => {
        if (feedback) {
          feedback.style.color = "#991b1b";
          feedback.textContent = String((err && err.message) || err);
        }
        btn.disabled = false;
      });
  }

  // v0.7.253 (RETIRED in v0.7.421): card on the Policies & COA page
  // that used to let the merchant enable + configure the in-house
  // buyer / researcher acknowledgment checkbox at checkout. The
  // in-house checkout-ack was removed in v0.7.417 and delegated to

  function renderPolicyChatBubble(planChat) {
    if (!planChat || !planChat.open) {
      return `
        <button type="button" class="rpca-chat-bubble" data-action="open-policy-chat" aria-label="Open page planner chat">
          <span class="rpca-chat-bubble-dot" aria-hidden="true"></span>
          <span>Page planner</span>
        </button>
      `;
    }
    const tpl = POLICY_TEMPLATES.find((t) => t.key === planChat.policyKey);
    const heading = tpl ? `Planning: ${tpl.label}` : "Page planner";
    const messages = (planChat.history || []).map((entry, index) => {
      const role = entry && entry.role === "assistant" ? "assistant" : "user";
      const text = entry && entry.text ? String(entry.text) : "";
      const label = role === "assistant" ? "Planner" : "You";
      let trailingActions = "";
      if (role === "assistant") {
        if (entry && entry.aiUnavailable) {
          // AI is gated off — surface a direct link to Setup instead of
          // the "use this plan as draft" button (the plan is the error
          // text, not real plan content). Also offer to fall back to
          // the canned template generator.
          const setupUrl = entry.setupUrl || (RPCA.sectionAdminUrls && RPCA.sectionAdminUrls.settings) || "";
          trailingActions = `
            <div class="rpca-chat-actions">
              ${setupUrl ? `<a class="rpca-button is-primary" href="${safeHref(setupUrl)}">Open Setup</a>` : ""}
              <button type="button" class="rpca-button is-ghost" data-action="generate-policy" data-policy-key="${escapeHtml(planChat.policyKey)}">Generate the standard template instead</button>
            </div>
          `;
        } else {
          trailingActions = `<button type="button" class="rpca-button is-text rpca-chat-use" data-action="use-plan-as-draft" data-history-index="${index}" data-policy-key="${escapeHtml(planChat.policyKey)}">Use this plan as the draft</button>`;
        }
      }
      // Preserve newlines from the AI reply (the toggle list uses them).
      const paragraphHtml = escapeHtml(text).replace(/\n/g, "<br>");
      return `<div class="rpca-chat-msg is-${role}"><span class="rpca-chat-role">${escapeHtml(label)}</span><p>${paragraphHtml}</p>${trailingActions}</div>`;
    }).join("");
    const intro = (planChat.history || []).length === 0
      ? `<div class="rpca-chat-msg is-assistant"><span class="rpca-chat-role">Planner</span><p>Hi - I will help you plan this page (AI-assisted; beta — experimental) so it matches the rest of your site. Tell me anything you want it to cover, the tone you want, or just type "go ahead with the standard plan" and I will outline it. These are draft suggestions only; review and edit before publishing. I can only help with this one page; other questions get a polite no.</p></div>`
      : "";
    const busyNote = planChat.busy ? `<p class="rpca-chat-busy">Planner is thinking...</p>` : "";
    const inputDisabled = planChat.busy ? " disabled" : "";

    return `
      <aside class="rpca-chat-panel" role="dialog" aria-label="Page planner chat">
        <header class="rpca-chat-header">
          <h3>${escapeHtml(heading)}</h3>
          <button type="button" class="rpca-button is-text" data-action="close-policy-chat" aria-label="Close planner">Close</button>
        </header>
        <div class="rpca-chat-thread">
          ${intro}${messages}${busyNote}
        </div>
        <form class="rpca-chat-form" data-action="submit-policy-chat" data-policy-key="${escapeHtml(planChat.policyKey)}">
          <textarea name="policyChatMessage" rows="2" placeholder="Tell the planner what you want, or type 'go ahead with the standard plan'..."${inputDisabled} aria-label="Message to the page planner">${escapeHtml(planChat.input || "")}</textarea>
          <div class="rpca-chat-form-actions">
            <button type="submit" class="rpca-button"${inputDisabled}>Send</button>
            <button type="button" class="rpca-button is-ghost" data-action="generate-policy" data-policy-key="${escapeHtml(planChat.policyKey)}"${inputDisabled}>Generate this page now</button>
          </div>
        </form>
      </aside>
    `;
  }

  function renderUnderwriterSurface() {
    const data = state.addendum2 || {};
    const audit = state.lastScan && state.lastScan.processorAudit ? state.lastScan.processorAudit : null;
    // v0.7.393 (Feature 6): print-ready processor compliance report. Opens
    // the admin-post endpoint in a new tab; the merchant saves it as a PDF.
    const processorReportUrl = (RPCA && RPCA.adminUrl || "/wp-admin/") + "admin-post.php?action=rpca_processor_report&nonce=" + encodeURIComponent((RPCA && RPCA.nonce) || "");
    return renderSubtabSurface("underwriter", ["Payment Review", "Disputes", "Card Review", "Early Refunds"], [
      `<article class="rpca-editorial-card"><h3>Payment readiness</h3><p>This is the main audit path for payment processing review. It checks the same visible areas reviewers can inspect without logging in.</p><div class="rpca-button-row"><button type="button" class="rpca-button is-primary" data-action="start-scan" data-scan-mode="processor_audit">Run processor audit scan</button><a class="rpca-button" href="${safeHref(processorReportUrl)}" target="_blank" rel="noopener">Open processor compliance report</a></div><p>The processor compliance report is a print-ready compliance summary you can save as a PDF and give to your payment processor or bank. It opens in a new browser tab.</p><p id="rpca-shell-scan-status" class="rpca-inline-status" role="status" aria-live="polite" hidden></p></article>${audit ? renderProcessorAudit(audit) : `<article class="rpca-editorial-card"><h3>No audit scan yet</h3><p>Run the processor audit scan to see the step-by-step reviewer path and the checklist of what looks right or needs attention.</p></article>`}${renderDailyReviewerWalk((data.simulator || {}))}`,
      `<article class="rpca-editorial-card"><h3>Disputes</h3><p>${escapeHtml(disputePlainSummary(data.disputes || {}))}</p></article>`,
      `<article class="rpca-editorial-card"><h3>Card review</h3><p>This summarizes card-brand and payment-review signals from the latest scan and saved proof records.</p></article>`,
      `<article class="rpca-editorial-card"><h3>Early refunds</h3><p>This records bank alerts that were handled before they became full disputes.</p></article>`,
    ]);
  }

  function renderDailyReviewerWalk(simulator) {
    const rows = Array.isArray(simulator.latestShadowWalk)
      ? simulator.latestShadowWalk
      : Array.isArray(simulator.latest)
        ? simulator.latest
        : [];
    const cleanRows = rows.filter((row) => row && typeof row === "object" && Object.keys(row).length);
    if (!cleanRows.length) {
      return `<article class="rpca-editorial-card"><h3>Daily reviewer walk</h3><p>No scheduled payment-review walk has been recorded yet. Run a processor audit scan or wait for the scheduled monitor to create the first report.</p></article>`;
    }
    return `
      <article class="rpca-editorial-card">
        <h3>Daily reviewer walk</h3>
        <p>This shows the latest scheduled payment-review reports in plain language.</p>
        <ul>
          ${cleanRows
            .slice(0, 5)
            .map((row) => {
              const type = row.type || "review report";
              const band = row.band || "unscored";
              const score = typeof row.score !== "undefined" ? `, score ${row.score}` : "";
              const when = row.created_at || row.createdAt || "";
              const reportUrl = row.reportUrl || "";
              const link = reportUrl
                ? ` &mdash; <a href="${safeHref(reportUrl)}" target="_blank" rel="noopener">open report</a>`
                : "";
              return `<li>${escapeHtml(type)}: ${escapeHtml(band)}${escapeHtml(score)}${when ? `, ${escapeHtml(when)}` : ""}${link}</li>`;
            })
            .join("")}
        </ul>
      </article>
    `;
  }

  function coaPlainSummary(coa) {
    const keys = Object.keys(coa || {});
    const unverified = Number(coa.unverified || coa.unverifiedProducts || 0);
    const pending = Number(coa.pending || coa.pendingReverifications || 0);
    if (!keys.length) return "No COA check records yet. Add COAs to products, then run the check.";
    if (unverified > 0) return `${unverified} product${unverified === 1 ? "" : "s"} need COA verification. Start with those first.`;
    if (pending > 0) return `${pending} COA check${pending === 1 ? "" : "s"} are waiting for a reply.`;
    return "COA records look current in the latest saved summary.";
  }

  function disputePlainSummary(disputes) {
    const keys = Object.keys(disputes || {});
    const intercepted = Number(disputes.interceptedMonth || disputes.preDisputesIntercepted || disputes.intercepted30 || 0);
    const impact = disputes.estimatedImpactAvoided || disputes.estimatedRatioImpactAvoided || "";
    if (!keys.length) return "No dispute records yet. When bank alerts arrive, they will appear here.";
    return `${intercepted} early dispute alert${intercepted === 1 ? "" : "s"} handled this month${impact ? `; estimated impact avoided: ${impact}.` : "."}`;
  }

  function renderSubtabSurface(key, labels, panels) {
    const active = state.activeSubtab[key] || labels[0];
    const activeIndex = Math.max(0, labels.indexOf(active));
    return `
      <div class="rpca-subtabs" role="tablist">
        ${labels.map((label) => `<button type="button" class="rpca-subtab ${label === active ? "is-active" : ""}" data-subtab-key="${key}" data-subtab="${escapeHtml(label)}">${escapeHtml(label)}</button>`).join("")}
      </div>
      <div>${panels[activeIndex] || panels[0]}</div>
    `;
  }

  function renderSettingsSurface() {
    const ui = uiState();
    const setup = ui.setup || {};
    const checked = (value) => (value ? "checked" : "");
    const toggles = Array.isArray(ui.settingsToggles) ? ui.settingsToggles : [];
    const preset = ui.settingsPreset || "recommended";
    const presetButton = (key, label, description) =>
      `<button type="button" class="rpca-chip ${preset === key ? "is-active" : ""}" data-action="preset" data-preset="${escapeHtml(key)}" title="${escapeHtml(description)}" aria-pressed="${preset === key ? "true" : "false"}">${escapeHtml(label)}</button>`;
    const groups = [
      { key: "ACTIVE PROTECTION", label: "Protects your store right now", help: "These controls can stop risky checkout or publishing actions before they go live." },
      { key: "EVIDENCE CAPTURE", label: "Saves proof for later", help: "These controls keep records you can show your bank, processor, or counsel later." },
      { key: "INTELLIGENCE", label: "Watches for new risks", help: "These controls look for new public examples and re-check your site over time." },
    ];

    return `
      <section class="rpca-cockpit">
        <article class="rpca-ai-key-card">
          <p class="rpca-kicker">AI assistant</p>
          <h3>AI access code (optional)</h3>
          <p>${escapeHtml(setup.hasGeminiKey ? "Your AI access code is saved. Paste a new code only if you want to replace it." : "Paste an AI access code here for plain-English explanations, smart rewrites, and AI-filled policy drafts. You can leave this blank and the scanner still works.")}</p>
          <div class="rpca-first-run-grid">
            <label>
              <span>AI access code (free at aistudio.google.com)</span>
              <input type="password" name="gemini_api_key" autocomplete="new-password" placeholder="${escapeHtml(setup.hasGeminiKey ? "Code saved. Paste a new code to replace it." : "Paste here - or leave blank")}" />
              <small>Stored privately on this site. Never shown again on this screen.</small>
            </label>
          </div>
          <div class="rpca-first-run-checks">
            <label><input type="checkbox" name="enable_gemini" ${checked(setup.enableGemini)} /> <span>Use AI for plain-English explanations and rewrites (beta — experimental)</span></label>
            <label><input type="checkbox" name="outbound_ai_consent" ${checked(setup.outboundAiConsent)} /> <span>Allow public page text to be sent to AI when AI is on</span></label>
            <label><input type="checkbox" name="gemini_uncertain_only" ${checked(setup.geminiUncertainOnly !== false)} /> <span>Save AI budget by only asking AI when the built-in checks are unsure</span></label>
            <label><input type="checkbox" name="private_mode" ${checked(setup.privateMode !== false)} /> <span>Privacy-only mode: do not contact AI at all (overrides the boxes above)</span></label>
            ${setup.hasGeminiKey ? `<label><input type="checkbox" name="clear_gemini_api_key" /> <span>Remove the saved AI access code</span></label>` : ""}
          </div>
          <details style="margin-top:var(--space-3);">
            <summary style="cursor:pointer; color:var(--ink-600); font-size:13px; font-weight:600;">Advanced AI settings</summary>
            <div class="rpca-first-run-grid" style="margin-top:var(--space-3);">
              <label>
                <span>AI model</span>
                <input type="text" name="gemini_model" value="${escapeHtml(setup.geminiModel || "gemini-2.5-flash")}" />
                <small>Leave as is unless Google's docs tell you otherwise.</small>
              </label>
              <label>
                <span>Daily AI call budget</span>
                <input type="number" min="1" max="500" name="gemini_daily_call_limit" value="${escapeHtml(setup.dailyCallLimit || 25)}" />
                <small>How many AI calls per day. Cached results don't count.</small>
              </label>
            </div>
          </details>
          <div class="rpca-button-row">
            <button type="button" class="rpca-button is-primary" data-action="save-ai-settings">Save</button>
            <button type="button" class="rpca-button is-text" data-section="cockpit">Back</button>
          </div>
          <p id="rpca-ai-key-status" class="rpca-inline-status" role="status" aria-live="polite" hidden></p>
        </article>

        <article class="rpca-ai-key-card" style="border-left-color:var(--accent-gold)">
          <p class="rpca-kicker">Optional crawler help</p>
          <h3>Extra public URLs to scan</h3>
          <p>Use this only when the automatic crawler misses a same-site public page because of a custom theme, unusual navigation, hidden documentation route, COA library path, or JavaScript-only menu. The normal scan already checks published WordPress content, navigation menus, sitemaps, and same-site links.</p>
          <p class="rpca-notice-card">Paste one public same-site URL per line. Do not paste admin pages, checkout/account/cart links, private documents, tokenized download links, customer-specific URLs, or cross-site URLs.</p>
          <div class="rpca-first-run-grid">
            <label>
              <span>Optional same-site URLs</span>
              <textarea name="entry_urls" rows="8" placeholder="https://example.com/coa-library&#10;https://example.com/research-use-only&#10;https://example.com/lab-documentation">${escapeHtml(setup.entryUrls || "")}</textarea>
              <small>These URLs are added to the front of the automatic scan queue, then normalized and filtered by the same crawler safety rules.</small>
            </label>
          </div>
          <div class="rpca-button-row">
            <button type="button" class="rpca-button is-primary" data-action="save-scanner-urls">Save URLs</button>
          </div>
          <p id="rpca-scanner-url-status" class="rpca-inline-status" role="status" aria-live="polite" hidden></p>
        </article>

        <article class="rpca-editorial-card">
          <p class="rpca-eyebrow">Quick presets</p>
          <h3>Pick a starting profile, then tune below</h3>
          <p>Each preset adjusts the protection switches below. You can still flip individual controls afterwards.</p>
          <div class="rpca-settings-presets">
            ${presetButton("recommended", "Recommended", "Balanced setup for most stores")}
            ${presetButton("strict", "More cautious", "More publishing blocks and stricter checks")}
            ${presetButton("permissive", "Lighter", "Fewer blocking checks; warn instead")}
            ${presetButton("custom", "Custom", "Use your own toggles below")}
          </div>
        </article>

        <div class="rpca-settings-groups">
          ${groups
            .map((group) => {
              const rows = toggles.filter((toggle) => toggle.group === group.key);
              if (!rows.length) return "";
              return `<section class="rpca-settings-group"><h3>${escapeHtml(group.label)}</h3><p>${escapeHtml(group.help)}</p>${rows.map(renderToggleRow).join("")}</section>`;
            })
            .join("")}
        </div>

        <footer class="rpca-button-row">
          <button type="button" class="rpca-button" data-action="export-config">Download setup</button>
          <label class="rpca-button">Upload setup<input type="file" data-action="import-config" accept="application/json" hidden /></label>
          <button type="button" class="rpca-button" data-action="open-freeze">Emergency stop</button>
        </footer>
      </section>
    `;
  }

  function renderToggleRow(toggle) {
    return `
      <article class="rpca-toggle-row" data-toggle-key="${escapeHtml(toggle.key)}" data-protection="${toggle.protection ? "1" : "0"}">
        <div class="rpca-toggle-head">
          <div>
            <div class="rpca-toggle-name">${escapeHtml(toggle.label)}</div>
            <p>${escapeHtml(toggle.description)}</p>
          </div>
          <button type="button" class="rpca-switch ${toggle.enabled ? "is-on" : ""}" data-action="toggle-setting" aria-pressed="${toggle.enabled ? "true" : "false"}">${toggle.enabled ? "On" : "Off"}</button>
        </div>
        <div class="rpca-toggle-meta">
          <span><strong>Impact:</strong> ${escapeHtml(toggle.impact || "")}</span>
          <span><strong>Cost:</strong> ${escapeHtml(toggle.cost || "")}</span>
          <span><strong>Needs:</strong> ${escapeHtml(toggle.dependencies || "")}</span>
          <span><strong>Helps:</strong> ${escapeHtml(toggle.dependents || "")}</span>
          <details><summary>Explain this in more detail</summary><p>${escapeHtml(toggle.description)}</p></details>
        </div>
        <div class="rpca-preview-popout"><strong>What users see</strong><p>${escapeHtml(toggle.preview || "")}</p></div>
      </article>
    `;
  }

  function renderHelpSurface() {
    return `
      <section class="rpca-panel-grid">
        <article class="rpca-editorial-card">
          <p class="rpca-eyebrow">Start here</p>
          <h3>The plain-English tutorial</h3>
          <p><strong>1. Setup Wizard.</strong> Walks you through the numbered steps: AI access code (optional), generate every policy page, install the 3 recommended free plugins (sitewide RUO banner, checkout acknowledgment checkbox, age gate), arm the Regulator Alerts email, run your first scan, work through findings.</p>
          <p><strong>2. Compliance Generator.</strong> One page where you (a) bulk-generate every legal policy as a WP draft, (b) one-click install the 3 recommended free wp.org plugins, (c) copy-paste snippets (robots.txt rules, JSON-LD Organization schema, cart disclaimer HTML, cookie consent banner), and (d) download fillable HTML records (SDS, recall protocol, CAPA, quality complaint log, supplier audit, lot release, staff training). Every card has its own "How to install / use this" step-by-step expander.</p>
          <p><strong>3. Run the audit.</strong> Click Start Scan on the cockpit. The scanner crawls your public pages, evaluates 100+ rule patterns, and produces a 0-100 readiness score with per-finding fixes.</p>
          <p><strong>4. Read Things to fix.</strong> Findings are sorted by severity (Critical / High / Medium / Low). Each row has the right action for its problem — "Generate safer text" for editable wording, "Apply fix: Publish [policy]" for missing pages, "Apply fix: Turn on secure-connection enforcement" for HSTS / Referrer-Policy / X-Frame-Options, etc.</p>
        </article>
        <article class="rpca-editorial-card">
          <p class="rpca-eyebrow">What the scanner looks for</p>
          <h3>10 review areas</h3>
          <p><strong>First screen & menu:</strong> does the home page read like a research supplier vs a consumer health store?</p>
          <p><strong>Product-page disclaimers:</strong> RUO + not-for-human-or-animal-consumption visible on EVERY peptide page (footer alone is not enough).</p>
          <p><strong>Medical-claim wording:</strong> disease, weight loss, dosing, body effects, human results, before/after testimonials, brand equivalence — all flagged.</p>
          <p><strong>Payment acceptable-use:</strong> pseudo-pharma claims, hidden processing, processor-workaround language, alternative-payment workarounds.</p>
          <p><strong>Policy pages:</strong> privacy / terms / refund / shipping / cancellation / contact pages must exist and resolve via WP core / WC settings / generated drafts.</p>
          <p><strong>Checkout safeguards:</strong> age gate, location restrictions, buyer acknowledgment, post-purchase RUO email language.</p>
          <p><strong>COA / SDS / lot proof:</strong> visible COA, batch/lot, purity, storage, lab-doc links when the page makes quality claims.</p>
          <p><strong>Cart pairings:</strong> peptide + bac water / syringe / starter-kit combinations (implies human-use intent).</p>
          <p><strong>Social / ads / affiliate:</strong> off-site channels (Instagram, affiliate pages, coupon pages) checked for testimonials, dosing, before/after, paid-post disclosure.</p>
          <p><strong>Crawler visibility / technical:</strong> HTTPS, HSTS, Referrer-Policy, X-Frame-Options, CSP, robots restrictions on RUO/product pages.</p>
        </article>
        <article class="rpca-editorial-card">
          <p class="rpca-eyebrow">The 3 delegated features</p>
          <h3>One-click free plugins</h3>
          <p>Compliance Guard used to ship its own banner, checkout acknowledgment, and age gate. As of v0.7.417 those three features are delegated to the most-installed free wp.org plugins for each job — no host-malware-scanner false positives, no in-house renderer bugs, no duplicated maintenance.</p>
          <p><strong>Site-wide RUO disclaimer:</strong> <em>WP Notification Bars</em> by MyThemeShop. Install + activate from the Compliance Generator card. Sticky-bottom, color-themed, dismiss-cookie-aware.</p>
          <p><strong>Checkout acknowledgment:</strong> <em>Additional Terms for WooCommerce</em> by MyPreview. Install + activate, configure the checkbox text under WooCommerce → Settings → Additional Terms. Saves to order meta for chargeback evidence.</p>
          <p><strong>Age gate:</strong> <em>Age Gate</em> by Phil Baker. Optional but processor-friendly. Self-confirm or DOB modes, trigger placement, color customization.</p>
          <p>All three install in one click via the AJAX endpoint that wraps WordPress's native <code>plugins_api()</code> + <code>Plugin_Upgrader</code> — same flow as wp-admin → Add New, just inline.</p>
        </article>
        <article class="rpca-editorial-card">
          <p class="rpca-eyebrow">Regulator Alerts</p>
          <h3>Real-time visitor monitoring</h3>
          <p>The Regulator Alerts page sends you an email the moment an FDA, FTC, HHS, USDA, DOJ, DEA, CFPB, CMS, or any .gov / .mil visitor lands on your store. Off by default — turn on from the Regulator Alerts sidebar entry.</p>
          <p><strong>How it detects:</strong> reverse-DNS PTR lookup on the visitor IP (cached 24h) + User-Agent patterns + 60-second behavior signals (request rate, 404s, regulatory crawl pattern, .gov referrer, header anomalies). 18-signal heuristic scoring, no external API calls.</p>
          <p><strong>Privacy:</strong> raw visitor IPs are NEVER stored — only a 16-char SHA-256 hash. The detection log is bounded at 200 entries (FIFO ring buffer). Rate-limited: max 1 email per IP-hash per hour, max 5 emails per site per hour.</p>
          <p><strong>Test it:</strong> on the Regulator Alerts page, type your email in the "Send the test to" box and click Send test alert. The email arrives within seconds (TasteWP and most managed hosts; localhost dev environments without SMTP will not deliver to external addresses).</p>
        </article>
        <article class="rpca-editorial-card">
          <p class="rpca-eyebrow">Proof Archive</p>
          <h3>Your compliance evidence vault</h3>
          <p>Five tabs that auto-fill with the receipts a payment processor / regulator / acquirer asks for:</p>
          <p><strong>Order Proof:</strong> every WooCommerce checkout creates a tamper-evident attestation (what disclaimer the buyer saw, what they ticked, SHA-256 hash chain).</p>
          <p><strong>Page Snapshots:</strong> rendered DOM of each checkout — proves the RUO disclaimer was visible at order time.</p>
          <p><strong>COA Checks:</strong> tracks Certificate of Analysis verification per product.</p>
          <p><strong>Test Orders:</strong> daily synthetic-checkout cron exercises age gate / shipping rules / cart-pairing warnings and logs failures.</p>
          <p><strong>Changes:</strong> audit timeline of every compliance-relevant edit (option saves, scan runs, plugins installed, fixes applied).</p>
          <p>All five tabs have a "How this fills in" empty-state when nothing is logged yet, so you know exactly what to do to populate each one.</p>
        </article>
        <article class="rpca-editorial-card">
          <p class="rpca-eyebrow">False positives</p>
          <h3>When the scan flags something wrong</h3>
          <p>If a finding looks wrong, open <em>Why this matters</em> on the row. The flagged text is the actual crawled text. Sentence context shows what the scanner read around the trigger.</p>
          <p>A sentence that says "not FDA approved," "research use only," or "not for human consumption" should not be treated as an approval claim by itself. If it still appears, the finding explains what nearby wording contradicted it.</p>
          <p>If the site has a COA page or RUO page that was missed, go to Setup and paste that public URL into <em>Extra public URLs to scan</em>, then run the audit again.</p>
          <p>If a compliance surface lives somewhere the public scanner can't see (theme-level age gate, host-level HSTS, off-site COA portal), open <em>Manual Checklist</em> and tick it as covered. The scanner credits the override on the next run.</p>
        </article>
        <article class="rpca-editorial-card">
          <p class="rpca-eyebrow">AI and privacy</p>
          <h3>What AI does and doesn't see</h3>
          <p>The plugin works without AI — rules, scoring, and auto-fix are all rule-based. With a free Gemini API key it adds: plain-English finding explanations, richer policy drafts, smarter false-positive detection, and live in-editor compliance checks.</p>
          <p><strong>Private mode</strong> means no page text is sent to AI. Default ON. Turn it OFF in Setup → AI access code only if you want Gemini reviewing your crawled page content.</p>
          <p>The AI access code is encrypted at rest, never shown after save, and can be cleared any time from Setup.</p>
          <p>If the server is close to timing out on a huge scan, the plugin finishes the local crawl and skips the AI second pass for that click instead of causing a 504.</p>
        </article>
        <article class="rpca-editorial-card">
          <p class="rpca-eyebrow">Glossary</p>
          <h3>Common words</h3>
          <p><strong>RUO:</strong> research use only. For this plugin, the safe story is qualified in-vitro laboratory research, not human or animal use.</p>
          <p><strong>COA:</strong> certificate of analysis — a lab report supporting identity, purity, lot, batch, and testing details.</p>
          <p><strong>SDS:</strong> safety data sheet, GHS-aligned, 16-section format. Generate the skeleton from the Compliance Generator.</p>
          <p><strong>CAPA:</strong> Corrective & Preventive Action — per-incident form acquirers ask for during compliance review.</p>
          <p><strong>Net impression:</strong> what the page makes a reviewer think after reading everything together, not just one isolated word.</p>
          <p><strong>Processor audit:</strong> a scan shaped around what high-risk payment reviewers can inspect before approving or monitoring the site.</p>
          <p><strong>HSTS / Referrer-Policy / X-Frame-Options:</strong> HTTP response headers that improve security posture. The plugin can turn them on from <em>Things to fix</em> via the WordPress send_headers hook (auto-verified after enable so you know if your host stripped them).</p>
        </article>
        <article class="rpca-editorial-card">
          <p class="rpca-eyebrow">Good workflow</p>
          <h3>Use it weekly</h3>
          <p>Run a fresh audit after changing product pages, menus, policies, checkout text, affiliate pages, COA pages, or educational content.</p>
          <p>Save evidence as you go: COAs, lab reports, supplier records, buyer acknowledgments, refund policies, shipping rules, and dated content reviews — the Proof Archive auto-collects most of this.</p>
          <p>Hide a finding only when you have read the context and believe it is not a problem. That feedback (via Adaptive Learning, if enabled) helps the scanner treat similar matches with more skepticism.</p>
          <p>Check the Regulations Feed monthly for FDA / FTC / processor updates that shift expectations.</p>
        </article>
      </section>
    `;
  }

  function renderCommandPalette(ui) {
    const items = Array.isArray(ui.commandItems) ? ui.commandItems : [];
    return `
      <div class="rpca-command-overlay" id="rpca-command-overlay" hidden>
        <div class="rpca-command" role="dialog" aria-modal="true" aria-label="Search this plugin">
          <input type="search" class="rpca-command-input" id="rpca-command-input" placeholder="Search for a page, setting, fix, or help topic" />
          <div class="rpca-command-results" id="rpca-command-results">
            ${items.map((item, index) => renderCommandRow(item, index)).join("")}
          </div>
        </div>
      </div>
    `;
  }

  function renderCommandRow(item, index) {
    return `<button type="button" class="rpca-command-row ${index === state.commandIndex ? "is-active" : ""}" data-command-index="${index}" data-section="${escapeHtml(item.target || "cockpit")}"><span class="rpca-command-type">${escapeHtml(plainCommandType(item.type || "item"))}</span><span>${escapeHtml(item.label || "")}</span></button>`;
  }

  function plainCommandType(type) {
    return {
      finding: "fix",
      page: "page",
      setting: "setting",
      action: "action",
      doc: "help",
      audit: "record",
    }[type] || "item";
  }

  function filterCommandPalette() {
    const input = el("rpca-command-input");
    const results = el("rpca-command-results");
    const ui = uiState();
    if (!input || !results) return;
    const query = input.value.toLowerCase();
    const items = (ui.commandItems || []).filter((item) => `${item.type} ${item.label}`.toLowerCase().includes(query));
    state.commandIndex = Math.min(state.commandIndex, Math.max(0, items.length - 1));
    results.innerHTML = items.map((item, index) => renderCommandRow(item, index)).join("");
  }

  function renderModalShell() {
    return `<div class="rpca-modal-overlay" id="rpca-modal-overlay" hidden><div class="rpca-modal" id="rpca-modal"></div></div>`;
  }

  function renderContextSidebar() {
    return `
      <aside class="rpca-help-rail" id="rpca-help-rail" hidden>
        <div class="rpca-context-panel">
          <p class="rpca-eyebrow">Context</p>
          <h3>${escapeHtml(sectionTitle(state.activeSection))}</h3>
          <div id="rpca-context-body">${renderContextBody()}</div>
          <div class="rpca-button-row"><button type="button" class="rpca-button" data-action="close-help">Close</button></div>
        </div>
      </aside>
    `;
  }

  function renderContextBody() {
    if (state.activeSection === "settings") {
      return "<p>Setup is grouped by what each control does: protect the store now, save proof for later, or watch for new risks. Turning off a protection control asks for a short reason so the record stays clear.</p>";
    }
    if (state.activeSection === "findings") {
      return "<p>Each row shows what was found, where it appeared, why it matters, and the fastest next step. Use Fix first when you only want the most important items.</p>";
    }
    if (state.activeSection === "cockpit") {
      return "<p>The score is a quick snapshot of how prepared the store looks today. The checklist below it shows what is set up and what still needs attention.</p>";
    }
    return "<p>This page explains saved scan results in plain language so you can decide what to fix next.</p>";
  }

  function renderAuditLanes(events) {
    const lanes = ["PUBLISH", "SCAN", "OVERRIDE", "ALERT"];
    return `<div class="rpca-audit-lanes">${lanes
      .map((lane) => {
        const dots = (events || [])
          .slice(0, 14)
          .map((event, index) => `<span class="rpca-audit-dot" title="${escapeHtml(event.message || event.type || lane)}" style="left:${Math.min(96, index * 7)}%"></span>`)
          .join("");
        return `<div class="rpca-audit-lane"><span>${lane}</span><div class="rpca-audit-track">${dots}</div></div>`;
      })
      .join("")}</div>`;
  }

  function openCommandPalette() {
    const overlay = el("rpca-command-overlay");
    const input = el("rpca-command-input");
    if (!overlay || !input) return;
    overlay.hidden = false;
    state.commandIndex = 0;
    window.setTimeout(() => input.focus(), 0);
  }

  function closeCommandPalette() {
    const overlay = el("rpca-command-overlay");
    if (overlay) overlay.hidden = true;
  }

  function openModal(html) {
    const overlay = el("rpca-modal-overlay");
    const modal = el("rpca-modal");
    if (!overlay || !modal) return;
    modal.innerHTML = html;
    overlay.hidden = false;
    const first = modal.querySelector("button, textarea, input");
    if (first) first.focus();
  }

  function closeModal() {
    const overlay = el("rpca-modal-overlay");
    if (overlay) overlay.hidden = true;
    state.activeDraft = null;
  }

  function findingByIndex(index) {
    const findings = uiState().findings || [];
    return findings[Number(index) || 0] || null;
  }

  function recordFeedback(findingId, type, reason) {
    if (!findingId) {
      return Promise.resolve(null);
    }
    return post("rpca_record_feedback", {
      findingId,
      type: type || "false_positive",
      reason: reason || "",
    }).catch((error) => {
      // Feedback recording is best-effort; don't block the UI on failure.
      return null;
    });
  }

  function originalTextForFinding(finding) {
    if (!finding) return "";
    const directEvidence = triggerEvidenceItems(finding, 1);
    if (directEvidence.length) {
      return directEvidence[0];
    }
    // Prefer the actual page-text excerpt (snippet) over the rule's matched term/label.
    // A "match" or "matches[0]" entry can be a descriptive label like "Disease or therapeutic
    // claims" rather than the wording that's actually on the page; basing the rewrite on
    // that produces a generic placeholder. Snippets always come from real page content.
    if (Array.isArray(finding.snippets)) {
      for (const snippet of finding.snippets) {
        const cleaned = String(snippet || "").trim();
        if (cleaned.length >= 4 && !looksLikeRuleLabel(cleaned)) {
          return cleaned;
        }
      }
    }
    for (const candidate of [finding.span, finding.quoted_span]) {
      const cleaned = String(candidate || "").trim();
      if (cleaned.length >= 4 && !looksLikeRuleLabel(cleaned)) {
        return cleaned;
      }
    }
    if (Array.isArray(finding.contextWindows)) {
      for (const contextWindow of finding.contextWindows) {
        const cleaned = String(contextWindow || "").trim();
        if (cleaned.length >= 4 && !looksLikeRuleLabel(cleaned)) {
          return cleaned;
        }
      }
    }
    if (Array.isArray(finding.matches)) {
      for (const m of finding.matches) {
        const cleaned = String(m || "").trim();
        if (cleaned.length >= 4 && !looksLikeRuleLabel(cleaned)) {
          return cleaned;
        }
      }
    }
    const rawMatch = String(finding.match || "").trim();
    if (rawMatch.length >= 4 && !looksLikeRuleLabel(rawMatch)) {
      return rawMatch;
    }
    // Last resort: even a label is better than nothing so the rewrite has something to work with.
    if (Array.isArray(finding.snippets) && finding.snippets[0]) return String(finding.snippets[0]).trim();
    return rawMatch || String(finding.span || finding.quoted_span || "");
  }

  function openDraftModal(index) {
    const finding = findingByIndex(index);
    if (!finding) {
      toast("This item has no saved details. Run a fresh scan and try again.");
      return;
    }
    const original = originalTextForFinding(finding);
    const localRewrite = generatedRewriteForFinding(finding);
    const path = (finding.url || "").trim();
    const hasEditablePost = Boolean(finding.postId);
    const supportsAi = Boolean(window.RPCA && RPCA.hasApiKey);

    state.activeDraft = {
      findingIndex: Number(index) || 0,
      finding,
      original,
      suggestion: localRewrite,
      variationIndex: 0,
      previousSuggestions: [],
    };

    const modalHtml = `
      <button type="button" class="rpca-modal-close" data-action="close-modal" aria-label="Close">&times;</button>
      <p class="rpca-eyebrow">Draft a compliant rewrite</p>
      <h2>Review the draft</h2>
      <p class="rpca-draft-meta">${escapeHtml(path || "Custom theme page (no WordPress post detected)")}</p>

      <div class="rpca-draft-pair">
        <div class="rpca-draft-col">
          <h4>Current wording</h4>
          <p><span class="rpca-diff-remove">"${escapeHtml(original)}"</span></p>
        </div>
        <div class="rpca-draft-col">
          <h4>Suggested compliant wording <span class="rpca-draft-thinking" hidden><span class="rpca-spinner" aria-hidden="true"></span> Asking Gemini...</span></h4>
          <p><span class="rpca-diff-add" id="rpca-draft-suggestion">"${escapeHtml(localRewrite)}"</span></p>
          <div id="rpca-draft-warning" hidden style="margin-top:8px;padding:10px;background:#fef3c7;border:1px solid #f59e0b;border-radius:6px;font-size:13px;color:#92400e;">
            <strong>Heads up — this rewrite still contains risky phrasing:</strong>
            <span id="rpca-draft-warning-detail"></span>
            <div style="margin-top:6px;color:#78350f;">Click "Try a different version" to regenerate, or edit the wording yourself before publishing.</div>
          </div>
        </div>
      </div>

      ${
        hasEditablePost
          ? `<div class="rpca-notice-card" style="margin-top:var(--space-4)">
               <strong>You can edit this page in WordPress.</strong> Use the editor button to open the matched WordPress post or page directly.
             </div>`
          : `<div class="rpca-notice-card" style="margin-top:var(--space-4)">
               <strong>This section is part of your store's design, not a regular page.</strong> We've prepared a draft you can open and copy the new wording from. Click "Save as draft for review" below.
             </div>`
      }

      <div class="rpca-button-row">
        <button type="button" class="rpca-button is-primary" data-action="save-as-draft" data-finding-index="${escapeHtml(index)}">
          <span class="rpca-button-label">Save as draft for review</span>
          <span class="rpca-spinner" data-role="draft-spinner" hidden aria-hidden="true"></span>
        </button>
        ${
          hasEditablePost
            ? `<button type="button" class="rpca-button" data-action="goto-draft-page" data-finding-index="${escapeHtml(index)}">Open the page editor</button>`
            : ""
        }
        <button type="button" class="rpca-button" data-action="copy-draft-text">Copy the new wording</button>
        ${
          supportsAi
            ? `<button type="button" class="rpca-button is-text" data-action="regenerate-draft">Try a different version</button>`
            : ""
        }
      </div>

      <p class="rpca-draft-footer">${
        supportsAi
          ? "We never publish anything for you. Save as draft makes a private draft you review and click Publish on yourself."
          : "We never publish anything for you. Save as draft makes a private draft so you can review the new wording before anything goes live. Add an AI access code in Setup for smarter rewrites."
      }</p>
      <p class="rpca-draft-footer" style="border-top:0; padding-top:0; margin-top:var(--space-2);">
        ${
          hasEditablePost
            ? "We'll copy this page in WordPress, swap the risky line for the new one, and save the copy as a draft."
            : "We'll create a new draft page with the new wording, ready for you to review and use."
        }
      </p>
    `;
    openModal(modalHtml);

    if (supportsAi) {
      const thinking = document.querySelector(".rpca-draft-thinking");
      if (thinking) thinking.hidden = false;
      requestGeminiRewrite(finding).then((aiText) => {
        const t = document.querySelector(".rpca-draft-thinking");
        if (t) t.hidden = true;
        if (!aiText || !state.activeDraft || state.activeDraft.findingIndex !== Number(index)) {
          return;
        }
        state.activeDraft.suggestion = aiText;
        const node = el("rpca-draft-suggestion");
        if (node) node.textContent = `"${aiText}"`;
        renderDraftWarningIfNeeded(aiText);
      });
    }
  }

  // v0.7.293 (Item #2): surface the "still risky" banner under the rewrite
  // by COMBINING two sources of truth:
  //   1. Server's residualTriggers (objects {pattern, category}) from the
  //      scanner-aware rpca_compliant_rewrite endpoint
  //   2. Client's checkForbiddenPhrases() — defense-in-depth in case the
  //      server's trigger list misses something the client knows about
  // Banner shows the union of both. If both come back empty AND the
  // server reported scannedClean=true, render a small green confirmation
  // instead of hiding the banner entirely (so the merchant sees that the
  // server actually ran a check and got a clean answer).
  function renderDraftWarningIfNeeded(text) {
    const warn = document.getElementById("rpca-draft-warning");
    const detail = document.getElementById("rpca-draft-warning-detail");
    if (!warn || !detail) return;
    const clientHits = checkForbiddenPhrases(text || "");
    const residual = (state.activeDraft && Array.isArray(state.activeDraft.lastResidualTriggers))
      ? state.activeDraft.lastResidualTriggers
      : [];
    const serverHits = residual.map(function (t) {
      return t && typeof t === "object" ? String(t.pattern || "") : String(t || "");
    }).filter(Boolean);
    const allHits = Array.from(new Set([].concat(clientHits, serverHits)));
    const geminiVerified = state.activeDraft ? state.activeDraft.lastGeminiVerified : null;
    const geminiReason = state.activeDraft ? String(state.activeDraft.lastGeminiVerifyReason || "") : "";
    // v0.7.301: Gemini's final verification gate can flag a rewrite the
    // local rule engine considers clean. If Gemini said STILL RISKY,
    // surface that even when there are zero local trigger hits.
    if (!allHits.length && geminiVerified === false) {
      detail.textContent = "";
      warn.hidden = false;
      warn.style.background = "#fef3c7";
      warn.style.borderColor = "#f59e0b";
      warn.style.color = "#92400e";
      warn.innerHTML = "<strong>Gemini flagged this rewrite as still risky.</strong>" +
        (geminiReason ? "<span> " + escapeHtml(geminiReason) + "</span>" : "") +
        '<div style="margin-top:6px;color:#78350f;">The local scanner found no trigger words, but Gemini’s compliance review still sees a problem. Click "Try a different version" to regenerate, or edit the wording yourself before publishing.</div>';
      return;
    }
    if (!allHits.length) {
      // Server says scannedClean — surface a one-line "Gemini approved" note
      // so the merchant sees their click did something.
      const attempts = Number(state.activeDraft && state.activeDraft.lastAttempts || 0);
      const cleanByServer = state.activeDraft && state.activeDraft.lastScannedClean === true;
      if (cleanByServer && attempts > 0) {
        detail.textContent = "";
        warn.hidden = false;
        warn.style.background = "#dcfce7";
        warn.style.borderColor = "#16a34a";
        warn.style.color = "#14532d";
        warn.innerHTML = "<strong>Gemini scanner-aware review passed</strong> (took " + attempts + " attempt" + (attempts === 1 ? "" : "s") + "). The rewrite reads clean against every rule the scanner watches for" + (geminiVerified === true ? ", and Gemini’s final compliance check confirmed it." : ".");
        return;
      }
      warn.hidden = true;
      return;
    }
    // Reset to amber when there are hits.
    warn.style.background = "#fef3c7";
    warn.style.borderColor = "#f59e0b";
    warn.style.color = "#92400e";
    warn.innerHTML = '<strong>Heads up — this rewrite still contains risky phrasing:</strong>' +
      '<span id="rpca-draft-warning-detail"> "' + allHits.slice(0, 5).join('", "') + '"' + (allHits.length > 5 ? ' (+' + (allHits.length - 5) + ' more)' : '') + '.</span>' +
      '<div style="margin-top:6px;color:#78350f;">Click "Try a different version" to regenerate, or edit the wording yourself before publishing.</div>';
  }

  // v0.7.293 (Item #2): factor out the forbidden-phrase lists + checker
  // to module scope so they survive the swap from rpca_chat to the new
  // rpca_compliant_rewrite endpoint AND so other UI paths (inline-rewrite
  // panel, Save-as-draft preview) can use them too. The server-side
  // scanner has its own trigger list — these client-side lists are a
  // defense-in-depth UX layer that warns the merchant if anything still
  // looks risky, even after the server says "scannedClean: true".
  const FORBIDDEN_BRAND_NAMES = [
    "Ozempic", "Wegovy", "Mounjaro", "Zepbound", "Trulicity", "Saxenda",
    "Rybelsus", "Victoza", "Byetta", "Bydureon", "Adlyxin", "Lyumjev",
    "Humalog", "Novolog", "Lantus", "Toujeo", "Tresiba",
  ];
  const FORBIDDEN_EQUIVALENCE_PHRASES = [
    "same active ingredient as", "active ingredient as",
    "same as", "alternative to", "equivalent to", "comparable to",
    "similar to", "knock-off of", "knockoff of", "generic version of",
    "off-brand", "off brand", "compounded version of", "research-grade version of",
    "same molecule as", "same compound as", "identical to",
    "as effective as", "works like",
  ];
  const FORBIDDEN_DOSING_PHRASES = [
    "inject", "injection", "subcutaneous", "subcutaneously", "intramuscular",
    "reconstitute", "reconstitution", "bacteriostatic water", "draw into the syringe",
    "weekly dose", "starting dose", "maintenance dose", "titration", "titrate",
    "mg per week", "mcg per week", "iu per week", "units per week", "once weekly",
    "twice weekly", "every other day",
  ];
  const FORBIDDEN_OUTCOME_PHRASES = [
    "weight loss", "lose weight", "lost weight", "pounds in", "lbs in",
    "before and after", "before-and-after", "transformation",
    "fat loss", "muscle gain", "anti-aging",
    "blood sugar", "glucose control", "appetite suppress",
    "real customers", "real results",
    "clinically proven", "clinical results", "studies show",
  ];
  const FORBIDDEN_HUMAN_PHRASES = [
    "patients", "patient", "consumers", "for human", "human use",
    "for personal use", "ready to use", "ready-to-use",
  ];
  const ALL_FORBIDDEN_PHRASES = [
    ...FORBIDDEN_BRAND_NAMES,
    ...FORBIDDEN_EQUIVALENCE_PHRASES,
    ...FORBIDDEN_DOSING_PHRASES,
    ...FORBIDDEN_OUTCOME_PHRASES,
    ...FORBIDDEN_HUMAN_PHRASES,
  ];
  function checkForbiddenPhrases(text) {
    if (!text) return [];
    const lower = String(text).toLowerCase();
    return ALL_FORBIDDEN_PHRASES.filter(function (phrase) {
      return lower.indexOf(phrase.toLowerCase()) !== -1;
    });
  }

  // v0.7.293 (Item #2): scanner-aware compliant rewrite via the
  // server-side rpca_compliant_rewrite endpoint. Replaces the v0.7.289
  // client-only rpca_chat path. The server runs the scanner against the
  // original AND the AI output, retries up to 3x with progressively
  // stricter "you still tripped these triggers" hints, and returns
  // { rewrite, attempts, scannedClean, residualTriggers, log,
  //   originalTriggers }. residualTriggers is an array of objects
  // { pattern, category } that the UI surfaces in the warning banner.
  //
  // opts.previousRewrites: array of merchant-rejected outputs (for "Try
  // a different version"). The server passes these into the prompt as a
  // "do not match these in shape or wording" block.
  function requestCompliantRewrite(finding, opts) {
    opts = opts || {};
    const original = originalTextForFinding(finding);
    if (!original) return Promise.resolve(null);
    const prevList = Array.isArray(opts.previousRewrites)
      ? opts.previousRewrites.filter(function (s) { return typeof s === "string" && s.trim(); }).slice(-5)
      : [];
    // Stash the checker on activeDraft for back-compat with v0.7.289 UI
    // banner code that read state.activeDraft.checkForbidden.
    if (state.activeDraft) {
      state.activeDraft.checkForbidden = checkForbiddenPhrases;
      state.activeDraft.lastResidualTriggers = [];
      state.activeDraft.lastScannedClean = null;
      state.activeDraft.lastAttempts = 0;
    }
    const payload = {
      originalText: original,
      findingId: finding && finding.id ? String(finding.id) : "",
    };
    prevList.forEach(function (s, i) { payload["previousRewrites[" + i + "]"] = s; });
    return post("rpca_compliant_rewrite", payload)
      .then(function (data) {
        if (!data || !data.rewrite) return null;
        const rewrite = String(data.rewrite).trim();
        if (state.activeDraft) {
          state.activeDraft.lastResidualTriggers = Array.isArray(data.residualTriggers) ? data.residualTriggers : [];
          state.activeDraft.lastScannedClean = !!data.scannedClean;
          // v0.7.301: the server now also runs the rewrite through the
          // full Gemini gate (compliance brief + Context Understanding
          // Guide). geminiVerified is true (Gemini agrees it is clean),
          // false (Gemini still sees risk), or null (gate could not run).
          state.activeDraft.lastLocalScannedClean = data.localScannedClean !== false;
          state.activeDraft.lastGeminiVerified = (typeof data.geminiVerified === "boolean") ? data.geminiVerified : null;
          state.activeDraft.lastGeminiVerifyReason = String(data.geminiVerifyReason || "");
          // v0.7.309: true when Gemini could not beat the deterministic
          // template, so the merchant is seeing the safe built-in
          // fallback (still a clean compliant entry — never the raw
          // un-rewritten original, which is the bug this fixed).
          state.activeDraft.lastUsedTemplateFallback = data.usedTemplateFallback === true;
          state.activeDraft.lastAttempts = Number(data.attempts || 0);
        }
        return rewrite;
      })
      .catch(function () { return null; });
  }

  // Back-compat shim — anything still calling the old name routes to the
  // new endpoint. The opts.variationIndex / opts.previousSuggestions
  // contract is preserved by mapping to opts.previousRewrites.
  function requestGeminiRewrite(finding, opts) {
    opts = opts || {};
    const previousRewrites = Array.isArray(opts.previousSuggestions) ? opts.previousSuggestions
      : (Array.isArray(opts.previousRewrites) ? opts.previousRewrites : []);
    return requestCompliantRewrite(finding, { previousRewrites: previousRewrites });
  }

  function copyDraftSuggestion() {
    const text = state.activeDraft && state.activeDraft.suggestion ? state.activeDraft.suggestion : "";
    if (!text) {
      toast("No suggested text to copy yet.");
      return;
    }
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(text).then(
        () => toast("Suggested text copied to your clipboard."),
        () => toast("Could not copy automatically. Select the text and copy it manually.")
      );
      return;
    }
    toast("Clipboard is not available. Select the suggested text and copy it manually.");
  }

  function toast(message) {
    const container = document.querySelector(".rpca-notifications");
    if (!container) return;
    const item = document.createElement("div");
    item.className = "rpca-toast";
    item.textContent = message;
    container.appendChild(item);
    window.setTimeout(() => item.remove(), 8000);
  }

  function refreshUiState() {
    return post("rpca_ui_state").then((data) => {
      state.uiState = data;
      renderAdminShell();
      return data;
    });
  }

  // v0.7.396 (user report: the trust-strip tabs — the Age gate especially —
  // kept showing a stale status after the setting was changed). The cockpit
  // renders the trust strip from the page-load uiState snapshot, which goes
  // stale when a control is toggled on the dedicated settings page, in a
  // second tab, or when the cockpit is restored from the browser back/
  // forward cache. On the first cockpit render per page view we pull a
  // fresh admin_ui_state() once, so all five trust tabs reflect live config
  // and the latest stored scan — independent of whether a scan has run.
  let cockpitStateRefreshed = false;
  function ensureCockpitStateFresh() {
    if (cockpitStateRefreshed) {
      return;
    }
    cockpitStateRefreshed = true;
    if (typeof refreshUiState === "function") {
      refreshUiState().catch(function () {});
    }
  }

  // Restored from the browser back/forward cache: JavaScript state is
  // frozen and not re-executed, so the trust strip would otherwise keep
  // its pre-navigation values. Re-pull fresh state on a bfcache restore.
  window.addEventListener("pageshow", function (event) {
    if (event && event.persisted) {
      cockpitStateRefreshed = false;
      ensureCockpitStateFresh();
    }
  });

  function commitToggle(key, value, reason) {
    return post("rpca_ui_event", {
      type: "toggle_change",
      target: key,
      value: value ? "1" : "0",
      message: reason || "",
    }).then((response) => {
      state.uiState = response.state || state.uiState;
      renderAdminShell();
      toast(value ? "Turned on." : "Turned off and saved to the log.");
    });
  }

  function applySettingsPreset(preset, label) {
    const previousPreset = (uiState().settingsPreset || "recommended");
    setSettingsPresetActive(preset);
    toast(`Saving ${label} setup...`);
    return post("rpca_ui_event", {
      type: "settings_preset",
      target: preset,
      message: `Applied ${label} setup.`,
    })
      .then((response) => {
        state.uiState = response.state || state.uiState;
        if (state.uiState) {
          state.uiState.settingsPreset = response.preset || preset;
        }
        renderAdminShell();
        toast(`${label} setup saved. The switches below were updated.`);
      })
      .catch((error) => {
        setSettingsPresetActive(previousPreset);
        toast(error.message || "Could not save that setup.");
      });
  }

  function setSettingsPresetActive(preset) {
    if (!state.uiState) {
      state.uiState = uiState();
    }
    state.uiState.settingsPreset = preset;
    document.querySelectorAll("[data-preset]").forEach((button) => {
      const active = button.getAttribute("data-preset") === preset;
      button.classList.toggle("is-active", active);
      button.setAttribute("aria-pressed", active ? "true" : "false");
    });
  }

  function openDisableModal(row, key) {
    const name = row.querySelector(".rpca-toggle-name") ? row.querySelector(".rpca-toggle-name").textContent : key;
    openModal(`
      <h2>Turn off ${escapeHtml(name)}?</h2>
      <p>This can make the store less protected. The plugin will save your reason in the log so the change is clear later.</p>
      <p>Some payment review scores and store checks may also go down.</p>
      <label>Why are you turning this off? (required):<textarea id="rpca-disable-reason"></textarea></label>
      <div class="rpca-button-row">
        <button type="button" class="rpca-button" data-action="close-modal">Keep it on</button>
        <button type="button" class="rpca-button is-primary" data-action="confirm-disable" data-toggle-key="${escapeHtml(key)}">Turn it off</button>
      </div>
    `);
  }

  function openFreezeModal(active) {
    openModal(`
      <h2>${active ? "Turn orders back on?" : "Emergency stop"}</h2>
      <p>${active ? "This turns the storefront back on." : "This stops new orders right away, shows your maintenance message, pauses outbound AI calls from this emergency control, and keeps all saved records untouched."}</p>
      <label>Reason ${active ? "for turning orders back on" : "for stopping orders"} (required):<textarea id="rpca-freeze-reason"></textarea></label>
      <div class="rpca-button-row">
        <button type="button" class="rpca-button" data-action="close-modal">Cancel</button>
        <button type="button" class="rpca-button is-primary" data-action="confirm-freeze" data-freeze-mode="${active ? "disable" : "enable"}">${active ? "TURN ORDERS BACK ON" : "STOP ORDERS NOW"}</button>
      </div>
    `);
  }

  function downloadJson(filename, data) {
    const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
    const link = document.createElement("a");
    link.href = URL.createObjectURL(blob);
    link.download = filename;
    document.body.appendChild(link);
    link.click();
    link.remove();
    window.setTimeout(() => URL.revokeObjectURL(link.href), 1000);
  }

  function bindShellEvents(root) {
    // v0.7.254: re-run idempotent bootstraps on every render so cards
    // that only exist on certain SPA sections (banner card on the
    // Compliance Generator page, buyer-ack card on the same page,
    // age-gate live preview on its dedicated page) get their
    // event listeners wired even when the merchant navigates in-place.
    // Each bootstrap uses its own sentinel attribute so re-runs do
    // NOT stack listeners.
    bootstrapBuyerAckLivePreview();
    bootstrapBannerCardLivePreview();
    if (root.dataset.bound === "1") return;
    root.dataset.bound = "1";
    root.addEventListener("click", (event) => {
      if (event.target && event.target.id === "rpca-modal-overlay") {
        closeModal();
        return;
      }
      if (event.target && event.target.id === "rpca-command-overlay") {
        closeCommandPalette();
        return;
      }
      // v0.7.377: never intercept clicks on raw form controls (file
      // inputs, checkboxes, radios, selects, textareas) UNLESS the
      // control itself carries data-action. Without this, a file
      // input inside a form bubbles its click to the action
      // dispatcher and the browser's native file picker never opens.
      // The import-config flow is preserved because that input has
      // its OWN data-action.
      if (event.target && event.target instanceof HTMLElement) {
        const tag = event.target.tagName;
        const hasOwnAction = event.target.hasAttribute("data-action");
        if (!hasOwnAction && (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA" || tag === "OPTION")) {
          return;
        }
        // Clicks on a <label> that targets a form control should also
        // be allowed to dispatch natively so the associated control
        // (especially file inputs) receives its synthetic click.
        if (!hasOwnAction && tag === "LABEL") {
          const labelEl = event.target;
          const forId = labelEl.getAttribute("for");
          const controlInside = labelEl.querySelector("input, select, textarea");
          if (forId || controlInside) {
            return;
          }
        }
      }
      const sectionButton = event.target.closest("[data-section]");
      const actionButton = event.target.closest("[data-action]");
      const subtab = event.target.closest("[data-subtab-key]");
      const viewButton = event.target.closest("[data-view]");
      if (sectionButton && !actionButton) {
        const locked = sectionButton.getAttribute("data-locked");
        const section = sectionButton.getAttribute("data-section");
        if (locked) {
          toast(`${sectionTitle(section)} unlocks on setup day ${locked}.`);
          return;
        }
        // Each SPA section has its own WordPress admin submenu page now.
        // Navigate to that page (real URL, real WP sidebar highlight,
        // bookmarkable) instead of switching JS state in place. Falls back
        // to the previous in-place switch when sectionAdminUrls is missing
        // (older RPCA payload shape).
        const targetUrl = RPCA.sectionAdminUrls && RPCA.sectionAdminUrls[section || "cockpit"];
        if (targetUrl) {
          event.preventDefault();
          window.location.href = targetUrl;
          return;
        }
        state.activeSection = section || "cockpit";
        window.location.hash = state.activeSection;
        renderAdminShell();
        return;
      }
      if (viewButton && !actionButton) {
        const view = viewButton.getAttribute("data-view") || "";
        state.findingView = state.findingView === view ? "" : view;
        state.currentFindingIndex = 0;
        renderAdminShell();
        event.preventDefault();
        return;
      }
      if (subtab) {
        state.activeSubtab[subtab.getAttribute("data-subtab-key")] = subtab.getAttribute("data-subtab");
        renderAdminShell();
        return;
      }
      if (!actionButton) return;
      handleShellAction(actionButton, event);
    });
    root.addEventListener("input", (event) => {
      if (event.target.matches("#rpca-command-input")) {
        filterCommandPalette();
      }
      if (event.target.matches("[data-finding-filter]")) {
        state.findingQuery = event.target.value || "";
        refreshFindingsList();
      }
    });
    root.addEventListener("change", (event) => {
      if (event.target.matches(".rpca-theme-select, .rpca-topbar-theme, [data-action='theme-select']")) {
        const theme = event.target.value;
        applyTheme(theme);
        post("rpca_save_theme", { theme }).catch(() => {});
      }
      // v0.7.266: large-site mode checkbox toggle. Persists to
      // localStorage so the choice survives a page reload. Mirrors
      // to every other checkbox on the page so the cockpit + the
      // onboarding hero stay in sync.
      if (event.target.matches("[data-rpca-large-site-mode]")) {
        const enabled = !!event.target.checked;
        state.largeSiteMode = enabled;
        try {
          if (window.localStorage) {
            window.localStorage.setItem("rpca-large-site-mode", enabled ? "1" : "0");
          }
        } catch (_err) {
          // localStorage unavailable (private mode etc.) - state still
          // sticks for the duration of this page load.
        }
        document.querySelectorAll("[data-rpca-large-site-mode]").forEach((node) => {
          if (node !== event.target) node.checked = enabled;
        });
      }
    });
    root.addEventListener("submit", (event) => {
      const form = event.target.closest("form[data-action='submit-policy-chat']");
      if (form) {
        event.preventDefault();
        sendPolicyChatMessage(form);
        return;
      }
    });
    root.addEventListener("input", (event) => {
      const textarea = event.target.closest(".rpca-chat-form textarea[name='policyChatMessage']");
      if (textarea) {
        state.policyPlanChat = state.policyPlanChat || {};
        state.policyPlanChat.input = textarea.value;
      }
    });
  }

  // ---------- Policies & COA: chat + generate ----------

  function openPolicyChat(policyKey) {
    if (!state.policyPlanChat) {
      state.policyPlanChat = { open: false, history: [], policyKey: "", input: "", busy: false };
    }
    if (policyKey && state.policyPlanChat.policyKey && state.policyPlanChat.policyKey !== policyKey) {
      // Switching to a different template resets the conversation so the
      // planner does not carry plan-A context into a plan-B request.
      state.policyPlanChat.history = [];
      state.policyPlanChat.input = "";
    }
    state.policyPlanChat.policyKey = policyKey || state.policyPlanChat.policyKey || "";
    state.policyPlanChat.open = true;
    if (state.activeSection !== "policies") {
      state.activeSection = "policies";
    }
    renderAdminShell();
  }

  // Affirmative confirmation messages that should NOT be sent to the AI;
  // they mean "use the most recent planner reply as the draft and ship
  // it." Catches "yes", "yeah", "looks good", etc. — anything more
  // specific than that goes through the normal chat flow so the planner
  // can adjust. Pattern matches the merchant's actual confirmation
  // vocabulary observed in the planner conversation logs.
  function isAffirmativeChatReply(message) {
    const normalized = String(message || "")
      .trim()
      .toLowerCase()
      .replace(/[.!?]+$/, "")
      .replace(/^(please\s+|just\s+)/, "");
    return /^(y|yes|yeah|yep|ya|yup|sure|ok|okay|k|kk|do it|do this|build it|build this|make it|generate it|generate this|use it|use this|run it|ship it|go ahead|go for it|sounds good|looks good|that works|that's good|thats good|good|great|perfect|approved|confirmed|let's go|lets go|proceed|continue|publish it)$/.test(normalized);
  }

  function lastPlannerReplyWithPlan(history) {
    if (!Array.isArray(history)) return null;
    for (let i = history.length - 1; i >= 0; i--) {
      const entry = history[i];
      if (
        entry &&
        entry.role === "assistant" &&
        !entry.aiUnavailable &&
        typeof entry.text === "string" &&
        entry.text.trim().length > 40 &&
        !/safety screen|did not pass/i.test(entry.text)
      ) {
        return entry;
      }
    }
    return null;
  }

  function sendPolicyChatMessage(form) {
    const textarea = form.querySelector("textarea[name='policyChatMessage']");
    if (!textarea) return;
    const message = String(textarea.value || "").trim();
    if (!message) return;
    state.policyPlanChat = state.policyPlanChat || { open: true, history: [], policyKey: form.getAttribute("data-policy-key") || "", input: "", busy: false };
    if (state.policyPlanChat.busy) return;

    // Affirmative shortcut: if the merchant typed "yes" / "go ahead" /
    // similar AND the previous planner reply contains a real plan, skip
    // the AI call (which previously returned wording too short to pass
    // the safety screen, leaving the merchant in a "yes -> safety
    // failure -> what now?" loop) and use that prior reply as the draft
    // content directly. Auto-redirects to the new draft on success.
    const priorPlan = lastPlannerReplyWithPlan(state.policyPlanChat.history);
    if (isAffirmativeChatReply(message) && priorPlan) {
      state.policyPlanChat.history.push({ role: "user", text: message });
      state.policyPlanChat.history.push({
        role: "assistant",
        text: "Building your draft from the plan above. The page will open in a new tab when WordPress saves it.",
      });
      state.policyPlanChat.input = "";
      state.policyPlanChat.busy = true;
      textarea.value = "";
      renderAdminShell();
      post("rpca_create_policy_page", {
        policyKey: state.policyPlanChat.policyKey,
        customContent: priorPlan.text,
      })
        .then((data) => {
          const editUrl = data && data.editUrl;
          state.policyPlanChat.history.push({
            role: "assistant",
            text: editUrl
              ? "Draft built. Opening it in a new tab so you can review and publish."
              : "Draft built. Open Pages in WordPress to find it.",
          });
          state.policyPlanChat.busy = false;
          renderAdminShell();
          if (editUrl) {
            // Open in new tab AND navigate this tab to the same edit URL
            // so the merchant lands on the editor without losing the
            // chat context if the popup is blocked.
            const opened = window.open(editUrl, "_blank", "noopener");
            if (!opened) {
              window.location.href = editUrl;
            }
          }
          toast("Draft built from your planner conversation.");
        })
        .catch((err) => {
          const message = (err && err.message) || "Could not create the draft. Check the activity log.";
          state.policyPlanChat.history.push({ role: "assistant", text: message });
          state.policyPlanChat.busy = false;
          renderAdminShell();
          toast(message);
          if (err && err.editUrl) {
            window.open(err.editUrl, "_blank", "noopener");
          }
        });
      return;
    }

    state.policyPlanChat.history.push({ role: "user", text: message });
    state.policyPlanChat.input = "";
    state.policyPlanChat.busy = true;
    renderAdminShell();

    post("rpca_policy_chat", {
      policyKey: state.policyPlanChat.policyKey,
      message,
      history: JSON.stringify(state.policyPlanChat.history.slice(-12)),
    })
      .then((data) => {
        const text = data && data.text ? String(data.text) : "Planner did not return a reply.";
        const entry = { role: "assistant", text };
        if (data && data.aiUnavailable) {
          entry.aiUnavailable = true;
          entry.setupUrl = data.setupUrl || (RPCA.sectionAdminUrls && RPCA.sectionAdminUrls.settings) || "";
          entry.missingToggles = Array.isArray(data.missingToggles) ? data.missingToggles : [];
        }
        state.policyPlanChat.history.push(entry);
      })
      .catch((err) => {
        const text = (err && err.message) || "Planner could not reach AI right now. Try again, or click 'Generate this page now' to use the standard template.";
        state.policyPlanChat.history.push({ role: "assistant", text });
      })
      .then(() => {
        state.policyPlanChat.busy = false;
        renderAdminShell();
      });
  }

  // v0.7.290: bulk-create selected policy pages.
  function runPolicyBulkGenerate(button, generateAllMissing) {
    const status = document.getElementById("rpca-policy-bulk-status");
    let keys;
    if (generateAllMissing) {
      const existence = (state.uiState && state.uiState.policyPagesGenerated) || {};
      keys = Array.from(document.querySelectorAll(".rpca-policy-checkbox"))
        .map((c) => c.getAttribute("data-policy-key") || "")
        .filter((k) => k && !(existence[k] && existence[k].exists));
    } else {
      keys = Array.from(document.querySelectorAll(".rpca-policy-checkbox:checked"))
        .map((c) => c.getAttribute("data-policy-key") || "")
        .filter(Boolean);
    }
    if (!keys.length) {
      if (status) {
        status.hidden = false;
        status.className = "rpca-inline-status";
        status.textContent = generateAllMissing
          ? "Every policy page already exists on your site."
          : "Tick at least one policy first, then click Generate selected.";
      }
      return;
    }
    const spinner = button ? button.querySelector("[data-role='bulk-policy-spinner']") : null;
    const label = button ? button.querySelector(".rpca-button-label") : null;
    const originalLabel = label ? label.textContent : "";
    if (button) button.disabled = true;
    if (spinner) spinner.hidden = false;
    if (label) label.textContent = "Generating " + keys.length + "...";
    if (status) {
      status.hidden = false;
      status.className = "rpca-inline-status";
      status.textContent = "Generating " + keys.length + " policy page" + (keys.length === 1 ? "" : "s") + " — this can take 20-40 seconds when AI is on...";
    }
    // Sent as policy[]=key entries (the WP-style array form WordPress
    // expects when handlers iterate $_POST['keys']).
    const formData = {};
    keys.forEach((k, i) => { formData["keys[" + i + "]"] = k; });
    formData.publish = "0"; // create as drafts so the merchant reviews first
    post("rpca_create_policy_pages_bulk", formData)
      .then((data) => {
        if (status) {
          status.className = "rpca-inline-status is-success";
          status.textContent = (data && data.summary) || ("Generated " + keys.length + " policy pages.");
        }
        toast((data && data.summary) || "Bulk policy generation complete.");
        // Refresh existence map so green checkmarks appear immediately.
        return refreshUiState().catch(() => null);
      })
      .catch((err) => {
        if (status) {
          status.className = "rpca-inline-status is-error";
          status.textContent = (err && err.message) || "Could not bulk-generate the selected policies.";
        }
        toast((err && err.message) || "Could not bulk-generate the selected policies.");
      })
      .finally(() => {
        if (button) button.disabled = false;
        if (spinner) spinner.hidden = true;
        if (label) label.textContent = originalLabel || "Generate selected";
      });
  }

  function generatePolicyPage(button, override) {
    override = override || {};
    const policyKey = override.policyKey || button.getAttribute("data-policy-key") || "";
    if (!policyKey) return;
    const customContent = typeof override.customContent === "string" ? override.customContent : "";
    const originalLabel = button.textContent;
    button.disabled = true;
    button.textContent = customContent ? "Building draft from plan..." : "Generating...";
    const payload = { policyKey };
    if (customContent) {
      payload.customContent = customContent;
    }
    post("rpca_create_policy_page", payload)
      .then((data) => {
        const editUrl = data && data.editUrl;
        const message = (data && data.message) || (customContent ? "Draft built from your planner conversation." : "Draft created.");
        toast(message);
        if (editUrl) {
          window.open(editUrl, "_blank", "noopener");
        }
      })
      .catch((err) => {
        const message = (err && err.message) || "Could not create the draft. Check the activity log.";
        toast(message);
        if (err && err.editUrl) {
          window.open(err.editUrl, "_blank", "noopener");
        }
      })
      .then(() => {
        button.disabled = false;
        button.textContent = originalLabel;
      });
  }

  // Sitewide disclaimer banner: read the form fields out of the
  // banner card, post them to the new endpoint, and render the
  // returned preview HTML inline. When `save` is true, the endpoint
  // also persists the option so the banner goes live on every page.
  function generateDisclaimerBanner(save) {
    state.disclaimerBannerPreview = state.disclaimerBannerPreview || {};
    state.disclaimerBannerPreview.busy = true;
    const root = document.getElementById("rpca-ui-root");
    if (!root) return;
    const textArea = root.querySelector("textarea[name='rpca-banner-text']");
    const enabledInput = root.querySelector("input[name='rpca-banner-enabled']");
    const placementInput = root.querySelector("input[name='rpca-banner-placement']:checked");
    const colorSelect = root.querySelector("select[name='rpca-banner-color-slug']");
    const colorModeSelect = root.querySelector("select[name='rpca-banner-color-mode']");
    const bgColorInput = root.querySelector("input[name='rpca-banner-bg-color']");
    const textColorInput = root.querySelector("input[name='rpca-banner-text-color']");
    const fontSizeInput = root.querySelector("input[name='rpca-banner-font-size']");
    const customClassInput = root.querySelector("input[name='rpca-banner-custom-class']");
    const dismissibleInput = root.querySelector("input[name='rpca-banner-dismissible']");
    const payload = {
      text: textArea ? textArea.value : "",
      enabled: enabledInput && enabledInput.checked ? "1" : "0",
      placement: placementInput ? placementInput.value : "sticky_bottom",
      colorSlug: colorSelect ? colorSelect.value : "",
      colorMode: colorModeSelect ? colorModeSelect.value : "auto",
      bgColor: bgColorInput ? bgColorInput.value : "",
      textColor: textColorInput ? textColorInput.value : "",
      fontSize: fontSizeInput ? fontSizeInput.value : "14",
      customClass: customClassInput ? customClassInput.value : "",
      dismissible: dismissibleInput && dismissibleInput.checked ? "1" : "0",
      save: save ? "1" : "0",
    };
    renderAdminShell();
    post("rpca_generate_disclaimer_banner", payload)
      .then((data) => {
        state.disclaimerBannerPreview = {
          previewHtml: (data && data.previewHtml) || "",
          placement: (data && data.placement) || payload.placement,
          text: (data && data.text) || payload.text,
          colorSlug: (data && data.colorSlug) || payload.colorSlug,
          designHints: (data && data.designHints) || (state.disclaimerBannerPreview && state.disclaimerBannerPreview.designHints) || {},
          dismissible: payload.dismissible === "1",
          saved: !!(data && data.saved),
          busy: false,
        };
        // Refresh the persisted snapshot so re-renders show saved values.
        if (save && window.RPCA && RPCA.uiState) {
          RPCA.uiState.banner = {
            enabled: data.enabled || "1",
            placement: data.placement,
            text: data.text,
            colorSlug: data.colorSlug,
            colorMode: data.colorMode,
            bgColor: data.bgColor,
            textColor: data.textColor,
            fontSize: data.fontSize,
            customClass: data.customClass,
            dismissible: payload.dismissible,
          };
        }
        if (save) {
          toast("Disclaimer banner saved and live on every page.");
        }
        renderAdminShell();
      })
      .catch((err) => {
        const message = (err && err.message) || "Could not generate the disclaimer banner.";
        toast(message);
        state.disclaimerBannerPreview.busy = false;
        renderAdminShell();
      });
  }

  // v0.7.254: live-preview wiring for the disclaimer banner card.
  // Updates the preview fallback (text / colors / font-size) as the
  // merchant edits, plus toggles the visibility of the slug-row and
  // custom-row based on the color-mode select. No-op when the card
  // is not on the current page.
  function bootstrapBannerCardLivePreview() {
    const card = document.querySelector("[data-rpca-banner-card]");
    if (!card) return;
    if (card.dataset.rpcaBannerBound === "1") return;
    card.dataset.rpcaBannerBound = "1";
    const colorModeSelect = card.querySelector("select[name='rpca-banner-color-mode']");
    const slugRow = card.querySelector("[data-rpca-banner-color-slug-row]");
    const customRow = card.querySelector("[data-rpca-banner-custom-row]");
    const textArea = card.querySelector("textarea[name='rpca-banner-text']");
    const bgColorInput = card.querySelector("input[name='rpca-banner-bg-color']");
    const textColorInput = card.querySelector("input[name='rpca-banner-text-color']");
    const fontSizeInput = card.querySelector("input[name='rpca-banner-font-size']");
    const fontSizeLabel = card.querySelector("[data-rpca-banner-font-size-value]");
    const fallback = card.querySelector("[data-rpca-banner-preview-fallback]");
    function refresh() {
      if (colorModeSelect && slugRow && customRow) {
        const mode = colorModeSelect.value;
        slugRow.hidden = mode !== "theme";
        customRow.hidden = mode !== "custom";
      }
      if (fontSizeInput && fontSizeLabel) {
        fontSizeLabel.textContent = String(parseInt(fontSizeInput.value, 10) || 14);
      }
      if (fallback) {
        if (textArea) fallback.textContent = textArea.value || "";
        const mode = colorModeSelect ? colorModeSelect.value : "auto";
        if (mode === "custom") {
          if (bgColorInput) fallback.style.background = bgColorInput.value;
          if (textColorInput) fallback.style.color = textColorInput.value;
        }
        if (fontSizeInput) fallback.style.fontSize = (parseInt(fontSizeInput.value, 10) || 14) + "px";
      }
    }
    card.addEventListener("input", refresh);
    card.addEventListener("change", refresh);
    refresh();

    // v0.7.254: scroll-into-view + highlight on hash arrival.
    // Per-trigger Apply-fix routes for missing-disclaimer findings
    // send the merchant here with #disclaimer-banner.
    if (window.location.hash === "#disclaimer-banner") {
      try {
        card.scrollIntoView({ behavior: "smooth", block: "start" });
      } catch (_e) {
        /* older browsers - best effort */
      }
      card.style.outline = "2px solid #3F8A4F";
      card.style.outlineOffset = "4px";
      const enabledCheckbox = card.querySelector("[name='rpca-banner-enabled']");
      if (enabledCheckbox && !enabledCheckbox.checked) {
        enabledCheckbox.checked = true;
      }
      setTimeout(() => {
        card.style.outline = "";
        card.style.outlineOffset = "";
      }, 4000);
    }
  }

  function handleShellAction(button, event) {
    const action = button.getAttribute("data-action");
    if (
      [
        "open-buyer-ack-setup",
        "open-disclaimer-banner-setup",
        "open-age-gate-setup",
        "open-coa-generator",
        "open-pages",
        "open-setup",
        "open-evidence",
        // v0.7.447 (live-site bug): the COA "Open page in WordPress to
        // add COA reference" link carries data-action="open-page-editor"
        // but was MISSING from this navigation allowlist. The delegated
        // click handler intercepts every [data-action] element and routes
        // it here; with no matching case the handler fell through and
        // returned without navigating, so the link did nothing (confirmed
        // dead on the live site). Adding it here makes the link navigate.
        // It is rendered with target="_blank", so honor that by opening a
        // new tab instead of replacing the current admin page.
        "open-page-editor",
        // v0.7.447: theme / plugin editor deep-links use the same
        // pattern and could hit the same dead-click trap; allowlist them
        // too so every finding deep-link navigates.
        "open-theme-editor",
        "open-plugin-editor",
      ].includes(action)
    ) {
      const href = normalizeNavigableUrl(button.getAttribute("href") || "");
      if (href) {
        event.preventDefault();
        // Respect target="_blank" links (page/theme/plugin editor open in
        // a new tab so the merchant keeps the findings list open).
        if ((button.getAttribute("target") || "") === "_blank") {
          window.open(href, "_blank", "noopener,noreferrer");
        } else {
          window.location.href = href;
        }
      }
      return;
    }
    if (action === "section") {
      state.activeSection = button.getAttribute("data-target") || "cockpit";
      renderAdminShell();
    }
    if (action === "open-command") {
      openCommandPalette();
    }
    if (action === "install-recommended-plugin") {
      // v0.7.421: one-click install of the recommended free WP
      // plugin from inside the Compliance Generator card. POSTs to
      // wp_ajax_rpca_install_recommended_plugin; reloads on success.
      event.preventDefault();
      handleInstallRecommendedPlugin(button);
      return;
    }
    if (action === "apply-trust-badge") {
      // v0.7.435: Apply / change / remove the public trust badge from
      // the site footer in one click. POSTs to wp_ajax_rpca_set_badge_auto_inject.
      event.preventDefault();
      handleApplyTrustBadge(button);
      return;
    }
    if (action === "save-initial-setup") {
      saveInitialSetup(button);
      return;
    }
    if (action === "save-ai-settings") {
      saveAiSettings(button);
      return;
    }
    if (action === "save-scanner-urls") {
      saveScannerUrls(button);
      return;
    }
    if (action === "start-onboarding-scan") {
      runShellScan(button, true);
      return;
    }
    if (action === "start-scan") {
      runShellScan(button, false);
      return;
    }
    if (action === "generate-policy") {
      generatePolicyPage(button);
      return;
    }
    // v0.7.290: Compliance Generator bulk select + bulk generate.
    if (action === "policy-bulk-select-all") {
      document.querySelectorAll(".rpca-policy-checkbox").forEach((c) => { c.checked = true; });
      return;
    }
    if (action === "policy-bulk-clear") {
      document.querySelectorAll(".rpca-policy-checkbox").forEach((c) => { c.checked = false; });
      return;
    }
    if (action === "policy-bulk-select-missing") {
      const existence = (state.uiState && state.uiState.policyPagesGenerated) || {};
      document.querySelectorAll(".rpca-policy-checkbox").forEach((c) => {
        const k = c.getAttribute("data-policy-key") || "";
        c.checked = !(existence[k] && existence[k].exists);
      });
      return;
    }
    if (action === "policy-bulk-generate" || action === "policy-bulk-generate-all") {
      runPolicyBulkGenerate(button, action === "policy-bulk-generate-all");
      return;
    }
    if (action === "open-policy-chat") {
      openPolicyChat(button.getAttribute("data-policy-key") || "");
      return;
    }
    if (action === "close-policy-chat") {
      state.policyPlanChat = state.policyPlanChat || {};
      state.policyPlanChat.open = false;
      renderAdminShell();
      return;
    }
    if (action === "submit-policy-chat") {
      // The form submit handler below calls preventDefault and dispatches
      // sendPolicyChatMessage. This action handler is only reached on the
      // odd browser path where the button's click bubbles before submit.
      const form = button.closest("form");
      if (form) {
        event.preventDefault();
        sendPolicyChatMessage(form);
      }
      return;
    }
    if (action === "banner-preview" || action === "banner-save") {
      generateDisclaimerBanner(action === "banner-save");
      return;
    }
    if (action === "buyer-ack-save") {
      saveBuyerAcknowledgmentSettings();
      return;
    }
    // v0.7.391 (Feature 3): one-click apply-all-safe-fixes.
    if (action === "apply-all-safe-fixes") {
      runApplyAllSafeFixes();
      return;
    }
    // v0.7.379: single-page rewrite picker.
    if (action === "page-rewriter-refresh-pages") {
      refreshPageRewriterPagesList();
      return;
    }
    if (action === "page-rewriter-generate") {
      runPageRewriterScan();
      return;
    }
    if (action === "page-rewriter-copy") {
      const fId = button.getAttribute("data-finding-id") || "";
      const idxAttr = button.getAttribute("data-finding-index");
      const idx = idxAttr === null || idxAttr === "" ? NaN : Number(idxAttr);
      copyPageRewriterRewrite(fId, idx);
      return;
    }
    if (action === "compliance-asset-view") {
      viewComplianceAsset(button.getAttribute("data-asset-key") || "");
      return;
    }
    if (action === "compliance-asset-copy") {
      copyComplianceAsset(button.getAttribute("data-asset-key") || "");
      return;
    }
    if (action === "compliance-asset-hide") {
      hideComplianceAsset(button.getAttribute("data-asset-key") || "");
      return;
    }
    if (action === "compliance-asset-download") {
      downloadComplianceAsset(button.getAttribute("data-asset-key") || "");
      return;
    }
    if (action === "compliance-asset-apply-toggle") {
      toggleComplianceAssetAutoInject(
        button.getAttribute("data-asset-key") || "",
        button.getAttribute("data-asset-state") || "on",
        button
      );
      return;
    }
    if (action === "use-plan-as-draft") {
      const idx = Number(button.getAttribute("data-history-index") || -1);
      const policyKey = button.getAttribute("data-policy-key") || "";
      const entry = (state.policyPlanChat && state.policyPlanChat.history && state.policyPlanChat.history[idx]) || null;
      const planText = entry && entry.text ? String(entry.text) : "";
      if (!planText) {
        toast("That planner reply is empty.");
        return;
      }
      generatePolicyPage(button, { customContent: planText, policyKey });
      return;
    }
    if (action === "snooze-one-thing") {
      post("rpca_ui_event", { type: "one_thing_snoozed", message: "Snoozed Today's One Thing for 24 hours.", target: "cockpit" }).then(() => toast("Snoozed for 24 hours."));
    }
    if (action === "skip-onboarding") {
      post("rpca_ui_event", { type: "onboarding_skip", message: `Skipped ahead to ${button.getAttribute("data-target")}.`, target: button.getAttribute("data-target") }).then(() => {
        state.uiState.onboarding.completed = true;
        state.activeSection = button.getAttribute("data-target") || "cockpit";
        renderAdminShell();
      });
    }
    if (action === "toggle-why" || action === "inline-rewrite") {
      const index = Number(button.getAttribute("data-index") || 0);
      state.currentFindingIndex = index;
      const row = document.querySelector(`[data-finding-index="${index}"]`);
      const detail = row && row.querySelector(".rpca-finding-detail");
      const finding = (uiState().findings || [])[index] || {};
      if (detail) {
        detail.hidden = false;
        detail.innerHTML =
          action === "toggle-why"
            ? renderWhyPanel(finding)
            : renderRewritePanel(finding);
      }
    }
    // v0.7.297 (Item #8): toggle inline AI-reason panel — surfaces
    // Gemini's plain-English reasoning right under the finding headline
    // (was previously buried in a tooltip on the AI context badge).
    if (action === "toggle-ai-reason") {
      const index = Number(button.getAttribute("data-index") || 0);
      const row = document.querySelector(`[data-finding-index="${index}"]`);
      const panel = row && row.querySelector('[data-rpca-ai-reason="1"]');
      if (panel) panel.hidden = !panel.hidden;
    }
    if (action === "dismiss-finding") {
      const index = Number(button.getAttribute("data-index") || 0);
      const finding = findingByIndex(index) || {};
      const row = document.querySelector(`[data-finding-index="${index}"]`);
      if (row) row.remove();
      recordFeedback(finding.id || "", "false_positive", "")
        .then((data) => {
          const tail = data && data.totalFalsePositive ? ` Compliance Guard now treats ${data.totalFalsePositive} pattern${data.totalFalsePositive === 1 ? "" : "s"} with extra skepticism.` : "";
          toast("Hidden and saved as a false-positive signal." + tail);
        })
        .catch(() => toast("Hidden from this list. Feedback could not be recorded."));
    }
    if (action === "unmark-applied") {
      // v0.7.284: undo the Applied tag and move the row back into pending.
      const findingId = button.getAttribute("data-finding-id") || "";
      if (findingId) {
        unmarkFindingApplied(findingId);
        toast("Moved back to pending. The next scan will re-check this item.");
      }
    }
    if (action === "toggle-setting") {
      const row = button.closest(".rpca-toggle-row");
      const key = row ? row.getAttribute("data-toggle-key") : "";
      const enabled = button.getAttribute("aria-pressed") === "true";
      const protection = row && row.getAttribute("data-protection") === "1";
      if (enabled && protection) {
        openDisableModal(row, key);
      } else {
        commitToggle(key, !enabled, "");
      }
    }
    if (action === "confirm-disable") {
      const reason = el("rpca-disable-reason") ? el("rpca-disable-reason").value.trim() : "";
      if (!reason) {
        toast("Please type a short reason first.");
        return;
      }
      commitToggle(button.getAttribute("data-toggle-key"), false, reason).then(closeModal);
    }
    if (action === "close-modal") closeModal();
    if (action === "open-freeze") {
      const freeze = uiState().freeze || {};
      if (!freeze.canUse) {
        openModal(`<h2>Extra permission needed</h2><p>Only users who were given emergency-stop permission can use this control. Regular admin access is not enough.</p><div class="rpca-button-row"><button type="button" class="rpca-button" data-action="close-modal">Close</button></div>`);
      } else {
        openFreezeModal(Boolean(freeze.active));
      }
    }
    if (action === "confirm-freeze") {
      const reason = el("rpca-freeze-reason") ? el("rpca-freeze-reason").value.trim() : "";
      if (!reason) {
        toast("Please type a short reason first.");
        return;
      }
      post("rpca_freeze_mode", { mode: button.getAttribute("data-freeze-mode"), reason }).then((response) => {
        if (state.uiState) state.uiState.freeze = response.freeze;
        closeModal();
        renderAdminShell();
      });
    }
    if (action === "export-config") {
      downloadJson("ruo-compliance-guard-configuration.json", { uiState: uiState(), lastScan: state.lastScan });
    }
    if (action === "preset") {
      applySettingsPreset(button.getAttribute("data-preset") || "recommended", button.textContent.trim());
    }
    if (action === "close-help") {
      const rail = el("rpca-help-rail");
      if (rail) rail.hidden = true;
    }
    if (action === "edit-rewrite") {
      const editIndex = Number(button.getAttribute("data-index"));
      const safeIndex = Number.isFinite(editIndex) ? editIndex : state.currentFindingIndex || 0;
      openDraftModal(safeIndex);
      return;
    }
    if (action === "try-another") {
      toast("Generated text stays as a draft until you approve the preview.");
      return;
    }
    if (action === "goto-draft-page") {
      const draftIndex = Number(button.getAttribute("data-finding-index") || 0);
      const finding = findingByIndex(draftIndex);
      if (!finding) {
        toast("Could not find this item. Run a fresh scan and try again.");
        return;
      }
      closeModal();
      goToFindingLocation(finding);
      return;
    }
    if (action === "copy-draft-text") {
      copyDraftSuggestion();
      return;
    }
    if (action === "regenerate-draft") {
      if (!state.activeDraft) {
        return;
      }
      const thinking = document.querySelector(".rpca-draft-thinking");
      if (thinking) thinking.hidden = false;
      // Capture the previously displayed wording (whether from the local
      // rewrite or the most recent Gemini reply) so the next call can
      // explicitly ask Gemini to differ from it.
      const previousList = (state.activeDraft.previousSuggestions || []).slice();
      if (state.activeDraft.suggestion) {
        const lastEntry = previousList.length ? previousList[previousList.length - 1] : "";
        if (state.activeDraft.suggestion !== lastEntry) {
          previousList.push(state.activeDraft.suggestion);
        }
      }
      state.activeDraft.previousSuggestions = previousList;
      state.activeDraft.variationIndex = Number(state.activeDraft.variationIndex || 0) + 1;
      requestGeminiRewrite(state.activeDraft.finding, {
        variationIndex: state.activeDraft.variationIndex,
        previousSuggestions: previousList,
      }).then((aiText) => {
        const t = document.querySelector(".rpca-draft-thinking");
        if (t) t.hidden = true;
        if (!aiText) {
          toast("Could not get a new suggestion right now. Try again in a moment.");
          return;
        }
        // If Gemini handed back the same text we already had, retry once
        // more with an even stronger differ-from-previous instruction so
        // the user actually sees a new option.
        const trimmedNew = String(aiText).trim();
        const trimmedOld = String(state.activeDraft && state.activeDraft.suggestion ? state.activeDraft.suggestion : "").trim();
        if (trimmedNew && trimmedNew === trimmedOld) {
          if (t) t.hidden = false;
          state.activeDraft.variationIndex = Number(state.activeDraft.variationIndex || 0) + 1;
          requestGeminiRewrite(state.activeDraft.finding, {
            variationIndex: state.activeDraft.variationIndex,
            previousSuggestions: state.activeDraft.previousSuggestions,
          }).then((retryText) => {
            const t2 = document.querySelector(".rpca-draft-thinking");
            if (t2) t2.hidden = true;
            const finalText = retryText || aiText;
            if (state.activeDraft) {
              state.activeDraft.suggestion = finalText;
            }
            const node = el("rpca-draft-suggestion");
            if (node) node.textContent = `"${finalText}"`;
            renderDraftWarningIfNeeded(finalText);
            if (!retryText || retryText.trim() === trimmedOld) {
              toast("Gemini returned a similar version. Try once more for a fresh take.");
            }
          });
          return;
        }
        if (state.activeDraft) {
          state.activeDraft.suggestion = aiText;
        }
        const node = el("rpca-draft-suggestion");
        if (node) node.textContent = `"${aiText}"`;
        renderDraftWarningIfNeeded(aiText);
      });
      return;
    }
    if (action === "save-as-draft") {
      saveAsDraft(button);
      return;
    }
    // v0.7.296 (Item #5 — preview-only): open the file-patch diff modal.
    // Never writes to disk — server-side ajax_preview_file_patch is
    // read-only. The destructive apply path is deferred per COLLAB.md.
    if (action === "preview-file-patch") {
      openFilePatchPreviewModal(button.getAttribute("data-finding-id") || "");
      return;
    }
    if (action === "copy-file-patch-proposed") {
      const stashed = window.__rpcaFilePatchPreview;
      if (!stashed || !Array.isArray(stashed.proposedLines) || !stashed.proposedLines.length) {
        toast("No proposed text to copy.");
        return;
      }
      const text = stashed.proposedLines.join("\n");
      if (navigator.clipboard && navigator.clipboard.writeText) {
        navigator.clipboard.writeText(text).then(
          function () { toast("Proposed lines copied. Paste them into the file editor manually."); },
          function () { toast("Could not copy automatically. Select the diff manually."); }
        );
      } else {
        toast("Clipboard is not available. Select the diff text manually.");
      }
      return;
    }
    if (action === "apply-policy-fix") {
      applyPolicyFix(button);
      return;
    }
    if (action === "apply-security-headers") {
      applySecurityHeadersFix(button);
      return;
    }
    if (action === "confirm-create-policy") {
      const forcedPolicy = button.getAttribute("data-policy-key") || "";
      const forcedFinding = button.getAttribute("data-finding-id") || "";
      closeModal();
      post("rpca_create_policy_page", { policyKey: forcedPolicy, findingId: forcedFinding })
        .then((data) => {
          toast((data && data.message) || "Draft created.");
          const editUrl = normalizeNavigableUrl(data && data.editUrl ? data.editUrl : "");
          if (editUrl) window.open(editUrl, "_blank", "noopener,noreferrer");
        })
        .catch((error) => {
          toast((error && error.message) || "Could not create the draft.");
          const editUrl = normalizeNavigableUrl(error && error.editUrl ? error.editUrl : "");
          if (editUrl) window.open(editUrl, "_blank", "noopener,noreferrer");
        });
      return;
    }
    if (action === "confirm-dismiss-policy") {
      const dismissId = button.getAttribute("data-finding-id") || "";
      closeModal();
      recordFeedback(dismissId, "false_positive", "Marked existing via AI cross-check").then(() => {
        toast("Marked as a false positive. Compliance Guard will treat similar matches with extra skepticism.");
      });
      return;
    }
    event.preventDefault();
  }

  // v0.7.296 (Item #5 — preview-only): open the file-patch diff modal.
  // Calls rpca_preview_file_patch (server-side read-only), shows the
  // unified diff with red strikethrough for removed lines and green for
  // added lines, surfaces residual triggers, and offers TWO actions:
  // Copy proposed lines (clipboard) + Open file in WP editor. NO Apply
  // button — destructive write is deferred per COLLAB.md Item #5 §4.
  function openFilePatchPreviewModal(findingId) {
    if (!findingId) {
      toast("Could not find this item. Run a fresh scan and try again.");
      return;
    }
    openModal(`
      <button type="button" class="rpca-modal-close" data-action="close-modal" aria-label="Close">&times;</button>
      <p class="rpca-eyebrow">File patch preview (read-only)</p>
      <h2>Gemini's proposed patch</h2>
      <p class="rpca-draft-meta" id="rpca-file-patch-meta">Asking Gemini for a scanner-aware patch...</p>
      <div id="rpca-file-patch-diff" style="font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:13px;line-height:1.45;max-height:360px;overflow:auto;background:#0f172a;color:#e2e8f0;border-radius:8px;padding:14px;margin:12px 0;">
        <div style="color:#94a3b8;">Loading...</div>
      </div>
      <div id="rpca-file-patch-status" class="rpca-inline-status" style="margin:8px 0;"></div>
      <div class="rpca-button-row" id="rpca-file-patch-actions" hidden style="display:flex;gap:8px;flex-wrap:wrap;">
        <button type="button" class="rpca-button is-primary" data-action="copy-file-patch-proposed">Copy proposed lines</button>
        <a id="rpca-file-patch-open-editor" class="rpca-button is-ghost" href="#" target="_blank" rel="noopener noreferrer">Open file in WordPress editor</a>
        <button type="button" class="rpca-button is-text" data-action="close-modal">Close</button>
      </div>
      <p style="margin-top:10px;font-size:12px;color:#64748b;">
        <strong>This preview never writes to disk.</strong> Copy the proposed lines and paste them into the WordPress file editor manually. Auto-apply is intentionally not available — file writes from AI output need additional safety controls (per the project safety review).
      </p>
    `);
    post("rpca_preview_file_patch", { findingId: findingId })
      .then(function (data) {
        const meta = document.getElementById("rpca-file-patch-meta");
        const diffNode = document.getElementById("rpca-file-patch-diff");
        const status = document.getElementById("rpca-file-patch-status");
        const actions = document.getElementById("rpca-file-patch-actions");
        if (!data || !Array.isArray(data.diff)) {
          if (diffNode) diffNode.innerHTML = '<div style="color:#fca5a5;">No diff returned.</div>';
          return;
        }
        const path = String(data.sourcePath || "");
        const sourceType = String(data.sourceType || "");
        if (meta) meta.textContent = "[" + sourceType + "] " + path + " (line " + (data.lineNumber || "?") + ")";
        if (diffNode) {
          const rows = data.diff.map(function (row) {
            const text = String(row && row.text != null ? row.text : "");
            const t = row && row.type;
            let bg = "transparent";
            let prefix = "  ";
            let color = "#e2e8f0";
            if (t === "remove") { bg = "rgba(248,113,113,0.18)"; prefix = "- "; color = "#fecaca"; }
            else if (t === "add") { bg = "rgba(74,222,128,0.18)"; prefix = "+ "; color = "#bbf7d0"; }
            return '<div style="padding:1px 6px;background:' + bg + ';color:' + color + ';white-space:pre-wrap;word-break:break-word;">' + escapeHtml(prefix + text) + "</div>";
          });
          diffNode.innerHTML = rows.join("");
        }
        const residual = Array.isArray(data.residualTriggers) ? data.residualTriggers : [];
        if (status) {
          if (data.scannedClean) {
            status.className = "rpca-inline-status is-success";
            status.textContent = "Gemini's proposed patch scans clean. " + (data.message || "");
          } else if (residual.length) {
            const phrases = residual.map(function (t) { return t && t.pattern ? t.pattern : String(t); }).filter(Boolean).slice(0, 5);
            status.className = "rpca-inline-status is-warn";
            status.textContent = "Heads up: the proposed patch still trips " + residual.length + " trigger(s): \"" + phrases.join('", "') + "\". Review carefully before pasting in.";
          } else {
            status.className = "rpca-inline-status";
            status.textContent = data.message || "";
          }
        }
        // Stash the proposed lines for the copy button.
        window.__rpcaFilePatchPreview = {
          findingId: findingId,
          sourceType: sourceType,
          sourcePath: path,
          proposedLines: Array.isArray(data.proposedLines) ? data.proposedLines : [],
        };
        // Wire the "Open file in WP editor" link based on sourceType.
        const link = document.getElementById("rpca-file-patch-open-editor");
        if (link) {
          let editorUrl = "";
          if (sourceType === "theme") {
            editorUrl = (window.RPCA && RPCA.adminUrl || "/wp-admin/") + "theme-editor.php?file=" + encodeURIComponent(path);
          } else if (sourceType === "plugin") {
            // Need mainPluginFile — not on the response, look it up from the cached uiState findings.
            const findings = (state.uiState && Array.isArray(state.uiState.findings)) ? state.uiState.findings : [];
            const f = findings.find(function (x) { return x && x.id === findingId; });
            const mainPlugin = f && f.mainPluginFile ? String(f.mainPluginFile) : "";
            if (mainPlugin) {
              editorUrl = (window.RPCA && RPCA.adminUrl || "/wp-admin/") + "plugin-editor.php?file=" + encodeURIComponent(path) + "&plugin=" + encodeURIComponent(mainPlugin);
            }
          }
          if (editorUrl) link.setAttribute("href", safeHref(editorUrl));
          else link.style.display = "none";
        }
        if (actions) actions.hidden = false;
      })
      .catch(function (err) {
        const diffNode = document.getElementById("rpca-file-patch-diff");
        if (diffNode) diffNode.innerHTML = '<div style="color:#fca5a5;">' + escapeHtml((err && err.message) || "Could not generate the patch preview right now.") + "</div>";
      });
  }

  function applySecurityHeadersFix(button) {
    if (!button) return;
    const spinner = button.querySelector("[data-role='header-spinner']");
    const label = button.querySelector(".rpca-button-label");
    const originalLabel = label ? label.textContent : "";
    button.disabled = true;
    if (spinner) spinner.hidden = false;
    if (label) label.textContent = "Turning on...";

    // v0.7.284: capture which finding triggered this so we can move it
    // into the Applied list once the headers turn on.
    const headersFindingId = button.getAttribute("data-finding-id") || "";
    post("rpca_apply_security_headers", {})
      .then((data) => {
        const message = (data && data.message) || "Site security improvements turned on. Run a fresh check to confirm.";
        // v0.7.423: the server now post-verifies the headers by
        // re-fetching the home URL. Only mark the finding as Applied
        // when verification actually confirmed at least one header
        // landed. When verification fails (host / CDN / page cache
        // stripping), keep the button enabled so the merchant can
        // see the explanation, fix at the host level, and re-try —
        // and DON'T mark the finding Applied (otherwise it
        // disappears from "Things to fix" even though it's not
        // actually fixed).
        toast(message);
        const verified = Boolean(data && data.verified);
        if (verified && headersFindingId) {
          markFindingApplied(headersFindingId);
        }
        if (verified) {
          if (label) label.textContent = "Turned on";
          button.classList.add("is-disabled");
          document.querySelectorAll('button[data-action="apply-security-headers"]').forEach((btn) => {
            if (btn === button) return;
            btn.disabled = true;
            btn.classList.add("is-disabled");
            const otherLabel = btn.querySelector(".rpca-button-label");
            if (otherLabel) otherLabel.textContent = "Turned on";
          });
        } else {
          // Verification failed (or couldn't run). Re-enable the
          // button so the merchant can try again after fixing the
          // host/CDN/cache layer.
          button.disabled = false;
          if (spinner) spinner.hidden = true;
          if (label && originalLabel) label.textContent = originalLabel;
        }
      })
      .catch((error) => {
        button.disabled = false;
        if (spinner) spinner.hidden = true;
        if (label && originalLabel) label.textContent = originalLabel;
        toast((error && error.message) || "Could not turn this on right now. Try again in a moment.");
      });
  }

  function applyPolicyFix(button) {
    if (!button) return;
    const policyKey = button.getAttribute("data-policy-key") || "";
    if (!policyKey) {
      toast("Could not determine which policy to create. Run a fresh scan and try again.");
      return;
    }
    const idx = Number(button.getAttribute("data-index") || 0);
    const finding = findingByIndex(idx) || {};
    const findingId = finding.id || "";

    const spinner = button.querySelector("[data-role='policy-spinner']");
    const label = button.querySelector(".rpca-button-label");
    const originalLabel = label ? label.textContent : "";
    const restoreButton = () => {
      button.disabled = false;
      if (spinner) spinner.hidden = true;
      if (label && originalLabel) label.textContent = originalLabel;
    };

    button.disabled = true;
    if (spinner) spinner.hidden = false;
    if (label) label.textContent = "Verifying with AI...";

    const supportsAi = Boolean(window.RPCA && RPCA.hasApiKey);
    const verifyPromise = supportsAi
      ? post("rpca_verify_policy_presence", { policyKey }).catch(() => ({
          aiAvailable: false,
          verifiedMissing: true,
        }))
      : Promise.resolve({ aiAvailable: false, verifiedMissing: true });

    verifyPromise.then((verify) => {
      const aiSaysExists = verify && verify.aiAvailable && !verify.verifiedMissing && (verify.confidence === "medium" || verify.confidence === "high");
      if (aiSaysExists) {
        restoreButton();
        openPolicyExistsConfirm({
          policyKey,
          policyLabel: policyLabelForKey(policyKey),
          matchUrl: verify.matchUrl || "",
          confidence: verify.confidence || "low",
          reason: verify.reason || "",
          findingId,
        });
        return;
      }

      // v0.7.257: Apply Fix path PUBLISHES the policy (with publish=1)
      // because the merchant explicitly asked for the finding to be
      // resolved. This also adds the page to the Compliance Policies
      // footer menu so the next scan's crawler can discover it via
      // the menu, and reviewers + visitors can find it from the
      // footer. The Compliance Generator's "Generate page" cards
      // continue to default to draft (they don't send publish=1).
      if (label) label.textContent = "Publishing policy page...";
      post("rpca_create_policy_page", {
        policyKey,
        findingId,
        publish: "1",
      })
        .then((data) => {
          const editUrl = normalizeNavigableUrl(data && data.editUrl ? data.editUrl : "");
          const permalink = normalizeNavigableUrl(data && data.permalink ? data.permalink : "");
          const message = (data && data.message) || "Policy page published.";
          toast(message);
          // v0.7.284: policy fix applied — log into the Applied list.
          if (findingId) {
            markFindingApplied(findingId);
          }
          // Open the public permalink (so the merchant can verify it
          // looks right) when published; fall back to the editor URL
          // when only a draft was created.
          const openUrl = permalink || editUrl;
          if (openUrl) {
            const opened = window.open(openUrl, "_blank", "noopener,noreferrer");
            if (!opened) {
              window.location.assign(openUrl);
            }
          }
          if (label) label.textContent = data && data.published ? "Published" : "Draft created";
          button.classList.add("is-disabled");
        })
        .catch((error) => {
          restoreButton();
          const errMessage = error && error.message ? error.message : "Could not create the policy page. Try again in a moment.";
          toast(errMessage);
          const editUrl = normalizeNavigableUrl(error && error.editUrl ? error.editUrl : "");
          if (editUrl) {
            window.open(editUrl, "_blank", "noopener,noreferrer");
          }
        });
    });
  }

  function openPolicyExistsConfirm(payload) {
    const matchUrl = payload.matchUrl || "";
    const reason = payload.reason || "";
    openModal(`
      <button type="button" class="rpca-modal-close" data-action="close-modal" aria-label="Close">&times;</button>
      <p class="rpca-eyebrow">AI cross-check</p>
      <h2>It looks like a ${escapeHtml(payload.policyLabel)} may already exist</h2>
      <p style="color:var(--ink-700); margin:0 0 var(--space-4)">Compliance Guard asked Gemini to look across the pages we scanned. Gemini thinks the ${escapeHtml(payload.policyLabel)} may already be live${matchUrl ? ` at <strong>${escapeHtml(matchUrl)}</strong>` : ""} (${escapeHtml(payload.confidence)} confidence).</p>
      ${reason ? `<p style="color:var(--ink-600); font-size:13px; margin:0 0 var(--space-4)"><em>${escapeHtml(reason)}</em></p>` : ""}
      <p style="color:var(--ink-600); margin:0 0 var(--space-4)">If this is a false positive, dismissing the finding will train Compliance Guard to skip similar matches in the future.</p>
      <div class="rpca-button-row">
        ${matchUrl ? `<a class="rpca-button is-primary" href="${safeHref(matchUrl)}" target="_blank" rel="noopener noreferrer">Open the existing page</a>` : ""}
        <button type="button" class="rpca-button" data-action="confirm-create-policy" data-policy-key="${escapeHtml(payload.policyKey)}" data-finding-id="${escapeHtml(payload.findingId || "")}">Create the draft anyway</button>
        <button type="button" class="rpca-button is-text" data-action="confirm-dismiss-policy" data-finding-id="${escapeHtml(payload.findingId || "")}">It is already there - dismiss</button>
      </div>
    `);
  }

  function saveAsDraft(button) {
    if (!state.activeDraft) {
      toast("No suggestion to save yet. Try generating a rewrite first.");
      return;
    }
    const finding = state.activeDraft.finding || {};
    const findingId = finding.id || "";
    const suggested = (state.activeDraft.suggestion || "").trim();
    if (!findingId) {
      toast("This item has no saved finding ID. Run a fresh scan and try again.");
      return;
    }
    if (!suggested) {
      toast("No suggested wording is loaded yet. Wait for the suggestion to generate.");
      return;
    }

    const spinner = button ? button.querySelector("[data-role='draft-spinner']") : null;
    const label = button ? button.querySelector(".rpca-button-label") : null;
    if (button) button.disabled = true;
    if (spinner) spinner.hidden = false;
    if (label) label.textContent = "Saving draft...";

    post("rpca_create_draft", {
      findingId,
      suggestedText: suggested,
    })
      .then((data) => {
        const editUrl = normalizeNavigableUrl(data && data.editUrl ? data.editUrl : "");
        const message = (data && data.message) || "Saved as a draft for review.";
        toast(message);
        // v0.7.284: draft created counts as the merchant having actioned
        // this finding — surface it in the Applied list so they remember.
        if (findingId) {
          markFindingApplied(findingId);
        }
        closeModal();
        if (editUrl) {
          const opened = window.open(editUrl, "_blank", "noopener,noreferrer");
          if (!opened) {
            window.location.assign(editUrl);
          }
        }
      })
      .catch((error) => {
        if (button) button.disabled = false;
        if (spinner) spinner.hidden = true;
        if (label) label.textContent = "Save as draft for review";
        toast(error.message || "Could not create the draft. Try again in a moment.");
      });
  }

  function initShellKeyboard() {
    document.addEventListener("keydown", (event) => {
      const commandOverlay = el("rpca-command-overlay");
      if (commandOverlay && !commandOverlay.hidden) {
        const rows = Array.from(document.querySelectorAll(".rpca-command-row"));
        if (event.key === "ArrowDown" || event.key === "ArrowUp") {
          event.preventDefault();
          state.commandIndex = Math.max(0, Math.min(rows.length - 1, state.commandIndex + (event.key === "ArrowDown" ? 1 : -1)));
          rows.forEach((row, index) => row.classList.toggle("is-active", index === state.commandIndex));
          return;
        }
        if (event.key === "Enter" && rows[state.commandIndex]) {
          event.preventDefault();
          rows[state.commandIndex].click();
          closeCommandPalette();
          return;
        }
      }
      const tag = event.target && event.target.tagName ? event.target.tagName.toLowerCase() : "";
      const typing = ["input", "textarea", "select"].includes(tag);
      if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
        event.preventDefault();
        openCommandPalette();
        return;
      }
      if ((event.metaKey || event.ctrlKey) && /^[1-8]$/.test(event.key)) {
        event.preventDefault();
        const sections = ["cockpit", "findings", "pages", "evidence", "intelligence", "underwriter", "settings", "help"];
        state.activeSection = sections[Number(event.key) - 1] || "cockpit";
        renderAdminShell();
        return;
      }
      if (event.key === "Escape") {
        closeCommandPalette();
        closeModal();
      }
      if (typing) return;
      if (event.key === "?") {
        const rail = el("rpca-help-rail");
        if (rail) rail.hidden = !rail.hidden;
      }
      if (event.key === ".") {
        const input = document.querySelector(".rpca-filter-input, .rpca-command-input");
        if (input) input.focus();
      }
      if (state.activeSection === "findings" && (event.key.toLowerCase() === "j" || event.key.toLowerCase() === "k")) {
        const visibleIndexes = visibleFindingEntries().map((entry) => entry.index);
        if (visibleIndexes.length) {
          const currentPosition = Math.max(0, visibleIndexes.indexOf(state.currentFindingIndex));
          const nextPosition = Math.max(0, Math.min(visibleIndexes.length - 1, currentPosition + (event.key.toLowerCase() === "j" ? 1 : -1)));
          state.currentFindingIndex = visibleIndexes[nextPosition];
          renderAdminShell();
        }
      }
      if (state.activeSection === "findings" && event.key.toLowerCase() === "e") {
        const button = document.querySelector(`[data-action="inline-rewrite"][data-index="${state.currentFindingIndex}"]`);
        if (button) button.click();
      }
      if (state.activeSection === "findings" && event.key.toLowerCase() === "x") {
        const button = document.querySelector(`[data-action="dismiss-finding"][data-index="${state.currentFindingIndex}"]`);
        if (button) button.click();
      }
      if (event.key.toLowerCase() === "g") {
        state.pendingGo = true;
        window.setTimeout(() => {
          state.pendingGo = false;
        }, 700);
      } else if (state.pendingGo && event.key.toLowerCase() === "c") {
        state.activeSection = "cockpit";
        state.pendingGo = false;
        renderAdminShell();
      } else if (state.pendingGo && event.key.toLowerCase() === "f") {
        state.activeSection = "findings";
        state.pendingGo = false;
        renderAdminShell();
      }
    });
  }

  function initSettingsTabs() {
    const buttons = Array.from(document.querySelectorAll(".rpca-tab-button"));
    const panels = Array.from(document.querySelectorAll(".rpca-tab-panel"));
    if (!buttons.length || !panels.length) {
      return;
    }

    const panelFor = (tab) =>
      panels.find((panel) => panel.getAttribute("data-rpca-tab-panel") === tab);

    function activate(tab) {
      if (!panelFor(tab)) {
        tab = "scanner";
      }
      buttons.forEach((button) => {
        const active = button.getAttribute("data-rpca-tab") === tab;
        button.classList.toggle("is-active", active);
        button.setAttribute("aria-selected", active ? "true" : "false");
      });
      panels.forEach((panel) => {
        const active = panel.getAttribute("data-rpca-tab-panel") === tab;
        panel.classList.toggle("is-active", active);
        panel.hidden = !active;
      });
      try {
        window.localStorage.setItem("rpca-active-tab", tab);
      } catch (error) {
        // Local storage is optional; tabs still work without it.
      }
    }

    buttons.forEach((button) => {
      button.addEventListener("click", () => {
        activate(button.getAttribute("data-rpca-tab"));
      });
    });

    let initial = "scanner";
    try {
      initial = window.localStorage.getItem("rpca-active-tab") || initial;
    } catch (error) {
      initial = "scanner";
    }
    if (window.location.hash && panelFor(window.location.hash.replace("#", ""))) {
      initial = window.location.hash.replace("#", "");
    }
    activate(initial);
  }

  function initWorkspaceTabs() {
    const buttons = Array.from(document.querySelectorAll(".rpca-workspace-button"));
    const panels = Array.from(document.querySelectorAll(".rpca-workspace-panel"));
    if (!buttons.length || !panels.length) {
      return;
    }

    const panelFor = (workspace) =>
      panels.find((panel) => panel.getAttribute("data-rpca-workspace-panel") === workspace);

    function activate(workspace) {
      if (!panelFor(workspace)) {
        workspace = "scanner";
      }
      buttons.forEach((button) => {
        const active = button.getAttribute("data-rpca-workspace") === workspace;
        button.classList.toggle("is-active", active);
        button.setAttribute("aria-current", active ? "page" : "false");
      });
      panels.forEach((panel) => {
        const active = panel.getAttribute("data-rpca-workspace-panel") === workspace;
        panel.classList.toggle("is-active", active);
        panel.hidden = !active;
      });
      try {
        window.localStorage.setItem("rpca-active-workspace", workspace);
      } catch (error) {
        // Local storage is optional; workspace sections still work without it.
      }
    }

    buttons.forEach((button) => {
      button.addEventListener("click", () => {
        activate(button.getAttribute("data-rpca-workspace"));
      });
    });

    let initial = "scanner";
    try {
      initial = window.localStorage.getItem("rpca-active-workspace") || initial;
    } catch (error) {
      initial = "scanner";
    }
    activate(initial);
  }

  document.addEventListener("DOMContentLoaded", () => {
    const initialHash = window.location.hash ? window.location.hash.replace("#", "") : "";
    if (initialHash) {
      state.activeSection = initialHash;
    }
    renderAdminShell();
    initShellKeyboard();
    renderAddendumDashboard(state.addendum2);
    renderScan(state.lastScan);
    renderFdaIntelligence(state.fdaIntelligence);
    initWorkspaceTabs();
    initSettingsTabs();

    const scanButton = el("rpca-run-scan");
    const refreshFdaButton = el("rpca-refresh-fda");
    const exportButton = el("rpca-export-scan");
    const exportActivityButton = el("rpca-export-activity");
    const clearButton = el("rpca-clear-scan");
    const chatButton = el("rpca-send-chat");
    const chatMessage = el("rpca-chat-message");
    const results = el("rpca-results");
    const drawer = el("rpca-fix-drawer");
    const closeFix = el("rpca-close-fix");

    if (scanButton) {
      scanButton.addEventListener("click", runScan);
    }
    if (refreshFdaButton) {
      refreshFdaButton.addEventListener("click", refreshFdaIntelligence);
    }
    if (exportButton) {
      exportButton.addEventListener("click", exportScan);
    }
    if (exportActivityButton) {
      exportActivityButton.addEventListener("click", exportActivityLog);
    }
    if (clearButton) {
      clearButton.addEventListener("click", clearScan);
    }
    if (chatButton) {
      chatButton.addEventListener("click", sendChat);
    }
    if (chatMessage) {
      chatMessage.addEventListener("keydown", (event) => {
        if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
          event.preventDefault();
          sendChat();
        }
      });
    }
    if (results) {
      results.addEventListener("click", (event) => {
        const button = event.target.closest(".rpca-preview-fix");
        if (button) {
          previewFix(button.getAttribute("data-finding-id"));
        }
      });
    }
    if (drawer) {
      drawer.addEventListener("click", (event) => {
        const applyButton = event.target.closest("#rpca-apply-fix");
        const collapseButton = event.target.closest("#rpca-collapse-fix");
        if (applyButton) {
          applyFix(applyButton.getAttribute("data-finding-id"));
        }
        if (collapseButton) {
          closeFixDrawer();
        }
      });
    }
    if (closeFix) {
      closeFix.addEventListener("click", closeFixDrawer);
    }

    appendChat(
      "assistant",
      state.lastScan
        ? "I have the saved scan loaded. Ask what to fix first or why an item was flagged."
        : "Start a scan first. After that, ask me about wording to review or generated safer text."
    );
    // v0.7.252: live-update the dedicated age-gate setup preview as
    // the merchant edits any field. No-op when the form is not on the
    // current page.
    bootstrapAgeGateLivePreview();
    // v0.7.253: live-update the buyer-acknowledgment preview pane on
    // the Compliance Generator page as the merchant edits the text
    // or error message. No-op when the card is not on the current
    // page.
    bootstrapBuyerAckLivePreview();
    // v0.7.254: live-update the sitewide disclaimer banner preview
    // and toggle the slug/custom rows based on color mode. No-op
    // when the banner card is not on the current page.
    bootstrapBannerCardLivePreview();
  });

  // v0.7.253: persist the buyer-acknowledgment settings card via the
  // dedicated AJAX endpoint. Reads the current values out of the
  // form, posts to rpca_save_buyer_acknowledgment_settings, updates
  // local state on success, surfaces a saved-note + toast.
  function saveBuyerAcknowledgmentSettings() {
    const card = document.querySelector("[data-rpca-buyer-ack-card]");
    if (!card) return;
    const get = (selector) => card.querySelector(selector);
    const enabled = get("[name='rpca-buyer-ack-enabled']") && get("[name='rpca-buyer-ack-enabled']").checked ? "1" : "0";
    const mode = (get("[name='rpca-buyer-ack-mode']") || {}).value || "auto";
    const text = (get("[name='rpca-buyer-ack-text']") || {}).value || "";
    const errorMessage = (get("[name='rpca-buyer-ack-error']") || {}).value || "";
    const customFormSelector = (get("[name='rpca-buyer-ack-form-selector']") || {}).value || "";
    const customSubmitSelector = (get("[name='rpca-buyer-ack-submit-selector']") || {}).value || "";
    const saveToOrderMeta = get("[name='rpca-buyer-ack-save-meta']") && get("[name='rpca-buyer-ack-save-meta']").checked ? "1" : "0";
    const saveButton = card.querySelector("[data-action='buyer-ack-save']");
    const savedNote = card.querySelector(".rpca-buyer-ack-saved-note");
    if (saveButton) {
      saveButton.disabled = true;
      saveButton.textContent = "Saving...";
    }
    post("rpca_save_buyer_acknowledgment_settings", {
      enabled,
      mode,
      text,
      errorMessage,
      customFormSelector,
      customSubmitSelector,
      saveToOrderMeta,
    })
      .then((data) => {
        // Refresh the local snapshot used by renderBuyerAcknowledgmentCard
        // so re-renders show the persisted values.
        if (window.RPCA && RPCA.uiState) {
          RPCA.uiState.buyerAck = {
            enabled: data.enabled,
            mode: data.mode,
            text: data.text,
            errorMessage: data.errorMessage,
            customFormSelector: data.customFormSelector,
            customSubmitSelector: data.customSubmitSelector,
            saveToOrderMeta: data.saveToOrderMeta,
          };
        }
        if (savedNote) savedNote.hidden = false;
        toast("Buyer acknowledgment settings saved.");
      })
      .catch((err) => {
        const msg = (err && err.message) || "Could not save buyer acknowledgment settings.";
        toast(msg);
      })
      .finally(() => {
        if (saveButton) {
          saveButton.disabled = false;
          saveButton.textContent = "Save acknowledgment settings";
        }
      });
  }

  // v0.7.256: compliance-asset view + copy + hide + download.
  // Server-side body lookup via rpca_get_compliance_asset (snippets +
  // email templates) or rpca_download_compliance_asset (downloadable
  // documents). The view path injects the body into the card's
  // textarea so the merchant can read + copy without leaving the
  // page; the download path streams an HTML file via a hidden form
  // submit.

  function complianceAssetCard(assetKey) {
    return document.querySelector('[data-rpca-asset-key="' + assetKey + '"]');
  }

  function viewComplianceAsset(assetKey) {
    if (!assetKey) return;
    const card = complianceAssetCard(assetKey);
    if (!card) return;
    const body = card.querySelector("[data-rpca-asset-body]");
    const textarea = card.querySelector("[data-rpca-asset-textarea]");
    if (!body || !textarea) return;
    if (!body.hidden && textarea.value) {
      // Already loaded and visible - hide it.
      body.hidden = true;
      return;
    }
    if (textarea.value) {
      // Already loaded; just unhide.
      body.hidden = false;
      return;
    }
    post("rpca_get_compliance_asset", { asset_key: assetKey })
      .then((data) => {
        if (!data || !data.body) {
          toast("Could not load asset.");
          return;
        }
        textarea.value = String(data.body);
        body.hidden = false;
      })
      .catch((err) => {
        const msg = (err && err.message) || "Could not load compliance asset.";
        toast(msg);
      });
  }

  function copyComplianceAsset(assetKey) {
    const card = complianceAssetCard(assetKey);
    if (!card) return;
    const textarea = card.querySelector("[data-rpca-asset-textarea]");
    if (!textarea || !textarea.value) {
      toast("Show the asset first, then copy.");
      return;
    }
    textarea.select();
    let copied = false;
    try {
      copied = document.execCommand("copy");
    } catch (_e) {
      copied = false;
    }
    if (!copied && navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(textarea.value).then(
        () => toast("Copied to clipboard."),
        () => toast("Could not copy. Select the text manually and copy.")
      );
      return;
    }
    toast(copied ? "Copied to clipboard." : "Could not copy. Select the text manually and copy.");
  }

  function hideComplianceAsset(assetKey) {
    const card = complianceAssetCard(assetKey);
    if (!card) return;
    const body = card.querySelector("[data-rpca-asset-body]");
    if (body) body.hidden = true;
  }

  function downloadComplianceAsset(assetKey) {
    if (!assetKey) return;
    const f = document.createElement("form");
    f.method = "POST";
    f.action = (window.RPCA && RPCA.ajaxUrl) || "";
    f.style.display = "none";
    [
      ["action", "rpca_download_compliance_asset"],
      ["nonce", (window.RPCA && RPCA.nonce) || ""],
      ["asset_key", assetKey],
    ].forEach(([name, value]) => {
      const input = document.createElement("input");
      input.type = "hidden";
      input.name = name;
      input.value = value;
      f.appendChild(input);
    });
    document.body.appendChild(f);
    f.submit();
    document.body.removeChild(f);
  }

  // v0.7.438 — flip the one-click Apply switch for a snippet or
  // transactional-email template. Surfaces the server confirmation
  // in a toast then reloads so the Applied / not-Applied pill repaints
  // from the freshly-persisted option (matches the trust-badge Apply
  // pattern in handleApplyTrustBadge). On a server error the toast
  // states what happened and the button re-enables for a retry.
  function toggleComplianceAssetAutoInject(assetKey, nextState, btn) {
    if (!assetKey) return;
    if (nextState !== "on" && nextState !== "off") return;
    if (btn) {
      btn.disabled = true;
      btn.textContent = nextState === "on" ? "Applying…" : "Removing…";
    }
    post("rpca_set_compliance_asset_auto_inject", {
      asset_key: assetKey,
      state: nextState,
    })
      .then((data) => {
        const msg = (data && data.message) ? String(data.message) : (nextState === "on" ? "Applied." : "Removed.");
        toast(msg);
        // Mirror the new state locally so the catalog payload is
        // consistent even before the reload completes (e.g. if a
        // navigation event runs first).
        if (window.RPCA && RPCA.uiState && RPCA.uiState.complianceAssets && RPCA.uiState.complianceAssets[assetKey]) {
          RPCA.uiState.complianceAssets[assetKey].autoInjectActive = nextState === "on";
        }
        // Reload after a short pause so the toast is visible and the
        // Applied pill repaints from the server-side options value
        // (the source of truth for what the public surfaces will
        // emit on the next request).
        setTimeout(() => { window.location.reload(); }, 650);
      })
      .catch((err) => {
        if (btn) {
          btn.disabled = false;
          btn.textContent = nextState === "on" ? "Apply automatically" : "Remove auto-apply";
        }
        toast((err && err.message) ? String(err.message) : "Could not save that change. Try again.");
      });
  }

  // v0.7.253: live-preview wiring for the buyer-ack card. The
  // checkbox label preview + error message preview update as the
  // merchant types. textContent only - never innerHTML - so any
  // injected markup is rendered as literal text. No-op when the
  // card is not on the current page. Also handles `#buyer-ack`
  // hash arrival (the Apply-fix link from a buyer-verification
  // finding sends the merchant here with that anchor): scrolls
  // the card into view, briefly highlights it, and pre-checks
  // the master toggle so the merchant's next click is the save
  // button.
  function bootstrapBuyerAckLivePreview() {
    const card = document.querySelector("[data-rpca-buyer-ack-card]");
    if (!card) return;
    if (card.dataset.rpcaBuyerAckBound === "1") return;
    card.dataset.rpcaBuyerAckBound = "1";
    const textInput = card.querySelector("[name='rpca-buyer-ack-text']");
    const errorInput = card.querySelector("[name='rpca-buyer-ack-error']");
    const previewText = card.querySelector("[data-rpca-buyer-ack-preview-text]");
    const previewError = card.querySelector("[data-rpca-buyer-ack-preview-error]");
    function refresh() {
      if (textInput && previewText) {
        previewText.textContent = textInput.value || "";
      }
      if (errorInput && previewError) {
        previewError.textContent = errorInput.value || "";
      }
    }
    if (textInput) textInput.addEventListener("input", refresh);
    if (errorInput) errorInput.addEventListener("input", refresh);
    refresh();

    if (window.location.hash === "#buyer-ack") {
      try {
        card.scrollIntoView({ behavior: "smooth", block: "start" });
      } catch (_e) {
        /* older browsers - best effort */
      }
      card.style.outline = "2px solid #3F8A4F";
      card.style.outlineOffset = "4px";
      const enabledCheckbox = card.querySelector("[name='rpca-buyer-ack-enabled']");
      if (enabledCheckbox && !enabledCheckbox.checked) {
        enabledCheckbox.checked = true;
      }
      setTimeout(() => {
        card.style.outline = "";
        card.style.outlineOffset = "";
      }, 4000);
    }
  }

  // v0.7.252: live-update the dedicated age-gate preview pane as the
  // merchant edits any preview-relevant field. The PHP renderer
  // already emitted the preview DOM with `data-rpca-age-gate-preview`
  // hooks so this function never inserts or removes nodes - it only
  // mutates text, attributes, and inline styles. That keeps the diff
  // small and avoids fighting WordPress's settings-form lifecycle.
  function bootstrapAgeGateLivePreview() {
    const form = document.querySelector("[data-rpca-age-gate-form]");
    const previewRoot = document.querySelector("[data-rpca-age-gate-preview-root]");
    if (!form || !previewRoot) {
      return;
    }
    // OPTION_KEY is localized via wp_localize_script as RPCA.optionKey.
    // Falls back to the historical default so the preview still works
    // even if a future cycle accidentally drops the localize key.
    const optionKey = (window.RPCA && window.RPCA.optionKey) || "rpca_options";
    // Helper: read a form field by its `OPTION_KEY[<name>]` name attr.
    function fieldByName(name) {
      return form.querySelector('[name="' + optionKey + "[" + name + ']"]');
    }
    // Helper: read raw value from a field (returns "" when absent).
    function read(name, fallback) {
      const node = fieldByName(name);
      if (!node) return fallback === undefined ? "" : fallback;
      if (node.type === "checkbox") return node.checked ? "1" : "0";
      return node.value == null ? (fallback === undefined ? "" : fallback) : node.value;
    }
    // Helper: clamp + integer-coerce. Mirrors the PHP sanitize bounds.
    function clampInt(v, lo, hi, fallback) {
      const n = parseInt(v, 10);
      if (!isFinite(n)) return fallback;
      return Math.max(lo, Math.min(hi, n));
    }
    // Helper: validate a hex color string. Returns the input if it is
    // a valid #rgb / #rgba / #rrggbb / #rrggbbaa, otherwise the
    // fallback. Matches the WordPress sanitize_hex_color contract.
    function safeHex(value, fallback) {
      if (typeof value !== "string") return fallback;
      const v = value.trim();
      return /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(v) ? v : fallback;
    }
    // Helper: validate a URL the way the live preview should accept
    // it (http/https only, no javascript:, no data: schemes). Returns
    // the input if valid, "" otherwise. Defensive: never assigns an
    // unsafe URL to the <img src> attribute.
    function safeUrl(value) {
      if (typeof value !== "string") return "";
      const v = value.trim();
      if (v === "") return "";
      try {
        const parsed = new URL(v, window.location.origin);
        if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
          return "";
        }
        return parsed.toString();
      } catch (_e) {
        return "";
      }
    }

    const previewByKey = {
      stage: previewRoot.querySelector('[data-rpca-age-gate-preview="stage"]'),
      box: previewRoot.querySelector('[data-rpca-age-gate-preview="box"]'),
      logo: previewRoot.querySelector('[data-rpca-age-gate-preview="logo"]'),
      title: previewRoot.querySelector('[data-rpca-age-gate-preview="title"]'),
      message: previewRoot.querySelector('[data-rpca-age-gate-preview="message"]'),
      accept: previewRoot.querySelector('[data-rpca-age-gate-preview="accept"]'),
      dob: previewRoot.querySelector('[data-rpca-age-gate-preview="dob"]'),
      decline: previewRoot.querySelector('[data-rpca-age-gate-preview="decline"]'),
    };

    function refreshPreview() {
      const bg = safeHex(read("age_gate_bg"), "#0f172a");
      const accent = safeHex(read("age_gate_accent"), "#8B2635");
      const radius = clampInt(read("age_gate_box_radius"), 0, 40, 8);
      const padding = clampInt(read("age_gate_box_padding"), 12, 80, 28);
      const width = clampInt(read("age_gate_box_width"), 320, 960, 560);
      const gradientOn = read("age_gradient_enabled") === "1";
      const gradientStart = safeHex(read("age_gradient_start"), bg);
      const gradientEnd = safeHex(read("age_gradient_end"), accent);
      const gradientAngle = clampInt(read("age_gradient_angle"), 0, 360, 135);

      if (previewByKey.stage) {
        previewByKey.stage.style.background = gradientOn
          ? "linear-gradient(" + gradientAngle + "deg, " + gradientStart + " 0%, " + gradientEnd + " 100%)"
          : bg;
      }
      if (previewByKey.box) {
        previewByKey.box.style.maxWidth = Math.min(360, Math.round(width / 2)) + "px";
        previewByKey.box.style.padding = Math.max(16, Math.round(padding / 1.5)) + "px";
        previewByKey.box.style.borderRadius = radius + "px";
      }
      if (previewByKey.logo) {
        const url = safeUrl(read("age_media_url"));
        const alt = read("age_media_alt");
        previewByKey.logo.src = url || "";
        previewByKey.logo.alt = alt || "";
        previewByKey.logo.hidden = url === "";
      }
      if (previewByKey.title) {
        previewByKey.title.textContent = read("age_gate_title");
      }
      if (previewByKey.message) {
        previewByKey.message.textContent = read("age_gate_message");
      }
      if (previewByKey.accept) {
        previewByKey.accept.textContent = read("age_accept_text") || "I am 18 or older";
        previewByKey.accept.style.background = accent;
      }
      if (previewByKey.dob) {
        const method = read("age_method", "both");
        const dobVisible = method === "both" || method === "dob";
        previewByKey.dob.hidden = !dobVisible;
        previewByKey.dob.textContent = read("age_dob_text") || "Enter date of birth";
      }
      if (previewByKey.decline) {
        previewByKey.decline.textContent = read("age_decline_text") || "Leave site";
      }

      // Range-slider value indicators next to each slider label.
      const valueSpans = form.querySelectorAll("[data-rpca-age-gate-value]");
      valueSpans.forEach((span) => {
        const fieldName = span.getAttribute("data-rpca-age-gate-value");
        if (!fieldName) return;
        const node = fieldByName(fieldName);
        if (node && node.value != null) {
          span.textContent = String(node.value);
        }
      });
    }

    // Listen on the form so input from any descendant field bubbles up
    // through one handler. Both `input` (text, color, range, textarea)
    // and `change` (select, checkbox) cover every field type the form
    // emits. Also fire once on init so the indicator spans seed
    // correctly even if the PHP-rendered values disagreed (they
    // should not, but defensive).
    form.addEventListener("input", refreshPreview);
    form.addEventListener("change", refreshPreview);
    refreshPreview();
  }
})();
