From 768d02c8404e58fc6ebd1525575481810fc500bd Mon Sep 17 00:00:00 2001 From: rays Date: Sat, 19 Sep 2026 02:24:56 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 10 +++ CLAUDE.md | 1 + tests/contrast.js | 62 +++++++++++++++++++ web/app.css | 152 +++++++++++++++++++++++++++++++++++++++++++++- web/login.html | 2 +- web/src/theme.ts | 4 ++ 6 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 tests/contrast.js diff --git a/CHANGELOG.md b/CHANGELOG.md index deffef5..f74eab5 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/CLAUDE.md b/CLAUDE.md index 06333f9..a22b627 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ``` diff --git a/tests/contrast.js b/tests/contrast.js new file mode 100644 index 0000000..0b009aa --- /dev/null +++ b/tests/contrast.js @@ -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`); diff --git a/web/app.css b/web/app.css index 7297b50..0a641d2 100644 --- a/web/app.css +++ b/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. */ diff --git a/web/login.html b/web/login.html index 848f014..4aed691 100644 --- a/web/login.html +++ b/web/login.html @@ -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 */ diff --git a/web/src/theme.ts b/web/src/theme.ts index 6b36d2b..38b406e 100644 --- a/web/src/theme.ts +++ b/web/src/theme.ts @@ -13,6 +13,10 @@ const THEMES: Record = { 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 = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'}; // Before themes came in light and dark, ipx.theme in localStorage held one of these.