Files
clawtap/docs/superpowers/plans/2026-03-26-pwa-optimization.md
kuannnn 42861ea7fa feat: ClawTap v0.1.0 — initial release
Multi-adapter mobile UI for AI coding assistants.
Supports Claude Code, Codex CLI, and Gemini CLI through one interface.

Features:
- Real-time bidirectional sync via tmux + WebSocket
- Cross-AI review (send one AI's output to another for review)
- Multi-review tabs with minimize/expand
- Push notifications (PWA) with smart session-aware filtering
- Three-channel event system (hooks, file watcher, pane monitor)
- Voice input, image paste, draft persistence
- Terminal-native design (JetBrains Mono, dark theme, pixel art claw)
- CLI with --adapter flag on every command
- Zero-overhead fire-and-forget hooks
2026-03-26 10:40:26 +08:00

17 KiB

PWA Optimization Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Bring CodeTap's PWA to production-grade quality with proper viewport handling, splash screens, install prompts, SW updates, badge management, draft persistence, navigation history, and social meta tags.

Architecture: All changes are additive — native Web APIs with feature detection, no new dependencies. App.tsx gains PWA lifecycle effects. ShimmerInput gains draft persistence. Manifest and HTML get richer metadata.

Tech Stack: Vite + vite-plugin-pwa + native Web APIs (beforeinstallprompt, History API, Network Information API)

Spec: docs/superpowers/specs/2026-03-26-pwa-optimization-design.md


File Structure

File Responsibility Action
index.html Viewport, splash images, OG tags Modify
vite.config.ts Manifest shortcuts, screenshots Modify
src/App.tsx Install prompt, SW update, badge clear, history API Modify
src/components/SessionsView.tsx Install banner UI Modify
src/components/ShimmerInput.tsx Draft auto-save Modify
src/components/StatusBar.tsx Slow network indicator Modify
src/index.css Safe area utilities Modify
public/splash/ iOS splash screen images Create
public/screenshots/ Manifest screenshots Create

Task 1: Viewport & Safe Areas

Files:

  • Modify: index.html

  • Modify: src/index.css

  • Step 1: Add viewport-fit=cover to index.html

Change the viewport meta tag:

<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
  • Step 2: Add safe area utilities to index.css

After the existing .safe-bottom rule:

.safe-top {
  padding-top: env(safe-area-inset-top);
}

.safe-x {
  padding-left: env(safe-area-inset-left);
  padding-right: env(safe-area-inset-right);
}
  • Step 3: Verify — open in iOS simulator or DevTools, check notch area

  • Step 4: Commit

git add index.html src/index.css
git commit -m "feat(pwa): viewport-fit=cover and full safe area utilities"

Task 2: iOS Splash Screens

Files:

  • Create: public/splash/ directory

  • Modify: index.html

  • Step 1: Create splash screen SVG generator script

Create a simple inline SVG splash as a data URI approach in index.html. This avoids needing to generate multiple PNGs. Use apple-mobile-web-app-startup-image with media queries for major iPhone sizes.

Add to <head> in index.html, after the apple-touch-icon link:

<!-- iOS Splash Screens -->
<meta name="apple-mobile-web-app-title" content="CodeTap" />
<link rel="apple-touch-startup-image"
  media="screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3)"
  href="/splash/splash-1290x2796.png" />
<link rel="apple-touch-startup-image"
  media="screen and (device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3)"
  href="/splash/splash-1179x2556.png" />
<link rel="apple-touch-startup-image"
  media="screen and (device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3)"
  href="/splash/splash-1284x2778.png" />
<link rel="apple-touch-startup-image"
  media="screen and (device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3)"
  href="/splash/splash-1170x2532.png" />
<link rel="apple-touch-startup-image"
  media="screen and (device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3)"
  href="/splash/splash-1125x2436.png" />
<link rel="apple-touch-startup-image"
  media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3)"
  href="/splash/splash-1242x2688.png" />
<link rel="apple-touch-startup-image"
  media="screen and (device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)"
  href="/splash/splash-750x1334.png" />
  • Step 2: Generate splash screen PNGs via Node.js canvas script

Create scripts/generate-splash.mjs that uses the built-in node:canvas or a simple HTML-to-PNG approach. Simplest method: create a single-use Node script that writes minimal HTML to a temp file and uses sharp or pure SVG-to-PNG. Actually, the most practical approach for a CLI tool: use a simple Node script that generates SVG strings and writes them as .svg files that Safari can use (Safari accepts SVG for startup images). If SVG doesn't work, use a single high-res PNG and reference it without media queries as a universal fallback.

Practical fallback: Create public/splash/ with a single splash.svg (dark bg + centered "CodeTap" text), referenced without media queries. Remove per-device media queries from Step 1 and use a single universal link tag instead:

<link rel="apple-touch-startup-image" href="/splash/splash.svg" />

If SVG is not supported by Safari for startup images (it isn't), generate a single 1290x2796 PNG using a canvas script at build time, or manually create one. The key requirement is: dark background (#09090b), centered CodeTap text or mascot.

  • Step 3: Verify — add to home screen on iOS, check splash appears

  • Step 4: Commit

git add -f public/splash/ index.html
git commit -m "feat(pwa): iOS splash screens for major iPhone sizes"

Task 3: Android Install Prompt

Files:

  • Modify: src/App.tsx

  • Modify: src/components/SessionsView.tsx

  • Step 1: Add install prompt state to App.tsx

Add state and effect near the top of App():

const [installPrompt, setInstallPrompt] = useState<any>(null);
const [installDismissed, setInstallDismissed] = useState(
  () => localStorage.getItem('codetap:install-dismissed') === 'true'
);

useEffect(() => {
  const handler = (e: Event) => {
    e.preventDefault();
    setInstallPrompt(e);
  };
  window.addEventListener('beforeinstallprompt', handler);
  const installedHandler = () => {
    setInstallPrompt(null);
    setInstallDismissed(true);
    localStorage.setItem('codetap:install-dismissed', 'true');
  };
  window.addEventListener('appinstalled', installedHandler);
  return () => {
    window.removeEventListener('beforeinstallprompt', handler);
    window.removeEventListener('appinstalled', installedHandler);
  };
}, []);
  • Step 2: Pass install props to SessionsView

Update the SessionsView rendering in App.tsx:

<SessionsView
  onOpenChat={openChat}
  onLogout={handleLogout}
  onOpenSettings={() => setView({ name: 'settings' })}
  installPrompt={!installDismissed ? installPrompt : null}
  onInstall={async () => {
    if (installPrompt) {
      installPrompt.prompt();
      const result = await installPrompt.userChoice;
      if (result.outcome === 'accepted') {
        setInstallPrompt(null);
        setInstallDismissed(true);
        localStorage.setItem('codetap:install-dismissed', 'true');
      }
    }
  }}
  onDismissInstall={() => {
    setInstallDismissed(true);
    localStorage.setItem('codetap:install-dismissed', 'true');
  }}
/>
  • Step 3: Add install banner to SessionsView

Add to SessionsView props interface and render a banner below the header when installPrompt is truthy:

// Add to props
installPrompt?: any;
onInstall?: () => void;
onDismissInstall?: () => void;

// Render below the header, before the tab bar
{installPrompt && (
  <div className="flex items-center gap-3 px-4 py-2.5 bg-accent/10 border-b border-accent/20">
    <span className="text-xs text-text flex-1 font-mono">Install CodeTap for a better experience</span>
    <button onClick={onInstall} className="text-xs font-medium text-accent hover:text-accent-light">Install</button>
    <button onClick={onDismissInstall} className="text-xs text-text-dim hover:text-text">Dismiss</button>
  </div>
)}
  • Step 4: Verify — open in Chrome Android (or DevTools Application panel), check install banner appears

  • Step 5: Commit

git add src/App.tsx src/components/SessionsView.tsx
git commit -m "feat(pwa): Android install prompt with dismissible banner"

Task 4: Service Worker Update Notification

Files:

  • Modify: src/App.tsx

  • Step 1: Add SW update detection and toast state

Add near other effects in App():

const [swUpdateAvailable, setSwUpdateAvailable] = useState(false);

useEffect(() => {
  const handleControllerChange = () => setSwUpdateAvailable(true);
  navigator.serviceWorker?.addEventListener('controllerchange', handleControllerChange);
  return () => navigator.serviceWorker?.removeEventListener('controllerchange', handleControllerChange);
}, []);
  • Step 2: Render update toast

Add before the closing </div> or at the bottom of the main render:

{swUpdateAvailable && (
  <div className="fixed bottom-6 left-4 right-4 bg-surface border border-accent/30 rounded-md px-4 py-3 flex items-center justify-between z-50 shadow-lg">
    <span className="text-sm text-text font-mono">New version available</span>
    <div className="flex gap-2">
      <button
        onClick={() => window.location.reload()}
        className="text-sm font-medium text-accent hover:text-accent-light"
      >Refresh</button>
      <button
        onClick={() => setSwUpdateAvailable(false)}
        className="text-sm text-text-dim hover:text-text"
      >Later</button>
    </div>
  </div>
)}
  • Step 3: Verify — modify SW file, rebuild, check toast appears

  • Step 4: Commit

git add src/App.tsx
git commit -m "feat(pwa): service worker update notification toast"

Task 5: Badge Clear on Focus

Files:

  • Modify: src/App.tsx

  • Step 1: Add visibility change listener

Add with other effects in App():

useEffect(() => {
  const handleVisibility = () => {
    if (document.visibilityState === 'visible') {
      navigator.clearAppBadge?.();
    }
  };
  document.addEventListener('visibilitychange', handleVisibility);
  return () => document.removeEventListener('visibilitychange', handleVisibility);
}, []);
  • Step 2: Commit
git add src/App.tsx
git commit -m "feat(pwa): clear app badge when app becomes visible"

Task 6: Manifest Shortcuts & Screenshots

Files:

  • Modify: vite.config.ts

  • Create: public/screenshots/ (placeholder)

  • Step 1: Add shortcuts to manifest in vite.config.ts

Add after the icons array in the manifest config:

shortcuts: [
  {
    name: 'New Chat',
    short_name: 'New',
    url: '/?action=newchat',
    icons: [{ src: '/pwa-192x192.png', sizes: '192x192' }],
  },
],
categories: ['developer-tools', 'productivity'],
  • Step 2: Handle ?action=newchat in App.tsx

Add after the existing ?session= URL handler:

useEffect(() => {
  const params = new URLSearchParams(window.location.search);
  if (params.get('action') === 'newchat' && authed) {
    window.history.replaceState({}, '', '/');
    // Shortcut just brings user to sessions view — they pick a project from there
    setView({ name: 'sessions' });
  }
}, [authed]);
  • Step 3: Add screenshots placeholder to manifest

Add to manifest in vite.config.ts:

screenshots: [
  {
    src: '/screenshots/narrow.png',
    sizes: '1080x1920',
    type: 'image/png',
    form_factor: 'narrow',
    label: 'CodeTap Chat View',
  },
  {
    src: '/screenshots/wide.png',
    sizes: '1920x1080',
    type: 'image/png',
    form_factor: 'wide',
    label: 'CodeTap Sessions View',
  },
],

Create public/screenshots/ directory with placeholder images (can be actual screenshots later).

  • Step 4: Commit
git add vite.config.ts src/App.tsx
git add -f public/screenshots/ 2>/dev/null || true
git commit -m "feat(pwa): manifest shortcuts, categories, and screenshots config"

Task 7: Input Draft Auto-Save

Files:

  • Modify: src/components/ShimmerInput.tsx

  • Step 1: Add draft persistence to ShimmerInput

ShimmerInput doesn't receive a sessionId prop, so use a global draft key. Add after the existing state declarations:

const DRAFT_KEY = 'codetap:draft';

// Restore draft on mount
useEffect(() => {
  if (!initialText) {
    const saved = localStorage.getItem(DRAFT_KEY);
    if (saved) setText(saved);
  }
}, []);

// Debounce-save draft on text change
const saveTimer = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
  clearTimeout(saveTimer.current);
  if (text.trim()) {
    saveTimer.current = setTimeout(() => {
      localStorage.setItem(DRAFT_KEY, text);
    }, 500);
  } else {
    localStorage.removeItem(DRAFT_KEY);
  }
  return () => clearTimeout(saveTimer.current);
}, [text]);
  • Step 2: Clear draft on send

In the existing send handler, add localStorage.removeItem(DRAFT_KEY) after onSend(...):

// Find the handleSend function and add after onSend call:
localStorage.removeItem(DRAFT_KEY);
  • Step 3: Verify — type text, close tab, reopen, check draft restored

  • Step 4: Commit

git add src/components/ShimmerInput.tsx
git commit -m "feat(pwa): auto-save input draft to localStorage with debounce"

Task 8: Slow Network Detection

Files:

  • Modify: src/components/StatusBar.tsx

  • Step 1: Add network quality detection

Add a hook at the top of StatusBar component:

const [slowNetwork, setSlowNetwork] = useState(false);

useEffect(() => {
  const conn = (navigator as any).connection;
  if (!conn) return;
  const check = () => {
    setSlowNetwork(conn.effectiveType === '2g' || conn.effectiveType === 'slow-2g');
  };
  check();
  conn.addEventListener('change', check);
  return () => conn.removeEventListener('change', check);
}, []);
  • Step 2: Render slow network indicator

Add inside the status bar, before or after the model display:

{slowNetwork && (
  <span className="text-warning text-[10px] font-mono">Slow</span>
)}
  • Step 3: Commit
git add src/components/StatusBar.tsx
git commit -m "feat(pwa): slow network indicator via Network Information API"

