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) => (
+
+ }
+ >
+ {customAccentColor ? 'Change' : 'Pick'}
+
+ )}
+
+ }
+ />
+
);
}
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);
+ });
+};