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.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,331 @@ 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
+ }
3355
+
3356
+ .toc-feature {
3357
+ margin-bottom: 0.25rem;
3358
+ }
3359
+
3360
+ .toc-feature-toggle {
3361
+ display: flex;
3362
+ align-items: center;
3363
+ width: 100%;
3364
+ padding: 0.375rem 1rem;
3365
+ border: none;
3366
+ background: none;
3367
+ text-align: left;
3368
+ cursor: pointer;
3369
+ font-size: 0.8125rem;
3370
+ font-weight: 600;
3371
+ color: var(--foreground);
3372
+ font-family: var(--font-sans);
3373
+ }
3374
+
3375
+ .toc-feature-toggle:hover {
3376
+ background: var(--accent);
3377
+ }
3378
+
3379
+ .toc-feature-toggle[aria-expanded="false"] + .toc-scenarios {
3380
+ display: none;
3381
+ }
3382
+
3383
+ .toc-scenarios {
3384
+ display: flex;
3385
+ flex-direction: column;
3386
+ }
3387
+
3388
+ .toc-scenario {
3389
+ display: flex;
3390
+ align-items: baseline;
3391
+ gap: 0.375rem;
3392
+ padding: 0.25rem 1rem 0.25rem 1.5rem;
3393
+ color: var(--muted-foreground);
3394
+ text-decoration: none;
3395
+ font-size: 0.8125rem;
3396
+ line-height: 1.4;
3397
+ border-left: 2px solid transparent;
3398
+ transition: all 0.1s ease;
3399
+ }
3400
+
3401
+ .toc-scenario:hover {
3402
+ color: var(--foreground);
3403
+ background: var(--accent);
3404
+ }
3405
+
3406
+ .toc-scenario.toc-active {
3407
+ color: var(--foreground);
3408
+ border-left-color: var(--primary);
3409
+ font-weight: 500;
3410
+ }
3411
+
3412
+ .toc-scenario.toc-failed {
3413
+ border-left-color: var(--error, var(--destructive));
3414
+ }
3415
+
3416
+ .toc-status {
3417
+ flex-shrink: 0;
3418
+ font-size: 0.75rem;
3419
+ }
3420
+
3421
+ .toc-toggle {
3422
+ display: inline-flex;
3423
+ align-items: center;
3424
+ justify-content: center;
3425
+ width: 2.25rem;
3426
+ height: 2.25rem;
3427
+ border: 1px solid var(--border);
3428
+ border-radius: var(--radius);
3429
+ background: var(--background);
3430
+ cursor: pointer;
3431
+ color: var(--foreground);
3432
+ font-size: 1rem;
3433
+ transition: all 0.15s ease;
3434
+ }
3435
+
3436
+ .toc-toggle:hover {
3437
+ background: var(--accent);
3438
+ }
3439
+
3440
+ /* Mobile: overlay sidebar */
3441
+ @media (max-width: 767px) {
3442
+ .toc-sidebar {
3443
+ position: fixed;
3444
+ left: 0;
3445
+ top: 0;
3446
+ z-index: 50;
3447
+ box-shadow: var(--shadow-sm, 0 1px 3px rgb(0 0 0 / 0.1));
3448
+ transform: translateX(-100%);
3449
+ transition: transform 0.2s ease;
3450
+ }
3451
+
3452
+ .toc-sidebar.toc-mobile-open {
3453
+ transform: translateX(0);
3454
+ }
3455
+ }
3456
+
3457
+ /* ============================================================================
3458
+ Theme Picker
3459
+ ============================================================================ */
3460
+ .theme-picker {
3461
+ height: 2.25rem;
3462
+ padding: 0 0.5rem;
3463
+ border: 1px solid var(--border);
3464
+ border-radius: var(--radius);
3465
+ background: var(--background);
3466
+ color: var(--foreground);
3467
+ font-size: 0.8125rem;
3468
+ font-family: var(--font-sans);
3469
+ cursor: pointer;
3470
+ transition: all 0.15s ease;
3471
+ }
3472
+
3473
+ .theme-picker:hover {
3474
+ background: var(--accent);
3475
+ }
3476
+
3477
+ .theme-picker:focus-visible {
3478
+ outline: 2px solid var(--ring);
3479
+ outline-offset: 2px;
3480
+ }
3481
+
2839
3482
  `;
2840
3483
 
2841
3484
  // src/formatters/html/themes/default.ts
@@ -12095,6 +12738,11 @@ function resolveTheme(nameOrTheme) {
12095
12738
  function getAvailableThemes() {
12096
12739
  return [...THEME_REGISTRY.keys()];
12097
12740
  }
12741
+ function getCssOnlyThemes() {
12742
+ return [...THEME_REGISTRY.values()].filter(
12743
+ (theme) => !theme.buildBody && !theme.generateTemplate
12744
+ );
12745
+ }
12098
12746
 
12099
12747
  // src/formatters/html/renderers/status.ts
12100
12748
  function getStatusIcon(status) {
@@ -12376,7 +13024,7 @@ function renderStep(step, stepResult, index, deps) {
12376
13024
  const stepClass = isContinuation ? "step continuation" : "step";
12377
13025
  const stepDocs = deps.renderDocs(step.docs, "step-docs");
12378
13026
  const textHtml = deps.highlightStepParams ? deps.highlightStepParams(step.text) : deps.escapeHtml(step.text);
12379
- return `<div class="${stepClass}">
13027
+ return `<div class="${stepClass}" data-keyword="${deps.escapeHtml(keywordTrimmed)}" data-text="${deps.escapeHtml(step.text)}">
12380
13028
  <span class="step-status ${statusClass}">${statusIcon}</span>
12381
13029
  <span class="step-keyword">${deps.escapeHtml(step.keyword)}</span>
12382
13030
  <span class="step-text">${textHtml}</span>
@@ -12507,7 +13155,11 @@ function renderScenario(args, deps) {
12507
13155
  </div>
12508
13156
  <div class="scenario-meta">${tags}${tickets}${sourceLink}${traceBadge}${metricBadges}</div>
12509
13157
  </div>
12510
- <span class="scenario-duration">${duration}</span>
13158
+ <div class="scenario-actions">
13159
+ <button class="copy-scenario-btn" onclick="copyScenarioAsMarkdown('scenario-${tc.id}')" aria-label="Copy scenario as markdown" title="Copy as Markdown">&#x2398;</button>
13160
+ <button class="permalink-anchor" onclick="copyPermalink('scenario-${tc.id}')" aria-label="Copy link to scenario" title="Copy link">#</button>
13161
+ <span class="scenario-duration">${duration}</span>
13162
+ </div>
12511
13163
  </div>
12512
13164
  <div class="scenario-content">
12513
13165
  ${storyDocs}
@@ -12717,6 +13369,7 @@ function renderFeature(args, deps) {
12717
13369
  const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
12718
13370
  const collapsedClass = deps.startCollapsed ? " collapsed" : "";
12719
13371
  const ariaExpanded = !deps.startCollapsed;
13372
+ const featureSlug = `feature-${slugify(file)}`;
12720
13373
  const scenarios = testCases.map(
12721
13374
  (tc) => deps.renderScenario(
12722
13375
  { tc, metrics: args.metricsMap?.get(tc.id) },
@@ -12724,8 +13377,9 @@ function renderFeature(args, deps) {
12724
13377
  )
12725
13378
  ).join("\n");
12726
13379
  return `
