Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 680b5d4773 | |||
| 84e4b428e4 | |||
| 768d02c840 |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.2] - 2026-09-19
|
||||
|
||||
### 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.
|
||||
|
||||
### Changed
|
||||
|
||||
- The themes in Settings are listed in alphabetical order.
|
||||
|
||||
### 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
|
||||
@@ -524,6 +540,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
||||
|
||||
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...main
|
||||
[0.8.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.1...v0.8.2
|
||||
[0.8.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.0...v0.8.1
|
||||
[0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0
|
||||
[0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "ipx"
|
||||
version = "0.8.1"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ipx"
|
||||
version = "0.8.1"
|
||||
version = "0.8.2"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
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 */
|
||||
|
||||
@@ -5,14 +5,18 @@
|
||||
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
|
||||
// the system, so no palette is written twice.
|
||||
const THEMES: Record<string, {name: string, modes: boolean}> = {
|
||||
modern: {name: 'Modern', modes: true},
|
||||
adwaita: {name: 'Adwaita', modes: true},
|
||||
catppuccin: {name: 'Catppuccin', modes: true},
|
||||
classic: {name: 'Classic', modes: false},
|
||||
dracula: {name: 'Dracula', modes: true},
|
||||
material: {name: 'Material', modes: true},
|
||||
adwaita: {name: 'Adwaita', modes: true},
|
||||
flatremix: {name: 'Flat Remix', modes: true},
|
||||
paper: {name: 'Paper', modes: false},
|
||||
gruvbox: {name: 'Gruvbox', modes: true},
|
||||
contrast: {name: 'High contrast', modes: true},
|
||||
material: {name: 'Material', modes: true},
|
||||
modern: {name: 'Modern', modes: true},
|
||||
nordic: {name: 'Nordic', modes: true},
|
||||
paper: {name: 'Paper', modes: false},
|
||||
solarized: {name: 'Solarized', 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