diff --git a/app/public/index.html b/app/public/index.html index 3f327e57..8860fcf0 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -30,10 +30,14 @@ WROLPi: Create your own off-grid library diff --git a/app/src/components/Theme.tsx b/app/src/components/Theme.tsx index a4d9b050..2d325f30 100644 --- a/app/src/components/Theme.tsx +++ b/app/src/components/Theme.tsx @@ -105,9 +105,12 @@ import { isDarkTheme, isThemeName, lightTheme, + mediaFilterSessionKey, nightTheme, + resolveMediaFilter, systemTheme, themeChoices, + themeMediaFilter, themeNames, themeSessionKey, } from "../themes/names"; @@ -127,6 +130,7 @@ export { nightTheme, systemTheme, themeChoices, + themeMediaFilter, themeNames, themeSessionKey, }; @@ -155,6 +159,28 @@ const readSavedTheme = (): SavedThemeName => { return null; } +/** + * The user's per-theme media filter choices: `{night: false}` means "night, but + * do not filter media". A theme absent from the map follows its own default. + */ +const readMediaFilters = (): Partial> => { + try { + const raw = localStorage.getItem(mediaFilterSessionKey); + if (raw) { + const parsed = JSON.parse(raw); + // Only keep known themes with boolean values; the rest is someone else's data. + if (parsed && typeof parsed === 'object') { + return Object.fromEntries(Object.entries(parsed) + .filter(([key, value]) => isThemeName(key) && typeof value === 'boolean')); + } + } + } catch (e) { + // Blocked storage, or a value an older version wrote. + console.error('Unable to read the media filter settings', e); + } + return {}; +} + interface ThemeProviderProps { children: React.ReactNode; } @@ -170,12 +196,28 @@ export function ThemeProvider({children, ...props}: ThemeProviderProps) { // theme is what is currently applied. const [theme, setThemeName] = useState(() => resolveTheme(readSavedTheme())); + // Which themes the user has turned media filtering on or off for. + const [mediaFilters, setMediaFilters] = useState>>( + readMediaFilters); + // `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]); + // `data-media-filter` names the filter to apply, or is absent when nothing should be + // filtered. index.html stamps this too: a flash of unfiltered thumbnails before the + // bundle arrives would cost the user the dark adaptation night mode exists to protect. + useEffect(() => { + const filter = resolveMediaFilter(theme, mediaFilters); + if (filter) { + document.documentElement.setAttribute('data-media-filter', filter); + } else { + document.documentElement.removeAttribute('data-media-filter'); + } + }, [theme, mediaFilters]); + // Follow the OS preference only while the user has not chosen a specific theme. useEffect(() => { if (isThemeName(savedTheme) || typeof window.matchMedia !== 'function') { @@ -210,6 +252,20 @@ export function ThemeProvider({children, ...props}: ThemeProviderProps) { setThemeName(resolveTheme(value)); } + /** + * Turn the current theme's media filter on or off. Stored per theme, so a user who + * wants amber's tint but not night's — or the reverse — gets both. + */ + const setMediaFilterEnabled = (enabled: boolean) => { + const next = {...mediaFilters, [theme]: enabled}; + setMediaFilters(next); + try { + localStorage.setItem(mediaFilterSessionKey, JSON.stringify(next)); + } catch (e) { + console.error('Unable to save the media filter setting', e); + } + } + // Retained for callers written against the old two-theme API. const setDarkTheme = (save = false) => { setThemeName(darkTheme); @@ -256,6 +312,10 @@ export function ThemeProvider({children, ...props}: ThemeProviderProps) { theme, savedTheme, isDark: dark, + // The filter this theme offers, if any, and whether it is currently applied. + mediaFilter: themeMediaFilter(theme), + mediaFilterEnabled: !!resolveMediaFilter(theme, mediaFilters), + setMediaFilterEnabled, setTheme, setDarkTheme, setLightTheme, diff --git a/app/src/components/ThemeProvider.test.js b/app/src/components/ThemeProvider.test.js index 8e1616bb..98995e66 100644 --- a/app/src/components/ThemeProvider.test.js +++ b/app/src/components/ThemeProvider.test.js @@ -1,3 +1,5 @@ +import fs from 'fs'; +import path from 'path'; import React, {useContext} from 'react'; import {act, render, screen} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -13,6 +15,7 @@ import { ThemeProvider, themeSessionKey, } from './Theme'; +import {mediaFilterSessionKey, resolveMediaFilter, themeMediaFilter} from '../themes/names'; // Lets a test drive `prefers-color-scheme` and assert on what the provider applied. let prefersDark = false; @@ -38,26 +41,37 @@ const mockMatchMedia = () => { }; function ThemeProbe() { - const {theme, savedTheme, isDark, setTheme, cycleSavedTheme} = useContext(ThemeContext); + const { + theme, savedTheme, isDark, setTheme, cycleSavedTheme, + mediaFilter, mediaFilterEnabled, setMediaFilterEnabled, + } = useContext(ThemeContext); return <> {theme} {String(savedTheme)} {String(isDark)} + {mediaFilter ? mediaFilter.id : 'none'} + {String(mediaFilterEnabled)} + + + + } const renderProvider = () => render(); const appliedTheme = () => document.documentElement.getAttribute('data-theme'); +const appliedFilter = () => document.documentElement.getAttribute('data-media-filter'); describe('ThemeProvider', () => { beforeEach(() => { prefersDark = false; localStorage.clear(); document.documentElement.removeAttribute('data-theme'); + document.documentElement.removeAttribute('data-media-filter'); mockMatchMedia(); }); @@ -140,6 +154,127 @@ describe('ThemeProvider', () => { }); }); +describe('ThemeProvider media filtering', () => { + beforeEach(() => { + prefersDark = false; + localStorage.clear(); + document.documentElement.removeAttribute('data-theme'); + document.documentElement.removeAttribute('data-media-filter'); + mockMatchMedia(); + }); + + it('filters media in night mode without being asked', async () => { + // An unfiltered thumbnail undoes dark adaptation, so night starts filtered. + renderProvider(); + + await userEvent.click(screen.getByRole('button', {name: 'night'})); + + expect(appliedFilter()).toBe('night-red'); + expect(screen.getByTestId('filter-on')).toHaveTextContent('true'); + }); + + it('leaves amber media alone until the user asks for it', async () => { + renderProvider(); + + await userEvent.click(screen.getByRole('button', {name: 'amber'})); + + expect(appliedFilter()).toBeNull(); + expect(screen.getByTestId('filter-offered')).toHaveTextContent('amber-mono'); + }); + + it('applies no filter, and offers none, in a theme without one', async () => { + renderProvider(); + + await userEvent.click(screen.getByRole('button', {name: 'light'})); + + expect(appliedFilter()).toBeNull(); + expect(screen.getByTestId('filter-offered')).toHaveTextContent('none'); + }); + + it('turns the filter off when the user says so, and persists it', async () => { + renderProvider(); + await userEvent.click(screen.getByRole('button', {name: 'night'})); + + await userEvent.click(screen.getByRole('button', {name: 'filter off'})); + + expect(appliedFilter()).toBeNull(); + expect(JSON.parse(localStorage.getItem(mediaFilterSessionKey))).toEqual({night: false}); + }); + + it('turns amber filtering on when the user asks', async () => { + renderProvider(); + await userEvent.click(screen.getByRole('button', {name: 'amber'})); + + await userEvent.click(screen.getByRole('button', {name: 'filter on'})); + + expect(appliedFilter()).toBe('amber-mono'); + }); + + it('keeps the choice separate for each theme', async () => { + // Wanting amber's tint says nothing about wanting night's, and turning night's off + // must not disarm a filter the user deliberately enabled elsewhere. + renderProvider(); + await userEvent.click(screen.getByRole('button', {name: 'amber'})); + await userEvent.click(screen.getByRole('button', {name: 'filter on'})); + await userEvent.click(screen.getByRole('button', {name: 'night'})); + + await userEvent.click(screen.getByRole('button', {name: 'filter off'})); + expect(appliedFilter()).toBeNull(); + + await userEvent.click(screen.getByRole('button', {name: 'amber'})); + expect(appliedFilter()).toBe('amber-mono'); + }); + + it('restores the saved choice on the next load', () => { + localStorage.setItem(themeSessionKey, nightTheme); + localStorage.setItem(mediaFilterSessionKey, JSON.stringify({night: false})); + + renderProvider(); + + expect(appliedFilter()).toBeNull(); + }); + + it('falls back to the theme default when the stored settings are unusable', () => { + localStorage.setItem(themeSessionKey, nightTheme); + localStorage.setItem(mediaFilterSessionKey, 'not json'); + + renderProvider(); + + // Failing open would leave a user in the dark staring at a white thumbnail. + expect(appliedFilter()).toBe('night-red'); + }); + + it('ignores stored entries for themes that no longer exist', () => { + localStorage.setItem(themeSessionKey, nightTheme); + localStorage.setItem(mediaFilterSessionKey, JSON.stringify({sepia: true, night: false})); + + renderProvider(); + + expect(appliedFilter()).toBeNull(); + }); +}); + +describe('resolveMediaFilter', () => { + it('gives every filtering theme a filter defined in MediaFilterDefs', () => { + // A theme naming a filter that does not exist would silently filter nothing. + const defs = fs.readFileSync( + path.join(__dirname, '..', 'themes', 'MediaFilterDefs.tsx'), 'utf8'); + + [nightTheme, amberTheme].forEach(theme => { + const filter = themeMediaFilter(theme); + expect(defs).toContain(`id='wrolpi-${filter.id}'`); + }); + }); + + it('honours an override in either direction', () => { + expect(resolveMediaFilter(nightTheme, {})).toBe('night-red'); + expect(resolveMediaFilter(nightTheme, {night: false})).toBe(''); + expect(resolveMediaFilter(amberTheme, {})).toBe(''); + expect(resolveMediaFilter(amberTheme, {amber: true})).toBe('amber-mono'); + expect(resolveMediaFilter(lightTheme, {light: true})).toBe(''); + }); +}); + describe('theme helpers', () => { beforeEach(() => { prefersDark = false; diff --git a/app/src/components/ThemeSamplePage.js b/app/src/components/ThemeSamplePage.js index f418da63..67650f84 100644 --- a/app/src/components/ThemeSamplePage.js +++ b/app/src/components/ThemeSamplePage.js @@ -62,6 +62,7 @@ export function ThemeSamplePage() { const [hotspot, setHotspot] = useState(true); const [dismissed, setDismissed] = useState(false); const [modalOpen, setModalOpen] = useState(false); + const [realColors, setRealColors] = useState(false); return

Component gallery

@@ -80,6 +81,53 @@ export function ThemeSamplePage() { +
+ +
+
+ {/* + A real , so it goes through whatever filter the current theme + applies — this is the only way to see the filter working without + leaving the page. The unfiltered copy beside it opts out with + `night-unfiltered`, so the two can be compared side by side. + */} + The WROLPi logo, filtered like any other media +

+ Filtered +

+
+
+ {/* + Kept behind a click: an unfiltered image is a bright patch, and + leaving one on screen in night mode would cost a user the dark + adaptation they came here to protect. They can ask for it. + */} + {realColors + ? The WROLPi logo, unfiltered + : } +

+ Real colors +

+
+

+ Images, video, PDFs, embedded pages, and the map canvas cannot read theme + tokens, so a monochrome theme remaps them with an SVG color matrix instead. + Night filters to red unless you turn it off; amber tints to match only if + you ask. Light and dark offer no filter, so both images look the same here. +

+ Use the toggle in the theme picker above to see the difference. An + element can opt out with night-unfiltered — that is what + the right-hand copy does, which is why it is behind a click: an + unfiltered image is a bright patch, and night mode should not spring + one on you. +

+
+
+
+
Page title (h1)
diff --git a/app/src/components/ui/ThemePicker.tsx b/app/src/components/ui/ThemePicker.tsx index 2a35b459..3ab2fcd0 100644 --- a/app/src/components/ui/ThemePicker.tsx +++ b/app/src/components/ui/ThemePicker.tsx @@ -2,6 +2,7 @@ import React, {useContext} from 'react'; import {ThemeContext} from '../../contexts/contexts'; import {systemTheme, themeChoices} from '../../themes/names'; import {Icon} from './Icon'; +import {Toggle} from './Inputs'; /* * The theme picker. @@ -15,29 +16,52 @@ export interface ThemePickerProps { withDescriptions?: boolean; } +/** + * The media filter toggle for the current theme. + * + * Rendered only by themes that offer a filter, because there is nothing to say + * about it otherwise — light and dark have no monochrome media treatment. + */ +export function MediaFilterToggle() { + const {mediaFilter, mediaFilterEnabled, setMediaFilterEnabled} = useContext(ThemeContext); + if (!mediaFilter) return null; + + return
+ setMediaFilterEnabled(event.currentTarget.checked)} + /> +
+} + 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 - })} -
+ return <> +
+ {themeChoices.map(choice => { + const active = choice.value === selected; + return + })} +
+ + } diff --git a/app/src/components/ui/ui.css b/app/src/components/ui/ui.css index 7703e2a4..64bd8911 100644 --- a/app/src/components/ui/ui.css +++ b/app/src/components/ui/ui.css @@ -110,6 +110,13 @@ html[data-theme="amber"] .wrolpi-theme-option-active { line-height: 1.4; } +/* Separated from the theme grid: it configures the chosen theme, it is not a choice. */ +.wrolpi-media-filter-setting { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + /* ---------------------------------------------------------------- Modals */ /* * Actions sit below the body, separated by a hairline rather than a raised bar. diff --git a/app/src/components/ui/ui.test.js b/app/src/components/ui/ui.test.js index c80a5504..f6f59782 100644 --- a/app/src/components/ui/ui.test.js +++ b/app/src/components/ui/ui.test.js @@ -322,7 +322,13 @@ describe('Toggle', () => { describe('ThemePicker', () => { const renderPicker = (themeContext = {}) => { const setTheme = jest.fn(); - const value = {savedTheme: null, setTheme, ...themeContext}; + const value = { + savedTheme: null, + setTheme, + mediaFilterEnabled: false, + setMediaFilterEnabled: jest.fn(), + ...themeContext, + }; render( @@ -368,6 +374,39 @@ describe('ThemePicker', () => { expect(screen.getByText(/night vision/i)).toBeInTheDocument(); }); + + it('offers the media filter toggle in a theme that has one', () => { + renderPicker({ + theme: 'night', + mediaFilter: {id: 'night-red', defaultOn: true, label: 'Filter media to red', + description: 'Videos, images, documents, and maps are remapped to red.'}, + mediaFilterEnabled: true, + }); + + expect(screen.getByRole('switch', {name: /filter media to red/i})).toBeChecked(); + }); + + it('offers no toggle in a theme with no filter to offer', () => { + // Light and dark have no monochrome media treatment, so there is nothing to say. + renderPicker({theme: 'light', mediaFilter: undefined, mediaFilterEnabled: false}); + + expect(screen.queryByRole('switch')).not.toBeInTheDocument(); + }); + + it('turns the filter off when the user flips the toggle', async () => { + const setMediaFilterEnabled = jest.fn(); + renderPicker({ + theme: 'night', + mediaFilter: {id: 'night-red', defaultOn: true, label: 'Filter media to red', + description: 'Videos, images, documents, and maps are remapped to red.'}, + mediaFilterEnabled: true, + setMediaFilterEnabled, + }); + + await userEvent.click(screen.getByRole('switch', {name: /filter media to red/i})); + + expect(setMediaFilterEnabled).toHaveBeenCalledWith(false); + }); }); describe('Confirm', () => { diff --git a/app/src/test-utils.js b/app/src/test-utils.js index b68990e0..e6d0a568 100644 --- a/app/src/test-utils.js +++ b/app/src/test-utils.js @@ -77,6 +77,10 @@ export function renderWithProviders( theme: inverted ? 'dark' : 'light', isDark: inverted, savedTheme: null, + // Media filtering is off by default here; a test that needs it sets these. + mediaFilter: undefined, + mediaFilterEnabled: false, + setMediaFilterEnabled: jest.fn(), setTheme: jest.fn(), setDarkTheme: jest.fn(), setLightTheme: jest.fn(), diff --git a/app/src/themes/MediaFilterDefs.tsx b/app/src/themes/MediaFilterDefs.tsx index 3054bd71..f78a858d 100644 --- a/app/src/themes/MediaFilterDefs.tsx +++ b/app/src/themes/MediaFilterDefs.tsx @@ -32,6 +32,29 @@ export function MediaFilterDefs() { + + {/* + Amber: the same luminance collapse, then tinted to the theme's amber + (#ff9500) by scaling each channel to that hue's ratios — red at full, + green at 0.584, blue at zero. Amber carries far more luminance than + red, so it needs a gentler lift; 0.85 keeps highlights from clipping + to a flat wash. + + This is opt-in: amber is a look, not a night-vision aid. + */} + + + + + + + } diff --git a/app/src/themes/names.ts b/app/src/themes/names.ts index adc59675..7d780894 100644 --- a/app/src/themes/names.ts +++ b/app/src/themes/names.ts @@ -1,4 +1,4 @@ -import {SavedThemeName, ThemeName} from '../types/theme'; +import {SavedThemeName, ThemeMediaFilter, ThemeName} from '../types/theme'; /* * Theme names and the choices offered by the pickers. @@ -29,6 +29,62 @@ export const isDarkTheme = (theme: ThemeName): boolean => darkThemes.includes(th export const isThemeName = (value: unknown): value is ThemeName => themeNames.includes(value as ThemeName); +/* + * Media filtering. + * + * Images, video, PDFs, embedded pages, and the map canvas cannot consume theme + * tokens, so a monochrome theme can pass them through an SVG color matrix + * instead (see MediaFilterDefs.tsx for the filters, tokens.css for the rule). + * + * A theme declares which filter it uses and whether it is on to begin with; the + * user can then turn it on or off for that theme. Night defaults to on — an + * unfiltered thumbnail undoes dark adaptation, which is the whole point of the + * mode — and amber defaults to off, since it is a look rather than a night-vision + * aid. Neither is forced: some users want their media tinted to match, some + * want to see the actual image. + * + * The choice is stored per theme, so turning it off in amber does not disarm it + * in night. + */ +export const themeMediaFilters: Partial> = { + [nightTheme]: { + id: 'night-red', + defaultOn: true, + label: 'Filter media to red', + description: 'Videos, images, documents, and maps are remapped to red. Turning this off ' + + 'shows their real colors, which will spoil your night vision.', + }, + [amberTheme]: { + id: 'amber-mono', + defaultOn: false, + label: 'Filter media to amber', + description: 'Videos, images, documents, and maps are remapped to amber, matching the ' + + 'interface. Off by default so media keeps its real colors.', + }, +}; + +/** The filter a theme offers, or undefined when it has none to offer. */ +export const themeMediaFilter = (theme: ThemeName): ThemeMediaFilter | undefined => + themeMediaFilters[theme]; + +export const mediaFilterSessionKey = 'media-filter'; + +/** + * Resolve the filter id to stamp on `` — '' when nothing should be filtered. + * + * `overrides` is the stored map of theme name to boolean; a theme missing from it + * has never been changed and follows its own default. + */ +export const resolveMediaFilter = ( + theme: ThemeName, + overrides: Partial>, +): string => { + const filter = themeMediaFilters[theme]; + if (!filter) return ''; + const enabled = overrides[theme] ?? filter.defaultOn; + return enabled ? filter.id : ''; +} + export interface ThemeChoice { value: SavedThemeName; text: string; @@ -52,12 +108,13 @@ export const themeChoices: ThemeChoice[] = [ 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.', + 'and maps are filtered to red as well, unless you turn that off.', }, { value: amberTheme, text: 'Amber', icon: 'terminal', - description: 'Monochrome amber, like a classic terminal.', + description: 'Monochrome amber, like a classic terminal. Media keeps its real colors ' + + 'unless you ask for it to be tinted too.', }, ]; diff --git a/app/src/themes/tokens.css b/app/src/themes/tokens.css index 4f94da48..7eaa20d2 100644 --- a/app/src/themes/tokens.css +++ b/app/src/themes/tokens.css @@ -169,15 +169,25 @@ body { /* ------------------------------------------------------------------------- */ /* * 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. + * tokens, so a theme can pass them through an SVG color matrix instead. Night + * maps luminance onto the red channel so nothing a user opens can break their + * dark adaptation; amber tints media to match the interface. * - * This targets the element types rather than an opt-in class on purpose: night - * mode has to fail safe. A library of thousands of files reaches the screen - * through many components, and one that forgot to opt in would undo half an - * hour of dark adaptation. `.media` stays available for a wrapper that needs - * filtering as a unit (a player with custom controls, say). + * These rules key on `data-media-filter`, not `data-theme`, because filtering is + * a per-theme setting the user controls rather than a property of the theme's + * palette: night is filtered unless turned off, amber is unfiltered unless + * turned on. An empty or absent attribute filters nothing, so light and dark — + * which offer no filter — match no rule at all. ThemeProvider stamps the + * attribute, and index.html stamps it before first paint (an unfiltered flash of + * thumbnails would cost the user their dark adaptation before React even loads). + * + * The selectors target element types rather than an opt-in class on purpose: + * filtering has to fail safe. A library of thousands of files reaches the + * screen through many components, and one that forgot to opt in would undo half + * an hour of dark adaptation. `.media` stays available for a wrapper that needs + * filtering as a unit (a player with custom controls, say), and + * `.night-unfiltered` opts a single element out — for media whose real colors + * are the point, like the side-by-side comparison on /theme-sample. * * Only leaf media is filtered, never an app-level ancestor: a CSS filter * creates a containing block, which would break `position: fixed` descendants @@ -186,14 +196,22 @@ body { * * Filter definitions live in (mounted once by App). */ -html[data-theme="night"] :is(img, video, canvas, iframe, embed, object, .media):not(.night-unfiltered) { +html[data-media-filter="night-red"] :is(img, video, canvas, iframe, embed, object, .media):not(.night-unfiltered) { filter: url(#wrolpi-night-red); } +html[data-media-filter="amber-mono"] :is(img, video, canvas, iframe, embed, object, .media):not(.night-unfiltered) { + filter: url(#wrolpi-amber-mono); +} + /* * A filter on an ancestor stops applying once the browser promotes a video to * native fullscreen, so the filter is restated on the fullscreened element. */ -html[data-theme="night"] :fullscreen:not(.night-unfiltered) { +html[data-media-filter="night-red"] :fullscreen:not(.night-unfiltered) { filter: url(#wrolpi-night-red); } + +html[data-media-filter="amber-mono"] :fullscreen:not(.night-unfiltered) { + filter: url(#wrolpi-amber-mono); +} diff --git a/app/src/themes/tokens.test.js b/app/src/themes/tokens.test.js index 5dbe7556..842ab0e9 100644 --- a/app/src/themes/tokens.test.js +++ b/app/src/themes/tokens.test.js @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; import {themeNames, themeSessionKey} from '../components/Theme'; +import {mediaFilterSessionKey, themeMediaFilter} from './names'; const readFile = (...parts) => fs.readFileSync(path.join(__dirname, ...parts), 'utf8'); @@ -73,4 +74,52 @@ describe('pre-paint theme script', () => { const head = indexHtml.slice(0, indexHtml.indexOf('')); expect(head).toContain("document.documentElement.setAttribute('data-theme'"); }); + + it('stamps the media filter before the bundle loads too', () => { + // Otherwise night mode paints a screen of unfiltered thumbnails while the bundle + // arrives, which costs the user the dark adaptation the theme exists to protect. + const head = indexHtml.slice(0, indexHtml.indexOf('')); + expect(head).toContain("document.documentElement.setAttribute('data-media-filter'"); + expect(head).toContain(`localStorage.getItem('${mediaFilterSessionKey}')`); + }); + + it('duplicates the same filter ids and defaults the app uses', () => { + // The script cannot import from names.ts, so it restates both. These assertions + // fail if a theme's filter id or default changes on only one side. + themeNames.forEach(theme => { + const filter = themeMediaFilter(theme); + if (!filter) return; + expect(indexHtml).toContain(`${theme}: '${filter.id}'`); + expect(indexHtml).toContain(`${theme}: ${filter.defaultOn}`); + }); + }); +}); + +describe('media filter rules', () => { + it('keys on the filter, not on the theme', () => { + // Filtering is a per-theme setting the user controls, so a rule keyed on + // `data-theme` could not be turned off. + const filterRules = [...tokensCss.matchAll(/^html\[([^\]]+)][^{]*{\s*filter:/gm)] + .map(match => match[1]); + + expect(filterRules.length).toBeGreaterThan(0); + filterRules.forEach(selector => expect(selector).toMatch(/^data-media-filter=/)); + }); + + it('has a rule for every filter a theme offers', () => { + themeNames.forEach(theme => { + const filter = themeMediaFilter(theme); + if (!filter) return; + expect(tokensCss).toContain(`html[data-media-filter="${filter.id}"]`); + // Restated for native fullscreen, where an ancestor's filter stops applying. + expect(tokensCss).toMatch( + new RegExp(`html\\[data-media-filter="${filter.id}"] :fullscreen`)); + }); + }); + + it('never filters an ancestor, only leaf media', () => { + // A CSS filter creates a containing block, which would break `position: fixed` + // descendants — the nav bar and every modal. + expect(tokensCss).not.toMatch(/data-media-filter="[^"]+"]\s*{\s*filter:/); + }); }); diff --git a/app/src/types/theme.ts b/app/src/types/theme.ts index e9451816..db8a98f7 100644 --- a/app/src/types/theme.ts +++ b/app/src/types/theme.ts @@ -5,6 +5,21 @@ import { CSSProperties } from 'react'; export type ThemeName = 'dark' | 'light' | 'night' | 'amber'; export type SavedThemeName = ThemeName | 'system' | null; +/** + * A theme's media filter: images, video, PDFs, and canvases cannot consume theme + * tokens, so a monochrome theme can remap them with an SVG color matrix instead. + * The theme declares the filter and whether it starts on; the user decides. + */ +export interface ThemeMediaFilter { + /** Matches a filter id in MediaFilterDefs, minus the `wrolpi-` prefix. */ + id: string; + /** On unless the user says otherwise. */ + defaultOn: boolean; + /** Label for the toggle, naming what the filter does. */ + label: string; + description: string; +} + // Style objects used by theme export interface InvertedProps { inverted?: boolean; @@ -30,6 +45,12 @@ export interface ThemeContextValue { savedTheme: SavedThemeName; /** True when the applied theme is built on a dark background */ isDark: boolean; + /** The media filter this theme offers, or undefined when it offers none */ + mediaFilter?: ThemeMediaFilter; + /** True when that filter is currently being applied to media */ + mediaFilterEnabled: boolean; + /** Turn the current theme's media filter on or off */ + setMediaFilterEnabled: (enabled: boolean) => void; /** Apply and save a theme (or 'system' to follow the OS preference) */ setTheme: (value: SavedThemeName) => void; /** Set dark theme */