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.
This commit is contained in:
+2
-41
@@ -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<string | null>(null);
|
||||
const [theme, setTheme] = useAppearance("system");
|
||||
const [unread, setUnread] = useState<Record<string, number>>({});
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const fullscreenRef = useRef(false);
|
||||
const [backdrop, setBackdrop] = useState<string | null>(null);
|
||||
|
||||
const stageRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-screen bg-slate-200 dark:bg-black"
|
||||
style={{ padding: frame }}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex min-w-0 flex-1 overflow-hidden " +
|
||||
(fullscreen ? "" : "rounded-xl border border-slate-300 dark:border-slate-800")
|
||||
}
|
||||
>
|
||||
<div className="flex h-screen">
|
||||
<Nav
|
||||
config={config}
|
||||
activeId={activeId}
|
||||
@@ -241,8 +204,6 @@ export default function App() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{settingsOpen && (
|
||||
<Settings
|
||||
config={config}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/** A glyph's path, and the brand's own colour. */
|
||||
export type BrandIcon = [path: string, hex: string];
|
||||
|
||||
/** Fetched once, lazily — it is four megabytes and nothing needs it at boot. */
|
||||
let pending: Promise<Record<string, BrandIcon>> | null = null;
|
||||
|
||||
export function loadBrandIcons(): Promise<Record<string, BrandIcon>> {
|
||||
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> = {
|
||||
"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, BrandIcon>): 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";
|
||||
}
|
||||
+2
-11
@@ -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 && <span className="absolute left-0 h-5 w-[3px] rounded-full bg-sky-500" />}
|
||||
<Favicon url={app.url} name={app.name} size={20} />
|
||||
<Favicon icon={app.icon} name={app.name} size={20} />
|
||||
{(unread[app.id] ?? 0) > 0 && (
|
||||
<span
|
||||
title={`${unread[app.id]} new since you last looked`}
|
||||
@@ -126,7 +119,6 @@ export default function Nav({
|
||||
|
||||
return (
|
||||
<aside className={`${shell} items-center`} style={{ width: RAIL }}>
|
||||
<div data-tauri-drag-region style={{ height: TITLEBAR }} className="w-full shrink-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}`}
|
||||
>
|
||||
<Favicon url={app.url} name={app.name} />
|
||||
<Favicon icon={app.icon} name={app.name} />
|
||||
<span className="truncate">{app.name}</span>
|
||||
{badge(app.id, app.id === activeId)}
|
||||
</button>
|
||||
@@ -181,7 +173,6 @@ export default function Nav({
|
||||
|
||||
return (
|
||||
<aside className={shell} style={{ width: PANEL }}>
|
||||
<div data-tauri-drag-region style={{ height: TITLEBAR }} className="shrink-0" />
|
||||
<header className="flex items-center gap-0.5 border-b border-slate-200 px-1.5 pb-2 dark:border-slate-800">
|
||||
{controls}
|
||||
<button
|
||||
|
||||
@@ -107,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} />
|
||||
<Favicon icon={app.icon} name={app.name} />
|
||||
<input
|
||||
value={app.name}
|
||||
onChange={(e) => patch(app, { name: e.target.value })}
|
||||
@@ -183,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} />
|
||||
<Favicon icon={app.icon} name={app.name} />
|
||||
<span className="text-[12px] font-medium">{app.name}</span>
|
||||
<Badge tone="accent">{app.hidden.length}</Badge>
|
||||
</div>
|
||||
@@ -231,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} />
|
||||
<Favicon icon={app.icon} name={app.name} />
|
||||
<span className="w-28 shrink-0 truncate text-[12px]">{app.name}</span>
|
||||
<input
|
||||
type="range"
|
||||
|
||||
+33
-56
@@ -5,16 +5,7 @@
|
||||
* Ported from FlightTube: slate and sky, a 9–15px type ladder, outline-first
|
||||
* controls, borders for separation and shadows only for elevation.
|
||||
*/
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
colourFor,
|
||||
glyphOn,
|
||||
hostOf,
|
||||
loadBrandIcons,
|
||||
slugForHost,
|
||||
type BrandIcon,
|
||||
} from "../brandIcons";
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
/** Every control in the app is this tall, so a row of mixed ones lines up. */
|
||||
export const CONTROL_H = "h-[30px]";
|
||||
@@ -210,64 +201,50 @@ export function Dialog({
|
||||
}
|
||||
|
||||
/**
|
||||
* An app's mark: its brand glyph, white, on a round tile of its own colour.
|
||||
* An app's mark: the icon the site itself declared, as the browser would use.
|
||||
*
|
||||
* 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.
|
||||
* Not an icon service. Asked about a domain, a service can only guess, and it
|
||||
* guesses badly — a marketing site's icon for a tool behind a login, nothing
|
||||
* at all for a private host, and no way to make it reconsider. The page is
|
||||
* carrying the real answer already, so it reports it and this draws it.
|
||||
*/
|
||||
export function Favicon({
|
||||
url,
|
||||
icon,
|
||||
name,
|
||||
size = 16,
|
||||
}: {
|
||||
url: string;
|
||||
icon?: string | null;
|
||||
name: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const [icons, setIcons] = useState<Record<string, BrandIcon> | 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 icon = slug ? icons?.[slug] : undefined;
|
||||
// The brand's own colour where there is one; a stable stand-in where there
|
||||
// is not, so an unbranded tool still reads as a distinct thing in the list.
|
||||
const colour = icon ? `#${icon[1]}` : colourFor(host || name);
|
||||
const ink = glyphOn(colour);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const letter = name.trim().charAt(0).toUpperCase() || "?";
|
||||
|
||||
if (!icon || failed) {
|
||||
/* Until a page has loaded and said, or if its icon will not load: the
|
||||
initial, in a tile the same size, so the row does not reflow later. */
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid shrink-0 place-items-center rounded bg-slate-200 font-semibold
|
||||
text-slate-500 dark:bg-slate-700 dark:text-slate-300"
|
||||
style={{ width: size, height: size, fontSize: Math.max(8, size * 0.5) }}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="grid shrink-0 place-items-center rounded-full"
|
||||
style={{ width: size, height: size, background: colour }}
|
||||
<img
|
||||
src={icon}
|
||||
alt=""
|
||||
aria-hidden
|
||||
>
|
||||
{icon ? (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill={ink}
|
||||
style={{ width: size * 0.58, height: size * 0.58 }}
|
||||
>
|
||||
<path d={icon[0]} />
|
||||
</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"
|
||||
style={{ fontSize: Math.max(8, size * 0.5), color: ink }}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
width={size}
|
||||
height={size}
|
||||
onError={() => setFailed(true)}
|
||||
className="shrink-0 rounded-[3px] object-contain"
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface WorkApp {
|
||||
userAgent: string | null;
|
||||
/** CSS selectors this app hides on every page. */
|
||||
hidden: string[];
|
||||
/** The icon the site itself declared, as the browser would use. */
|
||||
icon: string | null;
|
||||
/** Page zoom, remembered per app. */
|
||||
zoom: number;
|
||||
order: number;
|
||||
|
||||
Reference in New Issue
Block a user