studio-engine 0.12.1 → 0.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9febc9d4cd408725e94cd7cbd481b698f8653ec08f79f94dd3b77e7a79140a1b
4
- data.tar.gz: 39512b32582a5ca68c10271a320aad6e27f67a084ce39a0cf0405b2afd9a7839
3
+ metadata.gz: 7518a662f1e6ef4a2abcad98a5ebfbdff27726ec1eaf63a8d3a3ec11d83c778d
4
+ data.tar.gz: 76a89614b473c19998c98176a2f4b55d89cede2d79c744278f3e4e2eaaa4c21c
5
5
  SHA512:
6
- metadata.gz: 0a4cee970e8b66de10c44d6f953419d186b69406fcab64d9ed1a418a2038482223aa75cb72e356c528aabf269368b49f1f24ec7bad7d0b0f33a108982b7b02db
7
- data.tar.gz: 755b20e0bdd55e28163bfb53bd5029ac150efec66796c91633336a5e3c0187a8a9aefc5dd33b1f8dab89a8edb8d502b734ed3999caf4b4197562eb77e8fe614e
6
+ metadata.gz: b241dba69f4bae51b29afb449a40e041243a8a79620eef8bcf202e6a038a290262ba67600c09fe2f6e0e49636af3c2ea1c35cab8b6f3f131dd35f2d2aa08bc1d
7
+ data.tar.gz: 8d328e360a347df68e72c6f3d53a820462963c8c8a55f5e88c2226d9da1a152a0b3a847230436ae2f22416d4160452a723dfdcfd4e6029d6dcf1a63368c1a5b1
data/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
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`. Consumer Rails apps install the released RubyGems package with `gem "studio-engine", "~> 0.6"`; bumping the gem version and updating consumer lockfiles is a release.
4
4
 
5
+ ## 0.13.0 — 2026-07-19
6
+
7
+ ### Added
8
+
9
+ - **Engine-shipped component CSS** — `.card`/`.btn*`/`.badge`/`.input-field`/`.empty-state` + `text-2xs`/`text-3xs` preset tokens; consumers adopt with `@import "../builds/tailwind/studio_engine";` (docs/NEW_APP_SETUP.md section 13).
10
+ - **De-forked UI primitives** — the modal host upstreams Turf's animated host (inline keyframes, `window.ModalAnimations` registry, `enterAnim`/`exitAnim` props, directional `swap()`/`advance()`; API superset of the old host — behavioral deltas listed in the README), and `components/user_nav` gains partial slots (`balance_slot`/`extra_icons_slot`/`div2_slot`) with the legacy `*_html` string locals still honored.
11
+
12
+ ### Fixed
13
+
14
+ - **Theme invariants scan class attributes, not prose** — the used-classes
15
+ invariant extracts `class="..."` / `class: "..."` contents before tokenizing,
16
+ so a comment mentioning a `card-*`/`btn-*` token no longer trips it; the
17
+ theme-var invariant now asserts per mode (dark AND light) instead of the
18
+ union, so a var emitted in only one mode cannot pass.
19
+ - **Modal host review fixes** — `cardClasses()` no longer clobbers registry
20
+ `slide` animations (the directional swap flags are guarded, so
21
+ `enterAnim`/`exitAnim: "slide"` resolve to real classes instead of a frozen
22
+ 220ms snap); the README's behavioral-delta wording is corrected; and a
23
+ node-executed store suite hardens the rendered modal store (cardClasses
24
+ resolution, registry merge, animated close timing, swap races, late registry
25
+ replacement).
26
+ - **Consumer checkout fetches full history** — the consumer-CI checkout uses
27
+ `fetch-depth: 0` so the hub's e2e quarantine ratchet can resolve
28
+ `origin/release` (the default depth-1 single-ref checkout left the baseline
29
+ ref unresolvable, which is red by design).
30
+
5
31
  ## 0.12.1 — 2026-07-11
6
32
 
7
33
  ### Fixed
data/Gemfile CHANGED
@@ -9,6 +9,12 @@ gemspec
9
9
  # XSLT / xmlC14NExecute). This gem still does NOT declare nokogiri as a runtime
10
10
  # dependency — it arrives via Rails in the consuming app. Tracked in
11
11
  # SECURITY-AUDIT-2026-05-17.md.
12
+ #
13
+ # It is also not declared as a test dependency, but test/views/user_nav_test.rb
14
+ # now requires it directly to walk rendered markup. That resolves today because
15
+ # actionview pulls it in via rails-dom-testing; if that ever stops being true
16
+ # the suite fails loudly with a LoadError rather than silently skipping, so
17
+ # declare it here at that point rather than pre-emptively pinning it.
12
18
 
13
19
  group :development, :test do
14
20
  # SQLite drives the test/dummy Rails app's in-memory database (see
data/README.md CHANGED
@@ -125,6 +125,97 @@ slide between two emoji on hover and keyboard focus. The CSS ships through
125
125
  <% end %>
126
126
  ```
127
127
 
128
+ ### Modal host
129
+
130
+ `studio/modals/_host.html.erb` is the single shared shell for every modal. It
131
+ owns the backdrop, scroll lock, escape + click-outside dismissal, ARIA dialog
132
+ role, mount/unmount animations, and bfcache/Turbo snapshot cleanup. Animation
133
+ keyframes ship inline in the partial — consumers need no extra CSS.
134
+
135
+ Render it once near the end of the layout `<body>`, registering each modal in
136
+ the block:
137
+
138
+ ```erb
139
+ <%= render "studio/modals/host" do %>
140
+ <template x-if="$store.modals.current().id === 'crop-photo'">
141
+ <%= render "studio/modals/crop_photo" %>
142
+ </template>
143
+ <% end %>
144
+ ```
145
+
146
+ Store API (`Alpine.store('modals')`):
147
+
148
+ | Call | Behavior |
149
+ |------|----------|
150
+ | `open(id, props, opts)` | Push a modal onto the stack. `opts.replace: true` swaps the top entry with a directional slide. |
151
+ | `swap(id, props, opts)` | Sugar for `open(id, props, { replace: true })`. `opts.direction: 'back'` mirrors the slide. |
152
+ | `advance(propsPatch, opts)` | Patch the current entry's props with the same directional slide, without replacing the stack entry — for steps inside one modal whose outer `x-data` scope must survive. |
153
+ | `close()` | Animated close of the current modal (no-op if already closing). |
154
+ | `closeAll()` | Instant, unanimated clear (used by navigation cleanup). |
155
+ | `closeAllDismissible()` | Clears all modals except those opened with `dismissible: false`. |
156
+ | `isOpen(id)` / `current()` | Introspection. |
157
+
158
+ Recognized props: `dismissible: false` disables escape/click-outside dismissal
159
+ (e.g. an in-flight transaction); `enterAnim` / `exitAnim` pick a named
160
+ animation from the registry (`'pop'` default, `'shake'`, `'slide'`).
161
+
162
+ `window.ModalAnimations` is the animation registry — each key maps a CSS class
163
+ to its duration. To add a custom animation, define `window.ModalAnimations`
164
+ **before** the host renders with your extra keys (they merge over the engine
165
+ defaults) and ship the matching CSS class in the app stylesheet:
166
+
167
+ ```html
168
+ <script>
169
+ window.ModalAnimations = { enter: { wobble: { cls: 'my-modal-wobble', ms: 400 } } };
170
+ </script>
171
+ ```
172
+
173
+ Prefer an inline script placed before the host render, as above. A module that
174
+ loads after the host (e.g. via importmap) and assigns `window.ModalAnimations`
175
+ **replaces** the merged registry rather than extending it — the host guards
176
+ against this (unknown keys and gutted registries fall back to the built-in
177
+ `'pop'`), but your other custom keys are lost unless the late script merges
178
+ into the existing object instead of assigning over it.
179
+
180
+ `window.StudioModals.holdAtLeast(ms)` returns a thenable that resolves no
181
+ sooner than `ms` after creation — stamp it when a processing view becomes
182
+ visible so fast operations don't flash the spinner.
183
+
184
+ **Behavioral deltas vs the previous engine host (0.12 and earlier).** The
185
+ store's API surface is a superset, but three behaviors changed, and a consumer
186
+ deleting its shadow copy inherits them — regression-test these paths rather
187
+ than assuming drop-in equivalence:
188
+
189
+ - `close()` is now **asynchronous**: the entry animates out and is spliced
190
+ after its exit animation's registered duration (previously an immediate
191
+ `pop()`).
192
+ - `close()` **no-ops while the current entry is already closing** — a double
193
+ `close()` inside the animation window now pops ONE stacked entry, not two —
194
+ and `close()` on an empty stack no longer runs `_sync()`.
195
+ - `open(id, props, { replace: true })` (and `swap()`) is now **asynchronous**:
196
+ the replacement lands after the 220ms slide-out (previously an immediate
197
+ top-of-stack assignment).
198
+
199
+ ### User nav slots
200
+
201
+ `components/_user_nav.html.erb` renders the right-side navbar user section.
202
+ Apps customize it through partial slots — each an optional local naming a
203
+ partial (String path, or `{ partial:, locals: }`):
204
+
205
+ - `balance_slot` — balance display at the start of the top row
206
+ - `extra_icons_slot` — app icon buttons before the admin gear + theme toggle
207
+ - `div2_slot` — replaces the default second row (wallet address + level bar)
208
+
209
+ ```erb
210
+ <%= render "components/user_nav",
211
+ balance_slot: "components/wallet_balance",
212
+ div2_slot: { partial: "components/seeds_bar", locals: { compact: true } } %>
213
+ ```
214
+
215
+ The legacy string locals (`balance_html`, `extra_icons_html`, `div2_html` —
216
+ pre-rendered HTML injected via `raw`) are deprecated but still honored when the
217
+ matching slot is absent, so existing call sites render unchanged.
218
+
128
219
  ## Overriding Views
