@reportforge/playwright-pdf 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -198,7 +198,7 @@ All options are the second element of the reporter tuple.
198
198
  | `historySize` | `number` | `10` | Maximum number of test runs to keep in the history file (integer ≥ 2). Older runs are pruned on append. |
199
199
  | `showTrend` | `boolean` | `true` | Show the pass-rate sparkline and delta badge in the `detailed` template. Set to `false` to disable history tracking entirely. |
200
200
  | `flakinessTopN` | `number` | `5` | Maximum flaky tests to show in the flakiness table (`detailed` template). Set to `0` to disable the table entirely. |
201
- | `slowTestThreshold` | `number` | `10` | Minimum timed-test count before `SLOW` badges appear in the suite breakdown. The badge marks the 10 slowest tests; below this many it stays hidden (every test would be trivially "slowest"). Set to `0` to always badge the slowest. |
201
+ | `slowTestThreshold` | `number` | `10` | Minimum timed-test count before `SLOW` badges appear in the suite breakdown below this, every test is trivially the "slowest" so badges stay hidden. On larger runs only genuine outliers (at least 2× the median test duration) are badged, so the badge stays rare. Set to `0` to drop the run-size gate (outliers still required). |
202
202
  | `templatePath` | `string \| string[]` | — | Path to a custom Handlebars (`.hbs`) template file. Takes precedence over `template`. Pass an array to generate one PDF per custom template. See the Custom Templates docs. |
203
203
  | `sections` | `object` | per-template | Override which report sections appear, on top of the chosen template's defaults. Flat keys are the baseline for every chosen template; per-template keys (`minimal`\|`detailed`\|`executive`) override per template. Block toggles: `coverPage`, `analysisOneliner`, `releaseGate`, `summary`, `charts`, `trend`, `requirementsMatrix`, `ciEnvironment`, `suiteBreakdown`, `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`. Display modifiers: `passRate`, `fullEnvironment`, `retries`, `fullFailures`, `stackTraces`. See the Report Sections docs. |
204
204
  <!-- AUTOGEN:options-table END -->
package/dist/cli/index.js CHANGED
@@ -6473,7 +6473,7 @@ function deriveOptions(s) {
6473
6473
  showFullFailures: s.fullFailures
6474
6474
  };
6475
6475
  }
