@enbox/browser 0.3.42 → 0.3.44

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 (48) hide show
  1. package/dist/browser.mjs +144 -154
  2. package/dist/browser.mjs.map +4 -4
  3. package/dist/esm/browser-connect-handler.js +17 -20
  4. package/dist/esm/browser-connect-handler.js.map +1 -1
  5. package/dist/esm/dweb-connect-client.js +1 -1
  6. package/dist/esm/dweb-connect-client.js.map +1 -1
  7. package/dist/esm/index.js +21 -1
  8. package/dist/esm/index.js.map +1 -1
  9. package/dist/esm/relay-connect-runner.js +127 -0
  10. package/dist/esm/relay-connect-runner.js.map +1 -0
  11. package/dist/esm/ui/connect-modal.js +961 -0
  12. package/dist/esm/ui/connect-modal.js.map +1 -0
  13. package/dist/esm/ui/qr.js +468 -0
  14. package/dist/esm/ui/qr.js.map +1 -0
  15. package/dist/esm/ui/wallet-icon.js +55 -0
  16. package/dist/esm/ui/wallet-icon.js.map +1 -0
  17. package/dist/esm/ui/wallet-well-known.js +61 -0
  18. package/dist/esm/ui/wallet-well-known.js.map +1 -0
  19. package/dist/types/browser-connect-handler.d.ts +32 -7
  20. package/dist/types/browser-connect-handler.d.ts.map +1 -1
  21. package/dist/types/dweb-connect-client.d.ts +3 -1
  22. package/dist/types/dweb-connect-client.d.ts.map +1 -1
  23. package/dist/types/index.d.ts +14 -2
  24. package/dist/types/index.d.ts.map +1 -1
  25. package/dist/types/relay-connect-runner.d.ts +76 -0
  26. package/dist/types/relay-connect-runner.d.ts.map +1 -0
  27. package/dist/types/ui/connect-modal.d.ts +108 -0
  28. package/dist/types/ui/connect-modal.d.ts.map +1 -0
  29. package/dist/types/ui/qr.d.ts +55 -0
  30. package/dist/types/ui/qr.d.ts.map +1 -0
  31. package/dist/types/ui/wallet-icon.d.ts +13 -0
  32. package/dist/types/ui/wallet-icon.d.ts.map +1 -0
  33. package/dist/types/ui/wallet-well-known.d.ts +33 -0
  34. package/dist/types/ui/wallet-well-known.d.ts.map +1 -0
  35. package/package.json +9 -6
  36. package/src/browser-connect-handler.ts +51 -21
  37. package/src/dweb-connect-client.ts +1 -1
  38. package/src/index.ts +32 -2
  39. package/src/relay-connect-runner.ts +201 -0
  40. package/src/ui/connect-modal.ts +1167 -0
  41. package/src/ui/qr.ts +549 -0
  42. package/src/ui/wallet-icon.ts +63 -0
  43. package/src/ui/wallet-well-known.ts +57 -0
  44. package/dist/esm/ui/wallet-selector.js +0 -585
  45. package/dist/esm/ui/wallet-selector.js.map +0 -1
  46. package/dist/types/ui/wallet-selector.d.ts +0 -44
  47. package/dist/types/ui/wallet-selector.d.ts.map +0 -1
  48. package/src/ui/wallet-selector.ts +0 -658
@@ -0,0 +1,1167 @@
1
+ /**
2
+ * Connect modal — the single surface that owns a whole connect session.
3
+ *
4
+ * One Shadow-DOM dialog whose stage morphs in place through the session:
5
+ * QR (phone path) → pairing code → connected, with the in-browser popup
6
+ * path and the wallet catalog folded in as in-place alternates. The modal
7
+ * never chains into a second modal and stays mounted until the session
8
+ * resolves (connected / denied / cancelled).
9
+ *
10
+ * Layout: three zones —
11
+ *
12
+ * 1. **Header** — requesting app identity + close.
13
+ * 2. **Stage** — a height-stable region where states crossfade: the QR
14
+ * (or deep link on phones), the pairing-code input, progress, success,
15
+ * and error states.
16
+ * 3. **Footer** — the alternate-method link and the wallet switcher
17
+ * disclosure (catalog + custom URL — the entire legacy selector,
18
+ * demoted to an option).
19
+ *
20
+ * The phone path drives the `@enbox/connect` relay transport via
21
+ * {@link runRelayConnect}; the browser path drives the popup transport via
22
+ * the kernel `ConnectClient`. Wallet `connectServerUrl`s are discovered
23
+ * from each wallet's `/.well-known/enbox-connect` document unless
24
+ * overridden.
25
+ *
26
+ * @module
27
+ */
28
+
29
+ import type { WalletOption } from '../browser-connect-handler.js';
30
+ import type { ConnectPermissionRequest, ConnectResult, WalletUriHandoff } from '@enbox/connect';
31
+
32
+ import { ConnectClient } from '@enbox/connect';
33
+
34
+ import { buildWalletIcon } from './wallet-icon.js';
35
+ import {
36
+ collectBrowserClientMetadata,
37
+ PopupClientTransport,
38
+ PopupWindowClosedError,
39
+ } from '../dweb-connect-client.js';
40
+ import { encodeQr, qrToSvg } from './qr.js';
41
+ import { fetchWalletWellKnown, probeWalletWellKnown } from './wallet-well-known.js';
42
+ import { RelayConnectCancelledError, runRelayConnect } from '../relay-connect-runner.js';
43
+
44
+ /** How the user carries out the approval. */
45
+ export type ConnectMethod = 'phone' | 'browser';
46
+
47
+ /** localStorage key remembering the last successful method + wallet. */
48
+ const LAST_CHOICE_STORAGE_KEY = 'enbox:connect:lastChoice';
49
+
50
+ /** Safety margin subtracted from the relay pointer TTL before re-minting. */
51
+ const REMINT_SAFETY_MS = 30_000;
52
+
53
+ /** Minimum delay before an automatic re-mint. */
54
+ const REMINT_MIN_MS = 10_000;
55
+
56
+ /** Pairing code length (matches the wallet's generated PIN). */
57
+ const PIN_LENGTH = 4;
58
+
59
+ /** Options for {@link runConnectModal}. */
60
+ export interface ConnectModalOptions {
61
+ /** Wallet catalog shown in the wallet switcher. */
62
+ wallets?: WalletOption[];
63
+
64
+ /**
65
+ * Explicit wallet URL: hides the catalog switcher and uses this wallet
66
+ * for both methods.
67
+ */
68
+ walletUrl?: string;
69
+
70
+ /** Preferred method on open; overrides the remembered choice. */
71
+ preferredMethod?: ConnectMethod;
72
+
73
+ /**
74
+ * Remember the successful method + wallet in localStorage and pre-shape
75
+ * the next session accordingly.
76
+ * @default true
77
+ */
78
+ rememberChoice?: boolean;
79
+
80
+ /** Display name of the requesting application. */
81
+ appName?: string;
82
+
83
+ /** Icon URL of the requesting application. */
84
+ appIcon?: string;
85
+
86
+ /**
87
+ * Relay base URL override for the phone path. When omitted, each wallet's
88
+ * `connectServerUrl` is discovered from its `/.well-known/enbox-connect`
89
+ * document.
90
+ */
91
+ connectServerUrl?: string;
92
+
93
+ /**
94
+ * Path appended to the wallet origin to form the QR / deep-link target.
95
+ * @default '/connect/app'
96
+ */
97
+ relayWalletPath?: string;
98
+
99
+ /** Timeout in milliseconds for either method's handshake. */
100
+ timeout?: number;
101
+
102
+ /** DWN protocols and permission scopes being requested. */
103
+ permissionRequests: ConnectPermissionRequest[];
104
+
105
+ /** Internal dependency seams (tests). */
106
+ deps?: Partial<ConnectModalDeps>;
107
+ }
108
+
109
+ /** Injectable collaborators, defaulted to the real implementations. */
110
+ export interface ConnectModalDeps {
111
+ /** Runs one relay handshake (phone path). */
112
+ runRelay: typeof runRelayConnect;
113
+
114
+ /** Runs one popup handshake (browser path); must open the popup synchronously. */
115
+ runPopup: (options: {
116
+ walletUrl: string;
117
+ permissionRequests: ConnectPermissionRequest[];
118
+ appName: string;
119
+ appIcon?: string;
120
+ timeoutMs?: number;
121
+ }) => Promise<ConnectResult | undefined>;
122
+
123
+ /** Resolves a wallet origin to its relay `connectServerUrl` (or undefined). */
124
+ discoverConnectServerUrl: (walletOrigin: string) => Promise<string | undefined>;
125
+
126
+ /** Validates a pasted custom wallet URL. */
127
+ validateWalletUrl: (origin: string) => Promise<boolean>;
128
+
129
+ /** Whether to present the deep-link (same-device) variant of the phone path. */
130
+ isMobile: () => boolean;
131
+
132
+ /** Persistence for the remembered choice. */
133
+ storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | undefined;
134
+ }
135
+
136
+ /**
137
+ * Resolves a wallet origin to its relay `connectServerUrl` via the wallet's
138
+ * `/.well-known/enbox-connect` document ({@link fetchWalletWellKnown} — the
139
+ * same machinery the wallet selector uses for custom-URL validation).
140
+ */
141
+ export async function discoverWalletConnectServerUrl(walletOrigin: string): Promise<string | undefined> {
142
+ return (await fetchWalletWellKnown(walletOrigin))?.connectServerUrl;
143
+ }
144
+
145
+ /** Runs one popup handshake without collapsing early-close into a denial. */
146
+ async function runPopupConnect(options: {
147
+ walletUrl: string;
148
+ permissionRequests: ConnectPermissionRequest[];
149
+ appName: string;
150
+ appIcon?: string;
151
+ timeoutMs?: number;
152
+ }): Promise<ConnectResult | undefined> {
153
+ // The transport constructor calls `window.open` synchronously — callers
154
+ // must invoke this inside the user-gesture call stack.
155
+ const transport = new PopupClientTransport({
156
+ walletUrl : options.walletUrl,
157
+ timeoutMs : options.timeoutMs,
158
+ });
159
+ const client = new ConnectClient({ transport });
160
+
161
+ return await client.connect({
162
+ appName : options.appName,
163
+ appIcon : options.appIcon,
164
+ clientMetadata : collectBrowserClientMetadata(),
165
+ permissionRequests : options.permissionRequests,
166
+ });
167
+ }
168
+
169
+ function defaultIsMobile(): boolean {
170
+ if (typeof window === 'undefined') {
171
+ return false;
172
+ }
173
+ const coarse = window.matchMedia?.('(pointer: coarse)').matches ?? false;
174
+ return coarse && Math.min(window.innerWidth, window.innerHeight) < 900;
175
+ }
176
+
177
+ function defaultDeps(): ConnectModalDeps {
178
+ return {
179
+ runRelay : runRelayConnect,
180
+ runPopup : runPopupConnect,
181
+ discoverConnectServerUrl : discoverWalletConnectServerUrl,
182
+ validateWalletUrl : probeWalletWellKnown,
183
+ isMobile : defaultIsMobile,
184
+ storage : typeof localStorage === 'undefined' ? undefined : localStorage,
185
+ };
186
+ }
187
+
188
+ /** Remembered method + wallet from a previous successful session. */
189
+ interface LastChoice {
190
+ method: ConnectMethod;
191
+ walletUrl: string;
192
+ }
193
+
194
+ function readLastChoice(storage: ConnectModalDeps['storage']): LastChoice | undefined {
195
+ try {
196
+ const raw = storage?.getItem(LAST_CHOICE_STORAGE_KEY);
197
+ if (raw == null) {
198
+ return undefined;
199
+ }
200
+ const parsed = JSON.parse(raw) as Partial<LastChoice>;
201
+ if ((parsed.method === 'phone' || parsed.method === 'browser') && typeof parsed.walletUrl === 'string') {
202
+ return { method: parsed.method, walletUrl: parsed.walletUrl };
203
+ }
204
+ } catch { /* unreadable storage — behave as first visit */ }
205
+ return undefined;
206
+ }
207
+
208
+ function writeLastChoice(storage: ConnectModalDeps['storage'], choice: LastChoice): void {
209
+ try {
210
+ storage?.setItem(LAST_CHOICE_STORAGE_KEY, JSON.stringify(choice));
211
+ } catch { /* best effort */ }
212
+ }
213
+
214
+ /** One cancellable relay session. */
215
+ interface RelaySession {
216
+ cancelled: Promise<never>;
217
+ cancel: () => void;
218
+ active: boolean;
219
+ }
220
+
221
+ function createRelaySession(): RelaySession {
222
+ let cancel!: () => void;
223
+ const session: Partial<RelaySession> = { active: true };
224
+ const cancelled = new Promise<never>((_, reject) => {
225
+ cancel = (): void => {
226
+ if (session.active === true) {
227
+ session.active = false;
228
+ reject(new RelayConnectCancelledError());
229
+ }
230
+ };
231
+ });
232
+ // Mark handled so an unconsumed cancellation never surfaces as unhandled.
233
+ cancelled.catch((): undefined => undefined);
234
+ session.cancelled = cancelled;
235
+ session.cancel = cancel;
236
+ return session as RelaySession;
237
+ }
238
+
239
+ /**
240
+ * Opens the connect modal and resolves when the session ends.
241
+ *
242
+ * @returns The delegated credentials; `undefined` when the user denied the
243
+ * request in the wallet (or dismissed after a denial).
244
+ * @throws When the user cancels the modal, or the session fails and the
245
+ * user dismisses the error.
246
+ */
247
+ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectResult | undefined> {
248
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
249
+ throw new Error('[@enbox/browser] The connect modal is only available in browser environments.');
250
+ }
251
+
252
+ const deps: ConnectModalDeps = { ...defaultDeps(), ...options.deps };
253
+ const wallets = options.wallets ?? [];
254
+ const rememberChoice = options.rememberChoice ?? true;
255
+ const appName = options.appName ?? window.location.host;
256
+ const relayWalletPath = options.relayWalletPath ?? '/connect/app';
257
+ const isMobile = deps.isMobile();
258
+
259
+ const lastChoice = rememberChoice ? readLastChoice(deps.storage) : undefined;
260
+
261
+ // Wallet resolution order: explicit option → remembered → catalog head.
262
+ const lockedWallet = options.walletUrl !== undefined;
263
+ let walletUrl = options.walletUrl
264
+ ?? (lastChoice !== undefined && (lockedWallet || walletInCatalog(wallets, lastChoice.walletUrl)) ? lastChoice.walletUrl : undefined)
265
+ ?? wallets[0]?.url;
266
+
267
+ // Method resolution order: explicit option → remembered → phone.
268
+ let method: ConnectMethod = options.preferredMethod ?? lastChoice?.method ?? 'phone';
269
+
270
+ return new Promise<ConnectResult | undefined>((resolve, reject) => {
271
+ // ── Host + shadow root ─────────────────────────────────────
272
+ const host = document.createElement('div');
273
+ host.id = 'enbox-connect-modal';
274
+ const shadow = host.attachShadow({ mode: 'open' });
275
+
276
+ const isDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
277
+ const style = document.createElement('style');
278
+ style.textContent = buildStyles(isDark);
279
+ shadow.appendChild(style);
280
+
281
+ const overlay = document.createElement('div');
282
+ overlay.className = 'overlay';
283
+ const modal = document.createElement('div');
284
+ modal.className = 'modal';
285
+ modal.setAttribute('role', 'dialog');
286
+ modal.setAttribute('aria-modal', 'true');
287
+ modal.setAttribute('aria-label', `Connect to ${appName}`);
288
+
289
+ // ── Session state ──────────────────────────────────────────
290
+ let settled = false;
291
+ let relaySession: RelaySession | undefined;
292
+ let remintTimer: ReturnType<typeof setTimeout> | undefined;
293
+ let popupBusy = false;
294
+ let pinResolve: ((pin: string) => void) | undefined;
295
+ const discoveryCache = new Map<string, Promise<string | undefined>>();
296
+
297
+ const cleanup = (): void => {
298
+ relaySession?.cancel();
299
+ clearTimeout(remintTimer);
300
+ document.removeEventListener('keydown', onKeydown);
301
+ try { document.body.removeChild(host); } catch { /* best effort */ }
302
+ };
303
+
304
+ const settle = (settler: () => void): void => {
305
+ if (settled) { return; }
306
+ settled = true;
307
+ cleanup();
308
+ settler();
309
+ };
310
+
311
+ const succeed = (result: ConnectResult): void => {
312
+ if (rememberChoice) {
313
+ writeLastChoice(deps.storage, { method, walletUrl: walletUrl ?? '' });
314
+ }
315
+ renderConnected();
316
+ const finish = (): void => settle(() => resolve(result));
317
+ setTimeout(finish, 1_200);
318
+ };
319
+
320
+ const cancelModal = (): void => settle(() => reject(new Error('[@enbox/browser] Connect cancelled.')));
321
+
322
+ // ── Zones ──────────────────────────────────────────────────
323
+ modal.appendChild(buildHeader(appName, options.appIcon, cancelModal));
324
+
325
+ const stage = document.createElement('div');
326
+ stage.className = 'stage';
327
+ stage.setAttribute('aria-live', 'polite');
328
+ modal.appendChild(stage);
329
+
330
+ const footer = document.createElement('div');
331
+ footer.className = 'footer';
332
+ modal.appendChild(footer);
333
+
334
+ // ── Stage renderers (all render in place; nothing navigates) ──
335
+ const setStage = (...nodes: (HTMLElement | SVGElement)[]): void => {
336
+ stage.replaceChildren(...nodes);
337
+ stage.classList.remove('fade-in');
338
+ void stage.offsetWidth; // restart the transition
339
+ stage.classList.add('fade-in');
340
+ };
341
+
342
+ const renderBusy = (message: string): void => {
343
+ setStage(el('div', 'spinner'), el('p', 'stage-caption', message));
344
+ };
345
+
346
+ const renderQr = (handoff: WalletUriHandoff): void => {
347
+ if (isMobile) {
348
+ const link = document.createElement('a');
349
+ link.className = 'deep-link';
350
+ link.href = handoff.walletUri;
351
+ link.textContent = 'Continue in your wallet';
352
+ setStage(
353
+ link,
354
+ el('p', 'stage-caption', 'Your wallet opens to approve this connection.'),
355
+ el('p', 'stage-subline', 'Come back here when you’re done.'),
356
+ );
357
+ return;
358
+ }
359
+
360
+ const qrBox = document.createElement('div');
361
+ qrBox.className = 'qr-box';
362
+ qrBox.replaceChildren(qrToSvg(encodeQr(handoff.walletUri), {
363
+ dark : isDark ? '#e8e8f0' : '#14141f',
364
+ light : 'transparent',
365
+ quietZone : 2,
366
+ }));
367
+
368
+ setStage(
369
+ qrBox,
370
+ el('p', 'stage-caption', 'Scan with your phone’s camera'),
371
+ el('p', 'stage-subline', 'Your wallet stays on your phone, so you can approve things anywhere.'),
372
+ );
373
+ };
374
+
375
+ const renderPin = (attempt: number, previousError?: Error): Promise<string> => {
376
+ return new Promise<string>((resolvePin) => {
377
+ pinResolve = resolvePin;
378
+
379
+ const boxes = document.createElement('div');
380
+ boxes.className = 'pin-row';
381
+ if (previousError !== undefined) {
382
+ boxes.classList.add('shake');
383
+ }
384
+
385
+ const inputs: HTMLInputElement[] = [];
386
+ for (let i = 0; i < PIN_LENGTH; i++) {
387
+ const input = document.createElement('input');
388
+ input.className = 'pin-input';
389
+ input.type = 'text';
390
+ input.inputMode = 'numeric';
391
+ input.maxLength = 1;
392
+ input.setAttribute('aria-label', `Code digit ${i + 1}`);
393
+ inputs.push(input);
394
+ boxes.appendChild(input);
395
+ }
396
+
397
+ const submitIfComplete = (): void => {
398
+ const code = inputs.map((i) => i.value).join('');
399
+ if (code.length === PIN_LENGTH && pinResolve !== undefined) {
400
+ const resolveOnce = pinResolve;
401
+ pinResolve = undefined;
402
+ renderBusy('Checking the code…');
403
+ resolveOnce(code);
404
+ }
405
+ };
406
+
407
+ inputs.forEach((input, index) => {
408
+ input.addEventListener('input', () => {
409
+ input.value = input.value.replace(/\D/g, '').slice(0, 1);
410
+ if (input.value !== '' && index + 1 < inputs.length) {
411
+ inputs[index + 1].focus();
412
+ }
413
+ submitIfComplete();
414
+ });
415
+ input.addEventListener('keydown', (event) => {
416
+ if (event.key === 'Backspace' && input.value === '' && index > 0) {
417
+ inputs[index - 1].focus();
418
+ }
419
+ });
420
+ input.addEventListener('paste', (event) => {
421
+ const digits = (event.clipboardData?.getData('text') ?? '').replace(/\D/g, '').slice(0, PIN_LENGTH);
422
+ if (digits.length === 0) { return; }
423
+ event.preventDefault();
424
+ digits.split('').forEach((digit, digitIndex) => {
425
+ if (inputs[digitIndex] !== undefined) {
426
+ inputs[digitIndex].value = digit;
427
+ }
428
+ });
429
+ inputs[Math.min(digits.length, PIN_LENGTH) - 1]?.focus();
430
+ submitIfComplete();
431
+ });
432
+ });
433
+
434
+ setStage(
435
+ el('p', 'stage-caption', 'Enter the code shown on your phone'),
436
+ boxes,
437
+ ...(previousError !== undefined
438
+ ? [el('p', 'stage-error', 'That code doesn’t match — check your phone.')]
439
+ : [el('p', 'stage-subline', attempt === 1 ? 'Phone connected.' : '')]),
440
+ );
441
+
442
+ inputs[0].focus();
443
+ });
444
+ };
445
+
446
+ const renderConnected = (): void => {
447
+ const check = el('div', 'check');
448
+ check.textContent = '✓';
449
+ setStage(check, el('p', 'stage-caption', 'You’re connected.'));
450
+ };
451
+
452
+ const renderDenied = (): void => {
453
+ setStage(
454
+ el('p', 'stage-caption', 'No problem — nothing was shared.'),
455
+ stageButton('Start over', () => { void startMethod(); }),
456
+ stageLinkButton('Close', () => settle(() => resolve(undefined))),
457
+ );
458
+ };
459
+
460
+ const renderError = (message: string, error?: Error): void => {
461
+ setStage(
462
+ el('p', 'stage-caption', message),
463
+ stageButton('Start over', () => { void startMethod(); }),
464
+ stageLinkButton('Close', () => settle(() => reject(error ?? new Error(`[@enbox/browser] ${message}`)))),
465
+ );
466
+ };
467
+
468
+ const renderPhoneUnavailable = (): void => {
469
+ setStage(
470
+ el('p', 'stage-caption', 'This wallet can’t connect by phone here.'),
471
+ el('p', 'stage-subline', 'You can continue in this browser instead.'),
472
+ stageButton('Use this browser', () => { startPopup(); }),
473
+ );
474
+ };
475
+
476
+ const renderPopupPrompt = (): void => {
477
+ setStage(
478
+ el('p', 'stage-caption', 'Connect with a wallet in this browser'),
479
+ el('p', 'stage-subline', 'Your wallet opens in a small window to approve this connection.'),
480
+ stageButton('Open wallet window', () => { startPopup(); }),
481
+ );
482
+ };
483
+
484
+ const renderPopupWaiting = (): void => {
485
+ setStage(
486
+ el('div', 'spinner'),
487
+ el('p', 'stage-caption', 'Finish in the wallet window'),
488
+ el('p', 'stage-subline', 'We’ll wrap up here as soon as you’re done.'),
489
+ );
490
+ };
491
+
492
+ const renderPopupInterrupted = (blocked: boolean): void => {
493
+ setStage(
494
+ el('p', 'stage-caption', blocked
495
+ ? 'Your browser blocked the wallet window.'
496
+ : 'The wallet window was closed.'),
497
+ stageButton(blocked ? 'Open it now' : 'Reopen window', () => { startPopup(); }),
498
+ ...(isMobile ? [] : [stageLinkButton('Use your phone instead', () => { void switchMethod('phone'); })]),
499
+ );
500
+ };
501
+
502
+ // ── Method drivers ─────────────────────────────────────────
503
+ const resolveConnectServerUrl = async (origin: string): Promise<string | undefined> => {
504
+ if (options.connectServerUrl !== undefined) {
505
+ return options.connectServerUrl;
506
+ }
507
+ let cached = discoveryCache.get(origin);
508
+ if (cached === undefined) {
509
+ cached = deps.discoverConnectServerUrl(origin);
510
+ discoveryCache.set(origin, cached);
511
+ }
512
+ return cached;
513
+ };
514
+
515
+ const scheduleRemint = (expiresInSeconds: number): void => {
516
+ clearTimeout(remintTimer);
517
+ const delay = Math.max(REMINT_MIN_MS, expiresInSeconds * 1_000 - REMINT_SAFETY_MS);
518
+ remintTimer = setTimeout(() => {
519
+ // Never re-mint once the wallet has responded (pairing in progress).
520
+ if (!settled && pinResolve === undefined && method === 'phone') {
521
+ void startPhone();
522
+ }
523
+ }, delay);
524
+ };
525
+
526
+ const startPhone = async (): Promise<void> => {
527
+ relaySession?.cancel();
528
+ clearTimeout(remintTimer);
529
+
530
+ if (walletUrl === undefined) {
531
+ renderError('No wallet is configured.');
532
+ return;
533
+ }
534
+
535
+ method = 'phone';
536
+ renderFooter();
537
+ renderBusy('Getting things ready…');
538
+
539
+ const origin = new URL(walletUrl).origin;
540
+ const connectServerUrl = await resolveConnectServerUrl(origin);
541
+ if (connectServerUrl === undefined) {
542
+ renderPhoneUnavailable();
543
+ return;
544
+ }
545
+
546
+ const session = createRelaySession();
547
+ relaySession = session;
548
+
549
+ try {
550
+ const result = await deps.runRelay({
551
+ connectServerUrl,
552
+ walletUri : new URL(relayWalletPath, origin).toString(),
553
+ appName,
554
+ appIcon : options.appIcon,
555
+ clientMetadata : collectBrowserClientMetadata(),
556
+ permissionRequests : options.permissionRequests,
557
+ timeoutMs : options.timeout,
558
+ cancelled : session.cancelled,
559
+ onWalletUriReady : (handoff): void => {
560
+ if (session.active && !settled) {
561
+ renderQr(handoff);
562
+ scheduleRemint(handoff.expiresIn);
563
+ }
564
+ },
565
+ requestPin: (attempt, previousError): Promise<string> => {
566
+ clearTimeout(remintTimer);
567
+ return renderPin(attempt, previousError);
568
+ },
569
+ });
570
+
571
+ if (!session.active || settled) { return; }
572
+
573
+ if (result === undefined) {
574
+ renderDenied();
575
+ } else {
576
+ succeed(result);
577
+ }
578
+ } catch (error) {
579
+ if (!session.active || settled || error instanceof RelayConnectCancelledError) {
580
+ return;
581
+ }
582
+ const failure = error instanceof Error ? error : new Error(String(error));
583
+ renderError(
584
+ /timed out/i.test(failure.message)
585
+ ? 'That took too long, so we stopped for safety.'
586
+ : 'Something went wrong while connecting.',
587
+ failure,
588
+ );
589
+ }
590
+ };
591
+
592
+ const startPopup = (): void => {
593
+ // Synchronous popup open inside the click's call stack.
594
+ relaySession?.cancel();
595
+ clearTimeout(remintTimer);
596
+
597
+ if (walletUrl === undefined) {
598
+ renderError('No wallet is configured.');
599
+ return;
600
+ }
601
+ if (popupBusy) { return; }
602
+
603
+ method = 'browser';
604
+ renderFooter();
605
+
606
+ let flow: Promise<ConnectResult | undefined>;
607
+ try {
608
+ flow = deps.runPopup({
609
+ walletUrl,
610
+ permissionRequests : options.permissionRequests,
611
+ appName,
612
+ appIcon : options.appIcon,
613
+ timeoutMs : options.timeout,
614
+ });
615
+ } catch (error) {
616
+ const blocked = error instanceof Error && /popup blocked/i.test(error.message);
617
+ renderPopupInterrupted(blocked);
618
+ return;
619
+ }
620
+
621
+ popupBusy = true;
622
+ renderPopupWaiting();
623
+
624
+ flow
625
+ .then((result) => {
626
+ if (settled) { return; }
627
+ if (result === undefined) {
628
+ renderDenied();
629
+ } else {
630
+ succeed(result);
631
+ }
632
+ })
633
+ .catch((error: unknown) => {
634
+ if (settled) { return; }
635
+ if (error instanceof PopupWindowClosedError) {
636
+ renderPopupInterrupted(false);
637
+ return;
638
+ }
639
+ const failure = error instanceof Error ? error : new Error(String(error));
640
+ renderError(
641
+ /timed out/i.test(failure.message)
642
+ ? 'That took too long, so we stopped for safety.'
643
+ : 'Something went wrong while connecting.',
644
+ failure,
645
+ );
646
+ })
647
+ .finally(() => { popupBusy = false; });
648
+ };
649
+
650
+ const startMethod = async (): Promise<void> => {
651
+ if (method === 'browser') {
652
+ // window.open must stay inside a user gesture — prompt for the click.
653
+ relaySession?.cancel();
654
+ clearTimeout(remintTimer);
655
+ renderFooter();
656
+ renderPopupPrompt();
657
+ } else {
658
+ await startPhone();
659
+ }
660
+ };
661
+
662
+ const switchMethod = async (next: ConnectMethod): Promise<void> => {
663
+ method = next;
664
+ await startMethod();
665
+ };
666
+
667
+ // ── Footer: method link + wallet switcher ──────────────────
668
+ const renderFooter = (): void => {
669
+ footer.replaceChildren();
670
+
671
+ if (method === 'phone') {
672
+ const alt = document.createElement('button');
673
+ alt.className = 'footer-link method-link';
674
+ alt.textContent = 'No phone handy? Use this browser →';
675
+ // startPopup runs synchronously in this click handler.
676
+ alt.addEventListener('click', () => { startPopup(); });
677
+ footer.appendChild(alt);
678
+ } else if (!isMobile) {
679
+ const alt = document.createElement('button');
680
+ alt.className = 'footer-link method-link';
681
+ alt.textContent = 'Use your phone instead →';
682
+ alt.addEventListener('click', () => { void switchMethod('phone'); });
683
+ footer.appendChild(alt);
684
+ }
685
+
686
+ if (!lockedWallet && wallets.length > 0) {
687
+ footer.appendChild(buildWalletSwitcher());
688
+ }
689
+ };
690
+
691
+ const buildWalletSwitcher = (): HTMLElement => {
692
+ const wrap = document.createElement('div');
693
+ wrap.className = 'wallet-switcher';
694
+
695
+ const current = wallets.find((wallet) => wallet.url === walletUrl);
696
+ const toggle = document.createElement('button');
697
+ toggle.className = 'footer-link wallet-toggle';
698
+ toggle.setAttribute('aria-expanded', 'false');
699
+ toggle.textContent = `Wallet: ${current?.name ?? walletUrl ?? 'Choose'} ▾`;
700
+
701
+ const panel = document.createElement('div');
702
+ panel.className = 'wallet-panel';
703
+ panel.hidden = true;
704
+
705
+ // Search filter — only worth the chrome once the catalog grows.
706
+ const rows: Array<{ wallet: (typeof wallets)[number]; el: HTMLButtonElement }> = [];
707
+ const empty = document.createElement('div');
708
+ empty.className = 'wallet-empty';
709
+ empty.textContent = 'No wallets match your search.';
710
+ empty.hidden = true;
711
+
712
+ if (wallets.length > 5) {
713
+ const search = document.createElement('input');
714
+ search.type = 'search';
715
+ search.className = 'wallet-search';
716
+ search.placeholder = 'Search wallets…';
717
+ search.addEventListener('input', () => {
718
+ const query = search.value.trim().toLowerCase();
719
+ let visible = 0;
720
+ for (const { wallet, el } of rows) {
721
+ const match = query === ''
722
+ || wallet.name.toLowerCase().includes(query)
723
+ || wallet.url.toLowerCase().includes(query);
724
+ el.hidden = !match;
725
+ if (match) { visible += 1; }
726
+ }
727
+ empty.hidden = visible > 0;
728
+ });
729
+ panel.appendChild(search);
730
+ }
731
+
732
+ for (const wallet of wallets) {
733
+ const row = document.createElement('button');
734
+ row.className = 'wallet-row';
735
+ row.appendChild(buildWalletIcon(wallet));
736
+ const rowName = document.createElement('span');
737
+ rowName.className = 'wallet-row-name';
738
+ rowName.textContent = wallet.name;
739
+ row.appendChild(rowName);
740
+ if (wallet.url === walletUrl) {
741
+ row.classList.add('selected');
742
+ }
743
+ row.addEventListener('click', () => {
744
+ walletUrl = wallet.url;
745
+ renderFooter();
746
+ void startMethod();
747
+ });
748
+ rows.push({ wallet, el: row });
749
+ panel.appendChild(row);
750
+ }
751
+ panel.appendChild(empty);
752
+
753
+ // Custom wallet URL (the power-user path, demoted to the disclosure).
754
+ const custom = document.createElement('div');
755
+ custom.className = 'wallet-custom';
756
+ const input = document.createElement('input');
757
+ input.className = 'url-input';
758
+ input.type = 'url';
759
+ input.placeholder = 'https://your-wallet.example';
760
+ const go = document.createElement('button');
761
+ go.className = 'url-go';
762
+ go.textContent = 'Use';
763
+ const urlError = document.createElement('div');
764
+ urlError.className = 'url-error';
765
+ urlError.hidden = true;
766
+
767
+ // When validation fails we let the user override, since the connect
768
+ // handshake is the ultimate gate — a valid wallet behind strict CORS
769
+ // should not be permanently blocked.
770
+ let allowUnverified = false;
771
+
772
+ const useWallet = (origin: string): void => {
773
+ walletUrl = origin;
774
+ renderFooter();
775
+ void startMethod();
776
+ };
777
+
778
+ const submitCustom = async (): Promise<void> => {
779
+ const value = input.value.trim();
780
+ if (value === '') { return; }
781
+ let origin: string;
782
+ try {
783
+ origin = new URL(value.includes('://') ? value : `https://${value}`).origin;
784
+ } catch {
785
+ input.classList.add('invalid');
786
+ return;
787
+ }
788
+ if (allowUnverified) {
789
+ useWallet(origin);
790
+ return;
791
+ }
792
+ go.disabled = true;
793
+ go.textContent = 'Checking…';
794
+ urlError.hidden = true;
795
+ const valid = await deps.validateWalletUrl(origin);
796
+ go.disabled = false;
797
+ if (valid) {
798
+ go.textContent = 'Use';
799
+ useWallet(origin);
800
+ return;
801
+ }
802
+ allowUnverified = true;
803
+ go.textContent = 'Use anyway';
804
+ input.classList.add('invalid');
805
+ urlError.textContent = `Couldn't verify a wallet at ${origin}.`;
806
+ urlError.hidden = false;
807
+ };
808
+
809
+ input.addEventListener('input', () => {
810
+ allowUnverified = false;
811
+ go.textContent = 'Use';
812
+ input.classList.remove('invalid');
813
+ urlError.hidden = true;
814
+ });
815
+ input.addEventListener('keydown', (e) => {
816
+ if (e.key === 'Enter') { void submitCustom(); }
817
+ });
818
+ go.addEventListener('click', () => { void submitCustom(); });
819
+ custom.appendChild(input);
820
+ custom.appendChild(go);
821
+ panel.appendChild(custom);
822
+ panel.appendChild(urlError);
823
+
824
+ toggle.addEventListener('click', () => {
825
+ panel.hidden = !panel.hidden;
826
+ toggle.setAttribute('aria-expanded', panel.hidden ? 'false' : 'true');
827
+ });
828
+
829
+ wrap.appendChild(toggle);
830
+ wrap.appendChild(panel);
831
+ return wrap;
832
+ };
833
+
834
+ // ── Global interactions ────────────────────────────────────
835
+ overlay.addEventListener('click', (event) => {
836
+ if (event.target === overlay) {
837
+ cancelModal();
838
+ }
839
+ });
840
+
841
+ const onKeydown = (event: KeyboardEvent): void => {
842
+ if (event.key === 'Escape') {
843
+ cancelModal();
844
+ return;
845
+ }
846
+ if (event.key === 'Tab') {
847
+ trapFocus(shadow, event);
848
+ }
849
+ };
850
+ document.addEventListener('keydown', onKeydown);
851
+
852
+ overlay.appendChild(modal);
853
+ shadow.appendChild(overlay);
854
+ document.body.appendChild(host);
855
+
856
+ renderFooter();
857
+ void startMethod();
858
+ });
859
+ }
860
+
861
+ // ─── Small DOM helpers ───────────────────────────────────────────
862
+
863
+ function walletInCatalog(wallets: WalletOption[], url: string): boolean {
864
+ return wallets.some((wallet) => wallet.url === url);
865
+ }
866
+
867
+ function el(tag: string, className: string, text?: string): HTMLElement {
868
+ const node = document.createElement(tag);
869
+ node.className = className;
870
+ if (text !== undefined) {
871
+ node.textContent = text;
872
+ }
873
+ return node;
874
+ }
875
+
876
+ function stageButton(label: string, onClick: () => void): HTMLButtonElement {
877
+ const button = document.createElement('button');
878
+ button.className = 'stage-btn';
879
+ button.textContent = label;
880
+ button.addEventListener('click', onClick);
881
+ return button;
882
+ }
883
+
884
+ function stageLinkButton(label: string, onClick: () => void): HTMLButtonElement {
885
+ const button = document.createElement('button');
886
+ button.className = 'footer-link';
887
+ button.textContent = label;
888
+ button.addEventListener('click', onClick);
889
+ return button;
890
+ }
891
+
892
+ function buildHeader(appName: string, appIcon: string | undefined, onCancel: () => void): HTMLDivElement {
893
+ const header = document.createElement('div');
894
+ header.className = 'header';
895
+
896
+ const identity = document.createElement('div');
897
+ identity.className = 'header-identity';
898
+
899
+ if (appIcon !== undefined) {
900
+ const icon = document.createElement('img');
901
+ icon.className = 'header-icon';
902
+ icon.src = appIcon;
903
+ icon.alt = '';
904
+ icon.width = 24;
905
+ icon.height = 24;
906
+ icon.addEventListener('error', () => icon.remove());
907
+ identity.appendChild(icon);
908
+ }
909
+
910
+ const title = document.createElement('h2');
911
+ title.textContent = `Connect to ${appName}`;
912
+ identity.appendChild(title);
913
+
914
+ const closeBtn = document.createElement('button');
915
+ closeBtn.className = 'close-btn';
916
+ closeBtn.setAttribute('aria-label', 'Close');
917
+ closeBtn.textContent = '\u00d7';
918
+ closeBtn.addEventListener('click', onCancel);
919
+
920
+ header.appendChild(identity);
921
+ header.appendChild(closeBtn);
922
+ return header;
923
+ }
924
+
925
+ function trapFocus(shadow: ShadowRoot, event: KeyboardEvent): void {
926
+ const focusables = Array.from(
927
+ shadow.querySelectorAll<HTMLElement>('button, input, a[href], [tabindex]:not([tabindex="-1"])'),
928
+ ).filter((node) => !node.hasAttribute('disabled') && node.offsetParent !== null);
929
+
930
+ if (focusables.length === 0) {
931
+ return;
932
+ }
933
+
934
+ const first = focusables[0];
935
+ const last = focusables[focusables.length - 1];
936
+ const active = shadow.activeElement as HTMLElement | null;
937
+
938
+ if (event.shiftKey && (active === first || active === null)) {
939
+ event.preventDefault();
940
+ last.focus();
941
+ } else if (!event.shiftKey && active === last) {
942
+ event.preventDefault();
943
+ first.focus();
944
+ }
945
+ }
946
+
947
+ // ─── Styles ──────────────────────────────────────────────────────
948
+
949
+ function buildStyles(isDark: boolean): string {
950
+ const bg = isDark ? '#1a1a2e' : '#ffffff';
951
+ const text = isDark ? '#e0e0e0' : '#1a1a2e';
952
+ const muted = isDark ? '#888' : '#666';
953
+ const border = isDark ? '#333' : '#e0e0e0';
954
+ const itemBg = isDark ? '#16213e' : '#f8f9fa';
955
+ const accent = isDark ? '#4a9eff' : '#0066cc';
956
+ const accentSoft = isDark ? 'rgba(74, 158, 255, 0.16)' : 'rgba(0, 102, 204, 0.1)';
957
+ const danger = isDark ? '#ff6b6b' : '#c0392b';
958
+ const success = isDark ? '#43d17c' : '#1e9e5a';
959
+
960
+ return `
961
+ * { box-sizing: border-box; margin: 0; padding: 0; }
962
+
963
+ .overlay {
964
+ position: fixed;
965
+ inset: 0;
966
+ z-index: 2147483647;
967
+ display: flex;
968
+ align-items: center;
969
+ justify-content: center;
970
+ background: rgba(0, 0, 0, 0.5);
971
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
972
+ animation: fadeIn 0.15s ease-out;
973
+ }
974
+
975
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
976
+ @keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
977
+ @keyframes stageIn { from { opacity: 0; } to { opacity: 1; } }
978
+ @keyframes shake {
979
+ 0%, 100% { transform: translateX(0); }
980
+ 20%, 60% { transform: translateX(-6px); }
981
+ 40%, 80% { transform: translateX(6px); }
982
+ }
983
+ @keyframes spin { to { transform: rotate(360deg); } }
984
+
985
+ .modal {
986
+ background: ${bg};
987
+ color: ${text};
988
+ border-radius: 16px;
989
+ width: 400px;
990
+ max-width: 92vw;
991
+ max-height: 88vh;
992
+ overflow-y: auto;
993
+ padding: 20px 24px 16px;
994
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
995
+ animation: slideUp 0.2s ease-out;
996
+ display: flex;
997
+ flex-direction: column;
998
+ }
999
+
1000
+ .header { display: flex; justify-content: space-between; align-items: center; }
1001
+ .header-identity { display: flex; align-items: center; gap: 10px; min-width: 0; }
1002
+ .header-icon { border-radius: 6px; }
1003
+ h2 { font-size: 16px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1004
+
1005
+ .close-btn {
1006
+ background: none; border: none; font-size: 24px; cursor: pointer;
1007
+ color: ${muted}; padding: 4px 8px; border-radius: 8px; line-height: 1;
1008
+ }
1009
+ .close-btn:hover { color: ${text}; background: ${itemBg}; }
1010
+
1011
+ .stage {
1012
+ min-height: 300px;
1013
+ display: flex;
1014
+ flex-direction: column;
1015
+ align-items: center;
1016
+ justify-content: center;
1017
+ gap: 12px;
1018
+ text-align: center;
1019
+ padding: 16px 0;
1020
+ }
1021
+ .stage.fade-in > * { animation: stageIn 0.15s ease-out; }
1022
+
1023
+ .qr-box {
1024
+ width: 216px;
1025
+ height: 216px;
1026
+ padding: 12px;
1027
+ border-radius: 12px;
1028
+ background: ${isDark ? '#10192e' : '#ffffff'};
1029
+ border: 1px solid ${border};
1030
+ }
1031
+ .qr-box svg { width: 100%; height: 100%; display: block; }
1032
+
1033
+ .stage-caption { font-size: 15px; font-weight: 600; }
1034
+ .stage-subline { font-size: 13px; color: ${muted}; max-width: 300px; line-height: 1.45; }
1035
+ .stage-error { font-size: 13px; color: ${danger}; }
1036
+
1037
+ .deep-link {
1038
+ display: block;
1039
+ width: 100%;
1040
+ padding: 14px 16px;
1041
+ border-radius: 12px;
1042
+ background: ${accent};
1043
+ color: #fff;
1044
+ font-weight: 600;
1045
+ font-size: 15px;
1046
+ text-decoration: none;
1047
+ }
1048
+
1049
+ .pin-row { display: flex; gap: 10px; }
1050
+ .pin-row.shake { animation: shake 0.4s ease-in-out; }
1051
+ .pin-input {
1052
+ width: 48px; height: 56px;
1053
+ text-align: center;
1054
+ font-size: 24px; font-weight: 600;
1055
+ color: ${text};
1056
+ background: ${itemBg};
1057
+ border: 1px solid ${border};
1058
+ border-radius: 10px;
1059
+ outline: none;
1060
+ }
1061
+ .pin-input:focus { border-color: ${accent}; }
1062
+
1063
+ .check {
1064
+ width: 56px; height: 56px;
1065
+ display: flex; align-items: center; justify-content: center;
1066
+ border-radius: 50%;
1067
+ background: ${success};
1068
+ color: #fff;
1069
+ font-size: 28px;
1070
+ }
1071
+
1072
+ .spinner {
1073
+ width: 32px; height: 32px;
1074
+ border-radius: 50%;
1075
+ border: 3px solid ${border};
1076
+ border-top-color: ${accent};
1077
+ animation: spin 0.8s linear infinite;
1078
+ }
1079
+
1080
+ .stage-btn {
1081
+ padding: 10px 20px;
1082
+ border: none;
1083
+ border-radius: 10px;
1084
+ background: ${accent};
1085
+ color: #fff;
1086
+ font-size: 14px;
1087
+ font-weight: 500;
1088
+ cursor: pointer;
1089
+ }
1090
+ .stage-btn:hover { filter: brightness(1.1); }
1091
+
1092
+ .footer {
1093
+ border-top: 1px solid ${border};
1094
+ padding-top: 10px;
1095
+ display: flex;
1096
+ flex-direction: column;
1097
+ gap: 6px;
1098
+ align-items: center;
1099
+ }
1100
+
1101
+ .footer-link {
1102
+ background: none;
1103
+ border: none;
1104
+ color: ${muted};
1105
+ font-size: 13px;
1106
+ cursor: pointer;
1107
+ padding: 4px 8px;
1108
+ border-radius: 8px;
1109
+ }
1110
+ .footer-link:hover { color: ${text}; background: ${itemBg}; }
1111
+ .method-link { color: ${accent}; }
1112
+
1113
+ .wallet-switcher { width: 100%; display: flex; flex-direction: column; align-items: center; }
1114
+ .wallet-panel { width: 100%; padding: 6px 0; display: flex; flex-direction: column; gap: 4px; }
1115
+ .wallet-row {
1116
+ width: 100%;
1117
+ text-align: left;
1118
+ padding: 8px 12px;
1119
+ background: ${itemBg};
1120
+ border: 1px solid ${border};
1121
+ border-radius: 10px;
1122
+ color: ${text};
1123
+ font-size: 13px;
1124
+ cursor: pointer;
1125
+ }
1126
+ .wallet-row:hover, .wallet-row.selected { border-color: ${accent}; }
1127
+
1128
+ .wallet-icon { position: relative; width: 28px; height: 28px; flex-shrink: 0; display: inline-flex; }
1129
+ .wallet-badge {
1130
+ width: 28px; height: 28px; border-radius: 8px; display: inline-flex;
1131
+ align-items: center; justify-content: center; font-size: 13px;
1132
+ font-weight: 600; background: ${accentSoft}; color: ${accent};
1133
+ }
1134
+ .wallet-img { position: absolute; inset: 0; width: 28px; height: 28px; border-radius: 8px; object-fit: cover; }
1135
+ .wallet-row-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1136
+ .wallet-search {
1137
+ width: 100%; box-sizing: border-box; padding: 8px 10px; margin-bottom: 4px;
1138
+ border-radius: 8px; border: 1px solid ${border}; background: transparent;
1139
+ color: inherit; font: inherit;
1140
+ }
1141
+ .wallet-search:focus { outline: none; border-color: ${accent}; }
1142
+ .wallet-empty { padding: 8px 10px; font-size: 12px; opacity: 0.7; }
1143
+ .url-error { padding: 4px 2px 0; font-size: 12px; color: ${danger}; }
1144
+ .wallet-custom { display: flex; gap: 6px; margin-top: 4px; }
1145
+ .url-input {
1146
+ flex: 1;
1147
+ padding: 8px 12px;
1148
+ border: 1px solid ${border};
1149
+ border-radius: 10px;
1150
+ font-size: 13px;
1151
+ background: ${itemBg};
1152
+ color: ${text};
1153
+ outline: none;
1154
+ }
1155
+ .url-input:focus { border-color: ${accent}; }
1156
+ .url-input.invalid { border-color: ${danger}; }
1157
+ .url-go {
1158
+ padding: 8px 14px;
1159
+ border: none;
1160
+ border-radius: 10px;
1161
+ background: ${accent};
1162
+ color: #fff;
1163
+ font-size: 13px;
1164
+ cursor: pointer;
1165
+ }
1166
+ `;
1167
+ }