Skip to content

Add a custom theme, a background and a primary colour of the contributor's own - #650

Merged
zaerl merged 5 commits into
trunkfrom
feat/custom-theme
Oct 7, 2026
Merged

zaerl merged 5 commits into
trunkfrom
feat/custom-theme

Conversation

@zaerl

@zaerl zaerl commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Why

The prototype's dialog has a fourth theme, and the dark theme of #642 left it out: a background and an accent colour of the contributor's own, from which the design system builds every other colour as it builds the dark theme from its one seed. This adds it, under the name the design system gives the second colour: primary. Part of #560.

What changes

Custom, on the General tab. Under Theme, a fourth answer; chosen, two fields appear beneath it: Background and Primary, each typed as hex or picked with the system's colour picker, which is the swatch before the field. A colour is kept as six lowercase hex digits from three or six as typed; one that is not a colour is refused in main's words and the field goes back to what is kept. The picker's choice is kept when the picker is done, not at every step of a drag, since each keep is a write to the store; the field shows the colour under the pointer meanwhile. The colours start as the light theme's.

A custom theme is a scheme too. Main gives Electron light or dark by whether the background reads better with white text than with black, the crossover the design system's own ramp turns at, so the native form controls and the window's chrome match the page; and the window is made and painted in the background chosen, as is the Trac window, which main now hands its colour. The window reads the settings above the design system's provider, since the theme is one of them, and gives the provider the two colours; the terminal re-reads its colours whenever the theme as painted changes, by a name that changes with it.

When the colours do not read. The design system says when it could not reach the contrast it wants between two of the colours it built, and under Custom the dialog passes that on in one sentence. The colours are kept all the same: a contributor who chose them can read them, and the sentence is for the one who did not mean to.

Not in this pull request:

  • The moment before the settings arrive: a custom theme shows the standard theme of its scheme for one round trip to main at launch, then its own. Removing it means handing the renderer the theme before its first paint, new bridge surface for a separate change.
  • Mail rendering is unchanged here and tracked separately.

How to test this

Platforms: any. The journeys drive this on macOS and Windows. From the repository root, after npm ci and npm run build:once:

npx playwright test --project=journeys settings.spec i18n.spec
  • settings.spec.js, the custom theme journey: the colours are asked for under Custom alone and start as the light theme's; a dark background typed and entered is kept, Electron is told the scheme, and the page, the body and the window are painted in it at once; a primary colour in three digits is kept in six and the brand surface is built from it; a colour spelled another way shows as kept; a colour picked with the picker is kept; a colour that is not one is refused in main's words and what is kept stays; a light background makes a light theme again; started again, the window is made in the background and the fields show the colours kept.
  • i18n.spec.js: the control in the pseudo-locale.

Six claims were broken one at a time and each turned the journey red: the page not seeded from the colours, Electron not told the scheme, a refusal leaving the typed text, three digits not expanded, the window not painted in the background, and the picker driven from what is kept rather than the draft, which was the bug the first review pass found. tests/unit/theme.test.cjs holds the resolution, the crossover and the hex forms; settings.test.cjs what is accepted; ipc-wiring.test.cjs what Electron, the window and the Trac window are given.

Worth a look by hand (any platform, the current head): Settings → General → Theme → Custom. Type 102030 into Background and press Enter: the whole window is navy, the dialog included, and the native controls (the folder field's button, a select's list) are dark. Press the Primary swatch, drag in the picker: the field follows the pointer; close the picker: the buttons take the colour. Type navy: refused under the fields, the field back to the colour kept. Type fff8e1: a cream theme with dark text and light controls. Quit and open the app: it opens in the cream, after a moment of the standard light theme.

