berrypod/docs/plans/favicon.md
jamey edef628214 tidy docs: condense progress, trim readme, mark plan statuses
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:15:18 +00:00

189 lines
8.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Favicon & site icon management
> Status: Complete
> Tasks: #8688
> Tier: 3 (Compliance & quality)
## 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 |