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); + }); +};