/** * 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"; }