Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/render/card/themes/high-contrast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { CardTheme } from './index.js';

// An accessibility-first palette rather than an aesthetic one: every text token
// clears WCAG AAA (7:1) against BOTH the page background and the stat panel, so
// the card stays readable on a projector, in sunlight, or with low vision.
//
// Measured contrast ratios (vs bg / vs panel):
// fg #ffffff 21.00 / 17.04
// muted #d0d0d0 13.62 / 11.05
// accent #ffd60a 14.88 / 12.07
//
// The amber accent is deliberate: it stays distinguishable under the common
// red-green colour-vision deficiencies, which a green or red accent would not.
export const highContrast: CardTheme = {
name: 'high-contrast',
bg: '#000000',
panel: '#1c1c1c',
fg: '#ffffff',
muted: '#d0d0d0',
accent: '#ffd60a',
};
2 changes: 2 additions & 0 deletions src/render/card/themes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { catppuccin } from './catppuccin.js';
import { dark } from './dark.js';
import { dracula } from './dracula.js';
import { gruvbox } from './gruvbox.js';
import { highContrast } from './high-contrast.js';
import { light } from './light.js';
import { nord } from './nord.js';
import { solarizedLight } from './solarized-light.js';
Expand All @@ -28,6 +29,7 @@ export const themes: Record<string, CardTheme> = {
dark,
dracula,
gruvbox,
'high-contrast': highContrast,
light,
nord,
'solarized-light': solarizedLight,
Expand Down
60 changes: 60 additions & 0 deletions test/theme-high-contrast.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';

import { themes } from '../src/render/card/themes/index.js';

/** WCAG 2.1 relative luminance of an `#rrggbb` colour. */
function luminance(hex: string): number {
const channels = [1, 3, 5]
.map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
}

/** WCAG 2.1 contrast ratio between two `#rrggbb` colours, 1:1 to 21:1. */
function contrastRatio(a: string, b: string): number {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
return (hi + 0.05) / (lo + 0.05);
}

const AAA = 7;

describe('high-contrast theme', () => {
it('is registered with its palette', () => {
const theme = themes['high-contrast'];
expect(theme?.name).toBe('high-contrast');
for (const value of Object.values(theme ?? {})) {
if (value !== 'high-contrast') expect(value).toMatch(/^#[0-9a-f]{6}$/);
}
});

it('clears WCAG AAA for every text token against the background and the panel', () => {
const theme = themes['high-contrast'];
expect(theme).toBeDefined();
if (!theme) return;

for (const token of ['fg', 'muted', 'accent'] as const) {
expect(contrastRatio(theme[token], theme.bg)).toBeGreaterThanOrEqual(AAA);
expect(contrastRatio(theme[token], theme.panel)).toBeGreaterThanOrEqual(AAA);
}
});

it('is the highest-contrast theme available', () => {
const theme = themes['high-contrast'];
expect(theme).toBeDefined();
if (!theme) return;

const own = contrastRatio(theme.fg, theme.bg);
for (const [name, other] of Object.entries(themes)) {
if (name === 'high-contrast') continue;
expect(own).toBeGreaterThan(contrastRatio(other.fg, other.bg));
}
});
});

describe('contrast helper', () => {
it('matches the WCAG reference values', () => {
expect(contrastRatio('#ffffff', '#000000')).toBeCloseTo(21, 5);
expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 5);
expect(contrastRatio('#777777', '#777777')).toBeCloseTo(1, 5);
});
});
Loading