Draw apps with Simple Icons instead of fetched favicons
The favicon service was wrong as often as it was right: a sign-in page's icon for anything behind a login, nothing at all for a private host, and both answers cached past any way of asking again. Refreshing could not fix it, because the staleness was not local. Now every mark is. Each app is its brand glyph in white on a round tile, coloured from Tailwind's 500s by hashing the host - you find things by their colour, so one that moved every launch would be worse than none. A host with no glyph gets its initial in the same tile. Matching tries the registrable name first, since a self-hosted tool is nearly always on a subdomain of its vendor - aputure.odoo.com is Odoo, not Aputure. A short table covers what a domain cannot answer, which is most of Google. The build reduces Simple Icons' 15MB of SVGs to one 4.5MB map in public/, fetched once rather than parsed into the bundle at every start; the bundle stays at 233KB. It is generated on every build, so never committed and never stale. Traffic lights are offset to sit inside the window margin rather than crowding its edge.
This commit is contained in:
@@ -52,5 +52,4 @@ export const probeApps = () => invoke<void>("probe_apps");
|
||||
export const appReports = () => invoke<[string, string][]>("app_reports");
|
||||
|
||||
export const unreadCounts = () => invoke<[string, number][]>("unread_counts");
|
||||
export const refreshFavicons = () => invoke<Config>("refresh_favicons");
|
||||
export const stageSnapshot = () => invoke<string | null>("stage_snapshot");
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Brand glyphs for the nav, from Simple Icons (CC0).
|
||||
*
|
||||
* The favicon service this replaced was wrong as often as it was right: it
|
||||
* cached a sign-in page's icon for anything behind a login, served nothing at
|
||||
* all for private hosts, and could not be made to forget either. These are
|
||||
* local, so they are the same every time.
|
||||
*/
|
||||
|
||||
/** Fetched once, lazily — it is four megabytes and nothing needs it at boot. */
|
||||
let pending: Promise<Record<string, string>> | null = null;
|
||||
|
||||
export function loadBrandIcons(): Promise<Record<string, string>> {
|
||||
pending ??= fetch("/brand-icons.json")
|
||||
.then((r) => (r.ok ? r.json() : {}))
|
||||
.catch(() => ({}));
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts whose icon is not simply their domain name.
|
||||
*
|
||||
* Everything Google serves off `google.com` needs this, since the domain says
|
||||
* only that it is Google and not which of a dozen products you are looking at.
|
||||
*/
|
||||
const KNOWN: Record<string, string> = {
|
||||
"mail.google.com": "gmail",
|
||||
"drive.google.com": "googledrive",
|
||||
"chat.google.com": "googlechat",
|
||||
"calendar.google.com": "googlecalendar",
|
||||
"docs.google.com": "googledocs",
|
||||
"sheets.google.com": "googlesheets",
|
||||
"slides.google.com": "googleslides",
|
||||
"meet.google.com": "googlemeet",
|
||||
"keep.google.com": "googlekeep",
|
||||
"photos.google.com": "googlephotos",
|
||||
"analytics.google.com": "googleanalytics",
|
||||
"ads.google.com": "googleads",
|
||||
"console.cloud.google.com": "googlecloud",
|
||||
"outlook.office.com": "microsoftoutlook",
|
||||
"outlook.office365.com": "microsoftoutlook",
|
||||
"teams.microsoft.com": "microsoftteams",
|
||||
"onedrive.live.com": "microsoftonedrive",
|
||||
"sharepoint.com": "microsoftsharepoint",
|
||||
"web.whatsapp.com": "whatsapp",
|
||||
"news.ycombinator.com": "ycombinator",
|
||||
"x.com": "x",
|
||||
"app.asana.com": "asana",
|
||||
"app.slack.com": "slack",
|
||||
"mail.proton.me": "protonmail",
|
||||
};
|
||||
|
||||
/**
|
||||
* The Simple Icons slug for a host, or null to fall back to a monogram.
|
||||
*
|
||||
* Tries the registrable name first — `example.odoo.com` is Odoo, not Aputure —
|
||||
* because a self-hosted tool is nearly always on a subdomain of the vendor.
|
||||
*/
|
||||
export function slugForHost(host: string, icons: Record<string, string>): string | null {
|
||||
const h = host.toLowerCase().replace(/^www\./, "");
|
||||
if (KNOWN[h]) return KNOWN[h] in icons ? KNOWN[h] : null;
|
||||
|
||||
const parts = h.split(".");
|
||||
const candidates = [
|
||||
parts.length >= 2 ? parts[parts.length - 2] : null, // odoo.com → odoo
|
||||
h.replace(/\./g, ""), // x.com → xcom
|
||||
parts[0], // github.com → github
|
||||
parts.length >= 3 ? `${parts[0]}${parts[parts.length - 2]}` : null,
|
||||
].filter((c): c is string => !!c);
|
||||
|
||||
return candidates.find((c) => c in icons) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The palette the marks are drawn on.
|
||||
*
|
||||
* Tailwind's 500s, minus the ones that turn to mud behind a white glyph. The
|
||||
* choice is a hash rather than a random number so an app keeps its colour —
|
||||
* you learn where things are by their colour, and a colour that moved every
|
||||
* launch would be worse than none.
|
||||
*/
|
||||
const PALETTE = [
|
||||
"#0ea5e9", // sky
|
||||
"#6366f1", // indigo
|
||||
"#8b5cf6", // violet
|
||||
"#d946ef", // fuchsia
|
||||
"#ec4899", // pink
|
||||
"#f43f5e", // rose
|
||||
"#f97316", // orange
|
||||
"#f59e0b", // amber
|
||||
"#84cc16", // lime
|
||||
"#22c55e", // green
|
||||
"#10b981", // emerald
|
||||
"#14b8a6", // teal
|
||||
"#06b6d4", // cyan
|
||||
"#3b82f6", // blue
|
||||
"#a855f7", // purple
|
||||
"#64748b", // slate, for the ones that want to be quiet
|
||||
];
|
||||
|
||||
export function colourFor(key: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
hash = (hash * 31 + key.charCodeAt(i)) | 0;
|
||||
}
|
||||
return PALETTE[Math.abs(hash) % PALETTE.length];
|
||||
}
|
||||
|
||||
export function hostOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,6 @@ export default function Nav({
|
||||
onSelect, onToggleCollapse, onOpenSettings, onToggleGroup,
|
||||
onBack, onForward, onReload,
|
||||
}: Props) {
|
||||
const iconV = config.settings.faviconVersion ?? 0;
|
||||
const groups = [...config.groups].sort((a, b) => a.order - b.order);
|
||||
const inGroup = (id: string | null) =>
|
||||
config.apps.filter((a) => a.groupId === id).sort((a, b) => a.order - b.order);
|
||||
@@ -106,7 +105,7 @@ export default function Nav({
|
||||
}
|
||||
>
|
||||
{active && <span className="absolute left-0 h-5 w-[3px] rounded-full bg-sky-500" />}
|
||||
<Favicon url={app.url} name={app.name} size={20} version={iconV} />
|
||||
<Favicon url={app.url} name={app.name} size={20} />
|
||||
{(unread[app.id] ?? 0) > 0 && (
|
||||
<span
|
||||
title={`${unread[app.id]} new since you last looked`}
|
||||
@@ -168,7 +167,7 @@ export default function Nav({
|
||||
title={app.url}
|
||||
className={`${row} ${app.id === activeId ? active : inactive}`}
|
||||
>
|
||||
<Favicon url={app.url} name={app.name} version={iconV} />
|
||||
<Favicon url={app.url} name={app.name} />
|
||||
<span className="truncate">{app.name}</span>
|
||||
{badge(app.id, app.id === activeId)}
|
||||
</button>
|
||||
|
||||
@@ -99,16 +99,7 @@ export default function Settings({
|
||||
|
||||
{/* ----------------------------------------------------- apps */}
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<SectionHeading>Apps</SectionHeading>
|
||||
<button
|
||||
onClick={() => run(() => api.refreshFavicons())}
|
||||
title="Fetch every icon again — one that cached wrong will not fix itself"
|
||||
className={BTN}
|
||||
>
|
||||
Refresh icons
|
||||
</button>
|
||||
</div>
|
||||
<SectionHeading>Apps</SectionHeading>
|
||||
<div className="space-y-1.5">
|
||||
{config.apps.length === 0 && <p className={HELP}>Nothing yet. Add the first one below.</p>}
|
||||
{orderedApps.map((app) => (
|
||||
@@ -116,7 +107,7 @@ export default function Settings({
|
||||
key={app.id}
|
||||
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
|
||||
>
|
||||
<Favicon url={app.url} name={app.name} version={config.settings.faviconVersion ?? 0} />
|
||||
<Favicon url={app.url} name={app.name} />
|
||||
<input
|
||||
value={app.name}
|
||||
onChange={(e) => patch(app, { name: e.target.value })}
|
||||
@@ -192,7 +183,7 @@ export default function Settings({
|
||||
{withHidden.map((app) => (
|
||||
<div key={app.id} className={SUBPANEL}>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Favicon url={app.url} name={app.name} version={config.settings.faviconVersion ?? 0} />
|
||||
<Favicon url={app.url} name={app.name} />
|
||||
<span className="text-[12px] font-medium">{app.name}</span>
|
||||
<Badge tone="accent">{app.hidden.length}</Badge>
|
||||
</div>
|
||||
@@ -240,7 +231,7 @@ export default function Settings({
|
||||
key={app.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
|
||||
>
|
||||
<Favicon url={app.url} name={app.name} version={config.settings.faviconVersion ?? 0} />
|
||||
<Favicon url={app.url} name={app.name} />
|
||||
<span className="w-28 shrink-0 truncate text-[12px]">{app.name}</span>
|
||||
<input
|
||||
type="range"
|
||||
|
||||
+43
-36
@@ -5,7 +5,9 @@
|
||||
* Ported from FlightTube: slate and sky, a 9–15px type ladder, outline-first
|
||||
* controls, borders for separation and shadows only for elevation.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import { colourFor, hostOf, loadBrandIcons, slugForHost } from "../brandIcons";
|
||||
|
||||
/** Every control in the app is this tall, so a row of mixed ones lines up. */
|
||||
export const CONTROL_H = "h-[30px]";
|
||||
@@ -201,55 +203,60 @@ export function Dialog({
|
||||
}
|
||||
|
||||
/**
|
||||
* An app's mark. Google's favicon service is used rather than the site's own
|
||||
* /favicon.ico, because many work tools sit behind a login that would return
|
||||
* the sign-in page's icon — or a 403 — to a request without a session.
|
||||
* An app's mark: its brand glyph, white, on a round tile of its own colour.
|
||||
*
|
||||
* Local rather than fetched. The favicon service this replaced returned a
|
||||
* sign-in page's icon for anything behind a login, nothing at all for a
|
||||
* private host, and cached both answers past any way of asking again.
|
||||
*/
|
||||
export function Favicon({
|
||||
url,
|
||||
name,
|
||||
size = 16,
|
||||
version = 0,
|
||||
}: {
|
||||
url: string;
|
||||
name: string;
|
||||
size?: number;
|
||||
/** Bumped by "Refresh icons" to get past a wrongly cached one. */
|
||||
version?: number;
|
||||
}) {
|
||||
let host = "";
|
||||
try {
|
||||
host = new URL(url).hostname;
|
||||
} catch {
|
||||
host = "";
|
||||
}
|
||||
const [icons, setIcons] = useState<Record<string, string> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void loadBrandIcons().then((i) => live && setIcons(i));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const host = hostOf(url);
|
||||
const slug = icons ? slugForHost(host, icons) : null;
|
||||
const path = slug ? icons?.[slug] : undefined;
|
||||
const colour = colourFor(host || name);
|
||||
const letter = name.trim().charAt(0).toUpperCase() || "?";
|
||||
|
||||
return (
|
||||
<span
|
||||
className="relative grid shrink-0 place-items-center overflow-hidden rounded"
|
||||
style={{ width: size, height: size }}
|
||||
className="grid shrink-0 place-items-center rounded-full"
|
||||
style={{ width: size, height: size, background: colour }}
|
||||
aria-hidden
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 grid place-items-center rounded bg-slate-200
|
||||
text-[9px] font-bold text-slate-500 dark:bg-slate-700 dark:text-slate-300"
|
||||
style={{ fontSize: Math.max(9, size * 0.5) }}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
{host && (
|
||||
<img
|
||||
src={`https://www.google.com/s2/favicons?sz=64&domain=${host}&v=${version}`}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
className="relative rounded"
|
||||
onError={(e) => {
|
||||
// Leave the letter showing rather than a broken-image glyph.
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
{path ? (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="#fff"
|
||||
style={{ width: size * 0.58, height: size * 0.58 }}
|
||||
>
|
||||
<path d={path} />
|
||||
</svg>
|
||||
) : (
|
||||
/* No glyph for this host — its initial, in the same round tile, so a
|
||||
private tool sits in the row looking like it belongs. */
|
||||
<span
|
||||
className="font-semibold leading-none text-white"
|
||||
style={{ fontSize: Math.max(8, size * 0.5) }}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,6 @@ export interface Group {
|
||||
export interface Settings {
|
||||
navCollapsed: boolean;
|
||||
theme: "system" | "light" | "dark";
|
||||
faviconVersion: number;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
|
||||
Reference in New Issue
Block a user