ui: theme picker in Settings, and ship the component gallery

The gallery at /theme-sample is now a normal route rather than a development
one, and Settings links to it, so a user can see what a theme actually looks
like before committing to it. Every new component belongs in the gallery: it is
how the design gets reviewed, and how a component that only looks wrong in one
of the four themes gets caught.

Settings gains a Theme section with the new ThemePicker, which explains what
each theme is for - notably that night mode filters videos, documents and maps
to red as well, not just the interface.

Theme names and picker options move to themes/names.ts so the component library
can read them without importing components/Theme.tsx, which still pulls in
Semantic UI. Theme.tsx re-exports them, so existing call sites are unchanged.
The navigation bar's compact picker now reads the same list, so the two cannot
drift apart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-07-29 19:38:04 -06:00
parent 1bf8ea3da8
commit ea50ee2f16
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
10 changed files with 290 additions and 52 deletions

View file

@ -95,10 +95,9 @@ const router = createBrowserRouter(createRoutesFromElements(<Route
<Route index element={<ErrorBoundary><DashboardPage/></ErrorBoundary>}/>
<Route path='search/*' element={<ErrorBoundary><DashboardPage/></ErrorBoundary>}/>
<Route path='donate' element={<DonatePage/>}/>
{/* Component gallery for reviewing the design in each theme. Unlinked, and left out
of production builds so it cannot ship to users. */}
{process.env.NODE_ENV === 'development' &&
<Route path='theme-sample' element={<ErrorBoundary><ThemeSamplePage/></ErrorBoundary>}/>}
{/* Component gallery: lets a user see a theme before committing to it, and lets us
review the design. Linked from Settings. */}
<Route path='theme-sample' element={<ErrorBoundary><ThemeSamplePage/></ErrorBoundary>}/>
<Route path='videos'>
<Route element={<ErrorBoundary><VideosTabLayout/></ErrorBoundary>}>
<Route index element={<ErrorBoundary><VideosPage/></ErrorBoundary>}/>

View file

@ -26,24 +26,21 @@ import {useBluetooth, useDesktop, useHotspot, useSearchDirectories, useSearchOrd
import {Media, SettingsContext, StatusContext, ThemeContext} from "../contexts/contexts";
import {
Accordion,
amberTheme,
Breadcrumb,
Button,
Card as ThemedCard,
Confirm,
darkTheme,
Form,
Header,
Icon,
lightTheme,
Loader,
Menu,
Modal,
nightTheme,
Popup,
Segment,
Statistic,
systemTheme
systemTheme,
themeChoices
} from "./Theme";
import {FilePreviewContext} from "./FilePreview";
import _ from "lodash";
@ -1382,16 +1379,6 @@ export function LoadStatistic({label, value, cores, ...props}) {
{...props}/>;
}
// 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'},
];
export function DarkModeToggle() {
const {savedTheme, setTheme} = useContext(ThemeContext);
const active = themeChoices.find(i => i.value === savedTheme) || themeChoices[0];

View file

@ -97,28 +97,39 @@ import {
import {MantineProvider} from "@mantine/core";
import {Notifications} from "@mantine/notifications";
import {cssVariablesResolver, mantineTheme} from "../themes/mantine";
import {
amberTheme,
darkTheme,
defaultTheme,
explicitOnlyThemes,
isDarkTheme,
isThemeName,
lightTheme,
nightTheme,
systemTheme,
themeChoices,
themeNames,
themeSessionKey,
} from "../themes/names";
import {ColorToSemanticHexColor} from "./Vars";
import _ from "lodash";
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);
// Theme names and picker choices live in themes/names so the component library can use
// them without importing this module (which still pulls in Semantic UI). Re-exported
// here because most call sites already import them from Theme.
export {
amberTheme,
darkTheme,
defaultTheme,
isDarkTheme,
lightTheme,
nightTheme,
systemTheme,
themeChoices,
themeNames,
themeSessionKey,
};
/** Resolve the saved preference to the theme that should be applied right now. */
export const resolveTheme = (saved: SavedThemeName): ThemeName => {

View file

@ -21,6 +21,7 @@ import {
TextInput,
Textarea,
Checkbox,
ThemePicker,
Toggle,
Tooltip,
toast,
@ -28,11 +29,14 @@ import {
import {semanticColorNames} from '../themes/mantine';
/*
* A gallery of every component in the new library, in the current theme.
* A gallery of every component in the library, in the current theme.
*
* Reachable at /theme-sample. This exists to review the design and to catch a
* component that looks wrong in one of the four themes; it is not linked from
* the navigation.
* Reachable at /theme-sample and linked from Settings, so a user can see a theme
* before committing to it.
*
* EVERY new component belongs here. It is how we review the design and how we
* catch a component that only looks wrong in one of the four themes — which has
* already happened more than once.
*/
const Section = ({label, children}) => <section style={{marginBottom: 34}}>
@ -56,20 +60,18 @@ export function ThemeSamplePage() {
return <div style={{maxWidth: 1060, margin: '0 auto', padding: '20px 16px 60px', color: 'var(--text)'}}>
<h1 style={{fontSize: 21, fontWeight: 600, margin: '8px 0 4px'}}>Component gallery</h1>
<p style={{color: 'var(--muted)', fontSize: 13, marginTop: 0}}>
Every component in the new library, rendered in the <strong>{theme}</strong> theme.
Every component in the WROLPi interface library, rendered in the <strong>{theme}</strong> theme.
Switch themes here or from the navigation bar.
</p>
<Section label='Themes'>
<Row>
{['light', 'dark', 'night', 'amber'].map(name => <Button
key={name}
variant={theme === name ? 'filled' : 'default'}
onClick={() => setTheme(name)}
>
{name}
</Button>)}
</Row>
<Section label='Theme picker'>
<Panel>
<ThemePicker/>
<p style={{fontSize: 12, color: 'var(--muted)', marginBottom: 0, marginTop: 12}}>
The same picker the Settings page uses. The navigation bar has a compact
version; both read the same list of themes.
</p>
</Panel>
</Section>
<Section label='Statistics'>

View file

@ -29,6 +29,26 @@ import {toast} from "react-semantic-toasts-2";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {ConfigsTable} from "./Configs";
import {semanticUIColorMap} from "../Vars";
import {Link} from "react-router";
import {ThemePicker} from "../ui";
function ThemeSegment() {
return <Segment>
<Header as='h2'>Theme</Header>
<p>Choose how WROLPi looks. The choice is saved in this browser, so each device can
differ.</p>
<ThemePicker/>
<Divider/>
<Header as='h3'>Preview</Header>
<p>See every part of the interface in the theme you picked.</p>
<Link to='/theme-sample'>
<Button><Icon name='eye'/> View the components</Button>
</Link>
</Segment>;
}
export function ShutdownButton() {
const dockerized = useDockerized();
@ -914,6 +934,8 @@ export function SettingsPage() {
{body}
</Segment>
<ThemeSegment/>
{configsSegment}
<UpgradeSegment/>

View file

@ -0,0 +1,43 @@
import React, {useContext} from 'react';
import {ThemeContext} from '../../contexts/contexts';
import {systemTheme, themeChoices} from '../../themes/names';
import {Icon} from './Icon';
/*
* The theme picker.
*
* Used on the Settings page. The navigation bar has its own compact menu; both
* read their options from `themeChoices` so they cannot drift apart.
*/
export interface ThemePickerProps {
/** Show each theme's description under its name. */
withDescriptions?: boolean;
}
export function ThemePicker({withDescriptions = true}: ThemePickerProps) {
const {savedTheme, setTheme} = useContext(ThemeContext);
// A user who has never chosen is following the system preference.
const selected = savedTheme ?? systemTheme;
return <div role='radiogroup' aria-label='Theme' className='wrolpi-theme-picker'>
{themeChoices.map(choice => {
const active = choice.value === selected;
return <button
key={choice.value}
type='button'
role='radio'
aria-checked={active}
className={`wrolpi-theme-option${active ? ' wrolpi-theme-option-active' : ''}`}
onClick={() => setTheme(choice.value)}
>
<span className='wrolpi-theme-option-name'>
<Icon name={choice.icon}/>
{choice.text}
</span>
{withDescriptions &&
<span className='wrolpi-theme-option-description'>{choice.description}</span>}
</button>
})}
</div>
}

View file

@ -18,6 +18,7 @@ export * from './Surfaces';
export * from './DataTable';
export * from './Inputs';
export * from './Overlays';
export * from './ThemePicker';
export * from './toast';
// Layout primitives are used as-is; they carry no colors of their own.

View file

@ -53,6 +53,63 @@ html[data-theme="night"] .wrolpi-button-danger:hover:not(:disabled) {
/* The icon inside inherits currentColor, so it follows without another rule. */
/* ----------------------------------------------------------- Theme picker */
.wrolpi-theme-picker {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 10px;
}
.wrolpi-theme-option {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
text-align: left;
padding: 10px 12px;
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
font: inherit;
cursor: pointer;
}
.wrolpi-theme-option:hover {
background: var(--head);
}
/* The selected theme is marked by a heavier border as well as color, so the
selection survives in monochrome themes. */
.wrolpi-theme-option-active {
border: 2px solid var(--blue);
padding: 9px 11px;
}
html[data-theme="night"] .wrolpi-theme-option-active,
html[data-theme="amber"] .wrolpi-theme-option-active {
border-color: var(--text);
}
.wrolpi-theme-option:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 1px;
}
.wrolpi-theme-option-name {
display: inline-flex;
align-items: center;
gap: 7px;
font-weight: 600;
font-size: 13px;
}
.wrolpi-theme-option-description {
font-size: 12px;
color: var(--muted);
line-height: 1.4;
}
/* --------------------------------------------------------------- Switches */
/*
* Mantine's unchecked track is a gray from its own palette, which would put a

View file

@ -3,6 +3,7 @@ import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {MantineProvider} from '@mantine/core';
import {IconMoodSmile} from '@tabler/icons-react';
import {ThemeContext} from '../../contexts/contexts';
import {cssVariablesResolver, mantineTheme, semanticColorNames} from '../../themes/mantine';
import {
Button,
@ -16,6 +17,7 @@ import {
resolveIconName,
Status,
Table,
ThemePicker,
Toggle,
} from './index';
@ -233,6 +235,57 @@ describe('Toggle', () => {
});
});
describe('ThemePicker', () => {
const renderPicker = (themeContext = {}) => {
const setTheme = jest.fn();
const value = {savedTheme: null, setTheme, ...themeContext};
render(
<MantineProvider theme={mantineTheme} cssVariablesResolver={cssVariablesResolver}>
<ThemeContext.Provider value={value}><ThemePicker/></ThemeContext.Provider>
</MantineProvider>
);
return setTheme;
};
it('offers every theme plus following the system', () => {
renderPicker();
const options = screen.getAllByRole('radio').map(o => o.textContent);
['System', 'Light', 'Dark', 'Night', 'Amber'].forEach(name => {
expect(options.some(text => text.startsWith(name))).toBe(true);
});
});
it('marks the saved theme as selected', () => {
renderPicker({savedTheme: 'night'});
const night = screen.getAllByRole('radio').find(o => o.textContent.startsWith('Night'));
expect(night).toHaveAttribute('aria-checked', 'true');
});
it('treats a user who has never chosen as following the system', () => {
renderPicker({savedTheme: null});
const system = screen.getAllByRole('radio').find(o => o.textContent.startsWith('System'));
expect(system).toHaveAttribute('aria-checked', 'true');
});
it('applies the theme a user picks', async () => {
const setTheme = renderPicker();
await userEvent.click(screen.getAllByRole('radio').find(o => o.textContent.startsWith('Amber')));
expect(setTheme).toHaveBeenCalledWith('amber');
});
it('explains what night mode is for', () => {
// Users need to know it filters media too, not just the interface.
renderPicker();
expect(screen.getByText(/night vision/i)).toBeInTheDocument();
});
});
describe('Confirm', () => {
it('names the action on its confirming button', async () => {
const onConfirm = jest.fn();

63
app/src/themes/names.ts Normal file
View file

@ -0,0 +1,63 @@
import {SavedThemeName, ThemeName} from '../types/theme';
/*
* Theme names and the choices offered by the pickers.
*
* These live here rather than in components/Theme.tsx so the component library can
* use them without importing that module, which still pulls in Semantic UI.
*/
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 pickers offer them. */
export const themeNames: ThemeName[] = [lightTheme, darkTheme, nightTheme, amberTheme];
/** Themes built on a dark background. Semantic components are `inverted` in these. */
export const darkThemes: ThemeName[] = [darkTheme, nightTheme, amberTheme];
/** Themes a user must choose deliberately; `prefers-color-scheme` never picks them. */
export const explicitOnlyThemes: ThemeName[] = [nightTheme, amberTheme];
export const isDarkTheme = (theme: ThemeName): boolean => darkThemes.includes(theme);
export const isThemeName = (value: unknown): value is ThemeName =>
themeNames.includes(value as ThemeName);
export interface ThemeChoice {
value: SavedThemeName;
text: string;
/** Semantic icon name; resolved to a Tabler component by the Icon shim. */
icon: string;
description: string;
}
/** Shared by the navigation bar picker and the Settings page, so the two cannot drift. */
export const themeChoices: ThemeChoice[] = [
{
value: systemTheme,
text: 'System',
icon: 'lightbulb outline',
description: "Follow this device's light or dark preference.",
},
{value: lightTheme, text: 'Light', icon: 'sun', description: 'A light background.'},
{value: darkTheme, text: 'Dark', icon: 'moon', description: 'A dark background.'},
{
value: nightTheme,
text: 'Night',
icon: 'eye',
description: 'Red only, to preserve your night vision in the dark. Videos, documents, ' +
'and maps are filtered to red as well.',
},
{
value: amberTheme,
text: 'Amber',
icon: 'terminal',
description: 'Monochrome amber, like a classic terminal.',
},
];