Task 9: History API Navigation

Files:

  • Modify: src/App.tsx

  • Step 1: Push state on view changes

Modify the saveView function to also push history state:

function saveView(view: View) {
  sessionStorage.setItem('currentView', JSON.stringify(view));
  const url = view.name === 'chat' && view.sessionId
    ? `/?view=chat&session=${view.sessionId}`
    : view.name === 'settings'
      ? '/?view=settings'
      : '/';
  window.history.pushState({ view }, '', url);
}
  • Step 2: Listen for popstate (back button/gesture)

Add effect in App():

useEffect(() => {
  const handlePopState = (event: PopStateEvent) => {
    if (event.state?.view) {
      setView(event.state.view);
      sessionStorage.setItem('currentView', JSON.stringify(event.state.view));
    } else {
      setView({ name: 'sessions' });
      sessionStorage.setItem('currentView', JSON.stringify({ name: 'sessions' }));
    }
  };
  window.addEventListener('popstate', handlePopState);
  return () => window.removeEventListener('popstate', handlePopState);
}, []);
  • Step 3: Use replaceState for initial load (avoid double entry)

In the existing loadView() function, after loading the view, replace the current history entry:

// At the end of App() initialization, after first render:
useEffect(() => {
  window.history.replaceState({ view }, '', window.location.pathname + window.location.search);
}, []); // only on mount
  • Step 4: Verify — navigate sessions → chat → back gesture returns to sessions

  • Step 5: Commit

git add src/App.tsx
git commit -m "feat(pwa): History API navigation for back gesture support"

Task 10: OpenGraph Meta Tags

Files:

  • Modify: index.html

  • Step 1: Add OG and Twitter meta tags to index.html

Add before </head>:

<!-- OpenGraph -->
<meta property="og:title" content="CodeTap" />
<meta property="og:description" content="Use Claude Code from your phone. Real-time mobile UI synced with your desktop terminal." />
<meta property="og:type" content="website" />
<meta property="og:image" content="/pwa-512x512.png" />
<meta name="twitter:card" content="summary" />
<meta name="description" content="Use Claude Code from your phone. Real-time mobile UI synced with your desktop terminal." />
  • Step 2: Commit
git add index.html
git commit -m "feat(pwa): OpenGraph and Twitter Card meta tags"

Verification

  1. Run npm run dev in the worktree
  2. Open in Chrome DevTools → Application panel:
    • Check manifest loads correctly with shortcuts, screenshots, categories
    • Check service worker is registered
  3. Mobile testing (iOS):
    • Add to Home Screen → check splash screen appears
    • Verify content extends properly behind notch (viewport-fit=cover)
    • Test back gesture navigates correctly
  4. Mobile testing (Android / Chrome):
    • Check install banner appears in SessionsView
    • Dismiss and verify it doesn't reappear
    • Install and verify banner disappears
  5. Draft persistence:
    • Type text in input, close tab, reopen → text should be restored
    • Send message → draft should be cleared
  6. Badge:
    • Receive push notification with badge → switch to app → badge clears
  7. Network:
    • Throttle to 2G in DevTools → "Slow" indicator appears in StatusBar