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.js CHANGED
@@ -852,6 +852,7 @@ function applyAllFilters() {
852
852
  });
853
853
 
854
854
  updateFilterResults(visibleCount, totalCount);
855
+ syncTocVisibility();
855
856
  writeUrlState();
856
857
  }
857
858
 
@@ -868,13 +869,135 @@ function updateFilterResults(visible, total) {
868
869
  if (tc) tc.textContent = total;
869
870
  }
870
871
 
871
- // Keyboard shortcuts
872
+ // Keyboard navigation
873
+ var focusedScenarioIndex = -1;
874
+
875
+ function getVisibleScenarios() {
876
+ return Array.from(document.querySelectorAll('.scenario')).filter(function(s) {
877
+ return s.style.display !== 'none' && s.closest('.feature').style.display !== 'none';
878
+ });
879
+ }
880
+
881
+ function focusScenario(index) {
882
+ var scenarios = getVisibleScenarios();
883
+ if (scenarios.length === 0) return;
884
+
885
+ // Remove previous focus
886
+ var prev = document.querySelector('.scenario-focused');
887
+ if (prev) prev.classList.remove('scenario-focused');
888
+
889
+ // Wrap around
890
+ if (index < 0) index = scenarios.length - 1;
891
+ if (index >= scenarios.length) index = 0;
892
+ focusedScenarioIndex = index;
893
+
894
+ var scenario = scenarios[index];
895
+ scenario.classList.add('scenario-focused');
896
+ scenario.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
897
+ }
898
+
899
+ function showShortcutsOverlay() {
900
+ if (document.querySelector('.shortcuts-overlay')) return;
901
+ var overlay = document.createElement('div');
902
+ overlay.className = 'shortcuts-overlay';
903
+ overlay.innerHTML = '<div class="shortcuts-modal">' +
904
+ '<div class="shortcuts-title">Keyboard Shortcuts</div>' +
905
+ '<div class="shortcuts-grid">' +
906
+ '<kbd>j</kbd><span>Next scenario</span>' +
907
+ '<kbd>k</kbd><span>Previous scenario</span>' +
908
+ '<kbd>Enter</kbd><span>Expand/collapse scenario</span>' +
909
+ '<kbd>Escape</kbd><span>Collapse scenario / close</span>' +
910
+ '<kbd>/</kbd><span>Focus search</span>' +
911
+ '<kbd>?</kbd><span>Toggle this help</span>' +
912
+ '<kbd>e</kbd><span>Expand all</span>' +
913
+ '<kbd>c</kbd><span>Collapse all</span>' +
914
+ '<kbd>t</kbd><span>Toggle table of contents</span>' +
915
+ '</div></div>';
916
+ overlay.addEventListener('click', function(ev) {
917
+ if (ev.target === overlay) hideShortcutsOverlay();
918
+ });
919
+ document.body.appendChild(overlay);
920
+ }
921
+
922
+ function hideShortcutsOverlay() {
923
+ var overlay = document.querySelector('.shortcuts-overlay');
924
+ if (overlay) overlay.remove();
925
+ }
926
+
872
927
  function initKeyboardShortcuts() {
873
928
  document.addEventListener('keydown', function(e) {
874
- if (e.key === '/' && !e.ctrlKey && !e.metaKey && e.target.tagName !== 'INPUT') {
875
- e.preventDefault();
876
- var input = document.querySelector('.search-input');
877
- if (input) input.focus();
929
+ var tag = e.target.tagName;
930
+ if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') {
931
+ if (e.key === 'Escape') {
932
+ e.target.blur();
933
+ if (e.target.classList.contains('search-input')) {
934
+ e.target.value = '';
935
+ applyAllFilters();
936
+ }
937
+ }
938
+ return;
939
+ }
940
+
941
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
942
+
943
+ switch (e.key) {
944
+ case 'j':
945
+ e.preventDefault();
946
+ focusScenario(focusedScenarioIndex + 1);
947
+ break;
948
+ case 'k':
949
+ e.preventDefault();
950
+ focusScenario(focusedScenarioIndex - 1);
951
+ break;
952
+ case 'Enter':
953
+ e.preventDefault();
954
+ var scenarios = getVisibleScenarios();
955
+ if (focusedScenarioIndex >= 0 && focusedScenarioIndex < scenarios.length) {
956
+ var s = scenarios[focusedScenarioIndex];
957
+ var h = s.querySelector('.scenario-header');
958
+ if (h) toggleCollapse(h, s);
959
+ }
960
+ break;
961
+ case 'Escape':
962
+ if (document.querySelector('.shortcuts-overlay')) {
963
+ hideShortcutsOverlay();
964
+ } else {
965
+ var scenarios2 = getVisibleScenarios();
966
+ if (focusedScenarioIndex >= 0 && focusedScenarioIndex < scenarios2.length) {
967
+ var sc = scenarios2[focusedScenarioIndex];
968
+ if (!sc.classList.contains('collapsed')) {
969
+ sc.classList.add('collapsed');
970
+ var sh = sc.querySelector('.scenario-header');
971
+ if (sh) sh.setAttribute('aria-expanded', 'false');
972
+ }
973
+ }
974
+ }
975
+ break;
976
+ case '/':
977
+ e.preventDefault();
978
+ var input = document.querySelector('.search-input');
979
+ if (input) input.focus();
980
+ break;
981
+ case '?':
982
+ e.preventDefault();
983
+ if (document.querySelector('.shortcuts-overlay')) {
984
+ hideShortcutsOverlay();
985
+ } else {
986
+ showShortcutsOverlay();
987
+ }
988
+ break;
989
+ case 'e':
990
+ e.preventDefault();
991
+ expandAll();
992
+ break;
993
+ case 'c':
994
+ e.preventDefault();
995
+ collapseAll();
996
+ break;
997
+ case 't':
998
+ e.preventDefault();
999
+ if (typeof toggleToc === 'function') toggleToc();
1000
+ break;
878
1001
  }
879
1002
  });
880
1003
  }
