From 17d13d2505768158d03172f5f4b8038f9ca4ecf6 Mon Sep 17 00:00:00 2001 From: Vincent Date: Tue, 1 Sep 2026 15:03:09 +0200 Subject: [PATCH] Show each site's own icon; restore the native title bar Both previous attempts asked a question about a domain, and a domain does not know which product it is serving. Google's favicon service returned a marketing site's icon for anything behind a login and nothing for a private host; Simple Icons returned one flat brand mark where the real one is multicoloured and, for Gmail, carries the unread count. The page already holds the answer - fetched, authenticated, current. The injected script now 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 as usually the 16px tab icon. It rechecks on the same tick as the unread count, which is when a site like Gmail redraws its icon with a badge. The URL is stored, so the nav is right at launch rather than blank until every page has loaded. The custom frame is gone with it: ordinary macOS title bar, traffic lights where every other window puts them, and the bar following the app's Light/Dark choice through set_theme. A window that behaves like a window beats one that looks bespoke. --- .gitignore | 3 - .../specs/2026-09-01-work-app-design.md | 55 +++---- package-lock.json | 22 +-- package.json | 8 +- scripts/build-brand-icons.mjs | 36 ----- src-tauri/src/commands.rs | 26 +++- src-tauri/src/config.rs | 5 + src-tauri/src/inject.js | 53 +++++++ src-tauri/src/lib.rs | 3 + src-tauri/src/webviews.rs | 33 ++++ src-tauri/tauri.conf.json | 5 +- src/App.tsx | 43 +----- src/brandIcons.ts | 142 ------------------ src/components/Nav.tsx | 13 +- src/components/Settings.tsx | 6 +- src/components/ui.tsx | 89 ++++------- src/types.ts | 2 + 17 files changed, 186 insertions(+), 358 deletions(-) delete mode 100644 scripts/build-brand-icons.mjs delete mode 100644 src/brandIcons.ts 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 (