studio-engine 0.20.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: e3b84a5d11a22ad89cdbf077ba91c6d35374ba8a956c2fdc1207eb7033ec15c5
4
- data.tar.gz: 510f09d35fb5dd3562c9435c1df1f5cf8001b9a34dd12678eb32822f91ded766
3
+ metadata.gz: 14686260b35b4cd72975c3bf156020de61094144f09d8e71f3d6eeb045b5bcc3
4
+ data.tar.gz: 35b5f1bb5cb7f1236d21baa47c3e28d6d7f00420eb775da0719cd9874b29b4b8
5
5
  SHA512:
6
- metadata.gz: 4f6da2882ad34a600e5855c659ba525bda3d191737389d1b8348e0eca8eb01b1da2a48d7df55dec5b5072879acf6ba236439c1ac99fac2c82216ac68b8df6af3
7
- data.tar.gz: dbe6979e76badd522119308b4c459b1ebd5a246fa549364b2b78d7ca86e8b828c93523899aaa0813f874d1bb95be37e4c6cd32dab492c60988dbae84051d9ceb
6
+ metadata.gz: 4a8b31f5f092a8998590c47877fb23631316fbaee2ab8bca2199038d27497775db342a5ba4f2b759bd09b2ae44256ad57b857cea5742731f84c59a8525a3b1e3
7
+ data.tar.gz: 506f56fbadf51b53c7a1f779678bab0ba1de8fc485493acb4d3ac8aa19ddfd29c4b103d789107d3f4258b381d709fdefdd00fc7ab81dd8cdfffbc59f8baffb88
data/CHANGELOG.md CHANGED
@@ -2,6 +2,63 @@
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
+
5
62
  ## 0.20.0 — 2026-07-27
6
63
 
7
64
  Phase 1 of the studio-engine modal convergence: the engine now OWNS the wallet
@@ -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>
@@ -1,5 +1,5 @@
1
1
  <%#
2
- Wallet brand-icon sprite — inline SVG marks for the wallets the Connect-Wallet
2
+ Wallet brand-icon sprite — inline brand marks for the wallets the Connect-Wallet
3
3
  picker knows about (Phantom, Solflare, Backpack), OWNED BY THE ENGINE so every
4
4
  consumer inherits crisp brand icons with ZERO per-app asset files. Before this,
5
5
  the picker referenced app-served PNGs (/wallet-phantom.png, ...), so a fresh app
@@ -15,37 +15,26 @@
15
15
  the name and returns it only for a known brand, else null → letter fallback).
16
16
 
17
17
  The sprite is visually hidden (zero-box, absolute) and aria-hidden; only the
18
- <use> references paint. Each <symbol> carries its own viewBox + brand fills, so
19
- a <use> scales into whatever box the outer <svg> sizes. Marks are geometric
20
- interpretations of the official brand icons (Phantom purple ghost, Solflare
21
- yellow S, Backpack red pack); the letter tile stays the fallback for any wallet
22
- not in this set.
18
+ <use> references paint. Each <symbol> carries the OFFICIAL brand asset embedded
19
+ inline as a base64 PNG data-URI <image> byte-identical to the real logos each
20
+ wallet ships (matching Turf Monster's public/wallet-*.png), so no hand-drawn
21
+ approximation drifts from the brand. The letter tile stays the fallback for any
22
+ wallet not in this set.
23
23
  %>
24
24
  <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"
25
25
  style="position:absolute;width:0;height:0;overflow:hidden" data-wallet-brand-sprite>
26
- <%# Phantom — light-purple tile, cream ghost with two purple eye holes (the
27
- official ghost path; fill-rule evenodd cuts the eyes so the tile shows). %>
28
- <symbol id="se-wallet-phantom" viewBox="0 0 128 128">
29
- <rect width="128" height="128" rx="28" fill="#AB9FF2"/>
30
- <path fill="#FFFDF8" fill-rule="evenodd" d="M110.584 64.9142H99.142C99.142 41.7651 80.173 23 56.7724 23C33.6612 23 14.8716 41.3057 14.4118 64.0583C13.936 87.577 36.2456 108 60.0197 108H63.0263C83.9754 108 112.069 91.6259 116.459 71.7107C117.27 68.033 114.351 64.9142 110.584 64.9142ZM39.7689 65.9482C39.7689 69.0453 37.2384 71.5758 34.1413 71.5758C31.0442 71.5758 28.5137 69.0453 28.5137 65.9482V56.8554C28.5137 53.7583 31.0442 51.2278 34.1413 51.2278C37.2384 51.2278 39.7689 53.7583 39.7689 56.8554V65.9482ZM59.3312 65.9482C59.3312 69.0453 56.8007 71.5758 53.7036 71.5758C50.6065 71.5758 48.076 69.0453 48.076 65.9482V56.8554C48.076 53.7583 50.6065 51.2278 53.7036 51.2278C56.8007 51.2278 59.3312 53.7583 59.3312 56.8554V65.9482Z"/>
26
+ <%# Phantom — official brand mark (light-purple tile, cream ghost). %>
27
+ <symbol id="se-wallet-phantom" viewBox="0 0 460 460">
28
+ <image width="460" height="460" href="data:image/png;base64,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"/>
31
29
  </symbol>
32
30
 
33
- <%# Solflare — bright-yellow tile, bold black S crossed by a diagonal slash. %>
34
- <symbol id="se-wallet-solflare" viewBox="0 0 100 100">
35
- <rect width="100" height="100" rx="24" fill="#FFEC1F"/>
36
- <path d="M67 37c-3-7-11-11-20-11-10 0-17 5-17 13 0 7 5 11 16 14l8 2c9 2 13 5 13 11 0 8-8 13-18 13-9 0-16-3-19-10"
37
- fill="none" stroke="#131313" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
38
- <path d="M27 76 73 24" fill="none" stroke="#131313" stroke-width="8" stroke-linecap="round"/>
31
+ <%# Solflare — official brand mark (yellow tile, black blackletter S). %>
32
+ <symbol id="se-wallet-solflare" viewBox="0 0 361 361">
33
+ <image width="361" height="361" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAWkAAAFpCAYAAABee9lOAAAev0lEQVR4nOzdPWxUV97H8TOeGQyOhEmaNLC8SEkDEmRD5RQQ0uDKdhrobCKFrYIhgg5kWaQjAsxTxZHA7kyzxs2aJmCKuCKJkUiTSMEONNskGGmN38bz6JzhkmEytufl3nv+55zvp1mtnn3sy/je3/zv/7zlin+oQ0qpHQoAIM1spviHmlJKHbF9JQCAvxlssX0FAID1EdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIBghDQCCEdIAIFjO9gUAkdmnGXVnMqcmJrPq0P411XtyVR06sGb7sgCrMsU/1JRS6ojtC0GYns9n1MjtnBody6lHP//9xW73zqLq7lwlsBGqQUIaVkxNZ00wj96u/WWOwEaACGmkR1fNdyazavDKFjX3LNPUzyKwEQhCGsnT4Tw0nFfXh3Nq/kVz4VwNgQ2PEdJIzuzTjKma62lpNIvAhmcIacTPRjhXEwX2kY8KqruzYPVagAYR0oiPlHCupn170QR1V+cqgQ2XENJoXtI957gR2HAIIY3mXB/Oq8EreSfCuRoCG8IR0mjM1HRWnfqitempdJIQ2BCIkEZ9Zp9m1LmLrWribtb2pSSKwIYQhDRq53pro1HlgX20Y03taC/aviSEg5DG5mYet6hTZ1qr7q0Roq7jf1XYBPabns9n1NR0C28f8RnkqcOGBq9sUf/8ZBsBXWbiblZ91t+q3nm/TfX0blUjYzkTTqHTX+YfHNumPu0rfSaIB5U0qqJ6rl/IFba+Xz7u2fpGK+zm0JLqO7lq9bo8QCWNv7s+nKd6bkCoFbb+d+r7pXKsQn8WVNTNo5LGazpQevpa1YNpv2dupM3nCluHsA7jjdwbX1RHO+hRN4iBQ5RMTWdVT29rcDM30uZTYJ8601rTFgDt24vq/vgiG141hnYHSoODxyr6iUhGeUvkg2PbTGtp9qlbn7t+46o1oDV9X33cs9X0rVE/KumARQ+b7wtTXHDw1ZmO3Z2ras8uuRW2vmd04DYyXkFF3RDaHaFi9oZcUgM7jntm986i+uneS+dbPSkipEN0ZzJrHjbaG/JJCexqU+wapf9NuqImqGtCTzo0I2M5s9iAgHaDrlq/vLRF7Ttsr4et75m4Alq9+jfpnxfC9MQ4UEkHpJ7BHsiWVoVdyxS7RlFR14R2RygIaH/psIs2gIpzUC6Ne6breEGNjy4m+jscR0j7jhkcYYnjIF59z5y7lN4xaL0nVtWtG0up/C4HEdI+a2a6FNzXSGDbumcGzq+ogQvLqf5ORxDSviKgUa6WwLY1LZPe9IYIaR8R0NhItcCOc4pdPQjoTRHSviGgUQ8d2Ec/Kpi58wS0SIMM93uGVYSox9yzjJVZPwR07XiaPcIsDriAgK4PIe2JcxfTmzIFNKr3xCp7d9SJkPbAyFhODX2bb+pn6Opm904eHCSH+dCNIaQdN/O4JZZlu92dBfXkhwX143cvVf/nKwQ2YkVAN46Qdlg0kyMOU9OlW+HQgTV17atlAhuxIaCbQ0g7LM55rdV2JCOw0SwCunmEtKMGr2yJdardZj+LwEa9COh4sJjFQVPTWXMmYdx+e7hQ97aXM49b1OhYTt2ZzJk5t4B2c2hJ9Z1ctX0ZPmDTf9eYXe2+SGZ/39mn9d8OVNioREDHi5B2zOCVfGIV64Pvm1sIQ2CDgI4fIe2Qqels0/OhNxLnkfsEdngI6GTQk3bI3g/bEu37tm8vqj9/XUjs5yt62N4ioBNDT9oV14eTa3NE5l9kEj/klArbL/qLnYBOFiHtgOfzGdOLToOucNNCYLtNB/T98UUCOmGEtAOGhvOp7fXb7OBhowhst0QBHefBt6iOnrRwuoree3hbqhuy//HLgphdyuhhy0NAp4qetHRpVtGRO5Ny9qSmwpaFgE4flbRwb7/XlnpIdx0vqPHRxVR/Z72osNNHQFtBJS3ZyFgu9YDWJu5mE5/l0Swq7HRFp6kQ0OkjpAWzedLK0DfpzCaJA4GdLALaLtodQulKdt/hNmu/X7/aPnno9jFHtESax3mE1tHukCrN+crVzL9Ib252Uqiwm0NAy0AlLdQHx7bFul90o3S4+faaS4W9OQJajEFCWqDn8xn1zvv2Wh3l9MP6072Xti8jMQT23xHQotDukCg6b1ACXc0PXtli+zISQ0vkTV3HCwS0MHLSAK89eixnMYk2+HXeTAf0XeiB3Xti1cyPJ6BlIaQFklRJR85d2hLrftPShRbYnEcoVzhPnUNmf5f3Z5l/kTGnk4cU1BHfA5uAlo2BQ4Fa3n3L9iVsiP2DS3wYdCSgxWPgUJrn8/If9s/6W9W5i/4OJtbK9QqbgHYDIS3MjIC50bUY+jZv5nKH2P6oxrXAvnp5mYB2BE8YGvbo5xb1z0+2marahTeAtEgP7JtDS+rs6RXbl4EaEdJomq6q9x7eZuZTE9ZvKg/se+P2t39lPME9hDRiYfb6+LoU1qfOtIrf6jRt+svLdh+fgHYTIY1Y6bAevZ0zO/h93LPVLIIJvbqeedxiPgube7EQ0O5iCp4wkvbtiFPX8YLq6lxV3Z2FoFa0RQFt4/CGCAHtNDZYkkj6POlmhRLYtgOa4668QEhLZONcQ1t8DWwCGjFhMYtEIT1YE3ezZnHMO++3qZ5eP3rYBDTiREgLdGh/mA+XD4E9NZ0loBErQlqggzxgTga2vsZjBDRiRkgL5FNvNg4uBLa+Jn2NthDQ/mLgUBCz4OHSFjPPGJuTMuhoO6A57sprzO6Q4vpw3pzOHcqsjrjZCmwCGgkjpG2bedxillFLOBncF2kFNgGNFBDStjyfz5jKeejbvO1L8VpSga2/WG22pQjoYBDSNkxNZ9WpL1qdPc3DVXEFNgGNFBHSaaJ6lqPRwLYd0Ec6Cmp8ZImADgchnRaqZ7lqDWzbAc1xV0EipNMweGWL2WsZ8q0X2AQ0LCGkk/R8PqN6+lrVg+ms7UtBA6LAHhrOW519Q0AHjZBOytR0VvX0tjLvGU0hoIPHLnhJSGIPh/btRTOqj3AQ0NBYfxyzJHqX0b4MO9qL6oNj26jOAzBwfkUNXFi2fRkQgEo6Jqb/3Ls1sYA+dGBN7dlVpLIKwM2hJQIarxHSMdAB/XHPVrNbW9wqdzbr7iyYhxh+4jxCVCKkmxQFdBKj//qBrbb1pH6Ie0/wIPuGgEY1hHQTkgzoruOFDR/YWzeWCGqPENBYDyHdoCQDun17bb1ngtoPA+dXCGisi5BuQJIBrZ09vVrz3gw6qK9eZpDJZYNf582snevDeTX7lJk7eBOLWRqQ5BJhXUU/efiy7g10RsZy5lQXpue57+D+NdV7clX1naj9yxreYjFLvQavJHu8VT1VdDn9unx/fJEFLx7Qb2hfXtoi+kxHpIdKug5T01mzkjBJvz1cMPOhG8U5if6ScqYjUsXeHbXS4bf3cLKr/XQV/NO9l7H8rDuTWdOWof3hJwI7GLQ7apVG4B3tKMT2s/TD++ThS2Z/eGribtacr0hLxH+EdA10VZrEasJKu/8Rb0WkK6xbN5bUvfFFc6IH/ERg+42Q3oTp8V5M50ToaqsL46Ar9Pvji2bBxO6dvBr7jMD2DyG9iaHhvDdHXvWdXFVPflggrANRGdj6jRDuIaQ3oCuQ68P+zZIoD2vaIGHQgf1p31b19nttZnxlitOCnEFIb0BX0T7PjojmVt8bX2SAMRD6fh69XTqUYu+HbWbeP6scZSOkNzAylm4VPfPYzp/jaEfBDDD+8cuCWWLOgpgwzD3LmCXp+w63mW0O0r7fURtCeh13JrOp96Lnfrdb0exoL6qzp1fMXO3fHi6YjX/oXYfhwXSpfx1V1ww2ykFIr2NiMv2qQlKfcM+uojkd5MkPCyawqbDDEFXX77xf6l3TCrGPFYfrePu9Niv96GaXhSdNP7R3JnNqdCyX2C6AkKXreEH1/2sl1sVWqBkrDqvRFa2tAcPRsbyV31sr/QVS3hKhwvbfxN3SnjUf92wV9bYXCkK6igff27sRXRq8IbDD8uDVBmO0QdJFSFcxNW3vY5l7ljGbv7uGwA7H6O2cmRFy7iIDjGmgJ12FrX50pNGN/yWih+03fa9eu7zM8V/JYavSalrefcv2JZiVgPfHF21fRqwIbH8deTXXXvKgt6MYOKwkZWDkwXTWvE76hJaIv/T9Gp3TiHgR0oINfZt3aiCxHtUCm31E3Db/ImOO/fq4Zyu96hgR0sJ91t9qVoD5LArs++OLZmn6zaElMzcXbtJV9d7D26xtc+AbPsUKs5aXZlcz+HXeTHsKoTrZ0V40g1Djo6WNn9q30+N0ka6q//nJNm/fBNNESFeYeyrzIxm9nTOvkaFUJ/rf2dPLGY2u02+Cvo2tpC2MJ74OBw/Ifc1+9HOLqU58n5+qA1p/IRHQfhj6tvQmiMYQ0hV2tNu+gs3pm37vYT9fJQloP+k3QYK6MYS0o3SIRVtL+hLWBLTfCOrGENKOm3tWCuu333P7lA0COgwEdf1YcViFhBWHzeg6XlBdnauqu7PgxNJyAjo8N4eWWEpeG5aFV2N77444SQ/sO5NZU1n58nmjdj9+91IdOsCK002wLLwan26cymP9R8ZyYmaG6Gv5tM9uBX318rLq/5xjwmzQ96OUe1EyQrqKQ57uJyEpsPXv1tdik37lPnt6RV37qnRMmK7sCOz0zD3L0J+uAe2OKvQruK7wQnFw/5rqPbmqujtXU9nFTEpAb9QTZce+9NwbX+RorvXRk65GV5e62gxR0oFtO6DbtxfNlprdnbWHAoGdLP3mot9kUBUhvZ4Pjm0L/oGMO7AlBPT98cWmxhwI7GQMnF8xp9Pjbwjp9VwfzpttF1HSbGD7ENCVCOz4+HQaUcwI6fWE3PLYjA7sox0FE9q1hJ7tL7wkArpSFNgPvs+aAVrU7+rlZTOQizcQ0hs5dabVrJDC+nbvLJrqer3Atv0ZphHQlfQX/J3JrJqYzBHYdaA3XRUhvZGpV0fYozaVgR1iQFcisOvz75HFugZ1A0BIb+bjnq3mpAnURwek7RPXbQd0JQJ7c13HC+bAB7xGSG+Gato9uqLXD7qkgK5EYK/vj18WGED8C8vCN2MGyE6wEYwrDu5fM4fbSg5oVXFMGOc6vmlqmlgqx6dRg2uXlzlrzwE6oO+PLzpXhVUG9tXLy0EvTddvF/gLIV0D/RDdurFk+zKwAVcDupK+/rOnV8wsh3vji+pIgMulp76n/VOOkK5RdydtD6l8CehKRzsK5t/128OFoO69uWcZdscrQ0jX4drlZRMIkMPXgC63Z1fpTS6ksJ5hBedrfBJ10EGgAyHkfqEkIQR0uSisQ2iDPKDl8RohXScdCOOjiwwkCjFyO+fsuY6NitogVz0e0A7tb7oRQroBhw6UKjhfHxBXPPq5xewJsu9wm9m18PpwPqiH2wwwPnzpZVUd0t9xMyxmacLM4xZzwsncM24oSTbbT8RHtjexipsugP78lX08WHEYg+fzGbN0nK0qZQopsH0rGtb++z/blyABIR0X25sJYXO6Ouvu/Ov0dB/5VDQQ0gbLwuNy68aSWdpLn1qu+RcZ80X6ad9W9fZ7beaL9c6kX7MIohlIPkzVY650CSEdo76Tq2bfCB8HcnxTHtgt775l/fT0OEUrZF0PauZKl/ApxGzPrqL306N8NHE3a473euf9Nm8C24egBiGdmGh6VP/nHAfkmvLAdn1qH6tk3UdIJ0i/dl77ajmo5by+cX0uNqtk3UdIpyDEvRd8VBnYg1e2OBHY0SpZ1+ygXWgwBc8C/WAPfZM3S5ptHjGFeBzcv2bmYXd3rpovZKlcW/DCFDyDedI2RUcojd7OcY6iJ7qOF1TvyRWx87BdOrOTkDYIaSl0dT06ljezCnxZMRay3TtLp630n14RtUufvs/2HW6zfRk1IaQNFrNIoV+TBy4smxM5fvyuNCuEwR536S/awa/zZobIqTOtYnrX+j5zYcYRM1L+QkgLdOjAmpkVQmD7YfR2zlSvUsJ64MKK+Dn8kt4+bCOkhSOw/SElrEvnKMqeZXSISvo1QtohBLYfdFhHU/hs6T0pu+Wx+x/c1xFC2lEEttvmX5R61ns/bFNTFmZb7NlVFD1n3/dtZetBSHuAwHbX3LOMOtazVZ27uCX1vUK6OgWHNO2O15iC57GZxy1qaDjPPteOOLh/zaxMTbOKfPu9NnELqvTn8NO9l7YvQwqm4PlMv9LqoIYbHv3cYhabpLnHtcRFN7Q63sQT7CmfTugIia5qP+0rbZWaBol7n0u8Jpt4gj1EQLvvs/7WVIL66EfyAlHiNdnEU+wZAtofaQT1nl1FUYPMB/evid6kygaeZI/MPG4hoD2TSlD/Q04PuPek3BkntvA0e4KA9pcO6iQHgI92yAnpbsHTAm3hifZAFNDSplIhPj29WxObR717l4yQptVRHSHtOAkBrR8u6Rv2uG7uWcbs+ZGEPUKWYNPqqI6QdpiEgO49sWoWHvz564L698ii+e8EdjIm7matLCFPS5/gZeo2EdKOkhDQN4eWzAq5SHdnwfx3Ajs5p76Iv5qWsARb3ytsT1odIe0gXU1JCOi+DV5PCexkzD3LxD7bQ0I40upYH3t3OEY/oJ/1J9ObrNVmAb0R/QUz8Z+sujPJMWGN2r2zaDbTilPLu2/F+vPqcaSjoO6Pu3eaeUrYu8Mlrge0MtO9CuzY1yT95Zbm/h5J6z8te29r2whpR/gQ0JXYYrVxo2N525cQC/33lrjJkySEtANsB3T79qLpK8cZ0JUI7PpM3M3GNm867X2syw1cWLb2u11BSAsnIaDvjy+mWu0Q2LWJq+UxY2mV6pGOQqJf/L4gpAW7PpwXEdA29/etDOyB8ysc9//KA8fnTA9coBddC0JaqFNnWtWXl+wdVCohoCvpa9Gvxz/de6l+e7igrl5eDjqw46qkZ39Pv93RdbxgBpGxOUJaIB3QNo+8khjQlfbsKqqzp1eCDuz5Fxk1+7T5gJ17mn4MXPtqKfXf6SpCWhjbAb17p/yArhRyYMexO17aR6wNnF9hI6U6ENKC2A7o6ABQlwK6UmiB/ehx8y2POKrxWukigBkd9SGkhZAQ0LqClrBEOC6Vga0rON9micRRBae5B/mt/6PNUS9C2rLn8xkCOgU6sHUF9+SH0l4ivhx2+vxFc///ae6q1//5CoOFDSCkLYrOIySg09XdWdorQlfXvY5vjzn7e3OP8MR/0gnpUpuDKXeNIKQtkXBgbIgBXU5X17duLDkd1s1uUpVWJT0+Gu591ixC2gIJAa1DKeSALheF9Y/fvfSmDVKL2aeZVO7BgfMrTg9G20ZIp0xKQOtQIqDfpINEf3FdvbwcxN7XdyaTb7PpLz1mczSHkE6RhIBWr1aqnTrT6tV2l3GKZoT4PHVPG/om2Z309Bfd+AizOZpFSKdESkCrVyvVRm/n1Kd9W9XeD9vUuYtbUl/QIN2eXUUT1K72qjczNZ1N/NAF2mnx4MlMgQ7AvYe3iQjoSvpBHfo2r/75yTb1wbFtZlOnNBc3SHfrxpKXQT14Jdkq+ubQEn3omHB8VsIkHBjbiCMdBRNO3Z0FqiEBi402svbf/9X1v9dfwvsOtyV2PdGYB2LB8VlJcjWg1attMD/rb1XvvN9G/9qzivrcxeS2vz24f42AjhkhnRCXA7pS1L9++72w+9fXPNgHxBwEfDeZL9xo3j3iFebTljCfArqc/vdE/eu9H7YF17/e0V50vkpMqhfdvr3ItM6EENIx8zWgK809y5hDCfYdbjP/3pGxnNWz8tJy6MCa2YNCinoqe/03SuI0Fxf2H3cZIR0j/RDoKtP3gK5U3r/u6d3qff964MKKmMUutVau+gv0XEIn/Vy7vExAJ4iQjontA2OlmLibfd2/PnWm1cv+tQ7Gs6fdGkTUf4skioebQ0scJpsw/54gCwjov4sWzET968ErW7zqX/eelNHyONqxeQWr32ySGCwkoNNBSDeJgN7c3LOMGvw6b/rXHxzb5kX/es+uojlMVTr9xair6LgNnF8hoFNCSDeBgK7fo59bvOlfd3XaD6mDBzb+otCfcdxtjt4Tq2yalCJCukEEdPMq+9dpnhISh6Mf2a+kd7Sv/387d3FL7FsRsJowfSwLb4DkJcKu272zqLo7V1X/v9w4UXrvh22Jb1S0kfWWhCdRRBDQVrAsvF4EdLKiDZ+i/vX14bzo/rXNqWfrTQOcedwS+3Q7AtoeQroOBHS69Kv6l5e2vO5f6+pQGpshXe13R1vixtmHJqDtIqRrREDbNXG3tGBGWv96s4G7JFW2g5II6JtDSwS0ZYR0DQhoOaL518d6ZBxYsNHAXdIqQ/rcpXgHCpkHLQMhvQkCWi4JBxbs2WWv3VFexcd9nxLQchDS64heHQloN0T9632H/+pfpzHgaHMGSlTF6y+nuO7T9u1F9e+RRQJaEKbgVSHpPEI0TgdOd2fBLDrR/5mUlnffSuxnb2Ttv/+Ldaodu9mJxBS8SgS0P3w+cFcHapwBvXsnAS0VlXQZAjoMOpD6/7ViFs3E0a6wUUnrf0Nci2iiE1XYsF8kKukIAR0OHw4siCugj3QUCGjhSCQCOmghH7jbe2KVgHZA8Kk087jFTN8ioBHSgbtXLy+zSMUR/t6FNYjOI7S5QQ7kqefAXdfaJNEUu7OnZRxagM0FG9KhHBiL5pT3r6sdWDDj0BtYNIMjyemIiJ87d1iMCGg0wuUDCw7uX1M/3XvJFDsHBTcFj4BGaNjFzmlhTcEjoBEaBgjdF0xIj4zlCGgEo317Ud0bZ4DQB0GEdLR8Ns2A1g/JzaEl86q53gkaQBKi/vPRDgYIfeB9SNs4MDYaRe87WeoF/vnrgpn2pAMbSFK0QMWF8yFRG68HDm0E9Gb7IDyfz5hZAROTOXPaCBAX9oD20qC3IS0xoCvpwB65nVOjYzlWPKJh+s1tfJQd7Dzl5+wOGwHddbz+jWr0//bs6RXTP/zt4YIZidcPHFCrIx0F5j97zrtKevZpxqwOS1Pc81BnHreY6vrOZI4l61jXwPkVNXBh2fZlIFl+tjvSrKSTXigQ9a/1fzJ9EOrVzKHx0SVmb4TBz3ZH38lVU2UkLY2FAt2dhTdmiHQd58EM2ZGOgnrykOl1IfGyko4kedK3zZF0ZoiEifZGkPxsd5RLIqglTXWafZoxvWtmiPiL9kbQ/A9pFWNQSz9NWQf20Dd5Bhw9cqSjoMZHljg9JVx+9qQr3bqxZG72ZkgPaG3PrqK69tWyevLDgvrxu5eq//MVlqQ7bOD8Csdbwe+edLlmzjF0fbEAM0Tcou+3W/9HewNGGO2OSCNB7dtx9yNjOQYcBes6XprN48v9hqaF0e6I6BtfB64O3lr4FtDq1fRE/Vbwxy8LZgC01s8CyWrfXjRTOvXfxqf7Dc0LqpKOzD7NmPPqNnr1D6miYYaIXfqLUt9rrrbTkKiwKunInl2linq9QbXeE6tBVTT684j2EIkGHNlDJB36s2bvDWwkyEo6Uu04Lc6D+4v+fIaG8ww4JoC5z6hRmJV0RFcv5RX1zaElArqM/nw4tCB+XcdZ2o3aBV1JR3Sl+Hw+I2YVoWQsSW+cLgYGLqxw7iDqEdYUPMSLQwtqd3D/mhnn4Fgr1Cnsdgeaw6EFtRk4X/qMCGg0gkoasePQghLXV6pCBCppxE+HUrSHSDTgGNoeIkytQ1yopJGaO5NZNTqW93rAkX03EDMGDpE+X2eIhLRKFakhpGGXD0vS27cXTTh3d1I9I3aENORw8dACqmckjIFDyOHSoQXsWoe0UElDPGmHFhzpKFXPzHtGCmh3wC02Dy1gWTcsIKThpmiGyNBwPpUBR6pnWEJPGm7a0V40G2KVL0lP4pSZqPd8f5x9N2AHlTS8EueSdKpnCEAlDb+UL0n/8buXDS1Jp3qGJFTSCEKtM0SoniEMA4cIy3pL0pm5AaEIaYQrOrTgwfdZde0rqmeIREgDgGAMHAKAZIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAhGSAOAYIQ0AAj2/wEAAP//QUTFn+E20QkAAAAASUVORK5CYII="/>
39
34
  </symbol>
40
35
 
41
- <%# Backpack — red tile, white pack: handle arch, rounded body with a red buckle
42
- ring, and a rounded lower pocket band. %>
43
- <symbol id="se-wallet-backpack" viewBox="0 0 100 100">
44
- <rect width="100" height="100" rx="24" fill="#E33E3F"/>
45
- <path d="M39 32v-3a11 11 0 0 1 22 0v3" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round"/>
46
- <rect x="23" y="31" width="54" height="31" rx="15" fill="#fff"/>
47
- <circle cx="50" cy="46" r="8" fill="#E33E3F"/>
48
- <circle cx="50" cy="46" r="3.5" fill="#fff"/>
49
- <rect x="23" y="66" width="54" height="13" rx="6.5" fill="#fff"/>
36
+ <%# Backpack — official brand mark (red tile, white pack). %>
37
+ <symbol id="se-wallet-backpack" viewBox="0 0 128 128">
38
+ <image width="128" height="128" href="data:image/png;base64,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"/>
50
39
  </symbol>
51
40
  </svg>
@@ -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,11 @@
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
+
240
258
  <%# ---- The overlay: one backdrop + card; every specimen's real content is a
241
259
  single-root <template x-if> gated on the current modal id. Faithful to the
242
260
  shared host — backdrop fade, scroll lock, escape + click-self dismissal
@@ -257,6 +275,14 @@
257
275
  <div><%= render "style/modals/auth" %></div>
258
276
  </template>
259
277
 
278
+ <%# --- Eligibility + entry --- %>
279
+ <template x-if="$store.dsModals.current().id === 'age-verify'">
280
+ <div><%= render "style/modals/age_verify" %></div>
281
+ </template>
282
+ <template x-if="$store.dsModals.current().id === 'entry-tokens'">
283
+ <div><%= render "style/modals/entry_tokens" %></div>
284
+ </template>
285
+
260
286
  <%# --- Profile (real engine partials, opened on dsModals) --- %>
261
287
  <template x-if="$store.dsModals.current().id === 'crop-photo'">
262
288
  <div><%= render "studio/modals/crop_photo", store: "dsModals" %></div>
@@ -432,6 +458,68 @@
432
458
  </div>
433
459
  </section>
434
460
 
461
+ <%# ===================================================================== %>
462
+ <%# 1b. ELIGIBILITY + ENTRY %>
463
+ <%# ===================================================================== %>
464
+ <section class="space-y-5">
465
+ <div class="space-y-1">
466
+ <div class="flex flex-wrap items-center gap-3">
467
+ <h3 class="text-xl font-bold text-heading">Eligibility &amp; entry</h3>
468
+ <% if age_gate_on %>
469
+ <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">age gate on</span>
470
+ <% else %>
471
+ <span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">age gate off</span>
472
+ <% end %>
473
+ </div>
474
+ <p class="text-muted text-sm">
475
+ The pre-entry gate and the entry-purchase flow. The <strong>age gate</strong>
476
+ (<code class="font-mono text-2xs">studio/modals/blocks/_age_verify</code>) is a
477
+ NEW engine primitive gated by
478
+ <code class="font-mono text-2xs">Studio.feature?(:age_gate)</code> &mdash; a DOB
479
+ modal whose minimum age, jurisdiction label, endpoint, and legal copy are all
480
+ <strong>app-supplied</strong> (the engine hardcodes no policy). It is the heavier
481
+ sibling of the <code class="font-mono text-2xs">shared/_age_attestation</code>
482
+ checkbox inside Sign in above &mdash; <strong>run one or the other</strong>. The
483
+ <strong>entry-tokens</strong> flow is <strong>app-specific</strong> (payments
484
+ product): documented here composing engine chrome, but the packs, rails, and
485
+ the on-chain mint stay app-owned.
486
+ </p>
487
+ </div>
488
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
489
+ <%# Age gate — engine primitive, gated on :age_gate (disabled-but-present). %>
490
+ <%= render layout: "style/modal_specimen", locals: {
491
+ label: "Age gate (DOB)",
492
+ 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')),
493
+ open_expr: "$store.dsModals.open('age-verify')",
494
+ glow_when: ds_glow.call("age-verify"),
495
+ disabled: !age_gate_on, openable: true, disabled_label: "age gate off" } do %>
496
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2 text-center">
497
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
498
+ <div class="grid grid-cols-3 gap-1">
499
+ <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
500
+ <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
501
+ <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
502
+ </div>
503
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
504
+ </div>
505
+ <% end %>
506
+
507
+ <%# Entry tokens — app-specific flow, documented (not capability-gated). %>
508
+ <%= render layout: "style/modal_specimen", locals: {
509
+ label: "Entry tokens",
510
+ 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' })),
511
+ open_expr: "$store.dsModals.open('entry-tokens', { step: 'picker' })",
512
+ glow_when: ds_glow.call("entry-tokens") } do %>
513
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
514
+ <span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
515
+ <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
516
+ <span class="block h-6 w-full rounded border" style="border-color: var(--color-primary)"></span>
517
+ <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
518
+ </div>
519
+ <% end %>
520
+ </div>
521
+ </section>
522
+
435
523
  <%# ===================================================================== %>
