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.
@@ -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.
@@ -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"