@@ -1012,6 +1135,189 @@ function writeUrlState() {
1012
1135
  var url = window.location.pathname + (qs ? '?' + qs : '');
1013
1136
  history.replaceState(null, '', url);
1014
1137
  }
1138
+
1139
+ // Permalink copy
1140
+ function copyPermalink(anchorId) {
1141
+ var url = location.origin + location.pathname + location.search + '#' + anchorId;
1142
+ navigator.clipboard.writeText(url).then(function() {
1143
+ var el = document.getElementById(anchorId);
1144
+ if (el) showCopyToast(el);
1145
+ });
1146
+ }
1147
+
1148
+ function showCopyToast(el) {
1149
+ var existing = el.querySelector('.copy-toast');
1150
+ if (existing) existing.remove();
1151
+ var toast = document.createElement('span');
1152
+ toast.className = 'copy-toast';
1153
+ toast.textContent = 'Copied!';
1154
+ var header = el.querySelector('.feature-header, .scenario-header');
1155
+ if (header) {
1156
+ header.style.position = 'relative';
1157
+ header.appendChild(toast);
1158
+ }
1159
+ setTimeout(function() { toast.remove(); }, 1500);
1160
+ }
1161
+
1162
+ // Copy scenario as markdown
1163
+ function copyScenarioAsMarkdown(scenarioId) {
1164
+ var scenario = document.getElementById(scenarioId);
1165
+ if (!scenario) return;
1166
+
1167
+ var title = (scenario.querySelector('.scenario-name') || {}).textContent || '';
1168
+ var steps = scenario.querySelectorAll('.step, .step.continuation');
1169
+ var lines = ['### Scenario: ' + title.trim(), ''];
1170
+
1171
+ steps.forEach(function(step) {
1172
+ var keyword = step.getAttribute('data-keyword') || '';
1173
+ var text = step.getAttribute('data-text') || '';
1174
+ lines.push('- **' + keyword + '** ' + text);
1175
+ });
1176
+
1177
+ var errorBox = scenario.querySelector('.error-message');
1178
+ if (errorBox) {
1179
+ var errorText = errorBox.textContent || '';
1180
+ lines.push('');
1181
+ lines.push('> **Error:** ' + errorText.trim());
1182
+ }
1183
+
1184
+ var md = lines.join('\\n');
1185
+ navigator.clipboard.writeText(md).then(function() {
1186
+ showCopyToast(scenario);
1187
+ });
1188
+ }
1189
+
1190
+ // Hash scroll on load
1191
+ function initHashScroll() {
1192
+ if (!location.hash) return;
1193
+ var target = document.querySelector(location.hash);
1194
+ if (!target) return;
1195
+ var feature = target.closest('.feature');
1196
+ if (feature && feature.classList.contains('collapsed')) {
1197
+ feature.classList.remove('collapsed');
1198
+ var fh = feature.querySelector('.feature-header');
1199
+ if (fh) fh.setAttribute('aria-expanded', 'true');
1200
+ }
1201
+ if (target.classList.contains('collapsed')) {
1202
+ target.classList.remove('collapsed');
1203
+ var sh = target.querySelector('.scenario-header');
1204
+ if (sh) sh.setAttribute('aria-expanded', 'true');
1205
+ }
1206
+ setTimeout(function() {
1207
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' });
1208
+ target.classList.add('hash-highlight');
1209
+ }, 100);
1210
+ }
1211
+
1212
+ // Table of contents
1213
+ function toggleToc() {
1214
+ var sidebar = document.querySelector('.toc-sidebar');
1215
+ var wrapper = document.querySelector('.report-layout');
1216
+ if (!sidebar || !wrapper) return;
1217
+ var isMobile = window.matchMedia('(max-width: 767px)').matches;
1218
+ if (isMobile) {
1219
+ sidebar.classList.toggle('toc-mobile-open');
1220
+ } else {
1221
+ wrapper.classList.toggle('toc-hidden');
1222
+ var hidden = wrapper.classList.contains('toc-hidden');
1223
+ localStorage.setItem('toc-visible', String(!hidden));
1224
+ }
1225
+ }
1226
+
1227
+ function initToc() {
1228
+ var sidebar = document.querySelector('.toc-sidebar');
1229
+ if (!sidebar) return;
1230
+
1231
+ var saved = localStorage.getItem('toc-visible');
1232
+ var wrapper = document.querySelector('.report-layout');
1233
+ if (saved === 'false' && wrapper) {
1234
+ wrapper.classList.add('toc-hidden');
1235
+ }
1236
+
1237
+ // Active tracking via IntersectionObserver
1238
+ var observer = new IntersectionObserver(function(entries) {
1239
+ entries.forEach(function(entry) {
1240
+ if (entry.isIntersecting) {
1241
+ var id = entry.target.id;
1242
+ if (!id) return;
1243
+ document.querySelectorAll('.toc-scenario, .toc-feature-toggle').forEach(function(el) {
1244
+ el.classList.remove('toc-active');
1245
+ });
1246
+ var tocLink = sidebar.querySelector('a[href="#' + id + '"]');
1247
+ if (tocLink) tocLink.classList.add('toc-active');
1248
+ }
1249
+ });
1250
+ }, { rootMargin: '-10% 0px -80% 0px' });
1251
+
1252
+ document.querySelectorAll('.feature, .scenario').forEach(function(el) {
1253
+ if (el.id) observer.observe(el);
1254
+ });
1255
+
1256
+ // Click navigation: expand collapsed parents
1257
+ sidebar.querySelectorAll('.toc-scenario').forEach(function(link) {
1258
+ link.addEventListener('click', function(e) {
1259
+ var hash = link.getAttribute('href');
1260
+ if (!hash) return;
1261
+ var target = document.querySelector(hash);
1262
+ if (!target) return;
1263
+ var feature = target.closest('.feature');
1264
+ if (feature && feature.classList.contains('collapsed')) {
1265
+ feature.classList.remove('collapsed');
1266
+ var fh = feature.querySelector('.feature-header');
1267
+ if (fh) fh.setAttribute('aria-expanded', 'true');
1268
+ }
1269
+ if (target.classList.contains('collapsed')) {
1270
+ target.classList.remove('collapsed');
1271
+ var sh = target.querySelector('.scenario-header');
1272
+ if (sh) sh.setAttribute('aria-expanded', 'true');
1273
+ }
1274
+ });
1275
+ });
1276
+ }
1277
+
1278
+ // Theme picker
1279
+ function initThemePicker() {
1280
+ var picker = document.querySelector('.theme-picker');
1281
+ if (!picker) return;
1282
+
1283
+ var saved = localStorage.getItem('report-theme');
1284
+ if (saved) {
1285
+ picker.value = saved;
1286
+ switchReportTheme(saved);
1287
+ }
1288
+
1289
+ picker.addEventListener('change', function(e) {
1290
+ switchReportTheme(e.target.value);
1291
+ localStorage.setItem('report-theme', e.target.value);
1292
+ });
1293
+ }
1294
+
1295
+ function switchReportTheme(name) {
1296
+ document.querySelectorAll('style[data-theme-name]').forEach(function(s) {
1297
+ s.disabled = s.dataset.themeName !== name;
1298
+ });
1299
+ }
1300
+
1301
+ // Sync TOC visibility with filters
1302
+ function syncTocVisibility() {
1303
+ var sidebar = document.querySelector('.toc-sidebar');
1304
+ if (!sidebar) return;
1305
+
1306
+ sidebar.querySelectorAll('.toc-scenario').forEach(function(link) {
1307
+ var href = link.getAttribute('href');
1308
+ if (!href) return;
1309
+ var target = document.querySelector(href);
1310
+ link.style.display = (target && target.style.display !== 'none') ? '' : 'none';
1311
+ });
1312
+
1313
+ sidebar.querySelectorAll('.toc-feature').forEach(function(feature) {
1314
+ var visibleScenarios = feature.querySelectorAll('.toc-scenario');
1315
+ var anyVisible = Array.from(visibleScenarios).some(function(s) {
1316
+ return s.style.display !== 'none';
1317
+ });
1318
+ feature.style.display = anyVisible ? '' : 'none';
1319
+ });
1320
+ }
1015
1321
  `;
1016
1322
  var JS_MARKDOWN_FN = `
1017
1323
  function parseMarkdownSections(marked) {
@@ -1052,6 +1358,9 @@ function generateScript(options) {
1052
1358
  initCalls.push("initCollapse();");
1053
1359
  initCalls.push("initDetailLevel();");
1054
1360
  initCalls.push("applyAllFilters();");
1361
+ initCalls.push("initHashScroll();");
1362
+ initCalls.push("initToc();");
1363
+ initCalls.push("initThemePicker();");
1055
1364
  const initScript = `
1056
1365
  // Initialize on load
1057
1366
  document.addEventListener('DOMContentLoaded', () => {
@@ -1113,6 +1422,7 @@ function generateHtmlTemplate(title, styles, body, options = {}) {
1113
1422
  }
1114
1423
  const cdnStylesHtml = cdnStyles.length > 0 ? "\n " + cdnStyles.join("\n ") : "";
1115
1424
  const esmScriptHtml = generateEsmScript(options);
1425
+ const additionalThemeStyles = (options.additionalThemeCss ?? []).map((t) => `<style data-theme-name="${escapeHtml(t.name)}" disabled>${t.css}</style>`).join("\n ");
1116
1426
  return `<!DOCTYPE html>
1117
1427
  <html lang="en"${themeAttr} data-detail-level="full">
1118
1428
  <head>
@@ -1120,19 +1430,27 @@ function generateHtmlTemplate(title, styles, body, options = {}) {
1120
1430
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1121
1431
  <meta name="color-scheme" content="light dark">
1122
1432
  <title>${escapeHtml(title)}</title>${cdnStylesHtml}
1123
- <style>${styles}</style>
1433
+ <style${options.additionalThemeCss ? ` data-theme-name="${escapeHtml(options.activeThemeName ?? "default")}"` : ""}>${styles}</style>
1434
+ ${additionalThemeStyles}
1124
1435
  </head>
1125
1436
  <body>
1126
- <div class="container">
1127
- <header class="header">
1128
- <h1>${escapeHtml(title)}</h1>
1129
- <div class="header-actions">
1130
- ${includeSearch ? '<input type="text" class="search-input" placeholder="Search scenarios..." aria-label="Search scenarios">' : ""}
1131
- <button type="button" class="detail-toggle" onclick="toggleDetailLevel()" aria-label="Toggle detail level" title="Toggle documentation detail"></button>
1132
- ${includeDarkMode ? '<button type="button" class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle theme"></button>' : ""}
1437
+ <div class="report-layout">
1438
+ ${options.tocHtml ?? ""}
1439
+ <div class="main-content">
1440
+ <div class="container">
1441
+ <header class="header">
1442
+ <h1>${escapeHtml(title)}</h1>
1443
+ <div class="header-actions">
1444
+ <button type="button" class="toc-toggle" onclick="toggleToc()" aria-label="Toggle table of contents" title="Toggle contents">&#x2630;</button>
1445
+ ${includeSearch ? '<input type="text" class="search-input" placeholder="Search scenarios..." aria-label="Search scenarios">' : ""}
1446
+ <button type="button" class="detail-toggle" onclick="toggleDetailLevel()" aria-label="Toggle detail level" title="Toggle documentation detail"></button>
1447
+ ${options.themePickerHtml ?? ""}
1448
+ ${includeDarkMode ? '<button type="button" class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle theme"></button>' : ""}
1449
+ </div>
1450
+ </header>
1451
+ ${body}
1133
1452
  </div>
1134
- </header>
1135
- ${body}
1453
+ </div>
1136
1454
  </div>
1137
1455
  <script>${script}</script>${esmScriptHtml}
1138
1456
  </body>
@@ -2836,6 +3154,337 @@ body {
2836
3154
  display: none;
2837
3155
  }
2838
3156
 
3157
+ /* ============================================================================
3158
+ Permalink Anchors
3159
+ ============================================================================ */
3160
+ .permalink-anchor {
3161
+ display: inline-flex;
3162
+ align-items: center;
3163
+ justify-content: center;
3164
+ width: 1.5rem;
3165
+ height: 1.5rem;
3166
+ border: none;
3167
+ background: none;
3168
+ color: var(--muted-foreground);
3169
+ cursor: pointer;
3170
+ opacity: 0;
3171
+ transition: opacity 0.15s ease;
3172
+ font-size: 0.875rem;
3173
+ font-weight: 600;
3174
+ padding: 0;
3175
+ flex-shrink: 0;
3176
+ }
3177
+
3178
+ .feature-header:hover .permalink-anchor,
3179
+ .scenario-header:hover .permalink-anchor,
3180
+ .permalink-anchor:focus-visible {
3181
+ opacity: 1;
3182
+ }
3183
+
3184
+ .permalink-anchor:hover {
3185
+ color: var(--primary);
3186
+ }
3187
+
3188
+ .copy-toast {
3189
+ position: absolute;
3190
+ right: 0.5rem;
3191
+ top: 50%;
3192
+ transform: translateY(-50%);
3193
+ background: var(--foreground);
3194
+ color: var(--background);
3195
+ padding: 0.25rem 0.5rem;
3196
+ border-radius: var(--radius);
3197
+ font-size: 0.75rem;
3198
+ font-weight: 500;
3199
+ pointer-events: none;
3200
+ animation: fadeOut 1.5s ease forwards;
3201
+ z-index: 10;
3202
+ }
3203
+
3204
+ @keyframes fadeOut {
3205
+ 0%, 70% { opacity: 1; }
3206
+ 100% { opacity: 0; }
3207
+ }
3208
+
3209
+ .hash-highlight {
3210
+ animation: hashPulse 2s ease;
3211
+ }
3212
+
3213
+ @keyframes hashPulse {
3214
+ 0%, 100% { background: transparent; }
3215
+ 20% { background: color-mix(in srgb, var(--primary) 12%, transparent); }
3216
+ }
3217
+
3218
+ .scenario-actions {
3219
+ display: flex;
3220
+ align-items: center;
3221
+ gap: 0.25rem;
3222
+ flex-shrink: 0;
3223
+ }
3224
+
3225
+ .copy-scenario-btn {
3226
+ display: inline-flex;
3227
+ align-items: center;
3228
+ justify-content: center;
3229
+ width: 1.5rem;
3230
+ height: 1.5rem;
3231
+ border: none;
3232
+ background: none;
3233
+ color: var(--muted-foreground);
3234
+ cursor: pointer;
3235
+ opacity: 0;
3236
+ transition: opacity 0.15s ease;
3237
+ font-size: 0.875rem;
3238
+ padding: 0;
3239
+ flex-shrink: 0;
3240
+ }
3241
+
3242
+ .scenario-header:hover .copy-scenario-btn,
3243
+ .copy-scenario-btn:focus-visible {
3244
+ opacity: 1;
3245
+ }
3246
+
3247
+ .copy-scenario-btn:hover {
3248
+ color: var(--primary);
3249
+ }
3250
+
3251
+ /* ============================================================================
3252
+ Keyboard Navigation
3253
+ ============================================================================ */
3254
+ .scenario-focused {
3255
+ border-left: 2px solid var(--primary);
3256
+ }
3257
+
3258
+ .shortcuts-overlay {
3259
+ position: fixed;
3260
+ inset: 0;
3261
+ background: rgb(0 0 0 / 0.5);
3262
+ display: flex;
3263
+ align-items: center;
3264
+ justify-content: center;
3265
+ z-index: 100;
3266
+ }
3267
+
3268
+ .shortcuts-modal {
3269
+ background: var(--card);
3270
+ color: var(--card-foreground);
3271
+ border: 1px solid var(--border);
3272
+ border-radius: calc(var(--radius) * 2);
3273
+ padding: 1.5rem 2rem;
3274
+ max-width: 400px;
3275
+ width: 90vw;
3276
+ box-shadow: var(--shadow-md, 0 4px 12px rgb(0 0 0 / 0.15));
3277
+ }
3278
+
3279
+ .shortcuts-title {
3280
+ font-weight: 600;
3281
+ font-size: 1.125rem;
3282
+ margin-bottom: 1rem;
3283
+ padding-bottom: 0.5rem;
3284
+ border-bottom: 1px solid var(--border);
3285
+ }
3286
+
3287
+ .shortcuts-grid {
3288
+ display: grid;
3289
+ grid-template-columns: auto 1fr;
3290
+ gap: 0.5rem 1rem;
3291
+ align-items: center;
3292
+ }
3293
+
3294
+ .shortcuts-grid kbd {
3295
+ display: inline-flex;
3296
+ align-items: center;
3297
+ justify-content: center;
3298
+ min-width: 1.75rem;
3299
+ padding: 0.125rem 0.375rem;
3300
+ background: var(--muted);
3301
+ border: 1px solid var(--border);
3302
+ border-radius: calc(var(--radius) * 0.5);
3303
+ font-family: var(--font-mono);
3304
+ font-size: 0.75rem;
3305
+ font-weight: 500;
3306
+ color: var(--muted-foreground);
3307
+ }
3308
+
3309
+ .shortcuts-grid span {
3310
+ font-size: 0.875rem;
3311
+ color: var(--foreground);
3312
+ }
3313
+
3314
+ /* ============================================================================
3315
+ Table of Contents Sidebar
3316
+ ============================================================================ */
3317
+ .report-layout {
3318
+ display: flex;
3319
+ min-height: 100vh;
3320
+ }
3321
+
3322
+ .report-layout.toc-hidden .toc-sidebar {
3323
+ display: none;
3324
+ }
3325
+
3326
+ .main-content {
3327
+ flex: 1;
3328
+ min-width: 0;
3329
+ }
3330
+
3331
+ .toc-sidebar {
3332
+ width: 260px;
3333
+ flex-shrink: 0;
3334
+ position: sticky;
3335
+ top: 0;
3336
+ height: 100vh;
3337
+ overflow-y: auto;
3338
+ border-right: 1px solid var(--border);
3339
+ background: var(--card);
3340
+ padding: 1rem 0;
3341
+ font-size: 0.8125rem;
3342
+ }
3343
+
3344
+ .toc-header {
3345
+ padding: 0 1rem 0.75rem;
3346
+ border-bottom: 1px solid var(--border);
3347
+ margin-bottom: 0.5rem;
3348
+ }
3349
+
3350
+ .toc-title {
3351
+ font-weight: 600;
3352
+ font-size: 0.875rem;
3353
+ color: var(--foreground);
3354
+ text-decoration: none;
3355
+ cursor: pointer;
3356
+ }
3357
+
3358
+ a.toc-title:hover {
3359
+ color: var(--primary);
3360
+ }
3361
+
3362
+ .toc-feature {
3363
+ margin-bottom: 0.25rem;
3364
+ }
3365
+
3366
+ .toc-feature-toggle {
3367
+ display: flex;
3368
+ align-items: center;
3369
+ width: 100%;
3370
+ padding: 0.375rem 1rem;
3371
+ border: none;
3372
+ background: none;
3373
+ text-align: left;
3374
+ cursor: pointer;
3375
+ font-size: 0.8125rem;
3376
+ font-weight: 600;
3377
+ color: var(--foreground);
3378
+ font-family: var(--font-sans);
3379
+ }
3380
+
3381
+ .toc-feature-toggle:hover {
3382
+ background: var(--accent);
3383
+ }
3384
+
3385
+ .toc-feature-toggle[aria-expanded="false"] + .toc-scenarios {
3386
+ display: none;
3387
+ }
3388
+
3389
+ .toc-scenarios {
3390
+ display: flex;
3391
+ flex-direction: column;
3392
+ }
3393
+
3394
+ .toc-scenario {
3395
+ display: flex;
3396
+ align-items: baseline;
3397
+ gap: 0.375rem;
3398
+ padding: 0.25rem 1rem 0.25rem 1.5rem;
3399
+ color: var(--muted-foreground);
3400
+ text-decoration: none;
3401
+ font-size: 0.8125rem;
3402
+ line-height: 1.4;
3403
+ border-left: 2px solid transparent;
3404
+ transition: all 0.1s ease;
3405
+ }
3406
+
3407
+ .toc-scenario:hover {
3408
+ color: var(--foreground);
3409
+ background: var(--accent);
3410
+ }
3411
+
3412
+ .toc-scenario.toc-active {
3413
+ color: var(--foreground);
3414
+ border-left-color: var(--primary);
3415
+ font-weight: 500;
3416
+ }
3417
+
3418
+ .toc-scenario.toc-failed {
3419
+ border-left-color: var(--error, var(--destructive));
3420
+ }
3421
+
3422
+ .toc-status {
3423
+ flex-shrink: 0;
3424
+ font-size: 0.75rem;
3425
+ }
3426
+
3427
+ .toc-toggle {
3428
+ display: inline-flex;
3429
+ align-items: center;
3430
+ justify-content: center;
3431
+ width: 2.25rem;
3432
+ height: 2.25rem;
3433
+ border: 1px solid var(--border);
3434
+ border-radius: var(--radius);
3435
+ background: var(--background);
3436
+ cursor: pointer;
3437
+ color: var(--foreground);
3438
+ font-size: 1rem;
3439
+ transition: all 0.15s ease;
3440
+ }
3441
+
3442
+ .toc-toggle:hover {
3443
+ background: var(--accent);
3444
+ }
3445
+
3446
+ /* Mobile: overlay sidebar */
3447
+ @media (max-width: 767px) {
3448
+ .toc-sidebar {
3449
+ position: fixed;
3450
+ left: 0;
3451
+ top: 0;
3452
+ z-index: 50;
3453
+ box-shadow: var(--shadow-sm, 0 1px 3px rgb(0 0 0 / 0.1));
3454
+ transform: translateX(-100%);
3455
+ transition: transform 0.2s ease;
3456
+ }
3457
+
3458
+ .toc-sidebar.toc-mobile-open {
3459
+ transform: translateX(0);
3460
+ }
3461
+ }
3462
+
3463
+ /* ============================================================================
3464
+ Theme Picker
3465
+ ============================================================================ */
3466
+ .theme-picker {
3467
+ height: 2.25rem;
3468
+ padding: 0 0.5rem;
3469
+ border: 1px solid var(--border);
3470
+ border-radius: var(--radius);
3471
+ background: var(--background);
3472
+ color: var(--foreground);
3473
+ font-size: 0.8125rem;
3474
+ font-family: var(--font-sans);
3475
+ cursor: pointer;
3476
+ transition: all 0.15s ease;
3477
+ }
3478
+
3479
+ .theme-picker:hover {
3480
+ background: var(--accent);
3481
+ }
3482
+
3483
+ .theme-picker:focus-visible {
3484
+ outline: 2px solid var(--ring);
3485
+ outline-offset: 2px;
3486
+ }
3487
+
2839
3488
  `;
2840
3489
 
2841
3490
  // src/formatters/html/themes/default.ts
@@ -2885,7 +3534,7 @@ function corporateBuildBody(args, deps) {
2885
3534
  const sidebar = `
2886
3535
  <nav class="toc">
2887
3536
  <div class="toc-header">
2888
- <div class="toc-title">Test Report</div>
3537
+ <a href="#" class="toc-title" onclick="window.scrollTo({top:0,behavior:'smooth'});return false;">Test Report</a>
2889
3538
  <div class="toc-stats">
2890
3539
  <div class="toc-stat-row">
2891
3540
  <span class="toc-stat-label">Total</span>
@@ -12095,6 +12744,11 @@ function resolveTheme(nameOrTheme) {
12095
12744
  function getAvailableThemes() {
12096
12745
  return [...THEME_REGISTRY.keys()];
12097
12746
  }
12747
+ function getCssOnlyThemes() {
12748
+ return [...THEME_REGISTRY.values()].filter(
12749
+ (theme) => !theme.buildBody && !theme.generateTemplate
12750
+ );
12751
+ }
12098
12752
 
12099
12753
  // src/formatters/html/renderers/status.ts
12100
12754
  function getStatusIcon(status) {
@@ -12376,7 +13030,7 @@ function renderStep(step, stepResult, index, deps) {
12376
13030
  const stepClass = isContinuation ? "step continuation" : "step";
12377
13031
  const stepDocs = deps.renderDocs(step.docs, "step-docs");
12378
13032
  const textHtml = deps.highlightStepParams ? deps.highlightStepParams(step.text) : deps.escapeHtml(step.text);
12379
- return `<div class="${stepClass}">
13033
+ return `<div class="${stepClass}" data-keyword="${deps.escapeHtml(keywordTrimmed)}" data-text="${deps.escapeHtml(step.text)}">
12380
13034
  <span class="step-status ${statusClass}">${statusIcon}</span>
12381
13035
  <span class="step-keyword">${deps.escapeHtml(step.keyword)}</span>
12382
13036
  <span class="step-text">${textHtml}</span>
@@ -12507,7 +13161,11 @@ function renderScenario(args, deps) {
12507
13161
  </div>
12508
13162
  <div class="scenario-meta">${tags}${tickets}${sourceLink}${traceBadge}${metricBadges}</div>
12509
13163
  </div>
12510
- <span class="scenario-duration">${duration}</span>
13164
+ <div class="scenario-actions">
13165
+ <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>
13166
+ <button class="permalink-anchor" onclick="copyPermalink('scenario-${tc.id}')" aria-label="Copy link to scenario" title="Copy link">#</button>
13167
+ <span class="scenario-duration">${duration}</span>
13168
+ </div>
12511
13169
  </div>
12512
13170
  <div class="scenario-content">
12513
13171
  ${storyDocs}
@@ -12717,6 +13375,7 @@ function renderFeature(args, deps) {
12717
13375
  const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
12718
13376
  const collapsedClass = deps.startCollapsed ? " collapsed" : "";
12719
13377
  const ariaExpanded = !deps.startCollapsed;
13378
+ const featureSlug = `feature-${slugify(file)}`;
12720
13379
  const scenarios = testCases.map(
12721
13380
  (tc) => deps.renderScenario(
12722
13381
  { tc, metrics: args.metricsMap?.get(tc.id) },
@@ -12724,8 +13383,9 @@ function renderFeature(args, deps) {
12724
13383
  )
12725
13384
  ).join("\n");
12726
13385
  return `
12727
- <div class="feature${collapsedClass}">
13386
+ <div class="feature${collapsedClass}" id="${featureSlug}">
12728
13387
  <div class="feature-header" role="button" tabindex="0" aria-expanded="${ariaExpanded}">
13388
+ <button class="permalink-anchor" onclick="copyPermalink('${featureSlug}')" aria-label="Copy link to feature" title="Copy link">#</button>
12729
13389
  <div class="feature-info">
12730
13390
  <div class="feature-title">${deps.escapeHtml(featureName)}</div>
12731
13391
  <div class="feature-path">${deps.escapeHtml(file)}</div>
@@ -12838,6 +13498,57 @@ function renderFailureSummary(args, deps) {
12838
13498
  </div>`;
12839
13499
  }
12840
13500
 
13501
+ // src/formatters/html/renderers/toc.ts
13502
+ function groupBy4(items, keyFn) {
13503
+ const map = /* @__PURE__ */ new Map();
13504
+ for (const item of items) {
13505
+ const key = keyFn(item);
13506
+ const existing = map.get(key);
13507
+ if (existing) {
13508
+ existing.push(item);
13509
+ } else {
13510
+ map.set(key, [item]);
13511
+ }
13512
+ }
13513
+ return map;
13514
+ }
13515
+ function renderToc(args, deps) {
13516
+ const { run } = args;
13517
+ if (run.testCases.length === 0) return "";
13518
+ const byFile = groupBy4(run.testCases, (tc) => tc.sourceFile);
13519
+ const features = [];
13520
+ for (const [file, testCases] of byFile) {
13521
+ const suitePaths = testCases.map((tc) => tc.titlePath).filter((p) => p.length > 0);
13522
+ const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
13523
+ const featureSlug = `feature-${slugify(file)}`;
13524
+ const scenarios = testCases.map((tc) => {
13525
+ const statusIcon = deps.getStatusIcon(tc.status);
13526
+ const statusClass = `status-${tc.status}`;
13527
+ const failedClass = tc.status === "failed" ? " toc-failed" : "";
13528
+ return `<a class="toc-scenario${failedClass}" href="#scenario-${tc.id}">
13529
+ <span class="toc-status ${statusClass}">${statusIcon}</span>
13530
+ ${deps.escapeHtml(tc.story.scenario)}
13531
+ </a>`;
13532
+ }).join("\n");
13533
+ features.push(`<div class="toc-feature">
13534
+ <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}">
13535
+ ${deps.escapeHtml(featureName)}
13536
+ </button>
13537
+ <div class="toc-scenarios">
13538
+ ${scenarios}
13539
+ </div>
13540
+ </div>`);
13541
+ }
13542
+ return `<nav class="toc-sidebar" aria-label="Table of contents">
13543
+ <div class="toc-header">
13544
+ <a href="#" class="toc-title" onclick="window.scrollTo({top:0,behavior:'smooth'});return false;">Contents</a>
13545
+ </div>
13546
+ <div class="toc-body">
13547
+ ${features.join("\n")}
13548
+ </div>
13549
+ </nav>`;
13550
+ }
13551
+
12841
13552
  // src/formatters/html/renderers/index.ts
12842
13553
  function normalizeOptions(options = {}) {
12843
13554
  return {
@@ -12851,7 +13562,9 @@ function normalizeOptions(options = {}) {
12851
13562
  markdownEnabled: options.markdownEnabled ?? true,
12852
13563
  permalinkBaseUrl: options.permalinkBaseUrl,
12853
13564
  ticketUrlTemplate: options.ticketUrlTemplate,
12854
- theme: options.theme ?? "default"
13565
+ tocEnabled: options.tocEnabled ?? true,
13566
+ theme: options.theme ?? "default",
13567
+ themePickerEnabled: options.themePickerEnabled ?? false
12855
13568
  };
12856
13569
  }
12857
13570
  function createHtmlFormatter(options = {}) {
@@ -12893,6 +13606,10 @@ function createHtmlFormatter(options = {}) {
12893
13606
  scenarioDeps
12894
13607
  };
12895
13608
  const tagBarDeps = { escapeHtml };
13609
+ const tocDeps = {
13610
+ escapeHtml,
13611
+ getStatusIcon
13612
+ };
12896
13613
  const bodyDeps = {
12897
13614
  renderMetaInfo,
12898
13615
  renderSummary,
@@ -12911,6 +13628,16 @@ function createHtmlFormatter(options = {}) {
12911
13628
  const bodyFn = theme.buildBody ?? buildBody;
12912
13629
  const body = bodyFn({ run }, bodyDeps);
12913
13630
  const templateFn = theme.generateTemplate ?? generateHtmlTemplate;
13631
+ const isStructuralTheme = !!(theme.buildBody || theme.generateTemplate);
13632
+ const tocHtml = opts.tocEnabled && !isStructuralTheme ? renderToc({ run }, tocDeps) : void 0;
13633
+ let themePickerHtml;
13634
+ let additionalThemeCss;
13635
+ if (opts.themePickerEnabled) {
13636
+ const cssOnlyThemes = getCssOnlyThemes();
13637
+ const pickerOptions = cssOnlyThemes.map((t) => `<option value="${t.name}"${t.name === theme.name ? " selected" : ""}>${t.label}</option>`).join("");
13638
+ themePickerHtml = `<select class="theme-picker" aria-label="Select theme">${pickerOptions}</select>`;
13639
+ additionalThemeCss = cssOnlyThemes.filter((t) => t.name !== theme.name).map((t) => ({ name: t.name, label: t.label, css: t.css }));
13640
+ }
12914
13641
  return templateFn(
12915
13642
  opts.title,
12916
13643
  theme.css,
@@ -12922,7 +13649,11 @@ function createHtmlFormatter(options = {}) {
12922
13649
  mermaidEnabled: opts.mermaidEnabled,
12923
13650
  markdownEnabled: opts.markdownEnabled,
12924
13651
  additionalJs: theme.additionalJs,
12925
- additionalImports: theme.additionalImports
13652
+ additionalImports: theme.additionalImports,
13653
+ tocHtml,
13654
+ themePickerHtml,
13655
+ additionalThemeCss,
13656
+ activeThemeName: theme.name
12926
13657
  }
12927
13658
  );
12928
13659
  }
@@ -12978,7 +13709,7 @@ var JUnitFormatter = class {
12978
13709
  lines.push(
12979
13710
  `<testsuites name="${escapeXml(this.options.suiteName)}" tests="${tests}" failures="${failures}" errors="${errors}" skipped="${skipped}" time="${time}">`
12980
13711
  );
12981
- const byFile = groupBy4(run.testCases, (tc) => tc.sourceFile);
13712
+ const byFile = groupBy5(run.testCases, (tc) => tc.sourceFile);
12982
13713
  for (const [file, testCases] of byFile) {
12983
13714
  lines.push(...this.buildTestSuite(file, testCases, indent, newline));
12984
13715
  }
@@ -13151,7 +13882,7 @@ var JUnitFormatter = class {
13151
13882
  function escapeXml(str) {
13152
13883
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
13153
13884
  }
13154
- function groupBy4(items, keyFn) {
13885
+ function groupBy5(items, keyFn) {
13155
13886
  const map = /* @__PURE__ */ new Map();
13156
13887
  for (const item of items) {
13157
13888
  const key = keyFn(item);
@@ -13326,7 +14057,7 @@ var MarkdownFormatter = class {
13326
14057
  * Render scenarios grouped by file.
13327
14058
  */
13328
14059
  renderByFile(lines, testCases) {
13329
- const byFile = groupBy5(testCases, (tc) => tc.sourceFile);
14060
+ const byFile = groupBy6(testCases, (tc) => tc.sourceFile);
13330
14061
  for (const [file, fileTestCases] of byFile) {
13331
14062
  lines.push(`## ${file}`);
13332
14063
  lines.push("");
@@ -13343,7 +14074,7 @@ var MarkdownFormatter = class {
13343
14074
  * Render suite groups.
13344
14075
  */
13345
14076
  renderSuiteGroups(lines, testCases, baseLevel) {
13346
- const bySuite = groupBy5(
14077
+ const bySuite = groupBy6(
13347
14078
  testCases,
13348
14079
  (tc) => tc.titlePath.join(this.options.suiteSeparator)
13349
14080
  );
@@ -13637,7 +14368,7 @@ var MarkdownFormatter = class {
13637
14368
  return entries;
13638
14369
  }
13639
14370
  };
13640
- function groupBy5(items, keyFn) {
14371
+ function groupBy6(items, keyFn) {
13641
14372
  const map = /* @__PURE__ */ new Map();
13642
14373
  for (const item of items) {
13643
14374
  const key = keyFn(item);
@@ -17348,7 +18079,7 @@ var ReportGenerator = class {
17348
18079
  excludeTags: options.excludeTags ?? [],
17349
18080
  formats: options.formats ?? ["cucumber-json"],
17350
18081
  outputDir: options.outputDir ?? "reports",
17351
- outputName: options.outputName ?? "test-results",
18082
+ outputName: options.outputName ?? "index",
17352
18083
  outputNameTimestamp: options.outputNameTimestamp ?? false,
17353
18084
  sortTestCases: options.sortTestCases ?? "none",
17354
18085
  output: {
@@ -17377,7 +18108,9 @@ var ReportGenerator = class {
17377
18108
  markdownEnabled: options.html?.markdownEnabled ?? true,
17378
18109
  permalinkBaseUrl: options.html?.permalinkBaseUrl,
17379
18110
  ticketUrlTemplate: options.html?.ticketUrlTemplate,
17380
- theme: options.html?.theme ?? "default"
18111
+ theme: options.html?.theme ?? "default",
18112
+ tocEnabled: options.html?.tocEnabled ?? true,
18113
+ themePickerEnabled: options.html?.themePickerEnabled ?? false
17381
18114
  },
17382
18115
  junit: {
17383
18116
  suiteName: options.junit?.suiteName ?? "Test Suite",
@@ -17500,7 +18233,9 @@ var ReportGenerator = class {
17500
18233
  mermaidEnabled: this.options.html.mermaidEnabled,
17501
18234
  markdownEnabled: this.options.html.markdownEnabled,
17502
18235
  permalinkBaseUrl: this.options.html.permalinkBaseUrl,
17503
- ticketUrlTemplate: this.options.html.ticketUrlTemplate
18236
+ ticketUrlTemplate: this.options.html.ticketUrlTemplate,
18237
+ tocEnabled: this.options.html.tocEnabled,
18238
+ themePickerEnabled: this.options.html.themePickerEnabled
17504
18239
  });
17505
18240
  return formatter.format(run);
17506
18241
  }
@@ -17623,6 +18358,7 @@ export {
17623
18358
  generateRunId,
17624
18359
  generateTestCaseId,
17625
18360
  getAvailableThemes,
18361
+ getCssOnlyThemes,
17626
18362
  hasSufficientHistory,
17627
18363
  listScenarios,
17628
18364
  loadHistory,