Add a favicon, home-screen icons and a web manifest

The favicon is a bowl of soup: this household's catalog is half keitto, so it
is at least honest. favicon.svg follows the system theme in the tab strip.

Home-screen icons cannot do the same. They must be opaque and must not change
with the theme, so assets/icon.svg is a separate fixed-colour source, with the
artwork inside the central 80% for Android to mask to any launcher shape. The
same 512 is declared maskable in the manifest.

`make icons` rasterises with rsvg-convert and compresses with
`oxipng -o max --zopfli`, which beat `optipng -o7` at every size — 5860 bytes
against 6109 for the three files. Plain `oxipng -o max` was not an upgrade: it
won the two small icons and lost the 512, ending up larger overall. All output
verified pixel-identical to the rasterizer. The PNGs are committed so the
build needs no rasterizer.

Two things that only fail on a real device, so both are covered by smoke
checks: Go has no mime entry for .webmanifest and served it as octet-stream,
which browsers ignore; and a manifest fetch carries no credentials by default,
so behind Basic auth it needs crossorigin="use-credentials" or it 401s.
This commit is contained in:
Esa Kataja
2026-09-05 18:58:42 +03:00
parent d15f741ab1
commit 0538d61ba6
16 changed files with 283 additions and 4 deletions
+14 -3
View File
@@ -3,6 +3,7 @@
COMPOSE ?= podman compose
BIN := foodster
PKG := ./cmd/foodster
STATIC := cmd/foodster/static
# Vendored Datastar client. Bump, run `make vendor`, commit the result.
DATASTAR_VERSION ?= v1.0.3
@@ -18,7 +19,7 @@ endif
GOFILES = $(shell find . -name '*.go' -not -name '*_templ.go' 2>/dev/null)
.DEFAULT_GOAL := help
.PHONY: help generate build run seed test smoke check lint fix vendor image push release up down logs clean
.PHONY: help generate build run seed test smoke check lint fix icons vendor image push release up down logs clean
help: ## Show this help
@grep -hE '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) \
@@ -51,10 +52,20 @@ check: ## Everything that must pass before a commit
@$(MAKE) --no-print-directory smoke
@echo "check: all passed"
icons: ## Rasterise home-screen PNGs from assets/icon.svg and optimise them
rsvg-convert -w 180 -h 180 assets/icon.svg -o $(STATIC)/apple-touch-icon.png
rsvg-convert -w 192 -h 192 assets/icon.svg -o $(STATIC)/icon-192.png
rsvg-convert -w 512 -h 512 assets/icon.svg -o $(STATIC)/icon-512.png
# oxipng -o max alone loses to optipng on the 512; --zopfli wins at every
# size. Slow, but these are three tiny files built by hand.
oxipng -o max --zopfli --quiet \
$(STATIC)/apple-touch-icon.png $(STATIC)/icon-192.png $(STATIC)/icon-512.png
@ls -l $(STATIC)/*.png
vendor: ## Re-download the Datastar client (DATASTAR_VERSION=v1.0.3)
curl -sSfL -o cmd/foodster/static/datastar.js \
curl -sSfL -o $(STATIC)/datastar.js \
"https://cdn.jsdelivr.net/gh/starfederation/datastar@$(DATASTAR_VERSION)/bundles/datastar.js"
@head -1 cmd/foodster/static/datastar.js
@head -1 $(STATIC)/datastar.js
lint: generate ## go vet, gofmt check, golangci-lint when installed
go vet ./...
+20 -1
View File
@@ -27,8 +27,12 @@ Working:
Still to build:
- **UI polish.** Checkbox chips hide the native control and signal state only
through background colour, so "Tarjoillaan lisukkeiden kanssa" gives no
clear read of on versus off. Show a real checkbox. Same applies to the
category and side chips.
- Light / dark theme switch, saved per device (PRD §7.4).
- A proper app header and a `favicon.svg`.
- A proper app header.
- Stage 2: the seven-meal suggester, which starts once there is history to
weight against.
@@ -102,6 +106,21 @@ a scratch database:
make seed # or: SEED=seeds/other.json make seed
```
## Icons
`cmd/foodster/static/favicon.svg` is the browser-tab icon and follows the
system theme. The home-screen icons are PNGs, because a home-screen icon
cannot be transparent and must not change with the theme; they are rasterised
from `assets/icon.svg`, which is opaque and keeps the artwork inside the
central 80% so Android can mask it to any shape.
```sh
make icons # rsvg-convert, then optipng -o7
```
The PNGs are committed so the build needs no rasterizer. Re-run `make icons`
after editing `assets/icon.svg`.
## Migrations
Numbered SQL files in `cmd/foodster/migrations`, embedded in the binary and
+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"
role="img" aria-label="Foodster">
<!-- Source for the home-screen PNGs; `make icons` rasterises it.
Unlike favicon.svg this one is opaque and fixed-colour: a home screen
icon cannot be transparent and must not change with the system theme.
The bowl sits inside the central 80% so Android can mask it to any
shape without clipping the artwork. -->
<rect width="512" height="512" fill="#15616D"/>
<g transform="translate(112 112) scale(9)">
<g fill="#ECEDE8">
<rect x="1.5" y="12" width="29" height="3.2" rx="1.6"/>
<path d="M3.5 15.2 h25 a12.5 12.5 0 0 1 -25 0 z"/>
</g>
<g fill="none" stroke="#ECEDE8" stroke-width="3" stroke-linecap="round">
<path d="M11.5 9 c0 -2.1 2.1 -2.6 2.1 -4.6"/>
<path d="M19 9 c0 -2.1 2.1 -2.6 2.1 -4.6"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 918 B

+5
View File
@@ -15,6 +15,7 @@ import (
"flag"
"fmt"
"log"
"mime"
"net/http"
"os"
"os/signal"
@@ -140,6 +141,10 @@ func openDB(path string) (*sql.DB, error) {
}
func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
// Go's mime table has no entry for .webmanifest, and a manifest served as
// octet-stream is ignored by the browser.
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
a := &app{db: db, loc: loc}
mux := http.NewServeMux()
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Foodster">
<!-- Kulho: a bowl with steam. Finnish dinner is soup more often than not,
and this household's own catalog is half keitto. -->
<style>
svg { color: #15616D; }
@media (prefers-color-scheme: dark) { svg { color: #58C6D2; } }
</style>
<g fill="currentColor">
<rect x="1.5" y="12" width="29" height="3.2" rx="1.6"/>
<path d="M3.5 15.2 h25 a12.5 12.5 0 0 1 -25 0 z"/>
</g>
<g fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round">
<path d="M11.5 9 c0 -2.1 2.1 -2.6 2.1 -4.6"/>
<path d="M19 9 c0 -2.1 2.1 -2.6 2.1 -4.6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+17
View File
@@ -0,0 +1,17 @@
{
"id": "/",
"name": "Foodster",
"short_name": "Foodster",
"lang": "fi",
"dir": "ltr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ECEDE8",
"theme_color": "#15616D",
"icons": [
{ "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+7
View File
@@ -76,7 +76,14 @@ templ page(title, current string) {
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<meta name="color-scheme" content="light dark"/>
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ECEDE8"/>
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#121316"/>
<title>{ title }</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/>
<!-- use-credentials: the manifest is fetched behind Basic auth and
would otherwise come back 401 and be ignored. -->
<link rel="manifest" href="/static/manifest.webmanifest" crossorigin="use-credentials"/>
<link rel="stylesheet" href="/static/app.css"/>
<script type="module" src="/static/datastar.js"></script>
</head>
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Foodster">
<!-- Kulho: a bowl with steam. Finnish dinner is soup more often than not,
and this household's own catalog is half keitto. -->
<style>
svg { color: #15616D; }
@media (prefers-color-scheme: dark) { svg { color: #58C6D2; } }
</style>
<g fill="currentColor">
<rect x="1.5" y="12" width="29" height="3.2" rx="1.6"/>
<path d="M3.5 15.2 h25 a12.5 12.5 0 0 1 -25 0 z"/>
</g>
<g fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round">
<path d="M11.5 9 c0 -2.1 2.1 -2.6 2.1 -4.6"/>
<path d="M19 9 c0 -2.1 2.1 -2.6 2.1 -4.6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 688 B

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Foodster">
<!-- Kattila: a cooking pot, lid on, handles out. Cooking rather than eating. -->
<style>
svg { color: #15616D; }
@media (prefers-color-scheme: dark) { svg { color: #58C6D2; } }
</style>
<g fill="currentColor">
<circle cx="16" cy="6" r="2.5"/>
<rect x="2" y="9" width="28" height="3.4" rx="1.7"/>
<rect x="0" y="14" width="5.5" height="3.2" rx="1.6"/>
<rect x="26.5" y="14" width="5.5" height="3.2" rx="1.6"/>
<path d="M5 13.6 h22 v6.9 a5.5 5.5 0 0 1 -5.5 5.5 h-11 a5.5 5.5 0 0 1 -5.5 -5.5 z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 639 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Foodster">
<!-- Lusikka: one spoon, set on the diagonal. A fork is the cliché; a spoon
on its own is not, and it stays a single solid shape at any size. -->
<style>
svg { color: #15616D; }
@media (prefers-color-scheme: dark) { svg { color: #58C6D2; } }
</style>
<g fill="currentColor" transform="rotate(34 16 16)">
<ellipse cx="16" cy="9.5" rx="5.4" ry="7"/>
<rect x="13.9" y="15" width="4.2" height="14" rx="2.1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 545 B

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Foodster">
<!-- Ruisleipä: the Finnish rye loaf, hole in the middle. Nothing else in a
tab strip looks like this — which is the point, and also the risk. -->
<style>
.hole { fill: #ECEDE8; }
.loaf { fill: #6B4636; }
@media (prefers-color-scheme: dark) {
.hole { fill: #121316; }
.loaf { fill: #9A6E52; }
}
</style>
<circle class="loaf" cx="16" cy="16" r="14"/>
<circle class="hole" cx="16" cy="16" r="3.8"/>
</svg>

After

Width:  |  Height:  |  Size: 546 B

+118
View File
@@ -0,0 +1,118 @@
<!doctype html>
<html lang="en" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Foodster — favicon options</title>
<link rel="icon" href="a-kulho.svg" type="image/svg+xml">
<style>
:root{
color-scheme: light dark;
--paper: light-dark(#ECEDE8, #121316);
--card: light-dark(#FFFFFF, #1C1E22);
--ink: light-dark(#14161A, #E9EAE5);
--muted: light-dark(#6B6F6A, #8B8F89);
--line: light-dark(#D5D6D0, #2C2F34);
}
html[data-theme="light"]{color-scheme:only light;}
html[data-theme="dark"] {color-scheme:only dark;}
*{box-sizing:border-box;}
body{margin:0;background:var(--paper);color:var(--ink);
font-family:system-ui,sans-serif;font-size:16px;line-height:1.45;}
.wrap{max-width:900px;margin:0 auto;padding:24px 20px 60px;}
h1{font-size:20px;letter-spacing:-.02em;margin:0 0 4px;}
.lede{margin:0 0 22px;color:var(--muted);font-size:14px;}
.themebtn{font:inherit;font-size:13px;background:var(--card);border:1px solid var(--line);
color:var(--muted);padding:7px 12px;border-radius:7px;cursor:pointer;margin-bottom:22px;}
.opt{background:var(--card);border:1px solid var(--line);border-radius:12px;
padding:18px;margin-bottom:14px;}
.opt h2{font-size:16px;margin:0 0 2px;letter-spacing:-.02em;}
.opt p{margin:0 0 14px;font-size:13.5px;color:var(--muted);}
.sizes{display:flex;align-items:flex-end;gap:22px;flex-wrap:wrap;}
.size{text-align:center;}
.size img{display:block;}
.size span{display:block;margin-top:6px;font-size:10px;letter-spacing:.06em;
text-transform:uppercase;color:var(--muted);}
/* A browser tab is the real test: 16px against the tab strip. */
.tabsim{margin-top:16px;display:flex;gap:10px;flex-wrap:wrap;}
.tab{display:flex;align-items:center;gap:7px;border-radius:8px 8px 0 0;
padding:7px 14px 7px 10px;font-size:12.5px;}
.tab img{width:16px;height:16px;}
.onwhite{background:#fff;color:#333;border:1px solid #ddd;}
.ondark{background:#1b1d22;color:#bbb;border:1px solid #33363c;}
</style>
</head>
<body>
<div class="wrap">
<h1>Foodster — favicon, round two</h1>
<p class="lede">Objects from the kitchen rather than geometry. The 16&nbsp;px row and the tab strips are the only test that counts.</p>
<button class="themebtn" id="t">Theme: auto</button>
<div class="opt">
<h2>A · Kulho</h2>
<p>A bowl with steam. This household's catalog is half <em>keitto</em>, so soup is honest branding.</p>
<div class="sizes">
<div class="size"><img src="a-kulho.svg" width="16" height="16"><span>16</span></div>
<div class="size"><img src="a-kulho.svg" width="32" height="32"><span>32</span></div>
<div class="size"><img src="a-kulho.svg" width="64" height="64"><span>64</span></div>
<div class="size"><img src="a-kulho.svg" width="180" height="180"><span>180</span></div>
</div>
<div class="tabsim">
<span class="tab onwhite"><img src="a-kulho.svg" alt="">Foodster</span>
<span class="tab ondark"><img src="a-kulho.svg" alt="">Foodster</span>
</div>
</div>
<div class="opt">
<h2>B · Kattila</h2>
<p>A pot with the lid on and handles out. About cooking rather than eating.</p>
<div class="sizes">
<div class="size"><img src="b-kattila.svg" width="16" height="16"><span>16</span></div>
<div class="size"><img src="b-kattila.svg" width="32" height="32"><span>32</span></div>
<div class="size"><img src="b-kattila.svg" width="64" height="64"><span>64</span></div>
<div class="size"><img src="b-kattila.svg" width="180" height="180"><span>180</span></div>
</div>
<div class="tabsim">
<span class="tab onwhite"><img src="b-kattila.svg" alt="">Foodster</span>
<span class="tab ondark"><img src="b-kattila.svg" alt="">Foodster</span>
</div>
</div>
<div class="opt">
<h2>C · Lusikka</h2>
<p>One spoon on the diagonal. A fork is the cliché; a lone spoon is not, and it stays one solid shape at any size.</p>
<div class="sizes">
<div class="size"><img src="c-lusikka.svg" width="16" height="16"><span>16</span></div>
<div class="size"><img src="c-lusikka.svg" width="32" height="32"><span>32</span></div>
<div class="size"><img src="c-lusikka.svg" width="64" height="64"><span>64</span></div>
<div class="size"><img src="c-lusikka.svg" width="180" height="180"><span>180</span></div>
</div>
<div class="tabsim">
<span class="tab onwhite"><img src="c-lusikka.svg" alt="">Foodster</span>
<span class="tab ondark"><img src="c-lusikka.svg" alt="">Foodster</span>
</div>
</div>
<div class="opt">
<h2>D · Ruisleipä</h2>
<p>The rye loaf with its hole. Nothing else in a tab strip looks like this — which is the appeal and also the risk of reading as a ring.</p>
<div class="sizes">
<div class="size"><img src="d-ruisleipa.svg" width="16" height="16"><span>16</span></div>
<div class="size"><img src="d-ruisleipa.svg" width="32" height="32"><span>32</span></div>
<div class="size"><img src="d-ruisleipa.svg" width="64" height="64"><span>64</span></div>
<div class="size"><img src="d-ruisleipa.svg" width="180" height="180"><span>180</span></div>
</div>
<div class="tabsim">
<span class="tab onwhite"><img src="d-ruisleipa.svg" alt="">Foodster</span>
<span class="tab ondark"><img src="d-ruisleipa.svg" alt="">Foodster</span>
</div>
</div>
</div>
<script>
const h=document.documentElement,b=document.getElementById('t'),m=['auto','light','dark'];
b.onclick=()=>{const n=m[(m.indexOf(h.dataset.theme)+1)%3];h.dataset.theme=n;b.textContent='Theme: '+n;};
</script>
</body>
</html>
+11
View File
@@ -54,6 +54,17 @@ check "healthz needs no password" \
check "datastar client is served" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" "http://$addr/static/datastar.js")" "200"
check "favicon is served" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" "http://$addr/static/favicon.svg")" "200"
check "apple touch icon is served" \
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" "http://$addr/static/apple-touch-icon.png")" "200"
# A manifest served as octet-stream is silently ignored by the browser.
check "manifest has the right content type" \
"$(curl -s -o /dev/null -w '%{content_type}' -u ":$pass" "http://$addr/static/manifest.webmanifest")" \
"application/manifest+json"
check "catalog starts empty" \
"$(curl -s -u ":$pass" "http://$addr/ruoat")" "0 pääruokaa"