mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 02:48:38 -04:00
ui: media filtering is a per-theme setting, not a night-mode hardcode
Filtering media was keyed on `data-theme="night"`, which made it a property of
the theme that no user could turn off. Some people want their media tinted to
match the interface; some want to see the actual image.
A theme now declares a filter and whether it starts on, and the user decides:
night night-red on by default. An unfiltered thumbnail undoes dark
adaptation, which is the whole point of the mode.
amber amber-mono off by default. Amber is a look, not a night-vision
aid, so media keeps its real colors until asked for.
light - no filter offered, so no toggle is shown.
dark -
The choice is stored per theme, so wanting amber's tint says nothing about
wanting night's. The toggle lives in the theme picker, which puts it on the
Settings page and in the gallery at once.
Mechanically the rules now key on a `data-media-filter` attribute rather than
`data-theme`: a rule keyed on the theme could not be turned off. index.html
stamps that attribute before first paint alongside data-theme -- without it,
night mode paints a screen of unfiltered thumbnails while the bundle arrives
over a hotspot, costing the user the adaptation the theme exists to protect.
Tests cover both the attribute rule and the fact that the inline script's
duplicated ids and defaults still agree with names.ts.
The amber matrix is the same luminance collapse tinted to #ff9500 (red full,
green 0.584, blue zero) with a gentler gamma than night's: amber carries far
more luminance and clips to a flat wash otherwise.
The gallery gains a Media filtering section showing the WROLPi logo filtered,
beside an unfiltered copy that opts out with `night-unfiltered`. That copy is
behind a click on purpose -- an unfiltered image is a bright patch, and night
mode should not spring one on someone reading in the dark.
ui-design.md gains the media filtering contract it never got in Phase 0,
including the note that a Phase 3 custom theme names an existing filter id and
cannot ship its own matrix, since that would be code rather than data.
This commit is contained in:
parent
00c52872fa
commit
57985cea94
13 changed files with 546 additions and 39 deletions
|
|
@ -30,10 +30,14 @@
|
|||
<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.
|
||||
Stamp the saved theme, and the media filter it resolves to, on <html> before first
|
||||
paint. ThemeProvider sets the same attributes 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, and in night mode a flash of unfiltered thumbnails would cost the user the
|
||||
dark adaptation the theme exists to protect.
|
||||
|
||||
Keep the storage keys, theme names, and filter defaults in sync with
|
||||
src/themes/names.ts.
|
||||
*/
|
||||
(function () {
|
||||
var theme = 'light';
|
||||
|
|
@ -52,6 +56,24 @@
|
|||
theme = 'dark';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
|
||||
// Which filter each theme uses, and whether it applies unless the user says otherwise.
|
||||
var filters = {night: 'night-red', amber: 'amber-mono'};
|
||||
var defaults = {night: true, amber: false};
|
||||
if (filters[theme]) {
|
||||
var enabled = defaults[theme];
|
||||
try {
|
||||
var overrides = JSON.parse(localStorage.getItem('media-filter') || '{}');
|
||||
if (typeof overrides[theme] === 'boolean') {
|
||||
enabled = overrides[theme];
|
||||
}
|
||||
} catch (e) {
|
||||
// Blocked storage or malformed JSON: fall back to the theme's default.
|
||||
}
|
||||
if (enabled) {
|
||||
document.documentElement.setAttribute('data-media-filter', filters[theme]);
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -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<Record<ThemeName, boolean>> => {
|
||||
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<ThemeName>(() => resolveTheme(readSavedTheme()));
|
||||
|
||||
// Which themes the user has turned media filtering on or off for.
|
||||
const [mediaFilters, setMediaFilters] = useState<Partial<Record<ThemeName, boolean>>>(
|
||||
readMediaFilters);
|
||||
|
||||
// `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]);
|
||||
|
||||
// `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,
|
||||
|
|
|
|||
|
|
@ -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 <>
|
||||
<span data-testid='theme'>{theme}</span>
|
||||
<span data-testid='saved'>{String(savedTheme)}</span>
|
||||
<span data-testid='dark'>{String(isDark)}</span>
|
||||
<span data-testid='filter-offered'>{mediaFilter ? mediaFilter.id : 'none'}</span>
|
||||
<span data-testid='filter-on'>{String(mediaFilterEnabled)}</span>
|
||||
<button onClick={() => setTheme(nightTheme)}>night</button>
|
||||
<button onClick={() => setTheme(amberTheme)}>amber</button>
|
||||
<button onClick={() => setTheme(lightTheme)}>light</button>
|
||||
<button onClick={() => setTheme(systemTheme)}>system</button>
|
||||
<button onClick={cycleSavedTheme}>cycle</button>
|
||||
<button onClick={() => setMediaFilterEnabled(false)}>filter off</button>
|
||||
<button onClick={() => setMediaFilterEnabled(true)}>filter on</button>
|
||||
</>
|
||||
}
|
||||
|
||||
const renderProvider = () => render(<ThemeProvider><ThemeProbe/></ThemeProvider>);
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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 <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>
|
||||
|
|
@ -80,6 +81,53 @@ export function ThemeSamplePage() {
|
|||
</Panel>
|
||||
</Section>
|
||||
|
||||
<Section label='Media filtering'>
|
||||
<Panel>
|
||||
<div style={{display: 'flex', gap: 18, flexWrap: 'wrap', alignItems: 'flex-start'}}>
|
||||
<div>
|
||||
{/*
|
||||
A real <img>, 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.
|
||||
*/}
|
||||
<img src='/icon.svg' alt='The WROLPi logo, filtered like any other media'
|
||||
width={104} height={104}/>
|
||||
<p style={{fontSize: 11, color: 'var(--muted)', margin: '4px 0 0'}}>
|
||||
Filtered
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
{/*
|
||||
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
|
||||
? <img className='night-unfiltered' src='/icon.svg'
|
||||
alt='The WROLPi logo, unfiltered' width={104} height={104}/>
|
||||
: <Button role='cancel' icon='eye' style={{width: 104, height: 104}}
|
||||
onClick={() => setRealColors(true)}>Show</Button>}
|
||||
<p style={{fontSize: 11, color: 'var(--muted)', margin: '4px 0 0'}}>
|
||||
Real colors
|
||||
</p>
|
||||
</div>
|
||||
<p style={{fontSize: 12, color: 'var(--muted)', margin: 0, maxWidth: 430}}>
|
||||
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.
|
||||
<br/><br/>
|
||||
Use the toggle in the theme picker above to see the difference. An
|
||||
element can opt out with <code>night-unfiltered</code> — 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.
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
</Section>
|
||||
|
||||
<Section label='Headers'>
|
||||
<Panel>
|
||||
<Header as='h1'>Page title (h1)</Header>
|
||||
|
|
|
|||
|
|
@ -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 <div className='wrolpi-media-filter-setting'>
|
||||
<Toggle
|
||||
label={mediaFilter.label}
|
||||
description={mediaFilter.description}
|
||||
checked={mediaFilterEnabled}
|
||||
onChange={event => setMediaFilterEnabled(event.currentTarget.checked)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
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>
|
||||
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>
|
||||
<MediaFilterToggle/>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<MantineProvider theme={mantineTheme} cssVariablesResolver={cssVariablesResolver}>
|
||||
<ThemeContext.Provider value={value}><ThemePicker/></ThemeContext.Provider>
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -32,6 +32,29 @@ export function MediaFilterDefs() {
|
|||
<feFuncR type='gamma' amplitude='1' exponent='0.65' offset='0'/>
|
||||
</feComponentTransfer>
|
||||
</filter>
|
||||
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<filter id='wrolpi-amber-mono' colorInterpolationFilters='sRGB'>
|
||||
<feColorMatrix
|
||||
type='matrix'
|
||||
values='0.2126 0.7152 0.0722 0 0
|
||||
0.1242 0.4177 0.0422 0 0
|
||||
0 0 0 0 0
|
||||
0 0 0 1 0'
|
||||
/>
|
||||
<feComponentTransfer>
|
||||
<feFuncR type='gamma' amplitude='1' exponent='0.85' offset='0'/>
|
||||
<feFuncG type='gamma' amplitude='1' exponent='0.85' offset='0'/>
|
||||
</feComponentTransfer>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Record<ThemeName, ThemeMediaFilter>> = {
|
||||
[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 `<html>` — '' 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<Record<ThemeName, boolean>>,
|
||||
): 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.',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 <MediaFilterDefs/> (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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('</head>'));
|
||||
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('</head>'));
|
||||
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:/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue