Skip to content

epub.css: add optional dark-mode (light-dark) support - #11791

Open
k6G52m4Dz75W wants to merge 2 commits into
jgm:mainfrom
k6G52m4Dz75W:epub-css-dark-mode
Open

epub.css: add optional dark-mode (light-dark) support#11791
k6G52m4Dz75W wants to merge 2 commits into
jgm:mainfrom
k6G52m4Dz75W:epub-css-dark-mode

Conversation

@k6G52m4Dz75W

Copy link
Copy Markdown

Add light/dark color scheme support to the default EPUB stylesheet

Summary

The default EPUB stylesheet (data/epub.css) hardcodes a
single dark text color (#1a1a1a) on top of the reading system's own (usually
white) background. This patch adds an optional dark-mode theme using the CSS
light-dark() function (CSS Color Module Level 5) without changing the
appearance for readers that do not support it
.

How it works

  • Added :root { color-scheme: light dark; } to enable light-dark() and to let
    the reading system flip its own canvas/scrollbars to dark.
  • Wrapped the file's only explicit color (#1a1a1a, used for body text, the
    <hr> rule, and table borders) with light-dark(#1a1a1a, #e5e5e5), so the
    theme cleanly inverts in dark mode (the dark value is the inverse of the light
    value).
  • The html background uses light-dark(transparent, #1a1a1a):
    • Light mode: transparent lets the reader's own background (white, sepia,
      paper, …) show through, so we never override a reader's preferred light theme.
    • Dark mode: a dark canvas (#1a1a1a, slightly softer than pure black) is
      forced, so the book is actually dark even on readers that do not repaint their
      canvas from color-scheme.

Backward compatibility (progressive enhancement)

Each light-dark() declaration is preceded by a plain fallback using the
original #1a1a1a (e.g. background-color: #1a1a1a; immediately before
background-color: light-dark(#1a1a1a, #e5e5e5);).

A reader that does not understand light-dark() treats the function as an invalid
value and drops only that one declaration (it does not fall back to the first
argument). The preceding fallback therefore preserves the original look, so:

  • the <hr> separator stays visible,
  • table borders remain intact,
  • body text stays #1a1a1a.

No behavior change for legacy readers; the only difference is that modern readers
gain a dark theme.

Testing

Built an EPUB from a sample containing footnotes, blockquotes, code blocks, <hr>,
and tables; confirmed that light-dark() and the #1a1a1a fallbacks embed
correctly in the generated stylesheet1.css. (Actual dark/light rendering depends
on the reading system's support for color-scheme.)

Scope

Stylesheet only — no change to pandoc's conversion logic.

The default EPUB stylesheet hardcodes a single dark text color
(#1a1a1a) on top of the reading system's own background. This wraps
that color — and the <hr> rule and table borders that use it — with
CSS light-dark(), so readers supporting `color-scheme` get an
inverted dark theme.

Each light-dark() declaration is preceded by a plain #1a1a1a
fallback, so readers that do not support light-dark() simply drop
the new declaration and keep the unchanged original appearance
(graceful degradation). No change to pandoc's conversion logic.
@jgm

jgm commented Aug 4, 2026

Copy link
Copy Markdown
Owner

What if instead we simply removed the hard-coded dark color?

@k6G52m4Dz75W

Copy link
Copy Markdown
Author

You're right that, as a converter, a default stylesheet should stay neutral. Hard-coding a color means we're fighting the reading system, and for a generic default that's the wrong job. I'm happy to go either of two ways — I'll lay both out and let you pick.

Route A — drop the hard-coded colors entirely

This is the cleaner take on your suggestion. The five #1a1a1a occurrences across four rules aren't art direction, just "dark text, dark rules." Three of them need no replacement value because currentColor is already the initial border-color:

html {
  line-height: 1.2;
  font-family: Georgia, serif;
  /* color removed: inherits the reading system's text color */
}

hr {
  background-color: currentColor;
  border: none;
  height: 1px;
  margin: 1em 0;
}

tbody {
  margin-top: 0.5em;
  border-top: 1px solid;
  border-bottom: 1px solid;
}

th {
  border-top: 1px solid;
}

hr is the only one that needs an explicit value — with border: none; height: 1px and no background it would render invisible — and currentColor keeps it tied to the resolved text color.

One piece I'd still keep from the patch, and it matters independently of light-dark():

:root {
  color-scheme: light dark;
}

Once color is unset it falls back to the initial value canvastext, and per CSS Color Adjust L1 system colors resolve against the element's used color scheme. A document that never opts in is treated as light-only, so canvastext stays black even when the reading system has painted a dark canvas. Reading systems that implement dark mode by injecting their own color are fine either way, but the ones that just darken the canvas and expect the publication to use relative colors would give black-on-dark. The opt-in is what makes the removal reliable rather than incidental.

Trade-off: #1a1a1a was presumably a deliberate soft black, and dropping it means light mode gets the reading system's default, usually pure #000. That seems like the right thing to concede in a default sheet, but it is a visible change for existing light-mode output. This route also drops the dependency on Color Level 5 entirely — the diff is roughly four modified lines plus the :root block.

Route B — keep light-dark() but preserve the soft black

If the soft black is worth keeping, the minimal change is to keep light-dark() with #1a1a1a as its first argument (the light-mode color), so the existing look is unchanged in light mode and dark mode is added on top:

html {
  line-height: 1.2;
  font-family: Georgia, serif;
  color: light-dark(#1a1a1a, #e5e5e5);
}

hr {
  background-color: light-dark(#1a1a1a, #e5e5e5);
  border: none;
  height: 1px;
  margin: 1em 0;
}

tbody {
  margin-top: 0.5em;
  border-top: 1px solid light-dark(#1a1a1a, #e5e5e5);
  border-bottom: 1px solid light-dark(#1a1a1a, #e5e5e5);
}

th {
  border-top: 1px solid light-dark(#1a1a1a, #e5e5e5);
}

:root {
  color-scheme: light dark;
}

Pros: keeps the soft black in light mode and adds dark mode. Cons: it does depend on Color Level 5, which is the part your instinct was pushing back against.

The one thing I'd ask to keep in both routes

Whichever we pick, I'd suggest keeping an annotated example of the safe dual-declaration pattern in epub.css (as a comment), so authors who customize it do it correctly. The risk is this: once people see color-scheme: light dark in the shipped file, some will copy just the light-dark() form into their own stylesheet without a fallback —

color: light-dark(#1a1a1a, #e5e5e5);   /* wrong on its own: no fallback */

— and a reading system that doesn't understand light-dark() discards the entire declaration, not just the dark half. The text then falls back to the initial value, which in a dark canvas (with the color-scheme opt-in) resolves to light, but on older engines that never opted in can land as black-on-black or simply lose the intended color. That's an avoidable source of confusion.

The fix is trivial and worth documenting inline:

/*
 * Dark-mode customization for AUTHORS (optional):
 * Reading systems that don't understand light-dark() drop the whole
 * declaration, so always state a plain fallback FIRST. Older engines
 * ignore the second line and keep the first, so the color never vanishes.
 *
 *   color: #1a1a1a;                       (fallback: soft black)
 *   color: light-dark(#1a1a1a, #e5e5e5);  (dark mode -> light text)
 */

(Substitute border-color for the hairlines the same way.) Shipping this comment means anyone who copies the pattern gets the fallback for free, instead of discovering the hard way that bare light-dark() values can be silently dropped.

Happy to redo the PR either way — my own leaning is Route A for a default sheet, but the inline example is the part I'd most want to keep regardless of the choice.

(Out of scope here, but the highlighting CSS emitted by --highlight-style has the same issue with a much larger palette, and it can't be solved by omission the same way.)

@jgm

jgm commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Route B is basically the patch in this PR, right?
I don't know anything about these CSS details -- is there a disadvantage to depending on Color Level 5?
Does that mean that the epub would not work on older readers?

@k6G52m4Dz75W

Copy link
Copy Markdown
Author

Route B is basically the patch in this PR, right?

Yes — the current patch is Route B. It keeps light-dark() with #1a1a1a
as the light argument (so existing light-mode output is unchanged), adds
:root { color-scheme: light dark }, and ships the annotated
dual-declaration example as a comment so authors who copy the pattern keep a
fallback. The one refinement I'd suggest — optional, and already in the
epub.css draft — is using the Breeze dark palette for the dark values
instead of a mechanical inversion. More on that below.

I don't know anything about these CSS details -- is there a disadvantage to
depending on Color Level 5?

The only Color Level 5 piece is the light-dark() function. It shipped in
all major browsers in 2024 (Chromium 120+, Firefox 120+, Safari 17.5+) and
reading systems built on those engines pick it up for free. The real risk is
older readers that don't implement it — but it's contained two ways in this
patch:

  1. Per-declaration fallback. Every light-dark() line is preceded by a
    plain color: #1a1a1a; (or the equivalent border-color). An engine that
    doesn't understand light-dark() discards only the second line and keeps
    the first, so the original light look is preserved — the color never
    vanishes.
  2. Optional media-query layer. We can additionally add a
    @media (prefers-color-scheme: dark) { ... } block carrying the same dark
    values. That gives a second fallback path: readers without light-dark()
    support still get dark mode via the media query. The two layers are OR-ed,
    so the dark value stays consistent across both. (I've tested this
    combination; it degrades gracefully rather than breaking.)

So the practical downside of the Color L5 dependency is small.

Does that mean that the epub would not work on older readers?

No — that's the key reassurance. On an older reader the light-dark() lines
are ignored and the plain fallbacks take over, so the EPUB renders exactly as
it did before this patch (light mode only). Dark mode simply doesn't
activate, but nothing disappears, reflows, or breaks. The
color-scheme: light dark line only hints the reading system's own chrome
(scrollbars/canvas); it does not affect content rendering.

Context — the two open dark-highlighting efforts. This is separate from the
document-level change above, but I want to name it up front so the suggestion
below makes sense. There are two open efforts to make pandoc's syntax
highlighting
follow the reader's dark mode: PR #7226 (mb21's invert()/
filter-based approach) and PR #7131 (the --highlight-style-dark option). Both
lean on KDE's Breeze Dark palette — via skylighting, whose themes come from
KDE — and neither is merged yet, so pandoc's default highlighting still doesn't
auto-darken today. That context is what the next point builds on.

Suggestion: align the dark values with Breeze. The dark values aren't a
mechanical inversion of the light ones (a straight invert(#1a1a1a) =
#e5e5e5, which looks washed-out on a dark canvas). Instead they use the
designer-specified Breeze dark palette — #cfcfc2 text on #232629
background. Context: pandoc does its syntax highlighting through skylighting,
whose themes are derived from KDE's syntax-highlighting themes; KDE already
ships a Breeze Dark theme, and the open dark-mode highlighting PRs (#7226
and #7131) build on exactly that palette. Aligning the document's dark values
with the same breezedark colors is therefore a preparatory step — it keeps
the document body in sync with the dark highlighting scheme those PRs propose,
so the two will already match if/when that work lands. (For clarity: pandoc's
default highlighting does not yet follow the reader's color scheme
automatically — that's still the open question in those PRs, which aren't
merged. This document-level change is independent of them and safe to land
now; it just borrows the same light-dark() idea, applied at the document
level rather than the highlighting level.)

I've pushed a revision aligning the document's dark values with the breezedark
palette (#cfcfc2 text / #232629 bg) — the same KDE dark syntax colors that
PRs #7226 / #7131 build on — so the document body is already in sync with the
dark highlighting scheme those PRs propose. Happy to switch back to a plain
inversion if you'd prefer that. The @media fallback layer can still be added
if you'd like the extra safety margin.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants