A logo drawn from the radio's screen, to app icon guidelines (#55)

The 2004 icon is a whole radio on a transparent background and not
square, so the tab icon was padded and iOS painted the home-screen icon
on white. web/logo.svg is the radio's screen alone, its tuning scale and
orange needle, laid out as Apple's app icon guidelines ask: opaque and
full-bleed (the system cuts its own corners), a gradient background and
flat foreground layers with hard edges, no highlights or shadows of its
own, nothing thin enough to vanish at 32px. Each layer is a <g>, ready to
split out for Icon Composer. web/logo-dark.svg is the same layers
recoloured.

favicon.png (128) and apple-touch-icon.png (180) are renders of it. The
pages show it from /logo.svg, served outside the auth layer for the
sign-in page, with an app icon's rounded corners. The 2004 icon stays at
/icon.png for anything outside that links to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 13:31:16 +00:00
parent 162665b010
commit 7796566dfc
11 changed files with 70 additions and 18 deletions

View File

@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- A new logo, on every page, in the browser tab and on the home screen: the old radio's screen,
its tuning scale and orange needle, drawn flat to Apple's app icon guidelines, with a dark
version. The 2004 icon is still at /icon.png.
- Words that hide items. Settings has a list for every feed you read, and each feed's settings a
list of its own; an item with one of those words or phrases in its title or text is hidden
from you and is not downloaded on your account. Other people's lists never hide anything from

View File

@@ -67,6 +67,7 @@ pub fn router(state: WebState) -> Router {
.route("/login", get(login_page))
.route("/api/login", post(login))
.route("/icon.png", get(icon))
.route("/logo.svg", get(logo))
.route("/favicon.ico", get(favicon))
.route("/favicon.png", get(favicon))
.route("/apple-touch-icon.png", get(touch_icon))
@@ -552,8 +553,16 @@ fn script(js: &'static str) -> impl IntoResponse {
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], js)
}
/// The 2004 icon, served once for both pages rather than inlined as base64 into each. The
/// sign-in page shows it, so it sits outside the auth layer with /login.
/// The logo the pages show, served once rather than inlined into each. The sign-in page shows
/// it, so it sits outside the auth layer with /login.
async fn logo() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "max-age=86400")],
include_str!("../web/logo.svg"),
)
}
/// The 2004 icon. Nothing here shows it any more; it stays for whatever outside ipx links to it.
async fn icon() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
@@ -561,9 +570,8 @@ async fn icon() -> impl IntoResponse {
)
}
/// The logo, squared up with transparent padding: it is 128x121, and a tab icon that is not
/// square can be passed over. /favicon.ico is the same PNG, for a browser that asks for that
/// on its own; behind the auth layer it answered 401, and the tab stayed blank.
/// web/logo.svg at 128px. /favicon.ico is the same PNG, for a browser that asks for that on its
/// own; behind the auth layer it answered 401, and the tab stayed blank.
async fn favicon() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
@@ -571,8 +579,8 @@ async fn favicon() -> impl IntoResponse {
)
}
/// For an iPhone's home screen, which paints a transparent icon's background black, so this
/// one is on white.
/// web/logo.svg at 180px, for an iPhone's home screen. It is opaque edge to edge, as iOS paints
/// a transparent icon's background black and cuts its own corners.
async fn touch_icon() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],

View File

