Catppuccin, Gruvbox, Solarized and High contrast themes
Each in light and dark. The published palettes missed AA in 19 places, mostly Solarized and Catppuccin Latte, so each failing colour is moved the least distance, toward black or white, that clears every pair it is drawn in. Solarized dark's base0 had to rise to base1 to read on base02, so its dim sits between base2 and base1 to keep three steps of type. tests/contrast.js checks every palette against the pairs the page draws, and a border that matches the ground it sits on. Its first run caught Classic's links at 3.7:1 on the source list and Modern's faint at 4.3:1 on inputs; both are tuned. A failed toast moves to --panel, since the error colours are tuned for the page's grounds, not --raise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Four themes, each in light and dark: Catppuccin (Latte and Mocha), Gruvbox, Solarized, and High
|
||||
contrast, black and white with every colour at 7:1 or more.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Links in Classic, and hints and headings in Modern's dark half, are dark or light enough to
|
||||
read comfortably. A failed-action message is easier to read in every theme.
|
||||
|
||||
## [0.8.1] - 2026-09-19
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -100,6 +100,7 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
|
||||
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
|
||||
npx tsc -p . # type-checks web/src
|
||||
node tests/page-smoke.js
|
||||
node tests/contrast.js # every theme's palette against WCAG AA
|
||||
npx playwright test # 40 browser tests against a real daemon on fixture feeds
|
||||
```
|
||||
|
||||
|
||||
62
tests/contrast.js
Normal file
62
tests/contrast.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// Every theme's palette, checked against WCAG AA for the pairs the page actually draws. The
|
||||
// published palettes ipx borrows (Flat Remix, Paper, Adwaita...) fell short in places: an unread
|
||||
// count at 2.6:1, tags at 3.2:1. Each was tuned by hand; this keeps a new or edited theme honest.
|
||||
//
|
||||
// node tests/contrast.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const css = fs.readFileSync(path.join(__dirname, '../web/app.css'), 'utf8');
|
||||
const blocks = {};
|
||||
for (const m of css.matchAll(/^(:root(?:\[[^\]]+\])*)\s*\{([^}]*)\}/gm)) {
|
||||
const vars = {};
|
||||
for (const v of m[2].matchAll(/--(\w+):\s*(#[0-9a-f]{6})\b/gi)) vars[v[1]] = v[2].toLowerCase();
|
||||
if (Object.keys(vars).length) blocks[m[1]] = vars;
|
||||
}
|
||||
|
||||
const lum = h => {
|
||||
const c = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255)
|
||||
.map(c => c <= .03928 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
|
||||
return .2126 * c[0] + .7152 * c[1] + .0722 * c[2];
|
||||
};
|
||||
const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + .05) / (Math.min(x, y) + .05); };
|
||||
|
||||
// [colour, grounds it is drawn on, minimum]. 4.5 for text, 3 for an icon, dot or bar.
|
||||
const RULES = [
|
||||
['fg', ['bg', 'panel', 'panel2', 'raise'], 4.5],
|
||||
['dim', ['bg', 'panel', 'panel2'], 4.5], // metadata, labels
|
||||
['faint', ['bg', 'panel', 'panel2'], 4.5], // hints, column headings, the status bar
|
||||
['accent', ['bg', 'panel'], 4.5], // links, in the list and the reader
|
||||
['ink', ['accent'], 4.5], // a primary button's label
|
||||
['ink', ['accent2'], 4.5], // the unread count on its badge
|
||||
['accent2', ['bg', 'panel', 'raise'], 3], // the unread dot, the EQ bars, download bars
|
||||
['bad', ['bg', 'panel'], 4.5], // error text, the Error tag, a failed toast
|
||||
['warn', ['bg', 'panel'], 4.5], // the Gone tag, log warnings
|
||||
['good', ['bg', 'panel', 'panel2'], 3], // the downloaded and subscribed icons
|
||||
];
|
||||
|
||||
const base = blocks[':root'];
|
||||
const themes = {};
|
||||
for (const sel of Object.keys(blocks)) {
|
||||
const t = sel.match(/data-theme="(\w+)"/);
|
||||
if (!t) continue;
|
||||
const light = /data-mode="light"/.test(sel);
|
||||
// A theme's dark half is :root under its own block; its light half adds the light block.
|
||||
const name = `${t[1]}${light ? ' light' : ''}`;
|
||||
themes[name] = light
|
||||
? { ...base, ...blocks[`:root[data-theme="${t[1]}"]`], ...blocks[sel] }
|
||||
: { ...base, ...blocks[sel] };
|
||||
}
|
||||
themes['modern'] = base;
|
||||
|
||||
let bad = 0;
|
||||
for (const [name, p] of Object.entries(themes)) {
|
||||
for (const [fg, grounds, min] of RULES) for (const g of grounds) {
|
||||
const r = ratio(p[fg], p[g]);
|
||||
if (r < min) { bad++; console.log(`FAIL ${name}: --${fg} on --${g} is ${r.toFixed(2)}:1, needs ${min}`); }
|
||||
}
|
||||
// A border the same colour as the ground it is drawn on does not show (Nordic, once).
|
||||
if (p.line === p.panel2) { bad++; console.log(`FAIL ${name}: --line is --panel2, so borders on it vanish`); }
|
||||
}
|
||||
if (bad) process.exit(1);
|
||||
console.log(`OK: ${Object.keys(themes).length} palettes clear AA for every pair the page draws`);
|
||||
152
web/app.css
152
web/app.css
@@ -12,7 +12,7 @@
|
||||
--line:#2c3849;
|
||||
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
||||
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
||||
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
|
||||
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */
|
||||
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
||||
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
||||
--ink:#0e131b; /* text on an accent fill */
|
||||
@@ -239,6 +239,151 @@
|
||||
--bad:#9c3f49;
|
||||
--shadow:0 8px 28px rgba(46,52,64,.15);
|
||||
}
|
||||
/* Catppuccin: Mocha for the dark half, Latte for the light, from catppuccin.com/palette. Base,
|
||||
mantle and the surfaces for the grounds, text and subtext for type, mauve (its default accent)
|
||||
and peach for the rest. */
|
||||
:root[data-theme="catppuccin"] {
|
||||
--bg:#1e1e2e; /* base */
|
||||
--panel:#181825; /* mantle */
|
||||
--panel2:#313244; /* surface0 */
|
||||
--raise:#45475a; /* surface1 */
|
||||
--line:#45475a;
|
||||
--fg:#cdd6f4; /* text */
|
||||
--dim:#bac2de; /* subtext1 */
|
||||
--faint:#a6adc8; /* subtext0 */
|
||||
--accent:#cba6f7; /* mauve */
|
||||
--accent2:#fab387; /* peach */
|
||||
--ink:#11111b; /* crust */
|
||||
--good:#a6e3a1;
|
||||
--warn:#f9e2af;
|
||||
--bad:#f38ba8;
|
||||
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||
}
|
||||
:root[data-theme="catppuccin"][data-mode="light"] {
|
||||
--bg:#eff1f5; /* base */
|
||||
--panel:#e6e9ef; /* mantle */
|
||||
--panel2:#dce0e8; /* crust */
|
||||
--raise:#ccd0da; /* surface0 */
|
||||
--line:#bcc0cc; /* surface1 */
|
||||
--fg:#4c4f69; /* text */
|
||||
--dim:#5c5f77; /* subtext1 */
|
||||
--faint:#5f6276; /* subtext0 */
|
||||
--accent:#8638ec; /* mauve */
|
||||
--accent2:#c74e09; /* peach */
|
||||
--ink:#ffffff;
|
||||
--good:#3a9127;
|
||||
--warn:#925d13;
|
||||
--bad:#d00f39;
|
||||
--shadow:0 8px 28px rgba(76,79,105,.15);
|
||||
}
|
||||
/* Gruvbox, from its README's palette: the bg and fg ramps, with its faded colours for the light
|
||||
half as gruvbox itself does. Blue for the accent, orange for what is new. */
|
||||
:root[data-theme="gruvbox"] {
|
||||
--bg:#282828; /* bg0 */
|
||||
--panel:#1d2021; /* bg0_h */
|
||||
--panel2:#32302f; /* bg0_s */
|
||||
--raise:#3c3836; /* bg1 */
|
||||
--line:#504945; /* bg2 */
|
||||
--fg:#ebdbb2; /* fg */
|
||||
--dim:#d5c4a1; /* fg2 */
|
||||
--faint:#a89984; /* fg4 */
|
||||
--accent:#83a598; /* blue */
|
||||
--accent2:#fe8019; /* orange */
|
||||
--ink:#1d2021;
|
||||
--good:#b8bb26;
|
||||
--warn:#fabd2f;
|
||||
--bad:#fb533f;
|
||||
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||
}
|
||||
:root[data-theme="gruvbox"][data-mode="light"] {
|
||||
--bg:#fbf1c7; /* bg0 */
|
||||
--panel:#f2e5bc; /* bg0_s */
|
||||
--panel2:#ebdbb2; /* bg1 */
|
||||
--raise:#d5c4a1; /* bg2 */
|
||||
--line:#d5c4a1;
|
||||
--fg:#3c3836; /* fg */
|
||||
--dim:#504945; /* fg2 */
|
||||
--faint:#665c54; /* fg3 */
|
||||
--accent:#076678; /* faded blue */
|
||||
--accent2:#af3a03; /* faded orange */
|
||||
--ink:#fbf1c7;
|
||||
--good:#79740e;
|
||||
--warn:#8d5c10;
|
||||
--bad:#9d0006;
|
||||
--shadow:0 8px 28px rgba(60,56,54,.15);
|
||||
}
|
||||
/* Solarized, from ethanschoonover.com/solarized: base03 and base3 for the grounds, the base tones
|
||||
for type. It has two grounds per half, so panel2 and raise are steps between its own. */
|
||||
:root[data-theme="solarized"] {
|
||||
--bg:#002b36; /* base03 */
|
||||
--panel:#073642; /* base02 */
|
||||
--panel2:#0b3f4c;
|
||||
--raise:#124a58;
|
||||
--line:#1d5563;
|
||||
--fg:#eee8d5; /* base2 */
|
||||
--dim:#c3c7be; /* between base2 and base1: base1 had to lift to read on base02 */
|
||||
--faint:#97a5a7; /* base0, lifted to base1 until it reads on base02 */
|
||||
--accent:#4b9fda; /* blue */
|
||||
--accent2:#b58900; /* yellow */
|
||||
--ink:#002b36;
|
||||
--good:#859900;
|
||||
--warn:#bb9315;
|
||||
--bad:#e87674;
|
||||
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||
}
|
||||
:root[data-theme="solarized"][data-mode="light"] {
|
||||
--bg:#fdf6e3; /* base3 */
|
||||
--panel:#eee8d5; /* base2 */
|
||||
--panel2:#e6dfca;
|
||||
--raise:#ddd5bd;
|
||||
--line:#d3cab0;
|
||||
--fg:#073642; /* base02 */
|
||||
--dim:#52666d; /* base01 */
|
||||
--faint:#54666d; /* base00 */
|
||||
--accent:#1e6da5; /* blue */
|
||||
--accent2:#cb4b16; /* orange */
|
||||
--ink:#ffffff;
|
||||
--good:#768700;
|
||||
--warn:#846400;
|
||||
--bad:#c52d2a;
|
||||
--shadow:0 8px 28px rgba(0,43,54,.14);
|
||||
}
|
||||
/* High contrast: black and white with no mid-greys, every pair at 7:1 (AAA) or more, and borders
|
||||
that show. For reading, not for looks. */
|
||||
:root[data-theme="contrast"] {
|
||||
--bg:#000000;
|
||||
--panel:#000000;
|
||||
--panel2:#121212;
|
||||
--raise:#333333;
|
||||
--line:#9a9a9a;
|
||||
--fg:#ffffff;
|
||||
--dim:#ffffff;
|
||||
--faint:#d6d6d6;
|
||||
--accent:#8cc8ff;
|
||||
--accent2:#ffd400;
|
||||
--ink:#000000;
|
||||
--good:#7ee787;
|
||||
--warn:#ffd400;
|
||||
--bad:#ff9a8f;
|
||||
--shadow:0 0 0 1px #9a9a9a;
|
||||
}
|
||||
:root[data-theme="contrast"][data-mode="light"] {
|
||||
--bg:#ffffff;
|
||||
--panel:#ffffff;
|
||||
--panel2:#f0f0f0;
|
||||
--raise:#d6d6d6;
|
||||
--line:#555555;
|
||||
--fg:#000000;
|
||||
--dim:#000000;
|
||||
--faint:#303030;
|
||||
--accent:#0033a0;
|
||||
--accent2:#7a3d00;
|
||||
--ink:#ffffff;
|
||||
--good:#0a5c1c;
|
||||
--warn:#6b4500;
|
||||
--bad:#a30000;
|
||||
--shadow:0 0 0 1px #555555;
|
||||
}
|
||||
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
|
||||
:root[data-theme="classic"] {
|
||||
color-scheme:light;
|
||||
@@ -250,7 +395,7 @@
|
||||
--fg:#000000;
|
||||
--dim:#444444;
|
||||
--faint:#666666;
|
||||
--accent:#3875d7; /* Aqua selection blue */
|
||||
--accent:#3268c0; /* Aqua selection blue, a shade down so links clear AA on the source list */
|
||||
--accent2:#2a5db0;
|
||||
--ink:#ffffff;
|
||||
--good:#237a23;
|
||||
@@ -720,7 +865,8 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
background:var(--raise);border:1px solid var(--line);border-radius:9px;
|
||||
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
|
||||
}
|
||||
.toast.bad{border-color:var(--bad);color:var(--bad)}
|
||||
/* On --panel, not a toast's --raise: the error colours are tuned to read on the page's grounds. */
|
||||
.toast.bad{border-color:var(--bad);color:var(--bad);background:var(--panel)}
|
||||
@keyframes in{from{opacity:0;transform:translateY(6px)}}
|
||||
#burger,#dback{display:none}
|
||||
/* Totals for what is showing, along the bottom, as the original's status bar. */
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
--line:#2c3849;
|
||||
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
||||
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
||||
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
|
||||
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */
|
||||
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
||||
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
||||
--ink:#0e131b; /* text on an accent fill */
|
||||
|
||||
@@ -13,6 +13,10 @@ const THEMES: Record<string, {name: string, modes: boolean}> = {
|
||||
flatremix: {name: 'Flat Remix', modes: true},
|
||||
paper: {name: 'Paper', modes: false},
|
||||
nordic: {name: 'Nordic', modes: true},
|
||||
catppuccin: {name: 'Catppuccin', modes: true},
|
||||
gruvbox: {name: 'Gruvbox', modes: true},
|
||||
solarized: {name: 'Solarized', modes: true},
|
||||
contrast: {name: 'High contrast', modes: true},
|
||||
};
|
||||
const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'};
|
||||
// Before themes came in light and dark, ipx.theme in localStorage held one of these.
|
||||
|
||||
Reference in New Issue
Block a user