pastebin.richardson.dev

Dark Mode for Kiwix-Serve via Apache Reverse Proxy

Posted Mar 8, 202618.3 KB • Markdown Print Raw

Dark Mode for Kiwix-Serve via Apache Reverse Proxy

kiwix-serve 3.7.0 · Apache 2.4 · Catppuccin Mocha palette

Kiwix-serve does not include built-in theme support. This guide shows how to inject dark-mode CSS at the Apache reverse-proxy layer with mod_substitute, while avoiding changes to ZIM article content.


Table of Contents

  1. Architecture Overview
  2. The Problem
  3. Prerequisites
  4. Basic Reverse Proxy Setup
  5. Understanding Kiwix-Serve’s Page Structure
  6. The gzip Trap
  7. Scoping Injection with LocationMatch
  8. The Iframe Background Bleed-Through Problem
  9. Selector Scoping Strategy
  10. The Complete CSS
  11. Full Apache Configuration
  12. Verification
  13. Toggling Dark Mode On/Off
  14. Adapting to Other Color Palettes
  15. Troubleshooting

Architecture Overview

Browser ──HTTPS──▶ Apache 2.4 (mod_ssl + mod_proxy + mod_substitute)
                        │
                        │  mod_substitute injects <style> into </head>
                        │  only on / , /viewer , /nojs
                        │
                        ▼
                   kiwix-serve :8300  (serves ZIM files)
                        │
                        ├── /           Landing page (book library)
                        ├── /viewer     Viewer shell (toolbar + iframe)
                        ├── /nojs       No-JavaScript fallback
                        └── /wiki/…     ZIM article content (loaded inside iframe)

The key detail: the Kiwix viewer loads article content inside an <iframe>. CSS injected into the parent page cannot cross the iframe boundary into ZIM articles, but it can affect the iframe element itself.


The Problem

Kiwix-serve 3.7.0 (libkiwix 14.0.0) currently has:

  • No --dark-mode flag
  • No theme configuration
  • No CSS customization endpoint
  • A hardcoded light UI

If you prefer dark mode, the default library and toolbar can feel harsh in low-light conditions.


Prerequisites

Enable required Apache modules:

sudo a2enmod ssl rewrite proxy proxy_http headers substitute deflate filter
sudo systemctl restart apache2
ModulePurpose
mod_proxy / mod_proxy_httpReverse proxy to kiwix-serve
mod_sslHTTPS termination
mod_headersStrip Accept-Encoding before proxying
mod_substituteInject <style> into HTML responses
mod_deflateRe-compress responses after substitution
mod_filterRequired by mod_substitute content-type filtering

Basic Reverse Proxy Setup

Start with a minimal proxy before adding dark mode:

<VirtualHost *:443>
    ServerName mirror.example.com

    SSLEngine on
    SSLCertificateFile    /path/to/fullchain.pem
    SSLCertificateKeyFile /path/to/privkey.pem

    ProxyPreserveHost On
    ProxyPass        / http://127.0.0.1:8300/
    ProxyPassReverse / http://127.0.0.1:8300/
</VirtualHost>

Confirm this works first at https://mirror.example.com/.


Understanding Kiwix-Serve’s Page Structure

Kiwix-serve presents multiple page types with different HTML:

1. Landing Page (/)

<body>
  <div class="kiwixNav">        <!-- top navigation bar -->
  <div class="kiwixHomeBody">   <!-- book grid -->
    <div class="book__wrapper"> <!-- card -->
  <div class="kiwixfooter">     <!-- footer -->
</body>

2. Viewer Page (/viewer)

<body>
  <div id="kiwixtoolbarwrapper">  <!-- toolbar wrapper -->
    <div id="kiwixtoolbar">       <!-- controls -->
  <iframe id="content_iframe">    <!-- ZIM content -->
</body>

Important: Articles are loaded inside this iframe from paths like /wikipedia_en/Article_Name. Parent CSS cannot style the iframe document, but it can style the iframe element.

3. No-JS Fallback (/nojs)

A simplified library UI with many of the same classes as /.

4. ZIM Content (/wikipedia_en/..., /stackoverflow_en/..., etc.)

This is source article HTML from ZIM files. Do not inject your dark CSS here.


The gzip Trap

This is the most common mod_substitute failure mode.

The Problem

When browsers send Accept-Encoding: gzip, kiwix-serve returns gzip-compressed HTML. mod_substitute scans raw response bytes for </head>. In compressed output, that literal string does not exist, so the substitution silently does nothing.

Why curl Seems to Work

A basic curl request often gets uncompressed output, so substitution appears fine in tests while failing in browsers.

The Fix

Strip Accept-Encoding before proxying, then re-compress in Apache:

