@enbox/browser 0.3.46 → 0.3.47

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.
@@ -13,9 +13,10 @@
13
13
  * 2. **Stage** — a height-stable region where states crossfade: the QR
14
14
  * (or deep link on phones), the pairing-code input, progress, success,
15
15
  * and error states.
16
- * 3. **Footer** — one row holding the alternate-method link and the wallet
17
- * switcher disclosure (a tile grid with search + custom URL — the
18
- * entire legacy selector, demoted to an option).
16
+ * 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.
19
20
  *
20
21
  * Theming: the stylesheet is static and reads `--ec-*` design tokens.
21
22
  * Light/dark follows the visitor's system via `prefers-color-scheme` and
@@ -68,6 +69,9 @@ const PIN_LENGTH = 4;
68
69
  /** Catalog size beyond which the wallet switcher shows its search bar. */
69
70
  const WALLET_SEARCH_THRESHOLD = 4; // one full row of tiles
70
71
 
72
+ /** Wallet tiles shown in the collapsed identity row (plus the More tile). */
73
+ const WALLET_ROW_SIZE = 3;
74
+
71
75
  /** Palette overrides for one appearance's design tokens. */
72
76
  export interface ConnectModalPalette {
73
77
  /** Modal surface. */
@@ -334,7 +338,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
334
338
  }
335
339
 
336
340
  const deps: ConnectModalDeps = { ...defaultDeps(), ...options.deps };
337
- const wallets = options.wallets ?? [];
341
+ const wallets = dedupeWalletsByUrl(options.wallets ?? []);
338
342
  const rememberChoice = options.rememberChoice ?? true;
339
343
  const appName = options.appName ?? window.location.host;
340
344
  const relayWalletPath = options.relayWalletPath ?? '/connect/app';
@@ -418,6 +422,35 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
418
422
  let pinResolve: ((pin: string) => void) | undefined;
419
423
  const discoveryCache = new Map<string, Promise<string | undefined>>();
420
424
 
425
+ // Collapsed identity row: the selected wallet plus the next catalog
426
+ // wallets, never repeating one. Slots stay stable across in-row
427
+ // switches (only the highlight moves); the row recomposes only when a
428
+ // wallet from outside it — grid pick or custom URL — becomes selected,
429
+ // which puts that wallet in the first slot.
430
+ let rowWallets: WalletOption[] = [];
431
+
432
+ /** The catalog entry for `url`, or a synthesized option for custom URLs. */
433
+ const walletByUrl = (url: string): WalletOption =>
434
+ wallets.find((wallet) => wallet.url === url) ?? { name: hostnameOf(url) ?? url, url };
435
+
436
+ const composeWalletRow = (): void => {
437
+ if (walletUrl === undefined) {
438
+ rowWallets = wallets.slice(0, WALLET_ROW_SIZE);
439
+ return;
440
+ }
441
+ if (rowWallets.some((wallet) => wallet.url === walletUrl)) {
442
+ return;
443
+ }
444
+ const selected = walletByUrl(walletUrl);
445
+ rowWallets = [selected, ...wallets.filter((wallet) => wallet.url !== selected.url)].slice(0, WALLET_ROW_SIZE);
446
+ };
447
+
448
+ const selectWallet = (url: string): void => {
449
+ walletUrl = url;
450
+ renderFooter();
451
+ void startMethod();
452
+ };
453
+
421
454
  const cleanup = (): void => {
422
455
  relaySession?.cancel();
423
456
  clearTimeout(remintTimer);
@@ -469,14 +502,20 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
469
502
  };
470
503
 
471
504
  const renderQr = (handoff: WalletUriHandoff): void => {
505
+ const wallet = walletUrl !== undefined ? walletByUrl(walletUrl) : undefined;
506
+ const walletName = wallet?.name ?? 'your wallet';
507
+
472
508
  if (isMobile) {
473
509
  const link = document.createElement('a');
474
510
  link.className = 'deep-link';
475
511
  link.href = handoff.walletUri;
476
- link.textContent = 'Continue in your wallet';
512
+ if (wallet !== undefined) {
513
+ link.appendChild(buildWalletIcon(wallet));
514
+ }
515
+ link.appendChild(el('span', 'deep-link-label', `Continue in ${walletName}`));
477
516
  setStage(
478
517
  link,
479
- el('p', 'stage-caption', 'Your wallet opens to approve this connection.'),
518
+ el('p', 'stage-caption', `${walletName} opens to approve this connection.`),
480
519
  el('p', 'stage-subline', 'Come back here when you’re done.'),
481
520
  );
482
521
  return;
@@ -492,10 +531,20 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
492
531
  quietZone : 2,
493
532
  }));
