studio-engine 0.19.0 → 0.21.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: f49b9ef9699144b785efbe7d4839261bba26035970a6c411f5cd54d0606ea97c
4
- data.tar.gz: 3705abb5871fcfaa48b7c55613b0bc430b275c8bf9eb4c1625e43fcf14cfed32
3
+ metadata.gz: 14686260b35b4cd72975c3bf156020de61094144f09d8e71f3d6eeb045b5bcc3
4
+ data.tar.gz: 35b5f1bb5cb7f1236d21baa47c3e28d6d7f00420eb775da0719cd9874b29b4b8
5
5
  SHA512:
6
- metadata.gz: 584cbbcdeeb388b1a6b0115a790fb6fa3d1c4ce4c69f42e57f7f0985edb4f6338578db04dcfeb5c60bde65aada69509db87c07151a52aab9f9348739a332cdca
7
- data.tar.gz: dbbe745f30a632c21d528e336dd323a21224e71d39f97caf6fc43f68f958d1cb78da1a8660d735393847bdfeb67a79d15c1f12c199446f665c0267ff521c19fd
6
+ metadata.gz: 4a8b31f5f092a8998590c47877fb23631316fbaee2ab8bca2199038d27497775db342a5ba4f2b759bd09b2ae44256ad57b857cea5742731f84c59a8525a3b1e3
7
+ data.tar.gz: 506f56fbadf51b53c7a1f779678bab0ba1de8fc485493acb4d3ac8aa19ddfd29c4b103d789107d3f4258b381d709fdefdd00fc7ab81dd8cdfffbc59f8baffb88
data/CHANGELOG.md CHANGED
@@ -2,6 +2,125 @@
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.21.0 — 2026-07-27
6
+
7
+ Phase 2 of the studio-engine modal convergence: homes the entry-time age-gate DOB
8
+ modal as a policy-free engine primitive, and documents the app-specific entry-token
9
+ purchase flow as a living-style-guide specimen.
10
+
11
+ ### Added
12
+
13
+ - **Age-gate DOB modal — `studio/modals/blocks/_age_verify`.** The entry-time
14
+ date-of-birth gate (Month / Day / Year + a live "too young" hint + submit),
15
+ homed in the engine as the heavier sibling of the existing
16
+ `studio/modals/shared/_age_attestation` checkbox. **Run one or the other:** the
17
+ attestation is a signup-time "I confirm I'm of legal age" checkbox; this is an
18
+ entry-time DOB gate a server can recompute and stamp.
19
+
20
+ **The engine hardcodes NO legal policy** — the whole seam is app-supplied:
21
+ `min_age` (REQUIRED, with **no engine default** — 18 is itself a policy value),
22
+ `submit_url` (the app owns the authoritative recompute + DOB persistence),
23
+ `state` (an optional PASSIVE jurisdiction label — the modal never detects
24
+ geography and never offers an editable state field, so a spoofed client state
25
+ can't lower the bar), and `fine_print` (the app's per-jurisdiction legal copy;
26
+ the engine ships only a neutral, policy-free default). The per-state age table
27
+ that the app's policy encodes never enters the engine.
28
+
29
+ - **`ageVerifyModal` factory — `studio/_age_verify_assets`.** The Alpine data for
30
+ the DOB modal, shipped at page level (a `<script>` cloned out of a modal-host
31
+ template never runs). A consumer renders it once in its layout, like the shared
32
+ alpine factories; mirrors how `studio/_cropper_assets` homes `cropPhotoModal`.
33
+ The factory carries zero policy — no age default, no state table; the client
34
+ age math is UX only, the server recompute stays authoritative.
35
+
36
+ - **Living style guide — new "Eligibility & entry" group.** Adds two specimens to
37
+ the `/admin/style` Modals section:
38
+ - The **age-gate** specimen, opened with a clearly-labelled DEMO policy (21+ in
39
+ CA) that documents the app-supplied seam, gated by
40
+ `Studio.feature?(:age_gate)` — disabled-but-present-yet-openable when off,
41
+ like the web3 specimens.
42
+ - The **entry-tokens** specimen — the app-specific purchase flow
43
+ (picker → confirming → minted) documented by composing engine chrome
44
+ (`shell`, `card_header`, `progress_pill`) with illustrative demo packs. The
45
+ engine does NOT own the packs, pricing, rails, or the on-chain mint. The copy
46
+ reflects the real on-chain model: an entry token is a **prepaid entry credit
47
+ recorded on-chain** (not a wallet-held SPL token), **minted server-side** once
48
+ payment clears and **consumed** when the app's hold-to-confirm creates the
49
+ entry.
50
+
51
+ ### Capability gating
52
+
53
+ - **`:age_gate`** — a new, independent capability flag gating the age-gate
54
+ specimen (and the intended consumer mount). Off by default; a consumer opts in
55
+ via `config.features`.
56
+
57
+ ### Unchanged
58
+
59
+ - Backward compatible: every addition is additive/optional. Nothing existing
60
+ changes behaviour, and the lighter `_age_attestation` checkbox is untouched.
61
+
62
+ ## 0.20.0 — 2026-07-27
63
+
64
+ Phase 1 of the studio-engine modal convergence: the engine now OWNS the wallet
65
+ brand icons, homes the entry-confirmed celebration, and gates the web3/leveling
66
+ enrichments behind the two independent capability flags.
67
+
68
+ ### Added
69
+
70
+ - **Wallet brand icons ship inline from the engine** — new
71
+ `studio/modals/blocks/_wallet_brand_sprite` emits a hidden inline-SVG sprite
72
+ with the Phantom (purple ghost), Solflare (yellow S), and Backpack (red pack)
73
+ marks as `<symbol>`s. The Connect-wallet picker resolves a mark by name
74
+ (`brandIcon(name)` → `<use href="#se-wallet-…">`) and falls back to a letter
75
+ tile only for an unknown wallet. Every consumer inherits crisp brand icons
76
+ with **zero per-app asset files** — before this, the picker referenced
77
+ app-served PNGs (`/wallet-phantom.png`, …), so a fresh app that had not copied
78
+ those into its own `public/` showed 404 letter tiles.
79
+
80
+ - **Entry-confirmed celebration — `studio/modals/blocks/_entry_confirmed`.** The
81
+ canonical "you're in" card, homed in the engine and composed via the
82
+ `_success_card` **yield slot**: `_success_card` owns the web3 spine (the `:lg`
83
+ check header, branded Solana tx link, drain CTA with auto-redirect, confetti),
84
+ and `_entry_confirmed` injects the leveling enrichment into its block. All
85
+ numbers are **app-supplied** via Alpine expressions (default `props.*`):
86
+ `txSignature`, `lobbyUrl`, `seedsEarned`, `seedsTotal`. Turf-specific copy is
87
+ parameterized, not baked in.
88
+
89
+ - **Seeds progress bar — `studio/modals/blocks/_seeds_bar` + `_digit_reel`.**
90
+ Ported from Turf Monster and generalized: the animated 5-segment fill with a
91
+ 4-phase level-up sequence (fill → level pop → drain → refill) and the
92
+ rolling-digit counter. The `--bar-progress` registered custom property,
93
+ `.seeds-bar-continuous`, `.seeds-reel-track`, and the `seedsShimmer` keyframe
94
+ ship in `engine-motion.css`. Motion is class-owned (timing via a
95
+ `--seeds-fill-dur` knob) so `prefers-reduced-motion` actually cancels the
96
+ bar-fill, shimmer, and reel.
97
+
98
+ - **Free Entry Earned modal — `studio/modals/blocks/_free_entry_earned`.** The
99
+ standalone level-up reward modal, parameterized (store, icon, title, copy).
100
+
101
+ - **Living style guide** gains an engine-owned wallet-icon picker, an
102
+ "Entry confirmed" (web3) specimen, and "Entry + seeds level-up" +
103
+ "Free Entry Earned" (leveling) specimens, so the gated variants demo correctly
104
+ (generic-success vs seeds vs free-entry).
105
+
106
+ ### Capability gating
107
+
108
+ - **`:web3`** gates the generic entry-confirmed success (branded tx link +
109
+ heading + CTA), alongside the existing wallet / on-chain / deposit modals.
110
+ - **`:leveling`** — independent of `:web3` — gates the seeds bar and the Free
111
+ Entry Earned reward. `_entry_confirmed` self-gates its block on
112
+ `Studio.feature?(:leveling)`, so a **web3-only app (leveling off) gets the
113
+ clean success card automatically** — no seeds, no free entry.
114
+
115
+ ### Unchanged
116
+
117
+ - Backward compatible: every addition is additive/optional (new locals default
118
+ to today's behavior). The shared `_success_card`, `_card_header`,
119
+ `_cta_redirect`, and the other blocks are untouched in default behavior, so
120
+ existing McRitchie Studio and Turf Monster consumers render identically. No
121
+ engine view-primitive gains any signing / key material — signing stays an
122
+ app-supplied callback seam.
123
+
5
124
  ## 0.19.0 — 2026-07-27
6
125
 
7
126
  ### Added
@@ -27,6 +27,7 @@
27
27
  .level-up-pop level-up pop + glow burst (ported, TM)
28
28
  .nav-level-pop nav level-up bounce (ported, TM)
29
29
  .badge-with-sheen one-shot diagonal sheen wrapper (ported, TM)
30
+ .seeds-bar-continuous 5-segment lockstep progress fill (ported, TM)
30
31
 
31
32
  ─────────────────────────────────────────────────────────────────────────────
32
33
  OPT-IN — this layer does NOT auto-bundle.
@@ -927,6 +928,63 @@
927
928
  }
