Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcc9e56749 | ||
|
|
28164e762d | ||
|
|
90c2d79354 |
@@ -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`. |
|
||||
|
||||
@@ -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
@@ -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 |
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user