diff --git a/src/app/utils/accentColor.test.ts b/src/app/utils/accentColor.test.ts index 7303a0855..1c2abeb45 100644 --- a/src/app/utils/accentColor.test.ts +++ b/src/app/utils/accentColor.test.ts @@ -9,6 +9,8 @@ import { contrastingText, varNameFromToken, derivePrimaryPalette, + deriveAccentExtras, + buildAccentCss, } from './accentColor'; test('hexToRgb parses 6-digit hex (with/without #, trimmed)', () => { @@ -66,3 +68,22 @@ test('derivePrimaryPalette produces the full Primary token set', () => { assert.match(palette.MainHover, /^#[0-9a-f]{6}$/); assert.match(palette.MainActive, /^#[0-9a-f]{6}$/); }); + +test('deriveAccentExtras derives focus ring, link and selection from one base', () => { + const base = { r: 255, g: 136, b: 0 }; + const extras = deriveAccentExtras(base); + // focus ring keeps the translucent character in the accent hue + assert.equal(extras.focusRing, 'rgba(255, 136, 0, 0.5)'); + // link + selection background are the solid base hex + assert.equal(extras.link, '#ff8800'); + assert.equal(extras.selectionBg, '#ff8800'); + // selection text is WCAG-aware contrasting text over the base + assert.equal(extras.selectionText, contrastingText(base)); +}); + +test('buildAccentCss emits selection rules using the derived palette', () => { + const base = { r: 0, g: 0, b: 0 }; + const css = buildAccentCss(base); + assert.match(css, /::selection\{background:#000000;color:#fff;\}/); + assert.match(css, /::-moz-selection\{background:#000000;color:#fff;\}/); +}); diff --git a/src/app/utils/accentColor.ts b/src/app/utils/accentColor.ts index 9603b0fbf..d4981fc00 100644 --- a/src/app/utils/accentColor.ts +++ b/src/app/utils/accentColor.ts @@ -74,6 +74,45 @@ const PRIMARY_TOKENS: Record = { 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); @@ -96,22 +135,46 @@ export const derivePrimaryPalette = (base: Rgb): Record => { }; // 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. +// `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(); };