executable-stories-formatters 0.7.4 → 0.7.6

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/dist/index.cjs CHANGED
@@ -65,6 +65,7 @@ __export(src_exports, {
65
65
  generateRunId: () => generateRunId,
66
66
  generateTestCaseId: () => generateTestCaseId,
67
67
  getAvailableThemes: () => getAvailableThemes,
68
+ getCssOnlyThemes: () => getCssOnlyThemes,
68
69
  hasSufficientHistory: () => hasSufficientHistory,
69
70
  listScenarios: () => listScenarios,
70
71
  loadHistory: () => loadHistory,
@@ -952,6 +953,7 @@ function applyAllFilters() {
952
953
  });
953
954
 
954
955
  updateFilterResults(visibleCount, totalCount);
956
+ syncTocVisibility();
955
957
  writeUrlState();
956
958
  }
957
959
 
@@ -968,13 +970,135 @@ function updateFilterResults(visible, total) {
968
970
  if (tc) tc.textContent = total;
969
971
  }
970
972
 
971
- // Keyboard shortcuts
973
+ // Keyboard navigation
974
+ var focusedScenarioIndex = -1;
975
+
976
+ function getVisibleScenarios() {
977
+ return Array.from(document.querySelectorAll('.scenario')).filter(function(s) {
978
+ return s.style.display !== 'none' && s.closest('.feature').style.display !== 'none';
979
+ });
980
+ }
981
+
982
+ function focusScenario(index) {
983
+ var scenarios = getVisibleScenarios();
984
+ if (scenarios.length === 0) return;
985
+
986
+ // Remove previous focus
987
+ var prev = document.querySelector('.scenario-focused');
988
+ if (prev) prev.classList.remove('scenario-focused');
989
+
990
+ // Wrap around
991
+ if (index < 0) index = scenarios.length - 1;
992
+ if (index >= scenarios.length) index = 0;
993
+ focusedScenarioIndex = index;
994
+
995
+ var scenario = scenarios[index];
996
+ scenario.classList.add('scenario-focused');
997
+ scenario.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
998
+ }
999
+
1000
+ function showShortcutsOverlay() {
1001
+ if (document.querySelector('.shortcuts-overlay')) return;
1002
+ var overlay = document.createElement('div');
1003
+ overlay.className = 'shortcuts-overlay';
1004
+ overlay.innerHTML = '<div class="shortcuts-modal">' +
1005
+ '<div class="shortcuts-title">Keyboard Shortcuts</div>' +
1006
+ '<div class="shortcuts-grid">' +
1007
+ '<kbd>j</kbd><span>Next scenario</span>' +
1008
+ '<kbd>k</kbd><span>Previous scenario</span>' +
1009
+ '<kbd>Enter</kbd><span>Expand/collapse scenario</span>' +
1010
+ '<kbd>Escape</kbd><span>Collapse scenario / close</span>' +
1011
+ '<kbd>/</kbd><span>Focus search</span>' +
1012
+ '<kbd>?</kbd><span>Toggle this help</span>' +
1013
+ '<kbd>e</kbd><span>Expand all</span>' +
1014
+ '<kbd>c</kbd><span>Collapse all</span>' +
1015
+ '<kbd>t</kbd><span>Toggle table of contents</span>' +
1016
+ '</div></div>';
1017
+ overlay.addEventListener('click', function(ev) {
1018
+ if (ev.target === overlay) hideShortcutsOverlay();
1019
+ });
1020
+ document.body.appendChild(overlay);
1021
+ }
1022
+
1023
+ function hideShortcutsOverlay() {
1024
+ var overlay = document.querySelector('.shortcuts-overlay');
1025
+ if (overlay) overlay.remove();
1026
+ }
1027
+
972
1028
  function initKeyboardShortcuts() {
973
1029
  document.addEventListener('keydown', function(e) {
974
- if (e.key === '/' && !e.ctrlKey && !e.metaKey && e.target.tagName !== 'INPUT') {
975
- e.preventDefault();
976
- var input = document.querySelector('.search-input');
977
- if (input) input.focus();
1030
+ var tag = e.target.tagName;
1031
+ if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') {
1032
+ if (e.key === 'Escape') {
1033
+ e.target.blur();
1034
+ if (e.target.classList.contains('search-input')) {
1035
+ e.target.value = '';
1036
+ applyAllFilters();
1037
+ }
1038
+ }
1039
+ return;
1040
+ }
1041
+
1042
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
1043
+
1044
+ switch (e.key) {
1045
+ case 'j':
1046
+ e.preventDefault();
1047
+ focusScenario(focusedScenarioIndex + 1);
1048
+ break;
1049
+ case 'k':
1050
+ e.preventDefault();
1051
+ focusScenario(focusedScenarioIndex - 1);
1052
+ break;
1053
+ case 'Enter':
1054
+ e.preventDefault();
1055
+ var scenarios = getVisibleScenarios();
1056
+ if (focusedScenarioIndex >= 0 && focusedScenarioIndex < scenarios.length) {
1057
+ var s = scenarios[focusedScenarioIndex];
1058
+ var h = s.querySelector('.scenario-header');
1059
+ if (h) toggleCollapse(h, s);
1060
+ }
1061
+ break;
1062
+ case 'Escape':
1063
+ if (document.querySelector('.shortcuts-overlay')) {
1064
+ hideShortcutsOverlay();
1065
+ } else {
1066
+ var scenarios2 = getVisibleScenarios();
1067
+ if (focusedScenarioIndex >= 0 && focusedScenarioIndex < scenarios2.length) {
1068
+ var sc = scenarios2[focusedScenarioIndex];
1069
+ if (!sc.classList.contains('collapsed')) {
1070
+ sc.classList.add('collapsed');
1071
+ var sh = sc.querySelector('.scenario-header');
1072
+ if (sh) sh.setAttribute('aria-expanded', 'false');
1073
+ }
1074
+ }
1075
+ }
1076
+ break;
1077
+ case '/':
1078
+ e.preventDefault();
1079
+ var input = document.querySelector('.search-input');
1080
+ if (input) input.focus();
1081
+ break;
1082
+ case '?':
1083
+ e.preventDefault();
1084
+ if (document.querySelector('.shortcuts-overlay')) {
1085
+ hideShortcutsOverlay();
1086
+ } else {
1087
+ showShortcutsOverlay();
1088
+ }
1089
+ break;
1090
+ case 'e':
1091
+ e.preventDefault();
1092
+ expandAll();
1093
+ break;
1094
+ case 'c':
1095
+ e.preventDefault();
1096
+ collapseAll();
1097
+ break;
1098
+ case 't':
1099
+ e.preventDefault();
1100
+ if (typeof toggleToc === 'function') toggleToc();
1101
+ break;
978
1102
  }
979
1103
  });
980
1104
  }
