feat(accent): custom accent themes links, text selection, and focus rings
CI / Build & Quality Checks (push) Successful in 10m53s
CI / Trigger Desktop Build (push) Successful in 8s

The accent previously only overrode the folds Primary.* family; links kept the
hardcoded --tc-link blue, ::selection was browser-default, and focus rings were
neutral grey (Other.FocusRing). Now all three derive from the chosen base color:
- --tc-link → accent hex (messages, topics, URL previews)
- ::selection via an injected <style id="lotus-accent-style"> (accent bg +
  WCAG-contrasting text)
- Other.FocusRing → rgba(accent, 0.5)

Deliberately NOT recolored: Secondary.* (doubles as the neutral text/button/
badge palette), Success.* + mention pills (semantic mention/notification green),
scrollbar thumbs (folds styles them per-component; a global rule would only
half-apply). removeCustomAccent() clears everything — no residue when switching
off or to the TDS theme. +2 unit tests (561 total).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 16:44:26 -04:00
parent e31b84c08e
commit c1efa7b94e
2 changed files with 85 additions and 1 deletions
+21
View File
@@ -9,6 +9,8 @@ import {
contrastingText, contrastingText,
varNameFromToken, varNameFromToken,
derivePrimaryPalette, derivePrimaryPalette,
deriveAccentExtras,
buildAccentCss,
} from './accentColor'; } from './accentColor';
test('hexToRgb parses 6-digit hex (with/without #, trimmed)', () => { 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.MainHover, /^#[0-9a-f]{6}$/);
assert.match(palette.MainActive, /^#[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;\}/);
});
+64 -1
View File
@@ -74,6 +74,45 @@ const PRIMARY_TOKENS: Record<string, string> = {
OnContainer: color.Primary.OnContainer, 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. // Derive the 10 Primary sub-token values from a single chosen base color.
export const derivePrimaryPalette = (base: Rgb): Record<string, string> => { export const derivePrimaryPalette = (base: Rgb): Record<string, string> => {
const baseHex = rgbToHex(base); const baseHex = rgbToHex(base);
@@ -96,22 +135,46 @@ export const derivePrimaryPalette = (base: Rgb): Record<string, string> => {
}; };
// Apply a custom accent color by overriding the folds Primary CSS variables on // 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 => { export const applyCustomAccent = (hex: string): boolean => {
const base = hexToRgb(hex); const base = hexToRgb(hex);
if (!base) return false; if (!base) return false;
const palette = derivePrimaryPalette(base); const palette = derivePrimaryPalette(base);
Object.entries(PRIMARY_TOKENS).forEach(([key, token]) => { Object.entries(PRIMARY_TOKENS).forEach(([key, token]) => {
const varName = varNameFromToken(token); const varName = varNameFromToken(token);
if (varName) document.body.style.setProperty(varName, palette[key]); 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; return true;
}; };
// Remove all custom accent overrides, reverting to the active theme's defaults. // 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 => { export const removeCustomAccent = (): void => {
Object.values(PRIMARY_TOKENS).forEach((token) => { Object.values(PRIMARY_TOKENS).forEach((token) => {
const varName = varNameFromToken(token); const varName = varNameFromToken(token);
if (varName) document.body.style.removeProperty(varName); 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();
}; };