Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/guide/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions scripts/screenshots/capture.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 13 additions & 1 deletion scripts/screenshots/fixtures.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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')
);
}

Expand Down
51 changes: 46 additions & 5 deletions src/main.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand All @@ -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() {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions src/renderer/components/app-theme.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<DarkSchemeContext.Provider value={dark}>
<ThemeProvider isRoot color={themeColorSeeds(dark)}>{children}</ThemeProvider>
</DarkSchemeContext.Provider>
);
}

export function useDarkScheme() {
return useContext(DarkSchemeContext);
}
38 changes: 37 additions & 1 deletion src/renderer/components/settings-dialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<>
<ToggleGroupControl
__nextHasNoMarginBottom
__next40pxDefaultSize
isBlock
label={__('Theme')}
help={__('System follows your operating system’s light or dark setting.')}
value={settings ? settings.theme : undefined}
disabled={!settings}
onChange={(value) => { if (value) keep(value); }}
>
{themeItems().map((item) => (
<ToggleGroupControlOption key={item.value} value={item.value} label={item.label} />
))}
</ToggleGroupControl>
{error ? (
<Notice.Root intent="error" role="alert" spokenMessage={SILENT}>
<Notice.Description>{error}</Notice.Description>
</Notice.Root>
) : 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.
Expand Down Expand Up @@ -156,6 +191,7 @@ function GeneralTab({ settings, loaded, onChange }) {
<Stack direction="column" gap="2xl">
<Stack direction="column" gap="xl">
<Text variant="heading-lg" render={<h3 />}>{__('Appearance')}</Text>
<ThemeControl settings={settings} onChange={onChange} />
<LanguageControl settings={settings} loaded={loaded} onChange={onChange} />
</Stack>
<OpeningAndQuitting settings={settings} onChange={onChange} />
Expand Down
13 changes: 13 additions & 0 deletions src/renderer/hooks/use-site-terminal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
30 changes: 18 additions & 12 deletions src/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,25 @@
<meta http-equiv="Permissions-Policy" content="clipboard-read=(self), clipboard-write=(self)" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!--
`light`, not `light dark`: this window has no dark theme yet (#560). Its
surfaces take the design system's colours, which are the light theme's, and
@wordpress/components ships light styles only. Declaring support for both
let the browser paint the parts it owns, the native form controls, to match
the OS instead. On a machine in dark mode that produced charcoal inputs on
white cards, with placeholder text at dark-on-dark contrast: unreadable,
and the guidance this app puts in placeholders is exactly what a first-timer
needs to read.

This was a promise the app could not keep, so it stops making it. The day a
real dark theme exists, this goes back to `light dark` along with it.
Both schemes, since #560: the page paints each one. Which the window is in
is Electron's `nativeTheme`, which main sets from the theme setting and
Chromium answers `prefers-color-scheme` from. Declaring both lets the
browser paint the parts it owns, the native form controls, in the scheme
the page is in; declaring `light` alone would paint them light inside a
dark window. tests/unit/color-scheme.test.cjs holds the declaration and
the colour below to the theme module.
-->
<meta name="color-scheme" content="light dark" />
<!--
The page's first paint is before its script runs, and the tokens
stylesheet it paints with holds the light theme's values; the dark ones
are set on the document once the app has mounted. Without this, a dark
window shows a light page for that moment. Until the design system's
provider has mounted, which it marks on the document, and no longer: the
colour is the dark seed in src/theme.cjs, the one colour the window has
that is not a token, and after that moment the tokens are the theme.
-->
<meta name="color-scheme" content="light" />
<style>@media (prefers-color-scheme: dark) { html:not([data-wpds-root-provider]) body { background: #1e1e1e; } }</style>
<meta name="robots" content="noindex, nofollow" />
<meta name="referrer" content="no-referrer" />
</head>
Expand Down
12 changes: 4 additions & 8 deletions src/renderer/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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(<ThemeProvider isRoot><App /></ThemeProvider>);
root.render(<AppTheme><App /></AppTheme>);
});
Loading
Loading