diff --git a/app/public/index.html b/app/public/index.html
index 3a15d035..89d4a7de 100644
--- a/app/public/index.html
+++ b/app/public/index.html
@@ -28,6 +28,29 @@
Learn how to configure a non-root public URL by running `npm run build`.
-->
WROLPi: Create your own off-grid library
+
You need to enable JavaScript to run this app.
diff --git a/app/src/App.js b/app/src/App.js
index f1ed37db..c6018b91 100644
--- a/app/src/App.js
+++ b/app/src/App.js
@@ -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
+
diff --git a/app/src/components/Common.js b/app/src/components/Common.js
index d1adf38d..71dbd14c 100644
--- a/app/src/components/Common.js
+++ b/app/src/components/Common.js
@@ -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
+export function DarkModeToggle() {
+ const {savedTheme, setTheme} = useContext(ThemeContext);
+ const active = themeChoices.find(i => i.value === savedTheme) || themeChoices[0];
+
+ return }
+ title={`Theme: ${active.text}`}
+ style={{padding: 0, marginRight: '0.8em'}}
+ >
+
+
+ {themeChoices.map(choice => setTheme(choice.value)}
+ />)}
+
+
}
diff --git a/app/src/components/Theme.tsx b/app/src/components/Theme.tsx
index dd71446f..daeeea07 100644
--- a/app/src/components/Theme.tsx
+++ b/app/src/components/Theme.tsx
@@ -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:
- const [i, setI] = useState<{inverted?: boolean}>({});
- // Invert style when Semantic does not support it. Example: This paragraph changes style
- const [s, setS] = useState<{style?: CSSProperties}>({});
- // Invert text when Semantic does not support it.
- const [t, setT] = useState<{style?: CSSProperties}>({});
- const [inverted, setInverted] = useState('');
+ // savedTheme is the user's preference: a theme name, `system`, or null (never chosen).
+ const [savedTheme, setSavedTheme] = useState(readSavedTheme);
+ // theme is what is currently applied.
+ const [theme, setThemeName] = useState(() => resolveTheme(readSavedTheme()));
- // theme can be one of [darkTheme, lightTheme]
- const [theme, setTheme] = useState(lightTheme);
- // savedTheme can be one of [null, darkTheme, lightTheme, systemTheme]
- const [savedTheme, setSavedTheme] = useState(
- localStorage.getItem('color-scheme') as SavedThemeName
- );
+ // `data-theme` on 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,
diff --git a/app/src/components/ThemeProvider.test.js b/app/src/components/ThemeProvider.test.js
new file mode 100644
index 00000000..8e1616bb
--- /dev/null
+++ b/app/src/components/ThemeProvider.test.js
@@ -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 <>
+ {theme}
+ {String(savedTheme)}
+ {String(isDark)}
+ setTheme(nightTheme)}>night
+ setTheme(systemTheme)}>system
+ cycle
+ >
+}
+
+const renderProvider = () => render( );
+
+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 ', () => {
+ 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);
+ });
+});
diff --git a/app/src/contexts/contexts.js b/app/src/contexts/contexts.js
index 973a0f97..e22f81c1 100644
--- a/app/src/contexts/contexts.js
+++ b/app/src/contexts/contexts.js
@@ -30,6 +30,8 @@ export const ThemeContext = React.createContext({
t: {},
inverted: '',
savedTheme: null,
+ isDark: false,
+ setTheme: () => {},
setDarkTheme: () => {},
setLightTheme: () => {},
cycleSavedTheme: () => {},
diff --git a/app/src/test-utils.js b/app/src/test-utils.js
index 151f96eb..4b0e3f9e 100644
--- a/app/src/test-utils.js
+++ b/app/src/test-utils.js
@@ -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
};
diff --git a/app/src/themes/MediaFilterDefs.tsx b/app/src/themes/MediaFilterDefs.tsx
new file mode 100644
index 00000000..3054bd71
--- /dev/null
+++ b/app/src/themes/MediaFilterDefs.tsx
@@ -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
+
+ {/*
+ 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.
+ */}
+
+
+
+
+
+
+
+
+}
diff --git a/app/src/themes/fonts.css b/app/src/themes/fonts.css
new file mode 100644
index 00000000..99a3b921
--- /dev/null
+++ b/app/src/themes/fonts.css
@@ -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-` 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 .
+ */
+
+: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);
+}
diff --git a/app/src/themes/tokens.css b/app/src/themes/tokens.css
new file mode 100644
index 00000000..8f0702b7
--- /dev/null
+++ b/app/src/themes/tokens.css
@@ -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 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 , 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 (mounted once by App).
+ */
+html[data-theme="night"] .media {
+ filter: url(#wrolpi-night-red);
+}
diff --git a/app/src/themes/tokens.test.js b/app/src/themes/tokens.test.js
new file mode 100644
index 00000000..5dbe7556
--- /dev/null
+++ b/app/src/themes/tokens.test.js
@@ -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(''));
+ expect(head).toContain("document.documentElement.setAttribute('data-theme'");
+ });
+});
diff --git a/app/src/types/theme.ts b/app/src/types/theme.ts
index f7db51b7..e9451816 100644
--- a/app/src/types/theme.ts
+++ b/app/src/types/theme.ts
@@ -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 */