6476
- var import_handlebars, import_fs, import_path, TemplateEngine;
6476
+ var import_handlebars, import_fs, import_path, SLOW_MEDIAN_FACTOR, TemplateEngine;
6477
6477
  var init_engine = __esm({
6478
6478
  "src/templates/engine.ts"() {
6479
6479
  "use strict";
@@ -6485,6 +6485,7 @@ var init_engine = __esm({
6485
6485
  init_duration();
6486
6486
  init_SummaryWriter();
6487
6487
  init_sections();
6488
+ SLOW_MEDIAN_FACTOR = 2;
6488
6489
  TemplateEngine = class {
6489
6490
  constructor() {
6490
6491
  this.chartjsInline = "";
@@ -6612,8 +6613,10 @@ var init_engine = __esm({
6612
6613
  }
6613
6614
  }
6614
6615
  }
6616
+ const sortedDurations = tests.map((t) => t.durationMs).sort((a, b) => a - b);
6617
+ const medianMs = sortedDurations.length ? sortedDurations[Math.floor((sortedDurations.length - 1) / 2)] : 0;
6615
6618
  const ranked = tests.sort((a, b) => b.durationMs - a.durationMs).slice(0, 10);
6616
- return { ranked, timedTotal: tests.length };
6619
+ return { ranked, timedTotal: tests.length, medianMs };
6617
6620
  }
6618
6621
  /**
6619
6622
  * Project the suite tree into its render form: attach each test's failure detail
@@ -6628,7 +6631,8 @@ var init_engine = __esm({
6628
6631
  const chipById = sections.failureAnalysis ? new Map((data.classifications ?? []).map((c) => [c.testId, c])) : /* @__PURE__ */ new Map();
6629
6632
  const slowRankById = /* @__PURE__ */ new Map();
6630
6633
  if (sections.slowTests && slow.timedTotal > slowTestThreshold) {
6631
- slow.ranked.forEach((t, i) => slowRankById.set(t.id, i + 1));
6634
+ const floorMs = slow.medianMs * SLOW_MEDIAN_FACTOR;
6635
+ slow.ranked.filter((t) => t.durationMs >= floorMs).forEach((t, i) => slowRankById.set(t.id, i + 1));
6632
6636
  }
6633
6637
  const enrichTest = (t) => ({
6634
6638
  ...t,
package/dist/index.d.mts CHANGED
@@ -236,9 +236,10 @@ interface ReporterOptions {
236
236
  flakinessTopN?: number;
237
237
  /**
238
238
  * Minimum number of timed tests before `SLOW` badges appear in the suite
239
- * breakdown. The badge marks the 10 slowest tests; below this many timed
240
- * tests every test would trivially be the "slowest", so badges stay hidden.
241
- * Set to 0 to always badge the slowest. Must be a non-negative integer.
239
+ * breakdown below this, every test is trivially the "slowest", so badges
240
+ * stay hidden. On larger runs only genuine outliers (at least the median
241
+ * test duration) are badged, so the badge stays rare. Set to 0 to drop the
242
+ * run-size gate (outliers are still required). Must be a non-negative integer.
242
243
  * @default 10
243
244
  */
244
245
  slowTestThreshold?: number;
package/dist/index.d.ts CHANGED
@@ -236,9 +236,10 @@ interface ReporterOptions {
236
236
  flakinessTopN?: number;
237
237
  /**
238
238
  * Minimum number of timed tests before `SLOW` badges appear in the suite
239
- * breakdown. The badge marks the 10 slowest tests; below this many timed
240
- * tests every test would trivially be the "slowest", so badges stay hidden.
241
- * Set to 0 to always badge the slowest. Must be a non-negative integer.
239
+ * breakdown below this, every test is trivially the "slowest", so badges
240
+ * stay hidden. On larger runs only genuine outliers (at least the median
241
+ * test duration) are badged, so the badge stays rare. Set to 0 to drop the
242
+ * run-size gate (outliers are still required). Must be a non-negative integer.
242
243
  * @default 10
243
244
  */
244
245
  slowTestThreshold?: number;
package/dist/index.js CHANGED
@@ -12048,6 +12048,7 @@ function jsonForInlineScript(value) {
12048
12048
  function isUnknownValue(v) {
12049
12049
  return !v || v === "unknown";
12050
12050
  }
12051
+ var SLOW_MEDIAN_FACTOR = 2;
12051
12052
  function deriveOptions(s) {
12052
12053
  return {
12053
12054
  showCharts: s.charts,
@@ -12186,8 +12187,10 @@ var TemplateEngine = class {
12186
12187
  }
12187
12188
  }
12188
12189
  }
12190
+ const sortedDurations = tests.map((t) => t.durationMs).sort((a, b) => a - b);
12191
+ const medianMs = sortedDurations.length ? sortedDurations[Math.floor((sortedDurations.length - 1) / 2)] : 0;
12189
12192
  const ranked = tests.sort((a, b) => b.durationMs - a.durationMs).slice(0, 10);
12190
- return { ranked, timedTotal: tests.length };
12193
+ return { ranked, timedTotal: tests.length, medianMs };
12191
12194
  }
12192
12195
  /**
12193
12196
  * Project the suite tree into its render form: attach each test's failure detail
@@ -12202,7 +12205,8 @@ var TemplateEngine = class {
12202
12205
  const chipById = sections.failureAnalysis ? new Map((data.classifications ?? []).map((c) => [c.testId, c])) : /* @__PURE__ */ new Map();
12203
12206
  const slowRankById = /* @__PURE__ */ new Map();
12204
12207
  if (sections.slowTests && slow.timedTotal > slowTestThreshold) {
12205
- slow.ranked.forEach((t, i) => slowRankById.set(t.id, i + 1));
12208
+ const floorMs = slow.medianMs * SLOW_MEDIAN_FACTOR;
12209
+ slow.ranked.filter((t) => t.durationMs >= floorMs).forEach((t, i) => slowRankById.set(t.id, i + 1));
12206
12210
  }
12207
12211
  const enrichTest = (t) => ({
12208
12212
  ...t,
@@ -13291,7 +13295,7 @@ var PdfReporter = class {
13291
13295
  this.dataCollector = new DataCollector();
13292
13296
  this.pdfGenerator = new PdfGenerator();
13293
13297
  this.options = parseOptions(rawOptions);
13294
- const version = true ? "0.7.0" : "0.x";
13298
+ const version = true ? "0.7.2" : "0.x";
13295
13299
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
13296
13300
  this.licenseClient = new LicenseClient({
13297
13301
  licenseKey: this.options.licenseKey,
package/dist/index.mjs CHANGED
@@ -12049,6 +12049,7 @@ function jsonForInlineScript(value) {
12049
12049
  function isUnknownValue(v) {
12050
12050
  return !v || v === "unknown";
12051
12051
  }
12052
+ var SLOW_MEDIAN_FACTOR = 2;
12052
12053
  function deriveOptions(s) {
12053
12054
  return {
12054
12055
  showCharts: s.charts,
@@ -12187,8 +12188,10 @@ var TemplateEngine = class {
12187
12188
  }
12188
12189
  }
12189
12190
  }
12191
+ const sortedDurations = tests.map((t) => t.durationMs).sort((a, b) => a - b);
12192
+ const medianMs = sortedDurations.length ? sortedDurations[Math.floor((sortedDurations.length - 1) / 2)] : 0;
12190
12193
  const ranked = tests.sort((a, b) => b.durationMs - a.durationMs).slice(0, 10);
12191
- return { ranked, timedTotal: tests.length };
12194
+ return { ranked, timedTotal: tests.length, medianMs };
12192
12195
  }
12193
12196
  /**
12194
12197
  * Project the suite tree into its render form: attach each test's failure detail
@@ -12203,7 +12206,8 @@ var TemplateEngine = class {
12203
12206
  const chipById = sections.failureAnalysis ? new Map((data.classifications ?? []).map((c) => [c.testId, c])) : /* @__PURE__ */ new Map();
12204
12207
  const slowRankById = /* @__PURE__ */ new Map();
12205
12208
  if (sections.slowTests && slow.timedTotal > slowTestThreshold) {
12206
- slow.ranked.forEach((t, i) => slowRankById.set(t.id, i + 1));
12209
+ const floorMs = slow.medianMs * SLOW_MEDIAN_FACTOR;
12210
+ slow.ranked.filter((t) => t.durationMs >= floorMs).forEach((t, i) => slowRankById.set(t.id, i + 1));
12207
12211
  }
12208
12212
  const enrichTest = (t) => ({
12209
12213
  ...t,
@@ -13292,7 +13296,7 @@ var PdfReporter = class {
13292
13296
  this.dataCollector = new DataCollector();
13293
13297
  this.pdfGenerator = new PdfGenerator();
13294
13298
  this.options = parseOptions(rawOptions);
13295
- const version = true ? "0.7.0" : "0.x";
13299
+ const version = true ? "0.7.2" : "0.x";
13296
13300
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
13297
13301
  this.licenseClient = new LicenseClient({
13298
13302
  licenseKey: this.options.licenseKey,
@@ -18,7 +18,7 @@
18
18
 
19
19
  {{#if sections.releaseGate}}{{> release-gate}}{{/if}}
20
20
 
21
- {{#if sections.summary}}{{> executive-summary options=(obj showPassRate=sections.passRate) }}{{/if}}
21
+ {{#if sections.summary}}{{> executive-summary options=(obj showPassRate=sections.passRate releaseGateShown=sections.releaseGate) }}{{/if}}
22
22
 
23
23
  {{#if sections.charts}}{{> charts options=(obj showCharts=true showTrend=sections.trend) }}{{/if}}
24
24
 
@@ -18,7 +18,7 @@
18
18
 
19
19
  {{#if sections.releaseGate}}{{> release-gate}}{{/if}}
20
20
 
21
- {{#if sections.summary}}{{> executive-summary options=(obj showPassRate=sections.passRate) }}{{/if}}
21
+ {{#if sections.summary}}{{> executive-summary options=(obj showPassRate=sections.passRate releaseGateShown=sections.releaseGate) }}{{/if}}
22
22
 
23
23
  {{#if sections.charts}}{{> charts options=(obj showCharts=true showTrend=sections.trend) }}{{/if}}
24
24
 
@@ -18,7 +18,7 @@
18
18
 
19
19
  {{#if sections.releaseGate}}{{> release-gate}}{{/if}}
20
20
 
21
- {{#if sections.summary}}{{> executive-summary options=(obj showPassRate=sections.passRate) }}{{/if}}
21
+ {{#if sections.summary}}{{> executive-summary options=(obj showPassRate=sections.passRate releaseGateShown=sections.releaseGate) }}{{/if}}
22
22
 
23
23
  {{#if sections.charts}}{{> charts options=(obj showCharts=true showTrend=sections.trend) }}{{/if}}
24
24
 
@@ -166,14 +166,14 @@
166
166
  y: {
167
167
  stacked: true,
168
168
  // Widen the label gutter so the longest label isn't clipped at the canvas edge.
169
- afterFit: function(scale) { if (!scale.$rfPadded) { scale.$rfPadded = true; scale.width += 12; } },
169
+ afterFit: function(scale) { if (!scale.$rfPadded) { scale.$rfPadded = true; scale.width += 30; } },
170
170
  ticks: {
171
171
  font: { size: 9, family: 'Inter, sans-serif' }, color: '#1F1F1C', autoSkip: false,
172
- // Truncate long suite / describe labels so they fit the axis gutter
173
- // instead of clipping at the canvas edge.
172
+ // Truncate only very long suite / describe labels; the wider gutter above
173
+ // lets most labels show in full instead of clipping at the canvas edge.
174
174
  callback: function(value) {
175
175
  var l = this.getLabelForValue(value);
176
- return l.length > 18 ? l.slice(0, 17) + '…' : l;
176
+ return l.length > 24 ? l.slice(0, 23) + '…' : l;
177
177
  }
178
178
  },
179
179
  grid: { display: false },
@@ -215,11 +215,17 @@
215
215
 
216
216
  // Smart y-axis: pad above/below the actual data range so variation is readable
217
217
  // rather than a flat line at the top of a 0–100 axis when all runs pass at 96%.
218
+ // Pick the step FIRST, then snap the bounds to multiples of that step, so every
219
+ // tick (min, min+step, … max) is a round value — never 45% / 70% from a min that
220
+ // isn't itself a multiple of the step. Clamps to 0/100 keep the bounds round too.
218
221
  var minRate = Math.min.apply(null, trendRates);
219
222
  var maxRate = Math.max.apply(null, trendRates);
220
223
  var yPad = Math.max(8, Math.round((maxRate - minRate) * 0.4));
221
- var yMin = Math.max(0, minRate - yPad);
222
- var yMax = Math.min(100, maxRate + yPad);
224
+ var rawMin = Math.max(0, minRate - yPad);
225
+ var rawMax = Math.min(100, maxRate + yPad);
226
+ var yStep = (rawMax - rawMin) <= 20 ? 5 : (rawMax - rawMin) <= 50 ? 10 : 25;
227
+ var yMin = Math.max(0, Math.floor(rawMin / yStep) * yStep);
228
+ var yMax = Math.min(100, Math.ceil(rawMax / yStep) * yStep);
223
229
 
224
230
  // Data-label plugin: renders pass-rate % above each point.
225
231
  // Canvas ID guard prevents firing on the doughnut and bar charts.
@@ -279,7 +285,7 @@
279
285
  callback: function(v) { return v + '%'; },
280
286
  font: { size: 8.5, family: 'Inter, sans-serif' },
281
287
  color: '#8C8B82',
282
- count: 5,
288
+ stepSize: yStep,
283
289
  },
284
290
  grid: { color: 'rgba(31,31,28,0.07)' },
285
291
  border: { color: 'transparent' },
@@ -1,7 +1,13 @@
1
1
  <div class="section">
2
+ {{!-- The release gate (when the layout renders it) already states the verdict
3
+ prominently, so the chip would just repeat it — suppress it then. The layout
4
+ passes options.releaseGateShown; it is absent for custom templates, so the
5
+ chip renders by default there (a custom template may not include a gate). --}}
6
+ {{#unless options.releaseGateShown}}
2
7
  <span class="verdict verdict--{{stats.verdict}}">
3
8
  {{verdictLabel stats.verdict}}
4
9
  </span>
10
+ {{/unless}}
5
11
 
6
12
  <div class="kpi-strip">
7
13
  <div class="kpi-card kpi-card--total">
@@ -15,19 +21,21 @@
15
21
  <div class="kpi-card__rate">{{stats.passRate}}%</div>
16
22
  {{/if}}
17
23
  </div>
18
- <div class="kpi-card kpi-card--fail">
24
+ {{!-- Zero-value status cards are dimmed: a coloured "0" for an absent status
25
+ draws the eye to nothing and dilutes the counts that matter. --}}
26
+ <div class="kpi-card kpi-card--fail{{#unless stats.failed}} kpi-card--zero{{/unless}}">
19
27
  <div class="kpi-card__value">{{stats.failed}}</div>
20
28
  <div class="kpi-card__label">Failed</div>
21
29
  </div>
22
- <div class="kpi-card kpi-card--timedout">
30
+ <div class="kpi-card kpi-card--timedout{{#unless stats.timedOut}} kpi-card--zero{{/unless}}">
23
31
  <div class="kpi-card__value">{{stats.timedOut}}</div>
24
32
  <div class="kpi-card__label">Timed Out</div>
25
33
  </div>
26
- <div class="kpi-card kpi-card--skip">
34
+ <div class="kpi-card kpi-card--skip{{#unless stats.skipped}} kpi-card--zero{{/unless}}">
27
35
  <div class="kpi-card__value">{{stats.skipped}}</div>
28
36
  <div class="kpi-card__label">Skipped</div>
29
37
  </div>
30
- <div class="kpi-card kpi-card--flaky">
38
+ <div class="kpi-card kpi-card--flaky{{#unless stats.flaky}} kpi-card--zero{{/unless}}">
31
39
  <div class="kpi-card__value">{{stats.flaky}}</div>
32
40
  <div class="kpi-card__label">Flaky</div>
33
41
  </div>
@@ -38,10 +46,16 @@
38
46
  <span class="summary-row__label">Duration</span>
39
47
  <span class="summary-row__value">{{formatDuration stats.duration}}</span>
40
48
  </div>
49
+ {{!-- Pass rate normally shows on the Passed KPI card and the doughnut centre, so
50
+ it is omitted here to avoid a third restatement on one page. Fallback: if
51
+ BOTH of those are off (passRate modifier off AND charts off, e.g. a stripped
52
+ minimal), show it here so the headline metric never disappears entirely. --}}
53
+ {{#unless (or options.showPassRate @root.sections.charts)}}
41
54
  <div class="summary-row__item">
42
55
  <span class="summary-row__label">Pass Rate</span>
43
56
  <span class="summary-row__value" style="color:var(--pass);">{{stats.passRate}}%</span>
44
57
  </div>
58
+ {{/unless}}
45
59
  <div class="summary-row__item">
46
60
  <span class="summary-row__label">Generated</span>
47
61
  <span class="summary-row__value">{{formatDate meta.generatedAt}}</span>
@@ -39,7 +39,7 @@
39
39
  </thead>
40
40
  <tbody>
41
41
  {{#each tests}}
42
- <tr class="no-break">
42
+ <tr class="no-break{{#if failure}} test-row--has-detail{{/if}}">
43
43
  <td>
44
44
  <div style="font-weight:500;">{{title}}</div>
45
45
  {{#if location}}<div class="test-path">{{location}}</div>{{/if}}
@@ -56,9 +56,13 @@
56
56
  {{#if @root.options.showRetries}}<td style="color:var(--text-3);text-align:center;">{{retryCount}}</td>{{/if}}
57
57
  </tr>
58
58
  {{#if failure}}
59
- <tr class="failure-row no-break">
59
+ <tr class="failure-row">
60
60
  <td colspan="{{#if @root.options.showRetries}}4{{else}}3{{/if}}">
61
61
  <div class="failure-detail failure-detail--{{failure.severity}}">
62
+ <div class="failure-detail__head">
63
+ <span class="failure-detail__dot"></span>
64
+ <span class="failure-detail__sev">{{failure.severity}} severity</span>
65
+ </div>
62
66
  {{> failure-card failure=failure showFull=@root.options.showFullFailures showStack=@root.options.showStackTraces}}
63
67
  {{#if chip}}
64
68
  <div class="root-cause-chip root-cause-chip--{{chip.category}}">
@@ -77,7 +81,7 @@
77
81
  </table>
78
82
  {{/if}}
79
83
  {{#each suites}}
80
- <div style="padding-left:14px;border-left:2px solid var(--border);margin:4px 8px;">
84
+ <div style="padding-left:14px;border-left:1px solid var(--border);margin:4px 8px;">
81
85
  {{> suite-node this}}
82
86
  </div>
83
87
  {{/each}}
@@ -153,6 +153,13 @@ tr:last-child td { border-bottom: none; }
153
153
 
154
154
  /* ── Page breaks ────────────────────────────────────────────────────────── */
155
155
  .no-break { page-break-inside: avoid; }
156
+ /* Keep a failed test row glued to the START of its inline failure detail so the
157
+ title never orphans above it. The detail card itself may break internally (its
158
+ small pieces are individually break-protected), so a tall screenshot flows to the
159
+ next page rather than pushing the whole block down and stranding a blank page. */
160
+ .test-row--has-detail { page-break-after: avoid; break-after: avoid; }
161
+ .test-row--has-detail > td { border-bottom: none; }
162
+ .failure-row { page-break-before: avoid; break-before: avoid; }
156
163
 
157
164
  /* ── Section wrapper ────────────────────────────────────────────────────── */
158
165
  /* Large sections (suite breakdown, failures, tables) MAY flow across pages —
@@ -175,7 +182,6 @@ pre {
175
182
  background: var(--surface-2);
176
183
  color: var(--text-1);
177
184
  border: 1px solid var(--border);
178
- border-left: 3px solid var(--fail);
179
185
  padding: 10px 14px;
180
186
  border-radius: 4px;
181
187
  overflow-x: auto; white-space: pre-wrap; word-break: break-word;
@@ -190,7 +196,7 @@ code {
190
196
 
191
197
  /* ── Screenshot ─────────────────────────────────────────────────────────── */
192
198
  .screenshot {
193
- max-width: 100%; max-height: 260px;
199
+ max-width: 100%; max-height: 210px; width: auto;
194
200
  border: 1px solid var(--border-2); border-radius: 4px;
195
201
  display: block; margin-top: 10px;
196
202
  }
@@ -297,18 +303,37 @@ code {
297
303
  retry history, and root cause together as the failed test's detail (not
298
304
  three separate boxes). Lives here so every template's breakdown can use it. */
299
305
  .failure-row > td { padding: 0; border: none; background: transparent; }
306
+ /* A failed test's detail: a full-bordered card led by a severity dot — NOT a
307
+ coloured left rail. The design system forbids side-stripe borders, so the dot +
308
+ label now carry the severity the rail used to encode by colour alone. The card
309
+ may break across a page; only the small atomic pieces below stay intact, so a
310
+ tall screenshot flows to the next page instead of stranding a blank one. */
300
311
  .failure-detail {
301
- border-left: 3px solid var(--fail);
302
- margin: 2px 10px 10px 12px; /* slight indent under the test row + gap before next row */
303
- padding: 11px 0 11px 16px;
304
- page-break-inside: avoid;
312
+ border: 1px solid var(--border-2);
313
+ border-radius: 6px;
314
+ margin: 2px 10px 10px 12px;
315
+ padding: 12px 14px;
316
+ }
317
+ .failure-detail__head {
318
+ display: flex; align-items: center; gap: 7px; margin-bottom: 9px;
319
+ page-break-after: avoid; break-after: avoid;
320
+ }
321
+ .failure-detail__dot {
322
+ width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
323
+ background: var(--fail);
324
+ -webkit-print-color-adjust: exact; print-color-adjust: exact;
305
325
  }
306
- .failure-detail--critical { border-left-color: var(--fail); }
307
- .failure-detail--high { border-left-color: #A35E1E; }
308
- .failure-detail--medium { border-left-color: var(--timeout); }
309
- .failure-detail--low { border-left-color: var(--text-3); }
326
+ .failure-detail__sev {
327
+ font-family: 'Inter', system-ui, sans-serif;
328
+ font-size: 8.5px; font-weight: 700; letter-spacing: 0.6px;
329
+ text-transform: uppercase; color: var(--text-3);
330
+ }
331
+ .failure-detail--critical .failure-detail__dot { background: var(--fail); }
332
+ .failure-detail--high .failure-detail__dot { background: #A35E1E; }
333
+ .failure-detail--medium .failure-detail__dot { background: var(--timeout); }
334
+ .failure-detail--low .failure-detail__dot { background: var(--text-3); }
310
335
 
311
- /* error — flat on the rail, no inner box (override the base pre panel) */
336
+ /* error — flat inside the card (override the base pre panel) */
312
337
  .failure-detail pre {
313
338
  margin: 0; padding: 0;
314
339
  background: transparent; border: none; border-radius: 0; max-height: none;
@@ -325,6 +350,7 @@ code {
325
350
  padding-top: 10px; border-top: 1px solid var(--border);
326
351
  font-family: 'Inter', system-ui, sans-serif; font-weight: 600;
327
352
  letter-spacing: 0.5px; text-transform: uppercase;
353
+ page-break-inside: avoid; break-inside: avoid;
328
354
  }
329
355
  .retry-attempt {
330
356
  display: inline-block; padding: 2px 7px;
@@ -352,6 +378,7 @@ code {
352
378
  margin-top: 11px; padding-top: 10px;
353
379
  border-top: 1px solid var(--border);
354
380
  font-family: 'Inter', system-ui, sans-serif; font-size: 9.5px;
381
+ page-break-inside: avoid; break-inside: avoid;
355
382
  }
356
383
  .root-cause-chip__tag {
357
384
  font-size: 8px; font-weight: 700; letter-spacing: 0.8px; text-transform: uppercase;
@@ -664,6 +691,11 @@ code {
664
691
  print-color-adjust: exact;
665
692
  }
666
693
 
694
+ /* Dimmed zero-value KPI card — a coloured "0" for an absent status (no timeouts,
695
+ no flakes) draws the eye to nothing; muting it lets the real counts lead. Shared
696
+ modifier so every template's kpi-strip gets it. */
697
+ .kpi-card--zero { opacity: 0.42; }
698
+
667
699
  /* ── Suite breakdown — shared so any template (incl. executive) can toggle it on ─ */
668
700
  /* Values match the detailed template; minimal.css keeps its own tuned overrides
669
701
  for .suite-group (margin) and .suite-stats (gap), which win via templateCSS order. */
@@ -672,11 +704,11 @@ code {
672
704
  border: 1px solid var(--border-2);
673
705
  border-radius: 6px; margin-bottom: 10px; overflow: hidden;
674
706
  }
707
+ /* Full-width tinted header bar — no side-stripe (design system forbids them). */
675
708
  .suite-group__header {
676
709
  padding: 10px 14px;
677
710
  background: var(--surface-2);
678
- border-bottom: 1px solid var(--border);
679
- border-left: 3px solid var(--primary, #CC785C);
711
+ border-bottom: 1px solid var(--border-2);
680
712
  display: flex; justify-content: space-between; align-items: center;
681
713
  }
682
714
  .suite-title {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reportforge/playwright-pdf",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Enterprise-ready PDF reports for Playwright Test — minimal, detailed, and executive templates with CI/CD integrations",
5
5
  "license": "MIT",
6
6
  "author": "ReportForge",