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>
8.1 KiB
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 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_variantstable lib/berrypod/media.ex—store_favicon_variants/1,get_favicon_variants/0,get_icon_image/0lib/berrypod/media/image.ex— add"icon"toimage_typevalidationlib/berrypod/workers/favicon_generator_worker.ex— new Oban joblib/berrypod_web/controllers/favicon_controller.ex— serves all favicon routes + manifestlib/berrypod_web/router.ex— favicon routes (before the catch-all)lib/berrypod_web/components/layouts/shop_root.html.heex—<link>tags + theme-color metalib/berrypod_web/live/admin/theme/index.ex— icon upload section (alongside logo)priv/static/favicon.ico— pre-baked generic fallbackpriv/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 |