···109109 repository's current/default branch locally (HEAD points at it). Creating a remote and marking
110110 `trunk` as the default branch *on the host* (GitHub/Tangled/etc.) is a one-step manual action for
111111 the owner, since no remote exists to push to yet.
112112+113113+## PWA / installability
114114+115115+21. **Manifest-only PWA — deliberately no service worker.** The app is installable
116116+ to the home screen via `public/manifest.webmanifest` + committed PNG icons
117117+ (rasterized once from `icon.svg`, flattened onto `#0e1116`) + apple/mobile
118118+ web-app meta tags. There is intentionally **no service worker and no caching
119119+ layer**: this is a daily game with dynamic reads (Constellation leaderboard,
120120+ PDS writes, OAuth), and an over-eager cache is exactly what would serve a
121121+ stale puzzle or stale bundle. Consequence: iOS add-to-home-screen and
122122+ Android manual "Add to Home Screen" both work from the manifest alone; the
123123+ proactive Android install banner (which needs a service worker) is forgone on
124124+ purpose. Loading speed is unchanged (CDN + ~94 KB bundle) with no new failure
125125+ modes. The regression guard `tests/pwa.test.ts` uses `node:*` imports, so
126126+ `"node"` was added to the shared `tsconfig.json` `types` allowlist — a
127127+ conscious trade-off: Node globals become visible to `src/` typechecking, but
128128+ `noEmit` + Vite bundling mean it can't affect runtime; narrow later by giving
129129+ `tests` its own node-typed project if that boundary is wanted.
+3
README.md
···2626 gets the same word on the same calendar day, with no server in the loop.
2727- **Standards-conformant lexicons** under the `bzh.herve.atmot.*` authority, following the
2828 [Lexicon Style Guide](https://atproto.com/guides/lexicon-style-guide).
2929+- **Installs to your home screen, no strings attached.** A web manifest makes it installable as a
3030+ standalone app — with **no service worker and no caching**, so there's none of the stale-content
3131+ machinery that fights against a daily game whose reads are always live.
29323033It is **not** a clone of any commercial word game: it ships its own word lists, its own brand and
3134look-and-feel (a sky/atproto-leaning **teal / amber / slate** board palette, deliberately distinct),
···11+{
22+ "name": "AT Mot — a daily word game on atproto",
33+ "short_name": "AT Mot",
44+ "description": "A bilingual (English / French) daily 5-letter word game native to the AT Protocol. Your results live in your own PDS; the leaderboard is powered by the Constellation backlink index. No database.",
55+ "start_url": "/",
66+ "scope": "/",
77+ "display": "standalone",
88+ "background_color": "#0e1116",
99+ "theme_color": "#f7f8fa",
1010+ "lang": "en",
1111+ "dir": "ltr",
1212+ "categories": ["games"],
1313+ "icons": [
1414+ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
1515+ { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
1616+ ]
1717+}
+79
tests/pwa.test.ts
···11+import { describe, expect, it } from 'vitest';
22+import { readFileSync, existsSync } from 'node:fs';
33+import { fileURLToPath } from 'node:url';
44+55+// Resolve paths relative to the repo root (this file is in tests/).
66+const root = fileURLToPath(new URL('..', import.meta.url));
77+const p = (rel: string) => root + rel;
88+99+const SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
1010+1111+/** Read width/height from a PNG's IHDR chunk (bytes 16–24, big-endian). */
1212+function pngSize(path: string): { width: number; height: number } {
1313+ const buf = readFileSync(path);
1414+ if (!buf.subarray(0, 8).equals(SIG)) throw new Error(`${path} is not a PNG`);
1515+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
1616+}
1717+1818+/** PNG IHDR color type (byte 25): 2 = RGB (opaque), 6 = RGBA. */
1919+function pngColorType(path: string): number {
2020+ return readFileSync(path).readUInt8(25);
2121+}
2222+2323+describe('PWA icons', () => {
2424+ const icons: Array<[string, number]> = [
2525+ ['public/icon-192.png', 192],
2626+ ['public/icon-512.png', 512],
2727+ ['public/apple-touch-icon.png', 180],
2828+ ];
2929+3030+ for (const [rel, size] of icons) {
3131+ it(`${rel} exists and is ${size}x${size}`, () => {
3232+ expect(existsSync(p(rel)), `${rel} missing`).toBe(true);
3333+ const { width, height } = pngSize(p(rel));
3434+ expect(width).toBe(size);
3535+ expect(height).toBe(size);
3636+ // Icons must be opaque (flattened onto #0e1116), not RGBA.
3737+ expect(pngColorType(p(rel)), `${rel} has an alpha channel`).toBe(2);
3838+ });
3939+ }
4040+});
4141+4242+describe('manifest', () => {
4343+ const manifest = JSON.parse(readFileSync(p('public/manifest.webmanifest'), 'utf8'));
4444+4545+ it('has the required installability fields', () => {
4646+ expect(manifest.name).toBe('AT Mot — a daily word game on atproto');
4747+ expect(manifest.short_name).toBe('AT Mot');
4848+ expect(manifest.start_url).toBe('/');
4949+ expect(manifest.display).toBe('standalone');
5050+ expect(manifest.background_color).toBe('#0e1116');
5151+ });
5252+5353+ it('declares 192 and 512 PNG icons that exist on disk', () => {
5454+ const sizes = manifest.icons.map((i: { sizes: string }) => i.sizes);
5555+ expect(sizes).toContain('192x192');
5656+ expect(sizes).toContain('512x512');
5757+ for (const icon of manifest.icons as Array<{ src: string; type: string }>) {
5858+ expect(icon.type).toBe('image/png');
5959+ expect(existsSync(p('public' + icon.src)), `${icon.src} missing`).toBe(true);
6060+ }
6161+ });
6262+});
6363+6464+describe('index.html PWA wiring', () => {
6565+ const html = readFileSync(p('index.html'), 'utf8');
6666+6767+ it('links the manifest and apple-touch-icon', () => {
6868+ expect(html).toContain('rel="manifest"');
6969+ expect(html).toContain('href="/manifest.webmanifest"');
7070+ expect(html).toContain('rel="apple-touch-icon"');
7171+ expect(html).toContain('href="/apple-touch-icon.png"');
7272+ });
7373+7474+ it('declares mobile-web-app capability and iOS app title', () => {
7575+ expect(html).toContain('name="mobile-web-app-capable"');
7676+ expect(html).toContain('name="apple-mobile-web-app-title"');
7777+ expect(html).toContain('name="apple-mobile-web-app-status-bar-style"');
7878+ });
7979+});