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/cli.js CHANGED
@@ -1529,6 +1529,7 @@ function applyAllFilters() {
1529
1529
  });
1530
1530
 
1531
1531
  updateFilterResults(visibleCount, totalCount);
1532
+ syncTocVisibility();
1532
1533
  writeUrlState();
1533
1534
  }
1534
1535
 
@@ -1545,13 +1546,135 @@ function updateFilterResults(visible, total) {
1545
1546
  if (tc) tc.textContent = total;
1546
1547
  }
1547
1548
 
1548
- // Keyboard shortcuts
1549
+ // Keyboard navigation
1550
+ var focusedScenarioIndex = -1;
1551
+
1552
+ function getVisibleScenarios() {
1553
+ return Array.from(document.querySelectorAll('.scenario')).filter(function(s) {
1554
+ return s.style.display !== 'none' && s.closest('.feature').style.display !== 'none';
1555
+ });
1556
+ }
1557
+
1558
+ function focusScenario(index) {
1559
+ var scenarios = getVisibleScenarios();
1560
+ if (scenarios.length === 0) return;
1561
+
1562
+ // Remove previous focus
1563
+ var prev = document.querySelector('.scenario-focused');
1564
+ if (prev) prev.classList.remove('scenario-focused');
1565
+
1566
+ // Wrap around
1567
+ if (index < 0) index = scenarios.length - 1;
1568
+ if (index >= scenarios.length) index = 0;
1569
+ focusedScenarioIndex = index;
1570
+
1571
+ var scenario = scenarios[index];
1572
+ scenario.classList.add('scenario-focused');
1573
+ scenario.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1574
+ }
1575
+
1576
+ function showShortcutsOverlay() {
1577
+ if (document.querySelector('.shortcuts-overlay')) return;
1578
+ var overlay = document.createElement('div');
1579
+ overlay.className = 'shortcuts-overlay';
1580
+ overlay.innerHTML = '<div class="shortcuts-modal">' +
1581
+ '<div class="shortcuts-title">Keyboard Shortcuts</div>' +
1582
+ '<div class="shortcuts-grid">' +
1583
+ '<kbd>j</kbd><span>Next scenario</span>' +
1584
+ '<kbd>k</kbd><span>Previous scenario</span>' +
1585
+ '<kbd>Enter</kbd><span>Expand/collapse scenario</span>' +
1586
+ '<kbd>Escape</kbd><span>Collapse scenario / close</span>' +
1587
+ '<kbd>/</kbd><span>Focus search</span>' +
1588
+ '<kbd>?</kbd><span>Toggle this help</span>' +
1589
+ '<kbd>e</kbd><span>Expand all</span>' +
1590
+ '<kbd>c</kbd><span>Collapse all</span>' +
1591
+ '<kbd>t</kbd><span>Toggle table of contents</span>' +
1592
+ '</div></div>';
1593
+ overlay.addEventListener('click', function(ev) {
1594
+ if (ev.target === overlay) hideShortcutsOverlay();
1595
+ });
1596
+ document.body.appendChild(overlay);
1597
+ }
1598
+
1599
+ function hideShortcutsOverlay() {
1600
+ var overlay = document.querySelector('.shortcuts-overlay');
1601
+ if (overlay) overlay.remove();
1602
+ }
1603
+
1549
1604
  function initKeyboardShortcuts() {
1550
1605
  document.addEventListener('keydown', function(e) {
1551
- if (e.key === '/' && !e.ctrlKey && !e.metaKey && e.target.tagName !== 'INPUT') {
1552
- e.preventDefault();
1553
- var input = document.querySelector('.search-input');
1554
- if (input) input.focus();
1606
+ var tag = e.target.tagName;
1607
+ if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') {
1608
+ if (e.key === 'Escape') {
1609
+ e.target.blur();
1610
+ if (e.target.classList.contains('search-input')) {
1611
+ e.target.value = '';
1612
+ applyAllFilters();
1613
+ }
1614
+ }
1615
+ return;
1616
+ }
1617
+
1618
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
1619
+
1620
+ switch (e.key) {
1621
+ case 'j':
1622
+ e.preventDefault();
1623
+ focusScenario(focusedScenarioIndex + 1);
1624
+ break;
1625
+ case 'k':
1626
+ e.preventDefault();
1627
+ focusScenario(focusedScenarioIndex - 1);
1628
+ break;
1629
+ case 'Enter':
1630
+ e.preventDefault();
1631
+ var scenarios = getVisibleScenarios();
1632
+ if (focusedScenarioIndex >= 0 && focusedScenarioIndex < scenarios.length) {
1633
+ var s = scenarios[focusedScenarioIndex];
1634
+ var h = s.querySelector('.scenario-header');
1635
+ if (h) toggleCollapse(h, s);
1636
+ }
1637
+ break;
1638
+ case 'Escape':
1639
+ if (document.querySelector('.shortcuts-overlay')) {
1640
+ hideShortcutsOverlay();
1641
+ } else {
1642
+ var scenarios2 = getVisibleScenarios();
1643
+ if (focusedScenarioIndex >= 0 && focusedScenarioIndex < scenarios2.length) {
1644
+ var sc = scenarios2[focusedScenarioIndex];
1645
+ if (!sc.classList.contains('collapsed')) {
1646
+ sc.classList.add('collapsed');
1647
+ var sh = sc.querySelector('.scenario-header');
1648
+ if (sh) sh.setAttribute('aria-expanded', 'false');
1649
+ }
1650
+ }
1651
+ }
1652
+ break;
1653
+ case '/':
1654
+ e.preventDefault();
1655
+ var input = document.querySelector('.search-input');
1656
+ if (input) input.focus();
1657
+ break;
1658
+ case '?':
1659
+ e.preventDefault();
1660
+ if (document.querySelector('.shortcuts-overlay')) {
1661
+ hideShortcutsOverlay();
1662
+ } else {
1663
+ showShortcutsOverlay();
1664
+ }
1665
+ break;
1666
+ case 'e':
1667
+ e.preventDefault();
1668
+ expandAll();
1669
+ break;
1670
+ case 'c':
1671
+ e.preventDefault();
1672
+ collapseAll();
1673
+ break;
1674
+ case 't':
1675
+ e.preventDefault();
1676
+ if (typeof toggleToc === 'function') toggleToc();
1677
+ break;
1555
1678
  }
1556
1679
  });
1557
1680
  }