494
533
 
534
+ // The selected wallet's mark sits at the QR centre — the "this is the
535
+ // wallet you're connecting to" cue. The 44px plate over the ~180px
536
+ // symbol occludes ~5% of modules, well inside ECC level M's 15%
537
+ // recovery budget.
538
+ if (wallet !== undefined) {
539
+ const logo = el('div', 'qr-logo');
540
+ logo.appendChild(buildWalletIcon(wallet));
541
+ qrBox.appendChild(logo);
542
+ }
543
+
495
544
  setStage(
496
545
  qrBox,
497
546
  el('p', 'stage-caption', 'Scan with your phone’s camera'),
498
- el('p', 'stage-subline', 'Your wallet stays on your phone.'),
547
+ el('p', 'stage-subline', `${walletName} stays on your phone.`),
499
548
  );
500
549
  };
501
550
 
@@ -809,10 +858,20 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
809
858
  await startMethod();
810
859
  };
811
860
 
812
- // ── Footer: one row (method link + wallet toggle), panel below ──
861
+ // ── Footer: identity row (+ catalog panel), method link below ──
813
862
  const renderFooter = (): void => {
814
863
  footer.replaceChildren();
815
864
 
865
+ if (!lockedWallet) {
866
+ composeWalletRow();
867
+ const switcher = buildWalletSwitcher();
868
+ if (switcher.label !== undefined) {
869
+ footer.appendChild(switcher.label);
870
+ }
871
+ footer.appendChild(switcher.row);
872
+ footer.appendChild(switcher.panel);
873
+ }
874
+
816
875
  const row = document.createElement('div');
817
876
  row.className = 'footer-row';
818
877
 
@@ -831,32 +890,58 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
831
890
  row.appendChild(alt);
832
891
  }
833
892
 
834
- let panel: HTMLElement | undefined;
835
- if (!lockedWallet && wallets.length > 0) {
836
- const switcher = buildWalletSwitcher();
837
- row.appendChild(switcher.toggle);
838
- panel = switcher.panel;
839
- }
840
-
841
893
  if (row.childElementCount > 0) {
842
894
  footer.appendChild(row);
843
895
  }
844
- if (panel !== undefined) {
845
- footer.appendChild(panel);
846
- }
847
896
  };
848
897
 
849
- const buildWalletSwitcher = (): { toggle: HTMLButtonElement; panel: HTMLDivElement } => {
850
- const current = wallets.find((wallet) => wallet.url === walletUrl);
851
- const toggle = document.createElement('button');
852
- toggle.className = 'footer-link wallet-toggle';
853
- toggle.setAttribute('aria-expanded', 'false');
854
- toggle.textContent = `Wallet: ${current?.name ?? hostnameOf(walletUrl) ?? 'Choose'} ▾`;
898
+ const buildWalletSwitcher = (): { label?: HTMLElement; row: HTMLDivElement; panel: HTMLDivElement } => {
899
+ // Identity row: selected wallet + the next catalog wallets, then the
900
+ // More tile expanding the full catalog in place.
901
+ const row = document.createElement('div');
902
+ row.className = 'wallet-row';
903
+ row.setAttribute('role', 'radiogroup');
904
+ row.setAttribute('aria-label', 'Wallet');
905
+
906
+ for (const wallet of rowWallets) {
907
+ const tile = document.createElement('button');
908
+ tile.className = 'row-tile';
909
+ tile.title = wallet.description ?? wallet.name;
910
+ tile.setAttribute('role', 'radio');
911
+ const isSelected = wallet.url === walletUrl;
912
+ tile.setAttribute('aria-checked', isSelected ? 'true' : 'false');
913
+ if (isSelected) {
914
+ tile.classList.add('selected');
915
+ }
916
+ tile.appendChild(buildWalletIcon(wallet));
917
+ tile.appendChild(el('span', 'row-tile-name', wallet.name));
918
+ tile.addEventListener('click', () => {
919
+ if (wallet.url !== walletUrl) {
920
+ selectWallet(wallet.url);
921
+ }
922
+ });
923
+ row.appendChild(tile);
924
+ }
855
925
 
856
926
  const panel = document.createElement('div');
857
927
  panel.className = 'wallet-panel';
858
928
  panel.hidden = true;
859
929
 
930
+ const hiddenCount = wallets.filter((wallet) => !rowWallets.some((shown) => shown.url === wallet.url)).length;
931
+ const more = document.createElement('button');
932
+ more.className = 'row-tile more-tile';
933
+ more.title = 'All wallets';
934
+ more.setAttribute('aria-expanded', 'false');
935
+ more.appendChild(el('span', 'more-count', hiddenCount > 0 ? `+${hiddenCount}` : '⋯'));
936
+ more.appendChild(el('span', 'row-tile-name', 'More'));
937
+ more.addEventListener('click', () => {
938
+ panel.hidden = !panel.hidden;
939
+ more.setAttribute('aria-expanded', panel.hidden ? 'false' : 'true');
940
+ });
941
+ row.appendChild(more);
942
+
943
+ const label = rowWallets.length > 0 ? el('div', 'wallet-row-label', 'Connecting with') : undefined;
944
+
860
945
  // Search filter — appears once the catalog outgrows one grid row.
861
946
  const tiles: Array<{ wallet: (typeof wallets)[number]; el: HTMLButtonElement }> = [];
862
947
  const empty = document.createElement('div');
@@ -906,9 +991,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
906
991
  tile.setAttribute('aria-pressed', 'true');
907
992
  }
908
993
  tile.addEventListener('click', () => {
909
- walletUrl = wallet.url;
910
- renderFooter();
911
- void startMethod();
994
+ selectWallet(wallet.url);
912
995
  });
913
996
  tiles.push({ wallet, el: tile });
914
997
  grid.appendChild(tile);
@@ -936,12 +1019,6 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
936
1019
  // should not be permanently blocked.
937
1020
  let allowUnverified = false;
938
1021
 
939
- const useWallet = (origin: string): void => {
940
- walletUrl = origin;
941
- renderFooter();
942
- void startMethod();
943
- };
944
-
945
1022
  const submitCustom = async (): Promise<void> => {
946
1023
  const value = input.value.trim();
947
1024
  if (value === '') { return; }
@@ -953,7 +1030,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
953
1030
  return;
954
1031
  }
955
1032
  if (allowUnverified) {
956
- useWallet(origin);
1033
+ selectWallet(origin);
957
1034
  return;
958
1035
  }
959
1036
  go.disabled = true;
@@ -963,7 +1040,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
963
1040
  go.disabled = false;
964
1041
  if (valid) {
965
1042
  go.textContent = 'Use';
966
- useWallet(origin);
1043
+ selectWallet(origin);
967
1044
  return;
968
1045
  }
969
1046
  allowUnverified = true;
@@ -988,12 +1065,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
988
1065
  panel.appendChild(custom);
989
1066
  panel.appendChild(urlError);
990
1067
 
991
- toggle.addEventListener('click', () => {
992
- panel.hidden = !panel.hidden;
993
- toggle.setAttribute('aria-expanded', panel.hidden ? 'false' : 'true');
994
- });
995
-
996
- return { toggle, panel };
1068
+ return { label, row, panel };
997
1069
  };
998
1070
 
999
1071
  // ── Global interactions ────────────────────────────────────
@@ -1029,6 +1101,11 @@ function walletInCatalog(wallets: WalletOption[], url: string): boolean {
1029
1101
  return wallets.some((wallet) => wallet.url === url);
1030
1102
  }
1031
1103
 
1104
+ /** Drops catalog entries that repeat an earlier wallet's URL. */
1105
+ function dedupeWalletsByUrl(wallets: WalletOption[]): WalletOption[] {
1106
+ return wallets.filter((wallet, index) => wallets.findIndex((candidate) => candidate.url === wallet.url) === index);
1107
+ }
1108
+
1032
1109
  /** Compact display form of a wallet URL for the switcher toggle. */
1033
1110
  function hostnameOf(url: string | undefined): string | undefined {
1034
1111
  if (url === undefined) {
@@ -1243,6 +1320,7 @@ const MODAL_STYLES = `
1243
1320
  .stage.fade-in > * { animation: stageIn 0.15s ease-out; }
1244
1321
 
1245
1322
  .qr-box {
1323
+ position: relative;
1246
1324
  width: 200px;
1247
1325
  height: 200px;
1248
1326
  padding: 10px;
@@ -1252,6 +1330,25 @@ const MODAL_STYLES = `
1252
1330
  }
1253
1331
  .qr-box svg { width: 100%; height: 100%; display: block; }
1254
1332
 
1333
+ /* Selected wallet's mark on the QR centre. Like the card, the plate is
1334
+ theme-invariant white so the logo sits on a scannable ground. */
1335
+ .qr-logo {
1336
+ position: absolute;
1337
+ top: 50%;
1338
+ left: 50%;
1339
+ transform: translate(-50%, -50%);
1340
+ width: 44px;
1341
+ height: 44px;
1342
+ border-radius: 10px;
1343
+ background: #ffffff;
1344
+ border: 1px solid #e3e4e9;
1345
+ display: flex;
1346
+ align-items: center;
1347
+ justify-content: center;
1348
+ }
1349
+ .qr-logo .wallet-icon { width: 30px; height: 30px; }
1350
+ .qr-logo .wallet-badge { border-radius: 8px; font-size: 15px; }
1351
+
1255
1352
  .claimed-pulse {
1256
1353
  width: 64px; height: 64px; border-radius: 50%;
1257
1354
  background: var(--ec-accent-soft); position: relative;
@@ -1267,7 +1364,10 @@ const MODAL_STYLES = `
1267
1364
  .stage-error { font-size: 13px; color: var(--ec-danger); }
1268
1365
 
1269
1366
  .deep-link {
1270
- display: block;
1367
+ display: flex;
1368
+ align-items: center;
1369
+ justify-content: center;
1370
+ gap: 10px;
1271
1371
  width: 100%;
1272
1372
  padding: 13px 16px;
1273
1373
  border-radius: 12px;
@@ -1277,6 +1377,13 @@ const MODAL_STYLES = `
1277
1377
  font-size: 15px;
1278
1378
  text-decoration: none;
1279
1379
  }
1380
+ .deep-link .wallet-icon { width: 24px; height: 24px; }
1381
+ .deep-link .wallet-badge {
1382
+ border-radius: 6px;
1383
+ font-size: 12px;
1384
+ background: var(--ec-accent-contrast);
1385
+ }
1386
+ .deep-link .wallet-img { border-radius: 6px; }
1280
1387
 
1281
1388
  .pin-row { display: flex; gap: 10px; }
1282
1389
  .pin-row.shake { animation: shake 0.4s ease-in-out; }
@@ -1350,7 +1457,54 @@ const MODAL_STYLES = `
1350
1457
  }
1351
1458
  .footer-link:hover { color: var(--ec-text); background: var(--ec-surface); }
1352
1459
  .method-link { color: var(--ec-accent); }
1353
- .wallet-toggle { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1460
+
1461
+ .wallet-row-label { font-size: 12px; color: var(--ec-muted); padding: 2px 2px 0; }
1462
+
1463
+ .wallet-row {
1464
+ display: grid;
1465
+ grid-template-columns: repeat(4, 1fr);
1466
+ gap: 8px;
1467
+ }
1468
+ .row-tile {
1469
+ display: flex;
1470
+ flex-direction: column;
1471
+ align-items: center;
1472
+ justify-content: center;
1473
+ gap: 6px;
1474
+ padding: 10px 6px;
1475
+ min-width: 0;
1476
+ background: var(--ec-surface);
1477
+ border: 1px solid var(--ec-border);
1478
+ border-radius: 12px;
1479
+ color: var(--ec-text);
1480
+ font-size: 11px;
1481
+ cursor: pointer;
1482
+ }
1483
+ .row-tile:hover { border-color: var(--ec-accent); }
1484
+ .row-tile.selected {
1485
+ border-color: var(--ec-accent);
1486
+ background: var(--ec-accent-soft);
1487
+ box-shadow: inset 0 0 0 1px var(--ec-accent);
1488
+ }
1489
+ .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; }
1492
+
1493
+ .more-tile { color: var(--ec-muted); }
1494
+ .more-tile:hover { color: var(--ec-text); }
1495
+ .more-tile[aria-expanded='true'] { border-color: var(--ec-accent); }
1496
+ .more-count {
1497
+ width: 28px;
1498
+ height: 28px;
1499
+ border-radius: 8px;
1500
+ display: inline-flex;
1501
+ align-items: center;
1502
+ justify-content: center;
1503
+ font-size: 12px;
1504
+ font-weight: 600;
1505
+ background: var(--ec-accent-soft);
1506
+ color: var(--ec-accent);
1507
+ }
1354
1508
 
1355
1509
  .wallet-panel { width: 100%; padding: 4px 0 2px; display: flex; flex-direction: column; gap: 8px; }
1356
1510
  .wallet-search {