928
929
  }
929
930
 
931
+ /* =============================================================================
932
+ SEEDS BAR — the continuous 5-segment progress fill behind the leveling
933
+ entry-confirmed celebration (studio/modals/blocks/_seeds_bar). Ported from
934
+ turf-monster. ONE registered custom property (--bar-progress) drives all five
935
+ segment clip-paths in lockstep off a SINGLE transition curve, instead of five
936
+ chained ones. .seeds-bar-continuous parks it at 0 and eases changes;
937
+ seedsShimmer is the light sweep that rides across the filled bar. The whole
938
+ family is capability-gated at the view layer by Studio.feature?(:leveling), so
939
+ an app with leveling off never renders a bar and these classes idle unused.
940
+ ========================================================================== */
941
+
942
+ /* Registered custom property — makes --bar-progress interpolatable so a single
943
+ transition animates all five segment widths together (one ease, not five). */
944
+ @property --bar-progress {
945
+ syntax: "<number>";
946
+ initial-value: 0;
947
+ inherits: true;
948
+ }
949
+
950
+ /* The motion PROPERTIES (transition / animation) live on these classes — NOT on
951
+ the view's inline style — precisely so the reduced-motion override below can
952
+ reach and cancel them. An inline `style="transition: …"` beats a class rule on
953
+ specificity, so a class-based @media override could never win against it; the
954
+ view keeps only the dynamic values inline (the Alpine-bound transform /
955
+ --bar-progress, and the fill duration knob --seeds-fill-dur). */
956
+ .seeds-bar-continuous {
957
+ --bar-progress: 0;
958
+ transition: --bar-progress var(--seeds-fill-dur, 1.2s) cubic-bezier(0.16, 1, 0.3, 1);
959
+ }
960
+
961
+ /* The rolling digit reel's slide — same fill duration as the bar (via the shared
962
+ --seeds-fill-dur knob) so the counter tracks the fill. Class-based so
963
+ reduced-motion can cancel it (the view binds only the transform inline). */
964
+ .seeds-reel-track {
965
+ transition: transform var(--seeds-fill-dur, 1.2s) cubic-bezier(0.16, 1, 0.3, 1);
966
+ }
967
+
968
+ /* Light sweep across a filled segment. The five segments stagger a shared band
969
+ (via per-segment offsets in the view) so it reads as one continuous wave. */
970
+ .seeds-shimmer {
971
+ animation: seedsShimmer 2.5s ease-in-out 0.4s 1 both;
972
+ }
973
+
974
+ @keyframes seedsShimmer {
975
+ 0% { transform: translateX(-100%); }
976
+ 100% { transform: translateX(200%); }
977
+ }
978
+
979
+ /* Reduced-motion: drop the interpolation + shimmer; the bar, reel, and shimmer
980
+ all snap to their resting value. Reachable because the motion lives on the
981
+ classes above, not on inline styles. */
982
+ @media (prefers-reduced-motion: reduce) {
983
+ .seeds-bar-continuous { transition: none; }
984
+ .seeds-reel-track { transition: none; }
985
+ .seeds-shimmer { animation: none; }
986
+ }
987
+
930
988
  /* =============================================================================
931
989
  Reduced-motion: honor the user preference. Animated primitives settle into a
932
990
  sensible static resting state; the static primitives (fade-edge, text-gradient,
@@ -0,0 +1,120 @@
1
+ <%#
2
+ ageVerifyModal factory — the Alpine data for the engine age-gate DOB modal
3
+ (studio/modals/blocks/_age_verify). Ships here, at PAGE level, on purpose: the
4
+ modal partial mounts inside a modal host's single-root template, and a script
5
+ cloned out of a template never runs. So a consumer renders THIS once in its
6
+ layout (like the shared alpine factories), and the modal partial anywhere below
7
+ just references window.ageVerifyModal. Mirrors how studio/_cropper_assets homes
8
+ the cropPhotoModal factory for the crop modal.
9
+
10
+ The factory holds ZERO legal policy — no age default, no state table. minAge is
11
+ whatever the app hands it (a required prop upstream); the client "too young"
12
+ hint is UX only, and the server recompute at submit_url stays authoritative.
13
+
14
+ opts: { minAge, state, url, store, demo }.
15
+ minAge app-supplied minimum age (no engine fallback — 0 means unsupplied).
16
+ state passive jurisdiction label (server-detected upstream; never editable).
17
+ url app endpoint the DOB POSTs to (the app owns the authoritative check).
18
+ store Alpine modal store name backing close() (default "modals").
19
+ demo style-guide preview: resolve locally, skip the network POST.
20
+ %>
21
+ <script>
22
+ (function () {
23
+ if (window.ageVerifyModal) return;
24
+ window.ageVerifyModal = function (opts) {
25
+ opts = opts || {};
26
+ var now = new Date();
27
+ return {
28
+ month: "", day: "", year: "",
29
+ submitting: false,
30
+ error: "",
31
+ // No engine age default: 18 is itself a policy value. The app supplies it.
32
+ minAge: parseInt(opts.minAge, 10) || 0,
33
+ stateCode: opts.state || "",
34
+ url: opts.url || "",
35
+ store: opts.store || "modals",
36
+ demo: !!opts.demo,
37
+ months: [
38
+ { n: 1, label: "January" }, { n: 2, label: "February" }, { n: 3, label: "March" },
39
+ { n: 4, label: "April" }, { n: 5, label: "May" }, { n: 6, label: "June" },
40
+ { n: 7, label: "July" }, { n: 8, label: "August" }, { n: 9, label: "September" },
41
+ { n: 10, label: "October" }, { n: 11, label: "November" }, { n: 12, label: "December" }
42
+ ],
43
+ // Year range: 13 years ago (youngest plausible) back 100 years. This is a
44
+ // sane input range, not an age bar — the app's minAge decides eligibility.
45
+ years: (function () {
46
+ var ys = [], top = now.getFullYear() - 13, bottom = now.getFullYear() - 100;
47
+ for (var y = top; y >= bottom; y--) ys.push(y);
48
+ return ys;
49
+ })(),
50
+ // Days available for the chosen month/year (handles 28/29/30/31).
51
+ get dayOptions() {
52
+ var m = parseInt(this.month, 10), y = parseInt(this.year, 10);
53
+ var max = 31;
54
+ if (m) max = new Date(y || 2000, m, 0).getDate();
55
+ var ds = [];
56
+ for (var d = 1; d <= max; d++) ds.push(d);
57
+ return ds;
58
+ },
59
+ get complete() {
60
+ return !!(this.month && this.day && this.year);
61
+ },
62
+ get computedAge() {
63
+ if (!this.complete) return null;
64
+ var dob = new Date(parseInt(this.year, 10), parseInt(this.month, 10) - 1, parseInt(this.day, 10));
65
+ var age = now.getFullYear() - dob.getFullYear();
66
+ var hadBirthday = (now.getMonth() > dob.getMonth()) ||
67
+ (now.getMonth() === dob.getMonth() && now.getDate() >= dob.getDate());
68
+ if (!hadBirthday) age -= 1;
69
+ return age;
70
+ },
71
+ // Clamp an out-of-range day when the month changes (e.g. Feb after 31).
72
+ onMonthChange() {
73
+ if (this.day && parseInt(this.day, 10) > this.dayOptions.length) this.day = "";
74
+ this.error = "";
75
+ },
76
+ // Success path: flip a session flag if present, close via the app's store,
77
+ // and let the app resume its own flow (the entry gate listens for this).
78
+ _finish() {
79
+ try {
80
+ var sess = window.Alpine && Alpine.store && Alpine.store("session");
81
+ if (sess) sess.ageVerified = true;
82
+ } catch (e) {}
83
+ try { Alpine.store(this.store).close(); } catch (e) {}
84
+ window.dispatchEvent(new CustomEvent("age-verified"));
85
+ },
86
+ submit() {
87
+ if (!this.complete || this.submitting) return;
88
+ this.error = "";
89
+ // Client courtesy gate only — the server recompute is authoritative.
90
+ if (this.computedAge !== null && this.minAge && this.computedAge < this.minAge) return;
91
+ this.submitting = true;
92
+ var self = this;
93
+ if (this.demo) {
94
+ // Style-guide preview: no backend, resolve locally so the modal
95
+ // demonstrates the success handoff without an endpoint.
96
+ setTimeout(function () { self.submitting = false; self._finish(); }, 450);
97
+ return;
98
+ }
99
+ var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || "";
100
+ fetch(this.url, {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
103
+ body: JSON.stringify({ year: this.year, month: this.month, day: this.day })
104
+ })
105
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
106
+ .then(function (res) {
107
+ self.submitting = false;
108
+ if (res.ok && res.body && res.body.verified) { self._finish(); return; }
109
+ self.error = (res.body && res.body.error) ||
110
+ "We couldn't verify your age. Please check the date and try again.";
111
+ })
112
+ .catch(function () {
113
+ self.submitting = false;
114
+ self.error = "Network error — please try again.";
115
+ });
116
+ }
117
+ };
118
+ };
119
+ })();
120
+ </script>
@@ -0,0 +1,106 @@
1
+ <%#
2
+ Age-gate DOB modal — engine primitive. Collects a real date of birth
3
+ (Month / Day / Year), shows a live "too young" hint, and submits. Homed in the
4
+ engine as the heavier sibling of studio/modals/shared/_age_attestation (the
5
+ lighter one-checkbox legal attestation). RUN ONE OR THE OTHER, not both: the
6
+ attestation is a signup-time "I confirm I'm of legal age" checkbox; this is an
7
+ entry-time DOB gate that a server can recompute and stamp.
8
+
9
+ CRITICAL — the engine hardcodes NO legal policy. It renders the modal UI, the
10
+ DOB fields, and the submit; everything legal is app-supplied:
11
+
12
+ min_age (Integer, REQUIRED) — the app's minimum age. There is NO engine
13
+ default (18 is itself a policy value); the app resolves it from
14
+ its own policy against its own server-detected jurisdiction.
15
+ submit_url (String, REQUIRED) — the app endpoint the DOB POSTs to. The
16
+ app owns the authoritative recompute + DOB persistence; the modal
17
+ only shows the response.
18
+ state (String, optional) — a PASSIVE jurisdiction label for the copy
19
+ (e.g. "CA"). Server-detected upstream; the modal never detects
20
+ geography and never offers an editable state field (a spoofable
21
+ client state must not be able to lower the bar). Blank => the
22
+ jurisdiction clause is dropped.
23
+ title (String, optional) — modal title. Default "Verify your age".
24
+ intro (String, optional) — the lead sentence after the age line.
25
+ fine_print (String, optional) — the app's legal / jurisdiction copy (e.g.
26
+ its per-state age table). The engine ships only a neutral,
27
+ policy-free default; the app passes its real copy here.
28
+ modal_store (String, optional) — Alpine store backing close(). Default "modals".
29
+ demo (Boolean, optional) — style-guide preview: resolve locally, no POST.
30
+
31
+ Requires window.ageVerifyModal — render studio/_age_verify_assets once at
32
+ layout level (the factory can't ship inside this template; a cloned script
33
+ never runs). Single root element (modal-host template mount rule).
34
+ %>
35
+ <%
36
+ min_age = local_assigns.fetch(:min_age)
37
+ submit_url = local_assigns.fetch(:submit_url)
38
+ state = local_assigns[:state].to_s.strip
39
+ title = local_assigns.fetch(:title, "Verify your age")
40
+ intro = local_assigns.fetch(:intro, "Enter your date of birth to confirm you're eligible — we only ask once.")
41
+ fine_print = local_assigns.fetch(:fine_print, "We use your date of birth only to confirm eligibility.")
42
+ modal_store = local_assigns.fetch(:modal_store, "modals")
43
+ demo = local_assigns.fetch(:demo, false)
44
+ %>
45
+ <div x-data="ageVerifyModal({ minAge: <%= min_age.to_i %>, state: '<%= j state %>', url: '<%= j submit_url %>', store: '<%= j modal_store %>', demo: <%= demo ? 'true' : 'false' %> })">
46
+ <%= render layout: "studio/modals/blocks/shell", locals: { title: title, modal_store: modal_store } do %>
47
+ <p class="text-sm text-muted mb-4">
48
+ You must be <span class="text-heading font-semibold"><%= min_age %>+</span><%= state.present? ? " in #{state}" : "" %>.
49
+ <%= intro %>
50
+ </p>
51
+
52
+ <%# Month / Day / Year selectors %>
53
+ <div class="grid grid-cols-3 gap-2 mb-4">
54
+ <div>
55
+ <label class="label-upper block mb-1">Month</label>
56
+ <select x-model="month" @change="onMonthChange()" class="input-field !px-3 !py-2.5 text-sm appearance-none cursor-pointer">
57
+ <option value="" disabled>Month</option>
58
+ <template x-for="m in months" :key="m.n">
59
+ <option :value="m.n" x-text="m.label"></option>
60
+ </template>
61
+ </select>
62
+ </div>
63
+ <div>
64
+ <label class="label-upper block mb-1">Day</label>
65
+ <select x-model="day" @change="error = ''" class="input-field !px-3 !py-2.5 text-sm appearance-none cursor-pointer">
66
+ <option value="" disabled>Day</option>
67
+ <template x-for="d in dayOptions" :key="d">
68
+ <option :value="d" x-text="d"></option>
69
+ </template>
70
+ </select>
71
+ </div>
72
+ <div>
73
+ <label class="label-upper block mb-1">Year</label>
74
+ <select x-model="year" @change="onMonthChange()" class="input-field !px-3 !py-2.5 text-sm appearance-none cursor-pointer">
75
+ <option value="" disabled>Year</option>
76
+ <template x-for="y in years" :key="y">
77
+ <option :value="y" x-text="y"></option>
78
+ </template>
79
+ </select>
80
+ </div>
81
+ </div>
82
+
83
+ <%# Live too-young hint — shown once a full date is picked and it fails the
84
+ app-supplied bar, so the user sees the problem before submitting. %>
85
+ <template x-if="complete && computedAge !== null && minAge && computedAge < minAge">
86
+ <p class="text-red-400 text-sm mb-3" x-text="'You must be ' + minAge + '+' + (stateCode ? ' in ' + stateCode : '') + '.'"></p>
87
+ </template>
88
+
89
+ <%# Server error (invalid date, or the authoritative too-young check). %>
90
+ <template x-if="error">
91
+ <p class="text-red-400 text-sm mb-3" x-text="error"></p>
92
+ </template>
93
+
94
+ <button type="button" @click="submit()"
95
+ class="btn btn-primary btn-lg w-full"
96
+ :disabled="!complete || submitting || (computedAge !== null && minAge && computedAge < minAge)">
97
+ <span x-show="!submitting">Confirm &amp; Continue</span>
98
+ <span x-show="submitting" class="inline-flex items-center justify-center gap-2" style="display: none;">
99
+ <span class="cta-spinner" aria-hidden="true"></span>
100
+ Verifying…
101
+ </span>
102
+ </button>
103
+
104
+ <p class="text-[11px] text-muted text-center mt-3 leading-snug"><%= fine_print %></p>
105
+ <% end %>
106
+ </div>
@@ -0,0 +1,41 @@
1
+ <%#
2
+ Rolling-digit reel — a vertical stack of the values 0..max clipped to a single
3
+ cell, translated by -(value * cell_em)em so it slides as `value` changes. The
4
+ shared core behind rotating counters (a single 0–100 reel for the seeds bar,
5
+ per-unit em cells for countdowns). Ported from turf-monster; app-agnostic (only
6
+ Alpine expressions + locals — no app models or helpers).
7
+
8
+ Renders INSIDE a parent Alpine x-data scope: `value` and `transition` are Alpine
9
+ *expressions* evaluated there (e.g. "displaySeeds"; "'transform 1.9s ...'"). This
10
+ owns only the clip window + reel mechanics — colour, font, and any unit letter
11
+ live on the caller's wrapper.
12
+
13
+ Cells are em-sized, so the reel scales with the inherited font size; cell_em is
14
+ both the cell height (em) and the translate multiplier, so a value of N lands
15
+ cell N exactly under the window.
16
+
17
+ Locals:
18
+ value (String) — Alpine expr for the current value (drives translate)
19
+ max (Integer) — highest cell; renders cells 0..max
20
+ transition (String) — Alpine expr for the CSS transition (or "'none'")
21
+ width (String) — CSS width of the clip window, e.g. "2ch" / "3.2ch"
22
+ cell_em (Numeric?) — cell height in em + translate multiplier (default 1)
23
+ pad (Bool?) — zero-pad cell numbers to 2 digits (default false)
24
+ transition (String?) — Alpine expr for the slide transition when track_class
25
+ is NOT used (default "'none'"). Inline, so a caller can
26
+ snap on rollover; NOT reachable by reduced-motion CSS.
27
+ track_class(String?) — a CSS class that owns the slide transition. When set,
28
+ the reel binds ONLY the transform inline and the class
29
+ supplies the transition — so a reduced-motion @media
30
+ rule can cancel it (the seeds bar uses this). %>
31
+ <%
32
+ cell_em = local_assigns.fetch(:cell_em, 1)
33
+ pad = local_assigns.fetch(:pad, false)
34
+ track_class = local_assigns[:track_class]
35
+ transition = local_assigns.fetch(:transition, "'none'")
36
+ %>
37
+ <span style="display:inline-block;overflow:hidden;height:<%= cell_em %>em;line-height:<%= cell_em %>em;width:<%= width %>;text-align:right;">
38
+ <span class="block<%= " #{track_class}" if track_class %>" :style="{ transform: 'translateY(-' + (<%= value %> * <%= cell_em %>) + 'em)'<%= track_class ? "" : ", transition: #{transition}" %> }">
39
+ <% (0..max).each do |n| %><span class="block" style="height:<%= cell_em %>em;line-height:<%= cell_em %>em;"><%= pad ? format("%02d", n) : n %></span><% end %>
40
+ </span>
41
+ </span>
@@ -0,0 +1,86 @@
1
+ <%#
2
+ Entry-confirmed celebration — the canonical "you're in" card for an on-chain
3
+ (or entry-token-funded) entry. Homed in the engine and composed via the
4
+ _success_card YIELD SLOT (its documented purpose): _success_card owns the web3
5
+ spine — the :lg check header, the branded Solana tx link, the drain CTA with
6
+ auto-redirect, and the confetti — and this partial injects the LEVELING
7
+ enrichment (the seeds bar + level-up reward) into its block.
8
+
9
+ Capability gating (the two flags are independent):
10
+ * The generic success (heading + branded tx link + CTA) is a web3 celebration;
11
+ a caller renders this only in a :web3 context.
12
+ * The seeds bar + "Free Entry Earned" reward render ONLY under
13
+ Studio.feature?(:leveling). A web3-only app (leveling OFF) gets the clean
14
+ success card — no seeds, no free entry — automatically, because this partial
15
+ self-gates the block on the flag.
16
+
17
+ All numbers are APP-SUPPLIED via Alpine expressions (defaulting to props.*): the
18
+ engine renders the card, the app feeds txSignature / lobbyUrl / seedsEarned /
19
+ seedsTotal. The reward copy + CTA label + seeds-per-level are parameters, so no
20
+ Turf-specific string is baked in.
21
+
22
+ Single root: the outer <div> is one element so a <template x-if> clones it whole.
23
+
24
+ Locals (all optional; sensible props.* / static defaults):
25
+ modal_store — Alpine store name backing props/close. Default "modals".
26
+ title_key — Alpine expr for the headline. Default "props.title || 'Good Luck'".
27
+ subtitle — static sub-headline. Default "Entry Confirmed".
28
+ subtitle_key — Alpine expr for the sub-headline (priority over subtitle).
29
+ tx_signature_key — Alpine expr for the Solana tx hash. Default "props.txSignature".
30
+ cluster_param — explorer cluster query (e.g. "?cluster=devnet"). Default "".
31
+ lobby_url_key — Alpine expr for the CTA href + auto-redirect. Default "props.lobbyUrl".
32
+ cta_label — primary CTA text. Default "Continue".
33
+ seeds_earned_key — Alpine expr: seeds awarded this entry. Default "props.seedsEarned".
34
+ seeds_total_key — Alpine expr: new seeds total. Default "props.seedsTotal".
35
+ seeds_per_level — Integer seeds per level. Default 100.
36
+ free_entry_label — reward copy on a level-up. Default "Free Entry Earned 🎟️".
37
+ %>
38
+ <%
39
+ modal_store = local_assigns.fetch(:modal_store, "modals")
40
+ title_key = local_assigns.fetch(:title_key, "props.title || 'Good Luck'")
41
+ subtitle = local_assigns.fetch(:subtitle, "Entry Confirmed")
42
+ subtitle_key = local_assigns[:subtitle_key]
43
+ tx_signature_key = local_assigns.fetch(:tx_signature_key, "props.txSignature")
44
+ cluster_param = local_assigns.fetch(:cluster_param, "")
45
+ lobby_url_key = local_assigns.fetch(:lobby_url_key, "props.lobbyUrl")
46
+ cta_label = local_assigns.fetch(:cta_label, "Continue")
47
+ seeds_earned_key = local_assigns.fetch(:seeds_earned_key, "props.seedsEarned")
48
+ seeds_total_key = local_assigns.fetch(:seeds_total_key, "props.seedsTotal")
49
+ seeds_per_level = local_assigns.fetch(:seeds_per_level, 100)
50
+ free_entry_label = local_assigns.fetch(:free_entry_label, "Free Entry Earned 🎟️")
51
+
52
+ sc_locals = {
53
+ large_title: true,
54
+ icon_color: "primary",
55
+ title_key: title_key,
56
+ tx_signature_key: tx_signature_key,
57
+ tx_solana: true,
58
+ cta_label: cta_label,
59
+ cta_href_key: lobby_url_key,
60
+ cta_drain: true,
61
+ auto_redirect_url_key: lobby_url_key,
62
+ confetti: true
63
+ }
64
+ if subtitle_key
65
+ sc_locals[:message_key] = subtitle_key
66
+ else
67
+ sc_locals[:message] = subtitle
68
+ end
69
+ %>
70
+ <%# props getter + clusterParam live on the root scope so _success_card's nested
71
+ Alpine expressions (props.txSignature, props.lobbyUrl, clusterParam) resolve up
72
+ the scope chain. clusterParam feeds _success_card's branded tx-link URL. %>
73
+ <div x-data="{ get props() { var c = $store.<%= modal_store %>.current(); return (c && c.props) || {}; }, clusterParam: '<%= cluster_param %>' }">
74
+ <%= render "studio/modals/blocks/success_card", sc_locals do %>
75
+ <% if Studio.feature?(:leveling) %>
76
+ <%# Leveling enrichment — mounts only when this entry awarded seeds. %>
77
+ <template x-if="(<%= seeds_earned_key %>) > 0">
78
+ <%= render "studio/modals/blocks/seeds_bar",
79
+ seeds_earned_key: seeds_earned_key,
80
+ seeds_total_key: seeds_total_key,
81
+ seeds_per_level: seeds_per_level,
82
+ free_entry_label: free_entry_label %>
83
+ </template>
84
+ <% end %>
85
+ <% end %>
86
+ </div>
@@ -0,0 +1,46 @@
1
+ <%#
2
+ Free-entry-earned — the standalone level-up celebration modal. Pops when a user
3
+ crosses a seed level (the reward for leveling). Ported from turf-monster and
4
+ generalized: the store, icon, title, and reward copy are all parameters, so no
5
+ app-specific string is baked in.
6
+
7
+ Capability: a :leveling primitive — a caller opens it only under
8
+ Studio.feature?(:leveling).
9
+
10
+ Single root: the outer <div> is one element so a <template x-if> clones it whole.
11
+
12
+ Locals (all optional):
13
+ modal_store — Alpine store name backing props/close. Default "modals".
14
+ icon_emoji — celebration glyph. Default "🎉".
15
+ title_key — Alpine expr for the headline. Default shows "Level N" from
16
+ props.level, else "Free Entry Earned".
17
+ subtitle — static reward copy. Default a generic level-up line.
18
+ subtitle_key — Alpine expr for the reward copy (priority over subtitle).
19
+ close_label — dismiss link text. Default "Close".
20
+ %>
21
+ <%
22
+ modal_store = local_assigns.fetch(:modal_store, "modals")
23
+ icon_emoji = local_assigns.fetch(:icon_emoji, "🎉")
24
+ title_key = local_assigns.fetch(:title_key, "props.level ? ('Level ' + props.level) : 'Free Entry Earned'")
25
+ subtitle = local_assigns.fetch(:subtitle, "You leveled up and earned a reward. Keep earning more with each level.")
26
+ subtitle_key = local_assigns[:subtitle_key]
27
+ close_label = local_assigns.fetch(:close_label, "Close")
28
+
29
+ ch_locals = { size: :lg, icon_emoji: icon_emoji, title_key: title_key }
30
+ if subtitle_key
31
+ ch_locals[:subtitle_key] = subtitle_key
32
+ else
33
+ ch_locals[:subtitle] = subtitle
34
+ end
35
+ %>
36
+ <%# Confetti on mount — prefer a free-entry-specific burst if the host defined one,
37
+ else the shared success confetti; both are optional (guarded). %>
38
+ <div x-data="{ get props() { var c = $store.<%= modal_store %>.current(); return (c && c.props) || {}; } }"
39
+ x-init="setTimeout(function () { try { (window.fireFreeEntryConfetti || window.fireSuccessConfetti || function(){})(); } catch (e) {} }, 120)">
40
+ <%= render "studio/modals/blocks/card_header", ch_locals %>
41
+
42
+ <button type="button" @click="$store.<%= modal_store %>.close()"
43
+ class="block mx-auto mt-4 text-sm text-secondary hover:text-heading transition">
44
+ <%= close_label %>
45
+ </button>
46
+ </div>