129
220
 
130
221
  This is a non-isolated engine -- app views at the same path automatically override engine views. For example, placing `app/views/sessions/new.html.erb` in the consuming app replaces the engine's login page.
@@ -0,0 +1,146 @@
1
+ /*
2
+ studio-engine component classes — the shared UI component layer.
3
+
4
+ The engine's partials emit .card / .badge / .input-field / .empty-state /
5
+ .btn* and this file is where those classes are DEFINED. Consumers adopt with
6
+ ONE line in app/assets/tailwind/application.css (after `@import "tailwindcss";`):
7
+
8
+ @import "../builds/tailwind/studio_engine";
9
+
10
+ tailwindcss-rails auto-generates that entry point on every build/watch
11
+ (or manually: `bin/rails tailwindcss:engines`) because this file lives at the
12
+ Rails-engines contract path: app/assets/tailwind/<engine_name>/engine.css.
13
+
14
+ Requirements:
15
+ - Tailwind v4 (this file uses @utility, so classes sort/override correctly
16
+ in the utilities layer and take variants like `md:card`).
17
+ - The consumer's tailwind config spreads the engine preset
18
+ (tailwind/studio.tailwind.config.js) — it defines the surface / heading /
19
+ subtle / primary token utilities @apply'd below.
20
+
21
+ Colors resolve through the 7-role theme CSS custom properties (--color-cta,
22
+ --color-surface, ...) emitted by studio_theme_css_tag, so themed apps restyle
23
+ these components automatically in dark AND light mode.
24
+
25
+ Canonical source: these definitions were lifted verbatim from
26
+ mcritchie-studio app/assets/tailwind/application.css (the hub versions win
27
+ where per-app copies drifted). Keep the two in sync until the consumers drop
28
+ their local copies for this import.
29
+ */
30
+
31
+ /* -- Surfaces -------------------------------------------------------------- */
32
+
33
+ @utility card {
34
+ @apply bg-surface rounded-xl border border-subtle;
35
+ }
36
+
37
+ @utility card-hover {
38
+ @apply bg-surface rounded-xl border border-subtle hover:border-primary-700/50 hover:shadow-lg hover:shadow-primary-900/10 transition;
39
+ }
40
+
41
+ @utility empty-state {
42
+ @apply bg-surface rounded-xl border border-subtle p-8 text-center;
43
+ }
44
+
45
+ @utility json-debug {
46
+ @apply bg-inset rounded-xl border border-subtle p-4 overflow-x-auto;
47
+ }
48
+
49
+ /* -- Text + form ----------------------------------------------------------- */
50
+
51
+ @utility badge {
52
+ @apply inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium border;
53
+ }
54
+
55
+ @utility label-upper {
56
+ @apply text-xs text-muted uppercase tracking-wider;
57
+ }
58
+
59
+ @utility input-field {
60
+ @apply w-full bg-surface-alt border border-strong rounded-lg px-4 py-3 text-heading focus:border-primary focus:outline-hidden;
61
+ }
62
+
63
+ /* -- Button system (standardized across all Studio apps) ------------------- */
64
+
65
+ @utility btn {
66
+ @apply inline-flex items-center justify-center font-bold rounded-lg transition px-6 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed;
67
+ &:focus-visible {
68
+ outline: 2px solid color-mix(in srgb, var(--color-cta) 70%, transparent);
69
+ outline-offset: 2px;
70
+ }
71
+ }
72
+
73
+ @utility btn-primary {
74
+ @apply text-white;
75
+ background-color: var(--color-cta);
76
+ &:hover {
77
+ background-color: var(--color-cta-hover);
78
+ }
79
+ }
80
+
81
+ /* NOTE: btn-secondary and btn-success compile to IDENTICAL declarations (both
82
+ sit on var(--color-success)) — an intentional hub-canonical alias, kept as
83
+ two names so existing markup in every consumer keeps working. */
84
+ @utility btn-secondary {
85
+ @apply text-white;
86
+ background-color: var(--color-success);
87
+ &:hover {
88
+ filter: brightness(0.9);
89
+ }
90
+ }
91
+
92
+ @utility btn-outline {
93
+ @apply bg-transparent border text-heading;
94
+ border-color: var(--color-cta);
95
+ &:hover {
96
+ @apply text-white;
97
+ background-color: var(--color-cta);
98
+ }
99
+ }
100
+
101
+ @utility btn-danger {
102
+ @apply text-white;
103
+ background-color: var(--color-danger);
104
+ &:hover {
105
+ filter: brightness(0.9);
106
+ }
107
+ }
108
+
109
+ @utility btn-warning {
110
+ @apply text-white;
111
+ background-color: var(--color-warning);
112
+ &:hover {
113
+ filter: brightness(0.9);
114
+ }
115
+ }
116
+
117
+ @utility btn-success {
118
+ @apply text-white;
119
+ background-color: var(--color-success);
120
+ &:hover {
121
+ filter: brightness(0.9);
122
+ }
123
+ }
124
+
125
+ @utility btn-google {
126
+ @apply bg-white border border-strong shadow-xs;
127
+ color: #374151;
128
+ &:hover {
129
+ @apply bg-gray-50;
130
+ }
131
+ }
132
+
133
+ @utility btn-neutral {
134
+ @apply bg-surface-alt text-heading border border-strong;
135
+ &:hover {
136
+ @apply bg-surface;
137
+ }
138
+ }
139
+
140
+ @utility btn-sm {
141
+ @apply px-3 py-1.5 text-xs;
142
+ }
143
+
144
+ @utility btn-lg {
145
+ @apply px-8 py-3 text-base rounded-xl shadow-sm;
146
+ }
@@ -1,26 +1,61 @@
1
1
  <%# Shared right-side navbar user section.
