# PWA Dynamic Logo/Icon Research

**Date**: 2026-04-02
**Context**: Research into whether a PWA can display different logos/icons at runtime (e.g., per organization or per environment) and how to implement this in the XTablo codebase.

---

## Current State of the Codebase

The PWA has **not been implemented yet**. There is a design spec (`2026-04-02-pwa-design.md`) that describes the planned setup using `vite-plugin-pwa`, but no manifest, service worker, or PWA icons exist in the codebase today.

Relevant files:
- `apps/main/vite.config.ts` -- no PWA plugin configured
- `apps/main/public/` -- contains `icon.jpg`, `staging_icon.jpg`, logos, but no `pwa-icons/` directory
- `apps/main/worker/index.ts` -- minimal Cloudflare Worker stub (handles `/api/` prefix, otherwise 404)
- `apps/main/wrangler.toml` -- Cloudflare Workers config with `not_found_handling = "single-page-application"`

This means we have the opportunity to design the PWA with dynamic icons in mind from the start, rather than retrofitting.

---

## 1. Can the PWA Manifest Be Dynamic?

**Yes, but with significant caveats.**

### How it works

The `<link rel="manifest" href="...">` tag in HTML can point to any URL, including a dynamic endpoint. The browser fetches this URL to get the manifest JSON. So you could serve different manifests based on domain, cookie, query parameter, or any other request signal.

### Browser behavior on manifest fetch

- **Fetched once on page load** -- the browser reads the manifest when the page loads. It is not re-fetched on navigation within the SPA.
- **Cached aggressively** -- browsers cache the manifest. Chrome re-fetches it periodically (roughly every 24 hours for installed PWAs), but this is not standardized.
- **Determines install identity** -- the `start_url` + `id` fields in the manifest define the PWA's identity. Changing these creates a "different" PWA from the browser's perspective.

### Can a dynamic manifest change the installed PWA's icon?

- **Android (Chrome)**: Yes, partially. Chrome checks the manifest periodically and will update the home screen icon if the manifest has changed. However, the update is not immediate -- it can take days, and the user must visit the app.
- **iOS (Safari)**: **No.** Apple does not re-read the manifest after the PWA is added to the home screen. The icon is captured at install time and never updated. The only way to change it is for the user to delete and re-add the PWA.
- **Desktop (Chrome, Edge)**: Similar to Android. Periodic manifest checks can update the icon, but with delay.

**Bottom line**: A dynamic manifest is useful for serving the right icon at *install time*, but cannot reliably change icons for *already-installed* PWAs.

---

## 2. Approaches to Dynamic Logos

### Option A: Dynamic Manifest Endpoint via Cloudflare Worker

**How**: The Cloudflare Worker at `worker/index.ts` intercepts requests to `/manifest.webmanifest` (or `/manifest.json`) and returns a dynamically-generated JSON response. The manifest content is determined by the request hostname, a cookie, or a query parameter.

**In this codebase**: The worker is currently a stub. It could be extended to handle manifest requests:

```typescript
// Conceptual -- not for implementation
if (url.pathname === "/manifest.webmanifest") {
  const isStaging = url.hostname.includes("staging");
  const manifest = {
    name: isStaging ? "XTablo (Staging)" : "XTablo",
    icons: [
      { src: isStaging ? "/staging-icons/icon-192.png" : "/pwa-icons/icon-192.png", sizes: "192x192" },
      // ...
    ],
    // ...
  };
  return new Response(JSON.stringify(manifest), {
    headers: { "Content-Type": "application/manifest+json" },
  });
}
```

The `index.html` would use `<link rel="manifest" href="/manifest.webmanifest">` and the worker would serve the right one.

**Pros**:
- Clean separation: single HTML file, dynamic manifest
- Works well for the staging vs. production use case (different hostnames already exist)
- Could extend to per-organization icons if orgs have custom domains or subdomains

**Cons**:
- Adds logic to the worker that must stay in sync with icon files
- The `<link rel="manifest">` is fetched before React/JS runs, so you cannot use client-side state (auth, organization context) to influence which manifest is served -- only request-level signals (hostname, cookies)
- For per-organization branding, the org must be identifiable from the URL or a cookie at page load time

**Feasibility**: High for environment-based (staging/prod). Medium for per-org, depending on whether orgs are identifiable from the hostname.

### Option B: Multiple Static Manifests (Selected at Build Time)

**How**: Build separate manifests for each environment. The `vite-plugin-pwa` config reads environment variables and generates the right manifest.

**In this codebase**: The design spec already envisions this -- staging uses `staging_icon.jpg`, production uses `icon.jpg`. The `vite-plugin-pwa` config would use `process.env` to select names and icon paths.

**Pros**:
- Simplest approach
- Works perfectly for the staging/prod distinction
- No worker changes needed
- Fully static -- great caching, no runtime complexity

**Cons**:
- Cannot vary per-organization at runtime
- Each variant requires a separate build (fine for 2 environments, not viable for N organizations)

**Feasibility**: High for environment-based. Not viable for per-org.

### Option C: Runtime Favicon/Icon Swapping via JavaScript

**How**: JavaScript runs after page load and swaps `<link rel="icon">` and `<link rel="apple-touch-icon">` elements. Can also modify `<link rel="manifest">` href to point to different manifests.

**In this codebase**: A React hook or effect in `App.tsx` could read the current organization from Zustand/React Query and update favicon links accordingly.

**Pros**:
- Full access to client-side state (which org the user belongs to)
- Can change the browser tab favicon dynamically
- No worker or build changes needed

**Cons**:
- **Does not affect the installed PWA icon.** The manifest is read before JS runs. Changing `<link rel="manifest">` after page load has no effect on the installed PWA's icon.
- Brief flash of default icon before JS swaps it
- Apple touch icon must be set before the browser reads it (before JS runs)

**Feasibility**: Good for in-browser tab favicon only. Useless for installed PWA icons.

### Option D: Organization-Branded Theming (Colors, Not Icons)

**How**: Use `theme_color` in the manifest (and the HTML meta tag) to brand the PWA's title bar / splash screen per organization. Icons stay the same (XTablo branding), but colors vary.

**Pros**:
- Simpler than managing N icon sets
- `theme_color` can be set dynamically via the `<meta name="theme-color">` tag in JS (this actually works at runtime for the browser chrome)
- The manifest `theme_color` affects the installed PWA's title bar (set at install time)

**Cons**:
- Limited branding -- color only, not logo
- Same manifest caching issues as Option A for the installed app
- Runtime `<meta name="theme-color">` changes work in the browser but do not affect the installed PWA's title bar

**Feasibility**: Useful as a complementary approach. Not a full solution for dynamic logos.

---

## 3. Practical Constraints Summary

| Constraint | Impact |
|------------|--------|
| Manifest is fetched once per page load, before JS runs | Dynamic manifest must be determined from request-level signals (URL, cookies), not client-side state |
| iOS never updates installed PWA icons | Users must reinstall to see new icons; no workaround |
| Android updates icons with multi-day delay | Not suitable for real-time branding changes |
| `<meta name="theme-color">` works at runtime | Good for dynamic color theming in the browser |
| Cloudflare Workers can intercept any request | Worker-based dynamic manifest is technically straightforward |
| Organizations would need to be identifiable from URL/cookie | Per-org branding requires subdomain routing or a persistent cookie set before page load |

---

## 4. Recommended Approach for This Codebase

### For the initial PWA implementation: Option B (Static, Build-Time)

The design spec already handles this correctly. Two environments (staging, production) with different icons selected via environment variables in the `vite-plugin-pwa` config. This is the right approach for the first iteration.

### For future per-organization branding: Option A (Dynamic Worker Manifest) + Option C (Runtime Favicon)

If per-org branding becomes a requirement:

1. **Determine org from URL.** The most reliable approach is subdomain-based routing (e.g., `acme.xtablo.com`). This makes the org identifiable at the request level without cookies or JS.

2. **Worker serves dynamic manifest.** Extend `worker/index.ts` to intercept `/manifest.webmanifest` requests and return a manifest with org-specific icons, name, and theme_color. Icons would be stored in a known path pattern (e.g., `/org-icons/{org-slug}/icon-192.png`).

3. **Runtime favicon swap.** A React hook reads the current org from Zustand and updates `<link rel="icon">` for the browser tab experience. This handles the case where a user switches orgs without reloading.

4. **Accept the iOS limitation.** iOS users will see the icon from when they installed the PWA. Document this as a known limitation.

### What is NOT worth pursuing

- **Dynamically changing the manifest href via JavaScript after page load** -- browsers ignore this for install/icon purposes.
- **Building N manifests at build time for N organizations** -- does not scale and requires a deploy for each new org.
- **Service worker-level manifest interception** -- service workers do not intercept manifest fetches.

---

## 5. Implementation Plan (If Pursuing Dynamic Per-Org Icons)

This plan assumes the basic PWA (from the design spec) is already implemented.

### Phase 1: Subdomain routing

- Configure DNS wildcard for `*.xtablo.com`
- Update `wrangler.toml` routes to accept wildcard subdomains
- Add org resolution logic to the worker (subdomain -> org slug lookup)

### Phase 2: Org icon storage

- Define a convention for org icon paths in R2 or the public directory (e.g., `/org-icons/{slug}/icon-{size}.png`)
- Provide a default icon set as fallback
- Admin UI or API endpoint for uploading org icons (with automatic resizing via sharp or Cloudflare Image Resizing)

### Phase 3: Dynamic manifest in the worker

- Extend `worker/index.ts` to intercept `/manifest.webmanifest`
- Resolve org from subdomain
- Return manifest JSON with org-specific `name`, `icons`, and `theme_color`
- Set appropriate `Cache-Control` headers (short TTL for dynamic content, or `stale-while-revalidate`)

### Phase 4: Runtime favicon hook

- Create `useOrgFavicon()` hook
- On org change, update `<link rel="icon">` and `<meta name="theme-color">` in the document head
- Use org data already available from the Zustand user store

### Estimated effort

- Phase 1: Medium (DNS, worker routing, org resolution)
- Phase 2: Medium (icon pipeline, storage)
- Phase 3: Small (worker logic is straightforward)
- Phase 4: Small (React hook, DOM manipulation)

### Prerequisites

- Basic PWA must be implemented first (the design spec work)
- Organizations must have a `slug` or identifier suitable for subdomains
- Decision on whether to use subdomains vs. another org identification mechanism
