Files
crumbs/src/lib/utils/colors.test.ts
T
Frederic Cabassut a46387172c fix(theme): restore hover-wash contrast and correct doc claims from dark palette review
Separates --hover-wash from --border so the softened dark-mode border grey
no longer collapses hover/active tint washes to near-invisibility (measured
5% wash on --bg-surface: 4.7 -> 17.0 RGB distance). Light mode is unaffected
since --hover-wash falls back to var(--border) in :root.

Also fixes stale/incorrect claims surfaced by the final review: the
checkbox rule and missing CSS variables in CLAUDE.md, the "warm parchment"
description of dark mode in FEATURES.md, the chip contrast claim in the
palette design doc (chips are 3:1 UI, not 4:1 text), and adds a measured
lightness-floor test guarding against chips regressing to card lightness.
2026-07-31 12:03:39 +02:00

145 lines
5.4 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
getNoteColor,
getNoteChip,
NOTE_COLORS,
NOTE_COLORS_DARK,
NOTE_CHIPS_DARK
} from '$lib/utils/colors.js';
import type { NoteColor } from '$lib/types/index.js';
describe('getNoteColor', () => {
it('returns light color when isDark is false', () => {
expect(getNoteColor('default', false)).toBe('#faf5eb');
expect(getNoteColor('coral', false)).toBe('#faafa8');
});
it('returns dark color when isDark is true', () => {
expect(getNoteColor('default', true)).toBe('#21201f');
expect(getNoteColor('coral', true)).toBe('#3f1f1c');
});
it('returns default color for unknown color name', () => {
expect(getNoteColor('unknown' as any, false)).toBe('#faf5eb');
expect(getNoteColor('unknown' as any, true)).toBe('#21201f');
});
it('has matching keys in light and dark color maps', () => {
const lightKeys = Object.keys(NOTE_COLORS).sort();
const darkKeys = Object.keys(NOTE_COLORS_DARK).sort();
expect(lightKeys).toEqual(darkKeys);
});
});
function toRgb(hex: string): [number, number, number] {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
/** WCAG relative luminance. */
function relativeLuminance(hex: string): number {
const channels = toRgb(hex).map((v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrastRatio(a: string, b: string): number {
const [l1, l2] = [relativeLuminance(a), relativeLuminance(b)];
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function rgbDistance(a: string, b: string): number {
const [ra, ga, ba] = toRgb(a);
const [rb, gb, bb] = toRgb(b);
return Math.hypot(ra - rb, ga - gb, ba - bb);
}
describe('dark note colours', () => {
// Must track [data-theme="dark"] in src/app.css.
const DARK_TEXT = '#ece3d3';
const DARK_TEXT_MUTED = '#b3a695';
it('keeps body text above the AA threshold on every dark card', () => {
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
expect(contrastRatio(bg, DARK_TEXT), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
}
});
it('keeps muted text above the AA threshold on every dark card', () => {
// The shipped dark theme failed this at 3.77:1 with its previous muted
// colour (#9a8e7e). This guard exists so a future palette edit cannot
// reintroduce it — it does not discriminate the old surfaces, which were
// darker and so scored higher against the lifted muted colour.
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
expect(contrastRatio(bg, DARK_TEXT_MUTED), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
}
});
it('keeps every pair of dark surfaces distinguishable', () => {
// The old palette failed this: fog and storm sat 7.55 apart, so the twelve
// colours read as one dark smudge. Threshold 8 clears that while leaving
// headroom above the current closest pair, coral/peach at 12.0.
const entries = Object.entries(NOTE_COLORS_DARK);
for (let i = 0; i < entries.length; i++) {
for (let j = i + 1; j < entries.length; j++) {
const [[nameA, a], [nameB, b]] = [entries[i], entries[j]];
const distance = rgbDistance(a.bg, b.bg);
expect(distance, `${nameA} vs ${nameB}`).toBeGreaterThan(8);
}
}
});
});
describe('getNoteChip', () => {
it('returns the card colour in light mode, where pastels already are the cards', () => {
for (const color of Object.keys(NOTE_COLORS) as NoteColor[]) {
expect(getNoteChip(color, false)).toBe(getNoteColor(color, false));
}
});
it('returns a distinct vivid chip in dark mode, not the card surface', () => {
for (const color of Object.keys(NOTE_COLORS_DARK) as NoteColor[]) {
expect(getNoteChip(color, true)).not.toBe(getNoteColor(color, true));
}
});
it('falls back to default for an unknown colour', () => {
expect(getNoteChip('unknown' as any, true)).toBe(NOTE_CHIPS_DARK.default);
});
it('has matching keys with the colour maps', () => {
expect(Object.keys(NOTE_CHIPS_DARK).sort()).toEqual(Object.keys(NOTE_COLORS_DARK).sort());
});
it('keeps every pair of chips far enough apart to tell apart at 28px', () => {
// The regression this guards: at low chroma coral/peach/blossom and
// fog/storm collapse into each other.
const entries = Object.entries(NOTE_CHIPS_DARK);
for (let i = 0; i < entries.length; i++) {
for (let j = i + 1; j < entries.length; j++) {
const [[nameA, a], [nameB, b]] = [entries[i], entries[j]];
const distance = rgbDistance(a, b);
expect(distance, `${nameA} vs ${nameB}`).toBeGreaterThan(25);
}
}
});
it('keeps every chip lighter than every card, so chips read as vivid dots', () => {
// A chip set to card-lightness values could still pass the pairwise
// distance test above (25 apart) while being just as dark as the cards,
// reintroducing the "twelve grey dots" bug this palette fixed. Measured:
// the lightest card (mint) has luminance 0.0392; the darkest chip (coral)
// has luminance 0.1941 — almost 5x higher, comfortable headroom either
// side of the 0.1 threshold below.
const cardLuminances = Object.values(NOTE_COLORS_DARK).map(({ bg }) => relativeLuminance(bg));
const maxCardLuminance = Math.max(...cardLuminances);
expect(maxCardLuminance).toBeLessThan(0.1);
for (const [name, chip] of Object.entries(NOTE_CHIPS_DARK)) {
expect(relativeLuminance(chip), name).toBeGreaterThan(0.1);
}
});
});