diff --git a/README.md b/README.md index a8f2397..a1f083a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/version.py b/backend/version.py index 96ee23f..3af2d41 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.52.0" +APP_VERSION = "0.53.0" diff --git a/frontend/package.json b/frontend/package.json index 60a2dc8..2b5ee59 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.52.0", + "version": "0.53.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/stacks/IconPicker.tsx b/frontend/src/components/stacks/IconPicker.tsx index b058e6a..69543b6 100644 --- a/frontend/src/components/stacks/IconPicker.tsx +++ b/frontend/src/components/stacks/IconPicker.tsx @@ -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 ( - + {url ? ( - + ) : ( )} diff --git a/frontend/src/components/ui/StackIcon.tsx b/frontend/src/components/ui/StackIcon.tsx index 5fab567..e92d124 100644 --- a/frontend/src/components/ui/StackIcon.tsx +++ b/frontend/src/components/ui/StackIcon.tsx @@ -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 = { 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 = { + 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 = { @@ -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. ) : ( @@ -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(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); +} diff --git a/frontend/src/lib/imagePlate.test.ts b/frontend/src/lib/imagePlate.test.ts new file mode 100644 index 0000000..d48a3ff --- /dev/null +++ b/frontend/src/lib/imagePlate.test.ts @@ -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"); + }); +}); diff --git a/frontend/src/lib/imagePlate.ts b/frontend/src/lib/imagePlate.ts new file mode 100644 index 0000000..1d02307 --- /dev/null +++ b/frontend/src/lib/imagePlate.ts @@ -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, 0–1. */ + luminance: number; + /** Mean chroma (max − min channel), 0–1. Near zero means grey/black/white. */ + chroma: number; +} + +const cache = new Map(); + +/** + * 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 { + 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 { + 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 { + 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"; +}