studio-engine 0.62.6 → 0.64.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +69 -0
- data/README.md +61 -0
- data/app/assets/stylesheets/studio/sticky_table_header.css +4 -1
- data/app/assets/tailwind/studio_engine/engine-motion.css +24 -1
- data/app/assets/tailwind/studio_engine/engine.css +208 -4
- data/app/controllers/solana_sessions_controller.rb +9 -0
- data/app/views/layouts/_navbar.html.erb +6 -74
- data/app/views/layouts/studio/_flash.html.erb +8 -5
- data/app/views/layouts/studio/_head.html.erb +99 -8
- data/app/views/solana_sessions/phantom_callback.html.erb +343 -0
- data/app/views/studio/admin_models/_teams_table.html.erb +1 -1
- data/app/views/studio/banners/_app_banner.html.erb +4 -1
- data/app/views/studio/banners/_button.html.erb +2 -2
- data/app/views/studio/banners/_stack.html.erb +12 -4
- data/app/views/studio/modals/_host.html.erb +105 -5
- data/app/views/studio/modals/_scoped_host.html.erb +25 -2
- data/app/views/studio/modals/_wallet_connect.html.erb +330 -0
- data/app/views/studio/modals/blocks/_close_x.html.erb +18 -0
- data/app/views/studio/modals/blocks/_rail_row.html.erb +16 -0
- data/app/views/studio/modals/blocks/_success_card.html.erb +21 -6
- data/app/views/studio/modals/blocks/_wallet_brand_sprite.html.erb +6 -0
- data/app/views/studio/profiles/_identity_styles.html.erb +1 -1
- data/app/views/studio/solana/_deeplink_assets.html.erb +37 -0
- data/app/views/studio/solana/_phantom_deeplink.html.erb +167 -0
- data/app/views/style/_modals.html.erb +10 -1
- data/app/views/style/modals/_wallet_connect.html.erb +77 -171
- data/app/views/style/modals/_wallet_connect_slot.html.erb +18 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +49 -4
- metadata +7 -2
|
@@ -13,14 +13,53 @@
|
|
|
13
13
|
for pages that drive a modal host WITHOUT rendering this partial —
|
|
14
14
|
e.g. the living style guide's page-scoped dsModals host. The two are
|
|
15
15
|
byte-identical; keep them in sync if you retune a curve or duration.
|
|
16
|
+
|
|
17
|
+
THREE CONSUMER EXTENSION POINTS, so an app never has to fork this file
|
|
18
|
+
to change an animation, a width, or register an app-wide modal:
|
|
19
|
+
|
|
20
|
+
window.ModalAnimations per-modal enter/exit animation, merged
|
|
21
|
+
over the engine defaults (see below)
|
|
22
|
+
window.StudioModals.CARD_WIDTHS per-modal card width by modal id,
|
|
23
|
+
merged the same way; DEFAULT_CARD_WIDTH
|
|
24
|
+
covers every id it does not name
|
|
25
|
+
app/views/modals/_host_extras OPTIONAL app partial, rendered inside
|
|
26
|
+
the card on every render path through
|
|
27
|
+
this host — for a modal that belongs to
|
|
28
|
+
the app rather than to one call site
|
|
29
|
+
|
|
16
30
|
Consumer integration example and API reference: the "Modal host"
|
|
17
31
|
section of the gem README.
|
|
18
32
|
%>
|
|
19
33
|
<style>
|
|
20
34
|
/* Scroll lock applied by $store.modals._sync() when the stack is non-empty.
|
|
21
|
-
Combined with the fixed backdrop on the host below, this keeps the
|
|
22
|
-
|
|
23
|
-
|
|
35
|
+
Combined with the fixed backdrop on the host below, this keeps the page
|
|
36
|
+
beneath any modal from scrolling on wheel / touch / spacebar — a claim that
|
|
37
|
+
was FALSE wherever this gem's link sidebar shipped, until the rule below
|
|
38
|
+
moved off body. */
|
|
39
|
+
/* THE SCROLL LOCK GOES ON THE ELEMENT THAT ACTUALLY SCROLLS.
|
|
40
|
+
Measured on a consumer, 2026-08-27, before this change: with a modal open a
|
|
41
|
+
real wheel gesture still scrolled the page (600px → 1000px) and the sticky
|
|
42
|
+
header unpinned and slid away with it. The lock had been inert there for as
|
|
43
|
+
long as the link sidebar had shipped.
|
|
44
|
+
|
|
45
|
+
`body { overflow: hidden }` locks the viewport only when it PROPAGATES to
|
|
46
|
+
it, and body's overflow propagates only while `html` is `overflow: visible`.
|
|
47
|
+
This gem's own link-sidebar partial sets `html { overflow-x: clip }` so the
|
|
48
|
+
off-canvas panel never flashes a scrollbar — correct on its own terms, clip
|
|
49
|
+
creates no scroll container — but it ends the propagation. The lock then
|
|
50
|
+
stopped locking the viewport and started making BODY a scroll container
|
|
51
|
+
instead: one holding scrollTop 0 forever while the viewport scrolled past
|
|
52
|
+
it. Every `position: sticky` child of body then has a scrollport that never
|
|
53
|
+
moves, which is why the header unpinned and why the modal-open banner lift
|
|
54
|
+
had nothing to pin against.
|
|
55
|
+
|
|
56
|
+
So lock `html`, and put body back to `visible` so it is not a second scroll
|
|
57
|
+
container. An app that sets no overflow on html is unaffected — locking html
|
|
58
|
+
is correct there too; it was simply never the only thing that worked.
|
|
59
|
+
|
|
60
|
+
:has() is the trigger because the class lands on body, not html. */
|
|
61
|
+
html:has(body.modal-open) { overflow: hidden; }
|
|
62
|
+
body.modal-open { overflow: visible; }
|
|
24
63
|
|
|
25
64
|
/* Drain-bar keyframe used by studio/modals/blocks/_success_card when
|
|
26
65
|
cta_drain is set — the CTA button hosts a translucent overlay that
|
|
@@ -195,6 +234,32 @@
|
|
|
195
234
|
enter: Object.assign({}, animDefaults.enter, animOverrides.enter || {}),
|
|
196
235
|
exit: Object.assign({}, animDefaults.exit, animOverrides.exit || {})
|
|
197
236
|
};
|
|
237
|
+
|
|
238
|
+
// Per-modal CARD WIDTH — the second consumer extension point, deliberately
|
|
239
|
+
// shaped like the animation one above so a consumer learns the pattern once.
|
|
240
|
+
// Define window.StudioModals.CARD_WIDTHS BEFORE this script runs, keyed by
|
|
241
|
+
// modal id, and app entries merge OVER the engine defaults (empty: the
|
|
242
|
+
// engine ships no per-id width, only the default below).
|
|
243
|
+
//
|
|
244
|
+
// window.StudioModals = window.StudioModals || {};
|
|
245
|
+
// window.StudioModals.CARD_WIDTHS = { 'wallet-setup': 'max-w-md' };
|
|
246
|
+
//
|
|
247
|
+
// BY MODAL ID, NOT BY PROP, and that is the whole reason this is a registry.
|
|
248
|
+
// A card opened from several places would have to carry the prop at every
|
|
249
|
+
// opener, and ONE miss renders the same card at two widths depending on how
|
|
250
|
+
// the user got there. The originating case is turf-monster's wallet-setup,
|
|
251
|
+
// opened from three (the board's entry gate, the onboarding chain driver,
|
|
252
|
+
// and the post-Connect reopen).
|
|
253
|
+
//
|
|
254
|
+
// Resolved in cardClasses(), NEVER written as a static class on the card
|
|
255
|
+
// element: exactly one max-w-* may land on that div, and a static class
|
|
256
|
+
// alongside a bound one leaves the winner to stylesheet source order, which
|
|
257
|
+
// is not something this file gets to decide.
|
|
258
|
+
window.StudioModals = window.StudioModals || {};
|
|
259
|
+
var widthOverrides = window.StudioModals.CARD_WIDTHS || {};
|
|
260
|
+
window.StudioModals.CARD_WIDTHS = Object.assign({}, widthOverrides);
|
|
261
|
+
window.StudioModals.DEFAULT_CARD_WIDTH =
|
|
262
|
+
window.StudioModals.DEFAULT_CARD_WIDTH || 'max-w-sm';
|
|
198
263
|
// Late-binding guard: read window.ModalAnimations at CALL time, and
|
|
199
264
|
// never return undefined. A consumer script that loads after this one
|
|
200
265
|
// (e.g. an importmap module) may REPLACE the merged registry object
|
|
@@ -206,6 +271,17 @@
|
|
|
206
271
|
return table[key] || table.pop || animDefaults[channel].pop;
|
|
207
272
|
}
|
|
208
273
|
|
|
274
|
+
// Same late-binding guard as modalAnim, for the same reason and with one
|
|
275
|
+
// extra: a width that fails to resolve does not throw, it renders a
|
|
276
|
+
// FULL-BLEED card. A silent failure gets a literal floor, not a chance.
|
|
277
|
+
var DEFAULT_CARD_WIDTH = 'max-w-sm';
|
|
278
|
+
function modalCardWidth(id) {
|
|
279
|
+
var sm = window.StudioModals || {};
|
|
280
|
+
return (sm.CARD_WIDTHS && sm.CARD_WIDTHS[id]) ||
|
|
281
|
+
sm.DEFAULT_CARD_WIDTH ||
|
|
282
|
+
DEFAULT_CARD_WIDTH;
|
|
283
|
+
}
|
|
284
|
+
|
|
209
285
|
// Where focus returns when the last modal closes. A module-level closure, NOT a
|
|
210
286
|
// store property — see captureFocus below for why a DOM node must never live on
|
|
211
287
|
// a reactive store.
|
|
@@ -624,6 +700,10 @@
|
|
|
624
700
|
var c = this.current();
|
|
625
701
|
if (!c) return {};
|
|
626
702
|
var o = {};
|
|
703
|
+
// The card element carries no static max-w-*; this is the only
|
|
704
|
+
// one it ever gets. Set FIRST so the animation keys below, which
|
|
705
|
+
// never collide with a max-w-* name, read as additions to it.
|
|
706
|
+
o[modalCardWidth(c.id)] = true;
|
|
627
707
|
if (!c._settled && !c._swappingIn && !c._swappingOut && !c._closing) {
|
|
628
708
|
o[modalAnim('enter', c.props && c.props.enterAnim).cls] = true;
|
|
629
709
|
}
|
|
@@ -712,7 +792,7 @@
|
|
|
712
792
|
tabindex="-1" makes the backdrop programmatically focusable without adding it
|
|
713
793
|
to the tab order. The name comes from props (ariaLabel, else title, else the
|
|
714
794
|
modal id) so a screen reader never announces a bare unnamed dialog. %>
|
|
715
|
-
<div class="fixed inset-0 z-[
|
|
795
|
+
<div class="fixed inset-0 z-[var(--z-modal)] flex items-center justify-center p-4 modal-backdrop-mount"
|
|
716
796
|
:class="$store.modals.current()?._closing && 'modal-backdrop-unmount'"
|
|
717
797
|
style="background:rgba(0,0,0,0.6)"
|
|
718
798
|
role="dialog"
|
|
@@ -744,12 +824,32 @@
|
|
|
744
824
|
a dead end, since escape and click-outside are gated off. 100dvh, not 100vh:
|
|
745
825
|
mobile browsers shrink the visual viewport when the URL bar is showing, and
|
|
746
826
|
vh ignores that. %>
|
|
747
|
-
|
|
827
|
+
<%# No max-w-* here — cardClasses() supplies exactly one, per modal id, from
|
|
828
|
+
the CARD_WIDTHS registry above. max-h/overflow are unrelated and stay. %>
|
|
829
|
+
<div class="bg-surface rounded-xl border border-subtle shadow-2xl p-6 w-full max-h-[85dvh] overflow-y-auto"
|
|
748
830
|
:class="$store.modals.cardClasses()">
|
|
749
831
|
<%# Consumer-provided content registrations. Each block typically
|
|
750
832
|
contains a <template x-if="$store.modals.current().id === 'X'">
|
|
751
833
|
render "modals/X" </template>. %>
|
|
752
834
|
<%= yield if block_given? %>
|
|
835
|
+
|
|
836
|
+
<%# APP-WIDE modal registrations — the second seam. The block above is
|
|
837
|
+
per-CALLSITE: an app rendering this host from two layouts (a live one
|
|
838
|
+
and, say, an /admin preview harness) must repeat every registration in
|
|
839
|
+
both. A modal that belongs to the APP rather than to the page defines
|
|
840
|
+
app/views/modals/_host_extras.html.erb instead and is registered ONCE,
|
|
841
|
+
for every render path through this partial.
|
|
842
|
+
|
|
843
|
+
Convention, not a local, on purpose: a local would have to be passed at
|
|
844
|
+
each call site, which is the duplication this exists to remove. An app
|
|
845
|
+
that ships no such partial renders nothing and changes nothing.
|
|
846
|
+
|
|
847
|
+
It sits INSIDE the card, beside the block, because each registration is
|
|
848
|
+
a <template x-if> keyed on $store.modals.current().id — the outer
|
|
849
|
+
template x-if above is what guarantees current() is non-null here. %>
|
|
850
|
+
<% if lookup_context.exists?("host_extras", ["modals"], true) %>
|
|
851
|
+
<%= render "modals/host_extras" %>
|
|
852
|
+
<% end %>
|
|
753
853
|
</div>
|
|
754
854
|
</div>
|
|
755
855
|
</template>
|
|
@@ -53,7 +53,30 @@
|
|
|
53
53
|
<style>
|
|
54
54
|
/* Scroll lock, applied by the store's _sync() while the stack is non-empty.
|
|
55
55
|
Shipped here too because an app with no shared host never defines it. */
|
|
56
|
-
|
|
56
|
+
/* THE SCROLL LOCK GOES ON THE ELEMENT THAT ACTUALLY SCROLLS.
|
|
57
|
+
Measured on a consumer, 2026-08-27, before this change: with a modal open a
|
|
58
|
+
real wheel gesture still scrolled the page (600px → 1000px) and the sticky
|
|
59
|
+
header unpinned and slid away with it. The lock had been inert there for as
|
|
60
|
+
long as the link sidebar had shipped.
|
|
61
|
+
|
|
62
|
+
`body { overflow: hidden }` locks the viewport only when it PROPAGATES to
|
|
63
|
+
it, and body's overflow propagates only while `html` is `overflow: visible`.
|
|
64
|
+
This gem's own link-sidebar partial sets `html { overflow-x: clip }` so the
|
|
65
|
+
off-canvas panel never flashes a scrollbar — correct on its own terms, clip
|
|
66
|
+
creates no scroll container — but it ends the propagation. The lock then
|
|
67
|
+
stopped locking the viewport and started making BODY a scroll container
|
|
68
|
+
instead: one holding scrollTop 0 forever while the viewport scrolled past
|
|
69
|
+
it. Every `position: sticky` child of body then has a scrollport that never
|
|
70
|
+
moves, which is why the header unpinned and why the modal-open banner lift
|
|
71
|
+
had nothing to pin against.
|
|
72
|
+
|
|
73
|
+
So lock `html`, and put body back to `visible` so it is not a second scroll
|
|
74
|
+
container. An app that sets no overflow on html is unaffected — locking html
|
|
75
|
+
is correct there too; it was simply never the only thing that worked.
|
|
76
|
+
|
|
77
|
+
:has() is the trigger because the class lands on body, not html. */
|
|
78
|
+
html:has(body.modal-open) { overflow: hidden; }
|
|
79
|
+
body.modal-open { overflow: visible; }
|
|
57
80
|
</style>
|
|
58
81
|
|
|
59
82
|
<script>
|
|
@@ -313,7 +336,7 @@
|
|
|
313
336
|
</script>
|
|
314
337
|
|
|
315
338
|
<template x-if="$store.<%= scoped_store %>.current()">
|
|
316
|
-
<div class="fixed inset-0 z-[
|
|
339
|
+
<div class="fixed inset-0 z-[var(--z-modal)] flex items-center justify-center p-4 modal-backdrop-mount"
|
|
317
340
|
:class="$store.<%= scoped_store %>.current()?._closing && 'modal-backdrop-unmount'"
|
|
318
341
|
style="background:rgba(0,0,0,0.6)"
|
|
319
342
|
role="dialog"
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
"Connect Wallet" picker — the reown-style wallet chooser, OWNED BY THE ENGINE.
|
|
3
|
+
Promoted out of style/modals/_wallet_connect (where only the living style guide
|
|
4
|
+
could render it) so the apps stop each keeping their own copy: before this the
|
|
5
|
+
same screen existed THREE times — turf-monster 226 lines, mcritchie-studio 107,
|
|
6
|
+
and the guide's 176 — sharing no code and drifting apart. The hub's copy still
|
|
7
|
+
pointed at app-served PNGs the engine's brand sprite replaced in 0.20.
|
|
8
|
+
|
|
9
|
+
Mount it inside the modal host's <template x-if="id==='wallet-connect'">, so
|
|
10
|
+
this MUST have a SINGLE ROOT — everything lives inside the outer <div>.
|
|
11
|
+
|
|
12
|
+
Props (set by whoever opens the modal, read off the store entry):
|
|
13
|
+
linkMode — true when this is an account-LINKING flow (already logged in)
|
|
14
|
+
currentUserId — bound into the SIWS message when linking
|
|
15
|
+
returnUrl — where to go after a successful connect
|
|
16
|
+
|
|
17
|
+
Locals:
|
|
18
|
+
store — Alpine store name backing the modal. Default "modals".
|
|
19
|
+
connect_fn — name of the window function that connects AND verifies a
|
|
20
|
+
wallet, called as window[connect_fn](name, opts) and expected
|
|
21
|
+
to resolve { success: true, redirect } or { success: false,
|
|
22
|
+
error }. Default "solanaConnectAndVerify".
|
|
23
|
+
title — card heading. Default "Connect Wallet".
|
|
24
|
+
extra_data — EXTRA x-data members for this component, as a JS fragment with
|
|
25
|
+
NO surrounding braces, merged after the built-ins. This is how
|
|
26
|
+
an app adds its own state (and the hooks below) without forking
|
|
27
|
+
the picker. Default "".
|
|
28
|
+
|
|
29
|
+
SHAPED LIKE style/_modal_specimen's card_data but NOT handled
|
|
30
|
+
the same way, and the difference is deliberate. That file marks
|
|
31
|
+
open_expr and glow_when html_safe and leaves card_data escaped
|
|
32
|
+
(:42-44), so its fragment reaches the attribute as entities.
|
|
33
|
+
Both work — a browser decodes entities inside an attribute —
|
|
34
|
+
but only one is readable in View Source, and a reader comparing
|
|
35
|
+
the two seams deserves to be told they diverge rather than left
|
|
36
|
+
to assume a typo. If they are ever reconciled, reconcile them
|
|
37
|
+
in one pass; do not quietly copy this decision back.
|
|
38
|
+
slot — OPTIONAL partial path rendered between the heading and the
|
|
39
|
+
wallet rows: the place for an app's legal-age attestation or
|
|
40
|
+
any other pre-connect consent. It renders INSIDE this
|
|
41
|
+
component's root, so it can bind straight to the members
|
|
42
|
+
extra_data contributes.
|
|
43
|
+
slot_locals — locals for that partial. Default {}.
|
|
44
|
+
|
|
45
|
+
A NAMED LOCAL AND NOT A BLOCK, and this is load-bearing. `block_given?` is
|
|
46
|
+
ALWAYS TRUE inside a compiled Rails partial — PartialRenderer hands the
|
|
47
|
+
template a block either way — so a bare `yield if block_given?` falls through
|
|
48
|
+
to view_flow[:layout] and prints THE WHOLE CAPTURED PAGE BODY inside the card.
|
|
49
|
+
Both consuming apps mount the modal host in application.html.erb, which is
|
|
50
|
+
exactly when that flow is populated, and the hub's planned adoption is a bare
|
|
51
|
+
render with no block. Caught in review of this partial's own first version.
|
|
52
|
+
blocks/_card_header documents the same hazard and blocks/_entry_confirmed made
|
|
53
|
+
the same choice (above_seeds) for the same reason.
|
|
54
|
+
|
|
55
|
+
HOOKS — optional methods an app defines in extra_data. Each is called only if
|
|
56
|
+
it exists, so an app that needs none passes no extra_data at all. They are
|
|
57
|
+
METHODS rather than string-expression locals on purpose: an Alpine expression
|
|
58
|
+
threaded through an ERB local has to survive a double-quoted HTML attribute,
|
|
59
|
+
and an escaped handler there renders working-looking HTML that passes every
|
|
60
|
+
markup assertion. Methods live inside the x-data where they are just code.
|
|
61
|
+
|
|
62
|
+
onInit() extra init, run BEFORE the first wallet refresh
|
|
63
|
+
canPick() return falsy to ABORT a pick or a deep link (a consent
|
|
64
|
+
gate); absent means always allowed
|
|
65
|
+
verifyArgs() object merged into the connect options (extra fields
|
|
66
|
+
the app's verify endpoint needs)
|
|
67
|
+
onConnected(result) what to do on success; DEFAULT navigates to
|
|
68
|
+
props.returnUrl || result.redirect || '/'. Override it
|
|
69
|
+
for a demo or an in-page continuation — and note the
|
|
70
|
+
override OWNS the reset: pick() leaves `connecting` and
|
|
71
|
+
`picking` set, because the default navigates away and
|
|
72
|
+
never comes back. An override that stays on the page
|
|
73
|
+
must clear both or the rows stay disabled.
|
|
74
|
+
onDeepLink() REPLACES the Phantom deep-link action. Use it to stash
|
|
75
|
+
state that must survive the round trip out of the
|
|
76
|
+
browser, or to do something else entirely. The default
|
|
77
|
+
calls window.startPhantomDeepLink(linkMode, userId).
|
|
78
|
+
onBack() the Back button; DEFAULT closes the modal
|
|
79
|
+
%>
|
|
80
|
+
<%
|
|
81
|
+
store = local_assigns.fetch(:store, "modals")
|
|
82
|
+
connect_fn = local_assigns.fetch(:connect_fn, "solanaConnectAndVerify")
|
|
83
|
+
title = local_assigns.fetch(:title, "Connect Wallet")
|
|
84
|
+
# html_safe, and deliberately: extra_data is a DEVELOPER-AUTHORED JavaScript
|
|
85
|
+
# fragment, never user input. Without it ActionView escapes the fragment on
|
|
86
|
+
# its way into the x-data attribute and every single quote becomes ' —
|
|
87
|
+
# which still PARSES (the browser decodes entities in an attribute), so the
|
|
88
|
+
# page works and only the source reads wrong. That is exactly the failure a
|
|
89
|
+
# markup assertion cannot see. On style/_modal_specimen: see the header. It
|
|
90
|
+
# marks open_expr and glow_when safe but leaves card_data ESCAPED, so the two
|
|
91
|
+
# seams DIVERGE — deliberately, and not because one of them has a typo.
|
|
92
|
+
slot = local_assigns[:slot].presence
|
|
93
|
+
slot_locals = local_assigns.fetch(:slot_locals, {})
|
|
94
|
+
extra_data = local_assigns.fetch(:extra_data, "").to_s.strip
|
|
95
|
+
# Built here and marked safe ONCE, rather than interpolated at the call site.
|
|
96
|
+
# Two traps sit on this line, both of which render a working-looking page:
|
|
97
|
+
# 1. Marking extra_data safe is NOT enough. Interpolating a SafeBuffer into
|
|
98
|
+
# a plain string literal yields a PLAIN String, the safety is lost, and
|
|
99
|
+
# every quote in the fragment escapes to an entity. It still PARSES (a
|
|
100
|
+
# browser decodes entities in an attribute), so only the source reads
|
|
101
|
+
# wrong. Concatenate, then mark the result.
|
|
102
|
+
# 2. The fragment is developer-authored JavaScript, never user input. That
|
|
103
|
+
# is what makes html_safe correct here rather than a hole.
|
|
104
|
+
extra_fragment = extra_data.present? ? (",\n " + extra_data).html_safe : "".html_safe
|
|
105
|
+
%>
|
|
106
|
+
<div x-data="{
|
|
107
|
+
get props() { var c = $store.<%= store %>.current(); return (c && c.props) || {}; },
|
|
108
|
+
wallets: [],
|
|
109
|
+
installs: [
|
|
110
|
+
{ name: 'Phantom', url: 'https://phantom.app/download' },
|
|
111
|
+
{ name: 'Solflare', url: 'https://solflare.com/download' },
|
|
112
|
+
{ name: 'Backpack', url: 'https://backpack.app/downloads' }
|
|
113
|
+
],
|
|
114
|
+
connecting: false,
|
|
115
|
+
picking: '',
|
|
116
|
+
error: '',
|
|
117
|
+
init() {
|
|
118
|
+
if (typeof this.onInit === 'function') this.onInit();
|
|
119
|
+
this.refresh();
|
|
120
|
+
// Wallet Standard registration can land a tick after the module
|
|
121
|
+
// loads — re-read once so a just-registered wallet still appears.
|
|
122
|
+
var self = this;
|
|
123
|
+
setTimeout(function() { if (!self.connecting) self.refresh(); }, 300);
|
|
124
|
+
},
|
|
125
|
+
refresh() {
|
|
126
|
+
this.wallets = (window.walletProvider && window.walletProvider.available && window.walletProvider.available()) || [];
|
|
127
|
+
},
|
|
128
|
+
get isMobile() { return !!(window.walletProvider && window.walletProvider.isMobile && window.walletProvider.isMobile()); },
|
|
129
|
+
hasWallet(name) {
|
|
130
|
+
var n = ('' + name).toLowerCase();
|
|
131
|
+
return (this.wallets || []).some(function(w) { return w.name && w.name.toLowerCase() === n; });
|
|
132
|
+
},
|
|
133
|
+
allowed() {
|
|
134
|
+
return typeof this.canPick === 'function' ? !!this.canPick() : true;
|
|
135
|
+
},
|
|
136
|
+
// Resolve a known install-brand name to its sprite symbol suffix. The
|
|
137
|
+
// three install rows are always known brands, so this always resolves;
|
|
138
|
+
// an unknown name returns null and the use paints nothing (detected rows
|
|
139
|
+
// never use it — they keep the wallet's own Wallet-Standard icon).
|
|
140
|
+
brandIcon(name) {
|
|
141
|
+
var n = ('' + name).toLowerCase();
|
|
142
|
+
return ['phantom', 'solflare', 'backpack'].indexOf(n) !== -1 ? n : null;
|
|
143
|
+
},
|
|
144
|
+
// Is there anywhere for a mobile Phantom row to GO? A consumer that does
|
|
145
|
+
// not render studio/solana/phantom_deeplink has no startPhantomDeepLink,
|
|
146
|
+
// and every branch below turns on this answer.
|
|
147
|
+
//
|
|
148
|
+
// WHY IT IS CHECKED AT ALL: without it, adopting this picker replaced an
|
|
149
|
+
// app's dead-end install row with a dead BUTTON — the install row
|
|
150
|
+
// suppressed, the deep-link row painted, and its tap a no-op. Found
|
|
151
|
+
// before the hub adopted, which had no mobile wallet path whatsoever.
|
|
152
|
+
// An absent capability must not default to the permissive branch.
|
|
153
|
+
get canDeepLink() {
|
|
154
|
+
return typeof startPhantomDeepLink === 'function';
|
|
155
|
+
},
|
|
156
|
+
// A phone has no extension to install, and Phantom's own row on mobile
|
|
157
|
+
// is the deep link below — so drop Phantom here rather than paint a
|
|
158
|
+
// SECOND Phantom row pointing at a desktop download page the user
|
|
159
|
+
// cannot act on. ONLY when a deep link can replace it: with no deep
|
|
160
|
+
// link the install row is the only Phantom path there is, dead end on
|
|
161
|
+
// iOS or not, and removing it leaves the user nothing. Solflare and
|
|
162
|
+
// Backpack keep their install rows either way — there is no deep link
|
|
163
|
+
// for them, so the download page is still their only path.
|
|
164
|
+
get missingInstalls() {
|
|
165
|
+
var self = this;
|
|
166
|
+
return this.installs.filter(function(i) {
|
|
167
|
+
if (self.hasWallet(i.name)) return false;
|
|
168
|
+
if (self.isMobile && self.canDeepLink && i.name === 'Phantom') return false;
|
|
169
|
+
return true;
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
// ONE Phantom row in every state: injected (Phantom's in-app browser)
|
|
173
|
+
// means the detected row above already offers a working connect, so a
|
|
174
|
+
// deep link would only offer to leave Phantom to open Phantom.
|
|
175
|
+
get showPhantomDeepLink() {
|
|
176
|
+
return this.isMobile && !this.hasWallet('Phantom') && this.canDeepLink;
|
|
177
|
+
},
|
|
178
|
+
async pick(name) {
|
|
179
|
+
if (this.connecting) return;
|
|
180
|
+
if (!this.allowed()) return;
|
|
181
|
+
this.connecting = true; this.picking = name; this.error = '';
|
|
182
|
+
try {
|
|
183
|
+
var opts = { linkMode: this.props.linkMode, currentUserId: this.props.currentUserId };
|
|
184
|
+
if (typeof this.verifyArgs === 'function') Object.assign(opts, this.verifyArgs() || {});
|
|
185
|
+
var result = await window.<%= connect_fn %>(name, opts);
|
|
186
|
+
if (result && result.success) {
|
|
187
|
+
if (typeof window.handleSolanaVerifySuccess === 'function') window.handleSolanaVerifySuccess(result);
|
|
188
|
+
if (typeof this.onConnected === 'function') {
|
|
189
|
+
this.onConnected(result);
|
|
190
|
+
} else {
|
|
191
|
+
window.location.href = this.props.returnUrl || result.redirect || '/';
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
this.error = (result && result.error) || 'Verification failed.';
|
|
195
|
+
this.connecting = false; this.picking = '';
|
|
196
|
+
}
|
|
197
|
+
} catch (e) {
|
|
198
|
+
var msg = (e && e.code === 4001) ? 'Signature rejected' : ((e && e.message) || 'Connection failed');
|
|
199
|
+
if (typeof parseSolanaError === 'function') msg = parseSolanaError(msg);
|
|
200
|
+
this.error = msg; this.connecting = false; this.picking = '';
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
deepLink() {
|
|
204
|
+
// Same connecting guard as pick(). Without it a tap during an in-flight
|
|
205
|
+
// SIWS verify fires the deep link and walks the user out of the browser
|
|
206
|
+
// mid-signature. The app copies this was promoted from had the same
|
|
207
|
+
// hole; sharing the partial is the moment to close it for everyone.
|
|
208
|
+
if (this.connecting) return;
|
|
209
|
+
if (!this.allowed()) return;
|
|
210
|
+
if (typeof this.onDeepLink === 'function') { this.onDeepLink(); return; }
|
|
211
|
+
if (typeof startPhantomDeepLink === 'function') {
|
|
212
|
+
startPhantomDeepLink(this.props.linkMode || false, (this.props.linkMode && this.props.currentUserId) || null);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
back() {
|
|
216
|
+
if (this.connecting) return;
|
|
217
|
+
if (typeof this.onBack === 'function') { this.onBack(); return; }
|
|
218
|
+
// Alpine.store(), not the $store magic: this runs as a METHOD BODY, and
|
|
219
|
+
// both consuming apps reach the store this way from inside one. The
|
|
220
|
+
// $store form stays where it is proven — the props getter and the
|
|
221
|
+
// Alpine attribute expressions in the markup below.
|
|
222
|
+
Alpine.store('<%= store %>').close();
|
|
223
|
+
}<%= extra_fragment %>
|
|
224
|
+
}"
|
|
225
|
+
class="relative">
|
|
226
|
+
|
|
227
|
+
<%# Wallet brand-icon sprite. Inline SVG symbol marks for the known install
|
|
228
|
+
brands, referenced by the rows below via use href="#se-wallet-…". MUST
|
|
229
|
+
live inside this picker's single root so the host's template x-if clones
|
|
230
|
+
it with the card — a sibling of the root is dropped, painting empty
|
|
231
|
+
icons. Replaces the per-app wallet PNGs. %>
|
|
232
|
+
<%= render "studio/modals/blocks/wallet_brand_sprite" %>
|
|
233
|
+
|
|
234
|
+
<div class="relative mb-4">
|
|
235
|
+
<h3 class="text-heading font-bold text-lg text-center pt-1"><%= title %></h3>
|
|
236
|
+
<button @click="$store.<%= store %>.close()"
|
|
237
|
+
class="absolute top-0 right-0 -mr-1 text-secondary hover:text-heading text-xl leading-none"
|
|
238
|
+
aria-label="Close">×</button>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<%# App slot — consent, legal copy, anything that must be answered before a
|
|
242
|
+
wallet is picked. Rendered from a NAMED LOCAL, never a block: see the
|
|
243
|
+
header. The app's own x-data members (and canPick) arrive through
|
|
244
|
+
extra_data, so the slot partial can bind straight to them. %>
|
|
245
|
+
<% if slot %>
|
|
246
|
+
<%= render slot, **slot_locals %>
|
|
247
|
+
<% end %>
|
|
248
|
+
|
|
249
|
+
<div class="space-y-2">
|
|
250
|
+
<%# Detected wallets — INSTALLED, click to connect %>
|
|
251
|
+
<template x-for="w in wallets" :key="w.name">
|
|
252
|
+
<button type="button" @click="pick(w.name)" :disabled="connecting"
|
|
253
|
+
class="w-full flex items-center gap-3 p-3 rounded-xl bg-surface-alt border border-strong hover:bg-surface transition text-left disabled:opacity-60 disabled:cursor-wait">
|
|
254
|
+
<%# Brand mark, three ways, and NONE of them an app-served file. A wallet
|
|
255
|
+
that registered through Wallet Standard supplies its own icon, and
|
|
256
|
+
that is the most accurate mark available, so it wins. Otherwise the
|
|
257
|
+
engine sprite by brand name. Otherwise a letter tile. The markup
|
|
258
|
+
promoted from the app fell back to /solana-mark.svg, a per-app asset
|
|
259
|
+
— exactly what blocks/_wallet_brand_sprite exists to end, and a 404
|
|
260
|
+
in any app that had not copied it. %>
|
|
261
|
+
<template x-if="w.icon">
|
|
262
|
+
<img :src="w.icon" alt="" class="w-9 h-9 rounded-lg shrink-0">
|
|
263
|
+
</template>
|
|
264
|
+
<template x-if="!w.icon && brandIcon(w.name)">
|
|
265
|
+
<span class="w-9 h-9 rounded-lg overflow-hidden flex items-center justify-center shrink-0">
|
|
266
|
+
<svg class="w-9 h-9 shrink-0" aria-hidden="true"><use :href="'#se-wallet-' + brandIcon(w.name)"></use></svg>
|
|
267
|
+
</span>
|
|
268
|
+
</template>
|
|
269
|
+
<template x-if="!w.icon && !brandIcon(w.name)">
|
|
270
|
+
<span class="w-9 h-9 rounded-lg bg-inset flex items-center justify-center text-sm font-bold text-heading shrink-0" x-text="w.name.slice(0,1)"></span>
|
|
271
|
+
</template>
|
|
272
|
+
<span class="font-semibold text-heading" x-text="w.name"></span>
|
|
273
|
+
<span class="ml-auto flex items-center gap-2">
|
|
274
|
+
<span x-show="picking === w.name" class="inline-flex items-center gap-1.5 text-xs text-secondary"><span class="spinner" aria-hidden="true"></span>Connecting…</span>
|
|
275
|
+
<span x-show="picking !== w.name" class="badge border-primary text-primary">Installed</span>
|
|
276
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-muted">
|
|
277
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
|
278
|
+
</svg>
|
|
279
|
+
</span>
|
|
280
|
+
</button>
|
|
281
|
+
</template>
|
|
282
|
+
|
|
283
|
+
<%# Mobile Phantom — the deep link IS the Phantom row here, so it sits in
|
|
284
|
+
Phantom's usual first position wearing the same brand icon and chevron
|
|
285
|
+
as the install rows, not as a second Phantom entry at the bottom of the
|
|
286
|
+
list. Phantom's universal link covers both states on its own: app
|
|
287
|
+
installed opens it, app absent falls through to Phantom's install page. %>
|
|
288
|
+
<button x-show="showPhantomDeepLink" x-cloak type="button" @click="deepLink()"
|
|
289
|
+
class="w-full flex items-center gap-3 p-3 rounded-xl bg-surface-alt border border-strong hover:bg-surface transition text-left">
|
|
290
|
+
<svg class="w-9 h-9 shrink-0" aria-hidden="true"><use href="#se-wallet-phantom"></use></svg>
|
|
291
|
+
<span class="font-semibold text-heading">Phantom</span>
|
|
292
|
+
<span class="ml-auto flex items-center gap-2">
|
|
293
|
+
<span class="text-xs text-muted uppercase tracking-wide">Open app</span>
|
|
294
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-muted">
|
|
295
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
|
296
|
+
</svg>
|
|
297
|
+
</span>
|
|
298
|
+
</button>
|
|
299
|
+
|
|
300
|
+
<%# Featured wallets that aren't installed — open the install page %>
|
|
301
|
+
<template x-for="i in missingInstalls" :key="i.name">
|
|
302
|
+
<a :href="i.url" target="_blank" rel="noopener noreferrer"
|
|
303
|
+
class="w-full flex items-center gap-3 p-3 rounded-xl bg-surface-alt border border-strong hover:bg-surface transition no-underline">
|
|
304
|
+
<svg class="w-9 h-9 shrink-0" aria-hidden="true"><use :href="'#se-wallet-' + brandIcon(i.name)"></use></svg>
|
|
305
|
+
<span class="font-semibold text-heading" x-text="i.name"></span>
|
|
306
|
+
<span class="ml-auto flex items-center gap-2">
|
|
307
|
+
<span class="text-xs text-muted uppercase tracking-wide">Install</span>
|
|
308
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-muted">
|
|
309
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
|
310
|
+
</svg>
|
|
311
|
+
</span>
|
|
312
|
+
</a>
|
|
313
|
+
</template>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
<%# role="alert" — the engine requires every rendered error paragraph to carry
|
|
317
|
+
a live region (test/integration/modal_error_announcement_render_test.rb).
|
|
318
|
+
The app markup this was promoted from had none, so a connect failure was
|
|
319
|
+
shown and never ANNOUNCED. Adopting the primitive is what surfaced it: the
|
|
320
|
+
apps were never subject to this test. Same defect, same fix, as the
|
|
321
|
+
attestation error in studio/modals/shared/_age_attestation. %>
|
|
322
|
+
<template x-if="error">
|
|
323
|
+
<p role="alert" class="text-red-400 text-sm mt-3 text-center" x-text="error"></p>
|
|
324
|
+
</template>
|
|
325
|
+
|
|
326
|
+
<button type="button" @click="back()" :disabled="connecting"
|
|
327
|
+
class="mt-4 w-full text-center text-sm text-secondary hover:text-heading disabled:opacity-50">
|
|
328
|
+
← Back
|
|
329
|
+
</button>
|
|
330
|
+
</div>
|
|
@@ -13,6 +13,24 @@
|
|
|
13
13
|
modals/_wallet_deposit, _wallet_topup, _cdp_ramp, _onramp_hub, _buy_entry_token
|
|
14
14
|
and three under modals/auth/ (_paypal_tokens, _usdc_funding, _tokens).
|
|
15
15
|
|
|
16
|
+
THE /admin/style SPECIMENS SHOW STRUCTURE, NOT CONTENT. If you are adopting
|
|
17
|
+
this primitive to replace existing markup, take every VALUE — here modal_store
|
|
18
|
+
and label — from the markup you are REPLACING, and use the specimen only to
|
|
19
|
+
learn which locals exist and how they compose. A specimen is a demo: the
|
|
20
|
+
engine is entitled to pick any plausible value for it, and those values are
|
|
21
|
+
not your app's. style/modals/_ds_wallet_topup passes modal_store "dsModals",
|
|
22
|
+
its own page-scoped host; a callsite that copies it closes a store the app
|
|
23
|
+
does not have, so the mark renders and does nothing.
|
|
24
|
+
|
|
25
|
+
The incident that earned this paragraph: an adopter built a new callsite from
|
|
26
|
+
style/modals/_ds_wallet_topup and carried its icon across. The specimen passes
|
|
27
|
+
U+1F39F ADMISSION TICKETS; the markup being replaced had always drawn U+1F3AB
|
|
28
|
+
TICKET. A different glyph in a different colour landed on the primary rail of
|
|
29
|
+
the web2 kill-switch face of Top Up Wallet — the single call to action shown to
|
|
30
|
+
the audience that cannot pay with USDC. Nothing raised, CI was 6/6 green, and
|
|
31
|
+
no assertion anywhere pinned the glyph. It was caught only by rendering the
|
|
32
|
+
modal before and after and diffing the markup.
|
|
33
|
+
|
|
16
34
|
THE PARENT MUST BE POSITIONED. This is `absolute`, so it anchors to the nearest
|
|
17
35
|
positioned ancestor — every callsite wraps its card in `class="relative"`. On an
|
|
18
36
|
unpositioned parent the × climbs to whatever is positioned above it, which in a
|
|
@@ -9,6 +9,22 @@
|
|
|
9
9
|
why that is a named local rather than a class passthrough — a passthrough would
|
|
10
10
|
have let the eleventh copy drift on day one, which is how ten happened.
|
|
11
11
|
|
|
12
|
+
THE /admin/style SPECIMENS SHOW STRUCTURE, NOT CONTENT. If you are adopting
|
|
13
|
+
this primitive to replace existing markup, take every VALUE — icon_label,
|
|
14
|
+
icon_bg, title, subtitle, badge, data hooks — from the markup you are
|
|
15
|
+
REPLACING, and use the specimen only to learn which locals exist and how
|
|
16
|
+
they compose. A specimen is a demo: the engine is entitled to pick any
|
|
17
|
+
plausible value for it, and those values are not your app's.
|
|
18
|
+
|
|
19
|
+
The incident that earned this paragraph: an adopter built a new callsite from
|
|
20
|
+
style/modals/_ds_wallet_topup and carried its icon across. The specimen passes
|
|
21
|
+
U+1F39F ADMISSION TICKETS; the markup being replaced had always drawn U+1F3AB
|
|
22
|
+
TICKET. A different glyph in a different colour landed on the primary rail of
|
|
23
|
+
the web2 kill-switch face of Top Up Wallet — the single call to action shown to
|
|
24
|
+
the audience that cannot pay with USDC. Nothing raised, CI was 6/6 green, and
|
|
25
|
+
no assertion anywhere pinned the glyph. It was caught only by rendering the
|
|
26
|
+
modal before and after and diffing the markup.
|
|
27
|
+
|
|
12
28
|
THE EMPHASIS IS A RANKING, not decoration. A hub full of equally-weighted rails
|
|
13
29
|
asks the person to compare payment processors, which is not a question they can
|
|
14
30
|
answer. Exactly one rail should be :primary — the one the app wants taken —
|
|
@@ -211,9 +211,18 @@
|
|
|
211
211
|
3. cta_event: button dispatching event %>
|
|
212
212
|
<% if local_assigns[:cta_label] %>
|
|
213
213
|
<% if use_drain && local_assigns[:cta_href_key] %>
|
|
214
|
+
<%# The DRAIN branches carry the same .btn skin as every other CTA in this
|
|
215
|
+
family (_cta_redirect, _onchain_success, and this file's own non-drain
|
|
216
|
+
branches two blocks below). They were hand-rolled once and were the
|
|
217
|
+
only two non-.btn buttons in the engine's modal blocks: px-4 py-2.5
|
|
218
|
+
text-sm rounded-lg against the class's px-8 py-3 text-base rounded-xl,
|
|
219
|
+
no shadow, no hover, and no branded focus-visible ring — 40px tall
|
|
220
|
+
where the class gives 48px, under the 44px mobile touch target, on a
|
|
221
|
+
consumer's primary conversion CTA. btn-primary already sets the CTA
|
|
222
|
+
background, so the inline style that used to sit here is gone. The
|
|
223
|
+
absolutely-positioned drain overlay is unchanged. %>
|
|
214
224
|
<a :href="<%= cta_href_key %>"
|
|
215
|
-
class="
|
|
216
|
-
style="background: var(--color-cta);">
|
|
225
|
+
class="btn btn-primary btn-lg w-full relative overflow-hidden no-underline">
|
|
217
226
|
<div class="absolute inset-0 pointer-events-none origin-left"
|
|
218
227
|
style="background: rgba(255,255,255,0.18);"
|
|
219
228
|
:style="{ animation: 'studio-modal-drain ' + _total + 's linear forwards' }"></div>
|
|
@@ -221,17 +230,23 @@
|
|
|
221
230
|
</a>
|
|
222
231
|
<% elsif use_drain && local_assigns[:cta_event] %>
|
|
223
232
|
<button @click="$dispatch('<%= cta_event %>')"
|
|
224
|
-
class="
|
|
225
|
-
style="background: var(--color-cta);">
|
|
233
|
+
class="btn btn-primary btn-lg w-full relative overflow-hidden no-underline">
|
|
226
234
|
<div class="absolute inset-0 pointer-events-none origin-left"
|
|
227
235
|
style="background: rgba(255,255,255,0.18);"
|
|
228
236
|
:style="{ animation: 'studio-modal-drain ' + _total + 's linear forwards' }"></div>
|
|
229
237
|
<span class="relative z-10"><%= cta_label %></span>
|
|
230
238
|
</button>
|
|
239
|
+
<%# btn-lg, matching the drain branches above and every sibling in this family
|
|
240
|
+
(_onchain_success, _cta_redirect). Without it these two rendered 36px
|
|
241
|
+
(.btn alone: py-2 + text-sm) while the drain branch rendered 48 — the
|
|
242
|
+
same card, two heights, decided by whether an auto-redirect happened to
|
|
243
|
+
be configured. 36px is also under the 44px mobile touch-target
|
|
244
|
+
guideline. Not 40px: that is the HAND-ROLLED drain button one branch up
|
|
245
|
+
(px-4 py-2.5 text-sm), a different element with a different sum. %>
|
|
231
246
|
<% elsif local_assigns[:cta_href_key] %>
|
|
232
|
-
<a :href="<%= cta_href_key %>" class="btn btn-primary w-full"><%= cta_label %></a>
|
|
247
|
+
<a :href="<%= cta_href_key %>" class="btn btn-primary btn-lg w-full"><%= cta_label %></a>
|
|
233
248
|
<% elsif local_assigns[:cta_event] %>
|
|
234
|
-
<button @click="$dispatch('<%= cta_event %>')" class="btn btn-primary w-full"><%= cta_label %></button>
|
|
249
|
+
<button @click="$dispatch('<%= cta_event %>')" class="btn btn-primary btn-lg w-full"><%= cta_label %></button>
|
|
235
250
|
<% end %>
|
|
236
251
|
<% end %>
|
|
237
252
|
|
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
currentColor, so a caller can tint it to whatever it is standing in for. Use it
|
|
28
28
|
whenever a brand is absent or unrecognised, instead of hiding the avatar and
|
|
29
29
|
giving the surface two different shapes.
|
|
30
|
+
|
|
31
|
+
ONE CALLER DELIBERATELY DOES NOT: studio/modals/_wallet_connect paints a LETTER
|
|
32
|
+
TILE for a detected wallet whose brand it does not know, not se-wallet-default.
|
|
33
|
+
A detected wallet always has a name (it registered through Wallet Standard to
|
|
34
|
+
get there), and its initial identifies it where a generic mark would not. The
|
|
35
|
+
default mark remains right for a slot with no name to fall back on.
|
|
30
36
|
%>
|
|
31
37
|
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"
|
|
32
38
|
style="position:absolute;width:0;height:0;overflow:hidden" data-wallet-brand-sprite>
|