@@ -558,10 +558,10 @@ test('a second person has their own feeds and their own read state', async ({ br
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto('/login');
// The sign-in page shows the icon, so it has to load before anyone has signed in.
const icon = await page.request.get('/icon.png');
expect(icon.status()).toBe(200);
expect(icon.headers()['content-type']).toBe('image/png');
// 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');
expect(logo.status()).toBe(200);
expect(logo.headers()['content-type']).toBe('image/svg+xml');
await page.locator('#name').fill('sam');
await page.locator('#pw').fill('sampassword');
await page.locator('button[type=submit]').click();

View File

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

View File

@@ -495,7 +495,7 @@ a{color:var(--accent)}
}
.brand{display:flex;align-items:center;gap:9px;padding:14px 14px 10px}
.brand .logo{
width:30px;height:30px;flex:none;object-fit:contain;
width:30px;height:30px;flex:none;border-radius:7px;
}
.brand h1{font-size:16px;margin:0;font-weight:650;letter-spacing:-.01em;color:var(--fg);flex:1}
.iconbtn{
@@ -768,7 +768,7 @@ kbd{font:inherit;font-size:12px;color:var(--fg);background:var(--panel2);border:
body.adminpage{display:block;overflow:auto}
body.adminpage #topbar{position:sticky;top:0;z-index:5}
body.adminpage #topbar h1{font-size:16px;margin:0;font-weight:650}
body.adminpage #topbar .logo{border-radius:5px}
body.adminpage #topbar .logo{border-radius:6px}
body.adminpage #topbar > a.btn{text-decoration:none}
body.adminpage #admin{max-width:780px;margin:0 auto}
body.adminpage #admin h2{font-size:18px;margin:0 0 12px}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -34,7 +34,7 @@
<div id="shell">
<aside id="sidebar">
<div class="brand">
<img class="logo" src="/icon.png" alt="iPodderX" title="The original iPodderX icon, 2004"><h1>iPodderX</h1>
<img class="logo" src="/logo.svg" alt="iPodderX"><h1>iPodderX</h1>
</div>
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
<div id="feedlist"></div>

View File

@@ -55,9 +55,9 @@ form{
width:min(360px,100%);background:var(--panel);border:1px solid var(--line);
border-radius:14px;padding:22px;box-shadow:var(--shadow);
}
/* The one place the 2004 icon is shown at the size it was drawn for. */
/* The logo is a square app icon, so it gets an app icon's corners. */
.brand{display:flex;flex-direction:column;align-items:center;gap:6px;margin-bottom:20px}
.brand img{width:96px;height:auto}
.brand img{width:72px;height:72px;border-radius:16px}
h1{font-size:21px;margin:0;font-weight:650;letter-spacing:-.01em}
label{display:block;font-size:12px;color:var(--dim);margin:0 0 4px}
input{
@@ -74,7 +74,7 @@ button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
</style>
<form id="f">
<div class="brand"><img src="/icon.png" alt=""><h1>iPodderX</h1></div>
<div class="brand"><img src="/logo.svg" alt=""><h1>iPodderX</h1></div>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="username" autofocus required>
<label for="pw">Password</label>

19
web/logo-dark.svg Normal file
View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<!-- logo.svg's dark appearance: the same layers in the same places, only recoloured, as the
guidelines ask, so the icon reads as itself in either. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1d4468"/>
<stop offset="1" stop-color="#0c2238"/>
</linearGradient>
</defs>
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
<g id="scale" fill="#8fc2ea">
<rect x="120" y="520" width="112" height="304" rx="56" opacity=".45"/>
<rect x="288" y="360" width="112" height="464" rx="56" opacity=".7"/>
<rect x="456" y="440" width="112" height="384" rx="56" opacity=".55"/>
<rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/>
<rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/>
</g>
<g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#ff9f2e"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1003 B

22
web/logo.svg Normal file
View File

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<!-- The iPodderX radio's screen, reduced to its tuning scale and needle, laid out as Apple's
app icon guidelines ask: square and opaque (the system cuts the corners, and paints
transparency black), one background and flat foreground layers with hard edges, no
highlights or shadows of its own (the system adds them), nothing thin enough to vanish
at 32px. Each <g> is a layer, for splitting out into Icon Composer. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#dff0fb"/>
<stop offset="1" stop-color="#9fccef"/>
</linearGradient>
</defs>
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
<g id="scale" fill="#2f6aa0">
<rect x="120" y="520" width="112" height="304" rx="56" opacity=".45"/>
<rect x="288" y="360" width="112" height="464" rx="56" opacity=".7"/>
<rect x="456" y="440" width="112" height="384" rx="56" opacity=".55"/>
<rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/>
<rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/>
</g>
<g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#f7931e"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB