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/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,331 @@ 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
+ }
4032
+
4033
+ .toc-feature {
4034
+ margin-bottom: 0.25rem;
4035
+ }
4036
+
4037
+ .toc-feature-toggle {
4038
+ display: flex;
4039
+ align-items: center;
4040
+ width: 100%;
4041
+ padding: 0.375rem 1rem;
4042
+ border: none;
4043
+ background: none;
4044
+ text-align: left;
4045
+ cursor: pointer;
4046
+ font-size: 0.8125rem;
4047
+ font-weight: 600;
4048
+ color: var(--foreground);
4049
+ font-family: var(--font-sans);
4050
+ }
4051
+
4052
+ .toc-feature-toggle:hover {
4053
+ background: var(--accent);
4054
+ }
4055
+
4056
+ .toc-feature-toggle[aria-expanded="false"] + .toc-scenarios {
4057
+ display: none;
4058
+ }
4059
+
4060
+ .toc-scenarios {
4061
+ display: flex;
4062
+ flex-direction: column;
4063
+ }
4064
+
4065
+ .toc-scenario {
4066
+ display: flex;
4067
+ align-items: baseline;
4068
+ gap: 0.375rem;
4069
+ padding: 0.25rem 1rem 0.25rem 1.5rem;
4070
+ color: var(--muted-foreground);
4071
+ text-decoration: none;
4072
+ font-size: 0.8125rem;
4073
+ line-height: 1.4;
4074
+ border-left: 2px solid transparent;
4075
+ transition: all 0.1s ease;
4076
+ }
4077
+
4078
+ .toc-scenario:hover {
4079
+ color: var(--foreground);
4080
+ background: var(--accent);
4081
+ }
4082
+
4083
+ .toc-scenario.toc-active {
4084
+ color: var(--foreground);
4085
+ border-left-color: var(--primary);
4086
+ font-weight: 500;
4087
+ }
4088
+
4089
+ .toc-scenario.toc-failed {
4090
+ border-left-color: var(--error, var(--destructive));
4091
+ }
4092
+
4093
+ .toc-status {
4094
+ flex-shrink: 0;
4095
+ font-size: 0.75rem;
4096
+ }
4097
+
4098
+ .toc-toggle {
4099
+ display: inline-flex;
4100
+ align-items: center;
4101
+ justify-content: center;
4102
+ width: 2.25rem;
4103
+ height: 2.25rem;
4104
+ border: 1px solid var(--border);
4105
+ border-radius: var(--radius);
4106
+ background: var(--background);
4107
+ cursor: pointer;
4108
+ color: var(--foreground);
4109
+ font-size: 1rem;
4110
+ transition: all 0.15s ease;
4111
+ }
4112
+
4113
+ .toc-toggle:hover {
4114
+ background: var(--accent);
4115
+ }
4116
+
4117
+ /* Mobile: overlay sidebar */
4118
+ @media (max-width: 767px) {
4119
+ .toc-sidebar {
4120
+ position: fixed;
4121
+ left: 0;
4122
+ top: 0;
4123
+ z-index: 50;
4124
+ box-shadow: var(--shadow-sm, 0 1px 3px rgb(0 0 0 / 0.1));
4125
+ transform: translateX(-100%);
4126
+ transition: transform 0.2s ease;
4127
+ }
4128
+
4129
+ .toc-sidebar.toc-mobile-open {
4130
+ transform: translateX(0);
4131
+ }
4132
+ }
4133
+
4134
+ /* ============================================================================
4135
+ Theme Picker
4136
+ ============================================================================ */
4137
+ .theme-picker {
4138
+ height: 2.25rem;
4139
+ padding: 0 0.5rem;
4140
+ border: 1px solid var(--border);
4141
+ border-radius: var(--radius);
4142
+ background: var(--background);
4143
+ color: var(--foreground);
4144
+ font-size: 0.8125rem;
4145
+ font-family: var(--font-sans);
4146
+ cursor: pointer;
4147
+ transition: all 0.15s ease;
4148
+ }
4149
+
4150
+ .theme-picker:hover {
4151
+ background: var(--accent);
4152
+ }
4153
+
4154
+ .theme-picker:focus-visible {
4155
+ outline: 2px solid var(--ring);
4156
+ outline-offset: 2px;
4157
+ }
4158
+
3516
4159
  `;
3517
4160
 
3518
4161
  // src/formatters/html/themes/default.ts
@@ -12769,6 +13412,11 @@ function resolveTheme(nameOrTheme) {
12769
13412
  }
12770
13413
  return theme;
12771
13414
  }
13415
+ function getCssOnlyThemes() {
13416
+ return [...THEME_REGISTRY.values()].filter(
13417
+ (theme) => !theme.buildBody && !theme.generateTemplate
13418
+ );
13419
+ }
12772
13420
 
12773
13421
  // src/formatters/html/renderers/status.ts
12774
13422
  function getStatusIcon(status) {
@@ -13050,7 +13698,7 @@ function renderStep(step, stepResult, index, deps) {
13050
13698
  const stepClass = isContinuation ? "step continuation" : "step";
13051
13699
  const stepDocs = deps.renderDocs(step.docs, "step-docs");
13052
13700
  const textHtml = deps.highlightStepParams ? deps.highlightStepParams(step.text) : deps.escapeHtml(step.text);
13053
- return `<div class="${stepClass}">
13701
+ return `<div class="${stepClass}" data-keyword="${deps.escapeHtml(keywordTrimmed)}" data-text="${deps.escapeHtml(step.text)}">
13054
13702
  <span class="step-status ${statusClass}">${statusIcon}</span>
13055
13703
  <span class="step-keyword">${deps.escapeHtml(step.keyword)}</span>
13056
13704
  <span class="step-text">${textHtml}</span>
@@ -13176,7 +13824,11 @@ function renderScenario(args, deps) {
13176
13824
  </div>
13177
13825
  <div class="scenario-meta">${tags}${tickets}${sourceLink}${traceBadge}${metricBadges}</div>
13178
13826
  </div>
13179
- <span class="scenario-duration">${duration}</span>
13827
+ <div class="scenario-actions">
13828
+ <button class="copy-scenario-btn" onclick="copyScenarioAsMarkdown('scenario-${tc.id}')" aria-label="Copy scenario as markdown" title="Copy as Markdown">&#x2398;</button>
13829
+ <button class="permalink-anchor" onclick="copyPermalink('scenario-${tc.id}')" aria-label="Copy link to scenario" title="Copy link">#</button>
13830
+ <span class="scenario-duration">${duration}</span>
13831
+ </div>
13180
13832
  </div>
13181
13833
  <div class="scenario-content">
13182
13834
  ${storyDocs}
@@ -13386,6 +14038,7 @@ function renderFeature(args, deps) {
13386
14038
  const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
13387
14039
  const collapsedClass = deps.startCollapsed ? " collapsed" : "";
13388
14040
  const ariaExpanded = !deps.startCollapsed;
14041
+ const featureSlug = `feature-${slugify(file)}`;
13389
14042
  const scenarios = testCases.map(
13390
14043
  (tc) => deps.renderScenario(
13391
14044
  { tc, metrics: args.metricsMap?.get(tc.id) },
@@ -13393,8 +14046,9 @@ function renderFeature(args, deps) {
13393
14046
  )
13394
14047
  ).join("\n");
13395
14048
  return `
