studio-engine 0.25.1 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d87b5711dd0833ea78e20b6f3174dd57e5cc3b0311e56f962e8954a408943963
4
- data.tar.gz: 81708f5604bdebaa3f08504169e4bf1a55ba8ea9d8856f04594dbe49295d553c
3
+ metadata.gz: 3fefcb19f5cf631e4e1515bf0b113fb33167cb1173f039a0777038aac5173d57
4
+ data.tar.gz: f203c29195b71e945a055bcfa60ad58f0d1aea16fe2ce60e84de4de1993d62ec
5
5
  SHA512:
6
- metadata.gz: b3ffce4a4ba6b91444902123ee106f9f88fec2be0606d69f310ff464bfe336ca444de94f85c2c81873feb335080e2a9fc01390ff48df7f87977e645e0c3397ac
7
- data.tar.gz: 6584381c67a206525171c7195ded0999f8ce20ad896196aa8259b116e450c7db2a23ed95193dcb8281e49f57f09c5010817ce67e127bcc3d10c517ca4a67ca16
6
+ metadata.gz: d790f97aede359baf45915bbde14fef29cea6779a0866159a3b5f1dc5a396d24951d7ba3d796e4523e0dc17489319375929e8b3e2741ca9b7c569dd5f9dac91e
7
+ data.tar.gz: 25a54c2e44ad2e29608afe72fcf6b7dc8028fe28c47bd92e582e041b282f7f4e2b70ba5eb3468f4d8edb083f3d1fe185c340c135678b9a0a0556bfbab601a98b
data/CHANGELOG.md CHANGED
@@ -2,6 +2,137 @@
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.27.0 — 2026-07-28
6
+
7
+ **Two coordinated modal flows on the `/admin/style` Design System page — a walked
8
+ "Web3 Contest" wallet → on-chain flow and an extended "Contest entry & eligibility"
9
+ flow — plus a reusable minimum-visible-duration convention for load modals.** All
10
+ specimens are DEMO chrome (`demo: true`, no real signing); the engine gains only
11
+ view/chrome primitives and keeps custodial keys, wallet/multisig signing,
12
+ `Solana::Config`, and every on-chain write app-side.
13
+
14
+ ### Added
15
+
16
+ - **Minimum-visible-duration convention** — `studio/modals/_load_convention`
17
+ ships `window.StudioModals.holdAtLeast(minMs)` + a standard
18
+ `MIN_LOAD_MS` (1400) as a SINGLE definition, rendered by both the shared modal
19
+ host (`studio/modals/_host`, which drops its inline copy) and the style page.
20
+ A load spinner holds for at least `min_duration`, longer if the real async runs
21
+ longer — `resolveAt = max(min_duration, actual_completion)`; in demo mode
22
+ `min_duration` doubles as the auto-resolve timer. `_processing_card` gains
23
+ `min_duration` + `resolve_expr` locals so a demo load card self-advances.
24
+ - **Web3 Contest** section (renamed from "Web3") — a walked flow with
25
+ glow-follows-the-flow continuity: Connect Wallet → Processing on-chain
26
+ transaction → On-chain success. The Processing card is a load modal with a demo
27
+ success/error toggle (unchecked resolves to success, checked to error), both
28
+ resolved states built. Picking a wallet swaps to Processing, which auto-resolves
29
+ after the min-load duration.
30
+ - **Contest entry & eligibility** section (renamed from "Eligibility & entry",
31
+ moved directly under Web3 Contest) — the entry flow extended to
32
+ Entry tokens → Payment processing → Entry Tokens Minted → Contest enter
33
+ processing → Contest entered, with the glow following the step machine. Keeps
34
+ the Age gate + Entry tokens cards. The section states the honest web2/web3 map:
35
+ the token mint is web2-only (web3 funds USDC on-chain directly, no token); the
36
+ paths diverge at the funding front-end and the entry-submit endpoint, then
37
+ converge on the same on-chain Entry PDA and the same "Entry Confirmed" card.
38
+
39
+ ### Changed
40
+
41
+ - **`studio/modals/_host`** — the `holdAtLeast` helper now lives in the shared
42
+ `_load_convention` partial (same API, byte-identical behavior) so the host and
43
+ the DS page share one definition instead of drifting copies.
44
+ - **`style/modals/_entry_tokens`** — the confirming step uses the min-load
45
+ convention instead of a hardcoded timeout, and the step machine gains
46
+ `entering` + `entered` so hold-to-confirm walks through the on-chain consume to
47
+ the shared Entry Confirmed finish.
48
+ - **`style/modals/_onchain_tx`** — a processing modal opened with `demoResolve`
49
+ auto-resolves to success (or error when `demoError`) after the min-load
50
+ duration, honoring the convention.
51
+ - The Modals-section glow helper discriminates the on-chain-tx `state` and a
52
+ configurable step default, so the glow tracks a walked flow across modal ids.
53
+
54
+ ## 0.26.1 — 2026-07-28
55
+
56
+ **Fix the `/admin/style` Design System page breaking when reached via Turbo Drive
57
+ navigation (the admin sidebar "Design System" link).** Reached by a direct page
58
+ load the page was fine; reached by an in-app Turbo visit its modals would not open
59
+ on a card click and EVERY specimen glow ring lit at once. Both symptoms were one
60
+ cause: the page-scoped `dsModals` (and `dsSolanaModal`) Alpine store was registered
61
+ ONLY inside a `document.addEventListener('alpine:init', …)` handler in the page
62
+ body. Alpine loads via a deferred CDN `<script>` in the engine head and fires
63
+ `alpine:init` exactly once, on the first full-document load; Turbo Drive advance
64
+ visits swap `<body>` without reloading that head script, so `alpine:init` never
65
+ fires again — and because the registration lives only in the `/admin/style` body,
66
+ it was absent during that one `alpine:init`. The specimen cards' `x-data` still
67
+ re-initialize on the Turbo body swap (Alpine's MutationObserver), so their
68
+ `@click="$store.dsModals.open(…)"`, `:style` glow bindings, and the host's
69
+ `<template x-if="$store.dsModals.current()…">` all evaluated against an undefined
70
+ store and threw `Cannot read properties of undefined (reading 'current')` — no
71
+ modal opened, and the throwing `:style` wiped each card's static inline
72
+ `--studio-team-glow-opacity: 0`, so the CSS default of `1` relit all rings.
73
+
74
+ ### Fixed
75
+
76
+ - **`style/_modals`** — the `dsModals` + `dsSolanaModal` registration is now a
77
+ named `registerDsModals()` invoked through the same dual-guard the engine
78
+ already uses for `studio/modals/_image_upload`'s `cropPhotoModal`:
79
+ `if (window.Alpine) registerDsModals(); else document.addEventListener('alpine:init', registerDsModals);`.
80
+ On a Turbo visit Alpine has already started, so the store registers immediately
81
+ (the body script re-runs on every Turbo render) and `$store.dsModals` is defined
82
+ before the cards' bindings evaluate; on a first load Alpine has not booted yet,
83
+ so it still defers to `alpine:init`. The `if (Alpine.store('dsModals')) return;`
84
+ idempotency guard is unchanged, so a later `alpine:init` is a no-op. No consumer
85
+ change is required — `/admin/style` is an engine page.
86
+ - **Docs** — `style/_modal_specimen`'s fail-closed comment now records that the
87
+ static inline `--studio-team-glow-opacity: 0` guards the ring only while
88
+ `$store.dsModals` is DEFINED (a throwing `:style` wipes the inline value) — the
89
+ refinement of 0.24.1's "regardless of Alpine timing" claim that this bug exposed.
90
+
91
+ ### Swept
92
+
93
+ - Audited every engine `Alpine.store(...)` / `Alpine.data(...)` registration for
94
+ the same Turbo-visit fragility. `dsModals`/`dsSolanaModal` (`style/_modals`) was
95
+ the only page-scoped registration missing the guard. `studio/modals/_image_upload`
96
+ (`cropPhotoModal`) already carries it; the shared modal host (`studio/modals/_host`
97
+ → `modals`) and the theme/devMode stores (`layouts/studio/_head`) are app-wide
98
+ bootstrap rendered on the first full load, so their single `alpine:init`
99
+ registration persists across Turbo visits (verified: `$store.modals` stays
100
+ defined after a Turbo nav).
101
+
102
+ ## 0.26.0 — 2026-07-28
103
+
104
+ **The engine navbar pins itself under smooth-load, and the smooth-load CSS is
105
+ fully engine-owned.** Since 0.24 the `vt-pinned-header` pin was opt-in but the
106
+ engine's own `layouts/_navbar` never carried it, so a host that wanted the
107
+ pinned-header transition had to SHADOW the whole partial just to add one class
108
+ (acquisition-studio did exactly that — the known override-drift trap). The
109
+ navbar now self-pins when `Studio.smooth_load` is on, and the engine also ships
110
+ the `studio-header` cross-fade suppression that mcritchie-studio and
111
+ turf-monster had been carrying app-side as a bridge. **Consumer cleanup this
112
+ version unlocks:** acquisition-studio deletes its entire
113
+ `app/views/layouts/_navbar.html.erb` override; mcritchie-studio and
114
+ turf-monster delete their app-side `::view-transition-old(studio-header)` /
115
+ `::view-transition-new(studio-header) { animation: none; }` bridge blocks.
116
+
117
+ ### Changed
118
+
119
+ - **`layouts/_navbar`** — the sticky header adds `vt-pinned-header` itself when
120
+ `Studio.smooth_load` is on, non-preview branch ONLY (preview renders can
121
+ repeat per page, and a duplicate `view-transition-name` silently disables
122
+ every transition). With the flag off nothing extra renders, so a plain gem
123
+ bump changes no opted-out app.
124
+ - **`engine.css`** — new `::view-transition-old(studio-header)` /
125
+ `::view-transition-new(studio-header) { animation: none; }` rule beside the
126
+ smooth-load block: it suppresses the UA-default ~250ms plus-lighter
127
+ cross-fade between the header's two snapshots, which double-drew the wordmark
128
+ and buttons on any navigation from a scrolled page (collapsed header →
129
+ expanded at top). Lifts the identical bridge rule the hub and turf-monster
130
+ carried app-side.
131
+ - **`engine.css`** — `.turbo-progress-bar` background becomes
132
+ `var(--color-cta, #0076ff)`: the fallback (Turbo's own default blue) keeps
133
+ the bar visible in a layout that never renders the runtime theme block
134
+ (`studio_theme_css_tag`), where `--color-cta` is unset.
135
+
5
136
  ## 0.25.1 — 2026-07-28
6
137
 
7
138
  **Fix the Profile Leveling save-close regression 0.25.0 introduced.** The 0.25.0
@@ -110,6 +241,12 @@ follows the step machine), so this surfaced as a flash-of-all-glow on load.
110
241
  unchanged. The `engine-motion.css` `.studio-team-glow` default is untouched
111
242
  (the always-on Tricks demos depend on it).
112
243
 
244
+ > **Refined in 0.26.1:** "regardless of Alpine timing" holds only while
245
+ > `$store.dsModals` is defined. On a Turbo Drive visit the store went
246
+ > unregistered, the reactive `:style` threw, and Alpine wiped this inline
247
+ > default — relighting every ring. 0.26.1 dual-guards the store registration so
248
+ > it survives Turbo visits, restoring the fail-closed guarantee.
249
+
113
250
  ## 0.24.0 — 2026-07-28
114
251
 
115
252
  The **smooth-load convention**, opt-in per app. Pages materialize behind the
@@ -221,12 +221,28 @@
221
221
  view-transition-name: studio-header;
222
222
  }
223
223
 
224
+ /* Suppress the UA default on the pinned header's own group: without this the
225
+ browser runs its ~250ms plus-lighter cross-fade between the header's old and
226
+ new snapshots, and on any navigation from a scrolled page (collapsed header
227
+ → expanded at top) the wordmark and buttons draw TWICE, offset by the height
228
+ delta, until the blend settles. animation: none shows the incoming header
229
+ immediately while the group box still morphs its height. Engine-owned as of
230
+ 0.26.0 — this lifts the identical bridge rule mcritchie-studio and
231
+ turf-monster carried app-side; consumers delete their copies when adopting
232
+ this version. */
233
+ ::view-transition-old(studio-header),
234
+ ::view-transition-new(studio-header) {
235
+ animation: none;
236
+ }
237
+
224
238
  /* Turbo's progress bar is the slow-load feedback under no-preview (the old
225
239
  page holds until the fresh response). Theme it so every app's bar matches
226
- its brand instead of Turbo's default. */
240
+ its brand instead of Turbo's default. The fallback (#0076ff, Turbo's own
241
+ default blue) keeps the bar visible in a layout that never renders the
242
+ runtime theme block (studio_theme_css_tag), where --color-cta is unset. */
227
243
  .turbo-progress-bar {
228
244
  height: 3px;
229
- background: var(--color-cta);
245
+ background: var(--color-cta, #0076ff);
230
246
  }
231
247
 
232
248
  /* Reduced motion: keep the single-render swap, drop the choreography. Also
@@ -8,6 +8,14 @@
8
8
  %>
9
9
  <% show_user = local_assigns.fetch(:show_logged_in, logged_in?) %>
10
10
  <% is_preview = local_assigns.fetch(:preview, false) %>
11
+ <%# Smooth-load self-pin (engine 0.26): when the app opts into Studio.smooth_load,
12
+ the sticky header names itself as the shared view-transition group
13
+ (vt-pinned-header) so page content transitions beneath a navbar that stays
14
+ put — hosts no longer shadow this partial just to add the class. Non-preview
15
+ branch ONLY: preview renders can repeat per page, and a duplicate
16
+ view-transition-name silently disables every transition. With the flag off,
17
+ nothing extra renders. %>
18
+ <% pin_header = Studio.smooth_load && !is_preview %>
11
19
  <% balance_html = local_assigns.fetch(:balance_html, nil) %>
12
20
  <% extra_icons_html = local_assigns.fetch(:extra_icons_html, nil) %>
13
21
  <% show_logout_link = local_assigns.fetch(:show_logout_link, false) %>
@@ -19,7 +27,7 @@
19
27
  %>
20
28
 
21
29
  <header x-data="{ scrolled: false }" <%= '@scroll.window="scrolled = scrolled ? (window.scrollY > 5) : (window.scrollY > 60)"'.html_safe unless is_preview %>
22
- class="<%= is_preview ? 'bg-page' : 'sticky top-0 z-50 bg-page transition-shadow duration-300' %>"
30
+ class="<%= is_preview ? 'bg-page' : "#{'vt-pinned-header ' if pin_header}sticky top-0 z-50 bg-page transition-shadow duration-300" %>"
23
31
  :class="scrolled && 'shadow-lg border-b border-subtle is-scrolled'">
24
32
  <style>
25
33
  .user-nav-col { width: 14rem; }
@@ -153,24 +153,6 @@
153
153
 
154
154
  <script>
155
155
  (function() {
156
- // === StudioModals helpers ===========================================
157
- //
158
- // holdAtLeast(ms) — pair with the _processing_card block. Stamp the
159
- // moment a loading view becomes visible, then await the minimum
160
- // before swapping to success so the spinner doesn't flash past the
161
- // user when the operation finishes fast.
162
- window.StudioModals = window.StudioModals || {};
163
- window.StudioModals.holdAtLeast = function(minMs) {
164
- var startedAt = Date.now();
165
- return {
166
- then: function(callback) {
167
- var remaining = Math.max(0, minMs - (Date.now() - startedAt));
168
- if (remaining === 0) { callback(); return; }
169
- setTimeout(callback, remaining);
170
- }
171
- };
172
- };
173
-
174
156
  // === Modal animation registry ======================================
175
157
  //
176
158
  // The single place to define + tune named entrance / exit animations
@@ -490,6 +472,14 @@
490
472
  })();
491
473
  </script>
492
474
 
475
+ <%# The minimum-visible-duration convention (window.StudioModals.holdAtLeast +
476
+ MIN_LOAD_MS) — the SINGLE source, shared with the /admin/style demo page.
477
+ Pair it with the _processing_card block so a load spinner never flashes past
478
+ the user. Rendered AFTER the store <script> above so the store stays the
479
+ host's first <script> (a Node behavior harness extracts that first script).
480
+ See studio/modals/_load_convention for the full contract. %>
481
+ <%= render "studio/modals/load_convention" %>
482
+
493
483
  <template x-if="$store.modals.current()">
494
484
  <%# Backdrop + card use CSS keyframe animations (NOT Alpine
495
485
  x-transition), all shipped in the inline style block above. On
@@ -0,0 +1,55 @@
1
+ <%#
2
+ Minimum-visible-duration convention for load / processing modals.
3
+
4
+ A load modal shows its spinner for AT LEAST a minimum duration so it never
5
+ flashes past the user (registering "did anything happen?" instead of
6
+ "processing → done"), but LONGER whenever the real async takes longer:
7
+
8
+ resolveAt = max(min_duration, actual_completion)
9
+
10
+ This ships the convention on window.StudioModals, defined ONCE here and
11
+ rendered by BOTH the shared modal host (studio/modals/_host) and the living
12
+ style guide (style/_modals) — so every consumer app and the /admin/style demo
13
+ page share a single definition:
14
+
15
+ MIN_LOAD_MS — the standard default minimum (1400 ms). A caller may pass its
16
+ own, but reach for this so every load modal feels consistent.
17
+
18
+ holdAtLeast(minMs) — returns a thenable whose .then(cb) fires cb at
19
+ max(minMs, elapsed-since-this-call). Two usages:
20
+
21
+ REAL async — stamp the moment the loading view appears, then
22
+ resolve in the async completion, so a slow op is never cut
23
+ short and a fast one still honors the spinner:
24
+ var hold = StudioModals.holdAtLeast(StudioModals.MIN_LOAD_MS);
25
+ doWork().then(function () { hold.then(showSuccess); });
26
+
27
+ DEMO (no real work) — call .then immediately and minMs
28
+ DOUBLES as the auto-resolve timer: the demo advances after
29
+ min_duration exactly:
30
+ StudioModals.holdAtLeast(StudioModals.MIN_LOAD_MS).then(advance);
31
+
32
+ Idempotent (|| guards): never clobber a definition an app already shipped, and
33
+ safe to render more than once on a page.
34
+ %>
35
+ <script>
36
+ (function () {
37
+ window.StudioModals = window.StudioModals || {};
38
+ // Standard minimum-visible duration for a load spinner, in ms. ~1200–1500
39
+ // reads as "processing → done"; below ~1s the spinner flashes and reads as a
40
+ // glitch. Callers may override per-modal, but default to this for consistency.
41
+ window.StudioModals.MIN_LOAD_MS = window.StudioModals.MIN_LOAD_MS || 1400;
42
+ // holdAtLeast(minMs).then(cb) — cb fires at max(minMs, elapsed-since-call).
43
+ // Stamp the moment a loading view becomes visible; the anti-flicker floor.
44
+ window.StudioModals.holdAtLeast = window.StudioModals.holdAtLeast || function (minMs) {
45
+ var startedAt = Date.now();
46
+ return {
47
+ then: function (callback) {
48
+ var remaining = Math.max(0, minMs - (Date.now() - startedAt));
49
+ if (remaining === 0) { callback(); return; }
50
+ setTimeout(callback, remaining);
51
+ }
52
+ };
53
+ };
54
+ })();
55
+ </script>
@@ -2,10 +2,17 @@
2
2
  a { title } prop while a Turbo form uploads. Register it in your modal-host
3
3
  block alongside crop-photo. Single root for the host's <template x-if>.
4
4
 
5
- Local: store — Alpine store name backing the title read. Default "modals".
6
- The living style guide passes store: "dsModals" for its page-scoped host. %>
5
+ Locals:
6
+ store — Alpine store name backing the title read. Default "modals".
7
+ The living style guide passes store: "dsModals".
8
+ resolve_expr — optional demo auto-resolve (min-load convention). The real
9
+ Turbo submit drives the spinner and closes on completion, so
10
+ apps omit this; the /admin/style demo passes an expression
11
+ (e.g. close) so the specimen self-terminates after min_duration
12
+ instead of spinning forever. %>
7
13
  <% saving_store = local_assigns.fetch(:store, "modals") %>
8
14
  <div>
9
15
  <%= render "studio/modals/blocks/processing_card",
10
- title_key: "$store.#{saving_store}.current()?.props?.title || 'Saving…'" %>
16
+ title_key: "$store.#{saving_store}.current()?.props?.title || 'Saving…'",
17
+ resolve_expr: local_assigns[:resolve_expr] %>
11
18
  </div>
@@ -1,10 +1,16 @@
1
1
  <%#
2
2
  Reusable processing / loading card. Spinner + title + optional message.
3
- Pair with window.StudioModals.holdAtLeast(ms) on the caller side to
4
- enforce a minimum visible duration without that the spinner can
5
- flash by when the operation finishes faster than ~1s and the user
6
- registers "did anything happen?" instead of "ok, processing
7
- success".
3
+
4
+ Minimum-visible-duration convention (studio/modals/_load_convention):
5
+ a load spinner must show for AT LEAST a floor, but longer if the real async
6
+ runs longer resolveAt = max(min_duration, actual_completion) — so it never
7
+ flashes past the user ("did anything happen?") yet never cuts a slow op short.
8
+ Two ways to honor it:
9
+ • REAL async — the caller stamps window.StudioModals.holdAtLeast(min_duration)
10
+ when this card appears, then resolves in its completion callback.
11
+ • DEMO / auto-resolve — pass `resolve_expr`: this card self-schedules that
12
+ Alpine expression after `min_duration` (min_duration doubles as the demo's
13
+ auto-resolve timer), via the same holdAtLeast floor.
8
14
 
9
15
  Locals — pass static strings OR `_key` variants for Alpine-driven
10
16
  text that updates as state mutates (e.g. live store reads):
@@ -15,6 +21,10 @@
15
21
  size: spinner size — 'sm', 'md' (default), 'lg'
16
22
  color: color token for the spinner — 'primary' (default),
17
23
  'success', 'warning'
24
+ min_duration: minimum visible ms (default 1400, mirrors
25
+ StudioModals.MIN_LOAD_MS). The convention's floor.
26
+ resolve_expr: Alpine expression run after min_duration to advance the flow
27
+ (demo auto-resolve). Omit for a real spinner the caller drives.
18
28
 
19
29
  One of `title` or `title_key` is required.
20
30
  %>
@@ -27,8 +37,12 @@
27
37
  'lg' => 'w-16 h-16 border-4'
28
38
  }
29
39
  spinner_class = sizes[size] || sizes['md']
40
+ # min_duration mirrors StudioModals.MIN_LOAD_MS (the JS default) so the Ruby and
41
+ # JS sides of the convention agree on the same floor.
42
+ min_duration = local_assigns[:min_duration] || 1400
43
+ resolve_expr = local_assigns[:resolve_expr]
30
44
  %>
31
- <div class="text-center py-6">
45
+ <div class="text-center py-6"<%= " x-data=\"{}\" x-init=\"window.StudioModals.holdAtLeast(#{min_duration}).then(() => { #{resolve_expr} })\"".html_safe if resolve_expr %>>
32
46
  <div class="mx-auto <%= spinner_class %> rounded-full border-<%= color %>/30 border-t-<%= color %> animate-spin mb-5"></div>
33
47
  <% if local_assigns[:title_key] %>
34
48
  <p class="text-base font-bold text-heading mb-1" x-text="<%= title_key %>"></p>
@@ -59,7 +59,14 @@
59
59
  default (--studio-team-glow-opacity of 1) so the ring is OFF at first
60
60
  paint, BEFORE Alpine hydrates the reactive :style below. Without it every
61
61
  glow card shows its ring until Alpine boots (the fail-open flash). The
62
- :style then drives 0 to 0.95 with the 0.4s cross-fade. Keep BOTH. %>
62
+ :style then drives 0 to 0.95 with the 0.4s cross-fade. Keep BOTH.
63
+ CAVEAT: this fail-closed default holds only while $store.dsModals is
64
+ DEFINED. glow_when dereferences the store; if the store is missing the
65
+ reactive :style THROWS, and Alpine's style bind wipes this inline value
66
+ on the way, so the CSS default of 1 relights every ring. That is exactly
67
+ the Turbo-nav store gap fixed in 0.26.1 (style/_modals dual-guards the
68
+ dsModals registration so it survives Turbo visits, not just first load).
69
+ So the guarantee is: static inline 0 PLUS a registered dsModals. %>
63
70
  <% if glow_when %>style="--studio-team-glow-opacity: 0" :style="{ '--studio-team-glow-opacity': (<%= glow_when %>) ? '0.95' : '0' }"<% end %>>
64
71
  <article
65
72
  class="card overflow-hidden flex flex-col h-full<%= " opacity-95" if disabled %><%= " cursor-pointer" if clickable %>"
@@ -42,18 +42,22 @@
42
42
 
43
43
  # Active-card glow-match expression builder: yields an Alpine boolean that is
44
44
  # true when $store.dsModals.current() is THIS specimen's modal. It discriminates
45
- # by sub-state so two specimens that open the SAME modal id still glow the right
46
- # card: `step:` for the auth step machine, and `crop:` for the two crop-photo
47
- # variants (:picker vs :crop). The crop sub-state rides props.cropReady the
48
- # crop factory sets it true when the cropper mounts (on a direct image OR after
49
- # an in-modal pick), so the glow moves picker -> cropper as the flow advances.
50
- ds_glow = lambda do |id, step: nil, crop: nil, celebrate: nil|
45
+ # by sub-state so specimens that open the SAME modal id still glow the right
46
+ # card, and so the glow FOLLOWS a walked flow as the step machine advances:
47
+ # step: the auth / entry-tokens step machine (step_default is the
48
+ # modal's own default step 'credentials' for auth, 'picker'
49
+ # for entry-tokens).
50
+ # state: the on-chain-tx state (processing / success / error) lets the
51
+ # glow track Connect Wallet -> Processing -> On-chain success.
52
+ # crop: the two crop-photo variants (:picker vs :crop), on props.cropReady.
53
+ # celebrate: the input specimen (form) vs the "updated" (celebrate) specimen
54
+ # of the SAME leveling modal id.
55
+ ds_glow = lambda do |id, step: nil, step_default: "credentials", crop: nil, celebrate: nil, state: nil|
51
56
  cur = "$store.dsModals.current()"
52
57
  expr = "#{cur} && #{cur}.id === '#{id}'"
53
- expr += " && (#{cur}.props.step || 'credentials') === '#{step}'" if step
58
+ expr += " && (#{cur}.props.step || '#{step_default}') === '#{step}'" if step
59
+ expr += " && #{cur}.props.state === '#{state}'" if state
54
60
  expr += (crop == :crop ? " && !!#{cur}.props.cropReady" : " && !#{cur}.props.cropReady") unless crop.nil?
55
- # celebrate discriminates the input specimen (form) from the "updated" specimen
56
- # (celebrate) of the SAME leveling modal id, mirroring the step/crop sub-states.
57
61
  expr += (celebrate ? " && !!#{cur}.props.celebrate" : " && !#{cur}.props.celebrate") unless celebrate.nil?
58
62
  expr
59
63
  end
@@ -107,7 +111,10 @@
107
111
  isAvailable: function () { return false; }
108
112
  };
109
113
 
110
- document.addEventListener('alpine:init', function () {
114
+ // Register the page-scoped dsModals + dsSolanaModal stores. Named (not an
115
+ // inline alpine:init callback) so the dual-guard at the end can invoke it
116
+ // directly on a Turbo visit, when alpine:init will not fire again.
117
+ function registerDsModals() {
111
118
  if (Alpine.store('dsModals')) return;
112
119
 
113
120
  // Enter/exit animation registry — keys map to a CSS class (engine-
@@ -244,10 +251,29 @@
244
251
  get ctaLabel() { return this._read('ctaLabel', ''); },
245
252
  get ctaHref() { return this._read('ctaHref', ''); }
246
253
  });
247
- });
254
+ }
255
+
256
+ // Alpine's deferred CDN <script> fires alpine:init exactly ONCE, on the
257
+ // first full-document load. Turbo Drive advance visits swap the document
258
+ // body without reloading that head script, so alpine:init never fires
259
+ // again — and
260
+ // because this dsModals registration lives ONLY in the /admin/style body,
261
+ // it was absent during that one alpine:init. Registering here on the
262
+ // Turbo-visit path (Alpine already started) keeps $store.dsModals defined,
263
+ // so the specimen cards' @click / :style / <template x-if> don't throw.
264
+ // Mirrors studio/modals/_image_upload's cropPhotoModal guard.
265
+ if (window.Alpine) { registerDsModals(); }
266
+ else { document.addEventListener('alpine:init', registerDsModals); }
248
267
  })();
249
268
  </script>
250
269
 
270
+ <%# The minimum-visible-duration convention (window.StudioModals.holdAtLeast +
271
+ MIN_LOAD_MS) — the SAME single definition the shared modal host renders, so
272
+ the demo load modals here (payment/entry processing, on-chain tx, saving)
273
+ honor resolveAt = max(min_duration, actual) and auto-resolve after
274
+ min_duration in demo mode. A consumer app already has it via the host. %>
275
+ <%= render "studio/modals/load_convention" %>
276
+
251
277
  <%# Cropper.js + the cropPhotoModal factory — makes the Profile crop / upload
252
278
  modals openable on this page (loads cropper.js via <script defer> and
253
279
  defines the factory inline; no importmap change). %>
@@ -295,8 +321,11 @@
295
321
  <template x-if="$store.dsModals.current().id === 'crop-photo'">
296
322
  <div><%= render "studio/modals/crop_photo", store: "dsModals" %></div>
297
323
  </template>
324
+ <%# saving — the demo passes resolve_expr so the spinner self-terminates
325
+ after min_duration (a real app's Turbo submit drives + closes it). %>
298
326
  <template x-if="$store.dsModals.current().id === 'saving'">
299
- <div><%= render "studio/modals/saving", store: "dsModals" %></div>
327
+ <div><%= render "studio/modals/saving", store: "dsModals",
328
+ resolve_expr: "$store.dsModals.close()" %></div>
300
329
  </template>
301
330
 
302
331
  <%# --- Web3 --- %>
@@ -320,9 +349,12 @@
320
349
  </template>
321
350
 
322
351
  <%# --- System and status (reusable card blocks) --- %>
352
+ <%# ds-processing demos the min-duration convention on the canonical block:
353
+ resolve_expr auto-swaps to the success card after min_duration. %>
323
354
  <template x-if="$store.dsModals.current().id === 'ds-processing'">
324
355
  <div><%= render "studio/modals/blocks/processing_card",
325
- title: "Confirming entry", message: "Talking to the server…" %></div>
356
+ title: "Confirming entry", message: "Talking to the server…",
357
+ resolve_expr: "$store.dsModals.swap('ds-success')" %></div>
326
358
  </template>
327
359
  <template x-if="$store.dsModals.current().id === 'ds-success'">
328
360
  <div><%= render "studio/modals/blocks/success_card",
@@ -508,68 +540,6 @@
508
540
  </div>
509
541
  </section>
510
542
 
511
- <%# ===================================================================== %>
512
- <%# 1b. ELIGIBILITY + ENTRY %>
513
- <%# ===================================================================== %>
514
- <section class="space-y-5">
515
- <div class="space-y-1">
516
- <div class="flex flex-wrap items-center gap-3">
517
- <h3 class="text-xl font-bold text-heading">Eligibility &amp; entry</h3>
518
- <% if age_gate_on %>
519
- <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">age gate on</span>
520
- <% else %>
521
- <span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">age gate off</span>
522
- <% end %>
523
- </div>
524
- <p class="text-muted text-sm">
525
- The pre-entry gate and the entry-purchase flow. The <strong>age gate</strong>
526
- (<code class="font-mono text-2xs">studio/modals/blocks/_age_verify</code>) is a
527
- NEW engine primitive gated by
528
- <code class="font-mono text-2xs">Studio.feature?(:age_gate)</code> &mdash; a DOB
529
- modal whose minimum age, jurisdiction label, endpoint, and legal copy are all
530
- <strong>app-supplied</strong> (the engine hardcodes no policy). It is the heavier
531
- sibling of the <code class="font-mono text-2xs">shared/_age_attestation</code>
532
- checkbox inside Sign in above &mdash; <strong>run one or the other</strong>. The
533
- <strong>entry-tokens</strong> flow is <strong>app-specific</strong> (payments
534
- product): documented here composing engine chrome, but the packs, rails, and
535
- the on-chain mint stay app-owned.
536
- </p>
537
- </div>
538
- <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
539
- <%# Age gate — engine primitive, gated on :age_gate (disabled-but-present). %>
540
- <%= render layout: "style/modal_specimen", locals: {
541
- label: "Age gate (DOB)",
542
- reference: %(the "Age gate" DOB modal (studio-engine studio/modals/blocks/_age_verify) — a NEW engine primitive; the app supplies min_age / state / submit_url / fine_print and the engine hardcodes no legal policy. In this demo, Confirm & Continue advances to the Entry tokens purchase (page-level dsModals wiring on the primitive's 'age-verified' hook; the primitive's real submit posts to submit_url). Open with $store.dsModals.open('age-verify')),
543
- open_expr: "$store.dsModals.open('age-verify')",
544
- glow_when: ds_glow.call("age-verify"),
545
- disabled: !age_gate_on, openable: true, disabled_label: "age gate off" } do %>
546
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2 text-center">
547
- <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
548
- <div class="grid grid-cols-3 gap-1">
549
- <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
550
- <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
551
- <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
552
- </div>
553
- <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
554
- </div>
555
- <% end %>
556
-
557
- <%# Entry tokens — app-specific flow, documented (not capability-gated). %>
558
- <%= render layout: "style/modal_specimen", locals: {
559
- label: "Entry tokens",
560
- reference: %(the "Entry tokens" purchase flow (app-specific — documented in the engine style guide composing studio/modals/blocks chrome). The packs, pricing, rails, and on-chain mint stay app-owned. Open with $store.dsModals.open('entry-tokens', { step: 'picker' })),
561
- open_expr: "$store.dsModals.open('entry-tokens', { step: 'picker' })",
562
- glow_when: ds_glow.call("entry-tokens") } do %>
563
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
564
- <span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
565
- <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
566
- <span class="block h-6 w-full rounded border" style="border-color: var(--color-primary)"></span>
567
- <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
568
- </div>
569
- <% end %>
570
- </div>
571
- </section>
572
-
573
543
  <%# ===================================================================== %>
574
544
  <%# 2. PROFILE — crop / upload, live via cropper_assets %>
575
545
  <%# ===================================================================== %>
@@ -751,12 +721,13 @@
751
721
  </section>
752
722
 
753
723
  <%# ===================================================================== %>
754
- <%# 4. WEB3 — gated by Studio.feature?(:web3); off = disabled-but-present-yet-openable %>
724
+ <%# 2. WEB3 CONTEST the walked wallet -> on-chain flow (glow follows it). %>
725
+ <%# Gated by :web3; off = disabled-but-present-yet-openable. %>
755
726
  <%# ===================================================================== %>
756
727
  <section class="space-y-5">
757
728
  <div class="space-y-1">
758
729
  <div class="flex flex-wrap items-center gap-3">
759
- <h3 class="text-xl font-bold text-heading">Web3</h3>
730
+ <h3 class="text-xl font-bold text-heading">Web3 Contest</h3>
760
731
  <% if web3_on %>
761
732
  <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">enabled on this app</span>
762
733
  <% else %>
@@ -764,21 +735,31 @@
764
735
  <% end %>
765
736
  </div>
766
737
  <p class="text-muted text-sm">
767
- Wallet + on-chain modals, ported into the engine and gated by
738
+ The wallet + on-chain modals, ported into the engine and gated by
768
739
  <code class="font-mono text-2xs">Studio.feature?(:web3)</code>. Web3 is
769
740
  <strong><%= web3_on ? "on" : "off" %></strong> here, so these render
770
741
  <%= web3_on ? "live." : "greyed and badged — but STILL openable as a preview." %>
771
- The Connect-wallet picker ships its <strong>Phantom / Solflare / Backpack
772
- brand marks inline from the engine</strong>
742
+ The first three cards are a <strong>walked flow</strong> &mdash;
743
+ <strong>Connect Wallet &rarr; Processing on-chain transaction &rarr; On-chain
744
+ success</strong> &mdash; and the live card <strong>glows</strong>, following the
745
+ flow as it advances (open Connect Wallet and pick a wallet to watch it walk).
746
+ The <strong>Processing</strong> card is a load modal: its
747
+ <strong>success/error toggle</strong> chooses whether it resolves to success
748
+ or the error state, after the <strong>minimum-visible duration</strong>
749
+ (<code class="font-mono text-2xs">studio/modals/_load_convention</code>). The
750
+ Connect-wallet picker ships its <strong>Phantom / Solflare / Backpack brand
751
+ marks inline from the engine</strong>
773
752
  (<code class="font-mono text-2xs">studio/modals/blocks/_wallet_brand_sprite</code>),
774
753
  so every app inherits them with no per-app icon files.
775
754
  </p>
776
755
  </div>
777
756
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
757
+ <%# 1. Connect wallet — the walk's entry; picking a wallet swaps to Processing. %>
778
758
  <%= render layout: "style/modal_specimen", locals: {
779
759
  label: "Connect wallet",
780
- reference: %(the Web3 "Connect wallet" picker (studio-engine style/modals/_wallet_connect) — open with $store.dsModals.open('wallet-connect')),
760
+ reference: %(the Web3 Contest "Connect wallet" picker (studio-engine style/modals/_wallet_connect) — the walk's entry: picking a wallet runs a brief connecting state then swaps to the on-chain Processing modal. Open with $store.dsModals.open('wallet-connect')),
781
761
  open_expr: "$store.dsModals.open('wallet-connect')",
762
+ glow_when: ds_glow.call("wallet-connect"),
782
763
  disabled: !web3_on, openable: true } do %>
783
764
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
784
765
  <span class="block h-2 w-20 rounded" style="background: var(--color-text); opacity: .18"></span>
@@ -787,10 +768,14 @@
787
768
  </div>
788
769
  <% end %>
789
770
 
771
+ <%# 2. Processing — the load modal; the success/error toggle picks the resolution. %>
790
772
  <%= render layout: "style/modal_specimen", locals: {
791
- label: "On-chain tx · processing",
792
- 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…' })),
793
- open_expr: "$store.dsModals.open('onchain-tx', { state: 'processing', title: 'Confirming on-chain', message: 'Waiting for the wallet signature…' })",
773
+ label: "Processing on-chain tx",
774
+ reference: %(the Web3 Contest "Processing" on-chain load modal (style/modals/_onchain_tx via $store.dsSolanaModal) — holds the spinner at least MIN_LOAD_MS, then auto-resolves to success (or the error state when the toggle is set). Open with $store.dsModals.open('onchain-tx', { state: 'processing', demoResolve: true, demoError: false })),
775
+ card_data: "opts: { demoError: false }",
776
+ toggles: [{ model: "opts.demoError", label: "Resolve to error" }],
777
+ open_expr: "$store.dsModals.open('onchain-tx', { state: 'processing', title: 'Confirming on-chain', message: 'Waiting for the wallet signature…', demoResolve: true, demoError: opts.demoError })",
778
+ glow_when: ds_glow.call("onchain-tx", state: "processing"),
794
779
  disabled: !web3_on, openable: true } do %>
795
780
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
796
781
  <div class="loading-dots mx-auto"><span></span><span></span><span></span></div>
@@ -798,10 +783,12 @@
798
783
  </div>
799
784
  <% end %>
800
785
 
786
+ <%# 3. On-chain success — the resolved success end-state (the walk's finish). %>
801
787
  <%= render layout: "style/modal_specimen", locals: {
802
- label: "On-chain tx · success",
803
- 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' })),
788
+ label: "On-chain success",
789
+ reference: %(the Web3 Contest "On-chain success" state (style/modals/_onchain_tx + studio/modals/blocks/_onchain_success) — the resolved success card the walk lands on. Open with $store.dsModals.open('onchain-tx', { state: 'success', txSignature: '…', successTitle: 'Mint complete' })),
804
790
  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 })",
791
+ glow_when: ds_glow.call("onchain-tx", state: "success"),
805
792
  disabled: !web3_on, openable: true } do %>
806
793
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
807
794
  <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)">
@@ -811,10 +798,12 @@
811
798
  </div>
812
799
  <% end %>
813
800
 
801
+ <%# On-chain error — the resolved error end-state (the toggle's other branch). %>
814
802
  <%= render layout: "style/modal_specimen", locals: {
815
- label: "On-chain tx · error",
816
- 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.' })),
803
+ label: "On-chain error",
804
+ reference: %(the Web3 Contest "On-chain error" state (style/modals/_onchain_tx) — the resolved error card the Processing toggle resolves to. Open with $store.dsModals.open('onchain-tx', { state: 'error', title: "Couldn't confirm", errorMessage: 'The transaction was rejected. Give it another try.' })),
817
805
  open_expr: "$store.dsModals.open('onchain-tx', { state: 'error', title: 'Could not confirm', errorMessage: 'The transaction was rejected. Give it another try.' })",
806
+ glow_when: ds_glow.call("onchain-tx", state: "error"),
818
807
  disabled: !web3_on, openable: true } do %>
819
808
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
820
809
  <div class="text-2xl leading-none">⚠️</div>
@@ -822,9 +811,10 @@
822
811
  </div>
823
812
  <% end %>
824
813
 
814
+ <%# Wallet deposit — standalone top-up card (not part of the walk). %>
825
815
  <%= render layout: "style/modal_specimen", locals: {
826
816
  label: "Wallet deposit",
827
- 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…' })),
817
+ reference: %(the Web3 Contest "Wallet deposit" top-up card (style/modals/_wallet_deposit) — open with $store.dsModals.open('wallet-deposit', { neededCents: 500, usdcCents: 120, usdtCents: 0, address: 'Fo1L5…' })),
828
818
  open_expr: "$store.dsModals.open('wallet-deposit', { neededCents: 500, usdcCents: 120, usdtCents: 0, address: 'Fo1L5demoWALLETaddr9xQ2' })",
829
819
  disabled: !web3_on, openable: true } do %>
830
820
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
@@ -840,7 +830,7 @@
840
830
  Rewards below (gated by :leveling). %>
841
831
  <%= render layout: "style/modal_specimen", locals: {
842
832
  label: "Entry confirmed",
843
- reference: %(the Web3 "Entry confirmed" celebration (studio/modals/blocks/_entry_confirmed, composed via _success_card's yield — branded tx link + heading + drain CTA; the seeds bar is gated off by :leveling and stays hidden here) — open with $store.dsModals.open('entry-confirmed', { txSignature: '…', lobbyUrl: null, seedsEarned: 0 })),
833
+ reference: %(the Web3 Contest "Entry confirmed" celebration (studio/modals/blocks/_entry_confirmed, composed via _success_card's yield — branded tx link + heading + drain CTA; the seeds bar is gated off by :leveling and stays hidden here) — open with $store.dsModals.open('entry-confirmed', { txSignature: '…', lobbyUrl: null, seedsEarned: 0 })),
844
834
  open_expr: "$store.dsModals.open('entry-confirmed', { txSignature: '5xTrDemoSignature1234567890abcXYZ', lobbyUrl: null, seedsEarned: 0, seedsTotal: 40 })",
845
835
  disabled: !web3_on, openable: true } do %>
846
836
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
@@ -855,6 +845,144 @@
855
845
  </div>
856
846
  </section>
857
847
 
848
+ <%# ===================================================================== %>
849
+ <%# 3. CONTEST ENTRY & ELIGIBILITY — moved directly under Web3 Contest. The %>
850
+ <%# walked entry flow (glow follows), plus the honest web2/web3 map. %>
851
+ <%# ===================================================================== %>
852
+ <section class="space-y-5">
853
+ <div class="space-y-1">
854
+ <div class="flex flex-wrap items-center gap-3">
855
+ <h3 class="text-xl font-bold text-heading">Contest entry &amp; eligibility</h3>
856
+ <% if age_gate_on %>
857
+ <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">age gate on</span>
858
+ <% else %>
859
+ <span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">age gate off</span>
860
+ <% end %>
861
+ </div>
862
+ <p class="text-muted text-sm">
863
+ The pre-entry gate and the full entry flow. The <strong>age gate</strong>
864
+ (<code class="font-mono text-2xs">studio/modals/blocks/_age_verify</code>) is an
865
+ engine primitive gated by
866
+ <code class="font-mono text-2xs">Studio.feature?(:age_gate)</code> &mdash; a DOB
867
+ modal whose minimum age, jurisdiction label, endpoint, and legal copy are all
868
+ <strong>app-supplied</strong> (the engine hardcodes no policy). The entry flow is
869
+ a <strong>walked sequence</strong> &mdash; <strong>Entry tokens &rarr; Payment
870
+ processing &rarr; Entry Tokens Minted &rarr; Contest enter processing &rarr;
871
+ Contest entered</strong> &mdash; and the live card <strong>glows</strong>,
872
+ following the step machine (the two load steps honor the same min-duration
873
+ convention as Web3 Contest above).
874
+ </p>
875
+ <%# The web2/web3 finding — a real divergence, stated honestly, not invented. %>
876
+ <div class="mt-2 rounded-lg border border-subtle bg-surface p-3 text-xs text-secondary leading-snug">
877
+ <p class="text-heading font-semibold mb-1">Web2 vs web3 — the honest map</p>
878
+ <p class="mb-1">
879
+ The two funding paths <strong>diverge at the front-end and the entry-submit
880
+ endpoint, then converge</strong>. <strong>Web2</strong> (card / Stripe /
881
+ Coinflow / PayPal) mints one prepaid entry token per credit on-chain after
882
+ payment clears, then <strong>consumes</strong> one token to enter.
883
+ <strong>Web3</strong> (wallet / USDC on-chain) funds USDC directly and
884
+ <strong>mints no token</strong> &mdash; the mint&nbsp;&rarr;&nbsp;consume prelude
885
+ is web2-only.
886
+ </p>
887
+ <p>
888
+ Both create the <strong>same on-chain Entry PDA</strong> and resolve to the
889
+ <strong>same “Entry Confirmed” card</strong>. The walk below demos the web2
890
+ path end-to-end; web3 skips the mint and joins at
891
+ <strong>enter&nbsp;&rarr;&nbsp;entered</strong>.
892
+ </p>
893
+ </div>
894
+ </div>
895
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
896
+ <%# Age gate — engine primitive, gated on :age_gate (disabled-but-present). %>
897
+ <%= render layout: "style/modal_specimen", locals: {
898
+ label: "Age gate (DOB)",
899
+ reference: %(the "Age gate" DOB modal (studio-engine studio/modals/blocks/_age_verify) — an engine primitive; the app supplies min_age / state / submit_url / fine_print and the engine hardcodes no legal policy. In this demo, Confirm & Continue advances to the Entry tokens purchase (page-level dsModals wiring on the primitive's 'age-verified' hook; the primitive's real submit posts to submit_url). Open with $store.dsModals.open('age-verify')),
900
+ open_expr: "$store.dsModals.open('age-verify')",
901
+ glow_when: ds_glow.call("age-verify"),
902
+ disabled: !age_gate_on, openable: true, disabled_label: "age gate off" } do %>
903
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2 text-center">
904
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
905
+ <div class="grid grid-cols-3 gap-1">
906
+ <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
907
+ <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
908
+ <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
909
+ </div>
910
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
911
+ </div>
912
+ <% end %>
913
+
914
+ <%# 1. Entry tokens — the picker (walk's entry). Picking a pack advances. %>
915
+ <%= render layout: "style/modal_specimen", locals: {
916
+ label: "Entry tokens",
917
+ reference: %(the "Entry tokens" picker (app-specific — documented composing studio/modals/blocks chrome). The packs, pricing, rails, and on-chain mint stay app-owned. Picking a pack walks Payment processing -> Minted -> enter -> entered. Open with $store.dsModals.open('entry-tokens', { step: 'picker' })),
918
+ open_expr: "$store.dsModals.open('entry-tokens', { step: 'picker' })",
919
+ glow_when: ds_glow.call("entry-tokens", step: "picker", step_default: "picker") } do %>
920
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
921
+ <span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
922
+ <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
923
+ <span class="block h-6 w-full rounded border" style="border-color: var(--color-primary)"></span>
924
+ <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
925
+ </div>
926
+ <% end %>
927
+
928
+ <%# 2. Payment processing — the mint-at-purchase load step (web2). %>
929
+ <%= render layout: "style/modal_specimen", locals: {
930
+ label: "Payment processing",
931
+ reference: %(the "Payment processing" step (entry-tokens step 'confirming') — "Confirming your purchase…" over "Payment cleared — a server job mints one prepaid entry credit per token on-chain." A load modal; honors the min-duration convention then auto-advances to Entry Tokens Minted. Open with $store.dsModals.open('entry-tokens', { step: 'confirming', mintedCount: 3, mintedBalance: '3' })),
932
+ open_expr: "$store.dsModals.open('entry-tokens', { step: 'confirming', mintedCount: 3, mintedBalance: '3' })",
933
+ glow_when: ds_glow.call("entry-tokens", step: "confirming", step_default: "picker") } do %>
934
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
935
+ <div class="loading-dots mx-auto"><span></span><span></span><span></span></div>
936
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
937
+ <span class="block h-1.5 w-16 mx-auto rounded" style="background: var(--color-text); opacity: .12"></span>
938
+ </div>
939
+ <% end %>
940
+
941
+ <%# 3. Entry Tokens Minted — ready to consume at entry (hold to confirm). %>
942
+ <%= render layout: "style/modal_specimen", locals: {
943
+ label: "Entry Tokens Minted",
944
+ reference: %(the "Entry Tokens Minted" step (entry-tokens step 'minted') — "You now have N available." Holding to confirm consumes one token on-chain to create the contest entry (advances to Contest enter processing). Open with $store.dsModals.open('entry-tokens', { step: 'minted', mintedCount: 3, mintedBalance: '3' })),
945
+ open_expr: "$store.dsModals.open('entry-tokens', { step: 'minted', mintedCount: 3, mintedBalance: '3' })",
946
+ glow_when: ds_glow.call("entry-tokens", step: "minted", step_default: "picker") } do %>
947
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
948
+ <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)">
949
+ <span class="text-sm font-bold" style="color: var(--color-primary)">3</span>
950
+ </div>
951
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
952
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
953
+ </div>
954
+ <% end %>
955
+
956
+ <%# 4. Contest enter processing — the on-chain consume load step. %>
957
+ <%= render layout: "style/modal_specimen", locals: {
958
+ label: "Contest enter processing",
959
+ reference: %(the "Contest enter processing" step (entry-tokens step 'entering') — "Confirming your entry…" over "Consuming one token on-chain to create your contest entry." A load modal; honors the min-duration convention then auto-advances to Contest entered. Open with $store.dsModals.open('entry-tokens', { step: 'entering' })),
960
+ open_expr: "$store.dsModals.open('entry-tokens', { step: 'entering' })",
961
+ glow_when: ds_glow.call("entry-tokens", step: "entering", step_default: "picker") } do %>
962
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
963
+ <div class="loading-dots mx-auto"><span></span><span></span><span></span></div>
964
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
965
+ <span class="block h-1.5 w-16 mx-auto rounded" style="background: var(--color-text); opacity: .12"></span>
966
+ </div>
967
+ <% end %>
968
+
969
+ <%# 5. Contest entered — the shared Entry Confirmed finish (web2 + web3 converge). %>
970
+ <%= render layout: "style/modal_specimen", locals: {
971
+ label: "Contest entered",
972
+ reference: %(the "Contest entered" success (entry-tokens step 'entered') — "Good Luck / Entry Confirmed" with the branded on-chain tx link and a Contest Lobby CTA. The SAME finish both web2 and web3 entries converge on. Open with $store.dsModals.open('entry-tokens', { step: 'entered', txSignature: '…' })),
973
+ open_expr: "$store.dsModals.open('entry-tokens', { step: 'entered', txSignature: '5xTrDemoEntrySignature1234567890abc' })",
974
+ glow_when: ds_glow.call("entry-tokens", step: "entered", step_default: "picker") } do %>
975
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
976
+ <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)">
977
+ <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>
978
+ </div>
979
+ <span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
980
+ <span class="block h-4 w-full rounded" style="background: var(--color-cta)"></span>
981
+ </div>
982
+ <% end %>
983
+ </div>
984
+ </section>
985
+
858
986
  <%# ===================================================================== %>
859
987
  <%# 5. SYSTEM AND STATUS (reusable card blocks) %>
860
988
  <%# ===================================================================== %>
@@ -1,20 +1,30 @@
1
1
  <%#
2
- Living style guide specimen — the entry-token purchase flow. This documents an
3
- APP-SPECIFIC flow (Turf Monster's "Get Entry Tokens"); the engine deliberately
4
- does NOT own the packs, pricing, or payment rails. The specimen composes only
5
- engine CHROME (studio/modals/blocks/shell, card_header, progress_pill) and
6
- represents the packs as an illustrative demo — the app owns the payment logic
7
- and the on-chain mint.
2
+ Living style guide specimen — the contest entry & eligibility flow. This
3
+ documents an APP-SPECIFIC flow (Turf Monster's "Get Entry Tokens" → enter);
4
+ the engine deliberately does NOT own the packs, pricing, payment rails, or any
5
+ on-chain write. The specimen composes only engine CHROME (studio/modals/blocks/
6
+ shell, card_header, progress_pill, solana_tx_link) and represents the packs and
7
+ the on-chain moments as an illustrative demo — the app owns the money + signing.
8
8
 
9
9
  Opened on the page-scoped dsModals host with an internal step machine on
10
- props.step: picker -> confirming -> minted. Single-root templates throughout.
10
+ props.step, the full web2 walk:
11
11
 
12
- On-chain model (documented, not owned here): an entry token is a PROGRAM-OWNED
13
- prepaid entry credit recorded on-chain (a per-token account), NOT a fungible
14
- SPL token held in the user's wallet. Two distinct on-chain moments: (1) MINT at
15
- purchase, server-signed after the payment clears — not on the buyer's click;
16
- (2) CONSUME at entry, when the app's hold-to-confirm gesture spends one token to
17
- create the contest entry.
12
+ picker -> confirming -> minted -> entering -> entered
13
+
14
+ Single-root templates throughout. The two load steps (confirming, entering)
15
+ honor the minimum-visible-duration convention (studio/modals/_load_convention):
16
+ the spinner holds at least MIN_LOAD_MS, then auto-advances (demo mode)
17
+ resolveAt = max(min_duration, actual_completion).
18
+
19
+ On-chain model (documented, not owned here). An entry token is a PROGRAM-OWNED
20
+ prepaid entry credit recorded on-chain (a per-token account), NOT a fungible SPL
21
+ token in the user's wallet. WEB2 (card / Stripe / Coinflow / PayPal) has two
22
+ on-chain moments: (1) MINT at purchase, server-signed after the payment clears —
23
+ not on the buyer's click; (2) CONSUME at entry, when the hold-to-confirm gesture
24
+ spends one token to create the contest entry. WEB3 (wallet / USDC on-chain)
25
+ funds USDC directly and mints NO token — so the mint→consume prelude is
26
+ web2-only. Both paths converge at the SAME on-chain Entry PDA and the SAME
27
+ "Entry Confirmed" card.
18
28
  %>
19
29
  <%
20
30
  # Illustrative packs only — a real app reads these from its own config
@@ -29,10 +39,20 @@
29
39
  get props() { var c = Alpine.store('dsModals').current(); return (c && c.props) || {}; },
30
40
  buy(count, balance) {
31
41
  Alpine.store('dsModals').advance({ step: 'confirming', mintedCount: count, mintedBalance: balance });
32
- setTimeout(function () {
42
+ },
43
+ confirmEntry() {
44
+ Alpine.store('dsModals').advance({ step: 'entering' });
45
+ },
46
+ // Demo auto-resolve honoring the min-load convention: hold the spinner at
47
+ // least MIN_LOAD_MS, then advance — but only while this modal is still on
48
+ // the given step (guards against an early close / manual advance).
49
+ autoAdvance(fromStep, patch) {
50
+ window.StudioModals.holdAtLeast(window.StudioModals.MIN_LOAD_MS).then(function () {
33
51
  var c = Alpine.store('dsModals').current();
34
- if (c && c.id === 'entry-tokens') Alpine.store('dsModals').advance({ step: 'minted' });
35
- }, 1300);
52
+ if (c && c.id === 'entry-tokens' && c.props.step === fromStep) {
53
+ Alpine.store('dsModals').advance(patch);
54
+ }
55
+ });
36
56
  }
37
57
  }" class="relative">
38
58
 
@@ -68,36 +88,52 @@
68
88
  <% end %>
69
89
  </div>
70
90
 
71
- <%# Rails — token-funded only. The picker documents which rails mint tokens.
72
- The USDC-direct path is intentionally NOT a pack here (it's a separate
73
- rail that transfers USDC and mints no token). %>
91
+ <%# Rails — which funding path mints tokens vs funds an entry directly. %>
74
92
  <div class="mt-4 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-muted">
75
93
  <span>Pay by card</span>
76
94
  <span aria-hidden="true">&middot;</span>
77
95
  <span>PayPal / Venmo</span>
96
+ <span aria-hidden="true">&middot;</span>
97
+ <span>Coinflow</span>
78
98
  </div>
79
- <p class="text-center text-2xs text-muted mt-1">USDC-direct entry is a separate rail — it funds an entry with no token.</p>
99
+ <p class="text-center text-2xs text-muted mt-1">
100
+ These are the <strong>web2</strong> rails — they mint prepaid entry tokens.
101
+ The <strong>web3</strong> path funds USDC on-chain directly and mints no token.
102
+ </p>
80
103
 
81
- <%# The seam callout — what the engine owns vs what the app owns. %>
104
+ <%# The seam callout — what the engine owns vs the app, plus the honest
105
+ web2/web3 map (a real divergence, not invented). %>
82
106
  <div class="mt-4 rounded-lg border border-subtle bg-surface p-3 text-2xs text-secondary leading-snug">
83
107
  <p class="text-heading font-semibold mb-1">App-specific flow, engine chrome</p>
108
+ <p class="mb-2">
109
+ The engine supplies only the shell, header, progress pill, and tx link. The
110
+ packs, pricing, payment rails, and every on-chain write are
111
+ <strong>app-owned</strong> — this specimen represents them as a demo. On-chain,
112
+ an entry token is a <strong>prepaid entry credit recorded on-chain</strong>
113
+ (a per-token account), not a wallet-held token.
114
+ </p>
115
+ <p class="text-heading font-semibold mb-1">Web2 vs web3 — where they diverge and converge</p>
84
116
  <p class="mb-1">
85
- The engine supplies only the shell, header, and progress pill. The packs,
86
- pricing, payment rails, and the on-chain mint are <strong>app-owned</strong>
87
- this specimen represents them as a demo.
117
+ <strong>Web2</strong> (card / Stripe / Coinflow / PayPal): a server job mints
118
+ one prepaid entry credit per token on-chain once payment clears, then
119
+ hold-to-confirm <strong>consumes</strong> one token to create the entry.
120
+ </p>
121
+ <p class="mb-1">
122
+ <strong>Web3</strong> (wallet / USDC on-chain): funds USDC directly and
123
+ <strong>mints no token</strong> — the mint → consume prelude is web2-only.
88
124
  </p>
89
125
  <p>
90
- On-chain, an entry token is a <strong>prepaid entry credit recorded on-chain</strong>
91
- (not a transferable token held in your wallet): minted server-side once payment
92
- clears, then <strong>consumed</strong> when the app's hold-to-confirm creates the entry.
126
+ Both paths converge on the <strong>same on-chain Entry PDA</strong> and the
127
+ <strong>same “Entry Confirmed” card</strong> divergent funding front-end,
128
+ one shared entry entered finish.
93
129
  </p>
94
130
  </div>
95
131
  </div>
96
132
  </template>
97
133
 
98
- <%# ===== CONFIRMING (mint-at-purchase) ===== %>
134
+ <%# ===== CONFIRMING (payment processing — mint-at-purchase) ===== %>
99
135
  <template x-if="props.step === 'confirming'">
100
- <div x-init="setTimeout(function () { var c = Alpine.store('dsModals').current(); if (c && c.id === 'entry-tokens') Alpine.store('dsModals').advance({ step: 'minted' }); }, 1300)">
136
+ <div x-init="autoAdvance('confirming', { step: 'minted' })">
101
137
  <%= render "studio/modals/blocks/card_header",
102
138
  spinner: true,
103
139
  title: "Confirming your purchase…",
@@ -114,10 +150,39 @@
114
150
  You now have <span class="font-bold text-primary font-mono" x-text="props.mintedBalance"></span> available.
115
151
  <% end %>
116
152
  <p class="text-xs text-secondary text-center mb-4 leading-snug">
117
- In the app, holding to confirm now <strong>consumes one token on-chain</strong> to
118
- create your contest entry, then resolves to the shared Entry Confirmed card.
153
+ Holding to confirm now <strong>consumes one token on-chain</strong> to create
154
+ your contest entry.
119
155
  </p>
120
- <button @click="$store.dsModals.close()" class="btn btn-primary btn-lg w-full">Done</button>
156
+ <button @click="confirmEntry()" class="btn btn-primary btn-lg w-full">Hold to Confirm Entry</button>
157
+ <button @click="$store.dsModals.close()"
158
+ class="block mx-auto mt-3 text-xs text-secondary hover:text-heading underline underline-offset-2">
159
+ I&rsquo;ll confirm later
160
+ </button>
161
+ </div>
162
+ </template>
163
+
164
+ <%# ===== ENTERING (contest enter processing — the on-chain consume) ===== %>
165
+ <template x-if="props.step === 'entering'">
166
+ <div x-init="autoAdvance('entering', { step: 'entered', txSignature: '5xTrDemoEntrySignature1234567890abc' })">
167
+ <%= render "studio/modals/blocks/card_header",
168
+ spinner: true,
169
+ title: "Confirming your entry…",
170
+ subtitle: "Consuming one token on-chain to create your contest entry." %>
171
+ </div>
172
+ </template>
173
+
174
+ <%# ===== ENTERED (contest entered — the shared Entry Confirmed finish) ===== %>
175
+ <template x-if="props.step === 'entered'">
176
+ <div>
177
+ <%= render "studio/modals/blocks/card_header",
178
+ size: :lg, icon_color: 'primary',
179
+ title: "Good Luck", subtitle: "Entry Confirmed" %>
180
+ <%= render "studio/modals/blocks/solana_tx_link", tx_signature_key: "props.txSignature" %>
181
+ <button @click="$store.dsModals.close()" class="btn btn-primary btn-lg w-full">Contest Lobby &#x2694;&#xFE0F;</button>
182
+ <button @click="$store.dsModals.close()"
183
+ class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
184
+ Dismiss
185
+ </button>
121
186
  </div>
122
187
  </template>
123
188
  </div>
@@ -24,6 +24,30 @@
24
24
  }, 100);
25
25
  }
26
26
  });
27
+ // Demo auto-resolve (min-load convention): a processing modal opened with
28
+ // demoResolve holds its spinner at least MIN_LOAD_MS, then advances to
29
+ // success — or to error when demoError is set (the Processing specimen's
30
+ // success/error toggle). Guarded to the still-open, still-processing modal.
31
+ var entry = Alpine.store('dsModals').current();
32
+ if (!entry || entry.id !== 'onchain-tx' || !entry.props ||
33
+ entry.props.state !== 'processing' || !entry.props.demoResolve) return;
34
+ var minMs = entry.props.minDuration ||
35
+ (window.StudioModals && window.StudioModals.MIN_LOAD_MS) || 1400;
36
+ window.StudioModals.holdAtLeast(minMs).then(function () {
37
+ var cur = Alpine.store('dsModals').current();
38
+ if (!cur || cur.id !== 'onchain-tx' || cur.props.state !== 'processing') return;
39
+ if (entry.props.demoError) {
40
+ Alpine.store('dsModals').advance({ state: 'error',
41
+ title: 'Could not confirm',
42
+ errorMessage: 'The transaction was rejected. Give it another try.' });
43
+ } else {
44
+ Alpine.store('dsModals').advance({ state: 'success',
45
+ txSignature: '5xTrDemoSignature1234567890abcXYZ',
46
+ successTitle: 'Mint complete',
47
+ successSubtitle: 'Your on-chain action confirmed.',
48
+ ctaLabel: 'View wallet', ctaHref: null });
49
+ }
50
+ });
27
51
  }
28
52
  }">
29
53
 
@@ -60,7 +60,18 @@
60
60
  if (!this.attested()) return;
61
61
  this.connecting = true; this.picking = name; this.error = '';
62
62
  var self = this;
63
- setTimeout(function () { self.connecting = false; self.picking = ''; Alpine.store('dsModals').close(); }, 1100);
63
+ // DEMO walk continuity: a picked wallet runs a brief connecting state,
64
+ // then swaps to the on-chain tx modal in its processing state, which
65
+ // auto-resolves to on-chain success (demoResolve). So the glow follows
66
+ // Connect Wallet -> Processing -> On-chain success. (No real connect.)
67
+ setTimeout(function () {
68
+ self.connecting = false; self.picking = '';
69
+ Alpine.store('dsModals').swap('onchain-tx', {
70
+ state: 'processing', demoResolve: true, demoError: false,
71
+ title: 'Confirming on-chain',
72
+ message: 'Waiting for the wallet signature…'
73
+ });
74
+ }, 1100);
64
75
  },
65
76
  deepLink() {
66
77
  if (!this.attested()) return;
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.25.1"
2
+ VERSION = "0.27.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.25.1
4
+ version: 0.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-28 00:00:00.000000000 Z
11
+ date: 2026-07-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -273,6 +273,7 @@ files:
273
273
  - app/views/studio/modals/_crop_photo.html.erb
274
274
  - app/views/studio/modals/_host.html.erb
275
275
  - app/views/studio/modals/_image_upload.html.erb
276
+ - app/views/studio/modals/_load_convention.html.erb
276
277
  - app/views/studio/modals/_saving.html.erb
277
278
  - app/views/studio/modals/auth/_resend_footer.html.erb
278
279
  - app/views/studio/modals/blocks/_age_verify.html.erb