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
+23
View File
@@ -13,6 +13,25 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
> (Auto-update) + Phase 23 (Secrets & configs) + Phase 24 (Design System v2)
> complete.
## Upgrading to 0.53.0 — nothing to do
A dark-mode fix. 0.52.0 put every app logo on a white tile so that black line
art would not disappear; in dark mode that made each icon look like a sticker
pasted onto the row.
The tile is now the same neutral surface the rest of the UI uses, and only art
that genuinely vanishes into it gets a backing plate. The browser measures each
image once — how light it is *and* how colourful — because luminance alone gets
it wrong: Plex is dark orange and Home Assistant a mid blue, and both read
perfectly well on either ground. A plate needs low contrast **and** art with
essentially no colour of its own.
Across 66 common logos that means six get a plate in dark mode (Vaultwarden,
Tailscale, Frigate, Heimdall, Miniflux, MinIO — all solid black) and two in
light mode (Ollama, Open-WebUI — solid white). The other ~90% sit bare on the
tile, which is what they were always meant to do. Measuring falls back to "no
plate" wherever it cannot run.
## Upgrading to 0.52.0 — nothing to do
The icons 0.51.0 introduced are the **real app logos** now. A stack called
@@ -411,6 +430,10 @@ it is what your saved destination credentials are encrypted with.
512 KiB. Uploads live in `${DATA_DIR}/stack-icons/` and are
classified by their actual bytes, not by the filename or Content-Type the
browser claims. Cloning a stack copies its icon; deleting one removes it.
- **Legible in both themes.** A logo sits directly on the tile; only art that
would disappear into it — solid black in dark mode, solid white in light mode
— gets a backing plate. The browser measures each image's luminance and chroma
once to decide, so a dark *colourful* mark like Plex is left alone.
- **The status moved onto the icon.** In the stacks list and on the detail page
the status dot is gone: the icon carries a soft glow in the status colour
(green running, amber partial, red error, pulsing blue while an operation
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version."""
APP_VERSION = "0.52.0"
APP_VERSION = "0.53.0"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.52.0",
"version": "0.53.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ImageOff, Sparkles, Upload, X } from "lucide-react";
import { toast } from "sonner";
import { Button, Input } from "@/components/ui";
import { StackIcon } from "@/components/ui/StackIcon";
import { PLATE, StackIcon, useImagePlate } from "@/components/ui/StackIcon";
import { cn } from "@/lib/utils";
import { ICON_GROUPS, suggestIconName } from "@/lib/stackIcons";
import type { IconStack } from "@/lib/stackIcons";
@@ -348,10 +348,16 @@ function LogoThumb({ slug }: { slug: string }) {
return () => URL.revokeObjectURL(objectUrl);
}, [blob]);
const plate = useImagePlate(`logo:${slug}`, url);
return (
<span className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-md bg-white">
<span className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-md">
{url ? (
<img src={url} alt="" className="h-full w-full object-contain p-0.5" />
<img
src={url}
alt=""
className={cn("h-full w-full object-contain p-0.5", PLATE[plate])}
/>
) : (
<ImageOff className="h-3.5 w-3.5 text-slate-300" />
)}
+44 -4
View File
@@ -9,6 +9,8 @@ import {
} from "@/lib/stackIcons";
import type { IconStack } from "@/lib/stackIcons";
import { stacksApi } from "@/api/stacks";
import { imageTone, plateFor, type Plate, type Tone } from "@/lib/imagePlate";
import { useThemeStore } from "@/store/theme";
import type { StackStatus } from "@/types";
/**
@@ -21,7 +23,8 @@ import type { StackStatus } from "@/types";
*
* The icon is either an image the server holds — the app's real logo, or an
* upload — or a glyph derived from the name. Images are fetched through the API
* client because that endpoint needs the bearer token.
* client because that endpoint needs the bearer token, and sit directly on the
* tile; only art that would disappear into it gets a plate (see lib/imagePlate).
*/
type Size = "sm" | "md" | "lg";
@@ -32,6 +35,15 @@ const SIZES: Record<Size, { box: string; glyph: string; radius: string }> = {
lg: { box: "h-12 w-12", glyph: "h-6 w-6", radius: "rounded-[15px]" },
};
/** The ground an icon gets when it would otherwise vanish into the tile.
* Slightly inset and rounded, so it reads as part of the icon rather than as a
* second tile. */
export const PLATE: Record<Plate, string> = {
none: "",
light: "rounded-[7px] bg-white/95 p-[3px]",
dark: "rounded-[7px] bg-slate-900/90 p-[3px]",
};
/** Ring + halo per status. Kept as whole class strings because Tailwind only
* sees classes it can find literally in the source. */
const STATUS_STYLE: Record<StackStatus, { ring: string; halo: string; glyph: string }> = {
@@ -90,6 +102,9 @@ export function StackIcon({
resolved.kind === "image" && !previewUrl ? imageIconKey(stack) : null
);
const custom = previewUrl ?? stored;
// A pending file is keyed by its own object URL: "custom:pending" is the
// same string for every stack, so it would share one measurement.
const plate = useImagePlate(previewUrl ?? imageIconKey(stack) ?? stack.id, custom);
// The glyph doubles as the fallback for an image that cannot be fetched (a
// logo the server has not got yet), so derive it from the name either way
// rather than landing on the generic mark.
@@ -124,13 +139,11 @@ export function StackIcon({
)}
>
{custom ? (
// Logos are drawn for a light ground and many are dark line art, so
// the tile stays light in both themes rather than swallowing them.
// `contain`, not `cover`: a logo must not be cropped.
<img
src={custom}
alt=""
className="h-full w-full bg-white object-contain p-0.5"
className={cn("h-full w-full object-contain p-0.5", PLATE[plate])}
/>
) : (
<Glyph className={cn(dims.glyph, tone.glyph)} strokeWidth={2} />
@@ -175,3 +188,30 @@ function useStackImageUrl(stackId: string, icon: string | null | undefined): str
return url;
}
/**
* Whether this image needs a plate on the current theme, measured once.
*
* Starts at "none" and settles a frame later: a plate appearing is far less
* jarring than every icon flashing one while the measurement runs.
*/
export function useImagePlate(key: string, url: string | null): Plate {
const theme = useThemeStore((s) => s.theme);
const [tone, setTone] = useState<Tone | null>(null);
useEffect(() => {
if (!url) {
setTone(null);
return;
}
let live = true;
imageTone(key, url).then((value) => {
if (live) setTone(value);
});
return () => {
live = false;
};
}, [key, url]);
return plateFor(tone, theme);
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Which icons get a plate.
*
* Every number below was measured from the catalog's own PNGs, so this is the
* rule tested against the art it actually has to handle. The cases that matter:
* solid-black and solid-white line art must be rescued, and the colourful
* marks — which a luminance-only rule gets wrong, because dark orange and mid
* blue both sit in the middle — must be left alone.
*/
import { describe, expect, it } from "vitest";
import { plateFor, type Tone } from "./imagePlate";
const tone = (luminance: number, chroma: number): Tone => ({ luminance, chroma });
/** Measured: mean luminance and mean chroma of the real logos. */
const LOGO = {
// Monochrome, and at the ends of the scale.
vaultwarden: tone(0.0, 0.0),
tailscale: tone(0.0, 0.0),
heimdall: tone(0.0, 0.0),
minio: tone(0.02, 0.065),
ollama: tone(0.712, 0.0),
openWebui: tone(0.797, 0.0),
// Monochrome, but mid-grey: visible on both grounds already.
bazarr: tone(0.406, 0.0),
memos: tone(0.248, 0.037),
// Colourful. Dark or bright, hue carries them.
plex: tone(0.081, 0.149),
searxng: tone(0.137, 0.81),
jellyfin: tone(0.221, 0.36),
homeAssistant: tone(0.516, 0.35),
sonarr: tone(0.555, 0.115),
uptimeKuma: tone(0.794, 0.129),
};
describe("plateFor", () => {
it("leaves colourful logos alone in both themes", () => {
for (const logo of [
LOGO.plex,
LOGO.searxng,
LOGO.jellyfin,
LOGO.homeAssistant,
LOGO.sonarr,
LOGO.uptimeKuma,
]) {
expect(plateFor(logo, "dark")).toBe("none");
expect(plateFor(logo, "light")).toBe("none");
}
});
it("does not let a dark colour be mistaken for black", () => {
// Plex is dark *orange*: luminance alone would plate it, chroma saves it.
expect(plateFor(LOGO.plex, "dark")).toBe("none");
// Home Assistant is a mid blue — the case where a luminance-only rule
// plated a logo that is perfectly legible on white.
expect(plateFor(LOGO.homeAssistant, "light")).toBe("none");
});
it("gives black line art a light plate, but only in dark mode", () => {
for (const logo of [LOGO.vaultwarden, LOGO.tailscale, LOGO.heimdall, LOGO.minio]) {
expect(plateFor(logo, "dark")).toBe("light");
expect(plateFor(logo, "light")).toBe("none");
}
});
it("gives white line art a dark plate, but only in light mode", () => {
for (const logo of [LOGO.ollama, LOGO.openWebui]) {
expect(plateFor(logo, "light")).toBe("dark");
expect(plateFor(logo, "dark")).toBe("none");
}
});
it("leaves mid-grey art alone — it already has contrast on both", () => {
for (const logo of [LOGO.bazarr, LOGO.memos]) {
expect(plateFor(logo, "dark")).toBe("none");
expect(plateFor(logo, "light")).toBe("none");
}
});
it("adds nothing when the image could not be measured", () => {
// jsdom, a canvas that will not paint, an image that will not decode: the
// safe direction is no plate rather than one behind every icon.
expect(plateFor(null, "dark")).toBe("none");
expect(plateFor(null, "light")).toBe("none");
});
it("plates the extremes on the theme that swallows them", () => {
expect(plateFor(tone(0, 0), "dark")).toBe("light");
expect(plateFor(tone(1, 0), "light")).toBe("dark");
});
});
+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";
}