solana-studio 0.5.7 → 0.6.1

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: 37d288249a8aaba43c2315fc03d3638639f1f79692274e05a47eb7180d29b8ce
4
- data.tar.gz: 9bb5c251529c252d00f0e24572fb09cf5e358b401db167d381de1259a5bd2138
3
+ metadata.gz: b69fe07789c9b9f7f1db1627257d1bf48b9f4a0c8008d041737c56eeba6cf39c
4
+ data.tar.gz: 73c86733f7b77f5ace0cec51a59c5cc003d2fc728ce45d868b429abd0963bf45
5
5
  SHA512:
6
- metadata.gz: 0d11d32746254e437f67af2a4cdbf6ce7964e56595abac17b57a2793c4c6fc101f2f9b8e33d2871b88888a96d97f14d235cf9ae99f58ba1890cf3cc22d02c35a
7
- data.tar.gz: d5f5249972b54c3dd34c02685f6ed9b02088fc91b7f0c4bf05d0bfe2597ec5d591eccc0c0926ea8c935100742bdefaf293e94ee555803d9f831ebb7cf656946e
6
+ metadata.gz: ba01f48f7ae0f245db23c4d782b7b195f7cc7bea2e6dc59c8e657a2226e59d616fe09cc2ac88cc8024575bb5835576b4f50e5d1c4fdf0275e97e0d417cb320f8
7
+ data.tar.gz: ee63a339452249039f48ab8ce681d45e308a17c334230ec2cfd7887a05327529d9973694288a94d06f32333450a4a04ab2f7f26f9e5d5f4ef0c147db59c67754
data/CHANGELOG.md CHANGED
@@ -5,19 +5,28 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
5
5
  ## Unreleased
6
6
 
7
7
  ### Added
8
+ - **An optional `on_click` local on the wallet sign-in button** (`app/views/solana_studio/auth/_wallet_credential.html.erb`). The click was hardcoded to swap straight to the wallet-connect picker, and that single line blocked Turf Monster's adoption: its board stages a contest lineup in `localStorage`, the picker's redirect is UNCONDITIONAL, and `pendingContestEntry` has exactly one writer app-wide with no `beforeunload` fallback and no server draft for guests — so the default handler navigates away with a staged lineup never written anywhere. Lost, not mis-routed. Turf passes `on_click: "openWalletHub()"` and lets its own board listener save the cart and swap. **The seam is the TAIL of the handler, not the whole of it**: `attested()` stays template text no local can reach, so a host cannot drop the legal-age gate by simply never calling it, which a seam carrying the whole handler could — and the button would look and behave perfectly normal. Keeping the gate out of the local guarantees it is CALLED; making it CONTROL the tail needed one more step, because both land in ONE JavaScript expression and precedence decides which wins. `&&` binds tighter than `||`, `?:` and comma, so an override built on any of those reparsed and ran its tail with the gate FALSE — measured in Chromium against the vendored Alpine 3.16.1, where `on_click: "saveCart() || openWalletHub()"` ran `openWalletHub()` with the box unticked. The override is now **parenthesised** before it is emitted, so any expression is safe to pass. Never reachable in a shipped release: the seam has not shipped, and byte-identity proves no host passes the local. A host that passes no locals renders **byte-identical** output, measured by rendering the partial before and after and diffing, not asserted.
8
9
  - **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
10
  - **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
 
11
12
  ### Changed
