Skip to main content

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.

acme-corp
Playwright Test Report
PASSED
Jan 15, 2026 · 09:42
94%
Pass rate
156
Passed
10
Failed
3
Flaky
4m 32s
Duration
⎇ main · GitHub Actions · 169 tests
Pass rate
94%
Suite breakdown
auth tests
checkout
dashboard
api
checkout › should apply promo code
FAILED
Expected: "SAVE10" · Received: undefined

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
{{!-- 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.pdf

One 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:

FieldTypeDescription
meta.titlestringReport title (reportTitle option)
meta.projectNamestringProject name (inferred from package.json)
meta.generatedAtDateRender timestamp
meta.branding.primaryColorstringBrand colour hex
meta.branding.logoBase64string?Logo as a data URI
stats.totalnumberTotal test count
stats.passednumberPassed count
stats.failednumberFailed count
stats.passRatenumberPass rate 0–100
stats.durationnumberTotal duration ms
stats.verdictstring'passed' | 'failed' | 'timedout' | 'interrupted'
projectsProjectNode[]Per-project suite trees
failuresFailureRecord[]Failure records (inline only)
environment.branchstringGit branch
environment.ciProviderstringCI name
charts.trendobject?Trend data (present when showTrend: true)
tagGroupsTagGroup[]Tests grouped by @tag
slowTestsSlowTest[]Top-10 slowest tests
baseCSSstringFonts + base stylesheet — inject with {{{baseCSS}}}
chartjsScriptstringChart.js bundle as <script> tag — inject with {{{chartjsScript}}}

Helpers

All built-in Handlebars helpers are available in custom templates:

HelperUsageOutput
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
objoptions=(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:

html
<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.