2
- Locals (all optional):
3
- balance_html — raw HTML for balance display (e.g. Turf Monster wallet balance)
4
- extra_icons_htmlraw HTML for app-specific icon buttons (e.g. refresh button)
5
- show_logout_link boolean, show explicit "Log out" link (Studio uses this)
6
- div2_html raw HTML to replace default Div 2 (e.g. Turf Monster seeds bar)
2
+
3
+ Slot locals (all optional). Each slot names a partial to render in
4
+ that position a String partial path, or a Hash of
5
+ { partial: "path", locals: { ... } } when the partial needs locals:
6
+ balance_slot balance display, rendered at the start of the
7
+ top row (e.g. a wallet balance link)
8
+ extra_icons_slot — app-specific icon buttons, rendered before the
9
+ admin gear + theme toggle
10
+ div2_slot — replaces the default second row (the wallet
11
+ address + level progress bar) entirely
12
+ (e.g. a seeds bar)
13
+
14
+ Other locals:
15
+ show_logout_link — boolean, show explicit "Log out" link in the
16
+ second row (Studio uses this)
17
+
18
+ Legacy string locals (deprecated): balance_html, extra_icons_html,
19
+ div2_html — pre-rendered HTML injected via raw. Still honored when
20
+ the matching slot local is absent, so existing call sites render
21
+ unchanged. Prefer the slot locals for new work; they go through the
22
+ normal partial pipeline instead of trusting a raw string.
7
23
  %>
24
+ <% balance_slot ||= nil %>
25
+ <% extra_icons_slot ||= nil %>
26
+ <% div2_slot ||= nil %>
8
27
  <% balance_html ||= nil %>
9
28
  <% extra_icons_html ||= nil %>
10
29
  <% show_logout_link ||= false %>
11
30
  <% div2_html ||= nil %>
31
+ <%# Slot resolution: the slot local wins when both a slot and its legacy
32
+ string are passed. Returns nil when neither is present. %>
33
+ <% studio_nav_slot = lambda do |slot, legacy_html|
34
+ if slot.is_a?(Hash)
35
+ render slot[:partial], **slot.fetch(:locals, {})
36
+ elsif slot.present?
37
+ render slot.to_s
38
+ elsif legacy_html.present?
39
+ raw legacy_html
40
+ end
41
+ end %>
12
42
 
13
43
  <div class="flex gap-2">
14
44
  <% if logged_in? %>
15
45
  <%# Left column: Div 1 + Div 2 stacked %>
16
- <div class="flex-1">
17
- <%# Div 1 (Bigger): balance + icons + username %>
46
+ <%# min-w-0 belongs HERE, on the flex item of the outer row. A flex item
47
+ defaults to min-width auto, so without it this column refuses to
48
+ shrink below its content and the username overflows instead of
49
+ truncating. Div 1 below is a block child of this column, not a flex
50
+ item of the outer row, so min-w-0 there has no effect on shrinking.
51
+ The username link truncates on its own: overflow-hidden gives it an
52
+ automatic minimum size of zero inside Div 1. %>
53
+ <div class="flex-1 min-w-0">
54
+ <%# Div 1 (Bigger): balance + icons + username. %>
18
55
  <div class="flex items-center gap-2 leading-none" :style="'padding: 0 6px;' + ($store.devMode ? 'background: cornflowerblue;' : '')">
19
- <% if balance_html.present? %>
20
- <%= raw balance_html %>
21
- <% end %>
56
+ <%= studio_nav_slot.call(balance_slot, balance_html) %>
22
57
  <div class="flex items-center gap-2 ml-auto self-end">
23
- <%= raw extra_icons_html %>
58
+ <%= studio_nav_slot.call(extra_icons_slot, extra_icons_html) %>
24
59
  <%= render "components/admin_dropdown" %>
25
60
  <%= render "components/theme_toggle_morph" %>
26
61
  </div>
@@ -29,96 +64,102 @@
29
64
  <%= current_user.display_name %>
30
65
  <% end %>
31
66
  </div>
