Themes: Dracula, Material, Adwaita, Flat Remix, Paper, Nordic, each light, dark or Auto
The theme picker lists Modern (the old Dark and Light), Classic and six new palettes, from Dracula's spec (with Alucard), Material 3's baseline scheme, libadwaita's CSS variables, Flat Remix's _colors.scss, Paper and Nord. A second setting picks Light, Dark or Auto where a theme has both; Classic and Paper do not, so it is hidden for them. The page gets data-mode, light or dark, and Auto is worked out in theme.ts from the system, so each palette is written once instead of again under a media query. Every new palette clears WCAG AA for text on its backgrounds. An old ipx.theme of dark, light or auto carries over as Modern. Closes #27. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,13 @@ The long form, with what was wrong before and how it was found, is in
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Six more themes in Settings: Dracula, Material, Adwaita, Flat Remix, Paper and Nordic, beside
|
||||
Classic and Modern (the existing dark and light). Each that comes both ways has its own Light,
|
||||
Dark or Auto setting; Classic and Paper come one way only, so that setting is hidden for them.
|
||||
A theme chosen before this carries over.
|
||||
|
||||
### Changed
|
||||
|
||||
- A feed that fails to check gets a red ! in the feed list, and its page says why, in place of a
|
||||
|
||||
@@ -20,28 +20,54 @@ test('the page loads and lists the configured feeds', async ({ page }) => {
|
||||
expect(errors, 'the page script must not throw at load').toEqual([]);
|
||||
});
|
||||
|
||||
test('the theme dropdown in Settings jumps straight to a theme, including Auto', async ({ page }) => {
|
||||
const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
|
||||
test('Settings picks a theme and, where it has both, light, dark or Auto', async ({ page }) => {
|
||||
const root = () => page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]);
|
||||
const bg = () => page.evaluate(() => getComputedStyle(document.body).backgroundColor);
|
||||
await page.locator('#prefs').click();
|
||||
await expect(page.locator('#stheme')).toHaveValue(await theme());
|
||||
await expect(page.locator('#stheme')).toHaveValue((await root())[0]);
|
||||
|
||||
await page.locator('#stheme').selectOption('auto');
|
||||
await expect.poll(theme).toBe('auto');
|
||||
// Auto follows the system; emulating a light system must show the light palette live,
|
||||
// no reload needed, since it is a media query rather than something JS picks per click.
|
||||
await page.locator('#stheme').selectOption('modern');
|
||||
await page.locator('#smode').selectOption('auto');
|
||||
// Auto follows the system, live, with no reload.
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor))
|
||||
.toBe('rgb(242, 244, 247)'); // --bg in the light palette
|
||||
await expect.poll(root).toEqual(['modern', 'light']);
|
||||
await expect.poll(bg).toBe('rgb(242, 244, 247)'); // Modern's light --bg
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor))
|
||||
.toBe('rgb(14, 19, 27)'); // the bare :root is already dark; Auto adds nothing here
|
||||
await expect.poll(root).toEqual(['modern', 'dark']);
|
||||
await expect.poll(bg).toBe('rgb(14, 19, 27)'); // Modern's dark --bg
|
||||
|
||||
// Dracula, then its light half, Alucard.
|
||||
await page.locator('#stheme').selectOption('dracula');
|
||||
await page.locator('#smode').selectOption('dark');
|
||||
await expect.poll(bg).toBe('rgb(40, 42, 54)'); // #282A36
|
||||
await page.locator('#smode').selectOption('light');
|
||||
await expect.poll(bg).toBe('rgb(255, 251, 235)'); // #FFFBEB
|
||||
|
||||
// Classic and Paper come one way only, so there is nothing to choose.
|
||||
await page.locator('#stheme').selectOption('paper');
|
||||
await expect(page.locator('#smode')).toBeHidden();
|
||||
await expect.poll(bg).toBe('rgb(242, 238, 222)'); // #F2EEDE
|
||||
await page.locator('#stheme').selectOption('classic');
|
||||
await expect(page.locator('#smode')).toBeHidden();
|
||||
await page.reload();
|
||||
await expect.poll(theme).toBe('classic');
|
||||
await expect.poll(root).toEqual(['classic', 'light']);
|
||||
// The 2004 Mac app set its type in Lucida Grande.
|
||||
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
|
||||
expect(await page.locator('#theme').count(), 'the theme lives in Settings only').toBe(0);
|
||||
|
||||
// Back to Nordic, dark: the mode chosen before Classic is kept for the themes that have one.
|
||||
await page.locator('#prefs').click();
|
||||
await page.locator('#stheme').selectOption('nordic');
|
||||
await expect(page.locator('#smode')).toHaveValue('light');
|
||||
await page.locator('#smode').selectOption('dark');
|
||||
await expect.poll(bg).toBe('rgb(46, 52, 64)'); // nord0
|
||||
});
|
||||
|
||||
test('a theme saved before there was light and dark carries over', async ({ page }) => {
|
||||
await page.evaluate(() => { localStorage.setItem('ipx.theme', 'light'); localStorage.removeItem('ipx.mode'); });
|
||||
await page.reload();
|
||||
expect(await page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
|
||||
.toEqual(['modern', 'light']);
|
||||
});
|
||||
|
||||
test('settings opens and saves the global schedule', async ({ page }) => {
|
||||
|
||||
@@ -15,7 +15,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
// The files are one script, concatenated in this order, not modules: they share one top-level
|
||||
// scope, as the single inline script did, and code that runs at load needs what came before it.
|
||||
const PAGES = {
|
||||
'index.html': ['util', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'events'],
|
||||
'index.html': ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'events'],
|
||||
'login.html': ['login'],
|
||||
};
|
||||
|
||||
|
||||
222
web/index.html
222
web/index.html
@@ -30,7 +30,10 @@
|
||||
--bad:#e2705f;
|
||||
--shadow:0 8px 28px rgba(6,10,16,.55);
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
/* Every other theme overrides the same variables, under data-theme and data-mode. The page's
|
||||
script sets data-mode to light or dark, working Auto out from the system, so each theme has
|
||||
one block per variant here and none needs repeating under a media query. */
|
||||
:root[data-theme="modern"][data-mode="light"] {
|
||||
--bg:#f2f4f7;
|
||||
--panel:#ffffff; /* #FFFFFF device body */
|
||||
--panel2:#e9edf3;
|
||||
@@ -47,28 +50,203 @@
|
||||
--bad:#b3402f;
|
||||
--shadow:0 8px 28px rgba(45,83,145,.14);
|
||||
}
|
||||
/* Auto: the same palette as Light, but only while the system is set to light -- the default
|
||||
:root above is already dark, so nothing is needed for the dark half of Auto. Duplicated
|
||||
rather than shared with [data-theme="light"], the same way Classic repeats its own values;
|
||||
CSS custom properties have no way to say "these vars, but only under this media query". */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root[data-theme="auto"] {
|
||||
--bg:#f2f4f7;
|
||||
--panel:#ffffff;
|
||||
--panel2:#e9edf3;
|
||||
--raise:#dde3ec;
|
||||
--line:#d6d6d6;
|
||||
--fg:#1a1a1a;
|
||||
--dim:#606060;
|
||||
--faint:#767676;
|
||||
--accent:#2d5391;
|
||||
--accent2:#9a5f0a;
|
||||
--ink:#ffffff;
|
||||
--good:#2f7d4f;
|
||||
--warn:#b06f10;
|
||||
--bad:#b3402f;
|
||||
--shadow:0 8px 28px rgba(45,83,145,.14);
|
||||
/* Dracula, and Alucard, its light half, from draculatheme.com/spec. The spec's comment colour
|
||||
(#6272A4, #6C664B) is too faint for small text on its own background, so --faint is lifted. */
|
||||
:root[data-theme="dracula"] {
|
||||
--bg:#282a36;
|
||||
--panel:#21222c;
|
||||
--panel2:#343746;
|
||||
--raise:#44475a; /* selection */
|
||||
--line:#414558;
|
||||
--fg:#f8f8f2;
|
||||
--dim:#c9cbd6;
|
||||
--faint:#9ea8c7;
|
||||
--accent:#bd93f9; /* purple */
|
||||
--accent2:#ff79c6; /* pink */
|
||||
--ink:#282a36;
|
||||
--good:#50fa7b;
|
||||
--warn:#ffb86c;
|
||||
--bad:#ff8080;
|
||||
--shadow:0 8px 28px rgba(0,0,0,.45);
|
||||
}
|
||||
:root[data-theme="dracula"][data-mode="light"] {
|
||||
--bg:#fffbeb;
|
||||
--panel:#f7f2df;
|
||||
--panel2:#efe9d3;
|
||||
--raise:#cfcfde; /* selection */
|
||||
--line:#ddd6bd;
|
||||
--fg:#1f1f1f;
|
||||
--dim:#454137;
|
||||
--faint:#635d44;
|
||||
--accent:#644ac9;
|
||||
--accent2:#a3144d;
|
||||
--ink:#ffffff;
|
||||
--good:#14710a;
|
||||
--warn:#a34d14;
|
||||
--bad:#b83322;
|
||||
--shadow:0 8px 28px rgba(108,102,75,.18);
|
||||
}
|
||||
/* Material 3's baseline scheme: surface, the surface containers, outline and primary. */
|
||||
:root[data-theme="material"] {
|
||||
--bg:#141218; /* surface */
|
||||
--panel:#1d1b20; /* surface-container-low */
|
||||
--panel2:#211f26; /* surface-container */
|
||||
--raise:#36343b; /* surface-container-highest */
|
||||
--line:#49454f; /* outline-variant */
|
||||
--fg:#e6e0e9; /* on-surface */
|
||||
--dim:#cac4d0; /* on-surface-variant */
|
||||
--faint:#9a95a0; /* outline, a shade up to clear AA on the containers */
|
||||
--accent:#d0bcff; /* primary */
|
||||
--accent2:#efb8c8; /* tertiary */
|
||||
--ink:#381e72; /* on-primary */
|
||||
--good:#8fd6a0;
|
||||
--warn:#f2c46b;
|
||||
--bad:#f2b8b5; /* error */
|
||||
--shadow:0 8px 28px rgba(0,0,0,.5);
|
||||
}
|
||||
:root[data-theme="material"][data-mode="light"] {
|
||||
--bg:#fef7ff;
|
||||
--panel:#f7f2fa;
|
||||
--panel2:#f3edf7;
|
||||
--raise:#e6e0e9;
|
||||
--line:#cac4d0;
|
||||
--fg:#1d1b20;
|
||||
--dim:#49454f;
|
||||
--faint:#67626c; /* outline (#79747E), a shade down to clear AA on the containers */
|
||||
--accent:#6750a4;
|
||||
--accent2:#7d5260;
|
||||
--ink:#ffffff;
|
||||
--good:#2b6c3c;
|
||||
--warn:#7c5800;
|
||||
--bad:#b3261e;
|
||||
--shadow:0 8px 28px rgba(103,80,164,.14);
|
||||
}
|
||||
/* Adwaita, from libadwaita's own CSS variables: view, window and sidebar backgrounds, the blue
|
||||
accent, and its destructive, success and warning colours. */
|
||||
:root[data-theme="adwaita"] {
|
||||
--bg:#1d1d20; /* view */
|
||||
--panel:#2e2e32; /* sidebar, headerbar */
|
||||
--panel2:#222226; /* window */
|
||||
--raise:#3a3a3f;
|
||||
--line:#3f3f45;
|
||||
--fg:#ffffff;
|
||||
--dim:#c4c4c8;
|
||||
--faint:#a0a0a7;
|
||||
--accent:#81d0ff;
|
||||
--accent2:#3584e4;
|
||||
--ink:#1d1d20;
|
||||
--good:#78e9ab;
|
||||
--warn:#ffc252;
|
||||
--bad:#ff938c;
|
||||
--shadow:0 8px 28px rgba(0,0,0,.5);
|
||||
}
|
||||
:root[data-theme="adwaita"][data-mode="light"] {
|
||||
--bg:#ffffff;
|
||||
--panel:#ebebed;
|
||||
--panel2:#fafafb;
|
||||
--raise:#dcdce0;
|
||||
--line:#d9d9dd;
|
||||
--fg:#333338; /* rgb(0 0 6 / 80%) on white */
|
||||
--dim:#57575d;
|
||||
--faint:#66666c;
|
||||
--accent:#0461be;
|
||||
--accent2:#3584e4;
|
||||
--ink:#ffffff;
|
||||
--good:#00753a;
|
||||
--warn:#905400;
|
||||
--bad:#c30000;
|
||||
--shadow:0 8px 28px rgba(0,0,6,.12);
|
||||
}
|
||||
/* Flat Remix, from its GTK theme's _colors.scss: base and bg, fg, the selection blue, and its
|
||||
link colours, which are the selection blue taken down (light) or up (dark) until readable. */
|
||||
:root[data-theme="flatremix"] {
|
||||
--bg:#272a34; /* base */
|
||||
--panel:#23262f; /* bg: base darkened 2% */
|
||||
--panel2:#2e323d;
|
||||
--raise:#363b48;
|
||||
--line:#3a3f4c;
|
||||
--fg:#eeeeec;
|
||||
--dim:#babec8;
|
||||
--faint:#959baa;
|
||||
--accent:#8fb6ff; /* link */
|
||||
--accent2:#fd7d00; /* warning orange */
|
||||
--ink:#1a1c23;
|
||||
--good:#4cc28c;
|
||||
--warn:#ff9a3c;
|
||||
--bad:#ff6b6b;
|
||||
--shadow:0 8px 28px rgba(0,0,0,.3);
|
||||
}
|
||||
:root[data-theme="flatremix"][data-mode="light"] {
|
||||
--bg:#fafafa; /* base */
|
||||
--panel:#ffffff; /* bg */
|
||||
--panel2:#f0f0f1;
|
||||
--raise:#e4e5e7;
|
||||
--line:#d9d9d9; /* borders: bg darkened 15% */
|
||||
--fg:#22252b;
|
||||
--dim:#5c616c; /* fg */
|
||||
--faint:#686d78;
|
||||
--accent:#0060f0; /* link */
|
||||
--accent2:#fd7d00;
|
||||
--ink:#ffffff;
|
||||
--good:#23794f;
|
||||
--warn:#a85400;
|
||||
--bad:#d41919;
|
||||
--shadow:0 8px 28px rgba(0,0,0,.1);
|
||||
}
|
||||
/* Paper: a single light palette, black on the colour of paper, colour kept for what matters.
|
||||
Its blue and red are taken down a shade to clear AA on that background. */
|
||||
:root[data-theme="paper"] {
|
||||
--bg:#f2eede;
|
||||
--panel:#ebe7d7;
|
||||
--panel2:#e4e0d0;
|
||||
--raise:#d8d5c7; /* highlight */
|
||||
--line:#cdc9b9;
|
||||
--fg:#000000;
|
||||
--dim:#3d3a33;
|
||||
--faint:#5a564c;
|
||||
--accent:#1a5fae;
|
||||
--accent2:#b58900;
|
||||
--ink:#ffffff;
|
||||
--good:#216609;
|
||||
--warn:#795a00;
|
||||
--bad:#b1331f;
|
||||
--shadow:0 8px 28px rgba(0,0,0,.12);
|
||||
}
|
||||
/* Nordic: the Nord palette. Polar Night for the dark half, Snow Storm for the light, Frost for
|
||||
the accent and Aurora for the rest. Aurora red and Frost blue are shifted until they read. */
|
||||
:root[data-theme="nordic"] {
|
||||
--bg:#2e3440; /* nord0 */
|
||||
--panel:#3b4252; /* nord1 */
|
||||
--panel2:#434c5e; /* nord2 */
|
||||
--raise:#4c566a; /* nord3 */
|
||||
--line:#434c5e;
|
||||
--fg:#eceff4; /* nord6 */
|
||||
--dim:#d8dee9; /* nord4 */
|
||||
--faint:#b4bccb;
|
||||
--accent:#93c9d8; /* nord8, a shade up */
|
||||
--accent2:#ebcb8b; /* nord13 */
|
||||
--ink:#2e3440;
|
||||
--good:#b0c99b; /* nord14, a shade up */
|
||||
--warn:#ebcb8b;
|
||||
--bad:#f2aeb4;
|
||||
--shadow:0 8px 28px rgba(0,0,0,.35);
|
||||
}
|
||||
:root[data-theme="nordic"][data-mode="light"] {
|
||||
--bg:#eceff4; /* nord6 */
|
||||
--panel:#e5e9f0; /* nord5 */
|
||||
--panel2:#d8dee9; /* nord4 */
|
||||
--raise:#cdd4e0;
|
||||
--line:#c5cedb;
|
||||
--fg:#2e3440; /* nord0 */
|
||||
--dim:#3b4252; /* nord1 */
|
||||
--faint:#4c566a; /* nord3 */
|
||||
--accent:#3a5e8a;
|
||||
--accent2:#b0674e;
|
||||
--ink:#ffffff;
|
||||
--good:#446632;
|
||||
--warn:#7e590e;
|
||||
--bad:#9c3f49;
|
||||
--shadow:0 8px 28px rgba(46,52,64,.15);
|
||||
}
|
||||
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
|
||||
:root[data-theme="classic"] {
|
||||
|
||||
@@ -333,10 +333,12 @@ async function prefsModal(){
|
||||
const admin = !!(S.me&&S.me.admin);
|
||||
openModal(`<h3>Settings</h3>
|
||||
<div class="field"><label>Theme</label>
|
||||
<select id="stheme">${THEME_ORDER.map(t=>
|
||||
`<option value="${t}"${document.documentElement.dataset.theme===t?' selected':''}>${THEMES[t]}</option>`).join('')}</select>
|
||||
<span class="hint">Auto follows your system's light/dark setting. The same toggle is in
|
||||
the header, one click at a time; this jumps straight to the one you want.</span></div>
|
||||
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
|
||||
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
|
||||
<div class="field" id="smodefield"${THEMES[theme.name].modes?'':' hidden'}><label>Light or dark</label>
|
||||
<select id="smode">${Object.entries(MODES).map(([k,t])=>
|
||||
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
|
||||
<span class="hint">Auto follows your system's light/dark setting.</span></div>
|
||||
<div class="field"><label>Check feeds every</label>
|
||||
${admin?`<div class="inline">
|
||||
<input type="number" id="gnum" min="1" max="999" value="${gs.n}">
|
||||
@@ -378,6 +380,7 @@ async function prefsModal(){
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="${admin?'Cancel':'Close'}" aria-label="${admin?'Cancel':'Close'}">${ICON.close}</button>
|
||||
${admin?`<button class="btn ico primary" id="gsave" title="Save" aria-label="Save">${ICON.check}</button>`:''}</div>`);
|
||||
$('#stheme').onchange=e=>setTheme(e.target.value);
|
||||
$('#smode').onchange=e=>setTheme(undefined,e.target.value);
|
||||
$('#gopml').onclick=opmlModal;
|
||||
if(!admin) return;
|
||||
$('#gusers').onclick=usersModal;
|
||||
@@ -594,15 +597,4 @@ $('#feedlist').onkeydown=ev=>{
|
||||
};
|
||||
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
||||
$('#scrim').onclick=()=>nav(false);
|
||||
// Dark, light, the 2004 Mac app, and following the system, chosen in Settings and kept in
|
||||
// ipx.theme.
|
||||
const THEMES={dark:'Dark',light:'Light',classic:'Classic, the 2004 Mac app',auto:'Auto (matches your system)'};
|
||||
const THEME_ORDER=Object.keys(THEMES);
|
||||
function setTheme(t){
|
||||
if(!THEMES[t]) t='dark';
|
||||
document.documentElement.dataset.theme=t;
|
||||
const sel=$('#stheme'); if(sel) sel.value=t;
|
||||
try{ localStorage.setItem('ipx.theme',t); }catch{}
|
||||
}
|
||||
try{ setTheme(localStorage.getItem('ipx.theme')); }catch{ setTheme('dark'); }
|
||||
|
||||
|
||||
40
web/src/theme.ts
Normal file
40
web/src/theme.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/* ---------------- theme ---------------- */
|
||||
// A theme, and for those that come in both, light, dark or Auto, chosen in Settings and kept in
|
||||
// ipx.theme and ipx.mode. The page gets data-theme and 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},
|
||||
classic: {name: 'Classic, the 2004 Mac app', 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},
|
||||
nordic: {name: 'Nordic', 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 held one of these.
|
||||
const OLD_THEMES: Record<string, [string, string]> = {dark: ['modern', 'dark'], light: ['modern', 'light'], auto: ['modern', 'auto']};
|
||||
const systemDark = window.matchMedia?.('(prefers-color-scheme: dark)');
|
||||
const theme = {name: 'modern', mode: 'dark'};
|
||||
|
||||
function setTheme(name = theme.name, mode = theme.mode){
|
||||
theme.name = THEMES[name] ? name : 'modern';
|
||||
theme.mode = MODES[mode] ? mode : 'dark';
|
||||
const both = THEMES[theme.name].modes;
|
||||
const root = document.documentElement;
|
||||
root.dataset.theme = theme.name;
|
||||
// A theme with one palette has it whatever the mode; both of those are light.
|
||||
root.dataset.mode = !both ? 'light'
|
||||
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
|
||||
const sel = $('#stheme'); if(sel) sel.value = theme.name;
|
||||
const ms = $('#smode'); if(ms) ms.value = theme.mode;
|
||||
const mf = $('#smodefield'); if(mf) mf.hidden = !both;
|
||||
try{ localStorage.setItem('ipx.theme', theme.name); localStorage.setItem('ipx.mode', theme.mode); }catch{}
|
||||
}
|
||||
systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); });
|
||||
try{
|
||||
let name = localStorage.getItem('ipx.theme'), mode = localStorage.getItem('ipx.mode');
|
||||
if(OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
|
||||
setTheme(name ?? undefined, mode ?? undefined);
|
||||
}catch{ setTheme(); }
|
||||
Reference in New Issue
Block a user