studio-engine 0.62.5 → 0.63.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.
@@ -0,0 +1,315 @@
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
+ // A phone has no extension to install, and Phantom's own row on mobile
145
+ // is the deep link below — so drop Phantom here rather than paint a
146
+ // SECOND Phantom row pointing at a desktop download page the user
147
+ // cannot act on. Solflare and Backpack keep their install rows: there is
148
+ // no deep link for them, so the download page is still their only path.
149
+ get missingInstalls() {
150
+ var self = this;
151
+ return this.installs.filter(function(i) {
152
+ if (self.hasWallet(i.name)) return false;
153
+ if (self.isMobile && i.name === 'Phantom') return false;
154
+ return true;
155
+ });
156
+ },
157
+ // ONE Phantom row in every state: injected (Phantom's in-app browser)
158
+ // means the detected row above already offers a working connect, so a
159
+ // deep link would only offer to leave Phantom to open Phantom.
160
+ get showPhantomDeepLink() {
161
+ return this.isMobile && !this.hasWallet('Phantom');
162
+ },
163
+ async pick(name) {
164
+ if (this.connecting) return;
165
+ if (!this.allowed()) return;
166
+ this.connecting = true; this.picking = name; this.error = '';
167
+ try {
168
+ var opts = { linkMode: this.props.linkMode, currentUserId: this.props.currentUserId };
169
+ if (typeof this.verifyArgs === 'function') Object.assign(opts, this.verifyArgs() || {});
170
+ var result = await window.<%= connect_fn %>(name, opts);
171
+ if (result && result.success) {
172
+ if (typeof window.handleSolanaVerifySuccess === 'function') window.handleSolanaVerifySuccess(result);
173
+ if (typeof this.onConnected === 'function') {
174
+ this.onConnected(result);
175
+ } else {
176
+ window.location.href = this.props.returnUrl || result.redirect || '/';
177
+ }
178
+ } else {
179
+ this.error = (result && result.error) || 'Verification failed.';
180
+ this.connecting = false; this.picking = '';
181
+ }
182
+ } catch (e) {
183
+ var msg = (e && e.code === 4001) ? 'Signature rejected' : ((e && e.message) || 'Connection failed');
184
+ if (typeof parseSolanaError === 'function') msg = parseSolanaError(msg);
185
+ this.error = msg; this.connecting = false; this.picking = '';
186
+ }
187
+ },
188
+ deepLink() {
189
+ // Same connecting guard as pick(). Without it a tap during an in-flight
190
+ // SIWS verify fires the deep link and walks the user out of the browser
191
+ // mid-signature. The app copies this was promoted from had the same
192
+ // hole; sharing the partial is the moment to close it for everyone.
193
+ if (this.connecting) return;
194
+ if (!this.allowed()) return;
195
+ if (typeof this.onDeepLink === 'function') { this.onDeepLink(); return; }
196
+ if (typeof startPhantomDeepLink === 'function') {
197
+ startPhantomDeepLink(this.props.linkMode || false, (this.props.linkMode && this.props.currentUserId) || null);
198
+ }
199
+ },
200
+ back() {
201
+ if (this.connecting) return;
202
+ if (typeof this.onBack === 'function') { this.onBack(); return; }
203
+ // Alpine.store(), not the $store magic: this runs as a METHOD BODY, and
204
+ // both consuming apps reach the store this way from inside one. The
205
+ // $store form stays where it is proven — the props getter and the
206
+ // Alpine attribute expressions in the markup below.
207
+ Alpine.store('<%= store %>').close();
208
+ }<%= extra_fragment %>
209
+ }"
210
+ class="relative">
211
+
212
+ <%# Wallet brand-icon sprite. Inline SVG symbol marks for the known install
213
+ brands, referenced by the rows below via use href="#se-wallet-…". MUST
214
+ live inside this picker's single root so the host's template x-if clones
215
+ it with the card — a sibling of the root is dropped, painting empty
216
+ icons. Replaces the per-app wallet PNGs. %>
217
+ <%= render "studio/modals/blocks/wallet_brand_sprite" %>
218
+
219
+ <div class="relative mb-4">
220
+ <h3 class="text-heading font-bold text-lg text-center pt-1"><%= title %></h3>
221
+ <button @click="$store.<%= store %>.close()"
222
+ class="absolute top-0 right-0 -mr-1 text-secondary hover:text-heading text-xl leading-none"
223
+ aria-label="Close">&times;</button>
224
+ </div>
225
+
226
+ <%# App slot — consent, legal copy, anything that must be answered before a
227
+ wallet is picked. Rendered from a NAMED LOCAL, never a block: see the
228
+ header. The app's own x-data members (and canPick) arrive through
229
+ extra_data, so the slot partial can bind straight to them. %>
230
+ <% if slot %>
231
+ <%= render slot, **slot_locals %>
232
+ <% end %>
233
+
234
+ <div class="space-y-2">
235
+ <%# Detected wallets — INSTALLED, click to connect %>
236
+ <template x-for="w in wallets" :key="w.name">
237
+ <button type="button" @click="pick(w.name)" :disabled="connecting"
238
+ 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">
239
+ <%# Brand mark, three ways, and NONE of them an app-served file. A wallet
240
+ that registered through Wallet Standard supplies its own icon, and
241
+ that is the most accurate mark available, so it wins. Otherwise the
242
+ engine sprite by brand name. Otherwise a letter tile. The markup
243
+ promoted from the app fell back to /solana-mark.svg, a per-app asset
244
+ — exactly what blocks/_wallet_brand_sprite exists to end, and a 404
245
+ in any app that had not copied it. %>
246
+ <template x-if="w.icon">
247
+ <img :src="w.icon" alt="" class="w-9 h-9 rounded-lg shrink-0">
248
+ </template>
249
+ <template x-if="!w.icon && brandIcon(w.name)">
250
+ <span class="w-9 h-9 rounded-lg overflow-hidden flex items-center justify-center shrink-0">
251
+ <svg class="w-9 h-9 shrink-0" aria-hidden="true"><use :href="'#se-wallet-' + brandIcon(w.name)"></use></svg>
252
+ </span>
253
+ </template>
254
+ <template x-if="!w.icon && !brandIcon(w.name)">
255
+ <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>
256
+ </template>
257
+ <span class="font-semibold text-heading" x-text="w.name"></span>
258
+ <span class="ml-auto flex items-center gap-2">
259
+ <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>
260
+ <span x-show="picking !== w.name" class="badge border-primary text-primary">Installed</span>
261
+ <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">
262
+ <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
263
+ </svg>
264
+ </span>
265
+ </button>
266
+ </template>
267
+
268
+ <%# Mobile Phantom — the deep link IS the Phantom row here, so it sits in
269
+ Phantom's usual first position wearing the same brand icon and chevron
270
+ as the install rows, not as a second Phantom entry at the bottom of the
271
+ list. Phantom's universal link covers both states on its own: app
272
+ installed opens it, app absent falls through to Phantom's install page. %>
273
+ <button x-show="showPhantomDeepLink" x-cloak type="button" @click="deepLink()"
274
+ class="w-full flex items-center gap-3 p-3 rounded-xl bg-surface-alt border border-strong hover:bg-surface transition text-left">
275
+ <svg class="w-9 h-9 shrink-0" aria-hidden="true"><use href="#se-wallet-phantom"></use></svg>
276
+ <span class="font-semibold text-heading">Phantom</span>
277
+ <span class="ml-auto flex items-center gap-2">
278
+ <span class="text-xs text-muted uppercase tracking-wide">Open app</span>
279
+ <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">
280
+ <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
281
+ </svg>
282
+ </span>
283
+ </button>
284
+
285
+ <%# Featured wallets that aren't installed — open the install page %>
286
+ <template x-for="i in missingInstalls" :key="i.name">
287
+ <a :href="i.url" target="_blank" rel="noopener noreferrer"
288
+ class="w-full flex items-center gap-3 p-3 rounded-xl bg-surface-alt border border-strong hover:bg-surface transition no-underline">
289
+ <svg class="w-9 h-9 shrink-0" aria-hidden="true"><use :href="'#se-wallet-' + brandIcon(i.name)"></use></svg>
290
+ <span class="font-semibold text-heading" x-text="i.name"></span>
291
+ <span class="ml-auto flex items-center gap-2">
292
+ <span class="text-xs text-muted uppercase tracking-wide">Install</span>
293
+ <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">
294
+ <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
295
+ </svg>
296
+ </span>
297
+ </a>
298
+ </template>
299
+ </div>
300
+
301
+ <%# role="alert" — the engine requires every rendered error paragraph to carry
302
+ a live region (test/integration/modal_error_announcement_render_test.rb).
303
+ The app markup this was promoted from had none, so a connect failure was
304
+ shown and never ANNOUNCED. Adopting the primitive is what surfaced it: the
305
+ apps were never subject to this test. Same defect, same fix, as the
306
+ attestation error in studio/modals/shared/_age_attestation. %>
307
+ <template x-if="error">
308
+ <p role="alert" class="text-red-400 text-sm mt-3 text-center" x-text="error"></p>
309
+ </template>
310
+
311
+ <button type="button" @click="back()" :disabled="connecting"
312
+ class="mt-4 w-full text-center text-sm text-secondary hover:text-heading disabled:opacity-50">
313
+ &larr; Back
314
+ </button>
315
+ </div>
@@ -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,8 +230,7 @@
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>
@@ -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>
@@ -132,7 +132,7 @@
132
132
  a second transform property would silently replace the first. */
133
133
  left: 50%;
134
134
  width: min(42rem, calc(100% - 2rem));
135
- z-index: 30;
135
+ z-index: var(--z-sticky, 30);
136
136
  background: var(--color-surface);
137
137
  border: 1px solid var(--color-border-subtle);
138
138
  border-radius: 0.75rem;
@@ -116,6 +116,15 @@
116
116
  return { connecting: false, error: null, statusText: 'Connect Wallet',
117
117
  walletAvailable: false, isMobile: false, connect: function () {} };
118
118
  };
119
+ // Connect stub for the promoted wallet picker (studio/modals/_wallet_connect,
120
+ // configured by style/modals/_wallet_connect). Performs NO connect: it
121
+ // resolves success after a beat so the picker's connecting state is
122
+ // visible and its onConnected hook can continue the demo walk.
123
+ window.dsWalletConnectDemo = window.dsWalletConnectDemo || function () {
124
+ return new Promise(function (resolve) {
125
+ setTimeout(function () { resolve({ success: true }); }, 1100);
126
+ });
127
+ };
119
128
  window.walletProvider = window.walletProvider || {
120
129
  available: function () { return [{ name: 'Phantom' }, { name: 'Solflare' }]; },
121
130
  isMobile: function () { return false; },
@@ -306,7 +315,7 @@
306
315
  gated on props.dismissible (never @click.outside, so a hold modal can't
307
316
  close itself on the button release). %>
308
317
  <template x-if="$store.dsModals.current()">
309
- <div class="fixed inset-0 z-[120] flex items-center justify-center p-4 modal-backdrop-mount"
318
+ <div class="fixed inset-0 z-[var(--z-modal)] flex items-center justify-center p-4 modal-backdrop-mount"
310
319
  :class="$store.dsModals.current()?._closing && 'modal-backdrop-unmount'"
311
320
  style="background: rgba(0,0,0,0.6)"
312
321
  role="dialog" aria-modal="true"