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:
2026-09-01 14:45:41 +02:00
parent 1b6dabab0a
commit ada18d54ae
16 changed files with 251 additions and 76 deletions
+3
View File
@@ -2,3 +2,6 @@ node_modules
dist dist
src-tauri/target src-tauri/target
.DS_Store .DS_Store
# Generated from simple-icons by `npm run icons` on every build.
public/brand-icons.json
+1 -1
View File
@@ -4,7 +4,7 @@ A desktop browser for one thing only: the web tools you work in. A left nav list
clicking one shows it, and links between them navigate inside the app. Everything else clicking one shows it, and links between them navigate inside the app. Everything else
opens in your real browser. opens in your real browser.
Tauri 2 · Rust · React 19 · Tailwind CSS 4 · macOS Tauri 2 · Rust · React 19 · Tailwind CSS 4 · Simple Icons · macOS
There is no address bar, no tab strip, and no way to reach a site that is not on the list. There is no address bar, no tab strip, and no way to reach a site that is not on the list.
That is the point. That is the point.
@@ -250,6 +250,30 @@ the only thing that clears it; nothing else does, because nothing else means you
it. Counts live in memory rather than `apps.json`: a restart reloads every app anyway, and it. Counts live in memory rather than `apps.json`: a restart reloads every app anyway, and
a number that survived would be a claim the app can no longer support. a number that survived would be a claim the app can no longer support.
## App marks
Each app is drawn as its brand glyph in white on a round tile of its own colour, from
**Simple Icons** (CC0, ~3,400 marks). A host with no glyph gets its initial in the same
tile, so a private tool sits in the row looking like it belongs.
This replaced Google's favicon service, which was wrong as often as it was right: it
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. A "refresh icons" button could
not fix that, because the staleness was not local.
Colours are Tailwind 500s, chosen by hashing the host rather than at random — you find
things by their colour, and a colour that moved every launch would be worse than none.
The build turns Simple Icons' 15MB of SVG files into one 4.5MB map of slug to path data,
written to `public/` so it is fetched once at runtime rather than parsed into the JS
bundle at every start. The bundle stays at 233KB. The map is generated by `npm run icons`,
which `npm run build` runs, so it is never committed and never stale.
Matching a host to a glyph tries the registrable name first — `example.odoo.com` is Odoo,
not Aputure — because a self-hosted tool is nearly always on a subdomain of its vendor. A
short table handles the ones a domain cannot answer, which is most of Google: `google.com`
says only that it is Google, not which of a dozen products.
## The window ## The window
There is no title bar and no toolbar, so in a normal window the shell keeps a 6px margin There is no title bar and no toolbar, so in a normal window the shell keeps a 6px margin
+21 -1
View File
@@ -11,7 +11,8 @@
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0",
"simple-icons": "^16.29.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
@@ -2508,6 +2509,25 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/simple-icons": {
"version": "16.29.0",
"resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.29.0.tgz",
"integrity": "sha512-4H94f5ZgcCcgJroc902TFlFdgPu2IU2eD7+WebN2Z14xKYrKHeJ4UQcZwzgSuH4Rgzw+jp7sbHl40NefuIEYsg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/simple-icons"
},
{
"type": "github",
"url": "https://github.com/sponsors/simple-icons"
}
],
"license": "CC0-1.0",
"engines": {
"node": ">=0.12.18"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+5 -3
View File
@@ -5,16 +5,18 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "npm run icons && tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri", "tauri": "tauri",
"ship": "./scripts/ship.sh" "ship": "./scripts/ship.sh",
"icons": "node scripts/build-brand-icons.mjs"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0",
"simple-icons": "^16.29.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
+31
View File
@@ -0,0 +1,31 @@
/**
* Turns Simple Icons' 3,400 SVG files into one map of slug → path data.
*
* The package ships a file per icon and 15MB of SVG wrapper around what is
* really a single `d` attribute each. This keeps the attribute and throws the
* rest away, so the app carries about a megabyte instead of fifteen.
*
* The output goes to public/ rather than src/: it is fetched once at runtime
* instead of being parsed into the JS bundle at every startup.
*
* Run `npm run icons` after upgrading simple-icons. The output is committed,
* so a build never depends on this having been run.
*/
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const SRC = "node_modules/simple-icons/icons";
const OUT = "public/brand-icons.json";
const icons = {};
for (const file of readdirSync(SRC)) {
if (!file.endsWith(".svg")) continue;
const svg = readFileSync(join(SRC, file), "utf8");
const match = /\sd="([^"]+)"/.exec(svg);
if (!match) continue;
icons[file.slice(0, -4)] = match[1];
}
writeFileSync(OUT, JSON.stringify(icons));
const kb = Math.round(readFileSync(OUT).length / 1024);
console.log(`${Object.keys(icons).length} icons → ${OUT} (${kb} KB)`);
-11
View File
@@ -183,17 +183,6 @@ pub fn unread_counts(state: State<'_, AppState>) -> Vec<(String, u32)> {
unread_list(&state) unread_list(&state)
} }
/// Bumps the version every favicon URL carries, so a wrong one is refetched.
#[tauri::command]
pub fn refresh_favicons(state: State<'_, AppState>) -> Result<Config, String> {
{
let mut cfg = state.config.lock().unwrap();
cfg.settings.favicon_version = cfg.settings.favicon_version.wrapping_add(1);
}
state.persist()?;
Ok(state.cfg())
}
/// Hides every app, so a dialog is not painted over by a native view. /// Hides every app, so a dialog is not painted over by a native view.
/// A still of the app on screen, taken before a dialog covers it. /// A still of the app on screen, taken before a dialog covers it.
/// ///
-4
View File
@@ -77,9 +77,6 @@ pub struct Settings {
pub nav_collapsed: bool, pub nav_collapsed: bool,
#[serde(default = "default_theme")] #[serde(default = "default_theme")]
pub theme: String, pub theme: String,
/// Bumped to defeat a cached favicon that came back wrong.
#[serde(default)]
pub favicon_version: u32,
} }
fn default_theme() -> String { fn default_theme() -> String {
@@ -91,7 +88,6 @@ impl Default for Settings {
Self { Self {
nav_collapsed: false, nav_collapsed: false,
theme: default_theme(), theme: default_theme(),
favicon_version: 0,
} }
} }
} }
-1
View File
@@ -105,7 +105,6 @@ pub fn run() {
commands::set_nav_collapsed, commands::set_nav_collapsed,
commands::set_theme, commands::set_theme,
commands::unread_counts, commands::unread_counts,
commands::refresh_favicons,
commands::focus_window, commands::focus_window,
commands::notification_click, commands::notification_click,
commands::set_zoom, commands::set_zoom,
+2 -1
View File
@@ -20,7 +20,8 @@
"minHeight": 600, "minHeight": 600,
"center": true, "center": true,
"titleBarStyle": "Overlay", "titleBarStyle": "Overlay",
"hiddenTitle": true "hiddenTitle": true,
"trafficLightPosition": { "x": 19, "y": 18 }
} }
], ],
"security": { "csp": null } "security": { "csp": null }
-1
View File
@@ -52,5 +52,4 @@ export const probeApps = () => invoke<void>("probe_apps");
export const appReports = () => invoke<[string, string][]>("app_reports"); export const appReports = () => invoke<[string, string][]>("app_reports");
export const unreadCounts = () => invoke<[string, number][]>("unread_counts"); export const unreadCounts = () => invoke<[string, number][]>("unread_counts");
export const refreshFavicons = () => invoke<Config>("refresh_favicons");
export const stageSnapshot = () => invoke<string | null>("stage_snapshot"); export const stageSnapshot = () => invoke<string | null>("stage_snapshot");
+115
View File
@@ -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 "";
}
}
+2 -3
View File
@@ -33,7 +33,6 @@ export default function Nav({
onSelect, onToggleCollapse, onOpenSettings, onToggleGroup, onSelect, onToggleCollapse, onOpenSettings, onToggleGroup,
onBack, onForward, onReload, onBack, onForward, onReload,
}: Props) { }: Props) {
const iconV = config.settings.faviconVersion ?? 0;
const groups = [...config.groups].sort((a, b) => a.order - b.order); const groups = [...config.groups].sort((a, b) => a.order - b.order);
const inGroup = (id: string | null) => const inGroup = (id: string | null) =>
config.apps.filter((a) => a.groupId === id).sort((a, b) => a.order - b.order); 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" />} {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 && ( {(unread[app.id] ?? 0) > 0 && (
<span <span
title={`${unread[app.id]} new since you last looked`} title={`${unread[app.id]} new since you last looked`}
@@ -168,7 +167,7 @@ export default function Nav({
title={app.url} title={app.url}
className={`${row} ${app.id === activeId ? active : inactive}`} 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> <span className="truncate">{app.name}</span>
{badge(app.id, app.id === activeId)} {badge(app.id, app.id === activeId)}
</button> </button>
+3 -12
View File
@@ -99,16 +99,7 @@ export default function Settings({
{/* ----------------------------------------------------- apps */} {/* ----------------------------------------------------- apps */}
<section className="space-y-2"> <section className="space-y-2">
<div className="flex items-center justify-between">
<SectionHeading>Apps</SectionHeading> <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>
<div className="space-y-1.5"> <div className="space-y-1.5">
{config.apps.length === 0 && <p className={HELP}>Nothing yet. Add the first one below.</p>} {config.apps.length === 0 && <p className={HELP}>Nothing yet. Add the first one below.</p>}
{orderedApps.map((app) => ( {orderedApps.map((app) => (
@@ -116,7 +107,7 @@ export default function Settings({
key={app.id} key={app.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800" 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 <input
value={app.name} value={app.name}
onChange={(e) => patch(app, { name: e.target.value })} onChange={(e) => patch(app, { name: e.target.value })}
@@ -192,7 +183,7 @@ export default function Settings({
{withHidden.map((app) => ( {withHidden.map((app) => (
<div key={app.id} className={SUBPANEL}> <div key={app.id} className={SUBPANEL}>
<div className="mb-2 flex items-center gap-2"> <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> <span className="text-[12px] font-medium">{app.name}</span>
<Badge tone="accent">{app.hidden.length}</Badge> <Badge tone="accent">{app.hidden.length}</Badge>
</div> </div>
@@ -240,7 +231,7 @@ export default function Settings({
key={app.id} key={app.id}
className="flex items-center gap-3 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800" 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> <span className="w-28 shrink-0 truncate text-[12px]">{app.name}</span>
<input <input
type="range" type="range"
+40 -33
View File
@@ -5,7 +5,9 @@
* Ported from FlightTube: slate and sky, a 915px type ladder, outline-first * Ported from FlightTube: slate and sky, a 915px type ladder, outline-first
* controls, borders for separation and shadows only for elevation. * 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. */ /** Every control in the app is this tall, so a row of mixed ones lines up. */
export const CONTROL_H = "h-[30px]"; 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 * An app's mark: its brand glyph, white, on a round tile of its own colour.
* /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. * 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({ export function Favicon({
url, url,
name, name,
size = 16, size = 16,
version = 0,
}: { }: {
url: string; url: string;
name: string; name: string;
size?: number; size?: number;
/** Bumped by "Refresh icons" to get past a wrongly cached one. */
version?: number;
}) { }) {
let host = ""; const [icons, setIcons] = useState<Record<string, string> | null>(null);
try {
host = new URL(url).hostname; useEffect(() => {
} catch { let live = true;
host = ""; 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() || "?"; const letter = name.trim().charAt(0).toUpperCase() || "?";
return ( return (
<span <span
className="relative grid shrink-0 place-items-center overflow-hidden rounded" className="grid shrink-0 place-items-center rounded-full"
style={{ width: size, height: size }} style={{ width: size, height: size, background: colour }}
>
<span
aria-hidden 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" {path ? (
style={{ fontSize: Math.max(9, size * 0.5) }} <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} {letter}
</span> </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";
}}
/>
)} )}
</span> </span>
); );
-1
View File
@@ -22,7 +22,6 @@ export interface Group {
export interface Settings { export interface Settings {
navCollapsed: boolean; navCollapsed: boolean;
theme: "system" | "light" | "dark"; theme: "system" | "light" | "dark";
faviconVersion: number;
} }
export interface Config { export interface Config {