diff --git a/docs/guide/settings.md b/docs/guide/settings.md index 12af7ce9..e4031670 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -4,6 +4,7 @@ The app's settings are in one dialog, opened from the cog at the bottom right of ## General +- **Theme** — light, dark, or whatever your operating system is set to, which is the default. A change applies as you make it, to the whole window; nothing running is touched. - **Language** — which language the app is shown in: your system's language, which is the default, or one of the languages the app has a translation for. A change applies after a relaunch, which the dialog offers; relaunching stops running servers and builds, as quitting does. Translations come from [translate.wordpress.org](https://translate.wordpress.org/projects/meta/contributor-toolkit/) and ship with the app once they are mostly complete, so the list grows from release to release. - **Start the server when I open a site** and **Start the build watch when I open a site** — off unless you turn them on. On, opening a site that is set up starts its [development server](./running-the-site), its build watch, or both, as it would if you pressed Start; on WordPress Core the server's start brings the watch with it. Nothing starts for a site whose setup, update or deletion is under way, and nothing already running is started again. - **When I quit, running servers and build watches** — quitting always stops them, as it always has. **Stop them, and start them again next time** remembers which sites had a server or a watch running and starts them again when the app next opens, whichever site it opens on. diff --git a/scripts/screenshots/capture.cjs b/scripts/screenshots/capture.cjs index c939b251..9be48238 100644 --- a/scripts/screenshots/capture.cjs +++ b/scripts/screenshots/capture.cjs @@ -91,6 +91,11 @@ async function launchApp(env) { // From plain Node, require('electron') resolves to the binary's path — // the same trick scripts/run-tests-electron.cjs uses. executablePath: require('electron'), + // Playwright holds a page to the light scheme unless told not to. The + // pictures are of the theme the fixture's profile sets (#560), light + // unless SHOTS_THEME says dark, which the app reads from its own + // setting; so the page is left to follow the app. + colorScheme: null, args: [...ELECTRON_SWITCHES, repoRoot], // Dates rendered by the app must not rewrite screenshots according to the // maintainer's locale or timezone. diff --git a/scripts/screenshots/fixtures.cjs b/scripts/screenshots/fixtures.cjs index b236e707..1906a08b 100644 --- a/scripts/screenshots/fixtures.cjs +++ b/scripts/screenshots/fixtures.cjs @@ -21,6 +21,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { pathToFileURL } = require('url'); +const { THEMES } = require('../../src/theme.cjs'); // The fixture layer the journeys build their sites with: the app's own Git // binary, so a repository made here is one the app reads as it reads a clone. const { gitOk, initRepo, commitFiles, removeRepo } = require('../../tests/unit/helpers/git.cjs'); @@ -338,10 +339,21 @@ function buildFixture(variant) { return { userDataDir, sites: { wizardSite, readySite, staleSite, incompleteSite } }; } +// The pictures are of the light theme whatever the maintainer's machine is +// set to (#560), unless a dark one is asked for: `SHOTS_THEME=dark`. Anything +// else is refused here, the system's theme by name included: a value the +// app does not know falls back to the system's, which is the one thing the +// pin exists to keep out of the pictures. +const SHOTS_THEMES = THEMES.filter((theme) => theme !== 'system'); +const SHOTS_THEME = process.env.SHOTS_THEME || 'light'; +if (!SHOTS_THEMES.includes(SHOTS_THEME)) { + throw new Error(`SHOTS_THEME must be one of ${SHOTS_THEMES.join(', ')}, got "${SHOTS_THEME}"`); +} + function writeSettings(userDataDir, settings) { fs.writeFileSync( path.join(userDataDir, 'settings.json'), - JSON.stringify(settings, null, '\t') + JSON.stringify({ ...settings, preferences: { theme: SHOTS_THEME, ...(settings.preferences || {}) } }, null, '\t') ); } diff --git a/src/main.js b/src/main.js index 5c08743e..5c812bc7 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, Menu, ipcMain, dialog, shell, screen } = require('electron'); +const { app, BrowserWindow, Menu, ipcMain, dialog, shell, screen, nativeTheme } = require('electron'); const path = require('path'); const os = require('os'); const crypto = require('crypto'); @@ -93,6 +93,7 @@ const { addFilter } = require('@wordpress/hooks'); const { mergeInProgressError, mergeCheckFailedError } = require('./renderer/merge-in-progress.cjs'); const { parseHandle } = require('./wporg-handle.cjs'); const { SETTINGS, readSettings, acceptSetting } = require('./settings.cjs'); +const { windowBackground } = require('./theme.cjs'); const { parseEventName, buildProvenanceHeader, handoffFilename } = require('./patch-provenance.cjs'); const { describeRefused } = require('./safe-log'); const { detectEditors, matchDetectedEditor, openSiteInEditor, REFUSAL_REASONS } = require('./editor-launch'); @@ -561,6 +562,9 @@ function createWindow() { mainWindow = new BrowserWindow({ ...mainWindowSize(screen.getPrimaryDisplay().workAreaSize), icon: process.platform === 'linux' ? path.join(__dirname, '..', 'build', 'icon.png') : undefined, + // The colour of the theme the window is made in (#560), so that a dark + // window is not white for the moment before its page has painted. + backgroundColor: windowBackground(nativeTheme.shouldUseDarkColors), webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, @@ -684,9 +688,9 @@ ipcMain.handle('deep-link:ready', () => { // Resolved once: main applies it at startup for its own strings (the menu, the // native dialogs, the sentences it sends), and the window gets the same reply, // so the two cannot end up in different languages. That is also why a change -// in the settings shows after a relaunch and not before. This is the first -// read of the store, before there is a window: a store that cannot be read -// is logged and counts as no choice, since the window has to open to say so. +// in the settings shows after a relaunch and not before. Read before there +// is a window: a store that cannot be read is logged and counts as no +// choice, since the window has to open to say so. const LANGUAGES_DIR = path.join(__dirname, 'languages'); let localeReplyPromise = null; function localeReply() { @@ -710,6 +714,31 @@ function localeReply() { return localeReplyPromise; } +// The theme (#560), given to Electron. `nativeTheme` is the one place the +// choice is made: Chromium answers the page's `prefers-color-scheme` from it, +// and paints the window's chrome and the native form controls to match, so +// the page only has to follow what it is told, as it would the operating +// system's. Read from the store before the window is made, so the window is +// made in it; a store that cannot be read leaves the system's theme, with a +// line in the log, as it leaves the system's language. +async function applyStoredTheme() { + try { + nativeTheme.themeSource = readSettings((await getStore()).get('preferences')).theme; + } catch (e) { + logError('theme', `the settings could not be read, so the theme is the system's: ${String(e && e.message ? e.message : e)}`); + } + // A deep link can have opened the window while the store was read. + paintWindowForTheme(); +} + +// The colour the window was made with shows wherever the page has not +// painted yet (a live resize, a reload), so it is given again whenever the +// theme is: by the setting, here and in `settings:set`, and by Electron's +// `updated`, which is how the system's theme reaches it under 'system'. +function paintWindowForTheme() { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.setBackgroundColor(windowBackground(nativeTheme.shouldUseDarkColors)); +} + // The languages the settings offer: what the build ships, read once. let languagesPromise = null; function languages() { @@ -2652,6 +2681,10 @@ app.whenReady().then(async () => { // renderer output into the log file, which only applies to windows created // afterwards. initLogging(); + // Before the window: it is made in the theme, and kept in it when the + // system's theme changes under 'system'. + nativeTheme.on('updated', paintWindowForTheme); + await applyStoredTheme(); // Before the menu and the window: both build their labels from `__()`. applyLocale(await localeReply(), { setLocaleData, addFilter }); Menu.setApplicationMenu(Menu.buildFromTemplate(buildMenuTemplate({ @@ -3570,7 +3603,15 @@ ipcMain.handle('settings:set', async (_e, key, value) => { if (!accepted.ok) return { ok: false, error: accepted.error }; await setPreference(key, accepted.value); const s = await getStore(); - return { ok: true, settings: readSettings(s.get('preferences')) }; + const settings = readSettings(s.get('preferences')); + // The theme applies at once (#560): Electron tells the page, and the + // window's own colour is set here rather than left to Electron's + // `updated`, which is not promised for a change to 'system'. + if (key === 'theme') { + nativeTheme.themeSource = settings.theme; + paintWindowForTheme(); + } + return { ok: true, settings }; }); // The fallback that needs no configuration at all — see site-registry.js for why diff --git a/src/renderer/components/app-theme.jsx b/src/renderer/components/app-theme.jsx new file mode 100644 index 00000000..33d233cc --- /dev/null +++ b/src/renderer/components/app-theme.jsx @@ -0,0 +1,47 @@ +import { createContext, useContext, useEffect, useState } from 'react'; +import { ThemeProvider } from '@wordpress/theme'; +import { themeColorSeeds } from '../../theme.cjs'; + +// What Chromium says of the window's colour scheme. It says it from the theme +// main gave Electron (#560), or from the operating system under 'system', so +// this is the one thing the window reads: not the setting, which would be a +// second answer to the same question. +const DARK_SCHEME = '(prefers-color-scheme: dark)'; + +// Whether the window is in the dark scheme, for whatever paints with values +// rather than with a stylesheet (the terminal) and so has to be told when it +// changes. +const DarkSchemeContext = createContext(false); + +function usePrefersDark() { + const [dark, setDark] = useState(() => window.matchMedia(DARK_SCHEME).matches); + useEffect(() => { + const media = window.matchMedia(DARK_SCHEME); + const onChange = (event) => setDark(event.matches); + media.addEventListener('change', onChange); + // A change between the first read and the listener is not missed. + setDark(media.matches); + return () => media.removeEventListener('change', onChange); + }, []); + return dark; +} + +// The design system's provider, at the root of the window. `isRoot` puts what +// it overrides on the document rather than on its own wrapper, which is what +// reaches a modal or a popover: those are portalled to `body`, outside this +// tree. In the light scheme it is given no colour, so the tokens stylesheet's +// values stand as they ship; in the dark scheme it is given the dark seed and +// builds every colour token from it, for its own components, for the older +// ones through the variables they read, and for the app's own styles. +export function AppTheme({ children }) { + const dark = usePrefersDark(); + return ( + + {children} + + ); +} + +export function useDarkScheme() { + return useContext(DarkSchemeContext); +} diff --git a/src/renderer/components/settings-dialog.jsx b/src/renderer/components/settings-dialog.jsx index 4f21117a..6767d3ed 100644 --- a/src/renderer/components/settings-dialog.jsx +++ b/src/renderer/components/settings-dialog.jsx @@ -6,7 +6,7 @@ import { useEffect, useId, useMemo, useState } from 'react'; import { __experimentalToggleGroupControl as ToggleGroupControl, __experimentalToggleGroupControlOption as ToggleGroupControlOption } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { Button, Dialog, InputControl, Notice, SelectControl, Stack, SwitchControl, Tabs, Text } from '@wordpress/ui'; -import { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, phpVersionChoice, quitItems, SYSTEM_LANGUAGE } from '../settings-view.cjs'; +import { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, phpVersionChoice, quitItems, themeItems, SYSTEM_LANGUAGE } from '../settings-view.cjs'; import { FolderField } from './folder-field.jsx'; // A notice here is read by its role, and is not also spoken: the dialog it @@ -81,6 +81,41 @@ function LanguageControl({ settings, loaded, onChange }) { ); } +// The window's theme (#560): light, dark, or the operating system's. Main +// gives the choice to Electron, and the window follows what Chromium then +// says of the colour scheme, so the change is on screen as the control is +// pressed. +function ThemeControl({ settings, onChange }) { + const [error, setError] = useState(''); + const keep = async (value) => { + const result = await onChange('theme', value); + setError(result?.ok ? '' : (result?.error || __('Could not keep that.'))); + }; + return ( + <> + { if (value) keep(value); }} + > + {themeItems().map((item) => ( + + ))} + + {error ? ( + + {error} + + ) : null} + + ); +} + // What a site opened starts, and what the quit does with what is running. // The quit stops servers and watches either way: the one choice is whether // the next launch starts them again. @@ -156,6 +191,7 @@ function GeneralTab({ settings, loaded, onChange }) { }>{__('Appearance')} + diff --git a/src/renderer/hooks/use-site-terminal.jsx b/src/renderer/hooks/use-site-terminal.jsx index c9a86f4a..3e6c2be3 100644 --- a/src/renderer/hooks/use-site-terminal.jsx +++ b/src/renderer/hooks/use-site-terminal.jsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react import { Terminal } from '@xterm/xterm'; import { terminalFont, terminalTheme, tokenName, TERMINAL_READABILITY } from '../terminal-theme.cjs'; import { terminalGrid } from '../tray.cjs'; +import { useDarkScheme } from '../components/app-theme.jsx'; // What the terminal is painted with, read off the design system's tokens // where the terminal stands (#557). The terminal takes its colours and its @@ -58,6 +59,7 @@ const TERMINAL_INSTALL_ALIASES = ['npm install', 'npm i', 'install']; // None of them depends on the three runners, which may change as often as // they like. export function useSiteTerminal({ allowedScripts, runInstall, runScript, killCurrent, shown }) { + const dark = useDarkScheme(); // Read through a ref by the terminal's command handlers rather than closed // over: the xterm instance is created by an effect that depends on // `printHelp`, so a new array identity here would otherwise dispose and @@ -382,6 +384,17 @@ export function useSiteTerminal({ allowedScripts, runInstall, runScript, killCur fitTerminal(); }); + // Painted again when the window's scheme changes (#560). The terminal was + // given its colours as values when it opened, and a change to the tokens + // does not reach a value; so they are read again, after the provider has + // put the new tokens on the document, which it does in a layout effect, + // before this one runs. A terminal not yet opened is given them when it is. + useEffect(() => { + const term = terminalRef.current; + if (!term || !term.element || !container) return; + term.options.theme = readTerminalLook(container).theme; + }, [dark, container]); + // Fitted again whenever its element changes size: the tray dragged, the // window resized, and the element coming back on screen, which is a change // from no size to one. diff --git a/src/renderer/index.html b/src/renderer/index.html index badd3200..c88495c0 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -16,19 +16,25 @@ + + - + diff --git a/src/renderer/index.jsx b/src/renderer/index.jsx index efc33e42..3cc0da93 100644 --- a/src/renderer/index.jsx +++ b/src/renderer/index.jsx @@ -9,7 +9,6 @@ import { Page } from '@wordpress/admin-ui'; import { __, _x, setLocaleData } from '@wordpress/i18n'; import { addFilter } from '@wordpress/hooks'; import { drawerLeft, globe } from '@wordpress/icons'; -import { ThemeProvider } from '@wordpress/theme'; import { Badge, Button as UiButton, Card as UiCard, EmptyState, IconButton, Notice, Spinner as UiSpinner, Stack, Text, VisuallyHidden } from '@wordpress/ui'; // The design system's tokens: every `--wpds-*` custom property, at its default, // on `:root`. @@ -80,6 +79,7 @@ import { ApplyCard, ApplyPreviewDialog, PrCheckoutNotice } from './components/ap import { applyHeldReason, previewShown } from './apply-card.cjs'; import { TicketCard } from './components/ticket-card.jsx'; import { TicketListCard } from './components/ticket-list.jsx'; +import { AppTheme } from './components/app-theme.jsx'; import { useDetectedEditors } from './hooks/use-detected-editors.jsx'; import { useContributorProvenance } from './hooks/use-contributor-provenance.jsx'; import { useSettings } from './hooks/use-settings.jsx'; @@ -2406,13 +2406,9 @@ async function loadLocale() { document.title = __('WordPress Contributor Toolkit'); } -// The design system's provider, at its defaults: the tokens stylesheet already -// holds every value, so this changes nothing on screen. It is the one place -// to set colour and corner radius from, for whatever comes to set them. `isRoot` -// puts whatever it overrides on the document rather than on its own wrapper, -// which is what reaches a modal or a popover: those are portalled to `body`, -// outside this tree. +// Under the design system's provider, in the theme the window is in (#560): +// see app-theme.jsx. loadLocale().then(() => { const root = createRoot(document.getElementById('root')); - root.render(); + root.render(); }); diff --git a/src/renderer/settings-view.cjs b/src/renderer/settings-view.cjs index fc6aa021..af6e4394 100644 --- a/src/renderer/settings-view.cjs +++ b/src/renderer/settings-view.cjs @@ -8,7 +8,7 @@ * returns. */ -const { __, sprintf } = require('@wordpress/i18n'); +const { __, _x, sprintf } = require('@wordpress/i18n'); /** * The line under "GitHub": whose account the app holds, or why none. @@ -123,6 +123,20 @@ function quitItems() { ]; } +/** + * The entries of the theme control (#560): light, dark, or the operating + * system's. The prototype's fourth, a custom pair of colours, is not offered. + * + * @return {Array<{value: string, label: string}>} + */ +function themeItems() { + return [ + { value: 'light', label: _x('Light', 'the window’s theme') }, + { value: 'dark', label: _x('Dark', 'the window’s theme') }, + { value: 'system', label: _x('System', 'the window’s theme: the operating system’s') } + ]; +} + /** * What the next launch starts for a site, from the list the last quit left: * its server, its watch, both, or nothing. @@ -138,4 +152,4 @@ function resumeFor(resume, sitePath) { return server || watch ? { server, watch } : null; } -module.exports = { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, phpVersionChoice, quitItems, resumeFor, SYSTEM_LANGUAGE }; +module.exports = { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, phpVersionChoice, quitItems, themeItems, resumeFor, SYSTEM_LANGUAGE }; diff --git a/src/renderer/shell.css b/src/renderer/shell.css index 5e458ef7..ff0db516 100644 --- a/src/renderer/shell.css +++ b/src/renderer/shell.css @@ -686,6 +686,38 @@ body { font-size: inherit; } +/* The older library's dialogs and popovers are painted white with dark text + in its own stylesheet, with no token behind the colour, so in the dark + theme (#560) they would be the one light thing on the page. Painted here + with the tokens the design system's own dialogs use, so they follow the + theme as everything else does. The modal's shadow stays as the library + draws it: black, as the design system's own are under either theme. The + ring the popover draws in the same property as its shadow is a light grey + of its own, so in the dark theme, and only there, the two are restated: + the hairline, and one soft layer mixed from the text's colour, which on a + dark surface is a faint light glow rather than a black shadow, since a + black shadow on a near-black page is no edge at all. In the light theme + the popover is as the library draws it. */ +.components-modal__frame, +.components-popover__content { + background: var(--wpds-color-background-surface-neutral-strong); + color: var(--wpds-color-foreground-content-neutral); +} + +.components-modal__frame h1, +.components-modal__frame h2, +.components-modal__frame h3 { + color: var(--wpds-color-foreground-content-neutral); +} + +@media (prefers-color-scheme: dark) { + .components-popover__content { + box-shadow: + 0 0 0 var(--wpds-border-width-xs) var(--wpds-color-stroke-surface-neutral), + 0 4px 12px color-mix(in srgb, var(--wpds-color-foreground-content-neutral) 10%, transparent); + } +} + /* What a first-timer cannot know about pull requests, behind its question. */ .destination-how { color: var(--wpds-color-foreground-content-neutral-weak); diff --git a/src/renderer/terminal-theme.cjs b/src/renderer/terminal-theme.cjs index 04b09993..6f4aee43 100644 --- a/src/renderer/terminal-theme.cjs +++ b/src/renderer/terminal-theme.cjs @@ -1,19 +1,22 @@ // What the site's terminal is painted with (#557). The terminal draws itself // and is told its colours and its font as values, not as CSS, so they are -// read off the design system's tokens when it is made: which token each of -// its colours is, is decided here. They are read once: a terminal made under -// one theme keeps it. +// read off the design system's tokens when it is made, and again when the +// window's theme changes (#560): which token each of its colours is, is +// decided here. // -// It is a light surface with dark text, like the log panes beside it. The +// It is the surface the log panes beside it have, with the text's own colour +// on it: light with dark text in the light theme, dark with light text in +// the dark one, since every token below follows the theme. The // colours a command can ask for by number are the design system's nearest: // red is what an error is said in, green a success, yellow a warning. A // command's "black" and its bright "white" are the text's own colour, and -// its "white" and bright "black" the quieter text's, since on this surface -// white could not be read. +// its "white" and bright "black" the quieter text's, since on the light +// surface white could not be read. // // The eight bright colours are the eight plain ones. The design system's // stronger colours are for text on a tinted notice, and on this surface are -// all but black: an error asked for in bright red would lose its red. +// all but black in the light theme: an error asked for in bright red would +// lose its red. 'use strict'; const NEUTRAL = 'var(--wpds-color-foreground-content-neutral)'; diff --git a/src/settings.cjs b/src/settings.cjs index e4ea16db..d42d21f7 100644 --- a/src/settings.cjs +++ b/src/settings.cjs @@ -18,6 +18,7 @@ */ const { __ } = require('@wordpress/i18n'); +const { THEMES } = require('./theme.cjs'); // What the quit setting can be. const QUIT_BEHAVIOURS = ['stop', 'restart']; @@ -79,6 +80,17 @@ const SETTINGS = { return { ok: true, value }; } }, + // The window's theme (#560): light, dark, or the operating system's, + // which is the fallback. Main applies it to Electron's native theme, and + // the window follows what Chromium then says of the colour scheme. + theme: { + fallback: 'system', + accept(value) { + if (value === null || value === undefined || value === '') return { ok: true, value: null }; + if (!THEMES.includes(value)) return { ok: false, error: __('Choose light, dark, or your system’s theme.') }; + return { ok: true, value }; + } + }, // The folder new sites are made in, each in a subfolder of its own. Unset, // the create-site dialog asks for one every time, as it did before. The // path is kept as the system's dialog gave it: a folder's name can end in @@ -120,6 +132,7 @@ function readSettings(preferences = {}) { autoStartServer: flag('autoStartServer'), autoStartWatch: flag('autoStartWatch'), quitBehavior: QUIT_BEHAVIOURS.includes(stored.quitBehavior) ? stored.quitBehavior : SETTINGS.quitBehavior.fallback, + theme: THEMES.includes(stored.theme) ? stored.theme : SETTINGS.theme.fallback, newSiteLocation: text('newSiteLocation') }; } diff --git a/src/theme.cjs b/src/theme.cjs new file mode 100644 index 00000000..e1ffaa19 --- /dev/null +++ b/src/theme.cjs @@ -0,0 +1,53 @@ +'use strict'; + +/** + * The window's theme (#560): light, dark, or whatever the operating system + * is set to, and what each side of the app does with the answer. + * + * Main gives the choice to Electron's `nativeTheme`, which is the one place + * the choice has to be made: Chromium then answers `prefers-color-scheme` in + * the window from it, and paints the window's own chrome and the native form + * controls to match. The window only reads that answer, and seeds the design + * system's colours from it. So there is one source of truth, and a change + * reaches the window as the operating system's would. + * + * Pure, and shared by main and the window: the dark seed is both the colour + * the design system builds its dark ramp from and the colour a window is + * made with, so that nothing white shows before the page has painted. + */ + +// What the theme setting can be. 'system' follows the operating system. +const THEMES = ['light', 'dark', 'system']; + +// The design system's own seeds, as the prototype has them: the colour the +// dark theme's surfaces are built from, and the colour the light theme's +// are. In the light theme the window passes no seed at all, since the tokens +// stylesheet already holds the light values and generating them again would +// move them by a hair; the light seed is the window's background only. +const DARK_BACKGROUND = '#1e1e1e'; +const LIGHT_BACKGROUND = '#fcfcfc'; + +/** + * What the design system's provider is given for the scheme the window is + * in: a seed to build the dark ramp from, or nothing, which is the light + * theme as the tokens stylesheet ships it. + * + * @param {boolean} dark Whether the window is in the dark scheme. + * @return {Object} The provider's `color` prop. + */ +function themeColorSeeds(dark) { + return dark ? { background: DARK_BACKGROUND } : {}; +} + +/** + * The colour a window is made with, so that the frame is not white for the + * moment before the page paints a dark one. + * + * @param {boolean} dark Whether the window is in the dark scheme. + * @return {string} `#rrggbb`. + */ +function windowBackground(dark) { + return dark ? DARK_BACKGROUND : LIGHT_BACKGROUND; +} + +module.exports = { THEMES, themeColorSeeds, windowBackground, DARK_BACKGROUND, LIGHT_BACKGROUND }; diff --git a/tests/e2e/helpers/app.cjs b/tests/e2e/helpers/app.cjs index 2f3579f6..813cf9bd 100644 --- a/tests/e2e/helpers/app.cjs +++ b/tests/e2e/helpers/app.cjs @@ -195,19 +195,27 @@ class Session { /** * Seeds settings.json and launches the app. * - * @param {Object} settings Initial electron-store contents. Defaults to a - * first-launch app with no sites. + * @param {Object} settings Initial electron-store contents. Defaults to a + * first-launch app with no sites. * @param {Object} [options] - * @param {string|false} [options.lang] The locale to launch in, in place of en-US, - * or `false` for no `--lang` at all: the - * app then picks its language as it does - * for a contributor, from the settings - * and the OS. It holds across restart(). + * @param {string|false} [options.lang] The locale to launch in, in place of en-US, + * or `false` for no `--lang` at all: the + * app then picks its language as it does + * for a contributor, from the settings + * and the OS. It holds across restart(). + * @param {?string} [options.colorScheme] Left out, Playwright holds the page + * to the light scheme whatever the machine + * and the theme setting say, so a journey + * is the same on every machine. `null` + * lets the page follow the app's own theme + * (#560), for a journey about it. It holds + * across restart(). * @return {Promise<{app: Object, page: Object}>} The Electron app and its first window. */ - async start( settings = EMPTY_SETTINGS, { lang } = {} ) { + async start( settings = EMPTY_SETTINGS, { lang, colorScheme } = {} ) { if ( this.app ) throw new Error( 'This session already has an app running; call restart() instead.' ); this.lang = lang; + this.colorScheme = colorScheme; this.writeSettings( settings ); return this.#launch(); } @@ -252,6 +260,7 @@ class Session { // does not recognise without a word — so a `slowMo` added here would leave the // tests passing at full speed and look like it had worked. ...( VIDEO_DIR ? { recordVideo: { dir: VIDEO_DIR } } : {} ), + ...( this.colorScheme === undefined ? {} : { colorScheme: this.colorScheme } ), env: { ...process.env, TZ: 'UTC', diff --git a/tests/e2e/journeys/settings.spec.js b/tests/e2e/journeys/settings.spec.js index c7775e4f..22095b74 100644 --- a/tests/e2e/journeys/settings.spec.js +++ b/tests/e2e/journeys/settings.spec.js @@ -20,6 +20,7 @@ const { test, expect } = require( '../helpers/app.cjs' ); const ui = require( '../helpers/ui.cjs' ); const { makeSite } = require( '../helpers/git-site.cjs' ); const { pseudoLocalize } = require( '../../../src/renderer/pseudo-locale.cjs' ); +const { DARK_BACKGROUND, LIGHT_BACKGROUND } = require( '../../../src/theme.cjs' ); const openFromMenu = ( app ) => app.evaluate( ( { Menu } ) => Menu.getApplicationMenu().getMenuItemById( 'settings' ).click() ); @@ -249,3 +250,81 @@ test( 'the Sites tab keeps the PHP version and the debug flags the next server s await expect( kept.getByRole( 'switch', { name: 'Report notices and deprecations (WP_DEBUG)', exact: true } ) ).not.toBeChecked(); await expect( kept.getByRole( 'switch', { name: 'Use unminified scripts (SCRIPT_DEBUG)', exact: true } ) ).toBeChecked(); } ); + +test( 'the theme set in the settings is the one the window is painted in, and a change is on screen as it is made (#560)', async ( { session } ) => { + const site = await makeSite( session ); + // The page follows the app's own theme here, and not the light scheme + // Playwright holds every other journey to. + const { app, page } = await session.start( { ...site.settings, preferences: { theme: 'dark' } }, { colorScheme: null } ); + const themeSource = ( electronApp ) => electronApp.evaluate( ( { nativeTheme } ) => nativeTheme.themeSource ); + // The colour the window itself was made with, which shows where the page + // has not painted yet; and the colour of the theme Electron says it is in. + const windowColour = ( electronApp ) => electronApp.evaluate( ( { BrowserWindow } ) => BrowserWindow.getAllWindows()[ 0 ].getBackgroundColor().toLowerCase() ); + const systemColour = ( electronApp ) => electronApp.evaluate( ( { nativeTheme } ) => nativeTheme.shouldUseDarkColors ).then( ( dark ) => ( dark ? DARK_BACKGROUND : LIGHT_BACKGROUND ) ); + const prefersDark = ( window ) => window.evaluate( () => window.matchMedia( '(prefers-color-scheme: dark)' ).matches ); + const bodyColour = () => page.evaluate( () => window.getComputedStyle( document.body ).backgroundColor ); + const tokenColour = ( token ) => ui.tokenColour( page, token ); + + // INVARIANT — stored dark, the window starts dark: Electron is told, the + // page is in the dark scheme, and once the app has mounted the body is + // painted with the token as the dark theme has it, which the light + // theme's value below is not. Before the mount the body is the dark seed + // and the tokens still the stylesheet's, which is the moment the window + // is made dark for; so the body is read once it is the token's colour. + expect( await themeSource( app ) ).toBe( 'dark' ); + await expect.poll( () => prefersDark( page ) ).toBe( true ); + await expect( ui.renderedApp( page ) ).toBeVisible(); + await expect.poll( async () => ( await bodyColour() ) === ( await tokenColour( 'var(--wpds-color-background-surface-neutral)' ) ) ).toBe( true ); + const darkBody = await bodyColour(); + + // INVARIANT — the terminal, which is told its colours as values, is + // painted with the dark tokens too. + await ui.openTray( page, 'Terminal' ); + // By its class and not under the tray's role: the settings dialog, once + // open, makes the rest of the page inert, and a role under it is not + // found. One site, so one terminal. + const viewport = page.locator( '.xterm-viewport' ); + const terminalSurface = () => viewport.evaluate( ( el ) => window.getComputedStyle( el ).backgroundColor ); + await expect.poll( terminalSurface ).toBe( await tokenColour( 'var(--wpds-color-background-surface-neutral-weak)' ) ); + const darkTerminal = await terminalSurface(); + + // INVARIANT — the control shows Dark. Light chosen is kept, Electron is + // told, and the page, the body and the terminal are light at once, with + // no relaunch. + await ui.settingsButton( page ).click(); + const dialog = ui.settingsDialog( page ); + const themes = dialog.getByRole( 'radiogroup', { name: 'Theme', exact: true } ); + await expect( themes.getByRole( 'radio', { name: 'Dark', exact: true } ) ).toBeChecked(); + await themes.getByRole( 'radio', { name: 'Light', exact: true } ).click(); + await expect( themes.getByRole( 'radio', { name: 'Light', exact: true } ) ).toBeChecked(); + await expect.poll( () => session.readSettings().preferences?.theme ).toBe( 'light' ); + await expect.poll( () => themeSource( app ) ).toBe( 'light' ); + await expect.poll( () => prefersDark( page ) ).toBe( false ); + // The scheme flips before the app has repainted for it, so the body is + // read once it has: until then the token is the dark one too. + await expect.poll( bodyColour ).not.toBe( darkBody ); + expect( await bodyColour() ).toBe( await tokenColour( 'var(--wpds-color-background-surface-neutral)' ) ); + await expect.poll( terminalSurface ).toBe( await tokenColour( 'var(--wpds-color-background-surface-neutral-weak)' ) ); + expect( await terminalSurface() ).not.toBe( darkTerminal ); + // And the window itself, where the page has not painted. + await expect.poll( () => windowColour( app ) ).toBe( LIGHT_BACKGROUND ); + + // INVARIANT — System is kept as the system's, Electron is left to follow + // it, and the window is the colour of whichever theme that is. + await themes.getByRole( 'radio', { name: 'System', exact: true } ).click(); + await expect.poll( () => session.readSettings().preferences?.theme ).toBe( 'system' ); + await expect.poll( () => themeSource( app ) ).toBe( 'system' ); + await expect.poll( () => windowColour( app ) ).toBe( await systemColour( app ) ); + + // INVARIANT — started again with dark kept, the window is made dark, so + // it is not white before its page paints, and the control says so. + // CHARACTERISATION — the colour it is made in is the dark seed. + await themes.getByRole( 'radio', { name: 'Dark', exact: true } ).click(); + await expect.poll( () => session.readSettings().preferences?.theme ).toBe( 'dark' ); + const again = await session.restart(); + expect( await themeSource( again.app ) ).toBe( 'dark' ); + expect( await windowColour( again.app ) ).toBe( DARK_BACKGROUND ); + await expect.poll( () => prefersDark( again.page ) ).toBe( true ); + await ui.settingsButton( again.page ).click(); + await expect( ui.settingsDialog( again.page ).getByRole( 'radio', { name: 'Dark', exact: true } ) ).toBeChecked(); +} ); diff --git a/tests/unit/color-scheme.test.cjs b/tests/unit/color-scheme.test.cjs index adb3029a..2b3b1592 100644 --- a/tests/unit/color-scheme.test.cjs +++ b/tests/unit/color-scheme.test.cjs @@ -1,39 +1,55 @@ 'use strict'; // The window declares which colour schemes it supports, and the browser paints -// the parts it owns — native form controls — to match. Declaring `light dark` -// on a machine in dark mode gave charcoal inputs on hand-painted white cards, -// with placeholder text at dark-on-dark contrast. That is not cosmetic here: -// this app puts guidance in placeholders ("Ticket number or URL, e.g. 62281", -// "WordPress.org username, e.g. janedoe"), so the unreadable text is the text -// a first-timer most needs. +// the parts it owns — native form controls — to match. Until #560 the window +// painted light only, and declared `light` only: declaring `light dark` on a +// machine in dark mode gave charcoal inputs on hand-painted white cards, with +// placeholder text at dark-on-dark contrast. Now that the page paints both +// schemes, the declaration has to say both: with `light` alone, the controls +// would be painted light inside a dark window, and the placeholders this app +// puts its guidance in ("Ticket number or URL, e.g. 62281") would be the +// unreadable text again, the other way round. // // A string assertion looks trivial, and the regression it guards is not: the // declaration is one word in a file nobody opens, the app looks perfect to // anyone whose OS is in light mode, and CI machines are in light mode too. The // only thing that would catch it is a reviewer on a dark laptop, which is how -// it was found. +// the first form of it was found. const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { DARK_BACKGROUND } = require('../../src/theme.cjs'); + const INDEX_HTML = path.join(__dirname, '..', '..', 'src', 'renderer', 'index.html'); -test('the window declares only the colour scheme it actually implements', () => { +test('the window declares both colour schemes, since it paints both (#560)', () => { const html = fs.readFileSync(INDEX_HTML, 'utf8'); const declaration = / { + const html = fs.readFileSync(INDEX_HTML, 'utf8'); + const prePaint = /@media\s*\(prefers-color-scheme:\s*dark\)\s*\{\s*html:not\(\[data-wpds-root-provider\]\)\s+body\s*\{\s*background:\s*(#[0-9a-f]{6})\s*;?\s*\}\s*\}/i.exec(html); + + assert.ok(prePaint, 'index.html no longer paints the body for the dark scheme before the provider mounts, on `html:not([data-wpds-root-provider]) body`'); + assert.equal(prePaint[1].toLowerCase(), DARK_BACKGROUND.toLowerCase(), 'the colour painted before the app mounts is not the dark seed the app builds its theme from'); }); diff --git a/tests/unit/ipc-wiring.test.cjs b/tests/unit/ipc-wiring.test.cjs index 606c8cad..0939a910 100644 --- a/tests/unit/ipc-wiring.test.cjs +++ b/tests/unit/ipc-wiring.test.cjs @@ -50,6 +50,7 @@ const { // The applied-layer module turns the handler's measured status into the // attribution the renderer shows. const { attributeConflicts } = require('../../src/renderer/applied-layer.cjs'); +const { DARK_BACKGROUND, LIGHT_BACKGROUND } = require('../../src/theme.cjs'); const { nodeExecPath } = require('../../src/node-shims.cjs'); const SRC_DIR = path.join(__dirname, '..', '..', 'src'); const MAIN_PATH = path.join(SRC_DIR, 'main.js'); @@ -90,6 +91,7 @@ function createElectronStub({ ready = false } = {}) { const handlers = new Map(); const oneWay = new Map(); const appEvents = new Map(); + const nativeThemeListeners = []; const windows = []; const calls = { openExternal: [], @@ -128,6 +130,7 @@ function createElectronStub({ ready = false } = {}) { show() {} focus() {} restore() {} + setBackgroundColor(color) { this.options = { ...this.options, backgroundColor: color }; this.backgrounds = [...(this.backgrounds || []), color]; } isMinimized() { return false; } isDestroyed() { return false; } close() {} @@ -173,6 +176,15 @@ function createElectronStub({ ready = false } = {}) { isReady: () => true }, BrowserWindow: BrowserWindowStub, + // The theme (#560): what main sets, and what Electron would then say of + // it. Under 'system' this machine is taken to be light. + nativeTheme: { + themeSource: 'system', + get shouldUseDarkColors() { return this.themeSource === 'dark'; }, + on(event, listener) { nativeThemeListeners.push({ event, listener }); }, + // What Electron would do: tell main the theme changed. + update() { for (const { event, listener } of nativeThemeListeners) if (event === 'updated') listener(); } + }, Menu: { buildFromTemplate: (template) => ({ template }), setApplicationMenu: (menu) => { calls.applicationMenu.push(menu); } @@ -348,7 +360,9 @@ function loadMain({ stubs = {}, ready = false } = {}) { // logging is stubbed everywhere: electron-log resolves its file path through // `app.getPath`, which the electron stub only pretends to have, and a test has // no business writing to the contributor's log file either way. -function silentLogging() { +// `overrides` replaces any of the functions, for a test that reads what was +// logged. +function silentLogging(overrides = {}) { return { './logging': { initLogging: () => {}, @@ -356,7 +370,8 @@ function silentLogging() { logChildOutput: () => {}, flushChildOutput: () => {}, logEvent: () => {}, - logError: () => {} + logError: () => {}, + ...overrides } }; } @@ -1872,7 +1887,7 @@ test('settings:set asks the disk whether the folder is there', async (t) => { const settings = fakeSettingsStore(); const main = loadMain({ stubs: { ...silentLogging(), ...settings.stubs } }); - assert.deepEqual(await main.invoke('settings:set', 'newSiteLocation', folder), { ok: true, settings: { locale: null, phpVersion: '8.3', wpDebug: true, scriptDebug: true, autoStartServer: false, autoStartWatch: false, quitBehavior: 'stop', newSiteLocation: folder } }); + assert.deepEqual(await main.invoke('settings:set', 'newSiteLocation', folder), { ok: true, settings: { locale: null, phpVersion: '8.3', wpDebug: true, scriptDebug: true, autoStartServer: false, autoStartWatch: false, quitBehavior: 'stop', theme: 'system', newSiteLocation: folder } }); const gone = await main.invoke('settings:set', 'newSiteLocation', path.join(folder, 'gone')); assert.equal(gone.ok, false); assert.equal(settings.values.preferences.newSiteLocation, folder); @@ -1887,12 +1902,98 @@ test('settings:set refuses a key that is not a setting without writing, whatever const settings = fakeSettingsStore({ preferences: { wporgHandle: 'janedoe' } }); const main = loadMain({ stubs: { ...silentLogging(), ...settings.stubs } }); - assert.equal((await main.invoke('settings:set', 'theme', folder)).ok, false); + assert.equal((await main.invoke('settings:set', 'editor', folder)).ok, false); assert.equal((await main.invoke('settings:set', '__proto__', folder)).ok, false); assert.equal((await main.invoke('settings:set', 'newSiteLocation', 'sites')).ok, false, 'a path that is not a full one'); assert.deepEqual(settings.values.preferences, { wporgHandle: 'janedoe' }); }); +// The theme (#560) is Electron's to apply: main gives it `nativeTheme`, and +// Chromium answers the page's `prefers-color-scheme` from that. +test('settings:set gives the theme to Electron as it is kept, and the fallback when it is forgotten (#560)', async () => { + const settings = fakeSettingsStore(); + const main = loadMain({ stubs: { ...silentLogging(), ...settings.stubs } }); + + assert.equal((await main.invoke('settings:set', 'theme', 'dark')).settings.theme, 'dark'); + assert.equal(main.electron.nativeTheme.themeSource, 'dark'); + assert.equal(settings.values.preferences.theme, 'dark'); + + // A refusal leaves it. + assert.equal((await main.invoke('settings:set', 'theme', 'custom')).ok, false); + assert.equal(main.electron.nativeTheme.themeSource, 'dark'); + + assert.equal((await main.invoke('settings:set', 'theme', null)).settings.theme, 'system'); + assert.equal(main.electron.nativeTheme.themeSource, 'system'); + + // Another setting does not touch it. + await main.invoke('settings:set', 'theme', 'light'); + await main.invoke('settings:set', 'wpDebug', false); + assert.equal(main.electron.nativeTheme.themeSource, 'light'); +}); + +test('the ready path gives Electron the stored theme before the window is made, and makes the window in it (#560)', async () => { + const main = loadMain({ ready: true, stubs: { ...silentLogging(), ...fakeSettingsStore({ preferences: { theme: 'dark' } }).stubs, './i18n.cjs': { resolveCatalog: async () => null } } }); + await menuBuilt(main); + + assert.equal(main.electron.nativeTheme.themeSource, 'dark'); + assert.equal(main.windows.length, 1); + assert.equal(main.windows[0].options.backgroundColor, DARK_BACKGROUND, 'a dark window is made dark, not white until its page paints'); +}); + +// The colour the window was made with shows wherever its page has not +// painted yet, so it follows the theme: the setting gives it as it is +// changed, without waiting for Electron's `updated`, which is not promised +// for a change to 'system'; and `updated` gives it when the system's theme +// changes under 'system'. +test('the window is given the colour of the theme as the setting changes, and as the system\'s theme does (#560)', async () => { + const main = loadMain({ ready: true, stubs: { ...silentLogging(), ...fakeSettingsStore({ preferences: { theme: 'dark' } }).stubs, './i18n.cjs': { resolveCatalog: async () => null } } }); + await menuBuilt(main); + const [window] = main.windows; + // Made in the stored theme, and not painted again for it: the ready path + // reads the store before it makes the window. + assert.equal(window.options.backgroundColor, DARK_BACKGROUND); + assert.equal(window.backgrounds, undefined); + + // The setting, with no event from Electron. + await main.invoke('settings:set', 'theme', 'light'); + assert.deepEqual(window.backgrounds, [LIGHT_BACKGROUND]); + await main.invoke('settings:set', 'theme', 'system'); + assert.deepEqual(window.backgrounds, [LIGHT_BACKGROUND, LIGHT_BACKGROUND], 'under system this machine is light'); + + // The system's theme changing under 'system': Electron says so, and the + // window follows what it now says of the colours. + Object.defineProperty(main.electron.nativeTheme, 'shouldUseDarkColors', { value: true, configurable: true }); + main.electron.nativeTheme.update(); + assert.deepEqual(window.backgrounds, [LIGHT_BACKGROUND, LIGHT_BACKGROUND, DARK_BACKGROUND]); + + // A window that is gone is left alone. + window.isDestroyed = () => true; + main.electron.nativeTheme.update(); + await main.invoke('settings:set', 'theme', 'light'); + assert.equal(window.backgrounds.length, 3); +}); + +test('the ready path makes a light window light, and a store that cannot be read leaves the system\'s theme (#560)', async () => { + const light = loadMain({ ready: true, stubs: { ...silentLogging(), ...fakeSettingsStore({ preferences: { theme: 'light' } }).stubs, './i18n.cjs': { resolveCatalog: async () => null } } }); + await menuBuilt(light); + assert.equal(light.electron.nativeTheme.themeSource, 'light'); + assert.equal(light.windows[0].options.backgroundColor, LIGHT_BACKGROUND); + + const logged = []; + const broken = loadMain({ + ready: true, + stubs: { + ...silentLogging({ logError: (scope, message) => logged.push([scope, message]) }), + './settings-store': { getStore: async () => { throw new Error('settings.json is not JSON'); }, peekStore: () => null }, + './i18n.cjs': { resolveCatalog: async () => null } + } + }); + await menuBuilt(broken); + assert.equal(broken.electron.nativeTheme.themeSource, 'system'); + assert.equal(broken.windows.length, 1, 'the window still opens'); + assert.ok(logged.some(([scope, message]) => scope === 'theme' && message.includes('settings.json is not JSON')), `logged: ${JSON.stringify(logged)}`); +}); + // The menu's Settings… reaches the main window and brings it forward, and // not whichever window Electron lists first: a patch window is one too. async function menuBuilt(main) { @@ -6654,10 +6755,10 @@ test('settings:set keeps a language the build has, and refuses one it has not (# stubs: { ...silentLogging(), ...settings.stubs, './i18n.cjs': { resolveCatalog: async () => null, languageChoices: () => [{ tag: 'de', label: 'Deutsch' }, { tag: 'en', label: 'English' }] } } }); - assert.deepEqual(await main.invoke('settings:set', 'locale', 'de'), { ok: true, settings: { locale: 'de', phpVersion: '8.3', wpDebug: true, scriptDebug: true, autoStartServer: false, autoStartWatch: false, quitBehavior: 'stop', newSiteLocation: null } }); + assert.deepEqual(await main.invoke('settings:set', 'locale', 'de'), { ok: true, settings: { locale: 'de', phpVersion: '8.3', wpDebug: true, scriptDebug: true, autoStartServer: false, autoStartWatch: false, quitBehavior: 'stop', theme: 'system', newSiteLocation: null } }); assert.equal((await main.invoke('settings:set', 'locale', 'fr')).ok, false); assert.equal(settings.values.preferences.locale, 'de'); - assert.deepEqual(await main.invoke('settings:set', 'locale', null), { ok: true, settings: { locale: null, phpVersion: '8.3', wpDebug: true, scriptDebug: true, autoStartServer: false, autoStartWatch: false, quitBehavior: 'stop', newSiteLocation: null } }); + assert.deepEqual(await main.invoke('settings:set', 'locale', null), { ok: true, settings: { locale: null, phpVersion: '8.3', wpDebug: true, scriptDebug: true, autoStartServer: false, autoStartWatch: false, quitBehavior: 'stop', theme: 'system', newSiteLocation: null } }); }); test('app:relaunch relaunches through a quit, so the child sweep runs, without the launch\'s link or --lang (#559)', async (t) => { diff --git a/tests/unit/settings-view.test.cjs b/tests/unit/settings-view.test.cjs index 0908a210..e2c649cf 100644 --- a/tests/unit/settings-view.test.cjs +++ b/tests/unit/settings-view.test.cjs @@ -3,7 +3,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, phpVersionChoice, quitItems, resumeFor, SYSTEM_LANGUAGE } = require('../../src/renderer/settings-view.cjs'); +const { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, phpVersionChoice, quitItems, themeItems, resumeFor, SYSTEM_LANGUAGE } = require('../../src/renderer/settings-view.cjs'); test('the GitHub line says the account is still being read, and offers no sign-out, until it is', () => { assert.deepEqual(githubAccountLine(null), { text: 'Reading…', canSignOut: false }); @@ -74,6 +74,14 @@ test('the quit control offers stop and restart, and not the prototype\'s leaving assert.ok(quitItems().every((item) => item.label)); }); +test('the theme control offers light, dark and system, in that order, and not the prototype\'s custom colours (#560)', () => { + assert.deepEqual(themeItems(), [ + { value: 'light', label: 'Light' }, + { value: 'dark', label: 'Dark' }, + { value: 'system', label: 'System' } + ]); +}); + test('what the next launch starts for a site comes from the list the quit left', () => { const resume = { servers: ['/a', '/both'], watches: ['/w', '/both'] }; assert.deepEqual(resumeFor(resume, '/a'), { server: true, watch: false }); diff --git a/tests/unit/settings.test.cjs b/tests/unit/settings.test.cjs index e0fbeae4..5c499e98 100644 --- a/tests/unit/settings.test.cjs +++ b/tests/unit/settings.test.cjs @@ -12,19 +12,19 @@ const languages = (tags) => ({ isLanguage: (tag) => tags.includes(tag) }); const php = (versions) => ({ isPhpVersion: (version) => versions.includes(version) }); test('readSettings falls back for a store with nothing in it, and for values of the wrong kind', () => { - const fallbacks = { locale: null, phpVersion: '8.3', wpDebug: true, scriptDebug: true, autoStartServer: false, autoStartWatch: false, quitBehavior: 'stop', newSiteLocation: null }; + const fallbacks = { locale: null, phpVersion: '8.3', wpDebug: true, scriptDebug: true, autoStartServer: false, autoStartWatch: false, quitBehavior: 'stop', theme: 'system', newSiteLocation: null }; assert.deepEqual(readSettings(), fallbacks); assert.deepEqual(readSettings(undefined), fallbacks); assert.deepEqual(readSettings({}), fallbacks); - assert.deepEqual(readSettings({ newSiteLocation: 42, locale: ['de'], phpVersion: 8.4, wpDebug: 'false', scriptDebug: 0, autoStartServer: 'yes', autoStartWatch: 1, quitBehavior: 'leave' }), fallbacks); + assert.deepEqual(readSettings({ newSiteLocation: 42, locale: ['de'], phpVersion: 8.4, wpDebug: 'false', scriptDebug: 0, autoStartServer: 'yes', autoStartWatch: 1, quitBehavior: 'leave', theme: 'custom' }), fallbacks); assert.deepEqual(readSettings({ newSiteLocation: '', locale: '', phpVersion: '', wpDebug: null, scriptDebug: null }), fallbacks); assert.deepEqual(readSettings('not an object'), fallbacks); }); test('readSettings gives back a stored folder without asking the disk about it', () => { assert.deepEqual( - readSettings({ newSiteLocation: '/Users/jane/sites', locale: 'de', phpVersion: '8.4', wpDebug: false, scriptDebug: false, autoStartServer: true, autoStartWatch: true, quitBehavior: 'restart' }), - { locale: 'de', phpVersion: '8.4', wpDebug: false, scriptDebug: false, autoStartServer: true, autoStartWatch: true, quitBehavior: 'restart', newSiteLocation: '/Users/jane/sites' } + readSettings({ newSiteLocation: '/Users/jane/sites', locale: 'de', phpVersion: '8.4', wpDebug: false, scriptDebug: false, autoStartServer: true, autoStartWatch: true, quitBehavior: 'restart', theme: 'dark' }), + { locale: 'de', phpVersion: '8.4', wpDebug: false, scriptDebug: false, autoStartServer: true, autoStartWatch: true, quitBehavior: 'restart', theme: 'dark', newSiteLocation: '/Users/jane/sites' } ); }); @@ -54,7 +54,7 @@ test('a folder is refused when it is not a string, not a full path, or not on th test('a key that is not a setting is refused, and nothing is asked of the disk', () => { let asked = 0; - const result = acceptSetting('theme', 'dark', { isAbsolute: () => { asked++; return true; }, isDirectory: () => { asked++; return true; } }); + const result = acceptSetting('editor', 'vim', { isAbsolute: () => { asked++; return true; }, isDirectory: () => { asked++; return true; } }); assert.equal(result.ok, false); assert.equal(asked, 0); }); @@ -91,6 +91,15 @@ test('a debug flag is on or off, nothing means the fallback, and a string is ref } }); +test('the theme is light, dark or system, nothing means the system\'s, and the prototype\'s custom is refused (#560)', () => { + for (const theme of ['light', 'dark', 'system']) assert.deepEqual(acceptSetting('theme', theme, {}), { ok: true, value: theme }); + assert.deepEqual(acceptSetting('theme', null, {}), { ok: true, value: null }); + assert.deepEqual(acceptSetting('theme', '', {}), { ok: true, value: null }); + assert.deepEqual(acceptSetting('theme', 'custom', {}), { ok: false, error: 'Choose light, dark, or your system’s theme.' }); + assert.equal(acceptSetting('theme', 'Dark', {}).ok, false); + assert.equal(acceptSetting('theme', true, {}).ok, false); +}); + test('what happens on quit is stop or restart, nothing means the fallback, and anything else is refused', () => { assert.deepEqual(acceptSetting('quitBehavior', 'restart', {}), { ok: true, value: 'restart' }); assert.deepEqual(acceptSetting('quitBehavior', 'stop', {}), { ok: true, value: 'stop' }); diff --git a/tests/unit/theme.test.cjs b/tests/unit/theme.test.cjs new file mode 100644 index 00000000..89c22f42 --- /dev/null +++ b/tests/unit/theme.test.cjs @@ -0,0 +1,29 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { THEMES, themeColorSeeds, windowBackground, DARK_BACKGROUND } = require('../../src/theme.cjs'); + +test('the themes are light, dark and system: the prototype\'s custom colours are not one (#560)', () => { + assert.deepEqual(THEMES, ['light', 'dark', 'system']); +}); + +// In the light scheme the provider is given no colour, so the tokens +// stylesheet's values stand as they ship: generating them again from a seed +// would move every colour by a hair, and the light theme is what every +// picture in the guide shows. In the dark scheme it is given the dark seed +// and builds every token from it. +test('the provider is seeded in the dark scheme only, and from the dark background', () => { + assert.deepEqual(themeColorSeeds(false), {}); + assert.deepEqual(themeColorSeeds(true), { background: DARK_BACKGROUND }); +}); + +// The window's colour and the provider's seed have to be the one colour, or +// the page changes colour as it mounts; and both have to be what Electron +// takes for a window's colour, `#rrggbb`. +test('a window is made in the colour of its scheme, which in the dark scheme is the seed the theme is built from', () => { + assert.equal(windowBackground(true), themeColorSeeds(true).background); + assert.notEqual(windowBackground(false), windowBackground(true)); + for (const colour of [windowBackground(true), windowBackground(false)]) assert.match(colour, /^#[0-9a-f]{6}$/); +});