Modern in the logo's colours, and the logo in the page's mode (#63)

Modern's palette was sampled from the 2004 iPodderX icon: a neutral navy,
its screen blue and amber EQ bars. It now takes the new logo's colours,
the dark half from logo-dark.svg (navy ground, #8fc2ea scale, #ff6a1a
needle) and the light half from logo.svg (sky ground, #2f6aa0 scale, the
needle taken down to #c43e00 so white on it clears AA). The pending amber
and the error red moved apart from the needle's orange, and the sign-in
page's copy of the palette follows.

The pages always showed logo.svg, the light variant, even in a dark
theme; logo-dark.svg was never served. It is now, and the app and admin
pages show whichever matches data-mode, dark until the script says light,
as the palette is. The sign-in page, which has no account's theme, picks
by the system's with <picture>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 18:05:44 +00:00
parent 3cb36ab8b7
commit f9c9c2b7cc
8 changed files with 99 additions and 67 deletions

View File

@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- The Modern theme takes its colours from the new logo: its navy, the blue of its bars and the
orange of its needle.
- The logo follows the page: the dark version in a dark theme, the light one in a light theme.
- Between releases, the version on the logo says so: 0.9.1-dev, not 0.9.0. - Between releases, the version on the logo says so: 0.9.1-dev, not 0.9.0.
- The logo's needle is a brighter, redder orange that stands apart from the blue bars. - The logo's needle is a brighter, redder orange that stands apart from the blue bars.
- The logo is in the top bar beside the add-feed button, in place of the name at the top of the - The logo is in the top bar beside the add-feed button, in place of the name at the top of the

View File

@@ -68,6 +68,7 @@ pub fn router(state: WebState) -> Router {
.route("/api/login", post(login)) .route("/api/login", post(login))
.route("/icon.png", get(icon)) .route("/icon.png", get(icon))
.route("/logo.svg", get(logo)) .route("/logo.svg", get(logo))
.route("/logo-dark.svg", get(logo_dark))
.route("/favicon.ico", get(favicon)) .route("/favicon.ico", get(favicon))
.route("/favicon.png", get(favicon)) .route("/favicon.png", get(favicon))
.route("/apple-touch-icon.png", get(touch_icon)) .route("/apple-touch-icon.png", get(touch_icon))
@@ -562,6 +563,15 @@ async fn logo() -> impl IntoResponse {
) )
} }
/// logo.svg recoloured for a dark page. The pages pick one or the other by their light or dark
/// mode.
async fn logo_dark() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "max-age=86400")],
include_str!("../web/logo-dark.svg"),
)
}
/// The 2004 icon. Nothing here shows it any more; it stays for whatever outside ipx links to it. /// The 2004 icon. Nothing here shows it any more; it stays for whatever outside ipx links to it.
async fn icon() -> impl IntoResponse { async fn icon() -> impl IntoResponse {
( (

View File

@@ -47,10 +47,10 @@ test('Settings picks a theme and, where it has both, light, dark or Auto', async
// Auto follows the system, live, with no reload. // Auto follows the system, live, with no reload.
await page.emulateMedia({ colorScheme: 'light' }); await page.emulateMedia({ colorScheme: 'light' });
await expect.poll(root).toEqual(['modern', 'light']); await expect.poll(root).toEqual(['modern', 'light']);
await expect.poll(bg).toBe('rgb(242, 244, 247)'); // Modern's light --bg await expect.poll(bg).toBe('rgb(238, 245, 251)'); // Modern's light --bg
await page.emulateMedia({ colorScheme: 'dark' }); await page.emulateMedia({ colorScheme: 'dark' });
await expect.poll(root).toEqual(['modern', 'dark']); await expect.poll(root).toEqual(['modern', 'dark']);
await expect.poll(bg).toBe('rgb(14, 19, 27)'); // Modern's dark --bg await expect.poll(bg).toBe('rgb(10, 23, 38)'); // Modern's dark --bg
// Dracula, then its light half, Alucard. // Dracula, then its light half, Alucard.
await page.locator('#stheme').selectOption('dracula'); await page.locator('#stheme').selectOption('dracula');
@@ -559,9 +559,11 @@ test('a second person has their own feeds and their own read state', async ({ br
const page = await ctx.newPage(); const page = await ctx.newPage();
await page.goto('/login'); await page.goto('/login');
// The sign-in page shows the logo, so it has to load before anyone has signed in. // The sign-in page shows the logo, so it has to load before anyone has signed in.
const logo = await page.request.get('/logo.svg'); for (const path of ['/logo.svg', '/logo-dark.svg']) {
expect(logo.status()).toBe(200); const logo = await page.request.get(path);
expect(logo.headers()['content-type']).toBe('image/svg+xml'); expect(logo.status(), path).toBe(200);
expect(logo.headers()['content-type'], path).toBe('image/svg+xml');
}
await page.locator('#name').fill('sam'); await page.locator('#name').fill('sam');
await page.locator('#pw').fill('sampassword'); await page.locator('#pw').fill('sampassword');
await page.locator('button[type=submit]').click(); await page.locator('button[type=submit]').click();
@@ -1178,6 +1180,18 @@ test('a file not yet downloaded has its icon in line with the rest of its row',
for (const o of offsets) expect(Math.abs(o)).toBeLessThanOrEqual(1); for (const o of offsets) expect(Math.abs(o)).toBeLessThanOrEqual(1);
}); });
test('the logo is the dark one in dark mode and the light one in light mode', async ({ page }) => {
const shown = () => page.locator('#applogo img:visible').getAttribute('src');
await page.evaluate(() => setTheme('modern', 'dark'));
expect(await shown()).toMatch(/^\/logo-dark\.svg\?v=/);
await page.evaluate(() => setTheme('modern', 'light'));
expect(await shown()).toMatch(/^\/logo\.svg\?v=/);
// Paper has only a light palette, so the light logo whatever the mode asked.
await page.evaluate(() => setTheme('paper', 'dark'));
expect(await shown()).toMatch(/^\/logo\.svg\?v=/);
await page.evaluate(() => setTheme('modern', 'dark'));
});
test('the favicon is the logo, square, from both pages', async ({ page }) => { test('the favicon is the logo, square, from both pages', async ({ page }) => {
await expect(page.locator('link[rel="icon"]')).toHaveAttribute('href', /^\/favicon\.png\?v=[0-9a-f]{12}$/); await expect(page.locator('link[rel="icon"]')).toHaveAttribute('href', /^\/favicon\.png\?v=[0-9a-f]{12}$/);
// A browser asks for /favicon.ico on its own, signed in or not. // A browser asks for /favicon.ico on its own, signed in or not.

View File

@@ -13,7 +13,7 @@
<!-- The server sends this page, and its script, to admins only. --> <!-- The server sends this page, and its script, to admins only. -->
<header id="topbar"> <header id="topbar">
<a class="btn ico" href="/" title="Back to iPX" aria-label="Back to iPX" data-icon="left"></a> <a class="btn ico" href="/" title="Back to iPX" aria-label="Back to iPX" data-icon="left"></a>
<img class="logo" src="/logo.svg" alt="" width="26"> <img class="logo onlight" src="/logo.svg" alt="" width="26"><img class="logo ondark" src="/logo-dark.svg" alt="" width="26">
<h1>Admin</h1> <h1>Admin</h1>
<span class="grow"></span> <span class="grow"></span>
<nav class="tabs" id="atabs"> <nav class="tabs" id="atabs">

View File

@@ -2,44 +2,44 @@
anyone else. One variable file covers every weight used here; italics are synthesized. Classic anyone else. One variable file covers every weight used here; italics are synthesized. Classic
keeps Lucida Grande, the 2004 app's face. */ keeps Lucida Grande, the 2004 app's face. */
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap} @font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
/* Palette taken from the 2004 iPodderX icon: the silver device body, the blue /* Palette taken from the logo (web/logo.svg, logo-dark.svg): its navy ground, the tuning scale's
screen, and the amber EQ bars. Hex values in comments are sampled straight from it. */ blue and the orange needle. The dark half follows logo-dark.svg. */
:root { :root {
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */ --bg:#0a1726; /* the dark logo's navy (#0c2238), taken down */
--panel:#151c27; --panel:#0f1f31;
--panel2:#1c2431; --panel2:#15283d;
--raise:#25303f; --raise:#1d3450;
--line:#2c3849; --line:#264060;
--fg:#f5f5f5; /* #F5F5F5 device highlight */ --fg:#f3f7fb;
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */ --dim:#a3b6ca;
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */ --faint:#8a9fb5;
--accent:#92b2e6; /* #92B2E6 the screen blue */ --accent:#8fc2ea; /* #8FC2EA the dark logo's scale */
--accent2:#f49e2c; /* #F49E2C the EQ bars */ --accent2:#ff6a1a; /* #FF6A1A the dark logo's needle */
--ink:#0e131b; /* text on an accent fill */ --ink:#0a1726; /* text on an accent fill */
--good:#6fbf8b; --good:#6fbf8b;
--warn:#f49e2c; /* the amber doubles as the pending colour */ --warn:#f5a524; /* amber, apart from the needle's orange, for pending */
--bad:#e2705f; --bad:#ff6b7a; /* pushed towards pink, apart from the needle */
--shadow:0 8px 28px rgba(6,10,16,.55); --shadow:0 8px 28px rgba(4,10,20,.55);
} }
/* Every other theme overrides the same variables, under data-theme and data-mode. The page's /* 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 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. */ one block per variant here and none needs repeating under a media query. */
:root[data-theme="modern"][data-mode="light"] { :root[data-theme="modern"][data-mode="light"] {
--bg:#f2f4f7; --bg:#eef5fb; /* the light logo's sky (#DFF0FB), paler */
--panel:#ffffff; /* #FFFFFF device body */ --panel:#ffffff;
--panel2:#e9edf3; --panel2:#e4eff9;
--raise:#dde3ec; --raise:#d5e6f5;
--line:#d6d6d6; /* #D6D6D6 device edge */ --line:#c6d9ea;
--fg:#1a1a1a; /* #1A1A1A icon outline */ --fg:#10233a;
--dim:#606060; /* #606060 */ --dim:#46596e;
--faint:#6b6b6b; /* between the icon's #929292 and #606060, to clear AA on the page ground */ --faint:#56697e;
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */ --accent:#2f6aa0; /* #2F6AA0 the logo's scale */
--accent2:#985e0a; /* the EQ amber, taken down until white on it clears AA */ --accent2:#c43e00; /* the needle (#FF5500), taken down until white on it clears AA */
--ink:#ffffff; --ink:#ffffff;
--good:#2f7d4f; --good:#2f7d4f;
--warn:#985e0a; /* the same amber; the lighter one failed AA as text */ --warn:#985e0a; /* amber, apart from the needle's orange; lighter failed AA as text */
--bad:#b3402f; --bad:#b3263a; /* pushed towards crimson, apart from the needle */
--shadow:0 8px 28px rgba(45,83,145,.14); --shadow:0 8px 28px rgba(47,106,160,.14);
} }
/* Dracula, and Alucard, its light half, from draculatheme.com/spec. The spec's comment colour /* 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. */ (#6272A4, #6C664B) is too faint for small text on its own background, so --faint is lifted. */
@@ -493,7 +493,11 @@ a{color:var(--accent)}
background:var(--panel);border-right:1px solid var(--line); background:var(--panel);border-right:1px solid var(--line);
display:flex;flex-direction:column;min-height:0; display:flex;flex-direction:column;min-height:0;
} }
#applogo{width:28px;height:28px;flex:none;border-radius:7px} #applogo{display:flex;flex:none}
#applogo img{width:28px;height:28px;border-radius:7px}
/* The logo in the mode's own variant. Dark unless the page says light, as the palette is: with
Auto, data-mode arrives with the script, and until then the page is drawn dark. */
:root[data-mode="light"] .logo.ondark,:root:not([data-mode="light"]) .logo.onlight{display:none}
.iconbtn{ .iconbtn{
width:30px;height:30px;border-radius:8px;display:grid;place-items:center; width:30px;height:30px;border-radius:8px;display:grid;place-items:center;
color:var(--dim);flex:none; color:var(--dim);flex:none;

View File

@@ -75,7 +75,7 @@ export function buildPage(name, { minify = true } = {}) {
// The icons are named by their contents too. They are kept a day under a fixed name, and a new // The icons are named by their contents too. They are kept a day under a fixed name, and a new
// logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the // logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the
// query; /favicon.ico, which a browser asks for on its own, cannot carry one. // query; /favicon.ico, which a browser asks for on its own, cannot carry one.
out = out.replace(/(href|src)="\/(favicon\.png|apple-touch-icon\.png|logo\.svg)"/g, out = out.replace(/(href|src|srcset)="\/(favicon\.png|apple-touch-icon\.png|logo\.svg|logo-dark\.svg)"/g,
(_, attr, file) => `${attr}="/${file}?v=${hash(fs.readFileSync(path.join(here, file)))}"`); (_, attr, file) => `${attr}="/${file}?v=${hash(fs.readFileSync(path.join(here, file)))}"`);
if (!minify) return { html: out, js, script }; if (!minify) return { html: out, js, script };
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true }); const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });

View File

@@ -14,7 +14,7 @@
<button class="iconbtn" id="burger" title="Feeds" aria-label="Feeds" data-icon="menu"></button> <button class="iconbtn" id="burger" title="Feeds" aria-label="Feeds" data-icon="menu"></button>
<!-- One group per thing acted on, in the order the panes read: feeds, then the selected item. <!-- One group per thing acted on, in the order the panes read: feeds, then the selected item.
A phone hides the item group, which it has no table for. --> A phone hides the item group, which it has no table for. -->
<img class="logo" id="applogo" src="/logo.svg" alt="iPX" title="iPX {version}"> <span id="applogo" title="iPX {version}"><img class="logo onlight" src="/logo.svg" alt="iPX"><img class="logo ondark" src="/logo-dark.svg" alt="iPX"></span>
<div class="tgroup"> <div class="tgroup">
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button> <button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button>
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="circleMinus" disabled></button> <button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="circleMinus" disabled></button>

View File

@@ -10,40 +10,41 @@
/* Inter, from ipx itself; see the same rule in index.html. */ /* Inter, from ipx itself; see the same rule in index.html. */
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap} @font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
:root { :root {
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */ --bg:#0a1726; /* the dark logo's navy (#0c2238), taken down */
--panel:#151c27; --panel:#0f1f31;
--panel2:#1c2431; --panel2:#15283d;
--raise:#25303f; --raise:#1d3450;
--line:#2c3849; --line:#264060;
--fg:#f5f5f5; /* #F5F5F5 device highlight */ --fg:#f3f7fb;
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */ --dim:#a3b6ca;
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */ --faint:#8a9fb5;
--accent:#92b2e6; /* #92B2E6 the screen blue */ --accent:#8fc2ea; /* #8FC2EA the dark logo's scale */
--accent2:#f49e2c; /* #F49E2C the EQ bars */ --accent2:#ff6a1a; /* #FF6A1A the dark logo's needle */
--ink:#0e131b; /* text on an accent fill */ --ink:#0a1726; /* text on an accent fill */
--good:#6fbf8b; --good:#6fbf8b;
--warn:#f49e2c; /* the amber doubles as the pending colour */ --warn:#f5a524; /* amber, apart from the needle's orange, for pending */
--bad:#e2705f; --bad:#ff6b7a; /* pushed towards pink, apart from the needle */
--shadow:0 8px 28px rgba(6,10,16,.55); --shadow:0 8px 28px rgba(4,10,20,.55);
--r:10px; --r:10px;
} }
/* Signed out, there is no account to take a theme from, so this follows the system. */ /* Modern, as app.css has it. Signed out, there is no account to take a theme from, so this follows
the system. */
@media (prefers-color-scheme:light){:root { @media (prefers-color-scheme:light){:root {
--bg:#f2f4f7; --bg:#eef5fb; /* the light logo's sky (#DFF0FB), paler */
--panel:#ffffff; /* #FFFFFF device body */ --panel:#ffffff;
--panel2:#e9edf3; --panel2:#e4eff9;
--raise:#dde3ec; --raise:#d5e6f5;
--line:#d6d6d6; /* #D6D6D6 device edge */ --line:#c6d9ea;
--fg:#1a1a1a; /* #1A1A1A icon outline */ --fg:#10233a;
--dim:#606060; /* #606060 */ --dim:#46596e;
--faint:#6b6b6b; --faint:#56697e;
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */ --accent:#2f6aa0; /* #2F6AA0 the logo's scale */
--accent2:#985e0a; --accent2:#c43e00; /* the needle (#FF5500), taken down until white on it clears AA */
--ink:#ffffff; --ink:#ffffff;
--good:#2f7d4f; --good:#2f7d4f;
--warn:#985e0a; --warn:#985e0a; /* amber, apart from the needle's orange; lighter failed AA as text */
--bad:#b3402f; --bad:#b3263a; /* pushed towards crimson, apart from the needle */
--shadow:0 8px 28px rgba(45,83,145,.14); --shadow:0 8px 28px rgba(47,106,160,.14);
}} }}
*{box-sizing:border-box} *{box-sizing:border-box}
html,body{height:100%} html,body{height:100%}
@@ -74,7 +75,7 @@ button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
</style> </style>
<form id="f"> <form id="f">
<div class="brand"><img src="/logo.svg" alt=""><h1>iPX</h1></div> <div class="brand"><picture><source media="(prefers-color-scheme:light)" srcset="/logo.svg"><img src="/logo-dark.svg" alt=""></picture><h1>iPX</h1></div>
<label for="name">Name</label> <label for="name">Name</label>
<input id="name" name="name" autocomplete="username" autofocus required> <input id="name" name="name" autocomplete="username" autofocus required>
<label for="pw">Password</label> <label for="pw">Password</label>