How dark mode works in this system. Every colour comes from a token and the tokens flip, so a page or component built from them follows the reader without any work of its own; nobody hand-picks a dark value.
One control, identical on both surfaces, because a reader who learns it on arxiv.org
should not have to learn it again in a moderation queue. It ships as .ds-theme-toggle plus
theme.js, and every page under docs/ carries it.
Why a control at all, when the standing preference is to honour OS-level signals rather than offer in-product settings: an OS setting is one choice for a whole machine, and reading is per-context — the same laptop at night in a bright room, a shared library machine whose OS the reader does not control. It stays one control, not the first item of a settings page.
The three cards show the icon for each state; the bar below them is the control itself, the last item in the header, and one control cycles through the three. It is live, and so is the one at the top of this page.
The default: the page follows the machine's setting, and follows it again if it changes.
Always light, whatever the machine says.
Always dark. The next press returns to System.
In the header, where it lives
<!-- in the head, NOT deferred -->
<script src="theme.js"></script>
<!-- in the header bar -->
<button type="button" class="ds-theme-toggle"></button>
<span id="ds-theme-status" class="is-sr-only" role="status"></span>
<html> before the first paint or the page renders light and then flips — worst for
exactly the reader who chose dark because light hurts. check-policies.py fails on a page
that defers it.<button> cycling a value rather than the switch, which
means on or off. Leave the element empty: the script fills in the icon and the accessible name. It is the last item in the header bar, one button; the three states are never shown side by side.The same card four times. Nothing in its markup changes between the cells except the
data-theme attribute and, for internal tools, .ds-internal: every colour inside comes
from a token, and the tokens are declared again under the attribute, so the nearest ancestor that carries it
wins.
Accents hold in both modes: Open Blue and Access Lime stay themselves, which is why the text on them is a fixed token too.
<!-- The component: nothing in it knows which mode it is in -->
<div class="ds-card">
<p>Body text on a raised surface, with <a href="…">an inline link</a>.</p>
<div class="ds-alert ds-alert--success" role="status">…</div>
<div class="ds-btn-group">
<button class="ds-btn ds-btn-primary" type="button">Primary</button>
<button class="ds-btn" type="button">Secondary</button>
</div>
</div>
<!-- The same component, forced dark on any page -->
<div class="ds-card" data-theme="dark" style="color: var(--ds-text)">…</div>
<!-- Internal tools: the accent comes from the class, the mode from the attribute -->
<div class="ds-card ds-internal" data-theme="dark" style="color: var(--ds-text)">…</div>
data-theme wins, and
nesting works in both directions: a dark sample inside a light panel inside a page following the OS
renders correctly. The attribute re-points tokens and paints nothing itself. Anything inside that
already reads tokens follows; the container has to take its own ground and text from tokens too, which
here is .ds-card for the ground and color: var(--ds-text) for the text.<html>.Any page can opt out and lock to light, which is what a page whose job is to show light-mode rendering needs — colour specimens, side-by-side comparisons. Lock a whole page only when the entire page exists to demonstrate light-mode rendering; a single demo that must show one mode takes the attribute on its own container instead.
Use the theme control in the header: the page changes and this panel does not.
This panel stays light whatever the page does. Its <code>, its
links and its controls all read the light tokens declared on the panel.
<!-- The whole page: only when the page exists to show light rendering -->
<html lang="en" data-theme="light">
<!-- One demo, while the page follows the reader -->
<div class="ds-card" data-theme="light" style="color: var(--ds-text)">…</div>
@media block. Check that nothing
rewrites it: a static attribute in the markup is not a lock if a script on the page sets the attribute
at load. theme.js does exactly that, so a page that carries the theme control cannot also
be locked.color-scheme beside the
tokens, so native form controls, scrollbars and the default canvas take the same mode as the page. Do not
set it yourself to force a mode: it governs those browser-rendered widgets only and does nothing to any
stylesheet's prefers-color-scheme rules. Use data-theme.In order. Each step links to the reasoning below. If you follow only this list and read nothing else, dark mode will work.
--ds-surface. Not
--ds-canvas: the wash is a pale tint in light and is the canvas in dark, so anything
filled with it disappears. Trap 2--ds-text-on-accent on lime and on Open Blue alike.
Reaching for the plain token puts near-white text on a light fill, about 1.3:1.color-scheme to force a mode. It governs browser-rendered widgets
only — scrollbars, form controls — and does nothing to any stylesheet's prefers-color-scheme rules.
Use data-theme. Scoping a modedata-theme on the demo's own
container, not on the page. Pages should follow the reader.
Scoping a modedata-theme, mirror the dark block under the attribute as
well as the media query — including inherited code you did not write.
Why the attribute, not the toggleThree ways a token-driven page still comes out wrong in dark mode. All three were found building the blog, which is the first arXiv site to run dark end to end.
| Trap | What goes wrong | What to do |
|---|---|---|
| A token that fills a large field | Tokens whose dark value is a text accent go pale — Archival Blue flips to a light blue meant for headings. Painted across a masthead, the field washes out. | Pin the literal value and say why in a comment, the way the accent colors are pinned. An identity field is a fixed-role color, not a flipping one. |
| A fill that equals the canvas | Warm Wash is a pale tint in light and is the canvas in dark. Anything filled with it — placeholder blocks, quiet panels — disappears in dark mode. | Fill raised things with --ds-surface, which is white in light and a lifted surface in dark. Reserve the wash for the page itself. |
| Text color set outside the tokens | A platform preset or inherited color (a theme's global text setting, an inline style) cannot flip, so body text stays dark on a dark canvas. | Set body color and background from tokens, and let everything inherit. Check computed styles rather than trusting the cascade. |
Never hand-pick dark values into a light page
Dark tokens flip automatically with the OS, or not at all. If a demo needs a fixed mode, put
data-theme on the demo's own container rather than locking the whole page — and if you do
lock a page, check that nothing rewrites it. A static attribute in the markup is not a lock if a
script on the page sets the attribute at load.
color-scheme: light does not lock a page to light. It governs browser-rendered widgets —
scrollbars, form controls, the default canvas — and does nothing to any stylesheet's
prefers-color-scheme rules. A page that tried to force light this way still had a third-party
stylesheet paint a dark canvas underneath its light tokens. Use data-theme="light" to opt out.<div data-theme="light">. Tokens are declared on any element carrying the attribute, not only
on the root, and custom properties inherit, so the nearest ancestor with data-theme wins.
Nesting works in both directions: a dark sample inside a light panel inside a page following the OS renders
correctly, and the order of the blocks in the stylesheet does not matter.--ds-text-on-accent because the accent underneath it does not flip. A colour
specimen — a chip whose subject is the value itself — which is pinned to a literal and never scoped,
because it documents one specific value. And a code block, whose text is a fixed light value because the
chrome ground it sits on is dark in both modes.Dark values live in one @media (prefers-color-scheme: dark) block
in each stylesheet, keyed to the OS setting, and the same declarations are written again under
[data-theme="dark"] so the reader's choice wins over the machine's. The mirror is generated from
the @media block and must stay identical to it — python3 verification/check-drift.py
fails if the two disagree, on both stylesheets.
The OS preference is the default and covers most readers, but dark also has to win on a
light-OS machine whenever something sets data-theme="dark", which is why the mirror exists. The
trigger is the attribute, not a toggle: inherited code sets it too. The HTML paper mockup has no toggle of its
own, yet the ar5iv reader it builds on ships a theme control that writes ar5iv_theme to
localStorage, and a bootstrap script turns that into the attribute. Keying only to the media query
left a reader who picked dark there on a light-OS machine with a dark canvas and light tokens. If a page can
ever carry the attribute, mirror the block:
<html data-theme="dark"> <!-- forces dark regardless of the OS -->
<html data-theme="light"> <!-- forces light, and opts out of the @media block -->
<html> <!-- no attribute: follow the OS -->
--ds-text-on-accent on lime and on Open Blue alike. Reaching for plain --ds-text puts near-white text on a light blue fill in dark mode, about 1.3:1.The full public palette: Repository Brown becomes the dark canvas, accents hold
their light values, and the button constructions deepen their borders and shadows for definition. The
secondary zone ground, --ds-zone-secondary-bg, is Card Grey in light and the canvas in dark,
because Card Grey is the raised surface in dark and the two zones would not show. Both columns are read at
runtime from design-system.css through a light island and a dark island, so the values cannot
drift from the stylesheet and do not depend on the mode this page is in.
| Token | Light | Dark | Between modes |
|---|
Internal tools load the same tier 1 and take their accent from .ds-internal
on <html>, so everything above applies to them unchanged. Their own stylesheet,
internal-tools.css, re-points only the tokens whose dark value differs on that surface:
--ds-surface, --ds-surface-muted, --ds-surface-hover,
--ds-surface-hover-strong, --ds-surface-active and --ds-text-strong, a
shade apart from tier 1 because internal screens are dense with tables and want more separation between a row
and its ground. Its dark block is mirrored under [data-theme="dark"] the same way and checked by
the same script. Components riding those tokens: buttons, icon buttons, info card, alerts, forms, data table,
sortable headers, filter select, segmented control, toggle, type badges, metadata panel.