studio-engine 0.90.0 → 0.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c11c676d845c483117f043e7925f84cc0d7128dfa170740dd3d3c1c484438259
4
- data.tar.gz: a873622a9676a51d1864612008e09a4e7f8822a371e275b5e0586ea9b252ad9e
3
+ metadata.gz: 0cf7b3c1fa31a2eb5f9746838c8c4976d026920c986504c674f760c57c73a33d
4
+ data.tar.gz: c39625c99d384e68eedfee1a31dde4816e6f3aa2f004f6ab2f88c4e10d447b00
5
5
  SHA512:
6
- metadata.gz: eb5630d5317933c839f352af196f567be745f13c53ba90c503f373ae0869f2084ce0bff41a47489056a85cf36731dd39974f30f7ab553b9f6b43d920f353a6a4
7
- data.tar.gz: a4651700a47b3700f99ed6c00f9e56d5e9682b8771843d4156ca1566b66844a03e1d7a846889f3d5283bf301d537e301bc59284149f0ac97a9f0ad8e6fcbed7c
6
+ metadata.gz: bfeb555efeae9e981014cf5afb73c8e1eb7b01c90d92dc0c1ba6838d5c1acc950ddc908846be93d15d23ac07a9f5acf277c103c3d95b4611e85b758de5c940f0
7
+ data.tar.gz: 728932ef4510055ab9b8cd3d6f472abe228ee632febfe007251beb242aec114a1e11c77381a63e44167fb7817c79d8d6716d24687e4261b4c660968e23ffef8c
data/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.91.0 — 2026-10-06
8
+
9
+ ### Added
10
+
11
+ - **`fizz_portal: true` on `studio/hold_button`** lifts the bubbles out of a card
12
+ that clips them: the layers move to `<body>` in a fixed box that tracks the
13
+ button through scroll and resize, paints one rung above the button's highest
14
+ ancestor z-index, and mirrors its state and palette. The default is off, so
15
+ every existing caller renders as before. The style guide's live hold button
16
+ uses it.
17
+
7
18
  ## 0.90.0 — 2026-10-06
8
19
 
9
20
  ### Removed
@@ -1315,6 +1315,8 @@ body.modal-open { overflow: visible; }
1315
1315
  span.hold-stack positioning + z-order context
1316
1316
  span.hold-fizz resting bubble layer (behind the button)
1317
1317
  span.hold-fizz.hold-fizz-extra hover-only layer (lively level only)
1318
+ span.hold-fizz-portal wraps both layers with fizz_portal: true, and
1319
+ moves to <body> (see THE PORTAL below)
1318
1320
  button.hold-btn the button itself, painted OVER the bubbles
1319
1321
  div.hold-icon progress ring + tick
1320
1322
  ul.hold-text four sliding labels: idle/hold/done/blocked
@@ -1731,14 +1733,17 @@ body.modal-open { overflow: visible; }
1731
1733
  z-index: 1;
1732
1734
  }
1733
1735
 
1734
- .hold-stack > .hold-fizz {
1736
+ .hold-stack > .hold-fizz,
1737
+ .hold-stack > .hold-fizz-portal,
1738
+ .hold-fizz-portal > .hold-fizz {
1735
1739
  position: absolute;
1736
1740
  inset: 0;
1737
1741
  z-index: 0;
1738
1742
  pointer-events: none;
1739
1743
  }
1740
1744
 
1741
- .hold-stack .fizz-bit {
1745
+ .hold-stack .fizz-bit,
1746
+ .hold-fizz-portal .fizz-bit {
1742
1747
  position: absolute;
1743
1748
  width: var(--fs, 4px);
1744
1749
  height: var(--fs, 4px);
@@ -1760,17 +1765,20 @@ body.modal-open { overflow: visible; }
1760
1765
  /* Hover and the nudge shake both mean "look at me" — boil for the moment.
1761
1766
  Calm only: lively rests at this boil already and answers hover its own way,
1762
1767
  below, so it must not pick this rule up (it outranks lively's rest rule). */
1763
- .hold-stack:not(.fizz-lively):has(> .hold-btn:hover:not(.process):not(.loading):not(.success):not(.error)) .fizz-bit {
1768
+ .hold-stack:not(.fizz-lively):has(> .hold-btn:hover:not(.process):not(.loading):not(.success):not(.error)) .fizz-bit,
1769
+ .hold-fizz-portal:not(.fizz-lively).is-hover:not(.is-process):not(.is-loading):not(.is-success):not(.is-error) .fizz-bit {
1764
1770
  animation: fizz-boil calc(var(--ft, 1.8s) * 0.62) linear calc(var(--fd, 0s) * 0.35) infinite;
1765
1771
  }
1766
1772
 
1767
- .hold-stack.fizz-lively .fizz-bit {
1773
+ .hold-stack.fizz-lively .fizz-bit,
1774
+ .hold-fizz-portal.fizz-lively .fizz-bit {
1768
1775
  animation: fizz-boil calc(var(--ft, 1.8s) * 0.62) linear calc(var(--fd, 0s) * 0.35) infinite;
1769
1776
  }
1770
1777
 
1771
1778
  /* The hover layer keeps running while hidden. It has to: killing the animation
1772
1779
  would snap the bubbles to their base opacity: 0 and leave nothing to fade. */
1773
- .hold-stack > .hold-fizz-extra {
1780
+ .hold-stack > .hold-fizz-extra,
1781
+ .hold-fizz-portal > .hold-fizz-extra {
1774
1782
  opacity: 0;
1775
1783
  transition: opacity 0.45s ease;
1776
1784
  }
@@ -1778,29 +1786,69 @@ body.modal-open { overflow: visible; }
1778
1786
  /* The guard list is as much the point as the hover: a held, submitting,
1779
1787
  confirmed or blocked button speaks for itself, so the hover crowd fades away
1780
1788
  and leaves the ring and the burst to read cleanly. */
1781
- .hold-stack.fizz-lively:has(> .hold-btn:hover:not(.process):not(.loading):not(.success):not(.error)) > .hold-fizz-extra {
1789
+ .hold-stack.fizz-lively:has(> .hold-btn:hover:not(.process):not(.loading):not(.success):not(.error)) > .hold-fizz-extra,
1790
+ .hold-fizz-portal.fizz-lively.is-hover:not(.is-process):not(.is-loading):not(.is-success):not(.is-error) > .hold-fizz-extra {
1782
1791
  opacity: 1;
1783
1792
  }
1784
1793
 
1785
1794
  .hold-stack:has(> .hold-btn.nudge) .fizz-bit,
1786
- .hold-stack:has(> .hold-btn.nudge-soft) .fizz-bit {
1795
+ .hold-stack:has(> .hold-btn.nudge-soft) .fizz-bit,
1796
+ .hold-fizz-portal.is-nudge .fizz-bit,
1797
+ .hold-fizz-portal.is-nudge-soft .fizz-bit {
1787
1798
  animation: fizz-boil calc(var(--ft, 1.8s) * 0.55) linear calc(var(--fd, 0s) * 0.25) infinite;
1788
1799
  }
1789
1800
 
1790
1801
  .hold-stack:has(> .hold-btn.process) .fizz-bit,
1791
- .hold-stack:has(> .hold-btn.loading) .fizz-bit {
1802
+ .hold-stack:has(> .hold-btn.loading) .fizz-bit,
1803
+ .hold-fizz-portal.is-process .fizz-bit,
1804
+ .hold-fizz-portal.is-loading .fizz-bit {
1792
1805
  animation: fizz-boil calc(var(--ft, 1.8s) * 0.45) linear calc(var(--fd, 0s) * 0.2) infinite;
1793
1806
  }
1794
1807
 
1795
- .hold-stack:has(> .hold-btn.success) .fizz-bit {
1808
+ .hold-stack:has(> .hold-btn.success) .fizz-bit,
1809
+ .hold-fizz-portal.is-success .fizz-bit {
1796
1810
  animation: fizz-burst 1.1s cubic-bezier(0.12, 0.72, 0.24, 1) forwards;
1797
1811
  }
1798
1812
 
1799
- .hold-stack:has(> .hold-btn.error) .fizz-bit {
1813
+ .hold-stack:has(> .hold-btn.error) .fizz-bit,
1814
+ .hold-fizz-portal.is-error .fizz-bit {
1800
1815
  animation: none;
1801
1816
  opacity: 0;
1802
1817
  }
1803
1818
 
1819
+ /* THE PORTAL (studio/hold_button, fizz_portal: true). A button inside an
1820
+ overflow-hidden card has its bubbles cut off at the card's edge, because the
1821
+ layer is absolutely positioned inside the card's clip. With the option set,
1822
+ the button's script moves the layers out of the stack into one fixed box on
1823
+ <body> that tracks the stack's rect, so the bubbles rise past the card's edge
1824
+ instead.
1825
+
1826
+ Out of the stack, the state rules above cannot read the button through
1827
+ :has(), so the script mirrors its state onto the box as .is-process,
1828
+ .is-loading, .is-success, .is-error, .is-nudge, .is-nudge-soft and .is-hover,
1829
+ and copies the stack's --fizz-c-* palette onto it.
1830
+
1831
+ It paints one rung above the highest z-index among the button's ancestors
1832
+ (never below --z-raised), which the script sets inline. A page's own sticky
1833
+ or fixed bar must therefore sit ABOVE --z-raised (--z-sticky is the rung for
1834
+ one); a bar at z 20 or lower, such as Tailwind's sticky z-10 or z-20, ties or
1835
+ loses to the box, and the bubbles paint over it. Painting above the
1836
+ button would put bubbles on its face, so the clip path cuts the button's own
1837
+ rect out of the box: the bubbles show only outside it, as they do in the
1838
+ stack. The 50vmax margin is the room they have to rise. */
1839
+ .hold-fizz-portal.is-portaled {
1840
+ position: fixed;
1841
+ top: 0;
1842
+ left: 0;
1843
+ z-index: var(--z-raised, 20);
1844
+ pointer-events: none;
1845
+ clip-path: polygon(evenodd,
1846
+ -50vmax -50vmax, calc(100% + 50vmax) -50vmax,
1847
+ calc(100% + 50vmax) calc(100% + 50vmax), -50vmax calc(100% + 50vmax),
1848
+ -50vmax -50vmax,
1849
+ 0 0, 100% 0, 100% 100%, 0 100%, 0 0);
1850
+ }
1851
+
1804
1852
  @keyframes hold-tick {
1805
1853
  100% { transform: rotate(-90deg) translate(0, -5px) scale(var(--progress-scale)); }
1806
1854
  }
@@ -1867,7 +1915,9 @@ body.modal-open { overflow: visible; }
1867
1915
  it not to. */
1868
1916
  @media (prefers-reduced-motion: reduce) {
1869
1917
  .hold-stack .fizz-bit,
1870
- .hold-stack .hold-fizz-extra {
1918
+ .hold-stack .hold-fizz-extra,
1919
+ .hold-fizz-portal .fizz-bit,
1920
+ .hold-fizz-portal .hold-fizz-extra {
1871
1921
  animation: none !important;
1872
1922
  opacity: 0 !important;
1873
1923
  transition: none !important;
@@ -39,6 +39,13 @@
39
39
  layer, for a button that should not compete.
40
40
  fizz_colors - Up to 12 CSS colors the bubbles wear, as a static
41
41
  array (default: none = the built-in candy palette).
42
+ fizz_portal - Lift the bubbles out of the stack into a fixed layer on
43
+ <body> that tracks the button (default: false). For a
44
+ button inside an overflow-hidden card, whose bubbles
45
+ are otherwise cut off at the card's edge: with it set
46
+ they rise past the edge. The button, its timers and its
47
+ states are the same either way. See THE PORTAL in
48
+ engine-motion.css.
42
49
  fizz_bind - Alpine expression bound to the stack's :style, for a
43
50
  palette that changes at runtime (default: none). The
44
51
  turf-monster's board passes "fizzPalette", mapping
@@ -65,6 +72,7 @@
65
72
  <% fizz_colors ||= nil %>
66
73
  <% fizz_bind ||= nil %>
67
74
  <% fizz_level ||= :lively %>
75
+ <% fizz_portal = fizz && local_assigns[:fizz_portal] %>
68
76
  <%# Static palette (gallery, or any call site with fixed colors). A runtime
69
77
  palette rides fizz_bind instead. %>
70
78
  <% fizz_style = Array(fizz_colors).first(Studio::FizzHelper::SLOTS)
@@ -75,11 +83,17 @@
75
83
  transform makes it a stacking context), so the bubbles have to be a
76
84
  sibling the button paints over — they only show where they escape its
77
85
  edges, like carbonation coming off the sides. %>
78
- <span class="hold-stack<%= " fizz-lively" if fizz_level.to_s == "lively" %>"<% if fizz_bind %> :style="<%= fizz_bind %>"<% end %><% if fizz_style.present? %> style="<%= fizz_style %>"<% end %>>
86
+ <span class="hold-stack<%= " fizz-lively" if fizz_level.to_s == "lively" %>"<% if fizz_bind %> :style="<%= fizz_bind %>"<% end %><% if fizz_style.present? %> style="<%= fizz_style %>"<% end %><% if fizz_portal %> data-fizz-portal x-init="window.studioFizzPortal && window.studioFizzPortal.mount($el)"<% end %>>
79
87
  <%# Fizz layer — bubbles that simmer at rest, boil while the button is held,
80
88
  and burst outward on success. Purely decorative, so it is aria-hidden and
81
89
  pointer-events:none; the table behind it is seeded per hold_id (see Studio::FizzHelper). %>
82
90
  <% if fizz %>
91
+ <%# With fizz_portal the layers ride one wrapper, which the script below
92
+ moves to <body>. Until it does, the wrapper sits in the stack exactly
93
+ where the layers would. %>
94
+ <% if fizz_portal %>
95
+ <span class="hold-fizz-portal<%= " fizz-lively" if fizz_level.to_s == "lively" %>" aria-hidden="true">
96
+ <% end %>
83
97
  <%= render "studio/fizz_layer", seed: hold_id %>
84
98
  <%# Lively's hover doubles the COUNT, not the speed: a second scatter that
85
99
  rests invisible, fades in over the gaps in the first, and fades back
@@ -87,6 +101,9 @@
87
101
  <% if fizz_level.to_s == "lively" %>
88
102
  <%= render "studio/fizz_layer", seed: "#{hold_id}~extra", extra: true %>
89
103
  <% end %>
104
+ <% if fizz_portal %>
105
+ </span>
106
+ <% end %>
90
107
  <% end %>
91
108
  <button class="hold-btn"
92
109
  data-hold-id="<%= hold_id %>"
@@ -236,6 +253,151 @@
236
253
  Object.keys(nudgeTimers).forEach(function(id) { stopNudgeCycle(id); });
237
254
  });
238
255
 
256
+ // ── The fizz portal (fizz_portal: true) ─────────────────────────────
257
+ // Moves a stack's bubble layers to the document body in one fixed box that tracks the
258
+ // stack's rect, so a card's overflow clip cannot cut them off. The box
259
+ // mirrors the button's state classes and the stack's palette, because the
260
+ // CSS can no longer read either through the stack. engine-motion.css,
261
+ // THE PORTAL, holds the other half.
262
+ var PORTAL_STATES = ['process', 'loading', 'success', 'error', 'nudge', 'nudge-soft'];
263
+ var portals = [];
264
+
265
+ // One rung above the highest z-index among the stack's ancestors, so the
266
+ // bubbles clear the card, the panel or the modal the button sits in,
267
+ // and never below --z-raised.
268
+ function portalZ(stack) {
269
+ var floor = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--z-raised'), 10);
270
+ var top = isNaN(floor) ? 20 : floor;
271
+ for (var n = stack.parentElement; n && n !== document.body; n = n.parentElement) {
272
+ var z = parseInt(getComputedStyle(n).zIndex, 10);
273
+ if (!isNaN(z) && z + 1 > top) top = z + 1;
274
+ }
275
+ return top;
276
+ }
277
+
278
+ function syncPortalState(p) {
279
+ PORTAL_STATES.forEach(function(s) {
280
+ p.box.classList.toggle('is-' + s, p.button.classList.contains(s));
281
+ });
282
+ }
283
+
284
+ function syncPortalPalette(p) {
285
+ var cs = getComputedStyle(p.stack);
286
+ for (var i = 1; i <= <%= Studio::FizzHelper::SLOTS %>; i++) {
287
+ var v = cs.getPropertyValue('--fizz-c-' + i).trim();
288
+ if (v) p.box.style.setProperty('--fizz-c-' + i, v);
289
+ else p.box.style.removeProperty('--fizz-c-' + i);
290
+ }
291
+ }
292
+
293
+ function placePortal(p) {
294
+ if (!p.stack.isConnected) { unmountPortal(p, false); return; }
295
+ var r = p.stack.getBoundingClientRect();
296
+ var shown = p.visible && r.width > 0 && r.height > 0;
297
+ p.box.style.visibility = shown ? '' : 'hidden';
298
+ if (shown) {
299
+ p.box.style.transform = 'translate(' + r.left + 'px, ' + r.top + 'px)';
300
+ p.box.style.width = r.width + 'px';
301
+ p.box.style.height = r.height + 'px';
302
+ if (p.parked) { clearInterval(p.parked); p.parked = null; }
303
+ p.frame = requestAnimationFrame(function() { placePortal(p); });
304
+ } else {
305
+ p.frame = null;
306
+ // Parked: no frames run, so nothing would notice the stack leaving the
307
+ // DOM (an x-if, a Turbo stream, a closed modal) until the next page
308
+ // load. A slow check tears the box and its observers down when it does.
309
+ if (!p.parked) {
310
+ p.parked = setInterval(function() {
311
+ if (!p.stack.isConnected) unmountPortal(p, false);
312
+ }, 1000);
313
+ }
314
+ }
315
+ }
316
+
317
+ function unmountPortal(p, restore) {
318
+ if (p.frame) cancelAnimationFrame(p.frame);
319
+ if (p.parked) clearInterval(p.parked);
320
+ p.observers.forEach(function(o) { o.disconnect(); });
321
+ p.button.removeEventListener('mouseenter', p.enter);
322
+ p.button.removeEventListener('mouseleave', p.leave);
323
+ if (restore && p.stack.isConnected) {
324
+ // Back where the server rendered it, with nothing the script added,
325
+ // so a cached snapshot restores to the markup it came from.
326
+ p.box.className = p.box.className.replace(/\s*\bis-[\w-]+/g, '');
327
+ p.box.removeAttribute('style');
328
+ p.box.removeAttribute('data-fizz-portal-for');
329
+ p.stack.insertBefore(p.box, p.stack.firstChild);
330
+ } else {
331
+ p.box.remove();
332
+ }
333
+ delete p.stack._fizzPortal;
334
+ portals = portals.filter(function(q) { return q !== p; });
335
+ }
336
+
337
+ function mountPortal(stack) {
338
+ if (stack._fizzPortal) return stack._fizzPortal;
339
+ var box = stack.querySelector(':scope > .hold-fizz-portal');
340
+ var button = stack.querySelector(':scope > .hold-btn');
341
+ if (!box || !button) return null;
342
+
343
+ var p = { stack: stack, box: box, button: button, visible: true, frame: null, parked: null, observers: [] };
344
+ stack._fizzPortal = p;
345
+ portals.push(p);
346
+
347
+ document.body.appendChild(box);
348
+ box.classList.add('is-portaled');
349
+ box.setAttribute('data-fizz-portal-for', button.dataset.holdId || 'hold');
350
+ box.style.zIndex = portalZ(stack);
351
+ syncPortalState(p);
352
+ syncPortalPalette(p);
353
+
354
+ p.enter = function() { box.classList.add('is-hover'); };
355
+ p.leave = function() { box.classList.remove('is-hover'); };
356
+ button.addEventListener('mouseenter', p.enter);
357
+ button.addEventListener('mouseleave', p.leave);
358
+
359
+ var states = new MutationObserver(function() { syncPortalState(p); });
360
+ states.observe(button, { attributes: true, attributeFilter: ['class'] });
361
+ var palette = new MutationObserver(function() { syncPortalPalette(p); });
362
+ palette.observe(stack, { attributes: true, attributeFilter: ['style'] });
363
+ p.observers.push(states, palette);
364
+
365
+ // Track the rect every frame while the stack is on screen: a visible
366
+ // portal reads one rect per frame. Park the box while the stack is not
367
+ // on screen (scrolled away, inside a closed panel, or clipped out of a
368
+ // scroll container): no frames, only a once-a-second check that the
369
+ // stack is still in the DOM.
370
+ if ('IntersectionObserver' in window) {
371
+ var seen = new IntersectionObserver(function(entries) {
372
+ p.visible = entries[entries.length - 1].isIntersecting;
373
+ if (!p.stack.isConnected) { unmountPortal(p, false); return; }
374
+ if (p.visible && !p.frame) placePortal(p);
375
+ if (!p.visible) placePortal(p);
376
+ });
377
+ seen.observe(stack);
378
+ p.observers.push(seen);
379
+ }
380
+ placePortal(p);
381
+ return p;
382
+ }
383
+
384
+ function mountAllPortals() {
385
+ portals.slice().forEach(function(p) { if (!p.stack.isConnected) unmountPortal(p, false); });
386
+ document.querySelectorAll('.hold-stack[data-fizz-portal]').forEach(mountPortal);
387
+ }
388
+
389
+ window.studioFizzPortal = {
390
+ mount: mountPortal,
391
+ mountAll: mountAllPortals,
392
+ count: function() { return portals.length; }
393
+ };
394
+ document.addEventListener('turbo:load', mountAllPortals);
395
+ document.addEventListener('turbo:before-cache', function() {
396
+ portals.slice().forEach(function(p) { unmountPortal(p, true); });
397
+ });
398
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountAllPortals);
399
+ else mountAllPortals();
400
+
239
401
  window.holdBtnStart = function(el) {
240
402
  var holdId = el.dataset.holdId || 'hold';
241
403
  var scope = el.closest('[x-data]');
@@ -344,6 +344,8 @@
344
344
  brand — and every color also reads a <code class="font-mono text-2xs">--hold-*</code>
345
345
  input, so one instance can be re-themed from any ancestor.
346
346
  <code class="font-mono text-2xs">prefers-reduced-motion</code> switches the bubbles off.
347
+ Inside a card that clips, pass <code class="font-mono text-2xs">fizz_portal: true</code>
348
+ and the bubbles rise past the card&rsquo;s edge instead of stopping at it.
347
349
  </p>
348
350
  </div>
349
351
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -360,12 +362,14 @@
360
362
  </div>
361
363
  <% end %>
362
364
 
363
- <%# 2. The button itself, live: press and hold for two seconds. %>
365
+ <%# 2. The button itself, live: press and hold for two seconds. The card
366
+ clips, so this one sets fizz_portal and its bubbles rise past the
367
+ card's edge. %>
364
368
  <%= render layout: "style/specimen",
365
369
  locals: { klass: ".hold-btn (studio/hold_button)",
366
- usage: %(render "studio/hold_button", hold_id: "confirm", duration: 2000) } do %>
370
+ usage: %(render "studio/hold_button", hold_id: "confirm", duration: 2000, fizz_portal: true) } do %>
367
371
  <div class="w-full max-w-xs" x-data="{ submitting: false }">
368
- <%= render "studio/hold_button", hold_id: "style-hold", duration: 2000,
372
+ <%= render "studio/hold_button", hold_id: "style-hold", duration: 2000, fizz_portal: true,
369
373
  default_text: "Hold to Confirm", hold_text: "Almost there…", success_text: "Confirmed!" %>
370
374
  <button type="button" class="btn btn-neutral btn-sm mt-3 mx-auto block"
371
375
  @click="document.querySelector('.hold-btn[data-hold-id=style-hold]').classList.remove('process','success','error','loading')">Reset</button>
@@ -65,9 +65,11 @@
65
65
 
66
66
  <%# Section nav — sticky under the host navbar (--nav-h, 0 fallback). A pill per
67
67
  section; the row scrolls horizontally on narrow screens, its edges softened
68
- by the .fade-edge-x primitive. Order leads with Theme (the color foundation). %>
69
- <nav class="sticky z-20 -mx-4 px-4 py-3 bg-surface border-y border-subtle"
70
- style="top: var(--nav-h, 0px)" aria-label="Style guide sections">
68
+ by the .fade-edge-x primitive. Order leads with Theme (the color foundation).
69
+ It sits at --z-sticky, above --z-raised, so the live hold button's portal
70
+ bubbles scroll under it rather than over it. %>
71
+ <nav class="sticky -mx-4 px-4 py-3 bg-surface border-y border-subtle"
72
+ style="top: var(--nav-h, 0px); z-index: var(--z-sticky, 30)" aria-label="Style guide sections">
71
73
  <div class="fade-edge-x overflow-x-auto">
72
74
  <div class="flex items-center gap-2 min-w-max">
73
75
  <% sections.each do |anchor, label| %>
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.90.0"
2
+ VERSION = "0.91.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.90.0
4
+ version: 0.91.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie