@enbox/browser 0.3.45 → 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,19 @@
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** — the alternate-method link and the wallet switcher
17
- * disclosure (catalog + custom URL — the entire legacy selector,
18
- * 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.
20
+ *
21
+ * Theming: the stylesheet is static and reads `--ec-*` design tokens.
22
+ * Light/dark follows the visitor's system via `prefers-color-scheme` and
23
+ * updates live; the embedding app can force an appearance or override
24
+ * palette tokens through {@link ConnectModalOptions.theme}. Overrides are
25
+ * applied as inline custom properties — never interpolated into the
26
+ * stylesheet — so a hostile string can't escape its declaration. The QR
27
+ * card alone is theme-invariant (dark modules on white scan most
28
+ * reliably).
19
29
  *
20
30
  * The phone path drives the `@enbox/connect` relay transport via
21
31
  * {@link runRelayConnect}; the browser path drives the popup transport via
@@ -56,6 +66,77 @@ const REMINT_MIN_MS = 10_000;
56
66
  /** Pairing code length (matches the wallet's generated PIN). */
57
67
  const PIN_LENGTH = 4;
58
68
 
69
+ /** Catalog size beyond which the wallet switcher shows its search bar. */
70
+ const WALLET_SEARCH_THRESHOLD = 4; // one full row of tiles
71
+
72
+ /** Wallet tiles shown in the collapsed identity row (plus the More tile). */
73
+ const WALLET_ROW_SIZE = 3;
74
+
75
+ /** Palette overrides for one appearance's design tokens. */
76
+ export interface ConnectModalPalette {
77
+ /** Modal surface. */
78
+ background?: string;
79
+
80
+ /** Inset surfaces: tiles, inputs, hover fills. */
81
+ surface?: string;
82
+
83
+ /** Primary text. */
84
+ text?: string;
85
+
86
+ /** Secondary text. */
87
+ textMuted?: string;
88
+
89
+ /** Hairline borders and dividers. */
90
+ border?: string;
91
+
92
+ /** Brand accent: buttons, links, focus rings, selection. */
93
+ accent?: string;
94
+
95
+ /** Text/icon color on accent surfaces. */
96
+ accentContrast?: string;
97
+
98
+ /** Error text and invalid-input borders. */
99
+ danger?: string;
100
+
101
+ /** Success surfaces (the connected check). */
102
+ success?: string;
103
+ }
104
+
105
+ /** Optional color scheme provided by the embedding app. */
106
+ export interface ConnectModalTheme {
107
+ /**
108
+ * Follow the visitor's system appearance (`'auto'`, live-updating) or
109
+ * force one.
110
+ * @default 'auto'
111
+ */
112
+ appearance?: 'auto' | 'light' | 'dark';
113
+
114
+ /** Brand accent applied in both appearances (per-scheme palettes win). */
115
+ accent?: string;
116
+
117
+ /** Text/icon color on accent surfaces, in both appearances. */
118
+ accentContrast?: string;
119
+
120
+ /** Token overrides applied while the light appearance is active. */
121
+ light?: ConnectModalPalette;
122
+
123
+ /** Token overrides applied while the dark appearance is active. */
124
+ dark?: ConnectModalPalette;
125
+ }
126
+
127
+ /** Design-token custom property behind each palette key. */
128
+ const PALETTE_TOKENS: Record<keyof ConnectModalPalette, string> = {
129
+ background : '--ec-bg',
130
+ surface : '--ec-surface',
131
+ text : '--ec-text',
132
+ textMuted : '--ec-muted',
133
+ border : '--ec-border',
134
+ accent : '--ec-accent',
135
+ accentContrast : '--ec-accent-contrast',
136
+ danger : '--ec-danger',
137
+ success : '--ec-success',
138
+ };
139
+
59
140
  /** Options for {@link runConnectModal}. */
60
141
  export interface ConnectModalOptions {
61
142
  /** Wallet catalog shown in the wallet switcher. */
@@ -99,6 +180,13 @@ export interface ConnectModalOptions {
99
180
  /** Timeout in milliseconds for either method's handshake. */
100
181
  timeout?: number;
101
182
 
183
+ /**
184
+ * Color scheme. By default the modal follows the visitor's system
185
+ * light/dark appearance; apps can force an appearance and/or override
186
+ * palette tokens per scheme to match their brand.
187
+ */
188
+ theme?: ConnectModalTheme;
189
+
102
190
  /** DWN protocols and permission scopes being requested. */
103
191
  permissionRequests: ConnectPermissionRequest[];
104
192
 
@@ -250,7 +338,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
250
338
  }
251
339
 
252
340
  const deps: ConnectModalDeps = { ...defaultDeps(), ...options.deps };
253
- const wallets = options.wallets ?? [];
341
+ const wallets = dedupeWalletsByUrl(options.wallets ?? []);
254
342
  const rememberChoice = options.rememberChoice ?? true;
255
343
  const appName = options.appName ?? window.location.host;
256
344
  const relayWalletPath = options.relayWalletPath ?? '/connect/app';
@@ -273,9 +361,8 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
273
361
  host.id = 'enbox-connect-modal';
274
362
  const shadow = host.attachShadow({ mode: 'open' });
275
363
 
276
- const isDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
277
364
  const style = document.createElement('style');
278
- style.textContent = buildStyles(isDark);
365
+ style.textContent = MODAL_STYLES;
279
366
  shadow.appendChild(style);
280
367
 
281
368
  const overlay = document.createElement('div');
@@ -286,6 +373,47 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
286
373
  modal.setAttribute('aria-modal', 'true');
287
374
  modal.setAttribute('aria-label', `Connect to ${appName}`);
288
375
 
376
+ // ── Theme ──────────────────────────────────────────────────
377
+ // System light/dark is pure CSS (`prefers-color-scheme`), so it tracks
378
+ // the visitor live. A forced appearance pins the token block instead.
379
+ const theme = options.theme ?? {};
380
+ const appearance = theme.appearance ?? 'auto';
381
+ if (appearance !== 'auto') {
382
+ modal.setAttribute('data-appearance', appearance);
383
+ }
384
+
385
+ // Dapp palette overrides land as inline custom properties: setProperty
386
+ // parses each value as a lone declaration, so a hostile string cannot
387
+ // escape into the stylesheet.
388
+ const systemDark = window.matchMedia?.('(prefers-color-scheme: dark)');
389
+ const hasPaletteOverrides = theme.accent !== undefined || theme.accentContrast !== undefined
390
+ || theme.light !== undefined || theme.dark !== undefined;
391
+ const applyPaletteTokens = (): void => {
392
+ const scheme = appearance === 'auto'
393
+ ? (systemDark?.matches === true ? 'dark' : 'light')
394
+ : appearance;
395
+ const palette: ConnectModalPalette = {
396
+ accent : theme.accent,
397
+ accentContrast : theme.accentContrast,
398
+ ...theme[scheme],
399
+ };
400
+ for (const key of Object.keys(PALETTE_TOKENS) as Array<keyof ConnectModalPalette>) {
401
+ const value = palette[key];
402
+ if (typeof value === 'string' && value.trim() !== '') {
403
+ modal.style.setProperty(PALETTE_TOKENS[key], value);
404
+ } else {
405
+ modal.style.removeProperty(PALETTE_TOKENS[key]);
406
+ }
407
+ }
408
+ };
409
+ const onSchemeChange = (): void => applyPaletteTokens();
410
+ if (hasPaletteOverrides) {
411
+ applyPaletteTokens();
412
+ if (appearance === 'auto') {
413
+ systemDark?.addEventListener?.('change', onSchemeChange);
414
+ }
415
+ }
416
+
289
417
  // ── Session state ──────────────────────────────────────────
290
418
  let settled = false;
291
419
  let relaySession: RelaySession | undefined;
@@ -294,10 +422,40 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
294
422
  let pinResolve: ((pin: string) => void) | undefined;
295
423
  const discoveryCache = new Map<string, Promise<string | undefined>>();
296
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
+
297
454
  const cleanup = (): void => {
298
455
  relaySession?.cancel();
299
456
  clearTimeout(remintTimer);
300
457
  document.removeEventListener('keydown', onKeydown);
458
+ systemDark?.removeEventListener?.('change', onSchemeChange);
301
459
  try { document.body.removeChild(host); } catch { /* best effort */ }
302
460
  };
303
461
 
@@ -344,14 +502,20 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
344
502
  };
345
503
 
346
504
  const renderQr = (handoff: WalletUriHandoff): void => {
505
+ const wallet = walletUrl !== undefined ? walletByUrl(walletUrl) : undefined;
506
+ const walletName = wallet?.name ?? 'your wallet';
507
+
347
508
  if (isMobile) {
348
509
  const link = document.createElement('a');
349
510
  link.className = 'deep-link';
350
511
  link.href = handoff.walletUri;
351
- 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}`));
352
516
  setStage(
353
517
  link,
354
- el('p', 'stage-caption', 'Your wallet opens to approve this connection.'),
518
+ el('p', 'stage-caption', `${walletName} opens to approve this connection.`),
355
519
  el('p', 'stage-subline', 'Come back here when you’re done.'),
356
520
  );
357
521
  return;
@@ -359,16 +523,28 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
359
523
 
360
524
  const qrBox = document.createElement('div');
361
525
  qrBox.className = 'qr-box';
526
+ // Fixed colors on the white card: dark-on-light QRs scan reliably in
527
+ // both appearances (inverted codes trip up some camera apps).
362
528
  qrBox.replaceChildren(qrToSvg(encodeQr(handoff.walletUri), {
363
- dark : isDark ? '#e8e8f0' : '#14141f',
529
+ dark : '#14141f',
364
530
  light : 'transparent',
365
531
  quietZone : 2,
366
532
  }));
367
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
+
368
544
  setStage(
369
545
  qrBox,
370
546
  el('p', 'stage-caption', 'Scan with your phone’s camera'),
371
- el('p', 'stage-subline', 'Your wallet stays on your phone, so you can approve things anywhere.'),
547
+ el('p', 'stage-subline', `${walletName} stays on your phone.`),
372
548
  );
373
549
  };
374
550
 
@@ -682,64 +858,111 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
682
858
  await startMethod();
683
859
  };
684
860
 
685
- // ── Footer: method link + wallet switcher ──────────────────
861
+ // ── Footer: identity row (+ catalog panel), method link below ──
686
862
  const renderFooter = (): void => {
687
863
  footer.replaceChildren();
688
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
+
875
+ const row = document.createElement('div');
876
+ row.className = 'footer-row';
877
+
689
878
  if (method === 'phone') {
690
879
  const alt = document.createElement('button');
691
880
  alt.className = 'footer-link method-link';
692
- alt.textContent = 'No phone handy? Use this browser →';
881
+ alt.textContent = 'No phone? Use this browser →';
693
882
  // startPopup runs synchronously in this click handler.
694
883
  alt.addEventListener('click', () => { startPopup(); });
695
- footer.appendChild(alt);
884
+ row.appendChild(alt);
696
885
  } else if (!isMobile) {
697
886
  const alt = document.createElement('button');
698
887
  alt.className = 'footer-link method-link';
699
888
  alt.textContent = 'Use your phone instead →';
700
889
  alt.addEventListener('click', () => { void switchMethod('phone'); });
701
- footer.appendChild(alt);
890
+ row.appendChild(alt);
702
891
  }
703
892
 
704
- if (!lockedWallet && wallets.length > 0) {
705
- footer.appendChild(buildWalletSwitcher());
893
+ if (row.childElementCount > 0) {
894
+ footer.appendChild(row);
706
895
  }
707
896
  };
708
897
 
709
- const buildWalletSwitcher = (): HTMLElement => {
710
- const wrap = document.createElement('div');
711
- wrap.className = 'wallet-switcher';
712
-
713
- const current = wallets.find((wallet) => wallet.url === walletUrl);
714
- const toggle = document.createElement('button');
715
- toggle.className = 'footer-link wallet-toggle';
716
- toggle.setAttribute('aria-expanded', 'false');
717
- toggle.textContent = `Wallet: ${current?.name ?? 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
+ }
718
925
 
719
926
  const panel = document.createElement('div');
720
927
  panel.className = 'wallet-panel';
721
928
  panel.hidden = true;
722
929
 
723
- // Search filter — only worth the chrome once the catalog grows.
724
- const rows: Array<{ wallet: (typeof wallets)[number]; el: HTMLButtonElement }> = [];
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
+
945
+ // Search filter — appears once the catalog outgrows one grid row.
946
+ const tiles: Array<{ wallet: (typeof wallets)[number]; el: HTMLButtonElement }> = [];
725
947
  const empty = document.createElement('div');
726
948
  empty.className = 'wallet-empty';
727
949
  empty.textContent = 'No wallets match your search.';
728
950
  empty.hidden = true;
729
951
 
730
- if (wallets.length > 5) {
952
+ if (wallets.length > WALLET_SEARCH_THRESHOLD) {
731
953
  const search = document.createElement('input');
732
954
  search.type = 'search';
733
955
  search.className = 'wallet-search';
734
956
  search.placeholder = 'Search wallets…';
957
+ search.setAttribute('aria-label', 'Search wallets');
735
958
  search.addEventListener('input', () => {
736
959
  const query = search.value.trim().toLowerCase();
737
960
  let visible = 0;
738
- for (const { wallet, el } of rows) {
961
+ for (const { wallet, el: tile } of tiles) {
739
962
  const match = query === ''
740
963
  || wallet.name.toLowerCase().includes(query)
741
964
  || wallet.url.toLowerCase().includes(query);
742
- el.hidden = !match;
965
+ tile.hidden = !match;
743
966
  if (match) { visible += 1; }
744
967
  }
745
968
  empty.hidden = visible > 0;
@@ -747,26 +970,35 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
747
970
  panel.appendChild(search);
748
971
  }
749
972
 
973
+ // Square tiles, three per row; past two rows the grid scrolls in
974
+ // place so a large catalog never stretches the modal.
975
+ const scroll = document.createElement('div');
976
+ scroll.className = 'wallet-scroll';
977
+ const grid = document.createElement('div');
978
+ grid.className = 'wallet-grid';
979
+
750
980
  for (const wallet of wallets) {
751
- const row = document.createElement('button');
752
- row.className = 'wallet-row';
753
- row.appendChild(buildWalletIcon(wallet));
754
- const rowName = document.createElement('span');
755
- rowName.className = 'wallet-row-name';
756
- rowName.textContent = wallet.name;
757
- row.appendChild(rowName);
981
+ const tile = document.createElement('button');
982
+ tile.className = 'wallet-tile';
983
+ tile.title = wallet.name;
984
+ tile.appendChild(buildWalletIcon(wallet));
985
+ const tileName = document.createElement('span');
986
+ tileName.className = 'wallet-tile-name';
987
+ tileName.textContent = wallet.name;
988
+ tile.appendChild(tileName);
758
989
  if (wallet.url === walletUrl) {
759
- row.classList.add('selected');
990
+ tile.classList.add('selected');
991
+ tile.setAttribute('aria-pressed', 'true');
760
992
  }
761
- row.addEventListener('click', () => {
762
- walletUrl = wallet.url;
763
- renderFooter();
764
- void startMethod();
993
+ tile.addEventListener('click', () => {
994
+ selectWallet(wallet.url);
765
995
  });
766
- rows.push({ wallet, el: row });
767
- panel.appendChild(row);
996
+ tiles.push({ wallet, el: tile });
997
+ grid.appendChild(tile);
768
998
  }
769
- panel.appendChild(empty);
999
+ scroll.appendChild(grid);
1000
+ scroll.appendChild(empty);
1001
+ panel.appendChild(scroll);
770
1002
 
771
1003
  // Custom wallet URL (the power-user path, demoted to the disclosure).
772
1004
  const custom = document.createElement('div');
@@ -787,12 +1019,6 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
787
1019
  // should not be permanently blocked.
788
1020
  let allowUnverified = false;
789
1021
 
790
- const useWallet = (origin: string): void => {
791
- walletUrl = origin;
792
- renderFooter();
793
- void startMethod();
794
- };
795
-
796
1022
  const submitCustom = async (): Promise<void> => {
797
1023
  const value = input.value.trim();
798
1024
  if (value === '') { return; }
@@ -804,7 +1030,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
804
1030
  return;
805
1031
  }
806
1032
  if (allowUnverified) {
807
- useWallet(origin);
1033
+ selectWallet(origin);
808
1034
  return;
809
1035
  }
810
1036
  go.disabled = true;
@@ -814,7 +1040,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
814
1040
  go.disabled = false;
815
1041
  if (valid) {
816
1042
  go.textContent = 'Use';
817
- useWallet(origin);
1043
+ selectWallet(origin);
818
1044
  return;
819
1045
  }
820
1046
  allowUnverified = true;
@@ -839,14 +1065,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
839
1065
  panel.appendChild(custom);
840
1066
  panel.appendChild(urlError);
841
1067
 
842
- toggle.addEventListener('click', () => {
843
- panel.hidden = !panel.hidden;
844
- toggle.setAttribute('aria-expanded', panel.hidden ? 'false' : 'true');
845
- });
846
-
847
- wrap.appendChild(toggle);
848
- wrap.appendChild(panel);
849
- return wrap;
1068
+ return { label, row, panel };
850
1069
  };
851
1070
 
852
1071
  // ── Global interactions ────────────────────────────────────
@@ -882,6 +1101,23 @@ function walletInCatalog(wallets: WalletOption[], url: string): boolean {
882
1101
  return wallets.some((wallet) => wallet.url === url);
883
1102
  }
884
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
+
1109
+ /** Compact display form of a wallet URL for the switcher toggle. */
1110
+ function hostnameOf(url: string | undefined): string | undefined {
1111
+ if (url === undefined) {
1112
+ return undefined;
1113
+ }
1114
+ try {
1115
+ return new URL(url).hostname;
1116
+ } catch {
1117
+ return url;
1118
+ }
1119
+ }
1120
+
885
1121
  function el(tag: string, className: string, text?: string): HTMLElement {
886
1122
  const node = document.createElement(tag);
887
1123
  node.className = className;
@@ -963,21 +1199,45 @@ function trapFocus(shadow: ShadowRoot, event: KeyboardEvent): void {
963
1199
  }
964
1200
 
965
1201
  // ─── Styles ──────────────────────────────────────────────────────
966
-
967
- function buildStyles(isDark: boolean): string {
968
- const bg = isDark ? '#1a1a2e' : '#ffffff';
969
- const text = isDark ? '#e0e0e0' : '#1a1a2e';
970
- const muted = isDark ? '#888' : '#666';
971
- const border = isDark ? '#333' : '#e0e0e0';
972
- const itemBg = isDark ? '#16213e' : '#f8f9fa';
973
- const accent = isDark ? '#4a9eff' : '#0066cc';
974
- const accentSoft = isDark ? 'rgba(74, 158, 255, 0.16)' : 'rgba(0, 102, 204, 0.1)';
975
- const danger = isDark ? '#ff6b6b' : '#c0392b';
976
- const success = isDark ? '#43d17c' : '#1e9e5a';
977
-
978
- return `
1202
+ //
1203
+ // One static sheet; every color reads a `--ec-*` token. Light values sit on
1204
+ // `.modal`, dark values apply under `prefers-color-scheme` (so the system
1205
+ // appearance tracks live) and under a forced `data-appearance`. Dapp
1206
+ // palette overrides land as inline custom properties, which outrank all of
1207
+ // these — nothing user-provided is ever concatenated into this string.
1208
+
1209
+ /** Light-appearance design tokens. */
1210
+ const LIGHT_TOKENS = `
1211
+ --ec-bg: #ffffff;
1212
+ --ec-surface: #f5f6f8;
1213
+ --ec-text: #17171f;
1214
+ --ec-muted: #63636e;
1215
+ --ec-border: #e3e4e9;
1216
+ --ec-accent: #0a62d0;
1217
+ --ec-accent-contrast: #ffffff;
1218
+ --ec-danger: #c0392b;
1219
+ --ec-success: #1e9e5a;`;
1220
+
1221
+ /** Dark-appearance design tokens. */
1222
+ const DARK_TOKENS = `
1223
+ --ec-bg: #181822;
1224
+ --ec-surface: #23232f;
1225
+ --ec-text: #e9e9f0;
1226
+ --ec-muted: #9a9aa6;
1227
+ --ec-border: #33333f;
1228
+ --ec-accent: #58a6ff;
1229
+ --ec-accent-contrast: #0b1526;
1230
+ --ec-danger: #ff7a7a;
1231
+ --ec-success: #43d17c;`;
1232
+
1233
+ const MODAL_STYLES = `
979
1234
  * { box-sizing: border-box; margin: 0; padding: 0; }
980
1235
 
1236
+ /* Keep the hidden attribute authoritative: author display values (e.g.
1237
+ the panel's flex) would otherwise override the UA's [hidden] rule and
1238
+ leave "collapsed" sections permanently expanded. */
1239
+ [hidden] { display: none !important; }
1240
+
981
1241
  .overlay {
982
1242
  position: fixed;
983
1243
  inset: 0;
@@ -985,6 +1245,7 @@ function buildStyles(isDark: boolean): string {
985
1245
  display: flex;
986
1246
  align-items: center;
987
1247
  justify-content: center;
1248
+ padding: 16px;
988
1249
  background: rgba(0, 0, 0, 0.5);
989
1250
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
990
1251
  animation: fadeIn 0.15s ease-out;
@@ -999,115 +1260,159 @@ function buildStyles(isDark: boolean): string {
999
1260
  40%, 80% { transform: translateX(6px); }
1000
1261
  }
1001
1262
  @keyframes spin { to { transform: rotate(360deg); } }
1263
+ @keyframes claimedPulse {
1264
+ 0%, 100% { transform: scale(0.85); opacity: 0.7; }
1265
+ 50% { transform: scale(1); opacity: 1; }
1266
+ }
1002
1267
 
1003
- .modal {
1004
- background: ${bg};
1005
- color: ${text};
1268
+ .modal {${LIGHT_TOKENS}
1269
+ --ec-accent-soft: color-mix(in srgb, var(--ec-accent) 14%, transparent);
1270
+ background: var(--ec-bg);
1271
+ color: var(--ec-text);
1006
1272
  border-radius: 16px;
1007
1273
  width: 400px;
1008
- max-width: 92vw;
1009
- max-height: 88vh;
1274
+ max-width: 100%;
1275
+ max-height: calc(100vh - 32px);
1010
1276
  overflow-y: auto;
1011
- padding: 20px 24px 16px;
1012
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
1277
+ padding: 16px 20px 12px;
1278
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
1013
1279
  animation: slideUp 0.2s ease-out;
1014
1280
  display: flex;
1015
1281
  flex-direction: column;
1016
1282
  }
1283
+ @media (prefers-color-scheme: dark) {
1284
+ .modal {${DARK_TOKENS}
1285
+ }
1286
+ }
1287
+ .modal[data-appearance='light'] {${LIGHT_TOKENS}
1288
+ }
1289
+ .modal[data-appearance='dark'] {${DARK_TOKENS}
1290
+ }
1017
1291
 
1018
- .header { display: flex; justify-content: space-between; align-items: center; }
1292
+ button, input { font-family: inherit; }
1293
+ button:focus-visible, a:focus-visible {
1294
+ outline: 2px solid var(--ec-accent);
1295
+ outline-offset: 2px;
1296
+ }
1297
+
1298
+ .header { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
1019
1299
  .header-identity { display: flex; align-items: center; gap: 10px; min-width: 0; }
1020
- .header-icon { border-radius: 6px; }
1300
+ .header-icon { border-radius: 6px; flex-shrink: 0; }
1021
1301
  h2 { font-size: 16px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1022
1302
 
1023
1303
  .close-btn {
1024
1304
  background: none; border: none; font-size: 24px; cursor: pointer;
1025
- color: ${muted}; padding: 4px 8px; border-radius: 8px; line-height: 1;
1305
+ color: var(--ec-muted); padding: 4px 8px; border-radius: 8px; line-height: 1;
1306
+ flex-shrink: 0;
1026
1307
  }
1027
- .close-btn:hover { color: ${text}; background: ${itemBg}; }
1308
+ .close-btn:hover { color: var(--ec-text); background: var(--ec-surface); }
1028
1309
 
1029
1310
  .stage {
1030
- min-height: 300px;
1311
+ min-height: 240px;
1031
1312
  display: flex;
1032
1313
  flex-direction: column;
1033
1314
  align-items: center;
1034
1315
  justify-content: center;
1035
- gap: 12px;
1316
+ gap: 10px;
1036
1317
  text-align: center;
1037
- padding: 16px 0;
1318
+ padding: 12px 0 10px;
1038
1319
  }
1039
1320
  .stage.fade-in > * { animation: stageIn 0.15s ease-out; }
1040
1321
 
1041
1322
  .qr-box {
1042
- width: 216px;
1043
- height: 216px;
1044
- padding: 12px;
1323
+ position: relative;
1324
+ width: 200px;
1325
+ height: 200px;
1326
+ padding: 10px;
1045
1327
  border-radius: 12px;
1046
- background: ${isDark ? '#10192e' : '#ffffff'}
1328
+ background: #ffffff;
1329
+ border: 1px solid #e3e4e9;
1330
+ }
1331
+ .qr-box svg { width: 100%; height: 100%; display: block; }
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
+
1047
1352
  .claimed-pulse {
1048
1353
  width: 64px; height: 64px; border-radius: 50%;
1049
- background: ${accentSoft}; position: relative;
1354
+ background: var(--ec-accent-soft); position: relative;
1050
1355
  }
1051
1356
  .claimed-pulse::after {
1052
1357
  content: ''; position: absolute; inset: 18px; border-radius: 50%;
1053
- background: ${accent};
1054
- animation: enbox-claimed-pulse 1.6s ease-in-out infinite;
1055
- }
1056
- @keyframes enbox-claimed-pulse {
1057
- 0%, 100% { transform: scale(0.85); opacity: 0.7; }
1058
- 50% { transform: scale(1); opacity: 1; }
1358
+ background: var(--ec-accent);
1359
+ animation: claimedPulse 1.6s ease-in-out infinite;
1059
1360
  }
1060
- @media (prefers-reduced-motion: reduce) {
1061
- .claimed-pulse::after { animation: none; }
1062
- };
1063
- border: 1px solid ${border};
1064
- }
1065
- .qr-box svg { width: 100%; height: 100%; display: block; }
1066
1361
 
1067
1362
  .stage-caption { font-size: 15px; font-weight: 600; }
1068
- .stage-subline { font-size: 13px; color: ${muted}; max-width: 300px; line-height: 1.45; }
1069
- .stage-error { font-size: 13px; color: ${danger}; }
1363
+ .stage-subline { font-size: 13px; color: var(--ec-muted); max-width: 300px; line-height: 1.45; }
1364
+ .stage-error { font-size: 13px; color: var(--ec-danger); }
1070
1365
 
1071
1366
  .deep-link {
1072
- display: block;
1367
+ display: flex;
1368
+ align-items: center;
1369
+ justify-content: center;
1370
+ gap: 10px;
1073
1371
  width: 100%;
1074
- padding: 14px 16px;
1372
+ padding: 13px 16px;
1075
1373
  border-radius: 12px;
1076
- background: ${accent};
1077
- color: #fff;
1374
+ background: var(--ec-accent);
1375
+ color: var(--ec-accent-contrast);
1078
1376
  font-weight: 600;
1079
1377
  font-size: 15px;
1080
1378
  text-decoration: none;
1081
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; }
1082
1387
 
1083
1388
  .pin-row { display: flex; gap: 10px; }
1084
1389
  .pin-row.shake { animation: shake 0.4s ease-in-out; }
1085
1390
  .pin-input {
1086
- width: 48px; height: 56px;
1391
+ width: 46px; height: 54px;
1087
1392
  text-align: center;
1088
1393
  font-size: 24px; font-weight: 600;
1089
- color: ${text};
1090
- background: ${itemBg};
1091
- border: 1px solid ${border};
1394
+ color: var(--ec-text);
1395
+ background: var(--ec-surface);
1396
+ border: 1px solid var(--ec-border);
1092
1397
  border-radius: 10px;
1093
1398
  outline: none;
1094
1399
  }
1095
- .pin-input:focus { border-color: ${accent}; }
1400
+ .pin-input:focus { border-color: var(--ec-accent); }
1096
1401
 
1097
1402
  .check {
1098
1403
  width: 56px; height: 56px;
1099
1404
  display: flex; align-items: center; justify-content: center;
1100
1405
  border-radius: 50%;
1101
- background: ${success};
1102
- color: #fff;
1406
+ background: var(--ec-success);
1407
+ color: #ffffff;
1103
1408
  font-size: 28px;
1104
1409
  }
1105
1410
 
1106
1411
  .spinner {
1107
1412
  width: 32px; height: 32px;
1108
1413
  border-radius: 50%;
1109
- border: 3px solid ${border};
1110
- border-top-color: ${accent};
1414
+ border: 3px solid var(--ec-border);
1415
+ border-top-color: var(--ec-accent);
1111
1416
  animation: spin 0.8s linear infinite;
1112
1417
  }
1113
1418
 
@@ -1115,87 +1420,169 @@ function buildStyles(isDark: boolean): string {
1115
1420
  padding: 10px 20px;
1116
1421
  border: none;
1117
1422
  border-radius: 10px;
1118
- background: ${accent};
1119
- color: #fff;
1423
+ background: var(--ec-accent);
1424
+ color: var(--ec-accent-contrast);
1120
1425
  font-size: 14px;
1121
1426
  font-weight: 500;
1122
1427
  cursor: pointer;
1123
1428
  }
1124
- .stage-btn:hover { filter: brightness(1.1); }
1429
+ .stage-btn:hover { filter: brightness(1.08); }
1125
1430
 
1126
1431
  .footer {
1127
- border-top: 1px solid ${border};
1128
- padding-top: 10px;
1432
+ border-top: 1px solid var(--ec-border);
1433
+ padding-top: 8px;
1129
1434
  display: flex;
1130
1435
  flex-direction: column;
1131
1436
  gap: 6px;
1437
+ }
1438
+
1439
+ .footer-row {
1440
+ display: flex;
1132
1441
  align-items: center;
1442
+ justify-content: space-between;
1443
+ flex-wrap: wrap;
1444
+ gap: 2px 8px;
1133
1445
  }
1446
+ .footer-row > * { min-width: 0; }
1447
+ .footer-row > :only-child { margin-inline: auto; }
1134
1448
 
1135
1449
  .footer-link {
1136
1450
  background: none;
1137
1451
  border: none;
1138
- color: ${muted};
1452
+ color: var(--ec-muted);
1139
1453
  font-size: 13px;
1140
1454
  cursor: pointer;
1141
- padding: 4px 8px;
1455
+ padding: 5px 8px;
1142
1456
  border-radius: 8px;
1143
1457
  }
1144
- .footer-link:hover { color: ${text}; background: ${itemBg}; }
1145
- .method-link { color: ${accent}; }
1458
+ .footer-link:hover { color: var(--ec-text); background: var(--ec-surface); }
1459
+ .method-link { color: var(--ec-accent); }
1460
+
1461
+ .wallet-row-label { font-size: 12px; color: var(--ec-muted); padding: 2px 2px 0; }
1146
1462
 
1147
- .wallet-switcher { width: 100%; display: flex; flex-direction: column; align-items: center; }
1148
- .wallet-panel { width: 100%; padding: 6px 0; display: flex; flex-direction: column; gap: 4px; }
1149
1463
  .wallet-row {
1150
- width: 100%;
1151
- text-align: left;
1152
- padding: 8px 12px;
1153
- background: ${itemBg};
1154
- border: 1px solid ${border};
1155
- border-radius: 10px;
1156
- color: ${text};
1157
- font-size: 13px;
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
+ }
1508
+
1509
+ .wallet-panel { width: 100%; padding: 4px 0 2px; display: flex; flex-direction: column; gap: 8px; }
1510
+ .wallet-search {
1511
+ width: 100%; padding: 8px 12px;
1512
+ border-radius: 10px; border: 1px solid var(--ec-border); background: transparent;
1513
+ color: inherit; font: inherit; font-size: 13px;
1514
+ }
1515
+ .wallet-search:focus { outline: none; border-color: var(--ec-accent); }
1516
+
1517
+ .wallet-scroll { max-height: 260px; overflow-y: auto; }
1518
+ .wallet-grid {
1519
+ 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;
1529
+ 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;
1158
1537
  cursor: pointer;
1159
1538
  }
1160
- .wallet-row:hover, .wallet-row.selected { border-color: ${accent}; }
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
+ }
1545
+ .wallet-tile-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1161
1546
 
1162
1547
  .wallet-icon { position: relative; width: 28px; height: 28px; flex-shrink: 0; display: inline-flex; }
1548
+ .wallet-tile .wallet-icon { width: 40px; height: 40px; }
1163
1549
  .wallet-badge {
1164
- width: 28px; height: 28px; border-radius: 8px; display: inline-flex;
1550
+ width: 100%; height: 100%; border-radius: 8px; display: inline-flex;
1165
1551
  align-items: center; justify-content: center; font-size: 13px;
1166
- font-weight: 600; background: ${accentSoft}; color: ${accent};
1552
+ font-weight: 600; background: var(--ec-accent-soft); color: var(--ec-accent);
1167
1553
  }
1168
- .wallet-img { position: absolute; inset: 0; width: 28px; height: 28px; border-radius: 8px; object-fit: cover; }
1169
- .wallet-row-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1170
- .wallet-search {
1171
- width: 100%; box-sizing: border-box; padding: 8px 10px; margin-bottom: 4px;
1172
- border-radius: 8px; border: 1px solid ${border}; background: transparent;
1173
- color: inherit; font: inherit;
1174
- }
1175
- .wallet-search:focus { outline: none; border-color: ${accent}; }
1176
- .wallet-empty { padding: 8px 10px; font-size: 12px; opacity: 0.7; }
1177
- .url-error { padding: 4px 2px 0; font-size: 12px; color: ${danger}; }
1178
- .wallet-custom { display: flex; gap: 6px; margin-top: 4px; }
1554
+ .wallet-tile .wallet-badge { border-radius: 10px; font-size: 17px; }
1555
+ .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
+
1558
+ .wallet-empty { padding: 10px 4px; font-size: 12px; color: var(--ec-muted); text-align: center; }
1559
+
1560
+ .wallet-custom { display: flex; gap: 6px; }
1179
1561
  .url-input {
1180
1562
  flex: 1;
1181
1563
  padding: 8px 12px;
1182
- border: 1px solid ${border};
1564
+ border: 1px solid var(--ec-border);
1183
1565
  border-radius: 10px;
1184
1566
  font-size: 13px;
1185
- background: ${itemBg};
1186
- color: ${text};
1567
+ background: var(--ec-surface);
1568
+ color: var(--ec-text);
1187
1569
  outline: none;
1188
1570
  }
1189
- .url-input:focus { border-color: ${accent}; }
1190
- .url-input.invalid { border-color: ${danger}; }
1571
+ .url-input:focus { border-color: var(--ec-accent); }
1572
+ .url-input.invalid { border-color: var(--ec-danger); }
1191
1573
  .url-go {
1192
1574
  padding: 8px 14px;
1193
1575
  border: none;
1194
1576
  border-radius: 10px;
1195
- background: ${accent};
1196
- color: #fff;
1577
+ background: var(--ec-accent);
1578
+ color: var(--ec-accent-contrast);
1197
1579
  font-size: 13px;
1198
1580
  cursor: pointer;
1199
1581
  }
1200
- `;
1201
- }
1582
+ .url-error { font-size: 12px; color: var(--ec-danger); }
1583
+
1584
+ @media (prefers-reduced-motion: reduce) {
1585
+ .overlay, .modal, .stage.fade-in > *, .pin-row.shake { animation: none; }
1586
+ .claimed-pulse::after { animation: none; }
1587
+ }
1588
+ `;