32
- <%# Div 2 (Smaller): wallet address (left) + level (right) with progress bar %>
33
- <% server_level = current_user.respond_to?(:level) && current_user.level.present? ? current_user.level : nil %>
34
- <div x-data="{
35
- barWidth: 0,
36
- displayLevel: <%= server_level || 1 %>,
37
- leveledUp: false,
38
- init() {
39
- var self = this;
40
- var data = this.getSeedsData();
41
- if (data) {
42
- self.displayLevel = data.level;
43
- setTimeout(function() { self.barWidth = data.progress; }, 150);
44
- }
45
- },
46
- getSeedsData() {
47
- try {
48
- var raw = localStorage.getItem('seedsNavbar');
49
- if (!raw) return null;
50
- return JSON.parse(raw);
51
- } catch(e) { return null; }
52
- },
53
- replayLevel() {
54
- var self = this;
55
- var data = this.getSeedsData();
56
- var finalPct = data ? data.progress : self.barWidth;
57
- self.displayLevel = Math.max(1, self.displayLevel - 1);
58
- self.barWidth = 60;
59
- setTimeout(function() { self.barWidth = 100; }, 300);
60
- setTimeout(function() { self.displayLevel += 1; self.leveledUp = true; }, 1500);
61
- setTimeout(function() { self.leveledUp = false; self.barWidth = 0; }, 2800);
62
- setTimeout(function() { self.barWidth = finalPct; }, 3000);
63
- },
64
- handleSeedsUpdate(d) {
65
- var self = this;
66
- if (d.levelUp) {
67
- self.barWidth = d.oldPct;
67
+ <% div2_content = studio_nav_slot.call(div2_slot, div2_html) %>
68
+ <% if div2_content.present? %>
69
+ <%# Div 2 replaced by the consumer's slot (div2_slot or legacy div2_html). %>
70
+ <%= div2_content %>
71
+ <% else %>
72
+ <%# Div 2 (Smaller): wallet address (left) + level (right) with progress bar %>
73
+ <% server_level = current_user.respond_to?(:level) && current_user.level.present? ? current_user.level : nil %>
74
+ <div x-data="{
75
+ barWidth: 0,
76
+ displayLevel: <%= server_level || 1 %>,
77
+ leveledUp: false,
78
+ init() {
79
+ var self = this;
80
+ var data = this.getSeedsData();
81
+ if (data) {
82
+ self.displayLevel = data.level;
83
+ setTimeout(function() { self.barWidth = data.progress; }, 150);
84
+ }
85
+ },
86
+ getSeedsData() {
87
+ try {
88
+ var raw = localStorage.getItem('seedsNavbar');
89
+ if (!raw) return null;
90
+ return JSON.parse(raw);
91
+ } catch(e) { return null; }
92
+ },
93
+ replayLevel() {
94
+ var self = this;
95
+ var data = this.getSeedsData();
96
+ var finalPct = data ? data.progress : self.barWidth;
97
+ self.displayLevel = Math.max(1, self.displayLevel - 1);
98
+ self.barWidth = 60;
68
99
  setTimeout(function() { self.barWidth = 100; }, 300);
69
- setTimeout(function() { self.displayLevel = d.newLevel; self.leveledUp = true; }, 1500);
100
+ setTimeout(function() { self.displayLevel += 1; self.leveledUp = true; }, 1500);
70
101
  setTimeout(function() { self.leveledUp = false; self.barWidth = 0; }, 2800);
71
- setTimeout(function() { self.barWidth = d.progress; }, 3000);
72
- } else {
73
- self.displayLevel = d.level;
74
- self.barWidth = d.progress;
102
+ setTimeout(function() { self.barWidth = finalPct; }, 3000);
103
+ },
104
+ handleSeedsUpdate(d) {
105
+ var self = this;
106
+ if (d.levelUp) {
107
+ self.barWidth = d.oldPct;
108
+ setTimeout(function() { self.barWidth = 100; }, 300);
109
+ setTimeout(function() { self.displayLevel = d.newLevel; self.leveledUp = true; }, 1500);
110
+ setTimeout(function() { self.leveledUp = false; self.barWidth = 0; }, 2800);
111
+ setTimeout(function() { self.barWidth = d.progress; }, 3000);
112
+ } else {
113
+ self.displayLevel = d.level;
114
+ self.barWidth = d.progress;
115
+ }
75
116
  }
76
- }
77
- }"
78
- @navbar-replay-level.window="replayLevel()"
79
- @navbar-seeds-update.window="handleSeedsUpdate($event.detail)"
80
- class="relative mt-0.5" style="height: 14px;">
81
- <%# Fill bar %>
82
- <div class="absolute inset-y-0 left-0 rounded z-0" style="background: var(--color-cta); transition: width 1.2s cubic-bezier(0.16, 1, 0.3, 1);" :style="{ width: barWidth + '%' }"></div>
83
- <%# Text layer 1: muted (visible outside bar) %>
84
- <div class="absolute inset-0 flex items-center justify-between px-1.5 font-mono text-muted z-[1] nav-bar-text">
85
- <span>
86
- <% if current_user.respond_to?(:truncated_solana) && current_user.try(:solana_connected?) %>
87
- <%= current_user.truncated_solana %>
117
+ }"
118
+ @navbar-replay-level.window="replayLevel()"
119
+ @navbar-seeds-update.window="handleSeedsUpdate($event.detail)"
120
+ class="relative mt-0.5" style="height: 14px;">
121
+ <%# Fill bar %>
122
+ <div class="absolute inset-y-0 left-0 rounded z-0" style="background: var(--color-cta); transition: width 1.2s cubic-bezier(0.16, 1, 0.3, 1);" :style="{ width: barWidth + '%' }"></div>
123
+ <%# Text layer 1: muted (visible outside bar) %>
124
+ <div class="absolute inset-0 flex items-center justify-between px-1.5 font-mono text-muted z-[1] nav-bar-text">
125
+ <span>
126
+ <% if current_user.respond_to?(:truncated_solana) && current_user.try(:solana_connected?) %>
127
+ <%= current_user.truncated_solana %>
128
+ <% end %>
129
+ </span>
130
+ <% if server_level %>
131
+ <span :class="leveledUp && 'nav-level-pop'" x-text="'Level ' + displayLevel">Level <%= server_level %></span>
132
+ <% elsif show_logout_link %>
133
+ <%= link_to "Log out", logout_path, class: "font-mono text-muted no-underline nav-bar-text" %>
88
134
  <% end %>
89
- </span>
90
- <% if server_level %>
91
- <span :class="leveledUp && 'nav-level-pop'" x-text="'Level ' + displayLevel">Level <%= server_level %></span>
92
- <% elsif show_logout_link %>
93
- <%= link_to "Log out", logout_path, class: "font-mono text-muted no-underline nav-bar-text" %>
94
- <% end %>
95
- </div>
96
- <%# Text layer 2: white (clip-path reveals where bar covers) %>
97
- <div class="absolute inset-0 flex items-center justify-between px-1.5 font-mono text-white z-[2] nav-bar-text" :style="{ 'clip-path': 'inset(0 ' + (100 - barWidth) + '% 0 0)' }">
98
- <span>
99
- <% if current_user.respond_to?(:truncated_solana) && current_user.try(:solana_connected?) %>
100
- <%= current_user.truncated_solana %>
135
+ </div>
136
+ <%# Text layer 2: white (clip-path reveals where bar covers) %>
137
+ <div class="absolute inset-0 flex items-center justify-between px-1.5 font-mono text-white z-[2] nav-bar-text" :style="{ 'clip-path': 'inset(0 ' + (100 - barWidth) + '% 0 0)' }">
138
+ <span>
139
+ <% if current_user.respond_to?(:truncated_solana) && current_user.try(:solana_connected?) %>
140
+ <%= current_user.truncated_solana %>
141
+ <% end %>
142
+ </span>
143
+ <% if server_level %>
144
+ <span :class="leveledUp && 'nav-level-pop'" x-text="'Level ' + displayLevel">Level <%= server_level %></span>
145
+ <% elsif show_logout_link %>
146
+ <%= link_to "Log out", logout_path, class: "font-mono text-white no-underline nav-bar-text" %>
101
147
  <% end %>
102
- </span>
103
- <% if server_level %>
104
- <span :class="leveledUp && 'nav-level-pop'" x-text="'Level ' + displayLevel">Level <%= server_level %></span>
105
- <% elsif show_logout_link %>
106
- <%= link_to "Log out", logout_path, class: "font-mono text-white no-underline nav-bar-text" %>
107
- <% end %>
148
+ </div>
108
149
  </div>
109
- </div>
110
- <style>
111
- @keyframes navLevelPop {
112
- 0% { transform: scale(1); }
113
- 15% { transform: scale(1.5); }
114
- 35% { transform: scale(1.2); }
115
- 50% { transform: scale(1.4); }
116
- 70% { transform: scale(1.1); }
117
- 100% { transform: scale(1); }
118
- }
119
- .nav-level-pop { animation: navLevelPop 0.8s cubic-bezier(0.34, 1.56, 0.64, 1); }
120
- .nav-bar-text { font-size: 10px; }
121
- </style>
150
+ <style>
151
+ @keyframes navLevelPop {
152
+ 0% { transform: scale(1); }
153
+ 15% { transform: scale(1.5); }
154
+ 35% { transform: scale(1.2); }
155
+ 50% { transform: scale(1.4); }
156
+ 70% { transform: scale(1.1); }
157
+ 100% { transform: scale(1); }
158
+ }
159
+ .nav-level-pop { animation: navLevelPop 0.8s cubic-bezier(0.34, 1.56, 0.64, 1); }
160
+ .nav-bar-text { font-size: 10px; }
161
+ </style>
162
+ <% end %>
122
163
  </div>
123
164
  <%# Div 3 (Avatar): spans both rows %>
124
165
  <% account_link = defined?(account_path) ? account_path : "#" %>
@@ -1,12 +1,15 @@
1
1
  <%#
2
2
  Modal host. Single shared shell for any modal that opens through
3
3
  Alpine.store('modals'). Owns the backdrop, scroll lock (via
4
- modal-open on body), escape key, click-outside, transitions, and
5
- ARIA dialog role. Bundles the scroll-lock CSS, bfcache and Turbo
6
- Drive snapshot cleanup, store registration, and the
7
- window.StudioModals.holdAtLeast(ms) helper. See the gem README for
8
- the consumer integration example and API reference; documentation
9
- intentionally kept brief here.
4
+ modal-open on body), escape key, click-outside, ARIA dialog role,
5
+ mount and unmount animations, the per-modal animation registry
6
+ (window.ModalAnimations), directional swap() and advance() slides
7
+ for multi-step flows, bfcache and Turbo Drive snapshot cleanup,
8
+ store registration, and the window.StudioModals.holdAtLeast(ms)
9
+ helper. The animation keyframes ship inline in the style block
10
+ below, so consumers need no extra CSS to get the full behavior.
11
+ Consumer integration example and API reference: the "Modal host"
12
+ section of the gem README.
10
13
  %>
11
14
  <style>
12
15
  /* Scroll lock applied by $store.modals._sync() when the stack is non-empty.
@@ -21,6 +24,126 @@
21
24
  from { transform: scaleX(1); }
22
25
  to { transform: scaleX(0); }
23
26
  }
27
+
28
+ /* === Modal mount / unmount animations =================================
29
+ Bound by the host below via the .modal-card-{mount,unmount} and
30
+ .modal-backdrop-{mount,unmount} classes. The card open/close use
31
+ multi-keyframe spring-y curves so the modal POPS in with a small
32
+ overshoot and POPS out with a tiny anticipation windup before
33
+ falling — much more present than a plain ease. Backdrop stays on a
34
+ clean fade (a bouncing backdrop reads as a bug, not polish).
35
+
36
+ Durations: the modal-card-out keyframe stays at 220ms to match
37
+ CLOSE_ANIM_MS in the host's store — that's the timeout before the
38
+ entry is spliced off the stack. Bump CLOSE_ANIM_MS if you lengthen
39
+ this.
40
+
41
+ Re-skinning: don't fight these classes from an app stylesheet (this
42
+ inline block sits later in the document, so it wins ties). Instead
43
+ register a NEW animation — one entry in window.ModalAnimations plus
44
+ one class in the app's CSS — and open modals with that key. */
45
+
46
+ @keyframes modal-card-in {
47
+ 0% { opacity: 0; transform: scale(0.86) translateY(12px); }
48
+ 55% { opacity: 1; transform: scale(1.04) translateY(-3px); } /* overshoot */
49
+ 80% { transform: scale(0.99) translateY(1px); } /* small undershoot */
50
+ 100% { opacity: 1; transform: scale(1) translateY(0); } /* settle */
51
+ }
52
+ @keyframes modal-card-out {
53
+ 0% { opacity: 1; transform: scale(1) translateY(0); }
54
+ 25% { opacity: 1; transform: scale(1.03) translateY(-2px); } /* windup */
55
+ 100% { opacity: 0; transform: scale(0.92) translateY(4px); } /* fall */
56
+ }
57
+ @keyframes modal-backdrop-in {
58
+ from { opacity: 0; }
59
+ to { opacity: 1; }
60
+ }
61
+ @keyframes modal-backdrop-out {
62
+ from { opacity: 1; }
63
+ to { opacity: 0; }
64
+ }
65
+
66
+ /* Bounce ease the spring; the keyframes already do most of the work,
67
+ the easing just smooths the segments between them. */
68
+ .modal-card-mount { animation: modal-card-in 320ms cubic-bezier(0.34, 1.4, 0.5, 1) both; }
69
+ .modal-card-unmount { animation: modal-card-out 220ms cubic-bezier(0.6, -0.2, 0.7, 0.4) forwards; }
70
+ .modal-backdrop-mount { animation: modal-backdrop-in 180ms ease-out both; }
71
+ .modal-backdrop-unmount { animation: modal-backdrop-out 200ms ease-in forwards; }
72
+
73
+ /* Named entrance animation: "shake" — a "not quite yet" nope. The card
74
+ scales/fades in fast (first ~22%) then does a damped horizontal
75
+ wobble that settles to center, reading as "you can't do that just
76
+ yet." Wired via the ModalAnimations registry below: open a modal
77
+ with { enterAnim: 'shake' } to use it. Duration here MUST match
78
+ ModalAnimations.enter.shake.ms (600). */
79
+ @keyframes modal-card-shake-in {
80
+ 0% { opacity: 0; transform: scale(0.92) translateX(0); }
81
+ 22% { opacity: 1; transform: scale(1) translateX(-11px); }
82
+ 37% { transform: scale(1) translateX(9px); }
83
+ 52% { transform: scale(1) translateX(-7px); }
84
+ 67% { transform: scale(1) translateX(5px); }
85
+ 82% { transform: scale(1) translateX(-3px); }
86
+ 100% { opacity: 1; transform: scale(1) translateX(0); }
87
+ }
88
+ .modal-card-shake-in { animation: modal-card-shake-in 600ms cubic-bezier(0.36, 0.07, 0.19, 0.97) both; }
89
+
90
+ /* Inter-modal swap animations — directional slide for forward-flow feel.
91
+ The leaving modal slides off to the right + fades; the new one enters
92
+ from the left. Pairs with $store.modals.swap() (or open(_, _,
93
+ { replace: true })) — the store flips _swappingOut on the leaving
94
+ entry, waits CLOSE_ANIM_MS, then flips _swappingIn on the entering
95
+ entry. Total swap duration ~440ms (220 out + 220 in). */
96
+ @keyframes modal-card-slide-out-right {
97
+ from { opacity: 1; transform: translateX(0) scale(1); }
98
+ to { opacity: 0; transform: translateX(48px) scale(0.98); }
99
+ }
100
+ @keyframes modal-card-slide-in-left {
101
+ from { opacity: 0; transform: translateX(-48px) scale(0.98); }
102
+ to { opacity: 1; transform: translateX(0) scale(1); }
103
+ }
104
+ .modal-card-swap-out { animation: modal-card-slide-out-right 220ms cubic-bezier(0.4, 0, 1, 1) forwards; }
105
+ .modal-card-swap-in { animation: modal-card-slide-in-left 220ms cubic-bezier(0, 0, 0.2, 1) both; }
106
+
107
+ /* Back-direction pair — current step slides off stage-LEFT, next slides
108
+ in from the RIGHT. Mirror of the forward pair above; together they
109
+ give wizard nav a directional language (forward = right, back = left)
110
+ so the user's spatial model of "where the previous step lives" is
111
+ preserved. Trigger: $store.modals.advance(patch, { direction: 'back' })
112
+ or swap(id, props, { direction: 'back' }). */
113
+ @keyframes modal-card-slide-out-left {
114
+ from { opacity: 1; transform: translateX(0) scale(1); }
115
+ to { opacity: 0; transform: translateX(-48px) scale(0.98); }
116
+ }
117
+ @keyframes modal-card-slide-in-right {
118
+ from { opacity: 0; transform: translateX(48px) scale(0.98); }
119
+ to { opacity: 1; transform: translateX(0) scale(1); }
120
+ }
121
+ .modal-card-swap-out-back { animation: modal-card-slide-out-left 220ms cubic-bezier(0.4, 0, 1, 1) forwards; }
122
+ .modal-card-swap-in-back { animation: modal-card-slide-in-right 220ms cubic-bezier(0, 0, 0.2, 1) both; }
123
+
124
+ /* Honor reduced-motion preferences — drop the spring/slide motion,
125
+ just fade fast. Vestibular safety. */
126
+ @media (prefers-reduced-motion: reduce) {
127
+ .modal-card-mount,
128
+ .modal-card-unmount,
129
+ .modal-card-shake-in,
130
+ .modal-card-swap-out,
131
+ .modal-card-swap-in,
132
+ .modal-card-swap-out-back,
133
+ .modal-card-swap-in-back,
134
+ .modal-backdrop-mount,
135
+ .modal-backdrop-unmount {
136
+ animation-duration: 80ms;
137
+ animation-timing-function: linear;
138
+ }
139
+ @keyframes modal-card-shake-in { from { opacity: 0; } to { opacity: 1; } }
140
+ @keyframes modal-card-in { from { opacity: 0; } to { opacity: 1; } }
141
+ @keyframes modal-card-out { from { opacity: 1; } to { opacity: 0; } }
142
+ @keyframes modal-card-slide-out-right { from { opacity: 1; } to { opacity: 0; } }
143
+ @keyframes modal-card-slide-in-left { from { opacity: 0; } to { opacity: 1; } }
144
+ @keyframes modal-card-slide-out-left { from { opacity: 1; } to { opacity: 0; } }
145
+ @keyframes modal-card-slide-in-right { from { opacity: 0; } to { opacity: 1; } }
146
+ }
24
147
  </style>
