# Favicon & site icon management > Status: Complete > Tasks: #86–88 > 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 `` 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 `` 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 ``` 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. ## `` additions in `shop_root.html.heex` ```html ``` The SVG `` 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` — `` 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 `` 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 |