studio-engine 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -0
  3. data/app/assets/tailwind/studio_engine/engine-motion.css +620 -5
  4. data/app/controllers/style_controller.rb +25 -0
  5. data/app/views/studio/modals/_crop_photo.html.erb +5 -1
  6. data/app/views/studio/modals/_host.html.erb +5 -0
  7. data/app/views/studio/modals/_image_upload.html.erb +16 -5
  8. data/app/views/studio/modals/_saving.html.erb +6 -2
  9. data/app/views/studio/modals/auth/_resend_footer.html.erb +25 -0
  10. data/app/views/studio/modals/blocks/_card_header.html.erb +83 -0
  11. data/app/views/studio/modals/blocks/_cta_redirect.html.erb +73 -0
  12. data/app/views/studio/modals/blocks/_onchain_success.html.erb +52 -0
  13. data/app/views/studio/modals/blocks/_progress_pill.html.erb +23 -0
  14. data/app/views/studio/modals/blocks/_shell.html.erb +35 -0
  15. data/app/views/studio/modals/blocks/_solana_tx_link.html.erb +42 -0
  16. data/app/views/studio/modals/shared/_age_attestation.html.erb +34 -0
  17. data/app/views/studio/modals/shared/_email_field.html.erb +70 -0
  18. data/app/views/studio/modals/templates/_action.html.erb +25 -0
  19. data/app/views/studio/modals/templates/_form.html.erb +25 -0
  20. data/app/views/studio/modals/templates/_status.html.erb +45 -0
  21. data/app/views/studio/modals/templates/_success.html.erb +23 -0
  22. data/app/views/studio/modals/templates/_wizard.html.erb +75 -0
  23. data/app/views/style/_modal_specimen.html.erb +120 -0
  24. data/app/views/style/_modals.html.erb +690 -0
  25. data/app/views/style/_specimen.html.erb +64 -0
  26. data/app/views/style/_tasks.html.erb +112 -0
  27. data/app/views/style/_theme.html.erb +222 -0
  28. data/app/views/style/_tricks.html.erb +323 -0
  29. data/app/views/style/index.html.erb +60 -0
  30. data/app/views/style/modals/_auth.html.erb +252 -0
  31. data/app/views/style/modals/_onchain_tx.html.erb +62 -0
  32. data/app/views/style/modals/_wallet_connect.html.erb +138 -0
  33. data/app/views/style/modals/_wallet_deposit.html.erb +56 -0
  34. data/lib/studio/version.rb +1 -1
  35. data/lib/studio.rb +27 -1
  36. metadata +32 -6
  37. data/app/controllers/design_system_controller.rb +0 -12
  38. data/app/views/design_system/_specimen.html.erb +0 -27
  39. data/app/views/design_system/index.html.erb +0 -232
