From 806727023dbc0f7eb66c49cfb722fa74e292dcfd Mon Sep 17 00:00:00 2001 From: Roland Date: Wed, 29 Jul 2026 18:32:50 -0600 Subject: [PATCH] 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 --- app/public/index.html | 23 +++ app/src/App.js | 5 + app/src/components/Common.js | 49 ++++-- app/src/components/Theme.tsx | 182 +++++++++++++---------- app/src/components/ThemeProvider.test.js | 169 +++++++++++++++++++++ app/src/contexts/contexts.js | 2 + app/src/test-utils.js | 6 + app/src/themes/MediaFilterDefs.tsx | 37 +++++ app/src/themes/fonts.css | 25 ++++ app/src/themes/tokens.css | 178 ++++++++++++++++++++++ app/src/themes/tokens.test.js | 76 ++++++++++ app/src/types/theme.ts | 9 +- 12 files changed, 669 insertions(+), 92 deletions(-) create mode 100644 app/src/components/ThemeProvider.test.js create mode 100644 app/src/themes/MediaFilterDefs.tsx create mode 100644 app/src/themes/fonts.css create mode 100644 app/src/themes/tokens.css create mode 100644 app/src/themes/tokens.test.js 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 + 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)} + + + + +} + +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 +} 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 */