studio-engine 0.90.0 → 0.91.1

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: b177cff9fc53b20d566685aa744f1dc41f481017f3014c9a00ef71a43620365a
4
+ data.tar.gz: 100753f4d2f9dd2f936c8285a2b3aac844d2538099b066ef15013ca77f407392
5
5
  SHA512:
6
- metadata.gz: eb5630d5317933c839f352af196f567be745f13c53ba90c503f373ae0869f2084ce0bff41a47489056a85cf36731dd39974f30f7ab553b9f6b43d920f353a6a4
7
- data.tar.gz: a4651700a47b3700f99ed6c00f9e56d5e9682b8771843d4156ca1566b66844a03e1d7a846889f3d5283bf301d537e301bc59284149f0ac97a9f0ad8e6fcbed7c
6
+ metadata.gz: 76433dc917873ff167918968f9ec85d1d6765606e58f3c6502183cab9fca55b3b19a4bc74652f93c74222c3799e4cedbc411a7ce79c6d4d8ff207524982dca20
7
+ data.tar.gz: 3c69e3e3ed94f63952f9cbf2c2e75446b3d95a7d63c51e5b7331dce801bc5ae5329761b456f6cd968e276d6874daf453dcce551c20b87960c49a604eda81d80e
data/CHANGELOG.md CHANGED
@@ -4,6 +4,45 @@ 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.1 — 2026-10-06
8
+
9
+ ### Fixed
10
+
11
+ - **`POST /login` is drawn only for apps that declare `:password`.** A passwordless
12
+ app answered it 500 for a member's address and 422 for a stranger's, so the
13
+ answer revealed who is a member; it now answers 404 for every address. `GET
14
+ /login` and `login_path` are unchanged. An app that still wants a `POST /login`
15
+ of its own draws it before `Studio.routes(self)`.
16
+
17
+ - **SSO sign-up works on passwordless apps.** "Continue as …" (`POST
18
+ /sso_continue`) created a first-time visitor with `password:
19
+ SecureRandom.hex(16)`, which raised `UnknownAttributeError` on any User without
20
+ `has_secure_password`; the engine logged it and bounced to the login page. The
21
+ engine now sets a random password only when the User answers `password=`.
22
+ - **The survey keeps its h1 for a screen reader on every step.** From question 2
23
+ on, the stepper set `hidden` on the survey head, which took the page's only
24
+ h1 out of the accessibility tree. The head now steps into a 1px visual clip
25
+ instead, holding only the title.
26
+
27
+ ### Changed
28
+
29
+ - **The survey partials declare strict locals.** `studio/surveys/_question`
30
+ takes `question:`, `index:`, `total:`, `response:` and an optional `error:`;
31
+ `_script` and `_styles` take none. A render with an unknown or missing local
32
+ now raises instead of painting a blank. The survey accent's fallback reads
33
+ `Studio::ThemeResolver::DEFAULT_PRIMARY` instead of a copied hex.
34
+
35
+ ## 0.91.0 — 2026-10-06
36
+
37
+ ### Added
38
+
39
+ - **`fizz_portal: true` on `studio/hold_button`** lifts the bubbles out of a card
40
+ that clips them: the layers move to `<body>` in a fixed box that tracks the
41
+ button through scroll and resize, paints one rung above the button's highest
42
+ ancestor z-index, and mirrors its state and palette. The default is off, so
43
+ every existing caller renders as before. The style guide's live hold button
44
+ uses it.
45
+
7
46
  ## 0.90.0 — 2026-10-06
8
47
 
9
48
  ### Removed
data/README.md CHANGED
@@ -82,6 +82,11 @@ sign-in-link form when `:magic_link` is enabled, and the Google button only with
82
82
  and using the link creates the account. So `registration_params`' `:name` shows a
83
83
  Name field only on a password app.
84
84
 
85
+ `POST /login`, the password exchange, is drawn only when `auth_methods` includes
86
+ `:password`. A passwordless app answers it with 404 for every address, so it
87
+ cannot tell a member from a stranger. `GET /login` and `login_path` are drawn for
88
+ every app.
89
+
85
90
  ### Local log rotation (automatic — nothing to configure)
86
91
 
87
92
  The engine caps the host app's **development** log at 16 MB and its **test** log
@@ -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;
@@ -55,9 +55,15 @@ class SessionsController < ApplicationController
55
55
  email: session[:sso_email],
56
56
  name: session[:sso_name],
57
57
  provider: session[:sso_provider],
58
- uid: session[:sso_uid],
59
- password: SecureRandom.hex(16)
58
+ uid: session[:sso_uid]
60
59
  )
60
+ # Only a host that takes a password gets one: has_secure_password defines
61
+ # `password=` and requires a value on create, so those apps need a random
62
+ # placeholder. A passwordless User has no such writer, and passing
63
+ # `password:` to User.new raised UnknownAttributeError, which sso_continue
64
+ # rescued into a redirect: SSO sign-up failed silently on every passwordless
65
+ # app (/tasks/engine-sso-creates-passwordless-users).
66
+ user.password = SecureRandom.hex(16) if user.respond_to?(:password=)
61
67
  Studio.configure_sso_user.call(user)
62
68
  rescue_and_log(target: user) do
63
69
  user.save!
@@ -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]');
@@ -1,3 +1,4 @@
1
+ <%# locals: (question:, index:, total:, response:, error: nil) %>
1
2
  <%# One survey question as a fieldset (studio/surveys/show). It is a complete
2
3
  form control without JavaScript: native radios, checkboxes and text fields
