berrypod/docs/plans/favicon.md
jamey 0f1135256d add canonical URLs, robots.txt, and sitemap.xml
Canonical: all shop pages now assign og_url (reusing the existing og:url
assign), which the layout renders as <link rel="canonical">. Collection
pages strip the sort param so ?sort=price_asc doesn't create a duplicate
canonical.

robots.txt: dynamic controller disallows /admin/, /api/, /users/,
/webhooks/, /checkout/. Removed robots.txt from static_paths so it
goes through the router instead of Plug.Static.

sitemap.xml: auto-generated from all visible products + categories +
static pages, served as application/xml. 8 tests.

Also updates PROGRESS.md: marks tasks 55, 58, 59, 61, 62 as done.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 21:47:35 +00:00

8.1 KiB
Raw Blame History

Favicon & site icon management

Status: Planned Tasks: #8688 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 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.

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:

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):

{
  "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

<!-- 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.exstore_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