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 +4 -4
- data/CHANGELOG.md +26 -0
- data/Gemfile +6 -0
- data/README.md +91 -0
- data/app/assets/tailwind/studio_engine/engine.css +146 -0
- data/app/views/components/_user_nav.html.erb +136 -95
- data/app/views/studio/modals/_host.html.erb +401 -26
- data/lib/studio/version.rb +1 -1
- data/tailwind/studio.tailwind.config.js +8 -0
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7518a662f1e6ef4a2abcad98a5ebfbdff27726ec1eaf63a8d3a3ec11d83c778d
|
|
4
|
+
data.tar.gz: 76a89614b473c19998c98176a2f4b55d89cede2d79c744278f3e4e2eaaa4c21c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
<%=
|
|
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
|
-
|
|
33
|
-
<%
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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 =
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
self
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
</
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
<%
|
|
100
|
-
<%=
|
|
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
|
-
</
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
67
|
-
this.
|
|
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
|
|
76
|
-
// dismissible: false precisely to forbid
|
|
77
|
-
// survives a back/forward navigation
|
|
78
|
-
//
|
|
79
|
-
//
|
|
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
|
-
<%#
|
|
131
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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>. %>
|
data/lib/studio/version.rb
CHANGED
|
@@ -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.
|
|
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:
|
|
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:
|
|
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
|