Files
self/specs/SPEC-WEBVIEW-UI.md
Justin Hernandez 466fd5d8e7 update kmp specs (#1757)
* save new specs

* rename specs
2026-02-16 00:42:06 -08:00

621 lines
23 KiB
Markdown

# Person 1: UI / WebView / Bridge — Implementation Spec
## Overview
You are building the **web side** of the Self Mobile SDK. This means:
1. **`packages/webview-bridge/`** — JS bridge protocol library (npm package `@selfxyz/webview-bridge`)
2. **`packages/webview-app/`** — Vite-bundled React app that runs inside a native WebView
The output of `vite build` (a single `index.html` + JS bundle) gets bundled into the KMP SDK artifact by Person 2. You don't need to worry about native code — just make sure the bridge protocol is correct and the screens work.
---
## What to Delete First
Delete these directories entirely before starting (they're from the previous prototype):
- `packages/webview-bridge/`
- `packages/webview-app/`
The prototype was useful for learning. The architecture and bridge protocol are sound. You're recreating them from scratch with proper structure.
---
## Package 1: `@selfxyz/webview-bridge`
### Purpose
TypeScript library that handles all communication between the WebView and native shell. Provides:
- `WebViewBridge` class — manages request/response lifecycle, event subscriptions, timeouts
- Bridge adapter factories — one per `mobile-sdk-alpha` adapter interface
- `MockNativeBridge` — test utility for unit/integration tests without native
- Protocol types and JSON schema validation
### Package Structure
```
packages/webview-bridge/
src/
types.ts # Protocol types (BridgeDomain, BridgeRequest, etc.)
bridge.ts # WebViewBridge class
schema.ts # Message validation
mock.ts # MockNativeBridge for testing
adapters/
nfc-scanner.ts # NFCScannerAdapter → nfc.scan bridge
crypto.ts # CryptoAdapter (hash=WebCrypto, sign=bridge)
auth.ts # AuthAdapter → secureStorage.get with biometric
documents.ts # DocumentsAdapter → documents.* bridge
storage.ts # StorageAdapter → secureStorage.* bridge
analytics.ts # AnalyticsAdapter → analytics.* bridge (fire-and-forget)
haptic.ts # HapticAdapter → haptic.trigger bridge
navigation.ts # NavigationAdapter → React Router (no bridge)
lifecycle.ts # LifecycleAdapter → lifecycle.* bridge
index.ts # Re-exports all adapters
__tests__/
bridge.test.ts # WebViewBridge unit tests
schema.test.ts # Validation tests
adapters.test.ts # Adapter integration tests with MockNativeBridge
index.ts # Public exports
package.json
tsconfig.json
tsup.config.ts
```
### package.json
```json
{
"name": "@selfxyz/webview-bridge",
"version": "0.0.1-alpha.1",
"type": "module",
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" },
"./mock": { "types": "./dist/mock.d.ts", "import": "./dist/mock.js", "require": "./dist/mock.cjs" },
"./schema": { "types": "./dist/schema.d.ts", "import": "./dist/schema.js", "require": "./dist/schema.cjs" },
"./adapters": { "types": "./dist/adapters.d.ts", "import": "./dist/adapters.js", "require": "./dist/adapters.cjs" }
},
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "tsup",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/node": "^22.18.3",
"tsup": "^8.0.1",
"typescript": "^5.9.3",
"vitest": "^2.1.8"
},
"packageManager": "yarn@4.12.0"
}
```
### tsup.config.ts
```typescript
import { defineConfig } from 'tsup';
export default defineConfig({
entry: {
index: 'src/index.ts',
mock: 'src/mock.ts',
schema: 'src/schema.ts',
adapters: 'src/adapters/index.ts',
},
format: ['esm', 'cjs'],
dts: true,
clean: true,
splitting: false,
sourcemap: true,
});
```
### Key Implementation Details
#### types.ts — Protocol Types
The existing prototype types are correct. Key types to implement:
```typescript
export const BRIDGE_PROTOCOL_VERSION = 1;
export const DEFAULT_TIMEOUT_MS = 30_000;
export type BridgeDomain =
| 'nfc' | 'biometrics' | 'secureStorage' | 'camera'
| 'crypto' | 'haptic' | 'analytics' | 'lifecycle'
| 'documents' | 'navigation';
export type BridgeMessageType = 'request' | 'response' | 'event';
export interface BridgeError {
code: string;
message: string;
details?: Record<string, unknown>;
}
export interface BridgeRequest { type: 'request'; version: number; id: string; domain: BridgeDomain; method: string; params: Record<string, unknown>; timestamp: number; }
export interface BridgeResponse { type: 'response'; version: number; id: string; domain: BridgeDomain; requestId: string; success: boolean; data?: unknown; error?: BridgeError; timestamp: number; }
export interface BridgeEvent { type: 'event'; version: number; id: string; domain: BridgeDomain; event: string; data: unknown; timestamp: number; }
// Domain-specific method types
export type NfcMethod = 'scan' | 'cancelScan' | 'isSupported';
export type NfcEvent = 'scanProgress' | 'tagDiscovered' | 'scanError';
export type BiometricsMethod = 'authenticate' | 'isAvailable' | 'getBiometryType';
export type SecureStorageMethod = 'get' | 'set' | 'remove';
export type CameraMethod = 'scanMRZ' | 'isAvailable';
export type CryptoMethod = 'sign' | 'generateKey' | 'getPublicKey';
export type HapticMethod = 'trigger';
export type AnalyticsMethod = 'trackEvent' | 'trackNfcEvent' | 'logNfcEvent';
export type LifecycleMethod = 'ready' | 'dismiss' | 'setResult';
export type DocumentsMethod = 'loadCatalog' | 'saveCatalog' | 'loadById' | 'save' | 'delete';
export type NavigationMethod = 'goBack' | 'goTo';
// NFC-specific param/result types
export interface NfcScanParams {
passportNumber: string;
dateOfBirth: string;
dateOfExpiry: string;
canNumber?: string;
skipPACE?: boolean;
skipCA?: boolean;
extendedMode?: boolean;
usePacePolling?: boolean;
sessionId: string;
useCan?: boolean;
userId?: string;
}
export interface NfcScanProgress { step: string; percent: number; message?: string; }
export interface BiometricAuthParams { reason: string; fallbackLabel?: string; }
export interface VerificationResult { success: boolean; userId?: string; verificationId?: string; proof?: unknown; claims?: Record<string, unknown>; error?: BridgeError; }
```
#### bridge.ts — WebViewBridge Class
The existing prototype is solid. Key behaviors:
1. **Constructor**: Auto-detects native transport (Android `SelfNativeAndroid`, iOS `webkit.messageHandlers.SelfNativeIOS`), registers `window.SelfNativeBridge` global
2. **`request(domain, method, params, timeout?)`**: Creates request with UUID, sets up pending promise with timeout, sends via transport
3. **`fire(domain, method, params)`**: Same as request but no pending promise (fire-and-forget)
4. **`on(domain, event, handler)`**: Subscribe to native events, returns unsubscribe function
5. **`handleMessage(json)`**: Called by native via `_handleResponse`/`_handleEvent`, dispatches to pending or listeners
6. **`destroy()`**: Rejects all pending, clears listeners, removes global
**Transport detection:**
```typescript
// Android
if (globalThis.SelfNativeAndroid?.postMessage) { ... }
// iOS
if (globalThis.webkit?.messageHandlers?.SelfNativeIOS?.postMessage) { ... }
```
**Important:** The iOS handler name changed from the prototype. Person 2's spec says `SelfNativeIOS` as the WKScriptMessageHandler name. Make sure this matches.
#### Adapter Factories
Each adapter factory takes a `WebViewBridge` instance and returns an object conforming to the corresponding `mobile-sdk-alpha` adapter interface.
**NFC Scanner** (`nfc-scanner.ts`):
- `scan(opts)`: Calls `bridge.request('nfc', 'scan', params, 120_000)` with 120s timeout
- Handles `AbortSignal` — if aborted, fires `nfc.cancelScan` and rejects
- Helper `onNfcProgress(bridge, handler)` subscribes to `nfc:scanProgress` events
**Crypto** (`crypto.ts`):
- `hash(input, algo)`: Uses Web Crypto API (`crypto.subtle.digest`), no bridge round-trip
- `sign(data, keyRef)`: Encodes data as base64, calls `bridge.request('crypto', 'sign', { data, keyRef })`, decodes base64 result
**Auth** (`auth.ts`):
- `getPrivateKey()`: Calls `bridge.request('secureStorage', 'get', { key: 'self_private_key', requireBiometric: true })`, returns `null` on error
**Documents** (`documents.ts`):
- `loadDocumentCatalog()`: `bridge.request('documents', 'loadCatalog')`
- `saveDocumentCatalog(catalog)`: `bridge.request('documents', 'saveCatalog', { catalog })`
- `loadDocumentById(id)`: `bridge.request('documents', 'loadById', { id })`
- `saveDocument(id, data)`: `bridge.request('documents', 'save', { id, data })`
- `deleteDocument(id)`: `bridge.request('documents', 'delete', { id })`
**Storage** (`storage.ts`):
- `get(key)`: `bridge.request('secureStorage', 'get', { key })`
- `set(key, value)`: `bridge.request('secureStorage', 'set', { key, value })`
- `remove(key)`: `bridge.request('secureStorage', 'remove', { key })`
**Analytics** (`analytics.ts`) — all fire-and-forget:
- `trackEvent(event, payload)`: `bridge.fire('analytics', 'trackEvent', { event, payload })`
- `trackNfcEvent(name, properties)`: `bridge.fire('analytics', 'trackNfcEvent', { name, properties })`
- `logNFCEvent(level, message, context, details)`: `bridge.fire('analytics', 'logNfcEvent', { level, message, context, details })`
**Haptic** (`haptic.ts`):
- `trigger(type)`: `bridge.fire('haptic', 'trigger', { type })`
**Navigation** (`navigation.ts`) — NO bridge round-trip, uses React Router:
- `goBack()`: Calls provided `goBack` callback
- `goTo(routeName, params)`: Maps `RouteName` to URL path, calls provided `navigate` callback
Route map:
```typescript
const routeMap: Record<RouteName, string> = {
DocumentCamera: '/onboarding/camera',
DocumentOnboarding: '/onboarding',
CountryPicker: '/onboarding/country',
IDPicker: '/onboarding/id-type',
DocumentNFCScan: '/onboarding/nfc',
ManageDocuments: '/documents',
Home: '/',
AccountVerifiedSuccess: '/account/verified',
AccountRecoveryChoice: '/account/recovery',
SaveRecoveryPhrase: '/account/recovery/phrase',
ComingSoon: '/coming-soon',
DocumentDataNotFound: '/error/no-data',
Settings: '/settings',
};
```
**Lifecycle** (`lifecycle.ts`):
- `ready()`: `bridge.fire('lifecycle', 'ready', {})`
- `dismiss()`: `bridge.fire('lifecycle', 'dismiss', {})`
- `setResult(result)`: `bridge.request('lifecycle', 'setResult', result)` — this one awaits
#### MockNativeBridge
Test utility that implements `NativeTransport`. Intercepts outgoing messages, routes to registered mock handlers, and sends responses back:
- `handle(domain, method, handler)`: Register a mock handler
- `handleWith(domain, method, data)`: Register a handler that returns a fixed value
- `handleWithError(domain, method, error)`: Register a handler that throws
- `pushEvent(domain, event, data)`: Simulate a native event
- `messages`: Get all sent messages for assertions
- `messagesFor(domain)`: Filter by domain
### Validation & Testing
```bash
cd packages/webview-bridge
npm run build # tsup → dist/
npx vitest run # unit tests
npx tsc --noEmit # type-check
```
---
## Package 2: `@selfxyz/webview-app`
### Purpose
A private Vite-bundled React app that runs inside the native WebView. It:
1. Renders all screens using Tamagui
2. Wires screens to `mobile-sdk-alpha` via Zustand stores
3. Uses `@selfxyz/webview-bridge` adapters to connect SDK operations to native
### Package Structure
```
packages/webview-app/
public/
fonts/
Advercase-Regular.otf # Copy from app/web/fonts/
DINOT-Medium.otf
DINOT-Bold.otf
IBMPlexMono-Regular.otf
src/
main.tsx # Entry point: TamaguiProvider, BridgeProvider, SelfClientProvider, Router
App.tsx # React Router routes
fonts.css # @font-face declarations
reset.css # CSS reset
providers/
BridgeProvider.tsx # Creates and provides WebViewBridge instance
SelfClientProvider.tsx # Creates adapters, wires to mobile-sdk-alpha, signals lifecycle.ready()
screens/
onboarding/
CountryPickerScreen.tsx
IDSelectionScreen.tsx
DocumentCameraScreen.tsx
DocumentNFCScreen.tsx
ConfirmIdentificationScreen.tsx
proving/
ProvingScreen.tsx
VerificationResultScreen.tsx
home/
HomeScreen.tsx
account/
SettingsScreen.tsx
ComingSoonScreen.tsx
tamagui.config.ts # Shared Tamagui config (custom fonts)
vite.config.ts
tsconfig.json
package.json
index.html
```
### package.json
```json
{
"name": "@selfxyz/webview-app",
"version": "0.0.1-alpha.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@selfxyz/mobile-sdk-alpha": "workspace:^",
"@selfxyz/webview-bridge": "workspace:^",
"@tamagui/config": "1.126.14",
"lottie-react": "^2.4.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-native-web": "^0.19.13",
"react-router-dom": "^6.28.0",
"tamagui": "1.126.14",
"zustand": "^4.5.2"
},
"devDependencies": {
"@tamagui/vite-plugin": "1.126.14",
"@testing-library/react": "^14.1.2",
"@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.9.3",
"vite": "^6.1.0",
"vitest": "^2.1.8"
},
"packageManager": "yarn@4.12.0"
}
```
### vite.config.ts
```typescript
import { resolve } from 'path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { tamaguiPlugin } from '@tamagui/vite-plugin';
export default defineConfig({
resolve: {
extensions: ['.web.tsx', '.web.ts', '.web.js', '.tsx', '.ts', '.js'],
alias: {
'react-native': 'react-native-web',
'lottie-react-native': 'lottie-react',
},
},
plugins: [
react(),
tamaguiPlugin({
config: resolve(__dirname, 'tamagui.config.ts'),
components: ['tamagui'],
enableDynamicEvaluation: true,
excludeReactNativeWebExports: ['Switch', 'ProgressBar', 'Picker', 'CheckBox', 'Touchable'],
platform: 'web',
optimize: true,
}),
],
define: { global: 'globalThis' },
build: {
target: ['chrome90', 'safari15'],
rollupOptions: { output: { manualChunks: undefined } },
assetsInlineLimit: 102400, // Inline assets <100KB (fonts, small images)
outDir: 'dist',
emptyOutDir: true,
sourcemap: true,
},
server: { host: '0.0.0.0', port: 5173 },
});
```
### Key Files
#### main.tsx
```tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { TamaguiProvider, View } from 'tamagui';
import tamaguiConfig from '../tamagui.config';
import { App } from './App';
import { BridgeProvider } from './providers/BridgeProvider';
import { SelfClientProvider } from './providers/SelfClientProvider';
import './fonts.css';
import './reset.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<TamaguiProvider config={tamaguiConfig}>
<View flex={1} height="100vh" width="100%">
<BridgeProvider>
<SelfClientProvider>
<App />
</SelfClientProvider>
</BridgeProvider>
</View>
</TamaguiProvider>
</React.StrictMode>,
);
```
#### App.tsx — Routes
```tsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
// Import all screen components...
export const App: React.FC = () => (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomeScreen />} />
<Route path="/onboarding/country" element={<CountryPickerScreen />} />
<Route path="/onboarding/id-type" element={<IDSelectionScreen />} />
<Route path="/onboarding/camera" element={<DocumentCameraScreen />} />
<Route path="/onboarding/nfc" element={<DocumentNFCScreen />} />
<Route path="/onboarding/confirm" element={<ConfirmIdentificationScreen />} />
<Route path="/proving" element={<ProvingScreen />} />
<Route path="/proving/result" element={<VerificationResultScreen />} />
<Route path="/settings" element={<SettingsScreen />} />
<Route path="/account/verified" element={<VerificationResultScreen />} />
<Route path="/coming-soon" element={<ComingSoonScreen />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
);
```
#### BridgeProvider.tsx
Creates a singleton `WebViewBridge` instance with debug logging in dev mode. Provides it via React context.
```tsx
const bridge = useMemo(() => new WebViewBridge({ debug: import.meta.env.DEV }), []);
```
#### SelfClientProvider.tsx
Creates all bridge adapters, wires navigation to React Router, and signals `lifecycle.ready()` on mount.
```tsx
// Creates adapters:
const adapters = {
scanner: bridgeNFCScannerAdapter(bridge),
crypto: bridgeCryptoAdapter(bridge),
auth: bridgeAuthAdapter(bridge),
documents: bridgeDocumentsAdapter(bridge),
storage: bridgeStorageAdapter(bridge),
analytics: bridgeAnalyticsAdapter(bridge),
navigation: webNavigationAdapter(navigate, goBack),
};
const lifecycle = bridgeLifecycleAdapter(bridge);
// Signals ready on mount:
useEffect(() => { lifecycle.ready(); }, []);
```
### Screen Design Pattern
Every screen uses Tamagui components, imports colors/fonts from `@selfxyz/mobile-sdk-alpha/constants`, and accesses SDK via `useSelfClient()` hook.
```tsx
import { Text, View, YStack, XStack, ScrollView, Button, Spinner } from 'tamagui';
import { useNavigate } from 'react-router-dom';
import { black, white, slate300, slate500, amber50 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { useSelfClient } from '../../providers/SelfClientProvider';
```
**Consistent patterns across screens:**
- Header with back button (left arrow `\u2190`) and title
- `YStack flex={1} backgroundColor={white}` as page wrapper
- `fontFamily={dinot}` for all text
- `pressStyle={{ opacity: 0.7 }}` for tap feedback
- Bottom fixed action buttons
- `Spinner` from tamagui for loading states
### Tamagui Config
Same as `app/tamagui.config.ts`:
```typescript
import { createFont, createTamagui } from 'tamagui';
import { config } from '@tamagui/config/v3';
// Custom sizes, lineHeights, letterSpacing scales
// Custom fonts: advercase, dinot, plexMono
const appConfig = createTamagui({
...config,
fonts: { ...config.fonts, advercase: advercaseFont, dinot: dinotFont, plexMono: plexMonoFont },
});
```
### Font Setup
Copy `app/web/fonts/*.otf` into `packages/webview-app/public/fonts/`.
CSS (`fonts.css`):
```css
@font-face { font-family: 'Advercase-Regular'; src: url('/fonts/Advercase-Regular.otf') format('opentype'); font-display: swap; }
@font-face { font-family: 'DINOT-Bold'; src: url('/fonts/DINOT-Bold.otf') format('opentype'); font-display: swap; }
@font-face { font-family: 'DINOT-Medium'; src: url('/fonts/DINOT-Medium.otf') format('opentype'); font-display: swap; }
@font-face { font-family: 'IBMPlexMono-Regular'; src: url('/fonts/IBMPlexMono-Regular.otf') format('opentype'); font-display: swap; }
```
---
## Screen Reference (from existing RN app)
Use these existing app screens as UI reference for what the screens should look like and do:
| WebView Screen | RN App Reference | Key Elements |
|---------------|-----------------|--------------|
| CountryPickerScreen | `app/src/screens/documents/selection/CountryPickerScreen.tsx` | Search input, country list with flags |
| IDSelectionScreen | `app/src/screens/documents/selection/IDPickerScreen.tsx` | Grid of ID document types |
| DocumentCameraScreen | `app/src/screens/documents/scanning/DocumentCameraScreen.tsx` | MRZ camera view (calls `camera.scanMRZ`) |
| DocumentNFCScreen | `app/src/screens/documents/scanning/DocumentNFCScanScreen.tsx` | NFC scan progress, Lottie animation |
| ConfirmIdentificationScreen | `app/src/screens/documents/selection/ConfirmBelongingScreen.tsx` | Document preview, confirm/retry |
| ProvingScreen | `app/src/screens/verification/ProveScreen.tsx` | Disclosure items list, verify button |
| VerificationResultScreen | `app/src/screens/onboarding/AccountVerifiedSuccessScreen.tsx` | Success/failure with Lottie |
| HomeScreen | `app/src/screens/home/HomeScreen.tsx` | Document cards, points section |
| SettingsScreen | `app/src/screens/account/settings/SettingsScreen.tsx` | Settings list |
| ComingSoonScreen | `app/src/screens/shared/ComingSoonScreen.tsx` | Placeholder |
---
## Chunking Guide (Claude Code Sessions)
### Chunk 1F: Bridge Package (start here — no dependencies)
**Goal:** Build `packages/webview-bridge/` from scratch.
**Steps:**
1. Delete `packages/webview-bridge/` if it exists
2. Create package structure (package.json, tsconfig, tsup.config)
3. Implement `types.ts` — all protocol types
4. Implement `bridge.ts` — WebViewBridge class
5. Implement `schema.ts` — validation
6. Implement `mock.ts` — MockNativeBridge
7. Implement all adapters in `adapters/`
8. Write tests
9. Validate: `npm run build && npx vitest run`
**Estimated effort:** This is the most self-contained chunk. All interfaces are defined in the spec above and in `packages/mobile-sdk-alpha/src/types/public.ts`.
### Chunk 1B-1D: Screens (after bridge, can be parallel)
**Goal:** Build all screen components in `packages/webview-app/src/screens/`.
Each screen should:
- Use Tamagui components (`Text`, `View`, `YStack`, `XStack`, `ScrollView`, `Button`, `Spinner`)
- Import colors/fonts from `@selfxyz/mobile-sdk-alpha/constants`
- Access SDK via `useSelfClient()` and `useBridge()` hooks
- Use `useNavigate()` from `react-router-dom` for navigation
- Reference the corresponding RN app screen for UI fidelity
### Chunk 1E: WebView App Shell (after bridge + screens)
**Goal:** Wire everything together in `packages/webview-app/`.
**Steps:**
1. Delete `packages/webview-app/` if it exists
2. Create package structure (package.json, vite.config, tamagui.config, index.html)
3. Copy fonts into `public/fonts/`
4. Create `main.tsx`, `App.tsx`, `fonts.css`, `reset.css`
5. Create `BridgeProvider.tsx`, `SelfClientProvider.tsx`
6. Wire all screens with React Router
7. Validate: `npx vite dev` serves the app, `npx vite build` produces `dist/`
---
## Important Notes
1. **No `react-native` dependency in bridge package.** The bridge is pure TypeScript, works in any browser.
2. **`react-native-web` is only in webview-app.** The Vite alias maps `react-native``react-native-web`.
3. **Fonts are inlined by Vite** when < 100KB (`assetsInlineLimit: 102400`). This means the built HTML+JS is self-contained.
4. **`mobile-sdk-alpha` is a workspace dependency.** Its `constants/colors.ts` and `constants/fonts.ts` are used directly (but `fonts.ts` imports `Platform` from react-native, so webview-app needs the `react-native-web` alias).
5. **The `SelfClientProvider` should eventually call `createSelfClient(adapters)`** from `mobile-sdk-alpha` once that function is available. For now, expose individual adapters directly (matching the prototype pattern).