What must not have happened: the standard themes looking any different (the guide's pictures are the reference); a picker drag writing the store at every step; a colour kept that the field does not show; a server or build touched by a change.

What I could not test: Windows and Linux pickers, which the OS draws; the journeys drive the picker through its value, as a picker does when it is done.

Risks and limitations

  • Review: 2 passes, 6 findings fixed here, 1 follow-up left (the flash at launch), every style note applied. Details below.
  • A custom theme flashes the standard theme of its scheme at launch, for one round trip to main. See above.
  • The pre-paint of a dark window is the dark seed, not the custom background, for the moment before the page's script runs; then the standard dark theme, then the custom one.
  • Two colours can be chosen that read badly together. The dialog says so and keeps them; the standard themes are one press away.

Related

Part of #560, which is part of #542. Follows #642 and #649.


Design decisions and alternatives considered
  • "Primary", not the prototype's "accent". It is what the design system calls the seed, and what the provider's prop is named.
  • The scheme from the background's luminance, by the rule the design system's ramp uses to pick its text direction: white where it contrasts better than black. One rule, so the native controls and the ramp agree except within a hair of the crossover.
  • The picker commits when done, not as it is dragged. A drag is tens of events a second; each keep is a store write and an IPC round trip.
  • The picker driven from the draft. A controlled input is put back to its prop after every step of a drag, and the picker's closing then reads the old colour: the first review pass found the picker never saving.
  • The settings read above the provider. The theme is a setting, so the component that reads the settings has to sit above the one that paints with them; the app is handed what was read.
  • The warning passed on, the colours kept. The design system's answer is the authority on contrast; the contributor is the authority on what they want to look at.
Review outcome (required — see AGENTS.md)

Pass 1: 4 [fix here] · 1 [follow-up] · 4 style notes. Pass 2 (on the fix-up): 2 [fix here] · 0 [follow-up] · 3 style notes. All 6 fixed here, every style note applied, 1 follow-up left.

  • Review: completed — a separate agent context, read-only; head 8b3658a / base 59afb0c; the store rule against the changed handlers, the theme's ordering in main against Electron's themeSource, the luminance maths against the design system's own ramp direction, React's controlled-input restore against the colour picker's change, the design system's input and the provider's warnings; ESLint and the unit files run; 4 [fix here], 1 [follow-up].
    • Fixed: a colour picked with the picker was never kept, since a controlled input is put back to its prop after every step of a drag and the picker's closing read the old colour; the wiring test's Trac assertion never opened a Trac window; the fallback before the settings arrive was a branch in the component; the hex fields' monospace rule reached nothing.
    • Follow-up left: the standard theme of the scheme shows for one round trip at launch.
    • Style notes, applied: the crossover pinned; a colour spelled another way shown as kept; a dead prop; the translator comment checked.
  • Review: completed — a second separate agent context, read-only; head 7fdf4e0 / base 59afb0c; the picker's event order on a real picker and under Playwright's fill, the listener's double-commit, the accent ramp's first step for the journey's exact colours, the crossover recomputed; ESLint and the unit files run; 2 [fix here].
    • Fixed: a keep that finished late overwrote newer typing in the field; what the field shows after a keep, and what the picker holds while the field holds no colour, were decided in the component.
    • Style notes, applied: the Trac assertion built a repository it never opened; the journey's two exact colours say where they come from; a comment on the swatch.
  • Since review: 7fdf4e0 → 0a9965d is those two and the notes, not reviewed again; 31c125c stands the swatch in the design system's prefix slot as a centred square, from the maintainer's look at the dialog, not reviewed again. On 0a9965d: npm run lint, npm test, npm run test:electron, all 133 journeys, the packaged smoke and the docs build pass; on the head: npm run lint, npm run test:electron (2045) and all 133 journeys, the packaged smoke not run since the maintainer's own app held the single-instance lock. The commit after it fixes a Windows-only cleanup race in tests/unit/log-tail.test.cjs, a test this branch does not otherwise touch, which failed the Windows unit job on 31c125c: the read helper now settles on the stream's close, so the folder is removed after the file is.
Implementation notes
  • src/theme.cjs: THEMES with 'custom', THEME_KEYS, resolveTheme, nativeThemeSource, normalizeHexColor, isHexColor, isDarkColor, PRIMARY. src/settings.cjs: customBackground, customPrimary, acceptColor.
  • src/main.js: themeSettings, applyTheme, currentTheme, paintWindowForTheme; settings:set applies for any theme key; the Trac window is handed backgroundColor. src/trac-view.js: deps.backgroundColor.
  • src/renderer/components/app-theme.jsx: AppTheme({ settings }), useThemeKey, useThemeWarnings. src/renderer/index.jsx: Root reads the settings above the provider. src/renderer/hooks/use-site-terminal.jsx: re-reads on the theme's name.
  • src/renderer/components/settings-dialog.jsx: ColorField, the fourth option, the warning. src/renderer/settings-view.cjs: themeItems. src/renderer/shell.css: .theme-colors, .color-field, .color-swatch.
  • Tests: tests/unit/theme.test.cjs, settings.test.cjs, settings-view.test.cjs, ipc-wiring.test.cjs, trac-view.test.cjs; tests/e2e/journeys/settings.spec.js.
Screenshots or recording

Not attached: I have no way to upload images from the command line. The custom theme journey drives everything that changed; SHOTS_THEME takes light or dark only, so a picture of a custom theme is a hand's work.

🤖 Generated with Claude Code

zaerl and others added 3 commits October 7, 2026 10:59
…tor's own

The prototype's fourth theme (#560). Under Custom, the General tab asks for two colours, typed as hex or picked with the system's picker behind the swatch, and the design system builds every other colour from them, as it builds the dark theme from its one seed. The prototype calls the second colour the accent; the design system's name for it, primary, is used.

A custom theme is a scheme too: main gives Electron light or dark by whether the background reads better with black text or with white, so the native form controls and the window's chrome match the page, and makes and paints the window in the background chosen. The window reads the settings above the design system's provider now, since the theme is one of them, and the terminal re-reads its colours whenever the theme as painted changes, by a name that changes with it. A colour that is not one is refused in main's words and the field goes back to what is kept; three hex digits are kept as six. The dialog says when the design system could not reach the contrast it wants between two of the colours it built, and keeps the colours all the same.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ion tests

From the first review pass. A colour picked with the picker was never kept: the input was controlled by the colour kept, so it was put back to it after every step of a drag, and the picker's closing read the old colour. It is driven from the draft now, and the journey picks a colour. The fallback before the settings arrive is the module's 'system', not a branch in the component; the hex fields are in the terminal's type, which the rule for them reached only with a type on the input; the wiring test's Trac assertion opens a Trac window now, and the main window's colour is named as its own. The dark crossover is pinned, a colour spelled another way is shown as kept, and a prop nothing passed is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…yping

From the second review pass. What the field shows once main has answered a keep, and what the picker holds while the field holds no colour, are settings-view's to say, with their cases in its suite; an answer to an older draft leaves what has been typed since. The wiring test's Trac assertion seeds a path rather than building a repository it never opens, and the journey says where its two exact colours come from.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository: WordPress/contributor-toolkit/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: cfe1df3c-6dc2-44cb-abe8-668a6ea4323a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

zaerl and others added 2 commits October 7, 2026 11:41
The swatch sat in the field's row without the slot the design system pads and centres a prefix with: it stuck to the top of the row, the row shrank it narrower than it is tall, and the gap before it was one of its own. It stands in the slot now, with the slot's minimal padding, centred, and a square of its own size the row cannot shrink.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…er is removed

The Windows unit job failed on this branch's head in a test this branch does not touch: the test's read helper settled on the stream's `end`, the stream closed its file a turn later, and the test's cleanup removed the folder in between, which Windows refuses while a file in it is open. The helper settles on `close`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@zaerl
zaerl merged commit e0ed3ee into trunk Oct 7, 2026
10 checks passed
@zaerl
zaerl deleted the feat/custom-theme branch October 7, 2026 10:11
zaerl added a commit that referenced this pull request Oct 7, 2026
…ed from the app's first render (#651)

## Why

The follow-up recorded in
#650: a custom
theme was painted after a first render in the standard theme of its
scheme, a flash of the wrong colours at every launch, because the
settings, of which the theme is one, were read in an effect once the app
had mounted. Part of #560.

## What changes

**The settings are read before the first render**, beside the locale,
which the window already waited for; the two reads run together, so the
first paint waits for neither longer than before. What was read is
handed to the hook that holds the settings, which asks main again only
where that read failed, as it did before. No new bridge surface: the
same ask, made earlier.

**What remains:** before the page's script runs at all, the page paints
the stylesheet's light surface, or under the dark scheme the dark seed
`index.html` paints, over a window frame that is already the custom
colour; a custom theme shows that for that moment, then its own colours.
The review's idea of a transparent body until the provider mounts, so
the frame shows through, was tried and measured: in the light scheme the
window's colour did show through, but under the dark scheme Chromium
paints its own canvas colour, `#121212`, over the frame, so a custom
dark theme would still flash, and the standard dark theme would flash a
grey that is not its seed. Not taken. The page cannot know its colour
before its script runs without being handed it, which is new bridge
surface.

Mail rendering is unchanged here and tracked separately.

## How to test this

Platforms: any. The journeys drive this on macOS and Windows. From the
repository root, after `npm ci` and `npm run build:once`:

```
npx playwright test --project=journeys settings.spec
```

- `settings.spec.js`, the custom theme journey, at its end: the page is
reloaded with a script in it before any of its own, which notes the
document's own style at the moment the app mounts, before anything is
painted; the custom colours are in it, and not the standard theme's, and
not none. On the code before this change the journey receives an empty
style there, since a custom light theme's first render was the standard
light theme, which puts nothing on the document.

Nothing else is new to test: the settings dialog's journeys and the
language relaunch cover the hook as it is handed the settings.

**Worth a look by hand** (any platform, the current head): Settings →
General → Theme → Custom, a background such as `fff8e1`, quit, open the
app: the window is cream from the app's first render, with no moment of
the standard light theme after the page has mounted. With `102030`: navy
from the app's first render; what the page paints before its script runs
is the dark seed.

**What must not have happened:** the app failing to open its window when
the settings cannot be read (the read's failure is logged and the hook
asks again); a slower first paint; the language relaunch offer appearing
on a fresh launch.

**What I could not test:** the packaged smoke on this head, since my own
copy of the app held the single-instance lock; the change is the
renderer's and the bridge is unchanged.

## Risks and limitations

- Review: 2 passes, 1 finding fixed here, the follow-up tried and not
taken, every style note applied. Details below.
- A custom dark theme still shows the dark seed for the moment before
the page's script runs. See above.

## Related

Follow-up of #650,
part of #560 and #542.

---

<details>
<summary>Review outcome (required — see AGENTS.md)</summary>

**Pass 1: 1 [fix here] · 1 [follow-up] · 0 style notes. Pass 2 (on the
fix-up): 0 [fix here] · 0 [follow-up] · 2 style notes. The finding fixed
here, the follow-up tried and not taken, both style notes applied.**

- **Review:** completed — a separate agent context, read-only; head
`c3801dd` / base `e0ed3ee`; the hook's seeding against the language
relaunch's `loaded`, the two reads' failure paths and timing, the reload
against the deep-link queue and the provider's root count, the
observer's timing against React's commit, the journey red on the old
code; ESLint and the unit files run; 1 [fix here], 1 [follow-up].
- Fixed: the comment, the journey and the commit said "first frame" and
"before anything is painted", where the page paints before its script
runs; they say the app's first render.
- Follow-up, tried and not taken: a transparent body until the provider
mounts. Measured above.
- **Review:** completed — a second separate agent context, read-only;
head `d434c55` / base `e0ed3ee`; the wording checked place by place, the
observer's timing against the provider's layout effect; ESLint and the
unit files run; 0 findings, 2 style notes.
- Style notes, applied: the first commit's message still said "first
frame", so the fix-up is folded into it under the corrected words; the
branch was named for the first paint and is named for the first render.
- **Since review:** the fold and the rename, no change to the tree since
`d434c55`. On the tree: `npm run lint`, `npm test`, `npm run
test:electron` (2045), all 133 journeys and the docs build pass; the
packaged smoke could not run, since the maintainer's own app holds the
single-instance lock, and the bridge is unchanged.

</details>

<details>
<summary>Implementation notes</summary>

- `src/renderer/index.jsx`: `loadSettings`, `Root({ initialSettings })`,
the two reads before the first render.
`src/renderer/hooks/use-settings.jsx`: `useSettings(initial)`.
`src/renderer/components/app-theme.jsx`: the comment.
- `tests/e2e/journeys/settings.spec.js`: the reload with the mount
observer.

</details>

<details>
<summary>Screenshots or recording</summary>

Not attached: what changed is the first frame, which a picture taken
later does not show. The journey reads it.

</details>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@zaerl zaerl added this to the v2.0.0 milestone Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant