@enbox/browser 0.3.44 → 0.3.46

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,18 @@
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** — 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).
19
+ *
20
+ * Theming: the stylesheet is static and reads `--ec-*` design tokens.
21
+ * Light/dark follows the visitor's system via `prefers-color-scheme` and
22
+ * updates live; the embedding app can force an appearance or override
23
+ * palette tokens through {@link ConnectModalOptions.theme}. Overrides are
24
+ * applied as inline custom properties — never interpolated into the
25
+ * stylesheet — so a hostile string can't escape its declaration. The QR
26
+ * card alone is theme-invariant (dark modules on white scan most
27
+ * reliably).
19
28
  *
20
29
  * The phone path drives the `@enbox/connect` relay transport via
21
30
  * {@link runRelayConnect}; the browser path drives the popup transport via
@@ -56,6 +65,74 @@ const REMINT_MIN_MS = 10_000;
56
65
  /** Pairing code length (matches the wallet's generated PIN). */
57
66
  const PIN_LENGTH = 4;
58
67
 
68
+ /** Catalog size beyond which the wallet switcher shows its search bar. */
69
+ const WALLET_SEARCH_THRESHOLD = 4; // one full row of tiles
70
+
71
+ /** Palette overrides for one appearance's design tokens. */
72
+ export interface ConnectModalPalette {
73
+ /** Modal surface. */
74
+ background?: string;
75
+
76
+ /** Inset surfaces: tiles, inputs, hover fills. */
77
+ surface?: string;
78
+
79
+ /** Primary text. */
80
+ text?: string;
81
+
82
+ /** Secondary text. */
83
+ textMuted?: string;
84
+
85
+ /** Hairline borders and dividers. */
86
+ border?: string;
87
+
88
+ /** Brand accent: buttons, links, focus rings, selection. */
89
+ accent?: string;
90
+
91
+ /** Text/icon color on accent surfaces. */
92
+ accentContrast?: string;
93
+
94
+ /** Error text and invalid-input borders. */
95
+ danger?: string;
96
+
97
+ /** Success surfaces (the connected check). */
98
+ success?: string;
99
+ }
100
+
101
+ /** Optional color scheme provided by the embedding app. */
102
+ export interface ConnectModalTheme {
103
+ /**
104
+ * Follow the visitor's system appearance (`'auto'`, live-updating) or
105
+ * force one.
106
+ * @default 'auto'
107
+ */
108
+ appearance?: 'auto' | 'light' | 'dark';
109
+
110
+ /** Brand accent applied in both appearances (per-scheme palettes win). */
111
+ accent?: string;
112
+
113
+ /** Text/icon color on accent surfaces, in both appearances. */
114
+ accentContrast?: string;
115
+
116
+ /** Token overrides applied while the light appearance is active. */
117
+ light?: ConnectModalPalette;
118
+
119
+ /** Token overrides applied while the dark appearance is active. */
120
+ dark?: ConnectModalPalette;
121
+ }
122
+
123
+ /** Design-token custom property behind each palette key. */
124
+ const PALETTE_TOKENS: Record<keyof ConnectModalPalette, string> = {
125
+ background : '--ec-bg',
126
+ surface : '--ec-surface',
127
+ text : '--ec-text',
128
+ textMuted : '--ec-muted',
129
+ border : '--ec-border',
130
+ accent : '--ec-accent',
131
+ accentContrast : '--ec-accent-contrast',
132
+ danger : '--ec-danger',
133
+ success : '--ec-success',
134
+ };
135
+
59
136
  /** Options for {@link runConnectModal}. */
60
137
  export interface ConnectModalOptions {
61
138
  /** Wallet catalog shown in the wallet switcher. */
@@ -99,6 +176,13 @@ export interface ConnectModalOptions {
99
176
  /** Timeout in milliseconds for either method's handshake. */
100
177
  timeout?: number;
101
178
 
179
+ /**
180
+ * Color scheme. By default the modal follows the visitor's system
181
+ * light/dark appearance; apps can force an appearance and/or override
182
+ * palette tokens per scheme to match their brand.
183
+ */
184
+ theme?: ConnectModalTheme;
185
+
102
186
  /** DWN protocols and permission scopes being requested. */
103
187
  permissionRequests: ConnectPermissionRequest[];
104
188
 
@@ -273,9 +357,8 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
273
357
  host.id = 'enbox-connect-modal';
274
358
  const shadow = host.attachShadow({ mode: 'open' });
275
359
 
276
- const isDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
277
360
  const style = document.createElement('style');
278
- style.textContent = buildStyles(isDark);
361
+ style.textContent = MODAL_STYLES;
279
362
  shadow.appendChild(style);
280
363
 
281
364
  const overlay = document.createElement('div');
@@ -286,6 +369,47 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
286
369
  modal.setAttribute('aria-modal', 'true');
287
370
  modal.setAttribute('aria-label', `Connect to ${appName}`);
288
371
 
372
+ // ── Theme ──────────────────────────────────────────────────
373
+ // System light/dark is pure CSS (`prefers-color-scheme`), so it tracks
374
+ // the visitor live. A forced appearance pins the token block instead.
375
+ const theme = options.theme ?? {};
376
+ const appearance = theme.appearance ?? 'auto';
377
+ if (appearance !== 'auto') {
378
+ modal.setAttribute('data-appearance', appearance);
379
+ }
380
+
381
+ // Dapp palette overrides land as inline custom properties: setProperty
382
+ // parses each value as a lone declaration, so a hostile string cannot
383
+ // escape into the stylesheet.
384
+ const systemDark = window.matchMedia?.('(prefers-color-scheme: dark)');
385
+ const hasPaletteOverrides = theme.accent !== undefined || theme.accentContrast !== undefined
386
+ || theme.light !== undefined || theme.dark !== undefined;
387
+ const applyPaletteTokens = (): void => {
388
+ const scheme = appearance === 'auto'
389
+ ? (systemDark?.matches === true ? 'dark' : 'light')
390
+ : appearance;
391
+ const palette: ConnectModalPalette = {
392
+ accent : theme.accent,
393
+ accentContrast : theme.accentContrast,
394
+ ...theme[scheme],
395
+ };
396
+ for (const key of Object.keys(PALETTE_TOKENS) as Array<keyof ConnectModalPalette>) {
397
+ const value = palette[key];
398
+ if (typeof value === 'string' && value.trim() !== '') {
399
+ modal.style.setProperty(PALETTE_TOKENS[key], value);
400
+ } else {
401
+ modal.style.removeProperty(PALETTE_TOKENS[key]);
402
+ }
403
+ }
404
+ };
405
+ const onSchemeChange = (): void => applyPaletteTokens();
406
+ if (hasPaletteOverrides) {
407
+ applyPaletteTokens();
408
+ if (appearance === 'auto') {
409
+ systemDark?.addEventListener?.('change', onSchemeChange);
410
+ }
411
+ }
412
+
289
413
  // ── Session state ──────────────────────────────────────────
290
414
  let settled = false;
291
415
  let relaySession: RelaySession | undefined;
@@ -298,6 +422,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
298
422
  relaySession?.cancel();
299
423
  clearTimeout(remintTimer);
300
424
  document.removeEventListener('keydown', onKeydown);
425
+ systemDark?.removeEventListener?.('change', onSchemeChange);
301
426
  try { document.body.removeChild(host); } catch { /* best effort */ }
302
427
  };
303
428
 
@@ -359,8 +484,10 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
359
484
 
360
485
  const qrBox = document.createElement('div');
361
486
  qrBox.className = 'qr-box';
487
+ // Fixed colors on the white card: dark-on-light QRs scan reliably in
488
+ // both appearances (inverted codes trip up some camera apps).
362
489
  qrBox.replaceChildren(qrToSvg(encodeQr(handoff.walletUri), {
363
- dark : isDark ? '#e8e8f0' : '#14141f',
490
+ dark : '#14141f',
364
491
  light : 'transparent',
365
492
  quietZone : 2,
366
493
  }));
@@ -368,7 +495,17 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
368
495
  setStage(
369
496
  qrBox,
370
497
  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.'),
498
+ el('p', 'stage-subline', 'Your wallet stays on your phone.'),
499
+ );
500
+ };
501
+
502
+ const renderClaimed = (): void => {
503
+ const pulse = document.createElement('div');
504
+ pulse.className = 'claimed-pulse';
505
+ setStage(
506
+ pulse,
507
+ el('p', 'stage-caption', 'Phone connected — finish there'),
508
+ el('p', 'stage-subline', 'Approve the request on your phone, then come back here.'),
372
509
  );
373
510
  };
374
511
 
@@ -562,6 +699,14 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
562
699
  scheduleRemint(handoff.expiresIn);
563
700
  }
564
701
  },
702
+ onClaimed: (): void => {
703
+ if (session.active && !settled) {
704
+ // The phone has the request — stop re-minting (a new pointer
705
+ // would orphan the approval in progress) and show progress.
706
+ clearTimeout(remintTimer);
707
+ renderClaimed();
708
+ }
709
+ },
565
710
  requestPin: (attempt, previousError): Promise<string> => {
566
711
  clearTimeout(remintTimer);
567
712
  return renderPin(attempt, previousError);
@@ -664,64 +809,75 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
664
809
  await startMethod();
665
810
  };
666
811
 
667
- // ── Footer: method link + wallet switcher ──────────────────
812
+ // ── Footer: one row (method link + wallet toggle), panel below ──
668
813
  const renderFooter = (): void => {
669
814
  footer.replaceChildren();
670
815
 
816
+ const row = document.createElement('div');
817
+ row.className = 'footer-row';
818
+
671
819
  if (method === 'phone') {
672
820
  const alt = document.createElement('button');
673
821
  alt.className = 'footer-link method-link';
674
- alt.textContent = 'No phone handy? Use this browser →';
822
+ alt.textContent = 'No phone? Use this browser →';
675
823
  // startPopup runs synchronously in this click handler.
676
824
  alt.addEventListener('click', () => { startPopup(); });
677
- footer.appendChild(alt);
825
+ row.appendChild(alt);
678
826
  } else if (!isMobile) {
679
827
  const alt = document.createElement('button');
680
828
  alt.className = 'footer-link method-link';
681
829
  alt.textContent = 'Use your phone instead →';
682
830
  alt.addEventListener('click', () => { void switchMethod('phone'); });
683
- footer.appendChild(alt);
831
+ row.appendChild(alt);
684
832
  }
685
833
 
834
+ let panel: HTMLElement | undefined;
686
835
  if (!lockedWallet && wallets.length > 0) {
687
- footer.appendChild(buildWalletSwitcher());
836
+ const switcher = buildWalletSwitcher();
837
+ row.appendChild(switcher.toggle);
838
+ panel = switcher.panel;
688
839
  }
689
- };
690
840
 
691
- const buildWalletSwitcher = (): HTMLElement => {
692
- const wrap = document.createElement('div');
693
- wrap.className = 'wallet-switcher';
841
+ if (row.childElementCount > 0) {
842
+ footer.appendChild(row);
843
+ }
844
+ if (panel !== undefined) {
845
+ footer.appendChild(panel);
846
+ }
847
+ };
694
848
 
