@enbox/browser 0.3.45 → 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,7 @@ 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.'),
372
499
  );
373
500
  };
374
501
 
@@ -682,64 +809,75 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
682
809
  await startMethod();
683
810
  };
684
811
 
685
- // ── Footer: method link + wallet switcher ──────────────────
812
+ // ── Footer: one row (method link + wallet toggle), panel below ──
686
813
  const renderFooter = (): void => {
687
814
  footer.replaceChildren();
688
815
 
816
+ const row = document.createElement('div');
817
+ row.className = 'footer-row';
818
+
689
819
  if (method === 'phone') {
690
820
  const alt = document.createElement('button');
691
821
  alt.className = 'footer-link method-link';
692
- alt.textContent = 'No phone handy? Use this browser →';
822
+ alt.textContent = 'No phone? Use this browser →';
693
823
  // startPopup runs synchronously in this click handler.
694
824
  alt.addEventListener('click', () => { startPopup(); });
695
- footer.appendChild(alt);
825
+ row.appendChild(alt);
696
826
  } else if (!isMobile) {
697
827
  const alt = document.createElement('button');
698
828
  alt.className = 'footer-link method-link';
699
829
  alt.textContent = 'Use your phone instead →';
700
830
  alt.addEventListener('click', () => { void switchMethod('phone'); });
701
- footer.appendChild(alt);
831
+ row.appendChild(alt);
702
832
  }
703
833
 
834
+ let panel: HTMLElement | undefined;
704
835
  if (!lockedWallet && wallets.length > 0) {
705
- footer.appendChild(buildWalletSwitcher());
836
+ const switcher = buildWalletSwitcher();
837
+ row.appendChild(switcher.toggle);
838
+ panel = switcher.panel;
706
839
  }
707
- };
708
840
 
709
- const buildWalletSwitcher = (): HTMLElement => {
710
- const wrap = document.createElement('div');
711
- wrap.className = 'wallet-switcher';
841
+ if (row.childElementCount > 0) {
842
+ footer.appendChild(row);
843
+ }
844
+ if (panel !== undefined) {
845
+ footer.appendChild(panel);
846
+ }
847
+ };
712
848
 
849
+ const buildWalletSwitcher = (): { toggle: HTMLButtonElement; panel: HTMLDivElement } => {
713
850
  const current = wallets.find((wallet) => wallet.url === walletUrl);
714
851
  const toggle = document.createElement('button');
715
852
  toggle.className = 'footer-link wallet-toggle';
716
853
  toggle.setAttribute('aria-expanded', 'false');
717
- toggle.textContent = `Wallet: ${current?.name ?? walletUrl ?? 'Choose'} ▾`;
854
+ toggle.textContent = `Wallet: ${current?.name ?? hostnameOf(walletUrl) ?? 'Choose'} ▾`;
718
855
 
719
856
  const panel = document.createElement('div');
720
857
  panel.className = 'wallet-panel';
721
858
  panel.hidden = true;
722
859
 
723
- // Search filter — only worth the chrome once the catalog grows.
724
- 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 }> = [];
725
862
  const empty = document.createElement('div');
726
863
  empty.className = 'wallet-empty';
727
864
  empty.textContent = 'No wallets match your search.';
728
865
  empty.hidden = true;
729
866
 
730
- if (wallets.length > 5) {
867
+ if (wallets.length > WALLET_SEARCH_THRESHOLD) {
731
868
  const search = document.createElement('input');
732
869
  search.type = 'search';
733
870
  search.className = 'wallet-search';
734
871
  search.placeholder = 'Search wallets…';
872
+ search.setAttribute('aria-label', 'Search wallets');
735
873
  search.addEventListener('input', () => {
736
874
  const query = search.value.trim().toLowerCase();
737
875
  let visible = 0;
738
- for (const { wallet, el } of rows) {
876
+ for (const { wallet, el: tile } of tiles) {
739
877
  const match = query === ''
740
878
  || wallet.name.toLowerCase().includes(query)
741
879
  || wallet.url.toLowerCase().includes(query);
742
- el.hidden = !match;
880
+ tile.hidden = !match;
743
881
  if (match) { visible += 1; }
744
882
  }
745
883
  empty.hidden = visible > 0;
@@ -747,26 +885,37 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
747
885
  panel.appendChild(search);
748
886
  }
749
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
+
750
895
  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);
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);
758
904
  if (wallet.url === walletUrl) {
759
- row.classList.add('selected');
905
+ tile.classList.add('selected');
906
+ tile.setAttribute('aria-pressed', 'true');
760
907
  }
761
- row.addEventListener('click', () => {
908
+ tile.addEventListener('click', () => {
762
909
  walletUrl = wallet.url;
763
910
  renderFooter();
764
911
  void startMethod();
765
912
  });
766
- rows.push({ wallet, el: row });
767
- panel.appendChild(row);
913
+ tiles.push({ wallet, el: tile });
914
+ grid.appendChild(tile);
768
915
  }
769
- panel.appendChild(empty);
916
+ scroll.appendChild(grid);
917
+ scroll.appendChild(empty);
918
+ panel.appendChild(scroll);
770
919
 
771
920
  // Custom wallet URL (the power-user path, demoted to the disclosure).
772
921
  const custom = document.createElement('div');
@@ -844,9 +993,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
844
993
  toggle.setAttribute('aria-expanded', panel.hidden ? 'false' : 'true');
845
994
  });
846
995
 
847
- wrap.appendChild(toggle);
848
- wrap.appendChild(panel);
849
- return wrap;
996
+ return { toggle, panel };
850
997
  };
851
998
 
852
999
  // ── Global interactions ────────────────────────────────────
@@ -882,6 +1029,18 @@ function walletInCatalog(wallets: WalletOption[], url: string): boolean {
882
1029
  return wallets.some((wallet) => wallet.url === url);
883
1030
  }
