feat: draw tag colours in a colour-blind-safe palette when that setting is on

This commit is contained in:
Linus Rath
2026-10-31 14:52:39 +01:00
parent 4724c28f53
commit c6501ec886
10 changed files with 291 additions and 11 deletions
+3
View File
@@ -1393,6 +1393,9 @@
},
"font": {
"description_ios": "Atkinson Hyperlegible und Lexend sind so gestaltet, dass sich die Buchstaben gut unterscheiden; OpenDyslexic hilft manchen Menschen mit Legasthenie. Auf diesem Gerät gilt die Schrift für Nachrichten als reiner Text."
},
"cvd_palette": {
"description_mobile": "Verwendet Farben für Tags und Kalender, die bei Rot-Grün- oder Blau-Gelb-Schwäche unterscheidbar bleiben."
}
}
},
+3
View File
@@ -963,6 +963,9 @@
"updates": "Updates"
},
"accessibility": {
"cvd_palette": {
"description_mobile": "Uses tag and calendar colours that stay distinct with red-green or blue-yellow colour blindness."
},
"font": {
"description_ios": "Atkinson Hyperlegible and Lexend are designed to be easy to tell apart; OpenDyslexic helps some people with dyslexia. On this device the font is used for plain-text messages."
},
@@ -76,6 +76,7 @@ export function AccessibilitySettings() {
const update = useSettingsStore((s) => s.updateSetting);
const fontSize = useSettingsStore((s) => s.fontSize);
const appFont = useSettingsStore((s) => s.appFont);
const colorBlindPalette = useSettingsStore((s) => s.colorBlindPalette);
const animationsEnabled = useSettingsStore((s) => s.animationsEnabled);
const toastDuration = useSettingsStore((s) => s.toastDuration);
const sendConfirmation = useSettingsStore((s) => s.sendConfirmation);
@@ -170,6 +171,20 @@ export function AccessibilitySettings() {
</SettingItem>
</SettingsSection>
<SettingsSection title={t('settings.accessibility.sections.contrast', 'Colour and contrast')}>
<SettingItem
label={t('settings.accessibility.cvd_palette.label', 'Colour-blind-safe colours')}
description={t('settings.accessibility.cvd_palette.description_mobile', 'Uses tag and calendar colours that stay distinct with red-green or blue-yellow colour blindness.')}
noBorder
>
<ToggleSwitch
checked={colorBlindPalette}
onChange={(v) => update('colorBlindPalette', v)}
accessibilityLabel={t('settings.accessibility.cvd_palette.label', 'Colour-blind-safe colours')}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t('settings.accessibility.sections.motion', 'Motion')}>
<SettingItem
label={t('settings.accessibility.reduce_motion.label', 'Reduce motion')}
+6 -4
View File
@@ -113,7 +113,7 @@ export function KeywordSettings() {
/>
);
}
const palette = c.tags[kw.color];
const palette = (c.tagsAsPicked ?? c.tags)[kw.color];
return (
<View key={kw.id} style={styles.kwRow}>
<View style={[styles.kwDot, { backgroundColor: palette.dot }]} />
@@ -178,7 +178,7 @@ export function KeywordSettings() {
</Text>
{scan.found.map((kw) => (
<View key={kw.id} style={styles.kwRow}>
<View style={[styles.kwDot, { backgroundColor: c.tags[kw.color]?.dot ?? c.textMuted }]} />
<View style={[styles.kwDot, { backgroundColor: (c.tagsAsPicked ?? c.tags)[kw.color]?.dot ?? c.textMuted }]} />
<View style={{ flex: 1 }}>
<Text style={styles.kwLabel}>{kw.label}</Text>
<Text style={styles.kwId}>{kw.keyword} · {kw.count}</Text>
@@ -251,7 +251,7 @@ function SharedTagsSection() {
) : (
tags.map((kw) => (
<View key={kw.id} style={styles.kwRow}>
<View style={[styles.kwDot, { backgroundColor: c.tags[kw.color]?.dot ?? c.textMuted }]} />
<View style={[styles.kwDot, { backgroundColor: (c.tagsAsPicked ?? c.tags)[kw.color]?.dot ?? c.textMuted }]} />
<Text style={styles.kwLabel}>{kw.label}</Text>
<Text style={styles.kwId}>$label:{kw.id}</Text>
</View>
@@ -313,7 +313,9 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
<Text style={styles.formLabel}>{t('settings.keywords.color_field', "Color")}</Text>
<View style={styles.palette}>
{PALETTE_KEYS.map((key) => {
const p = c.tags[key];
// The colours as picked, also with colour-blind-safe colours on
// (webmail keyword-settings.tsx draws KEYWORD_PALETTE).
const p = (c.tagsAsPicked ?? c.tags)[key];
return (
<Pressable
key={key}
+89
View File
@@ -0,0 +1,89 @@
// Colour-blind-safe colours (webmail lib/cvd-palette.ts and its tests,
// cf4d02b49), and how the app's theme draws tags with the setting on.
import { describe, it, expect } from 'vitest';
import { CVD_COLORS, cvdColor, cvdHue, cvdTagPalette } from '../cvd-palette';
import { resolvePalette } from '../../theme/colors';
import { DARK_COLORS, LIGHT_COLORS } from '../../theme/tokens';
import { mergeWithDefaults } from '../../stores/settings-store';
describe('cvdHue', () => {
it('maps the default calendar and tag colours onto separate palette colours', () => {
expect(cvdHue('#ef4444')).toBe('vermillion');
expect(cvdHue('#f97316')).toBe('orange');
expect(cvdHue('#eab308')).toBe('yellow');
expect(cvdHue('#22c55e')).toBe('bluishGreen');
expect(cvdHue('#06b6d4')).toBe('skyBlue');
expect(cvdHue('#3b82f6')).toBe('blue');
expect(cvdHue('#a855f7')).toBe('reddishPurple');
});
it('keeps red and green apart, the pair red-green colour blindness merges', () => {
expect(cvdHue('#dc2626')).not.toBe(cvdHue('#16a34a'));
expect(cvdHue('#b91c1c')).toBe('vermillion');
expect(cvdHue('#15803d')).toBe('bluishGreen');
});
it('reads tag palette names, with their light and dark shades', () => {
expect(cvdHue('red')).toBe('vermillion');
expect(cvdHue('teal-light')).toBe('skyBlue');
expect(cvdHue('indigo-dark')).toBe('blue');
expect(cvdHue('gray')).toBe('grey');
});
it('reads short hex, rgb() and hsl()', () => {
expect(cvdHue('#f00')).toBe('vermillion');
expect(cvdHue('rgb(0, 128, 0)')).toBe('bluishGreen');
expect(cvdHue('hsl(220, 80%, 50%)')).toBe('blue');
});
it('turns greys grey and gives up on what it cannot read', () => {
expect(cvdHue('#777777')).toBe('grey');
expect(cvdHue('var(--color-primary)')).toBeNull();
expect(cvdHue('')).toBeNull();
expect(cvdHue(undefined)).toBeNull();
});
});
describe('cvdColor', () => {
it('draws a colour as it is while the setting is off', () => {
expect(cvdColor('#3b82f6', false)).toBe('#3b82f6');
});
it('draws the palette colour while it is on, and leaves unreadable colours alone', () => {
expect(cvdColor('#3b82f6', true)).toBe(CVD_COLORS.blue);
expect(cvdColor('var(--color-primary)', true)).toBe('var(--color-primary)');
});
});
describe('tags drawn colour-blind-safe', () => {
it('draws the dot in the palette colour, the fill as its tint, the label in the text colour', () => {
const tags = cvdTagPalette(DARK_COLORS.tags, '#fafafa');
expect(tags.red).toEqual({ dot: CVD_COLORS.vermillion, bg: 'rgba(213, 94, 0, 0.15)', text: '#fafafa' });
expect(tags.green.dot).toBe(CVD_COLORS.bluishGreen);
expect(tags.red.dot).not.toBe(tags.green.dot);
expect(tags.gray.dot).toBe(CVD_COLORS.grey);
});
it('switches every c.tags colour in the theme and keeps the picked ones for the picker', () => {
const plain = resolvePalette('dark', null);
expect(plain).toBe(DARK_COLORS);
expect(plain.tags.red.dot).toBe('#ef4444');
const safe = resolvePalette('dark', null, true);
expect(safe.tags.red.dot).toBe(CVD_COLORS.vermillion);
expect(safe.tags.red.text).toBe(DARK_COLORS.text);
expect(safe.tagsAsPicked).toBe(DARK_COLORS.tags);
// The rest of the theme is untouched, and the palette object is stable.
expect(safe.primary).toBe(DARK_COLORS.primary);
expect(resolvePalette('dark', null, true)).toBe(safe);
const light = resolvePalette('light', 'builtin-roundcube-elastic', true);
expect(light.tags.blue.dot).toBe(CVD_COLORS.blue);
expect(light.tagsAsPicked).toBe(LIGHT_COLORS.tags);
});
it('is off by default and kept when stored', () => {
expect(mergeWithDefaults({}).colorBlindPalette).toBe(false);
expect(mergeWithDefaults({ colorBlindPalette: true }).colorBlindPalette).toBe(true);
});
});
+135
View File
@@ -0,0 +1,135 @@
/**
* The colour-blind-safe palette behind Settings > Accessibility >
* "Colour-blind-safe colours" (colorBlindPalette). A port of the webmail's
* lib/cvd-palette.ts (cf4d02b49).
*
* Tags and calendars keep the colours the user picked; this only changes how
* they are drawn. Every colour is moved to the nearest of the Okabe-Ito
* colours by hue: eight colours chosen to stay apart for protanopia,
* deuteranopia and tritanopia (Okabe & Ito, "Color Universal Design", 2008).
* Two picked colours of a similar hue (blue and indigo) can land on the same
* one; the names next to them still tell them apart.
*/
export const CVD_COLORS = {
vermillion: '#D55E00',
orange: '#E69F00',
yellow: '#F0E442',
bluishGreen: '#009E73',
skyBlue: '#56B4E9',
blue: '#0072B2',
reddishPurple: '#CC79A7',
grey: '#999999',
} as const;
export type CvdHue = keyof typeof CVD_COLORS;
/** The hues of the tag palette (theme/tokens TAGS), as Tailwind's 500 shades. */
const PALETTE_HUES: Record<string, string> = {
red: '#ef4444',
orange: '#f97316',
amber: '#f59e0b',
yellow: '#eab308',
lime: '#84cc16',
green: '#22c55e',
teal: '#14b8a6',
cyan: '#06b6d4',
blue: '#3b82f6',
indigo: '#6366f1',
purple: '#a855f7',
pink: '#ec4899',
gray: '#6b7280',
};
/** Hue (0-360) and saturation (0-1) of a colour, or null when it can't be read. */
function hueAndSaturation(color: string): { hue: number; saturation: number } | null {
let r: number, g: number, b: number;
const value = color.trim().toLowerCase();
const hex = /^#([0-9a-f]{3,8})$/.exec(value);
if (hex) {
let digits = hex[1];
if (digits.length === 3 || digits.length === 4) digits = digits.slice(0, 3).split('').map((d) => d + d).join('');
if (digits.length !== 6 && digits.length !== 8) return null;
r = parseInt(digits.slice(0, 2), 16);
g = parseInt(digits.slice(2, 4), 16);
b = parseInt(digits.slice(4, 6), 16);
} else {
const rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/.exec(value);
const hsl = /^hsla?\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%/.exec(value);
if (rgb) {
[r, g, b] = [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
} else if (hsl) {
return { hue: Number(hsl[1]) % 360, saturation: Number(hsl[2]) / 100 };
} else {
return null;
}
}
const [rn, gn, bn] = [r / 255, g / 255, b / 255];
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const delta = max - min;
const lightness = (max + min) / 2;
const saturation = delta === 0 ? 0 : delta / (1 - Math.abs(2 * lightness - 1));
let hue = 0;
if (delta !== 0) {
if (max === rn) hue = ((gn - bn) / delta) % 6;
else if (max === gn) hue = (bn - rn) / delta + 2;
else hue = (rn - gn) / delta + 4;
hue *= 60;
if (hue < 0) hue += 360;
}
return { hue, saturation };
}
/**
* The palette colour for `color`: a CSS colour (#hex, rgb(), hsl()) or a
* tag palette name ("red", "teal-light"). Null when it can't be read.
*/
export function cvdHue(color: string | null | undefined): CvdHue | null {
if (!color) return null;
const paletteName = color.replace(/-(light|dark)$/, '');
const parsed = hueAndSaturation(PALETTE_HUES[paletteName] ?? color);
if (!parsed) return null;
const { hue, saturation } = parsed;
if (saturation < 0.15) return 'grey';
if (hue < 18 || hue >= 345) return 'vermillion';
if (hue < 44) return 'orange';
if (hue < 70) return 'yellow';
if (hue < 165) return 'bluishGreen';
if (hue < 200) return 'skyBlue';
if (hue < 255) return 'blue';
return 'reddishPurple';
}
/**
* The colour to draw `color` in: itself, or its colour-blind-safe
* replacement when `enabled`. A colour it can't read is drawn as it is.
*/
export function cvdColor(color: string, enabled: boolean): string {
if (!enabled) return color;
const hue = cvdHue(color);
return hue ? CVD_COLORS[hue] : color;
}
/** `#RRGGBB` at `alpha`, as rgba() (React Native has no 8-digit-hex tint helper here). */
function withAlpha(hex: string, alpha: number): string {
const n = parseInt(hex.slice(1), 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
}
export interface TagSwatch { dot: string; bg: string; text: string }
/**
* A tag palette drawn colour-blind-safe, the way the webmail draws tags with
* the setting on (CVD_CLASSES): the dot in the palette colour, the fill as a
* 15 % tint of it, and the label in the text colour, since several of these
* colours are too light to read as text.
*/
export function cvdTagPalette<K extends string>(tags: Record<K, TagSwatch>, textColor: string): Record<K, TagSwatch> {
const out = {} as Record<K, TagSwatch>;
for (const key of Object.keys(tags) as K[]) {
const hex = CVD_COLORS[cvdHue(key) ?? cvdHue(tags[key].dot) ?? 'grey'];
out[key] = { dot: hex, bg: withAlpha(hex, hex === CVD_COLORS.yellow ? 0.2 : 0.15), text: textColor };
}
return out;
}
+2 -1
View File
@@ -67,6 +67,7 @@ export const SETTINGS_SEARCH_PATHS: Record<SettingsTabId, string[]> = {
'settings.accessibility.presets.motion',
'settings.accessibility.text_size',
'settings.accessibility.font',
'settings.accessibility.cvd_palette',
'settings.accessibility.reduce_motion',
'settings.accessibility.toast_duration',
'settings.accessibility.confirm_send',
@@ -269,7 +270,7 @@ export const SETTINGS_SEARCH_KEYWORDS: Record<SettingsTabId, string> = {
recovery: 'recover restore undelete deleted trash emptied lost messages',
language: 'locale region date time format translation',
notifications: 'alert push badge unifiedpush relay device reminder',
accessibility: 'a11y screen reader talkback voiceover low vision text size font bigger larger motion animation preset undo timeout duration confirm dyslexia dyslexic typeface atkinson lexend opendyslexic',
accessibility: 'a11y screen reader talkback voiceover low vision text size font bigger larger motion animation preset undo timeout duration confirm dyslexia dyslexic typeface atkinson lexend opendyslexic colour color blind blindness palette',
appearance: 'theme dark light font size text color animation density',
layout: 'swipe gesture quick actions toolbar order sort unified folder count avatar',
reading: 'mark read preview thread conversation archive delete attachment open monospace mono font plain text receipt',
+4
View File
@@ -166,6 +166,9 @@ export interface PersistedSettings {
// settings.ts, same keys for export/import). The reading font for the
// app's text and plain-text mail (theme/reading-fonts).
appFont: AppFont;
// Tag and calendar colours drawn in the colour-blind-safe palette
// (lib/cvd-palette, theme/colors).
colorBlindPalette: boolean;
// Composing
autoSelectReplyIdentity: boolean;
@@ -369,6 +372,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
activeThemeId: null,
// a11y-reading:
appFont: 'default',
colorBlindPalette: false,
autoSelectReplyIdentity: false,
replyIdentityMatch: 'domain',
+28 -6
View File
@@ -1,8 +1,9 @@
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useColorScheme } from 'react-native';
import { useSettingsStore } from '../stores/settings-store';
import { LIGHT_COLORS, DARK_COLORS, type ThemePalette } from './tokens';
import { getBuiltinTheme } from './builtin-themes';
import { cvdColor, cvdTagPalette } from '../lib/cvd-palette';
export type { ThemePalette };
@@ -12,15 +13,24 @@ const paletteCache = new Map<string, ThemePalette>();
* Base light/dark palette with the active built-in theme's overrides applied
* (Settings → Themes). Memoised per (scheme, theme) pair so `useColors()`
* keeps returning a stable object for `useMemo(() => makeStyles(c), [c])`.
*
* With "Colour-blind-safe colours" (Settings → Accessibility, webmail
* colorBlindPalette) the tag colours are drawn in that palette
* (lib/cvd-palette): every place that draws a tag from `c.tags` follows,
* as the webmail's tag formatter does. The picked colours stay in
* `tagsAsPicked` for the colour picker and tag settings.
*/
export function resolvePalette(scheme: 'light' | 'dark', themeId: string | null): ThemePalette {
export function resolvePalette(scheme: 'light' | 'dark', themeId: string | null, colorBlind = false): ThemePalette {
const base = scheme === 'light' ? LIGHT_COLORS : DARK_COLORS;
const theme = getBuiltinTheme(themeId);
if (!theme) return base;
const key = `${scheme}:${theme.id}`;
if (!theme && !colorBlind) return base;
const key = `${scheme}:${theme?.id ?? ''}:${colorBlind ? 'cvd' : ''}`;
const cached = paletteCache.get(key);
if (cached) return cached;
const merged: ThemePalette = { ...base, ...(scheme === 'light' ? theme.light : theme.dark) };
let merged: ThemePalette = theme ? { ...base, ...(scheme === 'light' ? theme.light : theme.dark) } : base;
if (colorBlind) {
merged = { ...merged, tags: cvdTagPalette(merged.tags, merged.text), tagsAsPicked: merged.tags };
}
paletteCache.set(key, merged);
return merged;
}
@@ -36,12 +46,24 @@ export function resolvePalette(scheme: 'light' | 'dark', themeId: string | null)
export function useColors(): ThemePalette {
const themePref = useSettingsStore((s) => s.theme);
const activeThemeId = useSettingsStore((s) => s.activeThemeId);
const colorBlind = useSettingsStore((s) => s.colorBlindPalette);
const systemScheme = useColorScheme();
const resolved =
themePref === 'system'
? systemScheme === 'light' ? 'light' : 'dark'
: themePref;
return useMemo(() => resolvePalette(resolved, activeThemeId), [resolved, activeThemeId]);
return useMemo(() => resolvePalette(resolved, activeThemeId, colorBlind), [resolved, activeThemeId, colorBlind]);
}
/**
* Draws a colour the user picked (a calendar's, a tag's hex) in the
* colour-blind-safe palette while that setting is on, and as it is otherwise
* (webmail hooks/use-cvd-color.ts). For colours that don't come from
* `c.tags`, such as calendar colours from the server.
*/
export function useCvdColor(): (color: string) => string {
const enabled = useSettingsStore((s) => s.colorBlindPalette);
return useCallback((color: string) => cvdColor(color, enabled), [enabled]);
}
/**
+6
View File
@@ -74,6 +74,12 @@ export interface ThemePalette {
// Tags
tags: TagColors;
/**
* The tag colours as the user picks them, set while tags are drawn in the
* colour-blind-safe palette (theme/colors): the colour picker and the tag
* settings show the colours themselves, as in the webmail.
*/
tagsAsPicked?: TagColors;
// Navigation
navActive: string;