solana-studio 0.5.2 → 0.5.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa775c67a1f3ae154dabf231c294c86e6a03dc74f2866fcfdab2cb7f8bf9da9b
4
- data.tar.gz: 6dd4bd255c9ea0d99c8daaeff4a176152226e63a61f36943869f70f3e9a57441
3
+ metadata.gz: f23721d4f7078e324181a032346ecde03ec9155fab5b598e08c2d570f66cc3f2
4
+ data.tar.gz: cb9cb4d1bd2a7dd354d9adc62e0b7c2ef09a099b8151ac5b1c8688f16fc55541
5
5
  SHA512:
6
- metadata.gz: b52fd18bf1bab93c0a060d5cc135b1e9bc81bdfb5b32b9771251f831ffaaa5653891706927904e5e57a852094c3a4e41a45350e84bcca16f4f30cd9913bdbc55
7
- data.tar.gz: 701023cfadfee72780409a021e2e2bfa2223cb3366a07659409764f5aa7f4aa1fb2adc961a5b7ba68103fe5263dfa77506f659727c6c315171d63faa4d16f735
6
+ metadata.gz: 984eb74053d0f3bb8e46009bef2ec8092c3f0c425237eaf6adb8c97b295583fa02f8a7dd644f4ee04191bf1e1c3108d21f8a74644f097c28c980c253f816a539
7
+ data.tar.gz: 69fd31c57ecf75e77a31b6a4deb1e15d45a35ac9dbb98495f23b137cd1beed1b23a947edcc572e4a090f3e388e4a5e158a40b68b2a475b7573b32282a6447df7
data/CHANGELOG.md CHANGED
@@ -6,12 +6,22 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
6
6
 
7
7
  ### Added
8
8
  - **The wallet sign-in button, contributed into studio-engine's auth modal** (`app/views/solana_studio/auth/_wallet_credential.html.erb`). Part of the base/bolt-on split: studio-engine plus McRitchie Studio is the template every app is built from, web2 and web3 alike, and this gem plus Turf Monster is the web3 bolt-on. Sign-in is a base concern so the modal stays in the engine; **wallets are not**, so the button moved here. The engine renders whatever resolves at `solana_studio/auth/wallet_credential` and nothing when the path is empty, so bundling this gem IS the registration and a web2 app carries no wallet markup at all. Deliberately NOT solved by giving this gem its own auth modal: that would fork a surface both apps sign in through, which is how the wallet picker reached three drifting copies before it was promoted. The engine keeps both halves of the existing gate (`Studio.auth_method?(:wallet)` and `Studio.feature?(:web3)`) so the hub — which bundles this gem for its signing primitives with web3 off — still renders a web2 sign-in modal.
9
+ - **The web3 modals move here** — `solana_studio/modals/wallet_connect`, `solana_studio/modals/web3_step_up`, `solana_studio/phantom_deeplink` and `solana_studio/deeplink_assets`, promoted out of studio-engine byte for byte. They are Solana-specific to the last line, and studio-engine is the engine EVERY app bundles, including apps that ship no chain feature at all. Only the home changes: the markup, the locals and the behaviour are identical, and the four files are added here before studio-engine drops its copies, so no consumer is ever without a home for them.
10
+
11
+ ### Changed
12
+ - `.github/workflows/gem-ci.yml` asserts all four new partials are inside the BUILT gem, not just the manifest. `test/gemspec_test.rb` already asserts the invariant that every `app/` file reaches `spec.files`; the artifact check is a different claim, and only the artifact is what a consumer installs.
9
13
 
10
14
  ### Tests
11
15
  - `test/auth_credential_test.rb` (7): the partial sits at the path the engine resolves, the click handler consults the modal's age gate, visibility binds to the shared `methodOn('wallet')` toggle, `modal_store` is required with no default, the swap carries the picker's `backTo`/`ageAttested` contract, the brand gradient id is namespaced against collisions, and the mark stays inline rather than becoming a sprockets asset request. Every assertion reads ERB-comment-stripped source, because this file documents its own contract in prose and a bare grep would match the paragraph instead of the code — proven by mutating the gate into a comment and watching it go red.
12
16
  - `test/gemspec_test.rb` and `.github/workflows/gem-ci.yml` name the new partial alongside the modal, so a `spec.files` glob that stopped matching it fails at build rather than in a consumer.
17
+ - **`test/web3_modals_test.rb`** (5) — resolves each partial BY VIRTUAL PATH through an `ActionView::LookupContext` over the engine's own view paths, which is the mechanism a consumer uses, rather than `File.exist?` (true of a file no consumer can reach). Each identifier must sit under this gem's view root — asserted that way rather than by matching `/gems/` in the path, because turf-monster's own `test/support/resolved_wallet_picker.rb` warns against that form: it fails a path checkout while proving nothing extra.
18
+ - **`test/erb_comment_leak_test.rb`** (6) — ported from studio-engine, whose glob stops covering these files the moment they leave it. An ERB comment ends at its FIRST close sequence, so a comment quoting a tag leaks its tail into the page as visible prose; the two most comment-dense files in the move (`_wallet_connect`, `_web3_step_up`, 8 comments each) were exactly the ones losing the guard. All three leak signatures ported with their guard-the-guard probes, and mutation-proved on a real moved file.
19
+
20
+ ### Notes for hosts
21
+ - **These require studio-engine at render time** — they render its modal host and its shared blocks by name, and paint with its utilities and theme tokens. `solana_studio/modals/network_mismatch` already shipped on exactly these terms, so this is the established arrangement rather than new coupling. studio-engine stays a DEVELOPMENT dependency; a runtime one would drag Rails into every plain-Ruby consumer.
22
+ - **`phantom_deeplink` still emits `Studio.wallet_sign_in_statement`, deliberately.** studio-engine's `solana_sessions/phantom_callback` rebuilds the signed message to verify it, so both halves read one accessor precisely so they cannot drift. Parameterising it would decouple the gem, read as an improvement, and break the signature check on every mobile sign-in. Pinned from both directions in `test/web3_modals_test.rb`.
13
23
 
14
- ## Unreleased — targets v0.5.0 (minor)
24
+ ## v0.5.0
15
25
 
16
26
  Ships the gem's first Rails half, and its first CI. The version number is
17
27
  allocated by the release conductor, not by this entry.
data/README.md CHANGED
@@ -214,6 +214,60 @@ Why a contributed button and not a second auth modal: Turf Monster wants Google
214
214
  plus magic-link plus wallet, McRitchie Studio wants Google plus magic-link. A
215
215
  forked modal would put a surface both apps sign in through into two files, which
216
216
  is how the wallet picker reached three copies before it was promoted.
217
+ ### Web3 modals
218
+
219
+ Four partials promoted out of studio-engine, where every consumer paid for them
220
+ whether or not it shipped a chain feature at all:
221
+
222
+ | Virtual path | What it is |
223
+ |---|---|
224
+ | `solana_studio/modals/wallet_connect` | The Connect-Wallet picker — detected wallets, install rows, and Phantom's mobile deep-link row |
225
+ | `solana_studio/modals/web3_step_up` | The sign-with-your-wallet step-up card |
226
+ | `solana_studio/phantom_deeplink` | Defines `window.startPhantomDeepLink(linkMode, currentUserId)` — the phone round trip |
227
+ | `solana_studio/deeplink_assets` | An idempotent, non-blocking tweetnacl loader for that round trip |
228
+
229
+ ```erb
230
+ <%# once, inside your modal host %>
231
+ <template x-if="$store.modals.current().id === 'wallet-connect'">
232
+ <%= render "solana_studio/modals/wallet_connect" %>
233
+ </template>
234
+
235
+ <template x-if="$store.modals.current().id === 'web3-step-up'">
236
+ <%= render "solana_studio/modals/web3_step_up" %>
237
+ </template>
238
+ ```
239
+
240
+ Render `solana_studio/phantom_deeplink` **once**, anywhere the Connect-Wallet
241
+ flow can be reached. The picker gates its mobile Phantom row on
242
+ `startPhantomDeepLink` existing, so a host that skips it keeps the install row
243
+ instead of painting a button that does nothing.
244
+
245
+ **These require studio-engine at render time.** They render its modal host and
246
+ its shared blocks (`studio/modals/blocks/wallet_brand_sprite`, `.../card_header`)
247
+ by name, paint with its utilities (`badge`, `pulse-cta`, `spinner`) and theme
248
+ role tokens, and drive its store through `$store.<store>.swap()`. studio-engine
249
+ stays a **development** dependency here — a runtime one would drag Rails into
250
+ every plain-Ruby consumer, which is what `lib/solana_studio/engine.rb`'s guard
251
+ exists to prevent — so this is a documented host requirement, not something the
252
+ gem can enforce from inside. `solana_studio/modals/network_mismatch` already
253
+ shipped on exactly these terms.
254
+
255
+ #### The signed statement is not configurable
256
+
257
+ `solana_studio/phantom_deeplink` emits `Studio.wallet_sign_in_statement`, and it
258
+ must keep doing so. studio-engine's `solana_sessions/phantom_callback`
259
+ **rebuilds** the signed message to post for verification, so the two read one
260
+ accessor precisely so they cannot drift; a caller-supplied statement would break
261
+ the signature check on every mobile sign-in. `test/web3_modals_test.rb` pins
262
+ this from both directions.
263
+
264
+ #### Choosing between the loader and your own tag
265
+
266
+ `solana_studio/deeplink_assets` **appends** a script element, so it is
267
+ asynchronous. A callback that reads `nacl` at parse time will lose that race.
268
+ Adopt the loader only together with a callback that waits; otherwise keep a
269
+ blocking `<script>` tag of your own with the same SRI-pinned URL. turf-monster
270
+ deliberately does the latter.
217
271
 
218
272
  ## Dependencies
219
273
 
@@ -226,6 +280,33 @@ is how the wallet picker reached three copies before it was promoted.
226
280
 
227
281
  See [RUNBOOK.md](./RUNBOOK.md) for troubleshooting and local test commands.
228
282
 
283
+ ### 🧊 The durable-nonce primitives have two consumers, and one of them is on ice
284
+
285
+ `Solana::SystemProgram` and `Solana::NonceAccount` landed together in **v0.4.6
286
+ (2026-06-02, `11ec512`)** for **two** consumers at once, and the commit says so:
287
+ *"the reusable core for the signing console's two-browser flow and for making
288
+ turf's operator tx flows expiry-immune."*
289
+
290
+ **The first of those went on ice on 2026-08-31.** McRitchie Studio's admin
291
+ signing console — N wallets signing in separate browsers, anchored on a durable
292
+ nonce so a half-signed transaction does not expire between signers — is **frozen
293
+ in place: still working, not removed, not deprecated**, and not expected to drive
294
+ any further work in this gem. Its full note (why it was frozen, and the one
295
+ question that would revive it) lives in the hub, at `docs/SIGNING_CONSOLE_V2.md`.
296
+
297
+ **Do not read that as permission to drop these two files.** The second consumer
298
+ is the one in production:
299
+
300
+ | Primitive | Live use |
301
+ |---|---|
302
+ | `Solana::SystemProgram.advance_nonce_account` | turf-monster prepends it as **instruction #0** of a durable-nonce vault cosign transaction (`app/services/solana/vault.rb`). Its cosign validator also **allow-lists exactly this one System instruction** — a nonce-anchored entry with any other System instruction is rejected. |
303
+ | `Solana::NonceAccount.parse` | turf-monster reads the on-chain nonce account in the same path. |
304
+
305
+ So the frozen consumer is the *quieter* one, never the only one. Both primitives
306
+ are byte-match tested in `test/system_program_test.rb`, and a change to either
307
+ still lands on turf-monster's money path — treat them as `onchain`, not as dead
308
+ code left over from a shelved tool.
309
+
229
310
  ### The browser lane
230
311
 
231
312
  The Ruby suite cannot see two things this gem ships: whether
