2026-06-28 14:22:08 -04:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-01 16:44:26 -04:00
|
|
|
// The neutral focus-ring token folds uses for the outline on inputs, buttons,
|
|
|
|
|
// switches, checkboxes and radios. Its default is a semi-transparent grey/black,
|
|
|
|
|
// so tinting it in the accent hue themes every focus ring without touching the
|
|
|
|
|
// neutral Secondary family (see below). We keep the same translucent character
|
|
|
|
|
// so it reads as a ring rather than a fill.
|
|
|
|
|
const FOCUS_RING_TOKEN = color.Other.FocusRing;
|
|
|
|
|
|
|
|
|
|
// `--tc-link` is the global anchor color (index.css `a { color: var(--tc-link) }`);
|
|
|
|
|
// overriding it themes plain links inside messages, room topics and URL previews.
|
|
|
|
|
const LINK_VAR = '--tc-link';
|
|
|
|
|
|
|
|
|
|
// Injected stylesheet id — carries rules that cannot be expressed as a single
|
|
|
|
|
// CSS variable (currently text ::selection).
|
|
|
|
|
const ACCENT_STYLE_ID = 'lotus-accent-style';
|
|
|
|
|
|
|
|
|
|
export type AccentExtras = {
|
|
|
|
|
focusRing: string;
|
|
|
|
|
link: string;
|
|
|
|
|
selectionBg: string;
|
|
|
|
|
selectionText: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Derive the extra (non-Primary) accent values from the single base color, using
|
|
|
|
|
// the same helpers as the Primary palette so everything stays in one hue.
|
|
|
|
|
export const deriveAccentExtras = (base: Rgb): AccentExtras => ({
|
|
|
|
|
focusRing: rgba(base, 0.5),
|
|
|
|
|
link: rgbToHex(base),
|
|
|
|
|
selectionBg: rgbToHex(base),
|
|
|
|
|
selectionText: contrastingText(base),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Build the injected stylesheet body. Selection uses a solid accent fill with
|
|
|
|
|
// WCAG-aware contrasting text so highlighted text stays readable.
|
|
|
|
|
export const buildAccentCss = (base: Rgb): string => {
|
|
|
|
|
const { selectionBg, selectionText } = deriveAccentExtras(base);
|
|
|
|
|
const selection = `background:${selectionBg};color:${selectionText};`;
|
|
|
|
|
return `::selection{${selection}}::-moz-selection{${selection}}`;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-28 14:22:08 -04:00
|
|
|
// 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
|
2026-07-01 16:44:26 -04:00
|
|
|
// `document.body`, tinting the focus-ring and link vars, and injecting a small
|
|
|
|
|
// stylesheet for text selection. Returns true when applied, false when the input
|
|
|
|
|
// is invalid.
|
2026-06-28 14:22:08 -04:00
|
|
|
export const applyCustomAccent = (hex: string): boolean => {
|
|
|
|
|
const base = hexToRgb(hex);
|
|
|
|
|
if (!base) return false;
|
2026-07-01 16:44:26 -04:00
|
|
|
|
2026-06-28 14:22:08 -04:00
|
|
|
const palette = derivePrimaryPalette(base);
|
|
|
|
|
Object.entries(PRIMARY_TOKENS).forEach(([key, token]) => {
|
|
|
|
|
const varName = varNameFromToken(token);
|
|
|
|
|
if (varName) document.body.style.setProperty(varName, palette[key]);
|
|
|
|
|
});
|
2026-07-01 16:44:26 -04:00
|
|
|
|
|
|
|
|
const extras = deriveAccentExtras(base);
|
|
|
|
|
const focusRingVar = varNameFromToken(FOCUS_RING_TOKEN);
|
|
|
|
|
if (focusRingVar) document.body.style.setProperty(focusRingVar, extras.focusRing);
|
|
|
|
|
document.body.style.setProperty(LINK_VAR, extras.link);
|
|
|
|
|
|
|
|
|
|
let styleEl = document.getElementById(ACCENT_STYLE_ID) as HTMLStyleElement | null;
|
|
|
|
|
if (!styleEl) {
|
|
|
|
|
styleEl = document.createElement('style');
|
|
|
|
|
styleEl.id = ACCENT_STYLE_ID;
|
|
|
|
|
document.head.appendChild(styleEl);
|
|
|
|
|
}
|
|
|
|
|
styleEl.textContent = buildAccentCss(base);
|
|
|
|
|
|
2026-06-28 14:22:08 -04:00
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Remove all custom accent overrides, reverting to the active theme's defaults.
|
2026-07-01 16:44:26 -04:00
|
|
|
// Idempotent — safe to call even when nothing was applied.
|
2026-06-28 14:22:08 -04:00
|
|
|
export const removeCustomAccent = (): void => {
|
|
|
|
|
Object.values(PRIMARY_TOKENS).forEach((token) => {
|
|
|
|
|
const varName = varNameFromToken(token);
|
|
|
|
|
if (varName) document.body.style.removeProperty(varName);
|
|
|
|
|
});
|
2026-07-01 16:44:26 -04:00
|
|
|
|
|
|
|
|
const focusRingVar = varNameFromToken(FOCUS_RING_TOKEN);
|
|
|
|
|
if (focusRingVar) document.body.style.removeProperty(focusRingVar);
|
|
|
|
|
document.body.style.removeProperty(LINK_VAR);
|
|
|
|
|
|
|
|
|
|
document.getElementById(ACCENT_STYLE_ID)?.remove();
|
2026-06-28 14:22:08 -04:00
|
|
|
};
|