pelican theme upgrade: right sidebar table of contents
#######################################################

:author: Russell Ballestrini
:slug: pelican-theme-upgrade-right-sidebar-toc
:date: 2025-10-11 12:00
:tags: Code, Python
:status: published

**The companion** `pelican-svbhack theme repo <https://github.com/russellballestrini/pelican-svbhack>`_ **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:

.. code-block:: css

 :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:

.. code-block:: css

 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:

.. code-block:: css

 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 <https://github.com/getpelican/pelican/pull/3512>`_ to add this functionality directly to Pelican core. Once merged, this will work automatically without needing the plugin or template filter.

.. TODO: Remove lib/strip_toc_summary.py plugin and strip_anchors filter from index.html template when PR #3512 is merged into Pelican upstream.

**The Plugin**

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

.. code-block:: python

 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:

.. code-block:: jinja

 <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:

.. code-block:: css

 :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>``:

.. code-block:: javascript

 (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:

.. code-block:: javascript

 (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:

.. code-block:: css

 /* 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:

.. code-block:: css

 /* 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:

.. code-block:: javascript

 (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 </capability-driven-presentation/>`_ post from 2016. For more on resilient web design philosophy, I highly recommend the free online book `Resilient Web Design <https://resilientwebdesign.com/>`_ 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:

.. code-block:: html

 <!-- 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:

.. code-block:: javascript

 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:

.. code-block:: javascript

 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.

.. contents::
