@vitronai/themis 0.1.0-beta.0

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.
@@ -0,0 +1,2141 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { buildStabilityReport } = require('./stability');
4
+
5
+ const REPORT_LEXICONS = {
6
+ classic: {
7
+ pass: 'PASS',
8
+ fail: 'FAIL',
9
+ skip: 'SKIP',
10
+ total: 'TOTAL',
11
+ summaryPassWord: 'passed',
12
+ summaryFailWord: 'failed',
13
+ summarySkipWord: 'skipped',
14
+ filePassWord: 'pass',
15
+ fileFailWord: 'fail',
16
+ fileSkipWord: 'skip'
17
+ },
18
+ themis: {
19
+ pass: 'CLEAR',
20
+ fail: 'BREACH',
21
+ skip: 'DEFERRED',
22
+ total: 'DOCKET',
23
+ summaryPassWord: 'clear',
24
+ summaryFailWord: 'breach',
25
+ summarySkipWord: 'deferred',
26
+ filePassWord: 'clear',
27
+ fileFailWord: 'breach',
28
+ fileSkipWord: 'deferred'
29
+ }
30
+ };
31
+
32
+ function printSpec(result, options = {}) {
33
+ const lexicon = resolveLexicon(options.lexicon);
34
+ for (const file of result.files) {
35
+ console.log(`\n${file.file}`);
36
+ for (const test of file.tests) {
37
+ const icon = test.status === 'passed' ? lexicon.pass : (test.status === 'skipped' ? lexicon.skip : lexicon.fail);
38
+ console.log(` ${icon} ${test.fullName} (${test.durationMs}ms)`);
39
+ if (test.error) {
40
+ console.log(` ${test.error.message}`);
41
+ }
42
+ }
43
+ }
44
+
45
+ const summaryLine =
46
+ `\n${result.summary.passed}/${result.summary.total} ${lexicon.summaryPassWord}, ` +
47
+ `${result.summary.failed} ${lexicon.summaryFailWord}, ` +
48
+ `${result.summary.skipped} ${lexicon.summarySkipWord} in ${result.summary.durationMs}ms`;
49
+ if (result.summary.failed > 0) {
50
+ console.error(summaryLine);
51
+ } else {
52
+ console.log(summaryLine);
53
+ }
54
+ }
55
+
56
+ function printJson(result) {
57
+ console.log(JSON.stringify(result));
58
+ }
59
+
60
+ function printAgent(result) {
61
+ const failures = collectAgentFailures(result.files || []);
62
+ const failureClusters = clusterFailures(failures);
63
+ const stability = result.stability || buildStabilityReport([result]);
64
+ const comparison = result.artifacts?.comparison || buildAgentComparison(result, failures);
65
+ const artifactPaths = result.artifacts?.paths || {
66
+ lastRun: '.themis/last-run.json',
67
+ failedTests: '.themis/failed-tests.json',
68
+ runDiff: '.themis/run-diff.json',
69
+ runHistory: '.themis/run-history.json'
70
+ };
71
+
72
+ const payload = {
73
+ schema: 'themis.agent.result.v1',
74
+ meta: result.meta,
75
+ summary: result.summary,
76
+ failures,
77
+ artifacts: artifactPaths,
78
+ analysis: {
79
+ fingerprintVersion: 'fnv1a32-message-v1',
80
+ failureClusters,
81
+ stability,
82
+ comparison
83
+ },
84
+ hints: {
85
+ rerunFailed: 'npx themis test --rerun-failed',
86
+ targetedRerun: 'npx themis test --match "<regex>"',
87
+ updateSnapshots: 'npx themis test -u',
88
+ diffLastRun: 'cat .themis/run-diff.json'
89
+ }
90
+ };
91
+
92
+ console.log(JSON.stringify(payload));
93
+ }
94
+
95
+ function buildAgentComparison(result, failures) {
96
+ return {
97
+ status: 'baseline',
98
+ previousRunId: '',
99
+ previousRunAt: '',
100
+ currentRunAt: String(result.meta?.startedAt || ''),
101
+ delta: {
102
+ total: Number(result.summary?.total || 0),
103
+ passed: Number(result.summary?.passed || 0),
104
+ failed: Number(result.summary?.failed || 0),
105
+ skipped: Number(result.summary?.skipped || 0),
106
+ durationMs: roundDuration(result.summary?.durationMs || 0)
107
+ },
108
+ newFailures: failures.map((failure) => failure.fullName),
109
+ resolvedFailures: []
110
+ };
111
+ }
112
+
113
+ function writeHtmlReport(result, options = {}) {
114
+ const cwd = options.cwd || process.cwd();
115
+ const outputPath = resolveHtmlOutputPath(cwd, options.outputPath);
116
+ const backgroundRelativePath = ensureHtmlBackgroundAsset(outputPath, options.backgroundAssetPath);
117
+ const reportArtRelativePath = ensureHtmlReportAsset(outputPath, options.reportAssetPath);
118
+ const html = renderHtmlReport(result, {
119
+ ...options,
120
+ backgroundRelativePath,
121
+ reportArtRelativePath
122
+ });
123
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
124
+ fs.writeFileSync(outputPath, html, 'utf8');
125
+ return outputPath;
126
+ }
127
+
128
+ function printNext(result, options = {}) {
129
+ const lexicon = resolveLexicon(options.lexicon);
130
+ const style = createStyle();
131
+ const files = Array.isArray(result.files) ? result.files : [];
132
+ const summary = result.summary || { total: 0, passed: 0, failed: 0, skipped: 0, durationMs: 0 };
133
+ const meta = result.meta || {};
134
+ const stability = result.stability || null;
135
+
136
+ const allTests = [];
137
+ for (const file of files) {
138
+ for (const test of file.tests || []) {
139
+ allTests.push({
140
+ ...test,
141
+ file: file.file
142
+ });
143
+ }
144
+ }
145
+
146
+ const slowest = [...allTests]
147
+ .filter((test) => test.status !== 'skipped')
148
+ .sort((a, b) => (b.durationMs || 0) - (a.durationMs || 0))
149
+ .slice(0, 5);
150
+
151
+ const failures = allTests.filter((test) => test.status === 'failed');
152
+
153
+ console.log('');
154
+ console.log(style.cyan(bannerLine('=')));
155
+ console.log(style.bold(style.cyan('THEMIS NEXT REPORT')));
156
+ console.log(
157
+ `${style.dim('started')} ${meta.startedAt || 'n/a'} ${style.dim('workers')} ${meta.maxWorkers || 'n/a'} ${style.dim('duration')} ${formatMs(summary.durationMs)}`
158
+ );
159
+ console.log(style.cyan(bannerLine('-')));
160
+ console.log(
161
+ `${statusBadge(style, 'passed', summary.passed, lexicon)} ` +
162
+ `${statusBadge(style, 'failed', summary.failed, lexicon)} ` +
163
+ `${statusBadge(style, 'skipped', summary.skipped, lexicon)} ${style.bold(`${lexicon.total} ${summary.total}`)}`
164
+ );
165
+ console.log(style.cyan(bannerLine('=')));
166
+
167
+ if (stability && stability.runs > 1) {
168
+ const unstableCount = Number(stability.summary?.unstable || 0);
169
+ const stableFailCount = Number(stability.summary?.stableFail || 0);
170
+ const stablePassCount = Number(stability.summary?.stablePass || 0);
171
+ const gateStatus = unstableCount === 0 && stableFailCount === 0 ? style.green('STABLE') : style.red('UNSTABLE');
172
+ console.log(style.bold('Stability Gate'));
173
+ console.log(
174
+ ` ${style.dim('runs')} ${stability.runs} ` +
175
+ `${style.dim('stable_pass')} ${stablePassCount} ` +
176
+ `${style.dim('stable_fail')} ${stableFailCount} ` +
177
+ `${style.dim('unstable')} ${unstableCount} ` +
178
+ `${style.bold(gateStatus)}`
179
+ );
180
+
181
+ if (unstableCount > 0) {
182
+ const unstableTests = stability.tests
183
+ .filter((entry) => entry.classification === 'unstable')
184
+ .slice(0, 5);
185
+ for (const entry of unstableTests) {
186
+ console.log(` ${style.yellow('UNSTABLE')} ${entry.fullName} ${style.dim(`[${entry.statuses.join(' -> ')}]`)}`);
187
+ }
188
+ }
189
+
190
+ if (stableFailCount > 0) {
191
+ const stableFailTests = stability.tests
192
+ .filter((entry) => entry.classification === 'stable_fail')
193
+ .slice(0, 5);
194
+ for (const entry of stableFailTests) {
195
+ console.log(` ${style.red('STABLE_FAIL')} ${entry.fullName} ${style.dim(`[${entry.statuses.join(' -> ')}]`)}`);
196
+ }
197
+ }
198
+
199
+ console.log(style.cyan(bannerLine('-')));
200
+ }
201
+
202
+ for (const file of files) {
203
+ const fileStats = summarizeTests(file.tests || []);
204
+ const fileStatus = fileStats.failed > 0 ? 'failed' : (fileStats.passed > 0 ? 'passed' : 'skipped');
205
+ const fileDuration = roundDuration((file.tests || []).reduce((sum, test) => sum + (test.durationMs || 0), 0));
206
+
207
+ console.log(
208
+ `${statusTag(style, fileStatus, lexicon)} ${file.file} ` +
209
+ `${style.dim(`(${fileStats.passed} ${lexicon.filePassWord}, ${fileStats.failed} ${lexicon.fileFailWord}, ${fileStats.skipped} ${lexicon.fileSkipWord}, ${formatMs(fileDuration)})`)}`
210
+ );
211
+
212
+ for (const test of file.tests || []) {
213
+ if (test.status !== 'failed') {
214
+ continue;
215
+ }
216
+ console.log(` ${style.red(lexicon.fail)} ${test.fullName}`);
217
+ if (test.error && test.error.message) {
218
+ const messageLines = String(test.error.message).split('\n').slice(0, 2);
219
+ for (const line of messageLines) {
220
+ console.log(` ${style.dim(line)}`);
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ if (slowest.length > 0) {
227
+ console.log('');
228
+ console.log(style.bold('Slowest Tests'));
229
+ for (const test of slowest) {
230
+ console.log(` ${padLeft(formatMs(test.durationMs), 10)} ${test.fullName}`);
231
+ }
232
+ }
233
+
234
+ if (failures.length > 0) {
235
+ const failureClusters = clusterFailures(collectAgentFailures(files));
236
+
237
+ if (failureClusters.length > 0) {
238
+ console.log('');
239
+ console.log(style.bold('Failure Clusters'));
240
+ for (const cluster of failureClusters.slice(0, 5)) {
241
+ const label = `${cluster.count}x ${cluster.message}`;
242
+ console.log(` ${style.red(cluster.fingerprint)} ${label}`);
243
+ }
244
+ }
245
+
246
+ console.log('');
247
+ console.log(style.bold(style.red('Failure Details')));
248
+ failures.forEach((test, index) => {
249
+ console.log(` ${index + 1}. ${test.fullName}`);
250
+ console.log(` ${style.dim(test.file)}`);
251
+ if (test.error && test.error.stack) {
252
+ const stackLines = String(test.error.stack).split('\n').slice(0, 8);
253
+ for (const line of stackLines) {
254
+ console.log(` ${style.dim(`| ${line}`)}`);
255
+ }
256
+ }
257
+ });
258
+ }
259
+
260
+ console.log('');
261
+ console.log(style.bold('Agent Loop Commands'));
262
+ console.log(` ${style.cyan('rerun failed:')} npx themis test --rerun-failed --reporter next`);
263
+ console.log(` ${style.cyan('targeted rerun:')} npx themis test --match \"<regex>\" --reporter next`);
264
+ console.log(style.cyan(bannerLine('=')));
265
+ }
266
+
267
+ function summarizeTests(tests) {
268
+ const passed = tests.filter((test) => test.status === 'passed').length;
269
+ const failed = tests.filter((test) => test.status === 'failed').length;
270
+ const skipped = tests.filter((test) => test.status === 'skipped').length;
271
+ return { passed, failed, skipped };
272
+ }
273
+
274
+ function statusBadge(style, kind, value, lexicon) {
275
+ if (kind === 'passed') {
276
+ return `${style.bold(style.green(lexicon.pass))} ${value}`;
277
+ }
278
+ if (kind === 'failed') {
279
+ return `${style.bold(style.red(lexicon.fail))} ${value}`;
280
+ }
281
+ return `${style.bold(style.yellow(lexicon.skip))} ${value}`;
282
+ }
283
+
284
+ function statusTag(style, kind, lexicon) {
285
+ if (kind === 'passed') {
286
+ return style.bold(style.green(`[${lexicon.pass}]`));
287
+ }
288
+ if (kind === 'failed') {
289
+ return style.bold(style.red(`[${lexicon.fail}]`));
290
+ }
291
+ return style.bold(style.yellow(`[${lexicon.skip}]`));
292
+ }
293
+
294
+ function resolveLexicon(name) {
295
+ if (name && REPORT_LEXICONS[name]) {
296
+ return REPORT_LEXICONS[name];
297
+ }
298
+ return REPORT_LEXICONS.classic;
299
+ }
300
+
301
+ function createStyle() {
302
+ const enabled = Boolean(process.stdout.isTTY && !process.env.NO_COLOR);
303
+ return {
304
+ bold(text) {
305
+ return applyAnsi(enabled, text, [1]);
306
+ },
307
+ dim(text) {
308
+ return applyAnsi(enabled, text, [2]);
309
+ },
310
+ red(text) {
311
+ return applyAnsi(enabled, text, [31]);
312
+ },
313
+ green(text) {
314
+ return applyAnsi(enabled, text, [32]);
315
+ },
316
+ yellow(text) {
317
+ return applyAnsi(enabled, text, [33]);
318
+ },
319
+ cyan(text) {
320
+ return applyAnsi(enabled, text, [36]);
321
+ }
322
+ };
323
+ }
324
+
325
+ function applyAnsi(enabled, text, codes) {
326
+ if (!enabled) {
327
+ return text;
328
+ }
329
+ const open = codes.map((code) => `\x1b[${code}m`).join('');
330
+ return `${open}${text}\x1b[0m`;
331
+ }
332
+
333
+ function bannerLine(char) {
334
+ return char.repeat(72);
335
+ }
336
+
337
+ function formatMs(value) {
338
+ return `${roundDuration(value)}ms`;
339
+ }
340
+
341
+ function roundDuration(value) {
342
+ return Math.round(Number(value || 0) * 100) / 100;
343
+ }
344
+
345
+ function padLeft(value, width) {
346
+ const text = String(value);
347
+ if (text.length >= width) {
348
+ return text;
349
+ }
350
+ return `${' '.repeat(width - text.length)}${text}`;
351
+ }
352
+
353
+ function collectAgentFailures(files) {
354
+ const failures = [];
355
+ for (const file of files) {
356
+ for (const test of file.tests || []) {
357
+ if (test.status !== 'failed') {
358
+ continue;
359
+ }
360
+ const message = test.error ? test.error.message : 'Unknown error';
361
+ const stack = test.error ? test.error.stack : '';
362
+ failures.push({
363
+ file: file.file,
364
+ testName: test.name,
365
+ fullName: test.fullName,
366
+ durationMs: test.durationMs,
367
+ message,
368
+ stack,
369
+ fingerprint: computeFailureFingerprint(message)
370
+ });
371
+ }
372
+ }
373
+ return failures;
374
+ }
375
+
376
+ function clusterFailures(failures) {
377
+ const byFingerprint = new Map();
378
+
379
+ for (const failure of failures) {
380
+ let cluster = byFingerprint.get(failure.fingerprint);
381
+ if (!cluster) {
382
+ cluster = {
383
+ fingerprint: failure.fingerprint,
384
+ count: 0,
385
+ message: normalizeFailureMessage(failure.message),
386
+ tests: []
387
+ };
388
+ byFingerprint.set(failure.fingerprint, cluster);
389
+ }
390
+ cluster.count += 1;
391
+ cluster.tests.push(failure.fullName);
392
+ }
393
+
394
+ const clusters = [...byFingerprint.values()];
395
+ for (const cluster of clusters) {
396
+ cluster.tests.sort();
397
+ }
398
+
399
+ clusters.sort((a, b) => {
400
+ if (b.count !== a.count) {
401
+ return b.count - a.count;
402
+ }
403
+ return a.fingerprint.localeCompare(b.fingerprint);
404
+ });
405
+
406
+ return clusters;
407
+ }
408
+
409
+ function computeFailureFingerprint(message) {
410
+ const normalized = normalizeFailureMessage(message);
411
+ const hash = fnv1a32(`v1|${normalized}`);
412
+ return `f1-${hash}`;
413
+ }
414
+
415
+ function normalizeFailureMessage(message) {
416
+ return String(message || '')
417
+ .split('\n')[0]
418
+ .trim()
419
+ .toLowerCase()
420
+ .replace(/\s+/g, ' ');
421
+ }
422
+
423
+ function fnv1a32(input) {
424
+ let hash = 0x811c9dc5;
425
+ for (let i = 0; i < input.length; i += 1) {
426
+ hash ^= input.charCodeAt(i);
427
+ hash = Math.imul(hash, 0x01000193);
428
+ }
429
+ return (hash >>> 0).toString(16).padStart(8, '0');
430
+ }
431
+
432
+ function resolveHtmlOutputPath(cwd, outputPath) {
433
+ if (!outputPath) {
434
+ return path.join(cwd, '.themis', 'report.html');
435
+ }
436
+ if (path.isAbsolute(outputPath)) {
437
+ return outputPath;
438
+ }
439
+ return path.join(cwd, outputPath);
440
+ }
441
+
442
+ function renderHtmlReport(result, options = {}) {
443
+ const lexicon = resolveLexicon(options.lexicon);
444
+ const backgroundRelativePath = options.backgroundRelativePath || null;
445
+ const files = Array.isArray(result.files) ? result.files : [];
446
+ const summary = result.summary || { total: 0, passed: 0, failed: 0, skipped: 0, durationMs: 0 };
447
+ const meta = result.meta || {};
448
+ const stability = result.stability || null;
449
+
450
+ const allTests = [];
451
+ for (const file of files) {
452
+ for (const test of file.tests || []) {
453
+ allTests.push({
454
+ ...test,
455
+ file: file.file
456
+ });
457
+ }
458
+ }
459
+
460
+ const slowest = [...allTests]
461
+ .filter((test) => test.status !== 'skipped')
462
+ .sort((a, b) => Number(b.durationMs || 0) - Number(a.durationMs || 0))
463
+ .slice(0, 8);
464
+
465
+ const failures = allTests.filter((test) => test.status === 'failed');
466
+ const failureClusters = clusterFailures(collectAgentFailures(files));
467
+ const gateState = stability && stability.runs > 1
468
+ ? (Number(stability.summary?.unstable || 0) === 0 && Number(stability.summary?.stableFail || 0) === 0 ? 'stable' : 'unstable')
469
+ : null;
470
+ const runStatus = summary.failed > 0 ? 'FAILED' : 'PASSED';
471
+ const pageTitle = `Themis Test Report — ${runStatus} (${summary.passed}/${summary.total} passed)`;
472
+
473
+ const summaryCards = [
474
+ { key: 'passed', label: lexicon.pass, value: summary.passed, tone: 'pass' },
475
+ { key: 'failed', label: lexicon.fail, value: summary.failed, tone: 'fail' },
476
+ { key: 'skipped', label: lexicon.skip, value: summary.skipped, tone: 'skip' },
477
+ { key: 'total', label: lexicon.total, value: summary.total, tone: 'total' }
478
+ ]
479
+ .map((card, index) => {
480
+ return (
481
+ `<article class="stat-card ${card.tone}" style="--card-index:${index};">` +
482
+ `<div class="stat-label">${escapeHtml(String(card.label))}</div>` +
483
+ `<div class="stat-value">${escapeHtml(String(card.value))}</div>` +
484
+ '</article>'
485
+ );
486
+ })
487
+ .join('\n');
488
+
489
+ const stabilitySection = stability && stability.runs > 1
490
+ ? (
491
+ '<section class="panel">' +
492
+ '<h2>Stability Gate</h2>' +
493
+ `<div class="stability-grid">` +
494
+ `<div class="chip"><span>Runs</span><strong>${escapeHtml(String(stability.runs))}</strong></div>` +
495
+ `<div class="chip"><span>stable_pass</span><strong>${escapeHtml(String(stability.summary?.stablePass || 0))}</strong></div>` +
496
+ `<div class="chip"><span>stable_fail</span><strong>${escapeHtml(String(stability.summary?.stableFail || 0))}</strong></div>` +
497
+ `<div class="chip"><span>unstable</span><strong>${escapeHtml(String(stability.summary?.unstable || 0))}</strong></div>` +
498
+ `<div class="chip gate ${gateState}"><span>Gate</span><strong>${gateState === 'stable' ? 'STABLE' : 'UNSTABLE'}</strong></div>` +
499
+ '</div>' +
500
+ renderStabilityHighlights(stability) +
501
+ '</section>'
502
+ )
503
+ : '';
504
+
505
+ const failureClusterSection = failureClusters.length > 0
506
+ ? (
507
+ '<section class="panel">' +
508
+ '<h2>Failure Clusters</h2>' +
509
+ `<div class="cluster-list">` +
510
+ failureClusters.slice(0, 10).map((cluster) => {
511
+ return (
512
+ '<article class="cluster-item">' +
513
+ `<div class="cluster-fingerprint">${escapeHtml(cluster.fingerprint)}</div>` +
514
+ `<div class="cluster-message">${escapeHtml(cluster.message)}</div>` +
515
+ `<div class="cluster-count">${escapeHtml(`${cluster.count}x`)}</div>` +
516
+ '</article>'
517
+ );
518
+ }).join('\n') +
519
+ '</div>' +
520
+ '</section>'
521
+ )
522
+ : '';
523
+
524
+ const filePanels = files.map((file, fileIndex) => {
525
+ const fileStats = summarizeTests(file.tests || []);
526
+ const fileStatus = fileStats.failed > 0 ? 'failed' : (fileStats.passed > 0 ? 'passed' : 'skipped');
527
+ const fileDuration = roundDuration((file.tests || []).reduce((sum, test) => sum + Number(test.durationMs || 0), 0));
528
+ const fileFailures = (file.tests || []).filter((test) => test.status === 'failed');
529
+ const searchBlob = [
530
+ file.file,
531
+ ...(file.tests || []).map((test) => test.fullName || test.name || ''),
532
+ ...fileFailures.map((test) => test.error?.message || ''),
533
+ ].join(' ').toLowerCase();
534
+ const fileTestRows = (file.tests || []).map((test) => {
535
+ const testTone = test.status === 'passed' ? 'pass' : (test.status === 'failed' ? 'fail' : 'skip');
536
+ const testLabel = test.status === 'passed'
537
+ ? lexicon.pass
538
+ : (test.status === 'failed' ? lexicon.fail : lexicon.skip);
539
+ const errorPreview = String(test.error?.message || '')
540
+ .split('\n')
541
+ .map((line) => line.trim())
542
+ .filter(Boolean)
543
+ .slice(0, 2)
544
+ .map((line) => `<div class="test-error-line">${escapeHtml(line)}</div>`)
545
+ .join('');
546
+
547
+ return (
548
+ `<li class="test-item ${testTone}">` +
549
+ `<div class="test-status ${testTone}">${escapeHtml(String(testLabel))}</div>` +
550
+ '<div class="test-main">' +
551
+ `<div class="test-name">${escapeHtml(test.fullName || test.name || 'Unnamed test')}</div>` +
552
+ (errorPreview ? `<div class="test-error">${errorPreview}</div>` : '') +
553
+ '</div>' +
554
+ `<div class="test-duration">${escapeHtml(formatMs(test.durationMs || 0))}</div>` +
555
+ '</li>'
556
+ );
557
+ }).join('\n');
558
+ const fileSummaryGrid =
559
+ '<div class="file-summary-grid">' +
560
+ `<div class="file-summary-chip"><span>${escapeHtml(String(lexicon.pass))}</span><strong>${escapeHtml(String(fileStats.passed))}</strong></div>` +
561
+ `<div class="file-summary-chip"><span>${escapeHtml(String(lexicon.fail))}</span><strong>${escapeHtml(String(fileStats.failed))}</strong></div>` +
562
+ `<div class="file-summary-chip"><span>${escapeHtml(String(lexicon.skip))}</span><strong>${escapeHtml(String(fileStats.skipped))}</strong></div>` +
563
+ `<div class="file-summary-chip"><span>DURATION</span><strong>${escapeHtml(formatMs(fileDuration))}</strong></div>` +
564
+ '</div>';
565
+ const fileDetails = (file.tests || []).length > 0
566
+ ? (
567
+ `<details class="file-details"${fileFailures.length > 0 ? ' open' : ''}>` +
568
+ `<summary>View tests <span>${escapeHtml(String((file.tests || []).length))}</span></summary>` +
569
+ `<ul class="test-list">${fileTestRows}</ul>` +
570
+ '</details>'
571
+ )
572
+ : '';
573
+
574
+ return (
575
+ `<article class="file-panel ${fileStatus}" data-file-status="${escapeHtml(fileStatus)}" data-file-search="${escapeHtml(searchBlob)}" style="--card-index:${fileIndex};">` +
576
+ '<header class="file-header">' +
577
+ `<div class="file-path">${escapeHtml(file.file)}</div>` +
578
+ `<div class="file-meta">${escapeHtml(`${fileStats.passed} ${lexicon.filePassWord}, ${fileStats.failed} ${lexicon.fileFailWord}, ${fileStats.skipped} ${lexicon.fileSkipWord}, ${formatMs(fileDuration)}`)}</div>` +
579
+ '</header>' +
580
+ fileSummaryGrid +
581
+ (fileFailures.length > 0
582
+ ? `<ul class="failure-list">` +
583
+ fileFailures.map((test) => {
584
+ const lines = String(test.error?.message || '').split('\n').slice(0, 2).map((line) => `<div class="failure-line">${escapeHtml(line)}</div>`).join('');
585
+ return (
586
+ '<li class="failure-entry">' +
587
+ `<div class="failure-name">${escapeHtml(test.fullName)}</div>` +
588
+ lines +
589
+ '</li>'
590
+ );
591
+ }).join('\n') +
592
+ '</ul>'
593
+ : '<div class="file-pass">No failing tests in this file.</div>') +
594
+ fileDetails +
595
+ '</article>'
596
+ );
597
+ }).join('\n');
598
+
599
+ const slowestSection = slowest.length > 0
600
+ ? (
601
+ '<section class="panel">' +
602
+ '<h2>Slowest Tests</h2>' +
603
+ '<div class="slow-list">' +
604
+ slowest.map((test) => {
605
+ return (
606
+ '<article class="slow-item">' +
607
+ `<div class="slow-time">${escapeHtml(formatMs(test.durationMs))}</div>` +
608
+ `<div class="slow-name">${escapeHtml(test.fullName)}</div>` +
609
+ '</article>'
610
+ );
611
+ }).join('\n') +
612
+ '</div>' +
613
+ '</section>'
614
+ )
615
+ : '';
616
+
617
+ const generatedAt = new Date().toISOString();
618
+ const hasInteractiveFilters = files.length > 0;
619
+ const backgroundImageLayer = backgroundRelativePath
620
+ ? `url("${escapeCssUrl(backgroundRelativePath)}")`
621
+ : 'none';
622
+ const heroReportArt = options.reportArtRelativePath
623
+ ? `<div class="hero-story-art"><img src="${escapeHtml(options.reportArtRelativePath)}" alt="" aria-hidden="true"></div>`
624
+ : '';
625
+ const statusLabel = runStatus === 'PASSED' ? 'Passed' : 'Action Needed';
626
+ const statusHeadline = runStatus === 'PASSED' ? 'All Checks Cleared' : 'Failures Need Attention';
627
+ const statusSummary = runStatus === 'PASSED'
628
+ ? 'The run completed cleanly. Review the slowest tests and file timings below.'
629
+ : 'The run completed with failures or instability. Use the sections below to triage quickly.';
630
+ const quickActionsSection =
631
+ '<section class="panel triage-panel" id="actions">' +
632
+ '<h2>Quick Actions</h2>' +
633
+ '<div class="action-grid">' +
634
+ [
635
+ {
636
+ title: failures.length > 0 ? 'Rerun Failed' : 'Rerun Workflow',
637
+ description: failures.length > 0
638
+ ? 'Replay only the failing tests from the last recorded run.'
639
+ : 'Keep the failure loop ready when the suite turns red.',
640
+ command: 'npx themis test --rerun-failed --reporter html'
641
+ },
642
+ {
643
+ title: 'Targeted Match',
644
+ description: 'Run a narrow slice of the suite while investigating one area.',
645
+ command: 'npx themis test --match "<regex>" --reporter html'
646
+ },
647
+ {
648
+ title: 'Agent Payload',
649
+ description: 'Emit the machine-readable verdict contract for agents and tooling.',
650
+ command: 'npx themis test --agent'
651
+ },
652
+ {
653
+ title: 'Stability Sweep',
654
+ description: 'Repeat the suite to classify tests as stable or unstable.',
655
+ command: 'npx themis test --stability 3 --reporter html'
656
+ }
657
+ ].map((action) => {
658
+ return (
659
+ '<article class="action-card">' +
660
+ `<div class="action-title">${escapeHtml(action.title)}</div>` +
661
+ `<p class="action-description">${escapeHtml(action.description)}</p>` +
662
+ `<code class="action-command">${escapeHtml(action.command)}</code>` +
663
+ `<button class="copy-button" type="button" data-copy-text="${escapeHtml(action.command)}">Copy</button>` +
664
+ '</article>'
665
+ );
666
+ }).join('\n') +
667
+ '</div>' +
668
+ '</section>';
669
+ const focusPanel = failures.length > 0
670
+ ? (
671
+ '<section class="panel triage-panel" id="focus">' +
672
+ '<h2>Primary Failures</h2>' +
673
+ '<div class="focus-list">' +
674
+ failures.slice(0, 4).map((test) => {
675
+ const firstLine = String(test.error?.message || 'No error message available').split('\n')[0];
676
+ return (
677
+ '<article class="focus-item">' +
678
+ '<div class="focus-head">' +
679
+ `<div class="focus-name">${escapeHtml(test.fullName)}</div>` +
680
+ `<div class="focus-duration">${escapeHtml(formatMs(test.durationMs || 0))}</div>` +
681
+ '</div>' +
682
+ `<div class="focus-file">${escapeHtml(test.file || '')}</div>` +
683
+ `<div class="focus-message">${escapeHtml(firstLine)}</div>` +
684
+ '</article>'
685
+ );
686
+ }).join('\n') +
687
+ '</div>' +
688
+ '</section>'
689
+ )
690
+ : (
691
+ '<section class="panel triage-panel" id="focus">' +
692
+ '<h2>Clean Verdict</h2>' +
693
+ '<div class="clean-state">' +
694
+ '<strong>No failing tests in this run.</strong>' +
695
+ '<p>The report is ready for speed review, stability sweeps, and artifact handoff to AI agents.</p>' +
696
+ '</div>' +
697
+ '</section>'
698
+ );
699
+ const stabilityPanel = stabilitySection
700
+ ? stabilitySection.replace('<section class="panel">', '<section class="panel insight-panel" id="stability">')
701
+ : '';
702
+ const failureClusterPanel = failureClusterSection
703
+ ? failureClusterSection.replace('<section class="panel">', '<section class="panel insight-panel" id="clusters">')
704
+ : '';
705
+ const slowestPanel = slowestSection
706
+ ? slowestSection.replace('<section class="panel">', '<section class="panel insight-panel" id="slowest">')
707
+ : '';
708
+ const insightsGrid = [stabilityPanel, failureClusterPanel, slowestPanel].filter(Boolean).join('\n');
709
+ const triageGrid = [quickActionsSection, focusPanel].join('\n');
710
+
711
+ return `<!doctype html>
712
+ <html lang="en">
713
+ <head>
714
+ <meta charset="utf-8">
715
+ <meta name="viewport" content="width=device-width, initial-scale=1">
716
+ <title>${escapeHtml(pageTitle)}</title>
717
+ <style>
718
+ :root {
719
+ --bg-1: #071521;
720
+ --bg-2: #0f2736;
721
+ --bg-3: #1f4a52;
722
+ --ink: #f5f8fb;
723
+ --ink-dim: #a0b4c2;
724
+ --panel: rgba(7, 16, 25, 0.76);
725
+ --panel-border: rgba(173, 214, 255, 0.2);
726
+ --pass: #22c55e;
727
+ --fail: #ef4444;
728
+ --skip: #f59e0b;
729
+ --total: #2dd4bf;
730
+ --accent: #f97316;
731
+ --shadow: 0 24px 48px rgba(2, 7, 12, 0.48);
732
+ }
733
+
734
+ * { box-sizing: border-box; }
735
+
736
+ html {
737
+ min-height: 100%;
738
+ background: #050d14;
739
+ }
740
+
741
+ body {
742
+ margin: 0;
743
+ min-height: 100vh;
744
+ font-family: "Space Grotesk", "Avenir Next", "Segoe UI", sans-serif;
745
+ color: var(--ink);
746
+ background:
747
+ radial-gradient(64rem 34rem at 12% 10%, rgba(249, 115, 22, 0.18), transparent 68%),
748
+ radial-gradient(72rem 40rem at 88% 16%, rgba(45, 212, 191, 0.16), transparent 64%),
749
+ linear-gradient(160deg, rgba(4, 11, 18, 0.97), rgba(7, 20, 30, 0.96) 44%, rgba(8, 25, 35, 0.98));
750
+ background-attachment: fixed;
751
+ line-height: 1.45;
752
+ position: relative;
753
+ overflow-x: hidden;
754
+ }
755
+
756
+ body::before {
757
+ content: "";
758
+ position: fixed;
759
+ inset: 0;
760
+ pointer-events: none;
761
+ z-index: 0;
762
+ background-image: ${backgroundImageLayer};
763
+ background-repeat: no-repeat;
764
+ background-position: center top;
765
+ background-size: cover;
766
+ opacity: 0.46;
767
+ filter: saturate(1.05) contrast(1.08);
768
+ transform: scale(1.02);
769
+ transform-origin: center top;
770
+ }
771
+
772
+ body::after {
773
+ content: "";
774
+ position: fixed;
775
+ inset: 0;
776
+ pointer-events: none;
777
+ z-index: 0;
778
+ background:
779
+ linear-gradient(180deg, rgba(5, 10, 16, 0.16), rgba(5, 10, 16, 0.42) 24%, rgba(5, 10, 16, 0.82)),
780
+ radial-gradient(circle at top, rgba(125, 211, 252, 0.08), transparent 36%);
781
+ }
782
+
783
+ .wrap {
784
+ width: min(1100px, 92vw);
785
+ margin: 1.5rem auto 3rem;
786
+ display: grid;
787
+ gap: 1.1rem;
788
+ position: relative;
789
+ z-index: 1;
790
+ }
791
+
792
+ .hero {
793
+ position: relative;
794
+ overflow: hidden;
795
+ background:
796
+ linear-gradient(135deg, rgba(7, 17, 28, 0.82), rgba(8, 26, 38, 0.56)),
797
+ radial-gradient(circle at top right, rgba(45, 212, 191, 0.12), transparent 28%);
798
+ border: 1px solid rgba(126, 188, 255, 0.22);
799
+ border-radius: 24px;
800
+ box-shadow: var(--shadow);
801
+ padding: 1.35rem;
802
+ backdrop-filter: blur(14px);
803
+ animation: rise 520ms ease both;
804
+ }
805
+
806
+ .hero::before {
807
+ content: "";
808
+ position: absolute;
809
+ inset: auto -12% 56% 38%;
810
+ height: 14rem;
811
+ background: radial-gradient(circle, rgba(249, 115, 22, 0.14), transparent 66%);
812
+ pointer-events: none;
813
+ }
814
+
815
+ .hero-top {
816
+ display: flex;
817
+ justify-content: space-between;
818
+ align-items: flex-start;
819
+ gap: 1rem;
820
+ margin-bottom: 1.1rem;
821
+ }
822
+
823
+ .hero-kicker {
824
+ display: inline-flex;
825
+ align-items: center;
826
+ gap: 0.55rem;
827
+ padding: 0.38rem 0.72rem;
828
+ border-radius: 999px;
829
+ border: 1px solid rgba(173, 214, 255, 0.22);
830
+ background: rgba(5, 14, 22, 0.42);
831
+ font-size: 0.76rem;
832
+ font-weight: 700;
833
+ letter-spacing: 0.12em;
834
+ text-transform: uppercase;
835
+ color: #d8e9f8;
836
+ }
837
+
838
+ .hero-kicker::before {
839
+ content: "";
840
+ width: 0.52rem;
841
+ height: 0.52rem;
842
+ border-radius: 999px;
843
+ background: linear-gradient(135deg, var(--accent), #facc15);
844
+ box-shadow: 0 0 18px rgba(249, 115, 22, 0.5);
845
+ flex: 0 0 auto;
846
+ }
847
+
848
+ .hero-status {
849
+ display: inline-flex;
850
+ align-items: center;
851
+ justify-content: center;
852
+ min-width: 8.5rem;
853
+ padding: 0.48rem 0.82rem;
854
+ border-radius: 999px;
855
+ border: 1px solid rgba(173, 214, 255, 0.18);
856
+ background: rgba(3, 12, 19, 0.5);
857
+ font-size: 0.76rem;
858
+ font-weight: 700;
859
+ letter-spacing: 0.12em;
860
+ text-transform: uppercase;
861
+ }
862
+
863
+ .hero-status.passed {
864
+ color: #baf7ce;
865
+ box-shadow: inset 0 0 0 1px rgba(34, 197, 94, 0.14);
866
+ }
867
+
868
+ .hero-status.failed {
869
+ color: #fecaca;
870
+ box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.14);
871
+ }
872
+
873
+ .hero-main {
874
+ display: grid;
875
+ grid-template-columns: 1fr;
876
+ gap: 1rem;
877
+ align-items: stretch;
878
+ }
879
+
880
+ .hero-copy {
881
+ display: grid;
882
+ gap: 1rem;
883
+ align-content: start;
884
+ }
885
+
886
+ .hero-story {
887
+ position: relative;
888
+ overflow: hidden;
889
+ display: grid;
890
+ align-items: center;
891
+ min-height: 18rem;
892
+ padding: 1.2rem clamp(16rem, 37%, 29rem) 1.15rem 1.2rem;
893
+ border-radius: 20px;
894
+ border: 1px solid rgba(173, 214, 255, 0.16);
895
+ background: linear-gradient(180deg, rgba(5, 15, 23, 0.62), rgba(5, 17, 26, 0.34));
896
+ transition:
897
+ transform 220ms ease,
898
+ border-color 220ms ease,
899
+ box-shadow 220ms ease,
900
+ background 220ms ease;
901
+ }
902
+
903
+ .hero-story::after {
904
+ content: "";
905
+ position: absolute;
906
+ inset: 0;
907
+ pointer-events: none;
908
+ background:
909
+ linear-gradient(90deg, rgba(5, 15, 23, 0.97) 0%, rgba(5, 15, 23, 0.9) 36%, rgba(5, 15, 23, 0.54) 60%, rgba(5, 15, 23, 0.08) 100%);
910
+ }
911
+
912
+ .hero-story > * {
913
+ position: relative;
914
+ z-index: 1;
915
+ }
916
+
917
+ .hero-story-art {
918
+ position: absolute;
919
+ inset: 0.8rem 0.85rem 0.8rem auto;
920
+ width: clamp(16rem, 39%, 30rem);
921
+ display: flex;
922
+ align-items: center;
923
+ justify-content: center;
924
+ pointer-events: none;
925
+ z-index: 0;
926
+ border-radius: 18px;
927
+ overflow: hidden;
928
+ background:
929
+ linear-gradient(180deg, rgba(7, 18, 28, 0.48), rgba(7, 20, 31, 0.18)),
930
+ radial-gradient(circle at 50% 30%, rgba(125, 211, 252, 0.12), transparent 60%);
931
+ }
932
+
933
+ .hero-story-art img {
934
+ width: 100%;
935
+ height: 100%;
936
+ object-fit: cover;
937
+ object-position: center center;
938
+ display: block;
939
+ opacity: 0.98;
940
+ filter:
941
+ drop-shadow(0 28px 38px rgba(0, 0, 0, 0.34))
942
+ drop-shadow(0 0 28px rgba(125, 211, 252, 0.12));
943
+ transition:
944
+ transform 280ms ease,
945
+ filter 280ms ease,
946
+ opacity 280ms ease;
947
+ }
948
+
949
+ .hero-story-copy {
950
+ position: relative;
951
+ z-index: 1;
952
+ display: grid;
953
+ gap: 0.82rem;
954
+ max-width: 34rem;
955
+ }
956
+
957
+ .hero-brand-copy {
958
+ display: flex;
959
+ flex-wrap: wrap;
960
+ align-items: center;
961
+ gap: 0.45rem 0.8rem;
962
+ }
963
+
964
+ .eyebrow {
965
+ margin: 0;
966
+ font-size: 0.74rem;
967
+ font-weight: 700;
968
+ letter-spacing: 0.2em;
969
+ text-transform: uppercase;
970
+ color: var(--accent);
971
+ }
972
+
973
+ .hero-wordmark {
974
+ margin: 0;
975
+ display: flex;
976
+ flex-wrap: wrap;
977
+ align-items: center;
978
+ gap: 0.45rem;
979
+ font-size: clamp(1.05rem, 1.8vw, 1.4rem);
980
+ line-height: 1.05;
981
+ letter-spacing: -0.02em;
982
+ color: #f7fbff;
983
+ }
984
+
985
+ .hero-wordmark span {
986
+ display: inline-flex;
987
+ align-items: center;
988
+ min-height: 2rem;
989
+ padding: 0.28rem 0.62rem;
990
+ border-radius: 999px;
991
+ border: 1px solid rgba(125, 211, 252, 0.24);
992
+ background: rgba(10, 34, 49, 0.62);
993
+ color: #83d6ff;
994
+ font-size: 0.82rem;
995
+ font-weight: 700;
996
+ letter-spacing: 0.08em;
997
+ text-transform: uppercase;
998
+ }
999
+
1000
+ .hero-brand-summary {
1001
+ margin: 0;
1002
+ color: #9db4c6;
1003
+ font-size: 0.92rem;
1004
+ }
1005
+
1006
+ .hero-copy h1 {
1007
+ margin: 0;
1008
+ font-size: clamp(2rem, 4vw, 3.4rem);
1009
+ letter-spacing: -0.02em;
1010
+ line-height: 0.94;
1011
+ max-width: 8ch;
1012
+ text-wrap: balance;
1013
+ }
1014
+
1015
+ .hero-summary {
1016
+ margin: 0;
1017
+ max-width: 58ch;
1018
+ color: #d6e7f4;
1019
+ font-size: 1rem;
1020
+ }
1021
+
1022
+ .hero-side {
1023
+ border-radius: 20px;
1024
+ border: 1px solid rgba(173, 214, 255, 0.16);
1025
+ background: linear-gradient(180deg, rgba(5, 14, 22, 0.76), rgba(6, 18, 28, 0.52));
1026
+ padding: 1.05rem;
1027
+ display: grid;
1028
+ gap: 0.9rem;
1029
+ align-content: start;
1030
+ transition:
1031
+ transform 220ms ease,
1032
+ border-color 220ms ease,
1033
+ box-shadow 220ms ease,
1034
+ background 220ms ease;
1035
+ }
1036
+
1037
+ .hero-side-label {
1038
+ margin: 0;
1039
+ color: var(--ink-dim);
1040
+ font-size: 0.72rem;
1041
+ font-weight: 700;
1042
+ text-transform: uppercase;
1043
+ letter-spacing: 0.12em;
1044
+ }
1045
+
1046
+ .meta {
1047
+ margin-top: 0;
1048
+ display: grid;
1049
+ gap: 0.65rem;
1050
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1051
+ }
1052
+
1053
+ .meta-chip {
1054
+ display: grid;
1055
+ gap: 0.34rem;
1056
+ align-content: start;
1057
+ min-height: 5rem;
1058
+ padding: 0.78rem 0.82rem;
1059
+ border-radius: 14px;
1060
+ border: 1px solid rgba(173, 214, 255, 0.16);
1061
+ color: var(--ink-dim);
1062
+ font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, Consolas, monospace;
1063
+ font-size: 0.75rem;
1064
+ background: linear-gradient(180deg, rgba(6, 16, 24, 0.58), rgba(6, 18, 27, 0.34));
1065
+ transition:
1066
+ transform 180ms ease,
1067
+ border-color 180ms ease,
1068
+ background 180ms ease;
1069
+ }
1070
+
1071
+ .meta-chip strong {
1072
+ color: #7fcfff;
1073
+ font-size: 0.7rem;
1074
+ letter-spacing: 0.14em;
1075
+ text-transform: uppercase;
1076
+ font-weight: 700;
1077
+ }
1078
+
1079
+ .meta-chip span {
1080
+ color: #dce9f5;
1081
+ font-size: 0.92rem;
1082
+ font-weight: 700;
1083
+ line-height: 1.25;
1084
+ word-break: break-word;
1085
+ }
1086
+
1087
+ .hero-statline {
1088
+ display: grid;
1089
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1090
+ gap: 0.7rem;
1091
+ margin-top: 0.1rem;
1092
+ }
1093
+
1094
+ .hero-stat {
1095
+ padding: 0.75rem 0.82rem;
1096
+ border-radius: 16px;
1097
+ border: 1px solid rgba(173, 214, 255, 0.16);
1098
+ background: linear-gradient(180deg, rgba(5, 14, 22, 0.42), rgba(6, 18, 28, 0.24));
1099
+ color: #d8e7f4;
1100
+ font-size: 0.76rem;
1101
+ font-weight: 700;
1102
+ text-transform: uppercase;
1103
+ letter-spacing: 0.08em;
1104
+ transition:
1105
+ transform 180ms ease,
1106
+ border-color 180ms ease,
1107
+ background 180ms ease,
1108
+ box-shadow 180ms ease;
1109
+ }
1110
+
1111
+ .hero-stat strong {
1112
+ display: block;
1113
+ margin: 0 0 0.22rem;
1114
+ color: #fff;
1115
+ font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, Consolas, monospace;
1116
+ font-weight: 700;
1117
+ font-size: 1.2rem;
1118
+ letter-spacing: -0.03em;
1119
+ }
1120
+
1121
+ .stats {
1122
+ display: grid;
1123
+ gap: 0.8rem;
1124
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1125
+ }
1126
+
1127
+ .stat-card {
1128
+ position: relative;
1129
+ overflow: hidden;
1130
+ border-radius: 18px;
1131
+ border: 1px solid var(--panel-border);
1132
+ background:
1133
+ linear-gradient(180deg, rgba(7, 16, 25, 0.78), rgba(7, 18, 28, 0.5)),
1134
+ radial-gradient(circle at top right, rgba(125, 211, 252, 0.08), transparent 34%);
1135
+ box-shadow: var(--shadow);
1136
+ padding: 1rem 1.05rem;
1137
+ animation: rise 580ms ease both;
1138
+ animation-delay: calc(60ms * var(--card-index));
1139
+ transition:
1140
+ transform 220ms ease,
1141
+ border-color 220ms ease,
1142
+ box-shadow 220ms ease,
1143
+ background 220ms ease;
1144
+ }
1145
+
1146
+ .stat-card::after {
1147
+ content: "";
1148
+ position: absolute;
1149
+ inset: 0 auto auto 0;
1150
+ width: 100%;
1151
+ height: 3px;
1152
+ background: linear-gradient(90deg, rgba(249, 115, 22, 0.82), rgba(125, 211, 252, 0.42));
1153
+ opacity: 0.7;
1154
+ }
1155
+
1156
+ .stat-label {
1157
+ font-size: 0.72rem;
1158
+ letter-spacing: 0.09em;
1159
+ text-transform: uppercase;
1160
+ color: var(--ink-dim);
1161
+ font-weight: 700;
1162
+ }
1163
+
1164
+ .stat-value {
1165
+ margin-top: 0.28rem;
1166
+ font-size: clamp(1.15rem, 2.4vw, 1.8rem);
1167
+ font-weight: 700;
1168
+ font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, Consolas, monospace;
1169
+ }
1170
+
1171
+ .stat-card.pass .stat-value { color: var(--pass); }
1172
+ .stat-card.fail .stat-value { color: var(--fail); }
1173
+ .stat-card.skip .stat-value { color: var(--skip); }
1174
+ .stat-card.total .stat-value { color: var(--total); }
1175
+
1176
+ .panel {
1177
+ border-radius: 16px;
1178
+ border: 1px solid var(--panel-border);
1179
+ background: var(--panel);
1180
+ box-shadow: var(--shadow);
1181
+ padding: 0.95rem 1rem;
1182
+ animation: rise 640ms ease both;
1183
+ transition:
1184
+ transform 220ms ease,
1185
+ border-color 220ms ease,
1186
+ box-shadow 220ms ease,
1187
+ background 220ms ease;
1188
+ }
1189
+
1190
+ .panel h2 {
1191
+ margin: 0 0 0.6rem;
1192
+ font-size: 0.95rem;
1193
+ text-transform: uppercase;
1194
+ letter-spacing: 0.09em;
1195
+ color: #d7e8f5;
1196
+ }
1197
+
1198
+ .insights-grid {
1199
+ display: grid;
1200
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1201
+ gap: 1rem;
1202
+ align-items: start;
1203
+ }
1204
+
1205
+ .triage-grid {
1206
+ display: grid;
1207
+ grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
1208
+ gap: 1rem;
1209
+ align-items: start;
1210
+ }
1211
+
1212
+ .triage-panel {
1213
+ min-height: 100%;
1214
+ }
1215
+
1216
+ .insight-panel {
1217
+ min-height: 100%;
1218
+ }
1219
+
1220
+ .insights-grid .insight-panel:last-child:nth-child(odd) {
1221
+ grid-column: 1 / -1;
1222
+ }
1223
+
1224
+ .section-nav,
1225
+ .files-toolbar {
1226
+ border-radius: 16px;
1227
+ border: 1px solid var(--panel-border);
1228
+ background: rgba(6, 16, 24, 0.72);
1229
+ box-shadow: var(--shadow);
1230
+ padding: 0.9rem 1rem;
1231
+ }
1232
+
1233
+ .section-nav {
1234
+ display: flex;
1235
+ flex-wrap: wrap;
1236
+ gap: 0.55rem;
1237
+ align-items: center;
1238
+ }
1239
+
1240
+ .section-link,
1241
+ .filter-chip {
1242
+ display: inline-flex;
1243
+ align-items: center;
1244
+ justify-content: center;
1245
+ min-height: 2.2rem;
1246
+ padding: 0.42rem 0.78rem;
1247
+ border-radius: 999px;
1248
+ border: 1px solid rgba(173, 214, 255, 0.18);
1249
+ background: rgba(5, 14, 22, 0.42);
1250
+ color: #dbe9f5;
1251
+ font-size: 0.78rem;
1252
+ font-weight: 700;
1253
+ letter-spacing: 0.06em;
1254
+ text-transform: uppercase;
1255
+ text-decoration: none;
1256
+ cursor: pointer;
1257
+ transition:
1258
+ transform 180ms ease,
1259
+ border-color 180ms ease,
1260
+ background 180ms ease,
1261
+ box-shadow 180ms ease,
1262
+ color 180ms ease;
1263
+ }
1264
+
1265
+ .section-link:hover,
1266
+ .filter-chip:hover {
1267
+ border-color: rgba(125, 211, 252, 0.36);
1268
+ background: rgba(8, 24, 35, 0.74);
1269
+ }
1270
+
1271
+ .files-toolbar {
1272
+ display: grid;
1273
+ gap: 0.9rem;
1274
+ }
1275
+
1276
+ .files-toolbar-top {
1277
+ display: flex;
1278
+ flex-wrap: wrap;
1279
+ justify-content: space-between;
1280
+ gap: 0.75rem;
1281
+ align-items: center;
1282
+ }
1283
+
1284
+ .files-toolbar h2 {
1285
+ margin: 0;
1286
+ }
1287
+
1288
+ .toolbar-count {
1289
+ color: var(--ink-dim);
1290
+ font-size: 0.8rem;
1291
+ letter-spacing: 0.04em;
1292
+ text-transform: uppercase;
1293
+ }
1294
+
1295
+ .toolbar-controls {
1296
+ display: grid;
1297
+ grid-template-columns: minmax(0, 1.3fr) auto;
1298
+ gap: 0.75rem;
1299
+ align-items: center;
1300
+ }
1301
+
1302
+ .search-input {
1303
+ width: 100%;
1304
+ min-height: 2.8rem;
1305
+ border-radius: 14px;
1306
+ border: 1px solid rgba(173, 214, 255, 0.18);
1307
+ background: rgba(4, 13, 20, 0.72);
1308
+ color: var(--ink);
1309
+ padding: 0.72rem 0.88rem;
1310
+ font: inherit;
1311
+ }
1312
+
1313
+ .search-input::placeholder {
1314
+ color: #7e96a7;
1315
+ }
1316
+
1317
+ .search-input:focus {
1318
+ outline: none;
1319
+ border-color: rgba(125, 211, 252, 0.48);
1320
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.16);
1321
+ }
1322
+
1323
+ .filter-row {
1324
+ display: flex;
1325
+ flex-wrap: wrap;
1326
+ gap: 0.5rem;
1327
+ }
1328
+
1329
+ .filter-chip.active {
1330
+ border-color: rgba(125, 211, 252, 0.42);
1331
+ background: rgba(10, 36, 54, 0.86);
1332
+ color: #ffffff;
1333
+ }
1334
+
1335
+ .action-grid {
1336
+ display: grid;
1337
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1338
+ gap: 0.75rem;
1339
+ }
1340
+
1341
+ .action-card {
1342
+ display: grid;
1343
+ gap: 0.55rem;
1344
+ padding: 0.85rem 0.9rem;
1345
+ border-radius: 14px;
1346
+ border: 1px solid rgba(173, 214, 255, 0.18);
1347
+ background:
1348
+ linear-gradient(180deg, rgba(5, 15, 23, 0.64), rgba(6, 18, 28, 0.42)),
1349
+ radial-gradient(circle at top right, rgba(125, 211, 252, 0.08), transparent 36%);
1350
+ transition:
1351
+ transform 220ms ease,
1352
+ border-color 220ms ease,
1353
+ box-shadow 220ms ease,
1354
+ background 220ms ease;
1355
+ }
1356
+
1357
+ .action-title {
1358
+ color: #f4f8fc;
1359
+ font-size: 0.92rem;
1360
+ font-weight: 700;
1361
+ letter-spacing: 0.02em;
1362
+ }
1363
+
1364
+ .action-description {
1365
+ margin: 0;
1366
+ color: var(--ink-dim);
1367
+ font-size: 0.82rem;
1368
+ }
1369
+
1370
+ .action-command {
1371
+ display: block;
1372
+ padding: 0.65rem 0.72rem;
1373
+ border-radius: 12px;
1374
+ border: 1px solid rgba(173, 214, 255, 0.14);
1375
+ background: rgba(4, 13, 20, 0.72);
1376
+ color: #a8e0ff;
1377
+ font-size: 0.76rem;
1378
+ line-height: 1.4;
1379
+ word-break: break-word;
1380
+ }
1381
+
1382
+ .copy-button {
1383
+ width: fit-content;
1384
+ min-height: 2rem;
1385
+ padding: 0.42rem 0.72rem;
1386
+ border-radius: 999px;
1387
+ border: 1px solid rgba(173, 214, 255, 0.18);
1388
+ background: rgba(8, 24, 35, 0.74);
1389
+ color: #ffffff;
1390
+ font: inherit;
1391
+ font-size: 0.76rem;
1392
+ font-weight: 700;
1393
+ letter-spacing: 0.06em;
1394
+ text-transform: uppercase;
1395
+ cursor: pointer;
1396
+ transition:
1397
+ transform 180ms ease,
1398
+ border-color 180ms ease,
1399
+ background 180ms ease,
1400
+ box-shadow 180ms ease;
1401
+ }
1402
+
1403
+ .copy-button:hover {
1404
+ border-color: rgba(125, 211, 252, 0.42);
1405
+ background: rgba(10, 36, 54, 0.9);
1406
+ }
1407
+
1408
+ .focus-list {
1409
+ display: grid;
1410
+ gap: 0.65rem;
1411
+ }
1412
+
1413
+ .focus-item,
1414
+ .clean-state {
1415
+ display: grid;
1416
+ gap: 0.34rem;
1417
+ padding: 0.82rem 0.88rem;
1418
+ border-radius: 14px;
1419
+ border: 1px solid rgba(173, 214, 255, 0.16);
1420
+ background: linear-gradient(180deg, rgba(5, 15, 23, 0.64), rgba(6, 18, 28, 0.38));
1421
+ transition:
1422
+ transform 220ms ease,
1423
+ border-color 220ms ease,
1424
+ box-shadow 220ms ease,
1425
+ background 220ms ease;
1426
+ }
1427
+
1428
+ .focus-item {
1429
+ border-color: rgba(239, 68, 68, 0.28);
1430
+ background: linear-gradient(180deg, rgba(39, 10, 13, 0.56), rgba(24, 11, 16, 0.28));
1431
+ }
1432
+
1433
+ .focus-head {
1434
+ display: flex;
1435
+ gap: 0.75rem;
1436
+ justify-content: space-between;
1437
+ align-items: baseline;
1438
+ }
1439
+
1440
+ .focus-name {
1441
+ color: #f8d0d0;
1442
+ font-size: 0.86rem;
1443
+ font-weight: 700;
1444
+ }
1445
+
1446
+ .focus-duration {
1447
+ color: #fca5a5;
1448
+ font-size: 0.76rem;
1449
+ font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, Consolas, monospace;
1450
+ }
1451
+
1452
+ .focus-file {
1453
+ color: #c7dceb;
1454
+ font-size: 0.75rem;
1455
+ font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, Consolas, monospace;
1456
+ word-break: break-all;
1457
+ }
1458
+
1459
+ .focus-message,
1460
+ .clean-state p {
1461
+ color: var(--ink-dim);
1462
+ font-size: 0.82rem;
1463
+ margin: 0;
1464
+ }
1465
+
1466
+ .clean-state strong {
1467
+ color: #c3f4d0;
1468
+ font-size: 0.92rem;
1469
+ }
1470
+
1471
+ .stability-grid {
1472
+ display: grid;
1473
+ gap: 0.45rem;
1474
+ grid-template-columns: repeat(5, minmax(0, 1fr));
1475
+ margin-bottom: 0.5rem;
1476
+ }
1477
+
1478
+ .chip {
1479
+ border-radius: 12px;
1480
+ border: 1px solid rgba(173, 214, 255, 0.24);
1481
+ background: rgba(6, 14, 23, 0.54);
1482
+ padding: 0.44rem 0.55rem;
1483
+ display: grid;
1484
+ gap: 0.16rem;
1485
+ }
1486
+
1487
+ .chip span {
1488
+ color: var(--ink-dim);
1489
+ font-size: 0.68rem;
1490
+ font-weight: 700;
1491
+ text-transform: uppercase;
1492
+ letter-spacing: 0.08em;
1493
+ }
1494
+
1495
+ .chip strong {
1496
+ font-size: 0.95rem;
1497
+ font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, Consolas, monospace;
1498
+ }
1499
+
1500
+ .chip.gate.stable strong { color: var(--pass); }
1501
+ .chip.gate.unstable strong { color: var(--fail); }
1502
+
1503
+ .stability-list {
1504
+ margin: 0;
1505
+ padding-left: 0;
1506
+ list-style: none;
1507
+ display: grid;
1508
+ gap: 0.34rem;
1509
+ }
1510
+
1511
+ .stability-list li {
1512
+ border-left: 3px solid rgba(248, 113, 113, 0.72);
1513
+ padding: 0.34rem 0.5rem;
1514
+ background: rgba(6, 12, 20, 0.55);
1515
+ border-radius: 8px;
1516
+ font-size: 0.82rem;
1517
+ }
1518
+
1519
+ .file-grid {
1520
+ display: grid;
1521
+ gap: 0.7rem;
1522
+ }
1523
+
1524
+ .file-panel {
1525
+ border-radius: 14px;
1526
+ border: 1px solid rgba(173, 214, 255, 0.22);
1527
+ background: rgba(5, 13, 20, 0.56);
1528
+ box-shadow: var(--shadow);
1529
+ padding: 0.8rem 0.9rem;
1530
+ animation: rise 720ms ease both;
1531
+ animation-delay: calc(32ms * var(--card-index));
1532
+ transition:
1533
+ transform 220ms ease,
1534
+ border-color 220ms ease,
1535
+ box-shadow 220ms ease,
1536
+ background 220ms ease;
1537
+ }
1538
+
1539
+ .file-panel.failed { border-color: rgba(239, 68, 68, 0.52); }
1540
+ .file-panel.passed { border-color: rgba(34, 197, 94, 0.45); }
1541
+ .file-panel.is-hidden { display: none; }
1542
+
1543
+ .file-header {
1544
+ display: grid;
1545
+ gap: 0.35rem;
1546
+ margin-bottom: 0.5rem;
1547
+ }
1548
+
1549
+ .file-path {
1550
+ font-size: 0.84rem;
1551
+ word-break: break-all;
1552
+ font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, Consolas, monospace;
1553
+ color: #c8dded;
1554
+ }
1555
+
1556
+ .file-meta {
1557
+ color: var(--ink-dim);
1558
+ font-size: 0.78rem;
1559
+ }
1560
+
1561
+ .file-pass {
1562
+ color: #9fc8ad;
1563
+ font-size: 0.82rem;
1564
+ }
1565
+
1566
+ .file-summary-grid {
1567
+ display: grid;
1568
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1569
+ gap: 0.55rem;
1570
+ margin-bottom: 0.7rem;
1571
+ }
1572
+
1573
+ .file-summary-chip {
1574
+ display: grid;
1575
+ gap: 0.18rem;
1576
+ padding: 0.52rem 0.58rem;
1577
+ border-radius: 12px;
1578
+ border: 1px solid rgba(173, 214, 255, 0.14);
1579
+ background: rgba(6, 16, 24, 0.38);
1580
+ transition:
1581
+ transform 180ms ease,
1582
+ border-color 180ms ease,
1583
+ background 180ms ease;
1584
+ }
1585
+
1586
+ .file-summary-chip span {
1587
+ color: var(--ink-dim);
1588
+ font-size: 0.66rem;
1589
+ font-weight: 700;
1590
+ letter-spacing: 0.08em;
1591
+ text-transform: uppercase;
1592
+ }
1593
+
1594
+ .file-summary-chip strong {
1595
+ color: #f4f8fc;
1596
+ font-size: 0.82rem;
1597
+ font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, Consolas, monospace;
1598
+ }
1599
+
1600
+ .failure-list {
1601
+ margin: 0;
1602
+ padding: 0;
1603
+ list-style: none;
1604
+ display: grid;
1605
+ gap: 0.46rem;
1606
+ }
1607
+
1608
+ .failure-entry {
1609
+ padding: 0.45rem 0.55rem;
1610
+ border-radius: 10px;
1611
+ background: rgba(47, 12, 12, 0.45);
1612
+ border: 1px solid rgba(252, 165, 165, 0.28);
1613
+ }
1614
+
1615
+ .failure-name {
1616
+ font-weight: 600;
1617
+ font-size: 0.82rem;
1618
+ margin-bottom: 0.2rem;
1619
+ }
1620
+
1621
+ .failure-line {
1622
+ font-size: 0.76rem;
1623
+ color: #f5b3b3;
1624
+ }
1625
+
1626
+ .file-details {
1627
+ margin-top: 0.78rem;
1628
+ border-top: 1px solid rgba(173, 214, 255, 0.12);
1629
+ padding-top: 0.78rem;
1630
+ }
1631
+
1632
+ .file-details summary {
1633
+ list-style: none;
1634
+ display: flex;
1635
+ align-items: center;
1636
+ justify-content: space-between;
1637
+ gap: 0.75rem;
1638
+ cursor: pointer;
1639
+ color: #e1eef9;
1640
+ font-size: 0.82rem;
1641
+ font-weight: 700;
1642
+ transition: color 180ms ease, transform 180ms ease;
1643
+ }
1644
+
1645
+ .file-details summary::-webkit-details-marker {
1646
+ display: none;
1647
+ }
1648
+
1649
+ .file-details summary span {
1650
+ color: #8bd7ff;
1651
+ font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, Consolas, monospace;
1652
+ font-size: 0.76rem;
1653
+ }
1654
+
1655
+ .test-list {
1656
+ margin: 0.75rem 0 0;
1657
+ padding: 0;
1658
+ list-style: none;
1659
+ display: grid;
1660
+ gap: 0.55rem;
1661
+ }
1662
+
1663
+ .test-item {
1664
+ display: grid;
1665
+ grid-template-columns: auto minmax(0, 1fr) auto;
1666
+ gap: 0.65rem;
1667
+ align-items: start;
1668
+ padding: 0.62rem 0.66rem;
1669
+ border-radius: 12px;
1670
+ border: 1px solid rgba(173, 214, 255, 0.12);
1671
+ background: rgba(6, 14, 23, 0.48);
1672
+ transition:
1673
+ transform 180ms ease,
1674
+ border-color 180ms ease,
1675
+ background 180ms ease,
1676
+ box-shadow 180ms ease;
1677
+ }
1678
+
1679
+ .test-status {
1680
+ min-width: 4.25rem;
1681
+ padding: 0.26rem 0.42rem;
1682
+ border-radius: 999px;
1683
+ text-align: center;
1684
+ font-size: 0.68rem;
1685
+ font-weight: 700;
1686
+ letter-spacing: 0.08em;
1687
+ text-transform: uppercase;
1688
+ border: 1px solid transparent;
1689
+ }
1690
+
1691
+ .test-status.pass {
1692
+ color: #baf7ce;
1693
+ border-color: rgba(34, 197, 94, 0.24);
1694
+ background: rgba(14, 48, 28, 0.38);
1695
+ }
1696
+
1697
+ .test-status.fail {
1698
+ color: #fecaca;
1699
+ border-color: rgba(239, 68, 68, 0.24);
1700
+ background: rgba(60, 18, 18, 0.36);
1701
+ }
1702
+
1703
+ .test-status.skip {
1704
+ color: #fde68a;
1705
+ border-color: rgba(245, 158, 11, 0.22);
1706
+ background: rgba(58, 35, 8, 0.34);
1707
+ }
1708
+
1709
+ .test-main {
1710
+ display: grid;
1711
+ gap: 0.28rem;
1712
+ min-width: 0;
1713
+ }
1714
+
1715
+ .test-name {
1716
+ color: #e6f0f9;
1717
+ font-size: 0.8rem;
1718
+ font-weight: 600;
1719
+ word-break: break-word;
1720
+ }
1721
+
1722
+ .test-duration {
1723
+ color: #8bd7ff;
1724
+ font-size: 0.74rem;
1725
+ font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, Consolas, monospace;
1726
+ white-space: nowrap;
1727
+ }
1728
+
1729
+ .test-error {
1730
+ display: grid;
1731
+ gap: 0.16rem;
1732
+ }
1733
+
1734
+ .test-error-line {
1735
+ color: #f1b5b5;
1736
+ font-size: 0.74rem;
1737
+ }
1738
+
1739
+ .cluster-list,
1740
+ .slow-list {
1741
+ display: grid;
1742
+ gap: 0.45rem;
1743
+ }
1744
+
1745
+ .cluster-item,
1746
+ .slow-item {
1747
+ display: grid;
1748
+ gap: 0.3rem;
1749
+ align-items: center;
1750
+ border-radius: 10px;
1751
+ border: 1px solid rgba(173, 214, 255, 0.2);
1752
+ background: rgba(6, 12, 18, 0.56);
1753
+ padding: 0.5rem 0.56rem;
1754
+ transition:
1755
+ transform 180ms ease,
1756
+ border-color 180ms ease,
1757
+ background 180ms ease,
1758
+ box-shadow 180ms ease;
1759
+ }
1760
+
1761
+ .cluster-item {
1762
+ grid-template-columns: auto 1fr auto;
1763
+ }
1764
+
1765
+ .cluster-fingerprint {
1766
+ font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, Consolas, monospace;
1767
+ color: #fecaca;
1768
+ font-size: 0.72rem;
1769
+ }
1770
+
1771
+ .cluster-message {
1772
+ font-size: 0.82rem;
1773
+ }
1774
+
1775
+ .cluster-count {
1776
+ font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, Consolas, monospace;
1777
+ color: #fca5a5;
1778
+ font-size: 0.78rem;
1779
+ }
1780
+
1781
+ .slow-item {
1782
+ grid-template-columns: auto 1fr;
1783
+ }
1784
+
1785
+ .slow-time {
1786
+ font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, Consolas, monospace;
1787
+ color: #7dd3fc;
1788
+ font-size: 0.78rem;
1789
+ min-width: 5.8rem;
1790
+ }
1791
+
1792
+ .slow-name {
1793
+ font-size: 0.82rem;
1794
+ color: #d9e7f3;
1795
+ }
1796
+
1797
+ .footer {
1798
+ color: var(--ink-dim);
1799
+ font-size: 0.75rem;
1800
+ text-align: right;
1801
+ font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, Consolas, monospace;
1802
+ padding: 0.2rem 0.15rem;
1803
+ }
1804
+
1805
+ @media (hover: hover) {
1806
+ .hero-story:hover,
1807
+ .hero-side:hover,
1808
+ .stat-card:hover,
1809
+ .panel:hover,
1810
+ .action-card:hover,
1811
+ .focus-item:hover,
1812
+ .clean-state:hover,
1813
+ .file-panel:hover {
1814
+ transform: translateY(-3px);
1815
+ border-color: rgba(125, 211, 252, 0.28);
1816
+ box-shadow: 0 28px 54px rgba(2, 7, 12, 0.42);
1817
+ }
1818
+
1819
+ .hero-story:hover {
1820
+ background: linear-gradient(180deg, rgba(7, 18, 28, 0.72), rgba(6, 20, 31, 0.42));
1821
+ }
1822
+
1823
+ .hero-story:hover .hero-story-art img {
1824
+ transform: scale(1.03) translateY(-4px);
1825
+ filter:
1826
+ drop-shadow(0 34px 42px rgba(0, 0, 0, 0.38))
1827
+ drop-shadow(0 0 34px rgba(125, 211, 252, 0.18));
1828
+ }
1829
+
1830
+ .hero-side:hover .meta-chip,
1831
+ .file-panel:hover .file-summary-chip {
1832
+ border-color: rgba(125, 211, 252, 0.22);
1833
+ background: rgba(8, 22, 33, 0.56);
1834
+ }
1835
+
1836
+ .hero-stat:hover,
1837
+ .meta-chip:hover,
1838
+ .file-summary-chip:hover,
1839
+ .test-item:hover,
1840
+ .cluster-item:hover,
1841
+ .slow-item:hover {
1842
+ transform: translateY(-2px);
1843
+ border-color: rgba(125, 211, 252, 0.26);
1844
+ background: rgba(8, 24, 35, 0.68);
1845
+ box-shadow: 0 14px 28px rgba(2, 7, 12, 0.24);
1846
+ }
1847
+
1848
+ .section-link:hover,
1849
+ .filter-chip:hover,
1850
+ .copy-button:hover,
1851
+ .file-details summary:hover {
1852
+ transform: translateY(-1px);
1853
+ }
1854
+
1855
+ .section-link:hover,
1856
+ .filter-chip:hover {
1857
+ box-shadow: 0 10px 22px rgba(2, 7, 12, 0.22);
1858
+ }
1859
+
1860
+ .copy-button:hover {
1861
+ box-shadow: 0 12px 24px rgba(2, 7, 12, 0.24);
1862
+ }
1863
+
1864
+ .file-details summary:hover {
1865
+ color: #ffffff;
1866
+ }
1867
+ }
1868
+
1869
+ @keyframes rise {
1870
+ from {
1871
+ opacity: 0;
1872
+ transform: translateY(8px);
1873
+ }
1874
+ to {
1875
+ opacity: 1;
1876
+ transform: translateY(0);
1877
+ }
1878
+ }
1879
+
1880
+ @media (max-width: 980px) {
1881
+ .hero-main { grid-template-columns: 1fr; }
1882
+ .hero-story {
1883
+ min-height: 16rem;
1884
+ padding-right: clamp(13rem, 38%, 20rem);
1885
+ }
1886
+ .hero-story-copy { max-width: 30rem; }
1887
+ .meta { grid-template-columns: repeat(2, minmax(0, 1fr)); }
1888
+ .triage-grid { grid-template-columns: 1fr; }
1889
+ .action-grid { grid-template-columns: 1fr; }
1890
+ .toolbar-controls { grid-template-columns: 1fr; }
1891
+ .insights-grid { grid-template-columns: 1fr; }
1892
+ .file-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
1893
+ .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
1894
+ .stability-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
1895
+ .chip.gate { grid-column: span 2; }
1896
+ }
1897
+
1898
+ @media (max-width: 620px) {
1899
+ .wrap { width: min(1100px, 94vw); margin-top: 1rem; }
1900
+ .hero { padding: 1rem; }
1901
+ .hero-top { flex-direction: column; align-items: stretch; }
1902
+ .hero-status { width: fit-content; }
1903
+ .hero-main { gap: 1rem; }
1904
+ .hero-brand-copy { gap: 0.34rem; }
1905
+ .hero-story {
1906
+ min-height: 0;
1907
+ padding: 0.9rem;
1908
+ }
1909
+ .hero-story-art {
1910
+ inset: 0.7rem 0.7rem auto auto;
1911
+ width: 9rem;
1912
+ height: 9rem;
1913
+ }
1914
+ .hero-story-copy { max-width: none; }
1915
+ .hero-copy h1 { font-size: clamp(1.5rem, 10vw, 2.3rem); max-width: none; }
1916
+ .meta { grid-template-columns: 1fr; }
1917
+ .meta-chip { min-height: auto; }
1918
+ .hero-statline { grid-template-columns: repeat(2, minmax(0, 1fr)); }
1919
+ .file-summary-grid { grid-template-columns: 1fr 1fr; }
1920
+ .test-item { grid-template-columns: 1fr; }
1921
+ .test-duration { white-space: normal; }
1922
+ .meta-chip { padding: 0.56rem 0.64rem; }
1923
+ .cluster-item { grid-template-columns: 1fr; }
1924
+ .slow-item { grid-template-columns: 1fr; }
1925
+ .slow-time { min-width: auto; }
1926
+ body::before { background-position: center top; opacity: 0.54; }
1927
+ }
1928
+ </style>
1929
+ </head>
1930
+ <body>
1931
+ <main class="wrap">
1932
+ <section class="hero">
1933
+ <div class="hero-top">
1934
+ <div class="hero-kicker">THEMIS TEST REPORT</div>
1935
+ <div class="hero-status ${runStatus === 'PASSED' ? 'passed' : 'failed'}">${escapeHtml(statusLabel)}</div>
1936
+ </div>
1937
+ <div class="hero-main">
1938
+ <div class="hero-copy">
1939
+ <div class="hero-story">
1940
+ ${heroReportArt}
1941
+ <div class="hero-story-copy">
1942
+ <div class="hero-brand-copy">
1943
+ <p class="eyebrow">Verdict Engine</p>
1944
+ <p class="hero-wordmark">Themis <span>${escapeHtml(statusLabel)}</span></p>
1945
+ </div>
1946
+ <h1>${escapeHtml(statusHeadline)}</h1>
1947
+ <p class="hero-summary">${escapeHtml(statusSummary)}</p>
1948
+ <p class="hero-brand-summary">High-signal test reporting designed for fast triage, timing review, and failure navigation.</p>
1949
+ </div>
1950
+ </div>
1951
+ <div class="hero-statline">
1952
+ <div class="hero-stat"><strong>${escapeHtml(String(summary.passed || 0))}</strong>passed</div>
1953
+ <div class="hero-stat"><strong>${escapeHtml(String(summary.failed || 0))}</strong>failed</div>
1954
+ <div class="hero-stat"><strong>${escapeHtml(String(summary.skipped || 0))}</strong>skipped</div>
1955
+ <div class="hero-stat"><strong>${escapeHtml(String(summary.total || 0))}</strong>total</div>
1956
+ </div>
1957
+ <div class="hero-side">
1958
+ <p class="hero-side-label">Run Overview</p>
1959
+ <div class="meta">
1960
+ <div class="meta-chip"><strong>Started</strong><span>${escapeHtml(meta.startedAt || 'n/a')}</span></div>
1961
+ <div class="meta-chip"><strong>Finished</strong><span>${escapeHtml(meta.finishedAt || 'n/a')}</span></div>
1962
+ <div class="meta-chip"><strong>Workers</strong><span>${escapeHtml(String(meta.maxWorkers || 'n/a'))}</span></div>
1963
+ <div class="meta-chip"><strong>Duration</strong><span>${escapeHtml(formatMs(summary.durationMs || 0))}</span></div>
1964
+ </div>
1965
+ </div>
1966
+ </div>
1967
+ </div>
1968
+ </section>
1969
+
1970
+ <section class="stats" id="summary">
1971
+ ${summaryCards}
1972
+ </section>
1973
+
1974
+ <nav class="section-nav" aria-label="Report sections">
1975
+ <a class="section-link" href="#summary">Summary</a>
1976
+ <a class="section-link" href="#triage">Triage</a>
1977
+ ${stabilityPanel ? '<a class="section-link" href="#stability">Stability</a>' : ''}
1978
+ ${failureClusterPanel ? '<a class="section-link" href="#clusters">Clusters</a>' : ''}
1979
+ ${slowestPanel ? '<a class="section-link" href="#slowest">Slowest</a>' : ''}
1980
+ <a class="section-link" href="#files">Files</a>
1981
+ </nav>
1982
+
1983
+ <section class="triage-grid" id="triage">
1984
+ ${triageGrid}
1985
+ </section>
1986
+
1987
+ ${insightsGrid ? `<section class="insights-grid">${insightsGrid}</section>` : ''}
1988
+
1989
+ <section class="files-toolbar" id="files">
1990
+ <div class="files-toolbar-top">
1991
+ <h2>Files</h2>
1992
+ <div class="toolbar-count"><span data-files-count>${escapeHtml(String(files.length))}</span> shown</div>
1993
+ </div>
1994
+ ${hasInteractiveFilters ? `
1995
+ <div class="toolbar-controls">
1996
+ <input class="search-input" type="search" placeholder="Search files, tests, or failure text" aria-label="Search files" data-file-search-input>
1997
+ <div class="filter-row" role="group" aria-label="Filter file results">
1998
+ <button class="filter-chip active" type="button" data-file-filter="all">All</button>
1999
+ <button class="filter-chip" type="button" data-file-filter="failed">Failed</button>
2000
+ <button class="filter-chip" type="button" data-file-filter="passed">Passed</button>
2001
+ <button class="filter-chip" type="button" data-file-filter="skipped">Skipped</button>
2002
+ </div>
2003
+ </div>` : ''}
2004
+ </section>
2005
+
2006
+ <section class="panel">
2007
+ <div class="file-grid">
2008
+ ${filePanels || '<article class="file-panel"><div class="file-pass">No files executed.</div></article>'}
2009
+ </div>
2010
+ </section>
2011
+
2012
+ <div class="footer">generated ${escapeHtml(generatedAt)} | failures ${escapeHtml(String(failures.length))}</div>
2013
+ </main>
2014
+ <script>
2015
+ (() => {
2016
+ const cards = Array.from(document.querySelectorAll('[data-file-status]'));
2017
+ const searchInput = document.querySelector('[data-file-search-input]');
2018
+ const buttons = Array.from(document.querySelectorAll('[data-file-filter]'));
2019
+ const count = document.querySelector('[data-files-count]');
2020
+ const copyButtons = Array.from(document.querySelectorAll('[data-copy-text]'));
2021
+ let activeFilter = 'all';
2022
+
2023
+ const applyFilters = () => {
2024
+ const query = (searchInput?.value || '').trim().toLowerCase();
2025
+ let visible = 0;
2026
+
2027
+ cards.forEach((card) => {
2028
+ const status = card.getAttribute('data-file-status') || '';
2029
+ const haystack = card.getAttribute('data-file-search') || '';
2030
+ const matchesFilter = activeFilter === 'all' || status === activeFilter;
2031
+ const matchesQuery = !query || haystack.includes(query);
2032
+ const show = matchesFilter && matchesQuery;
2033
+ card.classList.toggle('is-hidden', !show);
2034
+ if (show) visible += 1;
2035
+ });
2036
+
2037
+ if (count) count.textContent = String(visible);
2038
+ };
2039
+
2040
+ buttons.forEach((button) => {
2041
+ button.addEventListener('click', () => {
2042
+ activeFilter = button.getAttribute('data-file-filter') || 'all';
2043
+ buttons.forEach((candidate) => candidate.classList.toggle('active', candidate === button));
2044
+ applyFilters();
2045
+ });
2046
+ });
2047
+
2048
+ searchInput?.addEventListener('input', applyFilters);
2049
+
2050
+ copyButtons.forEach((button) => {
2051
+ button.addEventListener('click', async () => {
2052
+ const text = button.getAttribute('data-copy-text') || '';
2053
+ const original = button.textContent || 'Copy';
2054
+ try {
2055
+ if (navigator.clipboard && text) {
2056
+ await navigator.clipboard.writeText(text);
2057
+ button.textContent = 'Copied';
2058
+ } else {
2059
+ button.textContent = 'Copy N/A';
2060
+ }
2061
+ } catch (error) {
2062
+ button.textContent = 'Copy N/A';
2063
+ }
2064
+ window.setTimeout(() => {
2065
+ button.textContent = original;
2066
+ }, 1400);
2067
+ });
2068
+ });
2069
+
2070
+ applyFilters();
2071
+ })();
2072
+ </script>
2073
+ </body>
2074
+ </html>
2075
+ `;
2076
+ }
2077
+
2078
+ function renderStabilityHighlights(stability) {
2079
+ if (!stability || !Array.isArray(stability.tests)) {
2080
+ return '';
2081
+ }
2082
+ const highlights = stability.tests
2083
+ .filter((entry) => entry.classification !== 'stable_pass')
2084
+ .slice(0, 8);
2085
+
2086
+ if (highlights.length === 0) {
2087
+ return '<ul class="stability-list"><li>All tracked tests remained stable across runs.</li></ul>';
2088
+ }
2089
+
2090
+ return (
2091
+ '<ul class="stability-list">' +
2092
+ highlights.map((entry) => {
2093
+ return `<li><strong>${escapeHtml(entry.classification.toUpperCase())}</strong> ${escapeHtml(entry.fullName)} <span>${escapeHtml(`[${entry.statuses.join(' -> ')}]`)}</span></li>`;
2094
+ }).join('') +
2095
+ '</ul>'
2096
+ );
2097
+ }
2098
+
2099
+ function ensureHtmlBackgroundAsset(outputPath, backgroundAssetPath) {
2100
+ const sourcePath = backgroundAssetPath || path.join(__dirname, 'assets', 'themisBg.png');
2101
+ return ensureHtmlAsset(outputPath, sourcePath, 'themis-bg');
2102
+ }
2103
+
2104
+ function ensureHtmlReportAsset(outputPath, reportAssetPath) {
2105
+ const sourcePath = reportAssetPath || path.join(__dirname, 'assets', 'themisReport.png');
2106
+ return ensureHtmlAsset(outputPath, sourcePath, 'themis-report');
2107
+ }
2108
+
2109
+ function ensureHtmlAsset(outputPath, sourcePath, name) {
2110
+ if (!fs.existsSync(sourcePath)) {
2111
+ return null;
2112
+ }
2113
+
2114
+ const outputDir = path.dirname(outputPath);
2115
+ const extension = path.extname(sourcePath) || '.png';
2116
+ const destinationPath = path.join(outputDir, `${name}${extension}`);
2117
+ fs.mkdirSync(outputDir, { recursive: true });
2118
+ fs.copyFileSync(sourcePath, destinationPath);
2119
+ return path.basename(destinationPath);
2120
+ }
2121
+
2122
+ function escapeCssUrl(value) {
2123
+ return String(value).replace(/"/g, '\\"');
2124
+ }
2125
+
2126
+ function escapeHtml(value) {
2127
+ return String(value)
2128
+ .replace(/&/g, '&amp;')
2129
+ .replace(/</g, '&lt;')
2130
+ .replace(/>/g, '&gt;')
2131
+ .replace(/"/g, '&quot;')
2132
+ .replace(/'/g, '&#39;');
2133
+ }
2134
+
2135
+ module.exports = {
2136
+ printSpec,
2137
+ printJson,
2138
+ printAgent,
2139
+ printNext,
2140
+ writeHtmlReport
2141
+ };