From ea50ee2f1690d862b33a8b9cc6f7b3bb2c500280 Mon Sep 17 00:00:00 2001 From: Roland Date: Wed, 29 Jul 2026 19:38:04 -0600 Subject: [PATCH] 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 --- app/src/App.js | 7 ++- app/src/components/Common.js | 17 +------- app/src/components/Theme.tsx | 47 ++++++++++++-------- app/src/components/ThemeSamplePage.js | 32 +++++++------- app/src/components/admin/Settings.js | 22 ++++++++++ app/src/components/ui/ThemePicker.tsx | 43 ++++++++++++++++++ app/src/components/ui/index.ts | 1 + app/src/components/ui/ui.css | 57 ++++++++++++++++++++++++ app/src/components/ui/ui.test.js | 53 ++++++++++++++++++++++ app/src/themes/names.ts | 63 +++++++++++++++++++++++++++ 10 files changed, 290 insertions(+), 52 deletions(-) create mode 100644 app/src/components/ui/ThemePicker.tsx create mode 100644 app/src/themes/names.ts diff --git a/app/src/App.js b/app/src/App.js index 7cb029ed..425a3a45 100644 --- a/app/src/App.js +++ b/app/src/App.js @@ -95,10 +95,9 @@ const router = createBrowserRouter(createRoutesFromElements(}/> }/> }/> - {/* 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' && - }/>} + {/* Component gallery: lets a user see a theme before committing to it, and lets us + review the design. Linked from Settings. */} + }/> }> }/> diff --git a/app/src/components/Common.js b/app/src/components/Common.js index 71dbd14c..5b8f5363 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -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]; diff --git a/app/src/components/Theme.tsx b/app/src/components/Theme.tsx index 928ce203..a4d9b050 100644 --- a/app/src/components/Theme.tsx +++ b/app/src/components/Theme.tsx @@ -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 => { diff --git a/app/src/components/ThemeSamplePage.js b/app/src/components/ThemeSamplePage.js index cd1a3f00..bd33d0b2 100644 --- a/app/src/components/ThemeSamplePage.js +++ b/app/src/components/ThemeSamplePage.js @@ -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}) =>
@@ -56,20 +60,18 @@ export function ThemeSamplePage() { return

Component gallery

- Every component in the new library, rendered in the {theme} theme. + Every component in the WROLPi interface library, rendered in the {theme} theme. Switch themes here or from the navigation bar.

-
- - {['light', 'dark', 'night', 'amber'].map(name => )} - +
+ + +

+ The same picker the Settings page uses. The navigation bar has a compact + version; both read the same list of themes. +

+
diff --git a/app/src/components/admin/Settings.js b/app/src/components/admin/Settings.js index f96af6be..64478c13 100644 --- a/app/src/components/admin/Settings.js +++ b/app/src/components/admin/Settings.js @@ -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 +
Theme
+

Choose how WROLPi looks. The choice is saved in this browser, so each device can + differ.

+ + + + + +
Preview
+

See every part of the interface in the theme you picked.

+ + + +
; +} export function ShutdownButton() { const dockerized = useDockerized(); @@ -914,6 +934,8 @@ export function SettingsPage() { {body} + + {configsSegment} diff --git a/app/src/components/ui/ThemePicker.tsx b/app/src/components/ui/ThemePicker.tsx new file mode 100644 index 00000000..2a35b459 --- /dev/null +++ b/app/src/components/ui/ThemePicker.tsx @@ -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
+ {themeChoices.map(choice => { + const active = choice.value === selected; + return + })} +
+} diff --git a/app/src/components/ui/index.ts b/app/src/components/ui/index.ts index a5d732d1..cb36342e 100644 --- a/app/src/components/ui/index.ts +++ b/app/src/components/ui/index.ts @@ -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. diff --git a/app/src/components/ui/ui.css b/app/src/components/ui/ui.css index 17d0f74d..04ce5b5f 100644 --- a/app/src/components/ui/ui.css +++ b/app/src/components/ui/ui.css @@ -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 diff --git a/app/src/components/ui/ui.test.js b/app/src/components/ui/ui.test.js index 74ba7ec7..1d1c1aa2 100644 --- a/app/src/components/ui/ui.test.js +++ b/app/src/components/ui/ui.test.js @@ -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( + + + + ); + 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(); diff --git a/app/src/themes/names.ts b/app/src/themes/names.ts new file mode 100644 index 00000000..adc59675 --- /dev/null +++ b/app/src/themes/names.ts @@ -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.', + }, +];