RequestHeader unset Accept-Encoding
AddOutputFilterByType DEFLATE text/html
AddOutputFilterByType SUBSTITUTE text/html

Flow:

Browser ──[Accept-Encoding: gzip]──▶ Apache
Apache strips Accept-Encoding    ──▶ kiwix-serve
kiwix-serve returns uncompressed ──▶ Apache
mod_substitute injects <style>   ──▶ mod_deflate compresses
Apache ──[Content-Encoding: gzip]──▶ Browser

Only apply this to the pages you modify; avoid forcing uncompressed responses for all paths.


Scoping Injection with LocationMatch

Inject only into these paths:

PathRegex MatchPage
/^/$Landing page
/viewer^/viewer$Viewer shell
/nojs^/nojs$No-JS fallback

Combined pattern: ^/(viewer|nojs)?$

<LocationMatch "^/(viewer|nojs)?$">
    RequestHeader unset Accept-Encoding
    AddOutputFilterByType DEFLATE text/html
    AddOutputFilterByType SUBSTITUTE text/html
    Substitute "s|</head>|<style>/* dark mode CSS here */</style></head>|i"
</LocationMatch>

Everything else (/wikipedia_en/..., /skin/..., etc.) remains untouched.


The Iframe Background Bleed-Through Problem

Even with URL scoping, one subtle viewer issue remains.

What Happens

If you darken the viewer page <body>, that background can show around transparent iframe regions, producing dark bleed-through behind light article content.

The Fix

  1. Do not set viewer body background.
  2. Apply dark body background only on landing pages via body:has(.kiwixNav).
  3. Force iframe element white:
#content_iframe {
  background: #fff !important;
  color-scheme: light;
}
  1. Set color-scheme appropriately:
body:has(#kiwixtoolbarwrapper) { color-scheme: dark; }
#content_iframe { color-scheme: light; }

Selector Scoping Strategy

Some Kiwix classes are generic (.modal, .fadeOut, .noResults). Scope those selectors under Kiwix-specific parents for safety.

SelectorRiskScoping
.kiwixNav, .kiwixSearchLowDirect
.kiwixHomeBody, .kiwixfooterLowDirect
#kiwixtoolbar, #content_iframeLowDirect
.book__wrapper, .book__titleMedium.kiwixHomeBody .book__*
.fadeOutHigh.kiwixHomeBody .fadeOut
.noResultsMedium.kiwixHomeBody .noResults
.modal, .modal-headingHighbody:has(.kiwixNav) .modal
.loader-spinnerMedium.kiwixHomeBody~.loader .loader-spinner

The Complete CSS

The full CSS below is human-readable. The Apache Substitute directive uses a minified one-line version.

Wrapped in @media (prefers-color-scheme: dark), so it only applies to users in dark mode.

@media (prefers-color-scheme: dark) {
  body:has(.kiwixNav) {
    background: #1e1e2e !important;
    color: #cdd6f4 !important;
    color-scheme: dark;
  }

  body:has(#kiwixtoolbarwrapper) { color-scheme: dark; }

  #content_iframe {
    background: #fff !important;
    color-scheme: light;
  }

  .kiwixNav { background: #181825 !important; }
  .kiwixNav__kiwixFilter { background: #313244 !important; color: #cdd6f4 !important; }
  .kiwixNav__select { background: #45475a !important; border-color: #585b70 !important; }
  .kiwixNav__select::after { background: #45475a !important; color: #cdd6f4 !important; }
  .kiwixSearch { background: #313244 !important; color: #cdd6f4 !important; border-color: #585b70 !important; }
  .kiwixButton { background: #45475a !important; color: #cdd6f4 !important; border-color: #585b70 !important; }

  .kiwixHomeBody { background: #1e1e2e !important; }
  .kiwixHomeBody__results { color: #cdd6f4 !important; }

  .kiwixHomeBody .book__wrapper { background: #313244 !important; color: #cdd6f4 !important; border-color: #45475a !important; }
  .kiwixHomeBody .book__header,
  .kiwixHomeBody .book__title { color: #cdd6f4 !important; }
  .kiwixHomeBody .book__description { color: #a6adc8 !important; }
  .kiwixHomeBody .book__download { background: #89b4fa !important; }
  .kiwixHomeBody .book__download > span { color: #1e1e2e !important; }
  .kiwixHomeBody .book__download:hover { background: #b4befe !important; }
  .kiwixHomeBody .book__languageTag { background: #45475a !important; color: #cdd6f4 !important; }
  .kiwixHomeBody .book__tags,
  .kiwixHomeBody .book__meta { color: #a6adc8 !important; }

  .kiwixHomeBody .fadeOut {
    background: linear-gradient(180deg, rgba(30,30,46,0) 0%, #1e1e2e 100%) !important;
  }

  .kiwixfooter { color: #a6adc8 !important; }
  .kiwixfooter a { color: #89b4fa !important; }

  .kiwixHomeBody ~ .loader .loader-spinner {
    border-color: #313244 !important;
    border-top-color: #89b4fa !important;
  }

  .kiwixHomeBody .noResults { color: #cdd6f4 !important; }
  .kiwixHomeBody .noResults > a { color: #89b4fa !important; }

  body:has(.kiwixNav) .modal { background: #313244 !important; color: #cdd6f4 !important; border-color: #45475a !important; }
  body:has(.kiwixNav) .modal-heading { background: #181825 !important; border-color: #45475a !important; }
  body:has(.kiwixNav) .modal-title { color: #cdd6f4 !important; }

  #kiwixtoolbar { background: #181825 !important; border-bottom-color: #45475a !important; }
  .kiwix #kiwixtoolbar button,
  .kiwix #kiwixtoolbar input[type="submit"] {
    background: #313244 !important;
    color: #cdd6f4 !important;
    border-color: #585b70 !important;
  }
  .kiwix #kiwixtoolbar #kiwixsearchform input[type="text"] {
    background: #313244 !important;
    color: #cdd6f4 !important;
    border-color: #585b70 !important;
  }
}

Color Palette: Catppuccin Mocha

RoleHexUsed For
Base#1e1e2ePage backgrounds
Mantle#181825Navbar, toolbar, modal headers
Surface0#313244Cards, inputs, search boxes, modals
Surface1#45475aButtons, selects, language tags
Overlay0#585b70Borders
Text#cdd6f4Primary text
Subtext0#a6adc8Descriptions, metadata, footer
Blue#89b4faLinks, download buttons, spinner accents
Lavender#b4befeHover states

Full Apache Configuration

# mirror.example.com — Kiwix multi-mirror with dark mode

<VirtualHost *:80>
    ServerName mirror.example.com
    RewriteEngine On
    RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=302,L]
</VirtualHost>

<IfModule mod_ssl.c>
    <VirtualHost *:443>
        ServerName mirror.example.com

        SSLEngine on
        SSLCertificateFile    /path/to/fullchain.pem
        SSLCertificateKeyFile /path/to/privkey.pem

        ProxyPreserveHost On
        ProxyPass        / http://127.0.0.1:8300/
        ProxyPassReverse / http://127.0.0.1:8300/

        <LocationMatch "^/(viewer|nojs)?$">
            RequestHeader unset Accept-Encoding
            AddOutputFilterByType DEFLATE text/html
            AddOutputFilterByType SUBSTITUTE text/html
            Substitute "s|</head>|<style>@media(prefers-color-scheme:dark){body:has(.kiwixNav){background:#1e1e2e!important;color:#cdd6f4!important;color-scheme:dark}body:has(#kiwixtoolbarwrapper){color-scheme:dark}#content_iframe{background:#fff!important;color-scheme:light}.kiwixNav{background:#181825!important}.kiwixNav__kiwixFilter{background:#313244!important;color:#cdd6f4!important}.kiwixNav__select{background:#45475a!important;border-color:#585b70!important}.kiwixNav__select::after{background:#45475a!important;color:#cdd6f4!important}.kiwixSearch{background:#313244!important;color:#cdd6f4!important;border-color:#585b70!important}.kiwixButton{background:#45475a!important;color:#cdd6f4!important;border-color:#585b70!important}.kiwixHomeBody{background:#1e1e2e!important}.kiwixHomeBody__results{color:#cdd6f4!important}.kiwixHomeBody .book__wrapper{background:#313244!important;color:#cdd6f4!important;border-color:#45475a!important}.kiwixHomeBody .book__header,.kiwixHomeBody .book__title{color:#cdd6f4!important}.kiwixHomeBody .book__description{color:#a6adc8!important}.kiwixHomeBody .book__download{background:#89b4fa!important}.kiwixHomeBody .book__download>span{color:#1e1e2e!important}.kiwixHomeBody .book__download:hover{background:#b4befe!important}.kiwixHomeBody .book__languageTag{background:#45475a!important;color:#cdd6f4!important}.kiwixHomeBody .book__tags,.kiwixHomeBody .book__meta{color:#a6adc8!important}.kiwixHomeBody .fadeOut{background:linear-gradient(180deg,rgba(30,30,46,0) 0%,#1e1e2e 100%)!important}.kiwixfooter{color:#a6adc8!important}.kiwixfooter a{color:#89b4fa!important}.kiwixHomeBody~.loader .loader-spinner{border-color:#313244!important;border-top-color:#89b4fa!important}.kiwixHomeBody .noResults{color:#cdd6f4!important}.kiwixHomeBody .noResults>a{color:#89b4fa!important}body:has(.kiwixNav) .modal{background:#313244!important;color:#cdd6f4!important;border-color:#45475a!important}body:has(.kiwixNav) .modal-heading{background:#181825!important;border-color:#45475a!important}body:has(.kiwixNav) .modal-title{color:#cdd6f4!important}#kiwixtoolbar{background:#181825!important;border-bottom-color:#45475a!important}.kiwix #kiwixtoolbar button,.kiwix #kiwixtoolbar input[type=\"submit\"]{background:#313244!important;color:#cdd6f4!important;border-color:#585b70!important}.kiwix #kiwixtoolbar #kiwixsearchform input[type=\"text\"]{background:#313244!important;color:#cdd6f4!important;border-color:#585b70!important}}</style></head>|i"
        </LocationMatch>
    </VirtualHost>
</IfModule>

Validate and reload:

sudo apache2ctl configtest && sudo systemctl reload apache2

Verification

1. Check Injection Counts

curl -sk https://mirror.example.com/ | grep -c 'prefers-color-scheme'
curl -sk https://mirror.example.com/viewer | grep -c 'prefers-color-scheme'
curl -sk https://mirror.example.com/wikipedia_en/ | grep -c 'prefers-color-scheme'

Expected output: 1, 1, 0.

2. Visual Verification (Playwright)

docker run --rm \
  --add-host=mirror.example.com:YOUR_SERVER_IP \
  -v /tmp/screenshots:/screenshots \
  mcr.microsoft.com/playwright:v1.49.1-noble \
  bash -c '
    cd /tmp && npm init -y >/dev/null 2>&1
    npm i [email protected] >/dev/null 2>&1
    NODE_PATH=/tmp/node_modules node -e "
      const { chromium } = require(\"playwright\");
      (async () => {
        const browser = await chromium.launch();
        const ctx = await browser.newContext({
          colorScheme: \"dark\",
          viewport: { width: 1280, height: 800 },
          ignoreHTTPSErrors: true
        });
        const page = await ctx.newPage();
        await page.goto(\"https://mirror.example.com/\", { waitUntil: \"domcontentloaded\" });
        await page.waitForTimeout(3000);
        await page.screenshot({ path: \"/screenshots/landing.png\" });
        await browser.close();
      })();
    "
  '

3. Pixel Check

from PIL import Image
img = Image.open('/tmp/screenshots/landing.png')
r, g, b = img.getpixel((640, 400))[:3]
assert max(r, g, b) < 80, f"Expected dark pixel, got RGB({r},{g},{b})"

Toggling Dark Mode On/Off

Disable

sudo sed -i '/<LocationMatch "^\/(viewer|nojs)?\$">/,/<\/LocationMatch>/s/^/#DM# /' \
  /etc/apache2/sites-available/mirror.example.com.conf
sudo apache2ctl configtest && sudo systemctl reload apache2

Re-enable

sudo sed -i 's/^#DM# //' /etc/apache2/sites-available/mirror.example.com.conf
sudo apache2ctl configtest && sudo systemctl reload apache2

Adapting to Other Color Palettes

Replace hex values using this mapping:

VariableCatppuccin Mocha
Page background#1e1e2e
Navbar / toolbar#181825
Cards / inputs#313244
Buttons / selects#45475a
Borders#585b70
Primary text#cdd6f4
Secondary text#a6adc8
Accent / links#89b4fa
Hover accent#b4befe

Popular alternatives: Dracula, Nord, Gruvbox, and Tokyo Night.


Troubleshooting

CSS works with curl, not browser

Likely gzip handling. Verify:

  • mod_headers is enabled
  • RequestHeader unset Accept-Encoding is inside the correct <LocationMatch>
  • Apache was reloaded

Dark background leaks around article content

Do not set viewer body background. Use:

  • body:has(#kiwixtoolbarwrapper) { color-scheme: dark }
  • #content_iframe { background: #fff !important; color-scheme: light }

Apache syntax error on Substitute

Escape internal quotes as \", especially selectors like input[type=\"text\"].

CSS appears in HTML but visuals are unchanged

Likely browser cache. Hard-refresh (Ctrl+Shift+R) or test in an incognito window.

LocationMatch not matching /

^/(viewer|nojs)?$ matches:

  • /
  • /viewer
  • /nojs

URL fragments are not sent to the server, so /viewer#hash still matches /viewer at the HTTP layer.

mod_substitute silently does nothing

Check module load and filters:

apachectl -M | grep substitute

Also ensure response Content-Type is text/html.


Compatibility

ComponentTested Version
kiwix-serve3.7.0 (libkiwix 14.0.0, libzim 9.2.3)
Apache2.4.66 (Debian)
BrowserChromium 131, Firefox 134

If a future Kiwix release changes class names/markup, inspect fresh HTML (curl -s http://127.0.0.1:8300/) and adjust selectors.