849
+ const buildWalletSwitcher = (): { toggle: HTMLButtonElement; panel: HTMLDivElement } => {
695
850
  const current = wallets.find((wallet) => wallet.url === walletUrl);
696
851
  const toggle = document.createElement('button');
697
852
  toggle.className = 'footer-link wallet-toggle';
698
853
  toggle.setAttribute('aria-expanded', 'false');
699
- toggle.textContent = `Wallet: ${current?.name ?? walletUrl ?? 'Choose'} ▾`;
854
+ toggle.textContent = `Wallet: ${current?.name ?? hostnameOf(walletUrl) ?? 'Choose'} ▾`;
700
855
 
701
856
  const panel = document.createElement('div');
702
857
  panel.className = 'wallet-panel';
703
858
  panel.hidden = true;
704
859
 
705
- // Search filter — only worth the chrome once the catalog grows.
706
- const rows: Array<{ wallet: (typeof wallets)[number]; el: HTMLButtonElement }> = [];
860
+ // Search filter — appears once the catalog outgrows one grid row.
861
+ const tiles: Array<{ wallet: (typeof wallets)[number]; el: HTMLButtonElement }> = [];
707
862
  const empty = document.createElement('div');
708
863
  empty.className = 'wallet-empty';
709
864
  empty.textContent = 'No wallets match your search.';
710
865
  empty.hidden = true;
711
866
 
712
- if (wallets.length > 5) {
867
+ if (wallets.length > WALLET_SEARCH_THRESHOLD) {
713
868
  const search = document.createElement('input');
714
869
  search.type = 'search';
715
870
  search.className = 'wallet-search';
716
871
  search.placeholder = 'Search wallets…';
872
+ search.setAttribute('aria-label', 'Search wallets');
717
873
  search.addEventListener('input', () => {
718
874
  const query = search.value.trim().toLowerCase();
719
875
  let visible = 0;
720
- for (const { wallet, el } of rows) {
876
+ for (const { wallet, el: tile } of tiles) {
721
877
  const match = query === ''
722
878
  || wallet.name.toLowerCase().includes(query)
723
879
  || wallet.url.toLowerCase().includes(query);
724
- el.hidden = !match;
880
+ tile.hidden = !match;
725
881
  if (match) { visible += 1; }
726
882
  }
727
883
  empty.hidden = visible > 0;
@@ -729,26 +885,37 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
729
885
  panel.appendChild(search);
730
886
  }
731
887
 
888
+ // Square tiles, three per row; past two rows the grid scrolls in
889
+ // place so a large catalog never stretches the modal.
890
+ const scroll = document.createElement('div');
891
+ scroll.className = 'wallet-scroll';
892
+ const grid = document.createElement('div');
893
+ grid.className = 'wallet-grid';
894
+
732
895
  for (const wallet of wallets) {
733
- const row = document.createElement('button');
734
- row.className = 'wallet-row';
735
- row.appendChild(buildWalletIcon(wallet));
736
- const rowName = document.createElement('span');
737
- rowName.className = 'wallet-row-name';
738
- rowName.textContent = wallet.name;
739
- row.appendChild(rowName);
896
+ const tile = document.createElement('button');
897
+ tile.className = 'wallet-tile';
898
+ tile.title = wallet.name;
899
+ tile.appendChild(buildWalletIcon(wallet));
900
+ const tileName = document.createElement('span');
901
+ tileName.className = 'wallet-tile-name';
902
+ tileName.textContent = wallet.name;
903
+ tile.appendChild(tileName);
740
904
  if (wallet.url === walletUrl) {
741
- row.classList.add('selected');
905
+ tile.classList.add('selected');
906
+ tile.setAttribute('aria-pressed', 'true');
742
907
  }
743
- row.addEventListener('click', () => {
908
+ tile.addEventListener('click', () => {
744
909
  walletUrl = wallet.url;
745
910
  renderFooter();
746
911
  void startMethod();
747
912
  });
748
- rows.push({ wallet, el: row });
749
- panel.appendChild(row);
913
+ tiles.push({ wallet, el: tile });
914
+ grid.appendChild(tile);
750
915
  }
751
- panel.appendChild(empty);
916
+ scroll.appendChild(grid);
917
+ scroll.appendChild(empty);
918
+ panel.appendChild(scroll);
752
919
 
753
920
  // Custom wallet URL (the power-user path, demoted to the disclosure).
754
921
  const custom = document.createElement('div');
@@ -826,9 +993,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
826
993
  toggle.setAttribute('aria-expanded', panel.hidden ? 'false' : 'true');
827
994
  });
828
995
 
829
- wrap.appendChild(toggle);
830
- wrap.appendChild(panel);
831
- return wrap;
996
+ return { toggle, panel };
832
997
  };
833
998
 
834
999
  // ── Global interactions ────────────────────────────────────
@@ -864,6 +1029,18 @@ function walletInCatalog(wallets: WalletOption[], url: string): boolean {
864
1029
  return wallets.some((wallet) => wallet.url === url);
865
1030
  }
866
1031
 
1032
+ /** Compact display form of a wallet URL for the switcher toggle. */
1033
+ function hostnameOf(url: string | undefined): string | undefined {
1034
+ if (url === undefined) {
1035
+ return undefined;
1036
+ }
1037
+ try {
1038
+ return new URL(url).hostname;
1039
+ } catch {
1040
+ return url;
1041
+ }
1042
+ }
1043
+
867
1044
  function el(tag: string, className: string, text?: string): HTMLElement {
868
1045
  const node = document.createElement(tag);
869
1046
  node.className = className;
@@ -945,21 +1122,45 @@ function trapFocus(shadow: ShadowRoot, event: KeyboardEvent): void {
945
1122
  }
946
1123
 
947
1124
  // ─── Styles ──────────────────────────────────────────────────────
948
-
949
- function buildStyles(isDark: boolean): string {
950
- const bg = isDark ? '#1a1a2e' : '#ffffff';
951
- const text = isDark ? '#e0e0e0' : '#1a1a2e';
952
- const muted = isDark ? '#888' : '#666';
953
- const border = isDark ? '#333' : '#e0e0e0';
954
- const itemBg = isDark ? '#16213e' : '#f8f9fa';
955
- const accent = isDark ? '#4a9eff' : '#0066cc';
956
- const accentSoft = isDark ? 'rgba(74, 158, 255, 0.16)' : 'rgba(0, 102, 204, 0.1)';
957
- const danger = isDark ? '#ff6b6b' : '#c0392b';
958
- const success = isDark ? '#43d17c' : '#1e9e5a';
959
-
960
- return `
1125
+ //
1126
+ // One static sheet; every color reads a `--ec-*` token. Light values sit on
1127
+ // `.modal`, dark values apply under `prefers-color-scheme` (so the system
1128
+ // appearance tracks live) and under a forced `data-appearance`. Dapp
1129
+ // palette overrides land as inline custom properties, which outrank all of
1130
+ // these — nothing user-provided is ever concatenated into this string.
1131
+
1132
+ /** Light-appearance design tokens. */
1133
+ const LIGHT_TOKENS = `
1134
+ --ec-bg: #ffffff;
1135
+ --ec-surface: #f5f6f8;
1136
+ --ec-text: #17171f;
1137
+ --ec-muted: #63636e;
1138
+ --ec-border: #e3e4e9;
1139
+ --ec-accent: #0a62d0;
1140
+ --ec-accent-contrast: #ffffff;
1141
+ --ec-danger: #c0392b;
1142
+ --ec-success: #1e9e5a;`;
1143
+
1144
+ /** Dark-appearance design tokens. */
1145
+ const DARK_TOKENS = `
1146
+ --ec-bg: #181822;
1147
+ --ec-surface: #23232f;
1148
+ --ec-text: #e9e9f0;
1149
+ --ec-muted: #9a9aa6;
1150
+ --ec-border: #33333f;
1151
+ --ec-accent: #58a6ff;
1152
+ --ec-accent-contrast: #0b1526;
1153
+ --ec-danger: #ff7a7a;
1154
+ --ec-success: #43d17c;`;
1155
+
1156
+ const MODAL_STYLES = `
961
1157
  * { box-sizing: border-box; margin: 0; padding: 0; }
962
1158
 
1159
+ /* Keep the hidden attribute authoritative: author display values (e.g.
1160
+ the panel's flex) would otherwise override the UA's [hidden] rule and
1161
+ leave "collapsed" sections permanently expanded. */
1162
+ [hidden] { display: none !important; }
1163
+
963
1164
  .overlay {
964
1165
  position: fixed;
965
1166
  inset: 0;
@@ -967,6 +1168,7 @@ function buildStyles(isDark: boolean): string {
967
1168
  display: flex;
968
1169
  align-items: center;
969
1170
  justify-content: center;
1171
+ padding: 16px;
970
1172
  background: rgba(0, 0, 0, 0.5);
971
1173
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
972
1174
  animation: fadeIn 0.15s ease-out;
@@ -981,66 +1183,96 @@ function buildStyles(isDark: boolean): string {
981
1183
  40%, 80% { transform: translateX(6px); }
982
1184
  }
983
1185
  @keyframes spin { to { transform: rotate(360deg); } }
1186
+ @keyframes claimedPulse {
1187
+ 0%, 100% { transform: scale(0.85); opacity: 0.7; }
1188
+ 50% { transform: scale(1); opacity: 1; }
1189
+ }
984
1190
 
985
- .modal {
986
- background: ${bg};
987
- color: ${text};
1191
+ .modal {${LIGHT_TOKENS}
1192
+ --ec-accent-soft: color-mix(in srgb, var(--ec-accent) 14%, transparent);
1193
+ background: var(--ec-bg);
1194
+ color: var(--ec-text);
988
1195
  border-radius: 16px;
989
1196
  width: 400px;
990
- max-width: 92vw;
991
- max-height: 88vh;
1197
+ max-width: 100%;
1198
+ max-height: calc(100vh - 32px);
992
1199
  overflow-y: auto;
993
- padding: 20px 24px 16px;
994
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
1200
+ padding: 16px 20px 12px;
1201
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
995
1202
  animation: slideUp 0.2s ease-out;
996
1203
  display: flex;
997
1204
  flex-direction: column;
998
1205
  }
1206
+ @media (prefers-color-scheme: dark) {
1207
+ .modal {${DARK_TOKENS}
1208
+ }
1209
+ }
1210
+ .modal[data-appearance='light'] {${LIGHT_TOKENS}
1211
+ }
1212
+ .modal[data-appearance='dark'] {${DARK_TOKENS}
1213
+ }
1214
+
1215
+ button, input { font-family: inherit; }
1216
+ button:focus-visible, a:focus-visible {
1217
+ outline: 2px solid var(--ec-accent);
1218
+ outline-offset: 2px;
1219
+ }
999
1220
 
1000
- .header { display: flex; justify-content: space-between; align-items: center; }
1221
+ .header { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
1001
1222
  .header-identity { display: flex; align-items: center; gap: 10px; min-width: 0; }
1002
- .header-icon { border-radius: 6px; }
1223
+ .header-icon { border-radius: 6px; flex-shrink: 0; }
1003
1224
  h2 { font-size: 16px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1004
1225
 
1005
1226
  .close-btn {
1006
1227
  background: none; border: none; font-size: 24px; cursor: pointer;
1007
- color: ${muted}; padding: 4px 8px; border-radius: 8px; line-height: 1;
1228
+ color: var(--ec-muted); padding: 4px 8px; border-radius: 8px; line-height: 1;
1229
+ flex-shrink: 0;
1008
1230
  }
1009
- .close-btn:hover { color: ${text}; background: ${itemBg}; }
1231
+ .close-btn:hover { color: var(--ec-text); background: var(--ec-surface); }
1010
1232
 
1011
1233
  .stage {
1012
- min-height: 300px;
1234
+ min-height: 240px;
1013
1235
  display: flex;
1014
1236
  flex-direction: column;
1015
1237
  align-items: center;
1016
1238
  justify-content: center;
1017
- gap: 12px;
1239
+ gap: 10px;
1018
1240
  text-align: center;
1019
- padding: 16px 0;
1241
+ padding: 12px 0 10px;
1020
1242
  }
1021
1243
  .stage.fade-in > * { animation: stageIn 0.15s ease-out; }
1022
1244
 
1023
1245
  .qr-box {
1024
- width: 216px;
1025
- height: 216px;
1026
- padding: 12px;
1246
+ width: 200px;
1247
+ height: 200px;
1248
+ padding: 10px;
1027
1249
  border-radius: 12px;
1028
- background: ${isDark ? '#10192e' : '#ffffff'};
1029
- border: 1px solid ${border};
1250
+ background: #ffffff;
1251
+ border: 1px solid #e3e4e9;
1030
1252
  }
1031
1253
  .qr-box svg { width: 100%; height: 100%; display: block; }
1032
1254
 
1255
+ .claimed-pulse {
1256
+ width: 64px; height: 64px; border-radius: 50%;
1257
+ background: var(--ec-accent-soft); position: relative;
1258
+ }
1259
+ .claimed-pulse::after {
1260
+ content: ''; position: absolute; inset: 18px; border-radius: 50%;
1261
+ background: var(--ec-accent);
1262
+ animation: claimedPulse 1.6s ease-in-out infinite;
1263
+ }
1264
+
1033
1265
  .stage-caption { font-size: 15px; font-weight: 600; }
1034
- .stage-subline { font-size: 13px; color: ${muted}; max-width: 300px; line-height: 1.45; }
1035
- .stage-error { font-size: 13px; color: ${danger}; }
1266
+ .stage-subline { font-size: 13px; color: var(--ec-muted); max-width: 300px; line-height: 1.45; }
1267
+ .stage-error { font-size: 13px; color: var(--ec-danger); }
1036
1268
 
1037
1269
  .deep-link {
1038
1270
  display: block;
1039
1271
  width: 100%;
1040
- padding: 14px 16px;
1272
+ padding: 13px 16px;
1041
1273
  border-radius: 12px;
1042
- background: ${accent};
1043
- color: #fff;
1274
+ background: var(--ec-accent);
1275
+ color: var(--ec-accent-contrast);
1044
1276
  font-weight: 600;
1045
1277
  font-size: 15px;
1046
1278
  text-decoration: none;
@@ -1049,31 +1281,31 @@ function buildStyles(isDark: boolean): string {
1049
1281
  .pin-row { display: flex; gap: 10px; }
1050
1282
  .pin-row.shake { animation: shake 0.4s ease-in-out; }
1051
1283
  .pin-input {
1052
- width: 48px; height: 56px;
1284
+ width: 46px; height: 54px;
1053
1285
  text-align: center;
1054
1286
  font-size: 24px; font-weight: 600;
1055
- color: ${text};
1056
- background: ${itemBg};
1057
- border: 1px solid ${border};
1287
+ color: var(--ec-text);
1288
+ background: var(--ec-surface);
1289
+ border: 1px solid var(--ec-border);
1058
1290
  border-radius: 10px;
1059
1291
  outline: none;
1060
1292
  }
1061
- .pin-input:focus { border-color: ${accent}; }
1293
+ .pin-input:focus { border-color: var(--ec-accent); }
1062
1294
 
1063
1295
  .check {
1064
1296
  width: 56px; height: 56px;
1065
1297
  display: flex; align-items: center; justify-content: center;
1066
1298
  border-radius: 50%;
1067
- background: ${success};
1068
- color: #fff;
1299
+ background: var(--ec-success);
1300
+ color: #ffffff;
1069
1301
  font-size: 28px;
1070
1302
  }
1071
1303
 
1072
1304
  .spinner {
1073
1305
  width: 32px; height: 32px;
1074
1306
  border-radius: 50%;
1075
- border: 3px solid ${border};
1076
- border-top-color: ${accent};
1307
+ border: 3px solid var(--ec-border);
1308
+ border-top-color: var(--ec-accent);
1077
1309
  animation: spin 0.8s linear infinite;
1078
1310
  }
1079
1311
 
@@ -1081,87 +1313,122 @@ function buildStyles(isDark: boolean): string {
1081
1313
  padding: 10px 20px;
1082
1314
  border: none;
1083
1315
  border-radius: 10px;
1084
- background: ${accent};
1085
- color: #fff;
1316
+ background: var(--ec-accent);
1317
+ color: var(--ec-accent-contrast);
1086
1318
  font-size: 14px;
1087
1319
  font-weight: 500;
1088
1320
  cursor: pointer;
1089
1321
  }
1090
- .stage-btn:hover { filter: brightness(1.1); }
1322
+ .stage-btn:hover { filter: brightness(1.08); }
1091
1323
 
1092
1324
  .footer {
1093
- border-top: 1px solid ${border};
1094
- padding-top: 10px;
1325
+ border-top: 1px solid var(--ec-border);
1326
+ padding-top: 8px;
1095
1327
  display: flex;
1096
1328
  flex-direction: column;
1097
1329
  gap: 6px;
1330
+ }
1331
+
1332
+ .footer-row {
1333
+ display: flex;
1098
1334
  align-items: center;
1335
+ justify-content: space-between;
1336
+ flex-wrap: wrap;
1337
+ gap: 2px 8px;
1099
1338
  }
1339
+ .footer-row > * { min-width: 0; }
1340
+ .footer-row > :only-child { margin-inline: auto; }
1100
1341
 
1101
1342
  .footer-link {
1102
1343
  background: none;
1103
1344
  border: none;
1104
- color: ${muted};
1345
+ color: var(--ec-muted);
1105
1346
  font-size: 13px;
1106
1347
  cursor: pointer;
1107
- padding: 4px 8px;
1348
+ padding: 5px 8px;
1108
1349
  border-radius: 8px;
1109
1350
  }
1110
- .footer-link:hover { color: ${text}; background: ${itemBg}; }
1111
- .method-link { color: ${accent}; }
1351
+ .footer-link:hover { color: var(--ec-text); background: var(--ec-surface); }
1352
+ .method-link { color: var(--ec-accent); }
1353
+ .wallet-toggle { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1112
1354
 
1113
- .wallet-switcher { width: 100%; display: flex; flex-direction: column; align-items: center; }
1114
- .wallet-panel { width: 100%; padding: 6px 0; display: flex; flex-direction: column; gap: 4px; }
1115
- .wallet-row {
1116
- width: 100%;
1117
- text-align: left;
1118
- padding: 8px 12px;
1119
- background: ${itemBg};
1120
- border: 1px solid ${border};
1121
- border-radius: 10px;
1122
- color: ${text};
1123
- font-size: 13px;
1355
+ .wallet-panel { width: 100%; padding: 4px 0 2px; display: flex; flex-direction: column; gap: 8px; }
1356
+ .wallet-search {
1357
+ width: 100%; padding: 8px 12px;
1358
+ border-radius: 10px; border: 1px solid var(--ec-border); background: transparent;
1359
+ color: inherit; font: inherit; font-size: 13px;
1360
+ }
1361
+ .wallet-search:focus { outline: none; border-color: var(--ec-accent); }
1362
+
1363
+ .wallet-scroll { max-height: 260px; overflow-y: auto; }
1364
+ .wallet-grid {
1365
+ display: grid;
1366
+ grid-template-columns: repeat(3, 1fr);
1367
+ gap: 8px;
1368
+ }
1369
+ .wallet-tile {
1370
+ aspect-ratio: 1;
1371
+ display: flex;
1372
+ flex-direction: column;
1373
+ align-items: center;
1374
+ justify-content: center;
1375
+ gap: 8px;
1376
+ padding: 8px;
1377
+ min-width: 0;
1378
+ background: var(--ec-surface);
1379
+ border: 1px solid var(--ec-border);
1380
+ border-radius: 12px;
1381
+ color: var(--ec-text);
1382
+ font-size: 12px;
1124
1383
  cursor: pointer;
1125
1384
  }
1126
- .wallet-row:hover, .wallet-row.selected { border-color: ${accent}; }
1385
+ .wallet-tile:hover { border-color: var(--ec-accent); }
1386
+ .wallet-tile.selected {
1387
+ border-color: var(--ec-accent);
1388
+ background: var(--ec-accent-soft);
1389
+ box-shadow: inset 0 0 0 1px var(--ec-accent);
1390
+ }
1391
+ .wallet-tile-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1127
1392
 
1128
1393
  .wallet-icon { position: relative; width: 28px; height: 28px; flex-shrink: 0; display: inline-flex; }
1394
+ .wallet-tile .wallet-icon { width: 40px; height: 40px; }
1129
1395
  .wallet-badge {
1130
- width: 28px; height: 28px; border-radius: 8px; display: inline-flex;
1396
+ width: 100%; height: 100%; border-radius: 8px; display: inline-flex;
1131
1397
  align-items: center; justify-content: center; font-size: 13px;
1132
- font-weight: 600; background: ${accentSoft}; color: ${accent};
1133
- }
1134
- .wallet-img { position: absolute; inset: 0; width: 28px; height: 28px; border-radius: 8px; object-fit: cover; }
1135
- .wallet-row-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1136
- .wallet-search {
1137
- width: 100%; box-sizing: border-box; padding: 8px 10px; margin-bottom: 4px;
1138
- border-radius: 8px; border: 1px solid ${border}; background: transparent;
1139
- color: inherit; font: inherit;
1398
+ font-weight: 600; background: var(--ec-accent-soft); color: var(--ec-accent);
1140
1399
  }
1141
- .wallet-search:focus { outline: none; border-color: ${accent}; }
1142
- .wallet-empty { padding: 8px 10px; font-size: 12px; opacity: 0.7; }
1143
- .url-error { padding: 4px 2px 0; font-size: 12px; color: ${danger}; }
1144
- .wallet-custom { display: flex; gap: 6px; margin-top: 4px; }
1400
+ .wallet-tile .wallet-badge { border-radius: 10px; font-size: 17px; }
1401
+ .wallet-img { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 8px; object-fit: cover; }
1402
+ .wallet-tile .wallet-img { border-radius: 10px; }
1403
+
1404
+ .wallet-empty { padding: 10px 4px; font-size: 12px; color: var(--ec-muted); text-align: center; }
1405
+
1406
+ .wallet-custom { display: flex; gap: 6px; }
1145
1407
  .url-input {
1146
1408
  flex: 1;
1147
1409
  padding: 8px 12px;
1148
- border: 1px solid ${border};
1410
+ border: 1px solid var(--ec-border);
1149
1411
  border-radius: 10px;
1150
1412
  font-size: 13px;
1151
- background: ${itemBg};
1152
- color: ${text};
1413
+ background: var(--ec-surface);
1414
+ color: var(--ec-text);
1153
1415
  outline: none;
1154
1416
  }
1155
- .url-input:focus { border-color: ${accent}; }
1156
- .url-input.invalid { border-color: ${danger}; }
1417
+ .url-input:focus { border-color: var(--ec-accent); }
1418
+ .url-input.invalid { border-color: var(--ec-danger); }
1157
1419
  .url-go {
1158
1420
  padding: 8px 14px;
1159
1421
  border: none;
1160
1422
  border-radius: 10px;
1161
- background: ${accent};
1162
- color: #fff;
1423
+ background: var(--ec-accent);
1424
+ color: var(--ec-accent-contrast);
1163
1425
  font-size: 13px;
1164
1426
  cursor: pointer;
1165
1427
  }
1166
- `;
1167
- }
1428
+ .url-error { font-size: 12px; color: var(--ec-danger); }
1429
+
1430
+ @media (prefers-reduced-motion: reduce) {
1431
+ .overlay, .modal, .stage.fade-in > *, .pin-row.shake { animation: none; }
1432
+ .claimed-pulse::after { animation: none; }
1433
+ }
1434
+ `;