Skip to content

feat(ui): pair a light theme and a dark theme, switched by mode - #1217

Merged
backnotprop merged 2 commits into
mainfrom
feat/theme-pairs
Aug 6, 2026
Merged

feat(ui): pair a light theme and a dark theme, switched by mode#1217
backnotprop merged 2 commits into
mainfrom
feat/theme-pairs

Conversation

@backnotprop

@backnotprop backnotprop commented Aug 6, 2026

Copy link
Copy Markdown
Owner

TLDR: You can now pick which palette is your light theme and which is your dark theme, and System mode flips between the two as your OS scheme changes (Kanagawa Lotus by day, Kanagawa Wave at night). ThemeProvider stores { mode, light, dark } instead of one palette plus a mode, the Settings Theme tab assigns one half at a time, and the Light/Dark/System buttons are permanently enabled because a dark-only palette simply never occupies the light slot. The pair persists to ~/.plannotator/config.json under theme, so it survives the random port each hook invocation runs on.

This implements the design @kunaaal13 wrote in #1211, adopted nearly as written.

What changed

  • packages/ui/utils/themeRegistry.ts: pair helpers (ThemePair, themesForHalf, themeSupportsHalf, seedThemePair, normalizeThemePair, resolvePairTheme). isThemeModeAvailable() and normalizeThemeMode() are gone: there is nothing left to coerce.
  • packages/ui/components/ThemeProvider.tsx: resolves pair[preferredMode] (preferredMode already resolved system against prefers-color-scheme) and exposes lightTheme / darkTheme / setHalfTheme alongside the existing colorTheme / setColorTheme.
  • packages/ui/components/ThemeTab.tsx: a Light/Dark switch decides which half the grid is filling, the grid lists only the palettes that can render that half (from the registry's existing modeSupport, with both palettes appearing in each half using that mode's swatches), and a summary line names both halves with each side clickable to jump the switch. Same tab in both apps, plus the compact preview strip.
  • Mode buttons: the disabled state and the "Not supported by the current color theme" tooltip are deleted from ThemeTab, ModeToggle, PlanHeaderMenu, and ReviewHeaderMenu.
  • The colorblind palette from feat(ui): add colorblind theme #1192 composes with no special casing: it is a both palette, so it is assignable to either or both halves.

Someone who never opens the Theme tab sees no change at all: the default pair is today's default palette in both halves.

Config round-trip

The pair is one entry in the SETTINGS registry (themePair) with serverKey: 'theme', so configStore handles cookie write, debounced POST /api/config, and the server override on init() exactly as it does for diffOptions.

Server changes were needed, and landed in both runtimes:

  • getServerConfig() builds its payload from an explicit key list, so theme had to be added (packages/shared/config.ts, vendored to Pi by vendor.sh). saveConfig() also shallow-merges theme the way it merges diffOptions.
  • POST /api/config allowlists each key it will persist, so theme had to be added to all seven handlers: packages/server/{index,review,annotate,goal-setup}.ts (Bun) and apps/pi-extension/server/server{Plan,Review,Annotate}.ts (Pi).

Verified live across two runtimes of the same binary: a pair chosen in an annotate session was written to config.json and picked up by a fresh code review session on a different port.

Migration

  • Cookies: plannotator-theme (mode) keeps its meaning and gains plannotator-light-theme / plannotator-dark-theme.
  • A user coming from an older release has neither half, so both are seeded from their stored plannotator-color-theme palette: a both palette fills the whole pair, a mode-restricted one fills the half it supports while the other half falls back to plannotator.
  • ThemeProvider keeps writing plannotator-color-theme with the palette actually on screen, so a version downgrade still finds a palette and never renders an unstyled first frame. The migrated pair is persisted before that mirror runs, since the mirror overwrites the key the migration was derived from.
  • Anything unusable in a cookie or a hand-edited config.json (an unknown id, a dark-only palette in the light half, mode: "sepia") is repaired rather than trusted.

Syntax highlighting

No work needed, as the issue predicted, and confirmed live: resolveSyntaxTheme() / SHIKI_THEME_MAP are keyed on (colorTheme, mode), and resolving the pair earlier hands them the right pair member. Flipping the OS scheme in a review swaps both the UI palette and the diff's matched syntax colors.

Review findings addressed

