Upload one source image, get a complete, best-practice favicon setup generated automatically. No manual resizing, no wrestling with `.ico` files, no outdated `<link>` tags. Works like the best favicon generators online — sensible defaults, small amount of customisation, nothing more.
## Logo vs favicon
Often the same thing, often not. A wide wordmark logo (`Robin's Nature Shop` with a leaf motif) is completely unreadable at 16×16. A favicon wants to be a tight square mark — just the symbol, no text.
**Default behaviour:** use the existing logo upload as the favicon source (toggled on). The toggle lives in the theme editor, next to the logo upload section. If the shop owner hasn't uploaded a logo yet, the favicon falls back to a generic Berrypod icon.
**Override:** if the logo isn't suitable, upload a separate icon image (PNG or SVG, ideally 512×512 or larger). This becomes the favicon source instead. Stored as `image_type: "icon"` in the existing `images` table — same pattern as `"logo"` and `"header"`.
## What gets generated
From a single source image, an Oban worker generates and stores everything:
| Output | Size | Purpose |
|--------|------|---------|
| `favicon.svg` | Source SVG | Modern browsers (Chrome 80+, Firefox 41+, Safari 13.1+) — if source is SVG |
| `favicon-32x32.png` | 32×32 | PNG fallback for browsers without SVG favicon support |
| `apple-touch-icon.png` | 180×180 | iOS "Add to Home Screen" |
**`.ico` file:** `libvips` (used by the `image` library) doesn't output ICO natively. ICO is only needed for very old browsers and some Windows desktop edge cases (taskbar pinning). A pre-baked generic `favicon.ico` ships in `priv/static/` as a passive fallback — browsers request it at startup without a `<link>` tag, so it just needs to exist. Not worth generating dynamically.
**SVG dark mode:** if the source is SVG, the served `favicon.svg` gets a `prefers-color-scheme: dark` media query injected wrapping the fill colour. The icon adapts automatically to browser/OS dark mode — something no raster format can do.
add :svg, :text # dark-mode-injected SVG content; nil if source not SVG
add :generated_at, :utc_datetime
timestamps()
end
```
Single-row pattern (enforced by application logic) — only one favicon variant set at a time. On regeneration, the row is upserted.
## Serving
Generated files can't go in `priv/static/` (ephemeral filesystem in Docker/Fly.io). Instead, served via a `FaviconController` at the expected root paths:
```
GET /favicon.svg → serves svg column (if available)
GET /favicon-32x32.png → serves png_32 column
GET /apple-touch-icon.png → serves png_180 column
GET /icon-192.png → serves png_192 column
GET /icon-512.png → serves png_512 column
GET /site.webmanifest → dynamically generated JSON from settings
GET /favicon.ico → static file from priv/static/ (pre-baked fallback)
```
All image responses include `Cache-Control: public, max-age=86400` (1 day) and an `ETag` based on `generated_at`. Browsers cache aggressively once served.
If no favicon has been generated yet, responses fall through to a bundled default icon in `priv/static/defaults/`.
## Dynamic `site.webmanifest`
Served as `application/manifest+json`, generated fresh from settings on each request (cached with a short TTL):
```json
{
"name": "Robin's Nature Shop",
"short_name": "Robin's",
"theme_color": "#2d4a3e",
"background_color": "#ffffff",
"display": "minimal-ui",
"start_url": "/",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
```
`theme_color` is read from the active theme settings (the primary/accent colour). `background_color` defaults to white. `short_name` is configurable — defaults to shop name truncated at 12 characters.