@@ -0,0 +1,690 @@
1
+ <%# Modals section of the living style guide (admin/style).
2
+
3
+ The REAL Turf Monster modal system, ported into the engine and made
4
+ live-openable. A page-scoped host (dsModals) mirrors the shared host's full
5
+ API — open / swap / advance / close / current + the enter-animation
6
+ registry — so every specimen opens the genuine full-size modal built from
7
+ the engine partials, without colliding with the app-level shared host that
8
+ application.html.erb already mounts on $store.modals.
9
+
10
+ Why page-scoped (dsModals) and not $store.modals: both MS and TM render the
11
+ shared studio/modals/host once at layout level with an unconditional x-if on
12
+ $store.modals.current(), registering ONLY that app's own modal ids. Pushing
13
+ a novel specimen id onto $store.modals would make that host paint an empty
14
+ card behind ours. So this section owns a tiny, self-contained store + overlay
15
+ and registers all specimen content itself.
16
+
17
+ Groups: Auth (the sign-in step machine), Profile (crop / upload, opened
18
+ through studio/cropper_assets), Web3 (wallet-connect / on-chain-tx / deposit,
19
+ gated by Studio.feature?(:web3) — disabled-but-present-yet-openable when off),
20
+ System and status (the reusable card blocks), Templates (the copy-from
21
+ archetypes), and Rewards (level-up, gated by :leveling).
22
+
23
+ The modal motion (modal-card-* / modal-backdrop-* keyframes + studio-modal-
24
+ drain + body.modal-open) ships in engine-motion.css, so a consumer that
25
+ bundles it animates these for real. %>
26
+ <%
27
+ web3_on = Studio.feature?(:web3)
28
+ leveling_on = Studio.feature?(:leveling)
29
+
30
+ # Auth method-toggle defaults — mirror the app's Studio.auth_method? config;
31
+ # Solana Wallet also needs the web3 capability, so it defaults OFF on an app
32
+ # (like McRitchie Studio) that ships web3 off.
33
+ ml_default = Studio.auth_method?(:magic_link)
34
+ g_default = Studio.auth_method?(:google)
35
+ w_default = Studio.auth_method?(:wallet) && web3_on
36
+
37
+ # Active-card glow-match expression builder: yields an Alpine boolean that is
38
+ # true when $store.dsModals.current() is THIS specimen's modal. It discriminates
39
+ # by sub-state so two specimens that open the SAME modal id still glow the right
40
+ # card: `step:` for the auth step machine, and `crop:` for the two crop-photo
41
+ # variants (:picker vs :crop). The crop sub-state rides props.cropReady — the
42
+ # crop factory sets it true when the cropper mounts (on a direct image OR after
43
+ # an in-modal pick), so the glow moves picker -> cropper as the flow advances.
44
+ ds_glow = lambda do |id, step: nil, crop: nil|
45
+ cur = "$store.dsModals.current()"
46
+ expr = "#{cur} && #{cur}.id === '#{id}'"
47
+ expr += " && (#{cur}.props.step || 'credentials') === '#{step}'" if step
48
+ expr += (crop == :crop ? " && !!#{cur}.props.cropReady" : " && !#{cur}.props.cropReady") unless crop.nil?
49
+ expr
50
+ end
51
+ %>
52
+ <section id="modals" class="space-y-10" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)"
53
+ x-data="{}" @ds-modal-close.window="$store.dsModals.close()">
54
+ <div class="space-y-1">
55
+ <h2 class="text-2xl font-bold text-heading">Modals</h2>
56
+ <p class="text-muted text-sm">
57
+ The shared modal host and the reusable modal partials, ported from Turf
58
+ Monster into the engine. Each tile is a <strong>visual representation, not
59
+ to scale</strong> &mdash; <strong>click a whole card</strong> to open the real
60
+ modal (Enter/Space work too). The header <strong>Copy</strong> yields an
61
+ agent-ready reference you can paste into a chat; option <strong>toggles</strong>
62
+ configure the modal before you open it; and the card that matches the open
63
+ modal + step <strong>glows</strong>, following the step machine as it advances.
64
+ </p>
65
+ </div>
66
+
67
+ <%# ---- Page-scoped modal host (dsModals) --------------------------------
68
+ The full store API — open(id, props, opts) LIFO stack, swap() directional
69
+ slide, advance(patch, opts) in-modal step change, close / closeAll,
70
+ current(), cardClasses() driving the enter/exit animation registry, and
71
+ body.modal-open scroll lock. Plus the dsSolanaModal proxy over the store
72
+ (stable show/success/error/close API for the on-chain-tx modal) and the
73
+ credential stubs (postMagicLink / walletProvider / solanaWalletConnect) so
74
+ the auth + wallet specimens run without an auth backend. Defined in a plain
75
+ inline script (no importmap dependency) so Alpine's defer finds it. %>
76
+ <script>
77
+ (function () {
78
+ // === Credential stubs — never clobber a real one an app already ships. ==
79
+ window.postMagicLink = window.postMagicLink || function () {
80
+ return Promise.resolve({ success: true });
81
+ };
82
+ window.solanaWalletConnect = window.solanaWalletConnect || function () {
83
+ return { connecting: false, error: null, statusText: 'Connect Wallet',
84
+ walletAvailable: false, isMobile: false, connect: function () {} };
85
+ };
86
+ window.walletProvider = window.walletProvider || {
87
+ available: function () { return [{ name: 'Phantom' }, { name: 'Solflare' }]; },
88
+ isMobile: function () { return false; },
89
+ isAvailable: function () { return false; }
90
+ };
91
+
92
+ document.addEventListener('alpine:init', function () {
93
+ if (Alpine.store('dsModals')) return;
94
+
95
+ // Enter/exit animation registry — keys map to a CSS class (engine-
96
+ // motion.css) plus its duration in ms. Open a modal with
97
+ // { enterAnim: 'shake' } / { exitAnim: 'slide' } to pick one; omitted
98
+ // uses 'pop' (the spring bounce). The directional swap()/advance()
99
+ // slide uses the modal-card-swap-* classes regardless of these keys.
100
+ var CLOSE_ANIM_MS = 220;
101
+ var SWAP_IN_MS = 220;
102
+ var Anim = {
103
+ enter: {
104
+ pop: { cls: 'modal-card-mount', ms: 320 },
105
+ shake: { cls: 'modal-card-shake-in', ms: 600 },
106
+ slide: { cls: 'modal-card-swap-in', ms: 220 }
107
+ },
108
+ exit: {
109
+ pop: { cls: 'modal-card-unmount', ms: 220 },
110
+ slide: { cls: 'modal-card-swap-out', ms: 220 }
111
+ }
112
+ };
113
+ function modalAnim(channel, key) {
114
+ var table = Anim[channel] || {};
115
+ return table[key] || table.pop;
116
+ }
117
+
118
+ Alpine.store('dsModals', {
119
+ stack: [],
120
+ current: function () { return this.stack.length ? this.stack[this.stack.length - 1] : null; },
121
+ isOpen: function (id) {
122
+ for (var i = 0; i < this.stack.length; i++) { if (this.stack[i].id === id) return true; }
123
+ return false;
124
+ },
125
+ // open(id, props, opts) — opts.replace swaps the top entry with a
126
+ // directional slide (forward = exit right / enter left); otherwise
127
+ // push (stacking — closing reveals the entry underneath).
128
+ open: function (id, props, opts) {
129
+ props = props || {}; opts = opts || {};
130
+ var self = this;
131
+ if (opts.replace && this.stack.length > 0) {
132
+ var current = this.current();
133
+ if (current && !current._closing) {
134
+ var dir = (opts.direction === 'back') ? 'back' : 'forward';
135
+ current._swapDir = dir;
136
+ current._swappingOut = true;
137
+ current._closing = true;
138
+ setTimeout(function () {
139
+ var idx = self.stack.indexOf(current);
140
+ var newEntry = { id: id, props: props, _swappingIn: true, _swapDir: dir };
141
+ if (idx >= 0) { self.stack[idx] = newEntry; } else { self.stack.push(newEntry); }
142
+ self._sync();
143
+ setTimeout(function () {
144
+ newEntry._swappingIn = false; newEntry._swapDir = null; newEntry._settled = true;
145
+ }, SWAP_IN_MS);
146
+ }, CLOSE_ANIM_MS);
147
+ return;
148
+ }
149
+ this.stack[this.stack.length - 1] = { id: id, props: props };
150
+ } else {
151
+ this.stack.push({ id: id, props: props });
152
+ }
153
+ this._sync();
154
+ },
155
+ // swap(id, props, opts) ≡ open with { replace: true }.
156
+ swap: function (id, props, opts) {
157
+ return this.open(id, props, Object.assign({ replace: true }, opts || {}));
158
+ },
159
+ // advance(patch, opts) — in-flow step change: same directional slide as
160
+ // swap() but WITHOUT replacing the stack entry, so the modal's outer
161
+ // x-data scope survives (the auth step machine relies on this).
162
+ advance: function (propsPatch, opts) {
163
+ opts = opts || {};
164
+ var cur = this.current();
165
+ if (!cur || cur._closing || cur._swappingOut) return;
166
+ var self = this;
167
+ cur._swapDir = (opts.direction === 'back') ? 'back' : 'forward';
168
+ cur._swappingOut = true;
169
+ setTimeout(function () {
170
+ if (self.stack.indexOf(cur) < 0) return;
171
+ if (propsPatch) Object.assign(cur.props, propsPatch);
172
+ cur._swappingOut = false;
173
+ cur._swappingIn = true;
174
+ setTimeout(function () {
175
+ cur._swappingIn = false; cur._swapDir = null; cur._settled = true;
176
+ }, SWAP_IN_MS);
177
+ }, CLOSE_ANIM_MS);
178
+ },
179
+ close: function () {
180
+ var self = this;
181
+ var entry = this.current();
182
+ if (!entry || entry._closing) return;
183
+ entry._closing = true;
184
+ setTimeout(function () {
185
+ var idx = self.stack.indexOf(entry);
186
+ if (idx >= 0) { self.stack.splice(idx, 1); self._sync(); }
187
+ }, modalAnim('exit', entry.props && entry.props.exitAnim).ms);
188
+ },
189
+ closeAll: function () { this.stack = []; this._sync(); },
190
+ cardClasses: function () {
191
+ var c = this.current();
192
+ if (!c) return {};
193
+ var o = {};
194
+ if (!c._settled && !c._swappingIn && !c._swappingOut && !c._closing) {
195
+ o[modalAnim('enter', c.props && c.props.enterAnim).cls] = true;
196
+ }
197
+ if (c._closing && !c._swappingOut) {
198
+ o[modalAnim('exit', c.props && c.props.exitAnim).cls] = true;
199
+ }
200
+ o['modal-card-swap-in'] = !!(c._swappingIn && c._swapDir !== 'back');
201
+ o['modal-card-swap-out'] = !!(c._swappingOut && c._swapDir !== 'back');
202
+ o['modal-card-swap-in-back'] = !!(c._swappingIn && c._swapDir === 'back');
203
+ o['modal-card-swap-out-back'] = !!(c._swappingOut && c._swapDir === 'back');
204
+ return o;
205
+ },
206
+ _sync: function () {
207
+ if (this.stack.length) { document.body.classList.add('modal-open'); }
208
+ else { document.body.classList.remove('modal-open'); }
209
+ }
210
+ });
211
+
212
+ // dsSolanaModal — stateless proxy over dsModals for the on-chain-tx
213
+ // modal. Reads the current 'onchain-tx' entry's props so the partial's
214
+ // $store.dsSolanaModal.{state,title,txSignature,...} reads stay stable.
215
+ Alpine.store('dsSolanaModal', {
216
+ _cur: function () { var c = Alpine.store('dsModals').current(); return (c && c.id === 'onchain-tx') ? c : null; },
217
+ _read: function (k, fb) { var c = this._cur(); return c ? (c.props[k] !== undefined ? c.props[k] : fb) : fb; },
218
+ close: function () { if (this._cur()) Alpine.store('dsModals').close(); },
219
+ get state() { return this._read('state', null); },
220
+ get title() { return this._read('title', ''); },
221
+ get message() { return this._read('message', ''); },
222
+ get txSignature() { return this._read('txSignature', null); },
223
+ get errorMessage() { return this._read('errorMessage', ''); },
224
+ get successTitle() { return this._read('successTitle', ''); },
225
+ get successSubtitle() { return this._read('successSubtitle', ''); },
226
+ get ctaLabel() { return this._read('ctaLabel', ''); },
227
+ get ctaHref() { return this._read('ctaHref', ''); }
228
+ });
229
+ });
230
+ })();
231
+ </script>
232
+
233
+ <%# Cropper.js + the cropPhotoModal factory — makes the Profile crop / upload
234
+ modals openable on this page (loads cropper.js via <script defer> and
235
+ defines the factory inline; no importmap change). %>
236
+ <%= render "studio/cropper_assets" %>
237
+
238
+ <%# ---- The overlay: one backdrop + card; every specimen's real content is a
239
+ single-root <template x-if> gated on the current modal id. Faithful to the
240
+ shared host — backdrop fade, scroll lock, escape + click-self dismissal
241
+ gated on props.dismissible (never @click.outside, so a hold modal can't
242
+ close itself on the button release). %>
243
+ <template x-if="$store.dsModals.current()">
244
+ <div class="fixed inset-0 z-[120] flex items-center justify-center p-4 modal-backdrop-mount"
245
+ :class="$store.dsModals.current()?._closing && 'modal-backdrop-unmount'"
246
+ style="background: rgba(0,0,0,0.6)"
247
+ role="dialog" aria-modal="true"
248
+ @keydown.escape.window="$store.dsModals.current() && $store.dsModals.current().props.dismissible !== false && $store.dsModals.close()"
249
+ @click.self="$store.dsModals.current() && $store.dsModals.current().props.dismissible !== false && $store.dsModals.close()">
250
+ <div class="bg-surface rounded-xl border border-subtle shadow-2xl p-6 max-w-sm w-full"
251
+ :class="$store.dsModals.cardClasses()">
252
+
253
+ <%# --- Auth suite --- %>
254
+ <template x-if="$store.dsModals.current().id === 'auth'">
255
+ <div><%= render "style/modals/auth" %></div>
256
+ </template>
257
+
258
+ <%# --- Profile (real engine partials, opened on dsModals) --- %>
259
+ <template x-if="$store.dsModals.current().id === 'crop-photo'">
260
+ <div><%= render "studio/modals/crop_photo", store: "dsModals" %></div>
261
+ </template>
262
+ <template x-if="$store.dsModals.current().id === 'saving'">
263
+ <div><%= render "studio/modals/saving", store: "dsModals" %></div>
264
+ </template>
265
+
266
+ <%# --- Web3 --- %>
267
+ <template x-if="$store.dsModals.current().id === 'wallet-connect'">
268
+ <div><%= render "style/modals/wallet_connect" %></div>
269
+ </template>
270
+ <template x-if="$store.dsModals.current().id === 'onchain-tx'">
271
+ <div><%= render "style/modals/onchain_tx" %></div>
272
+ </template>
273
+ <template x-if="$store.dsModals.current().id === 'wallet-deposit'">
274
+ <div><%= render "style/modals/wallet_deposit" %></div>
275
+ </template>
276
+
277
+ <%# --- System and status (reusable card blocks) --- %>
278
+ <template x-if="$store.dsModals.current().id === 'ds-processing'">
279
+ <div><%= render "studio/modals/blocks/processing_card",
280
+ title: "Confirming entry", message: "Talking to the server…" %></div>
281
+ </template>
282
+ <template x-if="$store.dsModals.current().id === 'ds-success'">
283
+ <div><%= render "studio/modals/blocks/success_card",
284
+ title: "Entry confirmed", message: "You're in — good luck!",
285
+ icon_color: "success", cta_label: "Continue", cta_event: "ds-modal-close" do %><% end %></div>
286
+ </template>
287
+ <template x-if="$store.dsModals.current().id === 'ds-error'">
288
+ <div><%= render "studio/modals/blocks/error_card",
289
+ title: "Couldn't connect", icon_emoji: "⚠️",
290
+ message: "Something went wrong. Give it another try.",
291
+ cta_label: "Try again", cta_event: "ds-modal-close",
292
+ secondary_label: "Dismiss", secondary_event: "ds-modal-close" %></div>
293
+ </template>
294
+ <template x-if="$store.dsModals.current().id === 'ds-countdown'">
295
+ <div x-data="{ n: 5, t: 5, timer: null }"
296
+ x-init="timer = setInterval(() => { if (n > 0) { n--; } else { clearInterval(timer); } }, 1000)">
297
+ <div class="text-center mb-4">
298
+ <p class="text-heading font-bold text-lg mb-1">Heading to the lobby</p>
299
+ <p class="text-secondary text-xs">Sit tight — this happens automatically.</p>
300
+ </div>
301
+ <%= render "studio/modals/blocks/progress_countdown", current_key: "n", total_key: "t" %>
302
+ <button type="button" class="btn btn-outline btn-sm w-full mt-4"
303
+ @click="$store.dsModals.close()">Cancel</button>
304
+ </div>
305
+ </template>
306
+
307
+ <%# --- Templates (copy-from archetypes) --- %>
308
+ <template x-if="$store.dsModals.current().id === 'template-wizard'">
309
+ <div><%= render "studio/modals/templates/wizard", modal_store: "dsModals" %></div>
310
+ </template>
311
+ <template x-if="$store.dsModals.current().id === 'template-form'">
312
+ <div><%= render "studio/modals/templates/form", modal_store: "dsModals" %></div>
313
+ </template>
314
+ <template x-if="$store.dsModals.current().id === 'template-action'">
315
+ <div><%= render "studio/modals/templates/action", modal_store: "dsModals" %></div>
316
+ </template>
317
+ <template x-if="$store.dsModals.current().id === 'template-status'">
318
+ <div><%= render "studio/modals/templates/status", modal_store: "dsModals" %></div>
319
+ </template>
320
+ <template x-if="$store.dsModals.current().id === 'template-success'">
321
+ <div><%= render "studio/modals/templates/success", modal_store: "dsModals" %></div>
322
+ </template>
323
+
324
+ <%# --- Rewards / leveling --- %>
325
+ <template x-if="$store.dsModals.current().id === 'levelup'">
326
+ <div><%= render "studio/modals/blocks/success_card",
327
+ title: "Level 10!", message: "You leveled up.", icon_emoji: "🎉",
328
+ large_title: true, cta_label: "Nice", cta_event: "ds-modal-close" do %>
329
+ <div class="flex items-center justify-center">
330
+ <span class="sheen sheen--loop studio-glow studio-glow--pulse inline-flex h-14 w-14 items-center justify-center rounded-full text-white text-lg font-extrabold"
331
+ style="background: var(--color-cta); --studio-glow-color: var(--color-success)">10</span>
332
+ </div>
333
+ <% end %></div>
334
+ </template>
335
+
336
+ </div>
337
+ </div>
338
+ </template>
339
+
340
+ <%# ===================================================================== %>
341
+ <%# 1. AUTH suite %>
342
+ <%# ===================================================================== %>
343
+ <section class="space-y-5">
344
+ <div class="space-y-1">
345
+ <h3 class="text-xl font-bold text-heading">Auth</h3>
346
+ <p class="text-muted text-sm">
347
+ The sign-in step machine
348
+ (<code class="font-mono text-2xs">style/modals/_auth</code>) — one modal,
349
+ internal steps <code class="font-mono text-2xs">credentials &rarr;
350
+ magic-link-sent &rarr; magic-link-resent</code>. Toggle the methods below a
351
+ card, then open it to watch them gate the modal. The live card
352
+ <strong>glows</strong>, and the glow follows the step machine as you advance.
353
+ </p>
354
+ </div>
355
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
356
+ <%# Sign in — method toggles + the credentials-step glow. %>
357
+ <%= render layout: "style/modal_specimen", locals: {
358
+ label: "Sign in",
359
+ reference: %(the Auth "Sign in" modal (studio-engine style/modals/_auth, step 'credentials') — open with $store.dsModals.open('auth', { step: 'credentials', picksRequired: 6 }); toggle methods via props.methods { magicLink, google, wallet } + props.terms),
360
+ card_data: "opts: { magicLink: #{ml_default}, google: #{g_default}, wallet: #{w_default}, terms: true }",
361
+ open_expr: "$store.dsModals.open('auth', { step: 'credentials', picksRequired: 6, methods: { magicLink: opts.magicLink, google: opts.google, wallet: opts.wallet }, terms: opts.terms })",
362
+ glow_when: ds_glow.call("auth", step: "credentials"),
363
+ toggles: [
364
+ { model: "opts.magicLink", label: "Magic Link" },
365
+ { model: "opts.google", label: "Google" },
366
+ { model: "opts.wallet", label: "Solana Wallet" },
367
+ { model: "opts.terms", label: "Terms" }
368
+ ] } do %>
369
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2 text-center">
370
+ <span class="block h-2 w-16 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
371
+ <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
372
+ <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
373
+ <span class="block h-6 w-full rounded" style="background: var(--color-cta); opacity: .5"></span>
374
+ </div>
375
+ <% end %>
376
+
377
+ <%= render layout: "style/modal_specimen", locals: {
378
+ label: "Magic link sent",
379
+ reference: %(the Auth "Magic link sent" step (style/modals/_auth, step 'magic-link-sent') — open with $store.dsModals.open('auth', { step: 'magic-link-sent', sentEmail: 'you@example.com' })),
380
+ open_expr: "$store.dsModals.open('auth', { step: 'magic-link-sent', sentEmail: 'you@example.com' })",
381
+ glow_when: ds_glow.call("auth", step: "magic-link-sent") } do %>
382
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
383
+ <div class="text-2xl leading-none">📬</div>
384
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
385
+ <span class="block h-1.5 w-16 mx-auto rounded" style="background: var(--color-text); opacity: .12"></span>
386
+ </div>
387
+ <% end %>
388
+
389
+ <%# NEW — the magic-link-resent step (the resend confirmation slide). %>
390
+ <%= render layout: "style/modal_specimen", locals: {
391
+ label: "Magic link resent",
392
+ reference: %(the Auth "Magic link resent" step (style/modals/_auth, step 'magic-link-resent') — open with $store.dsModals.open('auth', { step: 'magic-link-resent', sentEmail: 'you@example.com' })),
393
+ open_expr: "$store.dsModals.open('auth', { step: 'magic-link-resent', sentEmail: 'you@example.com' })",
394
+ glow_when: ds_glow.call("auth", step: "magic-link-resent") } do %>
395
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
396
+ <div class="mx-auto w-7 h-7 rounded-full flex items-center justify-center" style="background: color-mix(in srgb, var(--color-primary) 15%, transparent)">
397
+ <svg class="w-4 h-4" style="color: var(--color-primary)" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
398
+ </div>
399
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
400
+ <span class="block h-1.5 w-16 mx-auto rounded" style="background: var(--color-text); opacity: .12"></span>
401
+ </div>
402
+ <% end %>
403
+
404
+ <%= render layout: "style/modal_specimen", locals: {
405
+ label: "Redirect (drain)",
406
+ reference: %(the Auth "Redirect" step (style/modals/_auth, step 'redirect', drain CTA) — open with $store.dsModals.open('auth', { step: 'redirect', icon: '📍', title: 'One more step', message: 'Taking you there.', url: null })),
407
+ open_expr: "$store.dsModals.open('auth', { step: 'redirect', icon: '📍', title: 'One more step', message: 'Taking you there.', url: null })",
408
+ glow_when: ds_glow.call("auth", step: "redirect") } do %>
409
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
410
+ <div class="text-2xl leading-none">📍</div>
411
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
412
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
413
+ </div>
414
+ <% end %>
415
+ </div>
416
+ </section>
417
+
418
+ <%# ===================================================================== %>
419
+ <%# 2. PROFILE — crop / upload, live via cropper_assets %>
420
+ <%# ===================================================================== %>
421
+ <section class="space-y-5">
422
+ <div class="space-y-1">
423
+ <h3 class="text-xl font-bold text-heading">Profile</h3>
424
+ <p class="text-muted text-sm">
425
+ The avatar / photo modals ship from the engine
426
+ (<code class="font-mono text-2xs">studio/modals/_crop_photo</code>,
427
+ <code class="font-mono text-2xs">studio/modals/_image_upload</code>) and
428
+ open live here — <code class="font-mono text-2xs">studio/cropper_assets</code>
429
+ is rendered above, so cropper.js loads and the modals mount on this page's
430
+ host.
431
+ </p>
432
+ </div>
433
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
434
+ <%# Image upload FIRST — the empty picker (crop-photo opened with no imageUrl).
435
+ cropReady: false is the defined starting sub-state; the crop factory
436
+ flips it true once an image is picked, moving the glow to Crop photo. %>
437
+ <%= render layout: "style/modal_specimen", locals: {
438
+ label: "Image upload",
439
+ reference: %(the Profile "Image upload" modal (studio-engine studio/modals/_crop_photo as the empty picker) — open with $store.dsModals.open('crop-photo', {})),
440
+ open_expr: "$store.dsModals.open('crop-photo', { cropReady: false })",
441
+ glow_when: ds_glow.call("crop-photo", crop: :picker) } do %>
442
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
443
+ <div class="mx-auto w-20 h-20 rounded-lg border-2 border-dashed flex items-center justify-center" style="border-color: var(--color-border-strong)">
444
+ <span class="text-2xl leading-none">⬆️</span>
445
+ </div>
446
+ <span class="block h-1.5 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .12"></span>
447
+ </div>
448
+ <% end %>
449
+
450
+ <%# Crop photo SECOND — the cropper mounted on a chosen image. cropReady:
451
+ true glows this card immediately on a direct open (the factory also sets
452
+ it when the cropper mounts). %>
453
+ <%= render layout: "style/modal_specimen", locals: {
454
+ label: "Crop photo",
455
+ reference: %(the Profile "Crop photo" modal (studio/modals/_crop_photo + studio/_cropper_assets) — open with $store.dsModals.open('crop-photo', { imageUrl: '/icon.png', aspectRatio: 1 })),
456
+ open_expr: "$store.dsModals.open('crop-photo', { imageUrl: '/icon.png', aspectRatio: 1, cropReady: true })",
457
+ glow_when: ds_glow.call("crop-photo", crop: :crop) } do %>
458
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
459
+ <div class="mx-auto w-20 h-20 rounded-lg border-2 flex items-center justify-center" style="border-color: var(--color-cta)">
460
+ <span class="text-muted text-2xs">Crop area</span>
461
+ </div>
462
+ <span class="block h-4 w-full rounded" style="background: var(--color-cta)"></span>
463
+ </div>
464
+ <% end %>
465
+
466
+ <%= render layout: "style/modal_specimen", locals: {
467
+ label: "Saving card",
468
+ reference: %(the Profile "Saving" card (studio/modals/_saving loading state) — open with $store.dsModals.open('saving', { title: 'Saving…' })),
469
+ open_expr: "$store.dsModals.open('saving', { title: 'Saving…' })",
470
+ glow_when: ds_glow.call("saving") } do %>
471
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
472
+ <div class="mx-auto w-8 h-8 rounded-full border-4 animate-spin" style="border-color: color-mix(in srgb, var(--color-cta) 30%, transparent); border-top-color: var(--color-cta)"></div>
473
+ <span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
474
+ </div>
475
+ <% end %>
476
+ </div>
477
+ </section>
478
+
479
+ <%# ===================================================================== %>
480
+ <%# 3. WEB3 — gated by Studio.feature?(:web3); off = disabled-but-present-yet-openable %>
481
+ <%# ===================================================================== %>
482
+ <section class="space-y-5">
483
+ <div class="space-y-1">
484
+ <div class="flex flex-wrap items-center gap-3">
485
+ <h3 class="text-xl font-bold text-heading">Web3</h3>
486
+ <% if web3_on %>
487
+ <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">enabled on this app</span>
488
+ <% else %>
489
+ <span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">disabled on this app</span>
490
+ <% end %>
491
+ </div>
492
+ <p class="text-muted text-sm">
493
+ Wallet + on-chain modals, ported into the engine and gated by
494
+ <code class="font-mono text-2xs">Studio.feature?(:web3)</code>. Web3 is
495
+ <strong><%= web3_on ? "on" : "off" %></strong> here, so these render
496
+ <%= web3_on ? "live." : "greyed and badged — but STILL openable as a preview." %>
497
+ </p>
498
+ </div>
499
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
500
+ <%= render layout: "style/modal_specimen", locals: {
501
+ label: "Connect wallet",
502
+ reference: %(the Web3 "Connect wallet" picker (studio-engine style/modals/_wallet_connect) — open with $store.dsModals.open('wallet-connect')),
503
+ open_expr: "$store.dsModals.open('wallet-connect')",
504
+ disabled: !web3_on, openable: true } do %>
505
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
506
+ <span class="block h-2 w-20 rounded" style="background: var(--color-text); opacity: .18"></span>
507
+ <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
508
+ <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
509
+ </div>
510
+ <% end %>
511
+
512
+ <%= render layout: "style/modal_specimen", locals: {
513
+ label: "On-chain tx · processing",
514
+ reference: %(the Web3 "On-chain tx" modal, processing state (style/modals/_onchain_tx via $store.dsSolanaModal) — open with $store.dsModals.open('onchain-tx', { state: 'processing', title: 'Confirming on-chain', message: 'Waiting for the wallet signature…' })),
515
+ open_expr: "$store.dsModals.open('onchain-tx', { state: 'processing', title: 'Confirming on-chain', message: 'Waiting for the wallet signature…' })",
516
+ disabled: !web3_on, openable: true } do %>
517
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
518
+ <div class="loading-dots mx-auto"><span></span><span></span><span></span></div>
519
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
520
+ </div>
521
+ <% end %>
522
+
523
+ <%= render layout: "style/modal_specimen", locals: {
524
+ label: "On-chain tx · success",
525
+ reference: %(the Web3 "On-chain tx" modal, generic-success state (style/modals/_onchain_tx + studio/modals/blocks/_onchain_success) — open with $store.dsModals.open('onchain-tx', { state: 'success', txSignature: '…', successTitle: 'Mint complete' })),
526
+ open_expr: "$store.dsModals.open('onchain-tx', { state: 'success', txSignature: '5xTrDemoSignature1234567890', successTitle: 'Mint complete', successSubtitle: 'Your on-chain action confirmed.', ctaLabel: 'View wallet', ctaHref: null })",
527
+ disabled: !web3_on, openable: true } do %>
528
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
529
+ <div class="mx-auto w-7 h-7 rounded-full flex items-center justify-center" style="background: color-mix(in srgb, var(--color-success) 15%, transparent)">
530
+ <svg class="w-4 h-4" style="color: var(--color-success)" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
531
+ </div>
532
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
533
+ </div>
534
+ <% end %>
535
+
536
+ <%= render layout: "style/modal_specimen", locals: {
537
+ label: "On-chain tx · error",
538
+ reference: %(the Web3 "On-chain tx" modal, error state (style/modals/_onchain_tx) — open with $store.dsModals.open('onchain-tx', { state: 'error', title: "Couldn't confirm", errorMessage: 'The transaction was rejected. Give it another try.' })),
539
+ open_expr: "$store.dsModals.open('onchain-tx', { state: 'error', title: 'Could not confirm', errorMessage: 'The transaction was rejected. Give it another try.' })",
540
+ disabled: !web3_on, openable: true } do %>
541
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
542
+ <div class="text-2xl leading-none">⚠️</div>
543
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
544
+ </div>
545
+ <% end %>
546
+
547
+ <%= render layout: "style/modal_specimen", locals: {
548
+ label: "Wallet deposit",
549
+ reference: %(the Web3 "Wallet deposit" top-up card (style/modals/_wallet_deposit) — open with $store.dsModals.open('wallet-deposit', { neededCents: 500, usdcCents: 120, usdtCents: 0, address: 'Fo1L5…' })),
550
+ open_expr: "$store.dsModals.open('wallet-deposit', { neededCents: 500, usdcCents: 120, usdtCents: 0, address: 'Fo1L5demoWALLETaddr9xQ2' })",
551
+ disabled: !web3_on, openable: true } do %>
552
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
553
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
554
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
555
+ <span class="block h-5 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
556
+ </div>
557
+ <% end %>
558
+ </div>
559
+ </section>
560
+
561
+ <%# ===================================================================== %>
562
+ <%# 4. SYSTEM AND STATUS (reusable card blocks) %>
563
+ <%# ===================================================================== %>
564
+ <section class="space-y-5">
565
+ <div class="space-y-1">
566
+ <h3 class="text-xl font-bold text-heading">System &amp; status</h3>
567
+ <p class="text-muted text-sm">
568
+ The reusable card blocks under <code class="font-mono text-2xs">studio/modals/blocks/</code> &mdash;
569
+ the studio-canonical processing, success, error, and countdown states.
570
+ </p>
571
+ </div>
572
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
573
+ <%= render layout: "style/modal_specimen", locals: {
574
+ label: "Processing",
575
+ reference: %(the "Processing" card block (studio/modals/blocks/_processing_card) — open with $store.dsModals.open('ds-processing')),
576
+ open_expr: "$store.dsModals.open('ds-processing')" } do %>
577
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
578
+ <div class="mx-auto w-6 h-6 rounded-full border-2 animate-spin" style="border-color: color-mix(in srgb, var(--color-cta) 30%, transparent); border-top-color: var(--color-cta)"></div>
579
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
580
+ <span class="block h-1.5 w-16 mx-auto rounded" style="background: var(--color-text); opacity: .12"></span>
581
+ </div>
582
+ <% end %>
583
+
584
+ <%= render layout: "style/modal_specimen", locals: {
585
+ label: "Success",
586
+ reference: %(the "Success" card block (studio/modals/blocks/_success_card, entry-confirmed style) — open with $store.dsModals.open('ds-success')),
587
+ open_expr: "$store.dsModals.open('ds-success')" } do %>
588
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
589
+ <div class="mx-auto w-7 h-7 rounded-full flex items-center justify-center" style="background: color-mix(in srgb, var(--color-success) 15%, transparent)">
590
+ <svg class="w-4 h-4" style="color: var(--color-success)" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
591
+ </div>
592
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
593
+ <span class="block h-4 w-full rounded" style="background: var(--color-cta)"></span>
594
+ </div>
595
+ <% end %>
596
+
597
+ <%= render layout: "style/modal_specimen", locals: {
598
+ label: "Error / retry",
599
+ reference: %(the "Error" card block (studio/modals/blocks/_error_card) — open with $store.dsModals.open('ds-error')),
600
+ open_expr: "$store.dsModals.open('ds-error')" } do %>
601
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
602
+ <div class="text-2xl leading-none">⚠️</div>
603
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
604
+ <span class="block h-4 w-20 mx-auto rounded border" style="border-color: var(--color-border-strong)"></span>
605
+ </div>
606
+ <% end %>
607
+
608
+ <%= render layout: "style/modal_specimen", locals: {
609
+ label: "Countdown",
610
+ reference: %(the "Countdown" progress block (studio/modals/blocks/_progress_countdown) — open with $store.dsModals.open('ds-countdown')),
611
+ open_expr: "$store.dsModals.open('ds-countdown')" } do %>
612
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
613
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
614
+ <div class="w-full rounded-full overflow-hidden" style="height: 6px; background: rgba(128,128,128,0.2)">
615
+ <div style="height: 100%; width: 60%; border-radius: 9999px; background: var(--color-cta)"></div>
616
+ </div>
617
+ <span class="block h-1.5 w-16 mx-auto rounded" style="background: var(--color-text); opacity: .12"></span>
618
+ </div>
619
+ <% end %>
620
+ </div>
621
+ </section>
622
+
623
+ <%# ===================================================================== %>
624
+ <%# 5. TEMPLATES (copy-from archetypes) %>
625
+ <%# ===================================================================== %>
626
+ <section class="space-y-5">
627
+ <div class="space-y-1">
628
+ <h3 class="text-xl font-bold text-heading">Templates</h3>
629
+ <p class="text-muted text-sm">
630
+ Reusable archetypes under <code class="font-mono text-2xs">studio/modals/templates/</code> &mdash;
631
+ the copy-from reference cards for a new modal (wizard, form, action,
632
+ status, success).
633
+ </p>
634
+ </div>
635
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
636
+ <% [
637
+ ["template-wizard", "Wizard", "🧭", "studio/modals/templates/_wizard"],
638
+ ["template-form", "Form", "📝", "studio/modals/templates/_form"],
639
+ ["template-action", "Action", "❓", "studio/modals/templates/_action"],
640
+ ["template-status", "Status", "⏳", "studio/modals/templates/_status"],
641
+ ["template-success", "Success", "✅", "studio/modals/templates/_success"]
642
+ ].each do |id, label, glyph, partial| %>
643
+ <%= render layout: "style/modal_specimen", locals: {
644
+ label: label,
645
+ reference: %(the "#{label}" modal template (#{partial}) — open with $store.dsModals.open('#{id}')),
646
+ open_expr: "$store.dsModals.open('#{id}')" } do %>
647
+ <div class="pointer-events-none w-32 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
648
+ <div class="text-2xl leading-none"><%= glyph %></div>
649
+ <span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
650
+ <span class="block h-4 w-full rounded" style="background: var(--color-cta); opacity: .6"></span>
651
+ </div>
652
+ <% end %>
653
+ <% end %>
654
+ </div>
655
+ </section>
656
+
657
+ <%# ===================================================================== %>
658
+ <%# 6. REWARDS / leveling (gated by Studio.feature?(:leveling)) %>
659
+ <%# ===================================================================== %>
660
+ <section class="space-y-5">
661
+ <div class="space-y-1">
662
+ <div class="flex flex-wrap items-center gap-3">
663
+ <h3 class="text-xl font-bold text-heading">Rewards</h3>
664
+ <% if leveling_on %>
665
+ <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">enabled on this app</span>
666
+ <% else %>
667
+ <span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">disabled on this app</span>
668
+ <% end %>
669
+ </div>
670
+ <p class="text-muted text-sm">
671
+ The level-up celebration, gated by <code class="font-mono text-2xs">Studio.feature?(:leveling)</code>.
672
+ Leveling is <strong><%= leveling_on ? "on" : "off" %></strong> here, so this renders
673
+ <%= leveling_on ? "live." : "greyed and badged — but STILL openable as a preview." %>
674
+ </p>
675
+ </div>
676
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
677
+ <%= render layout: "style/modal_specimen", locals: {
678
+ label: "Level up",
679
+ reference: %(the Rewards "Level up" celebration (studio/modals/blocks/_success_card with the sheen + studio-glow badge) — open with $store.dsModals.open('levelup')),
680
+ open_expr: "$store.dsModals.open('levelup')",
681
+ disabled: !leveling_on, openable: true } do %>
682
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
683
+ <div class="text-2xl leading-none">🎉</div>
684
+ <span class="inline-flex h-8 w-8 items-center justify-center rounded-full text-white text-sm font-extrabold" style="background: var(--color-cta)">10</span>
685
+ <span class="block h-4 w-full rounded" style="background: var(--color-cta)"></span>
686
+ </div>
687
+ <% end %>
688
+ </div>
689
+ </section>
690
+ </section>