23 KiB
Person 1: UI / WebView / Bridge — Implementation Spec
Overview
You are building the web side of the Self Mobile SDK. This means:
packages/webview-bridge/— JS bridge protocol library (npm package@selfxyz/webview-bridge)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:
WebViewBridgeclass — manages request/response lifecycle, event subscriptions, timeouts- Bridge adapter factories — one per
mobile-sdk-alphaadapter 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
{
"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
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:
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:
- Constructor: Auto-detects native transport (Android
SelfNativeAndroid, iOSwebkit.messageHandlers.SelfNativeIOS), registerswindow.SelfNativeBridgeglobal request(domain, method, params, timeout?): Creates request with UUID, sets up pending promise with timeout, sends via transportfire(domain, method, params): Same as request but no pending promise (fire-and-forget)on(domain, event, handler): Subscribe to native events, returns unsubscribe functionhandleMessage(json): Called by native via_handleResponse/_handleEvent, dispatches to pending or listenersdestroy(): Rejects all pending, clears listeners, removes global
Transport detection:
// 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): Callsbridge.request('nfc', 'scan', params, 120_000)with 120s timeout- Handles
AbortSignal— if aborted, firesnfc.cancelScanand rejects - Helper
onNfcProgress(bridge, handler)subscribes tonfc:scanProgressevents
Crypto (crypto.ts):
hash(input, algo): Uses Web Crypto API (crypto.subtle.digest), no bridge round-tripsign(data, keyRef): Encodes data as base64, callsbridge.request('crypto', 'sign', { data, keyRef }), decodes base64 result
Auth (auth.ts):
getPrivateKey(): Callsbridge.request('secureStorage', 'get', { key: 'self_private_key', requireBiometric: true }), returnsnullon 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 providedgoBackcallbackgoTo(routeName, params): MapsRouteNameto URL path, calls providednavigatecallback
Route map:
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 handlerhandleWith(domain, method, data): Register a handler that returns a fixed valuehandleWithError(domain, method, error): Register a handler that throwspushEvent(domain, event, data): Simulate a native eventmessages: Get all sent messages for assertionsmessagesFor(domain): Filter by domain
Validation & Testing
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:
- Renders all screens using Tamagui
- Wires screens to
mobile-sdk-alphavia Zustand stores - Uses
@selfxyz/webview-bridgeadapters 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
{
"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
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
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
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.
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.
// 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.
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 wrapperfontFamily={dinot}for all textpressStyle={{ opacity: 0.7 }}for tap feedback- Bottom fixed action buttons
Spinnerfrom tamagui for loading states
Tamagui Config
Same as app/tamagui.config.ts:
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):
@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:
- Delete
packages/webview-bridge/if it exists - Create package structure (package.json, tsconfig, tsup.config)
- Implement
types.ts— all protocol types - Implement
bridge.ts— WebViewBridge class - Implement
schema.ts— validation - Implement
mock.ts— MockNativeBridge - Implement all adapters in
adapters/ - Write tests
- 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()anduseBridge()hooks - Use
useNavigate()fromreact-router-domfor 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:
- Delete
packages/webview-app/if it exists - Create package structure (package.json, vite.config, tamagui.config, index.html)
- Copy fonts into
public/fonts/ - Create
main.tsx,App.tsx,fonts.css,reset.css - Create
BridgeProvider.tsx,SelfClientProvider.tsx - Wire all screens with React Router
- Validate:
npx vite devserves the app,npx vite buildproducesdist/
Important Notes
- No
react-nativedependency in bridge package. The bridge is pure TypeScript, works in any browser. react-native-webis only in webview-app. The Vite alias mapsreact-native→react-native-web.- Fonts are inlined by Vite when < 100KB (
assetsInlineLimit: 102400). This means the built HTML+JS is self-contained. mobile-sdk-alphais a workspace dependency. Itsconstants/colors.tsandconstants/fonts.tsare used directly (butfonts.tsimportsPlatformfrom react-native, so webview-app needs thereact-native-webalias).- The
SelfClientProvidershould eventually callcreateSelfClient(adapters)frommobile-sdk-alphaonce that function is available. For now, expose individual adapters directly (matching the prototype pattern).