Skip to content

Add appearance settings with themes and accent colors#1550

Draft
juliusmarminge wants to merge 3 commits intomainfrom
t3code/appearance-system
Draft

Add appearance settings with themes and accent colors#1550
juliusmarminge wants to merge 3 commits intomainfrom
t3code/appearance-system

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Mar 29, 2026

Closes #1284
Closes #1535
Closes #1545
Closes #418
Closes #464
Closes #254
Closes #1279
Closes #233
Closes #400
Closes #1183

Summary

  • Adds a dedicated Appearance settings page for color mode, built-in theme selection, and accent color overrides.
  • Replaces the old theme-only hook with a unified useAppearance flow that syncs theme state, applies tokens, and prevents FOUC.
  • Moves appearance state into server-owned settings and updates desktop IPC to receive the richer appearance payload.
  • Updates chat, diff, and worker theme consumers to resolve from the new appearance state.

Testing

  • Not run.
  • Expected validation per repo requirements: bun fmt
  • Expected validation per repo requirements: bun lint
  • Expected validation per repo requirements: bun typecheck
  • Expected validation for this feature: bun run test

High Contrast

This should probably not be it's own theme, but a slider you can adjust on any theme?

CleanShot 2026-03-29 at 13 52 43@2x CleanShot 2026-03-29 at 13 52 51@2x

Color Blind

CleanShot 2026-03-29 at 13 54 08@2x CleanShot 2026-03-29 at 13 54 04@2x

Configurable Accent

CleanShot 2026-03-29 at 13 54 42@2x

Settings (NEEDS A LOT OF WORK STILL)

CleanShot 2026-03-29 at 13 54 32@2x

Note

Medium Risk
Moves theme control into server-authoritative settings and adds cross-surface token injection plus new Electron IPC, which could cause regressions in initial render (FOUC) and dark-mode synchronization across web/desktop.

Overview
Adds a dedicated Appearance settings page to manage colorMode (system/light/dark), select a built-in theme, and override the accent hue, and wires it into the settings navigation/routes.

Replaces the legacy useTheme flow with a unified useAppearance hook that applies the .dark class and injects theme CSS tokens (with a localStorage cache to reduce FOUC), updates consumers (chat markdown, diff panel, diff worker pool) to read resolvedTheme from appearance, and updates “restore defaults” to reset the new appearance fields.

Extends contracts and Electron bridging by adding DesktopAppearance plus a new desktop:set-appearance IPC (setTheme deprecated) so the web UI can sync richer appearance state to the desktop shell (currently used to set nativeTheme.themeSource).

Written by Cursor Bugbot for commit f4ab7eb. This will update automatically on new commits. Configure here.

Note

Add appearance settings with theme and accent color controls

  • Adds a new /settings/appearance route with a settings panel for color mode (light/dark/system), built-in theme selection, and accent color customization (presets + hex picker).
  • Introduces useAppearance to replace the deleted useTheme hook; it drives .dark class and CSS custom property injection, caches values in localStorage to prevent FOUC, and syncs to Electron via desktopBridge.setAppearance.
  • Adds a themes.ts module defining token types, three built-in themes (t3code, high-contrast, color-blind), accent presets, and helpers to inject/remove CSS variables.
  • Extends ServerSettings with colorMode, activeThemeId, and accentHue fields; migrates the legacy t3code:theme localStorage key to colorMode on startup.
  • Removes the theme selector from the General settings panel and moves it to the new Appearance panel.

Macroscope summarized f4ab7eb.

- Move theme control into a dedicated appearance settings panel
- Add built-in themes, accent overrides, and desktop IPC sync
- Migrate settings and update components to use unified appearance state
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 29, 2026

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 UI

Review profile: CHILL

Plan: Pro

Run ID: 814214a0-065e-4256-b8ac-745b5d8e4ef9

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
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/appearance-system

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

@github-actions github-actions bot added size:XL 500-999 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Mar 29, 2026
@juliusmarminge juliusmarminge marked this pull request as draft March 29, 2026 18:52
return cachedSystemDark;
}

function subscribeSystemDark(listener: () => void): () => void {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium hooks/useAppearance.ts:93

Each call to subscribeSystemDark registers a new handler on the MediaQueryList, and each handler iterates over the full systemDarkListeners array. With N concurrent subscriptions, a single media-query change triggers N² listener invocations instead of N, causing quadratic performance degradation as the number of subscribing components grows. The handler should be registered once globally rather than per-subscription.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/hooks/useAppearance.ts around line 93:

Each call to `subscribeSystemDark` registers a new `handler` on the MediaQueryList, and each handler iterates over the full `systemDarkListeners` array. With N concurrent subscriptions, a single media-query change triggers N² listener invocations instead of N, causing quadratic performance degradation as the number of subscribing components grows. The handler should be registered once globally rather than per-subscription.

Evidence trail:
apps/web/src/hooks/useAppearance.ts lines 93-103 at REVIEWED_COMMIT: The `subscribeSystemDark` function registers a new handler via `mq.addEventListener('change', handler)` on every call (line 99), and each handler iterates `for (const l of systemDarkListeners) l()` (line 97-98). With N subscribers, N handlers are attached, each iterating N listeners = N² total calls.

Comment on lines +29 to +38
function suppressTransitions(fn: () => void) {
document.documentElement.classList.add("no-transitions");
fn();
// Force reflow so the no-transitions class takes effect before removal
// oxlint-disable-next-line no-unused-expressions
document.documentElement.offsetHeight;
requestAnimationFrame(() => {
document.documentElement.classList.remove("no-transitions");
});
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low hooks/useAppearance.ts:29

In suppressTransitions, if fn() throws an exception, the requestAnimationFrame callback that removes the no-transitions class is never scheduled, leaving the class on document.documentElement permanently. This disables all CSS transitions for the rest of the session. Consider wrapping fn() in a try/finally to ensure the cleanup always runs.

 function suppressTransitions(fn: () => void) {
   document.documentElement.classList.add("no-transitions");
-  fn();
-  // Force reflow so the no-transitions class takes effect before removal
-  // oxlint-disable-next-line no-unused-expressions
-  document.documentElement.offsetHeight;
-  requestAnimationFrame(() => {
-    document.documentElement.classList.remove("no-transitions");
-  });
+  try {
+    fn();
+    // Force reflow so the no-transitions class takes effect before removal
+    // oxlint-disable-next-line no-unused-expressions
+    document.documentElement.offsetHeight;
+  } finally {
+    requestAnimationFrame(() => {
+      document.documentElement.classList.remove("no-transitions");
+    });
+  }
 }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/hooks/useAppearance.ts around lines 29-38:

In `suppressTransitions`, if `fn()` throws an exception, the `requestAnimationFrame` callback that removes the `no-transitions` class is never scheduled, leaving the class on `document.documentElement` permanently. This disables all CSS transitions for the rest of the session. Consider wrapping `fn()` in a try/finally to ensure the cleanup always runs.

Evidence trail:
apps/web/src/hooks/useAppearance.ts lines 27-35 at REVIEWED_COMMIT: The `suppressTransitions` function adds `no-transitions` class, calls `fn()`, then schedules cleanup via `requestAnimationFrame`. There is no try/finally block, so if `fn()` throws, the cleanup code never executes.

Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Effect cleanup removes shared global style element
    • Removed the useEffect cleanup that called removeThemeTokens(), since the shared global <style> element should persist across component unmounts and the FOUC-prevention code plus remaining mounted components' effects handle reapplication.
  • ✅ Fixed: Theme migration unreachable without old settings key
    • Moved the t3code:theme localStorage migration block before the OLD_SETTINGS_KEY early-return guard so it runs independently regardless of whether old-format settings exist.
  • ✅ Fixed: Redundant appearance update before full settings reset
    • Removed the redundant updateSettings() call that set appearance defaults immediately before resetSettings(), which already applies all defaults including the same appearance values.

Create PR

Or push these changes by commenting:

@cursor push 57ebd82da6
Preview (57ebd82da6)
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -424,7 +424,7 @@
 
 export function useSettingsRestore(onRestored?: () => void) {
   const settings = useSettings();
-  const { updateSettings, resetSettings } = useUpdateSettings();
+  const { resetSettings } = useUpdateSettings();
 
   const isGitWritingModelDirty = !Equal.equals(
     settings.textGenerationModelSelection ?? null,
@@ -487,10 +487,9 @@
     );
     if (!confirmed) return;
 
-    updateSettings({ colorMode: "system", activeThemeId: "t3code", accentHue: null });
     resetSettings();
     onRestored?.();
-  }, [changedSettingLabels, onRestored, resetSettings, updateSettings]);
+  }, [changedSettingLabels, onRestored, resetSettings]);
 
   return {
     changedSettingLabels,

diff --git a/apps/web/src/hooks/useAppearance.ts b/apps/web/src/hooks/useAppearance.ts
--- a/apps/web/src/hooks/useAppearance.ts
+++ b/apps/web/src/hooks/useAppearance.ts
@@ -12,7 +12,7 @@
 import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
 import type { ColorMode } from "@t3tools/contracts/settings";
 import type { DesktopAppearance } from "@t3tools/contracts";
-import { applyThemeTokens, findThemeById, removeThemeTokens, BUILT_IN_THEMES } from "~/lib/themes";
+import { applyThemeTokens, findThemeById, BUILT_IN_THEMES } from "~/lib/themes";
 import { useSettings, useUpdateSettings } from "./useSettings";
 
 // ── Constants ────────────────────────────────────────────────────
@@ -138,8 +138,6 @@
 
     // Sync to Electron
     syncDesktopAppearance({ mode: colorMode, themeId: activeThemeId, accentHue });
-
-    return () => removeThemeTokens();
   }, [resolvedTheme, activeTheme, accentHue, colorMode, activeThemeId]);
 
   const setColorMode = useCallback(

diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts
--- a/apps/web/src/hooks/useSettings.ts
+++ b/apps/web/src/hooks/useSettings.ts
@@ -235,10 +235,9 @@
 export function migrateLocalSettingsToServer(): void {
   if (typeof window === "undefined") return;
 
-  const raw = localStorage.getItem(OLD_SETTINGS_KEY);
-  if (!raw) return;
-
-  // Migrate old t3code:theme localStorage key to server colorMode
+  // Migrate old t3code:theme localStorage key to server colorMode.
+  // This runs independently of OLD_SETTINGS_KEY since the old useTheme hook
+  // wrote t3code:theme separately.
   try {
     const oldTheme = localStorage.getItem("t3code:theme");
     if (oldTheme === "light" || oldTheme === "dark" || oldTheme === "system") {
@@ -250,6 +249,9 @@
     // Best-effort — don't block startup
   }
 
+  const raw = localStorage.getItem(OLD_SETTINGS_KEY);
+  if (!raw) return;
+
   try {
     const old = JSON.parse(raw);
     if (!Predicate.isObject(old)) return;

// Sync to Electron
syncDesktopAppearance({ mode: colorMode, themeId: activeThemeId, accentHue });

return () => removeThemeTokens();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Effect cleanup removes shared global style element

High Severity

The useEffect cleanup calls removeThemeTokens(), which removes a shared global <style> element by ID. Since multiple components (ChatView, DiffPanel, DiffWorkerPoolProvider, ChatMarkdown, AppearanceSettingsPanel) each call useAppearance(), each gets its own effect cleanup. When any of these components unmounts (e.g., navigating from chat to settings), the cleanup deletes the shared style element. The root component's effect doesn't re-run because its dependencies haven't changed, so theme token CSS overrides are permanently lost until a dependency changes. Non-default themes and accent colors break on navigation.

Additional Locations (1)
Fix in Cursor Fix in Web

}
} catch {
// Best-effort — don't block startup
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Theme migration unreachable without old settings key

Medium Severity

The t3code:theme localStorage migration is placed after if (!raw) return; on line 239, where raw reads from OLD_SETTINGS_KEY. Since t3code:theme was written independently by the now-deleted useTheme hook, users whose old settings were already migrated (removing OLD_SETTINGS_KEY) or who never had old-format settings will hit the early return. Their theme preference in t3code:theme is never migrated to the server's colorMode setting, silently losing the preference.

Fix in Cursor Fix in Web

if (!confirmed) return;

setTheme("system");
updateSettings({ colorMode: "system", activeThemeId: "t3code", accentHue: null });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant appearance update before full settings reset

Low Severity

updateSettings({ colorMode: "system", activeThemeId: "t3code", accentHue: null }) is called immediately before resetSettings(), which internally calls updateSettings(DEFAULT_UNIFIED_SETTINGS) — containing the same appearance defaults. This fires two separate server RPCs with overlapping content when one suffices.

Fix in Cursor Fix in Web

- Write theme CSS variables at the intermediate token level so Tailwind picks them up
- Call `useAppearance()` before the root route can return early to satisfy Hooks rules
- Apply theme overrides as inline CSS variables
- Add diff-specific tokens and color-blind diff palette
- Update swatches and diff stats to use new semantic colors
@github-actions github-actions bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Mar 29, 2026
Comment on lines +68 to +69
border: "--alpha(var(--color-black) / 45%)",
input: "--alpha(var(--color-black) / 50%)",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High lib/themes.ts:68

--alpha() is not valid CSS syntax, so the border and input token values for the High Contrast theme produce invalid CSS when consumed. The browser ignores these declarations and the overrides silently fail to apply. Lines 68–69 and 73–74 should use color-mix(in srgb, var(--color-black) 45%, transparent) (or equivalent modern syntax) instead of --alpha(...).

-    border: "--alpha(var(--color-black) / 45%)",
-    input: "--alpha(var(--color-black) / 50%)",
+    border: "color-mix(in srgb, var(--color-black) 45%, transparent)",
+    input: "color-mix(in srgb, var(--color-black) 50%, transparent)",
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/lib/themes.ts around lines 68-69:

`--alpha()` is not valid CSS syntax, so the `border` and `input` token values for the High Contrast theme produce invalid CSS when consumed. The browser ignores these declarations and the overrides silently fail to apply. Lines 68–69 and 73–74 should use `color-mix(in srgb, var(--color-black) 45%, transparent)` (or equivalent modern syntax) instead of `--alpha(...)`.

Evidence trail:
apps/web/src/lib/themes.ts lines 68-69, 73-74 (--alpha usage); apps/web/src/lib/themes.ts lines 180-182 (el.style.setProperty applying raw values); apps/web/src/index.css lines 77-85, 107-115 (--alpha usage in build-time CSS); GitHub discussion https://github.com/tailwindlabs/tailwindcss/discussions/18124 (collaborator @wongjn: "`--alpha()` is syntactic sugar for `color-mix()`" - confirms it's a build-time transformation)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

1 participant