Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 bcc9e56749 Serve the MealQuest favicon instead of the browser default (Maestro 7c778111)
CI / ci (push) Successful in 10m2s
CI / ci (pull_request) Successful in 9m58s
index.html linked only the apple-touch-icon, so browsers fell back to a
default favicon. generate-icons now also writes favicon.ico (16/32/48
PNG-in-ICO) and 16/32 px PNG favicons from the v1 logo; index.html links
them, and the service worker precaches every icon and favicon so the
offline shell keeps them (the glob covers the manifest icons, so the
plugin no longer injects them a second time).

pwa.test.ts asserts the build ships the favicons at the right sizes,
links them from index.html, and precaches each icon exactly once.

Task: 7c778111-c615-4437-a3f6-0e36bcb97e63

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:23:21 -04:00
orchestrator-agent 28164e762d Production smoke evidence in the launch summary (Maestro 0544e318) (#212)
CI / ci (push) Successful in 10m5s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-12 09:00:24 -04:00
orchestrator-agent 90c2d79354 Lighthouse production results and launch summary (Maestro 2c8f8887) (#204)
CI / ci (push) Successful in 11m9s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 20:39:35 -04:00
orchestrator-agent 8b0e2f25a9 Document the deploy: SHA-embedded build and step-3 verification (Maestro 8611c4b6) (#203)
CI / ci (push) Successful in 11m55s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 18:25:55 -04:00
19 changed files with 45271 additions and 10 deletions
+1 -1
View File
@@ -85,7 +85,7 @@ behaviour, exercise it (hit the endpoint, render the page).
| `migrations/` | SQL migrations (planned). |
| `web/` | React PWA source. |
| `deploy/` | Deployment files (planned). |
| `docs/` | Project documentation, including [ARCHITECTURE.md](docs/ARCHITECTURE.md). |
| `docs/` | Project documentation, including [ARCHITECTURE.md](docs/ARCHITECTURE.md), launch evidence in [LAUNCH.md](docs/LAUNCH.md), and Lighthouse reports in `docs/lighthouse/`. |
| `design/` | Requirements and UI handoff. **Read-only reference** — do not edit. |
| `Dockerfile` | Multi-stage image build, at the repository root. |
| `Makefile` | `dev`, `test`, `build`, `docker`. |
+255
View File
@@ -0,0 +1,255 @@
# MealQuest deploy runbook
The one way to deploy MealQuest to production: the docker compose project
`mealquest` on office-srvr `10.60.1.14` (see
[ARCHITECTURE.md](../docs/ARCHITECTURE.md#topology)). Production runs `main`
or a release tag; only `overseer` merges `dev` → `main`. Every deploy builds
on the host, from the checked-out commit, with `make docker`.
Host setup (clone, firewall, the systemd unit, Traefik, DNS, Litestream) and
the database restore are in CTXD project `mealquest`, `RUNBOOKS.MD`. This
file is the build, restart, verification and rollback procedure; where it
uses a CTXD section, it names it.
All commands run on `10.60.1.14` as `overseer`, in bash, from the checkout
`/home/overseer/mealquest`. A block that ends with `FAILED` or a non-zero
`$?` stops the deploy: do not go on to the next step.
## How the image carries its version
`make docker` runs `docker build --build-arg VERSION=<version>` and tags the
image `mealquest:<version>`, where `<version>` is
`git describe --tags --always` of the checkout (a release tag such as
`v1.0.0`, otherwise the short SHA), with `-dirty` added when a tracked file
is modified. The [Dockerfile](../Dockerfile) links it into the binary, and
`/health` reports it:
```json
{"status":"ok","version":"v1.0.0"}
```
Never pass `--build-arg VERSION` by hand, and never build with
`docker compose build` or `docker compose up --build`: compose passes no
version, so that image reports `dev`.
The compose service `mealquest` runs the image named `mealquest-mealquest`
(compose's name for the image it would build; check with
`docker compose -f deploy/compose.yaml config --images mealquest`). It
already exists, so `docker compose up` uses it and never builds. A deploy
points that name at the `make docker` image with `docker tag`. Every
`mealquest:<version>` image is kept for rollback; do not prune them.
## Prerequisites
- `make`, `git`, `curl`, `python3` and Docker Engine with Compose on the
host (`make` is not in the base install: `sudo apt-get install make`).
- `deploy/.env` (git-ignored, owner `overseer`, mode 0600) sets every
variable [`deploy/.env.example`](.env.example) sets, with
`MEALQUEST_BIND_ADDRESS=10.60.1.14`. It must not set the variables
`deploy/compose.yaml` fixes (`MEALQUEST_HTTP_ADDR`, `MEALQUEST_DATA_DIR`,
`MEALQUEST_SEARXNG_URL` and the `*_FILE` variables). Creating it: CTXD
`RUNBOOKS.MD` "Stack and first deploy" > "3. Environment file".
- `deploy/secrets/` (git-ignored, mode 0700) holds `secret_key`,
`oidc_client_secret`, `google_client_secret`, `vapid_private_key` and
`litestream_key`, each owned by `10001:10001` with mode 0600, and
`searxng.env`, owned by `overseer` with mode 0600. Creating them: CTXD
`RUNBOOKS.MD` "Stack and first deploy" > "4. Secrets".
- The compose file is the checkout's [`deploy/compose.yaml`](compose.yaml);
the systemd unit [`mealquest.service`](systemd/mealquest.service) starts
and stops it and is the only way the stack starts.
- DNS and Traefik already route `https://mealquest.cubecraftlabs.com` to
`10.60.1.14:8090` (CTXD `RUNBOOKS.MD` "Reverse proxy (Traefik)", "DNS").
Check `deploy/.env` and the secrets before every deploy (a new release may
add a variable; the service refuses to boot without it). Both commands must
print nothing:
```sh
cd /home/overseer/mealquest
comm -23 <(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env.example | sort -u) \
<(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env | sort -u) # variables missing from deploy/.env
sudo find deploy/secrets -type f ! -perm 600 # secrets not mode 0600
```
## Deploy
### 1. Build
Record the release production is running now, check out the new release
and build its image. Nothing restarts.
The rollback target is the version the running service reports on
`/health`, read before the checkout changes, never the checkout's `HEAD`:
after a failed or deferred deploy the checkout can be ahead of production.
The block resolves that version to a commit and prints it as the rollback
SHA. A running version of `dev` or `*-dirty` names no commit: the block
fails, and the rollback target must be established by hand before going on.
The checkout must be clean (the same test as the Makefile's `GIT_DIRTY`:
no modified tracked file outside the generated `internal/frontend/dist`).
The block computes the image tag exactly as `make docker` does, `-dirty`
included, and checks that image exists after the build.
```sh
cd /home/overseer/mealquest && (
set -euo pipefail
fail() { echo "$*" >&2; exit 1; }
built=
trap 'st=$?
if [ "$st" -eq 0 ] && [ -n "$built" ]; then echo "built: $built"
else [ "$st" -ne 0 ] || st=1; echo "build FAILED (status $st)" >&2; fi
exit "$st"' EXIT
bind=$(grep -E '^MEALQUEST_BIND_ADDRESS=' deploy/.env | cut -d = -f 2-)
[ -n "$bind" ] || fail "deploy/.env sets no MEALQUEST_BIND_ADDRESS"
running=$(curl -fsS "http://$bind:8090/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \
|| fail "http://$bind:8090/health did not return a version: no running release to roll back to"
case "$running" in
dev|*-dirty) fail "the running release reports $running, which names no commit" ;;
esac
git fetch --tags origin
rollback=$(git rev-parse --verify --quiet "$running^{commit}") \
|| fail "running version $running is not a commit in this repository"
echo "rollback SHA: $rollback (running version $running)" # RECORD it for rollback
git checkout --detach origin/main # or the release tag, e.g. v1.0.0
dirty=$(git status --porcelain --untracked-files=no -- . ':(exclude)internal/frontend/dist')
[ -z "$dirty" ] || fail "the checkout has modified tracked files; clean it first:
$dirty"
v=$(git describe --tags --always)${dirty:+-dirty} # the Makefile's VERSION
make docker
docker image inspect "mealquest:$v" > /dev/null 2>&1 || fail "no image mealquest:$v: the build failed"
built="mealquest:$v"
)
```
### 2. Ship and restart
1. **Pre-restart gates.** Run CTXD `RUNBOOKS.MD` "Routine deploy and
rollback" > "Deploy" step 2 (a verified pre-deploy restore point; record
it with the rollback SHA) and step 3 (boot-required configuration). The
restart applies migrations the previous release refuses, so without that
restore point there is no rollback. If either fails, do not restart:
production stays on the previous release.
2. **Point the stack at the new image and restart.** `docker compose pull`
fetches the pinned `litestream` and `searxng` images before anything
stops; `mealquest` is never pulled. The restart applies the firewall,
then runs `down` and `up -d`, and returns once `mealquest` passes its
healthcheck (`/health` inside the container).
```sh
cd /home/overseer/mealquest && (
set -euo pipefail
fail() { echo "$*" >&2; exit 1; }
up=
trap 'st=$?
if [ "$st" -eq 0 ] && [ -n "$up" ]; then echo "restarted on: $up"
else [ "$st" -ne 0 ] || st=1; echo "restart FAILED (status $st)" >&2; fi
exit "$st"' EXIT
v=$(git describe --tags --always)
docker compose -f deploy/compose.yaml pull litestream searxng
docker tag "mealquest:$v" mealquest-mealquest
sudo systemctl restart mealquest
for s in mealquest litestream searxng; do
id=$(docker compose -f deploy/compose.yaml ps --status running --quiet "$s")
[ -n "$id" ] || fail "$s is not running"
done
id=$(docker compose -f deploy/compose.yaml ps --quiet mealquest)
health=$(docker inspect -f '{{.State.Health.Status}}' "$id")
[ "$health" = healthy ] || fail "mealquest is $health, not healthy"
up="mealquest:$v"
)
```
On failure read `journalctl -u mealquest` and
`docker compose -f deploy/compose.yaml logs mealquest`: a configuration
error names the variable. Fix it or roll back.
### 3. Verify the deployed version and a real screen
A `/health` 200 alone proves only that some build is up. This step checks,
on the LAN address and through Traefik, that:
- `/health` reports exactly the checkout's `git describe --tags --always`;
- `/` and the Plan screen's route `/plan` serve the built PWA shell (the
React root and a hashed `/assets/` script, not the placeholder page an
unbuilt embed serves);
- that script is served as JavaScript.
```sh
cd /home/overseer/mealquest && (
set -euo pipefail
fail() { echo "$*" >&2; exit 1; }
want=
trap 'st=$?
if [ "$st" -eq 0 ] && [ -n "$want" ]; then echo "deployed and verified: $want ($(git rev-parse HEAD))"
else [ "$st" -ne 0 ] || st=1; echo "verification FAILED (status $st): the deploy is not verified" >&2; fi
exit "$st"' EXIT
v=$(git describe --tags --always)
for base in http://10.60.1.14:8090 https://mealquest.cubecraftlabs.com; do
got=$(curl -fsS "$base/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \
|| fail "$base/health did not return a version"
[ "$got" = "$v" ] || fail "$base/health reports version $got, the checkout is $v"
for path in / /plan; do
page=$(curl -fsS "$base$path") || fail "$base$path did not return 200"
grep -q '<div id="root"></div>' <<< "$page" || fail "$base$path is not the PWA shell"
js=$(grep -oE 'src="/assets/[^"]+\.js"' <<< "$page" | head -n 1 | cut -d '"' -f 2) || true
[ -n "$js" ] || fail "$base$path references no built /assets/ script"
type=$(curl -fsS -o /dev/null -w '%{content_type}' "$base$js") || fail "$base$js did not return 200"
case "$type" in
text/javascript*) ;;
*) fail "$base$js is $type, not JavaScript" ;;
esac
done
echo "$base: version $got, PWA shell and $js served"
done
want=$v
)
```
- `deployed and verified: <version> (<SHA>)` and `$?` = `0` → open
`https://mealquest.cubecraftlabs.com` in a browser: sign in and the Home
screen renders with the household's data. Then record the deployed
version and SHA, the rollback SHA, the pre-deploy restore point and the
date in the release's Maestro task.
- `verification FAILED`, any error or a non-zero `$?` → the deploy is not
done. A version mismatch means the stack runs a different image than the
checkout: repeat step 2, or roll back.
## Rollback to a previous release
Rolling back points `mealquest-mealquest` at the previous release's
`mealquest:<version>` image again; that release's checkout keeps the
compose file and the version check consistent with it.
1. **Check whether the rollback crosses a migration** with CTXD
`RUNBOOKS.MD` "Routine deploy and rollback" > "Rollback to a previous
SHA" (its first block), run against the rollback SHA that deploy step 1
recorded. If it prints versions unknown to the rollback SHA, restore the
database to the pre-deploy restore point first (CTXD `RUNBOOKS.MD`
"Restore the database", with `-timestamp <restore point>`); changes
since then are lost, so tell the household.
2. **Check out the rollback SHA and retag its image.** An image built before
this runbook was not tagged `mealquest:<version>`; `make docker` builds
it from the same commit.
```sh
cd /home/overseer/mealquest && (
set -euo pipefail
fail() { echo "$*" >&2; exit 1; }
P=<rollback SHA> # recorded by step 1, from the running release's /health
back=
trap 'st=$?
if [ "$st" -eq 0 ] && [ -n "$back" ]; then echo "rolled back to: $back"
else [ "$st" -ne 0 ] || st=1; echo "rollback FAILED (status $st)" >&2; fi
exit "$st"' EXIT
git checkout --detach "$P"
v=$(git describe --tags --always)
docker image inspect "mealquest:$v" > /dev/null 2>&1 || make docker
docker image inspect "mealquest:$v" > /dev/null 2>&1 || fail "no image mealquest:$v"
docker tag "mealquest:$v" mealquest-mealquest
sudo systemctl restart mealquest
back="mealquest:$v"
)
```
3. **Run step 3 above** unchanged. The rollback is done only when it prints
`deployed and verified:` with the previous release's version.
+96
View File
@@ -0,0 +1,96 @@
# Launch evidence
Evidence that the production PWA meets the launch gates. The raw reports and
the check script are committed under [lighthouse/](lighthouse/).
## Run conditions
| | |
| --- | --- |
| Target | Production, <https://mealquest.cubecraftlabs.com> |
| Build | `mealquest:743a155` |
| Session | Signed in (household session cookie, supplied at run time) |
| Date | 2026-10-11 |
| Tool | Lighthouse 13.5.0, mobile form factor |
The session cookie value is redacted from the committed reports
(`configSettings.extraHeaders`); nothing else in them is changed.
## Lighthouse
Reports: [lighthouse/2026-10-11/](lighthouse/2026-10-11/) — one
`.report.json` and one `.report.html` per page.
| Page | Route | Performance | Accessibility | Best practices |
| --- | --- | --- | --- | --- |
| Home | `/` | 71 | 100 | 100 |
| Plan | `/plan` | 72 | 100 | 100 |
| Discover | `/discover` | 66 | 100 | 100 |
| Dinner detail | `/dinner/<id>` | 70 | 100 | 100 |
**Gate: accessibility ≥ 95 on every page — met** (100 on all four).
Performance is recorded for reference but is not gated.
## Installability
Lighthouse 10 and later removed the PWA category, so installability was checked
directly with a Playwright probe:
[installability-check.mjs](lighthouse/2026-10-11/installability-check.mjs).
It loads each of the four pages above in a signed-in Chrome session with
Playwright's Pixel 5 mobile emulation (viewport, touch, `isMobile` and mobile
user agent; the cookie comes from the `CK` environment variable, never the
file) and gates every page on all of:
- **Manifest** — the linked manifest is fetched with HTTP 200 and has
`name` "MealQuest", `display` "standalone", a `start_url`, and icons at
192x192, 512x512 and one with `maskable` purpose.
- **Service worker** — `navigator.serviceWorker.ready` resolves within 10
seconds (expiry is a failure), and a registration exists whose active worker
is `activated`.
It prints a PASS or FAIL line per condition per page and exits 1 if any
condition fails on any page.
**Result: PASS on all four pages.**
## Production smoke — 2026-10-11/12
An end-to-end smoke of the launch flows against production, run in the
household's owner session.
| | |
| --- | --- |
| Target | Production, <https://mealquest.cubecraftlabs.com> |
| Build | `mealquest:743a155`, then `mealquest:561f249` mid-smoke |
| Session | Owner session (signed in) |
| Google Chat | Test space "MealQuest - Test" (not the family space) |
| Google Calendar | Test calendar "MealQuest - Test" (not the family calendar) |
| Dates | 2026-10-11 to 2026-10-12 |
The smoke spanned two deploys: Feature 24 shipped mid-smoke through the new
auto-deploy path, moving production from `743a155` to `561f249`.
| Step | Result |
| --- | --- |
| Sign-in session and CSRF | ✓ |
| `/home` shows the Regulars | ✓ |
| Suggestion batch through the real LLM | ✓ 10 grounded cards, each "Why this?" citing the Regulars |
| Try it | ✓ dinner created with a grounded photo, added to the Quest log |
| Pass | ✓ declined dinner never re-suggested; deck 10 → 8 |
| AI draft, Week 42 (Oct 12–18) | ✓ 7 days, no duplicates, Quest-log pick marked `is_new`, `ai_note` cites the Regulars |
| Day swap | ✓ Monday → Slow Cooker French Dip Sandwiches (crock pot) |
| Push to Calendar | ✓ synced; owner confirmed 7 events; a second push made no duplicates |
| Supply run | ✓ 27 items, AI title "A Beefy Week with Pizza Night" |
| Have-it toggle | ✓ needed items 27 → 26 |
| Send to Google Chat | ✓ `sent_to_chat_at` stamped; owner confirmed receipt in the test space |
| Test push notification | ✓ HTTP 202, `event_key` `plan_ready:test-…`, 3 subscriptions; owner confirmed receipt on their iPhone |
| Quick dinner ideas | ✓ 6 ideas |
| Cook from what I have | ✓ 4 ideas, with uses and missing ingredients computed |
**Result: PASS on every step.**
Evidence form: server-side API responses and timestamps, plus the owner's
explicit confirmations for the steps that land outside the app (calendar
events, Google Chat message, push notification on the phone). The owner's
confirmation quotes are recorded on Maestro task
`0544e318-9b5f-4264-9884-1ec8cb63fd49`. Phone screenshots were not captured.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,53 @@
import { chromium, devices } from 'playwright';
const ck = process.env.CK;
if (!ck) { console.error('CK (session cookie) is not set'); process.exit(2); }
const origin = 'https://mealquest.cubecraftlabs.com';
const pages = [['home','/'],['plan','/plan'],['discover','/discover'],['dinner','/dinner/01M4PKT9QYA6C4JCF67XE7RA6R']];
const SW_READY_TIMEOUT_MS = 10000;
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox'] });
// Mobile emulation: Pixel 5 viewport, device scale, touch, isMobile and user agent.
const ctx = await browser.newContext({ ...devices['Pixel 5'] });
await ctx.addCookies([{ name: '__Host-mealquest_session', value: ck, url: origin + '/' }]);
let fail = 0;
for (const [name, path] of pages) {
const page = await ctx.newPage();
page.on('console', m => { if (/sw|service|error/i.test(m.text())) console.log(' [console]', m.text().slice(0,120)); });
page.on('pageerror', e => console.log(' [pageerror]', String(e).slice(0,120)));
await page.goto(origin + path, { waitUntil: 'networkidle', timeout: 30000 });
const href = await page.evaluate(() => document.querySelector('link[rel="manifest"]')?.href || null);
const sw = await page.evaluate(async (ms) => {
const ready = await Promise.race([
navigator.serviceWorker.ready.then(() => 'ready', e => 'error: ' + String(e)),
new Promise(r => setTimeout(() => r('timeout after ' + ms + ' ms'), ms)),
]);
const regs = await navigator.serviceWorker.getRegistrations();
return { ready, registrations: regs.length, state: regs[0]?.active?.state || null };
}, SW_READY_TIMEOUT_MS);
const manifest = href
? await page.evaluate(async (h) => { const r = await fetch(h); return { status: r.status, body: r.ok ? await r.json() : {} }; }, href)
: { status: 'no manifest link', body: {} };
const m = manifest.body;
const icons = m.icons || [];
const hasSize = (s) => icons.some(i => (i.sizes || '').split(/\s+/).includes(s));
const checks = [
['manifest HTTP 200', manifest.status === 200, manifest.status],
['manifest name "MealQuest"', m.name === 'MealQuest', m.name],
['manifest display "standalone"', m.display === 'standalone', m.display],
['manifest start_url present', !!m.start_url, m.start_url],
['icon 192x192', hasSize('192x192'), icons.map(i => i.sizes).join(',')],
['icon 512x512', hasSize('512x512'), icons.map(i => i.sizes).join(',')],
['icon purpose maskable', icons.some(i => (i.purpose || '').split(/\s+/).includes('maskable')), icons.map(i => i.purpose || 'any').join(',')],
['service worker ready', sw.ready === 'ready', sw.ready],
['service worker registered', sw.registrations > 0, sw.registrations],
['service worker activated', sw.state === 'activated', sw.state],
];
console.log(`${name} (${path}) url=${page.url().replace(origin, '')}:`);
for (const [label, ok, actual] of checks) console.log(` ${ok ? 'PASS' : 'FAIL'} ${label} (got ${JSON.stringify(actual ?? null)})`);
const pass = checks.every(([, ok]) => ok);
if (!pass) fail++;
console.log(` => ${pass ? 'INSTALLABLE' : 'NOT INSTALLABLE'}`);
await page.close();
}
await browser.close();
console.log(fail ? `FAILED on ${fail} of ${pages.length} pages` : `PASS on all ${pages.length} pages`);
process.exit(fail ? 1 : 0);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#5a358c" />
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>MealQuest</title>
</head>
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 785 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

+60 -4
View File
@@ -1,5 +1,5 @@
// Command generate-icons writes the PWA icons in web/public from the
// MealQuest logo. Run it from the repository root:
// Command generate-icons writes the PWA icons and favicons in web/public from
// the MealQuest logo. Run it from the repository root:
//
// go run ./web/scripts/generate-icons
//
@@ -9,10 +9,13 @@
package main
import (
"bytes"
"encoding/binary"
"fmt"
"image"
"image/color"
"image/png"
"io"
"math"
"os"
"path/filepath"
@@ -27,6 +30,9 @@ const (
maskableScale = 0.66
)
// faviconSizes are the images packed into favicon.ico.
var faviconSizes = []int{16, 32, 48}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "generate-icons:", err)
@@ -49,6 +55,8 @@ func run() error {
{"icons/icon-192.png", resized(logo, 192)},
{"icons/icon-512.png", resized(logo, 512)},
{"icons/icon-maskable-512.png", maskable(logo, 512)},
{"icons/favicon-16.png", resized(logo, 16)},
{"icons/favicon-32.png", resized(logo, 32)},
{"apple-touch-icon.png", resized(logo, 180)},
}
for _, out := range outputs {
@@ -58,9 +66,53 @@ func run() error {
}
fmt.Printf("wrote %s (%dx%d)\n", path, out.image.Rect.Dx(), out.image.Rect.Dy())
}
ico, err := favicon(logo)
if err != nil {
return err
}
path := filepath.Join(public, "favicon.ico")
if err := os.WriteFile(path, ico, 0o644); err != nil {
return err
}
fmt.Printf("wrote %s (%v)\n", path, faviconSizes)
return nil
}
// favicon is an ICO file holding the logo at each of faviconSizes as an
// embedded PNG: a 6-byte header, a 16-byte directory entry per image, then
// the images.
func favicon(logo image.Image) ([]byte, error) {
var images [][]byte
for _, size := range faviconSizes {
var buf bytes.Buffer
if err := encode(&buf, resized(logo, size)); err != nil {
return nil, fmt.Errorf("encode favicon %dx%d: %w", size, size, err)
}
images = append(images, buf.Bytes())
}
var out bytes.Buffer
le := binary.LittleEndian
out.Write(le.AppendUint16(nil, 0)) // reserved
out.Write(le.AppendUint16(nil, 1)) // type: icon
out.Write(le.AppendUint16(nil, uint16(len(images)))) // image count
offset := 6 + 16*len(images)
for i, size := range faviconSizes {
out.WriteByte(byte(size)) // width
out.WriteByte(byte(size)) // height
out.WriteByte(0) // palette size: none
out.WriteByte(0) // reserved
out.Write(le.AppendUint16(nil, 1)) // colour planes
out.Write(le.AppendUint16(nil, 32)) // bits per pixel
out.Write(le.AppendUint32(nil, uint32(len(images[i]))))
out.Write(le.AppendUint32(nil, uint32(offset)))
offset += len(images[i])
}
for _, img := range images {
out.Write(img)
}
return out.Bytes(), nil
}
// load decodes the logo. The file is a PNG despite its .jpg name, so the
// format is sniffed rather than taken from the extension.
func load(path string) (image.Image, error) {
@@ -84,14 +136,18 @@ func save(path string, img image.Image) error {
if err != nil {
return err
}
enc := png.Encoder{CompressionLevel: png.BestCompression}
if err := enc.Encode(f, img); err != nil {
if err := encode(f, img); err != nil {
f.Close()
return fmt.Errorf("encode %s: %w", path, err)
}
return f.Close()
}
func encode(w io.Writer, img image.Image) error {
enc := png.Encoder{CompressionLevel: png.BestCompression}
return enc.Encode(w, img)
}
func resized(logo image.Image, size int) *image.RGBA {
dst := image.NewRGBA(image.Rect(0, 0, size, size))
draw.CatmullRom.Scale(dst, dst.Rect, logo, logo.Bounds(), draw.Src, nil)
+43 -3
View File
@@ -1,7 +1,8 @@
// @vitest-environment node
// Builds the app with the production config and serves the result, so the
// assertions cover what ships in dist: the web manifest, the service worker,
// every icon, and the service worker registration that only builds include.
// every icon and favicon, and the service worker registration that only builds
// include.
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { build, createServer, preview, type PreviewServer, type ViteDevServer } from 'vite'
import tokens from './styles/tokens.css?raw'
@@ -12,9 +13,13 @@ const icons = [
{ path: '/icons/icon-192.png', size: 192 },
{ path: '/icons/icon-512.png', size: 512 },
{ path: '/icons/icon-maskable-512.png', size: 512 },
{ path: '/icons/favicon-16.png', size: 16 },
{ path: '/icons/favicon-32.png', size: 32 },
{ path: '/apple-touch-icon.png', size: 180 },
]
const faviconSizes = [16, 32, 48]
function token(name: string): string {
const value = tokens.match(new RegExp(`^\\s*${name}:\\s*([^;\\s]+);`, 'm'))?.[1]
if (!value) throw new Error(`${name} is not defined in tokens.css`)
@@ -38,6 +43,18 @@ function pngSize(bytes: Uint8Array): { width: number; height: number } {
return { width: view.getUint32(16), height: view.getUint32(20) }
}
// The images in an ICO file: a 6-byte header (reserved, type 1, count), then
// a 16-byte directory entry per image giving its byte length and offset.
function icoImages(bytes: Uint8Array): Uint8Array[] {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
expect([view.getUint16(0, true), view.getUint16(2, true)]).toEqual([0, 1])
return Array.from({ length: view.getUint16(4, true) }, (_, i) => {
const entry = 6 + 16 * i
const offset = view.getUint32(entry + 12, true)
return bytes.subarray(offset, offset + view.getUint32(entry + 8, true))
})
}
describe('production build', () => {
let server: PreviewServer
let origin: string
@@ -94,13 +111,25 @@ describe('production build', () => {
expect(pngSize(bytes)).toEqual({ width: size, height: size })
})
it('ships /favicon.ico holding the logo as PNGs at every favicon size', async () => {
const bytes = new Uint8Array(await (await get('/favicon.ico')).arrayBuffer())
expect(icoImages(bytes).map((png) => pngSize(png))).toEqual(
faviconSizes.map((size) => ({ width: size, height: size })),
)
})
it('emits sw.js with the precache list injected', async () => {
const sw = await (await get('/sw.js')).text()
expect(sw).not.toContain('self.__WB_MANIFEST')
expect(sw).toContain('"url":"index.html"')
for (const { path } of icons.slice(0, 3)) {
expect(sw).toContain(`"url":"${path.slice(1)}"`)
})
it('precaches every icon and favicon so the offline shell keeps them', async () => {
const urls = precacheEntries(await (await get('/sw.js')).text()).map((entry) => entry.url)
for (const path of [...icons.map((icon) => icon.path), '/favicon.ico']) {
expect(urls).toContain(path.slice(1))
}
expect(new Set(urls).size, 'precache URLs are unique').toBe(urls.length)
})
it('precaches the shell, JS, CSS and every self-hosted font', async () => {
@@ -129,6 +158,17 @@ describe('production build', () => {
expect(html).toContain('<link rel="apple-touch-icon" href="/apple-touch-icon.png" />')
})
it('links the favicons from index.html', async () => {
const html = await (await get('/')).text()
expect(html).toContain('<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />')
expect(html).toContain(
'<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />',
)
expect(html).toContain(
'<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png" />',
)
})
it('registers /sw.js at scope / from index.html', async () => {
const html = await (await get('/')).text()
expect(html).toContain('<script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script>')
+5 -2
View File
@@ -17,8 +17,11 @@ export default defineConfig({
srcDir: 'src',
filename: 'sw.ts',
injectRegister: 'script',
// Precache the shell, JS, CSS and the self-hosted fonts.
injectManifest: { globPatterns: ['**/*.{html,js,css,woff2}'] },
// Precache the shell, JS, CSS, the self-hosted fonts and every icon
// and favicon. The glob already covers the manifest icons, so the
// plugin does not add them a second time.
injectManifest: { globPatterns: ['**/*.{html,js,css,woff2,png,ico}'] },
includeManifestIcons: false,
// The web manifest lives in manifest.json: its theme and background
// colours are --color-accent and --color-bg from src/styles/tokens.css,
// and hex colours may not be written in TypeScript.