13
+ - **The wallet step-up card leads with the WALLET, not a padlock** (`app/views/solana_studio/modals/_web3_step_up.html.erb`). Four operator edits in one pass. (1) The card's mark is now the remembered brand's own sprite at 56px, falling back to the neutral billfold this file already draws for its no-brand button — it replaces a lock emoji, on the reasoning that a card asking someone to reach for a specific object should show them the object. Hand-rolled rather than passed to `studio/modals/blocks/_card_header`, because that block takes an emoji, a spinner or one of two fixed icons and has NO slot for a brand mark; its else-branch paints a check tinted with the host's `primary` token. Per the modal-lifecycle module the trigger for a primitive is a second consumer or a third copy, and this is the first — when a second card wants a brand header, add an icon slot to `card_header` rather than a second hand-rolled one. (2) The default `subtext` drops from four lines to one: *This account is secured by a Solana wallet. You are signed in, but this session can't sign on-chain — so on-chain actions still need your wallet.* becomes *Your account is secured by a Solana wallet.* **A host that overrides `subtext` never sees this** — see Notes for hosts. (3) The truncated address moved UP out of the footnote into the body, inside an `x-if` on `walletHint`, so the card names the wallet it is asking for where the sentence is rather than under the button. The footnote it replaces is gone. (4) The **full-width "Use a different wallet" row is gone**, replaced by a quiet **"Not your wallet?" link beside the address** — same `openPicker()` handler, a fraction of the layout, and the card still runs CTA → `Not now`. Both are operator calls. The link carries TWO gates and they answer different questions: the `x-if` is `walletHint` (no address, no sentence to correct) and the `x-show` is `canOneClick` (with no remembered brand the PRIMARY button is already the picker, so the link would be a second door into the same room). **Those two states are not the same** — an account that linked its wallet before the host stored the brand has an address and no brand — which is why the link needs both. Without it the address made a mismatch VISIBLE without making it ACTIONABLE: `providerMissing` is written in exactly one place, inside `signIn()` after the `reachable()` guard, so nothing auto-flips, and a remembered wallet that was simply the WRONG one had no route to the picker at all.
14
+ - **The body block carries `mb-5`, and that is a FIX rather than a taste change.** The old callsite passed a BLOCK to `card_header`, which wraps `yield` in a `<p>` of its own — so the rendered card emitted `<p class="text-xs text-secondary mb-5"><p class="text-sm text-body">` and the HTML parser auto-closed it into a stray EMPTY paragraph. That empty paragraph WAS the body-to-CTA gap. Hand-rolling the header removed the accident, so the margin now has to be declared. Both new icon branches also carry `aria-hidden`, which `card_header`'s svg did not.
15
+ - **The wallet button's visibility gate tolerates a host that never defined `methodOn`.** `x-show="methodOn('wallet')"` became `x-show="typeof methodOn === 'function' ? methodOn('wallet') : true"`. A bare call in a host without the member THROWS, Alpine grades the throw as falsy, and the button **silently never renders** — no error a user can see, no missing asset, nothing in the page to debug. That was the second, independent blocker on Turf Monster's adoption, and it is invisible by construction. MEASURED in Chromium against the vendored Alpine 3.16.1, driving the real rendered markup: without the guard the console carries `methodOn is not defined` and the button resolves to `display:none`; with it the button shows, and a host that DOES define `methodOn` still follows the toggle in **both** directions. The fallback is `true` rather than false because bundling the gem has already answered *is wallet implemented* — a host with no toggle has expressed no opinion about showing it, and defaulting to false rebuilds the same invisible button from the other side. The test stays in **Alpine** deliberately: Ruby decides whether the partial EXISTS and Alpine decides whether it SHOWS, and folding the visibility into Ruby collapses that split and brings back the floating-divider bug on a toggle page.
12
16
  - `.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.
13
17
 
14
18
  ### Tests
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.
19
+ - **`test/views/web3_step_up_modal_test.rb` (17 -> 24)** seven tests for the polish pass above, one of them the CONTROL that keeps the other six honest. **The first version of them was INERT and this is recorded because the failure mode is invisible from inside the file**: they asserted `'#se-wallet-' + provider` and `canOneClick` against the WHOLE rendered card, and both strings already lived in it — the first in the pre-existing 36px CTA row, the second in the x-data getter. Deleting the ENTIRE new brand header left the suite at 22 runs / 118 assertions / **0 failures** while a probe on the same tree confirmed the header was gone from the HTML (header div present true->false, `w-14 h-14` tiles 3->0, sprite-binding sites 2->1). **Six of nine mutants survived.** The fix is STRUCTURAL rather than a longer string: `brand_header` slices the element immediately ABOVE the heading and `header_branch` slices one `x-if` branch of it by its GUARD, so no assertion can be satisfied by markup elsewhere in the card and a swap of the two branches moves the markup to the other guard. `test_the_header_slice_can_come_up_empty` is the control strip the header from the rendered HTML and the slice must find no branch — and it is not decorative: widening `brand_header` to the whole card takes it RED against the CTA row, which is the original bug reproduced. Re-measured on the SHIPPED tree across **20 mutants, every one KILLED, no survivors**, each shown to reach the file by its own diffstat against a baseline of **24 runs / 151 assertions / 0 failures**, restored clean after every one. The card: delete the whole header (+0/-15, 2 failures), swap its two branches (+5/-5), delete the billfold fallback (+0/-8), hardcode the mark to `#se-wallet-phantom` (+1/-1), drop both `x-if` guards (+0/-4), an HTML-entity padlock (+1/-6) and a literal-codepoint one (+1/-6) **both** forms, because `&#128274;` renders identically and sails past a codepoint-only refutation — restore the alternate-wallet row (+4/-0), `mb-5` -> `mb-0` (+1/-1), and the margin drifting onto an empty neighbour (+2/-1, 4 failures). The correction link: delete it (+1/-3), move it OUTSIDE the `walletHint` guard (+4/-3), point it at `dismiss()` (+1/-1), drop the `canOneClick` gate (+1/-1), INVERT that gate (+1/-1), relabel it as the row it replaced (+1/-1, 2 failures), let it grow back to `w-full` (+1/-1), delete the address and keep the link (+1/-2, 2 failures), split the nowrap group so the link wraps onto its own line (+1/-1), and unguard the address line entirely (+0/-1, 2 failures). The link and the address are asserted SEPARATELY even though they share one block, because an assertion matching the block as a whole cannot say which half broke — the same shape as the inert-header bug.
20
+ - **`test/views/auth_wallet_credential_test.rb`** (14, new) — the RENDER tier for the credential button, which this gem never had. It is the tier that can see this change at all: the default handler is assembled by ERB, so the string hosts depend on appears ZERO times in the template source and a source grep stays green on a partial whose default never reaches the attribute. Pins the default handler against the literal previous bytes, the default tracking `modal_store` (a hardcoded `"modals"` would give the style guide a dead button), the override replacing the default, the override leaving **no** trace of it, the age gate LEADING an override — narrowly, and the assertion now says so, because `start_with?` cannot see whether the gate also CONTROLS the tail — the handler reaching the page **unescaped** — read from the RAW html, never the parsed attribute, because Nokogiri decodes entities and the assertion would otherwise pass on the very bug it exists to catch — the default carrying no double quote that would close the attribute, and both halves of the visibility gate. Mutation-proven five ways, every mode red: dropping `raw`, hardcoding the store name, reverting the gate to the bare call, letting `on_click` swallow the age gate, and dropping the picker's props. A **behavioural tier** was added alongside the string assertions: it runs the rendered handler in a real JS engine, built the way Alpine builds it, and asserts the gate CONTROLS the tail for every top-level operator that binds looser than `&&`. It carries its own control — the pre-fix spelling, asserted to STILL bypass — so a harness that quietly stopped seeing anything goes red instead of green. Both were mutation-proven: blanking the harness took three tests red, pinning `attested()` to a constant took two.
21
+ - `test/auth_credential_test.rb` (7 -> 11): 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. The `on_click` assertion is bound to the CONCERN rather than one spelling of it: it pinned `fetch(:on_click) do`, which went red when the precedence fix traded that for a `key?` branch while preserving every property the assertion existed to protect. It now asserts the local is read through an accessor that tolerates its ABSENCE, which is the thing that actually matters. A new assertion pins the parentheses around an overridden tail, labelled as the WEAKER copy — it matches characters, and the render tier next door is the authority.
16
22
  - `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
23
  - **`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
