studio-engine 0.26.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: 8d1e665b17a360de65ba411842a7c8368e8077160cf34e4a218ebbe1857d0988
4
- data.tar.gz: 1b9ebc66583eeacb8c65d97f4ec1eb90b0451829cf7ed263d0a5cd8895b96b88
3
+ metadata.gz: 3fefcb19f5cf631e4e1515bf0b113fb33167cb1173f039a0777038aac5173d57
4
+ data.tar.gz: f203c29195b71e945a055bcfa60ad58f0d1aea16fe2ce60e84de4de1993d62ec
5
5
  SHA512:
6
- metadata.gz: 61d7d9f100144b0683bfa27a12a86ad4b0cac8cc3280c067c9f016a99edf98b0c28f73cf38e2f212d26183336f519d03f633970b15381ebcf165ba9ee143997b
7
- data.tar.gz: 56b58a13c533dd060fd5d8313987735123e6ed1bd1f148d15ef51ea7306815f365dabf900770f0b8de84fc81b6588bf80e61545ba5a8440b49243a3db7883f52
6
+ metadata.gz: d790f97aede359baf45915bbde14fef29cea6779a0866159a3b5f1dc5a396d24951d7ba3d796e4523e0dc17489319375929e8b3e2741ca9b7c569dd5f9dac91e
7
+ data.tar.gz: 25a54c2e44ad2e29608afe72fcf6b7dc8028fe28c47bd92e582e041b282f7f4e2b70ba5eb3468f4d8edb083f3d1fe185c340c135678b9a0a0556bfbab601a98b
data/CHANGELOG.md CHANGED
@@ -2,6 +2,55 @@
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
+
5
54
  ## 0.26.1 — 2026-07-28
6
55
 
7
56
  **Fix the `/admin/style` Design System page breaking when reached via Turbo Drive
@@ -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>
@@ -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
@@ -263,6 +267,13 @@
263
267
  })();
264
268
  </script>
265
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
+
266
277
  <%# Cropper.js + the cropPhotoModal factory — makes the Profile crop / upload
267
278
  modals openable on this page (loads cropper.js via <script defer> and
268
279
  defines the factory inline; no importmap change). %>
@@ -310,8 +321,11 @@
310
321
  <template x-if="$store.dsModals.current().id === 'crop-photo'">
311
322
  <div><%= render "studio/modals/crop_photo", store: "dsModals" %></div>
312
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). %>
313
326
  <template x-if="$store.dsModals.current().id === 'saving'">
314
- <div><%= render "studio/modals/saving", store: "dsModals" %></div>
327
+ <div><%= render "studio/modals/saving", store: "dsModals",
328
+ resolve_expr: "$store.dsModals.close()" %></div>
315
329
  </template>
316
330
 
317
331
  <%# --- Web3 --- %>
@@ -335,9 +349,12 @@
335
349
  </template>
336
350
 
337
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. %>
338
354
  <template x-if="$store.dsModals.current().id === 'ds-processing'">
339
355
  <div><%= render "studio/modals/blocks/processing_card",
340
- 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>
341
358
  </template>
342
359
  <template x-if="$store.dsModals.current().id === 'ds-success'">
343
360
  <div><%= render "studio/modals/blocks/success_card",
@@ -523,68 +540,6 @@
523
540
  </div>
524
541
  </section>
525
542
 
526
- <%# ===================================================================== %>
527
- <%# 1b. ELIGIBILITY + ENTRY %>
528
- <%# ===================================================================== %>
529
- <section class="space-y-5">
530
- <div class="space-y-1">
531
- <div class="flex flex-wrap items-center gap-3">
532
- <h3 class="text-xl font-bold text-heading">Eligibility &amp; entry</h3>
533
- <% if age_gate_on %>
534
- <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">age gate on</span>
535
- <% else %>
536
- <span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">age gate off</span>
537
- <% end %>
538
- </div>
539
- <p class="text-muted text-sm">
540
- The pre-entry gate and the entry-purchase flow. The <strong>age gate</strong>
541
- (<code class="font-mono text-2xs">studio/modals/blocks/_age_verify</code>) is a
542
- NEW engine primitive gated by
543
- <code class="font-mono text-2xs">Studio.feature?(:age_gate)</code> &mdash; a DOB
544
- modal whose minimum age, jurisdiction label, endpoint, and legal copy are all
545
- <strong>app-supplied</strong> (the engine hardcodes no policy). It is the heavier
546
- sibling of the <code class="font-mono text-2xs">shared/_age_attestation</code>
547
- checkbox inside Sign in above &mdash; <strong>run one or the other</strong>. The
548
- <strong>entry-tokens</strong> flow is <strong>app-specific</strong> (payments
549
- product): documented here composing engine chrome, but the packs, rails, and
550
- the on-chain mint stay app-owned.
551
- </p>
552
- </div>
553
- <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
554
- <%# Age gate — engine primitive, gated on :age_gate (disabled-but-present). %>
555
- <%= render layout: "style/modal_specimen", locals: {
556
- label: "Age gate (DOB)",
557
- 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')),
558
- open_expr: "$store.dsModals.open('age-verify')",
559
- glow_when: ds_glow.call("age-verify"),
560
- disabled: !age_gate_on, openable: true, disabled_label: "age gate off" } do %>
561
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2 text-center">
562
- <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
563
- <div class="grid grid-cols-3 gap-1">
564
- <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
565
- <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
566
- <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
567
- </div>
568
- <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
569
- </div>
570
- <% end %>
571
-
572
- <%# Entry tokens — app-specific flow, documented (not capability-gated). %>
573
- <%= render layout: "style/modal_specimen", locals: {
574
- label: "Entry tokens",
575
- 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' })),
576
- open_expr: "$store.dsModals.open('entry-tokens', { step: 'picker' })",
577
- glow_when: ds_glow.call("entry-tokens") } do %>
578
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
579
- <span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
580
- <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
581
- <span class="block h-6 w-full rounded border" style="border-color: var(--color-primary)"></span>
582
- <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
583
- </div>
584
- <% end %>
585
- </div>
586
- </section>
587
-
588
543
  <%# ===================================================================== %>
