diff --git a/.gitignore b/.gitignore index e2e2acf..f2ae863 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ node_modules dist src-tauri/target .DS_Store + +# Generated from simple-icons by `npm run icons` on every build. +public/brand-icons.json diff --git a/README.md b/README.md index a471f36..f0198e9 100644 --- a/README.md +++ b/README.md @@ -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 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. That is the point. diff --git a/docs/superpowers/specs/2026-09-01-work-app-design.md b/docs/superpowers/specs/2026-09-01-work-app-design.md index 7e300c8..f870751 100644 --- a/docs/superpowers/specs/2026-09-01-work-app-design.md +++ b/docs/superpowers/specs/2026-09-01-work-app-design.md @@ -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 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 There is no title bar and no toolbar, so in a normal window the shell keeps a 6px margin diff --git a/package-lock.json b/package-lock.json index 016e9e8..b678395 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "simple-icons": "^16.29.0" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", @@ -2508,6 +2509,25 @@ "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 288db41..02af6b0 100644 --- a/package.json +++ b/package.json @@ -5,16 +5,18 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "build": "npm run icons && tsc && vite build", "preview": "vite preview", "tauri": "tauri", - "ship": "./scripts/ship.sh" + "ship": "./scripts/ship.sh", + "icons": "node scripts/build-brand-icons.mjs" }, "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "simple-icons": "^16.29.0" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", diff --git a/scripts/build-brand-icons.mjs b/scripts/build-brand-icons.mjs new file mode 100644 index 0000000..b75c32d --- /dev/null +++ b/scripts/build-brand-icons.mjs @@ -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)`); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 2649ccd..13217c0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -183,17 +183,6 @@ pub fn unread_counts(state: State<'_, AppState>) -> Vec<(String, u32)> { 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 { - { - 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. /// A still of the app on screen, taken before a dialog covers it. /// diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 7d8559a..97ef2fb 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -77,9 +77,6 @@ pub struct Settings { pub nav_collapsed: bool, #[serde(default = "default_theme")] pub theme: String, - /// Bumped to defeat a cached favicon that came back wrong. - #[serde(default)] - pub favicon_version: u32, } fn default_theme() -> String { @@ -91,7 +88,6 @@ impl Default for Settings { Self { nav_collapsed: false, theme: default_theme(), - favicon_version: 0, } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7e12700..9ee05f4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -105,7 +105,6 @@ pub fn run() { commands::set_nav_collapsed, commands::set_theme, commands::unread_counts, - commands::refresh_favicons, commands::focus_window, commands::notification_click, commands::set_zoom, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ef3ee6d..5e40313 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -20,7 +20,8 @@ "minHeight": 600, "center": true, "titleBarStyle": "Overlay", - "hiddenTitle": true + "hiddenTitle": true, + "trafficLightPosition": { "x": 19, "y": 18 } } ], "security": { "csp": null } diff --git a/src/api.ts b/src/api.ts index a9e526f..63a8f7e 100644 --- a/src/api.ts +++ b/src/api.ts @@ -52,5 +52,4 @@ export const probeApps = () => invoke("probe_apps"); export const appReports = () => invoke<[string, string][]>("app_reports"); export const unreadCounts = () => invoke<[string, number][]>("unread_counts"); -export const refreshFavicons = () => invoke("refresh_favicons"); export const stageSnapshot = () => invoke("stage_snapshot"); diff --git a/src/brandIcons.ts b/src/brandIcons.ts new file mode 100644 index 0000000..fa7a8d9 --- /dev/null +++ b/src/brandIcons.ts @@ -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> | null = null; + +export function loadBrandIcons(): Promise> { + 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 = { + "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 | 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 ""; + } +} diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index 1b59aea..778b9ca 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -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 && } - + {(unread[app.id] ?? 0) > 0 && ( - + {app.name} {badge(app.id, app.id === activeId)} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 2b7ed4a..49a228b 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -99,16 +99,7 @@ export default function Settings({ {/* ----------------------------------------------------- apps */}
-
- Apps - -
+ Apps
{config.apps.length === 0 &&

Nothing yet. Add the first one below.

} {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" > - + patch(app, { name: e.target.value })} @@ -192,7 +183,7 @@ export default function Settings({ {withHidden.map((app) => (
- + {app.name} {app.hidden.length}
@@ -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" > - + {app.name} | 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 ( - - {letter} - - {host && ( - { - // Leave the letter showing rather than a broken-image glyph. - e.currentTarget.style.display = "none"; - }} - /> + {path ? ( + + + + ) : ( + /* No glyph for this host — its initial, in the same round tile, so a + private tool sits in the row looking like it belongs. */ + + {letter} + )} ); diff --git a/src/types.ts b/src/types.ts index 914e304..d8761f3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,7 +22,6 @@ export interface Group { export interface Settings { navCollapsed: boolean; theme: "system" | "light" | "dark"; - faviconVersion: number; } export interface Config {