Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
bb983bf
ENH: Implement sticky RHS table of contents with scroll highlighting
mmcky Dec 11, 2025
60f86f7
ENH: Add discrete Back to top button for sticky TOC mode
mmcky Dec 11, 2025
168be20
STY: Make Back to top button more discrete - remove background color
mmcky Dec 11, 2025
cd49a63
ENH: Auto-expand TOC subsections as you scroll
mmcky Dec 11, 2025
72f70ae
FIX: Keep TOC subsections expanded while within parent section
mmcky Dec 11, 2025
f97dac6
FIX: Keep entire section expanded while scrolling within it
mmcky Dec 11, 2025
1b3e036
FIX: Improve TOC hierarchy traversal and expand logic
mmcky Dec 11, 2025
2a6ac61
ENH: Add contents_autoexpand config option (default: false)
mmcky Dec 11, 2025
f8d6e5e
CONF: Change contents_autoexpand default to True for testing
mmcky Dec 11, 2025
bc38fb8
FIX: Simplify auto-expand logic for TOC subsections
mmcky Dec 11, 2025
57cfa9b
CI: Temporarily disable visual regression tests for sticky TOC develo…
mmcky Dec 11, 2025
784bafc
FIX: Correct ancestor traversal for TOC auto-expand
mmcky Dec 11, 2025
6d1d51e
DEBUG: Add console logging for TOC expand troubleshooting
mmcky Dec 11, 2025
a162725
DEBUG: Add more initialization logging
mmcky Dec 11, 2025
6e162a8
FIX: Handle string boolean values from theme.conf in Jinja templates
mmcky Dec 11, 2025
8c4ef0f
Clean up sticky TOC implementation and add tests
mmcky Dec 11, 2025
2726c3c
Remove scrollbar from sticky TOC, clip overflow at bottom
mmcky Dec 12, 2025
1a4182b
Fix autoexpand=false mode: highlight parent section and fix Top button
mmcky Dec 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ QuantEcon Book Theme is a Sphinx theme specifically designed for Jupyter Book pr

Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.

## ⚠️ CORE RULES - READ FIRST

These rules MUST be followed in every session:

1. **GitHub CLI (gh) Output**: ALWAYS write `gh` command output to a `/tmp` file for reliable capture:
```bash
gh run list 2>&1 | tee /tmp/gh_output.txt
gh pr view 123 2>&1 | tee /tmp/gh_output.txt
```

2. **NEVER Cancel Long-Running Commands**: Many commands take 5-15+ minutes. Set appropriate timeouts:
- `npm install` - ~50 seconds
- `tox` - 5-15 minutes (set timeout to 30+ minutes)
- `tox -e docs-update` - 5-15 minutes (set timeout to 30+ minutes)
- `pip install -e .` - 3-10 minutes (set timeout to 15+ minutes)

3. **Always Use tox for Tests**: DO NOT use `pytest` directly - always use `tox` for proper environment isolation

4. **Asset Compilation**: Run `npm run build` after any changes to files in `src/quantecon_book_theme/assets/`

## Working Effectively