@@ -0,0 +1,37 @@
1
+ <%#
2
+ tweetnacl, for the Phantom mobile deep link. Both halves of that flow need
3
+ window.nacl: the deep link generates an x25519 keypair so Phantom can encrypt
4
+ its reply, and the callback opens that box with the stashed secret.
5
+
6
+ IDEMPOTENT ON PURPOSE. turf-monster already loads tweetnacl from its own
7
+ layout, so this must not load a second copy there; the guard means a consumer
8
+ that already supplies nacl keeps its own and one that does not (the hub, which
9
+ had no mobile wallet path at all) gets it here.
10
+
11
+ Pinned to an exact version rather than a range: this is signing-path crypto,
12
+ and a range lets a CDN swap it under a running app. The same reasoning is
13
+ written out beside turf-monster's web3.js pin.
14
+
15
+ Render before solana_studio/phantom_deeplink and in the callback view.
16
+ %>
17
+ <script>
18
+ // NOT document.write. Under turbo-rails a Drive visit re-executes body scripts
19
+ // at readyState === 'complete', where document.write implicitly calls
20
+ // document.open() and BLANKS THE PAGE — on exactly the consumer this guard
21
+ // exists for. Append a real element instead; it is also non-blocking.
22
+ (function () {
23
+ if (typeof window.nacl !== 'undefined') return;
24
+ if (document.querySelector('script[data-studio-nacl]')) return;
25
+ var s = document.createElement('script');
26
+ s.src = 'https://cdn.jsdelivr.net/npm/tweetnacl@1.0.3/nacl-fast.min.js';
27
+ // Derived with `openssl dgst -sha384 -binary | openssl base64 -A` against the
28
+ // fetched file, and cross-checked against the value turf-monster already pins
29
+ // for this exact URL. NEVER hand-write an SRI: a plausible-looking wrong one
30
+ // makes the browser refuse the script and nacl silently absent, which fails
31
+ // every mobile sign-in before any other code runs.
32
+ s.integrity = 'sha384-05+sicyRJQ56XpL4U9HJ8YbtSzFDvAg7apPKOGV6A0JsAJKFM68jp5oLnUjG5mEp';
33
+ s.crossOrigin = 'anonymous';
34
+ s.setAttribute('data-studio-nacl', '');
35
+ document.head.appendChild(s);
36
+ })();
37
+ </script>
@@ -0,0 +1,167 @@
1
+ <%#
2
+ Phantom MOBILE deep link — the round trip a phone needs to sign in with a
3
+ wallet. On desktop Phantom is a browser extension the page can call directly;
4
+ on a phone it is a separate APP, so the page hands off to it and comes back.
5
+
6
+ PROMOTED from turf-monster (app/javascript/phantom_deeplink.js) so every
7
+ consumer gets it. The server half was already shared — the engine's
8
+ solana_sessions_controller owns nonce + verify and Solana::SessionAuth owns
9
+ verify_solana_signature! — only this mobile round trip was app-side.
10
+
11
+ Render it once, wherever the Connect-Wallet flow can be reached — an ERB
12
+ output tag rendering the partial path "solana_studio/phantom_deeplink".
13
+ (Described rather than quoted on purpose: an ERB comment ends at the first
14
+ close sequence, so spelling the tag out here would terminate this comment and
15
+ leak the rest of it as visible text into every consuming app. The engine's
16
+ erb_comment_leak_test enforces that, and it caught this very paragraph.)
17
+
18
+ It defines window.startPhantomDeepLink(linkMode, currentUserId). The engine's
19
+ wallet picker (solana_studio/modals/_wallet_connect) gates its mobile Phantom row on
20
+ that function EXISTING, so a consumer that does not render this partial keeps
21
+ its install row instead of painting a button that does nothing.
22
+
23
+ THREE THINGS THAT MUST NOT BE MADE CONFIGURABLE — traced through
24
+ Solana::SessionAuth#verify_solana_signature! before this was generalised:
25
+
26
+ domain the client builds it from window.location.host and the server
27
+ checks it against request.host_with_port (OPSEC-018). An app
28
+ override breaks sign-in.
29
+ User-ID line OPSEC-005 does a SUBSTRING match on "User-ID: <id>". Do not
30
+ reformat it and do not translate it.
31
+ the nonce deleted server-side BEFORE verification; that is the replay
32
+ protection for the login path.
33
+
34
+ The STATEMENT is the one part that varies, and it varies safely: the server
35
+ does NOT verify it — it is what the human reads inside Phantom. It derives
36
+ from Studio.app_name, which reproduces turf-monster's previous literal
37
+ ("Sign in to Turf Monster") BYTE FOR BYTE, so promoting this changed no
38
+ signed message. It MUST match the callback's copy exactly, because the
39
+ callback reconstructs the signed message to post for verification — both read
40
+ this same helper, which is why they cannot drift.
41
+
42
+ Requires tweetnacl (window.nacl); render "solana_studio/deeplink_assets"
43
+ first, or supply it yourself.
44
+ %>
45
+ <script>
46
+ (function () {
47
+ var STATEMENT = <%= Studio.wallet_sign_in_statement.to_json.html_safe %>;
48
+
49
+ // Base58, INLINE and self-contained. turf-monster took this from a separate
50
+ // app/javascript/base58.js module that assigned window.encodeBase58, so its
51
+ // deep link depended on a second global. Promoting only the deep link would
52
+ // have left encodeBase58 undefined in any consumer without that file — it
53
+ // throws on the first keypair encode, at the moment the user taps Connect.
54
+ // The callback view already carries its own codec for the same reason; this
55
+ // matches it, so the partial has exactly one dependency (nacl) and it is a
56
+ // guarded one.
57
+ // THE CONSTANT THE ENCODER READS. It was left behind when the function body
58
+ // was inlined: base58.js declares it at module scope, so it is unreachable
59
+ // from a classic script, and encodeBase58 referenced a free variable that
60
+ // resolved to nothing AT CALL TIME. Every mobile sign-in threw
61
+ // "B58_ALPHABET is not defined" on the first keypair encode.
62
+ //
63
+ // Invisible to eleven passing view tests and to a browser spec that checked
64
+ // `typeof startPhantomDeepLink` without ever CALLING it. A parse-time
65
+ // mutation cannot reach this class either — the program parses fine.
66
+ const B58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
67
+
68
+ function encodeBase58(bytes) {
69
+ const digits = [0];
70
+ for (let i = 0; i < bytes.length; i++) {
71
+ let carry = bytes[i];
72
+ for (let j = 0; j < digits.length; j++) {
73
+ carry += digits[j] << 8;
74
+ digits[j] = carry % 58;
75
+ carry = (carry / 58) | 0;
76
+ }
77
+ while (carry) { digits.push(carry % 58); carry = (carry / 58) | 0; }
78
+ }
79
+ let str = '';
80
+ for (let i = 0; i < bytes.length && bytes[i] === 0; i++) str += '1';
81
+ for (let i = digits.length - 1; i >= 0; i--) str += B58_ALPHABET[digits[i]];
82
+ return str;
83
+ }
84
+
85
+ // Phantom deep link protocol for mobile browsers
86
+ // Uses signIn deep link — ONE trip to Phantom (connect + sign combined)
87
+ // Flow: generate keypair → fetch nonce → build SIWS input → redirect to Phantom signIn
88
+
89
+ function startPhantomDeepLink(linkMode, currentUserId) {
90
+ // The consumer declares its cluster on <body data-solana-cluster="...">.
91
+ // turf-monster does; an app that does NOT silently signs against devnet while
92
+ // its wallet is on mainnet, which reads to the user as a rejected signature
93
+ // and to the log as nothing. Warn loudly rather than guess quietly — the
94
+ // default stays devnet so behaviour is unchanged for anyone already relying
95
+ // on it.
96
+ var cluster = document.body.dataset.solanaCluster;
97
+ if (!cluster) {
98
+ console.warn('[StudioSolana] no data-solana-cluster on <body>; defaulting to devnet. ' +
99
+ 'Set it on the body tag to sign against the cluster you mean.');
100
+ cluster = 'devnet';
101
+ }
102
+ var callbackUrl = window.location.origin + '/auth/phantom/callback';
103
+
104
+ // Generate x25519 keypair for decrypting Phantom's response
105
+ var dappKeyPair = nacl.box.keyPair();
106
+
107
+ // Fetch nonce from server, then redirect to Phantom signIn
108
+ fetch('/auth/solana/nonce')
109
+ .then(function(r) { return r.json(); })
110
+ .then(function(data) {
111
+ // OPSEC-005: when linking a wallet to a logged-in user, embed
112
+ // `User-ID: <id>` so the server can refuse signatures captured
113
+ // against a different session's nonce. Login (no currentUserId)
114
+ // skips the binding line.
115
+ var statement = STATEMENT;
116
+ if (linkMode && currentUserId) {
117
+ statement = statement + '\nUser-ID: ' + currentUserId;
118
+ }
119
+
120
+ // Build SIWS input (CAIP-122 / Sign In With Solana format)
121
+ // Note: chainId omitted — optional per spec, avoids mismatch warning
122
+ // when app is on devnet but user's wallet is on mainnet
123
+ var signInInput = {
124
+ domain: window.location.host,
125
+ statement: statement,
126
+ uri: window.location.origin,
127
+ version: '1',
128
+ nonce: data.nonce,
129
+ issuedAt: new Date().toISOString()
130
+ };
131
+
132
+ // Save state to localStorage (persists across redirect)
133
+ localStorage.setItem('phantom_dl_secret', encodeBase58(dappKeyPair.secretKey));
134
+ localStorage.setItem('phantom_dl_pubkey', encodeBase58(dappKeyPair.publicKey));
135
+ localStorage.setItem('phantom_dl_nonce', data.nonce);
136
+ localStorage.setItem('phantom_dl_nonce_at', Date.now().toString());
137
+ localStorage.setItem('phantom_dl_step', 'signIn');
138
+ localStorage.setItem('phantom_dl_link_mode', linkMode ? 'true' : 'false');
139
+ localStorage.setItem('phantom_dl_cluster', cluster);
140
+ if (currentUserId) {
141
+ localStorage.setItem('phantom_dl_user_id', String(currentUserId));
142
+ } else {
143
+ localStorage.removeItem('phantom_dl_user_id');
144
+ }
145
+
146
+ // Base58-encode the SIWS input JSON (NOT encrypted — per Phantom signIn protocol)
147
+ var payloadB58 = encodeBase58(new TextEncoder().encode(JSON.stringify(signInInput)));
148
+
149
+ // Build Phantom signIn deep link
150
+ var params = new URLSearchParams({
151
+ dapp_encryption_public_key: encodeBase58(dappKeyPair.publicKey),
152
+ cluster: cluster,
153
+ app_url: window.location.origin,
154
+ redirect_link: callbackUrl,
155
+ payload: payloadB58
156
+ });
157
+
158
+ window.location.href = 'https://phantom.app/ul/v1/signIn?' + params.toString();
159
+ })
160
+ .catch(function(err) {
161
+ alert('Failed to start Phantom connection: ' + err.message);
162
+ });
163
+ }
164
+
165
+ window.startPhantomDeepLink = startPhantomDeepLink;
166
+ })();
167
+ </script>
@@ -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 solana_studio/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>
@@ -0,0 +1,292 @@
1
+ <%#
2
+ Web3 step-up — the shared "your session cannot sign on-chain" card.
3
+
4
+ THE SITUATION IT NAMES: an account that holds a self-custody wallet signs in
5
+ with a WEB2 credential — a magic link, email, or Google. Both facts are
6
+ ordinary alone; together they mean the person is signed in but cannot sign
7
+ anything on-chain. `SessionContext` has modelled that intersection since it was
8
+ lifted into this engine (`web2?` AND `phantom_linked?`) and nothing acted on
9
+ it, so every web3 Studio app logged a wallet owner straight in with no web3
10
+ beat at all. This partial is the missing step; SessionContext stays the state.
11
+
12
+ Ported from turf-monster (2026-08-21), where it shipped first as the working
13
+ vertical. What is NOT shared — and deliberately stays in the host — is WHEN to
14
+ show it and what it costs to dismiss: the host's policy decides who sees the
15
+ card, and the host's gates keep their own teeth. This partial owns ONE step.
16
+
17
+ ADVISORY BY CONSTRUCTION. The card is dismissible and says so. A self-custody
18
+ wallet is the one credential a host cannot reset for a user, so a card that
19
+ could not be closed would lock a legitimate owner out of their own account
20
+ over a wallet they merely cannot reach right now. That is why the escape hatch
21
+ below is not optional decoration — and why enforcement must live in the host's
22
+ on-chain gates, never in this card.
23
+
24
+ Locals (all optional, defaults via local_assigns.fetch):
25
+ heading — (default "Sign in with your wallet")
26
+ subtext — the one-line why
27
+ help_url — the escape hatch for a user who cannot reach their wallet
28
+ (default "/help"). Set to nil to drop the line entirely.
29
+ help_label — (default "Get help")
30
+ picker_modal_id — the host's brand picker, reached by "Use a different
31
+ wallet" (default "wallet-connect"). The picker is expected
32
+ to honour a `backTo` prop pointing back at this card.
33
+ modal_id — this card's own id, passed to the picker as backTo
34
+ (default "web3-step-up")
35
+ modal_store — Alpine store name (default "modals"; the living style
36
+ guide mounts a page-scoped host and passes "dsModals")
37
+ dismiss_event — window event dispatched on dismissal (default
38
+ "web3-step-up-dismissed"). The HOST decides what happens
39
+ next — typically releasing an onboarding chain it held
40
+ while this card had the screen. This partial never opens
41
+ another modal itself.
42
+
43
+ PROPS (set by the opener, all optional — the host's policy produces them):
44
+ provider — normalised brand key ('phantom' / 'solflare' / 'backpack'),
45
+ or null when the account linked its wallet before the host
46
+ started remembering the brand.
47
+ providerLabel — how that brand writes its own name, for the row.
48
+ walletHint — the truncated address (4…4), so the user can confirm the
49
+ card is asking for the wallet they think it is.
50
+
51
+ WITH a remembered provider this is ONE row. Without one it is the same card
52
+ with the picker as its primary action — the fallback is never a dead end.
53
+
54
+ CONTRACT WITH THE HOST'S JS. Signing calls `window.solanaConnectAndVerify(name,
55
+ { linkMode: false })`, the global every web3 Studio app already provides, and
56
+ hands a success to `window.handleSolanaVerifySuccess` when present. linkMode is
57
+ deliberately FALSE: the link path binds to the current user but does not grant
58
+ the on-chain session, which is the thing this card exists to obtain. One
59
+ inherited consequence, stated plainly: signing with a DIFFERENT wallet signs
60
+ you into that wallet's account, exactly as a standalone wallet button does. The
61
+ walletHint is shown precisely so that is a visible choice, not a surprise.
62
+
63
+ CRITICAL (Alpine): this partial is cloned from a <template x-if> by the modal
64
+ host, so it must have ONE root element, and the x-data below is a
65
+ DOUBLE-QUOTED attribute — a single " anywhere inside it (a code comment
66
+ included) closes it early and the whole component mounts as a silent no-op that
67
+ still renders markup. Keep every inner string SINGLE-quoted.
68
+ %>
69
+ <%
70
+ heading = local_assigns.fetch(:heading, "Sign in with your wallet")
71
+ subtext = local_assigns.fetch(:subtext,
72
+ "This account is secured by a Solana wallet. You are signed in, but this " \
73
+ "session can’t sign on-chain — so on-chain actions still need your wallet.")
74
+ help_url = local_assigns.fetch(:help_url, "/help")
75
+ help_label = local_assigns.fetch(:help_label, "Get help")
76
+ picker_modal_id = local_assigns.fetch(:picker_modal_id, "wallet-connect")
77
+ modal_id = local_assigns.fetch(:modal_id, "web3-step-up")
78
+ modal_store = local_assigns.fetch(:modal_store, "modals")
79
+ dismiss_event = local_assigns.fetch(:dismiss_event, "web3-step-up-dismissed")
80
+ %>
81
+ <div x-data="{
82
+ get props() { var c = $store.<%= modal_store %>.current(); return (c && c.props) || {}; },
83
+ connecting: false,
84
+ error: '',
85
+ // Set when the remembered wallet turns out not to be reachable in THIS
86
+ // browser (a different machine, the extension removed). It flips the card
87
+ // to the picker rather than leaving the user pressing a button that
88
+ // cannot work.
89
+ providerMissing: false,
90
+ // Which wallets this document can actually see. Read on a poll, NOT once
91
+ // at mount: wallet provider registration fills in asynchronously, and
92
+ // this card auto-opens on the render right after auth — the worst
93
+ // possible moment. A single early read is a coin flip that would badge an
94
+ // installed wallet as missing.
95
+ wallets: [],
96
+ _poll: null,
97
+ _onRegister: null,
98
+ get provider() { return this.props.provider || null; },
99
+ get providerLabel() { return this.props.providerLabel || null; },
100
+ get walletHint() { return this.props.walletHint || null; },
101
+ // One-click only while we remember a brand AND it is still reachable.
102
+ get canOneClick() { return !!this.provider && !this.providerMissing; },
103
+ // Does the remembered brand answer in THIS document right now? Drives the
104
+ // Installed badge, the same one the connect picker shows, so a detected
105
+ // wallet reads identically in both places.
106
+ get detected() {
107
+ var name = ('' + (this.provider || '')).toLowerCase();
108
+ if (!name) return false;
109
+ return (this.wallets || []).some(function(w) { return w.name && w.name.toLowerCase() === name; });
110
+ },
111
+ init() {
112
+ var self = this;
113
+ this.refresh();
114
+ this._onRegister = function() { if (!self.connecting) self.refresh(); };
115
+ window.addEventListener('wallet-standard:register-wallet', this._onRegister);
116
+ this._poll = setInterval(function() {
117
+ if (self.connecting) return;
118
+ self.refresh();
119
+ if (self.wallets.length > 0) { clearInterval(self._poll); self._poll = null; }
120
+ }, 1000);
121
+ },
122
+ destroy() {
123
+ if (this._poll) clearInterval(this._poll);
124
+ if (this._onRegister) window.removeEventListener('wallet-standard:register-wallet', this._onRegister);
125
+ },
126
+ refresh() {
127
+ this.wallets = (window.walletProvider && window.walletProvider.available && window.walletProvider.available()) || [];
128
+ },
129
+ reachable(name) {
130
+ if (!window.walletProvider || !window.walletProvider.get) return false;
131
+ return !!window.walletProvider.get(name);
132
+ },
133
+ async signIn() {
134
+ if (this.connecting) return;
135
+ var name = this.provider;
136
+ if (!name) return this.openPicker();
137
+ if (!this.reachable(name)) {
138
+ this.providerMissing = true;
139
+ this.error = 'We could not reach your ' + (this.providerLabel || 'wallet') + ' in this browser.';
140
+ return;
141
+ }
142
+ if (typeof window.solanaConnectAndVerify !== 'function') {
143
+ this.error = 'Wallet sign-in is unavailable on this page.';
144
+ return;
145
+ }
146
+ this.connecting = true; this.error = '';
147
+ try {
148
+ var result = await window.solanaConnectAndVerify(name, { linkMode: false });
149
+ if (result && result.success) {
150
+ if (typeof window.handleSolanaVerifySuccess === 'function') window.handleSolanaVerifySuccess(result);
151
+ // Reload rather than follow a redirect: the user was already
152
+ // somewhere, and the only thing that changed is that this session
153
+ // can now sign. Sending them to a post-login landing would move
154
+ // them off the page they were reading.
155
+ window.location.reload();
156
+ return;
157
+ }
158
+ this.error = (result && result.error) || 'Verification failed.';
159
+ } catch (e) {
160
+ var msg = (e && e.code === 4001) ? 'Signature rejected' : ((e && e.message) || 'Connection failed');
161
+ if (typeof parseSolanaError === 'function') msg = parseSolanaError(msg);
162
+ this.error = msg;
163
+ }
164
+ this.connecting = false;
165
+ },
166
+ // Hand off to the host's brand picker. swap() rather than open() so the
167
+ // picker's back arrow returns HERE instead of stranding the user.
168
+ openPicker() {
169
+ if (this.connecting) return;
170
+ $store.<%= modal_store %>.swap('<%= picker_modal_id %>',
171
+ { backTo: '<%= modal_id %>', stepUpProps: this.props, returnUrl: window.location.href });
172
+ },
173
+ // Dismiss. Dispatched so the HOST knows this card is done and can release
174
+ // whatever it was holding — this partial never opens another modal.
175
+ dismiss() {
176
+ if (this.connecting) return;
177
+ window.dispatchEvent(new CustomEvent('<%= dismiss_event %>'));
178
+ $store.<%= modal_store %>.close();
179
+ }
180
+ }"
181
+ class="relative">
182
+
183
+ <%# Brand sprite — MUST live inside this card's single root so the host's
184
+ <template x-if> clones it along with the card. A sibling of the root is
185
+ dropped and the icon paints empty. %>
186
+ <%= render "studio/modals/blocks/wallet_brand_sprite" %>
187
+
188
+ <div class="relative mb-1">
189
+ <button @click="dismiss()" :disabled="connecting"
190
+ class="absolute top-0 right-0 -mr-1 text-secondary hover:text-heading text-xl leading-none disabled:opacity-50"
191
+ aria-label="Close">&times;</button>
192
+ </div>
193
+
194
+ <%= render "studio/modals/blocks/card_header", icon_emoji: "🔐", title: heading do %>
195
+ <p class="text-sm text-body"><%= subtext %></p>
196
+ <% end %>
197
+
198
+ <%# PRIMARY — THE STANDARD WEB3 AUTH BUTTON: a wallet row, not a filled CTA.
199
+ Same shape the connect picker uses — brand mark, the wallet's own name, an
200
+ Installed badge, chevron — so a wallet reads identically everywhere it is
201
+ offered and this card does not invent a third look for one action.
202
+
203
+ It carries `pulse-cta` (engine-motion) because it is the ONE target on the
204
+ card and the whole point of the card is that the user should press it. %>
205
+ <template x-if="canOneClick">
206
+ <div>
207
+ <button type="button" @click="signIn()" :disabled="connecting"
208
+ style="--pulse-cta-color: var(--color-primary); --pulse-cta-strength: 0.4; --pulse-cta-scale: 1.02"
209
+ class="pulse-cta 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">
210
+ <span class="w-9 h-9 rounded-lg overflow-hidden flex items-center justify-center shrink-0">
211
+ <svg class="w-9 h-9" aria-hidden="true"><use :href="'#se-wallet-' + provider"></use></svg>
212
+ </span>
213
+ <span class="font-semibold text-heading" x-text="providerLabel"></span>
214
+ <span class="ml-auto flex items-center gap-2">
215
+ <span x-show="connecting" class="inline-flex items-center gap-1.5 text-xs text-secondary"><span class="spinner" aria-hidden="true"></span>Connecting&hellip;</span>
216
+ <span x-show="!connecting && detected" class="badge border-primary text-primary">Installed</span>
217
+ <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">
218
+ <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
219
+ </svg>
220
+ </span>
221
+ </button>
222
+ <%# One line on what pressing it DOES. A signature prompt is alarming
223
+ without it, and "no funds move" is the difference between clicking and
224
+ bailing. The address rides here so the user can confirm the card is
225
+ asking for the wallet they think it is. %>
226
+ <p class="text-[11px] text-muted mt-1.5 px-1">
227
+ Signing proves the wallet is yours &mdash; it does not move any funds.
228
+ <template x-if="walletHint">
229
+ <span>Wallet <span class="font-mono text-secondary" x-text="walletHint"></span></span>
230
+ </template>
231
+ </p>
232
+ </div>
233
+ </template>
234
+
235
+ <%# No remembered brand — the same row shape with no mark to show, so the two
236
+ halves of this card look like one card. %>
237
+ <template x-if="!canOneClick">
238
+ <button type="button" @click="openPicker()" :disabled="connecting"
239
+ style="--pulse-cta-color: var(--color-primary); --pulse-cta-strength: 0.4; --pulse-cta-scale: 1.02"
240
+ class="pulse-cta 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">
241
+ <%# A neutral wallet mark, NOT an emoji. This tile sits inches from a real
242
+ brand mark, so a platform emoji (the first pass used U+1F45B PURSE)
243
+ reads as a pink handbag next to Phantom and is the one thing on the
244
+ card that belongs to no design system. Stroked in the same idiom as
245
+ the chevron beside it, on theme tokens.
246
+
247
+ A billfold: body plus clasp, and NO band across the top. The first
248
+ drawing had one and it read as a credit-card magstripe at 20px — the
249
+ wrong object on a card whose whole subject is a wallet. %>
250
+ <span class="w-9 h-9 rounded-lg bg-inset flex items-center justify-center shrink-0" aria-hidden="true">
251
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" class="w-5 h-5 text-secondary">
252
+ <rect x="3" y="6.5" width="18" height="11" rx="2.5" />
253
+ <circle cx="16.6" cy="12" r="1.3" fill="currentColor" stroke="none" />
254
+ </svg>
255
+ </span>
256
+ <span class="font-semibold text-heading">Connect your wallet</span>
257
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="ml-auto w-4 h-4 text-muted">
258
+ <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
259
+ </svg>
260
+ </button>
261
+ </template>
262
+
263
+ <template x-if="error">
264
+ <p role="alert" class="text-red-400 text-sm mt-3 text-center" x-text="error"></p>
265
+ </template>
266
+
267
+ <%# SECONDARY — always reachable, so a user whose remembered wallet is the
268
+ wrong one is never cornered by our memory of it. %>
269
+ <template x-if="canOneClick">
270
+ <button type="button" @click="openPicker()" :disabled="connecting"
271
+ class="mt-3 block w-full text-center text-sm text-secondary hover:text-heading disabled:opacity-50">
272
+ Use a different wallet
273
+ </button>
274
+ </template>
275
+
276
+ <div class="mt-4 pt-3 border-t border-strong">
277
+ <button type="button" @click="dismiss()" :disabled="connecting"
278
+ class="block w-full text-center text-sm text-secondary hover:text-heading disabled:opacity-50">
279
+ Not now
280
+ </button>
281
+ <%# THE ESCAPE HATCH. A self-custody wallet is the one credential a host
282
+ cannot reset for the user, so the card must not end without a way to
283
+ reach a human. Droppable only by passing help_url: nil, which is a
284
+ deliberate act at the callsite rather than an omission. %>
285
+ <% if help_url.present? %>
286
+ <p class="mt-2 text-center text-[11px] text-muted">
287
+ Can&rsquo;t access your wallet?
288
+ <a href="<%= help_url %>" class="underline hover:text-secondary"><%= help_label %></a>
289
+ </p>
290
+ <% end %>
291
+ </div>
292
+ </div>
@@ -16,5 +16,5 @@ module SolanaStudio
16
16
  # through the normal cycle. Splitting the version out is the same shape
17
17
  # studio-engine already uses (lib/studio/version.rb) and hands each file back
18
18
  # to its real owner: this one to the release, the gemspec to the PR.
19
- VERSION = "0.5.2"
19
+ VERSION = "0.5.3"
20
20
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solana-studio
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.2
4
+ version: 0.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -38,8 +38,12 @@ files:
38
38
  - LICENSE
39
39
  - README.md
40
40
  - app/assets/javascripts/solana_studio/network_guard.js
41
+ - app/views/solana_studio/_deeplink_assets.html.erb
42
+ - app/views/solana_studio/_phantom_deeplink.html.erb
41
43
  - app/views/solana_studio/auth/_wallet_credential.html.erb
42
44
  - app/views/solana_studio/modals/_network_mismatch.html.erb
45
+ - app/views/solana_studio/modals/_wallet_connect.html.erb
46
+ - app/views/solana_studio/modals/_web3_step_up.html.erb
43
47
  - lib/solana-studio.rb
44
48
  - lib/solana/auth_verifier.rb
45
49
  - lib/solana/borsh.rb