executable-stories-formatters 0.7.4 → 0.7.5

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,331 @@ 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
+ }
3456
+
3457
+ .toc-feature {
3458
+ margin-bottom: 0.25rem;
3459
+ }
3460
+
3461
+ .toc-feature-toggle {
3462
+ display: flex;
3463
+ align-items: center;
3464
+ width: 100%;
3465
+ padding: 0.375rem 1rem;
3466
+ border: none;
3467
+ background: none;
3468
+ text-align: left;
3469
+ cursor: pointer;
3470
+ font-size: 0.8125rem;
3471
+ font-weight: 600;
3472
+ color: var(--foreground);
3473
+ font-family: var(--font-sans);
3474
+ }
3475
+
3476
+ .toc-feature-toggle:hover {
3477
+ background: var(--accent);
3478
+ }
3479
+
3480
+ .toc-feature-toggle[aria-expanded="false"] + .toc-scenarios {
3481
+ display: none;
3482
+ }
3483
+
3484
+ .toc-scenarios {
3485
+ display: flex;
3486
+ flex-direction: column;
3487
+ }
3488
+
3489
+ .toc-scenario {
3490
+ display: flex;
3491
+ align-items: baseline;
3492
+ gap: 0.375rem;
3493
+ padding: 0.25rem 1rem 0.25rem 1.5rem;
3494
+ color: var(--muted-foreground);
3495
+ text-decoration: none;
3496
+ font-size: 0.8125rem;
3497
+ line-height: 1.4;
3498
+ border-left: 2px solid transparent;
3499
+ transition: all 0.1s ease;
3500
+ }
3501
+
3502
+ .toc-scenario:hover {
3503
+ color: var(--foreground);
3504
+ background: var(--accent);
3505
+ }
3506
+
3507
+ .toc-scenario.toc-active {
3508
+ color: var(--foreground);
3509
+ border-left-color: var(--primary);
3510
+ font-weight: 500;
3511
+ }
3512
+
3513
+ .toc-scenario.toc-failed {
3514
+ border-left-color: var(--error, var(--destructive));
3515
+ }
3516
+
3517
+ .toc-status {
3518
+ flex-shrink: 0;
3519
+ font-size: 0.75rem;
3520
+ }
3521
+
3522
+ .toc-toggle {
3523
+ display: inline-flex;
3524
+ align-items: center;
3525
+ justify-content: center;
3526
+ width: 2.25rem;
3527
+ height: 2.25rem;
3528
+ border: 1px solid var(--border);
3529
+ border-radius: var(--radius);
3530
+ background: var(--background);
3531
+ cursor: pointer;
3532
+ color: var(--foreground);
3533
+ font-size: 1rem;
3534
+ transition: all 0.15s ease;
3535
+ }
3536
+
3537
+ .toc-toggle:hover {
3538
+ background: var(--accent);
3539
+ }
3540
+
3541
+ /* Mobile: overlay sidebar */
3542
+ @media (max-width: 767px) {
3543
+ .toc-sidebar {
3544
+ position: fixed;
3545
+ left: 0;
3546
+ top: 0;
3547
+ z-index: 50;
3548
+ box-shadow: var(--shadow-sm, 0 1px 3px rgb(0 0 0 / 0.1));
3549
+ transform: translateX(-100%);
3550
+ transition: transform 0.2s ease;
3551
+ }
3552
+
3553
+ .toc-sidebar.toc-mobile-open {
3554
+ transform: translateX(0);
3555
+ }
3556
+ }
3557
+
3558
+ /* ============================================================================
3559
+ Theme Picker
3560
+ ============================================================================ */
3561
+ .theme-picker {
3562
+ height: 2.25rem;
3563
+ padding: 0 0.5rem;
3564
+ border: 1px solid var(--border);
3565
+ border-radius: var(--radius);
3566
+ background: var(--background);
3567
+ color: var(--foreground);
3568
+ font-size: 0.8125rem;
3569
+ font-family: var(--font-sans);
3570
+ cursor: pointer;
3571
+ transition: all 0.15s ease;
3572
+ }
3573
+
3574
+ .theme-picker:hover {
3575
+ background: var(--accent);
3576
+ }
3577
+
3578
+ .theme-picker:focus-visible {
3579
+ outline: 2px solid var(--ring);
3580
+ outline-offset: 2px;
3581
+ }
3582
+
2939
3583
  `;
2940
3584
 
2941
3585
  // src/formatters/html/themes/default.ts
@@ -12195,6 +12839,11 @@ function resolveTheme(nameOrTheme) {
12195
12839
  function getAvailableThemes() {
12196
12840
  return [...THEME_REGISTRY.keys()];
12197
12841
  }
12842
+ function getCssOnlyThemes() {
12843
+ return [...THEME_REGISTRY.values()].filter(
12844
+ (theme) => !theme.buildBody && !theme.generateTemplate
12845
+ );
12846
+ }
12198
12847
 
12199
12848
  // src/formatters/html/renderers/status.ts
12200
12849
  function getStatusIcon(status) {
@@ -12476,7 +13125,7 @@ function renderStep(step, stepResult, index, deps) {
12476
13125
  const stepClass = isContinuation ? "step continuation" : "step";
12477
13126
  const stepDocs = deps.renderDocs(step.docs, "step-docs");
12478
13127
  const textHtml = deps.highlightStepParams ? deps.highlightStepParams(step.text) : deps.escapeHtml(step.text);
12479
- return `<div class="${stepClass}">
13128
+ return `<div class="${stepClass}" data-keyword="${deps.escapeHtml(keywordTrimmed)}" data-text="${deps.escapeHtml(step.text)}">
12480
13129
  <span class="step-status ${statusClass}">${statusIcon}</span>
12481
13130
  <span class="step-keyword">${deps.escapeHtml(step.keyword)}</span>
12482
13131
  <span class="step-text">${textHtml}</span>
@@ -12607,7 +13256,11 @@ function renderScenario(args, deps) {
12607
13256
  </div>
12608
13257
  <div class="scenario-meta">${tags}${tickets}${sourceLink}${traceBadge}${metricBadges}</div>
12609
13258
  </div>
12610
- <span class="scenario-duration">${duration}</span>
13259
+ <div class="scenario-actions">
13260
+ <button class="copy-scenario-btn" onclick="copyScenarioAsMarkdown('scenario-${tc.id}')" aria-label="Copy scenario as markdown" title="Copy as Markdown">&#x2398;</button>
13261
+ <button class="permalink-anchor" onclick="copyPermalink('scenario-${tc.id}')" aria-label="Copy link to scenario" title="Copy link">#</button>
13262
+ <span class="scenario-duration">${duration}</span>
13263
+ </div>
12611
13264
  </div>
12612
13265
  <div class="scenario-content">
12613
13266
  ${storyDocs}
@@ -12817,6 +13470,7 @@ function renderFeature(args, deps) {
12817
13470
  const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
12818
13471
  const collapsedClass = deps.startCollapsed ? " collapsed" : "";
12819
13472
  const ariaExpanded = !deps.startCollapsed;
13473
+ const featureSlug = `feature-${slugify(file)}`;
12820
13474
  const scenarios = testCases.map(
12821
13475
  (tc) => deps.renderScenario(
12822
13476
  { tc, metrics: args.metricsMap?.get(tc.id) },
@@ -12824,8 +13478,9 @@ function renderFeature(args, deps) {
12824
13478
  )
12825
13479
  ).join("\n");
12826
13480
  return `
