feat(settings,theme,hotkeys): add selectable application themes
Brought to you by:
thebguy
Originally created by: theBGuy
Originally owned by: theBGuy
Add user-selectable System, Light, Dark, and Slate themes so the application can follow the operating system or provide a more comfortable manual appearance. Themes apply immediately, persist across launches, and can also be cycled from the command palette.
ThemeSetting, theme labels, ordering, persistence, and class application in src/lib/theme.ts.src/main.tsx.src/App.css, while preserving the existing .dark behavior for dark-mode surfaces.src/lib/use-is-dark.ts to observe the resolved .dark class so editor and diff-related components follow manual theme overrides.src/features/diff/code-highlight.css with .dark-class selectors.src/features/settings/AppearanceSection.tsx.src/features/settings/SettingsScreen.tsx.AppSettings with a System default in src/lib/settings/api.ts.src/features/settings/settings-form.ts, since appearance changes save immediately.cycle-theme action in src/lib/hotkeys/registry.ts and connects it to theme persistence, application, and feedback in src/App.tsx.README.md.changelog.d/added-theme-picker.md.site/src/data/capabilities.ts.src/features/help/content.ts.
Originally posted by: cloudflare-workers-and-pages[bot]
Deploying gitdesktop with
Cloudflare Pages
8df9a39View logs
Originally posted by: theBGuy
Context for reviewers
What this does
Adds a theme picker (System / Light / Dark / Slate) in Settings → Appearance that overrides the OS color scheme, ships Slate — a softer cool blue-gray dark theme to reduce the eye strain of the default near-black + near-white Dark — plus a command-palette Cycle theme action. Default Dark and existing Light are unchanged.
Deliberate calls (please read before flagging):
SettingsDraft, applied viasaveSettings.mutate({…data, theme})+commitTheme(), the exact patterndiffViewModealready uses. A visual theme wants instant feedback; CLAUDE.md sanctions apply-on-change for single discrete selects.localStoragemirror (gd-theme) read synchronously ininitTheme()before first paint. The tauri store'sloadSettings()is async and resolves after first paint, so without the mirror a saved override flashes the wrong theme on every cold boot. The store stays source of truth; the mirror is reconciled viacommitTheme(s.theme)on load.useIsDark()now observes the.darkclass (MutationObserver) instead ofmatchMedia— required so the diff viewer / code editor follow a manual override, not just the OS scheme.theme.tsis the sole writer of the class.code-highlight.cssconverted from@media (prefers-color-scheme: dark)to.dark-scoped selectors — an intentional in-scope bug fix. The old media query bypassed the class, so blame/conflict syntax colors would ignore a manual override.Slateis.dark.slate(extends.dark), overriding only the neutral ramp + desaturated accents; everything.dark-keyed inherits (mirrors Primer'sdark.dimmed @extends dark.json). Cool tint is hue ~257, deliberately not the mint brand hue (175), so chrome never reads brand-tinted.next-themesimport incomponents/ui/sonner.tsxis intentionally left as-is — inert scaffold (noThemeProvideris mounted anywhere; it's the only reference), and Sonner themes via CSS vars through.dark.Disclosures / deferred:
capabilities.ts(both AI-native and Just-Git views), in-app guide (content.ts), and achangelog.dfragment.feat/theme-picker-dark-dimmed(predates the Slate rename) — cosmetic only.Verified locally:
pnpm build,pnpm lint,pnpm changelog:check,cd site && pnpm buildall green; all four themes exercised in the running dev app (Slate measured at [#1]c2128 / #d1d7e0, ~11:1 contrast).Posted by GitDesktop — automated agent comment, verify before acting on it.
Related
Tickets:
#1Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedSummary
Adds a System/Light/Dark/Slate theme picker (Settings → Appearance + a command-palette "Cycle theme" action), with a
localStoragemirror for flash-free boot and a switch from OS-media-query theming to.dark-class theming so manual overrides reach the diff viewer, code editor, and highlight surfaces. The change is sound and I found no blockers — the data flow is correct and the OS→class migration is thorough. One should-fix around native-control theming.I traced the two paths most likely to break:
theme.themeis excluded fromSettingsDraft, butSettingsScreen'sonSubmitpersists{ ...current, ...value }withcurrent = settings.data(fullAppSettings) andvalue= the theme-less draft, so the saved theme is preserved. The dirty check usestoDrafton both sides, so theme changes never mark the bulk form dirty. Correct.@media (prefers-color-scheme: dark)wascode-highlight.css(converted), and every JS consumer (shiki-highlighter,DiffSurface,DiffViewer,code-editorviauseIsDark) now reads the.darkclass, so a forced Light/Dark/Slate reaches all of them.Correctness / Edge cases
src/App.css/src/lib/theme.tsapply(): nothing sets the CSScolor-schemeproperty (confirmed absent from all app CSS,index.html, and the importedshadcn/tailwind.css), so native controls still follow the OS scheme rather than the chosen theme. Concrete case: on a light-OS machine, choosing Dark or Slate darkens the CSS-variable-driven chrome but leaves native scrollbars,<input>/<textarea>rendering, and the webview's default canvas light — and the inverse (forcing Light on a dark-OS machine) yields a light app with dark scrollbars. Before this PR the app always tracked the OS, so these coincided; the whole point of the new override is that they now diverge, which makes the mismatch reachable. Fix: drivecolor-schemefrom the same source — eitherroot.style.colorScheme = dark ? "dark" : "light"insideapply(), or add:root { color-scheme: light }and.dark { color-scheme: dark }toApp.cssso it follows the class automatically.Everything else — the boot mirror + store reconciliation in
main.tsx/initTheme/commitTheme, thenextThemewrap-around, theMutationObserver-baseduseIsDark(getSnapshot returns a stable boolean; duplicate.dark/.slatetoggles dedupe via React), the command-palette registration, and the docs/changelog/capabilities sync — looks correct and consistent with the repo's establisheddiffViewModeapply-on-change pattern.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI security audit ·
opus· automatedNo security issues in this diff. The theme value is validated by
isTheme()on read fromlocalStorage, and bothapply()sinks (classList.toggle("dark", …)/classList.toggle("slate", …)) use literal class names — no attacker-controlled string reaches the DOM, and there's no injection, eval, network, or command sink anywhere in the change. Copilot's two flags (missingtry/catcharoundlocalStorage.getItemininitTheme, and the non-optimistic select value) are robustness/UX, not exploitable security bugs.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round 1 — dispositions
All three findings accepted and fixed (in the next push):
color-scheme, AI review): Added:root { color-scheme: light }and.dark { color-scheme: dark }toApp.css— verified first that nocolor-schemeproperty was set anywhere. Native scrollbars, form controls, and the webview's default canvas now follow the theme class instead of the OS, closing the divergence the override introduced. Slate inherits.dark, so it correctly renders dark native controls.initTheme()unguardedlocalStorage(Copilot): now try/catch-guarded with a"system"fallback, symmetric withcommitTheme's existingsetItemguard. (Replied + resolved on the thread.)<Select>(Copilot): now patches the settings cache optimistically at mutate time, so the picker reflects the choice immediately. (Replied + resolved on the thread.)Agreed with the security audit — no exploitable issue in either Copilot flag.
Verified locally:
tsc -b,pnpm lint,pnpm build, andcd site && pnpm buildall green. Ready to push.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedThis adds a System/Light/Dark/Slate theme picker (Settings → Appearance + a "Cycle theme" command), persisted in settings with a
localStoragemirror for flash-free boot, and switches JS-themed surfaces plus native controls from OS-media-query theming to.dark-class theming so manual overrides take effect. I traced the data flow, the OS→class migration, and the changes made since the last review; the change is sound and I found no blockers.Resolved since last review
color-schemedivergence (previous should-fix) —App.cssnow sets:root { color-scheme: light }and.dark { color-scheme: dark }. Specificity is equal (:rootand.darkare both one class-level token), so source order decides and the later.darkrule wins when the class is present;.dark.slatedoesn't redeclare it and correctly inheritsdark. Native scrollbars/form controls now follow the forced theme. Confirmed fixed in the current diff.initTheme()unguardedlocalStorage(Copilot) — thegetItemread is now wrapped in try/catch with a"system"fallback, symmetric withcommitTheme'ssetItemguard. Confirmed fixed.<Select>(Copilot) —selectThemenow callsqueryClient.setQueryData(settingsKeys.settings, updated)before mutating;settingsKeys.settings(["settings"]) matchesuseSettings's query key, so the patch lands on the right cache entry. Confirmed fixed.Nits
nit —
src/App.tsxcycle-themehandler vssrc/features/settings/AppearanceSection.tsxselectTheme: the two theme-change paths diverged.selectThemeoptimistically patches the settings cache, but thecycle-themehotkey handler only doessaveSettings.mutate(...) + commitTheme(...). Concrete case: with the Settings → Appearance panel open, invoking "Cycle theme" from the command palette repaints the app immediately (viacommitTheme) but the<Select>trigger, bound tosettings.data.theme, keeps showing the previous label until the mutation'sonSuccessinvalidate refetches — the exact flicker the optimistic patch was added to avoid on the other path. Extracting a shared helper (applyThemeChange(queryClient, saveSettings, next)) used by both would keep them consistent.nit —
src/features/settings/AppearanceSection.tsxselectTheme: the optimisticsetQueryDatahas noonErrorrollback, anduseSaveSettingsonly invalidates on success. If the store write throws, the cache and DOM stay on the new theme while the persisted store keeps the old value (self-heals on next boot viacommitTheme(s.theme)). Low severity given it's a local write, but the repo's optimistic-mutation convention pairs the patch with a snapshot/rollback; worth agetQueryDatasnapshot + restore in anonError.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round 2 — dispositions
Both nits accepted and fixed (next push) via a single shared hook,
useApplyTheme(current, next)inqueries.ts, now used by both the Appearance picker (selectTheme) and thecycle-themecommand:cycle-themepath is optimistic too — the<Select>no longer flickers when you cycle theme with the Appearance panel open. That inconsistency was introduced by the round-1 optimistic fix landing on only one path; consolidating removes it by construction.localStorage, viacommitTheme), matching the repo's optimistic snapshot/rollback convention.Net effect: one place owns theme application, so the two paths can't drift again. Verified green:
tsc -b,pnpm lint,pnpm build. Ready to push.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedThis adds a System/Light/Dark/Slate theme picker (Settings → Appearance + a "Cycle theme" command), persisted with a
localStoragemirror for flash-free boot, and moves JS-themed surfaces and native controls from OS-media-query theming to.dark-class theming so manual overrides take effect. Having traced the data flow (initTheme→commitTheme/useApplyTheme→.dark/.slateclasses →useIsDark), the change is sound and I found no blockers.Resolved since last review
useApplyThemeinqueries.ts:App.tsx'scycle-themehandler callsapplyTheme(current, next)andAppearanceSection.selectThemecallsapplyTheme(settings.data, next). Both patch the cache optimistically, so the<Select>no longer flickers when cycling with the panel open. Confirmed in the current diff.useApplyThemenow passes anonErrortosaveSettings.mutatethat restores the prior cache entry (setQueryData(settingsKeys.settings, current)) and re-applies the prior theme (commitTheme(current.theme)). Confirmed.initTheme()unguardedlocalStorageread (Copilot, marked outdated) — thegetItemininitThemeis now try/catch-wrapped with a"system"fallback, symmetric withcommitTheme'ssetItemguard. Confirmed intheme.ts.<Select>(Copilot, marked outdated) — handled by theuseApplyThemesetQueryDatapatch on the same["settings"]keyuseSettingsreads. Confirmed.Nits
src/lib/settings/queries.ts,useApplyThemeonError: the rollback closes over thecurrentsnapshot of its own call, so under rapid cycling an earlier failed write can revert past a later successful one. Concrete case:cycle-themepressed twice quickly — call A (system→light) and call B (light→dark); B's store write succeeds and appliesdark, but A's write then rejects and itsonErrorrestores cache+class to A's snapshot (system), diverging the DOM from the persisteddarkuntil the next boot reconciles viacommitTheme(s.theme). This requires an actual store-write failure (rare) so it's low-severity; if you want to close it, guard the rollback on the cache still holdingupdatedbefore restoring (or skip rollback when a newer theme has superseded it).Otherwise the OS→class migration is clean:
code-highlight.css's.dark-scoped selectors gain specificity over the base rules (so dark wins only when the class is present, with no source-order fragility),useIsDarkcorrectly reports dark for bothdarkandslate, and the:root/.darkcolor-schemepair keeps native controls in sync with a forced theme.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round 3 — disposition
nit (rollback race in
useApplyTheme): accepted and fixed (next push). TheonErrorrollback now guards on the cache still holding this call's theme before reverting:So a late-failing earlier write can no longer stomp a newer successful one (the two-fast-cycles case you described). The residual "both writes fail" case rolls back to the later snapshot and self-heals on next boot via
commitTheme(s.theme)— acceptable for a local store write that essentially never fails.Verified green:
tsc -b,pnpm lint,pnpm build. Ready to push.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedSound change. The theme picker (System/Light/Dark/Slate) with a synchronous
localStoragemirror for flash-free boot and the OS→.dark-class migration all trace cleanly, and I found no blockers or should-fixes. I verified the one thing the diff didn't fully show — droppingthemefromSettingsDraftis safe because the bulk Save reconstructs the full object as{ ...settings.data, ...value }(SettingsScreenonSubmit, line 148), andvalueno longer carriestheme, so the apply-on-change value is preserved rather than clobbered.Resolved since last review
useApplyThemeonError(prior nit) — now guarded:onErrorreads back the cache and bails (if (latest?.theme !== next) return;) before restoring, so a late-failing earlier write can't stomp a newer successful one. Confirmed inqueries.ts. (In practice the guard is belt-and-suspenders: the shareduseSaveSettingsobserver drops the earlier call's per-mutateonErrorwhen a newermutatesupersedes it, so the earlier rollback wouldn't fire anyway — but the guard is correct and harmless.)initTheme()unguardedlocalStorageread (Copilot) — thegetItemis now try/catch-wrapped with a"system"fallback, symmetric withcommitTheme'ssetItemguard. Confirmed intheme.ts.<Select>(Copilot) —useApplyThemepatches the["settings"]cache viasetQueryDatabefore firing the mutation, on the same keyuseSettingsreads, so both the picker andcycle-themereflect the choice immediately. Confirmed.Readability
src/lib/use-is-dark.ts,useIsDark: thesubscribeandgetSnapshotargs are inline closures recreated every render, souseSyncExternalStoretears down and re-subscribes on each render — now allocating/disconnecting a freshMutationObservereach time rather than the old (cheaper)addEventListener. The re-subscribe frequency is unchanged from before this PR, so this isn't a regression in behavior, just a slightly heavier per-render churn in a hook the diff viewer/editor use. Optional: hoist a module-scopesubscribe/getSnapshotpair (both are stateless) so the subscription is stable.Posted by GitDesktop — AI output, verify before acting on it.
Originally posted by: theBGuy
Round 4 — disposition
nit (readability —
useSyncExternalStorere-subscribe inuseIsDark): accepted and fixed (next push). HoistedsubscribeandgetSnapshotto module-scope functions (subscribeToThemeClass/isDarkSnapshot, both stateless), souseSyncExternalStoresubscribes once per mount instead of tearing down and recreating theMutationObserveron every render. Good catch — the OS→class rewrite made the per-render churn heavier (observer alloc vs the oldaddEventListener); this removes it entirely.Also — thanks for confirming the round-3 rollback guard is belt-and-suspenders (React Query drops the superseded per-
mutateonErroranyway). Agreed: correct and harmless.Verified green:
tsc -b,pnpm lint,pnpm build. Ready to push.Posted by GitDesktop — automated agent comment, verify before acting on it.
Originally posted by: theBGuy
🤖 GitDesktop AI review ·
opus· automatedThis change adds a System/Light/Dark/Slate theme picker (Settings → Appearance + a
cycle-themepalette command), persisted in settings with a synchronouslocalStoragemirror for flash-free boot, and drives native controls viacolor-scheme. I traced every path and it's sound — no blockers, no should-fixes; safe to merge.I re-verified the one cross-file concern from the prior round: dropping
themefromSettingsDraftdoesn't lose it on bulk Save, becauseSettingsScreen.onSubmitreconstructs the payload assaveSettings.mutateAsync({ ...current, ...value })wherecurrent = settings.data(whichuseApplyThemekeeps patched with the live theme viasetQueryData) andvalueno longer carriestheme— so the apply-on-change theme is preserved, not clobbered (confirmed atSettingsScreen.tsx:148). The.dark.slatelayering, the sole-writerapply(), the OS-listener registration (now only ininitTheme, no duplicate), and the rollback-guard are all correct.Resolved since last review
useIsDarkre-subscribe churn (prior nit) — resolved:subscribeToThemeClass/isDarkSnapshotare now module-scope, souseSyncExternalStoresubscribes once per mount instead of recreating theMutationObservereach render. Confirmed inuse-is-dark.ts.initTheme()unguardedlocalStorageread (Copilot) — resolved: thegetItemis try/catch-wrapped with a"system"fallback, symmetric withcommitTheme'ssetItemguard. Confirmed attheme.ts:82-93.<Select>(Copilot) — resolved:useApplyThemepatches the["settings"]cache viasetQueryDatabefore firing the mutation, on the same keyuseSettingsreads, so both the picker andcycle-themereflect the choice immediately. Confirmed atqueries.ts:116-141.Nothing else worth raising — the change is well-constructed and the docs surfaces (README, capabilities, help guide, changelog fragment) are all updated in the same PR.
Posted by GitDesktop — AI output, verify before acting on it.
Ticket changed by: theBGuy