189 lines
8.1 KiB
Markdown
189 lines
8.1 KiB
Markdown
|
|
# Favicon & site icon management
|
|||
|
|
|
|||
|
|
> Status: Planned
|
|||
|
|
> Tasks: #86–88 in PROGRESS.md
|
|||
|
|
> Tier: 3 (Compliance & quality) — ships before page editor
|
|||
|
|
|
|||
|
|
## Goal
|
|||
|
|
|
|||
|
|
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" |
|
|||
|
|
| `icon-192.png` | 192×192 | Android / PWA manifest |
|
|||
|
|
| `icon-512.png` | 512×512 | PWA splash 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.
|
|||
|
|
|
|||
|
|
```svg
|
|||
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|||
|
|
<style>
|
|||
|
|
.icon-mark { fill: #1a1a1a; }
|
|||
|
|
@media (prefers-color-scheme: dark) {
|
|||
|
|
.icon-mark { fill: #ffffff; }
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
<!-- shop's own SVG content -->
|
|||
|
|
</svg>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
If the source is not an SVG, only the PNG variants are generated — no SVG favicon served.
|
|||
|
|
|
|||
|
|
## Generation pipeline
|
|||
|
|
|
|||
|
|
Follows the existing image optimization pattern: upload triggers an Oban job, job generates variants and stores them in the DB.
|
|||
|
|
|
|||
|
|
```elixir
|
|||
|
|
defmodule Berrypod.Workers.FaviconGeneratorWorker do
|
|||
|
|
use Oban.Worker, queue: :media
|
|||
|
|
|
|||
|
|
def perform(%Job{args: %{"source_image_id" => id}}) do
|
|||
|
|
image = Media.get_image!(id)
|
|||
|
|
|
|||
|
|
with {:ok, resized_32} <- resize(image, 32),
|
|||
|
|
{:ok, resized_180} <- resize(image, 180),
|
|||
|
|
{:ok, resized_192} <- resize(image, 192),
|
|||
|
|
{:ok, resized_512} <- resize(image, 512) do
|
|||
|
|
Media.store_favicon_variants(%{
|
|||
|
|
source_image_id: id,
|
|||
|
|
png_32: resized_32,
|
|||
|
|
png_180: resized_180,
|
|||
|
|
png_192: resized_192,
|
|||
|
|
png_512: resized_512,
|
|||
|
|
svg: (if image.is_svg, do: inject_dark_mode(image.svg_content))
|
|||
|
|
})
|
|||
|
|
end
|
|||
|
|
end
|
|||
|
|
end
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## Storage schema
|
|||
|
|
|
|||
|
|
New table `favicon_variants` — one row, updated on each regeneration:
|
|||
|
|
|
|||
|
|
```elixir
|
|||
|
|
create table(:favicon_variants, primary_key: false) do
|
|||
|
|
add :id, :binary_id, primary_key: true
|
|||
|
|
add :source_image_id, references(:images, type: :binary_id)
|
|||
|
|
add :png_32, :binary
|
|||
|
|
add :png_180, :binary
|
|||
|
|
add :png_192, :binary
|
|||
|
|
add :png_512, :binary
|
|||
|
|
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.
|
|||
|
|
|
|||
|
|
## `<head>` additions in `shop_root.html.heex`
|
|||
|
|
|
|||
|
|
```html
|
|||
|
|
<!-- Favicon & PWA -->
|
|||
|
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|||
|
|
<link rel="icon" href="/favicon-32x32.png" type="image/png" />
|
|||
|
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|||
|
|
<link rel="manifest" href="/site.webmanifest" />
|
|||
|
|
<meta name="theme-color" content={@theme_settings.primary_colour || "#000000"} />
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
The SVG `<link>` is listed first — browsers pick the first format they support. Older browsers fall through to the PNG.
|
|||
|
|
|
|||
|
|
## Customisation options
|
|||
|
|
|
|||
|
|
Surfaced in the theme editor, in the same section as the logo upload:
|
|||
|
|
|
|||
|
|
- **Favicon source** — "Use logo as icon" toggle (on by default) / "Upload separate icon" file input
|
|||
|
|
- **Short name** — shown under the icon on home screens; defaults to shop name (truncated)
|
|||
|
|
- **Icon background colour** — the fill shown when Android crops to a circle/squircle; defaults to theme background colour
|
|||
|
|
- **Theme colour** — browser chrome tint on Android and in the task switcher; defaults to theme primary colour
|
|||
|
|
|
|||
|
|
That's it. No more options — the generator handles everything else.
|
|||
|
|
|
|||
|
|
## Files to create/modify
|
|||
|
|
|
|||
|
|
- Migration — `favicon_variants` table
|
|||
|
|
- `lib/berrypod/media.ex` — `store_favicon_variants/1`, `get_favicon_variants/0`, `get_icon_image/0`
|
|||
|
|
- `lib/berrypod/media/image.ex` — add `"icon"` to `image_type` validation
|
|||
|
|
- `lib/berrypod/workers/favicon_generator_worker.ex` — new Oban job
|
|||
|
|
- `lib/berrypod_web/controllers/favicon_controller.ex` — serves all favicon routes + manifest
|
|||
|
|
- `lib/berrypod_web/router.ex` — favicon routes (before the catch-all)
|
|||
|
|
- `lib/berrypod_web/components/layouts/shop_root.html.heex` — `<link>` tags + theme-color meta
|
|||
|
|
- `lib/berrypod_web/live/admin/theme/index.ex` — icon upload section (alongside logo)
|
|||
|
|
- `priv/static/favicon.ico` — pre-baked generic fallback
|
|||
|
|
- `priv/static/defaults/` — bundled default favicon PNGs shown before any upload
|
|||
|
|
|
|||
|
|
## Tasks
|
|||
|
|
|
|||
|
|
| # | Task | Est |
|
|||
|
|
|---|------|-----|
|
|||
|
|
| 86 | Favicon source upload + settings — add `image_type: "icon"` to schema, "use logo as icon" toggle, icon upload in theme editor, `FaviconGeneratorWorker` Oban job, `favicon_variants` table | 2.5h |
|
|||
|
|
| 87 | `FaviconController` serving all favicon routes + dynamic `site.webmanifest`; add `<link>` tags and `theme-color` meta to `shop_root.html.heex`; bundle default fallback icons | 1.5h |
|
|||
|
|
| 88 | SVG dark mode injection for SVG-source favicons; icon background / short name customisation in theme editor | 1h |
|