Skip to content

Let people choose light, dark or Auto in Settings › Appearance - #491

Merged
cdeutsch merged 12 commits into
mainfrom
claude/dark-light-mode-toggle-89818c
Nov 12, 2026
Merged

cdeutsch merged 12 commits into
mainfrom
claude/dark-light-mode-toggle-89818c

Conversation

@cdeutsch

Copy link
Copy Markdown
Contributor

The app followed the device's light or dark setting and nothing else. This adds a choice: Settings › Appearance, with Auto, Light and Dark.

Where it lives, and why three states

It is a settings page, not a switch in the chrome. A theme is set once and then left alone, so a control on every view would take up room on every visit for a decision made once. The account menu is for the account, not the app.

The CSS-Tricks piece on two-state toggles argues against a third state for a control that is always on screen. It also says a settings panel is where all three belong, and that is this case: Auto has to be something you can pick again after choosing Light or Dark.

The three choices are tiles that each draw a small picture of the app in their theme, with Auto's tile split in half. They are real radio buttons, so arrow keys and screen readers treat them as one choice of three. A line underneath says what Auto is showing right now ("Follows this device, which is dark now"), or that a pinned choice is kept on this device only.

How it works

  • Dark rules key off [data-theme='dark'] on <html>, not @media (prefers-color-scheme). A test fails if a media-query dark rule comes back, and AGENTS.md says so.
  • An inline script in index.html sets the attribute before the first paint, so there is no flash of the wrong theme. A test runs that script against theme.ts's rules so the two cannot drift.
  • theme.ts holds the choice in localStorage (unbent.theme; Auto is no key at all). It follows the device while Auto is chosen and picks up a choice made in another tab. When private browsing refuses the write, the choice still holds until the page closes.
  • The front and features pages' <picture> sources and clips follow the choice, not the device.
  • The Turnstile sign-in check uses the chosen theme. The hosted /turnstile page that the apps' native sign-in screens embed always follows the phone instead, because those screens do.
  • Android: the status-bar and navigation-bar icons used to follow the phone's night mode, so they disappeared when the chosen theme differed from the phone's. They now follow the page's reported colour, as iOS already did.

Not covered

  • The Android bar-icon fix compiles and ./gradlew test passes, but it has not been checked on a device. To check it: on a phone in light mode, choose Dark (and the reverse), and see that the clock and battery stay readable.
  • On an Android cold start, the WebView's first frame is still the phone's colour for a moment, until the page reports its own.
  • The native screens (sign-in, App settings, share sheet) follow the phone, by design.

Checks

The web suite passes (2,763 tests) and tsc is clean for both the app and the service worker. I checked the page in the browser at desktop and phone widths, in both themes.
The release note for Android mentions the new setting. To fit it beside main's newer lines, I tightened the wording, and the note is now 499 characters.

🤖 Generated with Claude Code

cdeutsch and others added 12 commits November 12, 2026 12:17
…etting.

The dark theme lived under `prefers-color-scheme` media queries, so it could only ever follow the device. To let someone choose a theme in the app, the stylesheet now keys every dark rule off `[data-theme='dark']` on `<html>`, and `theme.ts` keeps that attribute set to the theme in effect: the stored choice, or the device's answer when there is none, kept current as the device or another tab changes it.

An inline script in `index.html` sets the attribute before the first frame, since the app's module runs after it and a dark reader would otherwise see a white flash on every load. A test runs that script against the module's rule so the two cannot drift.

The front and features pages' pictures, the features clips, and the colour the Android and iOS apps paint around the page follow the same store. With nothing chosen everything resolves exactly as before, so this commit changes nothing anyone sees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The app only followed the device's theme, with no way to read in dark on a light laptop or the reverse. Appearance is a new Settings page with three choices, each drawn as the app in miniature, and a line under them saying which theme Auto is showing right now.

Three states rather than the two-state toggle often recommended for a header switch: that argument is about a control that is always on screen. On a settings page, "go back to following my device" has to be something you can pick, or someone who chose Dark on a dark device could never follow it into light again. The choice is kept per device, like the device's own setting. The page sits beside Offline, the other page about this device rather than the account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…m the phone's.

`enableEdgeToEdge` picks the bars' icon colour from the phone's night mode, while the strips behind them are painted in the page's own colour. Those always agreed until Settings › Appearance let the page choose a theme of its own: Dark on a light phone put dark icons on a dark strip, and the clock and battery vanished.

The icons now take their contrast from the colour the page reports, as iOS's status bar already does from the same message. Before the page has reported, the system's answer stands, since the strip is still the app's own surface then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e's.

The Turnstile widget was rendered with Cloudflare's `theme: 'auto'`, which follows `prefers-color-scheme`. The theme choice is per device and outlives a sign-out, so someone who picked Light on a dark laptop got a dark check box on a light sign-in form. It now takes the theme in effect from `theme.ts`, read once at mount, since the widget is drawn once and the theme does not change on that screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uery.

Every dark rule used to sit under `@media (prefers-color-scheme: dark)`, and that is the habit the next one will copy. Written that way now, a rule follows the device past the theme chosen in Settings › Appearance, and nothing else would notice. A test reads the stylesheet for such a query, and AGENTS.md says which selector to use instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`setThemeChoice` assigned `fallback` above its `let`. It worked, since the function only runs after the module has loaded, but it read as a temporal dead zone waiting to happen. The declaration now sits with the module's other state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three paths in `theme.ts` had no test: the `storage` listener that carries a choice made in one tab to the others, the three answers `useDarkSourceMedia` gives every front and features picture, and the choice held for the page when storage refuses the write. Each is pinned now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tile rules went in between `.offline-storage` and `.offline-kept`, which put 120 lines between the kept notebooks' comment and the rules it describes. They now follow the Offline rules as a section of their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each tile carried a hand-copied set of four colours from its theme, with a comment asking whoever changed a theme to change its tile too. The two token blocks now also apply to `.theme-mini[data-theme]`, so a tile is drawn with exactly the colours its theme uses and cannot drift from them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Focusing a tile from the keyboard drew the tile's accent outline and the radio's own ring beside the word. The tile's is the larger and clearer of the two, so the radio's is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Turnstile` worked out the theme itself from storage, which skipped the choice `theme.ts` holds for a page whose storage refused the write. `currentTheme` is that one answer, exported for script that draws once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… chosen on the web.

The Android and iOS sign-in screens load `/turnstile` in a web view and draw it inside a native screen that follows the phone. The web view keeps the site's storage across a sign-out, so a theme chosen in Settings › Appearance reached that page: Dark on a light phone put a dark box in the middle of a light sign-in screen.

The page now follows the device. `followDeviceTheme` in `theme.ts` holds the page to the device's answer while the route is entered, and both the inline script and `theme.ts` start that way on a load of `/turnstile`, so no frame is drawn in the chosen theme first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Preview: https://unbent-pr-491.of2668.workers.dev

Sign in with Continue on the dev instance: a preview cannot register
its own OAuth callback (its host changes every PR), so it borrows a Google
session from https://dev.unbent.app and comes back signed in. A password with an
allowlisted address still works too.

Runs against the shared unbent-dev database, bucket and note stores (the same
notes as the dev instance), so what you change here changes there too. Production
is untouched. The Worker is deleted when this PR closes.

Worker unbent-pr-491 · deployed b70b724

@cdeutsch
cdeutsch merged commit ec998d5 into main Nov 12, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant