/ src / reports / html-report-template.js
html-report-template.js
  1  /**
  2   * HTML Report Template Generator
  3   *
  4   * Generates a self-contained HTML document for premium CRO audit reports.
  5   * All CSS is inline, all images are base64 data URIs, all icons are inline SVG.
  6   * Designed for Playwright page.pdf() rendering to A4.
  7   *
  8   * Brand: Audit&Fix (navy #1a365d / orange #e05d26)
  9   * See docs/09-business/auditandfix-brand.md
 10   */
 11  
 12  // Audit&Fix logo SVG (from auditandfix.com/assets/img/logo.svg)
 13  const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 210 48" width="210" height="48" role="img" aria-label="Audit&amp;Fix">
 14    <circle cx="18" cy="18" r="11" fill="none" stroke="#e05d26" stroke-width="3.5"/>
 15    <line x1="26" y1="26" x2="34" y2="34" stroke="#e05d26" stroke-width="3.5" stroke-linecap="round"/>
 16    <polyline points="12,18 16,22 24,13" fill="none" stroke="#ffffff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
 17    <text y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="26" font-weight="700" letter-spacing="-0.5">
 18      <tspan x="44" fill="#ffffff">Audit</tspan><tspan fill="#e05d26">&amp;</tspan><tspan fill="#ffffff">Fix</tspan>
 19    </text>
 20  </svg>`;
 21  
 22  // Logo variant for light backgrounds
 23  const LOGO_LIGHT_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 210 48" width="210" height="48" role="img" aria-label="Audit&amp;Fix">
 24    <circle cx="18" cy="18" r="11" fill="none" stroke="#e05d26" stroke-width="3.5"/>
 25    <line x1="26" y1="26" x2="34" y2="34" stroke="#e05d26" stroke-width="3.5" stroke-linecap="round"/>
 26    <polyline points="12,18 16,22 24,13" fill="none" stroke="#1a365d" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
 27    <text y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="26" font-weight="700" letter-spacing="-0.5">
 28      <tspan x="44" fill="#1a365d">Audit</tspan><tspan fill="#e05d26">&amp;</tspan><tspan fill="#1a365d">Fix</tspan>
 29    </text>
 30  </svg>`;
 31  
 32  // Small logo variant for page headers
 33  const LOGO_LIGHT_SVG_SMALL = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 210 48" width="250" height="58" role="img" aria-label="Audit&amp;Fix">
 34    <circle cx="18" cy="18" r="11" fill="none" stroke="#e05d26" stroke-width="3.5"/>
 35    <line x1="26" y1="26" x2="34" y2="34" stroke="#e05d26" stroke-width="3.5" stroke-linecap="round"/>
 36    <polyline points="12,18 16,22 24,13" fill="none" stroke="#1a365d" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
 37    <text y="32" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="26" font-weight="700" letter-spacing="-0.5">
 38      <tspan x="44" fill="#1a365d">Audit</tspan><tspan fill="#e05d26">&amp;</tspan><tspan fill="#1a365d">Fix</tspan>
 39    </text>
 40  </svg>`;
 41  
 42  // SVG icons for each scoring factor
 43  const FACTOR_ICONS = {
 44    headline_quality:
 45      '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="8" x2="14" y2="8"/><line x1="8" y1="12" x2="12" y2="12"/></svg>',
 46    value_proposition:
 47      '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>',
 48    unique_selling_proposition:
 49      '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5C7 4 9 7 12 7s5-3 7.5-3a2.5 2.5 0 0 1 0 5H18"/><path d="M12 7v14"/><path d="M8 21h8"/></svg>',
 50    call_to_action:
 51      '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 15l6-6m0 0l-6-6m6 6H9a6 6 0 0 0 0 12h3"/></svg>',
 52    urgency_messaging:
 53      '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
 54    hook_engagement:
 55      '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>',
 56    trust_signals:
 57      '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><polyline points="9 12 11 14 15 10"/></svg>',
 58    imagery_design:
 59      '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg>',
 60    offer_clarity:
 61      '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>',
 62    contextual_appropriateness:
 63      '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>',
 64  };
 65  
 66  const FACTOR_LABELS = {
 67    headline_quality: 'Headline Quality',
 68    value_proposition: 'Value Proposition',
 69    unique_selling_proposition: 'What Makes You Different (USP)',
 70    call_to_action: 'Call to Action Button (CTA)',
 71    urgency_messaging: 'Urgency & Scarcity',
 72    hook_engagement: 'Hook & Engagement',
 73    trust_signals: 'Trust & Credibility',
 74    imagery_design: 'Imagery & Design',
 75    offer_clarity: 'Offer Clarity',
 76    contextual_appropriateness: 'Industry Context',
 77  };
 78  
 79  const FACTOR_WEIGHTS = {
 80    headline_quality: 15,
 81    value_proposition: 14,
 82    unique_selling_proposition: 13,
 83    call_to_action: 13,
 84    urgency_messaging: 10,
 85    hook_engagement: 9,
 86    trust_signals: 11,
 87    imagery_design: 8,
 88    offer_clarity: 4,
 89    contextual_appropriateness: 3,
 90  };
 91  
 92  const TECH_EXPLANATIONS = {
 93    HSTS: "Without this, browsers won't enforce secure connections — leaving visitors open to interception.",
 94    CSP: 'Content Security Policy blocks attackers from injecting malicious code into your pages.',
 95    'X-Frame-Options':
 96      'Prevents your site being embedded inside another page — a trick used to hijack clicks.',
 97    'X-Content-Type-Options':
 98      'Stops browsers misinterpreting uploaded files, reducing a class of injection attacks.',
 99    'Referrer-Policy':
100      'Controls what information is shared with other sites when a visitor follows a link.',
101    'Permissions-Policy':
102      'Limits whether your site can access camera, location, or microphone — reducing exposure.',
103  };
104  const HTTPS_FAIL_EXPLANATION =
105    'Your site is not using a secure (HTTPS) connection. Visitors see a browser warning, which damages trust and reduces conversions.';
106  const MOBILE_FAIL_EXPLANATION =
107    "Your site doesn't adapt to mobile screens. Most visitors are on phones — a poor mobile experience directly reduces enquiries.";
108  
109  function getGradeColor(grade) {
110    if (!grade) return '#718096';
111    const letter = grade.charAt(0).toUpperCase();
112    switch (letter) {
113      case 'A':
114        return '#38a169';
115      case 'B':
116        return '#3182ce';
117      case 'C':
118        return '#d69e2e';
119      case 'D':
120        return '#dd6b20';
121      default:
122        return '#e53e3e';
123    }
124  }
125  
126  function getScoreColor(score) {
127    if (score >= 90) return '#38a169';
128    if (score >= 80) return '#3182ce';
129    if (score >= 70) return '#d69e2e';
130    if (score >= 60) return '#dd6b20';
131    return '#e53e3e';
132  }
133  
134  function getSeverityColor(severity) {
135    switch (severity) {
136      case 'high':
137        return '#e53e3e';
138      case 'medium':
139        return '#d69e2e';
140      case 'low':
141        return '#3182ce';
142      default:
143        return '#718096';
144    }
145  }
146  
147  function escapeHtml(str) {
148    if (!str) return '';
149    return String(str)
150      .replace(/&/g, '&amp;')
151      .replace(/</g, '&lt;')
152      .replace(/>/g, '&gt;')
153      .replace(/"/g, '&quot;');
154  }
155  
156  /**
157   * Generate SVG score ring
158   */
159  function scoreRing(score, size = 120, strokeWidth = 8) {
160    const radius = (size - strokeWidth) / 2;
161    const circumference = 2 * Math.PI * radius;
162    const offset = circumference - (score / 100) * circumference;
163    const color = getScoreColor(score);
164    const cx = size / 2;
165    const cy = size / 2;
166  
167    return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
168      <circle cx="${cx}" cy="${cy}" r="${radius}" fill="none" stroke="#e2e8f0" stroke-width="${strokeWidth}"/>
169      <circle cx="${cx}" cy="${cy}" r="${radius}" fill="none" stroke="${color}" stroke-width="${strokeWidth}"
170        stroke-dasharray="${circumference}" stroke-dashoffset="${offset}"
171        stroke-linecap="round" transform="rotate(-90 ${cx} ${cy})"
172        style="transition: stroke-dashoffset 0.5s ease;"/>
173      <text x="${cx}" y="${cy - 6}" text-anchor="middle" font-size="${size * 0.28}" font-weight="700" fill="${color}">${Math.round(score)}</text>
174      <text x="${cx}" y="${cy + 14}" text-anchor="middle" font-size="${size * 0.12}" fill="#718096">out of 100</text>
175    </svg>`;
176  }
177  
178  /**
179   * Generate small factor score ring
180   */
181  function factorRing(score, size = 48) {
182    const strokeWidth = 4;
183    const radius = (size - strokeWidth) / 2;
184    const circumference = 2 * Math.PI * radius;
185    const offset = circumference - (score / 10) * circumference;
186    const color = getScoreColor(score * 10);
187    const cx = size / 2;
188    const cy = size / 2;
189  
190    return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
191      <circle cx="${cx}" cy="${cy}" r="${radius}" fill="none" stroke="#e2e8f0" stroke-width="${strokeWidth}"/>
192      <circle cx="${cx}" cy="${cy}" r="${radius}" fill="none" stroke="${color}" stroke-width="${strokeWidth}"
193        stroke-dasharray="${circumference}" stroke-dashoffset="${offset}"
194        stroke-linecap="round" transform="rotate(-90 ${cx} ${cy})"/>
195      <text x="${cx}" y="${cy + 5}" text-anchor="middle" font-size="14" font-weight="700" fill="${color}">${score}</text>
196    </svg>`;
197  }
198  
199  /**
200   * Render the per-page header with section name and small logo
201   */
202  function renderPageHeader(sectionName) {
203    return `<div class="page-header">
204      <div class="page-header-section">${escapeHtml(sectionName)}</div>
205      <div>${LOGO_LIGHT_SVG_SMALL}</div>
206    </div>`;
207  }
208  
209  /**
210   * Render numbered action items with large coloured circles, or plain paragraphs
211   */
212  function renderActionItems(text, color) {
213    if (!text) return '';
214    const lines = text
215      .split('\n')
216      .map(l => l.trim())
217      .filter(Boolean);
218    return lines
219      .map(line => {
220        const m = line.match(/^(\d+)\.\s*(.*)/);
221        if (!m) return `<p class="action-plain">${escapeHtml(line)}</p>`;
222        return `<div class="action-item">
223          <div class="action-num" style="background:${color};">${m[1]}</div>
224          <div class="action-text">${escapeHtml(m[2])}</div>
225        </div>`;
226      })
227      .join('');
228  }
229  
230  /**
231   * Generate the full HTML report
232   * @param {Object} params
233   * @param {string} params.domain - Website domain
234   * @param {string} params.url - Full URL
235   * @param {Object} params.scoreJson - Full score JSON from Opus
236   * @param {Buffer|string} params.aboveFoldBuffer - Above-fold screenshot (Buffer or base64 string)
237   * @param {Array} params.problemCrops - Array of { factor, imageBuffer, description, recommendation, severity, current_text, suggested_text }
238   * @param {Object} [params.narrativeSections] - Opus-generated { executiveSummary, actionPlanWeek, actionPlanMonth, actionPlanQuarter, factorNarratives }
239   * @param {Date} [params.reportDate] - Report date
240   * @param {boolean} [params.isSample] - Whether this is a sample/demo report
241   * @param {boolean} [params.visionUsed] - Whether computer vision was used in scoring
242   * @returns {string} Complete HTML document
243   */
244  export function generateReportHTML({
245    domain,
246    url: _url,
247    scoreJson,
248    aboveFoldBuffer,
249    problemCrops,
250    narrativeSections,
251    reportDate,
252    isSample = false,
253    visionUsed = false,
254    variant = 'full',
255  }) {
256    const isQuickFixes = variant === 'quick-fixes';
257    const score = scoreJson.overall_calculation?.conversion_score || 0;
258    const grade = scoreJson.overall_calculation?.letter_grade || 'N/A';
259    const gradeColor = getGradeColor(grade);
260    const factors = scoreJson.factor_scores || {};
261    const strengths = scoreJson.key_strengths || [];
262    const weaknesses = scoreJson.critical_weaknesses || [];
263    const quickWins = scoreJson.quick_improvement_opportunities || [];
264    const techAssess = scoreJson.technical_assessment || {};
265    const recommendations = scoreJson.strategic_recommendations || [];
266    const narratives = narrativeSections || scoreJson.report_narratives || {};
267    const dateStr = (reportDate || new Date()).toLocaleDateString('en-GB', {
268      year: 'numeric',
269      month: 'long',
270      day: 'numeric',
271    });
272  
273    // Pagination counter
274    let _pg = 0;
275    const P = () => ++_pg;
276    const footer = (dom, extra = '') =>
277      `<div class="page-footer"><span>Confidential — Prepared by Audit&amp;Fix for ${escapeHtml(dom)}</span><span>${extra ? `${extra} · ` : ''}Page ${_pg}</span></div>`;
278  
279    // Convert above-fold buffer to base64 if needed
280    let aboveFoldBase64 = '';
281    if (aboveFoldBuffer) {
282      if (Buffer.isBuffer(aboveFoldBuffer)) {
283        aboveFoldBase64 = `data:image/jpeg;base64,${aboveFoldBuffer.toString('base64')}`;
284      } else if (typeof aboveFoldBuffer === 'string') {
285        aboveFoldBase64 = aboveFoldBuffer.startsWith('data:')
286          ? aboveFoldBuffer
287          : `data:image/jpeg;base64,${aboveFoldBuffer}`;
288      }
289    }
290  
291    const watermarkCSS = isSample
292      ? `.watermark{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%) rotate(-35deg);font-size:100px;font-weight:800;color:#e2e8f0;opacity:0.08;z-index:999;pointer-events:none;white-space:nowrap;letter-spacing:8px;}`
293      : '';
294    const watermarkHTML = isSample ? '<div class="watermark">SAMPLE REPORT</div>' : '';
295  
296    return `<!DOCTYPE html>
297  <html lang="en">
298  <head>
299  <meta charset="utf-8">
300  <title>${isQuickFixes ? 'Quick Fixes Report' : 'CRO Audit Report'} — ${escapeHtml(domain)}</title>
301  <style>
302    @page { size: A4 portrait; margin: 12mm 15mm 12mm; }
303    * { box-sizing: border-box; margin: 0; padding: 0; }
304    html, body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; color: #2d3748; font-size: 13px; line-height: 1.5; }
305  
306    .page { width: 100%; min-height: calc(297mm - 24mm); padding-bottom: 22px; page-break-after: always; position: relative; }
307    .page:last-child { page-break-after: auto; }
308    .page-footer { position: absolute; bottom: 4mm; left: 0; right: 0; font-size: 9px; color: #a0aec0; display: flex; justify-content: space-between; border-top: 1px solid #e2e8f0; padding-top: 4px; }
309  
310    /* Cover page */
311    .cover { background: linear-gradient(160deg, #1a365d 0%, #0f2440 100%); color: #fff; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; padding: 30mm 25mm; }
312    .cover-logo { margin-bottom: 40px; }
313    .cover-title { font-size: 32px; font-weight: 800; margin-bottom: 8px; letter-spacing: -0.5px; }
314    .cover-subtitle { font-size: 16px; font-weight: 300; color: #a0aec0; margin-bottom: 4px; }
315    .cover-domain { font-size: 18px; color: #e2e8f0; margin-top: 16px; margin-bottom: 6px; }
316    .cover-date { font-size: 12px; color: #718096; margin-bottom: 40px; }
317    .cover-accent { width: 80px; height: 4px; background: #e05d26; border-radius: 2px; margin: 20px auto; }
318    .cover-score { margin-top: 20px; }
319    .cover-grade { display: inline-block; padding: 6px 20px; border-radius: 20px; font-size: 16px; font-weight: 700; margin-top: 12px; }
320    .cover-footer { position: absolute; bottom: 20mm; left: 0; right: 0; text-align: center; font-size: 10px; color: #718096; }
321  
322    /* Section headers */
323    .section-title { font-size: 24px; font-weight: 700; color: #1a365d; margin-bottom: 4px; }
324    .section-accent { width: 60px; height: 3px; background: #e05d26; border-radius: 2px; margin-bottom: 16px; }
325  
326    /* Cards */
327    .card { background: #fff; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); padding: 14px 16px; margin-bottom: 10px; page-break-inside: avoid; }
328    .card-green { border-left: 4px solid #38a169; }
329    .card-red { border-left: 4px solid #e53e3e; }
330    .card-orange { border-left: 4px solid #e05d26; }
331    .card-navy { border-left: 4px solid #1a365d; }
332    .card-gray { border-left: 4px solid #a0aec0; }
333  
334    /* Score box */
335    .score-box { background: #f7fafc; border-radius: 8px; padding: 16px 20px; display: flex; align-items: center; gap: 20px; margin-bottom: 16px; }
336    .score-details { flex: 1; }
337    .score-bar-track { height: 12px; background: #e2e8f0; border-radius: 6px; overflow: hidden; margin-top: 6px; }
338    .score-bar-fill { height: 100%; border-radius: 6px; }
339  
340    /* Factor grid */
341    .factor-card { display: flex; gap: 12px; align-items: flex-start; padding: 12px 14px; }
342    .factor-icon { width: 28px; height: 28px; color: #1a365d; flex-shrink: 0; margin-top: 2px; }
343    .factor-body { flex: 1; min-width: 0; }
344    .factor-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
345    .factor-name { font-size: 14px; font-weight: 700; color: #1a365d; }
346    .factor-weight { font-size: 9px; color: #a0aec0; background: #f7fafc; padding: 1px 6px; border-radius: 10px; }
347    .factor-reasoning { font-size: 12px; color: #4a5568; margin-bottom: 3px; }
348    .factor-evidence { font-size: 11px; color: #718096; font-style: italic; background: #f7fafc; padding: 4px 8px; border-radius: 4px; margin-top: 3px; }
349    .factor-fix { font-size: 10px; color: #2d3748; margin-top: 4px; padding: 6px 8px; background: #f0fff4; border-left: 3px solid #38a169; border-radius: 0 4px 4px 0; }
350    .factor-fix-label { font-weight: 700; color: #38a169; }
351    .factor-copy-change { margin-top: 3px; font-size: 9.5px; }
352    .factor-copy-change .old { text-decoration: line-through; color: #e53e3e; }
353    .factor-copy-change .new { color: #38a169; font-weight: 600; }
354  
355    /* Problem areas */
356    .problem-card { margin-bottom: 14px; page-break-inside: avoid; }
357    .severity-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 9px; font-weight: 700; color: #fff; text-transform: uppercase; letter-spacing: 0.5px; }
358    .problem-img { width: 100%; max-height: 200px; object-fit: cover; border-radius: 6px; margin: 8px 0; border: 1px solid #e2e8f0; }
359    .problem-desc { font-size: 10px; color: #4a5568; margin-bottom: 6px; }
360    .problem-fix { font-size: 10px; padding: 8px 10px; background: #f0fff4; border-left: 3px solid #38a169; border-radius: 0 4px 4px 0; }
361  
362    /* Tech assessment */
363    .tech-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
364    .tech-item { display: flex; align-items: center; gap: 6px; font-size: 10px; padding: 4px 0; }
365    .tech-check { color: #38a169; font-weight: 700; font-size: 14px; }
366    .tech-cross { color: #e53e3e; font-weight: 700; font-size: 14px; }
367  
368    /* Recommendations */
369    .rec-card { display: flex; gap: 12px; align-items: flex-start; padding: 10px 14px; }
370    .rec-priority { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 13px; color: #fff; flex-shrink: 0; }
371    .rec-body { flex: 1; }
372    .rec-title { font-size: 13px; font-weight: 700; color: #1a365d; margin-bottom: 2px; }
373    .rec-desc { font-size: 12px; color: #4a5568; }
374    .rec-meta { display: flex; gap: 8px; margin-top: 4px; }
375    .rec-pill { font-size: 8px; padding: 1px 8px; border-radius: 10px; background: #f7fafc; color: #718096; }
376    .rec-pill-impact { background: #fed7d7; color: #c53030; }
377    .rec-pill-effort { background: #bee3f8; color: #2b6cb0; }
378  
379    /* Action plan */
380    .action-section { margin-bottom: 14px; }
381    .action-label { font-size: 13px; font-weight: 700; color: #1a365d; margin-bottom: 6px; padding-bottom: 4px; border-bottom: 2px solid #e2e8f0; }
382  
383    /* Narrative text */
384    .narrative { font-size: 13px; color: #2d3748; line-height: 1.65; margin-bottom: 12px; white-space: pre-line; }
385  
386    /* Strengths / weaknesses */
387    .finding-item { display: flex; gap: 6px; margin-bottom: 4px; font-size: 10px; }
388    .finding-icon { flex-shrink: 0; font-size: 12px; }
389  
390    /* Grade scale */
391    .grade-table { width: 100%; border-collapse: collapse; font-size: 11px; margin-top: 8px; }
392    .grade-table th, .grade-table td { padding: 3px 8px; text-align: left; border-bottom: 1px solid #e2e8f0; }
393    .grade-table th { background: #f7fafc; font-weight: 600; color: #1a365d; }
394    .grade-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
395  
396    /* About page */
397    .about-logo { margin-bottom: 12px; }
398    .about-text { font-size: 12px; color: #4a5568; line-height: 1.6; margin-bottom: 10px; }
399    .cta-box { background: linear-gradient(135deg, #1a365d, #2a4a7f); color: #fff; border-radius: 8px; padding: 16px 20px; text-align: center; margin-top: 16px; }
400    .cta-box h3 { font-size: 14px; margin-bottom: 4px; }
401    .cta-box p { font-size: 10px; color: #a0aec0; }
402  
403    /* Page header with logo */
404    .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; padding-bottom: 8px; border-bottom: 1px solid #e2e8f0; }
405    .page-header-section { font-size: 11px; color: #a0aec0; text-transform: uppercase; letter-spacing: 1px; font-weight: 600; }
406  
407    /* Action plan with large numbered circles */
408    .action-item { display: flex; gap: 16px; align-items: flex-start; margin-bottom: 16px; }
409    .action-num { width: 42px; height: 42px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: 800; color: #fff; flex-shrink: 0; margin-top: 2px; }
410    .action-text { font-size: 13px; color: #2d3748; line-height: 1.6; padding-top: 10px; }
411    .action-plain { font-size: 12px; color: #4a5568; margin-bottom: 6px; }
412  
413    /* Glossary box */
414    .glossary-box { background: #f7fafc; border-left: 3px solid #a0aec0; padding: 8px 12px; font-size: 11px; color: #4a5568; border-radius: 0 4px 4px 0; margin-bottom: 14px; }
415  
416    /* Tech explanation for failures */
417    .tech-explanation { font-size: 11px; color: #718096; margin-left: 22px; margin-top: 2px; margin-bottom: 4px; font-style: italic; }
418  
419    /* Problem area annotation */
420    .problem-annotation { font-size: 11px; color: #e05d26; font-weight: 600; text-align: center; margin: -4px 0 8px; }
421  
422    /* CRO intro sentence */
423    .report-intro { font-size: 12px; color: #718096; font-style: italic; margin-bottom: 10px; }
424  
425    ${watermarkCSS}
426  </style>
427  </head>
428  <body>
429  ${watermarkHTML}
430  
431  <!-- PAGE 1: COVER -->
432  <div class="page cover" id="page-cover">${(() => {
433      P();
434      return '';
435    })()}
436    <div class="cover-logo">${LOGO_SVG}</div>
437    ${isQuickFixes
438      ? '<div class="cover-title">Quick Fixes</div><div class="cover-title">Report</div>'
439      : '<div class="cover-title">Website CRO</div><div class="cover-title">Audit Report</div>'}
440  
441    <div class="cover-accent"></div>
442    <div class="cover-domain">${escapeHtml(domain)}</div>
443    <div class="cover-date">${escapeHtml(dateStr)}</div>
444    <div class="cover-score">${scoreRing(score, 140, 10)}</div>
445    <div class="cover-grade" style="background:${gradeColor};color:#fff;">Grade: ${escapeHtml(grade)}</div>
446    <div class="cover-footer">Confidential — Prepared by Audit&amp;Fix</div>
447  </div>
448  
449  <!-- PAGE 2: EXECUTIVE SUMMARY -->
450  <div class="page" id="page-summary">${(P(), '')}
451    ${renderPageHeader('Executive Summary')}
452    <h1 class="section-title">Executive Summary</h1>
453    <div class="section-accent"></div>
454  
455    <p class="report-intro">This report evaluates how well your website converts visitors into customers — a practice called Conversion Rate Optimisation (CRO).</p>
456  
457    <div class="score-box">
458      <div>${scoreRing(score, 90, 6)}</div>
459      <div class="score-details">
460        <div style="font-size:14px;font-weight:700;color:#1a365d;">Overall Conversion Score: ${Math.round(score)}/100 <span style="color:${gradeColor};margin-left:8px;">${escapeHtml(grade)}</span></div>
461        ${scoreJson.overall_calculation?.grade_interpretation ? `<div style="font-size:10px;color:#4a5568;margin-top:4px;">${escapeHtml(scoreJson.overall_calculation.grade_interpretation)}</div>` : ''}
462        <div class="score-bar-track" style="margin-top:8px;">
463          <div class="score-bar-fill" style="width:${score}%;background:${getScoreColor(score)};"></div>
464        </div>
465      </div>
466    </div>
467  
468    ${narratives.executive_summary ? `<div class="narrative">${escapeHtml(narratives.executive_summary)}</div>` : ''}
469  
470    ${aboveFoldBase64 ? `<img src="${aboveFoldBase64}" alt="Above-fold screenshot" style="width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid #e2e8f0;margin:10px 0;">` : ''}
471  
472    ${
473      strengths.length > 0
474        ? `
475    <div class="card card-green" style="margin-top:10px;">
476      <div style="font-size:12px;font-weight:700;color:#38a169;margin-bottom:6px;">Key Strengths</div>
477      ${strengths.map(s => `<div class="finding-item"><span class="finding-icon" style="color:#38a169;">+</span><span>${escapeHtml(s)}</span></div>`).join('')}
478    </div>`
479        : ''
480    }
481  
482    ${
483      weaknesses.length > 0
484        ? `
485    <div class="card card-red">
486      <div style="font-size:12px;font-weight:700;color:#e53e3e;margin-bottom:6px;">Areas for Improvement</div>
487      ${weaknesses.map(w => `<div class="finding-item"><span class="finding-icon" style="color:#e53e3e;">−</span><span>${escapeHtml(w)}</span></div>`).join('')}
488    </div>`
489        : ''
490    }
491  
492    ${footer(domain)}
493  </div>
494  
495  <!-- PAGES 3-4: FACTOR ANALYSIS -->
496  <div class="page" id="page-factors">${(P(), '')}
497    ${renderPageHeader('Factor Analysis')}
498    <h1 class="section-title">Factor Analysis</h1>
499    <div class="section-accent"></div>
500    <p style="font-size:10px;color:#718096;margin-bottom:8px;">${isQuickFixes
501      ? 'Your 5 worst-scoring conversion factors, each scored 0–10 and ranked by impact on visitor-to-customer conversion rates.'
502      : 'Each of the 10 conversion factors is scored 0–10 and weighted by its impact on visitor-to-customer conversion rates.'}</p>
503  
504    <div class="glossary-box"><strong>Quick glossary:</strong> CTA (Call to Action) = a button or link that tells visitors what to do. USP (Unique Selling Point) = what makes you different from competitors. "Above the fold" = visible without scrolling. "Friction" = anything that makes visitors hesitant or slows them down.</div>
505  
506    ${Object.entries(factors)
507      .slice(0, 5)
508      .map(([key, data]) => renderFactorCard(key, data, narratives.factor_narratives?.[key]))
509      .join('')}
510  
511    ${footer(domain)}
512  </div>
513  
514  ${isQuickFixes ? '' : `<div class="page">${(P(), '')}
515    ${Object.entries(factors)
516      .slice(5)
517      .map(([key, data]) => renderFactorCard(key, data, narratives.factor_narratives?.[key]))
518      .join('')}
519  
520    ${footer(domain)}
521  </div>`}
522  
523  <!-- PAGES 5-6: PROBLEM AREAS -->
524  ${
525    problemCrops && problemCrops.length > 0
526      ? `
527  <div class="page" id="page-screenshots">${(P(), '')}
528    ${renderPageHeader('Problem Areas')}
529    <h1 class="section-title">Problem Areas</h1>
530    <div class="section-accent"></div>
531    <p style="font-size:10px;color:#718096;margin-bottom:12px;">Zoomed-in screenshots showing specific conversion issues on your site, with exact recommendations for each.</p>
532  
533    ${problemCrops
534      .map((crop, i) => {
535        // Support both old format (imageBuffer) and new format (beforeBuffer/afterBuffer)
536        const toBase64 = buf => {
537          if (!buf) return '';
538          if (Buffer.isBuffer(buf)) return `data:image/jpeg;base64,${buf.toString('base64')}`;
539          if (typeof buf === 'string')
540            return buf.startsWith('data:') ? buf : `data:image/jpeg;base64,${buf}`;
541          return '';
542        };
543  
544        const beforeSrc = toBase64(crop.beforeBuffer || crop.imageBuffer);
545        const afterSrc = toBase64(crop.afterBuffer);
546        const hasBeforeAfter = beforeSrc && afterSrc;
547  
548        // Natural-size images: max-width capped, never stretched beyond actual pixels
549        const imgStyle =
550          'max-width:100%;max-height:220px;object-fit:contain;border-radius:6px;border:1px solid #e2e8f0;';
551  
552        return `
553      <div class="card problem-card">
554        <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
555          <span class="severity-badge" style="background:${getSeverityColor(crop.severity)};">${escapeHtml((crop.severity || 'medium').toUpperCase())}</span>
556          <span style="font-size:12px;font-weight:700;color:#1a365d;">${escapeHtml(FACTOR_LABELS[crop.factor] || crop.factor)}</span>
557        </div>
558        ${
559          hasBeforeAfter
560            ? `<div style="display:flex;gap:10px;margin:8px 0;align-items:flex-start;">
561                <div style="flex:1;text-align:center;">
562                  <div style="font-size:9px;font-weight:700;color:#e53e3e;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px;">Current</div>
563                  <img src="${beforeSrc}" alt="Current: ${escapeHtml(crop.factor)}" style="${imgStyle}">
564                </div>
565                <div style="flex:1;text-align:center;">
566                  <div style="font-size:9px;font-weight:700;color:#38a169;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px;">Recommended</div>
567                  <img src="${afterSrc}" alt="Recommended: ${escapeHtml(crop.factor)}" style="${imgStyle}">
568                </div>
569              </div>`
570            : beforeSrc
571              ? `<img src="${beforeSrc}" alt="Problem area: ${escapeHtml(crop.factor)}" class="problem-img" style="${imgStyle}margin:8px 0;">`
572              : ''
573        }
574        <div class="problem-desc">${escapeHtml(crop.description)}</div>
575        <div class="problem-fix">
576          <div style="font-weight:700;color:#38a169;margin-bottom:2px;">Recommendation:</div>
577          <div>${escapeHtml(crop.recommendation)}</div>
578          ${
579            crop.current_text && crop.suggested_text
580              ? `
581          <div class="factor-copy-change" style="margin-top:4px;">
582            <span class="old">${escapeHtml(crop.current_text)}</span> → <span class="new">${escapeHtml(crop.suggested_text)}</span>
583          </div>`
584              : ''
585          }
586        </div>
587      </div>
588      ${i === 2 ? `${footer(domain)}</div><div class="page">${(P(), '')}` : ''}`;
589      })
590      .join('')}
591  
592    ${footer(domain)}
593  </div>`
594      : ''
595  }
596  
597  <!-- PAGE: TECHNICAL ASSESSMENT -->
598  <div class="page">${(P(), '')}
599    ${renderPageHeader('Technical Assessment')}
600    <h1 class="section-title">Technical Assessment</h1>
601    <div class="section-accent"></div>
602  
603    <div class="card" style="margin-bottom:14px;">
604      <div style="font-size:13px;font-weight:700;color:#1a365d;margin-bottom:8px;">SSL / HTTPS</div>
605      <div class="tech-item">
606        <span class="${techAssess.ssl_status === 'https' || (techAssess.security_headers_present || []).includes('hsts') ? 'tech-check' : 'tech-cross'}">${techAssess.ssl_status === 'https' ? '✓' : '✗'}</span>
607        <span>${techAssess.ssl_status === 'https' ? 'Secure connection (HTTPS)' : 'Not secure — site is served over HTTP'}</span>
608      </div>
609      ${techAssess.ssl_status !== 'https' ? `<div class="tech-explanation">${escapeHtml(HTTPS_FAIL_EXPLANATION)}</div>` : ''}
610    </div>
611  
612    <div class="card" style="margin-bottom:14px;">
613      <div style="font-size:13px;font-weight:700;color:#1a365d;margin-bottom:8px;">Security Headers</div>
614      ${[
615        'HSTS',
616        'CSP',
617        'X-Frame-Options',
618        'X-Content-Type-Options',
619        'Referrer-Policy',
620        'Permissions-Policy',
621      ]
622        .map(header => {
623          const present = (techAssess.security_headers_present || []).some(h =>
624            h.toLowerCase().includes(header.toLowerCase())
625          );
626          const explanation = !present ? TECH_EXPLANATIONS[header] : '';
627          return `<div class="tech-item"><span class="tech-${present ? 'check' : 'cross'}">${present ? '✓' : '✗'}</span><span>${header}</span></div>${explanation ? `<div class="tech-explanation">${escapeHtml(explanation)}</div>` : ''}`;
628        })
629        .join('')}
630    </div>
631  
632    ${
633      techAssess.mobile_responsive !== undefined
634        ? `
635    <div class="card" style="margin-bottom:14px;">
636      <div style="font-size:13px;font-weight:700;color:#1a365d;margin-bottom:8px;">Mobile Responsiveness</div>
637      <div class="tech-item">
638        <span class="${techAssess.mobile_responsive ? 'tech-check' : 'tech-cross'}">${techAssess.mobile_responsive ? '✓' : '✗'}</span>
639        <span>${techAssess.mobile_responsive ? 'Site appears mobile-responsive' : 'Site does not appear mobile-responsive'}</span>
640      </div>
641      ${!techAssess.mobile_responsive ? `<div class="tech-explanation">${escapeHtml(MOBILE_FAIL_EXPLANATION)}</div>` : ''}
642    </div>`
643        : ''
644    }
645  
646    ${
647      (techAssess.performance_indicators || []).length > 0
648        ? `
649    <div class="card">
650      <div style="font-size:13px;font-weight:700;color:#1a365d;margin-bottom:8px;">Performance</div>
651      ${(techAssess.performance_indicators || []).map(p => `<div class="tech-item"><span class="tech-check">✓</span><span>${escapeHtml(p)}</span></div>`).join('')}
652    </div>`
653        : ''
654    }
655  
656    ${footer(domain)}
657  </div>
658  
659  <!-- PAGE: PRIORITISED RECOMMENDATIONS -->
660  <div class="page" id="page-recommendations">${(P(), '')}
661    ${renderPageHeader('Recommendations')}
662    <h1 class="section-title">Prioritised Recommendations</h1>
663    <div class="section-accent"></div>
664  
665    ${
666      quickWins.length > 0
667        ? `
668    <div style="font-size:14px;font-weight:700;color:#e05d26;margin-bottom:8px;">Quick Wins — Do This Week</div>
669    ${quickWins
670      .map(
671        (qw, i) => `
672      <div class="card card-orange" style="padding:8px 12px;">
673        <div style="font-size:10px;color:#2d3748;">${i + 1}. ${escapeHtml(typeof qw === 'string' ? qw : qw.description || '')}</div>
674      </div>`
675      )
676      .join('')}
677    `
678        : ''
679    }
680  
681    ${
682      recommendations.length > 0
683        ? `
684    <div style="font-size:14px;font-weight:700;color:#1a365d;margin:14px 0 8px;">All Recommendations by Priority</div>
685    ${recommendations
686      .map(rec => {
687        const catColor =
688          rec.category === 'quick_win'
689            ? '#e05d26'
690            : rec.category === 'strategic'
691              ? '#1a365d'
692              : '#a0aec0';
693        return `
694      <div class="card rec-card" style="border-left:4px solid ${catColor};">
695        <div class="rec-priority" style="background:${catColor};">${rec.priority || ''}</div>
696        <div class="rec-body">
697          <div class="rec-title">${escapeHtml(rec.title || rec.description || '')}</div>
698          ${rec.title && rec.description ? `<div class="rec-desc">${escapeHtml(rec.description)}</div>` : ''}
699          <div class="rec-meta">
700            ${rec.category ? `<span class="rec-pill">${escapeHtml(rec.category.replace('_', ' '))}</span>` : ''}
701            ${rec.expected_impact ? `<span class="rec-pill rec-pill-impact">${escapeHtml(rec.expected_impact)} impact</span>` : ''}
702            ${rec.estimated_effort ? `<span class="rec-pill rec-pill-effort">${escapeHtml(rec.estimated_effort)}</span>` : ''}
703            ${rec.who_should_do_it ? `<span class="rec-pill">${escapeHtml(rec.who_should_do_it)}</span>` : ''}
704          </div>
705        </div>
706      </div>`;
707      })
708      .join('')}
709    `
710        : ''
711    }
712  
713    ${footer(domain)}
714  </div>
715  
716  <!-- PAGE: ACTION PLAN -->
717  ${
718    narratives.action_plan_week || narratives.action_plan_month || narratives.action_plan_quarter
719      ? `
720  <div class="page">${(P(), '')}
721    ${renderPageHeader('Action Plan')}
722    <h1 class="section-title">Your Action Plan</h1>
723    <div class="section-accent"></div>
724    <p style="font-size:10px;color:#718096;margin-bottom:14px;">A practical roadmap for improving your website's conversion performance — broken into manageable timeframes.</p>
725  
726    ${
727      narratives.action_plan_week
728        ? `
729    <div class="action-section">
730      <div class="action-label" style="color:#e05d26;border-color:#e05d26;">This Week</div>
731      ${renderActionItems(narratives.action_plan_week, '#e05d26')}
732    </div>`
733        : ''
734    }
735  
736    ${
737      narratives.action_plan_month
738        ? `
739    <div class="action-section">
740      <div class="action-label">This Month</div>
741      ${renderActionItems(narratives.action_plan_month, '#1a365d')}
742    </div>`
743        : ''
744    }
745  
746    ${
747      narratives.action_plan_quarter
748        ? `
749    <div class="action-section">
750      <div class="action-label" style="color:#718096;border-color:#718096;">Next 3 Months</div>
751      ${renderActionItems(narratives.action_plan_quarter, '#718096')}
752    </div>`
753        : ''
754    }
755  
756    ${footer(domain)}
757  </div>`
758      : ''
759  }
760  
761  <!-- PAGE: ABOUT -->
762  <div class="page">${(P(), '')}
763    <h1 class="section-title">About This Report</h1>
764    <div class="section-accent"></div>
765  
766    <div class="about-logo">${LOGO_LIGHT_SVG}</div>
767  
768    <div class="about-text">This report was prepared by Audit&amp;Fix using AI-powered analysis of your website's conversion optimisation factors. Our system captures full-page screenshots, analyses HTML structure, checks security headers, and evaluates 10 key factors that influence whether a visitor becomes a customer.</div>
769  
770    <div class="about-text">The scoring methodology is based on industry-standard CRO best practices, with each factor weighted by its relative importance to conversion outcomes. Every recommendation includes specific, actionable changes — not generic advice.</div>
771  
772    ${visionUsed ? `<div class="about-text" style="color:#718096;font-style:italic;border-left:3px solid #e2e8f0;padding-left:12px;">Note: Your score may differ from any previous assessment. This report uses computer vision to evaluate your website&#39;s visual design and user experience &mdash; factors that affect how real visitors perceive your site. This provides a more complete picture than HTML analysis alone.</div>` : ''}
773  
774    <div style="font-size:13px;font-weight:700;color:#1a365d;margin:14px 0 6px;">Grade Scale</div>
775    <table class="grade-table">
776      <thead><tr><th>Grade</th><th>Score</th><th>Meaning</th></tr></thead>
777      <tbody>
778        <tr><td><span class="grade-dot" style="background:#38a169;"></span>A+</td><td>97–100</td><td>Exceptional — industry-leading</td></tr>
779        <tr><td><span class="grade-dot" style="background:#38a169;"></span>A</td><td>93–96</td><td>Excellent — strong conversion potential</td></tr>
780        <tr><td><span class="grade-dot" style="background:#38a169;"></span>A−</td><td>90–92</td><td>Very good — minor improvements possible</td></tr>
781        <tr><td><span class="grade-dot" style="background:#3182ce;"></span>B+</td><td>87–89</td><td>Good — a few clear opportunities</td></tr>
782        <tr><td><span class="grade-dot" style="background:#3182ce;"></span>B</td><td>83–86</td><td>Above average — room for improvement</td></tr>
783        <tr><td><span class="grade-dot" style="background:#3182ce;"></span>B−</td><td>80–82</td><td>Borderline — several barriers present</td></tr>
784        <tr><td><span class="grade-dot" style="background:#d69e2e;"></span>C+</td><td>77–79</td><td>Below average — meaningful work needed</td></tr>
785        <tr><td><span class="grade-dot" style="background:#d69e2e;"></span>C</td><td>73–76</td><td>Fair — significant issues across factors</td></tr>
786        <tr><td><span class="grade-dot" style="background:#d69e2e;"></span>C−</td><td>70–72</td><td>Weak — substantial work required</td></tr>
787        <tr><td><span class="grade-dot" style="background:#dd6b20;"></span>D+</td><td>67–69</td><td>Poor — major barriers across most factors</td></tr>
788        <tr><td><span class="grade-dot" style="background:#dd6b20;"></span>D</td><td>63–66</td><td>Very poor — fundamental issues</td></tr>
789        <tr><td><span class="grade-dot" style="background:#dd6b20;"></span>D−</td><td>60–62</td><td>Critical — losing most potential customers</td></tr>
790        <tr><td><span class="grade-dot" style="background:#e53e3e;"></span>F</td><td>0–59</td><td>Failing — comprehensive overhaul required</td></tr>
791      </tbody>
792    </table>
793  
794    ${isQuickFixes
795      ? `<div class="cta-box" style="margin-top:20px;">
796      <h3>Want the full picture?</h3>
797      <p>This report covers your 5 worst-scoring factors. Upgrade to the <strong>Full Audit</strong> for all 10 factors, zoomed screenshots, a technical assessment, and a prioritised roadmap. We'll credit what you paid for Quick Fixes toward the upgrade.</p>
798      <p style="margin-top:6px;color:#e05d26;font-weight:600;">Reply to this email to upgrade — auditandfix.com</p>
799    </div>`
800      : `<div class="cta-box" style="margin-top:20px;">
801      <h3>Made changes? Measure the improvement.</h3>
802      <p>Order a follow-up benchmarking report at 50% of the original price to see how much your score improved.</p>
803      <p style="margin-top:6px;color:#e05d26;font-weight:600;">reports@auditandfix.com</p>
804    </div>`}
805  
806    ${footer(domain)}
807  </div>
808  
809  </body>
810  </html>`;
811  }
812  
813  /**
814   * Render a single factor analysis card
815   */
816  function renderFactorCard(key, data, narrative) {
817    const label = FACTOR_LABELS[key] || key;
818    const weight = FACTOR_WEIGHTS[key] || 0;
819    const factorScore = data?.score ?? 0;
820    const icon = FACTOR_ICONS[key] || '';
821    // Support both data.evidence and data.current_text_context as evidence fallback
822    const evidence = data?.evidence || data?.current_text_context || '';
823  
824    return `
825    <div class="card factor-card">
826      <div class="factor-icon">${icon}</div>
827      <div style="flex-shrink:0;">${factorRing(factorScore)}</div>
828      <div class="factor-body">
829        <div class="factor-header">
830          <span class="factor-name">${escapeHtml(label)}</span>
831          <span class="factor-weight">${weight}%</span>
832        </div>
833        ${data?.reasoning ? `<div class="factor-reasoning">${escapeHtml(data.reasoning)}</div>` : ''}
834        ${evidence ? `<div class="factor-evidence">"${escapeHtml(evidence)}"</div>` : ''}
835        ${narrative ? `<div class="factor-fix"><span class="factor-fix-label">Plain English: </span>${escapeHtml(narrative)}</div>` : ''}
836        ${
837          data?.current_text && data?.suggested_text
838            ? `
839        <div class="factor-fix" style="background:#fff5f5;border-color:#e53e3e;">
840          <div class="factor-copy-change">
841            <span class="old">${escapeHtml(data.current_text)}</span> → <span class="new">${escapeHtml(data.suggested_text)}</span>
842          </div>
843          ${data.fix_detail ? `<div style="margin-top:3px;font-size:9.5px;color:#4a5568;">${escapeHtml(data.fix_detail)}</div>` : ''}
844        </div>`
845            : ''
846        }
847      </div>
848    </div>`;
849  }