studio-engine 0.55.0 → 0.56.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.
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The bubble table behind the hold-to-confirm button's fizz layers.
5
+ #
6
+ # The CodePen this adapts scattered its particles with Sass `random()` at
7
+ # compile time. There is no Sass here, and a runtime `rand` would re-scatter
8
+ # the bubbles on every render — which fights Turbo's page cache (a restored
9
+ # page would not match the one it replaced) and leaves the markup untestable.
10
+ # So the scatter is SEEDED: stable for a given button across renders, and
11
+ # different between two buttons on one page.
12
+ #
13
+ # ZONES. The button is cut into a 3x2 grid — three zones along the top edge,
14
+ # three along the bottom, numbered left to right, top row first. A bubble only
15
+ # ever wears the colours of the zone it sits in, so a six-item palette reads as
16
+ # six things standing around the button rather than one shaken bag of confetti.
17
+ # The outer columns also own the spray off the left and right sides, each
18
+ # within its own half.
19
+ #
20
+ # COLOURS. Each zone carries three slots — 1..18 across the six zones — and the
21
+ # two layers split them: the resting layer wears the FIRST of its zone's three,
22
+ # and the layer that fades in on hover alternates the second and third. A
23
+ # caller binds them as `--fizz-c-1..18` on the stack (turf-monster's board maps
24
+ # the six picked teams' light / dark / alt colours onto them, in pick order);
25
+ # anything unbound falls back to the bubble's own hue, so the effect is never
26
+ # colourless.
27
+ module FizzHelper
28
+ # Candy hues a bubble falls back to. Deliberately narrow — the source
29
+ # material spanned the whole wheel and read as confetti.
30
+ HUES = [ 262, 276, 292, 318, 336, 190, 168, 46 ].freeze
31
+
32
+ ZONE_COLUMNS = 3
33
+ ZONE_ROWS = 2
34
+ ZONES = ZONE_COLUMNS * ZONE_ROWS
35
+ COLORS_PER_ZONE = 3
36
+ SLOTS = ZONES * COLORS_PER_ZONE
37
+
38
+ # Which of its zone's three slots a layer's bubbles take.
39
+ LAYER_OFFSETS = {
40
+ base: [ 0 ].freeze, # the first colour
41
+ hover: [ 1, 2 ].freeze # the second and third, alternating
42
+ }.freeze
43
+
44
+ # One layer's bubbles. Each is a hash the partial writes out as inline
45
+ # custom properties: x/y place it (%), dx/dy are the drift it travels before
46
+ # fading (dy negative = rises off the top edge), size is px, delay and
47
+ # duration are seconds, hue is its fallback colour, zone and slot are its
48
+ # place in the palette.
49
+ def fizz_bits(seed, layer: :base, per_zone: 5)
50
+ offsets = LAYER_OFFSETS.fetch(layer)
51
+ prng = Random.new(seed.to_s.each_byte.sum * 7919 + per_zone)
52
+
53
+ ZONES.times.flat_map do |zone|
54
+ Array.new(per_zone) do |i|
55
+ fizz_zone_bit(prng, zone: zone, index: i).merge(
56
+ zone: zone + 1,
57
+ slot: (zone * COLORS_PER_ZONE) + offsets[i % offsets.size] + 1
58
+ )
59
+ end
60
+ end
61
+ end
62
+
63
+ # One bubble's colour: its slot's bound colour, else its own hue.
64
+ def fizz_color(bit)
65
+ "var(--fizz-c-#{bit[:slot]}, hsl(#{bit[:hue]} 92% 70%))"
66
+ end
67
+
68
+ # The inline style one bubble carries. Kept here rather than in the partial
69
+ # so the property names and the CSS that reads them stay in one place.
70
+ def fizz_bit_style(bit)
71
+ "left:#{bit[:x]}%;top:#{bit[:y]}%;" \
72
+ "--fs:#{bit[:size]}px;--fx:#{bit[:dx]}px;--fy:#{bit[:dy]}px;" \
73
+ "--fd:#{bit[:delay]}s;--ft:#{bit[:duration]}s;--fc:#{fizz_color(bit)}"
74
+ end
75
+
76
+ private
77
+
78
+ # Where in its own zone a bubble sits. The outer columns each throw one
79
+ # bubble off the side of the button, kept in their own half so the top-left
80
+ # zone never sprays into the bottom-left one.
81
+ def fizz_zone_bit(prng, zone:, index:)
82
+ row = zone / ZONE_COLUMNS # 0 = the top edge, 1 = the bottom
83
+ col = zone % ZONE_COLUMNS
84
+
85
+ return fizz_side_bit(prng, :left, row) if index.zero? && col.zero?
86
+ return fizz_side_bit(prng, :right, row) if index.zero? && col == ZONE_COLUMNS - 1
87
+
88
+ x = fizz_zone_x(prng, col)
89
+ row.zero? ? fizz_top_bit(prng, x) : fizz_bottom_bit(prng, x)
90
+ end
91
+
92
+ def fizz_zone_x(prng, col)
93
+ span = 100.0 / ZONE_COLUMNS
94
+ inset = span * 0.06 # breathing room, so neighbouring zones do not touch
95
+ (col * span + inset + prng.rand(span - (inset * 2))).round(1)
96
+ end
97
+
98
+ def fizz_top_bit(prng, x)
99
+ fizz_bit(prng, x: x, y: 2 + prng.rand(12),
100
+ dx: prng.rand(-8..8), dy: -(12 + prng.rand(24)))
101
+ end
102
+
103
+ def fizz_bottom_bit(prng, x)
104
+ fizz_bit(prng, x: x, y: 86 + prng.rand(12),
105
+ dx: prng.rand(-8..8), dy: 12 + prng.rand(24))
106
+ end
107
+
108
+ def fizz_side_bit(prng, side, row)
109
+ reach = 10 + prng.rand(18)
110
+ fizz_bit(prng,
111
+ x: side == :left ? prng.rand(5) : 95 + prng.rand(5),
112
+ y: row.zero? ? 14 + prng.rand(30) : 56 + prng.rand(30),
113
+ dx: side == :left ? -reach : reach,
114
+ dy: row.zero? ? -(2 + prng.rand(10)) : 2 + prng.rand(10))
115
+ end
116
+
117
+ def fizz_bit(prng, x:, y:, dx:, dy:)
118
+ { x: x, y: y, dx: dx, dy: dy,
119
+ size: 2 + prng.rand(5),
120
+ delay: (prng.rand(180) / 100.0).round(2),
121
+ duration: (1.4 + prng.rand(120) / 100.0).round(2),
122
+ hue: HUES[prng.rand(HUES.size)] }
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,16 @@
1
+ <%#
2
+ One layer of the hold button's fizz (Studio::FizzHelper).
3
+
4
+ Locals:
5
+ seed - the string the bubble table is seeded from (the button's hold_id,
6
+ or "<hold_id>~extra" for the second layer, so the two scatters land
7
+ in each other's gaps rather than on top of each other)
8
+ extra - true for the hover-only layer, which rests invisible and fades in
9
+ when the pointer arrives (default: false)
10
+
11
+ The two layers also split each zone's three colour slots: the resting one
12
+ takes the first, the hover one alternates the second and third.
13
+ Decoration only: aria-hidden here, pointer-events off in CSS.
14
+ %>
15
+ <% extra ||= false %>
16
+ <span class="hold-fizz<%= " hold-fizz-extra" if extra %>" aria-hidden="true"><% fizz_bits(seed, layer: extra ? :hover : :base).each do |bit| %><i class="fizz-bit" style="<%= fizz_bit_style(bit) %>"></i><% end %></span>
@@ -0,0 +1,405 @@
1
+ <%#
2
+ The hold-to-confirm button (Studio::FizzHelper + engine-motion.css).
3
+
4
+ A press-and-hold CTA for an action a host does not want taken by accident.
5
+ Ported from turf-monster, where it confirms a contest entry.
6
+
7
+ Per-instance config travels with the button as data-* attributes
8
+ and is evaluated against the surrounding x-data scope at runtime
9
+ via Alpine.evaluate (with `d` aliased to the scope's $data). This
10
+ lets a single page host multiple hold buttons with different
11
+ callbacks — the previous baked-in-ERB approach put callbacks on
12
+ a window-level handler that the FIRST rendered button populated,
13
+ silently forcing every later button to inherit those callbacks.
14
+
15
+ Locals:
16
+ default_text - Button label in idle state (default: "Hold to Confirm")
17
+ hold_text - Text shown while holding (default: "Almost there...")
18
+ success_text - Text shown on completion (default: "Confirmed!")
19
+ duration - Hold duration in ms (default: 2000)
20
+ hold_id - Unique ID for this button instance (default: "hold")
21
+ guard - JS expression that must be truthy to allow hold (default: none = always allow)
22
+ on_success - JS expression executed on successful hold (default: none)
23
+ validate - JS expression returning Promise<boolean>, called at validate_at ms (default: none)
24
+ validate_at - ms delay before running validation (default: 750)
25
+ early_action - JS expression executed early (for Web3 users) (default: none)
26
+ early_action_at - ms delay before firing early action (default: 1500)
27
+ early_action_guard - JS expression that must be truthy for early action (default: none)
28
+ on_hold_start - JS expression executed the instant a hold BEGINS, after
29
+ the guard/submitting checks pass (default: none). Used to
30
+ kick off work that should run in parallel with the hold
31
+ (e.g. the board's authoritative funding pre-check fetch,
32
+ resolved by the time the hold completes ~2s later).
33
+ fizz - Render the fizz particle layer (default: true). Pass
34
+ false for a flat button. Specimens: /admin/style.
35
+ fizz_level - :lively (default) or :calm. Lively rests at a full
36
+ boil, and hovering DOUBLES THE BUBBLE COUNT (a second
37
+ layer fades in over the gaps) at the same speed. Calm
38
+ simmers at rest and boils on hover instead — one
39
+ layer, for a button that should not compete.
40
+ fizz_colors - Up to 12 CSS colors the bubbles wear, as a static
41
+ array (default: none = the built-in candy palette).
42
+ fizz_bind - Alpine expression bound to the stack's :style, for a
43
+ palette that changes at runtime (default: none). The
44
+ turf-monster's board passes "fizzPalette", mapping
45
+ the six picked teams' light / dark / alt colors onto
46
+ --fizz-c-1..18, three per zone in pick order.
47
+ %>
48
+ <% default_text ||= "Hold to Confirm" %>
49
+ <% hold_text ||= "Almost there..." %>
50
+ <% success_text ||= "Confirmed!" %>
51
+ <% error_text ||= "Entry Blocked" %>
52
+ <% duration ||= 2000 %>
53
+ <% hold_id ||= "hold" %>
54
+ <% guard ||= nil %>
55
+ <% on_success ||= nil %>
56
+ <% validate ||= nil %>
57
+ <% validate_at ||= 750 %>
58
+ <% early_action ||= nil %>
59
+ <% early_action_at ||= 1500 %>
60
+ <% early_action_guard ||= nil %>
61
+ <% on_hold_start ||= nil %>
62
+ <%# `fizz ||= true` would coerce an explicit `fizz: false` back to true, so
63
+ read the key instead of the value. %>
64
+ <% fizz = local_assigns.key?(:fizz) ? local_assigns[:fizz] : true %>
65
+ <% fizz_colors ||= nil %>
66
+ <% fizz_bind ||= nil %>
67
+ <% fizz_level ||= :lively %>
68
+ <%# Static palette (gallery, or any call site with fixed colors). A runtime
69
+ palette rides fizz_bind instead. %>
70
+ <% fizz_style = Array(fizz_colors).first(Studio::FizzHelper::SLOTS)
71
+ .each_with_index.map { |c, i| "--fizz-c-#{i + 1}:#{c}" }.join(";") %>
72
+
73
+ <%# The stack exists so the fizz can sit BEHIND the button. Inside the button
74
+ a negative z-index still paints above its own background (the button's
75
+ transform makes it a stacking context), so the bubbles have to be a
76
+ sibling the button paints over — they only show where they escape its
77
+ 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 %>>
79
+ <%# Fizz layer — bubbles that simmer at rest, boil while the button is held,
80
+ and burst outward on success. Purely decorative, so it is aria-hidden and
81
+ pointer-events:none; the table behind it is seeded per hold_id (see Studio::FizzHelper). %>
82
+ <% if fizz %>
83
+ <%= render "studio/fizz_layer", seed: hold_id %>
84
+ <%# Lively's hover doubles the COUNT, not the speed: a second scatter that
85
+ rests invisible, fades in over the gaps in the first, and fades back
86
+ out when the pointer leaves. %>
87
+ <% if fizz_level.to_s == "lively" %>
88
+ <%= render "studio/fizz_layer", seed: "#{hold_id}~extra", extra: true %>
89
+ <% end %>
90
+ <% end %>
91
+ <button class="hold-btn"
92
+ data-hold-id="<%= hold_id %>"
93
+ data-duration="<%= duration %>"
94
+ <% if guard %>data-guard="<%= guard %>"<% end %>
95
+ <% if on_hold_start %>data-on-hold-start="<%= on_hold_start %>"<% end %>
96
+ <% if on_success %>data-on-success="<%= on_success %>"<% end %>
97
+ <% if validate %>data-validate="<%= validate %>" data-validate-at="<%= validate_at %>"<% end %>
98
+ <% if early_action %>data-early-action="<%= early_action %>" data-early-action-at="<%= early_action_at %>"<% end %>
99
+ <% if early_action_guard %>data-early-action-guard="<%= early_action_guard %>"<% end %>
100
+ style="--duration: <%= duration %>ms"
101
+ @mousedown="holdBtnStart($el)" @mouseup="holdBtnEnd($el)" @mouseleave="holdBtnEnd($el)"
102
+ @touchstart.prevent="holdBtnStart($el)" @touchend="holdBtnEnd($el)" @touchcancel="holdBtnEnd($el)">
103
+ <div class="hold-icon">
104
+ <svg class="progress" viewBox="0 0 32 32"><circle r="8" cx="16" cy="16"/></svg>
105
+ <svg class="tick" viewBox="0 0 24 24"><polyline points="18,7 11,16 6,12"/></svg>
106
+ </div>
107
+ <ul class="hold-text">
108
+ <li><%= default_text %></li>
109
+ <li><%= hold_text %></li>
110
+ <li><%= success_text %></li>
111
+ <li><%= error_text %></li>
112
+ </ul>
113
+ <div class="nudge-debug">
114
+ <svg viewBox="0 0 24 24">
115
+ <circle class="track" cx="12" cy="12" r="9"/>
116
+ <circle class="fill" cx="12" cy="12" r="9"/>
117
+ </svg>
118
+ <span class="countdown-num"></span>
119
+ </div>
120
+ </button>
121
+ </span>
122
+
123
+ <script>
124
+ (function() {
125
+ if (window._holdBtnInit) return;
126
+ window._holdBtnInit = true;
127
+
128
+ var timers = {};
129
+ var nudgeTimers = {};
130
+ var CIRCUMFERENCE = 2 * Math.PI * 9; // ~56.55, matches r=9
131
+
132
+ // Evaluate a JS expression string against an x-data scope. Both `d`
133
+ // and `data` are aliased to the scope's reactive $data so call sites
134
+ // can use either ("d.runHoldValidations()" or "data.selectionCount").
135
+ //
136
+ // Sync only. For async expressions (e.g. validate which calls an
137
+ // async method), Alpine.evaluate returns SYNCHRONOUSLY — it kicks
138
+ // the work off via evaluateLater + callback, and returns whatever
139
+ // is in `result` at return time (still undefined for async). Use
140
+ // evalInAsync for those.
141
+ function evalIn(scope, expr) {
142
+ var d = Alpine.$data(scope);
143
+ return Alpine.evaluate(scope, expr, { scope: { d: d, data: d } });
144
+ }
145
+
146
+ // Async-aware version — returns a Promise that resolves to the
147
+ // expression's value. For sync expressions, resolves immediately.
148
+ // For async ones (Promise-returning methods like
149
+ // d.runHoldValidations()), the AsyncFunction flattens the Promise
150
+ // so the resolved value bubbles up cleanly.
151
+ //
152
+ // We DON'T use Alpine.evaluateLater here. In Alpine 3.x the
153
+ // `extras` arg shape is version-dependent — passing
154
+ // { scope: { d, data } } silently fails to inject those vars in
155
+ // some builds (the user hit "d is not defined" when we tried
156
+ // that), and the working shape changes between minor versions.
157
+ // Compiling our own AsyncFunction with the named params we need
158
+ // is portable and doesn't depend on Alpine internals.
159
+ var _AsyncFn = Object.getPrototypeOf(async function() {}).constructor;
160
+ function evalInAsync(scope, expr) {
161
+ var d = Alpine.$data(scope);
162
+ try {
163
+ var fn = new _AsyncFn('d', 'data', 'Alpine', 'window',
164
+ '"use strict"; return (' + expr + ');');
165
+ return fn(d, d, Alpine, window);
166
+ } catch (e) {
167
+ return Promise.reject(e);
168
+ }
169
+ }
170
+
171
+ function updateDebugRing(el, secondsLeft, totalSeconds) {
172
+ var debug = el.querySelector('.nudge-debug');
173
+ if (!debug) return;
174
+ var fill = debug.querySelector('circle.fill');
175
+ var num = debug.querySelector('.countdown-num');
176
+ var fraction = secondsLeft / totalSeconds;
177
+ fill.style.strokeDashoffset = (CIRCUMFERENCE * fraction).toFixed(2);
178
+ num.textContent = secondsLeft;
179
+ }
180
+
181
+ function triggerNudge(el, cls) {
182
+ if (el.classList.contains('process') || el.classList.contains('success')) return;
183
+ el.classList.remove('nudge', 'nudge-soft');
184
+ void el.offsetWidth;
185
+ el.classList.add(cls);
186
+ }
187
+
188
+ function startNudgeCycle(el, softOnly) {
189
+ var id = el.dataset.holdId || 'hold';
190
+ stopNudgeCycle(id);
191
+ nudgeTimers[id] = {};
192
+
193
+ var firstDelay = softOnly ? 10 : 3;
194
+ var repeatDelay = 10;
195
+ var secsLeft = firstDelay;
196
+ var total = firstDelay;
197
+ var isFirst = !softOnly;
198
+
199
+ updateDebugRing(el, secsLeft, total);
200
+
201
+ nudgeTimers[id].tick = setInterval(function() {
202
+ secsLeft--;
203
+ if (secsLeft <= 0) {
204
+ triggerNudge(el, isFirst ? 'nudge' : 'nudge-soft');
205
+ isFirst = false;
206
+ total = repeatDelay;
207
+ secsLeft = repeatDelay;
208
+ }
209
+ updateDebugRing(el, secsLeft, total);
210
+ }, 1000);
211
+ }
212
+
213
+ function stopNudgeCycle(id) {
214
+ if (nudgeTimers[id]) {
215
+ clearTimeout(nudgeTimers[id].initial);
216
+ clearInterval(nudgeTimers[id].interval);
217
+ clearInterval(nudgeTimers[id].tick);
218
+ delete nudgeTimers[id];
219
+ }
220
+ }
221
+
222
+ // Auto-start nudge for any hold buttons already visible
223
+ document.addEventListener('turbo:load', function() {
224
+ document.querySelectorAll('.hold-btn').forEach(function(el) {
225
+ el.addEventListener('animationend', function(e) {
226
+ if (e.animationName === 'hold-nudge' || e.animationName === 'hold-nudge-soft') {
227
+ el.classList.remove('nudge', 'nudge-soft');
228
+ }
229
+ });
230
+ startNudgeCycle(el);
231
+ });
232
+ });
233
+
234
+ // Cleanup nudge timers before Turbo caches the page
235
+ document.addEventListener('turbo:before-cache', function() {
236
+ Object.keys(nudgeTimers).forEach(function(id) { stopNudgeCycle(id); });
237
+ });
238
+
239
+ window.holdBtnStart = function(el) {
240
+ var holdId = el.dataset.holdId || 'hold';
241
+ var scope = el.closest('[x-data]');
242
+ if (scope) {
243
+ try {
244
+ var data = Alpine.$data(scope);
245
+ // submitting === true means the previous attempt's POST is
246
+ // still in flight. Refuse to start a second hold so we don't
247
+ // double-submit and so the spinning button stays visible.
248
+ if (data && data.submitting) return;
249
+ var guard = el.dataset.guard;
250
+ if (guard && !evalIn(scope, guard)) return;
251
+ } catch(e) {
252
+ console.warn('[hold:' + holdId + '] guard/submitting check threw:', e);
253
+ }
254
+ } else {
255
+ console.warn('[hold:' + holdId + '] no [x-data] ancestor — evalIn calls will be no-ops');
256
+ }
257
+
258
+ // Hold-START hook (2026-06-13). Fires the instant a hold BEGINS — the
259
+ // guard + submitting checks above have already passed, so this only runs
260
+ // for a hold that is actually going to count down. Runs in PARALLEL with
261
+ // the 2s hold; the board uses it to kick off the authoritative funding
262
+ // pre-check fetch (beginFundingCheck) so the result is resolved by the
263
+ // time on_success fires. Wrapped — a throwing hook must never abort the
264
+ // hold itself.
265
+ if (scope) {
266
+ var onHoldStart = el.dataset.onHoldStart;
267
+ if (onHoldStart) {
268
+ try { evalIn(scope, onHoldStart); }
269
+ catch (e) { console.error('[hold:' + holdId + '] on_hold_start threw:', e); }
270
+ }
271
+ }
272
+
273
+ var id = el.dataset.holdId || 'hold';
274
+ var duration = parseInt(el.dataset.duration || '2000', 10);
275
+ var onSuccess = el.dataset.onSuccess;
276
+ var validate = el.dataset.validate;
277
+ var validateAt = parseInt(el.dataset.validateAt || '750', 10);
278
+ var earlyAction = el.dataset.earlyAction;
279
+ var earlyActionAt = parseInt(el.dataset.earlyActionAt || '1500', 10);
280
+ var earlyActionGuard = el.dataset.earlyActionGuard;
281
+
282
+ var circle = el.querySelector('svg.progress circle');
283
+
284
+ // Restart nudge cycle from hold start (suppressed visually by .process)
285
+ el.classList.remove('nudge', 'nudge-soft');
286
+ startNudgeCycle(el);
287
+
288
+ // Snap progress to 0 instantly before starting
289
+ if (circle) {
290
+ circle.style.transition = 'none';
291
+ el.classList.remove('process', 'success');
292
+ void circle.offsetWidth; // force reflow
293
+ circle.style.transition = '';
294
+ } else {
295
+ el.classList.remove('process', 'success');
296
+ }
297
+
298
+ el.style.setProperty('--duration', duration + 'ms');
299
+ el.classList.add('process');
300
+
301
+ timers[id] = setTimeout(function() {
302
+ if (onSuccess) {
303
+ // Stay in process state briefly while resolving
304
+ setTimeout(function() {
305
+ try {
306
+ var s = el.closest('[x-data]');
307
+ if (!s) {
308
+ console.warn('[hold:' + holdId + '] on_success: no [x-data] ancestor at fire time');
309
+ return;
310
+ }
311
+ evalIn(s, onSuccess);
312
+ } catch(e) {
313
+ console.error('[hold:' + holdId + '] on_success threw:', e);
314
+ el.classList.remove('process');
315
+ el.classList.add('success');
316
+ }
317
+ }, 500);
318
+ } else {
319
+ el.classList.remove('process');
320
+ el.classList.add('success');
321
+ }
322
+ }, duration);
323
+
324
+ if (earlyAction) {
325
+ timers[id + '_early'] = setTimeout(function() {
326
+ var s = el.closest('[x-data]');
327
+ if (s) {
328
+ if (!earlyActionGuard || evalIn(s, earlyActionGuard)) {
329
+ clearTimeout(timers[id]);
330
+ delete timers[id];
331
+ try { evalIn(s, earlyAction); }
332
+ catch(e) { console.error('[hold:' + holdId + '] early_action threw:', e); }
333
+ }
334
+ }
335
+ }, earlyActionAt);
336
+ }
337
+
338
+ if (validate) {
339
+ timers[id + '_val'] = setTimeout(function() {
340
+ var s = el.closest('[x-data]');
341
+ if (!s) {
342
+ console.warn('[hold:' + holdId + '] validate: no [x-data] ancestor');
343
+ return;
344
+ }
345
+ // Helper: abort the in-flight hold cleanly. Clears all timers
346
+ // for this id and drops the .process class so the user isn't
347
+ // stranded at "Almost there…" if validate rejects. Caller's
348
+ // validate is responsible for any setHoldError() if it wants
349
+ // an explicit error state (e.g. geo block).
350
+ var abort = function() {
351
+ clearTimeout(timers[id]); delete timers[id];
352
+ if (timers[id + '_early']) { clearTimeout(timers[id + '_early']); delete timers[id + '_early']; }
353
+ el.classList.remove('process');
354
+ var c = el.querySelector('svg.progress circle');
355
+ if (c) { c.style.transition = 'none'; void c.offsetWidth; c.style.transition = ''; }
356
+ };
357
+ try {
358
+ evalInAsync(s, validate).then(
359
+ function(ok) { if (!ok) abort(); },
360
+ function(err) {
361
+ console.error('[hold:' + holdId + '] validate rejected:', err);
362
+ abort();
363
+ }
364
+ );
365
+ } catch(e) {
366
+ console.error('[hold:' + holdId + '] validate threw synchronously:', e);
367
+ abort();
368
+ }
369
+ }, validateAt);
370
+ }
371
+ };
372
+
373
+ window.holdBtnEnd = function(el) {
374
+ if (el.classList.contains('success') || el.classList.contains('error')) return;
375
+ var id = el.dataset.holdId || 'hold';
376
+
377
+ // Snap progress circle back to 0 instantly (no reverse animation)
378
+ var circle = el.querySelector('svg.progress circle');
379
+ if (circle) {
380
+ circle.style.transition = 'none';
381
+ el.classList.remove('process');
382
+ void circle.offsetWidth; // force reflow
383
+ circle.style.transition = '';
384
+ } else {
385
+ el.classList.remove('process');
386
+ }
387
+
388
+ if (timers[id]) {
389
+ clearTimeout(timers[id]);
390
+ delete timers[id];
391
+ }
392
+ if (timers[id + '_val']) {
393
+ clearTimeout(timers[id + '_val']);
394
+ delete timers[id + '_val'];
395
+ }
396
+ if (timers[id + '_early']) {
397
+ clearTimeout(timers[id + '_early']);
398
+ delete timers[id + '_early'];
399
+ }
400
+
401
+ // Restart with soft-only nudges (big nudge already seen)
402
+ startNudgeCycle(el, true);
403
+ };
404
+ })();
405
+ </script>
@@ -25,7 +25,7 @@
25
25
 
26
26
  <%= render "studio/profiles/identity_styles" %>
27
27
 
28
- <%= render "studio/profiles/identity_mini", user: user %>
28
+ <%= render "studio/profiles/identity_mini", user: user, editable: editable %>
29
29
 
30
30
  <% if editable %>
31
31
  <%# `studio-identity-card-editable` is the hover hook: the operator asked for the
@@ -47,10 +47,31 @@
47
47
  The button stays for keyboard and assistive tech: a div with a click handler
48
48
  is unreachable by either, and this is still the only route to changing the
49
49
  photo. %>
50
+ <%# `relative` positions the save controls, which float in this card's
51
+ SOUTH-EAST corner while it is on screen (operator's call, 2026-08-15).
52
+ They are absolutely positioned so they do not push the centred avatar and
53
+ name off-centre, and so the card does not change height when they appear —
54
+ a card that grew on the first keystroke would shove the whole form down.
55
+
56
+ BELOW 640px THEY GO BACK IN FLOW and the card does grow, because absolute
57
+ positioning inside a text-center card lands them on the name and the email
58
+ at every phone width (measured: 106px of the name at 390px). The no-resize
59
+ guarantee above therefore holds at 640px and up; see the media query in
60
+ _identity_styles for why that is the right trade at phone widths. %>
50
61
  <div data-studio-identity-full
51
62
  @click="$refs.filePicker.click()"
52
- class="studio-identity-card-editable studio-identity-card-clickable card p-6 mb-6 text-center">
63
+ class="studio-identity-card-editable studio-identity-card-clickable card p-6 mb-6 text-center relative">
53
64
  <%= render "studio/profiles/identity_body", user: user, editable: true, attachable: attachable %>
65
+
66
+ <%# `.stop` ON THE WRAPPER, not only on the buttons. Both buttons stop their
67
+ own clicks, but the 8px GAP between them is still card surface — a click
68
+ landing there bubbled to the card's handler and opened the file picker.
69
+ Measured in review: a gap-midpoint click opened it once, a button click
70
+ zero times. Stopping at the wrapper covers the buttons, the gap, and
71
+ anything added here later. %>
72
+ <div class="studio-identity-actions" @click.stop>
73
+ <%= render "studio/profiles/save_controls" %>
74
+ </div>
54
75
  </div>
55
76
  <% else %>
56
77
  <%= link_to edit_profile_path,