From 3c4842df1e0915a7624cdd51c450af447294cb83 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 28 Jun 2026 14:22:08 -0400 Subject: [PATCH] feat(settings): custom accent color picker for non-TDS themes (P5-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a customAccentColor setting + a HexColorPickerPopOut in Settings → Appearance. When set (and Lotus Terminal/TDS is OFF), it derives a full folds Primary palette (Main/hover/active/line, contrasting OnMain, alpha-tiered Container set, OnContainer) from the chosen color and overrides the folds Primary CSS variables on document.body — resolving each var name from the imported folds color.Primary.* token strings (e.g. "var(--oq6d07f)"), the same body-level injection pattern used for mentionHighlightColor. The theme class is on document.body, so an inline override on body wins over it. Reverts to theme defaults when unset or when Lotus Terminal is enabled (TDS keeps its fixed palette); the picker is disabled with a note in TDS mode. Co-Authored-By: Claude Opus 4.8 --- src/app/features/settings/general/General.tsx | 50 ++++++++ src/app/pages/App.tsx | 12 ++ src/app/state/settings.ts | 2 + src/app/utils/accentColor.ts | 117 ++++++++++++++++++ 4 files changed, 181 insertions(+) create mode 100644 src/app/utils/accentColor.ts diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index b69c43bce..0a0a9babe 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -432,6 +432,7 @@ function Appearance() { settingsAtom, 'mentionHighlightColor', ); + const [customAccentColor, setCustomAccentColor] = useSetting(settingsAtom, 'customAccentColor'); const [fontFamily, setFontFamily] = useSetting(settingsAtom, 'fontFamily'); const [seasonalThemeOverride, setSeasonalThemeOverride] = useSetting( settingsAtom, @@ -684,6 +685,55 @@ function Appearance() { } /> + + + } + onRemove={customAccentColor ? () => setCustomAccentColor('') : undefined} + > + {(openPicker, opened) => ( + + )} + + } + /> + ); } diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index decae09a2..7f6973bd3 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -17,6 +17,7 @@ import { settingsAtom } from '../state/settings'; import { LotusToastContainer } from '../features/toast/LotusToastContainer'; import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge'; import { SeasonalEffect } from '../components/seasonal/SeasonalEffect'; +import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor'; const FONT_MAP: Record = { system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", @@ -51,6 +52,17 @@ function AppearanceEffects() { } }, [settings.mentionHighlightColor]); + useEffect(() => { + // Custom accent color applies only to non-TDS themes. When Lotus Terminal + // (TDS) is active it has its own fixed palette, so we remove any overrides. + const accent = settings.customAccentColor; + if (accent && !settings.lotusTerminal && applyCustomAccent(accent)) { + return () => removeCustomAccent(); + } + removeCustomAccent(); + return undefined; + }, [settings.customAccentColor, settings.lotusTerminal]); + useEffect(() => { const font = FONT_MAP[settings.fontFamily ?? 'inter'] ?? FONT_MAP.inter; document.body.style.setProperty('--font-secondary', font); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 4d07174c2..5db347673 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -146,6 +146,7 @@ export interface Settings { composerToolbarButtons: ComposerToolbarSettings; mentionHighlightColor: string; + customAccentColor: string; fontFamily: 'system' | 'inter' | 'jetbrains-mono' | 'fira-code'; afkAutoMute: boolean; @@ -242,6 +243,7 @@ const defaultSettings: Settings = { composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR, mentionHighlightColor: '', + customAccentColor: '', fontFamily: 'inter', afkAutoMute: false, diff --git a/src/app/utils/accentColor.ts b/src/app/utils/accentColor.ts new file mode 100644 index 000000000..9603b0fbf --- /dev/null +++ b/src/app/utils/accentColor.ts @@ -0,0 +1,117 @@ +import { color } from 'folds'; + +// Custom accent color support for non-TDS themes. The folds `Primary.*` tokens +// are imported as strings like "var(--oq6d07f)"; we extract the underlying CSS +// variable name at runtime and override it on `document.body`, mirroring the +// mention-highlight pattern in pages/App.tsx. When unset (or when the Lotus +// Terminal/TDS theme is active) the overrides are removed so the theme defaults +// take over again. + +export type Rgb = { r: number; g: number; b: number }; + +const clamp = (n: number): number => Math.max(0, Math.min(255, Math.round(n))); + +export const hexToRgb = (hex: string): Rgb | undefined => { + const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim()); + if (!m) return undefined; + const h = m[1]; + return { + r: parseInt(h.slice(0, 2), 16), + g: parseInt(h.slice(2, 4), 16), + b: parseInt(h.slice(4, 6), 16), + }; +}; + +const rgbToHex = ({ r, g, b }: Rgb): string => + `#${[clamp(r), clamp(g), clamp(b)].map((c) => c.toString(16).padStart(2, '0')).join('')}`; + +// Lighten/darken by moving each channel a percentage toward white/black. +export const lighten = ({ r, g, b }: Rgb, amount: number): Rgb => ({ + r: r + (255 - r) * amount, + g: g + (255 - g) * amount, + b: b + (255 - b) * amount, +}); + +export const darken = ({ r, g, b }: Rgb, amount: number): Rgb => ({ + r: r * (1 - amount), + g: g * (1 - amount), + b: b * (1 - amount), +}); + +export const rgba = ({ r, g, b }: Rgb, alpha: number): string => + `rgba(${clamp(r)}, ${clamp(g)}, ${clamp(b)}, ${alpha})`; + +// WCAG 2.1 relative luminance with gamma linearization (matches the mention +// highlight contrast logic in pages/App.tsx). +const toLinear = (c: number): number => { + const s = c / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; +}; + +export const relativeLuminance = ({ r, g, b }: Rgb): number => + 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); + +// Choose contrasting text color over the given base (threshold 0.179). +export const contrastingText = (rgb: Rgb): string => + relativeLuminance(rgb) > 0.179 ? '#000' : '#fff'; + +// Extract the underlying CSS variable name from a folds token string such as +// "var(--oq6d07f)" -> "--oq6d07f". +export const varNameFromToken = (token: string): string | undefined => + token.match(/var\((--[^)]+)\)/)?.[1]; + +// The folds Primary token family, keyed by sub-token name. +const PRIMARY_TOKENS: Record = { + Main: color.Primary.Main, + MainHover: color.Primary.MainHover, + MainActive: color.Primary.MainActive, + MainLine: color.Primary.MainLine, + OnMain: color.Primary.OnMain, + Container: color.Primary.Container, + ContainerHover: color.Primary.ContainerHover, + ContainerActive: color.Primary.ContainerActive, + ContainerLine: color.Primary.ContainerLine, + OnContainer: color.Primary.OnContainer, +}; + +// Derive the 10 Primary sub-token values from a single chosen base color. +export const derivePrimaryPalette = (base: Rgb): Record => { + const baseHex = rgbToHex(base); + // If the base is very light, darken OnContainer slightly so it stays readable + // against the (light, low-alpha) container backgrounds. + const onContainer = relativeLuminance(base) > 0.6 ? rgbToHex(darken(base, 0.25)) : baseHex; + + return { + Main: baseHex, + MainHover: rgbToHex(lighten(base, 0.08)), + MainActive: rgbToHex(darken(base, 0.08)), + MainLine: baseHex, + OnMain: contrastingText(base), + Container: rgba(base, 0.12), + ContainerHover: rgba(base, 0.16), + ContainerActive: rgba(base, 0.22), + ContainerLine: rgba(base, 0.4), + OnContainer: onContainer, + }; +}; + +// Apply a custom accent color by overriding the folds Primary CSS variables on +// `document.body`. Returns true when applied, false when the input is invalid. +export const applyCustomAccent = (hex: string): boolean => { + const base = hexToRgb(hex); + if (!base) return false; + const palette = derivePrimaryPalette(base); + Object.entries(PRIMARY_TOKENS).forEach(([key, token]) => { + const varName = varNameFromToken(token); + if (varName) document.body.style.setProperty(varName, palette[key]); + }); + return true; +}; + +// Remove all custom accent overrides, reverting to the active theme's defaults. +export const removeCustomAccent = (): void => { + Object.values(PRIMARY_TOKENS).forEach((token) => { + const varName = varNameFromToken(token); + if (varName) document.body.style.removeProperty(varName); + }); +};