Skip to content

Add appearance settings with themes and accent colors#1550

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

Add appearance settings with themes and accent colors#1550
juliusmarminge wants to merge 5 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: c5e61f85-382d-4b47-a9d6-1433cb9cbda9

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
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
- Replace single-theme settings with light and dark theme libraries
- Persist custom theme definitions and add import, copy, duplicate, and reset flows
- Update shared appearance contracts, UI, and tests
Comment on lines +57 to +62
const baseId = slugifyThemeId(`${themeDocument.id}-copy`);
const nextId = ensureUniqueThemeId(baseId, existingThemeIds);
const nextName =
nextId === `${themeDocument.id}-copy`
? `${themeDocument.name} Copy`
: `${themeDocument.name} Copy ${nextId.split("-").at(-1)}`;
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 settings/appearance.logic.ts:57

The condition nextId === \${themeDocument.id}-copy`compares against the rawthemeDocument.id, but nextIdis derived fromslugifyThemeId(`${themeDocument.id}-copy`). If themeDocument.idcontains uppercase letters or spaces (e.g., "Codex Dark"), the comparison fails becausenextIdis "codex-dark-copy" while the right side is "Codex Dark-copy". This causesnextName` to incorrectly include the numeric suffix ("Copy 2") even when no deduplication was needed, instead of the plain "Copy".

Suggested change
const baseId = slugifyThemeId(`${themeDocument.id}-copy`);
const nextId = ensureUniqueThemeId(baseId, existingThemeIds);
const nextName =
nextId === `${themeDocument.id}-copy`
? `${themeDocument.name} Copy`
: `${themeDocument.name} Copy ${nextId.split("-").at(-1)}`;
const nextId = ensureUniqueThemeId(baseId, existingThemeIds);
const nextName =
nextId === baseId
? `${themeDocument.name} Copy`
: `${themeDocument.name} Copy ${nextId.split("-").at(-1)}`;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/components/settings/appearance.logic.ts around lines 57-62:

The condition `nextId === \`${themeDocument.id}-copy\`` compares against the raw `themeDocument.id`, but `nextId` is derived from `slugifyThemeId(\`${themeDocument.id}-copy\``). If `themeDocument.id` contains uppercase letters or spaces (e.g., "Codex Dark"), the comparison fails because `nextId` is "codex-dark-copy" while the right side is "Codex Dark-copy". This causes `nextName` to incorrectly include the numeric suffix ("Copy 2") even when no deduplication was needed, instead of the plain "Copy".

Evidence trail:
apps/web/src/components/settings/appearance.logic.ts lines 8-14 (toKebabCase function), lines 16-18 (slugifyThemeId), lines 57-62 (createDuplicateTheme with the buggy comparison). The comparison on line 59 `nextId === \`${themeDocument.id}-copy\`` compares a slugified nextId against raw themeDocument.id without slugification.

Comment on lines +112 to +124
function parseHexColor(hex: string): Rgb {
const normalized = hex.replace("#", "");
const expanded =
normalized.length === 8 ? normalized.slice(0, 6) : normalized.length === 6 ? normalized : "";
if (!expanded) {
throw new Error(`Invalid hex color: ${hex}`);
}
return {
r: Number.parseInt(expanded.slice(0, 2), 16),
g: Number.parseInt(expanded.slice(2, 4), 16),
b: Number.parseInt(expanded.slice(4, 6), 16),
};
}
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 appearance/derive.ts:112

parseHexColor throws on valid 3-character shorthand hex colors like #fff. When normalized.length is 3, the expanded variable is set to empty string, causing the error throw. If any theme color uses shorthand notation, theme derivation will crash.

 function parseHexColor(hex: string): Rgb {
   const normalized = hex.replace("#", "");
   const expanded =
-    normalized.length === 8 ? normalized.slice(0, 6) : normalized.length === 6 ? normalized : "";
+    normalized.length === 8 ? normalized.slice(0, 6) : normalized.length === 6 ? normalized : normalized.length === 3 ? normalized.split("").map(c => c + c).join("") : "";
   if (!expanded) {
     throw new Error(`Invalid hex color: ${hex}`);
   }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/shared/src/appearance/derive.ts around lines 112-124:

`parseHexColor` throws on valid 3-character shorthand hex colors like `#fff`. When `normalized.length` is 3, the `expanded` variable is set to empty string, causing the error throw. If any theme color uses shorthand notation, theme derivation will crash.

Evidence trail:
packages/shared/src/appearance/derive.ts lines 112-123 at REVIEWED_COMMIT - The `parseHexColor` function only handles 6-char and 8-char hex colors. The ternary expression `normalized.length === 8 ? normalized.slice(0, 6) : normalized.length === 6 ? normalized : ""` sets `expanded` to empty string for 3-char inputs, triggering the error throw at line 117.

- Set built-in theme sidebar borders to `transparent`
- Update token derivation tests to assert the new sidebar border value
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