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, }; // 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}}`; }; // 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`, 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. 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]); }); 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); return true; }; // Remove all custom accent overrides, reverting to the active theme's defaults. // Idempotent — safe to call even when nothing was applied. export const removeCustomAccent = (): void => { Object.values(PRIMARY_TOKENS).forEach((token) => { const varName = varNameFromToken(token); if (varName) document.body.style.removeProperty(varName); }); 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(); };