studio-engine 0.20.0 → 0.22.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.
@@ -14,7 +14,10 @@
14
14
  card behind ours. So this section owns a tiny, self-contained store + overlay
15
15
  and registers all specimen content itself.
16
16
 
17
- Groups: Auth (the sign-in step machine), Profile (crop / upload, opened
17
+ Groups: Auth (the sign-in step machine), Eligibility and entry (the
18
+ app-supplied-policy age-gate DOB primitive, gated by Studio.feature?(:age_gate),
19
+ plus the app-specific entry-tokens purchase flow documented via engine chrome),
20
+ Profile (crop / upload, opened
18
21
  through studio/cropper_assets), Web3 (wallet-connect with engine-owned inline
19
22
  brand icons / on-chain-tx / deposit / the generic entry-confirmed celebration,
20
23
  gated by Studio.feature?(:web3) — disabled-but-present-yet-openable when off),
@@ -28,6 +31,7 @@
28
31
  <%
29
32
  web3_on = Studio.feature?(:web3)
30
33
  leveling_on = Studio.feature?(:leveling)
34
+ age_gate_on = Studio.feature?(:age_gate)
31
35
 
32
36
  # Auth method-toggle defaults — mirror the app's Studio.auth_method? config;
33
37
  # Solana Wallet also needs the web3 capability, so it defaults OFF on an app
@@ -51,8 +55,17 @@
51
55
  expr
52
56
  end
53
57
  %>
58
+ <%# DEMO flow wiring (style-page only): the age-gate primitive fires an
59
+ 'age-verified' window event on a successful confirm — its designed handoff
60
+ hook, the same one a production app listens for to resume the entry. Here the
61
+ style page plays that "app": on age-verified it opens the entry-token purchase
62
+ modal, so the DS walks eligibility to purchase in sequence. The engine
63
+ primitive is untouched; its real submit still posts to the app-supplied
64
+ submit_url. Kept on the section (always mounted) so it fires even as the
65
+ age-gate card closes. %>
54
66
  <section id="modals" class="space-y-10" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)"
55
- x-data="{}" @ds-modal-close.window="$store.dsModals.close()">
67
+ x-data="{}" @ds-modal-close.window="$store.dsModals.close()"
68
+ @age-verified.window="$store.dsModals.open('entry-tokens', { step: 'picker' })">
56
69
  <div class="space-y-1">
57
70
  <h2 class="text-2xl font-bold text-heading">Modals</h2>
58
71
  <p class="text-muted text-sm">
@@ -237,6 +250,16 @@
237
250
  defines the factory inline; no importmap change). %>
238
251
  <%= render "studio/cropper_assets" %>
239
252
 
253
+ <%# ageVerifyModal factory — rendered here at page level (never inside the
254
+ overlay template, where a cloned script would not run) so the age-gate
255
+ specimen opens live. A consumer app renders this in its layout the same way. %>
256
+ <%= render "studio/age_verify_assets" %>
257
+
258
+ <%# levelingActionModal factory — page level (never inside the overlay template,
259
+ where a cloned script would not run) so the change-username + quest-activity
260
+ specimens open live. A consumer app renders this in its layout the same way. %>
261
+ <%= render "studio/leveling_activity_assets" %>
262
+
240
263
  <%# ---- The overlay: one backdrop + card; every specimen's real content is a
241
264
  single-root <template x-if> gated on the current modal id. Faithful to the
242
265
  shared host — backdrop fade, scroll lock, escape + click-self dismissal
@@ -257,6 +280,14 @@
257
280
  <div><%= render "style/modals/auth" %></div>
258
281
  </template>
259
282
 
283
+ <%# --- Eligibility + entry --- %>
284
+ <template x-if="$store.dsModals.current().id === 'age-verify'">
285
+ <div><%= render "style/modals/age_verify" %></div>
286
+ </template>
287
+ <template x-if="$store.dsModals.current().id === 'entry-tokens'">
288
+ <div><%= render "style/modals/entry_tokens" %></div>
289
+ </template>
290
+
260
291
  <%# --- Profile (real engine partials, opened on dsModals) --- %>
261
292
  <template x-if="$store.dsModals.current().id === 'crop-photo'">
