yiekheng 272fbcfa8a feat(web): PWA via @serwist/next + manifest + icons (P3/T22)
The web app is now installable on a phone home screen with offline
fallback for static assets and the navigation shell.

Pieces
------
- `src/app/manifest.webmanifest/route.ts` — dynamic manifest route.
  Standalone display mode, portrait orientation, dark theme matching
  the app, "any maskable" icons so the same PNG works for both
  regular launchers and Android adaptive icons.

- `src/pwa/sw.ts` — service worker entry. Uses serwist's stock
  recipe: skipWaiting + clientsClaim so a new worker takes over on
  the next navigation, navigationPreload to race the network with
  the worker boot, and `defaultCache` for HTML-network-first /
  static-cache-first / image+font cache TTLs.

- `next.config.ts` — wraps the existing config with `withSerwistInit`.
  Disabled in development (`NODE_ENV !== "production"`) because a
  service worker on every dev reload makes hot-reload extremely
  flaky.

- `package.json` build script switched to `next build --webpack`.
  `@serwist/next` doesn't yet support Turbopack (it logs a warning
  and silently skips emitting `sw.js`), and Next 16 defaults the
  build to Turbopack. The dev server still uses Turbopack — only
  production builds switch to webpack.

- `src/app/layout.tsx` metadata gains `manifest`, `icons.icon` (192
  + 512 PNG), and `icons.apple` (180 PNG). The existing
  `appleWebApp.capable` already opts iOS into standalone mode.

Icons
-----
Generated by a tiny one-shot script (`scripts/gen-pwa-icons.ts`)
that uses the workspace's already-installed sharp to render an SVG
wordmark at 512 / 192 / 180 px. Placeholder branding (dark square
with "cm" wordmark) — swap in real artwork later by editing the SVG
in the script and re-running `pnpm --filter @cmbot/web run gen:icons`.

Build artefacts
---------------
- `apps/web/public/icon-512.png`, `icon-192.png`,
  `apple-touch-icon.png` ARE committed (stable input).
- `apps/web/public/sw.js` and `swe-worker-*.js` are NOT — they're
  regenerated on every production build. Added to `.gitignore`.

Verification
------------
- Production build emits `[serwist] Bundling the service worker
  script with the URL '/sw.js' and the scope '/'...` and `sw.js`
  shows up in `public/`.
- `/manifest.webmanifest` is in the build's static-route table.
- 249 web tests still passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:20:45 +08:00

70 lines
2.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env tsx
/**
* Generate placeholder PWA icons (icon-512.png, icon-192.png,
* apple-touch-icon.png) into apps/web/public/.
*
* Run once via `pnpm --filter @cmbot/web run gen:icons`. The output is
* intentionally minimal — a dark square with the "cm" wordmark in
* a light bold sans-serif — until a designer hands us a real icon.
*
* Sharp is already in the workspace's node_modules (Baileys depends
* on it), so we re-use it here rather than introducing a new image
* library. Output is written as PNG with no alpha channel; the
* "any maskable" purpose in the manifest covers both regular launch
* icons and Android adaptive icons (which crop to their own shape).
*/
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import sharp from "sharp";
const OUT_DIR = join(import.meta.dirname, "..", "public");
const BG = "#0a0a0a"; // matches the manifest theme_color
const FG = "#ffffff"; // wordmark
const TEXT = "cm";
/** Render the icon at the given pixel size. */
async function renderIcon(size: number): Promise<Buffer> {
// Font size is tuned to fill ~half the icon's height with comfortable
// padding around the wordmark — same proportions Apple/Google use
// for their own home-screen icons.
const fontSize = Math.round(size * 0.46);
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<rect width="${size}" height="${size}" fill="${BG}" />
<text
x="50%"
y="50%"
text-anchor="middle"
dominant-baseline="central"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif"
font-weight="700"
font-size="${fontSize}"
fill="${FG}"
letter-spacing="-${Math.round(fontSize * 0.04)}"
>${TEXT}</text>
</svg>
`.trim();
return sharp(Buffer.from(svg)).png().toBuffer();
}
async function main(): Promise<void> {
const targets: Array<{ size: number; filename: string }> = [
{ size: 512, filename: "icon-512.png" },
{ size: 192, filename: "icon-192.png" },
{ size: 180, filename: "apple-touch-icon.png" },
];
for (const { size, filename } of targets) {
const png = await renderIcon(size);
const out = join(OUT_DIR, filename);
await writeFile(out, png);
console.log(` ${filename} (${size}×${size}, ${png.byteLength} bytes)`);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});