884
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
+
885
1044
  function el(tag: string, className: string, text?: string): HTMLElement {
886
1045
  const node = document.createElement(tag);
887
1046
  node.className = className;
@@ -963,21 +1122,45 @@ function trapFocus(shadow: ShadowRoot, event: KeyboardEvent): void {
963
1122
  }
964
1123
 
965
1124
  // ─── 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 `
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 = `
979
1157
  * { box-sizing: border-box; margin: 0; padding: 0; }
980
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
+
981
1164
  .overlay {
982
1165
  position: fixed;
983
1166
  inset: 0;
@@ -985,6 +1168,7 @@ function buildStyles(isDark: boolean): string {
985
1168
  display: flex;
986
1169
  align-items: center;
987
1170
  justify-content: center;
1171
+ padding: 16px;
988
1172
  background: rgba(0, 0, 0, 0.5);
989
1173
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
990
1174
  animation: fadeIn 0.15s ease-out;
@@ -999,82 +1183,96 @@ function buildStyles(isDark: boolean): string {
999
1183
  40%, 80% { transform: translateX(6px); }
1000
1184
  }
1001
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
+ }
1002
1190
 
1003
- .modal {
1004
- background: ${bg};
1005
- 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);
1006
1195
  border-radius: 16px;
1007
1196
  width: 400px;
1008
- max-width: 92vw;
1009
- max-height: 88vh;
1197
+ max-width: 100%;
1198
+ max-height: calc(100vh - 32px);
1010
1199
  overflow-y: auto;
1011
- padding: 20px 24px 16px;
1012
- 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);
1013
1202
  animation: slideUp 0.2s ease-out;
1014
1203
  display: flex;
1015
1204
  flex-direction: column;
1016
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
+ }
1017
1220
 
1018
- .header { display: flex; justify-content: space-between; align-items: center; }
1221
+ .header { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
1019
1222
  .header-identity { display: flex; align-items: center; gap: 10px; min-width: 0; }
1020
- .header-icon { border-radius: 6px; }
1223
+ .header-icon { border-radius: 6px; flex-shrink: 0; }
1021
1224
  h2 { font-size: 16px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1022
1225
 
1023
1226
  .close-btn {
1024
1227
  background: none; border: none; font-size: 24px; cursor: pointer;
1025
- 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;
1026
1230
  }
1027
- .close-btn:hover { color: ${text}; background: ${itemBg}; }
1231
+ .close-btn:hover { color: var(--ec-text); background: var(--ec-surface); }
1028
1232
 
1029
1233
  .stage {
1030
- min-height: 300px;
1234
+ min-height: 240px;
1031
1235
  display: flex;
1032
1236
  flex-direction: column;
1033
1237
  align-items: center;
1034
1238
  justify-content: center;
1035
- gap: 12px;
1239
+ gap: 10px;
1036
1240
  text-align: center;
1037
- padding: 16px 0;
1241
+ padding: 12px 0 10px;
1038
1242
  }
1039
1243
  .stage.fade-in > * { animation: stageIn 0.15s ease-out; }
1040
1244
 
1041
1245
  .qr-box {
1042
- width: 216px;
1043
- height: 216px;
1044
- padding: 12px;
1246
+ width: 200px;
1247
+ height: 200px;
1248
+ padding: 10px;
1045
1249
  border-radius: 12px;
1046
- background: ${isDark ? '#10192e' : '#ffffff'}
1250
+ background: #ffffff;
1251
+ border: 1px solid #e3e4e9;
1252
+ }
1253
+ .qr-box svg { width: 100%; height: 100%; display: block; }
1254
+
1047
1255
  .claimed-pulse {
1048
1256
  width: 64px; height: 64px; border-radius: 50%;
1049
- background: ${accentSoft}; position: relative;
1257
+ background: var(--ec-accent-soft); position: relative;
1050
1258
  }
1051
1259
  .claimed-pulse::after {
1052
1260
  content: ''; position: absolute; inset: 18px; border-radius: 50%;
1053
- background: ${accent};
1054
- animation: enbox-claimed-pulse 1.6s ease-in-out infinite;
1261
+ background: var(--ec-accent);
1262
+ animation: claimedPulse 1.6s ease-in-out infinite;
1055
1263
  }
1056
- @keyframes enbox-claimed-pulse {
1057
- 0%, 100% { transform: scale(0.85); opacity: 0.7; }
1058
- 50% { transform: scale(1); opacity: 1; }
1059
- }
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
1264
 
1067
1265
  .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}; }
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); }
1070
1268
 
1071
1269
  .deep-link {
1072
1270
  display: block;
1073
1271
  width: 100%;
1074
- padding: 14px 16px;
1272
+ padding: 13px 16px;
1075
1273
  border-radius: 12px;
1076
- background: ${accent};
1077
- color: #fff;
1274
+ background: var(--ec-accent);
1275
+ color: var(--ec-accent-contrast);
1078
1276
  font-weight: 600;
1079
1277
  font-size: 15px;
1080
1278
  text-decoration: none;
@@ -1083,31 +1281,31 @@ function buildStyles(isDark: boolean): string {
1083
1281
  .pin-row { display: flex; gap: 10px; }
1084
1282
  .pin-row.shake { animation: shake 0.4s ease-in-out; }
1085
1283
  .pin-input {
1086
- width: 48px; height: 56px;
1284
+ width: 46px; height: 54px;
1087
1285
  text-align: center;
1088
1286
  font-size: 24px; font-weight: 600;
1089
- color: ${text};
1090
- background: ${itemBg};
1091
- border: 1px solid ${border};
1287
+ color: var(--ec-text);
1288
+ background: var(--ec-surface);
1289
+ border: 1px solid var(--ec-border);
1092
1290
  border-radius: 10px;
1093
1291
  outline: none;
1094
1292
  }
1095
- .pin-input:focus { border-color: ${accent}; }
1293
+ .pin-input:focus { border-color: var(--ec-accent); }
1096
1294
 
1097
1295
  .check {
1098
1296
  width: 56px; height: 56px;
1099
1297
  display: flex; align-items: center; justify-content: center;
1100
1298
  border-radius: 50%;
1101
- background: ${success};
1102
- color: #fff;
1299
+ background: var(--ec-success);
1300
+ color: #ffffff;
1103
1301
  font-size: 28px;
1104
1302
  }
1105
1303
 
1106
1304
  .spinner {
1107
1305
  width: 32px; height: 32px;
1108
1306
  border-radius: 50%;
1109
- border: 3px solid ${border};
1110
- border-top-color: ${accent};
1307
+ border: 3px solid var(--ec-border);
1308
+ border-top-color: var(--ec-accent);
1111
1309
  animation: spin 0.8s linear infinite;
1112
1310
  }
1113
1311
 
@@ -1115,87 +1313,122 @@ function buildStyles(isDark: boolean): string {
1115
1313
  padding: 10px 20px;
1116
1314
  border: none;
1117
1315
  border-radius: 10px;
1118
- background: ${accent};
1119
- color: #fff;
1316
+ background: var(--ec-accent);
1317
+ color: var(--ec-accent-contrast);
1120
1318
  font-size: 14px;
1121
1319
  font-weight: 500;
1122
1320
  cursor: pointer;
1123
1321
  }
1124
- .stage-btn:hover { filter: brightness(1.1); }
1322
+ .stage-btn:hover { filter: brightness(1.08); }
1125
1323
 
1126
1324
  .footer {
1127
- border-top: 1px solid ${border};
1128
- padding-top: 10px;
1325
+ border-top: 1px solid var(--ec-border);
1326
+ padding-top: 8px;
1129
1327
  display: flex;
1130
1328
  flex-direction: column;
1131
1329
  gap: 6px;
1330
+ }
1331
+
1332
+ .footer-row {
1333
+ display: flex;
1132
1334
  align-items: center;
1335
+ justify-content: space-between;
1336
+ flex-wrap: wrap;
1337
+ gap: 2px 8px;
1133
1338
  }
1339
+ .footer-row > * { min-width: 0; }
1340
+ .footer-row > :only-child { margin-inline: auto; }
1134
1341
 
1135
1342
  .footer-link {
1136
1343
  background: none;
1137
1344
  border: none;
1138
- color: ${muted};
1345
+ color: var(--ec-muted);
1139
1346
  font-size: 13px;
1140
1347
  cursor: pointer;
1141
- padding: 4px 8px;
1348
+ padding: 5px 8px;
1142
1349
  border-radius: 8px;
1143
1350
  }
1144
- .footer-link:hover { color: ${text}; background: ${itemBg}; }
1145
- .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; }
1146
1354
 
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
- .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;
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;
1158
1383
  cursor: pointer;
1159
1384
  }
1160
- .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; }
1161
1392
 
1162
1393
  .wallet-icon { position: relative; width: 28px; height: 28px; flex-shrink: 0; display: inline-flex; }
1394
+ .wallet-tile .wallet-icon { width: 40px; height: 40px; }
1163
1395
  .wallet-badge {
1164
- width: 28px; height: 28px; border-radius: 8px; display: inline-flex;
1396
+ width: 100%; height: 100%; border-radius: 8px; display: inline-flex;
1165
1397
  align-items: center; justify-content: center; font-size: 13px;
1166
- font-weight: 600; background: ${accentSoft}; color: ${accent};
1167
- }
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;
1398
+ font-weight: 600; background: var(--ec-accent-soft); color: var(--ec-accent);
1174
1399
  }
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; }
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; }
1179
1407
  .url-input {
1180
1408
  flex: 1;
1181
1409
  padding: 8px 12px;
1182
- border: 1px solid ${border};
1410
+ border: 1px solid var(--ec-border);
1183
1411
  border-radius: 10px;
1184
1412
  font-size: 13px;
1185
- background: ${itemBg};
1186
- color: ${text};
1413
+ background: var(--ec-surface);
1414
+ color: var(--ec-text);
1187
1415
  outline: none;
1188
1416
  }
1189
- .url-input:focus { border-color: ${accent}; }
1190
- .url-input.invalid { border-color: ${danger}; }
1417
+ .url-input:focus { border-color: var(--ec-accent); }
1418
+ .url-input.invalid { border-color: var(--ec-danger); }
1191
1419
  .url-go {
1192
1420
  padding: 8px 14px;
1193
1421
  border: none;
1194
1422
  border-radius: 10px;
1195
- background: ${accent};
1196
- color: #fff;
1423
+ background: var(--ec-accent);
1424
+ color: var(--ec-accent-contrast);
1197
1425
  font-size: 13px;
1198
1426
  cursor: pointer;
1199
1427
  }
1200
- `;
1201
- }
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
+ `;