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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +558 -0
- data/app/helpers/studio/fizz_helper.rb +125 -0
- data/app/views/studio/_fizz_layer.html.erb +16 -0
- data/app/views/studio/_hold_button.html.erb +405 -0
- data/app/views/studio/profiles/_identity.html.erb +23 -2
- data/app/views/studio/profiles/_identity_mini.html.erb +74 -23
- data/app/views/studio/profiles/_identity_styles.html.erb +144 -0
- data/app/views/studio/profiles/_save_controls.html.erb +79 -0
- data/app/views/studio/profiles/edit.html.erb +51 -25
- data/app/views/style/_tricks.html.erb +59 -0
- data/lib/studio/version.rb +1 -1
- metadata +5 -2
- data/app/views/studio/profiles/_save_bar.html.erb +0 -39
|
@@ -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,
|