From 9ab189527974323fa9492f4576f8271e4f8fdb50 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 2 Apr 2026 21:45:48 +0200 Subject: [PATCH] docs: add dynamic PWA manifest with org logo design spec Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../2026-04-02-dynamic-pwa-manifest-design.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-02-dynamic-pwa-manifest-design.md diff --git a/docs/superpowers/specs/2026-04-02-dynamic-pwa-manifest-design.md b/docs/superpowers/specs/2026-04-02-dynamic-pwa-manifest-design.md new file mode 100644 index 0000000..af2a9fb --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-dynamic-pwa-manifest-design.md @@ -0,0 +1,158 @@ +# Dynamic PWA Manifest with Organization Logo + +**Date**: 2026-04-02 +**Based on**: [PWA Dynamic Logo Research](pwa-dynamic-logo-research.md) +**Approach**: Option A — Dynamic Worker Manifest with cookie-based org identification + +--- + +## Overview + +Enable organizations to upload a custom logo that replaces the default XTablo icons in the installed PWA. The Cloudflare Worker serves a dynamic `manifest.webmanifest` that includes org-specific icon URLs when a cookie identifies the current org. The server resizes the uploaded logo into all required PWA icon sizes and stores them in R2. + +--- + +## 1. Database — `logo_url` on Organizations + +**Migration**: Add a nullable `logo_url` column to the `organizations` table. + +```sql +ALTER TABLE public.organizations ADD COLUMN logo_url text; +``` + +When null, the worker serves the default XTablo icons. When set, it contains the R2 base path for the org's icons (e.g., `org-icons/42/`). + +--- + +## 2. R2 Storage — Icon Variants + +**Bucket prefix**: `org-icons/{org_id}/` + +**Generated sizes** (from a single uploaded image, minimum 512x512): + +| File | Size | Purpose | +|------|------|---------| +| `icon-16.png` | 16x16 | Favicon | +| `icon-32.png` | 32x32 | Favicon | +| `icon-180.png` | 180x180 | Apple touch icon | +| `icon-192.png` | 192x192 | PWA icon | +| `icon-512.png` | 512x512 | PWA icon | +| `icon-512-maskable.png` | 512x512 | PWA maskable icon (with safe zone padding) | + +**Resizing**: Done server-side with `sharp` on the API server (Cloud Run). + +--- + +## 3. Logo Upload API + +**Endpoint**: `PATCH /api/v1/users/organization` (extend existing) + +The existing endpoint accepts `{ name }`. Extend to: + +```typescript +{ + name?: string; + logo?: { content: string; contentType: string } | null; +} +``` + +- `logo` with content: upload and resize the image, store in R2, set `logo_url` +- `logo: null`: delete icons from R2, clear `logo_url` +- `logo` omitted: no change to the logo + +**Server-side flow**: +1. Validate: must be PNG, JPG, or WebP; minimum 512x512 pixels +2. Resize with `sharp` into the 6 variants listed above +3. Upload all variants to R2 at `org-icons/{org_id}/icon-{size}.png` +4. Update `organizations.logo_url` with the base path (`org-icons/{org_id}/`) +5. Return updated organization data + +**Icon serving endpoint**: `GET /api/v1/org-icons/:orgId/icon-:size.png` + +- Public (no auth required) — icons must be fetchable by the browser during PWA install +- Fetches from R2 and returns the image +- Cache headers: `Cache-Control: public, max-age=86400` + +--- + +## 4. Cookie-Based Org Identification + +**Cookie name**: `x-org-id` +**Set by**: Frontend, as a side effect in the `useOrganization` hook +**Value**: The organization's numeric `id` +**Attributes**: `path=/; secure; samesite=lax; max-age=31536000` +**Cleared on**: Logout + +The cookie is read by the Cloudflare Worker when serving the manifest. It allows the worker to identify the org without requiring authentication (the manifest fetch is unauthenticated). + +--- + +## 5. Dynamic Manifest in the Cloudflare Worker + +**Intercept path**: `/manifest.webmanifest` + +**Worker logic**: +1. Parse `x-org-id` cookie from the request +2. If cookie present: build manifest with org-specific icon URLs pointing to `/api/v1/org-icons/{org_id}/icon-{size}.png` +3. If cookie absent: build manifest with default XTablo icons from `/pwa-icons/` +4. Return JSON with `Content-Type: application/manifest+json` + +The worker does not check whether the org actually has a logo. It always builds org-specific icon URLs when the cookie is present. The icon-serving API endpoint handles the fallback: if the requested org icon doesn't exist in R2, it returns the default XTablo icon for that size. This keeps the worker stateless (no DB or R2 access needed). + +**Manifest content** (same structure as the current static manifest, only icons change): + +```json +{ + "name": "XTablo", + "short_name": "XTablo", + "description": "Collaborative project management for construction teams", + "start_url": "/", + "display": "standalone", + "orientation": "any", + "theme_color": "#1e1b2e", + "background_color": "#1e1b2e", + "icons": [ + { "src": "/api/v1/org-icons/42/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/api/v1/org-icons/42/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/api/v1/org-icons/42/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} +``` + +**Caching**: `Cache-Control: no-cache` on the manifest response. It's tiny JSON and must reflect the current org on every page load. + +**vite-plugin-pwa change**: Set `manifest: false` in the VitePWA config to stop generating a static manifest. Keep the `` tag manually in `index.html`. The plugin continues to handle service worker generation and precaching. + +--- + +## 6. Settings Page — Logo Upload UI + +**Location**: Extend the organization section in `apps/main/src/pages/settings.tsx` + +**UI elements**: +- Current org logo display (or placeholder when none set) +- Click-to-upload / drag-and-drop zone for a single image +- Client-side validation: image file type, minimum 512x512 resolution +- Sends base64 content to `PATCH /api/v1/users/organization` with the `logo` field +- On success: invalidates the org query cache (existing React Query pattern) +- "Remove logo" button: sends `logo: null` to clear + +--- + +## 7. What's Explicitly Out of Scope + +- **Runtime favicon swap** (`useOrgFavicon()` hook) — the static favicon is fine for now; the dynamic manifest handles the installed PWA icon +- **Subdomain-based org routing** — cookie-based identification is sufficient +- **Per-org PWA name** — always "XTablo" +- **Per-org theme_color** — always `#1e1b2e` + +--- + +## 8. Known Limitations + +| Limitation | Impact | +|------------|--------| +| iOS never updates installed PWA icons | Users must reinstall to see a new org logo | +| Android updates icons with multi-day delay | Not suitable for real-time branding changes | +| Cookie must be set before manifest fetch | First visit (unauthenticated) always gets default icons; org icons appear after login + next page load | +| Manifest `no-cache` means a worker request on every page load | Negligible cost — tiny JSON, fast worker response |