### Bootstrap and Environment Setup
Expand Down
57 changes: 57 additions & 0 deletions docs/configure.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,63 @@ Available Pygments styles include: `default`, `friendly`, `monokai`, `github-dar

When `qetheme_code_style` is `True` (the default), the custom QuantEcon code highlighting is used and the `pygments_style` setting is ignored. When set to `False`, the theme will respect your `pygments_style` configuration.

## Sticky Table of Contents

The theme supports a sticky right-hand-side table of contents (TOC) that remains visible as you scroll through the page. This feature includes scroll spy functionality to highlight the currently visible section, and optional auto-expansion of subsections.

### Enabling Sticky TOC

To enable the sticky table of contents:

```python
html_theme_options = {
...
"sticky_contents": True,
...
}
```

For Jupyter Book projects, add to your `_config.yml`:

```yaml
sphinx:
config:
html_theme_options:
sticky_contents: true
```

### Features

When enabled, the sticky TOC provides:

- **Fixed positioning**: The TOC stays visible as you scroll
- **Active section highlighting**: The currently visible section is highlighted in the TOC
- **Back to top button**: A discrete "Back to top" button appears after scrolling down 300px
- **Auto-expand subsections**: Subsections automatically expand to show the current hierarchy

### Auto-Expand Subsections

By default, subsections in the TOC automatically expand as you scroll to show the current section hierarchy. This makes it easier to navigate deeply nested content.

To disable auto-expansion while keeping the sticky TOC:

```python
html_theme_options = {
...
"sticky_contents": True,
"contents_autoexpand": False,
...
}
```

When `contents_autoexpand` is `True` (the default when sticky TOC is enabled), the TOC will:
- Highlight the active section with bold text (scroll tracking)
- Expand parent sections to show the active item
- Expand the active item itself to show its subsections
- Collapse unrelated sections to reduce visual clutter

When set to `False`, only top-level section names are displayed with bold highlighting to indicate which section the reader is currently in. This provides a cleaner, more compact TOC view while still tracking scroll position.

## Git-Based Metadata

The theme automatically displays git-based metadata for each page, including the last modified timestamp and an interactive changelog dropdown. This feature requires:
Expand Down
4 changes: 4 additions & 0 deletions src/quantecon_book_theme/assets/scripts/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { initCollapsibleCode, initTableContainers } from "./code-blocks.js";
import { initPopups, initLauncherSettings } from "./popups.js";
import { initPageHeader, initChangelog } from "./page-header.js";
import { initStderrWarnings } from "./stderr-warnings.js";
import { initScrollSpy } from "./scrollspy.js";

document.addEventListener("DOMContentLoaded", function () {
// Load feather icon set
Expand Down Expand Up @@ -46,4 +47,7 @@ document.addEventListener("DOMContentLoaded", function () {

// Initialize stderr warnings
initStderrWarnings();

// Initialize sticky TOC scroll tracking
initScrollSpy();
});
205 changes: 205 additions & 0 deletions src/quantecon_book_theme/assets/scripts/scrollspy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/**
* ScrollSpy Module - Tracks scroll position and highlights active TOC items
*
* This module provides scroll tracking functionality for the sticky table of contents.
* It highlights the currently visible section in the TOC as the user scrolls.
*/

/**
* Initialize ScrollSpy for the sticky table of contents
* Only activates when the .sticky class is present on the TOC inner container
*/
export function initScrollSpy() {
// Only initialize if sticky TOC is enabled
const stickyContainer = document.querySelector(".inner.sticky");
if (!stickyContainer) {
return;
}

const stickyToc = stickyContainer.querySelector("#bd-toc-nav");
if (!stickyToc) {
return;
}

// Check if autoexpand is enabled via data attribute
const autoExpandEnabled = stickyContainer.dataset.autoexpand === "true";

const tocLinks = stickyToc.querySelectorAll("a");
if (tocLinks.length === 0) {
return;
}

// Build a map of section IDs to their TOC links with hierarchy info
const sections = [];
// Get only top-level TOC items (direct children of the main ul)
const topLevelUl = stickyToc.querySelector("ul");
const topLevelItems = topLevelUl
? Array.from(topLevelUl.children).filter((el) => el.tagName === "LI")
: [];

tocLinks.forEach((link) => {
const href = link.getAttribute("href");
if (href && href.startsWith("#")) {
const targetId = href.substring(1);
const targetElement = document.getElementById(targetId);
if (targetElement) {
const listItem = link.parentElement;
// Find which top-level item this link belongs to
// Traverse up through ancestors to find a top-level item
let topLevelParent = null;
let current = listItem;
while (current) {
if (topLevelItems.includes(current)) {
topLevelParent = current;
break;
}
// Move to parent li
current = current.parentElement;
if (current) {
current = current.closest("li");
}
}

sections.push({
id: targetId,
element: targetElement,
link: link,
listItem: listItem,
topLevelItem: topLevelParent,
});
}
}
});

if (sections.length === 0) {
return;
}

// Offset from top of viewport to consider a section "active"
const OFFSET = 120;

// Get the back-to-top button if it exists
const backToTopBtn = document.querySelector(".back-to-top-btn");
// Scroll threshold before showing the back-to-top button
const BACK_TO_TOP_THRESHOLD = 300;

/**
* Update active state based on current scroll position
*/
function updateActiveSection() {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const viewportHeight = window.innerHeight;
let activeSection = null;

// Show/hide back-to-top button based on scroll position
if (backToTopBtn) {
if (scrollTop > BACK_TO_TOP_THRESHOLD) {
backToTopBtn.classList.add("visible");
} else {
backToTopBtn.classList.remove("visible");
}
}

// Find the section that is currently in view
// We iterate through sections and find the last one whose top is above the threshold
for (let i = 0; i < sections.length; i++) {
const section = sections[i];
const rect = section.element.getBoundingClientRect();
const sectionTop = rect.top + scrollTop;

// Section is considered active if its top is above the offset threshold
if (sectionTop <= scrollTop + OFFSET) {
activeSection = section;
} else {
// Since sections are in order, once we find one below threshold, stop
break;
}
}

// If we're near the bottom of the page, activate the last section
if (
scrollTop + viewportHeight >=
document.documentElement.scrollHeight - 50
) {
activeSection = sections[sections.length - 1];
}

// First, clear all active classes
sections.forEach((section) => {
section.listItem.classList.remove("active");
section.link.classList.remove("active");
});

// Clear expanded classes only if autoexpand is enabled
if (autoExpandEnabled) {
const allListItems = stickyToc.querySelectorAll("li");
allListItems.forEach((li) => {
li.classList.remove("expanded");
});
}

// Now set active class on current section
if (activeSection) {
// When autoexpand is disabled, highlight the top-level parent instead
// This keeps the visible section highlighted even when scrolling through subsections
if (!autoExpandEnabled && activeSection.topLevelItem) {
const topLevelLink = activeSection.topLevelItem.querySelector(":scope > a");
activeSection.topLevelItem.classList.add("active");
if (topLevelLink) {
topLevelLink.classList.add("active");
}
} else {
activeSection.listItem.classList.add("active");
activeSection.link.classList.add("active");
}

// Only do auto-expand logic if the feature is enabled
if (autoExpandEnabled) {
// Strategy: Expand all ancestors of the active item so it's visible,
// AND expand the active item itself if it has children (to show its subsections)

// 1. First, expand the active item itself if it has children
// This ensures when you're on "4.2 Function Basics", you see 4.2.1, 4.2.2, etc.
if (activeSection.listItem.querySelector(":scope > ul")) {
activeSection.listItem.classList.add("expanded");
}

// 2. Expand all ancestors from the active item up to the root
// Start from the parent of the active item's li
let parentUl = activeSection.listItem.parentElement;
while (parentUl) {
// parentUl is a <ul>, find its parent <li>
let parentLi = parentUl.parentElement;
if (parentLi && parentLi.tagName === "LI") {
// This li contains a ul (which contains our active item), so expand it
parentLi.classList.add("expanded");
// Move up to the next level
parentUl = parentLi.parentElement;
} else {
// We've reached the top (nav element or similar)
break;
}
}
}
}
}

// Throttle scroll events for better performance
let ticking = false;

function onScroll() {
if (!ticking) {
window.requestAnimationFrame(() => {
updateActiveSection();
ticking = false;
});
ticking = true;
}
}

// Listen for scroll events
window.addEventListener("scroll", onScroll, { passive: true });

// Initial update
updateActiveSection();
}
Loading
Loading