262
293
  <div><%= render "studio/modals/crop_photo", store: "dsModals" %></div>
@@ -350,6 +381,49 @@
350
381
  subtitle: "You leveled up! A Free Entry lands in your account soon. Keep leveling for more." %></div>
351
382
  </template>
352
383
 
384
+ <%# --- Leveling activities: change username + quest activity --- %>
385
+ <%# Each id renders the SAME engine primitive with an EXPLICIT leveling:
386
+ flag, so BOTH modes stay visible here regardless of this app's real
387
+ :leveling setting. All demo:true — the save resolves locally (no
388
+ backend), and the on-chain-accurate copy is passed as an app-authored
389
+ description (the engine default stays neutral). %>
390
+ <template x-if="$store.dsModals.current().id === 'change-username'">
391
+ <div><%= render "studio/modals/blocks/change_username",
392
+ modal_store: "dsModals", demo: true, leveling: true,
393
+ current_username: "turfmonster",
394
+ submit_url: "#demo-change-username",
395
+ description: "Your username is stored on-chain. Changes are saved via your managed wallet.",
396
+ quest_label: "Quest 1 of 4" %></div>
397
+ </template>
398
+ <template x-if="$store.dsModals.current().id === 'change-username-plain'">
399
+ <div><%= render "studio/modals/blocks/change_username",
400
+ modal_store: "dsModals", demo: true, leveling: false,
401
+ current_username: "mcritchie",
402
+ submit_url: "#demo-change-username",
403
+ description: "Choose the name shown across your account." %></div>
404
+ </template>
405
+ <template x-if="$store.dsModals.current().id === 'quest-activity'">
406
+ <div><%= render "studio/modals/blocks/leveling_activity",
407
+ modal_store: "dsModals", demo: true, leveling: true,
408
+ submit_url: "#demo-quest",
409
+ title: "Join the Newsletter",
410
+ description: "Sports news and contest updates — complete the quest for seeds.",
411
+ input: true, input_type: "email", placeholder: "you@example.com",
412
+ cta_label: "Subscribe", saved_label: "Subscribed", saving_label: "Subscribing…",
413
+ quest_label: "Quest 2 of 4",
414
+ icon_emoji: "📬", celebrate_title: "You're in",
415
+ celebrate_subtitle: "Nice — you earned seeds for joining. Keep completing quests to earn a Free Entry." %></div>
416
+ </template>
417
+ <template x-if="$store.dsModals.current().id === 'quest-activity-plain'">
418
+ <div><%= render "studio/modals/blocks/leveling_activity",
419
+ modal_store: "dsModals", demo: true, leveling: false,
420
+ submit_url: "#demo-quest",
421
+ title: "Join the Newsletter",
422
+ description: "Sports news and contest updates.",
423
+ input: true, input_type: "email", placeholder: "you@example.com",
424
+ cta_label: "Subscribe", saved_label: "Subscribed", saving_label: "Subscribing…" %></div>
425
+ </template>
426
+
353
427
  </div>
354
428
  </div>
355
429
  </template>
@@ -432,6 +506,68 @@
432
506
  </div>
433
507
  </section>
434
508
 
509
+ <%# ===================================================================== %>
510
+ <%# 1b. ELIGIBILITY + ENTRY %>
511
+ <%# ===================================================================== %>
512
+ <section class="space-y-5">
513
+ <div class="space-y-1">
514
+ <div class="flex flex-wrap items-center gap-3">
515
+ <h3 class="text-xl font-bold text-heading">Eligibility &amp; entry</h3>
516
+ <% if age_gate_on %>
517
+ <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">age gate on</span>
518
+ <% else %>
519
+ <span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">age gate off</span>
520
+ <% end %>
521
+ </div>
522
+ <p class="text-muted text-sm">
523
+ The pre-entry gate and the entry-purchase flow. The <strong>age gate</strong>
524
+ (<code class="font-mono text-2xs">studio/modals/blocks/_age_verify</code>) is a
525
+ NEW engine primitive gated by
526
+ <code class="font-mono text-2xs">Studio.feature?(:age_gate)</code> &mdash; a DOB
527
+ modal whose minimum age, jurisdiction label, endpoint, and legal copy are all
528
+ <strong>app-supplied</strong> (the engine hardcodes no policy). It is the heavier
529
+ sibling of the <code class="font-mono text-2xs">shared/_age_attestation</code>
530
+ checkbox inside Sign in above &mdash; <strong>run one or the other</strong>. The
531
+ <strong>entry-tokens</strong> flow is <strong>app-specific</strong> (payments
532
+ product): documented here composing engine chrome, but the packs, rails, and
533
+ the on-chain mint stay app-owned.
534
+ </p>
535
+ </div>
536
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
537
+ <%# Age gate — engine primitive, gated on :age_gate (disabled-but-present). %>
538
+ <%= render layout: "style/modal_specimen", locals: {
539
+ label: "Age gate (DOB)",
540
+ 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')),
541
+ open_expr: "$store.dsModals.open('age-verify')",
542
+ glow_when: ds_glow.call("age-verify"),
543
+ disabled: !age_gate_on, openable: true, disabled_label: "age gate off" } do %>
544
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2 text-center">
545
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
546
+ <div class="grid grid-cols-3 gap-1">
547
+ <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
548
+ <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
549
+ <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
550
+ </div>
551
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
552
+ </div>
553
+ <% end %>
554
+
555
+ <%# Entry tokens — app-specific flow, documented (not capability-gated). %>
556
+ <%= render layout: "style/modal_specimen", locals: {
557
+ label: "Entry tokens",
558
+ 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' })),
559
+ open_expr: "$store.dsModals.open('entry-tokens', { step: 'picker' })",
560
+ glow_when: ds_glow.call("entry-tokens") } do %>
561
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
562
+ <span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
563
+ <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
564
+ <span class="block h-6 w-full rounded border" style="border-color: var(--color-primary)"></span>
565
+ <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
566
+ </div>
567
+ <% end %>
568
+ </div>
569
+ </section>
570
+
435
571
  <%# ===================================================================== %>
436
572
  <%# 2. PROFILE — crop / upload, live via cropper_assets %>
437
573
  <%# ===================================================================== %>
@@ -771,4 +907,97 @@
771
907
  <% end %>
772
908
  </div>
773
909
  </section>
