@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
@@ -52,8 +53,16 @@ import { fetchWalletWellKnown, probeWalletWellKnown } from './wallet-well-known.
52
53
  import { RelayConnectCancelledError, runRelayConnect } from '../relay-connect-runner.js';
53
54
  /** localStorage key remembering the last successful method + wallet. */
54
55
  const LAST_CHOICE_STORAGE_KEY = 'enbox:connect:lastChoice';
55
- /** Safety margin subtracted from the relay pointer TTL before re-minting. */
56
- const REMINT_SAFETY_MS = 30000;
56
+ /**
57
+ * Safety margin subtracted from the relay pointer TTL before re-minting.
58
+ *
59
+ * The pointer is single-use and its TTL starts at mint time, but the wallet
60
+ * only dereferences it after the user scans — and, on a locked wallet, after
61
+ * they finish the unlock ceremony. A scanned code must therefore carry
62
+ * enough remaining life to absorb that whole window, so re-mint well before
63
+ * the deadline rather than just ahead of it.
64
+ */
65
+ const REMINT_SAFETY_MS = 120000;
57
66
  /** Minimum delay before an automatic re-mint. */
58
67
  const REMINT_MIN_MS = 10000;
59
68
  /** Pairing code length (matches the wallet's generated PIN). */
@@ -239,6 +248,7 @@ export function runConnectModal(options) {
239
248
  let settled = false;
240
249
  let relaySession;
241
250
  let remintTimer;
251
+ let remintAt;
242
252
  let popupBusy = false;
243
253
  let pinResolve;
244
254
  const discoveryCache = new Map();
@@ -269,8 +279,9 @@ export function runConnectModal(options) {
269
279
  const cleanup = () => {
270
280
  var _a;
271
281
  relaySession === null || relaySession === void 0 ? void 0 : relaySession.cancel();
272
- clearTimeout(remintTimer);
282
+ clearRemint();
273
283
  document.removeEventListener('keydown', onKeydown);
284
+ document.removeEventListener('visibilitychange', onVisibilityReturn);
274
285
  (_a = systemDark === null || systemDark === void 0 ? void 0 : systemDark.removeEventListener) === null || _a === void 0 ? void 0 : _a.call(systemDark, 'change', onSchemeChange);
275
286
  try {
276
287
  document.body.removeChild(host);
@@ -313,26 +324,50 @@ export function runConnectModal(options) {
313
324
  const renderBusy = (message) => {
314
325
  setStage(el('div', 'spinner'), el('p', 'stage-caption', message));
315
326
  };
327
+ /**
328
+ * Schedules the "finish in the wallet" morph after a handoff link is
329
+ * followed. Deferred one tick so the anchor's default new-tab navigation
330
+ * completes before the anchor is replaced, and guarded so a session that
331
+ * moved on meanwhile (re-mint, wallet response, settle) is never
332
+ * overwritten.
333
+ */
334
+ const morphToAway = (walletName) => {
335
+ const session = relaySession;
336
+ setTimeout(() => {
337
+ if (settled || session !== relaySession || (session === null || session === void 0 ? void 0 : session.active) !== true || pinResolve !== undefined) {
338
+ return;
339
+ }
340
+ renderAway(walletName);
341
+ }, 0);
342
+ };
343
+ /**
344
+ * Builds one same-device handoff anchor for the wallet URI. The wallet
345
+ * opens in a new tab so this modal — and the relay session under it —
346
+ * stays alive, with the pairing-code entry waiting when the user
347
+ * switches back. `noopener` keeps the wallet tab from reaching back into
348
+ * this one.
349
+ */
350
+ const buildHandoffLink = (handoff, walletName, className) => {
351
+ const link = document.createElement('a');
352
+ link.className = className;
353
+ link.href = handoff.walletUri;
354
+ link.target = '_blank';
355
+ link.rel = 'noopener noreferrer';
356
+ link.addEventListener('click', () => { morphToAway(walletName); });
357
+ return link;
358
+ };
316
359
  const renderQr = (handoff) => {
317
360
  var _a;
318
361
  const wallet = walletUrl !== undefined ? walletByUrl(walletUrl) : undefined;
319
362
  const walletName = (_a = wallet === null || wallet === void 0 ? void 0 : wallet.name) !== null && _a !== void 0 ? _a : 'your wallet';
320
- if (isMobile) {
321
- const link = document.createElement('a');
322
- link.className = 'deep-link';
323
- link.href = handoff.walletUri;
324
- if (wallet !== undefined) {
325
- link.appendChild(buildWalletIcon(wallet));
326
- }
327
- link.appendChild(el('span', 'deep-link-label', `Continue in ${walletName}`));
328
- setStage(link, el('p', 'stage-caption', `${walletName} opens to approve this connection.`), el('p', 'stage-subline', 'Come back here when you’re done.'));
329
- return;
330
- }
331
- const qrBox = document.createElement('div');
332
- qrBox.className = 'qr-box';
363
+ // The QR card is itself a handoff link on every device: opening it on
364
+ // this device runs the exact handshake the camera path runs on another
365
+ // one, and scanning it keeps working either way.
366
+ const qrLink = buildHandoffLink(handoff, walletName, 'qr-box qr-link');
367
+ qrLink.setAttribute('aria-label', `Open ${walletName} on this device`);
333
368
  // Fixed colors on the white card: dark-on-light QRs scan reliably in
334
369
  // both appearances (inverted codes trip up some camera apps).
335
- qrBox.replaceChildren(qrToSvg(encodeQr(handoff.walletUri), {
370
+ qrLink.replaceChildren(qrToSvg(encodeQr(handoff.walletUri), {
336
371
  dark: '#14141f',
337
372
  light: 'transparent',
338
373
  quietZone: 2,
@@ -340,22 +375,46 @@ export function runConnectModal(options) {
340
375
  // The selected wallet's mark sits at the QR centre — the "this is the
341
376
  // wallet you're connecting to" cue. The 44px plate over the ~180px
342
377
  // symbol occludes ~5% of modules, well inside ECC level M's 15%
343
- // recovery budget.
378
+ // recovery budget (the compact variant scales the plate down to
379
+ // match).
344
380
  if (wallet !== undefined) {
345
381
  const logo = el('div', 'qr-logo');
346
382
  logo.appendChild(buildWalletIcon(wallet));
347
- qrBox.appendChild(logo);
383
+ qrLink.appendChild(logo);
348
384
  }
349
- setStage(qrBox, el('p', 'stage-caption', 'Scan with your phone’s camera'), el('p', 'stage-subline', `${walletName} stays on your phone.`));
385
+ if (isMobile) {
386
+ const link = buildHandoffLink(handoff, walletName, 'deep-link');
387
+ if (wallet !== undefined) {
388
+ link.appendChild(buildWalletIcon(wallet));
389
+ }
390
+ link.appendChild(el('span', 'deep-link-label', `Continue in ${walletName}`));
391
+ qrLink.classList.add('compact');
392
+ setStage(link, el('p', 'stage-subline', `${walletName} opens in a new tab — approve there, then come back for your code.`), el('div', 'stage-divider', 'or scan with another phone'), qrLink);
393
+ return;
394
+ }
395
+ setStage(qrLink, el('p', 'stage-caption', 'Scan with your phone’s camera'), el('p', 'stage-subline', `${walletName} stays on your phone — or click the code to open it here.`));
396
+ };
397
+ /**
398
+ * Shown once a handoff link is followed on this device: the approval is
399
+ * happening in the wallet tab, this one waits for the code. "Start over"
400
+ * mints a fresh pointer — the right recovery, because the pointer the
401
+ * wallet tab carried away is single-use.
402
+ */
403
+ const renderAway = (walletName) => {
404
+ setStage(el('div', 'spinner'), el('p', 'stage-caption', `Finish in ${walletName}`), el('p', 'stage-subline', isMobile
405
+ ? `Approve the connection in the ${walletName} tab, then come back here for your code.`
406
+ : 'Approve the connection in the tab that just opened, then come back here for your code.'), stageLinkButton('Start over', () => { void startPhone(); }));
350
407
  };
351
408
  const renderClaimed = () => {
409
+ const walletName = walletUrl !== undefined ? walletByUrl(walletUrl).name : 'your wallet';
352
410
  const pulse = document.createElement('div');
353
411
  pulse.className = 'claimed-pulse';
354
- setStage(pulse, el('p', 'stage-caption', 'Phone connected — finish there'), el('p', 'stage-subline', 'Approve the request on your phone, then come back here.'));
412
+ setStage(pulse, el('p', 'stage-caption', `Request received — approve in ${walletName}`), el('p', 'stage-subline', 'Once you approve, you’ll get a code to enter here.'));
355
413
  };
356
414
  const renderPin = (attempt, previousError) => {
357
415
  return new Promise((resolvePin) => {
358
416
  pinResolve = resolvePin;
417
+ const walletName = walletUrl !== undefined ? walletByUrl(walletUrl).name : 'your wallet';
359
418
  const boxes = document.createElement('div');
360
419
  boxes.className = 'pin-row';
361
420
  if (previousError !== undefined) {
@@ -410,9 +469,9 @@ export function runConnectModal(options) {
410
469
  submitIfComplete();
411
470
  });
412
471
  });
413
- setStage(el('p', 'stage-caption', 'Enter the code shown on your phone'), boxes, ...(previousError !== undefined
414
- ? [el('p', 'stage-error', 'That code doesn’t match — check your phone.')]
415
- : [el('p', 'stage-subline', attempt === 1 ? 'Phone connected.' : '')]));
472
+ setStage(el('p', 'stage-caption', `Enter the code shown in ${walletName}`), boxes, ...(previousError !== undefined
473
+ ? [el('p', 'stage-error', `That code doesn’t match — check ${walletName}.`)]
474
+ : [el('p', 'stage-subline', attempt === 1 ? 'This confirms you’re the one approving.' : '')]));
416
475
  inputs[0].focus();
417
476
  });
418
477
  };
@@ -430,16 +489,21 @@ export function runConnectModal(options) {
430
489
  const renderPhoneUnavailable = () => {
431
490
  setStage(el('p', 'stage-caption', 'This wallet can’t connect by phone here.'), el('p', 'stage-subline', 'You can continue in this browser instead.'), stageButton('Use this browser', () => { startPopup(); }));
432
491
  };
492
+ // The popup surface is a small window on desktop but a new tab on
493
+ // phones — the copy names whichever the visitor will actually see.
494
+ const popupSurface = isMobile ? 'tab' : 'window';
433
495
  const renderPopupPrompt = () => {
434
- setStage(el('p', 'stage-caption', 'Connect with a wallet in this browser'), el('p', 'stage-subline', 'Your wallet opens in a small window to approve this connection.'), stageButton('Open wallet window', () => { startPopup(); }));
496
+ setStage(el('p', 'stage-caption', 'Connect with a wallet in this browser'), el('p', 'stage-subline', isMobile
497
+ ? 'Your wallet opens in a new tab to approve this connection — no code needed.'
498
+ : 'Your wallet opens in a small window to approve this connection.'), stageButton(isMobile ? 'Open wallet' : 'Open wallet window', () => { startPopup(); }));
435
499
  };
436
500
  const renderPopupWaiting = () => {
437
- setStage(el('div', 'spinner'), el('p', 'stage-caption', 'Finish in the wallet window'), el('p', 'stage-subline', 'We’ll wrap up here as soon as you’re done.'));
501
+ setStage(el('div', 'spinner'), el('p', 'stage-caption', `Finish in the wallet ${popupSurface}`), el('p', 'stage-subline', 'We’ll wrap up here as soon as you’re done.'));
438
502
  };
439
503
  const renderPopupInterrupted = (blocked) => {
440
504
  setStage(el('p', 'stage-caption', blocked
441
- ? 'Your browser blocked the wallet window.'
442
- : 'The wallet window was closed.'), stageButton(blocked ? 'Open it now' : 'Reopen window', () => { startPopup(); }), ...(isMobile ? [] : [stageLinkButton('Use your phone instead', () => { void switchMethod('phone'); })]));
505
+ ? `Your browser blocked the wallet ${popupSurface}.`
506
+ : `The wallet ${popupSurface} was closed.`), stageButton(blocked ? 'Open it now' : `Reopen ${popupSurface}`, () => { startPopup(); }), stageLinkButton(isMobile ? 'Use a code instead' : 'Use your phone instead', () => { void switchMethod('phone'); }));
443
507
  };
444
508
  // ── Method drivers ─────────────────────────────────────────
445
509
  const resolveConnectServerUrl = (origin) => __awaiter(this, void 0, void 0, function* () {
@@ -453,19 +517,28 @@ export function runConnectModal(options) {
453
517
  }
454
518
  return cached;
455
519
  });
456
- const scheduleRemint = (expiresInSeconds) => {
520
+ const clearRemint = () => {
457
521
  clearTimeout(remintTimer);
522
+ remintAt = undefined;
523
+ };
524
+ const remintNow = () => {
525
+ // Never re-mint once the wallet has responded (pairing in progress).
526
+ if (!settled && pinResolve === undefined && method === 'phone') {
527
+ void startPhone();
528
+ }
529
+ };
530
+ const scheduleRemint = (expiresInSeconds) => {
531
+ clearRemint();
458
532
  const delay = Math.max(REMINT_MIN_MS, expiresInSeconds * 1000 - REMINT_SAFETY_MS);
459
- remintTimer = setTimeout(() => {
460
- // Never re-mint once the wallet has responded (pairing in progress).
461
- if (!settled && pinResolve === undefined && method === 'phone') {
462
- void startPhone();
463
- }
464
- }, delay);
533
+ // The deadline is kept alongside the timer: background tabs (mobile
534
+ // especially) throttle or freeze timers, so the visibility handler
535
+ // re-checks it when the user returns.
536
+ remintAt = Date.now() + delay;
537
+ remintTimer = setTimeout(remintNow, delay);
465
538
  };
466
539
  const startPhone = () => __awaiter(this, void 0, void 0, function* () {
467
540
  relaySession === null || relaySession === void 0 ? void 0 : relaySession.cancel();
468
- clearTimeout(remintTimer);
541
+ clearRemint();
469
542
  if (walletUrl === undefined) {
470
543
  renderError('No wallet is configured.');
471
544
  return;
@@ -499,14 +572,14 @@ export function runConnectModal(options) {
499
572
  },
500
573
  onClaimed: () => {
501
574
  if (session.active && !settled) {
502
- // The phone has the request — stop re-minting (a new pointer
575
+ // The wallet has the request — stop re-minting (a new pointer
503
576
  // would orphan the approval in progress) and show progress.
504
- clearTimeout(remintTimer);
577
+ clearRemint();
505
578
  renderClaimed();
506
579
  }
507
580
  },
508
581
  requestPin: (attempt, previousError) => {
509
- clearTimeout(remintTimer);
582
+ clearRemint();
510
583
  return renderPin(attempt, previousError);
511
584
  },
512
585
  });
@@ -533,7 +606,7 @@ export function runConnectModal(options) {
533
606
  const startPopup = () => {
534
607
  // Synchronous popup open inside the click's call stack.
535
608
  relaySession === null || relaySession === void 0 ? void 0 : relaySession.cancel();
536
- clearTimeout(remintTimer);
609
+ clearRemint();
537
610
  if (walletUrl === undefined) {
538
611
  renderError('No wallet is configured.');
539
612
  return;
@@ -591,7 +664,7 @@ export function runConnectModal(options) {
591
664
  if (method === 'browser') {
592
665
  // window.open must stay inside a user gesture — prompt for the click.
593
666
  relaySession === null || relaySession === void 0 ? void 0 : relaySession.cancel();
594
- clearTimeout(remintTimer);
667
+ clearRemint();
595
668
  renderFooter();
596
669
  renderPopupPrompt();
597
670
  }
@@ -620,15 +693,17 @@ export function runConnectModal(options) {
620
693
  if (method === 'phone') {
621
694
  const alt = document.createElement('button');
622
695
  alt.className = 'footer-link method-link';
623
- alt.textContent = 'No phone? Use this browser →';
696
+ // On a phone both methods open the wallet on this device — what the
697
+ // popup channel actually buys the visitor is skipping the code.
698
+ alt.textContent = isMobile ? 'Or connect without a code →' : 'No phone? Use this browser →';
624
699
  // startPopup runs synchronously in this click handler.
625
700
  alt.addEventListener('click', () => { startPopup(); });
626
701
  row.appendChild(alt);
627
702
  }
628
- else if (!isMobile) {
703
+ else {
629
704
  const alt = document.createElement('button');
630
705
  alt.className = 'footer-link method-link';
631
- alt.textContent = 'Use your phone instead →';
706
+ alt.textContent = isMobile ? 'Use a code instead →' : 'Use your phone instead →';
632
707
  alt.addEventListener('click', () => { void switchMethod('phone'); });
633
708
  row.appendChild(alt);
634
709
  }
@@ -637,7 +712,7 @@ export function runConnectModal(options) {
637
712
  }
638
713
  };
639
714
  const buildWalletSwitcher = () => {
640
- var _a;
715
+ var _a, _b;
641
716
  // Identity row: selected wallet + the next catalog wallets, then the
642
717
  // More tile expanding the full catalog in place.
643
718
  const row = document.createElement('div');
@@ -666,26 +741,31 @@ export function runConnectModal(options) {
666
741
  const panel = document.createElement('div');
667
742
  panel.className = 'wallet-panel';
668
743
  panel.hidden = true;
669
- const hiddenCount = wallets.filter((wallet) => !rowWallets.some((shown) => shown.url === wallet.url)).length;
744
+ // The panel grid holds only the wallets NOT already visible in the
745
+ // row, so expanding never shows the same wallet twice.
746
+ const gridWallets = wallets.filter((wallet) => !rowWallets.some((shown) => shown.url === wallet.url));
670
747
  const more = document.createElement('button');
671
748
  more.className = 'row-tile more-tile';
672
749
  more.title = 'All wallets';
673
750
  more.setAttribute('aria-expanded', 'false');
674
- more.appendChild(el('span', 'more-count', hiddenCount > 0 ? `+${hiddenCount}` : '⋯'));
751
+ more.appendChild(el('span', 'more-count', gridWallets.length > 0 ? `+${gridWallets.length}` : '⋯'));
675
752
  more.appendChild(el('span', 'row-tile-name', 'More'));
676
753
  more.addEventListener('click', () => {
677
754
  panel.hidden = !panel.hidden;
678
755
  more.setAttribute('aria-expanded', panel.hidden ? 'false' : 'true');
756
+ if (!panel.hidden) {
757
+ updateScrollHint();
758
+ }
679
759
  });
680
760
  row.appendChild(more);
681
761
  const label = rowWallets.length > 0 ? el('div', 'wallet-row-label', 'Connecting with') : undefined;
682
- // Search filter — appears once the catalog outgrows one grid row.
762
+ // Search filter — appears once the grid outgrows one row of tiles.
683
763
  const tiles = [];
684
764
  const empty = document.createElement('div');
685
765
  empty.className = 'wallet-empty';
686
- empty.textContent = 'No wallets match your search.';
766
+ empty.textContent = 'No other wallets match your search.';
687
767
  empty.hidden = true;
688
- if (wallets.length > WALLET_SEARCH_THRESHOLD) {
768
+ if (gridWallets.length > WALLET_SEARCH_THRESHOLD) {
689
769
  const search = document.createElement('input');
690
770
  search.type = 'search';
691
771
  search.className = 'wallet-search';
@@ -704,28 +784,34 @@ export function runConnectModal(options) {
704
784
  }
705
785
  }
706
786
  empty.hidden = visible > 0;
787
+ updateScrollHint();
707
788
  });
708
789
  panel.appendChild(search);
709
790
  }
710
- // Square tiles, three per row; past two rows the grid scrolls in
711
- // place so a large catalog never stretches the modal.
791
+ // Tiles matching the identity row, four per row; past three rows the
792
+ // grid scrolls in place, with a bottom fade hinting at more below.
793
+ const scrollWrap = document.createElement('div');
794
+ scrollWrap.className = 'wallet-scroll-wrap';
795
+ scrollWrap.hidden = gridWallets.length === 0;
712
796
  const scroll = document.createElement('div');
713
797
  scroll.className = 'wallet-scroll';
714
798
  const grid = document.createElement('div');
715
799
  grid.className = 'wallet-grid';
716
- for (const wallet of wallets) {
800
+ const updateScrollHint = () => {
801
+ const overflowing = scroll.scrollHeight - scroll.clientHeight > 1;
802
+ const atEnd = scroll.scrollTop + scroll.clientHeight >= scroll.scrollHeight - 1;
803
+ scrollWrap.classList.toggle('scroll-hint', overflowing && !atEnd);
804
+ };
805
+ scroll.addEventListener('scroll', updateScrollHint);
806
+ for (const wallet of gridWallets) {
717
807
  const tile = document.createElement('button');
718
808
  tile.className = 'wallet-tile';
719
- tile.title = wallet.name;
809
+ tile.title = (_b = wallet.description) !== null && _b !== void 0 ? _b : wallet.name;
720
810
  tile.appendChild(buildWalletIcon(wallet));
721
811
  const tileName = document.createElement('span');
722
812
  tileName.className = 'wallet-tile-name';
723
813
  tileName.textContent = wallet.name;
724
814
  tile.appendChild(tileName);
725
- if (wallet.url === walletUrl) {
726
- tile.classList.add('selected');
727
- tile.setAttribute('aria-pressed', 'true');
728
- }
729
815
  tile.addEventListener('click', () => {
730
816
  selectWallet(wallet.url);
731
817
  });
@@ -734,7 +820,8 @@ export function runConnectModal(options) {
734
820
  }
735
821
  scroll.appendChild(grid);
736
822
  scroll.appendChild(empty);
737
- panel.appendChild(scroll);
823
+ scrollWrap.appendChild(scroll);
824
+ panel.appendChild(scrollWrap);
738
825
  // Custom wallet URL (the power-user path, demoted to the disclosure).
739
826
  const custom = document.createElement('div');
740
827
  custom.className = 'wallet-custom';
@@ -819,6 +906,26 @@ export function runConnectModal(options) {
819
906
  }
820
907
  };
821
908
  document.addEventListener('keydown', onKeydown);
909
+ // The visitor typically comes back from the wallet tab mid-session.
910
+ const onVisibilityReturn = () => {
911
+ var _a;
912
+ if (document.visibilityState !== 'visible' || settled) {
913
+ return;
914
+ }
915
+ // Background tabs throttle (or freeze) timers, so a re-mint that came
916
+ // due while the user was away fires now instead of never.
917
+ if (remintAt !== undefined && Date.now() >= remintAt) {
918
+ remintNow();
919
+ return;
920
+ }
921
+ // Returning with the code in hand: put the caret in the first empty
922
+ // pairing-code box so they can type it straight away.
923
+ if (pinResolve !== undefined) {
924
+ const inputs = Array.from(shadow.querySelectorAll('.pin-input'));
925
+ (_a = inputs.find((input) => input.value === '')) === null || _a === void 0 ? void 0 : _a.focus();
926
+ }
927
+ };
928
+ document.addEventListener('visibilitychange', onVisibilityReturn);
822
929
  overlay.appendChild(modal);
823
930
  shadow.appendChild(overlay);
824
931
  document.body.appendChild(host);
@@ -1060,6 +1167,31 @@ const MODAL_STYLES = `
1060
1167
  .qr-logo .wallet-icon { width: 30px; height: 30px; }
1061
1168
  .qr-logo .wallet-badge { border-radius: 8px; font-size: 15px; }
1062
1169
 
1170
+ /* The QR card doubles as the same-device handoff link. */
1171
+ a.qr-box { display: block; cursor: pointer; }
1172
+ .qr-box.qr-link:hover {
1173
+ border-color: var(--ec-accent);
1174
+ box-shadow: 0 0 0 3px var(--ec-accent-soft);
1175
+ }
1176
+ .qr-box.compact { width: 160px; height: 160px; }
1177
+ .qr-box.compact .qr-logo { width: 36px; height: 36px; }
1178
+ .qr-box.compact .qr-logo .wallet-icon { width: 24px; height: 24px; }
1179
+
1180
+ .stage-divider {
1181
+ display: flex;
1182
+ align-items: center;
1183
+ gap: 10px;
1184
+ width: 100%;
1185
+ color: var(--ec-muted);
1186
+ font-size: 12px;
1187
+ }
1188
+ .stage-divider::before, .stage-divider::after {
1189
+ content: '';
1190
+ flex: 1;
1191
+ height: 1px;
1192
+ background: var(--ec-border);
1193
+ }
1194
+
1063
1195
  .claimed-pulse {
1064
1196
  width: 64px; height: 64px; border-radius: 50%;
1065
1197
  background: var(--ec-accent-soft); position: relative;
@@ -1176,7 +1308,7 @@ const MODAL_STYLES = `
1176
1308
  grid-template-columns: repeat(4, 1fr);
1177
1309
  gap: 8px;
1178
1310
  }
1179
- .row-tile {
1311
+ .row-tile, .wallet-tile {
1180
1312
  display: flex;
1181
1313
  flex-direction: column;
1182
1314
  align-items: center;
@@ -1191,15 +1323,15 @@ const MODAL_STYLES = `
1191
1323
  font-size: 11px;
1192
1324
  cursor: pointer;
1193
1325
  }
1194
- .row-tile:hover { border-color: var(--ec-accent); }
1326
+ .row-tile:hover, .wallet-tile:hover { border-color: var(--ec-accent); }
1195
1327
  .row-tile.selected {
1196
1328
  border-color: var(--ec-accent);
1197
1329
  background: var(--ec-accent-soft);
1198
1330
  box-shadow: inset 0 0 0 1px var(--ec-accent);
1199
1331
  }
1200
1332
  .row-tile.selected .row-tile-name { font-weight: 600; }
1201
- .row-tile .wallet-icon { width: 28px; height: 28px; }
1202
- .row-tile-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1333
+ .row-tile .wallet-icon, .wallet-tile .wallet-icon { width: 28px; height: 28px; }
1334
+ .row-tile-name, .wallet-tile-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1203
1335
 
1204
1336
  .more-tile { color: var(--ec-muted); }
1205
1337
  .more-tile:hover { color: var(--ec-text); }
@@ -1225,46 +1357,33 @@ const MODAL_STYLES = `
1225
1357
  }
1226
1358
  .wallet-search:focus { outline: none; border-color: var(--ec-accent); }
1227
1359
 
1228
- .wallet-scroll { max-height: 260px; overflow-y: auto; }
1360
+ /* Cap the grid at three rows of tiles; past that it scrolls in place,
1361
+ and the wrapper's bottom fade hints that more wallets are below. */
1362
+ .wallet-scroll-wrap { position: relative; }
1363
+ .wallet-scroll { max-height: 220px; overflow-y: auto; }
1364
+ .wallet-scroll-wrap.scroll-hint::after {
1365
+ content: '';
1366
+ position: absolute;
1367
+ left: 0;
1368
+ right: 0;
1369
+ bottom: 0;
1370
+ height: 28px;
1371
+ background: linear-gradient(to bottom, transparent, var(--ec-bg));
1372
+ pointer-events: none;
1373
+ }
1229
1374
  .wallet-grid {
1230
1375
  display: grid;
1231
- grid-template-columns: repeat(3, 1fr);
1232
- gap: 8px;
1233
- }
1234
- .wallet-tile {
1235
- aspect-ratio: 1;
1236
- display: flex;
1237
- flex-direction: column;
1238
- align-items: center;
1239
- justify-content: center;
1376
+ grid-template-columns: repeat(4, 1fr);
1240
1377
  gap: 8px;
1241
- padding: 8px;
1242
- min-width: 0;
1243
- background: var(--ec-surface);
1244
- border: 1px solid var(--ec-border);
1245
- border-radius: 12px;
1246
- color: var(--ec-text);
1247
- font-size: 12px;
1248
- cursor: pointer;
1249
- }
1250
- .wallet-tile:hover { border-color: var(--ec-accent); }
1251
- .wallet-tile.selected {
1252
- border-color: var(--ec-accent);
1253
- background: var(--ec-accent-soft);
1254
- box-shadow: inset 0 0 0 1px var(--ec-accent);
1255
1378
  }
1256
- .wallet-tile-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1257
1379
 
1258
1380
  .wallet-icon { position: relative; width: 28px; height: 28px; flex-shrink: 0; display: inline-flex; }
1259
- .wallet-tile .wallet-icon { width: 40px; height: 40px; }
1260
1381
  .wallet-badge {
1261
1382
  width: 100%; height: 100%; border-radius: 8px; display: inline-flex;
1262
1383
  align-items: center; justify-content: center; font-size: 13px;
1263
1384
  font-weight: 600; background: var(--ec-accent-soft); color: var(--ec-accent);
1264
1385
  }
1265
- .wallet-tile .wallet-badge { border-radius: 10px; font-size: 17px; }
1266
1386
  .wallet-img { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 8px; object-fit: cover; }
1267
- .wallet-tile .wallet-img { border-radius: 10px; }
1268
1387
 
1269
1388
  .wallet-empty { padding: 10px 4px; font-size: 12px; color: var(--ec-muted); text-align: center; }
1270
1389