@@ -1689,6 +1812,189 @@ function writeUrlState() {
1689
1812
  var url = window.location.pathname + (qs ? '?' + qs : '');
1690
1813
  history.replaceState(null, '', url);
1691
1814
  }
1815
+
1816
+ // Permalink copy
1817
+ function copyPermalink(anchorId) {
1818
+ var url = location.origin + location.pathname + location.search + '#' + anchorId;
1819
+ navigator.clipboard.writeText(url).then(function() {
1820
+ var el = document.getElementById(anchorId);
1821
+ if (el) showCopyToast(el);
1822
+ });
1823
+ }
1824
+
1825
+ function showCopyToast(el) {
1826
+ var existing = el.querySelector('.copy-toast');
1827
+ if (existing) existing.remove();
1828
+ var toast = document.createElement('span');
1829
+ toast.className = 'copy-toast';
1830
+ toast.textContent = 'Copied!';
1831
+ var header = el.querySelector('.feature-header, .scenario-header');
1832
+ if (header) {
1833
+ header.style.position = 'relative';
1834
+ header.appendChild(toast);
1835
+ }
1836
+ setTimeout(function() { toast.remove(); }, 1500);
1837
+ }
1838
+
1839
+ // Copy scenario as markdown
1840
+ function copyScenarioAsMarkdown(scenarioId) {
1841
+ var scenario = document.getElementById(scenarioId);
1842
+ if (!scenario) return;
1843
+
1844
+ var title = (scenario.querySelector('.scenario-name') || {}).textContent || '';
1845
+ var steps = scenario.querySelectorAll('.step, .step.continuation');
1846
+ var lines = ['### Scenario: ' + title.trim(), ''];
1847
+
1848
+ steps.forEach(function(step) {
1849
+ var keyword = step.getAttribute('data-keyword') || '';
1850
+ var text = step.getAttribute('data-text') || '';
1851
+ lines.push('- **' + keyword + '** ' + text);
1852
+ });
1853
+
1854
+ var errorBox = scenario.querySelector('.error-message');
1855
+ if (errorBox) {
1856
+ var errorText = errorBox.textContent || '';
1857
+ lines.push('');
1858
+ lines.push('> **Error:** ' + errorText.trim());
1859
+ }
1860
+
1861
+ var md = lines.join('\\n');
1862
+ navigator.clipboard.writeText(md).then(function() {
1863
+ showCopyToast(scenario);
1864
+ });
1865
+ }
1866
+
1867
+ // Hash scroll on load
1868
+ function initHashScroll() {
1869
+ if (!location.hash) return;
1870
+ var target = document.querySelector(location.hash);
1871
+ if (!target) return;
1872
+ var feature = target.closest('.feature');
1873
+ if (feature && feature.classList.contains('collapsed')) {
1874
+ feature.classList.remove('collapsed');
1875
+ var fh = feature.querySelector('.feature-header');
1876
+ if (fh) fh.setAttribute('aria-expanded', 'true');
1877
+ }
1878
+ if (target.classList.contains('collapsed')) {
1879
+ target.classList.remove('collapsed');
1880
+ var sh = target.querySelector('.scenario-header');
1881
+ if (sh) sh.setAttribute('aria-expanded', 'true');
1882
+ }
1883
+ setTimeout(function() {
1884
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' });
1885
+ target.classList.add('hash-highlight');
1886
+ }, 100);
1887
+ }
1888
+
1889
+ // Table of contents
1890
+ function toggleToc() {
1891
+ var sidebar = document.querySelector('.toc-sidebar');
1892
+ var wrapper = document.querySelector('.report-layout');
1893
+ if (!sidebar || !wrapper) return;
1894
+ var isMobile = window.matchMedia('(max-width: 767px)').matches;
1895
+ if (isMobile) {
1896
+ sidebar.classList.toggle('toc-mobile-open');
1897
+ } else {
1898
+ wrapper.classList.toggle('toc-hidden');
1899
+ var hidden = wrapper.classList.contains('toc-hidden');
1900
+ localStorage.setItem('toc-visible', String(!hidden));
1901
+ }
1902
+ }
1903
+
1904
+ function initToc() {
1905
+ var sidebar = document.querySelector('.toc-sidebar');
1906
+ if (!sidebar) return;
1907
+
1908
+ var saved = localStorage.getItem('toc-visible');
1909
+ var wrapper = document.querySelector('.report-layout');
1910
+ if (saved === 'false' && wrapper) {
1911
+ wrapper.classList.add('toc-hidden');
1912
+ }
1913
+
1914
+ // Active tracking via IntersectionObserver
1915
+ var observer = new IntersectionObserver(function(entries) {
1916
+ entries.forEach(function(entry) {
1917
+ if (entry.isIntersecting) {
1918
+ var id = entry.target.id;
1919
+ if (!id) return;
1920
+ document.querySelectorAll('.toc-scenario, .toc-feature-toggle').forEach(function(el) {
1921
+ el.classList.remove('toc-active');
1922
+ });
1923
+ var tocLink = sidebar.querySelector('a[href="#' + id + '"]');
1924
+ if (tocLink) tocLink.classList.add('toc-active');
1925
+ }
1926
+ });
1927
+ }, { rootMargin: '-10% 0px -80% 0px' });
1928
+
1929
+ document.querySelectorAll('.feature, .scenario').forEach(function(el) {
1930
+ if (el.id) observer.observe(el);
1931
+ });
1932
+
1933
+ // Click navigation: expand collapsed parents
1934
+ sidebar.querySelectorAll('.toc-scenario').forEach(function(link) {
1935
+ link.addEventListener('click', function(e) {
1936
+ var hash = link.getAttribute('href');
1937
+ if (!hash) return;
1938
+ var target = document.querySelector(hash);
1939
+ if (!target) return;
1940
+ var feature = target.closest('.feature');
1941
+ if (feature && feature.classList.contains('collapsed')) {
1942
+ feature.classList.remove('collapsed');
1943
+ var fh = feature.querySelector('.feature-header');
1944
+ if (fh) fh.setAttribute('aria-expanded', 'true');
1945
+ }
1946
+ if (target.classList.contains('collapsed')) {
1947
+ target.classList.remove('collapsed');
1948
+ var sh = target.querySelector('.scenario-header');
1949
+ if (sh) sh.setAttribute('aria-expanded', 'true');
1950
+ }
1951
+ });
1952
+ });
1953
+ }
1954
+
1955
+ // Theme picker
1956
+ function initThemePicker() {
1957
+ var picker = document.querySelector('.theme-picker');
1958
+ if (!picker) return;
1959
+
1960
+ var saved = localStorage.getItem('report-theme');
1961
+ if (saved) {
1962
+ picker.value = saved;
1963
+ switchReportTheme(saved);
1964
+ }
1965
+
1966
+ picker.addEventListener('change', function(e) {
1967
+ switchReportTheme(e.target.value);
1968
+ localStorage.setItem('report-theme', e.target.value);
1969
+ });
1970
+ }
1971
+
1972
+ function switchReportTheme(name) {
1973
+ document.querySelectorAll('style[data-theme-name]').forEach(function(s) {
1974
+ s.disabled = s.dataset.themeName !== name;
1975
+ });
1976
+ }
1977
+
1978
+ // Sync TOC visibility with filters
1979
+ function syncTocVisibility() {
1980
+ var sidebar = document.querySelector('.toc-sidebar');
1981
+ if (!sidebar) return;
1982
+
1983
+ sidebar.querySelectorAll('.toc-scenario').forEach(function(link) {
1984
+ var href = link.getAttribute('href');
1985
+ if (!href) return;
1986
+ var target = document.querySelector(href);
1987
+ link.style.display = (target && target.style.display !== 'none') ? '' : 'none';
1988
+ });
1989
+
1990
+ sidebar.querySelectorAll('.toc-feature').forEach(function(feature) {
1991
+ var visibleScenarios = feature.querySelectorAll('.toc-scenario');
1992
+ var anyVisible = Array.from(visibleScenarios).some(function(s) {
1993
+ return s.style.display !== 'none';
1994
+ });
1995
+ feature.style.display = anyVisible ? '' : 'none';
1996
+ });
1997
+ }
1692
1998
  `;
1693
1999
  var JS_MARKDOWN_FN = `
