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.