12827
- <div class="feature${collapsedClass}">
13481
+ <div class="feature${collapsedClass}" id="${featureSlug}">
12828
13482
  <div class="feature-header" role="button" tabindex="0" aria-expanded="${ariaExpanded}">
13483
+ <button class="permalink-anchor" onclick="copyPermalink('${featureSlug}')" aria-label="Copy link to feature" title="Copy link">#</button>
12829
13484
  <div class="feature-info">
12830
13485
  <div class="feature-title">${deps.escapeHtml(featureName)}</div>
12831
13486
  <div class="feature-path">${deps.escapeHtml(file)}</div>
@@ -12938,6 +13593,57 @@ function renderFailureSummary(args, deps) {
12938
13593
  </div>`;
12939
13594
  }
12940
13595
 
13596
+ // src/formatters/html/renderers/toc.ts
13597
+ function groupBy4(items, keyFn) {
13598
+ const map = /* @__PURE__ */ new Map();
13599
+ for (const item of items) {
13600
+ const key = keyFn(item);
13601
+ const existing = map.get(key);
13602
+ if (existing) {
13603
+ existing.push(item);
13604
+ } else {
13605
+ map.set(key, [item]);
13606
+ }
13607
+ }
13608
+ return map;
13609
+ }
13610
+ function renderToc(args, deps) {
13611
+ const { run } = args;
13612
+ if (run.testCases.length === 0) return "";
13613
+ const byFile = groupBy4(run.testCases, (tc) => tc.sourceFile);
13614
+ const features = [];
13615
+ for (const [file, testCases] of byFile) {
13616
+ const suitePaths = testCases.map((tc) => tc.titlePath).filter((p) => p.length > 0);
13617
+ const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
13618
+ const featureSlug = `feature-${slugify(file)}`;
13619
+ const scenarios = testCases.map((tc) => {
13620
+ const statusIcon = deps.getStatusIcon(tc.status);
13621
+ const statusClass = `status-${tc.status}`;
13622
+ const failedClass = tc.status === "failed" ? " toc-failed" : "";
13623
+ return `<a class="toc-scenario${failedClass}" href="#scenario-${tc.id}">
13624
+ <span class="toc-status ${statusClass}">${statusIcon}</span>
13625
+ ${deps.escapeHtml(tc.story.scenario)}
13626
+ </a>`;
13627
+ }).join("\n");
13628
+ features.push(`<div class="toc-feature">
13629
+ <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}">
13630
+ ${deps.escapeHtml(featureName)}
13631
+ </button>
13632
+ <div class="toc-scenarios">
13633
+ ${scenarios}
13634
+ </div>
13635
+ </div>`);
13636
+ }
13637
+ return `<nav class="toc-sidebar" aria-label="Table of contents">
13638
+ <div class="toc-header">
13639
+ <span class="toc-title">Contents</span>
13640
+ </div>
13641
+ <div class="toc-body">
13642
+ ${features.join("\n")}
13643
+ </div>
13644
+ </nav>`;
13645
+ }
13646
+
12941
13647
  // src/formatters/html/renderers/index.ts
12942
13648
  function normalizeOptions(options = {}) {
12943
13649
  return {
@@ -12951,7 +13657,9 @@ function normalizeOptions(options = {}) {
12951
13657
  markdownEnabled: options.markdownEnabled ?? true,
12952
13658
  permalinkBaseUrl: options.permalinkBaseUrl,
12953
13659
  ticketUrlTemplate: options.ticketUrlTemplate,
12954
- theme: options.theme ?? "default"
13660
+ tocEnabled: options.tocEnabled ?? true,
13661
+ theme: options.theme ?? "default",
13662
+ themePickerEnabled: options.themePickerEnabled ?? false
12955
13663
  };
12956
13664
  }
12957
13665
  function createHtmlFormatter(options = {}) {
@@ -12993,6 +13701,10 @@ function createHtmlFormatter(options = {}) {
12993
13701
  scenarioDeps
12994
13702
  };
12995
13703
  const tagBarDeps = { escapeHtml };
13704
+ const tocDeps = {
13705
+ escapeHtml,
13706
+ getStatusIcon
13707
+ };
12996
13708
  const bodyDeps = {
12997
13709
  renderMetaInfo,
12998
13710
  renderSummary,
@@ -13011,6 +13723,16 @@ function createHtmlFormatter(options = {}) {
13011
13723
  const bodyFn = theme.buildBody ?? buildBody;
13012
13724
  const body = bodyFn({ run }, bodyDeps);
13013
13725
  const templateFn = theme.generateTemplate ?? generateHtmlTemplate;
13726
+ const isStructuralTheme = !!(theme.buildBody || theme.generateTemplate);
13727
+ const tocHtml = opts.tocEnabled && !isStructuralTheme ? renderToc({ run }, tocDeps) : void 0;
13728
+ let themePickerHtml;
13729
+ let additionalThemeCss;
13730
+ if (opts.themePickerEnabled) {
13731
+ const cssOnlyThemes = getCssOnlyThemes();
13732
+ const pickerOptions = cssOnlyThemes.map((t) => `<option value="${t.name}"${t.name === theme.name ? " selected" : ""}>${t.label}</option>`).join("");
13733
+ themePickerHtml = `<select class="theme-picker" aria-label="Select theme">${pickerOptions}</select>`;
13734
+ additionalThemeCss = cssOnlyThemes.filter((t) => t.name !== theme.name).map((t) => ({ name: t.name, label: t.label, css: t.css }));
13735
+ }
13014
13736
  return templateFn(
13015
13737
  opts.title,
13016
13738
  theme.css,
@@ -13022,7 +13744,11 @@ function createHtmlFormatter(options = {}) {
13022
13744
  mermaidEnabled: opts.mermaidEnabled,
13023
13745
  markdownEnabled: opts.markdownEnabled,
13024
13746
  additionalJs: theme.additionalJs,
13025
- additionalImports: theme.additionalImports
13747
+ additionalImports: theme.additionalImports,
13748
+ tocHtml,
13749
+ themePickerHtml,
13750
+ additionalThemeCss,
13751
+ activeThemeName: theme.name
13026
13752
  }
13027
13753
  );
13028
13754
  }
@@ -13078,7 +13804,7 @@ var JUnitFormatter = class {
13078
13804
  lines.push(
13079
13805
  `<testsuites name="${escapeXml(this.options.suiteName)}" tests="${tests}" failures="${failures}" errors="${errors}" skipped="${skipped}" time="${time}">`
13080
13806
  );
13081
- const byFile = groupBy4(run.testCases, (tc) => tc.sourceFile);
13807
+ const byFile = groupBy5(run.testCases, (tc) => tc.sourceFile);
13082
13808
  for (const [file, testCases] of byFile) {
13083
13809
  lines.push(...this.buildTestSuite(file, testCases, indent, newline));
13084
13810
  }
@@ -13251,7 +13977,7 @@ var JUnitFormatter = class {
13251
13977
  function escapeXml(str) {
13252
13978
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
13253
13979
  }
13254
- function groupBy4(items, keyFn) {
13980
+ function groupBy5(items, keyFn) {
13255
13981
  const map = /* @__PURE__ */ new Map();
13256
13982
  for (const item of items) {
13257
13983
  const key = keyFn(item);
@@ -13426,7 +14152,7 @@ var MarkdownFormatter = class {
13426
14152
  * Render scenarios grouped by file.
13427
14153
  */
13428
14154
  renderByFile(lines, testCases) {
13429
- const byFile = groupBy5(testCases, (tc) => tc.sourceFile);
14155
+ const byFile = groupBy6(testCases, (tc) => tc.sourceFile);
13430
14156
  for (const [file, fileTestCases] of byFile) {
13431
14157
  lines.push(`## ${file}`);
13432
14158
  lines.push("");
@@ -13443,7 +14169,7 @@ var MarkdownFormatter = class {
13443
14169
  * Render suite groups.
13444
14170
  */
13445
14171
  renderSuiteGroups(lines, testCases, baseLevel) {
13446
- const bySuite = groupBy5(
14172
+ const bySuite = groupBy6(
13447
14173
  testCases,
13448
14174
  (tc) => tc.titlePath.join(this.options.suiteSeparator)
13449
14175
  );
@@ -13737,7 +14463,7 @@ var MarkdownFormatter = class {
13737
14463
  return entries;
13738
14464
  }
13739
14465
  };
13740
- function groupBy5(items, keyFn) {
14466
+ function groupBy6(items, keyFn) {
13741
14467
  const map = /* @__PURE__ */ new Map();
13742
14468
  for (const item of items) {
13743
14469
  const key = keyFn(item);
@@ -17478,7 +18204,9 @@ var ReportGenerator = class {
17478
18204
  markdownEnabled: options.html?.markdownEnabled ?? true,
17479
18205
  permalinkBaseUrl: options.html?.permalinkBaseUrl,
17480
18206
  ticketUrlTemplate: options.html?.ticketUrlTemplate,
17481
- theme: options.html?.theme ?? "default"
18207
+ theme: options.html?.theme ?? "default",
18208
+ tocEnabled: options.html?.tocEnabled ?? true,
18209
+ themePickerEnabled: options.html?.themePickerEnabled ?? false
17482
18210
  },
17483
18211
  junit: {
17484
18212
  suiteName: options.junit?.suiteName ?? "Test Suite",
@@ -17601,7 +18329,9 @@ var ReportGenerator = class {
17601
18329
  mermaidEnabled: this.options.html.mermaidEnabled,
17602
18330
  markdownEnabled: this.options.html.markdownEnabled,
17603
18331
  permalinkBaseUrl: this.options.html.permalinkBaseUrl,
17604
- ticketUrlTemplate: this.options.html.ticketUrlTemplate
18332
+ ticketUrlTemplate: this.options.html.ticketUrlTemplate,
18333
+ tocEnabled: this.options.html.tocEnabled,
18334
+ themePickerEnabled: this.options.html.themePickerEnabled
17605
18335
  });
17606
18336
  return formatter.format(run);
17607
18337
  }
@@ -17725,6 +18455,7 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
17725
18455
  generateRunId,
17726
18456
  generateTestCaseId,
17727
18457
  getAvailableThemes,
18458
+ getCssOnlyThemes,
17728
18459
  hasSufficientHistory,
17729
18460
  listScenarios,
17730
18461
  loadHistory,