@@ -1112,6 +1236,189 @@ function writeUrlState() {
1112
1236
  var url = window.location.pathname + (qs ? '?' + qs : '');
1113
1237
  history.replaceState(null, '', url);
1114
1238
  }
1239
+
1240
+ // Permalink copy
1241
+ function copyPermalink(anchorId) {
1242
+ var url = location.origin + location.pathname + location.search + '#' + anchorId;
1243
+ navigator.clipboard.writeText(url).then(function() {
1244
+ var el = document.getElementById(anchorId);
1245
+ if (el) showCopyToast(el);
1246
+ });
1247
+ }
1248
+
1249
+ function showCopyToast(el) {
1250
+ var existing = el.querySelector('.copy-toast');
1251
+ if (existing) existing.remove();
1252
+ var toast = document.createElement('span');
1253
+ toast.className = 'copy-toast';
1254
+ toast.textContent = 'Copied!';
1255
+ var header = el.querySelector('.feature-header, .scenario-header');
1256
+ if (header) {
1257
+ header.style.position = 'relative';
1258
+ header.appendChild(toast);
1259
+ }
1260
+ setTimeout(function() { toast.remove(); }, 1500);
1261
+ }
1262
+
1263
+ // Copy scenario as markdown
1264
+ function copyScenarioAsMarkdown(scenarioId) {
1265
+ var scenario = document.getElementById(scenarioId);
1266
+ if (!scenario) return;
1267
+
1268
+ var title = (scenario.querySelector('.scenario-name') || {}).textContent || '';
1269
+ var steps = scenario.querySelectorAll('.step, .step.continuation');
1270
+ var lines = ['### Scenario: ' + title.trim(), ''];
1271
+
1272
+ steps.forEach(function(step) {
1273
+ var keyword = step.getAttribute('data-keyword') || '';
1274
+ var text = step.getAttribute('data-text') || '';
1275
+ lines.push('- **' + keyword + '** ' + text);
1276
+ });
1277
+
1278
+ var errorBox = scenario.querySelector('.error-message');
1279
+ if (errorBox) {
1280
+ var errorText = errorBox.textContent || '';
1281
+ lines.push('');
1282
+ lines.push('> **Error:** ' + errorText.trim());
1283
+ }
1284
+
1285
+ var md = lines.join('\\n');
1286
+ navigator.clipboard.writeText(md).then(function() {
1287
+ showCopyToast(scenario);
1288
+ });
1289
+ }
1290
+
1291
+ // Hash scroll on load
1292
+ function initHashScroll() {
1293
+ if (!location.hash) return;
1294
+ var target = document.querySelector(location.hash);
1295
+ if (!target) return;
1296
+ var feature = target.closest('.feature');
1297
+ if (feature && feature.classList.contains('collapsed')) {
1298
+ feature.classList.remove('collapsed');
1299
+ var fh = feature.querySelector('.feature-header');
1300
+ if (fh) fh.setAttribute('aria-expanded', 'true');
1301
+ }
1302
+ if (target.classList.contains('collapsed')) {
1303
+ target.classList.remove('collapsed');
1304
+ var sh = target.querySelector('.scenario-header');
1305
+ if (sh) sh.setAttribute('aria-expanded', 'true');
1306
+ }
1307
+ setTimeout(function() {
1308
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' });
1309
+ target.classList.add('hash-highlight');
1310
+ }, 100);
1311
+ }
1312
+
1313
+ // Table of contents
1314
+ function toggleToc() {
1315
+ var sidebar = document.querySelector('.toc-sidebar');
1316
+ var wrapper = document.querySelector('.report-layout');
1317
+ if (!sidebar || !wrapper) return;
1318
+ var isMobile = window.matchMedia('(max-width: 767px)').matches;
1319
+ if (isMobile) {
1320
+ sidebar.classList.toggle('toc-mobile-open');
1321
+ } else {
1322
+ wrapper.classList.toggle('toc-hidden');
1323
+ var hidden = wrapper.classList.contains('toc-hidden');
1324
+ localStorage.setItem('toc-visible', String(!hidden));
1325
+ }
1326
+ }
1327
+
1328
+ function initToc() {
1329
+ var sidebar = document.querySelector('.toc-sidebar');
1330
+ if (!sidebar) return;
1331
+
1332
+ var saved = localStorage.getItem('toc-visible');
1333
+ var wrapper = document.querySelector('.report-layout');
1334
+ if (saved === 'false' && wrapper) {
1335
+ wrapper.classList.add('toc-hidden');
1336
+ }
1337
+
1338
+ // Active tracking via IntersectionObserver
1339
+ var observer = new IntersectionObserver(function(entries) {
1340
+ entries.forEach(function(entry) {
1341
+ if (entry.isIntersecting) {
1342
+ var id = entry.target.id;
1343
+ if (!id) return;
1344
+ document.querySelectorAll('.toc-scenario, .toc-feature-toggle').forEach(function(el) {
1345
+ el.classList.remove('toc-active');
1346
+ });
1347
+ var tocLink = sidebar.querySelector('a[href="#' + id + '"]');
1348
+ if (tocLink) tocLink.classList.add('toc-active');
1349
+ }
1350
+ });
1351
+ }, { rootMargin: '-10% 0px -80% 0px' });
1352
+
1353
+ document.querySelectorAll('.feature, .scenario').forEach(function(el) {
1354
+ if (el.id) observer.observe(el);
1355
+ });
1356
+
1357
+ // Click navigation: expand collapsed parents
1358
+ sidebar.querySelectorAll('.toc-scenario').forEach(function(link) {
1359
+ link.addEventListener('click', function(e) {
1360
+ var hash = link.getAttribute('href');
1361
+ if (!hash) return;
1362
+ var target = document.querySelector(hash);
1363
+ if (!target) return;
1364
+ var feature = target.closest('.feature');
1365
+ if (feature && feature.classList.contains('collapsed')) {
1366
+ feature.classList.remove('collapsed');
1367
+ var fh = feature.querySelector('.feature-header');
1368
+ if (fh) fh.setAttribute('aria-expanded', 'true');
1369
+ }
1370
+ if (target.classList.contains('collapsed')) {
1371
+ target.classList.remove('collapsed');
1372
+ var sh = target.querySelector('.scenario-header');
1373
+ if (sh) sh.setAttribute('aria-expanded', 'true');
1374
+ }
1375
+ });
1376
+ });
1377
+ }
1378
+
1379
+ // Theme picker
1380
+ function initThemePicker() {
1381
+ var picker = document.querySelector('.theme-picker');
1382
+ if (!picker) return;
1383
+
1384
+ var saved = localStorage.getItem('report-theme');
1385
+ if (saved) {
1386
+ picker.value = saved;
1387
+ switchReportTheme(saved);
1388
+ }
1389
+
1390
+ picker.addEventListener('change', function(e) {
1391
+ switchReportTheme(e.target.value);
1392
+ localStorage.setItem('report-theme', e.target.value);
1393
+ });
1394
+ }
1395
+
1396
+ function switchReportTheme(name) {
1397
+ document.querySelectorAll('style[data-theme-name]').forEach(function(s) {
1398
+ s.disabled = s.dataset.themeName !== name;
1399
+ });
1400
+ }
1401
+
1402
+ // Sync TOC visibility with filters
1403
+ function syncTocVisibility() {
1404
+ var sidebar = document.querySelector('.toc-sidebar');
1405
+ if (!sidebar) return;
1406
+
1407
+ sidebar.querySelectorAll('.toc-scenario').forEach(function(link) {
1408
+ var href = link.getAttribute('href');
1409
+ if (!href) return;
1410
+ var target = document.querySelector(href);
1411
+ link.style.display = (target && target.style.display !== 'none') ? '' : 'none';
1412
+ });
1413
+
1414
+ sidebar.querySelectorAll('.toc-feature').forEach(function(feature) {
1415
+ var visibleScenarios = feature.querySelectorAll('.toc-scenario');
1416
+ var anyVisible = Array.from(visibleScenarios).some(function(s) {
1417
+ return s.style.display !== 'none';
1418
+ });
1419
+ feature.style.display = anyVisible ? '' : 'none';
1420
+ });
1421
+ }
1115
1422
  `;
1116
1423
  var JS_MARKDOWN_FN = `