1694
2000
  function parseMarkdownSections(marked) {
@@ -1729,6 +2035,9 @@ function generateScript(options) {
1729
2035
  initCalls.push("initCollapse();");
1730
2036
  initCalls.push("initDetailLevel();");
1731
2037
  initCalls.push("applyAllFilters();");
2038
+ initCalls.push("initHashScroll();");
2039
+ initCalls.push("initToc();");
2040
+ initCalls.push("initThemePicker();");
1732
2041
  const initScript = `
1733
2042
  // Initialize on load
1734
2043
  document.addEventListener('DOMContentLoaded', () => {
@@ -1790,6 +2099,7 @@ function generateHtmlTemplate(title, styles, body, options = {}) {
1790
2099
  }
1791
2100
  const cdnStylesHtml = cdnStyles.length > 0 ? "\n " + cdnStyles.join("\n ") : "";
1792
2101
  const esmScriptHtml = generateEsmScript(options);
2102
+ const additionalThemeStyles = (options.additionalThemeCss ?? []).map((t) => `<style data-theme-name="${escapeHtml(t.name)}" disabled>${t.css}</style>`).join("\n ");
1793
2103
  return `<!DOCTYPE html>
1794
2104
  <html lang="en"${themeAttr} data-detail-level="full">
1795
2105
  <head>
@@ -1797,19 +2107,27 @@ function generateHtmlTemplate(title, styles, body, options = {}) {
1797
2107
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1798
2108
  <meta name="color-scheme" content="light dark">
1799
2109
  <title>${escapeHtml(title)}</title>${cdnStylesHtml}
1800
- <style>${styles}</style>
2110
+ <style${options.additionalThemeCss ? ` data-theme-name="${escapeHtml(options.activeThemeName ?? "default")}"` : ""}>${styles}</style>
2111
+ ${additionalThemeStyles}
1801
2112
  </head>
1802
2113
  <body>
1803
- <div class="container">
1804
- <header class="header">
1805
- <h1>${escapeHtml(title)}</h1>
1806
- <div class="header-actions">
1807
- ${includeSearch ? '<input type="text" class="search-input" placeholder="Search scenarios..." aria-label="Search scenarios">' : ""}
1808
- <button type="button" class="detail-toggle" onclick="toggleDetailLevel()" aria-label="Toggle detail level" title="Toggle documentation detail"></button>
1809
- ${includeDarkMode ? '<button type="button" class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle theme"></button>' : ""}
2114
+ <div class="report-layout">
2115
+ ${options.tocHtml ?? ""}
2116
+ <div class="main-content">
2117
+ <div class="container">
2118
+ <header class="header">
2119
+ <h1>${escapeHtml(title)}</h1>
2120
+ <div class="header-actions">
2121
+ <button type="button" class="toc-toggle" onclick="toggleToc()" aria-label="Toggle table of contents" title="Toggle contents">&#x2630;</button>
2122
+ ${includeSearch ? '<input type="text" class="search-input" placeholder="Search scenarios..." aria-label="Search scenarios">' : ""}
2123
+ <button type="button" class="detail-toggle" onclick="toggleDetailLevel()" aria-label="Toggle detail level" title="Toggle documentation detail"></button>
2124
+ ${options.themePickerHtml ?? ""}
2125
+ ${includeDarkMode ? '<button type="button" class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle theme"></button>' : ""}
2126
+ </div>
2127
+ </header>
2128
+ ${body}
1810
2129
  </div>
1811
- </header>
1812
- ${body}
2130
+ </div>
1813
2131
  </div>
1814
2132
  <script>${script}</script>${esmScriptHtml}
1815
2133
  </body>
@@ -3513,6 +3831,337 @@ body {
3513
3831
  display: none;
3514
3832
  }
3515
3833
 
3834
+ /* ============================================================================
3835
+ Permalink Anchors
3836
+ ============================================================================ */
3837
+ .permalink-anchor {
3838
+ display: inline-flex;
3839
+ align-items: center;
3840
+ justify-content: center;
3841
+ width: 1.5rem;
3842
+ height: 1.5rem;
3843
+ border: none;
3844
+ background: none;
3845
+ color: var(--muted-foreground);
3846
+ cursor: pointer;
3847
+ opacity: 0;
3848
+ transition: opacity 0.15s ease;
3849
+ font-size: 0.875rem;
3850
+ font-weight: 600;
3851
+ padding: 0;
3852
+ flex-shrink: 0;
3853
+ }
3854
+
3855
+ .feature-header:hover .permalink-anchor,
3856
+ .scenario-header:hover .permalink-anchor,
3857
+ .permalink-anchor:focus-visible {
3858
+ opacity: 1;
3859
+ }
3860
+
3861
+ .permalink-anchor:hover {
3862
+ color: var(--primary);
3863
+ }
3864
+
3865
+ .copy-toast {
3866
+ position: absolute;
3867
+ right: 0.5rem;
3868
+ top: 50%;
3869
+ transform: translateY(-50%);
3870
+ background: var(--foreground);
3871
+ color: var(--background);
3872
+ padding: 0.25rem 0.5rem;
3873
+ border-radius: var(--radius);
3874
+ font-size: 0.75rem;
3875
+ font-weight: 500;
3876
+ pointer-events: none;
3877
+ animation: fadeOut 1.5s ease forwards;
3878
+ z-index: 10;
3879
+ }
3880
+
3881
+ @keyframes fadeOut {
3882
+ 0%, 70% { opacity: 1; }
3883
+ 100% { opacity: 0; }
3884
+ }
3885
+
3886
+ .hash-highlight {
3887
+ animation: hashPulse 2s ease;
3888
+ }
3889
+
3890
+ @keyframes hashPulse {
3891
+ 0%, 100% { background: transparent; }
3892
+ 20% { background: color-mix(in srgb, var(--primary) 12%, transparent); }
3893
+ }
3894
+
3895
+ .scenario-actions {
3896
+ display: flex;
3897
+ align-items: center;
3898
+ gap: 0.25rem;
3899
+ flex-shrink: 0;
3900
+ }
3901
+
3902
+ .copy-scenario-btn {
3903
+ display: inline-flex;
3904
+ align-items: center;
3905
+ justify-content: center;
3906
+ width: 1.5rem;
3907
+ height: 1.5rem;
3908
+ border: none;
3909
+ background: none;
3910
+ color: var(--muted-foreground);
3911
+ cursor: pointer;
3912
+ opacity: 0;
3913
+ transition: opacity 0.15s ease;
3914
+ font-size: 0.875rem;
3915
+ padding: 0;
3916
+ flex-shrink: 0;
3917
+ }
3918
+
3919
+ .scenario-header:hover .copy-scenario-btn,
3920
+ .copy-scenario-btn:focus-visible {
3921
+ opacity: 1;
3922
+ }
3923
+
3924
+ .copy-scenario-btn:hover {
3925
+ color: var(--primary);
3926
+ }
3927
+
3928
+ /* ============================================================================
3929
+ Keyboard Navigation
3930
+ ============================================================================ */
3931
+ .scenario-focused {
3932
+ border-left: 2px solid var(--primary);
3933
+ }
3934
+
3935
+ .shortcuts-overlay {
3936
+ position: fixed;
3937
+ inset: 0;
3938
+ background: rgb(0 0 0 / 0.5);
3939
+ display: flex;
3940
+ align-items: center;
3941
+ justify-content: center;
3942
+ z-index: 100;
3943
+ }
3944
+
3945
+ .shortcuts-modal {
3946
+ background: var(--card);
3947
+ color: var(--card-foreground);
3948
+ border: 1px solid var(--border);
3949
+ border-radius: calc(var(--radius) * 2);
3950
+ padding: 1.5rem 2rem;
3951
+ max-width: 400px;
3952
+ width: 90vw;
3953
+ box-shadow: var(--shadow-md, 0 4px 12px rgb(0 0 0 / 0.15));
3954
+ }
3955
+
3956
+ .shortcuts-title {
3957
+ font-weight: 600;
3958
+ font-size: 1.125rem;
3959
+ margin-bottom: 1rem;
3960
+ padding-bottom: 0.5rem;
3961
+ border-bottom: 1px solid var(--border);
3962
+ }
3963
+
3964
+ .shortcuts-grid {
3965
+ display: grid;
3966
+ grid-template-columns: auto 1fr;
3967
+ gap: 0.5rem 1rem;
3968
+ align-items: center;
3969
+ }
3970
+
3971
+ .shortcuts-grid kbd {
3972
+ display: inline-flex;
3973
+ align-items: center;
3974
+ justify-content: center;
3975
+ min-width: 1.75rem;
3976
+ padding: 0.125rem 0.375rem;
3977
+ background: var(--muted);
3978
+ border: 1px solid var(--border);
3979
+ border-radius: calc(var(--radius) * 0.5);
3980
+ font-family: var(--font-mono);
3981
+ font-size: 0.75rem;
3982
+ font-weight: 500;
3983
+ color: var(--muted-foreground);
3984
+ }
3985
+
3986
+ .shortcuts-grid span {
3987
+ font-size: 0.875rem;
3988
+ color: var(--foreground);
3989
+ }
3990
+
3991
+ /* ============================================================================
3992
+ Table of Contents Sidebar
3993
+ ============================================================================ */
3994
+ .report-layout {
3995
+ display: flex;
3996
+ min-height: 100vh;
3997
+ }
3998
+
3999
+ .report-layout.toc-hidden .toc-sidebar {
4000
+ display: none;
4001
+ }
4002
+
4003
+ .main-content {
4004
+ flex: 1;
4005
+ min-width: 0;
4006
+ }
4007
+
4008
+ .toc-sidebar {
4009
+ width: 260px;
4010
+ flex-shrink: 0;
4011
+ position: sticky;
4012
+ top: 0;
4013
+ height: 100vh;
4014
+ overflow-y: auto;
4015
+ border-right: 1px solid var(--border);
4016
+ background: var(--card);
4017
+ padding: 1rem 0;
4018
+ font-size: 0.8125rem;
4019
+ }
4020
+
4021
+ .toc-header {
4022
+ padding: 0 1rem 0.75rem;
4023
+ border-bottom: 1px solid var(--border);
4024
+ margin-bottom: 0.5rem;
4025
+ }
4026
+
4027
+ .toc-title {
4028
+ font-weight: 600;
4029
+ font-size: 0.875rem;
4030
+ color: var(--foreground);
4031
+ text-decoration: none;
4032
+ cursor: pointer;
4033
+ }
4034
+
4035
+ a.toc-title:hover {
4036
+ color: var(--primary);
4037
+ }
4038
+
4039
+ .toc-feature {
4040
+ margin-bottom: 0.25rem;
4041
+ }
4042
+
4043
+ .toc-feature-toggle {
4044
+ display: flex;
4045
+ align-items: center;
4046
+ width: 100%;
4047
+ padding: 0.375rem 1rem;
4048
+ border: none;
4049
+ background: none;
4050
+ text-align: left;
4051
+ cursor: pointer;
4052
+ font-size: 0.8125rem;
4053
+ font-weight: 600;
4054
+ color: var(--foreground);
4055
+ font-family: var(--font-sans);
4056
+ }
4057
+
4058
+ .toc-feature-toggle:hover {
4059
+ background: var(--accent);
4060
+ }
4061
+
4062
+ .toc-feature-toggle[aria-expanded="false"] + .toc-scenarios {
4063
+ display: none;
4064
+ }
4065
+
4066
+ .toc-scenarios {
4067
+ display: flex;
4068
+ flex-direction: column;
4069
+ }
4070
+
4071
+ .toc-scenario {
4072
+ display: flex;
4073
+ align-items: baseline;
4074
+ gap: 0.375rem;
4075
+ padding: 0.25rem 1rem 0.25rem 1.5rem;
4076
+ color: var(--muted-foreground);
4077
+ text-decoration: none;
4078
+ font-size: 0.8125rem;
4079
+ line-height: 1.4;
4080
+ border-left: 2px solid transparent;
4081
+ transition: all 0.1s ease;
4082
+ }
4083
+
4084
+ .toc-scenario:hover {
4085
+ color: var(--foreground);
4086
+ background: var(--accent);
4087
+ }
4088
+
4089
+ .toc-scenario.toc-active {
4090
+ color: var(--foreground);
4091
+ border-left-color: var(--primary);
4092
+ font-weight: 500;
4093
+ }
4094
+
4095
+ .toc-scenario.toc-failed {
4096
+ border-left-color: var(--error, var(--destructive));
4097
+ }
4098
+
4099
+ .toc-status {
4100
+ flex-shrink: 0;
4101
+ font-size: 0.75rem;
4102
+ }
4103
+
4104
+ .toc-toggle {
4105
+ display: inline-flex;
4106
+ align-items: center;
4107
+ justify-content: center;
4108
+ width: 2.25rem;
4109
+ height: 2.25rem;
4110
+ border: 1px solid var(--border);
4111
+ border-radius: var(--radius);
4112
+ background: var(--background);
4113
+ cursor: pointer;
4114
+ color: var(--foreground);
4115
+ font-size: 1rem;
4116
+ transition: all 0.15s ease;
4117
+ }
4118
+
4119
+ .toc-toggle:hover {
4120
+ background: var(--accent);
4121
+ }
4122
+
4123
+ /* Mobile: overlay sidebar */
4124
+ @media (max-width: 767px) {
4125
+ .toc-sidebar {
4126
+ position: fixed;
4127
+ left: 0;
4128
+ top: 0;
4129
+ z-index: 50;
4130
+ box-shadow: var(--shadow-sm, 0 1px 3px rgb(0 0 0 / 0.1));
4131
+ transform: translateX(-100%);
4132
+ transition: transform 0.2s ease;
4133
+ }
4134
+
4135
+ .toc-sidebar.toc-mobile-open {
4136
+ transform: translateX(0);
4137
+ }
4138
+ }
4139
+
4140
+ /* ============================================================================
4141
+ Theme Picker
4142
+ ============================================================================ */
4143
+ .theme-picker {
4144
+ height: 2.25rem;
4145
+ padding: 0 0.5rem;
4146
+ border: 1px solid var(--border);
4147
+ border-radius: var(--radius);
4148
+ background: var(--background);
4149
+ color: var(--foreground);
4150
+ font-size: 0.8125rem;
4151
+ font-family: var(--font-sans);
4152
+ cursor: pointer;
4153
+ transition: all 0.15s ease;
4154
+ }
4155
+
4156
+ .theme-picker:hover {
4157
+ background: var(--accent);
4158
+ }
4159
+
4160
+ .theme-picker:focus-visible {
4161
+ outline: 2px solid var(--ring);
4162
+ outline-offset: 2px;
4163
+ }
4164
+
3516
4165
  `;
3517
4166
 
3518
4167
  // src/formatters/html/themes/default.ts
@@ -3562,7 +4211,7 @@ function corporateBuildBody(args, deps) {
3562
4211
  const sidebar = `
3563
4212
  <nav class="toc">
3564
4213
  <div class="toc-header">
3565
- <div class="toc-title">Test Report</div>
4214
+ <a href="#" class="toc-title" onclick="window.scrollTo({top:0,behavior:'smooth'});return false;">Test Report</a>
3566
4215
  <div class="toc-stats">
3567
4216
  <div class="toc-stat-row">
3568
4217
  <span class="toc-stat-label">Total</span>
@@ -12769,6 +13418,11 @@ function resolveTheme(nameOrTheme) {
12769
13418
  }
12770
13419
  return theme;
12771
13420
  }
13421
+ function getCssOnlyThemes() {
13422
+ return [...THEME_REGISTRY.values()].filter(
13423
+ (theme) => !theme.buildBody && !theme.generateTemplate
13424
+ );
13425
+ }
12772
13426
 
12773
13427
  // src/formatters/html/renderers/status.ts
12774
13428
  function getStatusIcon(status) {
@@ -13050,7 +13704,7 @@ function renderStep(step, stepResult, index, deps) {
13050
13704
  const stepClass = isContinuation ? "step continuation" : "step";
13051
13705
  const stepDocs = deps.renderDocs(step.docs, "step-docs");
13052
13706
  const textHtml = deps.highlightStepParams ? deps.highlightStepParams(step.text) : deps.escapeHtml(step.text);
13053
- return `<div class="${stepClass}">
13707
+ return `<div class="${stepClass}" data-keyword="${deps.escapeHtml(keywordTrimmed)}" data-text="${deps.escapeHtml(step.text)}">
13054
13708
  <span class="step-status ${statusClass}">${statusIcon}</span>
13055
13709
  <span class="step-keyword">${deps.escapeHtml(step.keyword)}</span>
13056
13710
  <span class="step-text">${textHtml}</span>
@@ -13176,7 +13830,11 @@ function renderScenario(args, deps) {
13176
13830
  </div>
13177
13831
  <div class="scenario-meta">${tags}${tickets}${sourceLink}${traceBadge}${metricBadges}</div>
13178
13832
  </div>
13179
- <span class="scenario-duration">${duration}</span>
13833
+ <div class="scenario-actions">
13834
+ <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>
13835
+ <button class="permalink-anchor" onclick="copyPermalink('scenario-${tc.id}')" aria-label="Copy link to scenario" title="Copy link">#</button>
13836
+ <span class="scenario-duration">${duration}</span>
13837
+ </div>
13180
13838
  </div>
13181
13839
  <div class="scenario-content">
13182
13840
  ${storyDocs}
@@ -13386,6 +14044,7 @@ function renderFeature(args, deps) {
13386
14044
  const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
13387
14045
  const collapsedClass = deps.startCollapsed ? " collapsed" : "";
13388
14046
  const ariaExpanded = !deps.startCollapsed;
14047
+ const featureSlug = `feature-${slugify(file)}`;
13389
14048
  const scenarios = testCases.map(
13390
14049
  (tc) => deps.renderScenario(
13391
14050
  { tc, metrics: args.metricsMap?.get(tc.id) },
@@ -13393,8 +14052,9 @@ function renderFeature(args, deps) {
13393
14052
  )
13394
14053
  ).join("\n");
13395
14054
  return `
13396
- <div class="feature${collapsedClass}">
14055
+ <div class="feature${collapsedClass}" id="${featureSlug}">
13397
14056
  <div class="feature-header" role="button" tabindex="0" aria-expanded="${ariaExpanded}">
14057
+ <button class="permalink-anchor" onclick="copyPermalink('${featureSlug}')" aria-label="Copy link to feature" title="Copy link">#</button>
13398
14058
  <div class="feature-info">
13399
14059
  <div class="feature-title">${deps.escapeHtml(featureName)}</div>
13400
14060
  <div class="feature-path">${deps.escapeHtml(file)}</div>
@@ -13507,6 +14167,57 @@ function renderFailureSummary(args, deps) {
13507
14167
  </div>`;
13508
14168
  }
13509
14169
 
14170
+ // src/formatters/html/renderers/toc.ts
14171
+ function groupBy4(items, keyFn) {
14172
+ const map = /* @__PURE__ */ new Map();
14173
+ for (const item of items) {
14174
+ const key = keyFn(item);
14175
+ const existing = map.get(key);
14176
+ if (existing) {
14177
+ existing.push(item);
14178
+ } else {
14179
+ map.set(key, [item]);
14180
+ }
14181
+ }
14182
+ return map;
14183
+ }
14184
+ function renderToc(args, deps) {
14185
+ const { run } = args;
14186
+ if (run.testCases.length === 0) return "";
14187
+ const byFile = groupBy4(run.testCases, (tc) => tc.sourceFile);
14188
+ const features = [];
14189
+ for (const [file, testCases] of byFile) {
14190
+ const suitePaths = testCases.map((tc) => tc.titlePath).filter((p) => p.length > 0);
14191
+ const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
14192
+ const featureSlug = `feature-${slugify(file)}`;
14193
+ const scenarios = testCases.map((tc) => {
14194
+ const statusIcon = deps.getStatusIcon(tc.status);
14195
+ const statusClass = `status-${tc.status}`;
14196
+ const failedClass = tc.status === "failed" ? " toc-failed" : "";
14197
+ return `<a class="toc-scenario${failedClass}" href="#scenario-${tc.id}">
14198
+ <span class="toc-status ${statusClass}">${statusIcon}</span>
14199
+ ${deps.escapeHtml(tc.story.scenario)}
14200
+ </a>`;
14201
+ }).join("\n");
14202
+ features.push(`<div class="toc-feature">
14203
+ <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}">
14204
+ ${deps.escapeHtml(featureName)}
14205
+ </button>
14206
+ <div class="toc-scenarios">
14207
+ ${scenarios}
14208
+ </div>
14209
+ </div>`);
14210
+ }
14211
+ return `<nav class="toc-sidebar" aria-label="Table of contents">
14212
+ <div class="toc-header">
14213
+ <a href="#" class="toc-title" onclick="window.scrollTo({top:0,behavior:'smooth'});return false;">Contents</a>
14214
+ </div>
14215
+ <div class="toc-body">
14216
+ ${features.join("\n")}
14217
+ </div>
14218
+ </nav>`;
14219
+ }
14220
+
13510
14221
  // src/formatters/html/renderers/index.ts
13511
14222
  function normalizeOptions(options = {}) {
13512
14223
  return {
@@ -13520,7 +14231,9 @@ function normalizeOptions(options = {}) {
13520
14231
  markdownEnabled: options.markdownEnabled ?? true,
13521
14232
  permalinkBaseUrl: options.permalinkBaseUrl,
13522
14233
  ticketUrlTemplate: options.ticketUrlTemplate,
13523
- theme: options.theme ?? "default"
14234
+ tocEnabled: options.tocEnabled ?? true,
14235
+ theme: options.theme ?? "default",
14236
+ themePickerEnabled: options.themePickerEnabled ?? false
13524
14237
  };
13525
14238
  }
13526
14239
  function createHtmlFormatter(options = {}) {
@@ -13562,6 +14275,10 @@ function createHtmlFormatter(options = {}) {
13562
14275
  scenarioDeps
13563
14276
  };
13564
14277
  const tagBarDeps = { escapeHtml };
14278
+ const tocDeps = {
14279
+ escapeHtml,
14280
+ getStatusIcon
14281
+ };
13565
14282
  const bodyDeps = {
13566
14283
  renderMetaInfo,
13567
14284
  renderSummary,
@@ -13580,6 +14297,16 @@ function createHtmlFormatter(options = {}) {
13580
14297
  const bodyFn = theme.buildBody ?? buildBody;
13581
14298
  const body = bodyFn({ run }, bodyDeps);
13582
14299
  const templateFn = theme.generateTemplate ?? generateHtmlTemplate;
14300
+ const isStructuralTheme = !!(theme.buildBody || theme.generateTemplate);
14301
+ const tocHtml = opts.tocEnabled && !isStructuralTheme ? renderToc({ run }, tocDeps) : void 0;
14302
+ let themePickerHtml;
14303
+ let additionalThemeCss;
14304
+ if (opts.themePickerEnabled) {
14305
+ const cssOnlyThemes = getCssOnlyThemes();
14306
+ const pickerOptions = cssOnlyThemes.map((t) => `<option value="${t.name}"${t.name === theme.name ? " selected" : ""}>${t.label}</option>`).join("");
14307
+ themePickerHtml = `<select class="theme-picker" aria-label="Select theme">${pickerOptions}</select>`;
14308
+ additionalThemeCss = cssOnlyThemes.filter((t) => t.name !== theme.name).map((t) => ({ name: t.name, label: t.label, css: t.css }));
14309
+ }
13583
14310
  return templateFn(
13584
14311
  opts.title,
13585
14312
  theme.css,
@@ -13591,7 +14318,11 @@ function createHtmlFormatter(options = {}) {
13591
14318
  mermaidEnabled: opts.mermaidEnabled,
13592
14319
  markdownEnabled: opts.markdownEnabled,
13593
14320
  additionalJs: theme.additionalJs,
13594
- additionalImports: theme.additionalImports
14321
+ additionalImports: theme.additionalImports,
14322
+ tocHtml,
14323
+ themePickerHtml,
14324
+ additionalThemeCss,
14325
+ activeThemeName: theme.name
13595
14326
  }
13596
14327
  );
13597
14328
  }
@@ -13647,7 +14378,7 @@ var JUnitFormatter = class {
13647
14378
  lines.push(
13648
14379
  `<testsuites name="${escapeXml(this.options.suiteName)}" tests="${tests}" failures="${failures}" errors="${errors}" skipped="${skipped}" time="${time}">`
13649
14380
  );
13650
- const byFile = groupBy4(run.testCases, (tc) => tc.sourceFile);
14381
+ const byFile = groupBy5(run.testCases, (tc) => tc.sourceFile);
13651
14382
  for (const [file, testCases] of byFile) {
13652
14383
  lines.push(...this.buildTestSuite(file, testCases, indent, newline));
13653
14384
  }
@@ -13820,7 +14551,7 @@ var JUnitFormatter = class {
13820
14551
  function escapeXml(str) {
13821
14552
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
13822
14553
  }
13823
- function groupBy4(items, keyFn) {
14554
+ function groupBy5(items, keyFn) {
13824
14555
  const map = /* @__PURE__ */ new Map();
13825
14556
  for (const item of items) {
13826
14557
  const key = keyFn(item);
@@ -13995,7 +14726,7 @@ var MarkdownFormatter = class {
13995
14726
  * Render scenarios grouped by file.
13996
14727
  */
13997
14728
  renderByFile(lines, testCases) {
13998
- const byFile = groupBy5(testCases, (tc) => tc.sourceFile);
14729
+ const byFile = groupBy6(testCases, (tc) => tc.sourceFile);
13999
14730
  for (const [file, fileTestCases] of byFile) {
14000
14731
  lines.push(`## ${file}`);
14001
14732
  lines.push("");
@@ -14012,7 +14743,7 @@ var MarkdownFormatter = class {
14012
14743
  * Render suite groups.
14013
14744
  */
14014
14745
  renderSuiteGroups(lines, testCases, baseLevel) {
14015
- const bySuite = groupBy5(
14746
+ const bySuite = groupBy6(
14016
14747
  testCases,
14017
14748
  (tc) => tc.titlePath.join(this.options.suiteSeparator)
14018
14749
  );
@@ -14306,7 +15037,7 @@ var MarkdownFormatter = class {
14306
15037
  return entries;
14307
15038
  }
14308
15039
  };
14309
- function groupBy5(items, keyFn) {
15040
+ function groupBy6(items, keyFn) {
14310
15041
  const map = /* @__PURE__ */ new Map();
14311
15042
  for (const item of items) {
14312
15043
  const key = keyFn(item);
@@ -17230,7 +17961,7 @@ var ReportGenerator = class {
17230
17961
  excludeTags: options.excludeTags ?? [],
17231
17962
  formats: options.formats ?? ["cucumber-json"],
17232
17963
  outputDir: options.outputDir ?? "reports",
17233
- outputName: options.outputName ?? "test-results",
17964
+ outputName: options.outputName ?? "index",
17234
17965
  outputNameTimestamp: options.outputNameTimestamp ?? false,
17235
17966
  sortTestCases: options.sortTestCases ?? "none",
17236
17967
  output: {
@@ -17259,7 +17990,9 @@ var ReportGenerator = class {
17259
17990
  markdownEnabled: options.html?.markdownEnabled ?? true,
17260
17991
  permalinkBaseUrl: options.html?.permalinkBaseUrl,
17261
17992
  ticketUrlTemplate: options.html?.ticketUrlTemplate,
17262
- theme: options.html?.theme ?? "default"
17993
+ theme: options.html?.theme ?? "default",
17994
+ tocEnabled: options.html?.tocEnabled ?? true,
17995
+ themePickerEnabled: options.html?.themePickerEnabled ?? false
17263
17996
  },
17264
17997
  junit: {
17265
17998
  suiteName: options.junit?.suiteName ?? "Test Suite",
@@ -17382,7 +18115,9 @@ var ReportGenerator = class {
17382
18115
  mermaidEnabled: this.options.html.mermaidEnabled,
17383
18116
  markdownEnabled: this.options.html.markdownEnabled,
17384
18117
  permalinkBaseUrl: this.options.html.permalinkBaseUrl,
17385
- ticketUrlTemplate: this.options.html.ticketUrlTemplate
18118
+ ticketUrlTemplate: this.options.html.ticketUrlTemplate,
18119
+ tocEnabled: this.options.html.tocEnabled,
18120
+ themePickerEnabled: this.options.html.themePickerEnabled
17386
18121
  });
17387
18122
  return formatter.format(run);
17388
18123
  }
@@ -17488,7 +18223,7 @@ OPTIONS
17488
18223
  cucumber-messages Raw NDJSON (Cucumber Messages)
17489
18224
  --input-type <type> Input type: raw, canonical, or ndjson (default: raw)
17490
18225
  --output-dir <dir> Output directory (default: reports)
17491
- --output-name <name> Base filename (default: test-results)
18226
+ --output-name <name> Base filename (default: index)
17492
18227
  --output-name-timestamp Append run timestamp (UTC seconds) to output filename for before/after diffs
17493
18228
  --sort-test-cases <mode> Sort scenarios deterministically: id, source, none (default: none)
17494
18229
  --include <globs> Comma-separated globs to include test cases by sourceFile (e.g. "**/*.Story*.cs")
@@ -17503,6 +18238,8 @@ OPTIONS
17503
18238
  --html-no-mermaid Disable mermaid diagrams in HTML (enabled by default)
17504
18239
  --html-no-markdown Disable markdown parsing in HTML (enabled by default)
17505
18240
  --html-permalink-base-url <url> Base URL for source permalinks in HTML (e.g. "https://github.com/org/repo/blob/main")
18241
+ --html-no-toc Disable table of contents sidebar in HTML (enabled by default)
18242
+ --html-theme-picker Include theme picker in HTML report (embeds all CSS-only themes)
17506
18243
  --html-ticket-url-template <url> URL template for ticket links in HTML (use {ticket} as placeholder)
17507
18244
  --asset-mode <mode> Asset bundling: "none" (default) or "copy"
17508
18245
  --allow-missing-assets Warn on missing assets instead of failing
@@ -17570,7 +18307,7 @@ function parseCliArgs(argv) {
17570
18307
  "baseline-dir": { type: "string" },
17571
18308
  "input-type": { type: "string", default: "raw" },
17572
18309
  "output-dir": { type: "string", default: "reports" },
17573
- "output-name": { type: "string", default: "test-results" },
18310
+ "output-name": { type: "string", default: "index" },
17574
18311
  "output-name-timestamp": { type: "boolean", default: false },
17575
18312
  "sort-test-cases": { type: "string", default: "none" },
17576
18313
  include: { type: "string" },
@@ -17586,6 +18323,8 @@ function parseCliArgs(argv) {
17586
18323
  "html-no-markdown": { type: "boolean", default: false },
17587
18324
  "html-permalink-base-url": { type: "string" },
17588
18325
  "html-ticket-url-template": { type: "string" },
18326
+ "html-no-toc": { type: "boolean", default: false },
18327
+ "html-theme-picker": { type: "boolean", default: false },
17589
18328
  stdin: { type: "boolean", default: false },
17590
18329
  "json-summary": { type: "boolean", default: false },
17591
18330
  "emit-canonical": { type: "string" },
@@ -17745,6 +18484,8 @@ function parseCliArgs(argv) {
17745
18484
  htmlNoMarkdown: values["html-no-markdown"],
17746
18485
  htmlPermalinkBaseUrl: values["html-permalink-base-url"],
17747
18486
  htmlTicketUrlTemplate: values["html-ticket-url-template"],
18487
+ htmlNoToc: values["html-no-toc"],
18488
+ htmlThemePicker: values["html-theme-picker"],
17748
18489
  jsonSummary: values["json-summary"],
17749
18490
  emitCanonical: values["emit-canonical"],
17750
18491
  slackWebhook,
@@ -18247,7 +18988,9 @@ async function generateReports(run, args, _droppedMissingStory = 0) {
18247
18988
  mermaidEnabled: !args.htmlNoMermaid,
18248
18989
  markdownEnabled: !args.htmlNoMarkdown,
18249
18990
  permalinkBaseUrl: args.htmlPermalinkBaseUrl,
18250
- ticketUrlTemplate: args.htmlTicketUrlTemplate
18991
+ ticketUrlTemplate: args.htmlTicketUrlTemplate,
18992
+ tocEnabled: !args.htmlNoToc,
18993
+ themePickerEnabled: args.htmlThemePicker
18251
18994
  },
18252
18995
  assetMode: args.assetMode,
18253
18996
  allowMissingAssets: args.allowMissingAssets