@enbox/browser 0.3.48 → 0.3.51
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.
- package/dist/browser.mjs +58 -46
- package/dist/browser.mjs.map +3 -3
- package/dist/esm/browser-connect-handler.js +10 -0
- package/dist/esm/browser-connect-handler.js.map +1 -1
- package/dist/esm/dweb-connect-client.js +64 -9
- package/dist/esm/dweb-connect-client.js.map +1 -1
- package/dist/esm/dweb-connect-messages.js +7 -1
- package/dist/esm/dweb-connect-messages.js.map +1 -1
- package/dist/esm/dweb-connect-wallet.js +48 -2
- package/dist/esm/dweb-connect-wallet.js.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/relay-connect-runner.js +35 -2
- package/dist/esm/relay-connect-runner.js.map +1 -1
- package/dist/esm/ui/connect-modal.js +220 -101
- package/dist/esm/ui/connect-modal.js.map +1 -1
- package/dist/types/browser-connect-handler.d.ts.map +1 -1
- package/dist/types/dweb-connect-client.d.ts +9 -0
- package/dist/types/dweb-connect-client.d.ts.map +1 -1
- package/dist/types/dweb-connect-messages.d.ts +14 -1
- package/dist/types/dweb-connect-messages.d.ts.map +1 -1
- package/dist/types/dweb-connect-wallet.d.ts +23 -1
- package/dist/types/dweb-connect-wallet.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/relay-connect-runner.d.ts +11 -1
- package/dist/types/relay-connect-runner.d.ts.map +1 -1
- package/dist/types/ui/connect-modal.d.ts +11 -10
- package/dist/types/ui/connect-modal.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/browser-connect-handler.ts +10 -0
- package/src/dweb-connect-client.ts +61 -7
- package/src/dweb-connect-messages.ts +16 -1
- package/src/dweb-connect-wallet.ts +50 -1
- package/src/index.ts +2 -0
- package/src/relay-connect-runner.ts +42 -2
- package/src/ui/connect-modal.ts +246 -110
package/src/ui/connect-modal.ts
CHANGED
|
@@ -2,21 +2,22 @@
|
|
|
2
2
|
* Connect modal — the single surface that owns a whole connect session.
|
|
3
3
|
*
|
|
4
4
|
* One Shadow-DOM dialog whose stage morphs in place through the session:
|
|
5
|
-
* QR (phone path) → pairing code → connected, with the
|
|
6
|
-
* path and the wallet catalog folded in as in-place
|
|
7
|
-
* never chains into a second modal and stays mounted
|
|
8
|
-
* resolves (connected / denied / cancelled).
|
|
5
|
+
* QR / deep-link handoff (phone path) → pairing code → connected, with the
|
|
6
|
+
* in-browser popup path and the wallet catalog folded in as in-place
|
|
7
|
+
* alternates. The modal never chains into a second modal and stays mounted
|
|
8
|
+
* until the session resolves (connected / denied / cancelled).
|
|
9
9
|
*
|
|
10
10
|
* Layout: three zones —
|
|
11
11
|
*
|
|
12
12
|
* 1. **Header** — requesting app identity + close.
|
|
13
|
-
* 2. **Stage** — a height-stable region where states crossfade: the
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* 2. **Stage** — a height-stable region where states crossfade: the
|
|
14
|
+
* clickable QR (joined by a Continue deep-link button on phones; both
|
|
15
|
+
* open the wallet in a new tab so this session stays alive underneath),
|
|
16
|
+
* the pairing-code input, progress, success, and error states.
|
|
16
17
|
* 3. **Footer** — the wallet identity row ("Connecting with" + the selected
|
|
17
|
-
* wallet and the next catalog wallets as tiles,
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* wallet and the next catalog wallets as tiles), a More tile that expands
|
|
19
|
+
* the remaining wallets as a matching grid (search + custom URL) in
|
|
20
|
+
* place — a wallet is never shown twice — and the alternate-method link.
|
|
20
21
|
*
|
|
21
22
|
* Theming: the stylesheet is static and reads `--ec-*` design tokens.
|
|
22
23
|
* Light/dark follows the visitor's system via `prefers-color-scheme` and
|
|
@@ -57,8 +58,16 @@ export type ConnectMethod = 'phone' | 'browser';
|
|
|
57
58
|
/** localStorage key remembering the last successful method + wallet. */
|
|
58
59
|
const LAST_CHOICE_STORAGE_KEY = 'enbox:connect:lastChoice';
|
|
59
60
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Safety margin subtracted from the relay pointer TTL before re-minting.
|
|
63
|
+
*
|
|
64
|
+
* The pointer is single-use and its TTL starts at mint time, but the wallet
|
|
65
|
+
* only dereferences it after the user scans — and, on a locked wallet, after
|
|
66
|
+
* they finish the unlock ceremony. A scanned code must therefore carry
|
|
67
|
+
* enough remaining life to absorb that whole window, so re-mint well before
|
|
68
|
+
* the deadline rather than just ahead of it.
|
|
69
|
+
*/
|
|
70
|
+
const REMINT_SAFETY_MS = 120_000;
|
|
62
71
|
|
|
63
72
|
/** Minimum delay before an automatic re-mint. */
|
|
64
73
|
const REMINT_MIN_MS = 10_000;
|
|
@@ -418,6 +427,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
418
427
|
let settled = false;
|
|
419
428
|
let relaySession: RelaySession | undefined;
|
|
420
429
|
let remintTimer: ReturnType<typeof setTimeout> | undefined;
|
|
430
|
+
let remintAt: number | undefined;
|
|
421
431
|
let popupBusy = false;
|
|
422
432
|
let pinResolve: ((pin: string) => void) | undefined;
|
|
423
433
|
const discoveryCache = new Map<string, Promise<string | undefined>>();
|
|
@@ -453,8 +463,9 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
453
463
|
|
|
454
464
|
const cleanup = (): void => {
|
|
455
465
|
relaySession?.cancel();
|
|
456
|
-
|
|
466
|
+
clearRemint();
|
|
457
467
|
document.removeEventListener('keydown', onKeydown);
|
|
468
|
+
document.removeEventListener('visibilitychange', onVisibilityReturn);
|
|
458
469
|
systemDark?.removeEventListener?.('change', onSchemeChange);
|
|
459
470
|
try { document.body.removeChild(host); } catch { /* best effort */ }
|
|
460
471
|
};
|
|
@@ -501,31 +512,52 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
501
512
|
setStage(el('div', 'spinner'), el('p', 'stage-caption', message));
|
|
502
513
|
};
|
|
503
514
|
|
|
515
|
+
/**
|
|
516
|
+
* Schedules the "finish in the wallet" morph after a handoff link is
|
|
517
|
+
* followed. Deferred one tick so the anchor's default new-tab navigation
|
|
518
|
+
* completes before the anchor is replaced, and guarded so a session that
|
|
519
|
+
* moved on meanwhile (re-mint, wallet response, settle) is never
|
|
520
|
+
* overwritten.
|
|
521
|
+
*/
|
|
522
|
+
const morphToAway = (walletName: string): void => {
|
|
523
|
+
const session = relaySession;
|
|
524
|
+
setTimeout((): void => {
|
|
525
|
+
if (settled || session !== relaySession || session?.active !== true || pinResolve !== undefined) {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
renderAway(walletName);
|
|
529
|
+
}, 0);
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Builds one same-device handoff anchor for the wallet URI. The wallet
|
|
534
|
+
* opens in a new tab so this modal — and the relay session under it —
|
|
535
|
+
* stays alive, with the pairing-code entry waiting when the user
|
|
536
|
+
* switches back. `noopener` keeps the wallet tab from reaching back into
|
|
537
|
+
* this one.
|
|
538
|
+
*/
|
|
539
|
+
const buildHandoffLink = (handoff: WalletUriHandoff, walletName: string, className: string): HTMLAnchorElement => {
|
|
540
|
+
const link = document.createElement('a');
|
|
541
|
+
link.className = className;
|
|
542
|
+
link.href = handoff.walletUri;
|
|
543
|
+
link.target = '_blank';
|
|
544
|
+
link.rel = 'noopener noreferrer';
|
|
545
|
+
link.addEventListener('click', () => { morphToAway(walletName); });
|
|
546
|
+
return link;
|
|
547
|
+
};
|
|
548
|
+
|
|
504
549
|
const renderQr = (handoff: WalletUriHandoff): void => {
|
|
505
550
|
const wallet = walletUrl !== undefined ? walletByUrl(walletUrl) : undefined;
|
|
506
551
|
const walletName = wallet?.name ?? 'your wallet';
|
|
507
552
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
link.appendChild(buildWalletIcon(wallet));
|
|
514
|
-
}
|
|
515
|
-
link.appendChild(el('span', 'deep-link-label', `Continue in ${walletName}`));
|
|
516
|
-
setStage(
|
|
517
|
-
link,
|
|
518
|
-
el('p', 'stage-caption', `${walletName} opens to approve this connection.`),
|
|
519
|
-
el('p', 'stage-subline', 'Come back here when you’re done.'),
|
|
520
|
-
);
|
|
521
|
-
return;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
const qrBox = document.createElement('div');
|
|
525
|
-
qrBox.className = 'qr-box';
|
|
553
|
+
// The QR card is itself a handoff link on every device: opening it on
|
|
554
|
+
// this device runs the exact handshake the camera path runs on another
|
|
555
|
+
// one, and scanning it keeps working either way.
|
|
556
|
+
const qrLink = buildHandoffLink(handoff, walletName, 'qr-box qr-link');
|
|
557
|
+
qrLink.setAttribute('aria-label', `Open ${walletName} on this device`);
|
|
526
558
|
// Fixed colors on the white card: dark-on-light QRs scan reliably in
|
|
527
559
|
// both appearances (inverted codes trip up some camera apps).
|
|
528
|
-
|
|
560
|
+
qrLink.replaceChildren(qrToSvg(encodeQr(handoff.walletUri), {
|
|
529
561
|
dark : '#14141f',
|
|
530
562
|
light : 'transparent',
|
|
531
563
|
quietZone : 2,
|
|
@@ -534,33 +566,70 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
534
566
|
// The selected wallet's mark sits at the QR centre — the "this is the
|
|
535
567
|
// wallet you're connecting to" cue. The 44px plate over the ~180px
|
|
536
568
|
// symbol occludes ~5% of modules, well inside ECC level M's 15%
|
|
537
|
-
// recovery budget
|
|
569
|
+
// recovery budget (the compact variant scales the plate down to
|
|
570
|
+
// match).
|
|
538
571
|
if (wallet !== undefined) {
|
|
539
572
|
const logo = el('div', 'qr-logo');
|
|
540
573
|
logo.appendChild(buildWalletIcon(wallet));
|
|
541
|
-
|
|
574
|
+
qrLink.appendChild(logo);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (isMobile) {
|
|
578
|
+
const link = buildHandoffLink(handoff, walletName, 'deep-link');
|
|
579
|
+
if (wallet !== undefined) {
|
|
580
|
+
link.appendChild(buildWalletIcon(wallet));
|
|
581
|
+
}
|
|
582
|
+
link.appendChild(el('span', 'deep-link-label', `Continue in ${walletName}`));
|
|
583
|
+
|
|
584
|
+
qrLink.classList.add('compact');
|
|
585
|
+
setStage(
|
|
586
|
+
link,
|
|
587
|
+
el('p', 'stage-subline', `${walletName} opens in a new tab — approve there, then come back for your code.`),
|
|
588
|
+
el('div', 'stage-divider', 'or scan with another phone'),
|
|
589
|
+
qrLink,
|
|
590
|
+
);
|
|
591
|
+
return;
|
|
542
592
|
}
|
|
543
593
|
|
|
544
594
|
setStage(
|
|
545
|
-
|
|
595
|
+
qrLink,
|
|
546
596
|
el('p', 'stage-caption', 'Scan with your phone’s camera'),
|
|
547
|
-
el('p', 'stage-subline', `${walletName} stays on your phone.`),
|
|
597
|
+
el('p', 'stage-subline', `${walletName} stays on your phone — or click the code to open it here.`),
|
|
598
|
+
);
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Shown once a handoff link is followed on this device: the approval is
|
|
603
|
+
* happening in the wallet tab, this one waits for the code. "Start over"
|
|
604
|
+
* mints a fresh pointer — the right recovery, because the pointer the
|
|
605
|
+
* wallet tab carried away is single-use.
|
|
606
|
+
*/
|
|
607
|
+
const renderAway = (walletName: string): void => {
|
|
608
|
+
setStage(
|
|
609
|
+
el('div', 'spinner'),
|
|
610
|
+
el('p', 'stage-caption', `Finish in ${walletName}`),
|
|
611
|
+
el('p', 'stage-subline', isMobile
|
|
612
|
+
? `Approve the connection in the ${walletName} tab, then come back here for your code.`
|
|
613
|
+
: 'Approve the connection in the tab that just opened, then come back here for your code.'),
|
|
614
|
+
stageLinkButton('Start over', () => { void startPhone(); }),
|
|
548
615
|
);
|
|
549
616
|
};
|
|
550
617
|
|
|
551
618
|
const renderClaimed = (): void => {
|
|
619
|
+
const walletName = walletUrl !== undefined ? walletByUrl(walletUrl).name : 'your wallet';
|
|
552
620
|
const pulse = document.createElement('div');
|
|
553
621
|
pulse.className = 'claimed-pulse';
|
|
554
622
|
setStage(
|
|
555
623
|
pulse,
|
|
556
|
-
el('p', 'stage-caption',
|
|
557
|
-
el('p', 'stage-subline', '
|
|
624
|
+
el('p', 'stage-caption', `Request received — approve in ${walletName}`),
|
|
625
|
+
el('p', 'stage-subline', 'Once you approve, you’ll get a code to enter here.'),
|
|
558
626
|
);
|
|
559
627
|
};
|
|
560
628
|
|
|
561
629
|
const renderPin = (attempt: number, previousError?: Error): Promise<string> => {
|
|
562
630
|
return new Promise<string>((resolvePin) => {
|
|
563
631
|
pinResolve = resolvePin;
|
|
632
|
+
const walletName = walletUrl !== undefined ? walletByUrl(walletUrl).name : 'your wallet';
|
|
564
633
|
|
|
565
634
|
const boxes = document.createElement('div');
|
|
566
635
|
boxes.className = 'pin-row';
|
|
@@ -618,11 +687,11 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
618
687
|
});
|
|
619
688
|
|
|
620
689
|
setStage(
|
|
621
|
-
el('p', 'stage-caption',
|
|
690
|
+
el('p', 'stage-caption', `Enter the code shown in ${walletName}`),
|
|
622
691
|
boxes,
|
|
623
692
|
...(previousError !== undefined
|
|
624
|
-
? [el('p', 'stage-error',
|
|
625
|
-
: [el('p', 'stage-subline', attempt === 1 ? '
|
|
693
|
+
? [el('p', 'stage-error', `That code doesn’t match — check ${walletName}.`)]
|
|
694
|
+
: [el('p', 'stage-subline', attempt === 1 ? 'This confirms you’re the one approving.' : '')]),
|
|
626
695
|
);
|
|
627
696
|
|
|
628
697
|
inputs[0].focus();
|
|
@@ -659,18 +728,24 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
659
728
|
);
|
|
660
729
|
};
|
|
661
730
|
|
|
731
|
+
// The popup surface is a small window on desktop but a new tab on
|
|
732
|
+
// phones — the copy names whichever the visitor will actually see.
|
|
733
|
+
const popupSurface = isMobile ? 'tab' : 'window';
|
|
734
|
+
|
|
662
735
|
const renderPopupPrompt = (): void => {
|
|
663
736
|
setStage(
|
|
664
737
|
el('p', 'stage-caption', 'Connect with a wallet in this browser'),
|
|
665
|
-
el('p', 'stage-subline',
|
|
666
|
-
|
|
738
|
+
el('p', 'stage-subline', isMobile
|
|
739
|
+
? 'Your wallet opens in a new tab to approve this connection — no code needed.'
|
|
740
|
+
: 'Your wallet opens in a small window to approve this connection.'),
|
|
741
|
+
stageButton(isMobile ? 'Open wallet' : 'Open wallet window', () => { startPopup(); }),
|
|
667
742
|
);
|
|
668
743
|
};
|
|
669
744
|
|
|
670
745
|
const renderPopupWaiting = (): void => {
|
|
671
746
|
setStage(
|
|
672
747
|
el('div', 'spinner'),
|
|
673
|
-
el('p', 'stage-caption',
|
|
748
|
+
el('p', 'stage-caption', `Finish in the wallet ${popupSurface}`),
|
|
674
749
|
el('p', 'stage-subline', 'We’ll wrap up here as soon as you’re done.'),
|
|
675
750
|
);
|
|
676
751
|
};
|
|
@@ -678,10 +753,10 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
678
753
|
const renderPopupInterrupted = (blocked: boolean): void => {
|
|
679
754
|
setStage(
|
|
680
755
|
el('p', 'stage-caption', blocked
|
|
681
|
-
?
|
|
682
|
-
:
|
|
683
|
-
stageButton(blocked ? 'Open it now' :
|
|
684
|
-
|
|
756
|
+
? `Your browser blocked the wallet ${popupSurface}.`
|
|
757
|
+
: `The wallet ${popupSurface} was closed.`),
|
|
758
|
+
stageButton(blocked ? 'Open it now' : `Reopen ${popupSurface}`, () => { startPopup(); }),
|
|
759
|
+
stageLinkButton(isMobile ? 'Use a code instead' : 'Use your phone instead', () => { void switchMethod('phone'); }),
|
|
685
760
|
);
|
|
686
761
|
};
|
|
687
762
|
|
|
@@ -698,20 +773,31 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
698
773
|
return cached;
|
|
699
774
|
};
|
|
700
775
|
|
|
701
|
-
const
|
|
776
|
+
const clearRemint = (): void => {
|
|
702
777
|
clearTimeout(remintTimer);
|
|
778
|
+
remintAt = undefined;
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
const remintNow = (): void => {
|
|
782
|
+
// Never re-mint once the wallet has responded (pairing in progress).
|
|
783
|
+
if (!settled && pinResolve === undefined && method === 'phone') {
|
|
784
|
+
void startPhone();
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
const scheduleRemint = (expiresInSeconds: number): void => {
|
|
789
|
+
clearRemint();
|
|
703
790
|
const delay = Math.max(REMINT_MIN_MS, expiresInSeconds * 1_000 - REMINT_SAFETY_MS);
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
}, delay);
|
|
791
|
+
// The deadline is kept alongside the timer: background tabs (mobile
|
|
792
|
+
// especially) throttle or freeze timers, so the visibility handler
|
|
793
|
+
// re-checks it when the user returns.
|
|
794
|
+
remintAt = Date.now() + delay;
|
|
795
|
+
remintTimer = setTimeout(remintNow, delay);
|
|
710
796
|
};
|
|
711
797
|
|
|
712
798
|
const startPhone = async (): Promise<void> => {
|
|
713
799
|
relaySession?.cancel();
|
|
714
|
-
|
|
800
|
+
clearRemint();
|
|
715
801
|
|
|
716
802
|
if (walletUrl === undefined) {
|
|
717
803
|
renderError('No wallet is configured.');
|
|
@@ -750,14 +836,14 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
750
836
|
},
|
|
751
837
|
onClaimed: (): void => {
|
|
752
838
|
if (session.active && !settled) {
|
|
753
|
-
// The
|
|
839
|
+
// The wallet has the request — stop re-minting (a new pointer
|
|
754
840
|
// would orphan the approval in progress) and show progress.
|
|
755
|
-
|
|
841
|
+
clearRemint();
|
|
756
842
|
renderClaimed();
|
|
757
843
|
}
|
|
758
844
|
},
|
|
759
845
|
requestPin: (attempt, previousError): Promise<string> => {
|
|
760
|
-
|
|
846
|
+
clearRemint();
|
|
761
847
|
return renderPin(attempt, previousError);
|
|
762
848
|
},
|
|
763
849
|
});
|
|
@@ -786,7 +872,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
786
872
|
const startPopup = (): void => {
|
|
787
873
|
// Synchronous popup open inside the click's call stack.
|
|
788
874
|
relaySession?.cancel();
|
|
789
|
-
|
|
875
|
+
clearRemint();
|
|
790
876
|
|
|
791
877
|
if (walletUrl === undefined) {
|
|
792
878
|
renderError('No wallet is configured.');
|
|
@@ -845,7 +931,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
845
931
|
if (method === 'browser') {
|
|
846
932
|
// window.open must stay inside a user gesture — prompt for the click.
|
|
847
933
|
relaySession?.cancel();
|
|
848
|
-
|
|
934
|
+
clearRemint();
|
|
849
935
|
renderFooter();
|
|
850
936
|
renderPopupPrompt();
|
|
851
937
|
} else {
|
|
@@ -878,14 +964,16 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
878
964
|
if (method === 'phone') {
|
|
879
965
|
const alt = document.createElement('button');
|
|
880
966
|
alt.className = 'footer-link method-link';
|
|
881
|
-
|
|
967
|
+
// On a phone both methods open the wallet on this device — what the
|
|
968
|
+
// popup channel actually buys the visitor is skipping the code.
|
|
969
|
+
alt.textContent = isMobile ? 'Or connect without a code →' : 'No phone? Use this browser →';
|
|
882
970
|
// startPopup runs synchronously in this click handler.
|
|
883
971
|
alt.addEventListener('click', () => { startPopup(); });
|
|
884
972
|
row.appendChild(alt);
|
|
885
|
-
} else
|
|
973
|
+
} else {
|
|
886
974
|
const alt = document.createElement('button');
|
|
887
975
|
alt.className = 'footer-link method-link';
|
|
888
|
-
alt.textContent = 'Use your phone instead →';
|
|
976
|
+
alt.textContent = isMobile ? 'Use a code instead →' : 'Use your phone instead →';
|
|
889
977
|
alt.addEventListener('click', () => { void switchMethod('phone'); });
|
|
890
978
|
row.appendChild(alt);
|
|
891
979
|
}
|
|
@@ -927,29 +1015,35 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
927
1015
|
panel.className = 'wallet-panel';
|
|
928
1016
|
panel.hidden = true;
|
|
929
1017
|
|
|
930
|
-
|
|
1018
|
+
// The panel grid holds only the wallets NOT already visible in the
|
|
1019
|
+
// row, so expanding never shows the same wallet twice.
|
|
1020
|
+
const gridWallets = wallets.filter((wallet) => !rowWallets.some((shown) => shown.url === wallet.url));
|
|
1021
|
+
|
|
931
1022
|
const more = document.createElement('button');
|
|
932
1023
|
more.className = 'row-tile more-tile';
|
|
933
1024
|
more.title = 'All wallets';
|
|
934
1025
|
more.setAttribute('aria-expanded', 'false');
|
|
935
|
-
more.appendChild(el('span', 'more-count',
|
|
1026
|
+
more.appendChild(el('span', 'more-count', gridWallets.length > 0 ? `+${gridWallets.length}` : '⋯'));
|
|
936
1027
|
more.appendChild(el('span', 'row-tile-name', 'More'));
|
|
937
1028
|
more.addEventListener('click', () => {
|
|
938
1029
|
panel.hidden = !panel.hidden;
|
|
939
1030
|
more.setAttribute('aria-expanded', panel.hidden ? 'false' : 'true');
|
|
1031
|
+
if (!panel.hidden) {
|
|
1032
|
+
updateScrollHint();
|
|
1033
|
+
}
|
|
940
1034
|
});
|
|
941
1035
|
row.appendChild(more);
|
|
942
1036
|
|
|
943
1037
|
const label = rowWallets.length > 0 ? el('div', 'wallet-row-label', 'Connecting with') : undefined;
|
|
944
1038
|
|
|
945
|
-
// Search filter — appears once the
|
|
1039
|
+
// Search filter — appears once the grid outgrows one row of tiles.
|
|
946
1040
|
const tiles: Array<{ wallet: (typeof wallets)[number]; el: HTMLButtonElement }> = [];
|
|
947
1041
|
const empty = document.createElement('div');
|
|
948
1042
|
empty.className = 'wallet-empty';
|
|
949
|
-
empty.textContent = 'No wallets match your search.';
|
|
1043
|
+
empty.textContent = 'No other wallets match your search.';
|
|
950
1044
|
empty.hidden = true;
|
|
951
1045
|
|
|
952
|
-
if (
|
|
1046
|
+
if (gridWallets.length > WALLET_SEARCH_THRESHOLD) {
|
|
953
1047
|
const search = document.createElement('input');
|
|
954
1048
|
search.type = 'search';
|
|
955
1049
|
search.className = 'wallet-search';
|
|
@@ -966,30 +1060,37 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
966
1060
|
if (match) { visible += 1; }
|
|
967
1061
|
}
|
|
968
1062
|
empty.hidden = visible > 0;
|
|
1063
|
+
updateScrollHint();
|
|
969
1064
|
});
|
|
970
1065
|
panel.appendChild(search);
|
|
971
1066
|
}
|
|
972
1067
|
|
|
973
|
-
//
|
|
974
|
-
// place
|
|
1068
|
+
// Tiles matching the identity row, four per row; past three rows the
|
|
1069
|
+
// grid scrolls in place, with a bottom fade hinting at more below.
|
|
1070
|
+
const scrollWrap = document.createElement('div');
|
|
1071
|
+
scrollWrap.className = 'wallet-scroll-wrap';
|
|
1072
|
+
scrollWrap.hidden = gridWallets.length === 0;
|
|
975
1073
|
const scroll = document.createElement('div');
|
|
976
1074
|
scroll.className = 'wallet-scroll';
|
|
977
1075
|
const grid = document.createElement('div');
|
|
978
1076
|
grid.className = 'wallet-grid';
|
|
979
1077
|
|
|
980
|
-
|
|
1078
|
+
const updateScrollHint = (): void => {
|
|
1079
|
+
const overflowing = scroll.scrollHeight - scroll.clientHeight > 1;
|
|
1080
|
+
const atEnd = scroll.scrollTop + scroll.clientHeight >= scroll.scrollHeight - 1;
|
|
1081
|
+
scrollWrap.classList.toggle('scroll-hint', overflowing && !atEnd);
|
|
1082
|
+
};
|
|
1083
|
+
scroll.addEventListener('scroll', updateScrollHint);
|
|
1084
|
+
|
|
1085
|
+
for (const wallet of gridWallets) {
|
|
981
1086
|
const tile = document.createElement('button');
|
|
982
1087
|
tile.className = 'wallet-tile';
|
|
983
|
-
tile.title = wallet.name;
|
|
1088
|
+
tile.title = wallet.description ?? wallet.name;
|
|
984
1089
|
tile.appendChild(buildWalletIcon(wallet));
|
|
985
1090
|
const tileName = document.createElement('span');
|
|
986
1091
|
tileName.className = 'wallet-tile-name';
|
|
987
1092
|
tileName.textContent = wallet.name;
|
|
988
1093
|
tile.appendChild(tileName);
|
|
989
|
-
if (wallet.url === walletUrl) {
|
|
990
|
-
tile.classList.add('selected');
|
|
991
|
-
tile.setAttribute('aria-pressed', 'true');
|
|
992
|
-
}
|
|
993
1094
|
tile.addEventListener('click', () => {
|
|
994
1095
|
selectWallet(wallet.url);
|
|
995
1096
|
});
|
|
@@ -998,7 +1099,8 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
998
1099
|
}
|
|
999
1100
|
scroll.appendChild(grid);
|
|
1000
1101
|
scroll.appendChild(empty);
|
|
1001
|
-
|
|
1102
|
+
scrollWrap.appendChild(scroll);
|
|
1103
|
+
panel.appendChild(scrollWrap);
|
|
1002
1104
|
|
|
1003
1105
|
// Custom wallet URL (the power-user path, demoted to the disclosure).
|
|
1004
1106
|
const custom = document.createElement('div');
|
|
@@ -1086,6 +1188,28 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
1086
1188
|
};
|
|
1087
1189
|
document.addEventListener('keydown', onKeydown);
|
|
1088
1190
|
|
|
1191
|
+
// The visitor typically comes back from the wallet tab mid-session.
|
|
1192
|
+
const onVisibilityReturn = (): void => {
|
|
1193
|
+
if (document.visibilityState !== 'visible' || settled) {
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// Background tabs throttle (or freeze) timers, so a re-mint that came
|
|
1198
|
+
// due while the user was away fires now instead of never.
|
|
1199
|
+
if (remintAt !== undefined && Date.now() >= remintAt) {
|
|
1200
|
+
remintNow();
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Returning with the code in hand: put the caret in the first empty
|
|
1205
|
+
// pairing-code box so they can type it straight away.
|
|
1206
|
+
if (pinResolve !== undefined) {
|
|
1207
|
+
const inputs = Array.from(shadow.querySelectorAll<HTMLInputElement>('.pin-input'));
|
|
1208
|
+
inputs.find((input) => input.value === '')?.focus();
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
1211
|
+
document.addEventListener('visibilitychange', onVisibilityReturn);
|
|
1212
|
+
|
|
1089
1213
|
overlay.appendChild(modal);
|
|
1090
1214
|
shadow.appendChild(overlay);
|
|
1091
1215
|
document.body.appendChild(host);
|
|
@@ -1349,6 +1473,31 @@ const MODAL_STYLES = `
|
|
|
1349
1473
|
.qr-logo .wallet-icon { width: 30px; height: 30px; }
|
|
1350
1474
|
.qr-logo .wallet-badge { border-radius: 8px; font-size: 15px; }
|
|
1351
1475
|
|
|
1476
|
+
/* The QR card doubles as the same-device handoff link. */
|
|
1477
|
+
a.qr-box { display: block; cursor: pointer; }
|
|
1478
|
+
.qr-box.qr-link:hover {
|
|
1479
|
+
border-color: var(--ec-accent);
|
|
1480
|
+
box-shadow: 0 0 0 3px var(--ec-accent-soft);
|
|
1481
|
+
}
|
|
1482
|
+
.qr-box.compact { width: 160px; height: 160px; }
|
|
1483
|
+
.qr-box.compact .qr-logo { width: 36px; height: 36px; }
|
|
1484
|
+
.qr-box.compact .qr-logo .wallet-icon { width: 24px; height: 24px; }
|
|
1485
|
+
|
|
1486
|
+
.stage-divider {
|
|
1487
|
+
display: flex;
|
|
1488
|
+
align-items: center;
|
|
1489
|
+
gap: 10px;
|
|
1490
|
+
width: 100%;
|
|
1491
|
+
color: var(--ec-muted);
|
|
1492
|
+
font-size: 12px;
|
|
1493
|
+
}
|
|
1494
|
+
.stage-divider::before, .stage-divider::after {
|
|
1495
|
+
content: '';
|
|
1496
|
+
flex: 1;
|
|
1497
|
+
height: 1px;
|
|
1498
|
+
background: var(--ec-border);
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1352
1501
|
.claimed-pulse {
|
|
1353
1502
|
width: 64px; height: 64px; border-radius: 50%;
|
|
1354
1503
|
background: var(--ec-accent-soft); position: relative;
|
|
@@ -1465,7 +1614,7 @@ const MODAL_STYLES = `
|
|
|
1465
1614
|
grid-template-columns: repeat(4, 1fr);
|
|
1466
1615
|
gap: 8px;
|
|
1467
1616
|
}
|
|
1468
|
-
.row-tile {
|
|
1617
|
+
.row-tile, .wallet-tile {
|
|
1469
1618
|
display: flex;
|
|
1470
1619
|
flex-direction: column;
|
|
1471
1620
|
align-items: center;
|
|
@@ -1480,15 +1629,15 @@ const MODAL_STYLES = `
|
|
|
1480
1629
|
font-size: 11px;
|
|
1481
1630
|
cursor: pointer;
|
|
1482
1631
|
}
|
|
1483
|
-
.row-tile:hover { border-color: var(--ec-accent); }
|
|
1632
|
+
.row-tile:hover, .wallet-tile:hover { border-color: var(--ec-accent); }
|
|
1484
1633
|
.row-tile.selected {
|
|
1485
1634
|
border-color: var(--ec-accent);
|
|
1486
1635
|
background: var(--ec-accent-soft);
|
|
1487
1636
|
box-shadow: inset 0 0 0 1px var(--ec-accent);
|
|
1488
1637
|
}
|
|
1489
1638
|
.row-tile.selected .row-tile-name { font-weight: 600; }
|
|
1490
|
-
.row-tile .wallet-icon { width: 28px; height: 28px; }
|
|
1491
|
-
.row-tile-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1639
|
+
.row-tile .wallet-icon, .wallet-tile .wallet-icon { width: 28px; height: 28px; }
|
|
1640
|
+
.row-tile-name, .wallet-tile-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1492
1641
|
|
|
1493
1642
|
.more-tile { color: var(--ec-muted); }
|
|
1494
1643
|
.more-tile:hover { color: var(--ec-text); }
|
|
@@ -1514,46 +1663,33 @@ const MODAL_STYLES = `
|
|
|
1514
1663
|
}
|
|
1515
1664
|
.wallet-search:focus { outline: none; border-color: var(--ec-accent); }
|
|
1516
1665
|
|
|
1517
|
-
|
|
1666
|
+
/* Cap the grid at three rows of tiles; past that it scrolls in place,
|
|
1667
|
+
and the wrapper's bottom fade hints that more wallets are below. */
|
|
1668
|
+
.wallet-scroll-wrap { position: relative; }
|
|
1669
|
+
.wallet-scroll { max-height: 220px; overflow-y: auto; }
|
|
1670
|
+
.wallet-scroll-wrap.scroll-hint::after {
|
|
1671
|
+
content: '';
|
|
1672
|
+
position: absolute;
|
|
1673
|
+
left: 0;
|
|
1674
|
+
right: 0;
|
|
1675
|
+
bottom: 0;
|
|
1676
|
+
height: 28px;
|
|
1677
|
+
background: linear-gradient(to bottom, transparent, var(--ec-bg));
|
|
1678
|
+
pointer-events: none;
|
|
1679
|
+
}
|
|
1518
1680
|
.wallet-grid {
|
|
1519
1681
|
display: grid;
|
|
1520
|
-
grid-template-columns: repeat(
|
|
1521
|
-
gap: 8px;
|
|
1522
|
-
}
|
|
1523
|
-
.wallet-tile {
|
|
1524
|
-
aspect-ratio: 1;
|
|
1525
|
-
display: flex;
|
|
1526
|
-
flex-direction: column;
|
|
1527
|
-
align-items: center;
|
|
1528
|
-
justify-content: center;
|
|
1682
|
+
grid-template-columns: repeat(4, 1fr);
|
|
1529
1683
|
gap: 8px;
|
|
1530
|
-
padding: 8px;
|
|
1531
|
-
min-width: 0;
|
|
1532
|
-
background: var(--ec-surface);
|
|
1533
|
-
border: 1px solid var(--ec-border);
|
|
1534
|
-
border-radius: 12px;
|
|
1535
|
-
color: var(--ec-text);
|
|
1536
|
-
font-size: 12px;
|
|
1537
|
-
cursor: pointer;
|
|
1538
|
-
}
|
|
1539
|
-
.wallet-tile:hover { border-color: var(--ec-accent); }
|
|
1540
|
-
.wallet-tile.selected {
|
|
1541
|
-
border-color: var(--ec-accent);
|
|
1542
|
-
background: var(--ec-accent-soft);
|
|
1543
|
-
box-shadow: inset 0 0 0 1px var(--ec-accent);
|
|
1544
1684
|
}
|
|
1545
|
-
.wallet-tile-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1546
1685
|
|
|
1547
1686
|
.wallet-icon { position: relative; width: 28px; height: 28px; flex-shrink: 0; display: inline-flex; }
|
|
1548
|
-
.wallet-tile .wallet-icon { width: 40px; height: 40px; }
|
|
1549
1687
|
.wallet-badge {
|
|
1550
1688
|
width: 100%; height: 100%; border-radius: 8px; display: inline-flex;
|
|
1551
1689
|
align-items: center; justify-content: center; font-size: 13px;
|
|
1552
1690
|
font-weight: 600; background: var(--ec-accent-soft); color: var(--ec-accent);
|
|
1553
1691
|
}
|
|
1554
|
-
.wallet-tile .wallet-badge { border-radius: 10px; font-size: 17px; }
|
|
1555
1692
|
.wallet-img { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 8px; object-fit: cover; }
|
|
1556
|
-
.wallet-tile .wallet-img { border-radius: 10px; }
|
|
1557
1693
|
|
|
1558
1694
|
.wallet-empty { padding: 10px 4px; font-size: 12px; color: var(--ec-muted); text-align: center; }
|
|
1559
1695
|
|