# 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 `<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 |
