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:
2026-09-01 15:03:09 +02:00
parent ffedb72b83
commit 17d13d2505
17 changed files with 186 additions and 358 deletions
@@ -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