24
  - **`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
25
 
20
26
  ### Notes for hosts
27
+ - **The "Use a different wallet" row is GONE, and a host bound to it goes red at its next lock bump, not at this PR.** This gem ships only `.github/workflows/gem-ci.yml` — there is no consumer lane — so a green CI here cannot see a consumer at all. turf-monster bound to that row in TWO places: `test/controllers/web3_step_up_gallery_test.rb` ("a remembered wallet still offers a way to use another one", asserting both the label and `openPicker()`) and `e2e/web3_step_up.spec.js`, which does not merely assert copy — it clicks the row and walks step-up -> wallet-connect -> Back -> step-up, checking the props survive the round trip. It breaks at `bump_consumer_locks_for_qa` during the next `qa-release` sweep, which runs `bundle lock --update solana-studio --conservative` on every consumer. **THE ROUTE SURVIVES, ITS SPELLING DOES NOT.** A quiet **"Not your wallet?"** link beside the address calls the same `openPicker()` the row called, so a consumer's picker round trip is REPOINTED rather than deleted. Write that locator to accept EITHER spelling (`/Use a different wallet|Not your wallet\?/`) if the spec must be green on both sides of the bump — a consumer's own CI resolves the PUBLISHED gem, so a spec pinned to the new label is red on that consumer's PR and a spec pinned to the old one is red at the sweep. turf-monster's two files now do exactly that. The other unchanged route is the no-remembered-brand one: the "Connect your wallet" button IS `openPicker()` in both releases. Why the link exists at all, since the acceptance never named the cost: `providerMissing` is written in exactly ONE place (inside `signIn()`, after the `reachable()` guard) and nothing in `init()`, `refresh()` or the poll touches it, so there is no auto-flip — without the link a remembered-but-UNREACHABLE wallet had to press a button that could not work and read an error before the no-brand row appeared, and a remembered, REACHABLE, but WRONG wallet had no route to the picker at all. It is advisory either way: the session stays usable, `Not now` exits, and the help line still reaches a human.
28
+ - **The default `subtext` is one line now, and a host that OVERRIDES it sees none of that.** turf-monster passed its own four-line `subtext:`, so the gem's default changed and the card a player actually met did not — it kept the long copy AND gained the new address line, which is more copy rather than less. If you override `subtext`, this release is the moment to decide whether you still mean to. This gem's own `test/views/web3_step_up_modal_test.rb` refutes the string `this session can`, which was live in a consumer's override at the time this shipped.
29
+ - **RESOLVED — studio-engine's style-guide specimen no longer pins the OLD gate string.** This entry warned that `test/views/style_web3_specimens_test.rb` asserted the literal `x-show="methodOn('wallet')"`, which the new gate does not contain, and that it would go red the moment the engine bumped past this gem. Both halves have since moved and the warning is kept as provenance rather than as live guidance. Re-derived 2026-09-06: studio-engine's `Gemfile.lock` now resolves **solana-studio 0.6.0** (not the 0.5.3 this note was written against), and its assertion is now `assert_includes gate, "methodOn('wallet')"` — the SUBSTRING form this note asked for, landed by its own commit *Bind the wallet gate assertion to the CTA element*. The gate it reads, `typeof methodOn === 'function' ? methodOn('wallet') : true`, contains that substring, so the engine is green on the current pin. Nothing to do; the recorded reasoning stands, which is why it is corrected here rather than deleted.
21
30
  - **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
31
  - **`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`.
23
32
  - **The wallet sign-in button needs studio-engine >= 0.68.0.** 0.68.0 is the first engine whose `style/modals/_auth` looks up `solana_studio/auth/wallet_credential`; until it, the engine drew that button INLINE and never consulted the path at all, so this gem shipped the partial, its tests and its README section for a button no host rendered. Below the floor the partial is inert and NOTHING SAYS SO — no render, no button, no error — so the symptom is silence rather than a stack trace, which is the failure mode this ecosystem's Gemfile pin comments exist to prevent. DERIVED by unpacking the published gems, not read off a changelog: 0.67.2 carries no `solana_studio/auth` reference anywhere in `app/`, and 0.68.0 gates the render on `lookup_context.exists?("wallet_credential", ["solana_studio/auth"], true)`. Match on the NAMESPACE, not the basename — `style/modals/_wallet_connect` ships unchanged in both releases and answers an unrelated question. It pairs with a floor on THIS gem: 0.5.2 shipped the credential partial WITHOUT `solana_studio/modals/_wallet_connect`, which the engine's `web3_gem` capability gate requires, so on 0.5.2 the button is suppressed either side of the floor and 0.5.3 is the first version that can draw it. Full matrix in the README.
data/README.md CHANGED
@@ -249,9 +249,62 @@ correctly absent. And an app that declares `:wallet` but forgets the gem gets no
249
249
  button rather than a missing-partial error in front of someone signing in.
250
250
 
251
251
  The partial renders inside the modal's own Alpine scope and borrows three
252
- members from it — `methodOn('wallet')` for visibility, `attested()` for the
253
- legal-age gate, and `props.submitting` for the disabled state. It takes one
254
- required local, `modal_store`, which the engine passes.
252
+ members from it — `attested()` for the legal-age gate, `props.submitting` for
253
+ the disabled state, and `methodOn('wallet')` for visibility.
254
+
255
+ Only the first two are required. `methodOn` is called behind a `typeof` test, so
256
+ a host that never defined it **shows** the button rather than hiding it. A bare
257
+ call in a host without the member throws, Alpine grades the throw as falsy, and
258
+ the button **silently never renders** — no error a user can see, nothing in the
259
+ page to debug. Bundling the gem has already answered *is wallet implemented*; a
260
+ host with no toggle has expressed no opinion about showing it, and the answer to
261
+ no opinion is yes. The test stays in Alpine deliberately: Ruby decides whether
262
+ this partial exists, Alpine decides whether it shows, and folding the visibility
263
+ into Ruby brings back a floating divider on a toggle page.
264
+
265
+ #### Locals
266
+
267
+ | Local | Required | What it does |
268
+ |---|---|---|
269
+ | `modal_store` | **yes** | Alpine store name backing the modal. The engine's real host passes `"modals"`, the living style guide passes `"dsModals"`. No default on purpose: a wrong store name fails as a dead button rather than an error, so missing beats wrong. |
270
+ | `on_click` | no | Alpine expression run once the age gate passes. Defaults to swapping straight to the wallet-connect picker. Parenthesised before it is emitted, so any expression is safe to pass. |
271
+
272
+ `on_click` replaces what happens **after** `attested()` — never `attested()`
273
+ itself, which stays template text no local can reach. A seam carrying the whole
274
+ handler could drop the legal-age gate by simply never calling it, and the button
275
+ would look and behave completely normal.
276
+
277
+ Keeping the gate out of the local guarantees it is **called**. Making it
278
+ **control** what follows took one more step, because the two land side by side
279
+ in a single JavaScript expression and precedence, not the template, decides
280
+ which wins. `&&` binds tighter than `||`, `?:` and comma, so an override built
281
+ on any of those would reparse and run its tail with the gate **false**. The
282
+ override is therefore wrapped in parentheses before it is emitted, and you do
283
+ not need to bracket it yourself. Measured in Chromium against studio-engine's
284
+ vendored Alpine 3.16.1, both directions: with the gate false nothing runs, with
285
+ the gate true the whole override runs.
286
+
287
+ Pass it when the host has to do something before the picker navigates away. The
288
+ picker's redirect is unconditional, so a host holding unsaved state must write
289
+ it first. Turf Monster stages a contest lineup in `localStorage`, and adopting
290
+ the default would lose that lineup on wallet sign-in:
291
+
292
+ ```erb
293
+ <%= render "solana_studio/auth/wallet_credential",
294
+ modal_store: "modals",
295
+ on_click: "openWalletHub()" %>
296
+ ```
297
+
298
+ The expression is emitted **unescaped**, because it is developer-authored code
299
+ exactly like the template around it. Keep it free of double quotes — one closes
300
+ the attribute early and Alpine mounts the button as a silent no-op — and keep it
301
+ an **expression**: a statement (`let x = 1; foo()`) or a trailing `//` comment
302
+ is a syntax error once Alpine wraps the handler, measured both before and after
303
+ this change.
304
+
305
+ What those constraints have in common is the point. Every one of them fails
306
+ **loudly** — the button visibly does nothing. The precedence bug was the only
307
+ one that failed silently, and it is the one this change removes.
255
308
 
256
309
  Why a contributed button and not a second auth modal: Turf Monster wants Google
257
310
  plus magic-link plus wallet, McRitchie Studio wants Google plus magic-link. A
@@ -15,10 +15,22 @@
15
15
  leak the rest of it as visible text into every consuming app. The engine's
16
16
  erb_comment_leak_test enforces that, and it caught this very paragraph.)
17
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.
18
+ It defines window.startPhantomDeepLink(linkMode, currentUserId). THIS GEM's
19
+ wallet picker (solana_studio/modals/_wallet_connect) gates its mobile Phantom
20
+ row on that function EXISTING, so a consumer that does not render this partial
21
+ keeps its install row instead of painting a button that does nothing.
22
+
23
+ THE PICKER IS NOT THE ENGINE'S — do not read the engine references above as a
24
+ pattern to extend. studio-engine dropped studio/modals/_wallet_connect,
25
+ studio/modals/_web3_step_up and studio/solana together in 0.66.2 and has
26
+ shipped no wallet picker or signing card OF ITS OWN since; the style guide's
27
+ style/modals/_wallet_connect is a thin configuration that renders THIS gem's
28
+ partial. It did NOT stop shipping wallet UI altogether — that is the opposite
29
+ error: it keeps the SESSION half named above and the shared brand sprite
30
+ studio/modals/blocks/_wallet_brand_sprite, which this gem's own picker and
31
+ step-up card both render.
32
+ Anchored on that drop on purpose: a sentence naming the engine's CURRENT
33
+ version is falsified by its next release.
22
34
 
23
35
  THREE THINGS THAT MUST NOT BE MADE CONFIGURABLE — traced through
24
36
  Solana::SessionAuth#verify_solana_signature! before this was generalised:
@@ -25,13 +25,32 @@
25
25
  SCOPE — this renders INSIDE the engine auth modal's root x-data, so these
26
26
  members are in scope and are the seam's contract:
27
27
 
28
- methodOn(m) whether credential m should show. Reads props.methods per key
29
- and falls back to the app's own Studio.auth_method? default,
30
- which for wallet is auth_method?(:wallet) AND feature?(:web3).
31
- attested() the legal-age gate. Returns false and paints the error when
32
- the app requires attestation and the box is unticked. Every
33
- credential CTA in the modal calls it first; skipping it here
34
- would make wallet the one way around the gate.
28
+ attested() REQUIRED. The legal-age gate. Returns false and paints the
29
+ error when the app requires attestation and the box is
30
+ unticked. Every credential CTA in the modal calls it first;
31
+ skipping it here would make wallet the one way around the gate.
32
+
33
+ methodOn(m) OPTIONAL. Whether credential m should show. Reads props.methods
34
+ per key and falls back to the app's own Studio.auth_method?
35
+ default, which for wallet is auth_method?(:wallet) AND
36
+ feature?(:web3).
37
+
38
+ Called behind a typeof test, so a host that never defined it
39
+ shows the button instead of hiding it. That is not tolerance
40
+ for a sloppy host, it is the only safe default. A bare call in
41
+ a host without the member throws, Alpine grades the throw as
42
+ falsy, and the button SILENTLY NEVER RENDERS — nothing on the
43
+ page, nothing a user can report, nothing to debug. Turf
44
+ Monster hit exactly that. Bundling the gem has already
45
+ answered "is wallet implemented"; a host with no toggle has
46
+ expressed no opinion about showing it, and the answer to no
47
+ opinion is yes.
48
+
49
+ The test stays in ALPINE on purpose. Ruby decides whether this
50
+ partial EXISTS and Alpine decides whether it SHOWS. Folding
51
+ the visibility into Ruby collapses that split and brings back
52
+ the floating-divider bug on a toggle page, where the modal
53
+ draws a separator for a credential that then hides itself.
35
54
 
36
55
  THE HANDLER IS INLINE, not a method on a nested x-data, and that is deliberate.
37
56
  A child x-data would reach attested() through Alpine's prototypal scope merge,
@@ -39,19 +58,81 @@
39
58
  nesting away and silently drop the age gate. An expression in the attribute
40
59
  reads the enclosing scope directly, with nothing to get wrong.
41
60
 
61
+ THE AGE GATE IS NOT PART OF THE SEAM. on_click replaces what runs AFTER
62
+ attested() passes, never attested() itself, which stays template text no local
63
+ can reach. A seam that carried the whole handler could drop the gate simply by
64
+ not calling it, and the button would look and behave perfectly normal.
65
+
66
+ KEEPING IT OUT OF THE LOCAL IS ONLY HALF THE GUARANTEE, and assuming it was
67
+ the whole one is how this seam first merged with a hole in it. Template text
68
+ guarantees the gate is CALLED. It does not guarantee the gate CONTROLS what
69
+ follows, because the override lands beside it inside ONE JavaScript
70
+ expression, and JS precedence — not the template — decides which of the two
71
+ wins. Parenthesising the override is what makes the second guarantee true.
72
+ The note above the button carries the measurements.
73
+
42
74
  Locals:
43
75
  modal_store Alpine store name backing the modal. The engine's real host
44
76
  uses "modals"; the living style guide mounts a page-scoped host
45
77
  and passes "dsModals". Required, no default, because a wrong
46
78
  store name fails as a dead button rather than as an error.
79
+
80
+ on_click OPTIONAL Alpine expression, run once the age gate passes.
81
+ Defaults to swapping straight to the wallet-connect picker,
82
+ which suited every host until one had to save work first: Turf
83
+ Monster stages a lineup in localStorage and must write it
84
+ before the picker navigates away, so it passes "openWalletHub()"
85
+ and lets its own board listener do the save and the swap.
86
+ Emitted UNESCAPED, because it is developer-authored code just
87
+ like the template around it — so keep it free of double quotes
88
+ or it closes the attribute and mounts a dead button.
89
+
90
+ WRAPPED IN PARENTHESES before it is emitted, so any expression
91
+ is safe to pass. ||, ?: and comma all bind LOOSER than the &&
92
+ ahead of them, and without the wrap an override built on one
93
+ of those reparses into a handler that runs your tail with the
94
+ age gate FAILED. You do not need to bracket it yourself.
95
+
96
+ Those two warnings fail in opposite directions, which is why
97
+ both are stated. A stray double quote fails LOUDLY — the
98
+ button visibly does nothing. The precedence bug failed
99
+ SILENTLY — the button worked, and skipped the age check.
47
100
  %>
48
101
  <% modal_store = local_assigns.fetch(:modal_store) %>
49
- <%# The picker this opens is the wallet-connect modal. backTo returns the user
50
- here if they back out of the brand list, and ageAttested carries the gate
51
- they already passed so the picker does not ask a second time. %>
102
+ <%# The default picker this opens is the wallet-connect modal. backTo returns
103
+ the user here if they back out of the brand list, and ageAttested carries
104
+ the gate they already passed so the picker does not ask a second time.
105
+
106
+ AN OVERRIDE IS PARENTHESISED, THE DEFAULT IS NOT, and that asymmetry is the
107
+ whole of this fix. && binds TIGHTER than ||, than ?:, and than comma, so an
108
+ override whose top-level operator is one of those reparses: the gate ends up
109
+ guarding only the override's FIRST term instead of the whole tail. Measured
110
+ in Chromium against the vendored Alpine 3.16.1 with attested() false, before
111
+ the parentheses existed:
112
+
113
+ saveCart() || openWalletHub() ran openWalletHub()
114
+ wantsHub ? openHub() : saveIt() ran saveIt()
115
+ saveCart(), openWalletHub() ran openWalletHub()
116
+
117
+ Every one of those is a click that ran past a legal-age gate it had just
118
+ failed. Wrapping the override buys back the grouping the note above
119
+ promises, for any expression a host can write.
120
+
121
+ The DEFAULT stays bare, deliberately. It is a single call expression, which
122
+ already binds tighter than the gate, so parentheses would buy it nothing,
123
+ and a host that passes no local must keep rendering byte-for-byte what it
124
+ rendered before this seam existed. That is the additive contract the render
125
+ tier pins against a literal, and it is why the wrap hangs off key? rather
126
+ than off whatever fetch returned. %>
127
+ <% on_click =
128
+ if local_assigns.key?(:on_click)
129
+ "(#{local_assigns[:on_click]})"
130
+ else
131
+ "$store.#{modal_store}.swap('wallet-connect', { backTo: 'auth', ageAttested: true })"
132
+ end %>
52
133
  <button type="button"
53
- @click="attested() && $store.<%= modal_store %>.swap('wallet-connect', { backTo: 'auth', ageAttested: true })"
54
- x-show="methodOn('wallet')"
134
+ @click="attested() && <%= raw on_click %>"
135
+ x-show="typeof methodOn === 'function' ? methodOn('wallet') : true"
55
136
  :disabled="!!props.submitting"
56
137
  class="btn btn-neutral btn-lg w-full gap-3 mb-3 disabled:cursor-wait">
57
138
  <%# The Solana wordmark, inline rather than an asset request: this gem ships no
@@ -27,9 +27,13 @@
27
27
  help_url — the escape hatch for a user who cannot reach their wallet
28
28
  (default "/help"). Set to nil to drop the line entirely.
29
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.
30
+ picker_modal_id — the host's brand picker (default "wallet-connect"). TWO
31
+ routes reach it: the no-remembered-brand button, which IS
32
+ the picker's opener, and the quiet "Not your wallet?" link
33
+ beside the address. (Both replaced a full-width "Use a
34
+ different wallet" row dropped on 2026-09-06.) The picker
35
+ is expected to honour a `backTo` prop pointing back at
36
+ this card.
33
37
  modal_id — this card's own id, passed to the picker as backTo
34
38
  (default "web3-step-up")
35
39
  modal_store — Alpine store name (default "modals"; the living style
@@ -69,8 +73,7 @@
69
73
  <%
70
74
  heading = local_assigns.fetch(:heading, "Sign in with your wallet")
71
75
  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.")
76
+ "Your account is secured by a Solana wallet.")
74
77
  help_url = local_assigns.fetch(:help_url, "/help")
75
78
  help_label = local_assigns.fetch(:help_label, "Get help")
76
79
  picker_modal_id = local_assigns.fetch(:picker_modal_id, "wallet-connect")
@@ -83,9 +86,16 @@
83
86
  connecting: false,
84
87
  error: '',
85
88
  // 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
+ // browser (a different machine, the extension removed). WRITTEN IN ONE
90
+ // PLACE ONLY inside signIn(), after the reachable() guard so it
91
+ // never pre-empts: init(), refresh() and the poll below all leave it
92
+ // alone. The user presses the brand button, reads the error, and only
93
+ // THEN does the card fall back to the no-brand row. That row is the
94
+ // BUTTON that opens the picker, not the picker itself. Since 2026-09-06
95
+ // that is no longer the ONLY way out of a wallet we remember wrongly:
96
+ // the address line carries a link to the picker, so an unreachable or
97
+ // simply WRONG remembered wallet is correctable without first pressing
98
+ // a button that cannot work.
89
99
  providerMissing: false,
90
100
  // Which wallets this document can actually see. Read on a poll, NOT once
91
101
  // at mount: wallet provider registration fills in asynchronously, and
@@ -191,9 +201,80 @@
191
201
  aria-label="Close">&times;</button>
192
202
  </div>
193
203
 
194
- <%= render "studio/modals/blocks/card_header", icon_emoji: "🔐", title: heading do %>
204
+ <%# NOT blocks/_card_header, and the reason is a missing slot rather than a
205
+ preference. That block takes an emoji, a spinner, or one of two fixed
206
+ icons — there is no slot for a BRAND MARK, and its else-branch paints a
207
+ CHECK MARK tinted with the theme's own primary token: line 63-64 of
208
+ _card_header.html.erb draws bg-<icon_color>/15 with icon_color defaulting
209
+ to 'primary', so the colour follows the host's theme and is green only
210
+ where primary is. Either way it cannot render this card's icon at all,
211
+ which is the point. One card needing
212
+ a brand header is not yet a primitive: per the modal-lifecycle module the
213
+ trigger is a second consumer or a third copy, and this is the first. When a
214
+ second card wants one, add an icon slot to card_header rather than a
215
+ second hand-rolled header here.
216
+
217
+ THE MARK IS THE WALLET, not a padlock. The card is asking the user to reach
218
+ for a specific object; showing them the object beats a generic security
219
+ glyph, and where we remember WHICH wallet we show that brand — the same
220
+ mark the button below carries, so the card names one thing twice rather
221
+ than two things once. With no remembered brand it falls back to the neutral
222
+ billfold this file already draws for its no-brand button. %>
223
+ <div class="text-center mb-4">
224
+ <template x-if="canOneClick">
225
+ <span class="inline-flex w-14 h-14 rounded-2xl overflow-hidden items-center justify-center">
226
+ <svg class="w-14 h-14" aria-hidden="true"><use :href="'#se-wallet-' + provider"></use></svg>
227
+ </span>
228
+ </template>
229
+ <template x-if="!canOneClick">
230
+ <span class="inline-flex w-14 h-14 rounded-2xl bg-inset items-center justify-center" aria-hidden="true">
231
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" class="w-7 h-7 text-secondary">
232
+ <rect x="3" y="6.5" width="18" height="11" rx="2.5" />
233
+ <circle cx="16.6" cy="12" r="1.3" fill="currentColor" stroke="none" />
234
+ </svg>
235
+ </span>
236
+ </template>
237
+ </div>
238
+
239
+ <h3 class="text-heading font-bold text-lg leading-tight text-center mb-2"><%= heading %></h3>
240
+
241
+ <%# ONE LINE, plus a second only when we can name the wallet. The previous copy
242
+ ran four lines explaining that the session cannot sign on-chain — true, and
243
+ more than a person standing in front of a button needs. The address is the
244
+ part that earns its place: it is how the user confirms the card is asking
245
+ for the wallet they think it is, which is why it moved UP here from the
246
+ footnote under the button.
247
+
248
+ AND THE ONE THING THE ADDRESS ALONE CANNOT DO IS CORRECT IT. Naming the
249
+ wallet makes a mismatch VISIBLE without making it ACTIONABLE, which is
250
+ worse than not naming it — so the address carries a quiet link that opens
251
+ the picker. It replaces a full-width alternate-wallet row (dropped as an
252
+ operator call): same handler, a fraction of the layout, and the card still
253
+ runs CTA then Not now.
254
+
255
+ TWO GATES, and they answer different questions. The x-if is walletHint —
256
+ no address means no sentence to correct. The x-show is canOneClick,
257
+ because when there is no remembered brand the PRIMARY button is already
258
+ the picker: the link would be a second door into the same room, competing
259
+ with the one action this card exists to offer. Note that these two states
260
+ are NOT the same — an account that linked its wallet before the host
261
+ stored the brand has an address and no brand — which is exactly why the
262
+ link needs both. %>
263
+ <div class="text-center mb-5">
195
264
  <p class="text-sm text-body"><%= subtext %></p>
196
- <% end %>
265
+ <template x-if="walletHint">
266
+ <p class="text-sm text-body mt-1">Please sign in with wallet
267
+ <%# BESIDE the address, and the nowrap is what keeps it there. Without it
268
+ the link wraps onto a line of its own, centred under the sentence —
269
+ which is visually the row this replaced, at a smaller size. The group
270
+ breaks as ONE unit, so a narrow card moves the address and the link
271
+ down together instead of splitting them. %>
272
+ <span class="whitespace-nowrap"><span class="font-mono text-secondary"
273
+ x-text="walletHint"></span><button type="button" @click="openPicker()"
274
+ :disabled="connecting" x-show="canOneClick"
275
+ class="ml-1 text-xs text-muted underline hover:text-secondary disabled:opacity-50">Not your wallet?</button></span></p>
276
+ </template>
277
+ </div>
197
278
 
198
279
  <%# PRIMARY — THE STANDARD WEB3 AUTH BUTTON: a wallet row, not a filled CTA.
199
280
  Same shape the connect picker uses — brand mark, the wallet's own name, an
@@ -219,16 +300,6 @@
219
300
  </svg>
220
301
  </span>
221
302
  </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
303
  </div>
233
304
  </template>
234
305
 
@@ -264,14 +335,6 @@
264
335
  <p role="alert" class="text-red-400 text-sm mt-3 text-center" x-text="error"></p>
265
336
  </template>
266
337
 
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
338
 
276
339
  <div class="mt-4 pt-3 border-t border-strong">
277
340
  <button type="button" @click="dismiss()" :disabled="connecting"
@@ -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.7"
19
+ VERSION = "0.6.1"
20
20
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solana-studio
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.7
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-02 00:00:00.000000000 Z
11
+ date: 2026-09-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ed25519