436
524
  <%# 2. PROFILE — crop / upload, live via cropper_assets %>
437
525
  <%# ===================================================================== %>
@@ -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>
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.20.0"
2
+ VERSION = "0.21.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.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -243,6 +243,7 @@ files:
243
243
  - app/views/schema/index.html.erb
244
244
  - app/views/sessions/_sso_continue.html.erb
245
245
  - app/views/sessions/new.html.erb
246
+ - app/views/studio/_age_verify_assets.html.erb
246
247
  - app/views/studio/_confirm_interstitial.html.erb
247
248
  - app/views/studio/_cropper_assets.html.erb
248
249
  - app/views/studio/admin_models/_arenas_table.html.erb
@@ -263,6 +264,7 @@ files:
263
264
  - app/views/studio/modals/_image_upload.html.erb
264
265
  - app/views/studio/modals/_saving.html.erb
265
266
  - app/views/studio/modals/auth/_resend_footer.html.erb
267
+ - app/views/studio/modals/blocks/_age_verify.html.erb
266
268
  - app/views/studio/modals/blocks/_card_header.html.erb
267
269
  - app/views/studio/modals/blocks/_cta_redirect.html.erb
268
270
  - app/views/studio/modals/blocks/_digit_reel.html.erb
@@ -293,7 +295,9 @@ files:
293
295
  - app/views/style/_theme.html.erb
294
296
  - app/views/style/_tricks.html.erb
295
297
  - app/views/style/index.html.erb
298
+ - app/views/style/modals/_age_verify.html.erb
296
299
  - app/views/style/modals/_auth.html.erb
300
+ - app/views/style/modals/_entry_tokens.html.erb
297
301
  - app/views/style/modals/_onchain_tx.html.erb
298
302
  - app/views/style/modals/_wallet_connect.html.erb
299
303
  - app/views/style/modals/_wallet_deposit.html.erb