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:
parent
67e98d19b2
commit
9ab1895279
1 changed files with 158 additions and 0 deletions
158
docs/superpowers/specs/2026-04-02-dynamic-pwa-manifest-design.md
Normal file
158
docs/superpowers/specs/2026-04-02-dynamic-pwa-manifest-design.md
Normal 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 |
|
||||
Loading…
Reference in a new issue