studio-engine 0.62.5 → 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.
@@ -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
  }
@@ -244,6 +273,223 @@
244
273
  })();
245
274
  </script>
246
275
 
276
+ <style>
277
+ /* studio-engine: --nav-p, the sticky header's COLLAPSE PROGRESS, 0 (expanded)
278
+ to 1 (collapsed). Third of the header properties published from this file,
279
+ and the only one that is an INPUT to the header rather than a measurement
280
+ of it — --nav-h and --nav-bottom above say how big it is, this says how far
281
+ through its collapse it has got.
282
+
283
+ REGISTERED, not merely declared. Untyped, `calc(3rem - 1rem * var(--nav-p))`
284
+ is invalid at computed-value time on a page that has never scrolled and
285
+ every collapsing dimension drops to its unstyled default; the <number> type
286
+ is what makes the arithmetic legal and the initial value real. Registration
287
+ also makes it INTERPOLABLE, which is how /navbar's Scrolled toggle animates
288
+ the whole collapse by transitioning one property. */
289
+ @property --nav-p {
290
+ syntax: "<number>";
291
+ inherits: true;
292
+ initial-value: 0;
293
+ }
294
+ </style>
295
+ <script>
296
+ // studio-engine: navCollapse — the scroll-linked navbar collapse.
297
+ //
298
+ // Publishes --nav-p on the <header> once per animation frame from
299
+ // window.scrollY. The navbar's own stylesheet derives every collapsing
300
+ // dimension from it with calc(), so the header moves only in a frame the
301
+ // finger moved it, and stops the instant the finger does.
302
+ //
303
+ // It replaces `@scroll.window="scrolled = scrolled ? (scrollY > 5) : (scrollY
304
+ // > 60)"` plus `transition-all duration-300`. That pair let the FINGER set a
305
+ // step and an ease curve own everything after it. Measured in turf-monster at
306
+ // 390x844 before the change: the header ran 178px -> 139px and 34 of those
307
+ // 39px of document reflow landed AFTER the scroll had stopped, over 232ms, at
308
+ // up to 3px per frame of content nobody asked to move -- plus a 1px REVERSE
309
+ // lurch in the frame the class flipped, where a discrete text-3xl -> text-xl
310
+ // swap collided with the stylesheet's own `transition: font-size`.
311
+ //
312
+ // ADOPTING IT: put `nav-shell` and `x-data="navCollapse()"` on the header,
313
+ // give each breakpoint band a `--nav-ramp`, and write the collapsing
314
+ // dimensions as calc()s off --nav-p. This file ships NO sizing opinion, so an
315
+ // app whose navbar collapses to different endpoints than the engine's adopts
316
+ // the mechanism without touching its markup.
317
+ //
318
+ // Four details are load-bearing:
319
+ //
320
+ // passive + rAF — the listener never blocks the compositor and coalesces a
321
+ // burst of scroll events (iOS momentum fires far above 60Hz) into one write
322
+ // per frame. The write lands on the HEADER, not :root: an inherited custom
323
+ // property written on :root dirties style for the whole document every
324
+ // frame, and it would leak the live page's scroll progress into the preview
325
+ // headers on /navbar.
326
+ //
327
+ // the smoothstep — collapsing a sticky, IN-FLOW header pulls the page up,
328
+ // so during the collapse content moves by the scroll AND by the shrink:
329
+ // faster than the finger, always. That is inherent; reclaiming the vertical
330
+ // space is the point. What is tunable is the shape of the burst. --nav-ramp
331
+ // is sized at 3x the band's collapse total and the ramp is smoothstepped,
332
+ // whose slope is zero at both ends, so content speed LEAVES 1x, peaks near
333
+ // 1.5x mid-ramp, and returns to 1x with no velocity step. A linear ramp
334
+ // equal to the collapse hits 2x and steps straight back to 1x.
335
+ //
336
+ // the short-page guard — collapsing shortens the document by the collapse
337
+ // total. On a page with barely more than that to scroll, the collapse
338
+ // deletes the very scroll room that triggered it, the browser clamps
339
+ // scrollY to 0, and the navbar flaps open and shut forever. roomExpanded
340
+ // adds back the shrink ALREADY applied, so the measurement cannot chase
341
+ // itself as it collapses.
342
+ //
343
+ // reduced motion — scroll-linked motion has no clock left to slow down, but
344
+ // resizing type under a moving finger is itself the motion some readers are
345
+ // asking us to drop. Under the query --nav-p snaps 0/1 on the old
346
+ // 60/5 hysteresis instead of interpolating.
347
+ window.navCollapse = function () {
348
+ return {
349
+ scrolled: false,
350
+ p: 0,
351
+ _ramp: 144,
352
+ _maxPx: 5,
353
+ _reduce: null,
354
+ _onScroll: null,
355
+ _onResize: null,
356
+ _onReduce: null,
357
+ init: function () {
358
+ var self = this;
359
+ var el = this.$el;
360
+ var queued = false;
361
+
362
+ this._reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
363
+
364
+ function readRamp() {
365
+ var style = getComputedStyle(el);
366
+ var raw = parseFloat(style.getPropertyValue('--nav-ramp'));
367
+ self._ramp = raw > 0 ? raw : 144;
368
+ var step = parseFloat(style.getPropertyValue('--nav-max-step'));
369
+ self._maxPx = step > 0 ? step : 5;
370
+ }
371
+
372
+ function apply() {
373
+ queued = false;
374
+ var ramp = self._ramp;
375
+ // Clamped: rubber-band overscroll reports a NEGATIVE scrollY, and a
376
+ // negative progress inflates the navbar past its expanded size.
377
+ var y = Math.max(0, window.scrollY);
378
+
379
+ // The height the document WOULD have with the navbar expanded. The
380
+ // add-back is the whole trick — see the guard note above.
381
+ var roomExpanded = document.documentElement.scrollHeight
382
+ - window.innerHeight
383
+ + ramp * self.p;
384
+
385
+ // WHERE THE COLLAPSE WANTS TO BE, from scroll position alone.
386
+ var target;
387
+ var snap = false;
388
+ if (roomExpanded < ramp + 24) {
389
+ target = 0;
390
+ snap = true;
391
+ } else if (self._reduce.matches) {
392
+ target = (self.p > 0 ? y > 5 : y > 60) ? 1 : 0;
393
+ snap = true;
394
+ } else {
395
+ var t = Math.min(1, y / ramp);
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);
440
+ }
441
+
442
+ if (p !== self.p) {
443
+ self.p = p;
444
+ el.style.setProperty('--nav-p', p.toFixed(4));
445
+ }
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
+
458
+ // The shadow is the one thing still on a clock, and it may stay
459
+ // there: box-shadow paints, it never reflows, so it cannot move
460
+ // content. Hysteresis keeps it from strobing at the boundary.
461
+ var lit = self.scrolled ? y > 5 : y > 60;
462
+ if (lit !== self.scrolled) self.scrolled = lit;
463
+ }
464
+
465
+ this._onScroll = function () {
466
+ if (queued) return;
467
+ queued = true;
468
+ requestAnimationFrame(apply);
469
+ };
470
+ this._onResize = function () { readRamp(); self._onScroll(); };
471
+ this._onReduce = apply;
472
+
473
+ readRamp();
474
+ apply();
475
+
476
+ window.addEventListener('scroll', this._onScroll, { passive: true });
477
+ window.addEventListener('resize', this._onResize, { passive: true });
478
+ if (this._reduce.addEventListener) this._reduce.addEventListener('change', this._onReduce);
479
+ },
480
+ destroy: function () {
481
+ // A Turbo visit tears the header down and builds a new one; without
482
+ // this every visit would stack another listener on window.
483
+ if (this._onScroll) window.removeEventListener('scroll', this._onScroll);
484
+ if (this._onResize) window.removeEventListener('resize', this._onResize);
485
+ if (this._reduce && this._reduce.removeEventListener && this._onReduce) {
486
+ this._reduce.removeEventListener('change', this._onReduce);
487
+ }
488
+ }
489
+ };
490
+ };
491
+ </script>
492
+
247
493
  <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
