Random   •   Archives   •   RSS   •   About   •   Contact   •  

pelican theme upgrade: right sidebar table of contents

The companion pelican-svbhack theme repo lives here.

We upgraded the pelican-svbhack theme to add a right sidebar table of contents. This enhancement improves navigation on longer articles by providing quick access to section headings. The theme now features responsive breakpoints, dark mode with theme-aware syntax highlighting, and progressive enhancement for JavaScript-dependent features.

the design

The implementation follows these principles:

  1. Desktop experience: Fixed right sidebar at 10% width displaying the table of contents
  2. Mobile experience: Table of contents remains inline within the article content
  3. Pure CSS: No JavaScript required for positioning or behavior
  4. Fixed header: Navigation stays visible while scrolling
  5. CSS variables: All colors use variables for easy theming

key features

Right Sidebar TOC

On desktop screens (1024px+), the table of contents automatically moves from inline to a fixed right sidebar. The sidebar scrolls independently from the main content, with the scrollbar hidden for a clean appearance.

Article-Only Display

The right sidebar only appears on article pages, not on the homepage or archive pages. This is accomplished using a body class system that targets article pages specifically.

Fixed Header

The main navigation header now uses position: fixed so it stays visible when scrolling. All padding and spacing was adjusted to accommodate this change.

Responsive Design

On tablets and mobile devices (screens ≤1023px), the table of contents reverts to its inline position within the article content, ensuring usability across all screen sizes. The media queries are structured to unfix positioned elements first, then reposition them, creating smooth visual transitions at breakpoints.

Article content uses fluid width (max-width: 660px; width: 100%) instead of fixed width, allowing it to shrink responsively when the TOC sidebar is present. This prevents overlap at screen widths between 1024px and approximately 1300px, where the fixed header, article content, and TOC sidebar would otherwise compete for space.

CSS Color Variables

All hardcoded colors were converted to CSS custom properties:

:root {
  --color-white: #ffffff;
  --color-black: #000000;
  --color-text: #4d4d4d;
  --color-link: #0084B4;
  --color-border: #eeeeee;
  /* ... and more */
}

This makes it trivial to create new color schemes or dark mode variants.

implementation details

The right sidebar uses CSS positioning to reposition the existing inline table of contents:

body.article main article div.article_text div.contents {
  position: fixed;
  top: 0;
  right: 0;
  width: 10%;
  height: 100vh;
  padding: 40px 15px 40px 15px;
  overflow-y: auto;
  /* Hide scrollbar but keep functionality */
  scrollbar-width: none;
  -ms-overflow-style: none;
}

When the TOC is present, the main content width adjusts using the :has() pseudo-class:

body.article main:has(article div.article_text div.contents) {
  width: 65%;
}

homepage summary filtering

ReStructuredText automatically generates anchor links in section headings when a table of contents is present. These anchors work perfectly on article pages, but become broken links when article summaries appear on the homepage - the anchor targets don't exist in that context.

To solve this, we created a Pelican plugin that provides a strip_anchors Jinja2 filter. This filter removes TOC divs and toc-backref anchor links from article summaries before they're rendered on the homepage.

Update: We submitted Pull Request #3512 to add this functionality directly to Pelican core. Once merged, this will work automatically without needing the plugin or template filter.

The Plugin

The plugin (lib/strip_toc_summary.py) uses regex to strip problematic HTML:

def strip_anchors(text):
    """Jinja2 filter to strip TOC and anchor links from HTML text."""
    if not text:
        return text

    # Remove the entire <div class="contents"> ... </div> block
    text = re.sub(
        r'<div\s+class="contents[^"]*"[^>]*>.*?</div>',
        '',
        text,
        flags=re.DOTALL | re.IGNORECASE
    )

    # Remove anchor links from headings
    # Converts <a class="toc-backref" href="#id1">text</a> to just text
    text = re.sub(
        r'<a[^>]*class="[^"]*toc-backref[^"]*"[^>]*>(.*?)</a>',
        r'\1',
        text,
        flags=re.DOTALL | re.IGNORECASE
    )

    return text

Template Usage

The filter is applied in the theme's index.html template:

<div class="article_text">
  {{ article.summary | strip_anchors }}
</div>

This approach ensures that:

  1. Article pages retain their TOC anchor links for proper navigation
  2. Homepage summaries display clean headings without broken links
  3. The filtering happens at template render time, not during content generation

Why This Matters

Without this filter, homepage excerpts would contain links like <a href="#the-design">the design</a> that point to anchors that don't exist on the homepage. This creates a poor user experience with broken navigation. The filter strips these anchors while preserving the heading text, resulting in clean, functional homepage previews.

dark mode implementation

After publishing this post, we immediately implemented dark mode using the CSS variable system! The dark theme is now the default, with a light mode toggle available in both desktop and mobile navigation.

Key Features

The dark mode implementation includes:

  1. Default dark theme with light mode as the alternative
  2. localStorage persistence - your theme preference is saved across sessions
  3. Flash-of-unstyled-content prevention - inline script in <head> applies theme before page renders
  4. Toggle buttons in both desktop header and mobile menu
  5. Inverted colors for third-party widgets like UncloseAI button

Theme Colors

The dark mode uses these color overrides:

:root.light-mode {
  --color-white: #000000;
  --color-black: #ffffff;
  --color-text: #333333;
  --color-link: #0084B4;
  --color-link-visited: #551A8B;
  --color-border: #dddddd;
  --color-code-bg: #f5f5f5;
  --color-background: #ffffff;
  --color-header-bg: #ffffff;
}

By inverting the black and white variables and adjusting the other colors, the entire theme switches seamlessly between dark and light modes.

Flash-of-Unstyled-Content Prevention

To prevent the flash-of-unstyled-content when loading a saved theme preference, an inline script runs synchronously in the <head>:

(function() {
  const savedTheme = localStorage.getItem('theme');
  if (savedTheme === 'light') {
    document.documentElement.classList.add('light-mode');
  }
})();

This applies the theme class before the page renders, eliminating any visual flash.

Button Text Synchronization

The toggle buttons need to show the opposite of the current theme (when in dark mode, button says "Light"). This is handled by another inline script at the bottom of the page that executes after the buttons are in the DOM but before the page is visible:

(function() {
  const savedTheme = localStorage.getItem('theme');
  if (savedTheme === 'light') {
    const desktopButton = document.getElementById('theme-toggle');
    const mobileButton = document.getElementById('mobile-theme-toggle');
    if (desktopButton) desktopButton.textContent = 'Dark';
    if (mobileButton) mobileButton.textContent = 'Dark';
  }
})();

Third-Party Widget Styling

The UncloseAI button required special handling with hardcoded colors to override its default styling:

/* Dark mode (default) - white background, black text */
.uncloseai-floating-button,
#floating-ai-button {
  background-color: #ffffff;
  color: #000000;
  border: 2px solid #000000;
}

/* Light mode - black background, white text */
:root.light-mode .uncloseai-floating-button,
:root.light-mode #floating-ai-button {
  background-color: #000000;
  color: #ffffff;
  border: 2px solid #ffffff;
}

The UncloseAI widget dynamically sets CSS custom properties for its button colors, but our CSS naturally overrides these defaults because our theme stylesheet loads after the widget's CSS. No !important flags are needed - standard CSS cascade rules are sufficient.

Progressive Enhancement - Hiding Buttons Without JavaScript

The theme toggle buttons only work when JavaScript is enabled, so they should be hidden when JavaScript is disabled. This is accomplished using progressive enhancement:

/* Hide theme toggle buttons by default */
#theme-toggle,
#mobile-theme-toggle {
  display: none;
}

/* Show buttons only when JavaScript is enabled */
.js-enabled #theme-toggle,
.js-enabled #mobile-theme-toggle {
  display: inline-block;
}

The js-enabled class is added to the document element in the same inline script that handles flash-of-unstyled-content prevention:

(function() {
  // Add js-enabled class to show theme toggle buttons
  document.documentElement.classList.add('js-enabled');

  const savedTheme = localStorage.getItem('theme');
  if (savedTheme === 'light') {
    document.documentElement.classList.add('light-mode');
  }
})();

This approach ensures the buttons are never visible if they can't function. Without JavaScript, users still get a perfectly usable site in the default dark theme - they just don't see non-functional toggle buttons cluttering the interface.

This technique follows the same principles we wrote about in the capability driven presentation post from 2016. For more on resilient web design philosophy, I highly recommend the free online book Resilient Web Design by Jeremy Keith.

Syntax Highlighting Theme Switching

Code syntax highlighting needed theme-aware styling since most color schemes are designed for either light or dark backgrounds. We implemented automatic switching between Pygments themes:

  • Dark mode: Uses the monokai style with bright colors on dark backgrounds
  • Light mode: Uses the default style with dark colors on light backgrounds

The implementation loads both stylesheets but disables the inactive one:

<!-- Dark mode syntax highlighting (default) -->
<link rel="stylesheet" href="/theme/css/pygments-dark.css" id="pygments-dark">
<!-- Light mode syntax highlighting -->
<link rel="stylesheet" href="/theme/css/pygments-light.css" id="pygments-light" disabled>

The theme toggle function switches between them by enabling/disabling the stylesheets:

function toggleTheme() {
  const pygmentsDark = document.getElementById('pygments-dark');
  const pygmentsLight = document.getElementById('pygments-light');

  if (root.classList.contains('light-mode')) {
    // Switching to dark mode
    pygmentsDark.disabled = false;
    pygmentsLight.disabled = true;
  } else {
    // Switching to light mode
    pygmentsDark.disabled = true;
    pygmentsLight.disabled = false;
  }
}

The flash-of-unstyled-content prevention script also switches the syntax highlighting stylesheet before the page renders, ensuring code blocks always appear with the correct colors.

Preventing Scroll Jump with URL Anchors

When toggling themes on a page with a URL anchor (like #syntax-highlighting-theme-switching), the browser would jump to the anchor after theme changes caused layout reflow. This creates poor UX when users toggle themes mid-article.

The solution temporarily removes the hash from the URL during theme toggle, then restores it without triggering navigation:

function toggleTheme() {
  // Save hash and temporarily remove to prevent jump
  const hash = window.location.hash;
  if (hash) {
    history.replaceState(null, null, ' ');
  }

  // ... perform theme toggle ...

  // Restore hash without jumping
  if (hash) {
    history.replaceState(null, null, hash);
  }
}

By removing the hash before theme changes, the browser has no anchor to jump to during layout reflow. The hash is restored afterward, preserving the URL without triggering the browser's default anchor-jumping behavior.




Want comments on your site?

Remarkbox — is a free SaaS comment service which embeds into your pages to keep the conversation in the same place as your content. It works everywhere, even static HTML sites like this one!

Remarks: pelican theme upgrade: right sidebar table of contents

© Russell Ballestrini.