589
544
  <%# 2. PROFILE — crop / upload, live via cropper_assets %>
590
545
  <%# ===================================================================== %>
@@ -766,12 +721,13 @@
766
721
  </section>
767
722
 
768
723
  <%# ===================================================================== %>
769
- <%# 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. %>
770
726
  <%# ===================================================================== %>
771
727
  <section class="space-y-5">
772
728
  <div class="space-y-1">
773
729
  <div class="flex flex-wrap items-center gap-3">
774
- <h3 class="text-xl font-bold text-heading">Web3</h3>
730
+ <h3 class="text-xl font-bold text-heading">Web3 Contest</h3>
775
731
  <% if web3_on %>
776
732
  <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">enabled on this app</span>
777
733
  <% else %>
@@ -779,21 +735,31 @@
779
735
  <% end %>
780
736
  </div>
781
737
  <p class="text-muted text-sm">
782
- Wallet + on-chain modals, ported into the engine and gated by
738
+ The wallet + on-chain modals, ported into the engine and gated by
783
739
  <code class="font-mono text-2xs">Studio.feature?(:web3)</code>. Web3 is
784
740
  <strong><%= web3_on ? "on" : "off" %></strong> here, so these render
785
741
  <%= web3_on ? "live." : "greyed and badged — but STILL openable as a preview." %>
786
- The Connect-wallet picker ships its <strong>Phantom / Solflare / Backpack
787
- 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>
788
752
  (<code class="font-mono text-2xs">studio/modals/blocks/_wallet_brand_sprite</code>),
789
753
  so every app inherits them with no per-app icon files.
790
754
  </p>
791
755
  </div>
792
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. %>
793
758
  <%= render layout: "style/modal_specimen", locals: {
794
759
  label: "Connect wallet",
795
- 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')),
796
761
  open_expr: "$store.dsModals.open('wallet-connect')",
762
+ glow_when: ds_glow.call("wallet-connect"),
797
763
  disabled: !web3_on, openable: true } do %>
798
764
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
799
765
  <span class="block h-2 w-20 rounded" style="background: var(--color-text); opacity: .18"></span>
@@ -802,10 +768,14 @@
802
768
  </div>
803
769
  <% end %>
804
770
 
771
+ <%# 2. Processing — the load modal; the success/error toggle picks the resolution. %>
805
772
  <%= render layout: "style/modal_specimen", locals: {
806
- label: "On-chain tx · processing",
807
- 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…' })),
808
- 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"),
809
779
  disabled: !web3_on, openable: true } do %>
810
780
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
811
781
  <div class="loading-dots mx-auto"><span></span><span></span><span></span></div>
@@ -813,10 +783,12 @@
813
783
  </div>
814
784
  <% end %>
815
785
 
786
+ <%# 3. On-chain success — the resolved success end-state (the walk's finish). %>
816
787
  <%= render layout: "style/modal_specimen", locals: {
817
- label: "On-chain tx · success",
818
- 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' })),
819
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"),
820
792
  disabled: !web3_on, openable: true } do %>
821
793
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
822
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)">
@@ -826,10 +798,12 @@
826
798
  </div>
827
799
  <% end %>
828
800
 
801
+ <%# On-chain error — the resolved error end-state (the toggle's other branch). %>
829
802
  <%= render layout: "style/modal_specimen", locals: {
830
- label: "On-chain tx · error",
831
- 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.' })),
832
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"),
833
807
  disabled: !web3_on, openable: true } do %>
834
808
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
835
809
  <div class="text-2xl leading-none">⚠️</div>
@@ -837,9 +811,10 @@
837
811
  </div>
838
812
  <% end %>
839
813
 
814
+ <%# Wallet deposit — standalone top-up card (not part of the walk). %>
840
815
  <%= render layout: "style/modal_specimen", locals: {
841
816
  label: "Wallet deposit",
842
- 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…' })),
843
818
  open_expr: "$store.dsModals.open('wallet-deposit', { neededCents: 500, usdcCents: 120, usdtCents: 0, address: 'Fo1L5demoWALLETaddr9xQ2' })",
844
819
  disabled: !web3_on, openable: true } do %>
845
820
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
@@ -855,7 +830,7 @@
855
830
  Rewards below (gated by :leveling). %>
856
831
  <%= render layout: "style/modal_specimen", locals: {
857
832
  label: "Entry confirmed",
858
- 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 })),
859
834
  open_expr: "$store.dsModals.open('entry-confirmed', { txSignature: '5xTrDemoSignature1234567890abcXYZ', lobbyUrl: null, seedsEarned: 0, seedsTotal: 40 })",
860
835
  disabled: !web3_on, openable: true } do %>
861
836
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
@@ -870,6 +845,144 @@
870
845
  </div>
871
846
  </section>
872
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
+
873
986
  <%# ===================================================================== %>
874
987
  <%# 5. SYSTEM AND STATUS (reusable card blocks) %>
875
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.26.1"
2
+ VERSION = "0.27.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.26.1
4
+ version: 0.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -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