910
+
911
+ <%# ===================================================================== %>
912
+ <%# 7. LEVELING ACTIVITIES — change username + quest activities %>
913
+ <%# ===================================================================== %>
914
+ <section class="space-y-5">
915
+ <div class="space-y-1">
916
+ <div class="flex flex-wrap items-center gap-3">
917
+ <h3 class="text-xl font-bold text-heading">Leveling activities</h3>
918
+ <% if leveling_on %>
919
+ <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">leveling on</span>
920
+ <% else %>
921
+ <span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">leveling off</span>
922
+ <% end %>
923
+ </div>
924
+ <p class="text-muted text-sm">
925
+ The quest/leveling action modals
926
+ (<code class="font-mono text-2xs">studio/modals/blocks/_change_username</code>,
927
+ <code class="font-mono text-2xs">studio/modals/blocks/_leveling_activity</code>),
928
+ migrated from Turf Monster. ONE primitive, <strong>two modes</strong>, decided
929
+ by <code class="font-mono text-2xs">Studio.feature?(:leveling)</code>:
930
+ <strong>leveling on</strong> adds the quest pill + the seeds level-up
931
+ celebration; <strong>leveling off</strong> is the plain action modal (just
932
+ the input + Save + a Saved state — the McRitchie Studio shape). Each modal is
933
+ shown <strong>both ways</strong> below (the specimens pass an explicit
934
+ <code class="font-mono text-2xs">leveling:</code> so both render regardless of
935
+ this app's flag). The actual save is an <strong>app-supplied callback</strong>:
936
+ the modal POSTs to <code class="font-mono text-2xs">submit_url</code> and, for
937
+ an app that needs a wallet step, delegates it opaquely to an app-supplied
938
+ <code class="font-mono text-2xs">finalize_hook</code> — <strong>no wallet,
939
+ signing, or on-chain write lives in the engine</strong>. (These demos resolve
940
+ the save locally.)
941
+ </p>
942
+ </div>
943
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
944
+ <%# Change username — leveling ON: quest pill + seeds celebration on save. %>
945
+ <%= render layout: "style/modal_specimen", locals: {
946
+ label: "Change username (leveling)",
947
+ reference: %(the "Change username" modal WITH leveling (studio-engine studio/modals/blocks/_change_username, leveling: true) — quest pill + seeds level-up celebration on save. The on-chain save is an app-supplied callback (submit_url + an opaque finalize_hook); the engine is UI only. Open with $store.dsModals.open('change-username')),
948
+ open_expr: "$store.dsModals.open('change-username')",
949
+ glow_when: ds_glow.call("change-username") } do %>
950
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
951
+ <div class="flex items-center justify-between gap-2">
952
+ <span class="block h-2 w-16 rounded" style="background: var(--color-text); opacity: .18"></span>
953
+ <span class="text-[8px] font-semibold uppercase rounded-full px-1.5 py-0.5" style="color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 12%, transparent)">Quest 1/4</span>
954
+ </div>
955
+ <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
956
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
957
+ </div>
958
+ <% end %>
959
+
960
+ <%# Change username — leveling OFF: the plain MS-style action + Save. %>
961
+ <%= render layout: "style/modal_specimen", locals: {
962
+ label: "Change username (plain)",
963
+ reference: %(the "Change username" modal WITHOUT leveling (studio-engine studio/modals/blocks/_change_username, leveling: false) — the plain McRitchie Studio shape: input + Save, then a Saved state. No quest pill, no seeds. Same primitive, flag off. Open with $store.dsModals.open('change-username-plain')),
964
+ open_expr: "$store.dsModals.open('change-username-plain')",
965
+ glow_when: ds_glow.call("change-username-plain") } do %>
966
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
967
+ <span class="block h-2 w-16 rounded" style="background: var(--color-text); opacity: .18"></span>
968
+ <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
969
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
970
+ </div>
971
+ <% end %>
972
+
973
+ <%# Quest activity — leveling ON: generic quest (newsletter join) + seeds. %>
974
+ <%= render layout: "style/modal_specimen", locals: {
975
+ label: "Quest activity (leveling)",
976
+ reference: %(a generic quest activity WITH leveling (studio-engine studio/modals/blocks/_leveling_activity, leveling: true) — here a "Join the Newsletter" quest: quest pill + seeds celebration on complete. Covers join-newsletter / send-message and the rest as ONE parameterized primitive. Open with $store.dsModals.open('quest-activity')),
977
+ open_expr: "$store.dsModals.open('quest-activity')",
978
+ glow_when: ds_glow.call("quest-activity") } do %>
979
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
980
+ <div class="flex items-center justify-between gap-2">
981
+ <span class="text-lg leading-none">📬</span>
982
+ <span class="text-[8px] font-semibold uppercase rounded-full px-1.5 py-0.5" style="color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 12%, transparent)">Quest 2/4</span>
983
+ </div>
984
+ <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
985
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
986
+ </div>
987
+ <% end %>
988
+
989
+ <%# Quest activity — leveling OFF: the plain action (no quest, no seeds). %>
990
+ <%= render layout: "style/modal_specimen", locals: {
991
+ label: "Quest activity (plain)",
992
+ reference: %(the SAME generic activity WITHOUT leveling (studio-engine studio/modals/blocks/_leveling_activity, leveling: false) — a plain "Join the Newsletter" action: input + Subscribe, then Subscribed. No quest pill, no seeds. Open with $store.dsModals.open('quest-activity-plain')),
993
+ open_expr: "$store.dsModals.open('quest-activity-plain')",
994
+ glow_when: ds_glow.call("quest-activity-plain") } do %>
995
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
996
+ <span class="text-lg leading-none">📬</span>
997
+ <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
998
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
999
+ </div>
1000
+ <% end %>
1001
+ </div>
1002
+ </section>
774
1003
  </section>
@@ -259,6 +259,52 @@
259
259
  </div>
260
260
  </section>
261
261
 
262
+ <!-- Confetti & pulse -->
263
+ <section class="space-y-5">
264
+ <div class="space-y-1">
265
+ <h3 class="text-xl font-bold text-heading">Confetti &amp; pulse</h3>
266
+ <p class="text-muted text-sm max-w-2xl">
267
+ Celebration effects lifted from Turf Monster into the engine. Confetti is
268
+ <code class="font-mono text-2xs">window.studioConfetti</code> - a zero-dependency,
269
+ no-CDN effect (canvas-confetti is vendored into the engine and shipped through the
270
+ asset pipeline). Two callable variants, both honoring
271
+ <code class="font-mono text-2xs">prefers-reduced-motion</code>. The pulse is the
272
+ <code class="font-mono text-2xs">.pulse-cta</code> attention beat. Fire the confetti
273
+ with the buttons below.
274
+ </p>
275
+ </div>
276
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
277
+ <%= render layout: "style/specimen",
278
+ locals: { klass: "window.studioConfetti.burst(el)",
279
+ usage: %(window.studioConfetti.burst(cardEl)\n// radial burst from behind a card/modal, from its center) } do %>
280
+ <div x-data="{}" class="flex flex-col items-center gap-3 py-2">
281
+ <div x-ref="burstCard" class="card px-5 py-4 text-sm font-semibold text-heading text-center">Success!</div>
282
+ <button type="button" class="btn btn-primary btn-sm"
283
+ @click="window.studioConfetti && window.studioConfetti.burst($refs.burstCard)">Fire burst</button>
284
+ </div>
285
+ <% end %>
286
+
287
+ <%= render layout: "style/specimen",
288
+ locals: { klass: "window.studioConfetti.cannons()",
289
+ usage: %(window.studioConfetti.cannons()\n// left + right side-cannons, full-screen celebration) } do %>
290
+ <div x-data="{}" class="flex flex-col items-center gap-3 py-2">
291
+ <p class="text-sm font-semibold text-heading text-center">You joined a contest!</p>
292
+ <button type="button" class="btn btn-success btn-sm"
293
+ @click="window.studioConfetti && window.studioConfetti.cannons()">Fire cannons</button>
294
+ </div>
295
+ <% end %>
296
+
297
+ <%= render layout: "style/specimen",
298
+ locals: { klass: ".pulse-cta (knobs: --pulse-cta-color / -speed / -scale)",
299
+ usage: %(<button class="btn btn-primary pulse-cta">Change Username</button>\n<button class="btn btn-success pulse-cta" style="--pulse-cta-color: var(--color-success)">Go</button>) } do %>
300
+ <div class="flex flex-wrap items-center justify-center gap-4">
301
+ <button type="button" class="btn btn-primary pulse-cta">Change Username</button>
302
+ <button type="button" class="btn btn-success pulse-cta" style="--pulse-cta-color: var(--color-success)">Go</button>
303
+ </div>
304
+ <% end %>
305
+ </div>
306
+ </section>
307
+
262
308
  <!-- Leveling (capability-gated: Studio.feature?(:leveling)) -->
263
309
  <section class="space-y-5">
264
310
  <div class="space-y-1">
@@ -0,0 +1,17 @@
1
+ <%#
2
+ Living style guide specimen for the engine age-gate DOB modal
3
+ (studio/modals/blocks/_age_verify). Opened on the page-scoped dsModals host.
4
+
5
+ The whole point of this specimen is the SEAM: every legal value here is a DEMO
6
+ the app feeds — the engine primitive hardcodes none of it. So we pass an
7
+ illustrative policy (21+ in CA) and a demo fine_print that SAYS so, and run in
8
+ demo mode (no backend — submit resolves locally). A real app swaps these for
9
+ its own min_age / state / submit_url / legal copy resolved server-side.
10
+ %>
11
+ <%= render "studio/modals/blocks/age_verify",
12
+ min_age: 21,
13
+ state: "CA",
14
+ submit_url: "#demo-age-verify",
15
+ modal_store: "dsModals",
16
+ demo: true,
17
+ fine_print: "Demo policy the app supplies: 21+ in CA. The engine hardcodes no age and no state table — your app passes min_age, the jurisdiction label, this copy, and the endpoint that recomputes it server-side." %>
@@ -0,0 +1,123 @@
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.
8
+
9
+ Opened on the page-scoped dsModals host with an internal step machine on
10
+ props.step: picker -> confirming -> minted. Single-root templates throughout.
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.
18
+ %>
19
+ <%
20
+ # Illustrative packs only — a real app reads these from its own config
21
+ # (e.g. StripePurchase.available_packs), and they vary by funding mode.
22
+ demo_packs = [
23
+ { tokens: 1, price: "$5", note: nil },
24
+ { tokens: 3, price: "$12", note: "Popular" },
25
+ { tokens: 10, price: "$35", note: "Best value" }
26
+ ]
27
+ %>
28
+ <div x-data="{
29
+ get props() { var c = Alpine.store('dsModals').current(); return (c && c.props) || {}; },
30
+ buy(count, balance) {
31
+ Alpine.store('dsModals').advance({ step: 'confirming', mintedCount: count, mintedBalance: balance });
32
+ setTimeout(function () {
33
+ var c = Alpine.store('dsModals').current();
34
+ if (c && c.id === 'entry-tokens') Alpine.store('dsModals').advance({ step: 'minted' });
35
+ }, 1300);
36
+ }
37
+ }" class="relative">
38
+
39
+ <%# Shared close — top-right, above whichever step is active. %>
40
+ <button @click="$store.dsModals.close()"
41
+ class="absolute top-0 right-0 -mt-2 -mr-2 text-secondary hover:text-heading text-xl leading-none z-10"
42
+ aria-label="Close">&times;</button>
43
+
44
+ <%# ===== PICKER ===== %>
45
+ <template x-if="(props.step || 'picker') === 'picker'">
46
+ <div>
47
+ <div class="text-center pt-1 mb-3">
48
+ <h3 class="text-heading font-bold text-lg leading-tight">Get Entry Tokens</h3>
49
+ </div>
50
+ <%= render "studio/modals/blocks/progress_pill", current: 2, total: 3 %>
51
+ <p class="text-xs text-secondary mb-4 text-center">
52
+ 1 token = 1 contest entry. Pick a pack to finish.
53
+ </p>
54
+
55
+ <div class="space-y-2.5">
56
+ <% demo_packs.each do |pack| %>
57
+ <button type="button"
58
+ @click="buy(<%= pack[:tokens] %>, '<%= pack[:tokens] %>')"
59
+ class="w-full flex items-center justify-between gap-3 rounded-lg border border-strong bg-inset px-4 py-3 text-left hover:border-primary transition">
60
+ <span class="flex items-center gap-2">
61
+ <span class="text-heading font-semibold"><%= pack[:tokens] %> token<%= "s" unless pack[:tokens] == 1 %></span>
62
+ <% if pack[:note] %>
63
+ <span class="badge" style="color: var(--color-primary); border-color: var(--color-primary)"><%= pack[:note] %></span>
64
+ <% end %>
65
+ </span>
66
+ <span class="text-heading font-mono font-bold"><%= pack[:price] %></span>
67
+ </button>
68
+ <% end %>
69
+ </div>
70
+
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). %>
74
+ <div class="mt-4 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-muted">
75
+ <span>Pay by card</span>
76
+ <span aria-hidden="true">&middot;</span>
77
+ <span>PayPal / Venmo</span>
78
+ </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>
80
+
81
+ <%# The seam callout — what the engine owns vs what the app owns. %>
82
+ <div class="mt-4 rounded-lg border border-subtle bg-surface p-3 text-2xs text-secondary leading-snug">
83
+ <p class="text-heading font-semibold mb-1">App-specific flow, engine chrome</p>
84
+ <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.
88
+ </p>
89
+ <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.
93
+ </p>
94
+ </div>
95
+ </div>
96
+ </template>
97
+
98
+ <%# ===== CONFIRMING (mint-at-purchase) ===== %>
99
+ <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)">
101
+ <%= render "studio/modals/blocks/card_header",
102
+ spinner: true,
103
+ title: "Confirming your purchase…",
104
+ subtitle: "Payment cleared — a server job mints one prepaid entry credit per token on-chain." %>
105
+ </div>
106
+ </template>
107
+
108
+ <%# ===== MINTED (ready to consume at entry) ===== %>
109
+ <template x-if="props.step === 'minted'">
110
+ <div>
111
+ <%= render "studio/modals/blocks/card_header",
112
+ icon_color: 'primary',
113
+ title_key: "(props.mintedCount === 1 ? 'Entry Token Minted' : 'Entry Tokens Minted')" do %>
114
+ You now have <span class="font-bold text-primary font-mono" x-text="props.mintedBalance"></span> available.
115
+ <% end %>
116
+ <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.
119
+ </p>
120
+ <button @click="$store.dsModals.close()" class="btn btn-primary btn-lg w-full">Done</button>
121
+ </div>
122
+ </template>
123
+ </div>
data/lib/studio/engine.rb CHANGED
@@ -4,6 +4,8 @@ module Studio
4
4
  app.config.assets.precompile += %w[
5
5
  studio/sticky_table_header.css
6
6
  studio/sticky_table_header.js
7
+ studio/canvas_confetti.js
8
+ studio/studio_confetti.js
7
9
  ]
8
10
  end
9
11
 
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.20.0"
2
+ VERSION = "0.22.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.20.0
4
+ version: 0.22.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -170,7 +170,9 @@ files:
170
170
  - README.md
171
171
  - app/assets/images/resend-favicon.png
172
172
  - app/assets/images/ses-favicon.png
173
+ - app/assets/javascripts/studio/canvas_confetti.js
173
174
  - app/assets/javascripts/studio/sticky_table_header.js
175
+ - app/assets/javascripts/studio/studio_confetti.js
174
176
  - app/assets/stylesheets/studio/sticky_table_header.css
175
177
  - app/assets/tailwind/studio_engine/engine-motion.css
176
178
  - app/assets/tailwind/studio_engine/engine.css
@@ -243,8 +245,10 @@ files:
243
245
  - app/views/schema/index.html.erb
244
246
  - app/views/sessions/_sso_continue.html.erb
245
247
  - app/views/sessions/new.html.erb
248
+ - app/views/studio/_age_verify_assets.html.erb
246
249
  - app/views/studio/_confirm_interstitial.html.erb
247
250
  - app/views/studio/_cropper_assets.html.erb
251
+ - app/views/studio/_leveling_activity_assets.html.erb
248
252
  - app/views/studio/admin_models/_arenas_table.html.erb
249
253
  - app/views/studio/admin_models/_teams_table.html.erb
250
254
  - app/views/studio/admin_models/index.html.erb
@@ -263,12 +267,15 @@ files:
263
267
  - app/views/studio/modals/_image_upload.html.erb
264
268
  - app/views/studio/modals/_saving.html.erb
265
269
  - app/views/studio/modals/auth/_resend_footer.html.erb
270
+ - app/views/studio/modals/blocks/_age_verify.html.erb
266
271
  - app/views/studio/modals/blocks/_card_header.html.erb
272
+ - app/views/studio/modals/blocks/_change_username.html.erb
267
273
  - app/views/studio/modals/blocks/_cta_redirect.html.erb
268
274
  - app/views/studio/modals/blocks/_digit_reel.html.erb
269
275
  - app/views/studio/modals/blocks/_entry_confirmed.html.erb
270
276
  - app/views/studio/modals/blocks/_error_card.html.erb
271
277
  - app/views/studio/modals/blocks/_free_entry_earned.html.erb
278
+ - app/views/studio/modals/blocks/_leveling_activity.html.erb
272
279
  - app/views/studio/modals/blocks/_onchain_success.html.erb
273
280
  - app/views/studio/modals/blocks/_processing_card.html.erb
274
281
  - app/views/studio/modals/blocks/_progress_countdown.html.erb
@@ -293,7 +300,9 @@ files:
293
300
  - app/views/style/_theme.html.erb
294
301
  - app/views/style/_tricks.html.erb
295
302
  - app/views/style/index.html.erb
303
+ - app/views/style/modals/_age_verify.html.erb
296
304
  - app/views/style/modals/_auth.html.erb
305
+ - app/views/style/modals/_entry_tokens.html.erb
297
306
  - app/views/style/modals/_onchain_tx.html.erb
298
307
  - app/views/style/modals/_wallet_connect.html.erb
299
308
  - app/views/style/modals/_wallet_deposit.html.erb