Advanced
Custom templates
Use templatePath to render your own .hbs (Handlebars) file instead of the built-in templates. All helpers, partials, and report context are available to your template.
Example: forge-dark
A production-ready dark-branded template. Drop examples/templates/forge-dark.hbs from the repo into your project and point templatePath at it.
All sections (charts, tag coverage, failures, slow tests, environment) render automatically from live run data. Brand colors, logo, and watermark are picked up from reporter config.
// playwright.config.tsreporter: [ ['@reportforge/playwright-pdf', { templatePath: './templates/forge-dark.hbs', outputFile: 'reports/{date}-{branch}-report.pdf', primaryColor: '#CC785C', accentColor: '#C3553C', logo: './assets/logo.png', // optional showTrend: true, // enables trend chart }],]Full source — copy into your project and adjust colors, sections, or layout to fit your brand. Also available as a direct download.
{{!-- forge-dark.hbs — ReportForge custom template example. Source of truth: examples/templates/forge-dark.hbs Also served statically from: server/public/examples/forge-dark.hbs (keep in sync)--}}<!DOCTYPE html><html lang="en"><head> <meta charset="utf-8"> <title>{{meta.title}}</title> {{!-- Base fonts + reset from ReportForge --}} {{{baseCSS}}} <style> /* ── Reset + page ─────────────────────────────────────────────── */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } @page { size: A4; margin: 0; } body { font-family: Inter, sans-serif; background: #F5F4EF; color: #1A1A18; font-size: 11px; line-height: 1.5; -webkit-print-color-adjust: exact; print-color-adjust: exact; } /* ── Hero ────────────────────────────────────────────────────── */ .hero { background: linear-gradient(140deg, #14130F 0%, #1E1D19 50%, #14130F 100%); color: #FAF9F5; padding: 36px 48px 28px; position: relative; overflow: hidden; } /* Subtle brand-colour radial glow in top-right corner */ .hero::before { content: ''; position: absolute; top: -100px; right: -60px; width: 380px; height: 380px; border-radius: 50%; background: radial-gradient(circle, {{meta.branding.primaryColor}}28 0%, transparent 68%); pointer-events: none; } .hero-header { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; } .hero-logo { width: 42px; height: 42px; border-radius: 10px; object-fit: contain; flex-shrink: 0; background: rgba(255,255,255,0.07); padding: 4px; } .hero-title-block { flex: 1; } .hero-project { font-size: 10px; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: rgba(250,249,245,0.45); margin-bottom: 3px; } .hero-title { font-family: 'Source Serif Pro', Georgia, serif; font-size: 24px; font-weight: 700; color: #FAF9F5; line-height: 1.15; } .hero-right { text-align: right; flex-shrink: 0; } .verdict-badge { display: inline-flex; align-items: center; gap: 5px; padding: 4px 12px; border-radius: 20px; font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; } .verdict-passed { background: rgba(74,222,128,0.14); color: #4ADE80; border: 1px solid rgba(74,222,128,0.28); } .verdict-failed { background: rgba(248,113,113,0.14); color: #F87171; border: 1px solid rgba(248,113,113,0.28); } .verdict-timedout { background: rgba(251,191,36,0.14); color: #FBBf24; border: 1px solid rgba(251,191,36,0.28); } .verdict-interrupted { background: rgba(250,249,245,0.07); color: rgba(250,249,245,0.5); border: 1px solid rgba(250,249,245,0.1); } .hero-date { font-size: 9px; color: rgba(250,249,245,0.38); margin-top: 5px; } /* KPI strip */ .hero-kpis { display: flex; gap: 0; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.09); border-radius: 10px; overflow: hidden; margin-bottom: 18px; } .kpi { flex: 1; padding: 13px 16px; border-right: 1px solid rgba(255,255,255,0.07); text-align: center; } .kpi:last-child { border-right: none; } .kpi-val { font-family: 'Source Serif Pro', Georgia, serif; font-size: 22px; font-weight: 700; line-height: 1; margin-bottom: 4px; } .kpi-label { font-size: 8.5px; font-weight: 600; letter-spacing: 0.09em; text-transform: uppercase; color: rgba(250,249,245,0.42); } .kpi-rate .kpi-val { color: {{meta.branding.primaryColor}}; } .kpi-pass .kpi-val { color: #4ADE80; } .kpi-fail .kpi-val { color: #F87171; } .kpi-flaky .kpi-val { color: #FBBf24; } .kpi-skip .kpi-val { color: rgba(250,249,245,0.5); } .kpi-dur .kpi-val { color: rgba(250,249,245,0.82); font-size: 17px; } /* Progress bar */ .hero-progress { display: flex; align-items: center; gap: 12px; } .progress-track { flex: 1; height: 5px; background: rgba(255,255,255,0.1); border-radius: 3px; overflow: hidden; } .progress-fill { height: 100%; width: {{stats.passRate}}%; background: linear-gradient(90deg, {{meta.branding.primaryColor}}, {{meta.branding.accentColor}}); border-radius: 3px; } .hero-env-line { font-size: 9.5px; color: rgba(250,249,245,0.42); white-space: nowrap; } /* ── Content ──────────────────────────────────────────────────── */ .content { padding: 28px 48px 40px; } .section + .section { margin-top: 28px; } .section-heading { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; } .section-heading::after { content: ''; flex: 1; height: 1px; background: #E2E0D6; } .section-label { font-size: 9px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: #9C9A90; white-space: nowrap; } /* ── Charts ───────────────────────────────────────────────────── */ .charts-row { display: grid; grid-template-columns: 210px 1fr; gap: 14px; } .chart-card { background: #FFFFFF; border: 1px solid #E2E0D6; border-radius: 10px; padding: 14px 14px 10px; } .chart-card-label { font-size: 9px; font-weight: 600; color: #9C9A90; text-align: center; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.07em; } /* ── Trend ────────────────────────────────────────────────────── */ .trend-card { background: #FFFFFF; border: 1px solid #E2E0D6; border-radius: 10px; padding: 14px; margin-top: 14px; } .trend-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; } .trend-card-title { font-size: 9px; font-weight: 600; color: #9C9A90; text-transform: uppercase; letter-spacing: 0.07em; } .delta-badge { font-size: 9px; font-weight: 700; padding: 2px 8px; border-radius: 10px; } .delta-up { background: rgba(63,125,88,0.1); color: #3F7D58; } .delta-down { background: rgba(195,85,60,0.1); color: #C3553C; } /* ── Tag coverage ─────────────────────────────────────────────── */ .tags-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 8px; } .tag-card { background: #FFFFFF; border: 1px solid #E2E0D6; border-radius: 8px; padding: 9px 12px; display: flex; flex-direction: column; gap: 6px; } .tag-row { display: flex; align-items: center; justify-content: space-between; } .tag-name { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 10px; font-weight: 600; color: #1A1A18; } .tag-pct { font-size: 10px; font-weight: 700; color: #3F7D58; } .tag-pct-low { color: #C3553C; } .tag-bar-track { height: 4px; background: #E2E0D6; border-radius: 2px; overflow: hidden; } .tag-bar-fill { height: 100%; border-radius: 2px; background: {{meta.branding.primaryColor}}; } .tag-counts { font-size: 8.5px; color: #9C9A90; } /* ── Severity ring ────────────────────────────────────────────── */ .sev-critical { border-left-color: #7C1F1F !important; } .sev-high { border-left-color: #C3553C !important; } .sev-medium { border-left-color: #D49452 !important; } .sev-low { border-left-color: #7A786E !important; } /* ── Failure cards ────────────────────────────────────────────── */ .overflow-note { background: #FFFBF0; border: 1px solid #F0C875; border-radius: 8px; padding: 9px 13px; font-size: 10px; color: #7A5A28; margin-bottom: 10px; } .failure-card { background: #FFFFFF; border: 1px solid #E2E0D6; border-left: 3px solid #C3553C; border-radius: 0 8px 8px 0; padding: 11px 14px; margin-bottom: 8px; page-break-inside: avoid; } .failure-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 3px; } .failure-title { font-weight: 600; font-size: 11px; color: #1A1A18; line-height: 1.3; } .failure-suite { font-size: 9px; color: #9C9A90; margin-bottom: 8px; } .failure-error { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 9px; background: #FDF3F1; color: #8B2012; padding: 8px 10px; border-radius: 6px; white-space: pre-wrap; word-break: break-all; line-height: 1.65; border-left: 2px solid #C3553C; } .failure-screenshot { margin-top: 8px; border-radius: 6px; overflow: hidden; border: 1px solid #E2E0D6; } .failure-screenshot img { width: 100%; height: auto; display: block; max-height: 220px; object-fit: cover; object-position: top; } /* ── Slow tests ───────────────────────────────────────────────── */ .slow-table { width: 100%; border-collapse: collapse; background: #FFFFFF; border: 1px solid #E2E0D6; border-radius: 10px; overflow: hidden; } .slow-table th { font-size: 8.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #9C9A90; text-align: left; padding: 10px 12px; background: #FAFAF8; border-bottom: 1px solid #E2E0D6; } .slow-table td { padding: 8px 12px; font-size: 10px; border-bottom: 1px solid #F0EFE8; color: #3A3A34; vertical-align: middle; } .slow-table tr:last-child td { border-bottom: none; } .slow-rank { font-size: 9px; color: #B0AFA6; font-weight: 600; width: 20px; } .slow-dur { font-family: 'JetBrains Mono', monospace; font-size: 9.5px; font-weight: 600; color: #D49452; white-space: nowrap; } .slow-project { font-size: 8.5px; color: #9C9A90; margin-top: 1px; } /* ── Environment ──────────────────────────────────────────────── */ .env-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } .env-card { background: #FFFFFF; border: 1px solid #E2E0D6; border-radius: 8px; padding: 10px 12px; } .env-label { font-size: 8px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: #9C9A90; margin-bottom: 3px; } .env-val { font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 600; color: #1A1A18; word-break: break-all; } /* ── Page break ───────────────────────────────────────────────── */ .page-break { break-after: page; } /* ── Badge overrides (statusBadge helper output) ──────────────── */ .badge { display: inline-flex; align-items: center; padding: 2px 7px; border-radius: 4px; font-size: 8px; font-weight: 700; letter-spacing: 0.06em; } .badge--passed { background: rgba(63,125,88,0.1); color: #3F7D58; } .badge--failed { background: rgba(195,85,60,0.1); color: #C3553C; } .badge--timedout{ background: rgba(195,85,60,0.07); color: #C3553C; } .badge--skipped { background: rgba(122,120,110,0.1); color: #7A786E; } .badge--flaky { background: rgba(212,148,82,0.12); color: #D49452; } </style></head><body>{{!-- ══════════════════════════════════════ HERO — dark branded header══════════════════════════════════════════ --}}<div class="hero"> <div class="hero-header"> {{#if meta.branding.logoBase64}} <img class="hero-logo" src="{{meta.branding.logoBase64}}" alt="logo"> {{/if}} <div class="hero-title-block"> <div class="hero-project">{{meta.projectName}}</div> <div class="hero-title">{{meta.title}}</div> </div> <div class="hero-right"> <span class="verdict-badge verdict-{{stats.verdict}}">{{upperCase stats.verdict}}</span> <div class="hero-date">{{formatDate meta.generatedAt}}</div> </div> </div> {{!-- KPI strip --}} <div class="hero-kpis"> <div class="kpi kpi-rate"> <div class="kpi-val">{{stats.passRate}}%</div> <div class="kpi-label">Pass rate</div> </div> <div class="kpi kpi-pass"> <div class="kpi-val">{{stats.passed}}</div> <div class="kpi-label">Passed</div> </div> <div class="kpi kpi-fail"> <div class="kpi-val">{{stats.failed}}</div> <div class="kpi-label">Failed</div> </div> {{#if stats.flaky}} <div class="kpi kpi-flaky"> <div class="kpi-val">{{stats.flaky}}</div> <div class="kpi-label">Flaky</div> </div> {{/if}} {{#if stats.skipped}} <div class="kpi kpi-skip"> <div class="kpi-val">{{stats.skipped}}</div> <div class="kpi-label">Skipped</div> </div> {{/if}} <div class="kpi kpi-dur"> <div class="kpi-val">{{formatDuration stats.duration}}</div> <div class="kpi-label">Duration</div> </div> </div> {{!-- Progress bar + env line --}} <div class="hero-progress"> <div class="progress-track"> <div class="progress-fill"></div> </div> <div class="hero-env-line"> {{#if environment.branch}}⎇ {{environment.branch}}{{/if}} {{#if environment.ciProvider}} · {{environment.ciProvider}}{{/if}} · {{stats.total}} tests </div> </div></div>{{!-- ══════════════════════════════════════ CONTENT══════════════════════════════════════════ --}}<div class="content"> {{!-- ── Charts ──────────────────────────────── --}} <div class="section"> <div class="section-heading"> <span class="section-label">Results</span> </div> <div class="charts-row"> <div class="chart-card"> <div class="chart-card-label">Pass rate</div> <canvas id="passRateChart" width="180" height="180"></canvas> </div> <div class="chart-card"> <div class="chart-card-label">Suite breakdown</div> <canvas id="suitesBarChart" width="370" height="180"></canvas> </div> </div> {{!-- Trend chart (requires showTrend: true in reporter config + ≥2 history entries) --}} {{#if charts.trend}} <div class="trend-card"> <div class="trend-card-header"> <span class="trend-card-title">Pass rate trend — last {{charts.trend.entries.length}} runs</span> {{#if charts.trend.delta}} <span class="delta-badge {{#if (gt charts.trend.delta 0)}}delta-up{{else}}delta-down{{/if}}"> {{#if (gt charts.trend.delta 0)}}↑{{else}}↓{{/if}} {{absFixed charts.trend.delta}}% vs previous </span> {{/if}} </div> <canvas id="trendChart" width="660" height="110"></canvas> </div> {{/if}} </div> {{!-- ── Tag coverage ────────────────────────── --}} {{#if tagGroups.length}} <div class="section"> <div class="section-heading"> <span class="section-label">Coverage by tag</span> </div> <div class="tags-grid"> {{#each tagGroups}} <div class="tag-card"> <div class="tag-row"> <span class="tag-name">{{tag}}</span> <span class="tag-pct {{#if (lt coveragePct 70)}}tag-pct-low{{/if}}">{{coveragePct}}%</span> </div> <div class="tag-bar-track"> <div class="tag-bar-fill" style="width:{{coveragePct}}%"></div> </div> <div class="tag-counts">{{passed}} passed · {{failed}} failed · {{total}} total</div> </div> {{/each}} </div> </div> {{/if}} {{!-- ── Failures ────────────────────────────── --}} {{#if failures.length}} <div class="section page-break"></div> <div class="section"> <div class="section-heading"> <span class="section-label">Failures ({{failures.length}}{{#if overflowFailureCount}} of {{stats.failed}}{{/if}})</span> </div> {{#if overflowFailureCount}} <div class="overflow-note"> {{overflowFailureCount}} additional failures written to <strong>{{overflowSidecarName}}</strong> </div> {{/if}} {{#each failures}} <div class="failure-card sev-{{severity}}"> <div class="failure-title-row"> <div class="failure-title">{{testTitle}}</div> {{{statusBadge 'failed'}}} </div> <div class="failure-suite">{{join suitePath " › "}}</div> {{#if error.message}} <div class="failure-error">{{error.message}}</div> {{/if}} {{#if screenshotBase64}} <div class="failure-screenshot"> <img src="{{screenshotBase64}}" alt="failure screenshot"> </div> {{/if}} </div> {{/each}} </div> {{/if}} {{!-- ── Slow tests ──────────────────────────── --}} {{#if slowTests.length}} <div class="section"> <div class="section-heading"> <span class="section-label">Slowest tests</span> </div> <table class="slow-table"> <thead> <tr> <th>#</th> <th>Test</th> <th>Duration</th> <th>Status</th> </tr> </thead> <tbody> {{#each (take slowTests 10)}} <tr> <td class="slow-rank">{{addOne @index}}</td> <td> <div>{{title}}</div> <div class="slow-project">{{suiteTitle}} · {{project}}</div> </td> <td class="slow-dur">{{durationFormatted}}</td> <td>{{{statusBadge status}}}</td> </tr> {{/each}} </tbody> </table> </div> {{/if}} {{!-- ── Environment ─────────────────────────── --}} <div class="section"> <div class="section-heading"> <span class="section-label">Environment</span> </div> <div class="env-grid"> {{#if environment.branch}} <div class="env-card"> <div class="env-label">Branch</div> <div class="env-val">{{environment.branch}}</div> </div> {{/if}} {{#if environment.commit}} <div class="env-card"> <div class="env-label">Commit</div> <div class="env-val">{{environment.commit}}</div> </div> {{/if}} {{#if environment.ciProvider}} <div class="env-card"> <div class="env-label">CI</div> <div class="env-val">{{environment.ciProvider}}</div> </div> {{/if}} {{#if environment.os}} <div class="env-card"> <div class="env-label">OS</div> <div class="env-val">{{environment.os}}</div> </div> {{/if}} {{#if environment.nodeVersion}} <div class="env-card"> <div class="env-label">Node.js</div> <div class="env-val">{{environment.nodeVersion}}</div> </div> {{/if}} {{#if environment.playwrightVersion}} <div class="env-card"> <div class="env-label">Playwright</div> <div class="env-val">{{environment.playwrightVersion}}</div> </div> {{/if}} </div> </div></div>{{!-- ══════════════════════════════════════ CHART.JS══════════════════════════════════════════ --}}{{{chartjsScript}}}<script>(function () { var remaining = 0; function chartDone() { if (--remaining === 0) window.__chartsReady = true; } var passed = {{charts.passRate.passed}}; var failed = {{charts.passRate.failed}}; var skipped = {{charts.passRate.skipped}}; var flaky = {{charts.passRate.flaky}}; var total = passed + failed + skipped + flaky; var rate = total > 0 ? Math.round((passed / total) * 100) : 0; var primary = '{{meta.branding.primaryColor}}'; var accent = '{{meta.branding.accentColor}}'; /* ── Centre-label plugin ───────────────────────────────────────── */ Chart.register({ id: 'forgeCentre', afterDatasetsDraw: function (chart) { if (chart.canvas.id !== 'passRateChart') return; var ctx = chart.ctx; var cx = (chart.chartArea.left + chart.chartArea.right) / 2; var cy = (chart.chartArea.top + chart.chartArea.bottom) / 2; ctx.save(); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = '700 24px "Source Serif Pro", Georgia, serif'; ctx.fillStyle = '#1A1A18'; ctx.fillText(rate + '%', cx, cy - 7); ctx.font = '600 8px Inter, sans-serif'; ctx.fillStyle = '#9C9A90'; ctx.fillText('PASS RATE', cx, cy + 12); ctx.restore(); } }); /* ── Pass rate doughnut ────────────────────────────────────────── */ remaining += 1; new Chart(document.getElementById('passRateChart'), { type: 'doughnut', data: { labels: ['Passed', 'Failed', 'Skipped', 'Flaky'], datasets: [{ data: [passed, failed, skipped, flaky], backgroundColor: ['#3F7D58', '#C3553C', '#7A786E', '#D49452'], borderColor: ['#F5F4EF', '#F5F4EF', '#F5F4EF', '#F5F4EF'], borderWidth: 2, hoverOffset: 4 }] }, options: { responsive: false, cutout: '70%', plugins: { legend: { position: 'bottom', labels: { font: { size: 8.5, family: 'Inter, sans-serif' }, color: '#5A5A52', padding: 8, boxWidth: 8, boxHeight: 8 } } }, animation: { onComplete: chartDone } } }); /* ── Suite results bar ─────────────────────────────────────────── */ var suiteLabels = [{{{jsonArray charts.suiteResults 'label'}}}]; var suitePassed = [{{{jsonArray charts.suiteResults 'passed'}}}]; var suiteFailed = [{{{jsonArray charts.suiteResults 'failed'}}}]; var suiteSkipped = [{{{jsonArray charts.suiteResults 'skipped'}}}]; remaining += 1; new Chart(document.getElementById('suitesBarChart'), { type: 'bar', data: { labels: suiteLabels, datasets: [ { label: 'Passed', data: suitePassed, backgroundColor: '#3F7D58', borderRadius: 3 }, { label: 'Failed', data: suiteFailed, backgroundColor: '#C3553C', borderRadius: 3 }, { label: 'Skipped', data: suiteSkipped, backgroundColor: '#7A786E', borderRadius: 3 } ] }, options: { indexAxis: 'y', responsive: false, scales: { x: { stacked: true, ticks: { font: { size: 8.5, family: 'Inter, sans-serif' }, color: '#5A5A52', stepSize: 1 }, grid: { color: 'rgba(26,26,24,0.06)' }, border: { color: '#D6D4C8' } }, y: { stacked: true, ticks: { font: { size: 8.5, family: 'Inter, sans-serif' }, color: '#1A1A18' }, grid: { display: false }, border: { color: '#D6D4C8' } } }, plugins: { legend: { position: 'bottom', labels: { font: { size: 8.5, family: 'Inter, sans-serif' }, color: '#5A5A52', padding: 8, boxWidth: 8, boxHeight: 8 } } }, animation: { onComplete: chartDone } } }); /* ── Trend line ────────────────────────────────────────────────── */ {{#if charts.trend}} var trendRaw = {{{safeJsonData charts.trend.entries}}}; var trendData = trendRaw.slice().reverse(); var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; var trendLabels = trendData.map(function (e) { var d = new Date(e.timestamp); return months[d.getMonth()] + ' ' + d.getDate(); }); var trendRates = trendData.map(function (e) { return e.passRate; }); var ptColors = trendData.map(function (e) { return e.verdict === 'passed' ? '#3F7D58' : e.verdict === 'failed' ? '#C3553C' : e.verdict === 'partial' ? '#D49452' : '#7A786E'; }); var minR = Math.min.apply(null, trendRates); var maxR = Math.max.apply(null, trendRates); var pad = Math.max(8, Math.round((maxR - minR) * 0.4)); var tc = document.getElementById('trendChart').getContext('2d'); var grad = tc.createLinearGradient(0, 0, 0, 110); grad.addColorStop(0, primary + '2A'); grad.addColorStop(1, primary + '04'); remaining += 1; new Chart(document.getElementById('trendChart'), { type: 'line', data: { labels: trendLabels, datasets: [{ data: trendRates, borderColor: primary, backgroundColor: grad, fill: true, tension: 0.35, pointRadius: 5, pointBackgroundColor: ptColors, pointBorderColor: '#FFFFFF', pointBorderWidth: 2, borderWidth: 2 }] }, options: { animation: { onComplete: chartDone }, responsive: false, layout: { padding: { top: 18, right: 8, bottom: 4, left: 4 } }, scales: { y: { min: Math.max(0, minR - pad), max: Math.min(100, maxR + pad), ticks: { callback: function (v) { return v + '%'; }, font: { size: 8, family: 'Inter, sans-serif' }, color: '#9C9A90', count: 4 }, grid: { color: 'rgba(26,26,24,0.07)' }, border: { color: 'transparent' } }, x: { ticks: { font: { size: 8, family: 'Inter, sans-serif' }, color: '#9C9A90', maxRotation: 0 }, grid: { display: false }, border: { color: '#E2E0D6' } } }, plugins: { legend: { display: false }, tooltip: { callbacks: { label: function (ctx) { return ' ' + ctx.parsed.y + '% pass rate'; } } } } } }); {{/if}} if (remaining === 0) window.__chartsReady = true;}());</script></body></html>Quick start
Point templatePath at a .hbs file relative to your project root:
// playwright.config.tsreporter: [ ['@reportforge/playwright-pdf', { templatePath: './templates/brand-report.hbs', outputFile: 'reports/{date}-{branch}-report.pdf', }],]ReportForge reads, compiles, and caches the template at run time. The template receives the full TemplateContext documented below.
Multiple custom templates
Pass an array to generate one PDF per template per run. The template's basename (without .hbs) is injected into the output filename automatically:
templatePath: ['./templates/executive.hbs', './templates/engineering.hbs'],outputFile: 'reports/{date}-report.pdf',// → reports/2026-01-15-report-executive.pdf// → reports/2026-01-15-report-engineering.pdfOne license check, one browser launch, N PDFs — same as the built-in multi-template mode.
Precedence
templatePath takes precedence over template. When templatePath is set, the template field is ignored.
Template context
Your template receives a TemplateContext object. The top-level fields are:
| Field | Type | Description |
|---|---|---|
| meta.title | string | Report title (reportTitle option) |
| meta.projectName | string | Project name (inferred from package.json) |
| meta.generatedAt | Date | Render timestamp |
| meta.branding.primaryColor | string | Brand colour hex |
| meta.branding.logoBase64 | string? | Logo as a data URI |
| stats.total | number | Total test count |
| stats.passed | number | Passed count |
| stats.failed | number | Failed count |
| stats.passRate | number | Pass rate 0–100 |
| stats.duration | number | Total duration ms |
| stats.verdict | string | 'passed' | 'failed' | 'timedout' | 'interrupted' |
| projects | ProjectNode[] | Per-project suite trees |
| failures | FailureRecord[] | Failure records (inline only) |
| environment.branch | string | Git branch |
| environment.ciProvider | string | CI name |
| charts.trend | object? | Trend data (present when showTrend: true) |
| tagGroups | TagGroup[] | Tests grouped by @tag |
| slowTests | SlowTest[] | Top-10 slowest tests |
| baseCSS | string | Fonts + base stylesheet — inject with {{{baseCSS}}} |
| chartjsScript | string | Chart.js bundle as <script> tag — inject with {{{chartjsScript}}} |
Helpers
All built-in Handlebars helpers are available in custom templates:
| Helper | Usage | Output |
|---|---|---|
| formatDuration | {{formatDuration stats.duration}} | "3m 12s" |
| formatDate | {{formatDate meta.generatedAt}} | "Jan 01, 2026, 09:00" |
| upperCase | {{upperCase stats.verdict}} | "PASSED" |
| addOne | {{addOne @index}} | 1-based index |
| eq / gt / gte / lt | {{#if (gt a b)}} | Comparison helpers |
| or | {{#if (or a b)}} | Logical OR |
| absFixed | {{absFixed n}} | Absolute value as integer string |
| reverse / take | {{#each (take arr 5)}} | Array helpers |
| statusBadge | {{{statusBadge test.status}}} | HTML status badge |
| safeJsonData | {{{safeJsonData value}}} | JSON-safe inline script data |
| jsonArray | {{{jsonArray arr "key"}}} | JS array literal for Chart.js |
| obj | options=(obj key=val) | Hash object for partials |
Built-in partials
Reuse any built-in partial in your custom template. Each partial accepts an options object to toggle sections:
{{> head}}{{> watermark}}{{> cover-page}}{{> executive-summary options=(obj showPassRate=true) }}{{> charts options=(obj showCharts=true) }}{{> suite-breakdown options=(obj showRetries=true) }}{{> failure-deep-dive options=(obj showFullFailures=true showStackTraces=true) }}{{> slow-tests limit=10 showProject=true}}{{> defect-log}}{{> ci-environment options=(obj showFullEnvironment=true) }}{{> requirements-matrix}}{{> release-gate}}Chart.js
The {{{chartjsScript}}} variable injects the Chart.js bundle. Charts signal render completion by setting window.__chartsReady = true. If your template does not use {{{chartjsScript}}}, include the following to avoid a 30-second wait:
<script>window.__chartsReady = true;</script>Custom templates always have options.showCharts set to true — use it to conditionally render chart canvases if you include the Chart.js bundle.