12727
- <div class="feature${collapsedClass}">
13380
+ <div class="feature${collapsedClass}" id="${featureSlug}">
12728
13381
  <div class="feature-header" role="button" tabindex="0" aria-expanded="${ariaExpanded}">
13382
+ <button class="permalink-anchor" onclick="copyPermalink('${featureSlug}')" aria-label="Copy link to feature" title="Copy link">#</button>
12729
13383
  <div class="feature-info">
12730
13384
  <div class="feature-title">${deps.escapeHtml(featureName)}</div>
12731
13385
  <div class="feature-path">${deps.escapeHtml(file)}</div>
@@ -12838,6 +13492,57 @@ function renderFailureSummary(args, deps) {
12838
13492
  </div>`;
12839
13493
  }
12840
13494
 
13495
+ // src/formatters/html/renderers/toc.ts
13496
+ function groupBy4(items, keyFn) {
13497
+ const map = /* @__PURE__ */ new Map();
13498
+ for (const item of items) {
13499
+ const key = keyFn(item);
13500
+ const existing = map.get(key);
13501
+ if (existing) {
13502
+ existing.push(item);
13503
+ } else {
13504
+ map.set(key, [item]);
13505
+ }
13506
+ }
13507
+ return map;
13508
+ }
13509
+ function renderToc(args, deps) {
13510
+ const { run } = args;
13511
+ if (run.testCases.length === 0) return "";
13512
+ const byFile = groupBy4(run.testCases, (tc) => tc.sourceFile);
13513
+ const features = [];
13514
+ for (const [file, testCases] of byFile) {
13515
+ const suitePaths = testCases.map((tc) => tc.titlePath).filter((p) => p.length > 0);
13516
+ const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
13517
+ const featureSlug = `feature-${slugify(file)}`;
13518
+ const scenarios = testCases.map((tc) => {
13519
+ const statusIcon = deps.getStatusIcon(tc.status);
13520
+ const statusClass = `status-${tc.status}`;
13521
+ const failedClass = tc.status === "failed" ? " toc-failed" : "";
13522
+ return `<a class="toc-scenario${failedClass}" href="#scenario-${tc.id}">
13523
+ <span class="toc-status ${statusClass}">${statusIcon}</span>
13524
+ ${deps.escapeHtml(tc.story.scenario)}
13525
+ </a>`;
13526
+ }).join("\n");
13527
+ features.push(`<div class="toc-feature">
13528
+ <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}">
13529
+ ${deps.escapeHtml(featureName)}
13530
+ </button>
13531
+ <div class="toc-scenarios">
13532
+ ${scenarios}
13533
+ </div>
13534
+ </div>`);
13535
+ }
13536
+ return `<nav class="toc-sidebar" aria-label="Table of contents">
13537
+ <div class="toc-header">
13538
+ <span class="toc-title">Contents</span>
13539
+ </div>
13540
+ <div class="toc-body">
13541
+ ${features.join("\n")}
13542
+ </div>
13543
+ </nav>`;
13544
+ }
13545
+
12841
13546
  // src/formatters/html/renderers/index.ts
12842
13547
  function normalizeOptions(options = {}) {
12843
13548
  return {
@@ -12851,7 +13556,9 @@ function normalizeOptions(options = {}) {
12851
13556
  markdownEnabled: options.markdownEnabled ?? true,
12852
13557
  permalinkBaseUrl: options.permalinkBaseUrl,
12853
13558
  ticketUrlTemplate: options.ticketUrlTemplate,
12854
- theme: options.theme ?? "default"
13559
+ tocEnabled: options.tocEnabled ?? true,
13560
+ theme: options.theme ?? "default",
13561
+ themePickerEnabled: options.themePickerEnabled ?? false
12855
13562
  };
12856
13563
  }
12857
13564
  function createHtmlFormatter(options = {}) {
@@ -12893,6 +13600,10 @@ function createHtmlFormatter(options = {}) {
12893
13600
  scenarioDeps
12894
13601
  };
12895
13602
  const tagBarDeps = { escapeHtml };
13603
+ const tocDeps = {
13604
+ escapeHtml,
13605
+ getStatusIcon
13606
+ };
12896
13607
  const bodyDeps = {
12897
13608
  renderMetaInfo,
12898
13609
  renderSummary,
@@ -12911,6 +13622,16 @@ function createHtmlFormatter(options = {}) {
12911
13622
  const bodyFn = theme.buildBody ?? buildBody;
12912
13623
  const body = bodyFn({ run }, bodyDeps);
12913
13624
  const templateFn = theme.generateTemplate ?? generateHtmlTemplate;
13625
+ const isStructuralTheme = !!(theme.buildBody || theme.generateTemplate);
13626
+ const tocHtml = opts.tocEnabled && !isStructuralTheme ? renderToc({ run }, tocDeps) : void 0;
13627
+ let themePickerHtml;
13628
+ let additionalThemeCss;
13629
+ if (opts.themePickerEnabled) {
13630
+ const cssOnlyThemes = getCssOnlyThemes();
13631
+ const pickerOptions = cssOnlyThemes.map((t) => `<option value="${t.name}"${t.name === theme.name ? " selected" : ""}>${t.label}</option>`).join("");
13632
+ themePickerHtml = `<select class="theme-picker" aria-label="Select theme">${pickerOptions}</select>`;
13633
+ additionalThemeCss = cssOnlyThemes.filter((t) => t.name !== theme.name).map((t) => ({ name: t.name, label: t.label, css: t.css }));
13634
+ }
12914
13635
  return templateFn(
12915
13636
  opts.title,
12916
13637
  theme.css,
@@ -12922,7 +13643,11 @@ function createHtmlFormatter(options = {}) {
12922
13643
  mermaidEnabled: opts.mermaidEnabled,
12923
13644
  markdownEnabled: opts.markdownEnabled,
12924
13645
  additionalJs: theme.additionalJs,
12925
- additionalImports: theme.additionalImports
13646
+ additionalImports: theme.additionalImports,
13647
+ tocHtml,
13648
+ themePickerHtml,
13649
+ additionalThemeCss,
13650
+ activeThemeName: theme.name
12926
13651
  }
12927
13652
  );
12928
13653
  }
@@ -12978,7 +13703,7 @@ var JUnitFormatter = class {
12978
13703
  lines.push(
12979
13704
  `<testsuites name="${escapeXml(this.options.suiteName)}" tests="${tests}" failures="${failures}" errors="${errors}" skipped="${skipped}" time="${time}">`
12980
13705
  );
12981
- const byFile = groupBy4(run.testCases, (tc) => tc.sourceFile);
13706
+ const byFile = groupBy5(run.testCases, (tc) => tc.sourceFile);
12982
13707
  for (const [file, testCases] of byFile) {
12983
13708
  lines.push(...this.buildTestSuite(file, testCases, indent, newline));
12984
13709
  }
@@ -13151,7 +13876,7 @@ var JUnitFormatter = class {
13151
13876
  function escapeXml(str) {
13152
13877
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
13153
13878
  }
13154
- function groupBy4(items, keyFn) {
13879
+ function groupBy5(items, keyFn) {
13155
13880
  const map = /* @__PURE__ */ new Map();
13156
13881
  for (const item of items) {
13157
13882
  const key = keyFn(item);
@@ -13326,7 +14051,7 @@ var MarkdownFormatter = class {
13326
14051
  * Render scenarios grouped by file.
13327
14052
  */
13328
14053
  renderByFile(lines, testCases) {
13329
- const byFile = groupBy5(testCases, (tc) => tc.sourceFile);
14054
+ const byFile = groupBy6(testCases, (tc) => tc.sourceFile);
13330
14055
  for (const [file, fileTestCases] of byFile) {
13331
14056
  lines.push(`## ${file}`);
13332
14057
  lines.push("");
@@ -13343,7 +14068,7 @@ var MarkdownFormatter = class {
13343
14068
  * Render suite groups.
13344
14069
  */
13345
14070
  renderSuiteGroups(lines, testCases, baseLevel) {
13346
- const bySuite = groupBy5(
14071
+ const bySuite = groupBy6(
13347
14072
  testCases,
13348
14073
  (tc) => tc.titlePath.join(this.options.suiteSeparator)
13349
14074
  );
@@ -13637,7 +14362,7 @@ var MarkdownFormatter = class {
13637
14362
  return entries;
13638
14363
  }
13639
14364
  };
13640
- function groupBy5(items, keyFn) {
14365
+ function groupBy6(items, keyFn) {
13641
14366
  const map = /* @__PURE__ */ new Map();
13642
14367
  for (const item of items) {
13643
14368
  const key = keyFn(item);
@@ -17377,7 +18102,9 @@ var ReportGenerator = class {
17377
18102
  markdownEnabled: options.html?.markdownEnabled ?? true,
17378
18103
  permalinkBaseUrl: options.html?.permalinkBaseUrl,
17379
18104
  ticketUrlTemplate: options.html?.ticketUrlTemplate,
17380
- theme: options.html?.theme ?? "default"
18105
+ theme: options.html?.theme ?? "default",
18106
+ tocEnabled: options.html?.tocEnabled ?? true,
18107
+ themePickerEnabled: options.html?.themePickerEnabled ?? false
17381
18108
  },
17382
18109
  junit: {
17383
18110
  suiteName: options.junit?.suiteName ?? "Test Suite",
@@ -17500,7 +18227,9 @@ var ReportGenerator = class {
17500
18227
  mermaidEnabled: this.options.html.mermaidEnabled,
17501
18228
  markdownEnabled: this.options.html.markdownEnabled,
17502
18229
  permalinkBaseUrl: this.options.html.permalinkBaseUrl,
17503
- ticketUrlTemplate: this.options.html.ticketUrlTemplate
18230
+ ticketUrlTemplate: this.options.html.ticketUrlTemplate,
18231
+ tocEnabled: this.options.html.tocEnabled,
18232
+ themePickerEnabled: this.options.html.themePickerEnabled
17504
18233
  });
17505
18234
  return formatter.format(run);
17506
18235
  }
@@ -17623,6 +18352,7 @@ export {
17623
18352
  generateRunId,
17624
18353
  generateTestCaseId,
17625
18354
  getAvailableThemes,
18355
+ getCssOnlyThemes,
17626
18356
  hasSufficientHistory,
17627
18357
  listScenarios,
17628
18358
  loadHistory,