# ΞchoBraid Progressive Web App (PWA) Blueprint

## Repository Baseline Analysis

The [MultiplicityFoundation/EchoBraid](https://github.com/MultiplicityFoundation/EchoBraid) repository (default branch: `Multiplicity`) contains two Vite-powered sub-projects:

| Aspect | `app/` (Core Tool) | `website/` (Marketing Site) |
|--------|--------------------|-----------------------------|
| **Framework** | React 19 + react-router-dom (HashRouter) | React 19 + framer-motion + Three.js |
| **Build** | Vite 6 + TypeScript | Vite 6 + TypeScript |
| **Routing** | Hash-based (`/#/loop`, `/#/journal`, etc.) | State-based (no URL routing) |
| **Pages** | Home, SoftLoop, Journal, CoherenceMap, Settings | Home, Features, Copilot, Curriculum, Services, etc. |
| **API** | `@google/genai` (Gemini) | Gemini API key via env |
| **Existing PWA** | None | None |
| **Service Worker** | None | None |
| **Manifest** | None | None |

The README already mentions PWA in its badges and describes Capacitor-based mobile builds, but no PWA infrastructure exists yet. The app emphasizes **offline-first, local-first data sovereignty**, **low-stimulus UI**, and **dyslexia-friendly typography** — all of which align naturally with PWA capabilities.

***

## Phase 1: PWA Foundation

**Goal:** Install tooling, generate Web App Manifest, register a basic service worker.

### Step 1.1 — Install `vite-plugin-pwa`

Run from the **`app/`** directory (the primary PWA target):[^1][^2]

```bash
cd app
pnpm add -D vite-plugin-pwa
```

This installs the zero-config Vite PWA plugin which handles manifest generation, service worker creation via Workbox, and browser registration.[^1]

### Step 1.2 — Update `app/vite.config.ts`

Replace the current config with:

```typescript
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, '.', '');
  return {
    server: {
      port: 3000,
      host: '0.0.0.0',
    },
    plugins: [
      react(),
      VitePWA({
        registerType: 'autoUpdate',
        includeAssets: ['favicon.svg', 'favicon.ico', 'apple-touch-icon.png'],
        manifest: {
          name: 'ΞchoBraid — Learning that honors your signal',
          short_name: 'ΞchoBraid',
          description: 'Neurodivergent-aligned, consent-first dialogue tooling. Silence over output. Coherence over correctness.',
          theme_color: '#FAFAFA',
          background_color: '#FAFAFA',
          display: 'standalone',
          orientation: 'portrait',
          scope: '/',
          start_url: '/',
          categories: ['education', 'health', 'productivity'],
          icons: [
            {
              src: 'pwa-192x192.png',
              sizes: '192x192',
              type: 'image/png',
            },
            {
              src: 'pwa-512x512.png',
              sizes: '512x512',
              type: 'image/png',
            },
            {
              src: 'pwa-512x512.png',
              sizes: '512x512',
              type: 'image/png',
              purpose: 'any maskable',
            },
          ],
        },
        workbox: {
          clientsClaim: true,
          skipWaiting: true,
          globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
          navigateFallback: 'index.html',
          navigateFallbackAllowlist: [/^\/$/,  /^\/#\//],
        },
        devOptions: {
          enabled: true,
          type: 'module',
          navigateFallback: 'index.html',
        },
      }),
    ],
    define: {
      'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
      'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
    },
    resolve: {
      alias: {
        '@': path.resolve(__dirname, '.'),
      },
    },
  };
});
```

Key configuration decisions:[^3][^2]
- **`registerType: 'autoUpdate'`** — The service worker updates silently without prompting, honoring the low-stimulus principle. No disruptive "new version available" banners.
- **`display: 'standalone'`** — Removes browser chrome for an app-like experience.
- **`navigateFallback: 'index.html'`** — Ensures the HashRouter in `App.tsx` handles all routes offline.
- **Dark theme support** — `theme_color: '#FAFAFA'` matches the light default; see Phase 3 for dynamic theme-color switching.

### Step 1.3 — Update `app/index.html`

Add the following `<meta>` and `<link>` tags inside `<head>` of `app/index.html`:

```html
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#FAFAFA" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="ΞchoBraid" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#7FA99B" />
```

**Note:** The existing `<meta name="viewport">` tag is already correct. The `vite-plugin-pwa` plugin will auto-inject the `<link rel="manifest">` tag at build time.[^2]

### Step 1.4 — Create Offline Fallback Page

Create `app/public/offline.html`:

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>ΞchoBraid — Offline</title>
  <style>
    body {
      font-family: 'Lexend', 'Inter', sans-serif;
      background: #FAFAFA;
      color: #3F3F46;
      display: flex;
      align-items: center;
      justify-content: center;
      min-height: 100vh;
      margin: 0;
      line-height: 1.6;
      letter-spacing: 0.03em;
    }
    .container {
      text-align: center;
      max-width: 400px;
      padding: 2rem;
    }
    h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 1rem; }
    p { font-size: 1.1rem; color: #78716c; }
    .breath-dot {
      width: 20px; height: 20px;
      border-radius: 50%;
      background: #7FA99B;
      margin: 2rem auto;
      animation: breathe 8s ease-in-out infinite;
    }
    @keyframes breathe {
      0%, 100% { transform: scale(1); opacity: 0.8; }
      50% { transform: scale(1.3); opacity: 0.4; }
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="breath-dot"></div>
    <h1>You're offline right now.</h1>
    <p>That's okay. Your data is safe locally. Reconnect when you're ready — no rush.</p>
  </div>
</body>
</html>
```

This offline page mirrors EchoBraid's breath-harmonics aesthetic and non-coercive language.

***

## Phase 2: Offline-First & Caching Strategy

**Goal:** Implement caching strategies aligned with EchoBraid's local-first, sovereignty-centered architecture.

### Step 2.1 — Caching Strategy Map

EchoBraid's README declares: "Data stays local by default; export is explicit and redactable". The caching strategies should honor this:[^4][^5]

| Resource Type | Strategy | Cache Name | Rationale |
|--------------|----------|------------|-----------|
| App shell (HTML, JS, CSS) | **CacheFirst** | `echobraid-shell` | Instant loads; Vite hashes filenames for cache-busting[^4] |
| Fonts (Lexend, Inter) | **CacheFirst** | `echobraid-fonts` | Stable assets; critical for dyslexia-friendly rendering |
| Images/Icons | **CacheFirst** | `echobraid-images` | Static assets with long cache life[^4] |
| Gemini API calls | **NetworkFirst** | `echobraid-api` | Fresh responses preferred; cached fallback for offline[^4] |
| Navigation (HTML routes) | **NetworkFirst** | `echobraid-pages` | Ensures latest content while supporting offline[^4] |

### Step 2.2 — Advanced Workbox Config in `vite.config.ts`

Expand the `workbox` section:

```typescript
workbox: {
  clientsClaim: true,
  skipWaiting: true,
  globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
  navigateFallback: 'index.html',
  navigateFallbackAllowlist: [/^\/$/,  /^\/#\//],

  runtimeCaching: [
    // Google Fonts CSS
    {
      urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
      handler: 'CacheFirst',
      options: {
        cacheName: 'echobraid-google-fonts-css',
        expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 365 },
        cacheableResponse: { statuses: [0, 200] },
      },
    },
    // Google Fonts Files
    {
      urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
      handler: 'CacheFirst',
      options: {
        cacheName: 'echobraid-google-fonts-files',
        expiration: { maxEntries: 20, maxAgeSeconds: 60 * 60 * 24 * 365 },
        cacheableResponse: { statuses: [0, 200] },
      },
    },
    // Gemini API — NetworkFirst with 3s timeout
    {
      urlPattern: /^https:\/\/generativelanguage\.googleapis\.com\/.*/i,
      handler: 'NetworkFirst',
      options: {
        cacheName: 'echobraid-gemini-api',
        networkTimeoutSeconds: 3,
        expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 },
        cacheableResponse: { statuses: [0, 200] },
      },
    },
    // Tailwind CDN
    {
      urlPattern: /^https:\/\/cdn\.tailwindcss\.com\/.*/i,
      handler: 'CacheFirst',
      options: {
        cacheName: 'echobraid-tailwind',
        expiration: { maxEntries: 5, maxAgeSeconds: 60 * 60 * 24 * 30 },
        cacheableResponse: { statuses: [0, 200] },
      },
    },
  ],
},
```

### Step 2.3 — IndexedDB for Local-First Data

EchoBraid stores journal entries, session data, resonance engine logs, and coherence maps locally. Replace `localStorage` with IndexedDB via a lightweight wrapper.

Install `idb-keyval`:[^6]

```bash
pnpm add idb-keyval
```

Create `app/services/localStore.ts`:

```typescript
import { get, set, del, keys, clear } from 'idb-keyval';

export const LocalStore = {
  // Journal entries
  async saveJournalEntry(id: string, entry: object) {
    await set(`journal:${id}`, { ...entry, timestamp: Date.now() });
  },
  async getJournalEntry(id: string) {
    return get(`journal:${id}`);
  },
  async getAllJournalKeys() {
    const allKeys = await keys();
    return allKeys.filter(k => String(k).startsWith('journal:'));
  },

  // Resonance/session data
  async saveSessionData(id: string, data: object) {
    await set(`session:${id}`, data);
  },

  // Erase-after-24h compliance (per EB_ERASE_AFTER_24H config)
  async purgeExpired(maxAgeMs = 24 * 60 * 60 * 1000) {
    const allKeys = await keys();
    for (const key of allKeys) {
      const val = await get(key);
      if (val?.timestamp && Date.now() - val.timestamp > maxAgeMs) {
        await del(key);
      }
    }
  },

  // Full sovereignty wipe
  async eraseAll() {
    await clear();
  },
};
```

This integrates with the existing Settings page and honors the "erase after 24h" and "user-controlled retention" principles.

### Step 2.4 — Background Sync for Deferred API Calls

When the user is offline during a Soft Loop dialogue, queue Gemini API requests for later:

```typescript
// In the service worker (if using injectManifest strategy)
import { BackgroundSyncPlugin } from 'workbox-background-sync';

const bgSyncPlugin = new BackgroundSyncPlugin('echobraid-sync-queue', {
  maxRetentionTime: 24 * 60, // 24 hours in minutes
});
```

**Note:** This should be opt-in, consistent with Ξ.1 ("The system speaks only when coherence can be increased").

***

## Phase 3: Installability, Icons & Neurodivergent UX

**Goal:** Make the app installable, generate proper icons, and ensure the installed experience honors EchoBraid's sensory-aware design.

### Step 3.1 — Generate PWA Icon Assets

From a single source image (the ΞchoBraid logo), generate all required icon sizes. Place them in `app/public/`:

```
app/public/
├── favicon.ico          (32x32)
├── favicon.svg          (vector)
├── pwa-192x192.png      (192x192)
├── pwa-512x512.png      (512x512)
├── apple-touch-icon.png (180x180)
└── safari-pinned-tab.svg
```

Use `@vite-pwa/assets-generator` for automated generation:[^1]

```bash
pnpm add -D @vite-pwa/assets-generator
npx pwa-assets-generator --preset minimal-2023 public/favicon.svg
```

### Step 3.2 — Install Prompt (Consent-First)

EchoBraid must not auto-trigger aggressive install prompts — this violates its non-coercive principles. Create a gentle, dismissible install suggestion:

Create `app/components/InstallPrompt.tsx`:

```tsx
import React, { useState, useEffect } from 'react';

interface BeforeInstallPromptEvent extends Event {
  prompt: () => Promise<void>;
  userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}

const InstallPrompt: React.FC = () => {
  const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
  const [dismissed, setDismissed] = useState(false);

  useEffect(() => {
    const handler = (e: Event) => {
      e.preventDefault();
      setDeferredPrompt(e as BeforeInstallPromptEvent);
    };
    window.addEventListener('beforeinstallprompt', handler);
    return () => window.removeEventListener('beforeinstallprompt', handler);
  }, []);

  if (!deferredPrompt || dismissed) return null;

  return (
    <div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-80
      bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800
      rounded-2xl p-5 shadow-sm z-50 animate-fade-in">
      <p className="text-sm text-stone-600 dark:text-stone-400 mb-3 font-medium leading-relaxed">
        You may install ΞchoBraid for offline access. No pressure.
      </p>
      <div className="flex gap-3">
        <button
          onClick={async () => {
            await deferredPrompt.prompt();
            setDeferredPrompt(null);
          }}
          className="px-4 py-2 text-sm font-semibold bg-stone-800 dark:bg-stone-100
            text-white dark:text-stone-900 rounded-xl"
        >
          Install
        </button>
        <button
          onClick={() => setDismissed(true)}
          className="px-4 py-2 text-sm font-semibold text-stone-400 hover:text-stone-600"
        >
          Not now
        </button>
      </div>
    </div>
  );
};

export default InstallPrompt;
```

Add to `app/App.tsx`:
```tsx
import InstallPrompt from './components/InstallPrompt';
// Inside the Router, after <Navigation />:
<InstallPrompt />
```

### Step 3.3 — Dynamic Theme Color for Dark Mode

The existing app supports dark mode via `localStorage.theme`. Update the `<meta name="theme-color">` dynamically:

```typescript
// In App.tsx useEffect
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
  meta.setAttribute('content', isDark ? '#0c0a09' : '#FAFAFA');
}
```

### Step 3.4 — Accessibility in PWA Context

EchoBraid already implements several neurodivergent-friendly patterns:
- **Dyslexia-friendly fonts** (Lexend)
- **Adjustable contrast** (dark mode)
- **Minimal motion** (breath animations at 8s cycles)
- **`prefers-reduced-motion`** — Add media query respect in the service worker to skip caching heavy animation assets

Ensure the PWA manifest includes:
```json
{
  "prefer_related_applications": false,
  "categories": ["education", "health"]
}
```

This prevents the browser from suggesting native app stores over the PWA installation.[^7]

***

## Phase 4: Capacitor Sync & Production Hardening

**Goal:** Integrate PWA with the existing Capacitor mobile pipeline and validate for production.

### Step 4.1 — Capacitor Compatibility

The README describes Capacitor-based builds:

```bash
pnpm build
npx cap add ios
npx cap add android
```

> **Note:** `ios` workflows require **macOS + Xcode + CocoaPods**. If you're on Linux/Windows, use Android commands or run iOS steps on a macOS machine.

The PWA and Capacitor can coexist. Vite builds the same output; Capacitor wraps it in a native WebView. The service worker operates in the browser PWA context but is typically ignored inside Capacitor's WebView.

Add conditional service worker registration in `app/index.tsx`:

```typescript
// Only register SW in browser context, not Capacitor native
if (!('Capacitor' in window)) {
  // vite-plugin-pwa handles registration automatically
}
```

### Step 4.2 — Build & Verify

```bash
cd app
pnpm build
pnpm preview
```

Then open Chrome DevTools → Application tab:
1. **Manifest** — Verify name, icons, display mode, start_url
2. **Service Workers** — Confirm registered and activated
3. **Cache Storage** — Verify precached assets and runtime caches

### Step 4.3 — Lighthouse PWA Audit

Run Lighthouse in Chrome DevTools or CLI:

```bash
npx lighthouse http://localhost:4173 --only-categories=pwa --output=json
```

Target scores:
- **Installable** ✓ (manifest + service worker + HTTPS)
- **PWA Optimized** ✓ (redirects HTTP → HTTPS, has apple-touch-icon, theme-color meta)
- **Offline-capable** ✓ (service worker serves offline fallback)

### Step 4.4 — HTTPS Requirement

PWAs require HTTPS in production. Options for deployment:
- **GitHub Pages** — Free HTTPS for `*.github.io` domains
- **Netlify / Vercel** — Automatic HTTPS with custom domains
- **Cloudflare Pages** — Edge-deployed with HTTPS

### Step 4.5 — Update `package.json` Scripts

Add PWA-specific scripts to `app/package.json`:

```json
{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "generate-pwa-assets": "pwa-assets-generator --preset minimal-2023 public/favicon.svg",
    "pwa:ios": "pnpm build && node scripts/cap-sync.mjs ios",
    "pwa:android": "pnpm build && node scripts/cap-sync.mjs android"
  }
}
```

***

## Complete File Tree (New/Modified)

```
app/
├── public/
│   ├── favicon.ico               ← NEW
│   ├── favicon.svg               ← NEW
│   ├── pwa-192x192.png           ← NEW
│   ├── pwa-512x512.png           ← NEW
│   ├── apple-touch-icon.png      ← NEW
│   ├── safari-pinned-tab.svg     ← NEW
│   └── offline.html              ← NEW
├── components/
│   ├── BreathOverlay.tsx          (existing)
│   ├── Navigation.tsx             (existing)
│   └── InstallPrompt.tsx         ← NEW
├── services/
│   ├── resonanceEngine.ts         (existing)
│   └── localStore.ts             ← NEW
├── pages/
│   ├── Home.tsx                   (existing)
│   ├── SoftLoop.tsx               (existing)
│   ├── Journal.tsx                (existing)
│   ├── CoherenceMap.tsx           (existing)
│   ├── Settings.tsx               (existing)
│   └── HoloScriptor.tsx          (existing)
├── App.tsx                        ← MODIFIED (add InstallPrompt)
├── capacitor.config.ts            ← NEW
├── index.html                     ← MODIFIED (add PWA meta tags)
├── vite.config.ts                 ← MODIFIED (add VitePWA plugin)
├── package.json                   ← MODIFIED (add dependencies + scripts)
├── scripts/
│   └── cap-sync.mjs              ← NEW
└── tsconfig.json                  (no changes needed)
```

***

## Ethical Alignment Checklist

Every PWA decision must pass through EchoBraid's axioms:

| Axiom | PWA Implementation |
|-------|-------------------|
| **Ξ.1** — Speak only when coherence increases | Service worker updates silently (`autoUpdate`); no disruptive banners |
| **Ξ.2** — Feedback loops > explanations | Install prompt is observational ("You may install..."), not didactic |
| **Ξ.3** — No identity-phase bleed | No analytics in service worker; no tracking of install events |
| **Ξ.4** — Silence is lawful | Offline page shows breath animation + reassurance, not error messaging |
| **Ξ.5** — Neurodivergence is prime | Dyslexia fonts cached first; reduced-motion respected; low-stimulus offline page |
| **Local-first sovereignty** | IndexedDB replaces localStorage; 24h auto-purge honored; full erase available |
| **Consent-first** | Install prompt is dismissible and non-recurring; service worker is non-invasive |

***

## Implementation Priority

1. **Start with `app/`** — This is the core dialogue tool and the primary PWA candidate
2. **Website later** — The `website/` marketing site can receive a simpler PWA config (precache only) once `app/` is validated
3. **Avoid coupling** — Each sub-project should have its own `vite-plugin-pwa` config since they have separate `vite.config.ts` files and build pipelines

---

## References

1. [vite-pwa/vite-plugin-pwa: Zero-config PWA for Vite](https://github.com/vite-pwa/vite-plugin-pwa) - Zero-config PWA for Vite. Contribute to vite-pwa/vite-plugin-pwa development by creating an account ...

2. [Getting Started | Guide - Vite PWA - Netlify](https://vite-pwa-org.netlify.app/guide/) - Vite PWA will help you to turn your existing applications into PWAs with very little configuration. ...

3. [reactjs - Add PWA to Vite project - Stack Overflow](https://stackoverflow.com/questions/77950880/add-pwa-to-vite-project) - You need to add manifest object inside VitePWA() in vite.config file. Here's the link for detailed t...

4. [Offline-First PWAs: Service Worker Caching Strategies - MagicBell](https://www.magicbell.com/blog/offline-first-pwas-service-worker-caching-strategies) - This comprehensive guide explores offline-first architecture, service worker fundamentals, and the c...

5. [Workbox | web.dev](https://web.dev/learn/pwa/workbox) - Workbox is a set of modules that simplify common service worker interactions such as routing and cac...

6. [offline-storage-in-a-pwa/BLOG.md at master · johnnyreilly/offline-storage-in-a-pwa](https://github.com/johnnyreilly/offline-storage-in-a-pwa/blob/master/BLOG.md) - How to do offline storage in a PWA using idb-keyval, typescript, react and hooks - johnnyreilly/offl...

7. [WCAG & Neurodiversity: Inclusive Digital Experiences](https://www.wcag.com/blog/digital-accessibility-and-neurodiversity/) - Learn how WCAG helps users with different brain types, like autism, dyslexia, and ADHD. Learn the ma...

