studio-engine 0.58.1 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 349b589e663f401c61c9d310aaf72e9f86dc63c5dc77cb8ef90ff36904d3ea94
4
- data.tar.gz: 614fb35d60924ca44cb8c8f89b0acf48b821f3a325728048c3cc141983976155
3
+ metadata.gz: 9bf3942309a578282c3ed5d194123c773c659fde728ac53db036f29abee6d09f
4
+ data.tar.gz: 96dcf654f30bbbc63d7682394b24460091acbc52d310b39cb065ae771e3108a0
5
5
  SHA512:
6
- metadata.gz: e95e3ead35e2e1e91b22660e8fb4b1beb4509681129c56358c74e20bf8d460bb39f1edbca7ce787789ef7f38d8cf8ecfebc9372e4e417f749a38ea806bf1b279
7
- data.tar.gz: d8eb124ab7bdba7b5fbeb6f2b90c56f6528c6e65be7db24768a3f709daf300d8574124837936ac65269722252bf97e40ab5e34f57a9cb06eace380d3ace1b0d2
6
+ metadata.gz: 67264dd6f10ab6be519740067057f7e39c1d960976e7409b3524de3e61f094bc523172e5e1721dfd09c1bfe95c6bc935c5ccd133696bbdbc581e046b8c1da0f7
7
+ data.tar.gz: a210c2bbd8591ea197438150788be7594dca951ce7405f975e91892376b739218e0f048ffec483ca702d23573b429b44f104a75b8ca8a67091522a4496b74209
data/CHANGELOG.md CHANGED
@@ -188,6 +188,49 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
188
188
 
189
189
  ### Fixed
190
190
 
191
+ - **The Geo signpost now reaches every app whose admin chrome the engine owns.**
192
+ The row added last release went into `components/_admin_dropdown` alone, on the
193
+ premise that the shared dropdown reaches every app from one change. It does
194
+ not, and counted against the apps that actually consume this engine it reached
195
+ **none of them**. Wherever a host declares an admin-flagged sidebar section,
196
+ `studio_sidebar_replaces_admin_menu?` SUPPRESSES the dropdown and the link
197
+ sidebar is the admin menu — that is both `mcritchie-studio` and
198
+ `mcritchie-industries`. An app that forks its navbar outright renders neither,
199
+ which is `turf-monster`. Nobody was left on the plain engine navbar to see it,
200
+ so the app carrying the largest admin surface in the ecosystem was among those
201
+ guaranteed never to.
202
+
203
+ The row moved into its own partial, **`components/_geo_signpost`**, and BOTH
204
+ engine chromes render it — the dropdown as before, and
205
+ `components/_link_sidebar` under its own admin-chipped `Geo` heading. Nothing
206
+ to configure: a host on either chrome gets the signage from the version bump.
207
+
208
+ **Exactly one of them, never both.** The sidebar carries the row only where it
209
+ IS the admin menu (the host declared an admin-flagged section, so the dropdown
210
+ is suppressed). A host declaring only public sections renders BOTH chromes by
211
+ design, and there the dropdown keeps the row — fixing a reach gap by showing
212
+ an admin the same row twice is not a fix.
213
+
214
+ **A forked chrome still needs one line**, because the engine cannot reach a
215
+ fork by rendering. Inside your own admin block:
216
+
217
+ ```erb
218
+ <%= render "components/geo_signpost",
219
+ variant: :sidebar, close_action: "$store.sidebars.gearOpen = false" %>
220
+ ```
221
+
222
+ `variant:` picks the chrome's row shape (`:dropdown`, the default, or
223
+ `:sidebar`); `close_action:` is the Alpine expression that closes YOUR panel
224
+ behind the row — the store flag belongs to the caller, so the partial never
225
+ hardcodes one. See [`docs/GEO.md`](docs/GEO.md#finding-the-page--the-signpost-in-whichever-chrome-the-app-has).
226
+
227
+ Behavior is otherwise unchanged: same three states, same words, same
228
+ self-gating on `admin?`, and `ENABLE_GEO_BLOCKING` still governs the LINK and
229
+ never the gate. One test hook was renamed — `data-test="geo-signpost-disabled"`,
230
+ was `admin-dropdown-geo-disabled` — since the row renders in two chromes now
231
+ and a name claiming one of them was a lie. Grepped ecosystem-wide first: it
232
+ appeared in no consumer.
233
+
191
234
  - **`.conic-surface` keeps its wash inside its own box.** The wash was an
192
235
  OVERSIZED pseudo (`inset: -50%`) spun by a `transform` keyframe, cut back to
193
236
  the element by the host's `overflow: hidden`. That clip is not dependable: the
@@ -13,29 +13,10 @@
13
13
  <div x-show="open" x-cloak @click.outside="open = false" x-transition:enter="transition ease-out duration-100" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-75" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95" class="absolute right-0 mt-2 w-44 bg-surface border border-subtle rounded-lg shadow-lg z-50 py-1">
14
14
  <a href="/admin/theme" class="block px-4 py-2 text-sm text-body hover:text-heading hover:bg-surface-alt transition">Theme</a>
15
15
  <a href="/admin/navbar" class="block px-4 py-2 text-sm text-body hover:text-heading hover:bg-surface-alt transition">Navbar</a>
16
- <%# GEO — shown in every app, LIVE only where it can actually be used.
17
- A disabled row rather than a hidden one is the whole point: an app that has
18
- not turned geo on still learns the feature exists and what to set, instead
19
- of it being invisible until someone reads the gem's docs.
20
-
21
- Two prerequisites, and the row says WHICH is missing, because they are
22
- fixed in different places — an env var on the dyno, a line in the app's
23
- initializer. The route check is not decoration: where draw_geo_routes is
24
- false, `admin_geo_path` does not exist and calling it RAISES, so the
25
- disabled branch must never reach for the helper. %>
26
- <% geo_routes = respond_to?(:admin_geo_path) %>
27
- <% if Studio.geo_blocking_enabled? && geo_routes %>
28
- <a href="<%= admin_geo_path %>" class="block px-4 py-2 text-sm text-body hover:text-heading hover:bg-surface-alt transition">Geo</a>
29
- <% else %>
30
- <span class="block px-4 py-2 text-sm text-muted cursor-not-allowed select-none"
31
- data-test="admin-dropdown-geo-disabled"
32
- title="<%= geo_routes ? "Set ENABLE_GEO_BLOCKING=true to manage geo blocking" : "Set config.draw_geo_routes = true in config/initializers/studio.rb, then ENABLE_GEO_BLOCKING=true" %>">
33
- Geo
34
- <span class="block text-xs text-muted">
35
- <%= geo_routes ? "Set ENABLE_GEO_BLOCKING=true" : "Draw the geo routes first" %>
36
- </span>
37
- </span>
38
- <% end %>
16
+ <%# GEO — the signpost lives in components/_geo_signpost, because this
17
+ dropdown is only ONE of the engine's two admin chromes and an app on the
18
+ other one never renders this file. Same row, same policy, both chromes. %>
19
+ <%= render "components/geo_signpost" %>
39
20
  <a href="/error_logs" class="block px-4 py-2 text-sm text-body hover:text-heading hover:bg-surface-alt transition">Error Logs</a>
40
21
  </div>
41
22
  </div>
@@ -0,0 +1,133 @@
1
+ <%# THE GEO SIGNPOST — one row, rendered into whichever admin chrome the app has.
2
+
3
+ WHY IT IS ITS OWN PARTIAL, and not a block inside the dropdown. 0.58.0 put
4
+ this row in components/_admin_dropdown on the premise that the shared
5
+ dropdown reaches every app "from one change". It does not. Counted against
6
+ the apps that actually consume this engine, the row reached NONE of them:
7
+
8
+ * an app whose admin menu is the LINK SIDEBAR never renders the dropdown —
9
+ studio_sidebar_replaces_admin_menu? suppresses it so the two cog glyphs
10
+ do not read as a double gear. Both mcritchie-studio and
11
+ mcritchie-industries declare an admin-flagged section, so both are that;
12
+ * an app that forks the navbar outright renders neither — turf-monster
13
+ forks _user_nav and _navbar and owns components/_gear_sidebar;
14
+ * nobody was left on the plain engine navbar to see it.
15
+
16
+ So the policy below now has ONE home, and BOTH engine-owned chromes render
17
+ it: components/_admin_dropdown and components/_link_sidebar. A host that
18
+ forks its chrome adopts it in one line, inside its own admin block — an ERB
19
+ render tag around:
20
+
21
+ render "components/geo_signpost",
22
+ variant: :sidebar, close_action: "$store.sidebars.gearOpen = false"
23
+
24
+ Locals:
25
+ variant — :dropdown (default), the gear dropdown's narrow rows;
26
+ :sidebar, the link sidebar's wide emoji rows.
27
+ close_action — optional Alpine expression run on click, so the panel the
28
+ row lives in closes behind it. The flag is the CALLER's
29
+ (linkTreeOpen here, gearOpen in a forked chrome), which is
30
+ exactly why this partial must not hardcode one.
31
+
32
+ ADMIN CHROME, self-gated. The gate lives HERE rather than in each caller so
33
+ no caller can forget it: the dropdown shipped without one once and offered
34
+ /admin/theme and /error_logs to signed-out strangers on a public page.
35
+ %>
36
+ <% if respond_to?(:admin?) && admin? %>
37
+ <%# The two prerequisites, resolved ONCE and shared by both variants — the
38
+ point of a single partial is that the two chromes cannot drift into
39
+ disagreeing about whether geo is reachable.
40
+
41
+ The route check is not decoration: where draw_geo_routes is false,
42
+ `admin_geo_path` is not defined at all, so the disabled branch must never
43
+ reach for it. A NoMethodError here takes down admin chrome on EVERY page.
44
+
45
+ They are named separately because they are FIXED in different places — an
46
+ env var on the dyno, a line in the app's initializer — so the row says
47
+ which one is missing rather than a vague "unavailable". %>
48
+ <% geo_routes = respond_to?(:admin_geo_path) %>
49
+ <% geo_live = Studio.geo_blocking_enabled? && geo_routes %>
50
+
51
+ <%# THE HINT IS TWO PIECES, prose and machine string, and they are separate
52
+ elements on purpose. The subtitle exists to hand the operator a string to
53
+ copy, and that string is one unbreakable token — no spaces, and neither an
54
+ underscore nor an "=" is a break opportunity. Left as one run of text it
55
+ measured 187px inside the dropdown's 142px content box and painted past the
56
+ panel's right border, in every theme and at every width.
57
+
58
+ Splitting it lets the break class land on the token ALONE: prose still
59
+ wraps at word boundaries, and only the machine string breaks mid-character.
60
+ The class is `break-all`, not `break-words`, deliberately — the engine
61
+ ships no compiled CSS of its own, so a utility reaches a consumer only when
62
+ that consumer's Tailwind build scans these views. `break-all` is already
63
+ carried by engine views that print S3 keys, emails and magic URLs, so it is
64
+ in every consumer's output today; `break-words` appears nowhere in the
65
+ engine, and a consumer on a stale build would silently render this row with
66
+ no break at all — the bug again, in production. Widening the panel was the
67
+ third option and was declined: w-44 is the shared admin menu for every app,
68
+ and one row's subtitle is not a reason to move it.
69
+
70
+ The code string is also the ANSWER to the title-only instruction. In the
71
+ no-routes branch the initializer line used to live only in `title`, which
72
+ is mouse-only and invisible on touch; it is visible text now, and `title`
73
+ keeps the long form with the file path. %>
74
+ <% geo_lead = geo_routes ? "Set" : "Draw the geo routes first" %>
75
+ <% geo_code = geo_routes ? "ENABLE_GEO_BLOCKING=true" : "config.draw_geo_routes = true" %>
76
+ <% geo_title = geo_routes ? "Set ENABLE_GEO_BLOCKING=true to manage geo blocking" : "Set config.draw_geo_routes = true in config/initializers/studio.rb, then ENABLE_GEO_BLOCKING=true" %>
77
+ <% geo_close = local_assigns[:close_action].presence %>
78
+
79
+ <%# `local_assigns[...].presence`, not `fetch(:variant, :dropdown)`. fetch only
80
+ defaults an ABSENT key, so a caller computing the variant inline —
81
+ `variant: sidebar? && :sidebar` — hands over an explicit nil, and the old
82
+ `nil.to_sym` raised NoMethodError inside admin chrome on every page. %>
83
+ <% if local_assigns[:variant].presence&.to_sym == :sidebar %>
84
+ <%# Sidebar variant: the wide emoji row the link sidebar's own sections use,
85
+ so an engine-owned row is indistinguishable from a host-declared one. %>
86
+ <% if geo_live %>
87
+ <%= link_to admin_geo_path,
88
+ class: "group flex items-start gap-3 px-4 py-3 text-sm text-body hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-inset transition",
89
+ "@click": geo_close do %>
90
+ <span class="text-lg leading-none flex-shrink-0">🌍</span>
91
+ <span class="min-w-0">
92
+ <span class="block font-medium truncate">Geo</span>
93
+ <span class="block text-xs text-muted leading-snug mt-0.5">Country and subdivision blocking</span>
94
+ </span>
95
+ <% end %>
96
+ <% else %>
97
+ <%# No `select-none` here, and that is the fix rather than an omission: the
98
+ row's whole job in this state is to hand over the string below, and
99
+ select-none made it unselectable. role/aria-disabled give a screen
100
+ reader the "unavailable destination" this paints for a sighted eye. %>
101
+ <span class="flex items-start gap-3 px-4 py-3 text-sm text-muted cursor-not-allowed"
102
+ role="link"
103
+ aria-disabled="true"
104
+ data-test="geo-signpost-disabled"
105
+ title="<%= geo_title %>">
106
+ <span class="text-lg leading-none flex-shrink-0">🌍</span>
107
+ <span class="min-w-0">
108
+ <span class="block font-medium truncate">Geo</span>
109
+ <span class="block text-xs text-muted leading-snug mt-0.5"><%= geo_lead %>
110
+ <code class="font-mono break-all"><%= geo_code %></code>
111
+ </span>
112
+ </span>
113
+ </span>
114
+ <% end %>
115
+ <% else %>
116
+ <%# Dropdown variant: the narrow row that shipped in 0.58.0, unchanged apart
117
+ from the test hook, which no longer names one chrome. %>
118
+ <% if geo_live %>
119
+ <a href="<%= admin_geo_path %>" class="block px-4 py-2 text-sm text-body hover:text-heading hover:bg-surface-alt transition">Geo</a>
120
+ <% else %>
121
+ <span class="block px-4 py-2 text-sm text-muted cursor-not-allowed"
122
+ role="link"
123
+ aria-disabled="true"
124
+ data-test="geo-signpost-disabled"
125
+ title="<%= geo_title %>">
126
+ Geo
127
+ <span class="block text-xs text-muted"><%= geo_lead %>
128
+ <code class="font-mono break-all"><%= geo_code %></code>
129
+ </span>
130
+ </span>
131
+ <% end %>
132
+ <% end %>
133
+ <% end %>
@@ -136,6 +136,47 @@
136
136
  <% end %>
137
137
  <% end %>
138
138
  <% end %>
139
+
140
+ <%# THE GEO SIGNPOST — how a sidebar-chrome app gets geo signage at all.
141
+
142
+ This panel IS the admin menu wherever the host declares an admin-flagged
143
+ section (studio_sidebar_replaces_admin_menu?), and in exactly that case
144
+ the engine suppresses components/_admin_dropdown — so before this block
145
+ every app on this chrome was guaranteed NEVER to see the row the 0.58.0
146
+ signpost added, including the one carrying the largest admin surface in
147
+ the ecosystem. mcritchie-studio is that one:
148
+ app/views/layouts/application.html.erb renders this partial, and
149
+ app/helpers/link_tree_helper.rb stamps `admin: true` on every admin
150
+ section it declares. mcritchie-industries is the other.
151
+
152
+ Engine-owned rather than host-declared on purpose. Appending it to
153
+ Studio.sidebar_sections instead would make studio_sidebar? and
154
+ studio_sidebar_replaces_admin_menu? true for apps that declare NO
155
+ sections — raising a sidebar where none existed and taking Theme,
156
+ Navbar and Error Logs away from apps that rely on the dropdown.
157
+
158
+ EXACTLY ONE HOME PER APP, which is what the second half of the gate
159
+ buys. A host that declares only PUBLIC sections keeps the dropdown —
160
+ the engine navbar renders BOTH chromes for that shape, deliberately, so
161
+ its admins never lose Theme, Navbar and Error Logs. Signposting geo here
162
+ as well would show such an admin the same row twice, in two menus open
163
+ at the same time. So the row goes wherever the admin menu IS: the
164
+ dropdown for that shape, this panel for the shape that suppresses the
165
+ dropdown.
166
+
167
+ The predicate is `studio_sidebar_replaces_admin_menu?` (see
168
+ StudioSidebarHelper), computed here from the sections already in hand
169
+ rather than called: this partial renders in hosts and test rigs that
170
+ provide studio_sidebar_sections without the rest of the helper, and a
171
+ bare call would raise for them. The two must stay the same question —
172
+ if the helper's rule changes, this changes with it. %>
173
+ <% if sidebar_admin && sections.any? { |section| section[:admin] } %>
174
+ <div class="px-4 pt-4 pb-1 label-upper font-bold text-muted flex items-center gap-2">
175
+ <span>Geo</span>
176
+ <span class="rounded border border-primary/20 bg-primary/10 px-1.5 py-0.5 text-[10px] font-mono text-primary">ADMIN</span>
177
+ </div>
178
+ <%= render "components/geo_signpost", variant: :sidebar, close_action: close_action %>
179
+ <% end %>
139
180
  </nav>
140
181
  <% end %>
141
182
 
@@ -0,0 +1,292 @@
1
+ <%#
2
+ Web3 step-up — the shared "your session cannot sign on-chain" card.
3
+
4
+ THE SITUATION IT NAMES: an account that holds a self-custody wallet signs in
5
+ with a WEB2 credential — a magic link, email, or Google. Both facts are
6
+ ordinary alone; together they mean the person is signed in but cannot sign
7
+ anything on-chain. `SessionContext` has modelled that intersection since it was
8
+ lifted into this engine (`web2?` AND `phantom_linked?`) and nothing acted on
9
+ it, so every web3 Studio app logged a wallet owner straight in with no web3
10
+ beat at all. This partial is the missing step; SessionContext stays the state.
11
+
12
+ Ported from turf-monster (2026-08-21), where it shipped first as the working
13
+ vertical. What is NOT shared — and deliberately stays in the host — is WHEN to
14
+ show it and what it costs to dismiss: the host's policy decides who sees the
15
+ card, and the host's gates keep their own teeth. This partial owns ONE step.
16
+
17
+ ADVISORY BY CONSTRUCTION. The card is dismissible and says so. A self-custody
18
+ wallet is the one credential a host cannot reset for a user, so a card that
19
+ could not be closed would lock a legitimate owner out of their own account
20
+ over a wallet they merely cannot reach right now. That is why the escape hatch
21
+ below is not optional decoration — and why enforcement must live in the host's
22
+ on-chain gates, never in this card.
23
+
24
+ Locals (all optional, defaults via local_assigns.fetch):
25
+ heading — (default "Sign in with your wallet")
26
+ subtext — the one-line why
27
+ help_url — the escape hatch for a user who cannot reach their wallet
28
+ (default "/help"). Set to nil to drop the line entirely.
29
+ help_label — (default "Get help")
30
+ picker_modal_id — the host's brand picker, reached by "Use a different
31
+ wallet" (default "wallet-connect"). The picker is expected
32
+ to honour a `backTo` prop pointing back at this card.
33
+ modal_id — this card's own id, passed to the picker as backTo
34
+ (default "web3-step-up")
35
+ modal_store — Alpine store name (default "modals"; the living style
36
+ guide mounts a page-scoped host and passes "dsModals")
37
+ dismiss_event — window event dispatched on dismissal (default
38
+ "web3-step-up-dismissed"). The HOST decides what happens
39
+ next — typically releasing an onboarding chain it held
40
+ while this card had the screen. This partial never opens
41
+ another modal itself.
42
+
43
+ PROPS (set by the opener, all optional — the host's policy produces them):
44
+ provider — normalised brand key ('phantom' / 'solflare' / 'backpack'),
45
+ or null when the account linked its wallet before the host
46
+ started remembering the brand.
47
+ providerLabel — how that brand writes its own name, for the row.
48
+ walletHint — the truncated address (4…4), so the user can confirm the
49
+ card is asking for the wallet they think it is.
50
+
51
+ WITH a remembered provider this is ONE row. Without one it is the same card
52
+ with the picker as its primary action — the fallback is never a dead end.
53
+
54
+ CONTRACT WITH THE HOST'S JS. Signing calls `window.solanaConnectAndVerify(name,
55
+ { linkMode: false })`, the global every web3 Studio app already provides, and
56
+ hands a success to `window.handleSolanaVerifySuccess` when present. linkMode is
57
+ deliberately FALSE: the link path binds to the current user but does not grant
58
+ the on-chain session, which is the thing this card exists to obtain. One
59
+ inherited consequence, stated plainly: signing with a DIFFERENT wallet signs
60
+ you into that wallet's account, exactly as a standalone wallet button does. The
61
+ walletHint is shown precisely so that is a visible choice, not a surprise.
62
+
63
+ CRITICAL (Alpine): this partial is cloned from a <template x-if> by the modal
64
+ host, so it must have ONE root element, and the x-data below is a
65
+ DOUBLE-QUOTED attribute — a single " anywhere inside it (a code comment
66
+ included) closes it early and the whole component mounts as a silent no-op that
67
+ still renders markup. Keep every inner string SINGLE-quoted.
68
+ %>
69
+ <%
70
+ heading = local_assigns.fetch(:heading, "Sign in with your wallet")
71
+ subtext = local_assigns.fetch(:subtext,
72
+ "This account is secured by a Solana wallet. You are signed in, but this " \
73
+ "session can’t sign on-chain — so on-chain actions still need your wallet.")
74
+ help_url = local_assigns.fetch(:help_url, "/help")
75
+ help_label = local_assigns.fetch(:help_label, "Get help")
76
+ picker_modal_id = local_assigns.fetch(:picker_modal_id, "wallet-connect")
77
+ modal_id = local_assigns.fetch(:modal_id, "web3-step-up")
78
+ modal_store = local_assigns.fetch(:modal_store, "modals")
79
+ dismiss_event = local_assigns.fetch(:dismiss_event, "web3-step-up-dismissed")
80
+ %>
81
+ <div x-data="{
82
+ get props() { var c = $store.<%= modal_store %>.current(); return (c && c.props) || {}; },
83
+ connecting: false,
84
+ error: '',
85
+ // Set when the remembered wallet turns out not to be reachable in THIS
86
+ // browser (a different machine, the extension removed). It flips the card
87
+ // to the picker rather than leaving the user pressing a button that
88
+ // cannot work.
89
+ providerMissing: false,
90
+ // Which wallets this document can actually see. Read on a poll, NOT once
91
+ // at mount: wallet provider registration fills in asynchronously, and
92
+ // this card auto-opens on the render right after auth — the worst
93
+ // possible moment. A single early read is a coin flip that would badge an
94
+ // installed wallet as missing.
95
+ wallets: [],
96
+ _poll: null,
97
+ _onRegister: null,
98
+ get provider() { return this.props.provider || null; },
99
+ get providerLabel() { return this.props.providerLabel || null; },
100
+ get walletHint() { return this.props.walletHint || null; },
101
+ // One-click only while we remember a brand AND it is still reachable.
102
+ get canOneClick() { return !!this.provider && !this.providerMissing; },
103
+ // Does the remembered brand answer in THIS document right now? Drives the
104
+ // Installed badge, the same one the connect picker shows, so a detected
105
+ // wallet reads identically in both places.
106
+ get detected() {
107
+ var name = ('' + (this.provider || '')).toLowerCase();
108
+ if (!name) return false;
109
+ return (this.wallets || []).some(function(w) { return w.name && w.name.toLowerCase() === name; });
110
+ },
111
+ init() {
112
+ var self = this;
113
+ this.refresh();
114
+ this._onRegister = function() { if (!self.connecting) self.refresh(); };
115
+ window.addEventListener('wallet-standard:register-wallet', this._onRegister);
116
+ this._poll = setInterval(function() {
117
+ if (self.connecting) return;
118
+ self.refresh();
119
+ if (self.wallets.length > 0) { clearInterval(self._poll); self._poll = null; }
120
+ }, 1000);
121
+ },
122
+ destroy() {
123
+ if (this._poll) clearInterval(this._poll);
124
+ if (this._onRegister) window.removeEventListener('wallet-standard:register-wallet', this._onRegister);
125
+ },
126
+ refresh() {
127
+ this.wallets = (window.walletProvider && window.walletProvider.available && window.walletProvider.available()) || [];
128
+ },
129
+ reachable(name) {
130
+ if (!window.walletProvider || !window.walletProvider.get) return false;
131
+ return !!window.walletProvider.get(name);
132
+ },
133
+ async signIn() {
134
+ if (this.connecting) return;
135
+ var name = this.provider;
136
+ if (!name) return this.openPicker();
137
+ if (!this.reachable(name)) {
138
+ this.providerMissing = true;
139
+ this.error = 'We could not reach your ' + (this.providerLabel || 'wallet') + ' in this browser.';
140
+ return;
141
+ }
142
+ if (typeof window.solanaConnectAndVerify !== 'function') {
143
+ this.error = 'Wallet sign-in is unavailable on this page.';
144
+ return;
145
+ }
146
+ this.connecting = true; this.error = '';
147
+ try {
148
+ var result = await window.solanaConnectAndVerify(name, { linkMode: false });
149
+ if (result && result.success) {
150
+ if (typeof window.handleSolanaVerifySuccess === 'function') window.handleSolanaVerifySuccess(result);
151
+ // Reload rather than follow a redirect: the user was already
152
+ // somewhere, and the only thing that changed is that this session
153
+ // can now sign. Sending them to a post-login landing would move
154
+ // them off the page they were reading.
155
+ window.location.reload();
156
+ return;
157
+ }
158
+ this.error = (result && result.error) || 'Verification failed.';
159
+ } catch (e) {
160
+ var msg = (e && e.code === 4001) ? 'Signature rejected' : ((e && e.message) || 'Connection failed');
161
+ if (typeof parseSolanaError === 'function') msg = parseSolanaError(msg);
162
+ this.error = msg;
163
+ }
164
+ this.connecting = false;
165
+ },
166
+ // Hand off to the host's brand picker. swap() rather than open() so the
167
+ // picker's back arrow returns HERE instead of stranding the user.
168
+ openPicker() {
169
+ if (this.connecting) return;
170
+ $store.<%= modal_store %>.swap('<%= picker_modal_id %>',
171
+ { backTo: '<%= modal_id %>', stepUpProps: this.props, returnUrl: window.location.href });
172
+ },
173
+ // Dismiss. Dispatched so the HOST knows this card is done and can release
174
+ // whatever it was holding — this partial never opens another modal.
175
+ dismiss() {
176
+ if (this.connecting) return;
177
+ window.dispatchEvent(new CustomEvent('<%= dismiss_event %>'));
178
+ $store.<%= modal_store %>.close();
179
+ }
180
+ }"
181
+ class="relative">
182
+
183
+ <%# Brand sprite — MUST live inside this card's single root so the host's
184
+ <template x-if> clones it along with the card. A sibling of the root is
185
+ dropped and the icon paints empty. %>
186
+ <%= render "studio/modals/blocks/wallet_brand_sprite" %>
187
+
188
+ <div class="relative mb-1">
189
+ <button @click="dismiss()" :disabled="connecting"
190
+ class="absolute top-0 right-0 -mr-1 text-secondary hover:text-heading text-xl leading-none disabled:opacity-50"
191
+ aria-label="Close">&times;</button>
192
+ </div>
193
+
194
+ <%= render "studio/modals/blocks/card_header", icon_emoji: "🔐", title: heading do %>
195
+ <p class="text-sm text-body"><%= subtext %></p>
196
+ <% end %>
197
+
198
+ <%# PRIMARY — THE STANDARD WEB3 AUTH BUTTON: a wallet row, not a filled CTA.
199
+ Same shape the connect picker uses — brand mark, the wallet's own name, an
200
+ Installed badge, chevron — so a wallet reads identically everywhere it is
201
+ offered and this card does not invent a third look for one action.
202
+
203
+ It carries `pulse-cta` (engine-motion) because it is the ONE target on the
204
+ card and the whole point of the card is that the user should press it. %>
205
+ <template x-if="canOneClick">
206
+ <div>
207
+ <button type="button" @click="signIn()" :disabled="connecting"
208
+ style="--pulse-cta-color: var(--color-primary); --pulse-cta-strength: 0.4; --pulse-cta-scale: 1.02"
209
+ class="pulse-cta w-full flex items-center gap-3 p-3 rounded-xl bg-surface-alt border border-strong hover:bg-surface transition text-left disabled:opacity-60 disabled:cursor-wait">
210
+ <span class="w-9 h-9 rounded-lg overflow-hidden flex items-center justify-center shrink-0">
211
+ <svg class="w-9 h-9" aria-hidden="true"><use :href="'#se-wallet-' + provider"></use></svg>
212
+ </span>
213
+ <span class="font-semibold text-heading" x-text="providerLabel"></span>
214
+ <span class="ml-auto flex items-center gap-2">
215
+ <span x-show="connecting" class="inline-flex items-center gap-1.5 text-xs text-secondary"><span class="spinner" aria-hidden="true"></span>Connecting&hellip;</span>
216
+ <span x-show="!connecting && detected" class="badge border-primary text-primary">Installed</span>
217
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-muted">
218
+ <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
219
+ </svg>
220
+ </span>
221
+ </button>
222
+ <%# One line on what pressing it DOES. A signature prompt is alarming
223
+ without it, and "no funds move" is the difference between clicking and
224
+ bailing. The address rides here so the user can confirm the card is
225
+ asking for the wallet they think it is. %>
226
+ <p class="text-[11px] text-muted mt-1.5 px-1">
227
+ Signing proves the wallet is yours &mdash; it does not move any funds.
228
+ <template x-if="walletHint">
229
+ <span>Wallet <span class="font-mono text-secondary" x-text="walletHint"></span></span>
230
+ </template>
231
+ </p>
232
+ </div>
233
+ </template>
234
+
235
+ <%# No remembered brand — the same row shape with no mark to show, so the two
236
+ halves of this card look like one card. %>
237
+ <template x-if="!canOneClick">
238
+ <button type="button" @click="openPicker()" :disabled="connecting"
239
+ style="--pulse-cta-color: var(--color-primary); --pulse-cta-strength: 0.4; --pulse-cta-scale: 1.02"
240
+ class="pulse-cta w-full flex items-center gap-3 p-3 rounded-xl bg-surface-alt border border-strong hover:bg-surface transition text-left disabled:opacity-60">
241
+ <%# A neutral wallet mark, NOT an emoji. This tile sits inches from a real
242
+ brand mark, so a platform emoji (the first pass used U+1F45B PURSE)
243
+ reads as a pink handbag next to Phantom and is the one thing on the
244
+ card that belongs to no design system. Stroked in the same idiom as
245
+ the chevron beside it, on theme tokens.
246
+
247
+ A billfold: body plus clasp, and NO band across the top. The first
248
+ drawing had one and it read as a credit-card magstripe at 20px — the
249
+ wrong object on a card whose whole subject is a wallet. %>
250
+ <span class="w-9 h-9 rounded-lg bg-inset flex items-center justify-center shrink-0" aria-hidden="true">
251
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" class="w-5 h-5 text-secondary">
252
+ <rect x="3" y="6.5" width="18" height="11" rx="2.5" />
253
+ <circle cx="16.6" cy="12" r="1.3" fill="currentColor" stroke="none" />
254
+ </svg>
255
+ </span>
256
+ <span class="font-semibold text-heading">Connect your wallet</span>
257
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="ml-auto w-4 h-4 text-muted">
258
+ <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
259
+ </svg>
260
+ </button>
261
+ </template>
262
+
263
+ <template x-if="error">
264
+ <p class="text-red-400 text-sm mt-3 text-center" x-text="error"></p>
265
+ </template>
266
+
267
+ <%# SECONDARY — always reachable, so a user whose remembered wallet is the
268
+ wrong one is never cornered by our memory of it. %>
269
+ <template x-if="canOneClick">
270
+ <button type="button" @click="openPicker()" :disabled="connecting"
271
+ class="mt-3 block w-full text-center text-sm text-secondary hover:text-heading disabled:opacity-50">
272
+ Use a different wallet
273
+ </button>
274
+ </template>
275
+
276
+ <div class="mt-4 pt-3 border-t border-strong">
277
+ <button type="button" @click="dismiss()" :disabled="connecting"
278
+ class="block w-full text-center text-sm text-secondary hover:text-heading disabled:opacity-50">
279
+ Not now
280
+ </button>
281
+ <%# THE ESCAPE HATCH. A self-custody wallet is the one credential a host
282
+ cannot reset for the user, so the card must not end without a way to
283
+ reach a human. Droppable only by passing help_url: nil, which is a
284
+ deliberate act at the callsite rather than an omission. %>
285
+ <% if help_url.present? %>
286
+ <p class="mt-2 text-center text-[11px] text-muted">
287
+ Can&rsquo;t access your wallet?
288
+ <a href="<%= help_url %>" class="underline hover:text-secondary"><%= help_label %></a>
289
+ </p>
290
+ <% end %>
291
+ </div>
292
+ </div>
@@ -340,6 +340,14 @@
340
340
  <template x-if="$store.dsModals.current().id === 'wallet-connect'">
341
341
  <div><%= render "style/modals/wallet_connect" %></div>
342
342
  </template>
343
+ <%# web3-step-up — the REAL shared partial, not a style-guide specimen.
344
+ The other Web3 cards here render style/modals/* demos while each app
345
+ keeps its own live modal; this one renders studio/modals/* — the
346
+ same file a host renders in production — so what is reviewed here IS
347
+ the shipped card. modal_store mounts it on this page's own host. %>
348
+ <template x-if="$store.dsModals.current().id === 'web3-step-up'">
349
+ <div><%= render "studio/modals/web3_step_up", modal_store: "dsModals" %></div>
350
+ </template>
343
351
  <template x-if="$store.dsModals.current().id === 'onchain-tx'">
344
352
  <div><%= render "style/modals/onchain_tx" %></div>
345
353
  </template>
@@ -796,6 +804,16 @@
796
804
  marks inline from the engine</strong>
797
805
  (<code class="font-mono text-2xs">studio/modals/blocks/_wallet_brand_sprite</code>),
798
806
  so every app inherits them with no per-app icon files.
807
+ The two <strong>Web3 step-up</strong> cards are a different kind of thing
808
+ from the rest of this section: they render
809
+ <code class="font-mono text-2xs">studio/modals/_web3_step_up</code> &mdash;
810
+ the <strong>REAL shared partial a host renders in production</strong>,
811
+ not a style-guide copy of it &mdash; so what you review here is the
812
+ shipped card, and it cannot drift from what users see. They also carry the
813
+ <strong>standard web3 auth button</strong>: a wallet row (brand mark, the
814
+ wallet&rsquo;s own name, Installed badge, chevron) rather than a filled
815
+ CTA, glowing via <code class="font-mono text-2xs">pulse-cta</code> because
816
+ it is the one thing to press.
799
817
  </p>
800
818
  </div>
801
819
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -856,6 +874,36 @@
856
874
  </div>
857
875
  <% end %>
858
876
 
877
+ <%# Web3 step-up — the web2-session-on-a-wallet-account card. NOT part of
878
+ the three-card walk above: that walk is one on-chain transaction, this
879
+ is an AUTH state that precedes any of it. Two specimens because the
880
+ card has exactly two shapes, and the second is what every wallet linked
881
+ before a host recorded brands will see. %>
882
+ <%= render layout: "style/modal_specimen", locals: {
883
+ label: "Web3 step-up (remembered wallet)",
884
+ reference: %(the Web3 Contest "Web3 step-up" card (studio/modals/_web3_step_up — the REAL shared partial, not a specimen copy) — shown to an account that holds a self-custody wallet but authenticated this session with a web2 credential. One wallet row, glowing, because it is the only thing to press. Open with $store.dsModals.open('web3-step-up', { provider: 'phantom', providerLabel: 'Phantom', walletHint: '7xKp…JZ2Q' })),
885
+ open_expr: "$store.dsModals.open('web3-step-up', { provider: 'phantom', providerLabel: 'Phantom', walletHint: '7xKp…JZ2Q' })",
886
+ glow_when: ds_glow.call("web3-step-up"),
887
+ disabled: !web3_on, openable: true } do %>
888
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
889
+ <div class="text-2xl leading-none">🔐</div>
890
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
891
+ <span class="block h-7 w-full rounded-lg border" style="border-color: var(--color-border-strong)"></span>
892
+ </div>
893
+ <% end %>
894
+
895
+ <%= render layout: "style/modal_specimen", locals: {
896
+ label: "Web3 step-up (no remembered wallet)",
897
+ reference: %(the Web3 Contest "Web3 step-up" card with NO remembered brand (studio/modals/_web3_step_up) — every wallet linked before a host recorded brands lands here, so it is a live population and not a defensive branch. The row falls back to the picker rather than dead-ending. Open with $store.dsModals.open('web3-step-up', {})),
898
+ open_expr: "$store.dsModals.open('web3-step-up', {})",
899
+ disabled: !web3_on, openable: true } do %>
900
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
901
+ <div class="text-2xl leading-none">🔐</div>
902
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
903
+ <span class="block h-7 w-full rounded-lg border border-dashed" style="border-color: var(--color-border-strong)"></span>
904
+ </div>
905
+ <% end %>
906
+
859
907
  <%# Wallet deposit — standalone top-up card (not part of the walk). %>
860
908
  <%= render layout: "style/modal_specimen", locals: {
861
909
  label: "Wallet deposit",
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.58.1"
2
+ VERSION = "0.60.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.58.1
4
+ version: 0.60.0
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-08-21 00:00:00.000000000 Z
11
+ date: 2026-08-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -378,6 +378,7 @@ files:
378
378
  - app/views/components/_emoji_swap.html.erb
379
379
  - app/views/components/_empty_state.html.erb
380
380
  - app/views/components/_geo_badge.html.erb
381
+ - app/views/components/_geo_signpost.html.erb
381
382
  - app/views/components/_google_logo.html.erb
382
383
  - app/views/components/_input.html.erb
383
384
  - app/views/components/_json_debug.html.erb
@@ -443,6 +444,7 @@ files:
443
444
  - app/views/studio/modals/_load_convention.html.erb
444
445
  - app/views/studio/modals/_saving.html.erb
445
446
  - app/views/studio/modals/_scoped_host.html.erb
447
+ - app/views/studio/modals/_web3_step_up.html.erb
446
448
  - app/views/studio/modals/auth/_resend_footer.html.erb
447
449
  - app/views/studio/modals/blocks/_age_verify.html.erb
448
450
  - app/views/studio/modals/blocks/_card_header.html.erb