studio-engine 0.4.4 → 0.4.5
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 +25 -0
- data/app/views/studio/modals/_host.html.erb +156 -0
- data/app/views/studio/modals/blocks/_error_card.html.erb +50 -0
- data/app/views/studio/modals/blocks/_processing_card.html.erb +32 -0
- data/app/views/studio/modals/blocks/_progress_countdown.html.erb +38 -0
- data/app/views/studio/modals/blocks/_success_card.html.erb +141 -0
- data/lib/studio/version.rb +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b8bd5ff3b5b881934c12d4983ed7b2920f1d05cbb4b6cdd366699a074dae0278
|
|
4
|
+
data.tar.gz: a8de43a744f4a59201eb12a356b8c6a7958b6530f9140fc0c23de68919bd21a0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cea4fef7a573ca81bd340f0c4dfa9008b4f5a061eb7aa4e43a26cd521a80712494b1a118f2267af9a50c05f1b50976b5735ab141ba0bdee3cf497ce0c1ac22d6
|
|
7
|
+
data.tar.gz: 76bdfd4a85634ec028f2500a589de5df52cce55063571ba362b589c0a7711794463ae133435299a47737a710cb3aa8ba04057d7f1f4521ca8d8d7ab6b03f6c44
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Both consumer Rails apps pin to a tag in their `Gemfile`; bumping the tag is a release.
|
|
4
4
|
|
|
5
|
+
## v0.4.5 (2026-05-23)
|
|
6
|
+
|
|
7
|
+
Modal infrastructure — same shape as the toast system from v0.4.0. Apps render `studio/modals/host` once, then open through `Alpine.store('modals')` and compose the shared content blocks. No migration required for v0.4.4 consumers.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`studio/modals/host` partial.** Single shared shell that bundles the scroll-lock CSS, bfcache/Turbo snapshot cleanup, `Alpine.store('modals')` registration (stack-based), `window.StudioModals.holdAtLeast(ms)` helper, and the modal markup (z-[120] backdrop, fade-and-scale transitions, escape/click-outside/ARIA dialog). Consumer renders once in `application.html.erb` with a block that registers their app-specific content partials by id:
|
|
11
|
+
```erb
|
|
12
|
+
<%= render "studio/modals/host" do %>
|
|
13
|
+
<template x-if="$store.modals.current().id === 'auth'">
|
|
14
|
+
<%= render "modals/auth" %>
|
|
15
|
+
</template>
|
|
16
|
+
<% end %>
|
|
17
|
+
```
|
|
18
|
+
- **`Alpine.store('modals')` stack store.** API: `open(id, props, opts)` (with `opts.replace: true` for flicker-free transitions between steps in a wizard), `close()`, `closeAll()`, `isOpen(id)`, `current()`. Auto-syncs `body.modal-open` for scroll lock. Stack-based so modals can nest (e.g. confirm-on-top-of-form).
|
|
19
|
+
- **`window.StudioModals.holdAtLeast(ms)` helper.** Stamps the moment a loading view becomes visible, returns `{ then(callback) }` that delays the callback by the remaining time if the operation finished before the minimum. Replaces ad-hoc `Date.now() - startedAt` arithmetic at every async-success site. Mirrors `_navSpinnerMinMs` from `_head.html.erb`.
|
|
20
|
+
- **Four reusable content-block partials in `studio/modals/blocks/`.** Composable building blocks any modal can render to assemble its inner content:
|
|
21
|
+
- `_success_card` — icon (default green check, or any emoji), title, optional sub-text, optional Solana tx-signature explorer link, primary CTA (href or dispatched event), secondary CTA, **self-driven auto-redirect countdown** (progress bar + "Xs…" text, fires `window.location.href` at zero), and **opt-in confetti** burst via `window.fireSuccessConfetti`.
|
|
22
|
+
- `_error_card` — emoji icon (default ⏳, configurable for ⚠️ / 📍 / etc.), title, message (static string or Alpine-expression key for live updates), CTA that can reload the page, dispatch an event, or be omitted.
|
|
23
|
+
- `_processing_card` — spinner (sm/md/lg, three color tokens) + title + optional message. Designed to pair with `holdAtLeast` on the caller side.
|
|
24
|
+
- `_progress_countdown` — standalone progress bar + countdown text. Reads display values from caller-provided Alpine expressions so externally-driven countdowns (board's `setInterval` mutating `$store.modals` props) and internally-driven ones (success card's own timer) can share the same visualization.
|
|
25
|
+
|
|
26
|
+
### Architecture
|
|
27
|
+
- **Content vs. blocks.** Each consumer app owns its modal *content* partials (turf-monster's `modals/auth`, mcritchie-studio's account flows, etc.) because the flows are product-specific. The engine provides the *shell* (host) and the *building blocks* (cards) because those are universal UI vocabulary.
|
|
28
|
+
- **Single root requirement** on modal content partials — Alpine's `<template x-if>` clones only the first root element from its content. Top-level `<style>` blocks or stray siblings are silently dropped. Bake the style inside the partial's outer wrapping `<div>` if needed.
|
|
29
|
+
|
|
5
30
|
## v0.4.4 (2026-05-20)
|
|
6
31
|
|
|
7
32
|
Sticky-navbar scroll fixes — bounce-free for every consuming app, no migration required.
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Modal host — single shared shell for any modal that opens through
|
|
3
|
+
Alpine.store('modals'). Owns the backdrop, scroll lock (via
|
|
4
|
+
.modal-open on <body>), escape key, click-outside, transitions,
|
|
5
|
+
and ARIA dialog role.
|
|
6
|
+
|
|
7
|
+
Bundles together (toast partial precedent):
|
|
8
|
+
• body.modal-open scroll-lock CSS
|
|
9
|
+
• bfcache + Turbo Drive snapshot cleanup
|
|
10
|
+
• Alpine.store('modals') registration (stack-based API)
|
|
11
|
+
• window.StudioModals.holdAtLeast(ms) helper for min-visible-time
|
|
12
|
+
• The host shell markup (backdrop, card chrome, transitions, ARIA)
|
|
13
|
+
|
|
14
|
+
Consumer renders ONCE in the application layout and passes a block
|
|
15
|
+
that registers content partials by modal id:
|
|
16
|
+
|
|
17
|
+
<%%= render "studio/modals/host" do %%>
|
|
18
|
+
<template x-if="$store.modals.current().id === 'auth'">
|
|
19
|
+
<%%= render "modals/auth" %%>
|
|
20
|
+
</template>
|
|
21
|
+
<template x-if="$store.modals.current().id === 'checkEmail'">
|
|
22
|
+
<%%= render "modals/check_email" %%>
|
|
23
|
+
</template>
|
|
24
|
+
<%% end %%>
|
|
25
|
+
|
|
26
|
+
Modal CONTENT partials live in the consumer app (each app's auth /
|
|
27
|
+
checkout / tx / etc. is product-specific). The reusable content
|
|
28
|
+
BLOCKS live next to this partial in studio/modals/blocks/.
|
|
29
|
+
|
|
30
|
+
API (window.StudioModals + Alpine store):
|
|
31
|
+
$store.modals.open(id, props = {}, opts = {})
|
|
32
|
+
opts.replace: true → swap top of stack (no stack flicker)
|
|
33
|
+
$store.modals.close() pop top
|
|
34
|
+
$store.modals.closeAll()
|
|
35
|
+
$store.modals.isOpen(id)
|
|
36
|
+
$store.modals.current()
|
|
37
|
+
|
|
38
|
+
StudioModals.holdAtLeast(ms) returns { then(cb) }
|
|
39
|
+
Stamps mount time, delays cb so a fast operation can't flash
|
|
40
|
+
past the loading state. Mirrors the _navSpinnerMinMs pattern.
|
|
41
|
+
|
|
42
|
+
z-[120] sits above the navbar (z-[110]) and above toasts (z-60).
|
|
43
|
+
%>
|
|
44
|
+
<style>
|
|
45
|
+
/* Scroll lock applied by $store.modals._sync() when the stack is non-empty.
|
|
46
|
+
Combined with the fixed backdrop on the host below, this keeps the
|
|
47
|
+
page beneath any modal from scrolling on wheel / touch / spacebar. */
|
|
48
|
+
body.modal-open { overflow: hidden; }
|
|
49
|
+
</style>
|
|
50
|
+
|
|
51
|
+
<script>
|
|
52
|
+
(function() {
|
|
53
|
+
// === StudioModals helpers ===========================================
|
|
54
|
+
//
|
|
55
|
+
// holdAtLeast(ms) — pair with the _processing_card block. Stamp the
|
|
56
|
+
// moment a loading view becomes visible, then await the minimum
|
|
57
|
+
// before swapping to success so the spinner doesn't flash past the
|
|
58
|
+
// user when the operation finishes fast.
|
|
59
|
+
window.StudioModals = window.StudioModals || {};
|
|
60
|
+
window.StudioModals.holdAtLeast = function(minMs) {
|
|
61
|
+
var startedAt = Date.now();
|
|
62
|
+
return {
|
|
63
|
+
then: function(callback) {
|
|
64
|
+
var remaining = Math.max(0, minMs - (Date.now() - startedAt));
|
|
65
|
+
if (remaining === 0) { callback(); return; }
|
|
66
|
+
setTimeout(callback, remaining);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// === Alpine.store('modals') — the stack ============================
|
|
72
|
+
//
|
|
73
|
+
// Idempotent registration (return early if already registered) so
|
|
74
|
+
// consumer apps can include the host more than once without
|
|
75
|
+
// double-init.
|
|
76
|
+
document.addEventListener('alpine:init', function() {
|
|
77
|
+
if (Alpine.store('modals')) return;
|
|
78
|
+
Alpine.store('modals', {
|
|
79
|
+
stack: [],
|
|
80
|
+
open: function(id, props, opts) {
|
|
81
|
+
props = props || {};
|
|
82
|
+
opts = opts || {};
|
|
83
|
+
if (opts.replace && this.stack.length > 0) {
|
|
84
|
+
this.stack[this.stack.length - 1] = { id: id, props: props };
|
|
85
|
+
} else {
|
|
86
|
+
this.stack.push({ id: id, props: props });
|
|
87
|
+
}
|
|
88
|
+
this._sync();
|
|
89
|
+
},
|
|
90
|
+
close: function() {
|
|
91
|
+
this.stack.pop();
|
|
92
|
+
this._sync();
|
|
93
|
+
},
|
|
94
|
+
closeAll: function() {
|
|
95
|
+
this.stack = [];
|
|
96
|
+
this._sync();
|
|
97
|
+
},
|
|
98
|
+
isOpen: function(id) {
|
|
99
|
+
for (var i = 0; i < this.stack.length; i++) {
|
|
100
|
+
if (this.stack[i].id === id) return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
},
|
|
104
|
+
current: function() {
|
|
105
|
+
return this.stack.length ? this.stack[this.stack.length - 1] : null;
|
|
106
|
+
},
|
|
107
|
+
_sync: function() {
|
|
108
|
+
if (this.stack.length) {
|
|
109
|
+
document.body.classList.add('modal-open');
|
|
110
|
+
} else {
|
|
111
|
+
document.body.classList.remove('modal-open');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// === bfcache + Turbo snapshot cleanup ==============================
|
|
118
|
+
//
|
|
119
|
+
// Without this, a user who lands on a celebratory modal and then
|
|
120
|
+
// navigates away (or hits the browser's bfcache on back) sees the
|
|
121
|
+
// modal again on their next visit. Clear the stack on both signals.
|
|
122
|
+
function closeAllModals() {
|
|
123
|
+
if (window.Alpine && Alpine.store) {
|
|
124
|
+
var m = Alpine.store('modals');
|
|
125
|
+
if (m && typeof m.closeAll === 'function') m.closeAll();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
window.addEventListener('pageshow', function(e) { if (e.persisted) closeAllModals(); });
|
|
129
|
+
document.addEventListener('turbo:before-cache', closeAllModals);
|
|
130
|
+
})();
|
|
131
|
+
</script>
|
|
132
|
+
|
|
133
|
+
<template x-if="$store.modals.current()">
|
|
134
|
+
<div class="fixed inset-0 z-[120] flex items-center justify-center p-4"
|
|
135
|
+
style="background:rgba(0,0,0,0.6)"
|
|
136
|
+
role="dialog"
|
|
137
|
+
aria-modal="true"
|
|
138
|
+
@keydown.escape.window="$store.modals.close()"
|
|
139
|
+
@click.self="$store.modals.close()"
|
|
140
|
+
x-transition:enter="transition ease-out duration-200"
|
|
141
|
+
x-transition:enter-start="opacity-0"
|
|
142
|
+
x-transition:enter-end="opacity-100"
|
|
143
|
+
x-transition:leave="transition ease-in duration-150"
|
|
144
|
+
x-transition:leave-start="opacity-100"
|
|
145
|
+
x-transition:leave-end="opacity-0">
|
|
146
|
+
<div class="bg-surface rounded-xl border border-subtle shadow-2xl p-6 max-w-sm w-full"
|
|
147
|
+
x-transition:enter="transition ease-out duration-200"
|
|
148
|
+
x-transition:enter-start="opacity-0 scale-95"
|
|
149
|
+
x-transition:enter-end="opacity-100 scale-100">
|
|
150
|
+
<%# Consumer-provided content registrations. Each block typically
|
|
151
|
+
contains a <template x-if="$store.modals.current().id === 'X'">
|
|
152
|
+
render "modals/X" </template>. %>
|
|
153
|
+
<%= yield if block_given? %>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</template>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Reusable error / "still processing" card. Shown when an operation
|
|
3
|
+
timed out, failed, or needs the user to retry. Same composability
|
|
4
|
+
rules as _success_card — host shell provides the card chrome.
|
|
5
|
+
|
|
6
|
+
Locals (every one optional except title):
|
|
7
|
+
title: required headline ("Still processing", "Couldn't connect", etc.)
|
|
8
|
+
message: small body text. Static string OR an Alpine expression
|
|
9
|
+
when message_key is set (see below).
|
|
10
|
+
message_key: optional Alpine expression for the message (e.g.
|
|
11
|
+
"props.errorText"). Takes priority over `message`
|
|
12
|
+
when both are set.
|
|
13
|
+
icon_emoji: icon emoji (default "⏳"). Use "⚠️" for hard errors,
|
|
14
|
+
"📍" for geo, etc.
|
|
15
|
+
|
|
16
|
+
cta_label: primary action label (default "Refresh").
|
|
17
|
+
cta_event: dispatch event name. Mutually exclusive with cta_reload.
|
|
18
|
+
cta_reload: boolean. When true, the primary button reloads the page.
|
|
19
|
+
Convenient for "Still processing" recoveries.
|
|
20
|
+
|
|
21
|
+
secondary_label: optional secondary action ("Close", "Cancel", etc).
|
|
22
|
+
secondary_event: dispatch event for the secondary action.
|
|
23
|
+
%>
|
|
24
|
+
<%
|
|
25
|
+
icon_emoji = local_assigns[:icon_emoji] || "⏳" # ⏳
|
|
26
|
+
cta_label = local_assigns[:cta_label] || 'Refresh'
|
|
27
|
+
%>
|
|
28
|
+
<div class="text-center py-6">
|
|
29
|
+
<div class="text-5xl mb-4 leading-none"><%= icon_emoji %></div>
|
|
30
|
+
<p class="text-sm text-heading font-semibold mb-2"><%= title %></p>
|
|
31
|
+
|
|
32
|
+
<% if local_assigns[:message_key] %>
|
|
33
|
+
<p class="text-xs text-secondary mb-4" x-text="<%= message_key %>"></p>
|
|
34
|
+
<% elsif local_assigns[:message] %>
|
|
35
|
+
<p class="text-xs text-secondary mb-4"><%= message %></p>
|
|
36
|
+
<% end %>
|
|
37
|
+
|
|
38
|
+
<% if local_assigns[:cta_reload] %>
|
|
39
|
+
<button @click="window.location.reload()" class="btn btn-outline btn-sm"><%= cta_label %></button>
|
|
40
|
+
<% elsif local_assigns[:cta_event] %>
|
|
41
|
+
<button @click="$dispatch('<%= cta_event %>')" class="btn btn-outline btn-sm"><%= cta_label %></button>
|
|
42
|
+
<% end %>
|
|
43
|
+
|
|
44
|
+
<% if local_assigns[:secondary_label] && local_assigns[:secondary_event] %>
|
|
45
|
+
<button @click="$dispatch('<%= secondary_event %>')"
|
|
46
|
+
class="block mx-auto mt-3 text-xs text-secondary hover:text-heading underline underline-offset-2">
|
|
47
|
+
<%= secondary_label %>
|
|
48
|
+
</button>
|
|
49
|
+
<% end %>
|
|
50
|
+
</div>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Reusable processing / loading card. Spinner + title + optional message.
|
|
3
|
+
Pair with window.StudioModals.holdAtLeast(ms) on the caller side to
|
|
4
|
+
enforce a minimum visible duration — without that the spinner can
|
|
5
|
+
flash by when the operation finishes faster than ~1s and the user
|
|
6
|
+
registers "did anything happen?" instead of "ok, processing →
|
|
7
|
+
success".
|
|
8
|
+
|
|
9
|
+
Locals:
|
|
10
|
+
title: required string ("Confirming your purchase…", etc.)
|
|
11
|
+
message: optional supporting text
|
|
12
|
+
size: spinner size — 'sm', 'md' (default), 'lg'
|
|
13
|
+
color: color token for the spinner — 'primary' (default),
|
|
14
|
+
'success', 'warning'
|
|
15
|
+
%>
|
|
16
|
+
<%
|
|
17
|
+
size = local_assigns[:size] || 'md'
|
|
18
|
+
color = local_assigns[:color] || 'primary'
|
|
19
|
+
sizes = {
|
|
20
|
+
'sm' => 'w-8 h-8 border-2',
|
|
21
|
+
'md' => 'w-12 h-12 border-4',
|
|
22
|
+
'lg' => 'w-16 h-16 border-4'
|
|
23
|
+
}
|
|
24
|
+
spinner_class = sizes[size] || sizes['md']
|
|
25
|
+
%>
|
|
26
|
+
<div class="text-center py-6">
|
|
27
|
+
<div class="mx-auto <%= spinner_class %> rounded-full border-<%= color %>/30 border-t-<%= color %> animate-spin mb-5"></div>
|
|
28
|
+
<p class="text-base font-bold text-heading mb-1"><%= title %></p>
|
|
29
|
+
<% if local_assigns[:message] %>
|
|
30
|
+
<p class="text-xs text-secondary"><%= message %></p>
|
|
31
|
+
<% end %>
|
|
32
|
+
</div>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Standalone countdown bar + text. Visualization only — the actual
|
|
3
|
+
ticking state is owned by the caller (e.g. board's setInterval
|
|
4
|
+
mutating $store.modals.current().props.countdown). Reads its display
|
|
5
|
+
values from Alpine expressions passed via locals.
|
|
6
|
+
|
|
7
|
+
Locals:
|
|
8
|
+
current_key: required. Alpine expression for the current count
|
|
9
|
+
(e.g. "props.countdown").
|
|
10
|
+
total_key: required. Alpine expression for the total / starting
|
|
11
|
+
count (e.g. "props.seconds").
|
|
12
|
+
label: optional template. Use {count} as the placeholder for
|
|
13
|
+
the current value. Default: "Redirecting in {count}s…".
|
|
14
|
+
Pass nil to suppress the text entirely.
|
|
15
|
+
height_px: bar height in pixels. Default 6.
|
|
16
|
+
%>
|
|
17
|
+
<%
|
|
18
|
+
height = local_assigns[:height_px] || 6
|
|
19
|
+
default_label = "Redirecting in {count}s…"
|
|
20
|
+
label_raw = local_assigns.fetch(:label, default_label)
|
|
21
|
+
# Split around {count} so we can interpolate an x-text span without
|
|
22
|
+
# the placeholder ending up in the visible string when JS is off.
|
|
23
|
+
if label_raw
|
|
24
|
+
label_parts = label_raw.split('{count}', 2)
|
|
25
|
+
label_before = label_parts.first
|
|
26
|
+
label_after = label_parts.length > 1 ? label_parts.last : ''
|
|
27
|
+
end
|
|
28
|
+
%>
|
|
29
|
+
<div>
|
|
30
|
+
<div class="w-full rounded-full overflow-hidden mb-2" style="height:<%= height %>px;background:rgba(128,128,128,0.2)">
|
|
31
|
+
<div :style="'height:100%;border-radius:9999px;background:var(--color-cta);transition:width 1s linear;width:' + ((<%= current_key %>) / ((<%= total_key %>) || 1) * 100) + '%'"></div>
|
|
32
|
+
</div>
|
|
33
|
+
<% if label_raw %>
|
|
34
|
+
<p class="text-secondary text-xs">
|
|
35
|
+
<%= label_before %><span class="text-primary font-mono font-bold" x-text="<%= current_key %>"></span><%= label_after %>
|
|
36
|
+
</p>
|
|
37
|
+
<% end %>
|
|
38
|
+
</div>
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Reusable success card — the green-check celebration screen used by
|
|
3
|
+
the auth wizard's tokens-submitted step, the solanaModal success
|
|
4
|
+
state, and any future "X confirmed!" modal. Composable building
|
|
5
|
+
block; the host shell (backdrop, p-6, max-w-sm) is provided by
|
|
6
|
+
shared/_modal_host so this partial only renders inside-the-card
|
|
7
|
+
content.
|
|
8
|
+
|
|
9
|
+
Locals — every one is optional EXCEPT title:
|
|
10
|
+
|
|
11
|
+
title: (required) headline. Bold, prominent.
|
|
12
|
+
message: sub-headline. One sentence.
|
|
13
|
+
|
|
14
|
+
icon_emoji: renders the given emoji as the icon instead
|
|
15
|
+
of the default green check (e.g. "📍").
|
|
16
|
+
icon_color: Tailwind color token for the default check;
|
|
17
|
+
one of 'primary' (default), 'success', 'warning'.
|
|
18
|
+
|
|
19
|
+
tx_signature_key: Alpine expression that resolves to a Solana
|
|
20
|
+
tx signature (e.g. "props.txSignature"). When
|
|
21
|
+
truthy, renders the truncated explorer link.
|
|
22
|
+
Uses the global clusterParam set by the
|
|
23
|
+
solanaModal init in application.html.erb.
|
|
24
|
+
|
|
25
|
+
cta_label: primary CTA button text.
|
|
26
|
+
cta_href_key: Alpine expression for the button's href
|
|
27
|
+
(e.g. "props.redirectUrl || '/'"). Mutually
|
|
28
|
+
exclusive with cta_event.
|
|
29
|
+
cta_event: window event name to dispatch on click
|
|
30
|
+
(e.g. "auth-go-now"). Mutually exclusive
|
|
31
|
+
with cta_href_key.
|
|
32
|
+
|
|
33
|
+
secondary_label: optional secondary action label.
|
|
34
|
+
secondary_event: optional secondary action dispatch event.
|
|
35
|
+
|
|
36
|
+
auto_redirect_url_key: Alpine expression for the redirect URL. When
|
|
37
|
+
set, an internal countdown ticks down and
|
|
38
|
+
navigates when it hits zero. Renders the
|
|
39
|
+
progress bar + "Redirecting in Xs…" text.
|
|
40
|
+
auto_redirect_seconds: total seconds for the countdown. Default 5.
|
|
41
|
+
|
|
42
|
+
confetti: boolean. When true, fires
|
|
43
|
+
window.fireSuccessConfetti() ~100ms after
|
|
44
|
+
mount so the card is visible before the burst.
|
|
45
|
+
%>
|
|
46
|
+
<%
|
|
47
|
+
icon_color = local_assigns[:icon_color] || 'primary'
|
|
48
|
+
redirect_secs = local_assigns[:auto_redirect_seconds] || 5
|
|
49
|
+
has_redirect = !!local_assigns[:auto_redirect_url_key]
|
|
50
|
+
fire_confetti = !!local_assigns[:confetti]
|
|
51
|
+
|
|
52
|
+
data_attr = "{
|
|
53
|
+
_remaining: #{redirect_secs},
|
|
54
|
+
_total: #{redirect_secs},
|
|
55
|
+
_redirectTimer: null,
|
|
56
|
+
startCountdown(url) {
|
|
57
|
+
if (!url) return;
|
|
58
|
+
var self = this;
|
|
59
|
+
if (self._redirectTimer) clearInterval(self._redirectTimer);
|
|
60
|
+
self._redirectTimer = setInterval(function() {
|
|
61
|
+
self._remaining--;
|
|
62
|
+
if (self._remaining <= 0) {
|
|
63
|
+
clearInterval(self._redirectTimer);
|
|
64
|
+
self._redirectTimer = null;
|
|
65
|
+
window.location.href = url;
|
|
66
|
+
}
|
|
67
|
+
}, 1000);
|
|
68
|
+
},
|
|
69
|
+
fireConfetti() {
|
|
70
|
+
setTimeout(function() {
|
|
71
|
+
try { if (window.fireSuccessConfetti) window.fireSuccessConfetti(); } catch (_) {}
|
|
72
|
+
}, 100);
|
|
73
|
+
}
|
|
74
|
+
}".gsub(/\s+/, ' ').html_safe
|
|
75
|
+
|
|
76
|
+
init_calls = []
|
|
77
|
+
init_calls << "fireConfetti()" if fire_confetti
|
|
78
|
+
init_calls << "startCountdown(#{local_assigns[:auto_redirect_url_key]})" if has_redirect
|
|
79
|
+
%>
|
|
80
|
+
<div x-data="<%= data_attr %>"
|
|
81
|
+
<%= "x-init=\"#{init_calls.join('; ')}\"".html_safe if init_calls.any? %>
|
|
82
|
+
class="text-center py-6">
|
|
83
|
+
|
|
84
|
+
<%# Icon — emoji takes priority over the default circular green check %>
|
|
85
|
+
<% if local_assigns[:icon_emoji] %>
|
|
86
|
+
<div class="text-5xl mb-4 leading-none"><%= icon_emoji %></div>
|
|
87
|
+
<% else %>
|
|
88
|
+
<div class="mx-auto w-14 h-14 rounded-full bg-<%= icon_color %>/15 flex items-center justify-center mb-4">
|
|
89
|
+
<svg class="w-7 h-7 text-<%= icon_color %>" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
|
90
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
|
91
|
+
</svg>
|
|
92
|
+
</div>
|
|
93
|
+
<% end %>
|
|
94
|
+
|
|
95
|
+
<p class="text-lg font-bold text-heading mb-1"><%= title %></p>
|
|
96
|
+
<% if local_assigns[:message] %>
|
|
97
|
+
<p class="text-xs text-secondary mb-4"><%= message %></p>
|
|
98
|
+
<% end %>
|
|
99
|
+
|
|
100
|
+
<%# Explorer link — only rendered when the tx_signature_key expression
|
|
101
|
+
resolves to a truthy value (e.g. props.txSignature is set). %>
|
|
102
|
+
<% if local_assigns[:tx_signature_key] %>
|
|
103
|
+
<div x-show="<%= tx_signature_key %>" class="mb-5">
|
|
104
|
+
<a :href="'https://explorer.solana.com/tx/' + (<%= tx_signature_key %>) + (typeof clusterParam !== 'undefined' ? clusterParam : '')"
|
|
105
|
+
target="_blank" rel="noopener"
|
|
106
|
+
class="inline-flex items-center gap-1.5 text-xs font-mono text-secondary hover:text-primary underline underline-offset-2">
|
|
107
|
+
<span x-text="(<%= tx_signature_key %>) ? ((<%= tx_signature_key %>).slice(0, 8) + '…' + (<%= tx_signature_key %>).slice(-4)) : ''"></span>
|
|
108
|
+
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
109
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
|
110
|
+
</svg>
|
|
111
|
+
</a>
|
|
112
|
+
</div>
|
|
113
|
+
<% end %>
|
|
114
|
+
|
|
115
|
+
<%# Auto-redirect countdown — bar + "Redirecting in Xs…" text. Reads
|
|
116
|
+
from the partial's own _remaining/_total (started by x-init above). %>
|
|
117
|
+
<% if has_redirect %>
|
|
118
|
+
<div class="w-full rounded-full overflow-hidden mb-2" style="height:6px;background:rgba(128,128,128,0.2)">
|
|
119
|
+
<div :style="'height:100%;border-radius:9999px;background:var(--color-cta);transition:width 1s linear;width:' + (_remaining / _total * 100) + '%'"></div>
|
|
120
|
+
</div>
|
|
121
|
+
<p class="text-secondary text-xs mb-4">
|
|
122
|
+
Redirecting in <span class="text-primary font-mono font-bold" x-text="_remaining"></span>s…
|
|
123
|
+
</p>
|
|
124
|
+
<% end %>
|
|
125
|
+
|
|
126
|
+
<%# Primary CTA — either an <a href=...> or a <button> dispatching an event. %>
|
|
127
|
+
<% if local_assigns[:cta_label] %>
|
|
128
|
+
<% if local_assigns[:cta_href_key] %>
|
|
129
|
+
<a :href="<%= cta_href_key %>" class="btn btn-primary w-full"><%= cta_label %></a>
|
|
130
|
+
<% elsif local_assigns[:cta_event] %>
|
|
131
|
+
<button @click="$dispatch('<%= cta_event %>')" class="btn btn-primary w-full"><%= cta_label %></button>
|
|
132
|
+
<% end %>
|
|
133
|
+
<% end %>
|
|
134
|
+
|
|
135
|
+
<% if local_assigns[:secondary_label] && local_assigns[:secondary_event] %>
|
|
136
|
+
<button @click="$dispatch('<%= secondary_event %>')"
|
|
137
|
+
class="block mx-auto mt-3 text-xs text-secondary hover:text-heading underline underline-offset-2">
|
|
138
|
+
<%= secondary_label %>
|
|
139
|
+
</button>
|
|
140
|
+
<% end %>
|
|
141
|
+
</div>
|
data/lib/studio/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05-
|
|
11
|
+
date: 2026-05-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -146,6 +146,11 @@ files:
|
|
|
146
146
|
- app/views/schema/index.html.erb
|
|
147
147
|
- app/views/sessions/_sso_continue.html.erb
|
|
148
148
|
- app/views/sessions/new.html.erb
|
|
149
|
+
- app/views/studio/modals/_host.html.erb
|
|
150
|
+
- app/views/studio/modals/blocks/_error_card.html.erb
|
|
151
|
+
- app/views/studio/modals/blocks/_processing_card.html.erb
|
|
152
|
+
- app/views/studio/modals/blocks/_progress_countdown.html.erb
|
|
153
|
+
- app/views/studio/modals/blocks/_success_card.html.erb
|
|
149
154
|
- app/views/theme_settings/edit.html.erb
|
|
150
155
|
- lib/studio-engine.rb
|
|
151
156
|
- lib/studio.rb
|