13396
- <div class="feature${collapsedClass}">
14049
+ <div class="feature${collapsedClass}" id="${featureSlug}">
13397
14050
  <div class="feature-header" role="button" tabindex="0" aria-expanded="${ariaExpanded}">
14051
+ <button class="permalink-anchor" onclick="copyPermalink('${featureSlug}')" aria-label="Copy link to feature" title="Copy link">#</button>
13398
14052
  <div class="feature-info">
13399
14053
  <div class="feature-title">${deps.escapeHtml(featureName)}</div>
13400
14054
  <div class="feature-path">${deps.escapeHtml(file)}</div>
@@ -13507,6 +14161,57 @@ function renderFailureSummary(args, deps) {
13507
14161
  </div>`;
13508
14162
  }
13509
14163
 
14164
+ // src/formatters/html/renderers/toc.ts
14165
+ function groupBy4(items, keyFn) {
14166
+ const map = /* @__PURE__ */ new Map();
14167
+ for (const item of items) {
14168
+ const key = keyFn(item);
14169
+ const existing = map.get(key);
14170
+ if (existing) {
14171
+ existing.push(item);
14172
+ } else {
14173
+ map.set(key, [item]);
14174
+ }
14175
+ }
14176
+ return map;
14177
+ }
14178
+ function renderToc(args, deps) {
14179
+ const { run } = args;
14180
+ if (run.testCases.length === 0) return "";
14181
+ const byFile = groupBy4(run.testCases, (tc) => tc.sourceFile);
14182
+ const features = [];
14183
+ for (const [file, testCases] of byFile) {
14184
+ const suitePaths = testCases.map((tc) => tc.titlePath).filter((p) => p.length > 0);
14185
+ const featureName = suitePaths.length > 0 && suitePaths[0].length > 0 ? suitePaths[0][0] : file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? file;
14186
+ const featureSlug = `feature-${slugify(file)}`;
14187
+ const scenarios = testCases.map((tc) => {
14188
+ const statusIcon = deps.getStatusIcon(tc.status);
14189
+ const statusClass = `status-${tc.status}`;
14190
+ const failedClass = tc.status === "failed" ? " toc-failed" : "";
14191
+ return `<a class="toc-scenario${failedClass}" href="#scenario-${tc.id}">
14192
+ <span class="toc-status ${statusClass}">${statusIcon}</span>
14193
+ ${deps.escapeHtml(tc.story.scenario)}
14194
+ </a>`;
14195
+ }).join("\n");
14196
+ features.push(`<div class="toc-feature">
14197
+ <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}">
14198
+ ${deps.escapeHtml(featureName)}
14199
+ </button>
14200
+ <div class="toc-scenarios">
14201
+ ${scenarios}
14202
+ </div>
14203
+ </div>`);
14204
+ }
14205
+ return `<nav class="toc-sidebar" aria-label="Table of contents">
14206
+ <div class="toc-header">
14207
+ <span class="toc-title">Contents</span>
14208
+ </div>
14209
+ <div class="toc-body">
14210
+ ${features.join("\n")}
14211
+ </div>
14212
+ </nav>`;
14213
+ }
14214
+
13510
14215
  // src/formatters/html/renderers/index.ts
13511
14216
  function normalizeOptions(options = {}) {
13512
14217
  return {
@@ -13520,7 +14225,9 @@ function normalizeOptions(options = {}) {
13520
14225
  markdownEnabled: options.markdownEnabled ?? true,
13521
14226
  permalinkBaseUrl: options.permalinkBaseUrl,
13522
14227
  ticketUrlTemplate: options.ticketUrlTemplate,
13523
- theme: options.theme ?? "default"
14228
+ tocEnabled: options.tocEnabled ?? true,
14229
+ theme: options.theme ?? "default",
14230
+ themePickerEnabled: options.themePickerEnabled ?? false
13524
14231
  };
13525
14232
  }
13526
14233
  function createHtmlFormatter(options = {}) {
@@ -13562,6 +14269,10 @@ function createHtmlFormatter(options = {}) {
13562
14269
  scenarioDeps
13563
14270
  };
13564
14271
  const tagBarDeps = { escapeHtml };
14272
+ const tocDeps = {
14273
+ escapeHtml,
14274
+ getStatusIcon
14275
+ };
13565
14276
  const bodyDeps = {
13566
14277
  renderMetaInfo,
13567
14278
  renderSummary,
@@ -13580,6 +14291,16 @@ function createHtmlFormatter(options = {}) {
13580
14291
  const bodyFn = theme.buildBody ?? buildBody;
13581
14292
  const body = bodyFn({ run }, bodyDeps);
13582
14293
  const templateFn = theme.generateTemplate ?? generateHtmlTemplate;
14294
+ const isStructuralTheme = !!(theme.buildBody || theme.generateTemplate);
14295
+ const tocHtml = opts.tocEnabled && !isStructuralTheme ? renderToc({ run }, tocDeps) : void 0;
14296
+ let themePickerHtml;
14297
+ let additionalThemeCss;
14298
+ if (opts.themePickerEnabled) {
14299
+ const cssOnlyThemes = getCssOnlyThemes();
14300
+ const pickerOptions = cssOnlyThemes.map((t) => `<option value="${t.name}"${t.name === theme.name ? " selected" : ""}>${t.label}</option>`).join("");
14301
+ themePickerHtml = `<select class="theme-picker" aria-label="Select theme">${pickerOptions}</select>`;
14302
+ additionalThemeCss = cssOnlyThemes.filter((t) => t.name !== theme.name).map((t) => ({ name: t.name, label: t.label, css: t.css }));
14303
+ }
13583
14304
  return templateFn(
13584
14305
  opts.title,
13585
14306
  theme.css,
@@ -13591,7 +14312,11 @@ function createHtmlFormatter(options = {}) {
13591
14312
  mermaidEnabled: opts.mermaidEnabled,
13592
14313
  markdownEnabled: opts.markdownEnabled,
13593
14314
  additionalJs: theme.additionalJs,
13594
- additionalImports: theme.additionalImports
14315
+ additionalImports: theme.additionalImports,
14316
+ tocHtml,
14317
+ themePickerHtml,
14318
+ additionalThemeCss,
14319
+ activeThemeName: theme.name
13595
14320
  }
13596
14321
  );
13597
14322
  }
@@ -13647,7 +14372,7 @@ var JUnitFormatter = class {
13647
14372
  lines.push(
13648
14373
  `<testsuites name="${escapeXml(this.options.suiteName)}" tests="${tests}" failures="${failures}" errors="${errors}" skipped="${skipped}" time="${time}">`
13649
14374
  );
13650
- const byFile = groupBy4(run.testCases, (tc) => tc.sourceFile);
14375
+ const byFile = groupBy5(run.testCases, (tc) => tc.sourceFile);
13651
14376
  for (const [file, testCases] of byFile) {
13652
14377
  lines.push(...this.buildTestSuite(file, testCases, indent, newline));
13653
14378
  }
@@ -13820,7 +14545,7 @@ var JUnitFormatter = class {
13820
14545
  function escapeXml(str) {
13821
14546
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
13822
14547
  }
13823
- function groupBy4(items, keyFn) {
14548
+ function groupBy5(items, keyFn) {
13824
14549
  const map = /* @__PURE__ */ new Map();
13825
14550
  for (const item of items) {
13826
14551
  const key = keyFn(item);
@@ -13995,7 +14720,7 @@ var MarkdownFormatter = class {
13995
14720
  * Render scenarios grouped by file.
13996
14721
  */
13997
14722
  renderByFile(lines, testCases) {
13998
- const byFile = groupBy5(testCases, (tc) => tc.sourceFile);
14723
+ const byFile = groupBy6(testCases, (tc) => tc.sourceFile);
13999
14724
  for (const [file, fileTestCases] of byFile) {
14000
14725
  lines.push(`## ${file}`);
14001
14726
  lines.push("");
@@ -14012,7 +14737,7 @@ var MarkdownFormatter = class {
14012
14737
  * Render suite groups.
14013
14738
  */
14014
14739
  renderSuiteGroups(lines, testCases, baseLevel) {
14015
- const bySuite = groupBy5(
14740
+ const bySuite = groupBy6(
14016
14741
  testCases,
14017
14742
  (tc) => tc.titlePath.join(this.options.suiteSeparator)
14018
14743
  );
@@ -14306,7 +15031,7 @@ var MarkdownFormatter = class {
14306
15031
  return entries;
14307
15032
  }
14308
15033
  };
14309
- function groupBy5(items, keyFn) {
15034
+ function groupBy6(items, keyFn) {
14310
15035
  const map = /* @__PURE__ */ new Map();
14311
15036
  for (const item of items) {
14312
15037
  const key = keyFn(item);
@@ -17259,7 +17984,9 @@ var ReportGenerator = class {
17259
17984
  markdownEnabled: options.html?.markdownEnabled ?? true,
17260
17985
  permalinkBaseUrl: options.html?.permalinkBaseUrl,
17261
17986
  ticketUrlTemplate: options.html?.ticketUrlTemplate,
17262
- theme: options.html?.theme ?? "default"
17987
+ theme: options.html?.theme ?? "default",
17988
+ tocEnabled: options.html?.tocEnabled ?? true,
17989
+ themePickerEnabled: options.html?.themePickerEnabled ?? false
17263
17990
  },
17264
17991
  junit: {
17265
17992
  suiteName: options.junit?.suiteName ?? "Test Suite",
@@ -17382,7 +18109,9 @@ var ReportGenerator = class {
17382
18109
  mermaidEnabled: this.options.html.mermaidEnabled,
17383
18110
  markdownEnabled: this.options.html.markdownEnabled,
17384
18111
  permalinkBaseUrl: this.options.html.permalinkBaseUrl,
17385
- ticketUrlTemplate: this.options.html.ticketUrlTemplate
18112
+ ticketUrlTemplate: this.options.html.ticketUrlTemplate,
18113
+ tocEnabled: this.options.html.tocEnabled,
18114
+ themePickerEnabled: this.options.html.themePickerEnabled
17386
18115
  });
17387
18116
  return formatter.format(run);
17388
18117
  }
@@ -17503,6 +18232,8 @@ OPTIONS
17503
18232
  --html-no-mermaid Disable mermaid diagrams in HTML (enabled by default)
17504
18233
  --html-no-markdown Disable markdown parsing in HTML (enabled by default)
17505
18234
  --html-permalink-base-url <url> Base URL for source permalinks in HTML (e.g. "https://github.com/org/repo/blob/main")
18235
+ --html-no-toc Disable table of contents sidebar in HTML (enabled by default)
18236
+ --html-theme-picker Include theme picker in HTML report (embeds all CSS-only themes)
17506
18237
  --html-ticket-url-template <url> URL template for ticket links in HTML (use {ticket} as placeholder)
17507
18238
  --asset-mode <mode> Asset bundling: "none" (default) or "copy"
17508
18239
  --allow-missing-assets Warn on missing assets instead of failing
@@ -17586,6 +18317,8 @@ function parseCliArgs(argv) {
17586
18317
  "html-no-markdown": { type: "boolean", default: false },
17587
18318
  "html-permalink-base-url": { type: "string" },
17588
18319
  "html-ticket-url-template": { type: "string" },
18320
+ "html-no-toc": { type: "boolean", default: false },
18321
+ "html-theme-picker": { type: "boolean", default: false },
17589
18322
  stdin: { type: "boolean", default: false },
17590
18323
  "json-summary": { type: "boolean", default: false },
17591
18324
  "emit-canonical": { type: "string" },
@@ -17745,6 +18478,8 @@ function parseCliArgs(argv) {
17745
18478
  htmlNoMarkdown: values["html-no-markdown"],
17746
18479
  htmlPermalinkBaseUrl: values["html-permalink-base-url"],
17747
18480
  htmlTicketUrlTemplate: values["html-ticket-url-template"],
18481
+ htmlNoToc: values["html-no-toc"],
18482
+ htmlThemePicker: values["html-theme-picker"],
17748
18483
  jsonSummary: values["json-summary"],
17749
18484
  emitCanonical: values["emit-canonical"],
17750
18485
  slackWebhook,
@@ -18247,7 +18982,9 @@ async function generateReports(run, args, _droppedMissingStory = 0) {
18247
18982
  mermaidEnabled: !args.htmlNoMermaid,
18248
18983
  markdownEnabled: !args.htmlNoMarkdown,
18249
18984
  permalinkBaseUrl: args.htmlPermalinkBaseUrl,
18250
- ticketUrlTemplate: args.htmlTicketUrlTemplate
18985
+ ticketUrlTemplate: args.htmlTicketUrlTemplate,
18986
+ tocEnabled: !args.htmlNoToc,
18987
+ themePickerEnabled: args.htmlThemePicker
18251
18988
  },
18252
18989
  assetMode: args.assetMode,
18253
18990
  allowMissingAssets: args.allowMissingAssets