@le-space/p2pass 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +258 -0
  3. package/dist/backup/registry-backup.d.ts +26 -0
  4. package/dist/backup/registry-backup.js +51 -0
  5. package/dist/identity/identity-service.d.ts +116 -0
  6. package/dist/identity/identity-service.js +524 -0
  7. package/dist/identity/mode-detector.d.ts +29 -0
  8. package/dist/identity/mode-detector.js +124 -0
  9. package/dist/identity/signing-preference.d.ts +30 -0
  10. package/dist/identity/signing-preference.js +55 -0
  11. package/dist/index.d.ts +15 -0
  12. package/dist/index.js +91 -0
  13. package/dist/p2p/setup.d.ts +48 -0
  14. package/dist/p2p/setup.js +283 -0
  15. package/dist/recovery/ipns-key.d.ts +41 -0
  16. package/dist/recovery/ipns-key.js +127 -0
  17. package/dist/recovery/manifest.d.ts +106 -0
  18. package/dist/recovery/manifest.js +243 -0
  19. package/dist/registry/device-registry.d.ts +122 -0
  20. package/dist/registry/device-registry.js +275 -0
  21. package/dist/registry/index.d.ts +3 -0
  22. package/dist/registry/index.js +46 -0
  23. package/dist/registry/manager.d.ts +76 -0
  24. package/dist/registry/manager.js +376 -0
  25. package/dist/registry/pairing-protocol.d.ts +61 -0
  26. package/dist/registry/pairing-protocol.js +653 -0
  27. package/dist/ucan/storacha-auth.d.ts +45 -0
  28. package/dist/ucan/storacha-auth.js +164 -0
  29. package/dist/ui/StorachaFab.svelte +134 -0
  30. package/dist/ui/StorachaFab.svelte.d.ts +23 -0
  31. package/dist/ui/StorachaIntegration.svelte +2467 -0
  32. package/dist/ui/StorachaIntegration.svelte.d.ts +23 -0
  33. package/dist/ui/fonts/dm-mono-400.ttf +0 -0
  34. package/dist/ui/fonts/dm-mono-500.ttf +0 -0
  35. package/dist/ui/fonts/dm-sans-400.ttf +0 -0
  36. package/dist/ui/fonts/dm-sans-500.ttf +0 -0
  37. package/dist/ui/fonts/dm-sans-600.ttf +0 -0
  38. package/dist/ui/fonts/dm-sans-700.ttf +0 -0
  39. package/dist/ui/fonts/epilogue-400.ttf +0 -0
  40. package/dist/ui/fonts/epilogue-500.ttf +0 -0
  41. package/dist/ui/fonts/epilogue-600.ttf +0 -0
  42. package/dist/ui/fonts/epilogue-700.ttf +0 -0
  43. package/dist/ui/fonts/storacha-fonts.css +152 -0
  44. package/dist/ui/storacha-backup.d.ts +44 -0
  45. package/dist/ui/storacha-backup.js +218 -0
  46. package/package.json +112 -0
@@ -0,0 +1,2467 @@
1
+ <script>
2
+ import { onMount } from 'svelte';
3
+ import {
4
+ readSigningPreferenceFromStorage,
5
+ writeSigningPreferenceToStorage,
6
+ } from '../identity/signing-preference.js';
7
+ import { Upload, LogOut, Loader2, AlertCircle, CheckCircle, Download } from 'lucide-svelte';
8
+ import { listSpaces, getSpaceUsage } from './storacha-backup.js';
9
+ import { OrbitDBStorachaBridge } from 'orbitdb-storacha-bridge';
10
+ import { IdentityService, hasLocalPasskeyHint } from '../identity/identity-service.js';
11
+ import { getStoredSigningMode } from '../identity/mode-detector.js';
12
+ import {
13
+ createStorachaClient,
14
+ parseDelegation,
15
+ storeDelegation,
16
+ loadStoredDelegation,
17
+ clearStoredDelegation,
18
+ } from '../ucan/storacha-auth.js';
19
+ import {
20
+ openDeviceRegistry,
21
+ registerDevice,
22
+ getDeviceByDID,
23
+ listDevices as listRegistryDevices,
24
+ getArchiveEntry,
25
+ listKeypairs,
26
+ storeKeypairEntry,
27
+ storeArchiveEntry,
28
+ listDelegations,
29
+ storeDelegationEntry,
30
+ } from '../registry/device-registry.js';
31
+ import { deriveIPNSKeyPair } from '../recovery/ipns-key.js';
32
+ import {
33
+ createManifest,
34
+ publishManifest,
35
+ resolveManifest,
36
+ uploadArchiveToIPFS,
37
+ fetchArchiveFromIPFS,
38
+ } from '../recovery/manifest.js';
39
+ import { backupRegistryDb, restoreRegistryDb } from '../backup/registry-backup.js';
40
+ import { MultiDeviceManager } from '../registry/manager.js';
41
+ import { detectDeviceLabel, pairingFlow } from '../registry/pairing-protocol.js';
42
+ import { loadWebAuthnCredentialSafe } from '@le-space/orbitdb-identity-provider-webauthn-did/standalone';
43
+ import './fonts/storacha-fonts.css';
44
+
45
+ let {
46
+ orbitdb = null,
47
+ database = null,
48
+ isInitialized = false,
49
+ entryCount = 0,
50
+ databaseName = 'restored-db',
51
+ onRestore = () => {},
52
+ onBackup = () => {},
53
+ onAuthenticate = () => {},
54
+ /** Parent (e.g. StorachaFab) must show the floating panel — otherwise pairing UI is `display:none`. */
55
+ onPairingPromptOpen = () => {},
56
+ libp2p = null,
57
+ /** @deprecated Use {@link signingPreference} `'worker'` instead */
58
+ preferWorkerMode = false,
59
+ /** When set, overrides the in-panel signing mode selector (e.g. programmatic / tests). */
60
+ signingPreference: signingPreferenceOverride = null,
61
+ } = $props();
62
+
63
+ /** User-chosen strategy before passkey create/auth; persisted in localStorage. */
64
+ let selectedSigningPreference = $state(
65
+ /** @type {'hardware-ed25519' | 'hardware-p256' | 'worker'} */ ('hardware-ed25519')
66
+ );
67
+
68
+ onMount(() => {
69
+ const stored = readSigningPreferenceFromStorage();
70
+ if (stored) selectedSigningPreference = stored;
71
+ });
72
+
73
+ function setSigningPreference(/** @type {'hardware-ed25519' | 'hardware-p256' | 'worker'} */ p) {
74
+ selectedSigningPreference = p;
75
+ writeSigningPreferenceToStorage(p);
76
+ }
77
+
78
+ /** Emoji for linked-device row; works with {@link detectDeviceLabel} strings (OS · platform, etc.). */
79
+ function linkedDeviceIcon(/** @type {string | undefined} */ label) {
80
+ const s = (label || '').toLowerCase();
81
+ if (s.includes('iphone')) return '\uD83D\uDCF1';
82
+ if (s.includes('ipados') || s.includes('ipad')) return '\uD83D\uDCF1';
83
+ if (s.includes('android')) return '\uD83D\uDCF1';
84
+ if (s.includes('mac') || s.includes('ios')) return '\uD83D\uDCBB';
85
+ if (s.includes('win')) return '\uD83D\uDDA5\uFE0F';
86
+ if (s.includes('linux')) return '\uD83D\uDC27';
87
+ return '\uD83D\uDCF1';
88
+ }
89
+
90
+ // Component state
91
+ let showStoracha = $state(true);
92
+ let isLoading = $state(false);
93
+ let status = $state('');
94
+ let error = $state(null);
95
+ let success = $state(null);
96
+
97
+ // Auth state
98
+ let isLoggedIn = $state(false);
99
+ let client = $state(null);
100
+ let currentSpace = $state(null);
101
+
102
+ // Progress tracking state
103
+ let showProgress = $state(false);
104
+ let progressType = $state('');
105
+ let progressCurrent = $state(0);
106
+ let progressTotal = $state(0);
107
+ let progressPercentage = $state(0);
108
+ let progressCurrentBlock = $state(null);
109
+ let progressError = $state(null);
110
+
111
+ // Bridge instance
112
+ let bridge = null;
113
+
114
+ // Passkey + UCAN state
115
+ let identityService = new IdentityService();
116
+ let signingMode = $state(null); // { mode, did, algorithm, secure }
117
+ let delegationText = $state(''); // textarea for pasting delegation
118
+ let isAuthenticating = $state(false);
119
+ let spaces = $state([]);
120
+ let spaceUsage = $state(null);
121
+
122
+ // Registry DB state
123
+ let registryDb = $state(null);
124
+ const REGISTRY_ADDRESS_KEY = 'p2p_passkeys_registry_address';
125
+ const OWNER_DID_KEY = 'p2p_passkeys_owner_did';
126
+
127
+ // Tab state
128
+ let activeTab = $state('passkeys'); // 'storacha' | 'passkeys' — P2P Passkeys first (primary)
129
+
130
+ // P2P Passkeys state
131
+ let devices = $state([]);
132
+ let peerInfo = $state(null);
133
+ let linkInput = $state('');
134
+ let isLinking = $state(false);
135
+ let linkError = $state('');
136
+ let deviceManager = $state(null);
137
+ /** Prevents duplicate concurrent init from initRegistryDb + $effect. */
138
+ let deviceManagerInitInProgress = false;
139
+ let pendingPairRequest = $state(null);
140
+ let pendingPairResolve = $state(null);
141
+
142
+ // Recovery state
143
+ let isRecovering = $state(false);
144
+ let recoveryStatus = $state('');
145
+ let ipnsKeyPair = $state(null);
146
+ let ipnsNameString = $state('');
147
+ /** UI hint: local passkey / archive / registry keypair present — primary button shows "existing" copy. */
148
+ let localPasskeyDetected = $state(false);
149
+
150
+ const primaryPasskeyLabel = $derived(
151
+ localPasskeyDetected ? 'Authenticate with existing Passkey' : 'Create new Passkey'
152
+ );
153
+ const primaryPasskeyLoadingLabel = $derived(
154
+ localPasskeyDetected ? 'Authenticating...' : 'Creating...'
155
+ );
156
+ const passkeyStepHint = $derived(
157
+ localPasskeyDetected
158
+ ? 'Step 1: Sign in with your saved passkey, or recover from backup'
159
+ : 'Step 1: Create a new passkey, or recover an existing one from backup'
160
+ );
161
+
162
+ /** At least one remote peer (e.g. via bootstrap relay / circuit — not necessarily direct). */
163
+ let p2pHasRemotePeers = $state(false);
164
+ /** At least one open connection whose multiaddr uses WebRTC (browser direct path). */
165
+ let p2pHasDirectWebRtc = $state(false);
166
+ /** Count of connected remote peers (`libp2p.getPeers().length`). */
167
+ let p2pRemotePeerCount = $state(0);
168
+
169
+ function addrLooksLikeDirectWebRtc(maStr) {
170
+ const s = (maStr || '').toLowerCase();
171
+ return s.includes('/webrtc') || s.includes('webrtc-direct');
172
+ }
173
+
174
+ function syncP2pConnectionFlags(node) {
175
+ if (!node || typeof node.getPeers !== 'function') {
176
+ p2pHasRemotePeers = false;
177
+ p2pHasDirectWebRtc = false;
178
+ p2pRemotePeerCount = 0;
179
+ return;
180
+ }
181
+ try {
182
+ const peers = node.getPeers();
183
+ p2pRemotePeerCount = peers.length;
184
+ p2pHasRemotePeers = peers.length > 0;
185
+ const conns = typeof node.getConnections === 'function' ? node.getConnections() : [];
186
+ p2pHasDirectWebRtc = conns.some((c) => addrLooksLikeDirectWebRtc(c.remoteAddr?.toString?.()));
187
+ } catch {
188
+ p2pHasRemotePeers = false;
189
+ p2pHasDirectWebRtc = false;
190
+ p2pRemotePeerCount = 0;
191
+ }
192
+ }
193
+
194
+ $effect(() => {
195
+ const db = registryDb;
196
+ if (!db) return;
197
+ void (async () => {
198
+ try {
199
+ const sm = await getStoredSigningMode(db);
200
+ if (sm.mode) localPasskeyDetected = true;
201
+ } catch {
202
+ /* ignore */
203
+ }
204
+ })();
205
+ });
206
+
207
+ $effect(() => {
208
+ const node = libp2p;
209
+ if (!node) {
210
+ p2pHasRemotePeers = false;
211
+ p2pHasDirectWebRtc = false;
212
+ p2pRemotePeerCount = 0;
213
+ return;
214
+ }
215
+ syncP2pConnectionFlags(node);
216
+ const onChange = () => syncP2pConnectionFlags(node);
217
+ node.addEventListener('peer:connect', onChange);
218
+ node.addEventListener('peer:disconnect', onChange);
219
+ node.addEventListener('connection:open', onChange);
220
+ node.addEventListener('connection:close', onChange);
221
+ return () => {
222
+ node.removeEventListener('peer:connect', onChange);
223
+ node.removeEventListener('peer:disconnect', onChange);
224
+ node.removeEventListener('connection:open', onChange);
225
+ node.removeEventListener('connection:close', onChange);
226
+ };
227
+ });
228
+
229
+ /** Start MultiDeviceManager once libp2p + registry exist (handles restored signingMode / late orbitdb). */
230
+ $effect(() => {
231
+ if (!signingMode?.did || !orbitdb || !libp2p || !registryDb || deviceManager) return;
232
+ void initDeviceManager();
233
+ });
234
+
235
+ /** Gray = no stack; red = no peers; orange = relay / non-WebRTC only; green = ≥1 WebRTC direct transport. */
236
+ const p2pLedDotBg = $derived(
237
+ !libp2p
238
+ ? '#9ca3af'
239
+ : !p2pHasRemotePeers
240
+ ? '#ef4444'
241
+ : p2pHasDirectWebRtc
242
+ ? '#10b981'
243
+ : '#f97316'
244
+ );
245
+ const p2pLedShadow = $derived(
246
+ !libp2p
247
+ ? 'none'
248
+ : !p2pHasRemotePeers
249
+ ? '0 0 0 3px rgba(239, 68, 68, 0.25)'
250
+ : p2pHasDirectWebRtc
251
+ ? '0 0 0 3px rgba(16, 185, 129, 0.2)'
252
+ : '0 0 0 3px rgba(249, 115, 22, 0.22)'
253
+ );
254
+ const p2pLedPulse = $derived(!!libp2p && p2pHasRemotePeers);
255
+ const p2pLedTextColor = $derived(
256
+ !libp2p
257
+ ? '#6B7280'
258
+ : !p2pHasRemotePeers
259
+ ? '#991b1b'
260
+ : p2pHasDirectWebRtc
261
+ ? '#064e3b'
262
+ : '#9a3412'
263
+ );
264
+ const p2pConnectionLabel = $derived(
265
+ !libp2p
266
+ ? 'P2P Offline'
267
+ : !p2pHasRemotePeers
268
+ ? 'No remote peers'
269
+ : p2pHasDirectWebRtc
270
+ ? 'P2P Direct (WebRTC)'
271
+ : 'P2P Relay'
272
+ );
273
+
274
+ /** Link Device needs MultiDeviceManager (registry DB + libp2p). */
275
+ const linkDeviceReady = $derived(!!deviceManager);
276
+ const linkDeviceDisabled = $derived(isLinking || !linkInput.trim() || !linkDeviceReady);
277
+
278
+ function resetProgress() {
279
+ showProgress = false;
280
+ progressType = '';
281
+ progressCurrent = 0;
282
+ progressTotal = 0;
283
+ progressPercentage = 0;
284
+ progressCurrentBlock = null;
285
+ progressError = null;
286
+ }
287
+
288
+ function showMessage(message, type = 'info') {
289
+ if (type === 'error') {
290
+ error = message;
291
+ success = null;
292
+ } else {
293
+ success = message;
294
+ error = null;
295
+ }
296
+ setTimeout(() => {
297
+ error = null;
298
+ success = null;
299
+ }, 5000);
300
+ }
301
+
302
+ function clearForms() {
303
+ delegationText = '';
304
+ }
305
+
306
+ async function handleAuthenticate() {
307
+ isAuthenticating = true;
308
+ try {
309
+ signingMode = await identityService.initialize(undefined, {
310
+ preferWorkerMode,
311
+ signingPreference: signingPreferenceOverride ?? selectedSigningPreference,
312
+ });
313
+ showMessage(`Authenticated! Mode: ${signingMode.algorithm} (${signingMode.mode})`);
314
+
315
+ // Notify parent that authentication succeeded — await so P2P stack can init
316
+ await onAuthenticate(signingMode);
317
+
318
+ // Derive IPNS keypair for manifest operations
319
+ const kp = identityService.getIPNSKeyPair();
320
+ if (kp) ipnsKeyPair = kp;
321
+
322
+ // Open/create registry DB if OrbitDB is available
323
+ await initRegistryDb();
324
+
325
+ // Try auto-connect if delegation is stored (ignore whitespace-only junk)
326
+ const stored = await loadStoredDelegation(registryDb);
327
+ if (stored?.trim()) await handleConnectWithDelegation(stored.trim());
328
+ } catch (err) {
329
+ showMessage(`Authentication failed: ${err.message}`, 'error');
330
+ } finally {
331
+ isAuthenticating = false;
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Connect Storacha using a delegation string: parse, create client, set up bridge.
337
+ * Optionally stores the delegation to registry or localStorage.
338
+ */
339
+ async function connectStoracha(
340
+ delegationStr,
341
+ { store = false, storeRegistryDb = null, storeSpaceDid = '' } = {}
342
+ ) {
343
+ const [delegation, principal] = await Promise.all([
344
+ parseDelegation(delegationStr),
345
+ identityService.getPrincipal(),
346
+ ]);
347
+ client = await createStorachaClient(principal, delegation);
348
+
349
+ if (store) {
350
+ const spaceDid = storeSpaceDid || client.currentSpace()?.did?.() || '';
351
+ await storeDelegation(delegationStr, storeRegistryDb, spaceDid);
352
+ }
353
+
354
+ currentSpace = client.currentSpace();
355
+ isLoggedIn = true;
356
+
357
+ bridge = new OrbitDBStorachaBridge({ ucanClient: client });
358
+ if (currentSpace) bridge.spaceDID = currentSpace.did();
359
+ setupBridgeListeners();
360
+ await loadSpaceUsage();
361
+ }
362
+
363
+ async function handleRecover() {
364
+ isRecovering = true;
365
+ recoveryStatus = 'Authenticating with passkey...';
366
+ try {
367
+ const recovery = await identityService.initializeFromRecovery();
368
+ ipnsKeyPair = recovery.ipnsKeyPair;
369
+
370
+ const storedDid = localStorage.getItem(OWNER_DID_KEY);
371
+ const storedAddr = localStorage.getItem(REGISTRY_ADDRESS_KEY);
372
+ let recoveredLocally = false;
373
+
374
+ if (storedDid && storedAddr) {
375
+ console.log('[recovery] Attempting local OrbitDB path — DID:', storedDid);
376
+ recoveredLocally = await recoverFromLocalOrbitDB(storedDid, storedAddr);
377
+ } else {
378
+ console.log('[recovery] No local DID/registry cached, using IPNS');
379
+ }
380
+
381
+ if (!recoveredLocally) {
382
+ console.log('[recovery] Using IPNS/IPFS remote path');
383
+ await recoverFromIPNS(recovery.ipnsKeyPair);
384
+ }
385
+
386
+ recoveryStatus = '';
387
+ showMessage('Identity recovered successfully!');
388
+ } catch (err) {
389
+ showMessage(`Recovery failed: ${err.message}`, 'error');
390
+ recoveryStatus = '';
391
+ signingMode = null;
392
+ } finally {
393
+ isRecovering = false;
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Local-first recovery: start P2P with default identity, open the local
399
+ * OrbitDB registry, and restore the archive without hitting the network.
400
+ */
401
+ async function recoverFromLocalOrbitDB(storedDid, storedAddr) {
402
+ try {
403
+ recoveryStatus = 'Starting P2P stack...';
404
+ await onAuthenticate(null);
405
+
406
+ // Wait for Svelte prop propagation with retry
407
+ let waited = 0;
408
+ while (!orbitdb && waited < 2000) {
409
+ await new Promise((r) => setTimeout(r, 50));
410
+ waited += 50;
411
+ }
412
+ if (!orbitdb) {
413
+ console.log('[recovery:local] orbitdb not available after wait');
414
+ return false;
415
+ }
416
+
417
+ recoveryStatus = 'Opening local registry...';
418
+ const db = await openDeviceRegistry(orbitdb, storedDid, storedAddr);
419
+
420
+ const archiveEntry = await getArchiveEntry(db, storedDid);
421
+ if (!archiveEntry) {
422
+ console.log('[recovery:local] No archive in local OrbitDB for DID:', storedDid);
423
+ return false;
424
+ }
425
+
426
+ recoveryStatus = 'Restoring identity from local registry...';
427
+ await identityService.restoreFromManifest(archiveEntry, storedDid);
428
+ signingMode = identityService.getSigningMode();
429
+ console.log('[recovery:local] DID restored:', signingMode?.did);
430
+
431
+ registryDb = db;
432
+ await identityService.setRegistry(db);
433
+ await selfRegisterDevice();
434
+ // Local recovery bypasses initRegistryDb(), so MultiDeviceManager was never started — same as normal auth.
435
+ recoveryStatus = 'Initializing device linking...';
436
+ await initDeviceManager();
437
+
438
+ const stored = await loadStoredDelegation(db);
439
+ if (stored) {
440
+ recoveryStatus = 'Connecting to Storacha...';
441
+ await connectStoracha(stored);
442
+ }
443
+
444
+ console.log('[recovery:local] Succeeded — no network needed');
445
+ return true;
446
+ } catch (err) {
447
+ console.warn('[recovery:local] Failed:', err.message);
448
+ return false;
449
+ }
450
+ }
451
+
452
+ /**
453
+ * IPNS/IPFS fallback recovery: resolve the manifest from w3name,
454
+ * fetch the encrypted archive from the gateway, and restore.
455
+ * Stores the DID in localStorage for future local recoveries.
456
+ */
457
+ async function recoverFromIPNS(ipnsKP) {
458
+ recoveryStatus = 'Resolving IPNS manifest...';
459
+ const manifest = await resolveManifest(ipnsKP.privateKey);
460
+ if (!manifest) {
461
+ throw new Error('No recovery manifest found. This identity may not have been backed up yet.');
462
+ }
463
+ console.log('[recovery:ipns] Manifest resolved — ownerDid:', manifest.ownerDid);
464
+
465
+ if (!manifest.archiveCID) {
466
+ throw new Error('Manifest has no archiveCID. Cannot restore identity without it.');
467
+ }
468
+ recoveryStatus = 'Fetching encrypted archive...';
469
+ const archiveEntry = await fetchArchiveFromIPFS(manifest.archiveCID);
470
+ if (!archiveEntry) {
471
+ throw new Error('Failed to fetch encrypted archive from IPFS.');
472
+ }
473
+
474
+ recoveryStatus = 'Restoring identity...';
475
+ await identityService.restoreFromManifest(archiveEntry, manifest.ownerDid);
476
+ signingMode = identityService.getSigningMode();
477
+ console.log('[recovery:ipns] DID restored:', signingMode?.did);
478
+
479
+ // Only persist DID — registry address is stored when OrbitDB is actually opened
480
+ localStorage.setItem(OWNER_DID_KEY, manifest.ownerDid);
481
+
482
+ recoveryStatus = 'Starting P2P stack...';
483
+ await onAuthenticate(signingMode);
484
+
485
+ // Wait for orbitdb prop to propagate from parent
486
+ let waited = 0;
487
+ while (!orbitdb && waited < 2000) {
488
+ await new Promise((r) => setTimeout(r, 50));
489
+ waited += 50;
490
+ }
491
+
492
+ // Open registry DB so address gets persisted for future local recovery
493
+ await initRegistryDb();
494
+
495
+ if (manifest.delegation) {
496
+ recoveryStatus = 'Connecting to Storacha...';
497
+ await connectStoracha(manifest.delegation, { store: true, storeRegistryDb: registryDb });
498
+ }
499
+
500
+ console.log(
501
+ '[recovery:ipns] Succeeded — DID + registry address cached for future local recovery'
502
+ );
503
+ }
504
+
505
+ async function handlePublishManifest() {
506
+ if (!client || !registryDb || !signingMode?.did) return;
507
+
508
+ // Derive IPNS keypair if not already available
509
+ if (!ipnsKeyPair) {
510
+ const kp = identityService.getIPNSKeyPair();
511
+ if (!kp) {
512
+ console.warn('[ui] No IPNS keypair available, cannot publish manifest');
513
+ return;
514
+ }
515
+ ipnsKeyPair = kp;
516
+ }
517
+
518
+ try {
519
+ const addr = registryDb.address?.toString?.() || registryDb.address;
520
+
521
+ // Get the current delegation string
522
+ const delegationStr = await loadStoredDelegation(registryDb);
523
+
524
+ // Upload encrypted archive to IPFS for auth-free recovery
525
+ let archiveCID = null;
526
+ const archiveData = await identityService.getEncryptedArchiveData();
527
+ if (archiveData) {
528
+ try {
529
+ archiveCID = await uploadArchiveToIPFS(client, archiveData);
530
+ console.log('[ui] Archive uploaded to IPFS:', archiveCID);
531
+ } catch (err) {
532
+ console.warn('[ui] Failed to upload archive to IPFS:', err.message);
533
+ }
534
+ }
535
+
536
+ const manifest = createManifest({
537
+ registryAddress: addr,
538
+ delegation: delegationStr || '',
539
+ ownerDid: signingMode.did,
540
+ archiveCID,
541
+ });
542
+
543
+ const result = await publishManifest(client, ipnsKeyPair.privateKey, manifest);
544
+ ipnsNameString = result.nameString;
545
+
546
+ // Persist DID for future local-first recovery
547
+ localStorage.setItem(OWNER_DID_KEY, signingMode.did);
548
+
549
+ console.log('[ui] Manifest published:', result.nameString);
550
+ } catch (err) {
551
+ console.warn('[ui] Failed to publish manifest:', err.message);
552
+ }
553
+ }
554
+
555
+ async function selfRegisterDevice() {
556
+ if (!registryDb || !signingMode?.did) return;
557
+ try {
558
+ const existing = await getDeviceByDID(registryDb, signingMode.did);
559
+ if (existing) return;
560
+ const credential = loadWebAuthnCredentialSafe();
561
+ await registerDevice(registryDb, {
562
+ credential_id:
563
+ credential?.credentialId ||
564
+ credential?.id ||
565
+ libp2p?.peerId?.toString() ||
566
+ signingMode.did,
567
+ public_key:
568
+ credential?.publicKey?.x && credential?.publicKey?.y
569
+ ? { kty: 'EC', crv: 'P-256', x: credential.publicKey.x, y: credential.publicKey.y }
570
+ : null,
571
+ device_label: detectDeviceLabel(),
572
+ created_at: Date.now(),
573
+ status: 'active',
574
+ ed25519_did: signingMode.did,
575
+ });
576
+ console.log('[ui] Self-registered device in registry');
577
+ } catch (err) {
578
+ console.warn('[ui] Failed to self-register device:', err.message);
579
+ }
580
+ }
581
+
582
+ async function initRegistryDb() {
583
+ if (!orbitdb || !signingMode?.did) return;
584
+ try {
585
+ const storedAddr = localStorage.getItem(REGISTRY_ADDRESS_KEY);
586
+ registryDb = await openDeviceRegistry(orbitdb, signingMode.did, storedAddr);
587
+
588
+ const addr = registryDb.address?.toString?.() || registryDb.address;
589
+ if (addr) localStorage.setItem(REGISTRY_ADDRESS_KEY, addr);
590
+ localStorage.setItem(OWNER_DID_KEY, signingMode.did);
591
+
592
+ await identityService.setRegistry(registryDb);
593
+ await selfRegisterDevice();
594
+ console.log('[ui] Registry DB initialized:', addr, '— DID persisted:', signingMode.did);
595
+ await initDeviceManager();
596
+ } catch (err) {
597
+ // If a stored address exists but we can't write, it's from a different identity.
598
+ // Clear it and create a fresh registry for this identity.
599
+ const storedAddr = localStorage.getItem(REGISTRY_ADDRESS_KEY);
600
+ if (storedAddr) {
601
+ console.warn('[ui] Stale registry address, creating new registry:', err.message);
602
+ localStorage.removeItem(REGISTRY_ADDRESS_KEY);
603
+ try {
604
+ registryDb = await openDeviceRegistry(orbitdb, signingMode.did, null);
605
+ const addr = registryDb.address?.toString?.() || registryDb.address;
606
+ if (addr) localStorage.setItem(REGISTRY_ADDRESS_KEY, addr);
607
+ localStorage.setItem(OWNER_DID_KEY, signingMode.did);
608
+ await identityService.setRegistry(registryDb);
609
+ await selfRegisterDevice();
610
+ console.log('[ui] New registry DB created:', addr, '— DID persisted:', signingMode.did);
611
+ await initDeviceManager();
612
+ return;
613
+ } catch (retryErr) {
614
+ console.warn('[ui] Failed to create new registry:', retryErr.message);
615
+ }
616
+ }
617
+ console.warn('[ui] Failed to init registry DB:', err.message);
618
+ }
619
+ }
620
+
621
+ async function initDeviceManager() {
622
+ if (deviceManager) return;
623
+ if (deviceManagerInitInProgress) return;
624
+ if (!libp2p || !registryDb || !signingMode?.did) return;
625
+ deviceManagerInitInProgress = true;
626
+ try {
627
+ const credential = loadWebAuthnCredentialSafe();
628
+ deviceManager = await MultiDeviceManager.createFromExisting({
629
+ credential,
630
+ orbitdb,
631
+ libp2p,
632
+ identity: { id: signingMode.did },
633
+ onPairingRequest: async (request) => {
634
+ pairingFlow(
635
+ 'ALICE',
636
+ '[UI] onPairingRequest invoked — showing Storacha panel + passkeys tab',
637
+ {
638
+ fromDid: request?.identity?.id,
639
+ deviceLabel: request?.identity?.deviceLabel,
640
+ }
641
+ );
642
+ console.log(
643
+ '[p2p] Pairing request from:',
644
+ request?.identity?.id || request?.identity?.deviceLabel
645
+ );
646
+ showStoracha = true;
647
+ activeTab = 'passkeys';
648
+ onPairingPromptOpen();
649
+ pairingFlow('ALICE', '[UI] waiting for user — Approve or Deny (promise pending)');
650
+ return new Promise((resolve) => {
651
+ pendingPairRequest = request;
652
+ pendingPairResolve = resolve;
653
+ });
654
+ },
655
+ onDeviceLinked: (device) => {
656
+ devices = devices.filter((d) => d.ed25519_did !== device.ed25519_did);
657
+ devices = [...devices, device];
658
+ },
659
+ onDeviceJoined: (peerId) => {
660
+ console.log('[p2p] Peer joined:', peerId);
661
+ },
662
+ });
663
+ const dbAddr = registryDb.address?.toString?.() || registryDb.address;
664
+ if (dbAddr) await deviceManager.openExistingDb(dbAddr);
665
+
666
+ devices = await deviceManager.listDevices();
667
+ peerInfo = deviceManager.getPeerInfo();
668
+ console.log('[ui] MultiDeviceManager initialized');
669
+ } catch (err) {
670
+ console.warn('[ui] Failed to init MultiDeviceManager:', err.message);
671
+ } finally {
672
+ deviceManagerInitInProgress = false;
673
+ }
674
+ }
675
+
676
+ async function handleTabSwitch(tab) {
677
+ activeTab = tab;
678
+ if (tab === 'passkeys' && registryDb) {
679
+ try {
680
+ devices = await listRegistryDevices(registryDb);
681
+ } catch (err) {
682
+ console.warn('[ui] Failed to load devices:', err.message);
683
+ }
684
+ }
685
+ }
686
+
687
+ function handleCopyPeerInfo() {
688
+ let id = null;
689
+ if (deviceManager) {
690
+ peerInfo = deviceManager.getPeerInfo();
691
+ id = peerInfo?.peerId;
692
+ } else if (libp2p) {
693
+ id = libp2p.peerId.toString();
694
+ peerInfo = { peerId: id, multiaddrs: libp2p.getMultiaddrs().map((ma) => ma.toString()) };
695
+ }
696
+ if (!id) return;
697
+ navigator.clipboard.writeText(id);
698
+ showMessage('Peer ID copied — paste it on the other device after both are connected via P2P.');
699
+ }
700
+
701
+ /** Plain peer id, or legacy JSON `{ "peerId", "multiaddrs"? }`. */
702
+ function parseLinkPeerInput(raw) {
703
+ const t = raw.trim();
704
+ if (!t) throw new Error('Enter the other device’s peer id.');
705
+ if (t.startsWith('{')) {
706
+ let o;
707
+ try {
708
+ o = JSON.parse(t);
709
+ } catch {
710
+ throw new Error('Invalid JSON. Paste a peer id, or legacy { "peerId", "multiaddrs" }.');
711
+ }
712
+ return MultiDeviceManager._normalizeLinkPayload(o);
713
+ }
714
+ return MultiDeviceManager._normalizeLinkPayload(t);
715
+ }
716
+
717
+ async function migrateRegistryEntries(oldDb, newDb, did) {
718
+ // Read all entries from old DB once (stable — not being written to during migration)
719
+ const keypairs = await listKeypairs(oldDb);
720
+ const archive = await getArchiveEntry(oldDb, did);
721
+ const delegations = await listDelegations(oldDb);
722
+
723
+ if (keypairs.length === 0 && !archive && delegations.length === 0) {
724
+ console.log('[ui] No entries to migrate');
725
+ return true;
726
+ }
727
+
728
+ // Retry writes until ACL grant propagates from Device A
729
+ const maxWait = 120000;
730
+ const start = Date.now();
731
+ while (Date.now() - start < maxWait) {
732
+ try {
733
+ for (const kp of keypairs) {
734
+ await storeKeypairEntry(newDb, kp.did, kp.publicKey);
735
+ }
736
+ if (archive) {
737
+ await storeArchiveEntry(newDb, did, archive.ciphertext, archive.iv);
738
+ }
739
+ for (const d of delegations) {
740
+ await storeDelegationEntry(newDb, d.delegation, d.space_did);
741
+ }
742
+ console.log('[ui] Registry migration complete after', Date.now() - start, 'ms');
743
+ return true;
744
+ } catch (err) {
745
+ if (!err.message?.includes('not allowed to write')) {
746
+ console.warn('[ui] Registry migration error:', err.message);
747
+ return false;
748
+ }
749
+ }
750
+ await new Promise((r) => setTimeout(r, 1000));
751
+ }
752
+ console.warn('[ui] Registry migration timed out waiting for write access');
753
+ return false;
754
+ }
755
+
756
+ async function handleLinkDevice() {
757
+ if (!linkInput.trim()) {
758
+ showMessage('Enter the other device’s peer id (copy from the P2P Passkeys tab).', 'error');
759
+ return;
760
+ }
761
+ if (!deviceManager) {
762
+ showMessage(
763
+ 'Device linking is not ready yet. Wait for OrbitDB and the registry to finish initializing, and ensure this app has a libp2p instance.',
764
+ 'error'
765
+ );
766
+ return;
767
+ }
768
+ isLinking = true;
769
+ linkError = '';
770
+ try {
771
+ const payload = parseLinkPeerInput(linkInput);
772
+ const result = await deviceManager.linkToDevice(payload);
773
+ pairingFlow('BOB', '[UI] linkToDevice returned', {
774
+ type: result?.type,
775
+ reason: result?.reason,
776
+ });
777
+ if (result.type === 'granted') {
778
+ const sharedAddr = result.dbAddress?.toString?.() || result.dbAddress;
779
+ pairingFlow(
780
+ 'BOB',
781
+ '[UI] granted — will migrate local registry entries then persist shared address',
782
+ {
783
+ sharedAddrPrefix: sharedAddr ? String(sharedAddr).slice(0, 48) + '…' : null,
784
+ }
785
+ );
786
+
787
+ // Migrate Device B's entries to the shared registry before switching
788
+ let migrated = false;
789
+ if (registryDb && deviceManager.getDevicesDb() && signingMode?.did) {
790
+ const sharedDb = deviceManager.getDevicesDb();
791
+ console.log('[ui] Migrating entries from old registry to shared registry...');
792
+ migrated = await migrateRegistryEntries(registryDb, sharedDb, signingMode.did);
793
+ }
794
+
795
+ // Only switch to shared registry if migration succeeded
796
+ if (migrated && sharedAddr) {
797
+ localStorage.setItem(REGISTRY_ADDRESS_KEY, sharedAddr);
798
+ console.log('[ui] Persisted shared registry address from Device A:', sharedAddr);
799
+ } else if (!migrated) {
800
+ console.warn('[ui] Keeping old registry address — migration did not complete');
801
+ }
802
+ showMessage('Device linked successfully!');
803
+ pairingFlow('BOB', '[UI] device link success message shown; device list refreshed');
804
+ linkInput = '';
805
+ devices = await deviceManager.listDevices();
806
+ } else {
807
+ pairingFlow('BOB', '[UI] link rejected by Alice', { reason: result.reason });
808
+ linkError = result.reason || 'Link request was rejected';
809
+ }
810
+ } catch (err) {
811
+ pairingFlow('BOB', '[UI] linkToDevice threw', { error: err?.message });
812
+ linkError = `Failed to link: ${err.message}`;
813
+ } finally {
814
+ isLinking = false;
815
+ }
816
+ }
817
+
818
+ function handlePairDecision(decision) {
819
+ if (pendingPairResolve) {
820
+ pairingFlow('ALICE', '[UI] user clicked pairing decision — resolving promise to handler', {
821
+ decision,
822
+ });
823
+ pendingPairResolve(decision);
824
+ pendingPairResolve = null;
825
+ pendingPairRequest = null;
826
+ } else {
827
+ pairingFlow('ALICE', '[UI] handlePairDecision called but no pending pairing (ignored)', {
828
+ decision,
829
+ });
830
+ }
831
+ }
832
+
833
+ function formatDelegationImportError(err) {
834
+ const msg = err?.message || String(err);
835
+ if (/atob|correctly encoded|base64/i.test(msg)) {
836
+ return (
837
+ 'That text is not a valid UCAN delegation (base64 / CAR). ' +
838
+ 'Export a delegation from Storacha (e.g. w3up CLI or another browser that is already logged in) and paste it here. ' +
839
+ 'To link browsers over libp2p only, use the P2P Passkeys tab and paste the other device’s peer id — not this field.'
840
+ );
841
+ }
842
+ return `Delegation import failed: ${msg}`;
843
+ }
844
+
845
+ async function handleImportDelegation() {
846
+ if (!delegationText.trim()) {
847
+ showMessage('Please paste a UCAN delegation', 'error');
848
+ return;
849
+ }
850
+ isLoading = true;
851
+ try {
852
+ await handleConnectWithDelegation(delegationText.trim());
853
+ delegationText = '';
854
+ } catch (err) {
855
+ showMessage(formatDelegationImportError(err), 'error');
856
+ } finally {
857
+ isLoading = false;
858
+ }
859
+ }
860
+
861
+ async function handleConnectWithDelegation(delegationStr) {
862
+ await connectStoracha(delegationStr, { store: true, storeRegistryDb: registryDb });
863
+ showMessage('Connected to Storacha via UCAN delegation!');
864
+ await handlePublishManifest();
865
+ }
866
+
867
+ function setupBridgeListeners() {
868
+ if (!bridge) return;
869
+ bridge.on('uploadProgress', (progress) => {
870
+ progressType = 'upload';
871
+ progressCurrent = progress.current;
872
+ progressTotal = progress.total;
873
+ progressPercentage = progress.percentage;
874
+ progressCurrentBlock = progress.currentBlock;
875
+ progressError = progress.error;
876
+ showProgress = true;
877
+ });
878
+ bridge.on('downloadProgress', (progress) => {
879
+ progressType = 'download';
880
+ progressCurrent = progress.current;
881
+ progressTotal = progress.total;
882
+ progressPercentage = progress.percentage;
883
+ progressCurrentBlock = progress.currentBlock;
884
+ progressError = progress.error;
885
+ showProgress = true;
886
+ });
887
+ }
888
+
889
+ async function handleLogout() {
890
+ isLoggedIn = false;
891
+ client = null;
892
+ currentSpace = null;
893
+ spaces = [];
894
+ spaceUsage = null;
895
+ signingMode = null;
896
+ await clearStoredDelegation(registryDb);
897
+ if (bridge) {
898
+ bridge.removeAllListeners();
899
+ bridge = null;
900
+ }
901
+ resetProgress();
902
+ showMessage('Logged out successfully');
903
+ }
904
+
905
+ async function loadSpaceUsage() {
906
+ if (!client) return;
907
+ try {
908
+ spaceUsage = await getSpaceUsage(client);
909
+ } catch (err) {
910
+ console.warn('Failed to load space usage info:', err.message);
911
+ spaceUsage = null;
912
+ }
913
+ }
914
+
915
+ async function loadSpaces() {
916
+ if (!client) return;
917
+ isLoading = true;
918
+ status = 'Loading spaces...';
919
+ try {
920
+ spaces = await listSpaces(client);
921
+ await loadSpaceUsage();
922
+ } catch (err) {
923
+ showMessage(`Failed to load spaces: ${err.message}`, 'error');
924
+ } finally {
925
+ isLoading = false;
926
+ status = '';
927
+ }
928
+ }
929
+
930
+ async function handleBackup() {
931
+ if (!bridge) {
932
+ showMessage('Please log in first', 'error');
933
+ return;
934
+ }
935
+
936
+ const hasDatabase = database && entryCount > 0;
937
+ const hasRegistry = !!registryDb;
938
+
939
+ if (!hasDatabase && !hasRegistry) {
940
+ showMessage('No data to backup', 'error');
941
+ return;
942
+ }
943
+
944
+ isLoading = true;
945
+ resetProgress();
946
+ status = 'Preparing backup...';
947
+
948
+ try {
949
+ // Backup user database if available
950
+ if (hasDatabase) {
951
+ const result = await bridge.backup(orbitdb, database.address);
952
+
953
+ if (result.success) {
954
+ showMessage(
955
+ `Backup completed! ${result.blocksUploaded}/${result.blocksTotal} blocks uploaded`
956
+ );
957
+ onBackup(result);
958
+ } else {
959
+ showMessage(result.error, 'error');
960
+ return;
961
+ }
962
+ }
963
+
964
+ // Backup registry DB
965
+ if (hasRegistry) {
966
+ try {
967
+ const regResult = await backupRegistryDb(bridge, orbitdb, registryDb);
968
+ console.log('[ui] Registry DB backed up:', regResult);
969
+ if (!hasDatabase) {
970
+ showMessage('Registry backup completed!');
971
+ }
972
+ } catch (err) {
973
+ console.warn('[ui] Registry backup failed:', err.message);
974
+ }
975
+ }
976
+
977
+ await handlePublishManifest();
978
+ } catch (err) {
979
+ showMessage(`Backup failed: ${err.message}`, 'error');
980
+ } finally {
981
+ isLoading = false;
982
+ status = '';
983
+ resetProgress();
984
+ }
985
+ }
986
+
987
+ async function restoreFromSpaceFallback() {
988
+ if (!orbitdb) {
989
+ showMessage('OrbitDB not initialized. Please wait for initialization to complete.', 'error');
990
+ return;
991
+ }
992
+
993
+ isLoading = true;
994
+ resetProgress();
995
+ status = 'Preparing restore...';
996
+
997
+ try {
998
+ // Close existing database if provided
999
+ if (database) {
1000
+ status = 'Closing existing database...';
1001
+ try {
1002
+ await database.close();
1003
+ } catch {
1004
+ // Continue even if close fails
1005
+ }
1006
+ }
1007
+
1008
+ status = 'Starting restore...';
1009
+
1010
+ if (!bridge) {
1011
+ throw new Error('Bridge not initialized. Please connect to Storacha first.');
1012
+ }
1013
+
1014
+ const result = await bridge.restoreFromSpace(orbitdb, {
1015
+ timeout: 120000,
1016
+ preferredDatabaseName: databaseName,
1017
+ restartAfterRestore: true,
1018
+ verifyIntegrity: true,
1019
+ });
1020
+
1021
+ if (result.success) {
1022
+ showMessage(`Restore completed! ${result.entriesRecovered} entries recovered.`);
1023
+ onRestore(result.database);
1024
+ } else {
1025
+ showMessage(`Restore failed: ${result.error}`, 'error');
1026
+ }
1027
+ } catch (err) {
1028
+ showMessage(`Restore failed: ${err.message}`, 'error');
1029
+ } finally {
1030
+ isLoading = false;
1031
+ status = '';
1032
+ resetProgress();
1033
+ }
1034
+ }
1035
+
1036
+ function formatRelativeTime(dateString) {
1037
+ if (!dateString) return 'Never';
1038
+ const date = new Date(dateString);
1039
+ const now = new Date();
1040
+ const diffInSeconds = Math.floor((now - date) / 1000);
1041
+
1042
+ if (diffInSeconds < 60) return 'Just now';
1043
+ if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`;
1044
+ if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`;
1045
+ if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 86400)} days ago`;
1046
+ if (diffInSeconds < 31536000) return `${Math.floor(diffInSeconds / 2592000)} months ago`;
1047
+ return `${Math.floor(diffInSeconds / 31536000)} years ago`;
1048
+ }
1049
+
1050
+ function formatSpaceName(space) {
1051
+ return space.name === 'Unnamed Space' ? `Space ${space.did.slice(-8)}` : space.name;
1052
+ }
1053
+
1054
+ onMount(async () => {
1055
+ localPasskeyDetected = hasLocalPasskeyHint();
1056
+
1057
+ // Try to reopen registry DB from stored address
1058
+ if (orbitdb) {
1059
+ const storedAddr = localStorage.getItem(REGISTRY_ADDRESS_KEY);
1060
+ if (storedAddr) {
1061
+ try {
1062
+ registryDb = await openDeviceRegistry(orbitdb, null, storedAddr);
1063
+ console.log('[ui] Reopened registry DB from stored address');
1064
+ } catch (err) {
1065
+ console.warn('[ui] Failed to reopen registry:', err.message);
1066
+ }
1067
+ }
1068
+ }
1069
+
1070
+ // Check for stored signing mode (no biometric needed)
1071
+ const stored = identityService.getSigningMode();
1072
+ if (stored.mode) {
1073
+ signingMode = stored;
1074
+ }
1075
+ // Don't auto-connect — user must click "Authenticate" which may prompt biometric
1076
+ });
1077
+ </script>
1078
+
1079
+ {#snippet pairingApprovalPrompt()}
1080
+ {#if pendingPairRequest}
1081
+ <div
1082
+ data-testid="storacha-pairing-prompt"
1083
+ style="border-radius: 0.375rem; border: 2px solid #FFC83F; background: linear-gradient(to bottom right, #ffffff, #FFF8E1); padding: 1rem; box-shadow: 0 4px 12px rgba(233, 19, 21, 0.15);"
1084
+ >
1085
+ <h4
1086
+ style="margin: 0 0 0.5rem 0; font-weight: 700; color: #E91315; font-family: 'Epilogue', sans-serif; font-size: 0.875rem;"
1087
+ >
1088
+ Device Pairing Request
1089
+ </h4>
1090
+ <p
1091
+ style="margin: 0 0 0.5rem 0; font-size: 0.75rem; color: #374151; font-family: 'DM Sans', sans-serif; line-height: 1.4;"
1092
+ >
1093
+ A device wants to link to your account:
1094
+ </p>
1095
+ <div
1096
+ style="background: rgba(233, 19, 21, 0.04); border-radius: 0.25rem; padding: 0.5rem 0.75rem; margin-bottom: 0.75rem;"
1097
+ >
1098
+ <div style="font-size: 0.7rem; color: #6b7280; font-family: 'DM Sans', sans-serif;">
1099
+ <strong>Device:</strong>
1100
+ {pendingPairRequest.identity?.deviceLabel || 'Unknown'}
1101
+ </div>
1102
+ <div
1103
+ style="font-size: 0.65rem; color: #9ca3af; font-family: 'DM Mono', monospace; margin-top: 0.25rem; word-break: break-all;"
1104
+ >
1105
+ {pendingPairRequest.identity?.id
1106
+ ? pendingPairRequest.identity.id.slice(0, 20) +
1107
+ '...' +
1108
+ pendingPairRequest.identity.id.slice(-8)
1109
+ : 'N/A'}
1110
+ </div>
1111
+ </div>
1112
+ <div style="display: flex; gap: 0.5rem;">
1113
+ <button
1114
+ data-testid="storacha-pairing-approve"
1115
+ onclick={() => handlePairDecision('granted')}
1116
+ style="flex: 1; padding: 0.5rem 1rem; border-radius: 0.375rem; background: linear-gradient(135deg, #10b981, #059669); color: #fff; border: none; cursor: pointer; font-family: 'Epilogue', sans-serif; font-weight: 700; font-size: 0.8rem;"
1117
+ >
1118
+ Approve
1119
+ </button>
1120
+ <button
1121
+ data-testid="storacha-pairing-deny"
1122
+ onclick={() => handlePairDecision('rejected')}
1123
+ style="flex: 1; padding: 0.5rem 1rem; border-radius: 0.375rem; background: transparent; color: #dc2626; border: 1px solid #dc2626; cursor: pointer; font-family: 'Epilogue', sans-serif; font-weight: 700; font-size: 0.8rem;"
1124
+ >
1125
+ Deny
1126
+ </button>
1127
+ </div>
1128
+ </div>
1129
+ {/if}
1130
+ {/snippet}
1131
+
1132
+ {#snippet linkedDevicesPanel()}
1133
+ <div
1134
+ style="border-radius: 0.375rem; border: 1px solid #E91315; background: linear-gradient(to bottom right, #ffffff, #FFE4AE); padding: 0.75rem;"
1135
+ >
1136
+ <div
1137
+ style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem;"
1138
+ >
1139
+ <div
1140
+ style="font-size: 0.65rem; font-weight: 700; color: #E91315; text-transform: uppercase; letter-spacing: 0.05em; font-family: 'DM Sans', sans-serif;"
1141
+ >
1142
+ Linked Devices
1143
+ </div>
1144
+ <div
1145
+ style="display: flex; align-items: center; gap: 0.25rem; background: #FFC83F; padding: 0.125rem 0.5rem; border-radius: 9999px;"
1146
+ >
1147
+ <span
1148
+ style="font-size: 0.7rem; font-weight: 700; color: #111827; font-family: 'DM Mono', monospace;"
1149
+ >{devices.length}</span
1150
+ >
1151
+ </div>
1152
+ </div>
1153
+ {#if devices.length === 0}
1154
+ <div
1155
+ style="text-align: center; padding: 1rem; font-size: 0.8rem; color: #9ca3af; font-family: 'DM Sans', sans-serif;"
1156
+ >
1157
+ No devices linked yet
1158
+ </div>
1159
+ {:else}
1160
+ <div style="display: flex; flex-direction: column; gap: 0.375rem;">
1161
+ {#each devices as device}
1162
+ <div
1163
+ data-testid="storacha-linked-device-row"
1164
+ data-device-label={device.device_label || ''}
1165
+ style="display: flex; align-items: center; gap: 0.625rem; padding: 0.5rem; border-radius: 0.375rem; background: rgba(255, 255, 255, 0.7); border-left: 3px solid {device.status ===
1166
+ 'active'
1167
+ ? '#10b981'
1168
+ : '#E91315'};"
1169
+ >
1170
+ <div style="font-size: 1rem; flex-shrink: 0;">
1171
+ {linkedDeviceIcon(device.device_label)}
1172
+ </div>
1173
+ <div style="flex: 1; min-width: 0;">
1174
+ <div
1175
+ style="font-size: 0.8rem; font-weight: 600; color: #1f2937; font-family: 'DM Sans', sans-serif;"
1176
+ >
1177
+ {device.device_label || 'Unknown Device'}
1178
+ </div>
1179
+ <code style="font-size: 0.625rem; color: #6B7280; font-family: 'DM Mono', monospace;">
1180
+ {device.ed25519_did
1181
+ ? device.ed25519_did.slice(0, 16) + '...' + device.ed25519_did.slice(-8)
1182
+ : 'N/A'}
1183
+ </code>
1184
+ </div>
1185
+ <span
1186
+ style="font-size: 0.6rem; font-weight: 600; padding: 0.125rem 0.375rem; border-radius: 9999px; flex-shrink: 0; background: {device.status ===
1187
+ 'active'
1188
+ ? '#dcfce7'
1189
+ : '#fee2e2'}; color: {device.status === 'active'
1190
+ ? '#166534'
1191
+ : '#991b1b'}; font-family: 'DM Sans', sans-serif;"
1192
+ >
1193
+ {device.status}
1194
+ </span>
1195
+ </div>
1196
+ {/each}
1197
+ </div>
1198
+ {/if}
1199
+ </div>
1200
+ {/snippet}
1201
+
1202
+ <div
1203
+ data-testid="storacha-panel"
1204
+ class="storacha-panel"
1205
+ style="max-height: 70vh; overflow-y: auto; border-radius: 0.75rem; border: 1px solid #E91315; background: linear-gradient(to bottom right, #FFE4AE, #EFE3F3); padding: 1rem; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25); font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;"
1206
+ >
1207
+ <!-- Header -->
1208
+ <div
1209
+ style="margin-bottom: 1rem; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(233, 19, 21, 0.2); padding-bottom: 0.75rem;"
1210
+ >
1211
+ <div style="display: flex; align-items: center; gap: 0.75rem;">
1212
+ <div
1213
+ style="border-radius: 0.5rem; border: 1px solid rgba(233, 19, 21, 0.2); background-color: #ffffff; padding: 0.5rem; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1);"
1214
+ >
1215
+ <svg
1216
+ width="20"
1217
+ height="22"
1218
+ viewBox="0 0 154 172"
1219
+ fill="none"
1220
+ xmlns="http://www.w3.org/2000/svg"
1221
+ >
1222
+ <path
1223
+ d="M110.999 41.5313H71.4081C70.2881 41.5313 69.334 42.4869 69.334 43.6087V154.359C69.334 159.461 69.1847 164.596 69.334 169.698C69.334 169.773 69.334 169.839 69.334 169.914C69.334 171.036 70.2881 171.992 71.4081 171.992H111.646C112.766 171.992 113.72 171.036 113.72 169.914V129.613L111.646 131.69H151.884C153.004 131.69 153.959 130.735 153.959 129.613V95.7513C153.959 91.6796 154.041 87.5996 153.942 83.5362C153.685 72.9996 149.512 62.8038 142.318 55.1091C135.125 47.4144 125.319 42.7029 114.907 41.7141C113.604 41.5894 112.302 41.5313 110.991 41.5313C108.319 41.523 108.319 45.6777 110.991 45.6861C120.772 45.7193 130.305 49.4171 137.457 56.1229C144.608 62.8287 149.022 71.9443 149.702 81.6416C149.993 85.813 149.802 90.0592 149.802 94.2306V124.677C149.802 126.231 149.694 127.826 149.802 129.38C149.802 129.455 149.802 129.53 149.802 129.604L151.876 127.527H111.638C110.518 127.527 109.564 128.483 109.564 129.604V169.906L111.638 167.829H71.3998L73.474 169.906V48.7689C73.474 47.1319 73.5818 45.4617 73.474 43.8247C73.474 43.7499 73.474 43.6834 73.474 43.6087L71.3998 45.6861H110.991C113.662 45.6861 113.662 41.5313 110.991 41.5313H110.999Z"
1224
+ fill="#E91315"
1225
+ />
1226
+ <path
1227
+ d="M108.519 68.9694C108.452 62.9532 104.727 57.66 99.1103 55.5494C93.4935 53.4387 87.0886 55.2669 83.3718 59.779C79.5554 64.4157 78.9165 71.0966 82.0277 76.2901C85.1389 81.4836 91.2037 84.0762 97.1025 82.9544C103.723 81.6996 108.444 75.617 108.527 68.9694C108.56 66.2937 104.412 66.2937 104.379 68.9694C104.329 73.1325 101.749 77.0878 97.7579 78.4838C93.7673 79.8798 89.03 78.6749 86.3087 75.2265C83.5875 71.778 83.4879 67.2077 85.6865 63.6346C87.8851 60.0615 92.2076 58.1752 96.2811 59.0477C100.985 60.0532 104.32 64.1664 104.379 68.9777C104.412 71.6533 108.56 71.6533 108.527 68.9777L108.519 68.9694Z"
1228
+ fill="#E91315"
1229
+ />
1230
+ <path
1231
+ d="M94.265 73.3237C96.666 73.3237 98.6124 71.3742 98.6124 68.9695C98.6124 66.5647 96.666 64.6152 94.265 64.6152C91.8641 64.6152 89.9177 66.5647 89.9177 68.9695C89.9177 71.3742 91.8641 73.3237 94.265 73.3237Z"
1232
+ fill="#E91315"
1233
+ />
1234
+ <path
1235
+ d="M71.4081 36.8029H132.429C144.642 36.8029 150.64 28.5764 151.752 23.8981C152.863 19.2281 147.263 7.43685 133.624 22.1199C133.624 22.1199 141.754 6.32336 130.869 2.76686C119.984 -0.789637 107.473 10.1042 102.512 20.5577C102.512 20.5577 103.109 7.6529 91.8923 10.769C80.6754 13.8851 71.4081 36.7946 71.4081 36.7946V36.8029Z"
1236
+ fill="#E91315"
1237
+ />
1238
+ <path
1239
+ d="M18.186 66.1195C17.879 66.0531 17.8707 65.6126 18.1694 65.5212C31.6927 61.4246 42.2376 70.7895 46.0457 76.6312C48.3189 80.1212 51.6956 83.3868 54.1182 85.5058C55.4042 86.6276 55.0889 88.7216 53.5292 89.4113C52.4589 89.8849 50.7498 90.9402 49.2316 91.846C46.3859 93.5495 42.4699 100.554 33.0948 101.884C26.1921 102.856 17.6716 98.7014 13.6561 96.4329C13.3408 96.2584 13.5399 95.793 13.8884 95.8761C19.8536 97.3137 24.2673 94.8291 22.4753 91.5302C21.1395 89.0706 17.5223 88.1482 12.2789 90.2339C7.61621 92.087 2.07414 86.0376 0.597357 84.2843C0.439724 84.1015 0.555875 83.8106 0.788177 83.7857C5.16044 83.3453 9.41656 78.8664 12.2291 74.1715C14.801 69.8755 20.5837 69.4849 22.4255 69.4683C22.6744 69.4683 22.8154 69.1858 22.6661 68.9863C22.0605 68.1886 20.6169 66.6513 18.186 66.1112V66.1195ZM30.1413 87.9571C29.7264 87.9322 29.4692 88.3975 29.7181 88.7299C30.7967 90.1342 33.5345 92.5855 38.7448 90.9818C45.8134 88.8047 46.1038 84.3175 40.9516 80.3455C36.4798 76.9054 29.2204 77.5618 24.8647 79.8968C24.4084 80.1461 24.5992 80.8441 25.1136 80.8026C26.8641 80.6696 30.133 80.8607 32.0827 82.2401C34.7126 84.0932 35.617 88.331 30.1413 87.9654V87.9571Z"
1240
+ fill="#E91315"
1241
+ />
1242
+ </svg>
1243
+ </div>
1244
+ <div>
1245
+ <h3
1246
+ style="font-size: 1.125rem; font-weight: 700; color: #E91315; font-family: 'Epilogue', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;"
1247
+ >
1248
+ P2Pass
1249
+ </h3>
1250
+ <p
1251
+ style="font-size: 0.6875rem; color: #555; font-family: 'DM Mono', monospace; line-height: 1.35; max-width: 18rem;"
1252
+ >
1253
+ peer-to-peer passkeys and ucans
1254
+ </p>
1255
+ </div>
1256
+ </div>
1257
+
1258
+ <button
1259
+ class="storacha-toggle"
1260
+ onclick={() => (showStoracha = !showStoracha)}
1261
+ style="border-radius: 0.5rem; padding: 0.5rem; color: #E91315; transition: color 150ms, background-color 150ms; border: none; background: transparent; cursor: pointer;"
1262
+ title={showStoracha ? 'Collapse' : 'Expand'}
1263
+ aria-label={showStoracha ? 'Collapse P2Pass panel' : 'Expand P2Pass panel'}
1264
+ >
1265
+ <svg
1266
+ style="height: 1rem; width: 1rem; transition: transform 200ms; transform: {showStoracha
1267
+ ? 'rotate(180deg)'
1268
+ : 'rotate(0deg)'};"
1269
+ fill="none"
1270
+ viewBox="0 0 24 24"
1271
+ stroke="currentColor"
1272
+ >
1273
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
1274
+ </svg>
1275
+ </button>
1276
+ </div>
1277
+
1278
+ {#if showStoracha}
1279
+ <!-- OrbitDB Initialization Status -->
1280
+ {#if !isInitialized}
1281
+ <div
1282
+ style="margin-bottom: 1rem; border-radius: 0.5rem; border: 1px solid rgba(217, 169, 56, 0.4); background: linear-gradient(to right, #fff8e1, #fffde7, #fff3e0); padding: 1rem; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);"
1283
+ >
1284
+ <div style="display: flex; align-items: flex-start; gap: 0.75rem;">
1285
+ <div
1286
+ style="display: flex; height: 2rem; width: 2rem; align-items: center; justify-content: center; border-radius: 9999px; background: linear-gradient(to bottom right, #FFC83F, #e67e22); box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1); flex-shrink: 0;"
1287
+ >
1288
+ <Loader2
1289
+ style="height: 1rem; width: 1rem; color: #ffffff; animation: spin 1s linear infinite;"
1290
+ />
1291
+ </div>
1292
+ <div style="flex: 1; font-size: 0.875rem;">
1293
+ <div style="font-weight: 600; color: #78350f; font-family: 'Epilogue', sans-serif;">
1294
+ Database Initializing
1295
+ </div>
1296
+ <div
1297
+ style="margin-top: 0.25rem; color: rgba(120, 53, 15, 0.9); font-family: 'DM Sans', sans-serif;"
1298
+ >
1299
+ OrbitDB is still setting up. You can login to Storacha now, but backup & restore will
1300
+ be available once initialization completes.
1301
+ </div>
1302
+ <div style="margin-top: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
1303
+ <div
1304
+ style="height: 0.375rem; width: 6rem; border-radius: 9999px; background-color: #fde68a;"
1305
+ >
1306
+ <div
1307
+ style="height: 100%; width: 75%; border-radius: 9999px; background: linear-gradient(to right, #FFC83F, #e67e22); animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;"
1308
+ ></div>
1309
+ </div>
1310
+ <span style="font-size: 0.75rem; color: #92400e; font-family: 'DM Mono', monospace;"
1311
+ >Please wait...</span
1312
+ >
1313
+ </div>
1314
+ </div>
1315
+ </div>
1316
+ </div>
1317
+ {/if}
1318
+
1319
+ <!-- Status Messages -->
1320
+ {#if error}
1321
+ <div
1322
+ style="margin-bottom: 1rem; border-radius: 0.5rem; border: 1px solid rgba(233, 19, 21, 0.4); background: linear-gradient(to right, #fef2f2, #fdf2f8, #fff1f2); padding: 1rem; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);"
1323
+ >
1324
+ <div style="display: flex; align-items: flex-start; gap: 0.75rem;">
1325
+ <div
1326
+ style="display: flex; height: 2rem; width: 2rem; align-items: center; justify-content: center; border-radius: 9999px; background: linear-gradient(to bottom right, #E91315, #be123c); box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1); flex-shrink: 0;"
1327
+ >
1328
+ <AlertCircle style="height: 1rem; width: 1rem; color: #ffffff;" />
1329
+ </div>
1330
+ <div style="flex: 1;">
1331
+ <div style="font-weight: 600; color: #7f1d1d; font-family: 'Epilogue', sans-serif;">
1332
+ Error
1333
+ </div>
1334
+ <div
1335
+ style="margin-top: 0.25rem; font-size: 0.875rem; color: rgba(127, 29, 29, 0.9); font-family: 'DM Sans', sans-serif;"
1336
+ >
1337
+ {error}
1338
+ </div>
1339
+ </div>
1340
+ </div>
1341
+ </div>
1342
+ {/if}
1343
+
1344
+ {#if success}
1345
+ <div
1346
+ style="margin-bottom: 1rem; border-radius: 0.5rem; border: 1px solid rgba(16, 185, 129, 0.4); background: linear-gradient(to right, #ecfdf5, #f0fdf4, #f0fdfa); padding: 1rem; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);"
1347
+ >
1348
+ <div style="display: flex; align-items: flex-start; gap: 0.75rem;">
1349
+ <div
1350
+ style="display: flex; height: 2rem; width: 2rem; align-items: center; justify-content: center; border-radius: 9999px; background: linear-gradient(to bottom right, #10b981, #14b8a6); box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1); flex-shrink: 0;"
1351
+ >
1352
+ <CheckCircle style="height: 1rem; width: 1rem; color: #ffffff;" />
1353
+ </div>
1354
+ <div style="flex: 1;">
1355
+ <div style="font-weight: 600; color: #064e3b; font-family: 'Epilogue', sans-serif;">
1356
+ Success
1357
+ </div>
1358
+ <div
1359
+ style="margin-top: 0.25rem; font-size: 0.875rem; color: rgba(6, 78, 59, 0.9); font-family: 'DM Sans', sans-serif;"
1360
+ >
1361
+ {success}
1362
+ </div>
1363
+ </div>
1364
+ </div>
1365
+ </div>
1366
+ {/if}
1367
+
1368
+ <!-- Progress Bar -->
1369
+ {#if showProgress}
1370
+ <div
1371
+ style="margin-bottom: 1rem; border-radius: 0.5rem; border: 1px solid rgba(233, 19, 21, 0.3); background: linear-gradient(to right, #FFF5E6, #FFF0F0, #FFF5E6); padding: 1rem; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);"
1372
+ >
1373
+ <div style="display: flex; align-items: flex-start; gap: 0.75rem;">
1374
+ <div
1375
+ style="display: flex; height: 2rem; width: 2rem; align-items: center; justify-content: center; border-radius: 9999px; background: linear-gradient(to bottom right, #E91315, #FFC83F); box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1); flex-shrink: 0;"
1376
+ >
1377
+ <svg
1378
+ style="height: 1rem; width: 1rem; color: #ffffff; animation: spin 1s linear infinite;"
1379
+ fill="none"
1380
+ viewBox="0 0 24 24"
1381
+ stroke="currentColor"
1382
+ >
1383
+ <path
1384
+ stroke-linecap="round"
1385
+ stroke-linejoin="round"
1386
+ stroke-width="2"
1387
+ d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
1388
+ />
1389
+ </svg>
1390
+ </div>
1391
+ <div style="flex: 1;">
1392
+ <div
1393
+ style="margin-bottom: 0.5rem; display: flex; align-items: center; justify-content: space-between;"
1394
+ >
1395
+ <span style="font-weight: 600; color: #7A1518; font-family: 'Epilogue', sans-serif;">
1396
+ {progressType === 'upload' ? 'Uploading' : 'Downloading'} Progress
1397
+ </span>
1398
+ <span
1399
+ style="font-size: 0.875rem; font-weight: 500; color: #E91315; font-family: 'DM Mono', monospace;"
1400
+ >
1401
+ {progressPercentage}% ({progressCurrent}/{progressTotal})
1402
+ </span>
1403
+ </div>
1404
+ <div
1405
+ style="height: 0.75rem; width: 100%; border-radius: 9999px; background-color: #FFE4AE; box-shadow: inset 0 2px 4px 0 rgba(0,0,0,0.05);"
1406
+ >
1407
+ <div
1408
+ style="height: 0.75rem; border-radius: 9999px; background: linear-gradient(to right, #E91315, #FFC83F, #E91315); box-shadow: 0 1px 2px 0 rgba(0,0,0,0.05); transition: all 500ms ease-out; width: {progressPercentage}%"
1409
+ ></div>
1410
+ </div>
1411
+ {#if progressCurrentBlock}
1412
+ <div
1413
+ style="margin-top: 0.5rem; font-size: 0.75rem; color: rgba(122, 21, 24, 0.8); font-family: 'DM Mono', monospace;"
1414
+ >
1415
+ {progressType === 'upload' ? 'Current block:' : 'Current CID:'}
1416
+ <span style="font-weight: 500;">
1417
+ {progressType === 'upload'
1418
+ ? progressCurrentBlock.hash?.slice(0, 16)
1419
+ : progressCurrentBlock.storachaCID?.slice(0, 16)}...
1420
+ </span>
1421
+ </div>
1422
+ {/if}
1423
+ {#if progressError}
1424
+ <div
1425
+ style="margin-top: 0.5rem; border-radius: 0.375rem; background-color: #fee2e2; padding: 0.25rem 0.5rem; font-size: 0.75rem; color: #b91c1c; font-family: 'DM Sans', sans-serif;"
1426
+ >
1427
+ Error: {progressError.message}
1428
+ </div>
1429
+ {/if}
1430
+ </div>
1431
+ </div>
1432
+ </div>
1433
+ {/if}
1434
+
1435
+ <!-- Pairing prompt: mounted outside tab panels so it always shows (snippet renders nothing when idle). -->
1436
+ {@render pairingApprovalPrompt()}
1437
+
1438
+ {#if !isLoggedIn}
1439
+ <!-- Login Section -->
1440
+ <div style="display: flex; flex-direction: column; gap: 1rem;">
1441
+ <div
1442
+ style="text-align: center; font-size: 0.875rem; color: #374151; font-family: 'DM Sans', sans-serif;"
1443
+ >
1444
+ Create, use and replicate your passkeys and UCANs between your devices - recover them from
1445
+ decentralised Filecoin/Storacha storage
1446
+ </div>
1447
+
1448
+ {#if !signingMode}
1449
+ <!-- Step 1: passkey — labels follow hasLocalPasskeyHint() + registry -->
1450
+ <div style="display: flex; flex-direction: column; align-items: center; gap: 0.75rem;">
1451
+ <div
1452
+ style="text-align: center; font-size: 0.75rem; color: #6b7280; font-family: 'DM Sans', sans-serif;"
1453
+ >
1454
+ {passkeyStepHint}
1455
+ </div>
1456
+
1457
+ <fieldset
1458
+ disabled={isAuthenticating || signingPreferenceOverride != null}
1459
+ data-testid="storacha-signing-preference-group"
1460
+ style="margin: 0; width: 100%; max-width: 22rem; border-radius: 0.5rem; border: 1px solid rgba(233, 19, 21, 0.25); padding: 0.625rem 0.75rem; background: rgba(255, 255, 255, 0.6); box-sizing: border-box;"
1461
+ >
1462
+ <legend
1463
+ style="font-size: 0.7rem; font-weight: 600; color: #374151; font-family: 'Epilogue', sans-serif; padding: 0 0.25rem;"
1464
+ >
1465
+ Signing mode
1466
+ </legend>
1467
+ <div style="display: flex; flex-direction: column; gap: 0.4rem;">
1468
+ <label
1469
+ style="display: flex; align-items: flex-start; gap: 0.5rem; cursor: pointer; font-size: 0.68rem; color: #374151; font-family: 'DM Sans', sans-serif; line-height: 1.35;"
1470
+ >
1471
+ <input
1472
+ type="radio"
1473
+ name="storacha-signing-pref"
1474
+ data-testid="storacha-signing-pref-hardware-ed25519"
1475
+ checked={selectedSigningPreference === 'hardware-ed25519'}
1476
+ onchange={() => setSigningPreference('hardware-ed25519')}
1477
+ style="margin-top: 0.15rem; accent-color: #E91315;"
1478
+ />
1479
+ <span
1480
+ ><strong>Hardware Ed25519</strong> (default) — passkey signatures in the secure
1481
+ element; Ed25519 preferred, <strong>P-256 fallback</strong> if the authenticator does
1482
+ not support Ed25519.</span
1483
+ >
1484
+ </label>
1485
+ <label
1486
+ style="display: flex; align-items: flex-start; gap: 0.5rem; cursor: pointer; font-size: 0.68rem; color: #374151; font-family: 'DM Sans', sans-serif; line-height: 1.35;"
1487
+ >
1488
+ <input
1489
+ type="radio"
1490
+ name="storacha-signing-pref"
1491
+ data-testid="storacha-signing-pref-hardware-p256"
1492
+ checked={selectedSigningPreference === 'hardware-p256'}
1493
+ onchange={() => setSigningPreference('hardware-p256')}
1494
+ style="margin-top: 0.15rem; accent-color: #E91315;"
1495
+ />
1496
+ <span
1497
+ ><strong>Hardware P-256</strong> — WebAuthn ES256 only (no Ed25519 on this passkey).</span
1498
+ >
1499
+ </label>
1500
+ <label
1501
+ style="display: flex; align-items: flex-start; gap: 0.5rem; cursor: pointer; font-size: 0.68rem; color: #374151; font-family: 'DM Sans', sans-serif; line-height: 1.35;"
1502
+ >
1503
+ <input
1504
+ type="radio"
1505
+ name="storacha-signing-pref"
1506
+ data-testid="storacha-signing-pref-worker"
1507
+ checked={selectedSigningPreference === 'worker'}
1508
+ onchange={() => setSigningPreference('worker')}
1509
+ style="margin-top: 0.15rem; accent-color: #E91315;"
1510
+ />
1511
+ <span
1512
+ ><strong>Worker Ed25519</strong> — signing key in a web worker; WebAuthn used for
1513
+ PRF / user verification (recommended for OrbitDB multi-device).</span
1514
+ >
1515
+ </label>
1516
+ </div>
1517
+ </fieldset>
1518
+
1519
+ <button
1520
+ data-testid="storacha-passkey-primary"
1521
+ class="storacha-btn-primary"
1522
+ onclick={handleAuthenticate}
1523
+ disabled={isAuthenticating}
1524
+ style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; border-radius: 0.375rem; background-color: #E91315; padding: 0.625rem 1.5rem; color: #ffffff; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1); transition: color 150ms, background-color 150ms; border: none; cursor: pointer; font-family: 'Epilogue', sans-serif; font-weight: 600; font-size: 0.875rem; opacity: {isAuthenticating
1525
+ ? '0.5'
1526
+ : '1'};"
1527
+ >
1528
+ {#if isAuthenticating}
1529
+ <Loader2 style="height: 1rem; width: 1rem; animation: spin 1s linear infinite;" />
1530
+ <span>{primaryPasskeyLoadingLabel}</span>
1531
+ {:else}
1532
+ <svg
1533
+ style="height: 1rem; width: 1rem;"
1534
+ fill="none"
1535
+ viewBox="0 0 24 24"
1536
+ stroke="currentColor"
1537
+ >
1538
+ <path
1539
+ stroke-linecap="round"
1540
+ stroke-linejoin="round"
1541
+ stroke-width="2"
1542
+ d="M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.39-2.823 1.07-4"
1543
+ />
1544
+ </svg>
1545
+ <span>{primaryPasskeyLabel}</span>
1546
+ {/if}
1547
+ </button>
1548
+
1549
+ <!-- Recover from backup (IPNS / manifest) -->
1550
+ <button
1551
+ onclick={handleRecover}
1552
+ disabled={isRecovering}
1553
+ style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; border-radius: 0.375rem; background-color: transparent; padding: 0.5rem 1.25rem; color: #E91315; border: 1px solid #E91315; cursor: pointer; font-family: 'Epilogue', sans-serif; font-weight: 600; font-size: 0.75rem; opacity: {isRecovering
1554
+ ? '0.5'
1555
+ : '1'}; transition: background-color 150ms;"
1556
+ >
1557
+ {#if isRecovering}
1558
+ <Loader2
1559
+ style="height: 0.875rem; width: 0.875rem; animation: spin 1s linear infinite;"
1560
+ />
1561
+ <span>{recoveryStatus || 'Recovering...'}</span>
1562
+ {:else}
1563
+ <svg
1564
+ style="height: 0.875rem; width: 0.875rem;"
1565
+ fill="none"
1566
+ viewBox="0 0 24 24"
1567
+ stroke="currentColor"
1568
+ >
1569
+ <path
1570
+ stroke-linecap="round"
1571
+ stroke-linejoin="round"
1572
+ stroke-width="2"
1573
+ d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
1574
+ />
1575
+ </svg>
1576
+ <span>Recover Passkey</span>
1577
+ {/if}
1578
+ </button>
1579
+ </div>
1580
+ {:else}
1581
+ <!-- Step 2: Authenticated — show DID info + delegation import -->
1582
+ <div
1583
+ data-testid="storacha-post-auth"
1584
+ style="display: flex; flex-direction: column; gap: 0.75rem;"
1585
+ >
1586
+ <!-- Signing Mode Badge -->
1587
+ <div
1588
+ style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; flex-wrap: wrap;"
1589
+ >
1590
+ {#if signingMode.algorithm === 'Ed25519' && signingMode.mode === 'hardware'}
1591
+ <span
1592
+ style="display: inline-flex; align-items: center; gap: 0.25rem; border-radius: 9999px; background-color: #dcfce7; border: 1px solid #86efac; padding: 0.25rem 0.75rem; font-size: 0.75rem; font-weight: 600; color: #166534; font-family: 'DM Sans', sans-serif;"
1593
+ >
1594
+ <svg
1595
+ style="height: 0.625rem; width: 0.625rem;"
1596
+ fill="currentColor"
1597
+ viewBox="0 0 20 20"
1598
+ ><path
1599
+ fill-rule="evenodd"
1600
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
1601
+ clip-rule="evenodd"
1602
+ /></svg
1603
+ >
1604
+ Hardware Ed25519
1605
+ </span>
1606
+ {:else if signingMode.algorithm === 'P-256' && signingMode.mode === 'hardware'}
1607
+ <span
1608
+ style="display: inline-flex; align-items: center; gap: 0.25rem; border-radius: 9999px; background-color: #BDE0FF; border: 1px solid #0176CE; padding: 0.25rem 0.75rem; font-size: 0.75rem; font-weight: 600; color: #0176CE; font-family: 'DM Sans', sans-serif;"
1609
+ >
1610
+ <svg
1611
+ style="height: 0.625rem; width: 0.625rem;"
1612
+ fill="currentColor"
1613
+ viewBox="0 0 20 20"
1614
+ ><path
1615
+ fill-rule="evenodd"
1616
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
1617
+ clip-rule="evenodd"
1618
+ /></svg
1619
+ >
1620
+ Hardware P-256
1621
+ </span>
1622
+ {:else}
1623
+ <span
1624
+ style="display: inline-flex; align-items: center; gap: 0.25rem; border-radius: 9999px; background-color: #FFE4AE; border: 1px solid #FFC83F; padding: 0.25rem 0.75rem; font-size: 0.75rem; font-weight: 600; color: #92400e; font-family: 'DM Sans', sans-serif;"
1625
+ >
1626
+ <svg
1627
+ style="height: 0.625rem; width: 0.625rem;"
1628
+ fill="currentColor"
1629
+ viewBox="0 0 20 20"
1630
+ ><path
1631
+ fill-rule="evenodd"
1632
+ d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
1633
+ clip-rule="evenodd"
1634
+ /></svg
1635
+ >
1636
+ Worker Ed25519
1637
+ </span>
1638
+ {/if}
1639
+ {#if signingMode.secure}
1640
+ <span
1641
+ style="display: inline-flex; align-items: center; gap: 0.25rem; border-radius: 9999px; background-color: #dcfce7; border: 1px solid #86efac; padding: 0.25rem 0.5rem; font-size: 0.625rem; font-weight: 500; color: #166534; font-family: 'DM Mono', monospace;"
1642
+ >
1643
+ Secure
1644
+ </span>
1645
+ {/if}
1646
+ </div>
1647
+
1648
+ <!-- DID Display -->
1649
+ <div
1650
+ style="border-radius: 0.375rem; border: 1px solid rgba(233, 19, 21, 0.3); background: linear-gradient(to right, #ffffff, #EFE3F3); padding: 0.625rem 0.75rem; box-shadow: 0 1px 2px 0 rgba(0,0,0,0.05);"
1651
+ >
1652
+ <div
1653
+ style="font-size: 0.625rem; font-weight: 600; color: #6b7280; font-family: 'DM Sans', sans-serif; margin-bottom: 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;"
1654
+ >
1655
+ Your DID
1656
+ </div>
1657
+ <div style="display: flex; align-items: center; gap: 0.5rem;">
1658
+ <code
1659
+ style="flex: 1; font-size: 0.75rem; color: #374151; font-family: 'DM Mono', monospace; word-break: break-all; line-height: 1.4;"
1660
+ >
1661
+ {signingMode.did
1662
+ ? signingMode.did.length > 40
1663
+ ? signingMode.did.slice(0, 20) + '...' + signingMode.did.slice(-16)
1664
+ : signingMode.did
1665
+ : 'N/A'}
1666
+ </code>
1667
+ <button
1668
+ class="storacha-btn-icon"
1669
+ onclick={() => {
1670
+ if (signingMode.did) {
1671
+ navigator.clipboard.writeText(signingMode.did);
1672
+ showMessage('DID copied to clipboard!');
1673
+ }
1674
+ }}
1675
+ style="border-radius: 0.25rem; padding: 0.25rem; color: #0176CE; transition: all 150ms; border: none; background: transparent; cursor: pointer; flex-shrink: 0;"
1676
+ title="Copy full DID"
1677
+ aria-label="Copy DID to clipboard"
1678
+ >
1679
+ <svg
1680
+ style="height: 0.875rem; width: 0.875rem;"
1681
+ fill="none"
1682
+ viewBox="0 0 24 24"
1683
+ stroke="currentColor"
1684
+ >
1685
+ <path
1686
+ stroke-linecap="round"
1687
+ stroke-linejoin="round"
1688
+ stroke-width="2"
1689
+ d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
1690
+ />
1691
+ </svg>
1692
+ </button>
1693
+ </div>
1694
+ </div>
1695
+
1696
+ {#if activeTab !== 'passkeys'}
1697
+ <!-- Delegation Import -->
1698
+ <div
1699
+ style="border-radius: 0.375rem; border: 1px solid #E91315; background: linear-gradient(to bottom right, #ffffff, #EFE3F3); padding: 1rem; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);"
1700
+ >
1701
+ <h4
1702
+ style="margin-bottom: 0.5rem; font-weight: 700; color: #E91315; font-family: 'Epilogue', sans-serif; font-size: 0.875rem;"
1703
+ >
1704
+ Import UCAN Delegation
1705
+ </h4>
1706
+ <p
1707
+ style="margin-bottom: 0.75rem; font-size: 0.75rem; color: #6b7280; font-family: 'DM Sans', sans-serif; line-height: 1.4;"
1708
+ >
1709
+ Paste a <strong>Storacha UCAN delegation</strong> (from w3up, the CLI, or copied
1710
+ from a browser that already has access) to reach your space. This is not for
1711
+ linking devices over libp2p — use the <strong>P2P Passkeys</strong> tab and peer JSON
1712
+ for that.
1713
+ </p>
1714
+ <div style="display: flex; flex-direction: column; gap: 0.75rem;">
1715
+ <textarea
1716
+ class="storacha-textarea"
1717
+ bind:value={delegationText}
1718
+ placeholder="Paste your UCAN delegation here (base64 encoded)..."
1719
+ rows="4"
1720
+ style="width: 100%; resize: none; border-radius: 0.375rem; border: 1px solid #E91315; background-color: #ffffff; padding: 0.5rem 0.75rem; font-size: 0.75rem; color: #111827; font-family: 'DM Mono', monospace; outline: none; box-sizing: border-box;"
1721
+ ></textarea>
1722
+ <button
1723
+ class="storacha-btn-primary"
1724
+ onclick={handleImportDelegation}
1725
+ disabled={isLoading || !delegationText.trim()}
1726
+ style="display: flex; width: 100%; align-items: center; justify-content: center; gap: 0.5rem; border-radius: 0.375rem; background-color: #E91315; padding: 0.5rem 1rem; color: #ffffff; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1); transition: color 150ms, background-color 150ms; border: none; cursor: pointer; font-family: 'Epilogue', sans-serif; font-weight: 600; opacity: {isLoading ||
1727
+ !delegationText.trim()
1728
+ ? '0.5'
1729
+ : '1'}; box-sizing: border-box;"
1730
+ >
1731
+ {#if isLoading}
1732
+ <Loader2
1733
+ style="height: 1rem; width: 1rem; animation: spin 1s linear infinite;"
1734
+ />
1735
+ <span>Connecting...</span>
1736
+ {:else}
1737
+ <svg
1738
+ style="height: 1rem; width: 1rem;"
1739
+ fill="none"
1740
+ viewBox="0 0 24 24"
1741
+ stroke="currentColor"
1742
+ >
1743
+ <path
1744
+ stroke-linecap="round"
1745
+ stroke-linejoin="round"
1746
+ stroke-width="2"
1747
+ d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
1748
+ />
1749
+ </svg>
1750
+ <span>Connect</span>
1751
+ {/if}
1752
+ </button>
1753
+ </div>
1754
+ </div>
1755
+ {:else}
1756
+ <!-- Link Device (peer id) -->
1757
+ <div
1758
+ style="border-radius: 0.375rem; border: 1px solid #E91315; background: linear-gradient(to bottom right, #ffffff, #FFE4AE); padding: 1rem; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);"
1759
+ >
1760
+ <h4
1761
+ style="margin-bottom: 0.5rem; font-weight: 700; color: #E91315; font-family: 'Epilogue', sans-serif; font-size: 0.875rem;"
1762
+ >
1763
+ Link Another Device
1764
+ </h4>
1765
+ <p
1766
+ style="margin-bottom: 0.75rem; font-size: 0.75rem; color: #6b7280; font-family: 'DM Sans', sans-serif; line-height: 1.4;"
1767
+ >
1768
+ After both browsers are on the same app and P2P has discovered peers (pubsub),
1769
+ paste the other device’s <strong>peer id</strong> (copy from its Passkeys tab). Linking
1770
+ uses addresses libp2p already learned — no JSON.
1771
+ </p>
1772
+ {#if !linkDeviceReady}
1773
+ <div
1774
+ style="margin-bottom: 0.5rem; font-size: 0.7rem; color: #b45309; font-family: 'DM Sans', sans-serif; line-height: 1.35; border-radius: 0.375rem; background: rgba(254, 243, 199, 0.9); padding: 0.5rem 0.65rem; border: 1px solid #fbbf24;"
1775
+ >
1776
+ Linking is unavailable until the device registry and P2P stack finish loading
1777
+ (MultiDeviceManager). If this stays stuck, check the browser console and confirm
1778
+ OrbitDB initialized after passkey auth.
1779
+ </div>
1780
+ {/if}
1781
+ <div style="display: flex; flex-direction: column; gap: 0.75rem;">
1782
+ <input
1783
+ type="text"
1784
+ data-testid="storacha-link-peer-input"
1785
+ bind:value={linkInput}
1786
+ placeholder="Other device’s libp2p peer id (12D3KooW…)"
1787
+ autocomplete="off"
1788
+ spellcheck="false"
1789
+ style="width: 100%; border-radius: 0.375rem; border: 1px solid #E91315; background-color: #ffffff; padding: 0.5rem 0.75rem; font-size: 0.75rem; color: #111827; font-family: 'DM Mono', monospace; outline: none; box-sizing: border-box;"
1790
+ />
1791
+ {#if linkError}
1792
+ <div
1793
+ style="font-size: 0.7rem; color: #dc2626; font-family: 'DM Sans', sans-serif;"
1794
+ >
1795
+ {linkError}
1796
+ </div>
1797
+ {/if}
1798
+ <button
1799
+ data-testid="storacha-link-device-submit"
1800
+ data-mdm-ready={linkDeviceReady ? 'true' : 'false'}
1801
+ type="button"
1802
+ onclick={handleLinkDevice}
1803
+ disabled={linkDeviceDisabled}
1804
+ title={!linkDeviceReady
1805
+ ? 'Waiting for device registry / MultiDeviceManager to initialize'
1806
+ : 'Link using the other device’s peer id'}
1807
+ style="display: flex; width: 100%; align-items: center; justify-content: center; gap: 0.5rem; border-radius: 0.375rem; background-color: #E91315; padding: 0.5rem 1rem; color: #ffffff; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1); transition: color 150ms, background-color 150ms; border: none; cursor: {linkDeviceDisabled
1808
+ ? 'not-allowed'
1809
+ : 'pointer'}; font-family: 'Epilogue', sans-serif; font-weight: 600; opacity: {linkDeviceDisabled
1810
+ ? '0.5'
1811
+ : '1'}; box-sizing: border-box;"
1812
+ >
1813
+ {#if isLinking}
1814
+ <Loader2
1815
+ style="height: 1rem; width: 1rem; animation: spin 1s linear infinite;"
1816
+ />
1817
+ <span>Linking...</span>
1818
+ {:else}
1819
+ <svg
1820
+ style="height: 1rem; width: 1rem;"
1821
+ fill="none"
1822
+ viewBox="0 0 24 24"
1823
+ stroke="currentColor"
1824
+ >
1825
+ <path
1826
+ stroke-linecap="round"
1827
+ stroke-linejoin="round"
1828
+ stroke-width="2"
1829
+ d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
1830
+ />
1831
+ </svg>
1832
+ <span>Link Device</span>
1833
+ {/if}
1834
+ </button>
1835
+ </div>
1836
+ </div>
1837
+ {/if}
1838
+ </div>
1839
+ {/if}
1840
+
1841
+ {#if signingMode}
1842
+ <!-- Tab Navigation (visible after authentication) — P2P Passkeys first -->
1843
+ <div
1844
+ style="border-radius: 0.5rem; background: rgba(233, 19, 21, 0.06); padding: 0.25rem; display: flex; gap: 0.25rem;"
1845
+ >
1846
+ <button
1847
+ data-testid="storacha-tab-passkeys"
1848
+ onclick={() => handleTabSwitch('passkeys')}
1849
+ style="flex: 1; padding: 0.5rem 1rem; border-radius: 0.375rem; border: none; cursor: pointer; font-family: 'Epilogue', sans-serif; font-size: 0.8rem; font-weight: 600; transition: all 200ms; background: {activeTab ===
1850
+ 'passkeys'
1851
+ ? 'linear-gradient(135deg, #E91315, #FFC83F)'
1852
+ : 'transparent'}; color: {activeTab === 'passkeys'
1853
+ ? '#fff'
1854
+ : '#6B7280'}; box-shadow: {activeTab === 'passkeys'
1855
+ ? '0 2px 8px rgba(233, 19, 21, 0.3)'
1856
+ : 'none'};"
1857
+ >
1858
+ P2P Passkeys
1859
+ </button>
1860
+ <button
1861
+ data-testid="storacha-tab-storacha"
1862
+ onclick={() => handleTabSwitch('storacha')}
1863
+ style="flex: 1; padding: 0.5rem 1rem; border-radius: 0.375rem; border: none; cursor: pointer; font-family: 'Epilogue', sans-serif; font-size: 0.8rem; font-weight: 600; transition: all 200ms; background: {activeTab ===
1864
+ 'storacha'
1865
+ ? 'linear-gradient(135deg, #E91315, #FFC83F)'
1866
+ : 'transparent'}; color: {activeTab === 'storacha'
1867
+ ? '#fff'
1868
+ : '#6B7280'}; box-shadow: {activeTab === 'storacha'
1869
+ ? '0 2px 8px rgba(233, 19, 21, 0.3)'
1870
+ : 'none'};"
1871
+ >
1872
+ Storacha
1873
+ </button>
1874
+ </div>
1875
+
1876
+ {#if activeTab === 'passkeys'}
1877
+ <div style="display: flex; flex-direction: column; gap: 0.75rem;">
1878
+ <!-- Connection Status + Copy -->
1879
+ <div
1880
+ style="border-radius: 0.375rem; border: 1px solid #E91315; background: linear-gradient(to right, #ffffff, #FFE4AE); padding: 0.625rem 0.75rem;"
1881
+ >
1882
+ <div style="display: flex; align-items: center; justify-content: space-between;">
1883
+ <div style="display: flex; align-items: center; gap: 0.5rem;">
1884
+ <div style="display: flex; align-items: center; gap: 0.25rem;">
1885
+ <div
1886
+ style="height: 0.5rem; width: 0.5rem; border-radius: 9999px; background: {p2pLedDotBg}; box-shadow: {p2pLedShadow}; animation: {p2pLedPulse
1887
+ ? 'pulse 2s infinite'
1888
+ : 'none'};"
1889
+ ></div>
1890
+ {#if libp2p}
1891
+ <span
1892
+ data-testid="storacha-p2p-remote-peer-count"
1893
+ title="Connected libp2p peers"
1894
+ style="font-size: 0.65rem; font-weight: 700; font-family: 'DM Mono', monospace; color: {p2pLedTextColor}; line-height: 1; min-width: 0.65rem; text-align: center;"
1895
+ >{p2pRemotePeerCount}</span
1896
+ >
1897
+ {/if}
1898
+ </div>
1899
+ <span
1900
+ style="font-size: 0.75rem; font-weight: 600; color: {p2pLedTextColor}; font-family: 'Epilogue', sans-serif;"
1901
+ >
1902
+ {p2pConnectionLabel}
1903
+ </span>
1904
+ </div>
1905
+ <div style="display: flex; align-items: center; gap: 0.375rem;">
1906
+ {#if libp2p}
1907
+ <code
1908
+ style="font-size: 0.6rem; color: #E91315; font-family: 'DM Mono', monospace; background: rgba(233, 19, 21, 0.06); padding: 0.125rem 0.375rem; border-radius: 0.25rem;"
1909
+ >
1910
+ {libp2p.peerId.toString().slice(0, 8)}...{libp2p.peerId
1911
+ .toString()
1912
+ .slice(-4)}
1913
+ </code>
1914
+ <button
1915
+ data-testid="storacha-copy-peer-info"
1916
+ onclick={handleCopyPeerInfo}
1917
+ title="Copy your libp2p peer id"
1918
+ style="display: flex; align-items: center; justify-content: center; height: 1.25rem; width: 1.25rem; border-radius: 0.25rem; border: none; background: transparent; cursor: pointer; color: #E91315; padding: 0; transition: all 150ms;"
1919
+ >
1920
+ <svg
1921
+ style="height: 0.7rem; width: 0.7rem;"
1922
+ fill="none"
1923
+ viewBox="0 0 24 24"
1924
+ stroke="currentColor"
1925
+ >
1926
+ <path
1927
+ stroke-linecap="round"
1928
+ stroke-linejoin="round"
1929
+ stroke-width="2"
1930
+ d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
1931
+ />
1932
+ </svg>
1933
+ </button>
1934
+ {/if}
1935
+ </div>
1936
+ </div>
1937
+ {#if ipnsNameString}
1938
+ <div
1939
+ style="font-size: 0.7rem; color: #6b7280; font-family: 'DM Mono', monospace; margin-top: 0.25rem;"
1940
+ >
1941
+ IPNS: {ipnsNameString.slice(0, 20)}...
1942
+ </div>
1943
+ {/if}
1944
+ </div>
1945
+
1946
+ {@render linkedDevicesPanel()}
1947
+ </div>
1948
+ {/if}
1949
+ {/if}
1950
+ </div>
1951
+ {:else}
1952
+ <!-- Logged In Section -->
1953
+ <div style="display: flex; flex-direction: column; gap: 1rem;">
1954
+ <!-- Account Info -->
1955
+ <div
1956
+ style="display: flex; align-items: center; justify-content: space-between; border-radius: 0.375rem; border: 1px solid #E91315; background: linear-gradient(to right, #BDE0FF, #FFE4AE); padding: 0.75rem; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);"
1957
+ >
1958
+ <div style="display: flex; align-items: center; gap: 0.75rem;">
1959
+ <div
1960
+ style="display: flex; height: 2rem; width: 2rem; align-items: center; justify-content: center; border-radius: 9999px; background-color: #E91315; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1); flex-shrink: 0;"
1961
+ >
1962
+ <CheckCircle style="height: 1rem; width: 1rem; color: #ffffff;" />
1963
+ </div>
1964
+ <div>
1965
+ <div
1966
+ style="font-size: 0.875rem; font-weight: 700; color: #E91315; font-family: 'Epilogue', sans-serif;"
1967
+ >
1968
+ Connected to Storacha
1969
+ </div>
1970
+ {#if currentSpace}
1971
+ <div style="font-size: 0.75rem; color: #0176CE; font-family: 'DM Mono', monospace;">
1972
+ Space: {formatSpaceName(currentSpace)}
1973
+ </div>
1974
+ {/if}
1975
+ </div>
1976
+ </div>
1977
+
1978
+ <button
1979
+ class="storacha-btn-icon"
1980
+ onclick={handleLogout}
1981
+ style="display: flex; align-items: center; gap: 0.25rem; padding: 0.25rem 0.75rem; font-size: 0.875rem; color: #E91315; transition: color 150ms, background-color 150ms; border: none; background: transparent; cursor: pointer; font-family: 'DM Sans', sans-serif;"
1982
+ >
1983
+ <LogOut style="height: 0.75rem; width: 0.75rem;" />
1984
+ <span>Logout</span>
1985
+ </button>
1986
+ </div>
1987
+
1988
+ <!-- Tab Navigation — P2P Passkeys first -->
1989
+ <div
1990
+ style="border-radius: 0.5rem; background: rgba(233, 19, 21, 0.06); padding: 0.25rem; display: flex; gap: 0.25rem;"
1991
+ >
1992
+ <button
1993
+ data-testid="storacha-tab-passkeys"
1994
+ onclick={() => handleTabSwitch('passkeys')}
1995
+ style="flex: 1; padding: 0.5rem 1rem; border-radius: 0.375rem; border: none; cursor: pointer; font-family: 'Epilogue', sans-serif; font-size: 0.8rem; font-weight: 600; transition: all 200ms; background: {activeTab ===
1996
+ 'passkeys'
1997
+ ? 'linear-gradient(135deg, #E91315, #FFC83F)'
1998
+ : 'transparent'}; color: {activeTab === 'passkeys'
1999
+ ? '#fff'
2000
+ : '#6B7280'}; box-shadow: {activeTab === 'passkeys'
2001
+ ? '0 2px 8px rgba(233, 19, 21, 0.3)'
2002
+ : 'none'};"
2003
+ >
2004
+ P2P Passkeys
2005
+ </button>
2006
+ <button
2007
+ data-testid="storacha-tab-storacha"
2008
+ onclick={() => handleTabSwitch('storacha')}
2009
+ style="flex: 1; padding: 0.5rem 1rem; border-radius: 0.375rem; border: none; cursor: pointer; font-family: 'Epilogue', sans-serif; font-size: 0.8rem; font-weight: 600; transition: all 200ms; background: {activeTab ===
2010
+ 'storacha'
2011
+ ? 'linear-gradient(135deg, #E91315, #FFC83F)'
2012
+ : 'transparent'}; color: {activeTab === 'storacha'
2013
+ ? '#fff'
2014
+ : '#6B7280'}; box-shadow: {activeTab === 'storacha'
2015
+ ? '0 2px 8px rgba(233, 19, 21, 0.3)'
2016
+ : 'none'};"
2017
+ >
2018
+ Storacha
2019
+ </button>
2020
+ </div>
2021
+
2022
+ {#if activeTab === 'storacha'}
2023
+ <!-- Action Buttons -->
2024
+ <div style="display: flex; flex-direction: column; gap: 0.75rem;">
2025
+ <button
2026
+ class="storacha-btn-backup"
2027
+ onclick={handleBackup}
2028
+ disabled={isLoading || (!registryDb && entryCount === 0)}
2029
+ style="display: flex; width: 100%; align-items: center; justify-content: center; gap: 0.5rem; border-radius: 0.375rem; background-color: #FFC83F; padding: 0.5rem 1rem; font-weight: 700; color: #111827; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1); transition: color 150ms, background-color 150ms; border: none; cursor: pointer; font-family: 'Epilogue', sans-serif; opacity: {isLoading ||
2030
+ (!registryDb && entryCount === 0)
2031
+ ? '0.5'
2032
+ : '1'}; box-sizing: border-box;"
2033
+ >
2034
+ <Upload style="height: 1rem; width: 1rem;" />
2035
+ <span>Backup to Storacha</span>
2036
+ </button>
2037
+
2038
+ <button
2039
+ class="storacha-btn-restore"
2040
+ onclick={restoreFromSpaceFallback}
2041
+ disabled={isLoading || !isInitialized}
2042
+ style="display: flex; width: 100%; align-items: center; justify-content: center; gap: 0.5rem; border-radius: 0.375rem; background-color: #0176CE; padding: 0.5rem 1rem; font-weight: 700; color: #ffffff; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1); transition: color 150ms, background-color 150ms; border: none; cursor: pointer; font-family: 'Epilogue', sans-serif; opacity: {isLoading ||
2043
+ !isInitialized
2044
+ ? '0.5'
2045
+ : '1'}; box-sizing: border-box;"
2046
+ title="Restore database from Storacha backup"
2047
+ >
2048
+ <Download style="height: 1rem; width: 1rem;" />
2049
+ <span>Restore from Storacha</span>
2050
+ </button>
2051
+ </div>
2052
+
2053
+ <!-- Space Usage Information -->
2054
+ <div
2055
+ style="border-radius: 0.375rem; border: 1px solid #E91315; background: linear-gradient(to bottom right, #ffffff, #FFE4AE); padding: 1rem; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);"
2056
+ >
2057
+ <div
2058
+ style="margin-bottom: 0.75rem; display: flex; align-items: center; justify-content: space-between;"
2059
+ >
2060
+ <h4
2061
+ style="display: flex; align-items: center; gap: 0.5rem; font-weight: 700; color: #E91315; font-family: 'Epilogue', sans-serif; margin: 0;"
2062
+ >
2063
+ <svg
2064
+ style="height: 1rem; width: 1rem;"
2065
+ fill="none"
2066
+ viewBox="0 0 24 24"
2067
+ stroke="currentColor"
2068
+ >
2069
+ <path
2070
+ stroke-linecap="round"
2071
+ stroke-linejoin="round"
2072
+ stroke-width="2"
2073
+ d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
2074
+ />
2075
+ </svg>
2076
+ <span>Storage Analytics</span>
2077
+ </h4>
2078
+ <div style="display: flex; align-items: center; gap: 0.25rem;">
2079
+ <button
2080
+ class="storacha-btn-icon"
2081
+ onclick={loadSpaceUsage}
2082
+ disabled={isLoading}
2083
+ style="border-radius: 0.375rem; padding: 0.5rem; color: #E91315; transition: all 300ms; border: none; background: transparent; cursor: pointer; opacity: {isLoading
2084
+ ? '0.5'
2085
+ : '1'};"
2086
+ title="Refresh space usage"
2087
+ aria-label="Refresh space usage"
2088
+ >
2089
+ <svg
2090
+ style="height: 1rem; width: 1rem;"
2091
+ fill="none"
2092
+ viewBox="0 0 24 24"
2093
+ stroke="currentColor"
2094
+ >
2095
+ <path
2096
+ stroke-linecap="round"
2097
+ stroke-linejoin="round"
2098
+ stroke-width="2"
2099
+ d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
2100
+ />
2101
+ </svg>
2102
+ </button>
2103
+ {#if spaceUsage && spaceUsage.totalFiles <= 50 && !spaceUsage.analyzed}
2104
+ <button
2105
+ class="storacha-btn-icon"
2106
+ onclick={async () => {
2107
+ spaceUsage = await getSpaceUsage(client, true);
2108
+ }}
2109
+ disabled={isLoading}
2110
+ style="border-radius: 0.375rem; padding: 0.5rem; color: #0176CE; transition: all 300ms; border: none; background: transparent; cursor: pointer; opacity: {isLoading
2111
+ ? '0.5'
2112
+ : '1'};"
2113
+ title="Analyze file types"
2114
+ aria-label="Analyze file types"
2115
+ >
2116
+ <svg
2117
+ style="height: 1rem; width: 1rem;"
2118
+ fill="none"
2119
+ viewBox="0 0 24 24"
2120
+ stroke="currentColor"
2121
+ >
2122
+ <path
2123
+ stroke-linecap="round"
2124
+ stroke-linejoin="round"
2125
+ stroke-width="2"
2126
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
2127
+ />
2128
+ </svg>
2129
+ </button>
2130
+ {/if}
2131
+ </div>
2132
+ </div>
2133
+
2134
+ {#if spaceUsage}
2135
+ <div
2136
+ style="margin-bottom: 1rem; border-radius: 0.25rem; border: 1px solid #E91315; background: linear-gradient(to right, #EFE3F3, #ffffff); padding: 0.75rem; box-shadow: 0 1px 2px 0 rgba(0,0,0,0.05);"
2137
+ >
2138
+ <div
2139
+ style="display: flex; align-items: center; justify-content: space-between; font-size: 0.875rem;"
2140
+ >
2141
+ <div style="display: flex; align-items: center; gap: 0.5rem;">
2142
+ <div
2143
+ style="display: flex; height: 1.5rem; width: 1.5rem; align-items: center; justify-content: center; border-radius: 9999px; background-color: #E91315; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);"
2144
+ >
2145
+ <span
2146
+ style="font-size: 0.75rem; font-weight: 700; color: #ffffff; font-family: 'DM Mono', monospace;"
2147
+ >{spaceUsage.totalFiles}</span
2148
+ >
2149
+ </div>
2150
+ <span
2151
+ style="font-weight: 500; color: #1f2937; font-family: 'DM Sans', sans-serif;"
2152
+ >
2153
+ file{spaceUsage.totalFiles !== 1 ? 's' : ''} stored
2154
+ </span>
2155
+ </div>
2156
+ {#if spaceUsage.lastUploadDate}
2157
+ <div
2158
+ style="color: #0176CE; font-family: 'DM Mono', monospace; font-size: 0.75rem;"
2159
+ >
2160
+ Last upload: {formatRelativeTime(spaceUsage.lastUploadDate)}
2161
+ </div>
2162
+ {/if}
2163
+ </div>
2164
+
2165
+ {#if spaceUsage.totalFiles > 0}
2166
+ <div
2167
+ style="margin-top: 0.5rem; display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; font-size: 0.75rem;"
2168
+ >
2169
+ {#if spaceUsage.backupFiles > 0}
2170
+ <div style="display: flex; align-items: center; gap: 0.25rem;">
2171
+ <div
2172
+ style="height: 0.5rem; width: 0.5rem; border-radius: 9999px; background-color: #FFC83F;"
2173
+ ></div>
2174
+ <span style="color: #374151; font-family: 'DM Sans', sans-serif;">
2175
+ {spaceUsage.backupFiles} backup{spaceUsage.backupFiles !== 1 ? 's' : ''}
2176
+ </span>
2177
+ </div>
2178
+ {/if}
2179
+ {#if spaceUsage.blockFiles > 0}
2180
+ <div style="display: flex; align-items: center; gap: 0.25rem;">
2181
+ <div
2182
+ style="height: 0.5rem; width: 0.5rem; border-radius: 9999px; background-color: #0176CE;"
2183
+ ></div>
2184
+ <span style="color: #374151; font-family: 'DM Sans', sans-serif;">
2185
+ {spaceUsage.blockFiles} data block{spaceUsage.blockFiles !== 1 ? 's' : ''}
2186
+ </span>
2187
+ </div>
2188
+ {/if}
2189
+ {#if spaceUsage.otherFiles > 0}
2190
+ <div style="display: flex; align-items: center; gap: 0.25rem;">
2191
+ <div
2192
+ style="height: 0.5rem; width: 0.5rem; border-radius: 9999px; background-color: #E91315;"
2193
+ ></div>
2194
+ <span style="color: #374151; font-family: 'DM Sans', sans-serif;">
2195
+ {spaceUsage.otherFiles} other
2196
+ </span>
2197
+ </div>
2198
+ {/if}
2199
+ </div>
2200
+
2201
+ <div
2202
+ style="margin-top: 0.5rem; font-size: 0.75rem; color: #4b5563; font-family: 'DM Sans', sans-serif;"
2203
+ >
2204
+ {#if spaceUsage.oldestUploadDate && spaceUsage.oldestUploadDate !== spaceUsage.lastUploadDate}
2205
+ <div style="color: #0176CE; font-family: 'DM Mono', monospace;">
2206
+ Oldest upload: {formatRelativeTime(spaceUsage.oldestUploadDate)}
2207
+ </div>
2208
+ {/if}
2209
+ <em style="color: #6b7280;">Note: Each backup creates many data blocks</em>
2210
+ </div>
2211
+ {/if}
2212
+ </div>
2213
+ {:else if spaceUsage === null && isLoggedIn}
2214
+ <div
2215
+ style="margin-bottom: 1rem; border-radius: 0.25rem; border: 1px solid #E91315; background: linear-gradient(to right, #EFE3F3, #FFE4AE); padding: 0.75rem; box-shadow: 0 1px 2px 0 rgba(0,0,0,0.05);"
2216
+ >
2217
+ <div style="display: flex; align-items: center; gap: 0.5rem;">
2218
+ <div
2219
+ style="display: flex; height: 1.25rem; width: 1.25rem; align-items: center; justify-content: center; border-radius: 9999px; background-color: #E91315; flex-shrink: 0;"
2220
+ >
2221
+ <svg
2222
+ style="height: 0.75rem; width: 0.75rem; color: #ffffff;"
2223
+ fill="none"
2224
+ viewBox="0 0 24 24"
2225
+ stroke="currentColor"
2226
+ >
2227
+ <path
2228
+ stroke-linecap="round"
2229
+ stroke-linejoin="round"
2230
+ stroke-width="2"
2231
+ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
2232
+ />
2233
+ </svg>
2234
+ </div>
2235
+ <div
2236
+ style="font-size: 0.875rem; font-weight: 500; color: #E91315; font-family: 'DM Sans', sans-serif;"
2237
+ >
2238
+ Space usage information unavailable
2239
+ </div>
2240
+ </div>
2241
+ </div>
2242
+ {/if}
2243
+ </div>
2244
+ {/if}
2245
+
2246
+ {#if activeTab === 'passkeys'}
2247
+ <div style="display: flex; flex-direction: column; gap: 0.75rem;">
2248
+ <!-- Connection Status + Copy -->
2249
+ <div
2250
+ style="border-radius: 0.375rem; border: 1px solid #E91315; background: linear-gradient(to right, #ffffff, #FFE4AE); padding: 0.625rem 0.75rem;"
2251
+ >
2252
+ <div style="display: flex; align-items: center; justify-content: space-between;">
2253
+ <div style="display: flex; align-items: center; gap: 0.5rem;">
2254
+ <div style="display: flex; align-items: center; gap: 0.25rem;">
2255
+ <div
2256
+ style="height: 0.5rem; width: 0.5rem; border-radius: 9999px; background: {p2pLedDotBg}; box-shadow: {p2pLedShadow}; animation: {p2pLedPulse
2257
+ ? 'pulse 2s infinite'
2258
+ : 'none'};"
2259
+ ></div>
2260
+ {#if libp2p}
2261
+ <span
2262
+ data-testid="storacha-p2p-remote-peer-count"
2263
+ title="Connected libp2p peers"
2264
+ style="font-size: 0.65rem; font-weight: 700; font-family: 'DM Mono', monospace; color: {p2pLedTextColor}; line-height: 1; min-width: 0.65rem; text-align: center;"
2265
+ >{p2pRemotePeerCount}</span
2266
+ >
2267
+ {/if}
2268
+ </div>
2269
+ <span
2270
+ style="font-size: 0.75rem; font-weight: 600; color: {p2pLedTextColor}; font-family: 'Epilogue', sans-serif;"
2271
+ >
2272
+ {p2pConnectionLabel}
2273
+ </span>
2274
+ </div>
2275
+ <div style="display: flex; align-items: center; gap: 0.375rem;">
2276
+ {#if libp2p}
2277
+ <code
2278
+ style="font-size: 0.6rem; color: #E91315; font-family: 'DM Mono', monospace; background: rgba(233, 19, 21, 0.06); padding: 0.125rem 0.375rem; border-radius: 0.25rem;"
2279
+ >
2280
+ {libp2p.peerId.toString().slice(0, 8)}...{libp2p.peerId.toString().slice(-4)}
2281
+ </code>
2282
+ <button
2283
+ data-testid="storacha-copy-peer-info"
2284
+ onclick={handleCopyPeerInfo}
2285
+ title="Copy your libp2p peer id"
2286
+ style="display: flex; align-items: center; justify-content: center; height: 1.25rem; width: 1.25rem; border-radius: 0.25rem; border: none; background: transparent; cursor: pointer; color: #E91315; padding: 0; transition: all 150ms;"
2287
+ >
2288
+ <svg
2289
+ style="height: 0.7rem; width: 0.7rem;"
2290
+ fill="none"
2291
+ viewBox="0 0 24 24"
2292
+ stroke="currentColor"
2293
+ >
2294
+ <path
2295
+ stroke-linecap="round"
2296
+ stroke-linejoin="round"
2297
+ stroke-width="2"
2298
+ d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
2299
+ />
2300
+ </svg>
2301
+ </button>
2302
+ {/if}
2303
+ </div>
2304
+ </div>
2305
+ {#if ipnsNameString}
2306
+ <div
2307
+ style="font-size: 0.7rem; color: #6b7280; font-family: 'DM Mono', monospace; margin-top: 0.25rem;"
2308
+ >
2309
+ IPNS: {ipnsNameString.slice(0, 20)}...
2310
+ </div>
2311
+ {/if}
2312
+ </div>
2313
+
2314
+ {#if !libp2p}
2315
+ <div
2316
+ style="border-radius: 0.375rem; border: 1px dashed #E91315; background: rgba(233, 19, 21, 0.03); padding: 1.25rem; text-align: center;"
2317
+ >
2318
+ <div
2319
+ style="font-size: 0.8rem; font-weight: 700; color: #E91315; font-family: 'Epilogue', sans-serif; margin-bottom: 0.25rem;"
2320
+ >
2321
+ P2P Networking Not Available
2322
+ </div>
2323
+ <div
2324
+ style="font-size: 0.75rem; color: #6B7280; font-family: 'DM Sans', sans-serif; line-height: 1.4;"
2325
+ >
2326
+ Provide a libp2p instance to enable device linking and peer-to-peer sync.
2327
+ </div>
2328
+ </div>
2329
+ {:else}
2330
+ <!-- Link Device -->
2331
+ <div
2332
+ style="border-radius: 0.375rem; border: 1px solid #E91315; background: linear-gradient(to bottom right, #ffffff, #FFE4AE); padding: 0.75rem;"
2333
+ >
2334
+ <div
2335
+ style="font-size: 0.65rem; font-weight: 700; color: #E91315; text-transform: uppercase; letter-spacing: 0.05em; font-family: 'DM Sans', sans-serif; margin-bottom: 0.5rem;"
2336
+ >
2337
+ Link Another Device
2338
+ </div>
2339
+ {#if !linkDeviceReady}
2340
+ <div
2341
+ style="margin-bottom: 0.5rem; font-size: 0.65rem; color: #b45309; font-family: 'DM Sans', sans-serif; line-height: 1.35; border-radius: 0.375rem; background: rgba(254, 243, 199, 0.9); padding: 0.45rem 0.55rem; border: 1px solid #fbbf24;"
2342
+ >
2343
+ Registry / MultiDeviceManager not ready — button stays disabled until linking
2344
+ can run.
2345
+ </div>
2346
+ {/if}
2347
+ <div style="display: flex; flex-direction: column; gap: 0.5rem;">
2348
+ <input
2349
+ type="text"
2350
+ data-testid="storacha-link-peer-input"
2351
+ class="storacha-link-peer-id-input"
2352
+ bind:value={linkInput}
2353
+ placeholder="Other device’s peer id (12D3KooW…)"
2354
+ autocomplete="off"
2355
+ spellcheck="false"
2356
+ style="width: 100%; border-radius: 0.375rem; border: 1px solid #E91315; background: #ffffff; padding: 0.5rem 0.75rem; font-size: 0.75rem; color: #111827; font-family: 'DM Mono', monospace; outline: none; box-sizing: border-box;"
2357
+ />
2358
+ <button
2359
+ data-testid="storacha-link-device-submit"
2360
+ data-mdm-ready={linkDeviceReady ? 'true' : 'false'}
2361
+ type="button"
2362
+ onclick={handleLinkDevice}
2363
+ disabled={linkDeviceDisabled}
2364
+ title={!linkDeviceReady
2365
+ ? 'Waiting for device registry / MultiDeviceManager'
2366
+ : 'Link using the other device’s peer id'}
2367
+ style="display: flex; width: 100%; align-items: center; justify-content: center; gap: 0.5rem; border-radius: 0.375rem; background-color: #E91315; padding: 0.5rem 1rem; color: #ffffff; border: none; cursor: {linkDeviceDisabled
2368
+ ? 'not-allowed'
2369
+ : 'pointer'}; font-family: 'Epilogue', sans-serif; font-weight: 700; font-size: 0.8rem; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1); opacity: {linkDeviceDisabled
2370
+ ? '0.5'
2371
+ : '1'}; box-sizing: border-box;"
2372
+ >
2373
+ {#if isLinking}
2374
+ <Loader2
2375
+ style="height: 0.875rem; width: 0.875rem; animation: spin 1s linear infinite;"
2376
+ />
2377
+ Linking...
2378
+ {:else}
2379
+ <svg
2380
+ style="height: 0.875rem; width: 0.875rem;"
2381
+ fill="none"
2382
+ viewBox="0 0 24 24"
2383
+ stroke="currentColor"
2384
+ >
2385
+ <path
2386
+ stroke-linecap="round"
2387
+ stroke-linejoin="round"
2388
+ stroke-width="2"
2389
+ d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
2390
+ />
2391
+ </svg>
2392
+ Link Device
2393
+ {/if}
2394
+ </button>
2395
+ </div>
2396
+ {#if linkError}
2397
+ <div
2398
+ style="margin-top: 0.5rem; font-size: 0.75rem; color: #b91c1c; font-family: 'DM Sans', sans-serif;"
2399
+ >
2400
+ {linkError}
2401
+ </div>
2402
+ {/if}
2403
+ </div>
2404
+ {/if}
2405
+
2406
+ {@render linkedDevicesPanel()}
2407
+ </div>
2408
+ {/if}
2409
+ </div>
2410
+ {/if}
2411
+ {/if}
2412
+ </div>
2413
+
2414
+ <style>
2415
+ @keyframes spin {
2416
+ from {
2417
+ transform: rotate(0deg);
2418
+ }
2419
+ to {
2420
+ transform: rotate(360deg);
2421
+ }
2422
+ }
2423
+ @keyframes pulse {
2424
+ 0%,
2425
+ 100% {
2426
+ opacity: 1;
2427
+ }
2428
+ 50% {
2429
+ opacity: 0.5;
2430
+ }
2431
+ }
2432
+
2433
+ .storacha-panel::-webkit-scrollbar {
2434
+ width: 4px;
2435
+ }
2436
+ .storacha-panel::-webkit-scrollbar-track {
2437
+ background: rgba(0, 0, 0, 0.1);
2438
+ border-radius: 2px;
2439
+ }
2440
+ .storacha-panel::-webkit-scrollbar-thumb {
2441
+ background: rgba(0, 0, 0, 0.3);
2442
+ border-radius: 2px;
2443
+ }
2444
+ .storacha-panel::-webkit-scrollbar-thumb:hover {
2445
+ background: rgba(0, 0, 0, 0.4);
2446
+ }
2447
+
2448
+ .storacha-btn-primary:hover:not(:disabled) {
2449
+ background-color: #b91c1c;
2450
+ }
2451
+ .storacha-btn-backup:hover:not(:disabled) {
2452
+ background-color: #eab308;
2453
+ }
2454
+ .storacha-btn-restore:hover:not(:disabled) {
2455
+ background-color: #1d4ed8;
2456
+ }
2457
+ .storacha-btn-icon:hover:not(:disabled) {
2458
+ background-color: rgba(233, 19, 21, 0.1);
2459
+ }
2460
+ .storacha-toggle:hover {
2461
+ background-color: rgba(233, 19, 21, 0.08);
2462
+ }
2463
+ .storacha-textarea:focus {
2464
+ border-color: transparent;
2465
+ box-shadow: 0 0 0 2px #e91315;
2466
+ }
2467
+ </style>