@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.
Files changed (37) hide show
  1. package/dist/browser.mjs +58 -46
  2. package/dist/browser.mjs.map +3 -3
  3. package/dist/esm/browser-connect-handler.js +10 -0
  4. package/dist/esm/browser-connect-handler.js.map +1 -1
  5. package/dist/esm/dweb-connect-client.js +64 -9
  6. package/dist/esm/dweb-connect-client.js.map +1 -1
  7. package/dist/esm/dweb-connect-messages.js +7 -1
  8. package/dist/esm/dweb-connect-messages.js.map +1 -1
  9. package/dist/esm/dweb-connect-wallet.js +48 -2
  10. package/dist/esm/dweb-connect-wallet.js.map +1 -1
  11. package/dist/esm/index.js +1 -1
  12. package/dist/esm/index.js.map +1 -1
  13. package/dist/esm/relay-connect-runner.js +35 -2
  14. package/dist/esm/relay-connect-runner.js.map +1 -1
  15. package/dist/esm/ui/connect-modal.js +220 -101
  16. package/dist/esm/ui/connect-modal.js.map +1 -1
  17. package/dist/types/browser-connect-handler.d.ts.map +1 -1
  18. package/dist/types/dweb-connect-client.d.ts +9 -0
  19. package/dist/types/dweb-connect-client.d.ts.map +1 -1
  20. package/dist/types/dweb-connect-messages.d.ts +14 -1
  21. package/dist/types/dweb-connect-messages.d.ts.map +1 -1
  22. package/dist/types/dweb-connect-wallet.d.ts +23 -1
  23. package/dist/types/dweb-connect-wallet.d.ts.map +1 -1
  24. package/dist/types/index.d.ts +2 -2
  25. package/dist/types/index.d.ts.map +1 -1
  26. package/dist/types/relay-connect-runner.d.ts +11 -1
  27. package/dist/types/relay-connect-runner.d.ts.map +1 -1
  28. package/dist/types/ui/connect-modal.d.ts +11 -10
  29. package/dist/types/ui/connect-modal.d.ts.map +1 -1
  30. package/package.json +6 -6
  31. package/src/browser-connect-handler.ts +10 -0
  32. package/src/dweb-connect-client.ts +61 -7
  33. package/src/dweb-connect-messages.ts +16 -1
  34. package/src/dweb-connect-wallet.ts +50 -1
  35. package/src/index.ts +2 -0
  36. package/src/relay-connect-runner.ts +42 -2
  37. package/src/ui/connect-modal.ts +246 -110
@@ -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 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).
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 QR
14
- * (or deep link on phones), the pairing-code input, progress, success,
15
- * and error states.
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, capped so the same wallet
18
- * never appears twice), a More tile that expands the full catalog grid
19
- * with search + custom URL in place, and the alternate-method link.
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
- /** Safety margin subtracted from the relay pointer TTL before re-minting. */
61
- const REMINT_SAFETY_MS = 30_000;
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
- clearTimeout(remintTimer);
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
- if (isMobile) {
509
- const link = document.createElement('a');
510
- link.className = 'deep-link';
511
- link.href = handoff.walletUri;
512
- if (wallet !== undefined) {
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
- qrBox.replaceChildren(qrToSvg(encodeQr(handoff.walletUri), {
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
- qrBox.appendChild(logo);
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
- qrBox,
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', 'Phone connected — finish there'),
557
- el('p', 'stage-subline', 'Approve the request on your phone, then come back here.'),
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', 'Enter the code shown on your phone'),
690
+ el('p', 'stage-caption', `Enter the code shown in ${walletName}`),
622
691
  boxes,
623
692
  ...(previousError !== undefined
624
- ? [el('p', 'stage-error', 'That code doesn’t match — check your phone.')]
625
- : [el('p', 'stage-subline', attempt === 1 ? 'Phone connected.' : '')]),
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', 'Your wallet opens in a small window to approve this connection.'),
666
- stageButton('Open wallet window', () => { startPopup(); }),
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', 'Finish in the wallet window'),
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
- ? 'Your browser blocked the wallet window.'
682
- : 'The wallet window was closed.'),
683
- stageButton(blocked ? 'Open it now' : 'Reopen window', () => { startPopup(); }),
684
- ...(isMobile ? [] : [stageLinkButton('Use your phone instead', () => { void switchMethod('phone'); })]),
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 scheduleRemint = (expiresInSeconds: number): void => {
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
- remintTimer = setTimeout(() => {
705
- // Never re-mint once the wallet has responded (pairing in progress).
706
- if (!settled && pinResolve === undefined && method === 'phone') {
707
- void startPhone();
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
- clearTimeout(remintTimer);
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 phone has the request — stop re-minting (a new pointer
839
+ // The wallet has the request — stop re-minting (a new pointer
754
840
  // would orphan the approval in progress) and show progress.
755
- clearTimeout(remintTimer);
841
+ clearRemint();
756
842
  renderClaimed();
757
843
  }
758
844
  },
759
845
  requestPin: (attempt, previousError): Promise<string> => {
760
- clearTimeout(remintTimer);
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
- clearTimeout(remintTimer);
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
- clearTimeout(remintTimer);
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
- alt.textContent = 'No phone? Use this browser →';
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 if (!isMobile) {
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
- const hiddenCount = wallets.filter((wallet) => !rowWallets.some((shown) => shown.url === wallet.url)).length;
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', hiddenCount > 0 ? `+${hiddenCount}` : '⋯'));
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 catalog outgrows one grid row.
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 (wallets.length > WALLET_SEARCH_THRESHOLD) {
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
- // Square tiles, three per row; past two rows the grid scrolls in
974
- // place so a large catalog never stretches the modal.
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
- for (const wallet of wallets) {
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
- panel.appendChild(scroll);
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
- .wallet-scroll { max-height: 260px; overflow-y: auto; }
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(3, 1fr);
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