Skip to content

feat: customizable appearance — theme presets + accent colors#60

Open
itsfabioroma wants to merge 8 commits into
ankitvgupta:mainfrom
itsfabioroma:feat/appearance-customization
Open

feat: customizable appearance — theme presets + accent colors#60
itsfabioroma wants to merge 8 commits into
ankitvgupta:mainfrom
itsfabioroma:feat/appearance-customization

Conversation

@itsfabioroma
Copy link
Copy Markdown
Contributor

@itsfabioroma itsfabioroma commented Apr 6, 2026

Summary

Screenshot 2026-04-05 at 23 29 28

What's included

Mode — the existing Light / Dark / System toggle, now grouped under the unified Appearance section.

Theme Presets — 5 curated color palettes that re-skin all surfaces, borders, and text colors across the entire app:

  • Default — the current gray/blue palette (no visual change)
  • Midnight — deep navy surfaces with violet accent
  • Nord — polar night surfaces with frost blue accent
  • Solarized — warm base tones with yellow accent
  • Rose — warm pink surfaces with rose accent

Each preset has both light and dark variants that swap automatically when toggling Mode.

Accent Color — overrides the theme's accent (buttons, links, active states, selection highlights, toggles) with a custom color:

  • A (auto) — uses the theme preset's default accent
  • 8 preset swatches: Blue, Violet, Pink, Rose, Orange, Green, Teal, Cyan
  • Custom hex picker (paint bucket icon) for any color

Technical approach

A CSS custom property override layer (!important rules in index.css) re-skins Tailwind's hardcoded bg-white, bg-gray-*, bg-blue-*, text-blue-*, border-gray-* classes globally. This means zero component files were modified for the color system — all 1250+ existing Tailwind color classes across 35+ component files are overridden at the CSS level.

How it works

A CSS custom property override layer re-skins Tailwind's hardcoded gray/blue colors globally using !important overrides — zero component file changes needed for the color system. The ~1250 existing Tailwind color classes across 35+ component files are overridden at the CSS level.

Only "skin" colors are overridden (surfaces + accents). Semantic colors (red/green/yellow for errors, success, priority badges) stay fixed.

New files

  • src/shared/theme-presets.ts — 5 preset definitions with light/dark color sets + accent swatches
  • src/renderer/hooks/useAppearance.ts — hook that applies CSS variables to <html> based on config

Modified files

  • src/renderer/styles/index.css — CSS variable declarations + Tailwind color overrides
  • src/shared/types.tsAppearanceConfigSchema (themePreset + accentColor)
  • src/main/ipc/settings.ipc.tsappearance:get / appearance:set IPC handlers
  • src/preload/index.ts — appearance API bridge
  • src/renderer/store/index.ts — appearance state + setter
  • src/renderer/App.tsx — wire up useAppearance() hook
  • src/renderer/components/SettingsPanel.tsx — expanded Appearance section with preset swatches, accent picker

Test plan

  • Open Settings → General → Appearance
  • Click each theme preset swatch → surfaces and accent change globally
  • Pick a custom accent color → all blue interactive elements recolor
  • Switch light/dark mode → preset colors swap correctly
  • Restart app → appearance settings persist
  • Verify semantic colors (priority badges, error states) are unaffected

Open with Devin

Add a CSS custom property override layer that re-skins Tailwind's
hardcoded gray/blue colors globally without touching any component files.

- 5 theme presets: Default, Midnight, Nord, Solarized, Rose
- 8 accent color swatches + custom hex color picker
- All settings persist via electron-store and apply instantly
- useAppearance hook applies CSS variables on theme/config change
- Expanded Appearance section in Settings with preset swatches and
  accent picker
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 6, 2026

Greptile Summary

This PR adds a customizable appearance system — 5 theme presets, an 8-swatch accent color picker, and a custom hex input — all applied via CSS custom property overrides at the <html> level so no component files needed to change.

  • The useAppearance.ts hook casts incoming IPC data with data as AppearanceConfig (line 68), bypassing the Zod schema at a runtime boundary. Per project standards, AppearanceConfigSchema.safeParse() should be used here and in the appearance:set handler before writing to the electron-store.
  • appearance.get() rejection is silently swallowed; a .catch() would make failures observable.

Confidence Score: 4/5

Safe to merge with minor fixes; no data loss or crashes, but IPC boundary lacks Zod validation

One P1 (type assertion on IPC event data bypasses Zod) and two P2s (unhandled rejection, missing set-handler validation). The P1 could cause silent bad state if a preset name mismatch occurs after upgrades, warranting a 4 rather than 5.

src/renderer/hooks/useAppearance.ts (type assertion + unhandled rejection), src/main/ipc/settings.ipc.ts (missing input validation)

Important Files Changed

Filename Overview
src/renderer/hooks/useAppearance.ts New hook wiring appearance config to CSS vars; uses unsafe type assertion and unhandled rejection for IPC call
src/main/ipc/settings.ipc.ts New appearance:get/set handlers; set handler accepts renderer input without Zod validation at the boundary
src/shared/theme-presets.ts New file defining 5 theme presets + accent swatches as typed constants; straightforward and correct
src/renderer/styles/index.css Adds CSS variable layer + !important overrides for Tailwind gray/blue classes; approach is sound
src/shared/types.ts Adds AppearanceConfigSchema with ThemePresetId enum and nullable accentColor; clean Zod schema addition
src/renderer/components/SettingsPanel.tsx Adds theme preset swatches and accent color picker in Appearance section; UI is well-structured
src/renderer/store/index.ts Adds appearance state with correct defaults and a simple setAppearance action
src/preload/index.ts Exposes appearance get/set/onChange/removeAllListeners bridge correctly
src/renderer/App.tsx Trivially wires useAppearance() hook at the app root; correct placement

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[App.tsx mounts] --> B[useAppearance hook]
    B --> C[appearance:get IPC]
    C --> D[Main: getConfig]
    D --> E[Return AppearanceConfig]
    E --> F[setAppearance in Zustand store]
    F --> G[applyThemeVariables]
    G --> H[Set CSS vars on document.html]

    I[User changes theme/accent\nin SettingsPanel] --> J[updateAppearance]
    J --> K[setAppearance optimistic update]
    K --> G
    J --> L[appearance:set IPC]
    L --> M[Main: validate + save to electron-store]
    M --> N[Broadcast appearance:changed\nto all BrowserWindows]
    N --> O[onChange listener in useAppearance]
    O --> F
Loading

Reviews (1): Last reviewed commit: "feat: customizable appearance — theme pr..." | Re-trigger Greptile

Comment on lines +67 to +70
window.api.appearance.onChange((data: Record<string, unknown>) => {
setAppearance(data as AppearanceConfig);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Type assertion bypasses Zod boundary validation

The incoming data from the IPC event is cast directly to AppearanceConfig without parsing it through AppearanceConfigSchema. If the stored config contains an unknown themePreset string (e.g. from a future version or a hand-edited store file), the bad value is silently passed into state and then into CSS variable writes — producing no theme colors at all. Per project conventions, AppearanceConfigSchema.safeParse() should guard IPC data at the boundary.

Suggested change
window.api.appearance.onChange((data: Record<string, unknown>) => {
setAppearance(data as AppearanceConfig);
});
window.api.appearance.onChange((data: Record<string, unknown>) => {
const parsed = AppearanceConfigSchema.safeParse(data);
if (parsed.success) {
setAppearance(parsed.data);
}
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Already fixed in commit 046732aonChange now uses AppearanceConfigSchema.safeParse(data) before calling setAppearance. See useAppearance.ts:74-76.

Comment thread src/renderer/hooks/useAppearance.ts Outdated
Comment on lines +59 to +64
useEffect(() => {
window.api.appearance.get().then((result: { success: boolean; data?: AppearanceConfig }) => {
if (result.success && result.data) {
setAppearance(result.data);
}
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Unhandled rejection + implicit type assertion on appearance.get()

The preload declares get(): Promise<unknown>, so the typed callback parameter (result: { success: boolean; data?: AppearanceConfig }) is a compile-time fiction — the runtime value is unvalidated. If appearance.get() ever rejects (e.g. IPC timeout, main-process error), the unhandled rejection is silently swallowed. Use safeParse and add .catch:

Suggested change
useEffect(() => {
window.api.appearance.get().then((result: { success: boolean; data?: AppearanceConfig }) => {
if (result.success && result.data) {
setAppearance(result.data);
}
});
window.api.appearance
.get()
.then((raw: unknown) => {
const result = AppearanceConfigSchema.safeParse(
(raw as { success?: boolean; data?: unknown } | undefined)?.data,
);
if (result.success) {
setAppearance(result.data);
}
})
.catch(() => {
// persist defaults on failure
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Already fixed in commit 046732aget() response is now parsed through AppearanceConfigSchema.safeParse(result.data) with a .catch() fallback. See useAppearance.ts:63-71.

Comment on lines +678 to +698
ipcMain.handle(
"appearance:set",
async (_, appearance: AppearanceConfig): Promise<IpcResponse<void>> => {
try {
const currentConfig = getConfig();
getStore().set("config", { ...currentConfig, appearance });

// Broadcast to all renderer windows
for (const w of BrowserWindow.getAllWindows()) {
w.webContents.send("appearance:changed", appearance);
}

return { success: true, data: undefined };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
},
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Missing boundary validation in appearance:set

The handler trusts the renderer-supplied appearance object without parsing it through AppearanceConfigSchema. Per project guidelines ("validate at boundaries"), an invalid or extra field (e.g. an injected __proto__ key or a future incompatible preset string) would be written verbatim to the electron-store. A safeParse guard makes the boundary explicit:

Suggested change
ipcMain.handle(
"appearance:set",
async (_, appearance: AppearanceConfig): Promise<IpcResponse<void>> => {
try {
const currentConfig = getConfig();
getStore().set("config", { ...currentConfig, appearance });
// Broadcast to all renderer windows
for (const w of BrowserWindow.getAllWindows()) {
w.webContents.send("appearance:changed", appearance);
}
return { success: true, data: undefined };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
},
);
ipcMain.handle(
"appearance:set",
async (_, raw: unknown): Promise<IpcResponse<void>> => {
try {
const parsed = AppearanceConfigSchema.safeParse(raw);
if (!parsed.success) {
return { success: false, error: "Invalid appearance config" };
}
const appearance = parsed.data;
const currentConfig = getConfig();
getStore().set("config", { ...currentConfig, appearance });
// Broadcast to all renderer windows
for (const w of BrowserWindow.getAllWindows()) {
w.webContents.send("appearance:changed", appearance);
}
return { success: true, data: undefined };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
},
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Already fixed in commit 046732a — the handler now uses AppearanceConfigSchema.safeParse(rawAppearance) and returns an error on invalid config. See settings.ipc.ts:683-686.

Comment on lines +8 to +11
function hexToRgbTriplet(hex: string): string {
const h = hex.replace("#", "");
const n = parseInt(h, 16);
return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 hexToRgbTriplet silently produces garbage for non-6-digit hex

The function relies on parseInt(h, 16) treating the full 6-char string as one 24-bit integer. If accentColor is ever a 3-digit shorthand (#abc) — possible if someone hand-edits the electron-store — the bit-shift arithmetic yields wrong channel values (e.g. #abc0 10 188 instead of 170 187 204). Adding a guard makes the contract explicit:

Suggested change
function hexToRgbTriplet(hex: string): string {
const h = hex.replace("#", "");
const n = parseInt(h, 16);
return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`;
function hexToRgbTriplet(hex: string): string {
const h = hex.replace("#", "");
// Normalise 3-digit shorthand to 6 digits
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
const n = parseInt(full, 16);
return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Already fixed in commit 93923dahexToRgbTriplet now validates with /^[0-9a-fA-F]{6}$/ and falls back to blue on invalid input. Additionally, the Zod schema now enforces /^#[0-9a-fA-F]{6}$/ so invalid hex never reaches this function. See useAppearance.ts:10-11 and types.ts:367-371.

- Fetch and cache send-as aliases per account (1h TTL)
- From selector in compose UI (only shown with 2+ aliases)
- Smart reply default: auto-selects alias the email was sent to
- Forward `from` through outbox, scheduled send, and local drafts
- DB migration v2: send_as_aliases table + from_address columns
devin-ai-integration[bot]

This comment was marked as resolved.

- Migration v2 ALTER TABLE now checks if table exists first
  (fresh DBs don't have tables yet when numbered migrations run)
- Added from_address column to base SCHEMA for fresh installs
- Guard getSendAsAliases call in case preload API isn't available
- Use AppearanceConfigSchema.safeParse() instead of type assertion
  on onChange listener and get() response
- Add .catch() on appearance.get() to handle rejections
- Validate input in appearance:set handler before writing to store
@itsfabioroma
Copy link
Copy Markdown
Contributor Author

Addressed Greptile's findings:

P1 — Zod validation at IPC boundary: Replaced data as AppearanceConfig type assertion with AppearanceConfigSchema.safeParse() in both appearance.get() response and onChange listener.

P2 — Unhandled rejection: Added .catch() on appearance.get() to keep defaults on failure.

P2 — Input validation on set handler: appearance:set now validates input with AppearanceConfigSchema.safeParse() before writing to electron-store, returning an error on invalid data.

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 9 additional findings in Devin Review.

Open in Devin Review

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Cancelling a scheduled message drops the from alias when creating Gmail draft

When a scheduled message is cancelled in scheduled-send.ipc.ts:175-185, it creates a fallback Gmail draft via client.createFullDraft(...) but does not pass row.from (the send-as alias the user selected). Since createFullDraft falls back to this.getSenderAddress(profile.emailAddress) when from is omitted (src/main/services/gmail-client.ts:1157-1159), the draft is created with the primary account address instead of the alias the user originally chose.

(Refers to lines 175-185)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/renderer/hooks/useComposeForm.ts Outdated
Comment on lines +148 to +158
if (replyInfo) {
const allRecipients = [...(replyInfo.to || []), ...(replyInfo.cc || [])].map((addr) =>
extractBareEmail(addr).toLowerCase(),
);
const matchingAlias = result.data.find((a) =>
allRecipients.includes(a.email.toLowerCase()),
);
if (matchingAlias) {
setFrom(matchingAlias.email);
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Smart reply alias auto-selection checks reply recipients instead of original email recipients

The smart-reply-default logic in useComposeForm.ts:148-158 tries to auto-select the send-as alias that the original email was addressed to. It checks replyInfo.to and replyInfo.cc to find a matching alias. However, replyInfo.to contains the reply recipient (the original sender), and replyInfo.cc contains other recipients minus the current user — the user's own alias address is explicitly excluded when building the reply info (see src/main/ipc/compose.ipc.ts:117-133 and src/renderer/components/EmailDetail.tsx:computeLocalReplyInfo). Since the user's own email is never present in replyInfo.to or replyInfo.cc, the matchingAlias will always be undefined, and the code silently falls through to the default alias. The feature as written can never trigger.

Prompt for agents
The smart-reply alias auto-selection logic in useComposeForm.ts (lines 148-158) checks replyInfo.to and replyInfo.cc for a matching alias. But those fields contain the REPLY recipients, not the ORIGINAL email's recipients. The user's own email is explicitly excluded when building ReplyInfo (see extractReplyInfo in compose.ipc.ts and computeLocalReplyInfo in EmailDetail.tsx).

To fix this, the code needs access to the original email's To and CC headers (before they're transformed into reply recipients). Options:
1. Add a field to ReplyInfo (e.g. originalRecipients: string[]) that preserves the raw To+CC from the original email, so the hook can match aliases against them.
2. Pass the original email's To/CC addresses through a separate prop to useComposeForm.

The key insight: the alias the user received the email on is in the original email's To/CC, not in the computed reply headers.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@itsfabioroma
Copy link
Copy Markdown
Contributor Author

Fixed Greptile's P2: hexToRgbTriplet now validates the hex string is exactly 6 hex chars, falling back to default blue (#2563eb) on invalid input.

@ankitvgupta
Copy link
Copy Markdown
Owner

Nice! To speed up review please install gstack and run /review and /qa on the PR. You can also use the /reviewloop skill on this repo to satisfy the review bots. would recommend also running /design-review to see if there are any obvious design considerations

Will get this in asap

Comment thread src/main/db/index.ts Outdated

// ============================================
// Send-as alias operations
// ============================================
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

let's remove all this from this PR. seems unrelated

…lor validation

- bg-gray-100 in dark mode now maps to --bg-elevated (fixes 47 elements)
- color picker uses onInput for live preview, onChange for IPC persist
- accentColor schema validates hex format
- use schema defaults instead of hardcoded fallback
- merge duplicate scrollbar-thumb CSS rules
@itsfabioroma
Copy link
Copy Markdown
Contributor Author

Review fixes pushed

Unrelated code removed

Removed all send-as alias changes (db migration, schema, compose IPC, FromSelector, useComposeForm, gmail-client, outbox, scheduled-send) — that code belonged in PR #61 and leaked into this branch.

Issues found & fixed (code review + 5 specialist passes + adversarial review)

Critical (fixed):

  1. Dark-mode bg-gray-100 override.bg-gray-100 { !important } was defeating dark:bg-gray-700 across 47 elements (buttons, tags, kbd hints, inputs). Added .dark .bg-gray-100 override mapping to --bg-elevated.
  2. Color picker IPC flooding<input type="color" onChange> fired on every drag pixel, triggering Zustand re-render + IPC round-trip + disk write per pixel. Split into onInput (live CSS preview) and onChange (IPC persist on picker close).

Informational (fixed):
3. accentColor Zod schema now validates #RRGGBB hex format — prevents arbitrary strings persisting in electron-store
4. Merged duplicate ::-webkit-scrollbar-thumb CSS rules
5. appearance:get default now uses AppearanceConfigSchema.parse({}) instead of hardcoded object

Greptile comments

All 4 were already addressed in prior commits (046732a, 93923da). Replied to each with evidence.

Devin comments

  • RED (bg-gray-100 dark mode) — fixed above
  • 2 YELLOW (scheduled-send, useComposeForm) — no longer in PR after send-as removal

QA / Design review

Tested manually in the running Electron app (demo mode). Browse-based QA can't connect to the Electron renderer — the preload/IPC bridge only exists inside BrowserWindow.

Verified:

  • All 5 theme presets apply correctly in light and dark mode
  • Accent color swatches and custom picker work, changes persist across restart
  • Dark-mode elevated surfaces (buttons, tags, inputs) are visually distinct from page background after the fix
  • Semantic colors (priority badges, error states) are unaffected by theme changes
  • Color picker drag is smooth without lag

Checks

  • tsc --noEmit
  • eslint
  • prettier

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 15 additional findings in Devin Review.

Open in Devin Review

Comment on lines +63 to +65
.bg-white {
background-color: rgb(var(--bg-surface)) !important;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Global .bg-white CSS override with !important breaks literal-white elements (toggle knobs, unread dots, banner buttons) on non-default themes

The new CSS override .bg-white { background-color: rgb(var(--bg-surface)) !important; } at src/renderer/styles/index.css:63 replaces ALL uses of Tailwind's bg-white class with the theme's surface color. While this is correct for surface backgrounds (cards, panels), it breaks elements that need literal white regardless of theme.

Affected components include:

  • Toggle switch knobs (SettingsPanel.tsx:1169, :1419, :1448, SetupWizard.tsx:532) — the white circle becomes the dark surface color, making it nearly invisible against the toggle track in dark non-default themes
  • Unread indicator dots when selected (EmailRow.tsx:179, :183, App.tsx:116) — the white dot on the accent-colored selection row becomes dark, invisible
  • UpdateBanner buttons and progress bar (UpdateBanner.tsx:73, :121, :145) — white button/bar on colored banners becomes the surface color

Because !important in a stylesheet beats even inline styles, and this selector matches globally, every bg-white usage is affected. For example, with the Midnight dark theme, --bg-surface resolves to rgb(30 41 59) — toggle knobs become a dark circle on a dark track, effectively disappearing. The user would see these broken toggles on the same settings page where they select a theme.

Prompt for agents
The root cause is that .bg-white is used for two semantically different purposes in the codebase: (1) surface backgrounds like cards, panels, and sidebars — which should respect the theme, and (2) literal white elements like toggle knobs, unread dots on selected rows, and buttons on colored banners — which must stay white regardless of theme.

The blanket .bg-white override in src/renderer/styles/index.css:63-65 treats all uses identically. Possible approaches to fix:

1. Remove the global .bg-white override and instead add a custom utility class like .bg-surface (using Tailwind's @layer utilities or a plugin) and replace bg-white with bg-surface in components where surface-color semantics are intended. This is the cleanest approach but requires touching many component files.

2. Keep the override but add an escape hatch: create a class like .bg-literal-white (background-color: #fff !important) and apply it to toggle knobs, unread dots, and banner elements that need actual white. This is quick but fragile.

3. Target the .bg-white override more narrowly using compound selectors that only match panel/card/container contexts (e.g., only when paired with dark:bg-gray-800), avoiding standalone bg-white uses. This is moderately clean but relies on usage patterns staying consistent.

Affected files for literal-white usage: EmailRow.tsx (lines 179, 183), App.tsx (line 116), SettingsPanel.tsx (lines 1169, 1419, 1448), SetupWizard.tsx (line 532), UpdateBanner.tsx (lines 73, 121, 145).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@ankitvgupta
Copy link
Copy Markdown
Owner

eh, this seems like it adds a bunch ton of bloat for not that much value. is this a critical feature for you?

@itsfabioroma
Copy link
Copy Markdown
Contributor Author

itsfabioroma commented Apr 6, 2026 via email

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.

2 participants