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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 288a5cb85deb7c2c43a77b24e4d5bc1b958fd5652e2860bbe88a12d43c22f21e
4
- data.tar.gz: 1c6ee3b69613649611963e56ba681d066dfeda518bc1010e764ac560e9c308af
3
+ metadata.gz: b8bd5ff3b5b881934c12d4983ed7b2920f1d05cbb4b6cdd366699a074dae0278
4
+ data.tar.gz: a8de43a744f4a59201eb12a356b8c6a7958b6530f9140fc0c23de68919bd21a0
5
5
  SHA512:
6
- metadata.gz: c930ecdf8f851eaaff0d14db9661dc434132a877a08deec34745e66817e77292dbc95fd1d661769c566d74180cae623f3cfeb76f94ce7690bf4ca71a1e0fe649
7
- data.tar.gz: b3e2862a52d75a9bc7e122865dd1e9222859a17dbbf19f837f50f5e44a90e25bb1a01ec92afe77621a827cbfccda370e6235e8b60ae05ce01855c5f76a8e55ec
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>
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.4.4"
2
+ VERSION = "0.4.5"
3
3
  end
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
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-21 00:00:00.000000000 Z
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