Plate only the logos that need one, instead of all of them (0.53.0)
CI / check (push) Successful in 12m3s
CI / build-and-push (push) Successful in 1m56s

0.52.0 put every app logo on a white tile. The reason was real — a good number
of logos are solid black line art and vanish into a dark surface — but the cure
was applied to all of them, and in dark mode that makes each row look like it
has a sticker pasted on it.

So the tile goes back to the same neutral surface everything else uses, and the
decision is made per image. The browser already holds the bytes, so it draws
each one into a 24px canvas once and measures it: no new dependency, no second
request, and it covers uploads as well as catalog logos.

Measuring luminance alone was the first attempt and it is wrong. It plates Home
Assistant, whose logo is a mid-blue house that reads on anything, and it plates
Plex, which is dark *orange* — mean luminance cannot see that hue is doing the
work. So chroma is measured too, and a plate requires low contrast **and** art
with essentially no colour of its own. The threshold sits between the logos that
only look monochrome (Sonarr 0.115, Uptime Kuma 0.129) and the ones that are
(MinIO 0.065, Memos 0.037).

Checked against 66 real logos rather than guessed: six get a plate in dark mode
(Vaultwarden, Tailscale, Frigate, Heimdall, Miniflux, MinIO, all solid black),
two in light mode (Ollama, Open-WebUI, solid white). The other ~90% sit bare.
Mid-grey art like Bazarr is deliberately left alone — it already has contrast
against both grounds, and a plate would be noise.

Every failure path returns "no plate": jsdom with no canvas, a blocked canvas, an
image that will not decode. Being wrong in that direction costs contrast on a
handful of icons; being wrong the other way is the sticker problem again. The
unit test is built from the measured luminance/chroma pairs, so it tests the
rule against the art it actually has to handle.

0.53.0 because 0.52.0's images are already in the registry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-09-17 11:08:08 +02:00
co-authored by Claude Opus 5
parent b629d1b2c2
commit 76a228314a
7 changed files with 290 additions and 9 deletions
+121
View File
@@ -0,0 +1,121 @@
/**
* Does this icon need a plate behind it to stay visible?
*
* App logos come as they are: most are colourful marks that read on any ground,
* but a good number are monochrome line art — Vaultwarden, Tailscale, Frigate
* and Heimdall are solid black, Open-WebUI and Ollama solid white. Measured
* across 66 common logos, those are the ones that disappear into a tile.
*
* Putting every logo on a white plate fixes them and makes the other 80% look
* like stickers, which is what is wrong with it in dark mode. So the decision is
* made per image: the browser already holds the bytes, so it measures them once.
*
* Two numbers, not one. Mean luminance alone plates things that read perfectly
* well — Plex is dark *orange* and Home Assistant a mid blue, and both are
* obvious against either ground, because hue carries them. So a plate needs low
* contrast **and** art with essentially no colour of its own.
*
* Everything here degrades to "no plate" — a canvas that will not paint, an
* image that will not decode, jsdom in the test run. Being wrong that way costs
* contrast on a handful of icons; being wrong the other way would put a plate
* behind all of them.
*/
/** WCAG relative luminance of one 8-bit channel. */
function channel(value: number): number {
const c = value / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}
function contrast(a: number, b: number): number {
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
/** Relative luminance of the tile the icon sits on, per theme. Kept in step
* with --sp-surface-2 in styles/tokens.css. */
const TILE = { light: 0.93, dark: 0.0145 };
/** Below this, art and tile are too close to tell apart. 3:1 is the WCAG bar
* for graphics; 2:1 is deliberately lower, because a plate is itself a visual
* cost and this should only fire for art that genuinely disappears. */
const MIN_CONTRAST = 2;
/** Above this an icon has a colour of its own, and hue does the work that
* luminance cannot. Sits below the measured values for the logos that only
* look monochrome (Sonarr 0.115, Uptime Kuma 0.129) and above the ones that
* really are (MinIO 0.065, Memos 0.037). */
const MAX_MONOCHROME_CHROMA = 0.1;
export interface Tone {
/** Mean WCAG relative luminance of the opaque pixels, 01. */
luminance: number;
/** Mean chroma (max min channel), 01. Near zero means grey/black/white. */
chroma: number;
}
const cache = new Map<string, Tone | null>();
/**
* How light and how colourful an image is, or null if it cannot be measured.
* Cached per `key`, so a logo shared by ten stacks is measured once.
*/
export async function imageTone(key: string, url: string): Promise<Tone | null> {
const hit = cache.get(key);
if (hit !== undefined) return hit;
const value = await measure(url);
cache.set(key, value);
return value;
}
async function measure(url: string): Promise<Tone | null> {
try {
const image = await load(url);
// 24px is plenty: this is a single average, not a thumbnail, and it keeps
// a 1024px logo from being decoded at full size for one number.
const size = 24;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return null;
ctx.drawImage(image, 0, 0, size, size);
const { data } = ctx.getImageData(0, 0, size, size);
let luminance = 0;
let chroma = 0;
let counted = 0;
for (let i = 0; i < data.length; i += 4) {
// Anti-aliased edges are half-transparent and would drag a solid logo's
// average toward the middle; only count pixels that are really there.
if (data[i + 3] < 60) continue;
const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
luminance += 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
chroma += (Math.max(r, g, b) - Math.min(r, g, b)) / 255;
counted += 1;
}
if (counted === 0) return null;
return { luminance: luminance / counted, chroma: chroma / counted };
} catch {
return null;
}
}
function load(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("decode failed"));
image.src = url;
});
}
export type Plate = "none" | "light" | "dark";
/** The plate this art needs on this theme's tile. */
export function plateFor(tone: Tone | null, theme: "light" | "dark"): Plate {
if (tone === null) return "none";
if (tone.chroma > MAX_MONOCHROME_CHROMA) return "none";
if (contrast(tone.luminance, TILE[theme]) >= MIN_CONTRAST) return "none";
// Put the art on the ground it was drawn for: black art wants a light plate.
return tone.luminance < 0.5 ? "light" : "dark";
}