mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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.
304 lines
11 KiB
JavaScript
304 lines
11 KiB
JavaScript
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';
|
|
import {ThemeContext} from '../contexts/contexts';
|
|
import {
|
|
amberTheme,
|
|
darkTheme,
|
|
isDarkTheme,
|
|
lightTheme,
|
|
nightTheme,
|
|
resolveTheme,
|
|
systemTheme,
|
|
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;
|
|
|
|
const mockMatchMedia = () => {
|
|
const listeners = new Set();
|
|
window.matchMedia = jest.fn().mockImplementation(query => ({
|
|
matches: query === '(prefers-color-scheme: dark)' ? prefersDark : false,
|
|
media: query,
|
|
addEventListener: (_event, listener) => listeners.add(listener),
|
|
removeEventListener: (_event, listener) => listeners.delete(listener),
|
|
addListener: (listener) => listeners.add(listener),
|
|
removeListener: (listener) => listeners.delete(listener),
|
|
dispatchEvent: () => false,
|
|
}));
|
|
return {
|
|
// Emulate the OS switching to dark/light while the app is open.
|
|
change: (value) => {
|
|
prefersDark = value;
|
|
act(() => listeners.forEach(listener => listener({matches: value})));
|
|
},
|
|
};
|
|
};
|
|
|
|
function ThemeProbe() {
|
|
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();
|
|
});
|
|
|
|
it('applies the saved theme and stamps it on <html>', () => {
|
|
localStorage.setItem(themeSessionKey, nightTheme);
|
|
|
|
renderProvider();
|
|
|
|
expect(screen.getByTestId('theme')).toHaveTextContent(nightTheme);
|
|
expect(appliedTheme()).toBe(nightTheme);
|
|
});
|
|
|
|
it('falls back to the OS preference when no theme is saved', () => {
|
|
prefersDark = true;
|
|
|
|
renderProvider();
|
|
|
|
expect(screen.getByTestId('theme')).toHaveTextContent(darkTheme);
|
|
expect(screen.getByTestId('saved')).toHaveTextContent('null');
|
|
});
|
|
|
|
it('persists a chosen theme so it survives a reload', async () => {
|
|
renderProvider();
|
|
|
|
await userEvent.click(screen.getByRole('button', {name: 'night'}));
|
|
|
|
expect(localStorage.getItem(themeSessionKey)).toBe(nightTheme);
|
|
expect(appliedTheme()).toBe(nightTheme);
|
|
});
|
|
|
|
it('follows the OS preference while `system` is chosen', async () => {
|
|
const media = mockMatchMedia();
|
|
renderProvider();
|
|
await userEvent.click(screen.getByRole('button', {name: 'system'}));
|
|
|
|
media.change(true);
|
|
|
|
expect(screen.getByTestId('theme')).toHaveTextContent(darkTheme);
|
|
expect(appliedTheme()).toBe(darkTheme);
|
|
});
|
|
|
|
it('ignores the OS preference once a specific theme is chosen', async () => {
|
|
const media = mockMatchMedia();
|
|
renderProvider();
|
|
await userEvent.click(screen.getByRole('button', {name: 'night'}));
|
|
|
|
media.change(true);
|
|
|
|
expect(screen.getByTestId('theme')).toHaveTextContent(nightTheme);
|
|
});
|
|
|
|
it('reports night and amber as dark themes', async () => {
|
|
renderProvider();
|
|
|
|
await userEvent.click(screen.getByRole('button', {name: 'night'}));
|
|
|
|
expect(screen.getByTestId('dark')).toHaveTextContent('true');
|
|
});
|
|
|
|
it('cycles through every theme and back to system', async () => {
|
|
renderProvider();
|
|
const cycle = screen.getByRole('button', {name: 'cycle'});
|
|
|
|
const seen = [];
|
|
for (let index = 0; index < 5; index++) {
|
|
await userEvent.click(cycle);
|
|
seen.push(screen.getByTestId('saved').textContent);
|
|
}
|
|
|
|
expect(seen).toEqual([lightTheme, darkTheme, nightTheme, amberTheme, systemTheme]);
|
|
});
|
|
|
|
it('recovers from a theme name written by an older version', () => {
|
|
localStorage.setItem(themeSessionKey, 'sepia');
|
|
|
|
renderProvider();
|
|
|
|
expect(screen.getByTestId('theme')).toHaveTextContent(lightTheme);
|
|
expect(appliedTheme()).toBe(lightTheme);
|
|
});
|
|
});
|
|
|
|
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;
|
|
mockMatchMedia();
|
|
});
|
|
|
|
it('resolves explicit theme names as themselves', () => {
|
|
[lightTheme, darkTheme, nightTheme, amberTheme].forEach(theme => {
|
|
expect(resolveTheme(theme)).toBe(theme);
|
|
});
|
|
});
|
|
|
|
it('resolves `system` and null from the OS preference', () => {
|
|
expect(resolveTheme(systemTheme)).toBe(lightTheme);
|
|
expect(resolveTheme(null)).toBe(lightTheme);
|
|
|
|
prefersDark = true;
|
|
expect(resolveTheme(systemTheme)).toBe(darkTheme);
|
|
});
|
|
|
|
it('knows which themes are built on a dark background', () => {
|
|
expect(isDarkTheme(lightTheme)).toBe(false);
|
|
expect(isDarkTheme(darkTheme)).toBe(true);
|
|
expect(isDarkTheme(nightTheme)).toBe(true);
|
|
expect(isDarkTheme(amberTheme)).toBe(true);
|
|
});
|
|
});
|