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 +4 -4
- data/CHANGELOG.md +10 -1
- data/README.md +56 -3
- data/app/views/solana_studio/_phantom_deeplink.html.erb +16 -4
- data/app/views/solana_studio/auth/_wallet_credential.html.erb +93 -12
- data/app/views/solana_studio/modals/_web3_step_up.html.erb +91 -28
- data/lib/solana_studio/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b69fe07789c9b9f7f1db1627257d1bf48b9f4a0c8008d041737c56eeba6cf39c
|
|
4
|
+
data.tar.gz: 73c86733f7b77f5ace0cec51a59c5cc003d2fc728ce45d868b429abd0963bf45
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
-
|
|
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 `🔒` 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 — `
|
|
253
|
-
|
|
254
|
-
|
|
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).
|
|
19
|
-
wallet picker (solana_studio/modals/_wallet_connect) gates its mobile Phantom
|
|
20
|
-
that function EXISTING, so a consumer that does not render this partial
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
50
|
-
here if they back out of the brand list, and ageAttested carries
|
|
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() &&
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
"
|
|
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).
|
|
87
|
-
//
|
|
88
|
-
//
|
|
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">×</button>
|
|
192
202
|
</div>
|
|
193
203
|
|
|
194
|
-
|
|
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
|
-
|
|
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 — 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.
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-09-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ed25519
|