An adversarial review of the first push returned BLOCK. All five findings are fixed in ca80f44f:

  1. Seeding wrote to the server (data loss). ThemeProvider handed its resolved pair to the store through configStore.set(), which queues a debounced POST /api/config. configStore.init() applied the server config but never cancelled that queued write, so one cookie-less visit (fresh profile, incognito, cleared cookies) flushed a default pair to config.json after the real one had loaded, and the next session then restored those defaults over the user's cookies. Two fixes: a new configStore.seed() writes memory plus cookie and never the server (and never over a value init() already applied), and init() now retracts queued writes for the leaves the server just spoke for, which closes the same race for every server-synced setting instead of this one key. Audited the other serverKey settings: displayName generation is cookie-only, every other write is a user-action handler, and the review editor's panel-pair self-heal deliberately runs after init() on a genuinely conflicted persisted pair.
  2. Deleted public exports. isThemeModeAvailable() and normalizeThemeMode() are restored as one-line wrappers carrying @deprecated notes that point at themeSupportsHalf / resolveThemeMode, since packages/ui exports ./utils/*.
  3. setColorTheme semantics. It now assigns exactly one half and changes nothing else: a both-mode palette goes to the half currently on screen (no longer clobbering the other half's assignment), a mode-restricted palette goes to the half it supports without pinning the mode (render-time resolution already keeps a System user on a drawable palette), and it persists through a new configStore.setLocal() so it stays cookie-only exactly as it was before the pair, unless a host installed its own serverSync transport.
  4. storageKey / colorThemeStorageKey ignored on the read path. Both props are now honored when resolving the initial pair, so a host's stored pre-pair preference is migrated instead of discarded, and the mirror writes the host's keys rather than Plannotator's. The two halves have no pre-pair equivalent and stay on fixed keys; that limitation is documented on the props.
  5. Missing fresh-store test. Added a mount against a genuinely cookie-less store with the config POSTs captured, pinning zero writes, plus a companion case pinning that a real choice still reaches config.json. The pre-seeding helper is kept for the other tests. Also added direct setColorTheme cases for all three semantics, a host-storage-keys migration case, and configStore seed/retract unit tests. Every new test fails against the code it replaces (verified by reverting each fix in turn).

Re-verified live afterwards: with config.json holding {"theme":{"mode":"system","light":"rose-pine","dark":"kanagawa-wave"}}, a brand new browser context made zero POST /api/config calls, left the file byte-identical, and still rendered the server-configured palette; the returning profile kept flipping Rosé Pine and Kanagawa Wave with the OS scheme, and assigning a half from Settings still round-trips to the file.

Tests

bun test (3167 tests) and DOM_TESTS=1 bun test packages/ui packages/review-editor packages/editor (1013 tests) are green, as is bun run typecheck. New coverage:

  • packages/ui/components/ThemeProvider.test.tsx: half filtering, migration seeding, pair repair, the pair flipping on a live prefers-color-scheme change, every mode staying selectable while a dark-only palette owns the dark half, and a ThemeTab walk that assigns each half from its own grid.
  • packages/ui/config/themePairSetting.test.ts: cookie round-trip, legacy seeding, half rejection, the theme key round-trip in both directions, and the configStore seed / queued-write-retraction contracts.
  • Review-driven cases listed under "Review findings addressed" above.

Verified live

Built the real bundles (bun run --cwd apps/review build && bun run build:hook) and drove the built app against a scratch git repo with Playwright, screenshotting the Theme tab assigning the light half, assigning the dark half, and the summary with System active, plus the app flipping between Kanagawa Lotus and Kanagawa Wave as prefers-color-scheme is emulated in both the plan/annotate surface and the review surface. The first-run dialog chain still appears one dialog at a time with no stacking, and the theme announcement dialogs are untouched. Bundle delta: +2.9 kB raw, +1.4 kB gzip on each of the two single-file bundles.

Addresses part 1 of #1211. Parts 2 (custom user theme files) and 3 (font settings) stay open; the pair format part 2 depends on is what this PR establishes.

AI-assisted.

ThemeProvider stored one palette plus a mode, so picking a dark-only
palette pinned the mode and greyed out the Light/System buttons. Store a
pair instead: { mode, light, dark }, resolved as pair[preferredMode], so
System flips between the two choices as the OS scheme changes.

The Settings Theme tab now assigns one half at a time. A Light/Dark
switch decides which half the grid is filling, the grid lists only the
palettes that can render that half (from the registry's modeSupport), and
a summary line names both halves with each side clickable. Every mode
button is permanently enabled: a dark-only palette simply never occupies
the light slot, so no mode coercion is left to do.

The pair round-trips through the SETTINGS registry to the `theme` key in
~/.plannotator/config.json the way diffOptions does. A user upgrading
seeds both halves from their stored single palette, and the legacy
plannotator-color-theme key keeps tracking the active palette so a
downgrade never lands on an unstyled first frame.

Addresses part 1 of #1211.
…destructive

Review of #1217 found a data-loss path and three published-API regressions.

Seeding: ThemeProvider handed its resolved pair to the config store through
set(), which queues a debounced POST. configStore.init() applies the server
config but never cancelled that queued write, so a single cookie-less visit
(fresh profile, incognito, cleared cookies) flushed a default pair to
~/.plannotator/config.json AFTER the real one had arrived, and the next
session restored those defaults over the user's cookies. The provider now
uses a new configStore.seed(): memory plus cookie, never the server, and
never over a value init() already applied. init() additionally retracts
queued writes for the leaves the server just spoke for, which closes the
same race for every server-synced setting rather than this one key.

Deprecated APIs: isThemeModeAvailable() and normalizeThemeMode() are back as
one-line wrappers with @deprecated notes, since packages/ui exports utils/*.

setColorTheme: assigns exactly one half and nothing else. A both-mode palette
goes to the half on screen instead of clobbering both; a mode-restricted one
goes to its half without yanking a System user to an explicit mode (render
time already resolves that). It persists through configStore.setLocal(), so
it stays cookie-only as it was before the pair, unless a host installed its
own serverSync transport.

storageKey / colorThemeStorageKey are honored on the read path, so a host's
stored pre-pair preference is migrated rather than discarded. The two halves
have no pre-pair equivalent and stay on fixed keys, documented on the props.

Tests: a fresh-mount case that pins zero POSTs (the previous helper pre-seeded
cookies, which is why this was invisible), a case that pins a real choice
still reaching config.json, direct setColorTheme cases for all three
semantics, a host-storage-keys migration case, and configStore seed/retract
unit tests. All of them fail against the code they replace.
@backnotprop
backnotprop merged commit 2d65c65 into main Aug 6, 2026
15 checks passed
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