feat: customizable appearance — theme presets + accent colors#60
feat: customizable appearance — theme presets + accent colors#60itsfabioroma wants to merge 8 commits into
Conversation
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 SummaryThis 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
Confidence Score: 4/5Safe 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
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
Reviews (1): Last reviewed commit: "feat: customizable appearance — theme pr..." | Re-trigger Greptile |
| window.api.appearance.onChange((data: Record<string, unknown>) => { | ||
| setAppearance(data as AppearanceConfig); | ||
| }); | ||
|
|
There was a problem hiding this comment.
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.
| 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); | |
| } | |
| }); |
There was a problem hiding this comment.
Already fixed in commit 046732a — onChange now uses AppearanceConfigSchema.safeParse(data) before calling setAppearance. See useAppearance.ts:74-76.
| useEffect(() => { | ||
| window.api.appearance.get().then((result: { success: boolean; data?: AppearanceConfig }) => { | ||
| if (result.success && result.data) { | ||
| setAppearance(result.data); | ||
| } | ||
| }); |
There was a problem hiding this comment.
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:
| 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 | |
| }); |
There was a problem hiding this comment.
Already fixed in commit 046732a — get() response is now parsed through AppearanceConfigSchema.safeParse(result.data) with a .catch() fallback. See useAppearance.ts:63-71.
| 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", | ||
| }; | ||
| } | ||
| }, | ||
| ); |
There was a problem hiding this comment.
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:
| 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", | |
| }; | |
| } | |
| }, | |
| ); |
There was a problem hiding this comment.
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.
| function hexToRgbTriplet(hex: string): string { | ||
| const h = hex.replace("#", ""); | ||
| const n = parseInt(h, 16); | ||
| return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`; |
There was a problem hiding this comment.
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. #abc → 0 10 188 instead of 170 187 204). Adding a guard makes the contract explicit:
| 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}`; | |
| } |
There was a problem hiding this comment.
Already fixed in commit 93923da — hexToRgbTriplet 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
- 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
|
Addressed Greptile's findings: P1 — Zod validation at IPC boundary: Replaced P2 — Unhandled rejection: Added P2 — Input validation on set handler: |
There was a problem hiding this comment.
🟡 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)
Was this helpful? React with 👍 or 👎 to provide feedback.
| 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; | ||
| } |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
|
Fixed Greptile's P2: |
|
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 |
|
|
||
| // ============================================ | ||
| // Send-as alias operations | ||
| // ============================================ |
There was a problem hiding this comment.
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
Review fixes pushedUnrelated code removedRemoved 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):
Informational (fixed): Greptile commentsAll 4 were already addressed in prior commits (046732a, 93923da). Replied to each with evidence. Devin comments
QA / Design reviewTested 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:
Checks
|
| .bg-white { | ||
| background-color: rgb(var(--bg-surface)) !important; | ||
| } |
There was a problem hiding this comment.
🔴 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).
Was this helpful? React with 👍 or 👎 to provide feedback.
|
eh, this seems like it adds a bunch ton of bloat for not that much value. is this a critical feature for you? |
|
Maybe we can think deeper together here. I miss visual taste + the ability to personalize the email client to feel as if it was custom made to me.
I tend to develop an emotional relationship with products I use everyday which allows me to customize and I think most people also do.
Appearance is a great way to grow this seed. It visually reinforces the feeling of owning your own stack. I wanna see a billion people using this app and I think that's the way to go
Sent via Superhuman ( https://superhuman.com )
…On Mon, 6 Apr 2026 at 19:25, Ankit Gupta < ***@***.*** > wrote:
*ankitvgupta* left a comment (ankitvgupta/exo#60) (
#60 (comment) )
eh, this seems like it adds a bunch ton of bloat for not that much value.
is this a critical feature for you?
—
Reply to this email directly, view it on GitHub (
#60 (comment) ) ,
or unsubscribe (
https://github.com/notifications/unsubscribe-auth/AWSVG3OWMOMGPK26VHJCML34UQVERAVCNFSM6AAAAACXNUC7UCVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHM2DCOJVGM2DGNBXGY
).
You are receiving this because you authored the thread. Message ID: <ankitvgupta/mail-app/pull/60/c4195343476
@ github. com>
|
Summary
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:
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:
Technical approach
A CSS custom property override layer (
!importantrules inindex.css) re-skins Tailwind's hardcodedbg-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
!importantoverrides — 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 swatchessrc/renderer/hooks/useAppearance.ts— hook that applies CSS variables to<html>based on configModified files
src/renderer/styles/index.css— CSS variable declarations + Tailwind color overridessrc/shared/types.ts—AppearanceConfigSchema(themePreset + accentColor)src/main/ipc/settings.ipc.ts—appearance:get/appearance:setIPC handlerssrc/preload/index.ts— appearance API bridgesrc/renderer/store/index.ts— appearance state + settersrc/renderer/App.tsx— wire upuseAppearance()hooksrc/renderer/components/SettingsPanel.tsx— expanded Appearance section with preset swatches, accent pickerTest plan