25
148
 
26
149
  <script>
@@ -43,40 +166,237 @@
43
166
  };
44
167
  };
45
168
 
169
+ // === Modal animation registry ======================================
170
+ //
171
+ // The single place to define + tune named entrance / exit animations
172
+ // for the modal card. Each key maps to a CSS class (the defaults ship
173
+ // in the inline style block above) plus its duration in ms (the store
174
+ // waits this long before splicing a closing entry off the stack, so
175
+ // the exit keyframe plays to completion before the DOM unmounts).
176
+ //
177
+ // Pass a key into any open() call via props.enterAnim / props.exitAnim:
178
+ //
179
+ // $store.modals.open('foo', { enterAnim: 'shake' }) // shake in, default pop out
180
+ // $store.modals.open('bar', { exitAnim: 'slide' }) // default pop in, slide out
181
+ //
182
+ // Omitted → the modal uses 'pop' (spring bounce in / fall out).
183
+ //
184
+ // Consumer extension point: define window.ModalAnimations BEFORE this
185
+ // script runs (e.g. in a <script> above the host render) with extra
186
+ // keys per channel — app entries are merged OVER the engine defaults,
187
+ // so adding a custom animation never requires redefining pop/shake/
188
+ // slide. Each custom key needs one registry entry here + one CSS
189
+ // class in the app's stylesheet. To re-skin a modal's entrance, just
190
+ // change the enterAnim it's opened with — no host edits needed.
191
+ //
192
+ // NB: this is independent of the directional swap()/advance() slide,
193
+ // which is a separate in-flow transition mechanism (see below) and
194
+ // always uses the modal-card-swap-* classes regardless of these keys.
195
+ var animDefaults = {
196
+ enter: {
197
+ pop: { cls: 'modal-card-mount', ms: 320 }, // default — spring bounce in
198
+ shake: { cls: 'modal-card-shake-in', ms: 600 }, // "not quite yet" nope
199
+ slide: { cls: 'modal-card-swap-in', ms: 220 } // slide in from the left
200
+ },
201
+ exit: {
202
+ pop: { cls: 'modal-card-unmount', ms: 220 }, // default — anticipation + fall
203
+ slide: { cls: 'modal-card-swap-out', ms: 220 } // slide out to the right
204
+ }
205
+ };
206
+ var animOverrides = window.ModalAnimations || {};
207
+ window.ModalAnimations = {
208
+ enter: Object.assign({}, animDefaults.enter, animOverrides.enter || {}),
209
+ exit: Object.assign({}, animDefaults.exit, animOverrides.exit || {})
210
+ };
211
+ // Late-binding guard: read window.ModalAnimations at CALL time, and
212
+ // never return undefined. A consumer script that loads after this one
213
+ // (e.g. an importmap module) may REPLACE the merged registry object
214
+ // wholesale; without the animDefaults fallback a lookup miss would
215
+ // make close() throw on .ms and leave the modal stuck open. Unknown
216
+ // keys and gutted registries fall back to the built-in 'pop'.
217
+ function modalAnim(channel, key) {
218
+ var table = (window.ModalAnimations && window.ModalAnimations[channel]) || {};
219
+ return table[key] || table.pop || animDefaults[channel].pop;
220
+ }
221
+
46
222
  // === Alpine.store('modals') — the stack ============================
47
223
  //
48
224
  // Idempotent registration (return early if already registered) so
49
225
  // consumer apps can include the host more than once without
50
226
  // double-init.
227
+ //
228
+ // CLOSE_ANIM_MS: keep in sync with the modal-card-unmount + modal-
229
+ // backdrop-unmount keyframes in the inline style block above. When
230
+ // close() is called, we set _closing on the entry so the host can
231
+ // swap in the unmount classes, then actually splice the stack after
232
+ // this many ms so the animation plays before the DOM unmounts. If
233
+ // you change the keyframe durations there, bump this to match.
234
+ var CLOSE_ANIM_MS = 220;
235
+ var SWAP_IN_MS = 220;
236
+
51
237
  document.addEventListener('alpine:init', function() {
52
238
  if (Alpine.store('modals')) return;
53
239
  Alpine.store('modals', {
54
240
  stack: [],
241
+ // open(id, props, opts)
242
+ // opts.replace: true → swap the top entry with this one. When a
243
+ // non-closing modal is currently visible, the swap is a directional
244
+ // slide — current modal exits stage-right (modal-card-swap-out)
245
+ // and the new one enters stage-left (modal-card-swap-in). Gives
246
+ // a "forward progress" feel for wizards / multi-step flows.
247
+ // Defaults to a plain mount animation when the stack is empty.
248
+ // opts.replace: false (default) → push on top of the stack. Stacking
249
+ // semantics — when the new one closes, the previous is revealed
250
+ // underneath (e.g. crop-photo on top of profile).
251
+ // SWAP_IN_MS must match the modal-card-swap-in animation duration in
252
+ // the inline style block above; we clear the flag after that so a
253
+ // fresh open() doesn't re-trigger the slide.
55
254
  open: function(id, props, opts) {
56
255
  props = props || {};
57
256
  opts = opts || {};
257
+ var self = this;
258
+
58
259
  if (opts.replace && this.stack.length > 0) {
260
+ var current = this.current();
261
+ if (current && !current._closing) {
262
+ // Phase 1: mark leaving entry with _swappingOut + _closing so
263
+ // the :class binding swaps in the slide-out keyframe (instead
264
+ // of the default scale-pop modal-card-unmount) for CLOSE_ANIM_MS.
265
+ // _swapDir picks which slide direction (forward = exit right,
266
+ // back = exit left); the same flag carries onto the new entry
267
+ // below so slide-in mirrors slide-out.
268
+ var dir = (opts.direction === 'back') ? 'back' : 'forward';
269
+ current._swapDir = dir;
270
+ current._swappingOut = true;
271
+ current._closing = true;
272
+ setTimeout(function() {
273
+ // Phase 2: stack swap. New entry carries _swappingIn + the
274
+ // same _swapDir so the host's :class binding picks the
275
+ // matching slide-in keyframe. If the marked entry vanished
276
+ // during the slide-out window (another swap() hot-swapped
277
+ // it, or a close raced us), DROP this stale replacement —
278
+ // pushing it would resurrect a modal the user already
279
+ // navigated past (two swap() calls within CLOSE_ANIM_MS
280
+ // used to leave both on the stack). Mirrors close()'s
281
+ // stale-entry guard.
282
+ var idx = self.stack.indexOf(current);
283
+ if (idx < 0) return;
284
+ var newEntry = { id: id, props: props, _swappingIn: true, _swapDir: dir };
285
+ self.stack[idx] = newEntry;
286
+ self._sync();
287
+ // Phase 3: clear the swap-in flag + direction after the
288
+ // slide finishes so a future close() runs the unmount
289
+ // keyframe cleanly. _settled latches so the host's
290
+ // :class binding doesn't snap modal-card-mount back on
291
+ // (which would re-fire the bounce-in keyframe from
292
+ // opacity:0 and look like a flash mid-settle).
293
+ setTimeout(function() {
294
+ newEntry._swappingIn = false;
295
+ newEntry._swapDir = null;
296
+ newEntry._settled = true;
297
+ }, SWAP_IN_MS);
298
+ }, CLOSE_ANIM_MS);
299
+ return;
300
+ }
301
+ // Already mid-close — just hot-swap so we don't double up timers.
59
302
  this.stack[this.stack.length - 1] = { id: id, props: props };
60
303
  } else {
61
304
  this.stack.push({ id: id, props: props });
62
305
  }
63
306
  this._sync();
64
307
  },
308
+ // Sugar — `swap('foo', props)` ≡ `open('foo', props, { replace: true })`.
309
+ // Pass opts through so callers can request a back-direction slide
310
+ // on a cross-modal handoff if the new modal is semantically
311
+ // upstream of the current one (e.g. "return to home modal").
312
+ swap: function(id, props, opts) {
313
+ return this.open(id, props, Object.assign({ replace: true }, opts || {}));
314
+ },
315
+ // advance(propsPatch, opts) — patch the current entry's props
316
+ // WITH the same directional slide as swap(), but WITHOUT
317
+ // replacing the stack entry. Use this for in-flow step
318
+ // transitions inside one modal (e.g. picker → waiting →
319
+ // confirming) where the modal's outer x-data scope (form
320
+ // state, watchers, factories) needs to survive the transition.
321
+ // swap() drops a fresh entry into the stack which re-evaluates
322
+ // the registered <template x-if="id===..."> — fine for
323
+ // cross-modal handoffs, but heavy for "same modal, next step."
324
+ // advance() is the lightweight cousin.
325
+ //
326
+ // opts.direction: 'forward' (default — current exits right, next
327
+ // enters from left; reads as "moving ahead in the flow")
328
+ // or 'back' (mirrored — current exits left, next enters from
329
+ // right; reads as "returning to the previous step"). Pair
330
+ // forward calls with back calls on their inverse buttons so
331
+ // the user's spatial model of the wizard stays consistent.
332
+ //
333
+ // Timing mirrors swap():
334
+ // t=0 → flip _swappingOut, outer card slides off (CLOSE_ANIM_MS)
335
+ // t=220ms → patch props, flip _swappingIn, card slides in
336
+ // t=440ms → clear _swappingIn + _swapDir so the next open/
337
+ // close uses default mount/unmount keyframes
338
+ //
339
+ // Guards: no-op if no current entry, or if the entry is already
340
+ // closing or mid-swap (prevents double-clicks from stacking timers).
341
+ advance: function(propsPatch, opts) {
342
+ opts = opts || {};
343
+ var cur = this.current();
344
+ if (!cur || cur._closing || cur._swappingOut) return;
345
+ var self = this;
346
+ cur._swapDir = (opts.direction === 'back') ? 'back' : 'forward';
347
+ cur._swappingOut = true;
348
+ setTimeout(function() {
349
+ // Re-resolve in case the stack mutated during the slide-out
350
+ // window. If our entry got popped (e.g. user hit Escape mid-
351
+ // animation), bail rather than splat props onto a stale ref.
352
+ if (self.stack.indexOf(cur) < 0) return;
353
+ if (propsPatch) Object.assign(cur.props, propsPatch);
354
+ cur._swappingOut = false;
355
+ cur._swappingIn = true;
356
+ setTimeout(function() {
357
+ cur._swappingIn = false;
358
+ cur._swapDir = null;
359
+ cur._settled = true;
360
+ }, SWAP_IN_MS);
361
+ }, CLOSE_ANIM_MS);
362
+ },
65
363
  close: function() {
66
- this.stack.pop();
67
- this._sync();
364
+ var self = this;
365
+ var entry = this.current();
366
+ if (!entry || entry._closing) return; // empty stack or already closing
367
+ // Flip _closing → Alpine reactivity adds the modal-card-unmount
368
+ // and modal-backdrop-unmount classes via the :class bindings on
369
+ // the host. Wait for the animation to play, then splice THIS
370
+ // specific entry from the stack (not pop() — the top might have
371
+ // changed if another open() ran during the close window). The
372
+ // splice delay tracks the entry's chosen exit animation
373
+ // (props.exitAnim → registry ms) so a longer custom exit
374
+ // keyframe plays fully before the DOM unmounts; defaults to
375
+ // the 'pop' fall (220ms).
376
+ entry._closing = true;
377
+ setTimeout(function() {
378
+ var idx = self.stack.indexOf(entry);
379
+ if (idx >= 0) {
380
+ self.stack.splice(idx, 1);
381
+ self._sync();
382
+ }
383
+ }, modalAnim('exit', entry.props && entry.props.exitAnim).ms);
68
384
  },
69
385
  closeAll: function() {
386
+ // No animation — used by Turbo before-cache + bfcache cleanup
387
+ // where the user is navigating away and we just need the DOM
388
+ // clean immediately.
70
389
  this.stack = [];
71
390
  this._sync();
72
391
  },
73
392
  // Drops every modal whose props don't explicitly opt out of dismissal
74
393
  // (i.e. dismissible: false). Used by the bfcache + Turbo snapshot
75
- // cleanup below so an in-flight on-chain TX modal — which sets
76
- // dismissible: false precisely to forbid losing its UI mid-flight —
77
- // survives a back/forward navigation. Celebratory modals (default
78
- // dismissible: true) are still cleared so users don't return to a
79
- // stale "Success!" card.
394
+ // cleanup below so a modal guarding an in-flight operation (e.g. an
395
+ // on-chain TX) — which sets dismissible: false precisely to forbid
396
+ // losing its UI mid-flight — survives a back/forward navigation;
397
+ // the still-in-flight JS promise resolves against a non-empty
398
+ // stack. Celebratory modals (default dismissible: true) are still
399
+ // cleared so users don't return to a stale "Success!" card.
80
400
  closeAllDismissible: function() {
81
401
  this.stack = this.stack.filter(function(modal) {
82
402
  return modal.props && modal.props.dismissible === false;
@@ -92,6 +412,45 @@
92
412
  current: function() {
93
413
  return this.stack.length ? this.stack[this.stack.length - 1] : null;
94
414
  },
415
+ // cardClasses() — the animation class set for the visible card.
416
+ // Resolves the entry's enter/exit animation keys (props.enterAnim
417
+ // / props.exitAnim) through the ModalAnimations registry, while
418
+ // keeping the directional swap-* classes (the in-flow slide) as
419
+ // fixed names. Lifecycle → class:
420
+ // fresh mount → enter key class (default 'pop' → modal-card-mount)
421
+ // closing → exit key class (default 'pop' → modal-card-unmount)
422
+ // swap/advance → modal-card-swap-{in,out}[-back]
423
+ // The mount class is gated on !_settled (so a completed swap doesn't
424
+ // re-fire the bounce) and !_closing (so close shows ONLY the exit
425
+ // keyframe, not both stacked).
426
+ cardClasses: function() {
427
+ var c = this.current();
428
+ if (!c) return {};
429
+ var o = {};
430
+ if (!c._settled && !c._swappingIn && !c._swappingOut && !c._closing) {
431
+ o[modalAnim('enter', c.props && c.props.enterAnim).cls] = true;
432
+ }
433
+ if (c._closing && !c._swappingOut) {
434
+ o[modalAnim('exit', c.props && c.props.exitAnim).cls] = true;
435
+ }
436
+ // The registry's 'slide' entries resolve to the SAME class names
437
+ // as the fixed directional flags below (enter.slide is
438
+ // modal-card-swap-in, exit.slide is modal-card-swap-out). Never
439
+ // reassign a key the enter/exit resolution above already set —
440
+ // an unconditional assignment here clobbered those keys to
441
+ // false, so enterAnim/exitAnim 'slide' produced NO class and
442
+ // the exit sat motionless for the splice delay, then snapped.
443
+ var swapFlags = {
444
+ 'modal-card-swap-in': !!(c._swappingIn && c._swapDir !== 'back'),
445
+ 'modal-card-swap-out': !!(c._swappingOut && c._swapDir !== 'back'),
446
+ 'modal-card-swap-in-back': !!(c._swappingIn && c._swapDir === 'back'),
447
+ 'modal-card-swap-out-back': !!(c._swappingOut && c._swapDir === 'back')
448
+ };
449
+ for (var flag in swapFlags) {
450
+ if (!(flag in o)) o[flag] = swapFlags[flag];
451
+ }
452
+ return o;
453
+ },
95
454
  _sync: function() {
96
455
  if (this.stack.length) {
97
456
  document.body.classList.add('modal-open');
@@ -127,26 +486,42 @@
127
486
  </script>
128
487
 
129
488
  <template x-if="$store.modals.current()">
130
- <%# A modal can opt out of escape + click-outside dismissal by setting
131
- `dismissible: false` on its props (e.g. processing an on-chain tx
489
+ <%# Backdrop + card use CSS keyframe animations (NOT Alpine
490
+ x-transition), all shipped in the inline style block above. On
491
+ mount, the .modal-backdrop-mount + card mount classes fire fade-in
492
+ + drop-recoil. On close, the store flips _closing, the :class
493
+ bindings add the unmount classes, the exit keyframes play, and
494
+ after the exit duration the store splices the entry from the stack
495
+ and the whole thing unmounts (now invisible).
496
+
497
+ A modal can opt out of escape + click-outside dismissal by setting
498
+ dismissible: false on its props (e.g. processing an on-chain tx
132
499
  where an accidental click would orphan a signed but un-confirmed
133
500
  transaction). Defaults to dismissible. %>
134
- <div class="fixed inset-0 z-[120] flex items-center justify-center p-4"
501
+ <div class="fixed inset-0 z-[120] flex items-center justify-center p-4 modal-backdrop-mount"
502
+ :class="$store.modals.current()?._closing && 'modal-backdrop-unmount'"
135
503
  style="background:rgba(0,0,0,0.6)"
136
504
  role="dialog"
137
505
  aria-modal="true"
138
506
  @keydown.escape.window="$store.modals.current() && $store.modals.current().props.dismissible !== false && $store.modals.close()"
139
- @click.self="$store.modals.current() && $store.modals.current().props.dismissible !== false && $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">
507
+ @click.self="$store.modals.current() && $store.modals.current().props.dismissible !== false && $store.modals.close()">
508
+ <%# Card animation is class-driven (cardClasses() in the store) so it
509
+ can pick the right keyframe for the current store state. Plain
510
+ mount/unmount for first open + close, resolved through the
511
+ ModalAnimations registry (props.enterAnim / props.exitAnim).
512
+ Swap-out + swap-in for inter-modal transitions triggered by
513
+ $store.modals.swap() / advance() (or open with { replace: true }).
514
+ _swapDir picks forward (exit right / enter left — default) vs
515
+ back (exit left / enter right — opt-in via { direction: 'back' }).
516
+ The mount class is gated on !_settled so when a swap-in completes
517
+ the class binding does NOT snap back to the mount class and
518
+ re-fire the bounce-in keyframe from opacity:0 — without the gate,
519
+ the card briefly disappears and bounces in again right as the
520
+ slide finishes, which reads as a flash mid-transition. _settled
521
+ stays true for the rest of the entry's lifetime so a stale Alpine
522
+ re-evaluation can't retrigger the bounce. %>
146
523
  <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">
524
+ :class="$store.modals.cardClasses()">
150
525
  <%# Consumer-provided content registrations. Each block typically
151
526
  contains a <template x-if="$store.modals.current().id === 'X'">
152
527
  render "modals/X" </template>. %>
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.12.1"
2
+ VERSION = "0.13.0"
3
3
  end
@@ -9,6 +9,14 @@ module.exports = {
9
9
  mono: ['ui-monospace', 'SFMono-Regular', 'monospace'],
10
10
  },
11
11
  extend: {
12
+ // Sub-xs type scale for dense UI (badges, meta rows, tick labels).
13
+ // Bare sizes (no line-height tuple) so text-2xs / text-3xs are drop-in
14
+ // replacements for the text-[11px] / text-[10px] arbitrary values that
15
+ // dominate the consumer apps.
16
+ fontSize: {
17
+ '2xs': '0.6875rem', // 11px
18
+ '3xs': '0.625rem', // 10px
19
+ },
12
20
  colors: {
13
21
  // Theme-aware semantic tokens (reference CSS variables)
14
22
  page: 'var(--color-page)',
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.1
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-07-20 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: rails
@@ -171,6 +172,7 @@ files:
171
172
  - app/assets/images/ses-favicon.png
172
173
  - app/assets/javascripts/studio/sticky_table_header.js
173
174
  - app/assets/stylesheets/studio/sticky_table_header.css
175
+ - app/assets/tailwind/studio_engine/engine.css
174
176
  - app/controllers/concerns/solana/session_auth.rb
175
177
  - app/controllers/concerns/studio/admin_models.rb
176
178
  - app/controllers/concerns/studio/error_handling.rb
@@ -296,6 +298,7 @@ metadata:
296
298
  source_code_uri: https://github.com/amcritchie/studio-engine/tree/main
297
299
  bug_tracker_uri: https://github.com/amcritchie/studio-engine/issues
298
300
  changelog_uri: https://github.com/amcritchie/studio-engine/blob/main/CHANGELOG.md
301
+ post_install_message:
299
302
  rdoc_options: []
300
303
  require_paths:
301
304
  - lib
@@ -310,7 +313,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
310
313
  - !ruby/object:Gem::Version
311
314
  version: '0'
312
315
  requirements: []
313
- rubygems_version: 4.0.9
316
+ rubygems_version: 3.5.22
317
+ signing_key:
314
318
  specification_version: 4
315
319
  summary: Shared Rails engine providing auth, SSO, error logging, theming, and S3-backed
316
320
  image caching