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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/README.md +61 -0
  4. data/app/assets/stylesheets/studio/sticky_table_header.css +4 -1
  5. data/app/assets/tailwind/studio_engine/engine-motion.css +24 -1
  6. data/app/assets/tailwind/studio_engine/engine.css +208 -4
  7. data/app/controllers/solana_sessions_controller.rb +9 -0
  8. data/app/views/layouts/_navbar.html.erb +6 -74
  9. data/app/views/layouts/studio/_flash.html.erb +8 -5
  10. data/app/views/layouts/studio/_head.html.erb +99 -8
  11. data/app/views/solana_sessions/phantom_callback.html.erb +343 -0
  12. data/app/views/studio/admin_models/_teams_table.html.erb +1 -1
  13. data/app/views/studio/banners/_app_banner.html.erb +4 -1
  14. data/app/views/studio/banners/_button.html.erb +2 -2
  15. data/app/views/studio/banners/_stack.html.erb +12 -4
  16. data/app/views/studio/modals/_host.html.erb +105 -5
  17. data/app/views/studio/modals/_scoped_host.html.erb +25 -2
  18. data/app/views/studio/modals/_wallet_connect.html.erb +330 -0
  19. data/app/views/studio/modals/blocks/_close_x.html.erb +18 -0
  20. data/app/views/studio/modals/blocks/_rail_row.html.erb +16 -0
  21. data/app/views/studio/modals/blocks/_success_card.html.erb +21 -6
  22. data/app/views/studio/modals/blocks/_wallet_brand_sprite.html.erb +6 -0
  23. data/app/views/studio/profiles/_identity_styles.html.erb +1 -1
  24. data/app/views/studio/solana/_deeplink_assets.html.erb +37 -0
  25. data/app/views/studio/solana/_phantom_deeplink.html.erb +167 -0
  26. data/app/views/style/_modals.html.erb +10 -1
  27. data/app/views/style/modals/_wallet_connect.html.erb +77 -171
  28. data/app/views/style/modals/_wallet_connect_slot.html.erb +18 -0
  29. data/lib/studio/version.rb +1 -1
  30. data/lib/studio.rb +49 -4
  31. 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
- page beneath any modal from scrolling on wheel / touch / spacebar. */
23
- body.modal-open { overflow: hidden; }
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-[120] flex items-center justify-center p-4 modal-backdrop-mount"
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
- <div class="bg-surface rounded-xl border border-subtle shadow-2xl p-6 max-w-sm w-full max-h-[85dvh] overflow-y-auto"
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
- body.modal-open { overflow: hidden; }
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-[120] flex items-center justify-center p-4 modal-backdrop-mount"
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 &#39; —
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">&times;</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&hellip;</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
+ &larr; 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="relative overflow-hidden block w-full px-4 py-2.5 rounded-lg font-bold text-sm text-white text-center transition no-underline"
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="relative overflow-hidden block w-full px-4 py-2.5 rounded-lg font-bold text-sm text-white text-center transition no-underline"
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>