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>
This commit is contained in:
2026-10-12 09:23:21 -04:00
co-authored by Claude Opus 5.5
parent 28164e762d
commit bcc9e56749
7 changed files with 111 additions and 9 deletions
+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.