1117
1424
  function parseMarkdownSections(marked) {
@@ -1152,6 +1459,9 @@ function generateScript(options) {
1152
1459
  initCalls.push("initCollapse();");
1153
1460
  initCalls.push("initDetailLevel();");
1154
1461
  initCalls.push("applyAllFilters();");
1462
+ initCalls.push("initHashScroll();");
1463
+ initCalls.push("initToc();");
1464
+ initCalls.push("initThemePicker();");
1155
1465
  const initScript = `
1156
1466
  // Initialize on load
1157
1467
  document.addEventListener('DOMContentLoaded', () => {
@@ -1213,6 +1523,7 @@ function generateHtmlTemplate(title, styles, body, options = {}) {
1213
1523
  }
1214
1524
  const cdnStylesHtml = cdnStyles.length > 0 ? "\n " + cdnStyles.join("\n ") : "";
1215
1525
  const esmScriptHtml = generateEsmScript(options);
1526
+ const additionalThemeStyles = (options.additionalThemeCss ?? []).map((t) => `<style data-theme-name="${escapeHtml(t.name)}" disabled>${t.css}</style>`).join("\n ");
1216
1527
  return `<!DOCTYPE html>
1217
1528
  <html lang="en"${themeAttr} data-detail-level="full">
1218
1529
  <head>
@@ -1220,19 +1531,27 @@ function generateHtmlTemplate(title, styles, body, options = {}) {
1220
1531
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1221
1532
  <meta name="color-scheme" content="light dark">
1222
1533
  <title>${escapeHtml(title)}</title>${cdnStylesHtml}
1223
- <style>${styles}</style>
1534
+ <style${options.additionalThemeCss ? ` data-theme-name="${escapeHtml(options.activeThemeName ?? "default")}"` : ""}>${styles}</style>
1535
+ ${additionalThemeStyles}
1224
1536
  </head>
1225
1537
  <body>
1226
- <div class="container">
1227
- <header class="header">
1228
- <h1>${escapeHtml(title)}</h1>
1229
- <div class="header-actions">
1230
- ${includeSearch ? '<input type="text" class="search-input" placeholder="Search scenarios..." aria-label="Search scenarios">' : ""}
1231
- <button type="button" class="detail-toggle" onclick="toggleDetailLevel()" aria-label="Toggle detail level" title="Toggle documentation detail"></button>
1232
- ${includeDarkMode ? '<button type="button" class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle theme"></button>' : ""}
1538
+ <div class="report-layout">
1539
+ ${options.tocHtml ?? ""}
1540
+ <div class="main-content">
1541
+ <div class="container">
1542
+ <header class="header">
1543
+ <h1>${escapeHtml(title)}</h1>
1544
+ <div class="header-actions">
1545
+ <button type="button" class="toc-toggle" onclick="toggleToc()" aria-label="Toggle table of contents" title="Toggle contents">&#x2630;</button>
1546
+ ${includeSearch ? '<input type="text" class="search-input" placeholder="Search scenarios..." aria-label="Search scenarios">' : ""}
1547
+ <button type="button" class="detail-toggle" onclick="toggleDetailLevel()" aria-label="Toggle detail level" title="Toggle documentation detail"></button>
1548
+ ${options.themePickerHtml ?? ""}
1549
+ ${includeDarkMode ? '<button type="button" class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle theme"></button>' : ""}
1550
+ </div>
1551
+ </header>
1552
+ ${body}
1233
1553
  </div>
1234
- </header>
1235
- ${body}
1554
+ </div>
1236
1555
  </div>
1237
1556
  <script>${script}</script>${esmScriptHtml}
1238
1557
  </body>
@@ -2936,6 +3255,337 @@ body {
2936
3255
  display: none;
2937
3256
  }
2938
3257
 
3258
+ /* ============================================================================
3259
+ Permalink Anchors
3260
+ ============================================================================ */
3261
+ .permalink-anchor {
3262
+ display: inline-flex;
3263
+ align-items: center;
3264
+ justify-content: center;
3265
+ width: 1.5rem;
3266
+ height: 1.5rem;
3267
+ border: none;
3268
+ background: none;
3269
+ color: var(--muted-foreground);
3270
+ cursor: pointer;
3271
+ opacity: 0;
3272
+ transition: opacity 0.15s ease;
3273
+ font-size: 0.875rem;
3274
+ font-weight: 600;
3275
+ padding: 0;
3276
+ flex-shrink: 0;
3277
+ }
3278
+
3279
+ .feature-header:hover .permalink-anchor,
3280
+ .scenario-header:hover .permalink-anchor,
3281
+ .permalink-anchor:focus-visible {
3282
+ opacity: 1;
3283
+ }
3284
+
3285
+ .permalink-anchor:hover {
3286
+ color: var(--primary);
3287
+ }
3288
+
3289
+ .copy-toast {
3290
+ position: absolute;
3291
+ right: 0.5rem;
3292
+ top: 50%;
3293
+ transform: translateY(-50%);
3294
+ background: var(--foreground);
3295
+ color: var(--background);
3296
+ padding: 0.25rem 0.5rem;
3297
+ border-radius: var(--radius);
3298
+ font-size: 0.75rem;
3299
+ font-weight: 500;
3300
+ pointer-events: none;
3301
+ animation: fadeOut 1.5s ease forwards;
3302
+ z-index: 10;
3303
+ }
3304
+
3305
+ @keyframes fadeOut {
3306
+ 0%, 70% { opacity: 1; }
3307
+ 100% { opacity: 0; }
3308
+ }
3309
+
3310
+ .hash-highlight {
3311
+ animation: hashPulse 2s ease;
3312
+ }
3313
+
3314
+ @keyframes hashPulse {
3315
+ 0%, 100% { background: transparent; }
3316
+ 20% { background: color-mix(in srgb, var(--primary) 12%, transparent); }
3317
+ }
3318
+
3319
+ .scenario-actions {
3320
+ display: flex;
3321
+ align-items: center;
3322
+ gap: 0.25rem;
3323
+ flex-shrink: 0;
3324
+ }
3325
+
3326
+ .copy-scenario-btn {
3327
+ display: inline-flex;
3328
+ align-items: center;
3329
+ justify-content: center;
3330
+ width: 1.5rem;
3331
+ height: 1.5rem;
3332
+ border: none;
3333
+ background: none;
3334
+ color: var(--muted-foreground);
3335
+ cursor: pointer;
3336
+ opacity: 0;
3337
+ transition: opacity 0.15s ease;
3338
+ font-size: 0.875rem;
3339
+ padding: 0;
3340
+ flex-shrink: 0;
3341
+ }
3342
+
3343
+ .scenario-header:hover .copy-scenario-btn,
3344
+ .copy-scenario-btn:focus-visible {
3345
+ opacity: 1;
3346
+ }
3347
+
3348
+ .copy-scenario-btn:hover {
3349
+ color: var(--primary);
3350
+ }
3351
+
3352
+ /* ============================================================================
3353
+ Keyboard Navigation
3354
+ ============================================================================ */
3355
+ .scenario-focused {
3356
+ border-left: 2px solid var(--primary);
3357
+ }
3358
+
3359
+ .shortcuts-overlay {
3360
+ position: fixed;
3361
+ inset: 0;
3362
+ background: rgb(0 0 0 / 0.5);
3363
+ display: flex;
3364
+ align-items: center;
3365
+ justify-content: center;
3366
+ z-index: 100;
3367
+ }
3368
+
3369
+ .shortcuts-modal {
3370
+ background: var(--card);
3371
+ color: var(--card-foreground);
3372
+ border: 1px solid var(--border);
3373
+ border-radius: calc(var(--radius) * 2);
3374
+ padding: 1.5rem 2rem;
3375
+ max-width: 400px;
3376
+ width: 90vw;
3377
+ box-shadow: var(--shadow-md, 0 4px 12px rgb(0 0 0 / 0.15));
3378
+ }
3379
+
3380
+ .shortcuts-title {
3381
+ font-weight: 600;
3382
+ font-size: 1.125rem;
3383
+ margin-bottom: 1rem;
3384
+ padding-bottom: 0.5rem;
3385
+ border-bottom: 1px solid var(--border);
3386
+ }
3387
+
3388
+ .shortcuts-grid {
3389
+ display: grid;
3390
+ grid-template-columns: auto 1fr;
3391
+ gap: 0.5rem 1rem;
3392
+ align-items: center;
3393
+ }
3394
+
3395
+ .shortcuts-grid kbd {
3396
+ display: inline-flex;
3397
+ align-items: center;
3398
+ justify-content: center;
3399
+ min-width: 1.75rem;
3400
+ padding: 0.125rem 0.375rem;
3401
+ background: var(--muted);
3402
+ border: 1px solid var(--border);
3403
+ border-radius: calc(var(--radius) * 0.5);
3404
+ font-family: var(--font-mono);
3405
+ font-size: 0.75rem;
3406
+ font-weight: 500;
3407
+ color: var(--muted-foreground);
3408
+ }
3409
+
3410
+ .shortcuts-grid span {
3411
+ font-size: 0.875rem;
3412
+ color: var(--foreground);
3413
+ }
3414
+
3415
+ /* ============================================================================
3416
+ Table of Contents Sidebar
3417
+ ============================================================================ */
3418
+ .report-layout {
3419
+ display: flex;
3420
+ min-height: 100vh;
3421
+ }
3422
+
3423
+ .report-layout.toc-hidden .toc-sidebar {
3424
+ display: none;
3425
+ }
3426
+
3427
+ .main-content {
3428
+ flex: 1;
3429
+ min-width: 0;
3430
+ }
3431
+
3432
+ .toc-sidebar {
3433
+ width: 260px;
3434
+ flex-shrink: 0;
3435
+ position: sticky;
3436
+ top: 0;
3437
+ height: 100vh;
3438
+ overflow-y: auto;
3439
+ border-right: 1px solid var(--border);
3440
+ background: var(--card);
3441
+ padding: 1rem 0;
3442
+ font-size: 0.8125rem;
3443
+ }
3444
+
3445
+ .toc-header {
3446
+ padding: 0 1rem 0.75rem;
3447
+ border-bottom: 1px solid var(--border);
3448
+ margin-bottom: 0.5rem;
3449
+ }
3450
+
3451
+ .toc-title {
3452
+ font-weight: 600;
3453
+ font-size: 0.875rem;
3454
+ color: var(--foreground);
3455
+ text-decoration: none;
3456
+ cursor: pointer;
3457
+ }
3458
+
3459
+ a.toc-title:hover {
3460
+ color: var(--primary);
3461
+ }
3462
+
3463
+ .toc-feature {
3464
+ margin-bottom: 0.25rem;
3465
+ }
3466
+
3467
+ .toc-feature-toggle {
3468
+ display: flex;
3469
+ align-items: center;
3470
+ width: 100%;
3471
+ padding: 0.375rem 1rem;
3472
+ border: none;
3473
+ background: none;
3474
+ text-align: left;
3475
+ cursor: pointer;
3476
+ font-size: 0.8125rem;
3477
+ font-weight: 600;
3478
+ color: var(--foreground);
3479
+ font-family: var(--font-sans);
3480
+ }
3481
+
3482
+ .toc-feature-toggle:hover {
3483
+ background: var(--accent);
3484
+ }
3485
+
3486
+ .toc-feature-toggle[aria-expanded="false"] + .toc-scenarios {
3487
+ display: none;
3488
+ }
3489
+
3490
+ .toc-scenarios {
3491
+ display: flex;
3492
+ flex-direction: column;
3493
+ }
3494
+
3495
+ .toc-scenario {
3496
+ display: flex;
3497
+ align-items: baseline;
3498
+ gap: 0.375rem;
3499
+ padding: 0.25rem 1rem 0.25rem 1.5rem;
3500
+ color: var(--muted-foreground);
3501
+ text-decoration: none;
3502
+ font-size: 0.8125rem;
3503
+ line-height: 1.4;
3504
+ border-left: 2px solid transparent;
3505
+ transition: all 0.1s ease;
3506
+ }
3507
+
3508
+ .toc-scenario:hover {
3509
+ color: var(--foreground);
3510
+ background: var(--accent);
3511
+ }
3512
+
3513
+ .toc-scenario.toc-active {
3514
+ color: var(--foreground);
3515
+ border-left-color: var(--primary);
3516
+ font-weight: 500;
3517
+ }
3518
+
3519
+ .toc-scenario.toc-failed {
3520
+ border-left-color: var(--error, var(--destructive));
3521
+ }
3522
+
3523
+ .toc-status {
3524
+ flex-shrink: 0;
3525
+ font-size: 0.75rem;
3526
+ }
3527
+
3528
+ .toc-toggle {
3529
+ display: inline-flex;
3530
+ align-items: center;
3531
+ justify-content: center;
3532
+ width: 2.25rem;
3533
+ height: 2.25rem;
3534
+ border: 1px solid var(--border);
3535
+ border-radius: var(--radius);
3536
+ background: var(--background);
3537
+ cursor: pointer;
3538
+ color: var(--foreground);
3539
+ font-size: 1rem;
3540
+ transition: all 0.15s ease;
3541
+ }
3542
+
3543
+ .toc-toggle:hover {
3544
+ background: var(--accent);
3545
+ }
3546
+
3547
+ /* Mobile: overlay sidebar */
3548
+ @media (max-width: 767px) {
3549
+ .toc-sidebar {
3550
+ position: fixed;
3551
+ left: 0;
3552
+ top: 0;
3553
+ z-index: 50;
3554
+ box-shadow: var(--shadow-sm, 0 1px 3px rgb(0 0 0 / 0.1));
3555
+ transform: translateX(-100%);
3556
+ transition: transform 0.2s ease;
3557
+ }
3558
+
3559
+ .toc-sidebar.toc-mobile-open {
3560
+ transform: translateX(0);
3561
+ }
3562
+ }
3563
+
3564
+ /* ============================================================================
3565
+ Theme Picker
3566
+ ============================================================================ */
3567
+ .theme-picker {
3568
+ height: 2.25rem;
3569
+ padding: 0 0.5rem;
3570
+ border: 1px solid var(--border);
3571
+ border-radius: var(--radius);
3572
+ background: var(--background);
3573
+ color: var(--foreground);
3574
+ font-size: 0.8125rem;
3575
+ font-family: var(--font-sans);
3576
+ cursor: pointer;
3577
+ transition: all 0.15s ease;
3578
+ }
3579
+
3580
+ .theme-picker:hover {
3581
+ background: var(--accent);
3582
+ }
3583
+
3584
+ .theme-picker:focus-visible {
3585
+ outline: 2px solid var(--ring);
3586
+ outline-offset: 2px;
3587
+ }
3588
+
2939
3589
  `;
2940
3590
 
2941
3591
  // src/formatters/html/themes/default.ts
@@ -2985,7 +3635,7 @@ function corporateBuildBody(args, deps) {
2985
3635
  const sidebar = `
2986
3636
  <nav class="toc">
2987
3637
  <div class="toc-header">
2988
- <div class="toc-title">Test Report</div>
3638
+ <a href="#" class="toc-title" onclick="window.scrollTo({top:0,behavior:'smooth'});return false;">Test Report</a>
2989
3639
  <div class="toc-stats">
2990
3640
  <div class="toc-stat-row">
2991
3641
  <span class="toc-stat-label">Total</span>
@@ -12195,6 +12845,11 @@ function resolveTheme(nameOrTheme) {
12195
12845
  function getAvailableThemes() {
12196
12846
  return [...THEME_REGISTRY.keys()];
12197
12847
  }
12848
+ function getCssOnlyThemes() {
12849
+ return [...THEME_REGISTRY.values()].filter(
12850
+ (theme) => !theme.buildBody && !theme.generateTemplate
12851
+ );
12852
+ }
12198
12853
 
12199
12854
  // src/formatters/html/renderers/status.ts
12200
12855
  function getStatusIcon(status) {
@@ -12476,7 +13131,7 @@ function renderStep(step, stepResult, index, deps) {
12476
13131
  const stepClass = isContinuation ? "step continuation" : "step";
12477
13132
  const stepDocs = deps.renderDocs(step.docs, "step-docs");
12478
13133
  const textHtml = deps.highlightStepParams ? deps.highlightStepParams(step.text) : deps.escapeHtml(step.text);
12479
- return `<div class="${stepClass}">
13134
+ return `<div class="${stepClass}" data-keyword="${deps.escapeHtml(keywordTrimmed)}" data-text="${deps.escapeHtml(step.text)}">
12480
13135
  <span class="step-status ${statusClass}">${statusIcon}</span>
12481
13136
  <span class="step-keyword">${deps.escapeHtml(step.keyword)}</span>
12482
13137
  <span class="step-text">${textHtml}</span>
@@ -12607,7 +13262,11 @@ function renderScenario(args, deps) {
12607
13262
  </div>
12608
13263
  <div class="scenario-meta">${tags}${tickets}${sourceLink}${traceBadge}${metricBadges}</div>
12609
13264
  </div>
12610
- <span class="scenario-duration">${duration}</span>
13265
+ <div class="scenario-actions">
13266
+ <button class="copy-scenario-btn" onclick="copyScenarioAsMarkdown('scenario-${tc.id}')" aria-label="Copy scenario as markdown" title="Copy as Markdown"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
13267
+ <button class="permalink-anchor" onclick="copyPermalink('scenario-${tc.id}')" aria-label="Copy link to scenario" title="Copy link">#</button>
13268
+ <span class="scenario-duration">${duration}</span>
13269
+ </div>
12611
13270
  </div>
12612
13271
  <div class="scenario-content">
12613
13272
  ${storyDocs}
@@ -12817,6 +13476,7 @@ function renderFeature(args, deps) {
12817
13476
  const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
12818
13477
  const collapsedClass = deps.startCollapsed ? " collapsed" : "";
12819
13478
  const ariaExpanded = !deps.startCollapsed;
13479
+ const featureSlug = `feature-${slugify(file)}`;
12820
13480
  const scenarios = testCases.map(
12821
13481
  (tc) => deps.renderScenario(
12822
13482
  { tc, metrics: args.metricsMap?.get(tc.id) },
@@ -12824,8 +13484,9 @@ function renderFeature(args, deps) {
12824
13484
  )
12825
13485
  ).join("\n");
12826
13486
  return `
12827
- <div class="feature${collapsedClass}">
13487
+ <div class="feature${collapsedClass}" id="${featureSlug}">
12828
13488
  <div class="feature-header" role="button" tabindex="0" aria-expanded="${ariaExpanded}">
13489
+ <button class="permalink-anchor" onclick="copyPermalink('${featureSlug}')" aria-label="Copy link to feature" title="Copy link">#</button>
12829
13490
  <div class="feature-info">
12830
13491
  <div class="feature-title">${deps.escapeHtml(featureName)}</div>
12831
13492
  <div class="feature-path">${deps.escapeHtml(file)}</div>
@@ -12938,6 +13599,57 @@ function renderFailureSummary(args, deps) {
12938
13599
  </div>`;
12939
13600
  }
12940
13601
 
13602
+ // src/formatters/html/renderers/toc.ts
13603
+ function groupBy4(items, keyFn) {
13604
+ const map = /* @__PURE__ */ new Map();
13605
+ for (const item of items) {
13606
+ const key = keyFn(item);
13607
+ const existing = map.get(key);
13608
+ if (existing) {
13609
+ existing.push(item);
13610
+ } else {
13611
+ map.set(key, [item]);
13612
+ }
13613
+ }
13614
+ return map;
13615
+ }
13616
+ function renderToc(args, deps) {
13617
+ const { run } = args;
13618
+ if (run.testCases.length === 0) return "";
13619
+ const byFile = groupBy4(run.testCases, (tc) => tc.sourceFile);
13620
+ const features = [];
13621
+ for (const [file, testCases] of byFile) {
13622
+ const suitePaths = testCases.map((tc) => tc.titlePath).filter((p) => p.length > 0);
13623
+ const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
13624
+ const featureSlug = `feature-${slugify(file)}`;
13625
+ const scenarios = testCases.map((tc) => {
13626
+ const statusIcon = deps.getStatusIcon(tc.status);
13627
+ const statusClass = `status-${tc.status}`;
13628
+ const failedClass = tc.status === "failed" ? " toc-failed" : "";
13629
+ return `<a class="toc-scenario${failedClass}" href="#scenario-${tc.id}">
13630
+ <span class="toc-status ${statusClass}">${statusIcon}</span>
13631
+ ${deps.escapeHtml(tc.story.scenario)}
13632
+ </a>`;
13633
+ }).join("\n");
13634
+ features.push(`<div class="toc-feature">
13635
+ <button class="toc-feature-toggle" aria-expanded="true" onclick="this.setAttribute('aria-expanded', this.getAttribute('aria-expanded') === 'true' ? 'false' : 'true'); this.nextElementSibling.style.display = this.getAttribute('aria-expanded') === 'true' ? '' : 'none'" data-feature="#${featureSlug}">
13636
+ ${deps.escapeHtml(featureName)}
13637
+ </button>
13638
+ <div class="toc-scenarios">
13639
+ ${scenarios}
13640
+ </div>
13641
+ </div>`);
13642
+ }
13643
+ return `<nav class="toc-sidebar" aria-label="Table of contents">
13644
+ <div class="toc-header">
13645
+ <a href="#" class="toc-title" onclick="window.scrollTo({top:0,behavior:'smooth'});return false;">Contents</a>
13646
+ </div>
13647
+ <div class="toc-body">
13648
+ ${features.join("\n")}
13649
+ </div>
13650
+ </nav>`;
13651
+ }
13652
+
12941
13653
  // src/formatters/html/renderers/index.ts
12942
13654
  function normalizeOptions(options = {}) {
12943
13655
  return {
@@ -12951,7 +13663,9 @@ function normalizeOptions(options = {}) {
12951
13663
  markdownEnabled: options.markdownEnabled ?? true,
12952
13664
  permalinkBaseUrl: options.permalinkBaseUrl,
12953
13665
  ticketUrlTemplate: options.ticketUrlTemplate,
12954
- theme: options.theme ?? "default"
13666
+ tocEnabled: options.tocEnabled ?? true,
13667
+ theme: options.theme ?? "default",
13668
+ themePickerEnabled: options.themePickerEnabled ?? false
12955
13669
  };
12956
13670
  }
12957
13671
  function createHtmlFormatter(options = {}) {
@@ -12993,6 +13707,10 @@ function createHtmlFormatter(options = {}) {
12993
13707
  scenarioDeps
12994
13708
  };
12995
13709
  const tagBarDeps = { escapeHtml };
13710
+ const tocDeps = {
13711
+ escapeHtml,
13712
+ getStatusIcon
13713
+ };
12996
13714
  const bodyDeps = {
12997
13715
  renderMetaInfo,
12998
13716
  renderSummary,
@@ -13011,6 +13729,16 @@ function createHtmlFormatter(options = {}) {
13011
13729
  const bodyFn = theme.buildBody ?? buildBody;
13012
13730
  const body = bodyFn({ run }, bodyDeps);
13013
13731
  const templateFn = theme.generateTemplate ?? generateHtmlTemplate;
13732
+ const isStructuralTheme = !!(theme.buildBody || theme.generateTemplate);
13733
+ const tocHtml = opts.tocEnabled && !isStructuralTheme ? renderToc({ run }, tocDeps) : void 0;
13734
+ let themePickerHtml;
13735
+ let additionalThemeCss;
13736
+ if (opts.themePickerEnabled) {
13737
+ const cssOnlyThemes = getCssOnlyThemes();
13738
+ const pickerOptions = cssOnlyThemes.map((t) => `<option value="${t.name}"${t.name === theme.name ? " selected" : ""}>${t.label}</option>`).join("");
13739
+ themePickerHtml = `<select class="theme-picker" aria-label="Select theme">${pickerOptions}</select>`;
13740
+ additionalThemeCss = cssOnlyThemes.filter((t) => t.name !== theme.name).map((t) => ({ name: t.name, label: t.label, css: t.css }));
13741
+ }
13014
13742
  return templateFn(
13015
13743
  opts.title,
13016
13744
  theme.css,
@@ -13022,7 +13750,11 @@ function createHtmlFormatter(options = {}) {
13022
13750
  mermaidEnabled: opts.mermaidEnabled,
13023
13751
  markdownEnabled: opts.markdownEnabled,
13024
13752
  additionalJs: theme.additionalJs,
13025
- additionalImports: theme.additionalImports
13753
+ additionalImports: theme.additionalImports,
13754
+ tocHtml,
13755
+ themePickerHtml,
13756
+ additionalThemeCss,
13757
+ activeThemeName: theme.name
13026
13758
  }
13027
13759
  );
13028
13760
  }
@@ -13078,7 +13810,7 @@ var JUnitFormatter = class {
13078
13810
  lines.push(
13079
13811
  `<testsuites name="${escapeXml(this.options.suiteName)}" tests="${tests}" failures="${failures}" errors="${errors}" skipped="${skipped}" time="${time}">`
13080
13812
  );
13081
- const byFile = groupBy4(run.testCases, (tc) => tc.sourceFile);
13813
+ const byFile = groupBy5(run.testCases, (tc) => tc.sourceFile);
13082
13814
  for (const [file, testCases] of byFile) {
13083
13815
  lines.push(...this.buildTestSuite(file, testCases, indent, newline));
13084
13816
  }
@@ -13251,7 +13983,7 @@ var JUnitFormatter = class {
13251
13983
  function escapeXml(str) {
13252
13984
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
13253
13985
  }
13254
- function groupBy4(items, keyFn) {
13986
+ function groupBy5(items, keyFn) {
13255
13987
  const map = /* @__PURE__ */ new Map();
13256
13988
  for (const item of items) {
13257
13989
  const key = keyFn(item);
@@ -13426,7 +14158,7 @@ var MarkdownFormatter = class {
13426
14158
  * Render scenarios grouped by file.
13427
14159
  */
13428
14160
  renderByFile(lines, testCases) {
13429
- const byFile = groupBy5(testCases, (tc) => tc.sourceFile);
14161
+ const byFile = groupBy6(testCases, (tc) => tc.sourceFile);
13430
14162
  for (const [file, fileTestCases] of byFile) {
13431
14163
  lines.push(`## ${file}`);
13432
14164
  lines.push("");
@@ -13443,7 +14175,7 @@ var MarkdownFormatter = class {
13443
14175
  * Render suite groups.
13444
14176
  */
13445
14177
  renderSuiteGroups(lines, testCases, baseLevel) {
13446
- const bySuite = groupBy5(
14178
+ const bySuite = groupBy6(
13447
14179
  testCases,
13448
14180
  (tc) => tc.titlePath.join(this.options.suiteSeparator)
13449
14181
  );
@@ -13737,7 +14469,7 @@ var MarkdownFormatter = class {
13737
14469
  return entries;
13738
14470
  }
13739
14471
  };
13740
- function groupBy5(items, keyFn) {
14472
+ function groupBy6(items, keyFn) {
13741
14473
  const map = /* @__PURE__ */ new Map();
13742
14474
  for (const item of items) {
13743
14475
  const key = keyFn(item);
@@ -17449,7 +18181,7 @@ var ReportGenerator = class {
17449
18181
  excludeTags: options.excludeTags ?? [],
17450
18182
  formats: options.formats ?? ["cucumber-json"],
17451
18183
  outputDir: options.outputDir ?? "reports",
17452
- outputName: options.outputName ?? "test-results",
18184
+ outputName: options.outputName ?? "index",
17453
18185
  outputNameTimestamp: options.outputNameTimestamp ?? false,
17454
18186
  sortTestCases: options.sortTestCases ?? "none",
17455
18187
  output: {
@@ -17478,7 +18210,9 @@ var ReportGenerator = class {
17478
18210
  markdownEnabled: options.html?.markdownEnabled ?? true,
17479
18211
  permalinkBaseUrl: options.html?.permalinkBaseUrl,
17480
18212
  ticketUrlTemplate: options.html?.ticketUrlTemplate,
17481
- theme: options.html?.theme ?? "default"
18213
+ theme: options.html?.theme ?? "default",
18214
+ tocEnabled: options.html?.tocEnabled ?? true,
18215
+ themePickerEnabled: options.html?.themePickerEnabled ?? false
17482
18216
  },
17483
18217
  junit: {
17484
18218
  suiteName: options.junit?.suiteName ?? "Test Suite",
@@ -17601,7 +18335,9 @@ var ReportGenerator = class {
17601
18335
  mermaidEnabled: this.options.html.mermaidEnabled,
17602
18336
  markdownEnabled: this.options.html.markdownEnabled,
17603
18337
  permalinkBaseUrl: this.options.html.permalinkBaseUrl,
17604
- ticketUrlTemplate: this.options.html.ticketUrlTemplate
18338
+ ticketUrlTemplate: this.options.html.ticketUrlTemplate,
18339
+ tocEnabled: this.options.html.tocEnabled,
18340
+ themePickerEnabled: this.options.html.themePickerEnabled
17605
18341
  });
17606
18342
  return formatter.format(run);
17607
18343
  }
@@ -17725,6 +18461,7 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
17725
18461
  generateRunId,
17726
18462
  generateTestCaseId,
17727
18463
  getAvailableThemes,
18464
+ getCssOnlyThemes,
17728
18465
  hasSufficientHistory,
17729
18466
  listScenarios,
17730
18467
  loadHistory,