mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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:
parent
1bf8ea3da8
commit
ea50ee2f16
10 changed files with 290 additions and 52 deletions
|
|
@ -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>}/>
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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'>
|
||||
|
|
|
|||
|
|
@ -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/>
|
||||
|
|
|
|||
43
app/src/components/ui/ThemePicker.tsx
Normal file
43
app/src/components/ui/ThemePicker.tsx
Normal 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>
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
63
app/src/themes/names.ts
Normal 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.',
|
||||
},
|
||||
];
|
||||
Loading…
Add table
Add a link
Reference in a new issue