AT Mot — a bilingual (EN/FR) daily word game native to the AT Protocol.
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge add/pwa-installability: installable PWA (manifest + icons, no service worker)

+123 -1
+18
DECISIONS.md
··· 109 109 repository's current/default branch locally (HEAD points at it). Creating a remote and marking 110 110 `trunk` as the default branch *on the host* (GitHub/Tangled/etc.) is a one-step manual action for 111 111 the owner, since no remote exists to push to yet. 112 + 113 + ## PWA / installability 114 + 115 + 21. **Manifest-only PWA — deliberately no service worker.** The app is installable 116 + to the home screen via `public/manifest.webmanifest` + committed PNG icons 117 + (rasterized once from `icon.svg`, flattened onto `#0e1116`) + apple/mobile 118 + web-app meta tags. There is intentionally **no service worker and no caching 119 + layer**: this is a daily game with dynamic reads (Constellation leaderboard, 120 + PDS writes, OAuth), and an over-eager cache is exactly what would serve a 121 + stale puzzle or stale bundle. Consequence: iOS add-to-home-screen and 122 + Android manual "Add to Home Screen" both work from the manifest alone; the 123 + proactive Android install banner (which needs a service worker) is forgone on 124 + purpose. Loading speed is unchanged (CDN + ~94 KB bundle) with no new failure 125 + modes. The regression guard `tests/pwa.test.ts` uses `node:*` imports, so 126 + `"node"` was added to the shared `tsconfig.json` `types` allowlist — a 127 + conscious trade-off: Node globals become visible to `src/` typechecking, but 128 + `noEmit` + Vite bundling mean it can't affect runtime; narrow later by giving 129 + `tests` its own node-typed project if that boundary is wanted.
+3
README.md
··· 26 26 gets the same word on the same calendar day, with no server in the loop. 27 27 - **Standards-conformant lexicons** under the `bzh.herve.atmot.*` authority, following the 28 28 [Lexicon Style Guide](https://atproto.com/guides/lexicon-style-guide). 29 + - **Installs to your home screen, no strings attached.** A web manifest makes it installable as a 30 + standalone app — with **no service worker and no caching**, so there's none of the stale-content 31 + machinery that fights against a daily game whose reads are always live. 29 32 30 33 It is **not** a clone of any commercial word game: it ships its own word lists, its own brand and 31 34 look-and-feel (a sky/atproto-leaning **teal / amber / slate** board palette, deliberately distinct),
+5
index.html
··· 12 12 <meta name="theme-color" content="#f7f8fa" media="(prefers-color-scheme: light)" /> 13 13 <meta name="theme-color" content="#0e1116" media="(prefers-color-scheme: dark)" /> 14 14 <link rel="icon" href="/icon.svg" type="image/svg+xml" /> 15 + <link rel="manifest" href="/manifest.webmanifest" /> 16 + <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> 17 + <meta name="mobile-web-app-capable" content="yes" /> 18 + <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> 19 + <meta name="apple-mobile-web-app-title" content="AT Mot" /> 15 20 <link 16 21 rel="preload" 17 22 href="/fonts/OverusedGrotesk-VF.woff2"
public/apple-touch-icon.png

This is a binary file and will not be displayed.

public/icon-192.png

This is a binary file and will not be displayed.

public/icon-512.png

This is a binary file and will not be displayed.

+17
public/manifest.webmanifest
··· 1 + { 2 + "name": "AT Mot — a daily word game on atproto", 3 + "short_name": "AT Mot", 4 + "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.", 5 + "start_url": "/", 6 + "scope": "/", 7 + "display": "standalone", 8 + "background_color": "#0e1116", 9 + "theme_color": "#f7f8fa", 10 + "lang": "en", 11 + "dir": "ltr", 12 + "categories": ["games"], 13 + "icons": [ 14 + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, 15 + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } 16 + ] 17 + }
+79
tests/pwa.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { readFileSync, existsSync } from 'node:fs'; 3 + import { fileURLToPath } from 'node:url'; 4 + 5 + // Resolve paths relative to the repo root (this file is in tests/). 6 + const root = fileURLToPath(new URL('..', import.meta.url)); 7 + const p = (rel: string) => root + rel; 8 + 9 + const SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); 10 + 11 + /** Read width/height from a PNG's IHDR chunk (bytes 16–24, big-endian). */ 12 + function pngSize(path: string): { width: number; height: number } { 13 + const buf = readFileSync(path); 14 + if (!buf.subarray(0, 8).equals(SIG)) throw new Error(`${path} is not a PNG`); 15 + return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; 16 + } 17 + 18 + /** PNG IHDR color type (byte 25): 2 = RGB (opaque), 6 = RGBA. */ 19 + function pngColorType(path: string): number { 20 + return readFileSync(path).readUInt8(25); 21 + } 22 + 23 + describe('PWA icons', () => { 24 + const icons: Array<[string, number]> = [ 25 + ['public/icon-192.png', 192], 26 + ['public/icon-512.png', 512], 27 + ['public/apple-touch-icon.png', 180], 28 + ]; 29 + 30 + for (const [rel, size] of icons) { 31 + it(`${rel} exists and is ${size}x${size}`, () => { 32 + expect(existsSync(p(rel)), `${rel} missing`).toBe(true); 33 + const { width, height } = pngSize(p(rel)); 34 + expect(width).toBe(size); 35 + expect(height).toBe(size); 36 + // Icons must be opaque (flattened onto #0e1116), not RGBA. 37 + expect(pngColorType(p(rel)), `${rel} has an alpha channel`).toBe(2); 38 + }); 39 + } 40 + }); 41 + 42 + describe('manifest', () => { 43 + const manifest = JSON.parse(readFileSync(p('public/manifest.webmanifest'), 'utf8')); 44 + 45 + it('has the required installability fields', () => { 46 + expect(manifest.name).toBe('AT Mot — a daily word game on atproto'); 47 + expect(manifest.short_name).toBe('AT Mot'); 48 + expect(manifest.start_url).toBe('/'); 49 + expect(manifest.display).toBe('standalone'); 50 + expect(manifest.background_color).toBe('#0e1116'); 51 + }); 52 + 53 + it('declares 192 and 512 PNG icons that exist on disk', () => { 54 + const sizes = manifest.icons.map((i: { sizes: string }) => i.sizes); 55 + expect(sizes).toContain('192x192'); 56 + expect(sizes).toContain('512x512'); 57 + for (const icon of manifest.icons as Array<{ src: string; type: string }>) { 58 + expect(icon.type).toBe('image/png'); 59 + expect(existsSync(p('public' + icon.src)), `${icon.src} missing`).toBe(true); 60 + } 61 + }); 62 + }); 63 + 64 + describe('index.html PWA wiring', () => { 65 + const html = readFileSync(p('index.html'), 'utf8'); 66 + 67 + it('links the manifest and apple-touch-icon', () => { 68 + expect(html).toContain('rel="manifest"'); 69 + expect(html).toContain('href="/manifest.webmanifest"'); 70 + expect(html).toContain('rel="apple-touch-icon"'); 71 + expect(html).toContain('href="/apple-touch-icon.png"'); 72 + }); 73 + 74 + it('declares mobile-web-app capability and iOS app title', () => { 75 + expect(html).toContain('name="mobile-web-app-capable"'); 76 + expect(html).toContain('name="apple-mobile-web-app-title"'); 77 + expect(html).toContain('name="apple-mobile-web-app-status-bar-style"'); 78 + }); 79 + });
+1 -1
tsconfig.json
··· 4 4 "module": "ESNext", 5 5 "moduleResolution": "bundler", 6 6 "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 - "types": ["vite/client", "@atcute/atproto", "@atcute/bluesky"], 7 + "types": ["vite/client", "@atcute/atproto", "@atcute/bluesky", "node"], 8 8 "strict": true, 9 9 "noUnusedLocals": true, 10 10 "noUnusedParameters": true,