ui: theme tokens and a four-theme runtime

Every color now resolves through CSS custom properties on
html[data-theme=...], so a theme is a table of values rather than code.
Four themes ship: light, dark, night (red hues only, to preserve dark
adaptation off-grid) and amber (monochrome amber terminal).

Fonts are exposed as two roles, --font-body and --font-mono, which a theme
assigns a bundled face to; amber uses the mono face. Faces must be
self-hosted, never fetched from a CDN, so the app keeps working offline.

Night mode also filters media that cannot consume tokens - video, images,
PDFs, embedded pages, canvases - through an SVG color matrix that maps
luminance onto the red channel. Tag such an element with `.media`; a filter
on an app-level ancestor would create a containing block and break fixed
positioning.

ThemeProvider persists the choice and index.html stamps data-theme from
localStorage before the bundle loads, so the first paint already matches the
saved theme on a slow connection. The OS preference is followed only while
"system" is selected; night and amber are explicit choices. The nav toggle
becomes a five-item picker.

Semantic components still render themselves, so only the shell is
token-driven for now; the inverted props stay in place until each component
is migrated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-07-29 18:32:50 -06:00
parent 616424b223
commit 806727023d
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
12 changed files with 669 additions and 92 deletions

View file

@ -28,6 +28,29 @@
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>WROLPi: Create your own off-grid library</title>
<script>
/*
Stamp the saved theme on <html> before first paint. ThemeProvider sets the same
attribute once React mounts, but the bundle can take a while to arrive over a
hotspot — without this the page would paint in the wrong theme first. Keep the
storage key and theme names in sync with src/components/Theme.tsx.
*/
(function () {
var theme = 'light';
try {
var saved = localStorage.getItem('color-scheme');
if (saved === 'light' || saved === 'dark' || saved === 'night' || saved === 'amber') {
theme = saved;
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
// `system`, unset, or written by an older version.
theme = 'dark';
}
} catch (e) {
// Storage blocked; fall back to light.
}
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View file

@ -6,6 +6,10 @@ import {VideosTabLayout, VideosPage, VideosSettingsPage, VideosStatistics, Video
import AdminRoute from "./components/admin/AdminRoute";
import {Container} from "semantic-ui-react";
import 'semantic-ui-offline/semantic.min.css';
// Theme tokens load after Semantic so the shell's colors win over Semantic's defaults.
import './themes/fonts.css';
import './themes/tokens.css';
import {MediaFilterDefs} from "./themes/MediaFilterDefs";
import {MoreRoute} from "./components/Apps";
import {InventoryRoute} from "./components/inventory/InventoryRoute";
import {ArchiveRoute} from "./components/Archive";
@ -62,6 +66,7 @@ function Footer() {
function Root() {
return <QueryProvider>
<ThemeProvider>
<MediaFilterDefs/>
<TagsProvider>
<KeyboardShortcutsProvider>
<FilePreviewProvider>

View file

@ -9,6 +9,7 @@ import {
Container,
Dimmer,
DimmerDimmable,
Dropdown,
GridColumn,
GridRow,
Icon as SIcon,
@ -25,6 +26,7 @@ import {useBluetooth, useDesktop, useHotspot, useSearchDirectories, useSearchOrd
import {Media, SettingsContext, StatusContext, ThemeContext} from "../contexts/contexts";
import {
Accordion,
amberTheme,
Breadcrumb,
Button,
Card as ThemedCard,
@ -37,9 +39,11 @@ import {
Loader,
Menu,
Modal,
nightTheme,
Popup,
Segment,
Statistic
Statistic,
systemTheme
} from "./Theme";
import {FilePreviewContext} from "./FilePreview";
import _ from "lodash";
@ -1378,20 +1382,37 @@ export function LoadStatistic({label, value, cores, ...props}) {
{...props}/>;
}
export function DarkModeToggle() {
const {savedTheme, cycleSavedTheme} = useContext(ThemeContext);
let iconName = 'lightbulb outline';
if (savedTheme === darkTheme) {
iconName = 'moon';
} else if (savedTheme === lightTheme) {
iconName = 'sun';
}
// Theme picker entries. `night` and `amber` are only reachable here; neither is ever applied
// automatically. See ui-design.md.
const themeChoices = [
{value: systemTheme, text: 'System', icon: 'lightbulb outline'},
{value: lightTheme, text: 'Light', icon: 'sun'},
{value: darkTheme, text: 'Dark', icon: 'moon'},
{value: nightTheme, text: 'Night', icon: 'eye'},
{value: amberTheme, text: 'Amber', icon: 'terminal'},
];
return <Icon
name={iconName}
onClick={cycleSavedTheme}
style={{cursor: 'pointer', marginRight: '0.8em'}}
/>
export function DarkModeToggle() {
const {savedTheme, setTheme} = useContext(ThemeContext);
const active = themeChoices.find(i => i.value === savedTheme) || themeChoices[0];
return <Dropdown
item
icon={<Icon name={active.icon} style={{margin: 0}}/>}
title={`Theme: ${active.text}`}
style={{padding: 0, marginRight: '0.8em'}}
>
<Dropdown.Menu>
<Dropdown.Header content='Theme'/>
{themeChoices.map(choice => <Dropdown.Item
key={choice.value}
icon={choice.icon}
text={choice.text}
active={choice.value === active.value}
onClick={() => setTheme(choice.value)}
/>)}
</Dropdown.Menu>
</Dropdown>
}

View file

@ -100,10 +100,47 @@ import {ThemeContextValue, ThemeName, SavedThemeName} from "../types/theme";
export const darkTheme = 'dark';
export const lightTheme = 'light';
export const nightTheme = 'night';
export const amberTheme = 'amber';
export const defaultTheme = lightTheme;
export const systemTheme = 'system';
export const themeSessionKey = 'color-scheme';
// Every theme a user can apply, in the order the theme picker offers them.
export const themeNames: ThemeName[] = [lightTheme, darkTheme, nightTheme, amberTheme];
// Themes built on a dark background. Semantic components are `inverted` in these.
const darkThemes: ThemeName[] = [darkTheme, nightTheme, amberTheme];
// Themes a user must choose deliberately; `prefers-color-scheme` never picks them.
const explicitOnlyThemes: ThemeName[] = [nightTheme, amberTheme];
export const isDarkTheme = (theme: ThemeName): boolean => darkThemes.includes(theme);
const isThemeName = (value: unknown): value is ThemeName => themeNames.includes(value as ThemeName);
/** Resolve the saved preference to the theme that should be applied right now. */
export const resolveTheme = (saved: SavedThemeName): ThemeName => {
if (isThemeName(saved)) {
return saved;
}
// `system`, null, or a value written by an older version.
const prefersDark = typeof window.matchMedia === 'function'
&& window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? darkTheme : lightTheme;
}
const readSavedTheme = (): SavedThemeName => {
try {
const value = localStorage.getItem(themeSessionKey);
if (isThemeName(value) || value === systemTheme) {
return value;
}
} catch (e) {
// localStorage can throw when cookies/storage are blocked.
console.error('Unable to read the saved theme', e);
}
return null;
}
interface ThemeProviderProps {
children: React.ReactNode;
}
@ -114,98 +151,89 @@ export function ThemeProvider({children, ...props}: ThemeProviderProps) {
console.error('ThemeWrapper does not support props!');
}
// Properties to manipulate elements with theme.
// Invert when Semantic supports it. Example: <Menu {...i}>
const [i, setI] = useState<{inverted?: boolean}>({});
// Invert style when Semantic does not support it. Example: <p {...s}>This paragraph changes style</p>
const [s, setS] = useState<{style?: CSSProperties}>({});
// Invert text when Semantic does not support it.
const [t, setT] = useState<{style?: CSSProperties}>({});
const [inverted, setInverted] = useState<string>('');
// savedTheme is the user's preference: a theme name, `system`, or null (never chosen).
const [savedTheme, setSavedTheme] = useState<SavedThemeName>(readSavedTheme);
// theme is what is currently applied.
const [theme, setThemeName] = useState<ThemeName>(() => resolveTheme(readSavedTheme()));
// theme can be one of [darkTheme, lightTheme]
const [theme, setTheme] = useState<ThemeName>(lightTheme);
// savedTheme can be one of [null, darkTheme, lightTheme, systemTheme]
const [savedTheme, setSavedTheme] = useState<SavedThemeName>(
localStorage.getItem('color-scheme') as SavedThemeName
);
// `data-theme` on <html> is what the token CSS keys off of. index.html stamps it before
// first paint from the same localStorage value, so the first render already matches.
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
const setDarkTheme = (save = false) => {
console.debug('setDarkTheme');
setI({inverted: true});
setS({style: {backgroundColor: '#1B1C1D', color: '#dddddd'}});
setT({style: {color: '#eeeeee'}});
setInverted('inverted');
setTheme(darkTheme);
document.body.style.background = '#171616';
if (save) {
saveTheme(darkTheme);
// Follow the OS preference only while the user has not chosen a specific theme.
useEffect(() => {
if (isThemeName(savedTheme) || typeof window.matchMedia !== 'function') {
return;
}
const query = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => setThemeName(resolveTheme(savedTheme));
query.addEventListener('change', onChange);
return () => query.removeEventListener('change', onChange);
}, [savedTheme]);
const saveTheme = (value: SavedThemeName) => {
setSavedTheme(value);
try {
if (value === null) {
localStorage.removeItem(themeSessionKey);
} else {
localStorage.setItem(themeSessionKey, value);
}
} catch (e) {
console.error('Unable to save the theme', e);
}
}
/** Apply and persist a theme. Pass `system` to follow the OS preference. */
const setTheme = (value: SavedThemeName) => {
if (value !== systemTheme && value !== null && !isThemeName(value)) {
console.error(`Unknown theme! ${value}`);
return;
}
saveTheme(value);
setThemeName(resolveTheme(value));
}
// Retained for callers written against the old two-theme API.
const setDarkTheme = (save = false) => {
setThemeName(darkTheme);
if (save) saveTheme(darkTheme);
}
const setLightTheme = (save = false) => {
console.debug('setLightTheme');
setI({inverted: undefined});
setS({});
setT({});
setInverted('');
setTheme(lightTheme);
document.body.style.background = '#FFFFFF';
if (save) {
saveTheme(lightTheme);
}
}
const saveTheme = (value: ThemeName) => {
setSavedTheme(value);
localStorage.setItem(themeSessionKey, value);
setThemeName(lightTheme);
if (save) saveTheme(lightTheme);
}
const cycleSavedTheme = (e?: React.MouseEvent) => {
// Cycle: System -> Dark -> Light
// Cycle: System -> Light -> Dark -> Night -> Amber -> System
if (e) {
e.preventDefault();
}
if (savedTheme === systemTheme || savedTheme == null) {
saveTheme(darkTheme);
} else if (savedTheme === darkTheme) {
saveTheme(lightTheme);
} else if (savedTheme === lightTheme) {
saveTheme(systemTheme as ThemeName);
} else {
console.error(`Unknown theme! savedTheme=${savedTheme}`);
}
applyTheme();
const order: SavedThemeName[] = [systemTheme, ...themeNames];
const current = order.indexOf(isThemeName(savedTheme) ? savedTheme : systemTheme);
setTheme(order[(current + 1) % order.length]);
}
const matchTheme = (): ThemeName => {
// Returns darkTheme if saved theme is dark, lightTheme if saved theme is dark,
// darkTheme if system prefers dark, otherwise lightTheme.
const colorScheme = localStorage.getItem(themeSessionKey);
setSavedTheme(colorScheme as SavedThemeName);
if (colorScheme === darkTheme) {
return darkTheme;
} else if (colorScheme === lightTheme) {
return lightTheme
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? darkTheme : lightTheme;
const dark = isDarkTheme(theme);
// Compatibility layer for components which have not been migrated to tokens yet. Semantic
// has no concept of the night/amber themes, so they reuse its dark treatment while pulling
// their colors from the tokens. These values disappear with the last Semantic component.
const i = dark ? {inverted: true} : {inverted: undefined};
const inverted = dark ? 'inverted' : '';
let s: {style?: CSSProperties} = {};
let t: {style?: CSSProperties} = {};
if (theme === darkTheme) {
s = {style: {backgroundColor: '#1B1C1D', color: '#dddddd'}};
t = {style: {color: '#eeeeee'}};
} else if (explicitOnlyThemes.includes(theme)) {
s = {style: {backgroundColor: 'var(--panel)', color: 'var(--text)'}};
t = {style: {color: 'var(--text)'}};
}
const applyTheme = () => {
const match = matchTheme();
if (match === darkTheme) {
setDarkTheme();
} else {
setLightTheme();
}
};
useEffect(() => {
applyTheme();
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const themeValue: ThemeContextValue = {
i, // Used for Semantic elements which support "inverted".
s, // Used to invert the style some elements.
@ -213,6 +241,8 @@ export function ThemeProvider({children, ...props}: ThemeProviderProps) {
inverted, // Used to add "invert" to className.
theme,
savedTheme,
isDark: dark,
setTheme,
setDarkTheme,
setLightTheme,
cycleSavedTheme,

View file

@ -0,0 +1,169 @@
import React, {useContext} from 'react';
import {act, render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {ThemeContext} from '../contexts/contexts';
import {
amberTheme,
darkTheme,
isDarkTheme,
lightTheme,
nightTheme,
resolveTheme,
systemTheme,
ThemeProvider,
themeSessionKey,
} from './Theme';
// Lets a test drive `prefers-color-scheme` and assert on what the provider applied.
let prefersDark = false;
const mockMatchMedia = () => {
const listeners = new Set();
window.matchMedia = jest.fn().mockImplementation(query => ({
matches: query === '(prefers-color-scheme: dark)' ? prefersDark : false,
media: query,
addEventListener: (_event, listener) => listeners.add(listener),
removeEventListener: (_event, listener) => listeners.delete(listener),
addListener: (listener) => listeners.add(listener),
removeListener: (listener) => listeners.delete(listener),
dispatchEvent: () => false,
}));
return {
// Emulate the OS switching to dark/light while the app is open.
change: (value) => {
prefersDark = value;
act(() => listeners.forEach(listener => listener({matches: value})));
},
};
};
function ThemeProbe() {
const {theme, savedTheme, isDark, setTheme, cycleSavedTheme} = useContext(ThemeContext);
return <>
<span data-testid='theme'>{theme}</span>
<span data-testid='saved'>{String(savedTheme)}</span>
<span data-testid='dark'>{String(isDark)}</span>
<button onClick={() => setTheme(nightTheme)}>night</button>
<button onClick={() => setTheme(systemTheme)}>system</button>
<button onClick={cycleSavedTheme}>cycle</button>
</>
}
const renderProvider = () => render(<ThemeProvider><ThemeProbe/></ThemeProvider>);
const appliedTheme = () => document.documentElement.getAttribute('data-theme');
describe('ThemeProvider', () => {
beforeEach(() => {
prefersDark = false;
localStorage.clear();
document.documentElement.removeAttribute('data-theme');
mockMatchMedia();
});
it('applies the saved theme and stamps it on <html>', () => {
localStorage.setItem(themeSessionKey, nightTheme);
renderProvider();
expect(screen.getByTestId('theme')).toHaveTextContent(nightTheme);
expect(appliedTheme()).toBe(nightTheme);
});
it('falls back to the OS preference when no theme is saved', () => {
prefersDark = true;
renderProvider();
expect(screen.getByTestId('theme')).toHaveTextContent(darkTheme);
expect(screen.getByTestId('saved')).toHaveTextContent('null');
});
it('persists a chosen theme so it survives a reload', async () => {
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'night'}));
expect(localStorage.getItem(themeSessionKey)).toBe(nightTheme);
expect(appliedTheme()).toBe(nightTheme);
});
it('follows the OS preference while `system` is chosen', async () => {
const media = mockMatchMedia();
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'system'}));
media.change(true);
expect(screen.getByTestId('theme')).toHaveTextContent(darkTheme);
expect(appliedTheme()).toBe(darkTheme);
});
it('ignores the OS preference once a specific theme is chosen', async () => {
const media = mockMatchMedia();
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'night'}));
media.change(true);
expect(screen.getByTestId('theme')).toHaveTextContent(nightTheme);
});
it('reports night and amber as dark themes', async () => {
renderProvider();
await userEvent.click(screen.getByRole('button', {name: 'night'}));
expect(screen.getByTestId('dark')).toHaveTextContent('true');
});
it('cycles through every theme and back to system', async () => {
renderProvider();
const cycle = screen.getByRole('button', {name: 'cycle'});
const seen = [];
for (let index = 0; index < 5; index++) {
await userEvent.click(cycle);
seen.push(screen.getByTestId('saved').textContent);
}
expect(seen).toEqual([lightTheme, darkTheme, nightTheme, amberTheme, systemTheme]);
});
it('recovers from a theme name written by an older version', () => {
localStorage.setItem(themeSessionKey, 'sepia');
renderProvider();
expect(screen.getByTestId('theme')).toHaveTextContent(lightTheme);
expect(appliedTheme()).toBe(lightTheme);
});
});
describe('theme helpers', () => {
beforeEach(() => {
prefersDark = false;
mockMatchMedia();
});
it('resolves explicit theme names as themselves', () => {
[lightTheme, darkTheme, nightTheme, amberTheme].forEach(theme => {
expect(resolveTheme(theme)).toBe(theme);
});
});
it('resolves `system` and null from the OS preference', () => {
expect(resolveTheme(systemTheme)).toBe(lightTheme);
expect(resolveTheme(null)).toBe(lightTheme);
prefersDark = true;
expect(resolveTheme(systemTheme)).toBe(darkTheme);
});
it('knows which themes are built on a dark background', () => {
expect(isDarkTheme(lightTheme)).toBe(false);
expect(isDarkTheme(darkTheme)).toBe(true);
expect(isDarkTheme(nightTheme)).toBe(true);
expect(isDarkTheme(amberTheme)).toBe(true);
});
});

View file

@ -30,6 +30,8 @@ export const ThemeContext = React.createContext({
t: {},
inverted: '',
savedTheme: null,
isDark: false,
setTheme: () => {},
setDarkTheme: () => {},
setLightTheme: () => {},
cycleSavedTheme: () => {},

View file

@ -73,6 +73,12 @@ export function renderWithProviders(
s: inverted ? {style: {backgroundColor: '#1B1C1D', color: '#dddddd'}} : {}, // For style inversion
t: inverted ? {style: {color: '#eeeeee'}} : {}, // For text color inversion (Header, etc.)
theme: inverted ? 'dark' : 'light',
isDark: inverted,
savedTheme: null,
setTheme: jest.fn(),
setDarkTheme: jest.fn(),
setLightTheme: jest.fn(),
cycleSavedTheme: jest.fn(),
...themeContext
};

View file

@ -0,0 +1,37 @@
import React from 'react';
/**
* SVG filter definitions used by monochrome themes to remap media that cannot
* consume theme tokens: video, images, PDFs, embedded pages, canvases.
*
* Mounted once, near the app root. See tokens.css for the `.media` rule that
* applies these, and ui-design.md for the night-vision rationale.
*/
export function MediaFilterDefs() {
return <svg
aria-hidden='true'
focusable='false'
style={{position: 'absolute', width: 0, height: 0, overflow: 'hidden'}}
>
<defs>
{/*
Night: luminance -> red channel, green and blue driven to zero, so
every filtered pixel lights only the red subpixel. The gamma lift
brightens midtones; red is perceptually dim, and without it text on
a filtered document or PDF is unreadable.
*/}
<filter id='wrolpi-night-red' colorInterpolationFilters='sRGB'>
<feColorMatrix
type='matrix'
values='0.2126 0.7152 0.0722 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 1 0'
/>
<feComponentTransfer>
<feFuncR type='gamma' amplitude='1' exponent='0.65' offset='0'/>
</feComponentTransfer>
</filter>
</defs>
</svg>
}

25
app/src/themes/fonts.css Normal file
View file

@ -0,0 +1,25 @@
/*
* Bundled font registry.
*
* Fonts are exposed to themes as ROLES (--font-body, --font-mono), never as
* families. A theme assigns one of the bundled `--font-<name>` stacks to a
* role; it may not name an arbitrary family, because a theme config file
* cannot ship font files and an unknown family would silently fall back —
* offline, with no error.
*
* Every face here must be available offline. The system stacks cost nothing.
* Additional faces are added as `@fontsource/*` npm packages (woff2 bundled at
* build time) — NEVER a fonts CDN <link>.
*/
:root {
/* Available faces. */
--font-system-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, 'Noto Sans', sans-serif;
--font-system-mono: 'SF Mono', 'Cascadia Code', 'DejaVu Sans Mono', Menlo, Monaco, Consolas,
'Liberation Mono', monospace;
/* Role assignments. Themes override these. */
--font-body: var(--font-system-sans);
--font-mono: var(--font-system-mono);
}

178
app/src/themes/tokens.css Normal file
View file

@ -0,0 +1,178 @@
/*
* WROLPi theme tokens.
*
* Every color in the interface resolves through these custom properties, so a
* theme is a table of values and components never branch on the current mode.
* See ui-design.md for the design rules and ui-migration-plan.md for the
* migration this is part of.
*
* Four built-in themes:
* light - default
* dark - default when the OS prefers dark
* night - night-vision preserving; red hues ONLY. Status is encoded by
* brightness, danger by dashed borders. Explicit choice only.
* amber - monochrome amber terminal palette. Explicit choice only.
*
* `data-theme` is stamped on <html> by an inline script in index.html before
* first paint, then maintained by ThemeProvider.
*/
/* Light is also the fallback when `data-theme` is missing or unrecognized. */
:root,
html[data-theme="light"] {
--bg: #f3f4f3;
--panel: #ffffff;
--head: #e4e6e5;
--border: #8f9698;
--text: #22282a;
--muted: #5f686a;
--red: #b23a3a;
--amber: #95661a;
--orange: #b05a24;
--yellow: #96821c;
--olive: #6f7a24;
--green: #3f7a2e;
--teal: #2a7d78;
--blue: #20629c;
--violet: #5c4fa8;
--purple: #7b3f9c;
--pink: #b03a72;
--brown: #7a5233;
--grey: #6a7072;
--black: #22282a;
--white: #ffffff;
/* Text drawn on top of a filled color button. */
--btn-text: #ffffff;
}
/*
* Role aliases. Declared once: `var()` resolves on <html>, where each theme
* has already redefined the color it points at.
*/
:root {
--primary: var(--blue);
--success: var(--green);
--warning: var(--amber);
--danger: var(--red);
}
html[data-theme="dark"] {
--bg: #1c1f21;
--panel: #26292b;
--head: #141617;
--border: #4a5052;
--text: #e6e8e6;
--muted: #93999b;
--red: #e06c6c;
--amber: #d6a656;
--orange: #dd8a55;
--yellow: #d6c156;
--olive: #b5bd63;
--green: #7fb069;
--teal: #63bdb6;
--blue: #5c9dd6;
--violet: #9a8fe0;
--purple: #b884d6;
--pink: #dd7fae;
--brown: #b08a68;
--grey: #93999b;
--black: #0d0f10;
--white: #e6e8e6;
--btn-text: #1c1f21;
}
/*
* Night: red hues only. A single non-red pixel defeats the purpose of the
* mode, so the whole palette collapses onto one red ramp ordered by
* brightness — dim for structural roles, brightest reserved for danger.
*/
html[data-theme="night"] {
--bg: #0a0000;
--panel: #160404;
--head: #000000;
--border: #5c1010;
--text: #e04a4a;
--muted: #7a2525;
--red: #ff3b3b;
--amber: #d94040;
--orange: #e04a4a;
--yellow: #d94040;
--olive: #5c1a1a;
--green: #b03030;
--teal: #963030;
--blue: #8a2828;
--violet: #7a2929;
--purple: #6e2020;
--pink: #c04848;
--brown: #451212;
--grey: #7a2525;
--black: #2a0808;
--white: #ff8a8a;
--btn-text: #0a0000;
}
/* Amber: a monochrome amber terminal palette, same flat style as the rest. */
html[data-theme="amber"] {
--bg: #0d0600;
--panel: #1a0f00;
--head: #000000;
--border: #5c3a08;
--text: #ff9500;
--muted: #8a5810;
--red: #ffc040;
--amber: #e08a20;
--orange: #f09428;
--yellow: #e08a20;
--olive: #6e4610;
--green: #c97a10;
--teal: #8a581c;
--blue: #a86618;
--violet: #7a4c14;
--purple: #6e4410;
--pink: #d08430;
--brown: #452708;
--grey: #8a5810;
--black: #241504;
--white: #ffc875;
--btn-text: #0d0600;
/* Amber assigns the mono face to the body role; that is its character. */
--font-body: var(--font-system-mono);
}
/* ------------------------------------------------------------------------- */
/* Shell */
/* ------------------------------------------------------------------------- */
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
}
/* ------------------------------------------------------------------------- */
/* Media filtering */
/* ------------------------------------------------------------------------- */
/*
* Videos, images, PDFs, embedded pages, and the map canvas cannot consume
* tokens, so night mode passes them through an SVG color matrix that maps
* luminance onto the red channel and drives green and blue to zero. Nothing
* a user opens can break their dark adaptation.
*
* Tag the media element (or its immediate wrapper) with `.media`. Never
* filter an app-level ancestor: a CSS filter creates a containing block, which
* breaks `position: fixed` descendants like modals and the nav bar.
*
* Filter definitions live in <MediaFilterDefs/> (mounted once by App).
*/
html[data-theme="night"] .media {
filter: url(#wrolpi-night-red);
}

View file

@ -0,0 +1,76 @@
import fs from 'fs';
import path from 'path';
import {themeNames, themeSessionKey} from '../components/Theme';
const readFile = (...parts) => fs.readFileSync(path.join(__dirname, ...parts), 'utf8');
const tokensCss = readFile('tokens.css');
const fontsCss = readFile('fonts.css');
const indexHtml = readFile('..', '..', 'public', 'index.html');
/** The custom properties declared in the block for one theme. */
const themeTokens = (theme) => {
// Light doubles as the fallback, so its block is a grouped selector.
const pattern = theme === 'light'
? /:root,\s*html\[data-theme="light"]\s*{([^}]*)}/
: new RegExp(`html\\[data-theme="${theme}"]\\s*{([^}]*)}`);
const match = tokensCss.match(pattern);
expect(match).not.toBeNull();
return new Set([...match[1].matchAll(/(--[\w-]+)\s*:/g)].map(m => m[1]));
};
describe('theme tokens', () => {
it('declares a block for every theme', () => {
themeNames.forEach(theme => expect(themeTokens(theme).size).toBeGreaterThan(0));
});
it('defines the same tokens in every theme', () => {
// A token missing from one theme inherits the light value, which is how a stray
// white or blue ends up in night mode.
const light = themeTokens('light');
themeNames.filter(theme => theme !== 'light').forEach(theme => {
const missing = [...light].filter(token => !themeTokens(theme).has(token)
// Fonts are a role assignment, not a color; only amber overrides one.
&& !token.startsWith('--font-'));
expect({theme, missing}).toEqual({theme, missing: []});
});
});
it('uses only red hues in the night theme', () => {
// Night's whole purpose is that no non-red pixel reaches the eye.
const hexes = [...tokensCss.match(/html\[data-theme="night"]\s*{([^}]*)}/)[1]
.matchAll(/#([0-9a-f]{6})/gi)].map(m => m[1]);
expect(hexes.length).toBeGreaterThan(10);
hexes.forEach(hex => {
const [r, g, b] = [0, 2, 4].map(i => parseInt(hex.slice(i, i + 2), 16));
expect({hex, redDominant: r >= g && r >= b, neutralGreenBlue: g === b}).toEqual(
{hex, redDominant: true, neutralGreenBlue: true}
);
});
});
it('resolves fonts through role tokens only', () => {
expect(fontsCss).toMatch(/--font-body:/);
expect(fontsCss).toMatch(/--font-mono:/);
// Nothing may hardcode a family: a theme swaps the role, not the rule.
expect(tokensCss).toMatch(/font-family:\s*var\(--font-body\)/);
expect(tokensCss).not.toMatch(/font-family:(?!\s*var\()/);
});
});
describe('pre-paint theme script', () => {
// The inline script in index.html duplicates the theme names and storage key so the
// first paint matches the saved theme. These assertions fail if either side is renamed.
it('reads the same localStorage key as ThemeProvider', () => {
expect(indexHtml).toContain(`localStorage.getItem('${themeSessionKey}')`);
});
it('recognizes every theme name', () => {
themeNames.forEach(theme => expect(indexHtml).toContain(`'${theme}'`));
});
it('stamps data-theme before the bundle loads', () => {
const head = indexHtml.slice(0, indexHtml.indexOf('</head>'));
expect(head).toContain("document.documentElement.setAttribute('data-theme'");
});
});

View file

@ -1,7 +1,8 @@
import { CSSProperties } from 'react';
// Theme values
export type ThemeName = 'dark' | 'light';
// Theme values. `night` and `amber` are explicit user choices only; neither is
// ever selected by `prefers-color-scheme`.
export type ThemeName = 'dark' | 'light' | 'night' | 'amber';
export type SavedThemeName = ThemeName | 'system' | null;
// Style objects used by theme
@ -27,6 +28,10 @@ export interface ThemeContextValue {
theme: ThemeName;
/** User's saved theme preference */
savedTheme: SavedThemeName;
/** True when the applied theme is built on a dark background */
isDark: boolean;
/** Apply and save a theme (or 'system' to follow the OS preference) */
setTheme: (value: SavedThemeName) => void;
/** Set dark theme */
setDarkTheme: (save?: boolean) => void;
/** Set light theme */