studio-engine 0.17.0 → 0.18.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 +67 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +620 -5
- data/app/controllers/style_controller.rb +25 -0
- data/app/views/studio/modals/_crop_photo.html.erb +5 -1
- data/app/views/studio/modals/_host.html.erb +5 -0
- data/app/views/studio/modals/_image_upload.html.erb +16 -5
- data/app/views/studio/modals/_saving.html.erb +6 -2
- data/app/views/studio/modals/auth/_resend_footer.html.erb +25 -0
- data/app/views/studio/modals/blocks/_card_header.html.erb +83 -0
- data/app/views/studio/modals/blocks/_cta_redirect.html.erb +73 -0
- data/app/views/studio/modals/blocks/_onchain_success.html.erb +52 -0
- data/app/views/studio/modals/blocks/_progress_pill.html.erb +23 -0
- data/app/views/studio/modals/blocks/_shell.html.erb +35 -0
- data/app/views/studio/modals/blocks/_solana_tx_link.html.erb +42 -0
- data/app/views/studio/modals/shared/_age_attestation.html.erb +34 -0
- data/app/views/studio/modals/shared/_email_field.html.erb +70 -0
- data/app/views/studio/modals/templates/_action.html.erb +25 -0
- data/app/views/studio/modals/templates/_form.html.erb +25 -0
- data/app/views/studio/modals/templates/_status.html.erb +45 -0
- data/app/views/studio/modals/templates/_success.html.erb +23 -0
- data/app/views/studio/modals/templates/_wizard.html.erb +75 -0
- data/app/views/style/_modal_specimen.html.erb +120 -0
- data/app/views/style/_modals.html.erb +690 -0
- data/app/views/style/_specimen.html.erb +64 -0
- data/app/views/style/_tasks.html.erb +112 -0
- data/app/views/style/_theme.html.erb +222 -0
- data/app/views/style/_tricks.html.erb +323 -0
- data/app/views/style/index.html.erb +60 -0
- data/app/views/style/modals/_auth.html.erb +252 -0
- data/app/views/style/modals/_onchain_tx.html.erb +62 -0
- data/app/views/style/modals/_wallet_connect.html.erb +138 -0
- data/app/views/style/modals/_wallet_deposit.html.erb +56 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +27 -1
- metadata +32 -6
- data/app/controllers/design_system_controller.rb +0 -12
- data/app/views/design_system/_specimen.html.erb +0 -27
- data/app/views/design_system/index.html.erb +0 -232
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
store registration, and the window.StudioModals.holdAtLeast(ms)
|
|
9
9
|
helper. The animation keyframes ship inline in the style block
|
|
10
10
|
below, so consumers need no extra CSS to get the full behavior.
|
|
11
|
+
A canonical copy of the SAME keyframes also lives in the shared
|
|
12
|
+
motion layer (app/assets/tailwind/studio_engine/engine-motion.css)
|
|
13
|
+
for pages that drive a modal host WITHOUT rendering this partial —
|
|
14
|
+
e.g. the living style guide's page-scoped dsModals host. The two are
|
|
15
|
+
byte-identical; keep them in sync if you retune a curve or duration.
|
|
11
16
|
Consumer integration example and API reference: the "Modal host"
|
|
12
17
|
section of the gem README.
|
|
13
18
|
%>
|
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
function cropPhotoModal(opts) {
|
|
27
27
|
opts = opts || {};
|
|
28
28
|
return {
|
|
29
|
+
// Which Alpine store this modal is mounted in. Defaults to 'modals' (the
|
|
30
|
+
// shared host); the living style guide passes { store: 'dsModals' } so the
|
|
31
|
+
// crop-photo modal opens on its page-scoped host without colliding with
|
|
32
|
+
// the app-level shared host.
|
|
33
|
+
_storeName: opts.store || 'modals',
|
|
29
34
|
cropper: null,
|
|
30
35
|
imageUrl: null,
|
|
31
36
|
fromParent: false,
|
|
@@ -46,7 +51,7 @@
|
|
|
46
51
|
dispatch: false,
|
|
47
52
|
|
|
48
53
|
init() {
|
|
49
|
-
var current = this.$store.
|
|
54
|
+
var current = this.$store[this._storeName].current();
|
|
50
55
|
var props = (current && current.props) || {};
|
|
51
56
|
if (props.aspectRatio) this.aspectRatio = props.aspectRatio;
|
|
52
57
|
if (props.maxWidth) this.maxWidth = props.maxWidth;
|
|
@@ -77,8 +82,14 @@
|
|
|
77
82
|
});
|
|
78
83
|
// Cropping in progress — lock the modal (no click-outside / escape) so
|
|
79
84
|
// an accidental click doesn't discard the crop. Cancel still works.
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
// Also reflect the crop sub-state onto the store entry (cropReady) so
|
|
86
|
+
// an external observer can tell the picker state apart from the crop
|
|
87
|
+
// state — the modal's own `imageUrl` is LOCAL x-data and never lands on
|
|
88
|
+
// props, so props alone can't distinguish them. The living style guide
|
|
89
|
+
// reads this to move its active-card glow from the Image Upload card to
|
|
90
|
+
// the Crop Photo card the moment an image is loaded (picker -> cropper).
|
|
91
|
+
var cur = self.$store[self._storeName].current();
|
|
92
|
+
if (cur && cur.props) { cur.props.dismissible = false; cur.props.cropReady = true; }
|
|
82
93
|
});
|
|
83
94
|
},
|
|
84
95
|
|
|
@@ -112,7 +123,7 @@
|
|
|
112
123
|
|
|
113
124
|
cancel() {
|
|
114
125
|
if (this.cropper) { this.cropper.destroy(); this.cropper = null; }
|
|
115
|
-
this.$store.
|
|
126
|
+
this.$store[this._storeName].close();
|
|
116
127
|
},
|
|
117
128
|
|
|
118
129
|
confirm() {
|
|
@@ -134,7 +145,7 @@
|
|
|
134
145
|
// dispatch mode: the opener's host owns the post-confirm flow
|
|
135
146
|
// (processing modal -> success toast), so don't pop the stack here.
|
|
136
147
|
// fromParent (no dispatch): close the modal now.
|
|
137
|
-
if (!self.dispatch) self.$store.
|
|
148
|
+
if (!self.dispatch) self.$store[self._storeName].close();
|
|
138
149
|
}, "image/png");
|
|
139
150
|
}
|
|
140
151
|
};
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
<%# Generic "saving" loading card, opened by window.submitFormWithProgress with
|
|
2
2
|
a { title } prop while a Turbo form uploads. Register it in your modal-host
|
|
3
|
-
block alongside crop-photo. Single root for the host's <template x-if>.
|
|
3
|
+
block alongside crop-photo. Single root for the host's <template x-if>.
|
|
4
|
+
|
|
5
|
+
Local: store — Alpine store name backing the title read. Default "modals".
|
|
6
|
+
The living style guide passes store: "dsModals" for its page-scoped host. %>
|
|
7
|
+
<% saving_store = local_assigns.fetch(:store, "modals") %>
|
|
4
8
|
<div>
|
|
5
9
|
<%= render "studio/modals/blocks/processing_card",
|
|
6
|
-
title_key: "$store.
|
|
10
|
+
title_key: "$store.#{saving_store}.current()?.props?.title || 'Saving…'" %>
|
|
7
11
|
</div>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Resend-link + error + Close footer, shared by the auth modal's
|
|
3
|
+
magic-link-sent and magic-link-resent steps (identical in both). Ported from
|
|
4
|
+
turf-monster. Rendered INSIDE each step's root <div> (so the parent
|
|
5
|
+
<template x-if>'s single-root rule is unaffected) and within the auth modal's
|
|
6
|
+
x-data scope, so props.* and resendMagicLink() resolve from the host
|
|
7
|
+
component. Resend is a LINK; disabled during an in-flight resend and the 60s
|
|
8
|
+
cooldown, which it counts down inline. Keep this free of double-quotes and
|
|
9
|
+
backticks inside the Alpine expressions.
|
|
10
|
+
|
|
11
|
+
Locals:
|
|
12
|
+
modal_store — Alpine store name backing close(). Default "modals".
|
|
13
|
+
%>
|
|
14
|
+
<%
|
|
15
|
+
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
16
|
+
%>
|
|
17
|
+
<button @click="resendMagicLink()" :disabled="props.submitting === 'magic-link' || (props.resendCooldown || 0) > 0"
|
|
18
|
+
class="block mx-auto text-sm text-primary hover:text-primary-300 underline underline-offset-2 disabled:opacity-50 disabled:no-underline">
|
|
19
|
+
<span class="inline-flex items-center justify-center gap-1.5">
|
|
20
|
+
<span x-show="props.submitting === 'magic-link'" class="spinner" aria-hidden="true"></span>
|
|
21
|
+
<span x-text="props.submitting === 'magic-link' ? 'Resending…' : ((props.resendCooldown || 0) > 0 ? ('Resend available in ' + props.resendCooldown + 's') : 'Resend link')"></span>
|
|
22
|
+
</span>
|
|
23
|
+
</button>
|
|
24
|
+
<p x-show="props.resendError" x-cloak class="text-xs text-red-400 mt-3 text-center" x-text="props.resendError"></p>
|
|
25
|
+
<button @click="$store.<%= modal_store %>.close()" class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">Close</button>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Shared modal card header — icon pill + title + subtext. Owns the visual top
|
|
3
|
+
of every success / confirmation / status modal so the header style stays
|
|
4
|
+
aligned across the family. Each callsite slots its own action area (button,
|
|
5
|
+
link, hold button) BELOW this header. Ported from turf-monster into the engine
|
|
6
|
+
(studio-engine v0.18) so the shared modal blocks live in one place.
|
|
7
|
+
|
|
8
|
+
Locals (all optional except title/title_key):
|
|
9
|
+
title: static title string
|
|
10
|
+
title_key: Alpine expression for the title (x-text). Priority over title.
|
|
11
|
+
subtitle: static subtext string
|
|
12
|
+
subtitle_key: Alpine expression for the subtext (x-text). Priority over subtitle.
|
|
13
|
+
icon_color: color token for the default check — 'primary' (default),
|
|
14
|
+
'success', 'warning', 'mint'.
|
|
15
|
+
icon_emoji: large emoji (text-5xl, no pill) instead of the default check.
|
|
16
|
+
icon_emoji_key: Alpine expression returning the emoji (x-text). Priority
|
|
17
|
+
over icon_emoji.
|
|
18
|
+
icon: :error — X mark in a red pill (hard error states).
|
|
19
|
+
:external_tab — diagonal arrow pill (action in another tab).
|
|
20
|
+
spinner: truthy — three bouncing dots instead of the check pill,
|
|
21
|
+
for processing / loading states.
|
|
22
|
+
size: :md (default) OR :lg (celebration end-state, text-3xl title).
|
|
23
|
+
|
|
24
|
+
Block: if given, the block's content replaces the subtitle area. Explicit
|
|
25
|
+
subtitle locals are checked BEFORE block_given? — a partial's block_given? can
|
|
26
|
+
read TRUE off the layout yield even with no block passed (see the engine's
|
|
27
|
+
Rails-partial block_given? note), so lead with the locals.
|
|
28
|
+
%>
|
|
29
|
+
<%
|
|
30
|
+
icon_color = local_assigns[:icon_color] || 'primary'
|
|
31
|
+
size = local_assigns[:size] || :md
|
|
32
|
+
pill_class = size == :lg ? 'w-14 h-14' : 'w-12 h-12'
|
|
33
|
+
svg_class = size == :lg ? 'w-7 h-7' : 'w-6 h-6'
|
|
34
|
+
title_cls = size == :lg ? 'text-3xl font-bold text-heading mb-2' : 'text-lg font-bold text-heading mb-2'
|
|
35
|
+
sub_cls = size == :lg ? 'text-sm text-secondary mb-4' : 'text-xs text-secondary mb-5'
|
|
36
|
+
%>
|
|
37
|
+
<div class="text-center">
|
|
38
|
+
<% if local_assigns[:spinner] %>
|
|
39
|
+
<div class="mx-auto <%= pill_class %> flex items-center justify-center mb-4">
|
|
40
|
+
<div class="loading-dots">
|
|
41
|
+
<span></span>
|
|
42
|
+
<span></span>
|
|
43
|
+
<span></span>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
<% elsif local_assigns[:icon] == :error %>
|
|
47
|
+
<div class="mx-auto <%= pill_class %> rounded-full bg-red-500/15 flex items-center justify-center mb-4">
|
|
48
|
+
<svg class="<%= svg_class %> text-red-400" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
|
49
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
|
50
|
+
</svg>
|
|
51
|
+
</div>
|
|
52
|
+
<% elsif local_assigns[:icon] == :external_tab %>
|
|
53
|
+
<div class="mx-auto <%= pill_class %> rounded-full bg-<%= icon_color %>/15 flex items-center justify-center mb-4">
|
|
54
|
+
<svg class="<%= svg_class %> text-<%= icon_color %>" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
|
55
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 19.5l15-15m0 0H8.25m11.25 0v11.25"/>
|
|
56
|
+
</svg>
|
|
57
|
+
</div>
|
|
58
|
+
<% elsif local_assigns[:icon_emoji_key] %>
|
|
59
|
+
<div class="text-5xl mb-4 leading-none" x-text="<%= icon_emoji_key %>"></div>
|
|
60
|
+
<% elsif local_assigns[:icon_emoji] %>
|
|
61
|
+
<div class="text-5xl mb-4 leading-none"><%= icon_emoji %></div>
|
|
62
|
+
<% else %>
|
|
63
|
+
<div class="mx-auto <%= pill_class %> rounded-full bg-<%= icon_color %>/15 flex items-center justify-center mb-4">
|
|
64
|
+
<svg class="<%= svg_class %> text-<%= icon_color %>" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
|
65
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
|
66
|
+
</svg>
|
|
67
|
+
</div>
|
|
68
|
+
<% end %>
|
|
69
|
+
|
|
70
|
+
<% if local_assigns[:title_key] %>
|
|
71
|
+
<h3 class="<%= title_cls %>" x-text="<%= title_key %>"></h3>
|
|
72
|
+
<% elsif local_assigns[:title] %>
|
|
73
|
+
<h3 class="<%= title_cls %>"><%= title %></h3>
|
|
74
|
+
<% end %>
|
|
75
|
+
|
|
76
|
+
<% if local_assigns[:subtitle_key] %>
|
|
77
|
+
<p class="<%= sub_cls %>" x-text="<%= subtitle_key %>"></p>
|
|
78
|
+
<% elsif local_assigns[:subtitle] %>
|
|
79
|
+
<p class="<%= sub_cls %>"><%= subtitle %></p>
|
|
80
|
+
<% elsif block_given? %>
|
|
81
|
+
<p class="<%= sub_cls %>"><%= yield %></p>
|
|
82
|
+
<% end %>
|
|
83
|
+
</div>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Primary CTA with a drain animation that auto-redirects when the countdown
|
|
3
|
+
hits zero. Used on celebration cards where the user is leaving the modal
|
|
4
|
+
anyway — the drain is both a progress bar AND a click-now affordance. Ported
|
|
5
|
+
from turf-monster.
|
|
6
|
+
|
|
7
|
+
The drain always plays. The redirect is gated:
|
|
8
|
+
- URL truthy → drains, then window.location = URL
|
|
9
|
+
- URL null → drains; timer fires a no-op, a click falls through to
|
|
10
|
+
closing the modal (so a gallery preview stays alive).
|
|
11
|
+
|
|
12
|
+
Locals (optional except href_key):
|
|
13
|
+
href_key — Alpine expression: the destination URL or null. Re-read
|
|
14
|
+
inside the timer fire so callers can lazy-resolve.
|
|
15
|
+
label_html — Button label HTML. Default "Continue".
|
|
16
|
+
label_key — Alpine expression returning the label (x-text). Priority
|
|
17
|
+
over label_html.
|
|
18
|
+
duration_seconds — Drain + redirect timer in seconds. Default 5.
|
|
19
|
+
modal_store — Alpine store name backing close(). Default "modals".
|
|
20
|
+
|
|
21
|
+
Co-located x-data self-manages the timer. Keep the x-data free of
|
|
22
|
+
double-quotes and backticks — it is a double-quoted HTML attribute.
|
|
23
|
+
%>
|
|
24
|
+
<%
|
|
25
|
+
href_key = local_assigns.fetch(:href_key)
|
|
26
|
+
label_html = local_assigns.fetch(:label_html, "Continue")
|
|
27
|
+
duration_seconds = local_assigns.fetch(:duration_seconds, 5)
|
|
28
|
+
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
29
|
+
%>
|
|
30
|
+
<div x-data="{
|
|
31
|
+
_redirectTimer: null,
|
|
32
|
+
redirecting: false,
|
|
33
|
+
go(url) {
|
|
34
|
+
if (!url) return false;
|
|
35
|
+
this.redirecting = true;
|
|
36
|
+
try { window.location.href = url; } catch (_) {}
|
|
37
|
+
return true;
|
|
38
|
+
},
|
|
39
|
+
init() {
|
|
40
|
+
var self = this;
|
|
41
|
+
self._redirectTimer = setTimeout(function () {
|
|
42
|
+
self._redirectTimer = null;
|
|
43
|
+
self.go(<%= href_key %>);
|
|
44
|
+
}, <%= duration_seconds * 1000 %>);
|
|
45
|
+
},
|
|
46
|
+
destroy() {
|
|
47
|
+
if (this._redirectTimer) { clearTimeout(this._redirectTimer); this._redirectTimer = null; }
|
|
48
|
+
}
|
|
49
|
+
}">
|
|
50
|
+
<a :href="(<%= href_key %>) || '#'"
|
|
51
|
+
@click="
|
|
52
|
+
if (redirecting) { $event.preventDefault(); return; }
|
|
53
|
+
if ($event.metaKey || $event.ctrlKey || $event.shiftKey) return;
|
|
54
|
+
$event.preventDefault();
|
|
55
|
+
var u = (<%= href_key %>);
|
|
56
|
+
if (u) { go(u); } else { $store.<%= modal_store %>.close(); }
|
|
57
|
+
"
|
|
58
|
+
:class="redirecting ? 'pointer-events-none' : ''"
|
|
59
|
+
:aria-disabled="redirecting ? 'true' : 'false'"
|
|
60
|
+
class="btn btn-primary btn-lg w-full relative overflow-hidden no-underline">
|
|
61
|
+
<div class="absolute inset-0 pointer-events-none origin-left"
|
|
62
|
+
x-show="!redirecting"
|
|
63
|
+
style="background: rgba(255,255,255,0.18); animation: studio-modal-drain <%= duration_seconds %>s linear forwards;"></div>
|
|
64
|
+
<% if local_assigns[:label_key] %>
|
|
65
|
+
<span class="relative z-10" x-show="!redirecting" x-text="<%= label_key %>"></span>
|
|
66
|
+
<% else %>
|
|
67
|
+
<span class="relative z-10" x-show="!redirecting"><%= label_html.html_safe %></span>
|
|
68
|
+
<% end %>
|
|
69
|
+
<span class="relative z-10 inline-flex items-center justify-center" x-show="redirecting" x-cloak>
|
|
70
|
+
<span class="spinner" style="--spinner-color: currentColor" aria-hidden="true"></span>
|
|
71
|
+
</span>
|
|
72
|
+
</a>
|
|
73
|
+
</div>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Generic on-chain transaction success card — the default-fallback success
|
|
3
|
+
state for any Solana transaction that isn't a domain-specific celebration
|
|
4
|
+
(faucet mint, on-chain rename, contest creation). Ported from turf-monster.
|
|
5
|
+
|
|
6
|
+
Same visual spine as a rich entry card (card_header at :lg with the primary
|
|
7
|
+
check pill + branded Solana tx link) MINUS the enrichments: no seeds bar, no
|
|
8
|
+
level-up, no auto-redirect drain. The CTA is a plain optional link; when no
|
|
9
|
+
cta label/href is supplied the card degrades to just the Dismiss link.
|
|
10
|
+
|
|
11
|
+
Single root: content lives in one <div> so Alpine's <template x-if> clones it
|
|
12
|
+
whole.
|
|
13
|
+
|
|
14
|
+
Locals:
|
|
15
|
+
tx_signature_key — Alpine expression returning the tx hash (link mounts when truthy)
|
|
16
|
+
title_key — Alpine expression returning the headline
|
|
17
|
+
subtitle_key — Alpine expression returning the sub-headline
|
|
18
|
+
cta_label_key — Alpine expression returning the CTA label (button mounts when truthy)
|
|
19
|
+
cta_href_key — Alpine expression returning the CTA href
|
|
20
|
+
cluster_param — explorer cluster query, forwarded to solana_tx_link. Default "".
|
|
21
|
+
modal_store — Alpine store name backing close(). Default "modals".
|
|
22
|
+
%>
|
|
23
|
+
<%
|
|
24
|
+
tx_signature_key = local_assigns.fetch(:tx_signature_key)
|
|
25
|
+
title_key = local_assigns.fetch(:title_key)
|
|
26
|
+
subtitle_key = local_assigns.fetch(:subtitle_key)
|
|
27
|
+
cta_label_key = local_assigns.fetch(:cta_label_key)
|
|
28
|
+
cta_href_key = local_assigns.fetch(:cta_href_key)
|
|
29
|
+
cluster_param = local_assigns.fetch(:cluster_param, "")
|
|
30
|
+
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
31
|
+
%>
|
|
32
|
+
<div>
|
|
33
|
+
<%= render "studio/modals/blocks/card_header",
|
|
34
|
+
size: :lg,
|
|
35
|
+
icon_color: 'primary',
|
|
36
|
+
title_key: title_key,
|
|
37
|
+
subtitle_key: subtitle_key %>
|
|
38
|
+
|
|
39
|
+
<%= render "studio/modals/blocks/solana_tx_link",
|
|
40
|
+
tx_signature_key: tx_signature_key, cluster_param: cluster_param %>
|
|
41
|
+
|
|
42
|
+
<template x-if="(<%= cta_label_key %>)">
|
|
43
|
+
<a :href="(<%= cta_href_key %>) || '#'"
|
|
44
|
+
class="btn btn-primary btn-lg w-full no-underline"
|
|
45
|
+
x-text="<%= cta_label_key %>"></a>
|
|
46
|
+
</template>
|
|
47
|
+
|
|
48
|
+
<button @click="$store.<%= modal_store %>.close()"
|
|
49
|
+
class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
|
|
50
|
+
Dismiss
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Segmented progress pill — N rounded bars in a row, the first `current`
|
|
3
|
+
filled in primary, the rest muted outlines. Drops into any wizard-style modal
|
|
4
|
+
step to show position in a multi-step flow. Ported from turf-monster.
|
|
5
|
+
|
|
6
|
+
Locals:
|
|
7
|
+
current — 1-indexed step number to fill up to (current: 2, total: 3 fills
|
|
8
|
+
2/3). Required.
|
|
9
|
+
total — total segment count. Default 3.
|
|
10
|
+
%>
|
|
11
|
+
<%
|
|
12
|
+
current = local_assigns.fetch(:current)
|
|
13
|
+
total = local_assigns.fetch(:total, 3)
|
|
14
|
+
%>
|
|
15
|
+
<div class="flex items-center justify-center gap-1.5 mx-auto max-w-[200px] mb-4">
|
|
16
|
+
<% total.times do |i| %>
|
|
17
|
+
<% if (i + 1) <= current %>
|
|
18
|
+
<div class="h-1.5 flex-1 rounded-full bg-primary"></div>
|
|
19
|
+
<% else %>
|
|
20
|
+
<div class="h-1.5 flex-1 rounded-full bg-inset border border-subtle"></div>
|
|
21
|
+
<% end %>
|
|
22
|
+
<% end %>
|
|
23
|
+
</div>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Shared modal shell — opt-in convenience wrapper for a title row + close ×.
|
|
3
|
+
Ported from turf-monster. Wrap content with:
|
|
4
|
+
render layout: "studio/modals/blocks/shell", locals: { title: "…" } do … end
|
|
5
|
+
|
|
6
|
+
Locals (all optional):
|
|
7
|
+
title — top-row title string.
|
|
8
|
+
dismissible — default true; suppress the close button when false. (The host
|
|
9
|
+
gates escape + click-outside on props.dismissible at open()
|
|
10
|
+
time, not this flag.)
|
|
11
|
+
modal_store — Alpine store name backing close(). Default "modals".
|
|
12
|
+
%>
|
|
13
|
+
<%
|
|
14
|
+
shell_title = local_assigns[:title]
|
|
15
|
+
shell_dismissible = local_assigns.fetch(:dismissible, true)
|
|
16
|
+
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
17
|
+
%>
|
|
18
|
+
<div>
|
|
19
|
+
<% if shell_title || shell_dismissible %>
|
|
20
|
+
<div class="flex items-start justify-between gap-3 -mt-2 -mr-2 mb-3">
|
|
21
|
+
<% if shell_title %>
|
|
22
|
+
<h3 class="text-heading font-bold text-lg leading-tight pt-1"><%= shell_title %></h3>
|
|
23
|
+
<% else %>
|
|
24
|
+
<span></span>
|
|
25
|
+
<% end %>
|
|
26
|
+
<% if shell_dismissible %>
|
|
27
|
+
<button type="button" @click="$store.<%= modal_store %>.close()"
|
|
28
|
+
class="text-secondary hover:text-heading text-xl leading-none flex-shrink-0"
|
|
29
|
+
aria-label="Close">×</button>
|
|
30
|
+
<% end %>
|
|
31
|
+
</div>
|
|
32
|
+
<% end %>
|
|
33
|
+
|
|
34
|
+
<%= yield %>
|
|
35
|
+
</div>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Branded Solana explorer tx link — gradient Solana mark + truncated signature
|
|
3
|
+
+ external-link glyph in a subtle bordered pill. Shared by the on-chain
|
|
4
|
+
success cards (studio/modals/blocks/_onchain_success and a consumer's entry
|
|
5
|
+
card). Mounts only when the signature expression is truthy. Ported from
|
|
6
|
+
turf-monster; the cluster query is a plain local so the engine partial carries
|
|
7
|
+
no Solana::Config dependency.
|
|
8
|
+
|
|
9
|
+
Only one success card is ever in the DOM at a time, so the fixed gradient id
|
|
10
|
+
is collision-safe.
|
|
11
|
+
|
|
12
|
+
Locals:
|
|
13
|
+
tx_signature_key — Alpine expression returning the Solana tx hash. Required.
|
|
14
|
+
cluster_param — appended to the explorer URL (e.g. "?cluster=devnet").
|
|
15
|
+
Default "" (mainnet).
|
|
16
|
+
%>
|
|
17
|
+
<%
|
|
18
|
+
tx_signature_key = local_assigns.fetch(:tx_signature_key)
|
|
19
|
+
cluster_param = local_assigns.fetch(:cluster_param, "")
|
|
20
|
+
%>
|
|
21
|
+
<template x-if="(<%= tx_signature_key %>)">
|
|
22
|
+
<div class="text-center mb-3 -mt-1">
|
|
23
|
+
<a :href="'https://explorer.solana.com/tx/' + (<%= tx_signature_key %>) + '<%= cluster_param %>'"
|
|
24
|
+
target="_blank" rel="noopener"
|
|
25
|
+
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border border-subtle hover:border-primary/50 transition group no-underline"
|
|
26
|
+
style="background: rgb(var(--color-primary-500-rgb) / 0.06);">
|
|
27
|
+
<svg width="14" height="11" viewBox="0 0 397 311" fill="none">
|
|
28
|
+
<defs><linearGradient id="tx-solana-grad" x1="361" y1="-9" x2="153" y2="389" gradientUnits="userSpaceOnUse">
|
|
29
|
+
<stop offset="0" stop-color="#00FFA3"/><stop offset="1" stop-color="#DC1FFF"/>
|
|
30
|
+
</linearGradient></defs>
|
|
31
|
+
<path d="M65 234c2-2 6-4 9-4h317c6 0 9 7 5 11l-63 63c-2 2-6 4-9 4H6c-6 0-9-7-5-11l64-63z" fill="url(#tx-solana-grad)"/>
|
|
32
|
+
<path d="M65 4c2-2 6-4 9-4h317c6 0 9 7 5 11l-63 63c-2 2-6 4-9 4H6c-6 0-9-7-5-11L65 4z" fill="url(#tx-solana-grad)"/>
|
|
33
|
+
<path d="M333 119c-2-2-6-4-9-4H7c-6 0-9 7-5 11l63 63c2 2 6 4 9 4h317c6 0 9-7 5-11l-63-63z" fill="url(#tx-solana-grad)"/>
|
|
34
|
+
</svg>
|
|
35
|
+
<span class="font-mono text-[11px] text-secondary group-hover:text-primary transition"
|
|
36
|
+
x-text="(<%= tx_signature_key %>) ? ((<%= tx_signature_key %>).substring(0, 16) + '…') : ''"></span>
|
|
37
|
+
<svg class="w-3 h-3 text-muted group-hover:text-primary transition" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
38
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
|
39
|
+
</svg>
|
|
40
|
+
</a>
|
|
41
|
+
</div>
|
|
42
|
+
</template>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Legal-age attestation checkbox — underwriting compliance for skill-based
|
|
3
|
+
contests. Ported from turf-monster into the engine. Binds x-model into the
|
|
4
|
+
caller's Alpine scope; the caller gates its CTAs on that property. The SERVER
|
|
5
|
+
is the real boundary — every signup endpoint rejects new account creation
|
|
6
|
+
without the attestation.
|
|
7
|
+
|
|
8
|
+
Unlike the turf-monster original, this engine partial does NOT self-gate on an
|
|
9
|
+
AppFlags flag — a consumer that wants the attestation renders it; one that
|
|
10
|
+
doesn't simply omits the render. Callers that omit it initialize their
|
|
11
|
+
ageAttested Alpine state to true so every CTA gate passes.
|
|
12
|
+
|
|
13
|
+
Locals (all optional):
|
|
14
|
+
x_model — Alpine model target (default "ageAttested")
|
|
15
|
+
error_model — Alpine error-flag target (default "ageError")
|
|
16
|
+
%>
|
|
17
|
+
<%
|
|
18
|
+
x_model = local_assigns.fetch(:x_model, "ageAttested")
|
|
19
|
+
error_model = local_assigns.fetch(:error_model, "ageError")
|
|
20
|
+
%>
|
|
21
|
+
<div>
|
|
22
|
+
<label class="flex items-start gap-2.5 text-xs text-secondary leading-snug cursor-pointer select-none">
|
|
23
|
+
<input type="checkbox" x-model="<%= x_model %>" @change="<%= error_model %> = false"
|
|
24
|
+
data-age-attestation
|
|
25
|
+
class="mt-0.5 w-4 h-4 shrink-0 rounded accent-primary cursor-pointer">
|
|
26
|
+
<span>
|
|
27
|
+
I confirm I am of legal age to participate in skill-based contests in my
|
|
28
|
+
state (18+ in most states; 19+ in AL/NE; 21+ in IA/MA/VA).
|
|
29
|
+
</span>
|
|
30
|
+
</label>
|
|
31
|
+
<p x-show="<%= error_model %>" x-cloak class="text-xs text-red-400 mt-1.5 ml-6">
|
|
32
|
+
Please confirm you are of legal age to continue.
|
|
33
|
+
</p>
|
|
34
|
+
</div>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Email input for the modal family — a themed <input type="email"> that either
|
|
3
|
+
posts through its own `name` or feeds an enclosing x-model. Ported from
|
|
4
|
+
turf-monster; the live client-side validity decorator (window.emailValidator)
|
|
5
|
+
is intentionally NOT a dependency here so the engine partial is self-contained
|
|
6
|
+
— the server stays the real boundary. A consumer that ships emailValidator can
|
|
7
|
+
pass validator: true to opt back into the right-edge spinner / check / X.
|
|
8
|
+
|
|
9
|
+
Locals (all optional, sensible defaults via local_assigns.fetch):
|
|
10
|
+
name — input name attr (default "email"; pass nil/false to omit,
|
|
11
|
+
e.g. a modal that dispatches its x-model value instead)
|
|
12
|
+
id — input id attr (default: the name)
|
|
13
|
+
value — prefill value (default "")
|
|
14
|
+
placeholder — (default "you@example.com")
|
|
15
|
+
required — (default false)
|
|
16
|
+
autocomplete — (default "email")
|
|
17
|
+
autofocus — (default false)
|
|
18
|
+
input_class — base input classes (default "input-field")
|
|
19
|
+
x_model — Alpine x-model target resolved in an ENCLOSING scope
|
|
20
|
+
x_ref — Alpine x-ref name
|
|
21
|
+
disabled_expr — Alpine :disabled expression (e.g. "props.submitting")
|
|
22
|
+
validator — when true, wraps in x-data="emailValidator()" and paints the
|
|
23
|
+
right-edge validity indicator (consumer must ship the factory)
|
|
24
|
+
%>
|
|
25
|
+
<%
|
|
26
|
+
name = local_assigns.fetch(:name, "email")
|
|
27
|
+
field_id = local_assigns.fetch(:id, name)
|
|
28
|
+
field_value = local_assigns.fetch(:value, "")
|
|
29
|
+
placeholder = local_assigns.fetch(:placeholder, "you@example.com")
|
|
30
|
+
required = local_assigns.fetch(:required, false)
|
|
31
|
+
autocomplete = local_assigns.fetch(:autocomplete, "email")
|
|
32
|
+
autofocus = local_assigns.fetch(:autofocus, false)
|
|
33
|
+
input_class = local_assigns.fetch(:input_class, "input-field")
|
|
34
|
+
x_model = local_assigns.fetch(:x_model, nil)
|
|
35
|
+
x_ref = local_assigns.fetch(:x_ref, nil)
|
|
36
|
+
disabled_expr = local_assigns.fetch(:disabled_expr, nil)
|
|
37
|
+
validator = local_assigns.fetch(:validator, false)
|
|
38
|
+
%>
|
|
39
|
+
<div class="relative"<%= ' x-data="emailValidator()"'.html_safe if validator %>>
|
|
40
|
+
<input type="email" inputmode="email"
|
|
41
|
+
class="<%= input_class %><%= ' pr-10' if validator %>"
|
|
42
|
+
<% if validator %>:class="{ 'email-reject': _rejecting }"<% end %>
|
|
43
|
+
placeholder="<%= placeholder %>"
|
|
44
|
+
autocomplete="<%= autocomplete %>"
|
|
45
|
+
<%= "required" if required %>
|
|
46
|
+
<%= "autofocus" if autofocus %>
|
|
47
|
+
<% if name %>name="<%= name %>"<% end %>
|
|
48
|
+
<% if field_id %>id="<%= field_id %>"<% end %>
|
|
49
|
+
value="<%= field_value %>"
|
|
50
|
+
<% if x_model %>x-model="<%= x_model %>"<% end %>
|
|
51
|
+
<% if x_ref %>x-ref="<%= x_ref %>"<% end %>
|
|
52
|
+
<% if disabled_expr %>:disabled="<%= disabled_expr %>"<% end %>
|
|
53
|
+
<% if validator %>@input="onInput($event)" @change="onInput($event)" @blur="onBlur($event)"<% end %>>
|
|
54
|
+
|
|
55
|
+
<% if validator %>
|
|
56
|
+
<%# Right-edge validity indicator — consumer's emailValidator drives status.
|
|
57
|
+
pointer-events-none so it never eats a click on the input behind it. %>
|
|
58
|
+
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
|
59
|
+
<span x-show="status === 'loading'" x-cloak class="spinner text-secondary" aria-hidden="true"></span>
|
|
60
|
+
<svg x-show="status === 'valid'" x-cloak xmlns="http://www.w3.org/2000/svg" class="w-5 h-5"
|
|
61
|
+
style="color: var(--color-success)" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
|
|
62
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
|
|
63
|
+
</svg>
|
|
64
|
+
<svg x-show="status === 'invalid'" x-cloak xmlns="http://www.w3.org/2000/svg" class="w-5 h-5"
|
|
65
|
+
style="color: var(--color-danger)" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
|
|
66
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
|
|
67
|
+
</svg>
|
|
68
|
+
</div>
|
|
69
|
+
<% end %>
|
|
70
|
+
</div>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<%# === Template: ACTION modal ======================================
|
|
2
|
+
One-question dialog: centered icon pill + title + subtitle + primary /
|
|
3
|
+
secondary CTAs. Use for confirmations, alerts, destructive prompts.
|
|
4
|
+
Composes card_header for the top chunk. Ported from turf-monster.
|
|
5
|
+
|
|
6
|
+
Variants: pass icon :error / :external_tab / icon_emoji / spinner OR
|
|
7
|
+
icon_color :primary / :success / :warning to card_header.
|
|
8
|
+
|
|
9
|
+
Local: modal_store — Alpine store name backing close(). Default "modals". %>
|
|
10
|
+
<% modal_store = local_assigns.fetch(:modal_store, "modals") %>
|
|
11
|
+
<div x-data>
|
|
12
|
+
<%= render "studio/modals/blocks/card_header",
|
|
13
|
+
size: :lg,
|
|
14
|
+
icon: :error,
|
|
15
|
+
title: "Action Template",
|
|
16
|
+
subtitle: "A centered confirmation card with a primary + secondary CTA. Best for one-question dialogs and destructive prompts." %>
|
|
17
|
+
|
|
18
|
+
<button @click="$store.<%= modal_store %>.close()" class="btn btn-danger btn-lg w-full">
|
|
19
|
+
Confirm Action
|
|
20
|
+
</button>
|
|
21
|
+
<button @click="$store.<%= modal_store %>.close()"
|
|
22
|
+
class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
|
|
23
|
+
Cancel
|
|
24
|
+
</button>
|
|
25
|
+
</div>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<%# === Template: FORM modal ======================================
|
|
2
|
+
Starting point for any modal that captures input — settings, edit forms,
|
|
3
|
+
rename dialogs. Wraps content in the shared shell for a consistent title row
|
|
4
|
+
+ close ×. Ported from turf-monster.
|
|
5
|
+
|
|
6
|
+
Local: modal_store — Alpine store name backing close(). Default "modals". %>
|
|
7
|
+
<% modal_store = local_assigns.fetch(:modal_store, "modals") %>
|
|
8
|
+
<div x-data="{ value: '' }">
|
|
9
|
+
<%= render layout: "studio/modals/blocks/shell", locals: { title: "Form Template", modal_store: modal_store } do %>
|
|
10
|
+
<p class="text-sm text-muted mb-4">
|
|
11
|
+
Title row + close × come from <code class="text-xs bg-inset px-1 rounded">studio/modals/blocks/shell</code>.
|
|
12
|
+
Slot in whatever inputs + primary CTA your form needs.
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<form @submit.prevent="$store.<%= modal_store %>.close()" class="space-y-4">
|
|
16
|
+
<textarea x-model="value"
|
|
17
|
+
rows="3"
|
|
18
|
+
placeholder="Type something…"
|
|
19
|
+
class="input-field w-full resize-none"></textarea>
|
|
20
|
+
<button type="submit" class="btn btn-primary btn-lg w-full" :disabled="!value.trim()">
|
|
21
|
+
Save
|
|
22
|
+
</button>
|
|
23
|
+
</form>
|
|
24
|
+
<% end %>
|
|
25
|
+
</div>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<%# === Template: STATUS modal ======================================
|
|
2
|
+
Small-title in-flight card for short async work — server save, poll,
|
|
3
|
+
side-effect that finishes in a second or two. card_header default :md keeps
|
|
4
|
+
the title subordinate to the spinner. For end-state celebrations use the
|
|
5
|
+
success template. Ported from turf-monster; the demo cycles loading → error
|
|
6
|
+
at 1.6s so a viewer can step through both states.
|
|
7
|
+
|
|
8
|
+
Local: modal_store — Alpine store name backing close(). Default "modals". %>
|
|
9
|
+
<% modal_store = local_assigns.fetch(:modal_store, "modals") %>
|
|
10
|
+
<div x-data="{
|
|
11
|
+
state: 'loading',
|
|
12
|
+
start() {
|
|
13
|
+
this.state = 'loading';
|
|
14
|
+
var self = this;
|
|
15
|
+
setTimeout(function() { self.state = 'error'; }, 1600);
|
|
16
|
+
}
|
|
17
|
+
}"
|
|
18
|
+
x-init="start()">
|
|
19
|
+
<template x-if="state === 'loading'">
|
|
20
|
+
<div>
|
|
21
|
+
<%= render "studio/modals/blocks/card_header",
|
|
22
|
+
spinner: true,
|
|
23
|
+
title: "Working on it…",
|
|
24
|
+
subtitle: "Status template — small-title in-flight card for short async work." %>
|
|
25
|
+
<button @click="$store.<%= modal_store %>.close()"
|
|
26
|
+
class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
|
|
27
|
+
Cancel
|
|
28
|
+
</button>
|
|
29
|
+
</div>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<template x-if="state === 'error'">
|
|
33
|
+
<div>
|
|
34
|
+
<%= render "studio/modals/blocks/card_header",
|
|
35
|
+
icon: :error,
|
|
36
|
+
title: "Something went wrong",
|
|
37
|
+
subtitle: "Surface the real error message here. Try Again should re-run the failed op." %>
|
|
38
|
+
<button @click="start()" class="btn btn-primary btn-lg w-full">Try Again</button>
|
|
39
|
+
<button @click="$store.<%= modal_store %>.close()"
|
|
40
|
+
class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
|
|
41
|
+
Cancel
|
|
42
|
+
</button>
|
|
43
|
+
</div>
|
|
44
|
+
</template>
|
|
45
|
+
</div>
|