diff --git a/.gitignore b/.gitignore index f2ae863..e2e2acf 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,3 @@ 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/docs/superpowers/specs/2026-09-01-work-app-design.md b/docs/superpowers/specs/2026-09-01-work-app-design.md index 1fd864e..5273d91 100644 --- a/docs/superpowers/specs/2026-09-01-work-app-design.md +++ b/docs/superpowers/specs/2026-09-01-work-app-design.md @@ -252,47 +252,34 @@ 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. +Each app shows **the icon its own page declares**, exactly as a browser would. -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. +Two earlier attempts were wrong in the same way. Google's favicon service and Simple +Icons both answer a question about a *domain*, and a domain does not know which product +it is serving: the service returned a marketing site's icon for anything behind a login +and nothing for a private host, and Simple Icons returned a single flat brand mark where +the real one is multicoloured and, in Gmail's case, carries the unread count. -Each tile carries the **brand's own colour**, which Simple Icons publishes alongside the -glyph — Gmail red, Drive blue, Chat green, Gemini violet. The glyph is drawn black or -white depending on the tile's perceived brightness, because brand colours are chosen to -look right rather than to carry a white mark: GitHub and Notion are near-black, Snapchat -is pure yellow, and a fixed white glyph loses one end of that range. +The page has the answer already — fetched, authenticated, and current. So the injected +script reads `link[rel~="icon"]` and reports the best one: largest declared `sizes` wins, +an Apple touch icon counts as 180, and an `.ico` is penalised because it is usually the +16px one drawn for a browser tab in 2005. It rechecks on the same tick as the unread +count, because that is exactly when a site like Gmail redraws its icon with a badge on it. -A host with no mark falls back to a Tailwind 500 chosen by hashing the host, so it is -still stable — you find things by their colour, and one 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 and -hex, -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 URL is stored in `apps.json`, so the nav is right the moment it opens rather than +blank until every page has loaded. An app with nothing yet — or an icon that will not +load — falls back to its initial in a tile of the same size, so the row never reflows. ## The window -There is no title bar and no toolbar, so in a normal window the shell keeps a 6px margin -around itself, and the traffic lights are pushed in to sit within it rather than crowding -the corner — which also sets the depth of the nav's drag strip. +The window keeps its ordinary macOS title bar, with the traffic lights where every other +window puts them. It follows the app's own Light or Dark choice through `set_theme`; +"System" hands it back to the OS, which is what System means. -An app's corners are rounded on its own layer, not by the container around it: an app is a -native view sitting on top rather than something the shell lays out, so a `rounded-xl` on -its parent does nothing and it overhangs the curve. Only the right pair is rounded; the -left edge butts against the nav, and rounding it would cut a notch out of the middle of -the window. +An earlier version drew its own frame instead — a hidden title bar, a margin to grab, and +a corner radius applied to each app's layer because a native view sitting on top of the +shell cannot be clipped by the CSS around it. It is gone. A window that behaves like a +window is worth more than one that looks bespoke. The margin That margin is the only part of the window that is not a web page, and therefore the only place left to grab it by. Full screen has no use for it and gets the diff --git a/package-lock.json b/package-lock.json index b678395..016e9e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,7 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", "react": "^19.1.0", - "react-dom": "^19.1.0", - "simple-icons": "^16.29.0" + "react-dom": "^19.1.0" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", @@ -2509,25 +2508,6 @@ "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 02af6b0..288db41 100644 --- a/package.json +++ b/package.json @@ -5,18 +5,16 @@ "type": "module", "scripts": { "dev": "vite", - "build": "npm run icons && tsc && vite build", + "build": "tsc && vite build", "preview": "vite preview", "tauri": "tauri", - "ship": "./scripts/ship.sh", - "icons": "node scripts/build-brand-icons.mjs" + "ship": "./scripts/ship.sh" }, "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", "react": "^19.1.0", - "react-dom": "^19.1.0", - "simple-icons": "^16.29.0" + "react-dom": "^19.1.0" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", diff --git a/scripts/build-brand-icons.mjs b/scripts/build-brand-icons.mjs deleted file mode 100644 index 276668a..0000000 --- a/scripts/build-brand-icons.mjs +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Turns Simple Icons into one map of slug → [path, brand colour]. - * - * The package ships a file per icon and 15MB of SVG wrapper around what is - * really a single `d` attribute each, plus a separate metadata file carrying - * the brand's own hex. This joins the two and throws the rest away. - * - * 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. - * - * `npm run build` runs this, so the result is never committed and never stale. - */ -import { readFileSync, readdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - -const ICONS = "node_modules/simple-icons/icons"; -const META = "node_modules/simple-icons/data/simple-icons.json"; -const OUT = "public/brand-icons.json"; - -const hexes = new Map(); -for (const icon of JSON.parse(readFileSync(META, "utf8"))) { - hexes.set(icon.slug, icon.hex); -} - -const icons = {}; -for (const file of readdirSync(ICONS)) { - if (!file.endsWith(".svg")) continue; - const slug = file.slice(0, -4); - const path = /\sd="([^"]+)"/.exec(readFileSync(join(ICONS, file), "utf8"))?.[1]; - if (!path) continue; - icons[slug] = [path, hexes.get(slug) ?? "64748B"]; -} - -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 fcdcad3..994dded 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -41,6 +41,11 @@ impl AppState { config::save(&self.dir, &self.config.lock().unwrap()) } + /// Same, for the sentinel handlers that live outside this module. + pub fn save(&self) -> Result<(), String> { + self.persist() + } + /// Records a selector chosen by right-clicking it in the page. pub fn add_hidden(&self, app_id: &str, selector: &str) -> Result<(), String> { { @@ -298,6 +303,7 @@ pub fn add_app( group_id, user_agent: None, hidden: Vec::new(), + icon: None, zoom: 1.0, order, }; @@ -448,11 +454,27 @@ pub fn set_nav_collapsed(collapsed: bool, state: State<'_, AppState>) -> Result< } #[tauri::command] -pub fn set_theme(theme: String, state: State<'_, AppState>) -> Result<(), String> { - state.config.lock().unwrap().settings.theme = theme; +pub fn set_theme(theme: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), String> { + state.config.lock().unwrap().settings.theme = theme.clone(); + apply_window_theme(&app, &theme); state.persist() } +/// Puts the window's own title bar in the same light or dark as the app. +/// +/// `None` hands it back to the system, which is what "System" means — the bar +/// then follows the OS the way every other window does. +pub fn apply_window_theme(app: &AppHandle, theme: &str) { + let wanted = match theme { + "light" => Some(tauri::Theme::Light), + "dark" => Some(tauri::Theme::Dark), + _ => None, + }; + if let Some(w) = app.get_window("main") { + let _ = w.set_theme(wanted); + } +} + // ------------------------------------------------------- hidden elements /// Replaces an app's hidden selectors and re-applies them without a reload. diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 97ef2fb..53f9b8d 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -33,6 +33,10 @@ pub struct App { /// the thing you never want to see again. #[serde(default)] pub hidden: Vec, + /// The icon this app's own page last reported, kept so the nav is right + /// from the moment it opens rather than once every page has loaded. + #[serde(default)] + pub icon: Option, /// Page zoom, remembered per app: a dense ERP and a mail client do not /// want the same size. #[serde(default = "default_zoom")] @@ -187,6 +191,7 @@ pub fn seed() -> Config { group_id: Some(group.into()), user_agent: None, hidden: Vec::new(), + icon: None, zoom: 1.0, order, }; diff --git a/src-tauri/src/inject.js b/src-tauri/src/inject.js index f66e7dc..b21f391 100644 --- a/src-tauri/src/inject.js +++ b/src-tauri/src/inject.js @@ -445,6 +445,57 @@ }); } catch (e) { window.Notification = WorkNotification; } + /* ---------------------------------------------------------- the icon */ + + /* The site's own icon, read off the page it is on. + + An icon service asked about a domain can only guess, and it guesses badly: + it gets a marketing site's icon for a tool that lives behind a login, and + nothing at all for a private host. The page knows — it is carrying the + answer in its head, already fetched, already authenticated. Gmail even + redraws it with the unread count on it. + + Rechecked on the same tick as the count, because that is exactly when a + site like Gmail swaps it. */ + var lastIcon = null; + + function bestIcon() { + var links = document.querySelectorAll( + 'link[rel~="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]' + ); + var best = null; + var bestScore = -1; + for (var i = 0; i < links.length; i++) { + var href = links[i].getAttribute('href'); + if (!href) continue; + + /* Biggest declared size wins; an .ico is a last resort because it is + usually the 16px one drawn for a browser tab in 2005. */ + var sizes = links[i].getAttribute('sizes') || ''; + var size = parseInt((/(\d+)/.exec(sizes) || [])[1] || '0', 10); + var rel = (links[i].getAttribute('rel') || '').toLowerCase(); + var score = size || (rel.indexOf('apple') !== -1 ? 180 : 32); + if (/\.ico(\?|$)/i.test(href)) score -= 24; + + if (score > bestScore) { + bestScore = score; + best = href; + } + } + try { + return best ? new URL(best, document.baseURI).href : null; + } catch (e) { + return null; + } + } + + function checkIcon() { + var icon = bestIcon(); + if (!icon || icon === lastIcon) return; + lastIcon = icon; + send('icon', { u: icon }); + } + /* --------------------------------------------------- unread counting */ /* Sites put their unread count in the title — "Inbox (12)", "(3) Chat". @@ -491,6 +542,8 @@ } setInterval(checkUnread, 4000); + setInterval(checkIcon, 4000); + checkIcon(); setInterval(poke, 45000); /* ------------------------------------------------------------- start */ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9ee05f4..2fd9de5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -81,6 +81,9 @@ pub fn run() { // Asked for at startup rather than at the first notification, so // the prompt does not arrive attached to someone else's message. commands::ensure_notification_permission(&app.handle().clone()); + + let theme = app.state::().cfg().settings.theme; + commands::apply_window_theme(&app.handle().clone(), &theme); Ok(()) }) .invoke_handler(tauri::generate_handler![ diff --git a/src-tauri/src/webviews.rs b/src-tauri/src/webviews.rs index 4e4262f..b50b0f5 100644 --- a/src-tauri/src/webviews.rs +++ b/src-tauri/src/webviews.rs @@ -52,6 +52,13 @@ pub struct NotificationClick { pub notification_id: String, } +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IconEvent { + pub app_id: String, + pub icon: String, +} + #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct HiddenEvent { @@ -183,6 +190,31 @@ fn handle_sentinel( notify(&handle, &from, &name, &title, &format!("{total} unread"), String::new()); } + // The site told us its own icon. Stored rather than merely shown, + // so the nav is right at launch instead of blank until every page + // has finished loading. + "icon" => { + let Some(url) = params.get("u") else { return }; + let state = handle.state::(); + let changed = { + let mut cfg = state.config.lock().unwrap(); + match cfg.apps.iter_mut().find(|a| a.id == from) { + Some(a) if a.icon.as_deref() != Some(url.as_str()) => { + a.icon = Some(url.clone()); + true + } + _ => false, + } + }; + if changed { + let _ = state.save(); + let _ = handle.emit( + "icon-changed", + IconEvent { app_id: from.clone(), icon: url.clone() }, + ); + } + } + "manage" => { let _ = handle.emit("manage-hidden", from.clone()); } @@ -722,6 +754,7 @@ mod tests { group_id: None, user_agent: None, hidden: vec![".ad".into()], + icon: None, zoom: 1.0, order: 0, }; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index da7b2a4..c56d002 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -18,10 +18,7 @@ "height": 900, "minWidth": 900, "minHeight": 600, - "center": true, - "titleBarStyle": "Overlay", - "hiddenTitle": true, - "trafficLightPosition": { "x": 26, "y": 24 } + "center": true } ], "security": { "csp": null } diff --git a/src/App.tsx b/src/App.tsx index 3cee35c..d77f1f5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { listen } from "@tauri-apps/api/event"; -import { getCurrentWindow } from "@tauri-apps/api/window"; import * as api from "./api"; import Nav from "./components/Nav"; @@ -16,8 +15,6 @@ export default function App() { const [focusHidden, setFocusHidden] = useState(null); const [theme, setTheme] = useAppearance("system"); const [unread, setUnread] = useState>({}); - const [fullscreen, setFullscreen] = useState(false); - const fullscreenRef = useRef(false); const [backdrop, setBackdrop] = useState(null); const stageRef = useRef(null); @@ -29,23 +26,6 @@ export default function App() { const collapsed = config?.settings.navCollapsed ?? false; - /* The window has no frame of its own, so in a normal window the shell keeps - a margin around itself: somewhere to grab that is not a web page, and the - only way to move or place the window by hand. Full screen has no use for - it and gives the room back. */ - useEffect(() => { - const w = getCurrentWindow(); - const check = () => - void w.isFullscreen().then((f) => { - fullscreenRef.current = f; - setFullscreen(f); - }); - check(); - const un = w.onResized(check); - return () => { - void un.then((f) => f()); - }; - }, []); useEffect(() => { api.getConfig().then((c) => { @@ -67,9 +47,7 @@ export default function App() { const el = stageRef.current; if (!el) return; const r = el.getBoundingClientRect(); - // The radius goes with it: an app is a native view, so the container's - // rounded corners cannot clip it and its own layer has to be told. - void api.setStage(r.x, r.y, r.width, r.height, fullscreenRef.current ? 0 : 12); + void api.setStage(r.x, r.y, r.width, r.height, 0); }, []); useLayoutEffect(() => { @@ -94,9 +72,6 @@ export default function App() { void api.bootstrap(); }, [config, report]); - useEffect(() => { - report(); - }, [fullscreen, report]); useEffect(() => { if (activeId) void api.setActive(activeId); @@ -186,20 +161,8 @@ export default function App() { if (!config) return null; - const frame = fullscreen ? 0 : 6; - return ( -
-
+
- {settingsOpen && ( > | 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 = { - "gemini.google.com": "googlegemini", - "aistudio.google.com": "googlegemini", - "notebooklm.google.com": "googlegemini", - "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", - "claude.ai": "claude", - "chatgpt.com": "openai", - "chat.openai.com": "openai", - "linear.app": "linear", - "app.clickup.com": "clickup", - "app.hubspot.com": "hubspot", - "admin.shopify.com": "shopify", - "dash.cloudflare.com": "cloudflare", - "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 fallback palette, for a host with no brand mark of its own. - * - * Tailwind's 500s. 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 one 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 ""; - } -} - -/** - * Black or white for a glyph, whichever the tile can actually be read against. - * - * Brand colours are chosen to look right, not to carry a white glyph: GitHub - * and Notion are near-black, Snapchat is pure yellow. Perceived brightness - * decides, so both ends of that range stay legible. - */ -export function glyphOn(hex: string): string { - const n = Number.parseInt(hex.replace("#", ""), 16); - const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255]; - // Rec. 601 luma: green reads far brighter than blue at the same value. - return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? "#0f172a" : "#ffffff"; -} diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index 54465c3..db996d4 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -23,13 +23,6 @@ interface Props { */ const RAIL = 72; const PANEL = 240; -/** - * The strip the traffic lights sit in. Draggable, since there is no title bar. - * - * Deep enough to give them room: with the window's own frame around it too, - * anything shallower leaves them crowding the corner. - */ -const TITLEBAR = 48; export const navWidth = (collapsed: boolean) => (collapsed ? RAIL : PANEL); @@ -110,7 +103,7 @@ export default function Nav({ } > {active && } - + {(unread[app.id] ?? 0) > 0 && ( -
{/* Only the expander survives the rail's header. Back and forward are a two-finger swipe and reload is ⌘R, so a toolbar here would be clutter standing in for something nobody asked for. */} @@ -172,7 +164,7 @@ export default function Nav({ title={app.url} className={`${row} ${app.id === activeId ? active : inactive}`} > - + {app.name} {badge(app.id, app.id === activeId)} @@ -181,7 +173,6 @@ export default function Nav({ return (