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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user