docs: add dynamic PWA manifest with org logo design spec

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-04-02 21:45:48 +02:00
parent 67e98d19b2
commit 9ab1895279
No known key found for this signature in database

View file

@ -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 `<link rel="manifest" href="/manifest.webmanifest">` 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 |