248
494
  <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
249
495
  <% if Studio.sticky_table_headers %>
@@ -7,7 +7,11 @@
7
7
  border: 2px solid var(--color-border-strong);
8
8
  border-radius: 0.5rem;
9
9
  pointer-events: none;
10
- transition: width 0.15s ease;
10
+ /* --nav-p is a registered <number>, so the Scrolled toggle can TRANSITION
11
+ it directly — one interpolating property in place of the five
12
+ per-element font-size/width/padding transitions this block used to
13
+ carry. */
14
+ transition: width 0.15s ease, --nav-p 0.3s ease;
11
15
  }
12
16
  .navbar-preview header { position: static !important; }
13
17
 
@@ -18,19 +22,32 @@
18
22
  .navbar-preview.is-mobile .user-nav-col, .navbar-preview.is-mobile .user-nav-fit { padding-left: 0 !important; padding-right: 1rem !important; }
19
23
  .navbar-preview.is-mobile .nav-title { flex-direction: column !important; gap: 0 !important; line-height: 1.15 !important; }
20
24
  .navbar-preview.is-mobile .nav-title span:first-child { margin-bottom: -4px !important; }
21
- .navbar-preview.is-mobile .nav-title span:last-child { font-size: 1.5rem !important; }
22
25
  .navbar-preview.is-mobile .nav-logo-link { gap: 0.5rem !important; }
23
26
 
27
+ /* Breakpoint simulation for the --nav-p sizes. The live navbar picks these
28
+ up from media queries in layouts/_navbar, which cannot fire for a
29
+ CONTAINER at a fixed viewport width — so the same calc()s are re-keyed on
30
+ the wrapper. They are the same expressions, not a second set of numbers:
31
+ retune the collapse and retune both, or the preview starts lying. */
32
+ .navbar-preview.is-mobile .nav-shell {
33
+ --nav-logo-size: calc(3rem - 0.5rem * var(--nav-p));
34
+ }
35
+ .navbar-preview.bp-tiny .nav-shell {
36
+ --nav-title-size: calc(1.1rem - 0.2rem * var(--nav-p));
37
+ --nav-title-lead-size: calc(1.3rem - 0.3rem * var(--nav-p));
38
+ }
39
+ .navbar-preview.bp-small .nav-shell {
40
+ --nav-title-size: calc(1.25rem - 0.25rem * var(--nav-p));
41
+ --nav-title-lead-size: calc(1.5rem - 0.35rem * var(--nav-p));
42
+ }
43
+
24
44
  /* Tiny (< 400px) */
25
- .navbar-preview.bp-tiny .nav-title { font-size: 1.1rem !important; }
26
- .navbar-preview.bp-tiny .nav-title span:last-child { font-size: 1.3rem !important; }
27
45
  .navbar-preview.bp-tiny .nav-logo-link { gap: 0.25rem !important; }
28
46
  .navbar-preview.bp-tiny .user-nav-col { width: 14rem !important; }
29
47
  .navbar-preview.bp-tiny .user-nav-fit { max-width: 14rem !important; }
30
48
  .navbar-preview.bp-tiny .username-cap { max-width: 5rem !important; }
31
49
 
32
50
  /* Small (400-767px) */
33
- .navbar-preview.bp-small .nav-title { font-size: 1.25rem !important; }
34
51
  .navbar-preview.bp-small .user-nav-col { width: 15rem !important; }
35
52
  .navbar-preview.bp-small .user-nav-fit { max-width: 15rem !important; }
36
53
  .navbar-preview.bp-small .username-cap { max-width: 6rem !important; }
@@ -40,27 +57,14 @@
40
57
  .navbar-preview.is-desktop .user-nav-fit { max-width: 20rem !important; }
41
58
  .navbar-preview.is-desktop .username-cap { max-width: 7rem !important; }
42
59
 
43
- /* Transitions for scrolled toggle */
44
- .navbar-preview .nav-logo { transition: width 0.3s, height 0.3s; }
45
- .navbar-preview .nav-title,
46
- .navbar-preview .nav-title span:last-child { transition: font-size 0.3s; }
47
- .navbar-preview .py-6 { transition: padding 0.3s; }
60
+ /* Scrolled state. The size half of this used to be TWELVE !important rules
61
+ restating every collapsed value; the toggle now just sets --nav-p: 1 on
62
+ the wrapper (below) and the shipped calc()s do the rest, so the preview
63
+ exercises the real rules instead of a parallel copy of them. What is left
64
+ is the shadow, which the live header gets from .is-scrolled and a preview
65
+ header (no x-data, so no `scrolled`) cannot. */
48
66
  .navbar-preview header { transition: box-shadow 0.3s, border-color 0.3s; }
49
- .navbar-preview [data-balance-display] { transition: font-size 0.3s; }
50
-
51
- /* Scrolled state: base (all breakpoints) */
52
67
  .navbar-preview.is-scrolled-preview header { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); border-bottom: 1px solid var(--color-border-subtle); }
53
- .navbar-preview.is-scrolled-preview .py-6 { padding-top: 0.5rem !important; padding-bottom: 0.5rem !important; }
54
- .navbar-preview.is-scrolled-preview .nav-logo { width: 2rem !important; height: 2rem !important; }
55
- .navbar-preview.is-scrolled-preview .nav-title { font-size: 1.25rem !important; }
56
- .navbar-preview.is-scrolled-preview [data-balance-display] { font-size: 1.125rem !important; }
57
-
58
- /* Scrolled state: mobile overrides */
59
- .navbar-preview.is-mobile.is-scrolled-preview .nav-logo { width: 2.5rem !important; height: 2.5rem !important; }
60
- .navbar-preview.bp-tiny.is-scrolled-preview .nav-title { font-size: 0.9rem !important; }
61
- .navbar-preview.bp-tiny.is-scrolled-preview .nav-title span:last-child { font-size: 1rem !important; }
62
- .navbar-preview.bp-small.is-scrolled-preview .nav-title { font-size: 1rem !important; }
63
- .navbar-preview.bp-small.is-scrolled-preview .nav-title span:last-child { font-size: 1.15rem !important; }
64
68
 
65
69
  /* Slider with device marker */
66
70
  .slider-wrap { position: relative; }
@@ -139,7 +143,7 @@
139
143
  Scrolled
140
144
  </button>
141
145
  </div>
142
- <div class="navbar-preview <%= bp[:classes] %>" :class="scrolled && 'is-scrolled-preview'" :style="'width: ' + w + 'px'">
146
+ <div class="navbar-preview <%= bp[:classes] %>" :class="scrolled && 'is-scrolled-preview'" :style="'width: ' + w + 'px; --nav-p: ' + (scrolled ? 1 : 0)">
143
147
  <%= render "layouts/navbar", preview: true, show_logged_in: section[:show_logged_in] %>
144
148
  </div>
145
149
  </div>
@@ -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 %>
@@ -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
- page beneath any modal from scrolling on wheel / touch / spacebar. */
23
- body.modal-open { overflow: hidden; }
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-[120] flex items-center justify-center p-4 modal-backdrop-mount"
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
- <div class="bg-surface rounded-xl border border-subtle shadow-2xl p-6 max-w-sm w-full max-h-[85dvh] overflow-y-auto"
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
- body.modal-open { overflow: hidden; }
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-[120] flex items-center justify-center p-4 modal-backdrop-mount"
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"