Plate only the logos that need one, instead of all of them (0.53.0)
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:
1 parent
b629d1b2c2
commit
76a228314a
7 files changed
+290
-9
No files matched your search
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user