feat(settings): custom accent color picker for non-TDS themes (P5-1)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 14:22:08 -04:00
parent 1ee0f0b57a
commit 3c4842df1e
4 changed files with 181 additions and 0 deletions
@@ -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() {
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Custom Accent Color"
description={
lotusTerminal
? 'Only applies to non-TDS themes. Disable Lotus Terminal Mode to use a custom accent.'
: 'Recolors the app accent (buttons, active states, links, selected states). Leave empty to use the theme default.'
}
after={
<HexColorPickerPopOut
picker={
<HexColorPicker
color={customAccentColor || color.Primary.Main}
onChange={setCustomAccentColor}
/>
}
onRemove={customAccentColor ? () => setCustomAccentColor('') : undefined}
>
{(openPicker, opened) => (
<Button
type="button"
aria-pressed={opened}
onClick={openPicker}
disabled={lotusTerminal}
size="300"
variant="Secondary"
fill="Soft"
radii="300"
before={
<span
style={{
width: toRem(16),
height: toRem(16),
borderRadius: config.radii.R300,
background: customAccentColor || color.Primary.Main,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
display: 'inline-block',
flexShrink: 0,
}}
/>
}
>
<Text size="B300">{customAccentColor ? 'Change' : 'Pick'}</Text>
</Button>
)}
</HexColorPickerPopOut>
}
/>
</SequenceCard>
</Box>
);
}
+12
View File
@@ -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<string, string> = {
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);
+2
View File
@@ -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,
+117
View File
@@ -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<string, string> = {
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<string, string> => {
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);
});
};