3
4
  named answers[key], with `required` so the no-JS form validates in the
@@ -1,3 +1,4 @@
1
+ <%# locals: () %>
1
2
  <%# The survey stepper: progressive enhancement over studio/surveys/show.
2
3
  Plain JavaScript (no Alpine dependency), idempotent per root so a Turbo
3
4
  render that re-runs it never double-binds. Bound roots are tracked in a
@@ -137,8 +138,10 @@
137
138
  back.hidden = first;
138
139
  next.hidden = last;
139
140
  submit.hidden = !last;
140
- // The title and intro lead question 1 only; later screens are the question alone.
141
- if (head) head.hidden = !first;
141
+ // The title and intro lead question 1 only; later screens show the
142
+ // question alone. The head steps into a visual clip rather than
143
+ // `hidden`, so the page keeps its h1 for a screen reader on every step.
144
+ if (head) head.classList.toggle("studio-survey__head--later", !first);
142
145
  // The number-key shortcut only means something on a choice or scale.
143
146
  var step = steps[index];
144
147
  hint.hidden = !(step.dataset.key && (isChoice(step) || isMulti(step)));
@@ -1,3 +1,4 @@
1
+ <%# locals: () %>
1
2
  <%# Survey styles, scoped under .studio-survey. Plain CSS on the theme's CSS
2
3
  custom properties (--color-cta, --color-surface, ...) rather than Tailwind
3
4
  utilities, so the page looks the same in every host whatever its Tailwind
@@ -6,7 +7,7 @@
6
7
  <style>
7
8
  /* overflow-x: clip, not hidden: the slide-in must never widen the page, and
8
9
  clip makes no scroll container, so the sticky nav still sticks. */
9
- .studio-survey { --sv-accent: var(--color-cta, #8E82FE); display: flex; justify-content: center; padding: 1.25rem 1rem 2rem; color: var(--color-text-body); overflow-x: clip; }
10
+ .studio-survey { --sv-accent: var(--color-cta, <%= Studio::ThemeResolver::DEFAULT_PRIMARY %>); display: flex; justify-content: center; padding: 1.25rem 1rem 2rem; color: var(--color-text-body); overflow-x: clip; }
10
11
  .studio-survey [hidden] { display: none !important; }
11
12
  .studio-survey__shell { width: 100%; max-width: 36rem; }
12
13
  .studio-survey__sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
@@ -25,6 +26,11 @@
25
26
  .studio-survey.is-enhanced .studio-survey__head { margin-bottom: 1.25rem; }
26
27
  .studio-survey.is-enhanced .studio-survey__title { margin: 0 0 .375rem; font-size: 1rem; line-height: 1.4; font-weight: 700; color: var(--color-text-secondary); }
27
28
  .studio-survey.is-enhanced .studio-survey__head--echo .studio-survey__title { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
29
+ /* After question 1 the head steps aside visually, but its h1 stays the page's
30
+ heading for a screen reader: the head goes into a 1px clip and only the
31
+ title remains inside it. */
32
+ .studio-survey.is-enhanced .studio-survey__head--later { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
33
+ .studio-survey.is-enhanced .studio-survey__head--later > :not(.studio-survey__title) { display: none; }
28
34
  .studio-survey.is-enhanced .studio-survey__intro-copy { margin: 0; font-size: .9375rem; line-height: 1.5; color: var(--color-text-secondary); }
29
35
  .studio-survey.is-enhanced .studio-survey__meta { display: none; }
30
36
  .studio-survey__question { display: block; width: 100%; padding: 0; margin: 0 0 .5rem; font-size: 1.375rem; line-height: 1.3; font-weight: 700; color: var(--color-text); outline: none; }
@@ -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.1"
3
3
  end
data/lib/studio.rb CHANGED
@@ -146,8 +146,9 @@ module Studio
146
146
  # Which sign-in methods this app offers. The shared login/signup views render
147
147
  # a button/field per enabled method (gate with Studio.auth_method?). Order is
148
148
  # display order. Both McRitchie Studio + Turf Monster are passwordless; legacy
149
- # email+password is opt-in via :password (which also re-arms the User#authenticate
150
- # contract check — see validate_user_contract!).
149
+ # email+password is opt-in via :password, which also draws POST /login (see
150
+ # Studio.routes) and re-arms the User#authenticate contract check (see
151
+ # validate_user_contract!).
151
152
  #
152
153
  # :wallet is deliberately NOT in the default. This engine plus McRitchie Studio
153
154
  # is the BASE template for every app, web2 and web3 alike; solana-studio plus
@@ -1336,7 +1337,16 @@ module Studio
1336
1337
  def self.routes(router)
1337
1338
  router.instance_exec do
1338
1339
  get "login", to: "sessions#new"
1339
- post "login", to: "sessions#create"
1340
+
1341
+ # Email + password sign-in, drawn only for an app that declares :password.
1342
+ # SessionsController#create calls user.authenticate, which a passwordless
1343
+ # User does not define: on a passwordless app this POST answered 500 for a
1344
+ # member's address and 422 for a stranger's, which told anyone who asked
1345
+ # who the members are. Undrawn, it answers 404 for every address.
1346
+ # GET /login stays drawn for every app: it is the sign-in page, and
1347
+ # login_path names it.
1348
+ post "login", to: "sessions#create" if Studio.auth_method?(:password)
1349
+
1340
1350
  post "sso_continue", to: "sessions#sso_continue"
1341
1351
  get "sso_login", to: "sessions#sso_login"
1342
1352
  get "logout", to: "sessions#destroy"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie