studio-engine 0.62.6 → 0.64.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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/README.md +61 -0
  4. data/app/assets/stylesheets/studio/sticky_table_header.css +4 -1
  5. data/app/assets/tailwind/studio_engine/engine-motion.css +24 -1
  6. data/app/assets/tailwind/studio_engine/engine.css +208 -4
  7. data/app/controllers/solana_sessions_controller.rb +9 -0
  8. data/app/views/layouts/_navbar.html.erb +6 -74
  9. data/app/views/layouts/studio/_flash.html.erb +8 -5
  10. data/app/views/layouts/studio/_head.html.erb +99 -8
  11. data/app/views/solana_sessions/phantom_callback.html.erb +343 -0
  12. data/app/views/studio/admin_models/_teams_table.html.erb +1 -1
  13. data/app/views/studio/banners/_app_banner.html.erb +4 -1
  14. data/app/views/studio/banners/_button.html.erb +2 -2
  15. data/app/views/studio/banners/_stack.html.erb +12 -4
  16. data/app/views/studio/modals/_host.html.erb +105 -5
  17. data/app/views/studio/modals/_scoped_host.html.erb +25 -2
  18. data/app/views/studio/modals/_wallet_connect.html.erb +330 -0
  19. data/app/views/studio/modals/blocks/_close_x.html.erb +18 -0
  20. data/app/views/studio/modals/blocks/_rail_row.html.erb +16 -0
  21. data/app/views/studio/modals/blocks/_success_card.html.erb +21 -6
  22. data/app/views/studio/modals/blocks/_wallet_brand_sprite.html.erb +6 -0
  23. data/app/views/studio/profiles/_identity_styles.html.erb +1 -1
  24. data/app/views/studio/solana/_deeplink_assets.html.erb +37 -0
  25. data/app/views/studio/solana/_phantom_deeplink.html.erb +167 -0
  26. data/app/views/style/_modals.html.erb +10 -1
  27. data/app/views/style/modals/_wallet_connect.html.erb +77 -171
  28. data/app/views/style/modals/_wallet_connect_slot.html.erb +18 -0
  29. data/lib/studio/version.rb +1 -1
  30. data/lib/studio.rb +49 -4
  31. metadata +7 -2
@@ -202,15 +202,33 @@
202
202
  var ro = null;
203
203
  var current = null;
204
204
  var queued = false;
205
+ var lastH = null;
206
+ var lastBottom = null;
205
207
  function publish(header) {
206
208
  var style = document.documentElement.style;
207
- style.setProperty('--nav-h', header.offsetHeight + 'px');
209
+ // SKIP THE NO-OP WRITE. --nav-h and --nav-bottom are INHERITED custom
210
+ // properties on documentElement, so every write invalidates style for the
211
+ // whole document. Most republishes carry the same number — a scroll with
212
+ // the header already pinned, a resize that missed this element — and
213
+ // those cost nothing now.
214
+ // BOTH READS BEFORE EITHER WRITE. Writing a custom property on
215
+ // documentElement invalidates style, so a read taken after one write
216
+ // forces a fresh layout — the thrash this whole change exists to remove.
217
+ var h = header.offsetHeight;
208
218
  // Scroll-dependent where --nav-h is not: chrome above the header scrolls
209
219
  // away while an overlay is open (nothing here locks body scroll), so this
210
220
  // is republished on scroll. Clamped at 0 — once the sticky header is
211
221
  // pinned at top:0 the two values converge, which is why a consumer whose
212
222
  // header already starts at the viewport top sees no change at all.
213
- style.setProperty('--nav-bottom', Math.max(0, header.getBoundingClientRect().bottom) + 'px');
223
+ var bottom = Math.max(0, header.getBoundingClientRect().bottom);
224
+ if (h !== lastH) {
225
+ lastH = h;
226
+ style.setProperty('--nav-h', h + 'px');
227
+ }
228
+ if (bottom !== lastBottom) {
229
+ lastBottom = bottom;
230
+ style.setProperty('--nav-bottom', bottom + 'px');
231
+ }
214
232
  }
215
233
  function schedule() {
216
234
  if (queued || !current) return;
@@ -231,7 +249,18 @@
231
249
  }
232
250
  current = header;
233
251
  if (ro) ro.disconnect();
234
- ro = new ResizeObserver(function () { publish(header); });
252
+ // THROUGH schedule(), NOT STRAIGHT TO publish(). This callback used to
253
+ // call publish() directly, which was fine while the header only resized
254
+ // during a 300ms transition. navCollapse made it resize on EVERY scroll
255
+ // frame, and each direct publish forced layout and then wrote two
256
+ // inherited custom properties on documentElement. Measured in
257
+ // turf-monster at 6x CPU throttle: frames over 20ms were 13/24 THROUGH
258
+ // the collapse ramp versus 0/24 past it, median 26ms versus 8ms.
259
+ // Ablating this observer alone took it to 0/24 and median 13ms — it cost
260
+ // about 2.6x the reflow it was reacting to. schedule() already coalesces
261
+ // to one publish per frame and is what the scroll and resize listeners
262
+ // use; the ResizeObserver was the one path that bypassed it.
263
+ ro = new ResizeObserver(schedule);
235
264
  ro.observe(header);
236
265
  publish(header);
237
266
  }
@@ -320,6 +349,7 @@
320
349
  scrolled: false,
321
350
  p: 0,
322
351
  _ramp: 144,
352
+ _maxPx: 5,
323
353
  _reduce: null,
324
354
  _onScroll: null,
325
355
  _onResize: null,
@@ -332,8 +362,11 @@
332
362
  this._reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
333
363
 
334
364
  function readRamp() {
335
- var raw = parseFloat(getComputedStyle(el).getPropertyValue('--nav-ramp'));
365
+ var style = getComputedStyle(el);
366
+ var raw = parseFloat(style.getPropertyValue('--nav-ramp'));
336
367
  self._ramp = raw > 0 ? raw : 144;
368
+ var step = parseFloat(style.getPropertyValue('--nav-max-step'));
369
+ self._maxPx = step > 0 ? step : 5;
337
370
  }
338
371
 
339
372
  function apply() {
@@ -349,14 +382,61 @@
349
382
  - window.innerHeight
350
383
  + ramp * self.p;
351
384
 
352
- var p;
385
+ // WHERE THE COLLAPSE WANTS TO BE, from scroll position alone.
386
+ var target;
387
+ var snap = false;
353
388
  if (roomExpanded < ramp + 24) {
354
- p = 0;
389
+ target = 0;
390
+ snap = true;
355
391
  } else if (self._reduce.matches) {
356
- p = (self.p > 0 ? y > 5 : y > 60) ? 1 : 0;
392
+ target = (self.p > 0 ? y > 5 : y > 60) ? 1 : 0;
393
+ snap = true;
357
394
  } else {
358
395
  var t = Math.min(1, y / ramp);
359
- p = t * t * (3 - 2 * t);
396
+ target = t * t * (3 - 2 * t);
397
+ }
398
+
399
+ // THE RATE LIMIT — how far the collapse may travel in ONE frame.
400
+ //
401
+ // Position-linked progress fixed motion that OUTLIVED the gesture. It
402
+ // also guaranteed the opposite defect: if scrollY moves 90px between
403
+ // two frames, so does the header's whole range. Measured in
404
+ // turf-monster at 390x844, worst single-frame header height change by
405
+ // scroll profile —
406
+ //
407
+ // 8px/frame (slow, deliberate) 3.9px
408
+ // 24px/frame (normal swipe) 14.1px
409
+ // momentum flick 39.0px <- the ENTIRE collapse
410
+ // hard flick 39.0px
411
+ //
412
+ // 39px is the full 178 -> 139. It was found on a phone: slow
413
+ // scrolling looked right, a flick jumped. The measurements behind the
414
+ // original primitive all walked a constant 8px/frame, which is
415
+ // exactly the case that already worked.
416
+ //
417
+ // So the TARGET stays position-linked and the STEP is clamped. What
418
+ // makes this safe is that a slow scroll never REACHES the clamp —
419
+ // under --nav-max-step of header travel per frame the branch below
420
+ // returns `target` untouched, so the slow feel is not approximated,
421
+ // it is the same arithmetic.
422
+ //
423
+ // maxStep is derived, not tuned per band: engine.css sizes --nav-ramp
424
+ // at 3x the band's collapse total, so a cap of MAX px/frame is
425
+ // 3*MAX/ramp in --nav-p units. Mobile (ramp 120) gives 4.9px/frame,
426
+ // desktop (ramp 144) 5.0px. Retune --nav-ramp without keeping that 3x
427
+ // relation and this cap silently drifts with it.
428
+ var p;
429
+ if (snap) {
430
+ // A guard refusal and a reduced-motion state are DECISIONS, not
431
+ // motion; ramping them would animate the very thing each exists to
432
+ // avoid.
433
+ p = target;
434
+ } else {
435
+ var maxStep = (3 * self._maxPx) / ramp;
436
+ var delta = target - self.p;
437
+ p = Math.abs(delta) <= maxStep
438
+ ? target
439
+ : self.p + (delta > 0 ? maxStep : -maxStep);
360
440
  }
361
441
 
362
442
  if (p !== self.p) {
@@ -364,6 +444,17 @@
364
444
  el.style.setProperty('--nav-p', p.toFixed(4));
365
445
  }
366
446
 
447
+ // KEEP FRAMES COMING WHILE CATCHING UP. Nothing else will schedule
448
+ // one: the finger is off, so no more scroll events arrive, and
449
+ // without this the collapse freezes wherever the clamp left it. It
450
+ // converges LINEARLY and lands exactly — about 4 frames (~67ms) from
451
+ // a hard flick — rather than crawling asymptotically the way the
452
+ // 300ms ease this replaced did.
453
+ if (!snap && p !== target) {
454
+ queued = true;
455
+ requestAnimationFrame(apply);
456
+ }
457
+
367
458
  // The shadow is the one thing still on a clock, and it may stay
368
459
  // there: box-shadow paints, it never reflows, so it cannot move
369
460
  // content. Hysteresis keeps it from strobing at the boundary.
@@ -0,0 +1,343 @@
1
+ <div class="flex flex-col items-center justify-center min-h-[60vh] px-4">
2
+ <div class="text-center">
3
+ <div id="phantom-spinner" class="inline-block w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mb-4"></div>
4
+ <p id="phantom-status" class="text-secondary text-sm">Processing Phantom response...</p>
5
+ <p id="phantom-error" class="text-red-400 text-sm mt-2 hidden"></p>
6
+ </div>
7
+
8
+ <%# Visual debug log — NEVER rendered on a real production deploy. This sink printed
9
+ the dapp x25519 secret key (phantom_dl_secret) to the page and the console on every
10
+ mobile Phantom sign-in. Studio.wallet_debug_sink DEFAULTS TO OFF —
11
+ a sink that prints a signing key is opted into, not defaulted on. An app that
12
+ wants QA debugging back sets it to a predicate of its own (turf-monster:
13
+ -> { !AppFlags.live_production? }); Rails.env.production? will NOT do, because
14
+ a Heroku QA dyno runs RAILS_ENV=production. The script below
15
+ treats an absent sink as "debug off" and must stay null-safe: if this div is gone and
16
+ dbg() still touched logEl, the callback would throw and break sign-in outright. %>
17
+ <% if Studio.wallet_debug_sink? %>
18
+ <div id="phantom-log" class="mt-6 w-full max-w-lg text-left bg-surface-alt rounded-lg border border-subtle p-3 overflow-y-auto" style="max-height:50vh">
19
+ <p class="text-xs font-mono text-muted mb-2">Debug Log</p>
20
+ </div>
21
+ <% end %>
22
+ </div>
23
+
24
+ <script>
25
+ (function() {
26
+ var logEl = document.getElementById('phantom-log');
27
+ // The sink is absent on a real production deploy (see the ERB guard above), and that
28
+ // absence IS the off switch — for the console too, not just the page.
29
+ var DEBUG_SINK = !!logEl;
30
+
31
+ function dbg(label, value) {
32
+ if (!DEBUG_SINK) { return; }
33
+ var line = document.createElement('p');
34
+ line.className = 'text-xs font-mono break-all mb-1';
35
+ if (label === 'ERROR' || label === 'CATCH') {
36
+ line.style.color = '#EF4444';
37
+ } else if (label === 'OK') {
38
+ line.style.color = '#4BAF50';
39
+ } else {
40
+ line.style.color = '#94a3b8';
41
+ }
42
+ var val = (value === undefined || value === null) ? '' : String(value);
43
+ line.textContent = label + (val ? ': ' + val : '');
44
+ logEl.appendChild(line);
45
+ logEl.scrollTop = logEl.scrollHeight;
46
+ console.log('[PhantomDL]', label, value);
47
+ }
48
+
49
+ function truncate(s, n) {
50
+ return s && s.length > n ? s.substring(0, n) + '...' : s;
51
+ }
52
+
53
+ dbg('Page loaded', new Date().toISOString());
54
+ dbg('URL', window.location.href);
55
+
56
+ // Self-contained Base58
57
+ var B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
58
+
59
+ function b58encode(bytes) {
60
+ var digits = [0];
61
+ for (var i = 0; i < bytes.length; i++) {
62
+ var carry = bytes[i];
63
+ for (var j = 0; j < digits.length; j++) {
64
+ carry += digits[j] << 8;
65
+ digits[j] = carry % 58;
66
+ carry = (carry / 58) | 0;
67
+ }
68
+ while (carry) { digits.push(carry % 58); carry = (carry / 58) | 0; }
69
+ }
70
+ var str = '';
71
+ for (var i = 0; i < bytes.length && bytes[i] === 0; i++) str += '1';
72
+ for (var i = digits.length - 1; i >= 0; i--) str += B58[digits[i]];
73
+ return str;
74
+ }
75
+
76
+ function b58decode(str) {
77
+ var bytes = [];
78
+ for (var i = 0; i < str.length; i++) {
79
+ var idx = B58.indexOf(str[i]);
80
+ if (idx < 0) throw new Error('Invalid base58 character');
81
+ var carry = idx;
82
+ for (var j = 0; j < bytes.length; j++) {
83
+ carry += bytes[j] * 58;
84
+ bytes[j] = carry & 0xff;
85
+ carry >>= 8;
86
+ }
87
+ while (carry) { bytes.push(carry & 0xff); carry >>= 8; }
88
+ }
89
+ for (var i = 0; i < str.length && str[i] === '1'; i++) bytes.push(0);
90
+ return new Uint8Array(bytes.reverse());
91
+ }
92
+
93
+ var statusEl = document.getElementById('phantom-status');
94
+ var errorEl = document.getElementById('phantom-error');
95
+ var spinnerEl = document.getElementById('phantom-spinner');
96
+
97
+ function showError(msg) {
98
+ dbg('ERROR', msg);
99
+ spinnerEl.classList.add('hidden');
100
+ statusEl.classList.add('hidden');
101
+ errorEl.textContent = msg;
102
+ errorEl.classList.remove('hidden');
103
+ setTimeout(function() { window.location.href = '/signin'; }, 30000);
104
+ }
105
+
106
+ var ALL_KEYS = [
107
+ 'phantom_dl_secret', 'phantom_dl_pubkey', 'phantom_dl_nonce', 'phantom_dl_nonce_at',
108
+ 'phantom_dl_step', 'phantom_dl_link_mode', 'phantom_dl_cluster', 'phantom_dl_age_attested'
109
+ ];
110
+
111
+ // Keys whose VALUE must never be printed. ALL_KEYS above stays COMPLETE on purpose:
112
+ // cleanup() iterates it to REMOVE these from localStorage, so pruning the secret from
113
+ // it would leave the private key sitting on the device forever — a worse bug than the
114
+ // one this task fixes. Redact at the point of display instead.
115
+ var SECRET_KEYS = ['phantom_dl_secret'];
116
+
117
+ function cleanup() {
118
+ ALL_KEYS.forEach(function(k) { localStorage.removeItem(k); });
119
+ }
120
+
121
+ // Dump localStorage
122
+ dbg('--- localStorage ---');
123
+ ALL_KEYS.forEach(function(k) {
124
+ var v = localStorage.getItem(k);
125
+ var short = k.replace('phantom_dl_', '');
126
+ if (SECRET_KEYS.indexOf(k) !== -1) {
127
+ dbg(' ' + short, v ? '(present, ' + v.length + ' chars, redacted)' : '(empty)');
128
+ } else {
129
+ dbg(' ' + short, v ? truncate(v, 40) : '(empty)');
130
+ }
131
+ });
132
+
133
+ // Parse + dump URL params
134
+ var params = new URLSearchParams(window.location.search);
135
+ dbg('--- URL params ---');
136
+ params.forEach(function(val, key) {
137
+ dbg(' ' + key, truncate(val, 40));
138
+ });
139
+
140
+ // Check for Phantom error
141
+ if (params.get('errorCode')) {
142
+ dbg('ERROR', 'errorCode=' + params.get('errorCode'));
143
+ dbg('ERROR', 'errorMessage=' + params.get('errorMessage'));
144
+ cleanup();
145
+ showError('Phantom error: ' + (params.get('errorMessage') || 'Request rejected'));
146
+ return;
147
+ }
148
+
149
+ var step = localStorage.getItem('phantom_dl_step');
150
+ dbg('Step', step);
151
+
152
+ if (!step) {
153
+ showError('No pending Phantom request. Redirecting to login...');
154
+ return;
155
+ }
156
+
157
+ // Check nonce expiry
158
+ var nonceAt = parseInt(localStorage.getItem('phantom_dl_nonce_at') || '0');
159
+ var elapsed = Date.now() - nonceAt;
160
+ dbg('Nonce age', Math.round(elapsed / 1000) + 's');
161
+ if (elapsed > 270000) {
162
+ cleanup();
163
+ showError('Session expired. Please try again.');
164
+ return;
165
+ }
166
+
167
+ // Check nacl
168
+ dbg('nacl available', typeof nacl !== 'undefined');
169
+ if (typeof nacl === 'undefined') {
170
+ showError('TweetNaCl not loaded');
171
+ return;
172
+ }
173
+
174
+ // URL params from Phantom response
175
+ var phantomEncPubkeyB58 = params.get('phantom_encryption_public_key');
176
+ var dataParm = params.get('data');
177
+ var nonceParm = params.get('nonce');
178
+
179
+ dbg('Has phantom_encryption_public_key', !!phantomEncPubkeyB58);
180
+ dbg('Has data', !!dataParm);
181
+ dbg('Has nonce', !!nonceParm);
182
+
183
+ if (!phantomEncPubkeyB58 || !dataParm || !nonceParm) {
184
+ cleanup();
185
+ showError('Missing Phantom response parameters');
186
+ return;
187
+ }
188
+
189
+ try {
190
+ // Recover dapp secret key from localStorage
191
+ var dappSecretKey = b58decode(localStorage.getItem('phantom_dl_secret'));
192
+ dbg('Dapp secret key bytes', dappSecretKey.length);
193
+
194
+ // Compute shared secret (Diffie-Hellman)
195
+ var phantomPubBytes = b58decode(phantomEncPubkeyB58);
196
+ dbg('Phantom pubkey bytes', phantomPubBytes.length);
197
+ var sharedSecret = nacl.box.before(phantomPubBytes, dappSecretKey);
198
+ dbg('OK', 'Shared secret computed, ' + sharedSecret.length + 'B');
199
+
200
+ // Decrypt response
201
+ var data = b58decode(dataParm);
202
+ var nonce = b58decode(nonceParm);
203
+ dbg('Decrypt', 'data=' + data.length + 'B nonce=' + nonce.length + 'B');
204
+
205
+ var decrypted = nacl.box.open.after(data, nonce, sharedSecret);
206
+ if (!decrypted) throw new Error('Decryption failed — nacl.box.open.after returned null');
207
+
208
+ var responseText = new TextDecoder().decode(decrypted);
209
+ dbg('OK', 'Decrypted ' + responseText.length + ' chars');
210
+
211
+ var response = JSON.parse(responseText);
212
+
213
+ // Log ALL response fields for debugging
214
+ dbg('=== RESPONSE FIELDS ===');
215
+ var keys = Object.keys(response);
216
+ dbg('Keys', keys.join(', '));
217
+ keys.forEach(function(k) {
218
+ var v = response[k];
219
+ if (typeof v === 'string') {
220
+ dbg(' ' + k, truncate(v, 50));
221
+ } else if (typeof v === 'object' && v !== null) {
222
+ dbg(' ' + k, JSON.stringify(v).substring(0, 80));
223
+ } else {
224
+ dbg(' ' + k, String(v));
225
+ }
226
+ });
227
+
228
+ // Extract wallet address
229
+ var walletPubkey = response.address || response.public_key;
230
+ dbg('Wallet', walletPubkey);
231
+
232
+ if (!walletPubkey) {
233
+ throw new Error('No wallet address in response');
234
+ }
235
+
236
+ // Extract signature and signed message (SIWS response)
237
+ var signatureB58 = response.signature;
238
+ var signedMessageB58 = response.signed_message || response.signedMessage;
239
+ var outputB58 = response.output;
240
+
241
+ dbg('signature', truncate(signatureB58, 30));
242
+ dbg('signedMessage', truncate(signedMessageB58, 30));
243
+ dbg('output', truncate(outputB58, 30));
244
+
245
+ // Decode the signed message to UTF-8 text (server expects the raw message string)
246
+ var message = null;
247
+ if (signedMessageB58) {
248
+ var msgBytes = b58decode(signedMessageB58);
249
+ message = new TextDecoder().decode(msgBytes);
250
+ dbg('Message text', truncate(message, 60));
251
+ }
252
+
253
+ // If no signature in response, show debug info and stop
254
+ if (!signatureB58) {
255
+ dbg('ERROR', 'No signature field in response — signIn may not return SIWS data');
256
+ dbg('Full response', responseText.substring(0, 200));
257
+ showError('No signature in Phantom response. See debug log.');
258
+ return;
259
+ }
260
+
261
+ if (!message) {
262
+ // Try to reconstruct message from our stored data
263
+ var serverNonce = localStorage.getItem('phantom_dl_nonce');
264
+ var domain = window.location.host;
265
+ // OPSEC-005: include `User-ID: <id>` when we were linking — mirrors
266
+ // the statement we built in startPhantomDeepLink so the server's
267
+ // session-binding check passes.
268
+ var storedUserId = localStorage.getItem('phantom_dl_user_id');
269
+ var storedLinkMode = localStorage.getItem('phantom_dl_link_mode') === 'true';
270
+ var statementLine = <%= Studio.wallet_sign_in_statement.to_json.html_safe %>;
271
+ if (storedLinkMode && storedUserId) {
272
+ statementLine += '\nUser-ID: ' + storedUserId;
273
+ }
274
+ message = domain + ' wants you to sign in with your Solana account:\n' +
275
+ walletPubkey + '\n\n' + statementLine + '\n\nNonce: ' + serverNonce;
276
+ dbg('Reconstructed message', truncate(message, 60));
277
+ }
278
+
279
+ var linkMode = localStorage.getItem('phantom_dl_link_mode') === 'true';
280
+ var verifyUrl = linkMode ? '/account/link_solana' : '/auth/solana/verify';
281
+ var csrfToken = document.querySelector('meta[name="csrf-token"]');
282
+
283
+ dbg('Verify URL', verifyUrl);
284
+ dbg('CSRF token', !!csrfToken);
285
+ dbg('POSTing to verify...');
286
+ statusEl.textContent = 'Logging in...';
287
+
288
+ fetch(verifyUrl, {
289
+ method: 'POST',
290
+ headers: {
291
+ 'Content-Type': 'application/json',
292
+ 'X-CSRF-Token': csrfToken ? csrfToken.content : ''
293
+ },
294
+ body: JSON.stringify({
295
+ message: message,
296
+ signature: signatureB58,
297
+ pubkey: walletPubkey,
298
+ // Legal-age attestation, stashed by the Connect Wallet picker before
299
+ // the deep-link round trip (undefined is dropped by JSON.stringify).
300
+ age_attestation: (!linkMode && localStorage.getItem('phantom_dl_age_attested') === '1') ? '1' : undefined,
301
+ // Hardcoded, and correctly so: this whole page exists to service the
302
+ // PHANTOM mobile deep link (phantom_dl_*), so the brand is a property of
303
+ // the route rather than something to detect. A user arriving here signed
304
+ // in the Phantom app.
305
+ wallet_provider: 'phantom'
306
+ })
307
+ })
308
+ .then(function(r) {
309
+ dbg('Verify status', r.status);
310
+ return r.json();
311
+ })
312
+ .then(function(result) {
313
+ dbg('Verify result', JSON.stringify(result));
314
+ if (result.success) {
315
+ cleanup();
316
+ // Guarded, matching studio/modals/_wallet_connect two files over. An app
317
+ // that does not define this hook would otherwise throw HERE — after a
318
+ // successful verify, with the session already set — so the user is
319
+ // signed in and the page dies before redirecting. The engine cannot
320
+ // require a hook it does not ship.
321
+ if (typeof window.handleSolanaVerifySuccess === 'function') window.handleSolanaVerifySuccess(result);
322
+ dbg('OK', 'Login success! Redirecting...');
323
+ window.location.href = result.redirect || '/';
324
+ } else {
325
+ cleanup();
326
+ dbg('ERROR', 'Verify failed: ' + (result.error || JSON.stringify(result)));
327
+ showError(result.error || 'Verification failed');
328
+ }
329
+ })
330
+ .catch(function(err) {
331
+ dbg('ERROR', 'Fetch error: ' + err.message);
332
+ cleanup();
333
+ showError('Verification failed: ' + err.message);
334
+ });
335
+
336
+ } catch (e) {
337
+ dbg('CATCH', e.message);
338
+ dbg('CATCH', e.stack || '(no stack)');
339
+ cleanup();
340
+ showError(e.message || 'Something went wrong');
341
+ }
342
+ })();
343
+ </script>
@@ -53,7 +53,7 @@
53
53
  <template x-teleport="body">
54
54
  <div x-show="open"
55
55
  x-cloak
56
- class="fixed inset-0 z-[130] flex items-center justify-center bg-black/70 p-4"
56
+ class="fixed inset-0 z-[var(--z-lightbox)] flex items-center justify-center bg-black/70 p-4"
57
57
  role="dialog"
58
58
  aria-modal="true"
59
59
  aria-label="<%= team.name %> JSON"
@@ -41,7 +41,10 @@
41
41
  style = tones.fetch(tone)
42
42
  %>
43
43
 
44
- <div class="w-full" style="background:<%= style.fetch(:background) %>; color:<%= style.fetch(:color) %>; box-shadow:<%= style.fetch(:shadow) %>;">
44
+ <%# studio-app-banner is the LIFT HOOK, not styling: engine.css raises the bars
45
+ above the modal backdrop while body.modal-open is set. Keep the class even if
46
+ the markup changes. %>
47
+ <div class="studio-app-banner w-full" data-studio-app-banner style="background:<%= style.fetch(:background) %>; color:<%= style.fetch(:color) %>; box-shadow:<%= style.fetch(:shadow) %>;">
45
48
  <div class="max-w-7xl mx-auto flex items-center justify-between gap-3" style="<%= density_styles.fetch(density) %>">
46
49
  <span class="min-w-0 truncate"><%= message %></span>
47
50
  <% if actions.present? %>
@@ -66,8 +66,8 @@
66
66
  tooltip_content = capture do
67
67
  %>
68
68
  <span data-studio-banner-tooltip
69
- class="pointer-events-none absolute right-0 top-full z-[140] mt-2 w-max max-w-[260px] whitespace-normal rounded bg-white px-3 py-2 text-xs font-semibold text-slate-900 opacity-0 shadow-lg ring-1 ring-black/10 transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100"
70
- style="position:absolute; right:0; top:100%; z-index:140; margin-top:8px; max-width:260px; width:max-content; white-space:normal; border-radius:4px; background:#ffffff; color:#111827; padding:8px 12px; font-size:12px; font-weight:700; line-height:1.2; opacity:0; pointer-events:none; box-shadow:0 8px 18px rgba(0,0,0,0.18); transition:opacity 150ms ease;">
69
+ class="pointer-events-none absolute right-0 top-full z-[var(--z-tooltip)] mt-2 w-max max-w-[260px] whitespace-normal rounded bg-white px-3 py-2 text-xs font-semibold text-slate-900 opacity-0 shadow-lg ring-1 ring-black/10 transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100"
70
+ style="position:absolute; right:0; top:100%; z-index:var(--z-tooltip, 600); margin-top:8px; max-width:260px; width:max-content; white-space:normal; border-radius:4px; background:#ffffff; color:#111827; padding:8px 12px; font-size:12px; font-weight:700; line-height:1.2; opacity:0; pointer-events:none; box-shadow:0 8px 18px rgba(0,0,0,0.18); transition:opacity 150ms ease;">
71
71
  <%= tooltip %>
72
72
  </span>
73
73
  <%
@@ -78,9 +78,17 @@
78
78
  any_bar = show_environment || show_impersonation
79
79
  %>
80
80
  <% if any_bar %>
81
- <%# Normal flow, deliberately. No sticky, no z-index, no measured offset: the
82
- navbar is the only pinned chrome, so the bars simply take their own height
83
- above it. Not sticky also keeps the paint order right — the pinned navbar
84
- is positioned, so it draws over these bars as they scroll up behind it. %>
81
+ <%# Normal flow, deliberately. No sticky, no measured offset: the navbar is the
82
+ only pinned chrome, so the bars simply take their own height above it. Not
83
+ sticky also keeps the paint order right — the pinned navbar is positioned,
84
+ so it draws over these bars as they scroll up behind it.
85
+
86
+ ONE exception, and it is scoped to the state that makes it free:
87
+ `body.modal-open .studio-bar-stack` in engine.css lifts the stack to
88
+ --z-banner, above the modal backdrop, so DEV MODE and the email chip stay
89
+ lit and clickable while a modal is up. It cannot cost the paint order
90
+ above, because the same class locks body scroll — the bars never move
91
+ relative to the navbar while the lift is on. Flow is still the resting
92
+ state; nothing here is positioned until a modal opens. %>
85
93
  <div class="studio-bar-stack w-full" data-studio-bar-stack><%= bars %></div>
86
94
  <% end %>