mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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:
parent
616424b223
commit
806727023d
12 changed files with 669 additions and 92 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
169
app/src/components/ThemeProvider.test.js
Normal file
169
app/src/components/ThemeProvider.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -30,6 +30,8 @@ export const ThemeContext = React.createContext({
|
|||
t: {},
|
||||
inverted: '',
|
||||
savedTheme: null,
|
||||
isDark: false,
|
||||
setTheme: () => {},
|
||||
setDarkTheme: () => {},
|
||||
setLightTheme: () => {},
|
||||
cycleSavedTheme: () => {},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
|
|||
37
app/src/themes/MediaFilterDefs.tsx
Normal file
37
app/src/themes/MediaFilterDefs.tsx
Normal 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
25
app/src/themes/fonts.css
Normal 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
178
app/src/themes/tokens.css
Normal 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);
|
||||
}
|
||||
76
app/src/themes/tokens.test.js
Normal file
76
app/src/themes/tokens.test.js
Normal 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'");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue