studio-engine 0.62.6 → 0.63.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 +4 -4
- data/CHANGELOG.md +69 -0
- data/README.md +61 -0
- data/app/assets/stylesheets/studio/sticky_table_header.css +4 -1
- data/app/assets/tailwind/studio_engine/engine-motion.css +24 -1
- data/app/assets/tailwind/studio_engine/engine.css +208 -4
- data/app/views/layouts/_navbar.html.erb +6 -74
- data/app/views/layouts/studio/_flash.html.erb +8 -5
- data/app/views/layouts/studio/_head.html.erb +99 -8
- data/app/views/studio/admin_models/_teams_table.html.erb +1 -1
- data/app/views/studio/banners/_app_banner.html.erb +4 -1
- data/app/views/studio/banners/_button.html.erb +2 -2
- data/app/views/studio/banners/_stack.html.erb +12 -4
- data/app/views/studio/modals/_host.html.erb +105 -5
- data/app/views/studio/modals/_scoped_host.html.erb +25 -2
- data/app/views/studio/modals/_wallet_connect.html.erb +315 -0
- data/app/views/studio/modals/blocks/_success_card.html.erb +12 -4
- data/app/views/studio/modals/blocks/_wallet_brand_sprite.html.erb +6 -0
- data/app/views/studio/profiles/_identity_styles.html.erb +1 -1
- data/app/views/style/_modals.html.erb +10 -1
- data/app/views/style/modals/_wallet_connect.html.erb +77 -171
- data/app/views/style/modals/_wallet_connect_slot.html.erb +18 -0
- data/lib/studio/version.rb +1 -1
- metadata +3 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
389
|
+
target = 0;
|
|
390
|
+
snap = true;
|
|
355
391
|
} else if (self._reduce.matches) {
|
|
356
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -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-[
|
|
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
|
-
|
|
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-[
|
|
70
|
-
style="position:absolute; right:0; top:100%; z-index:
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 %>
|
|
@@ -13,14 +13,53 @@
|
|
|
13
13
|
for pages that drive a modal host WITHOUT rendering this partial —
|
|
14
14
|
e.g. the living style guide's page-scoped dsModals host. The two are
|
|
15
15
|
byte-identical; keep them in sync if you retune a curve or duration.
|
|
16
|
+
|
|
17
|
+
THREE CONSUMER EXTENSION POINTS, so an app never has to fork this file
|
|
18
|
+
to change an animation, a width, or register an app-wide modal:
|
|
19
|
+
|
|
20
|
+
window.ModalAnimations per-modal enter/exit animation, merged
|
|
21
|
+
over the engine defaults (see below)
|
|
22
|
+
window.StudioModals.CARD_WIDTHS per-modal card width by modal id,
|
|
23
|
+
merged the same way; DEFAULT_CARD_WIDTH
|
|
24
|
+
covers every id it does not name
|
|
25
|
+
app/views/modals/_host_extras OPTIONAL app partial, rendered inside
|
|
26
|
+
the card on every render path through
|
|
27
|
+
this host — for a modal that belongs to
|
|
28
|
+
the app rather than to one call site
|
|
29
|
+
|
|
16
30
|
Consumer integration example and API reference: the "Modal host"
|
|
17
31
|
section of the gem README.
|
|
18
32
|
%>
|
|
19
33
|
<style>
|
|
20
34
|
/* Scroll lock applied by $store.modals._sync() when the stack is non-empty.
|
|
21
|
-
Combined with the fixed backdrop on the host below, this keeps the
|
|
22
|
-
|
|
23
|
-
|
|
35
|
+
Combined with the fixed backdrop on the host below, this keeps the page
|
|
36
|
+
beneath any modal from scrolling on wheel / touch / spacebar — a claim that
|
|
37
|
+
was FALSE wherever this gem's link sidebar shipped, until the rule below
|
|
38
|
+
moved off body. */
|
|
39
|
+
/* THE SCROLL LOCK GOES ON THE ELEMENT THAT ACTUALLY SCROLLS.
|
|
40
|
+
Measured on a consumer, 2026-08-27, before this change: with a modal open a
|
|
41
|
+
real wheel gesture still scrolled the page (600px → 1000px) and the sticky
|
|
42
|
+
header unpinned and slid away with it. The lock had been inert there for as
|
|
43
|
+
long as the link sidebar had shipped.
|
|
44
|
+
|
|
45
|
+
`body { overflow: hidden }` locks the viewport only when it PROPAGATES to
|
|
46
|
+
it, and body's overflow propagates only while `html` is `overflow: visible`.
|
|
47
|
+
This gem's own link-sidebar partial sets `html { overflow-x: clip }` so the
|
|
48
|
+
off-canvas panel never flashes a scrollbar — correct on its own terms, clip
|
|
49
|
+
creates no scroll container — but it ends the propagation. The lock then
|
|
50
|
+
stopped locking the viewport and started making BODY a scroll container
|
|
51
|
+
instead: one holding scrollTop 0 forever while the viewport scrolled past
|
|
52
|
+
it. Every `position: sticky` child of body then has a scrollport that never
|
|
53
|
+
moves, which is why the header unpinned and why the modal-open banner lift
|
|
54
|
+
had nothing to pin against.
|
|
55
|
+
|
|
56
|
+
So lock `html`, and put body back to `visible` so it is not a second scroll
|
|
57
|
+
container. An app that sets no overflow on html is unaffected — locking html
|
|
58
|
+
is correct there too; it was simply never the only thing that worked.
|
|
59
|
+
|
|
60
|
+
:has() is the trigger because the class lands on body, not html. */
|
|
61
|
+
html:has(body.modal-open) { overflow: hidden; }
|
|
62
|
+
body.modal-open { overflow: visible; }
|
|
24
63
|
|
|
25
64
|
/* Drain-bar keyframe used by studio/modals/blocks/_success_card when
|
|
26
65
|
cta_drain is set — the CTA button hosts a translucent overlay that
|
|
@@ -195,6 +234,32 @@
|
|
|
195
234
|
enter: Object.assign({}, animDefaults.enter, animOverrides.enter || {}),
|
|
196
235
|
exit: Object.assign({}, animDefaults.exit, animOverrides.exit || {})
|
|
197
236
|
};
|
|
237
|
+
|
|
238
|
+
// Per-modal CARD WIDTH — the second consumer extension point, deliberately
|
|
239
|
+
// shaped like the animation one above so a consumer learns the pattern once.
|
|
240
|
+
// Define window.StudioModals.CARD_WIDTHS BEFORE this script runs, keyed by
|
|
241
|
+
// modal id, and app entries merge OVER the engine defaults (empty: the
|
|
242
|
+
// engine ships no per-id width, only the default below).
|
|
243
|
+
//
|
|
244
|
+
// window.StudioModals = window.StudioModals || {};
|
|
245
|
+
// window.StudioModals.CARD_WIDTHS = { 'wallet-setup': 'max-w-md' };
|
|
246
|
+
//
|
|
247
|
+
// BY MODAL ID, NOT BY PROP, and that is the whole reason this is a registry.
|
|
248
|
+
// A card opened from several places would have to carry the prop at every
|
|
249
|
+
// opener, and ONE miss renders the same card at two widths depending on how
|
|
250
|
+
// the user got there. The originating case is turf-monster's wallet-setup,
|
|
251
|
+
// opened from three (the board's entry gate, the onboarding chain driver,
|
|
252
|
+
// and the post-Connect reopen).
|
|
253
|
+
//
|
|
254
|
+
// Resolved in cardClasses(), NEVER written as a static class on the card
|
|
255
|
+
// element: exactly one max-w-* may land on that div, and a static class
|
|
256
|
+
// alongside a bound one leaves the winner to stylesheet source order, which
|
|
257
|
+
// is not something this file gets to decide.
|
|
258
|
+
window.StudioModals = window.StudioModals || {};
|
|
259
|
+
var widthOverrides = window.StudioModals.CARD_WIDTHS || {};
|
|
260
|
+
window.StudioModals.CARD_WIDTHS = Object.assign({}, widthOverrides);
|
|
261
|
+
window.StudioModals.DEFAULT_CARD_WIDTH =
|
|
262
|
+
window.StudioModals.DEFAULT_CARD_WIDTH || 'max-w-sm';
|
|
198
263
|
// Late-binding guard: read window.ModalAnimations at CALL time, and
|
|
199
264
|
// never return undefined. A consumer script that loads after this one
|
|
200
265
|
// (e.g. an importmap module) may REPLACE the merged registry object
|
|
@@ -206,6 +271,17 @@
|
|
|
206
271
|
return table[key] || table.pop || animDefaults[channel].pop;
|
|
207
272
|
}
|
|
208
273
|
|
|
274
|
+
// Same late-binding guard as modalAnim, for the same reason and with one
|
|
275
|
+
// extra: a width that fails to resolve does not throw, it renders a
|
|
276
|
+
// FULL-BLEED card. A silent failure gets a literal floor, not a chance.
|
|
277
|
+
var DEFAULT_CARD_WIDTH = 'max-w-sm';
|
|
278
|
+
function modalCardWidth(id) {
|
|
279
|
+
var sm = window.StudioModals || {};
|
|
280
|
+
return (sm.CARD_WIDTHS && sm.CARD_WIDTHS[id]) ||
|
|
281
|
+
sm.DEFAULT_CARD_WIDTH ||
|
|
282
|
+
DEFAULT_CARD_WIDTH;
|
|
283
|
+
}
|
|
284
|
+
|
|
209
285
|
// Where focus returns when the last modal closes. A module-level closure, NOT a
|
|
210
286
|
// store property — see captureFocus below for why a DOM node must never live on
|
|
211
287
|
// a reactive store.
|
|
@@ -624,6 +700,10 @@
|
|
|
624
700
|
var c = this.current();
|
|
625
701
|
if (!c) return {};
|
|
626
702
|
var o = {};
|
|
703
|
+
// The card element carries no static max-w-*; this is the only
|
|
704
|
+
// one it ever gets. Set FIRST so the animation keys below, which
|
|
705
|
+
// never collide with a max-w-* name, read as additions to it.
|
|
706
|
+
o[modalCardWidth(c.id)] = true;
|
|
627
707
|
if (!c._settled && !c._swappingIn && !c._swappingOut && !c._closing) {
|
|
628
708
|
o[modalAnim('enter', c.props && c.props.enterAnim).cls] = true;
|
|
629
709
|
}
|
|
@@ -712,7 +792,7 @@
|
|
|
712
792
|
tabindex="-1" makes the backdrop programmatically focusable without adding it
|
|
713
793
|
to the tab order. The name comes from props (ariaLabel, else title, else the
|
|
714
794
|
modal id) so a screen reader never announces a bare unnamed dialog. %>
|
|
715
|
-
<div class="fixed inset-0 z-[
|
|
795
|
+
<div class="fixed inset-0 z-[var(--z-modal)] flex items-center justify-center p-4 modal-backdrop-mount"
|
|
716
796
|
:class="$store.modals.current()?._closing && 'modal-backdrop-unmount'"
|
|
717
797
|
style="background:rgba(0,0,0,0.6)"
|
|
718
798
|
role="dialog"
|
|
@@ -744,12 +824,32 @@
|
|
|
744
824
|
a dead end, since escape and click-outside are gated off. 100dvh, not 100vh:
|
|
745
825
|
mobile browsers shrink the visual viewport when the URL bar is showing, and
|
|
746
826
|
vh ignores that. %>
|
|
747
|
-
|
|
827
|
+
<%# No max-w-* here — cardClasses() supplies exactly one, per modal id, from
|
|
828
|
+
the CARD_WIDTHS registry above. max-h/overflow are unrelated and stay. %>
|
|
829
|
+
<div class="bg-surface rounded-xl border border-subtle shadow-2xl p-6 w-full max-h-[85dvh] overflow-y-auto"
|
|
748
830
|
:class="$store.modals.cardClasses()">
|
|
749
831
|
<%# Consumer-provided content registrations. Each block typically
|
|
750
832
|
contains a <template x-if="$store.modals.current().id === 'X'">
|
|
751
833
|
render "modals/X" </template>. %>
|
|
752
834
|
<%= yield if block_given? %>
|
|
835
|
+
|
|
836
|
+
<%# APP-WIDE modal registrations — the second seam. The block above is
|
|
837
|
+
per-CALLSITE: an app rendering this host from two layouts (a live one
|
|
838
|
+
and, say, an /admin preview harness) must repeat every registration in
|
|
839
|
+
both. A modal that belongs to the APP rather than to the page defines
|
|
840
|
+
app/views/modals/_host_extras.html.erb instead and is registered ONCE,
|
|
841
|
+
for every render path through this partial.
|
|
842
|
+
|
|
843
|
+
Convention, not a local, on purpose: a local would have to be passed at
|
|
844
|
+
each call site, which is the duplication this exists to remove. An app
|
|
845
|
+
that ships no such partial renders nothing and changes nothing.
|
|
846
|
+
|
|
847
|
+
It sits INSIDE the card, beside the block, because each registration is
|
|
848
|
+
a <template x-if> keyed on $store.modals.current().id — the outer
|
|
849
|
+
template x-if above is what guarantees current() is non-null here. %>
|
|
850
|
+
<% if lookup_context.exists?("host_extras", ["modals"], true) %>
|
|
851
|
+
<%= render "modals/host_extras" %>
|
|
852
|
+
<% end %>
|
|
753
853
|
</div>
|
|
754
854
|
</div>
|
|
755
855
|
</template>
|
|
@@ -53,7 +53,30 @@
|
|
|
53
53
|
<style>
|
|
54
54
|
/* Scroll lock, applied by the store's _sync() while the stack is non-empty.
|
|
55
55
|
Shipped here too because an app with no shared host never defines it. */
|
|
56
|
-
|
|
56
|
+
/* THE SCROLL LOCK GOES ON THE ELEMENT THAT ACTUALLY SCROLLS.
|
|
57
|
+
Measured on a consumer, 2026-08-27, before this change: with a modal open a
|
|
58
|
+
real wheel gesture still scrolled the page (600px → 1000px) and the sticky
|
|
59
|
+
header unpinned and slid away with it. The lock had been inert there for as
|
|
60
|
+
long as the link sidebar had shipped.
|
|
61
|
+
|
|
62
|
+
`body { overflow: hidden }` locks the viewport only when it PROPAGATES to
|
|
63
|
+
it, and body's overflow propagates only while `html` is `overflow: visible`.
|
|
64
|
+
This gem's own link-sidebar partial sets `html { overflow-x: clip }` so the
|
|
65
|
+
off-canvas panel never flashes a scrollbar — correct on its own terms, clip
|
|
66
|
+
creates no scroll container — but it ends the propagation. The lock then
|
|
67
|
+
stopped locking the viewport and started making BODY a scroll container
|
|
68
|
+
instead: one holding scrollTop 0 forever while the viewport scrolled past
|
|
69
|
+
it. Every `position: sticky` child of body then has a scrollport that never
|
|
70
|
+
moves, which is why the header unpinned and why the modal-open banner lift
|
|
71
|
+
had nothing to pin against.
|
|
72
|
+
|
|
73
|
+
So lock `html`, and put body back to `visible` so it is not a second scroll
|
|
74
|
+
container. An app that sets no overflow on html is unaffected — locking html
|
|
75
|
+
is correct there too; it was simply never the only thing that worked.
|
|
76
|
+
|
|
77
|
+
:has() is the trigger because the class lands on body, not html. */
|
|
78
|
+
html:has(body.modal-open) { overflow: hidden; }
|
|
79
|
+
body.modal-open { overflow: visible; }
|
|
57
80
|
</style>
|
|
58
81
|
|
|
59
82
|
<script>
|
|
@@ -313,7 +336,7 @@
|
|
|
313
336
|
</script>
|
|
314
337
|
|
|
315
338
|
<template x-if="$store.<%= scoped_store %>.current()">
|
|
316
|
-
<div class="fixed inset-0 z-[
|
|
339
|
+
<div class="fixed inset-0 z-[var(--z-modal)] flex items-center justify-center p-4 modal-backdrop-mount"
|
|
317
340
|
:class="$store.<%= scoped_store %>.current()?._closing && 'modal-backdrop-unmount'"
|
|
318
341
|
style="background:rgba(0,0,0,0.6)"
|
|
319
342
|
role="dialog"
|