studio-engine 0.60.0 → 0.60.1

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.
@@ -0,0 +1,21 @@
1
+ <%#
2
+ Living style guide specimen for the engine AGE GATE refusal card
3
+ (studio/modals/blocks/_age_gate). New 2026-08-24.
4
+
5
+ It is reached two ways, and both are worth seeing:
6
+ - directly, from its own card here
7
+ - as the HANDOFF from the birthday card's refused branch, which is how a
8
+ real person meets it
9
+
10
+ Demo values only. watch_url points at an anchor rather than a route because the
11
+ engine has no idea what a contest is — the target is app-supplied, and a host
12
+ passes its own contest path. The back link swaps to 'birthday', the id this
13
+ page registers for the DOB card, so the round trip works on this page exactly
14
+ as it does in an app.
15
+ %>
16
+ <%= render "studio/modals/blocks/age_gate",
17
+ min_age: 21,
18
+ state: "CA",
19
+ watch_url: "#demo-watch-contest",
20
+ birthday_modal_id: "birthday",
21
+ modal_store: "dsModals" %>
@@ -0,0 +1,59 @@
1
+ <%#
2
+ Living style guide specimen for the engine BIRTHDAY DOB card
3
+ (studio/modals/blocks/_birthday, renamed from _age_verify on 2026-08-24).
4
+ Opened on the page-scoped dsModals host.
5
+
6
+ TWO MODES, ONE CARD, chosen by props.validates — the same choice a host makes
7
+ by passing min_age or leaving it out:
8
+
9
+ validates: true the app HAS a bar. min_age + state ride in, the card says
10
+ what the rule is, and submitting takes the refused branch
11
+ so the guide can walk the handoff to the age-gate card.
12
+ validates: false the app has NO bar. No min_age at all: the card is a plain
13
+ birthday asker, which is what a hub app wants when it
14
+ collects the date for a greeting rather than for entry.
15
+
16
+ The point of the seam: every legal value here is a DEMO the app feeds. The
17
+ engine primitive hardcodes no age and no state table, which is why the same
18
+ partial serves an app whose minimum is jurisdiction-dependent and an app with
19
+ no minimum at all.
20
+ %>
21
+ <%# ABSENT `validates` MEANS VALIDATING, and the default has exactly one home
22
+ here so the two branches cannot disagree about it.
23
+
24
+ Why absent is not "no bar": the only card that can REACH the age gate is the
25
+ validating one — the no-bar card carries no min_age, so the factory's _reject
26
+ never fires. So every arrival with no props is a return trip from the gate,
27
+ and `back()` swaps with EMPTY props by design (blocks/_age_gate.html.erb).
28
+ Reading undefined as falsy sent that return trip to the NO-BAR card, where
29
+ resubmitting the SAME under-age date was accepted — the guide demonstrating an
30
+ age gate being bypassed. Caught in review, 2026-08-25.
31
+
32
+ The engine primitive is right and is NOT changed: in a real host the mode
33
+ comes from ERB locals, not from a prop, so this is a two-mode SPECIMEN's
34
+ problem alone. %>
35
+ <div x-data="{
36
+ get props() { var c = $store.dsModals.current(); return (c && c.props) || {} },
37
+ get validates() { return this.props.validates !== false; }
38
+ }">
39
+ <template x-if="validates">
40
+ <div><%= render "studio/modals/blocks/birthday",
41
+ min_age: 21,
42
+ state: "CA",
43
+ submit_url: "#demo-birthday",
44
+ modal_store: "dsModals",
45
+ demo: true,
46
+ demo_underage: true,
47
+ fine_print: "Demo policy the app supplies: 21+ in CA. The engine hardcodes no age and no state table. Submitting from here takes the REFUSED branch, so you land on the age-gate card." %></div>
48
+ </template>
49
+
50
+ <%# No min_age at all — not min_age: 0, which would be the app claiming a bar
51
+ of zero. Omitting it is how a host says "I do not gate on this". %>
52
+ <template x-if="!validates">
53
+ <div><%= render "studio/modals/blocks/birthday",
54
+ submit_url: "#demo-birthday",
55
+ modal_store: "dsModals",
56
+ demo: true,
57
+ fine_print: "No age bar on this app. The card asks for the date and nothing gates on it — the eligibility wording and the refusal handoff are both absent." %></div>
58
+ </template>
59
+ </div>
@@ -0,0 +1,36 @@
1
+ <%#
2
+ Living style guide specimen — the "we did not co-sign that" refusal.
3
+
4
+ APP-SPECIFIC FLOW, engine chrome only. What this card names is turf-monster's:
5
+ POST confirm_onchain_entry answered 422 with code `tx_rejected`, meaning the
6
+ server refused to co-sign because the submitted transaction did not match the
7
+ entry it had prepared. The engine owns no entry preparation, no co-signing and
8
+ no verdict — it lends `blocks/_card_header` and nothing else, exactly as
9
+ style/modals/_entry_tokens borrows chrome for a flow the engine does not own.
10
+
11
+ WHY THE COPY IS VAGUE, and why a reviewer should not "improve" it: good actors
12
+ never see this card. It fires on a tampered or mismatched transaction, so the
13
+ copy is deliberately reassuring and deliberately silent about WHICH
14
+ server-side check failed — naming the check would help an attacker probe the
15
+ validation. The reassurance ("nothing was submitted and your funds are safe")
16
+ is the part that matters to the one honest user who ever hits it.
17
+
18
+ Single root: the outer <div> is the host's required root.
19
+ %>
20
+ <div x-data="{}">
21
+ <%= render "studio/modals/blocks/card_header",
22
+ icon_emoji: "🛡️",
23
+ title: "Transaction not signed" do %>
24
+ <p class="text-sm text-body">
25
+ For your security we didn&rsquo;t co-sign this transaction &mdash; it
26
+ didn&rsquo;t match the entry we prepared, so nothing was submitted and your
27
+ funds are safe. Please close this and try entering again. If it keeps
28
+ happening, <span class="text-primary">contact support</span>.
29
+ </p>
30
+ <% end %>
31
+
32
+ <%# One button, because there is exactly one thing to do. The host also closes
33
+ on escape and click-outside; this card is dismissible by design. %>
34
+ <button type="button" @click="$store.dsModals.close()"
35
+ class="btn btn-primary btn-lg w-full">Got it</button>
36
+ </div>
@@ -0,0 +1,55 @@
1
+ <%#
2
+ Living style guide specimen — the off-page quest celebration.
3
+
4
+ WHY IT EXISTS ALONGSIDE "Great Username", which looks almost identical: they
5
+ are the same beat reached two different ways, and the difference is where the
6
+ user is standing. The leveling-activity celebration fires INSIDE the modal that
7
+ did the work. This card is what a host shows when the quest completed somewhere
8
+ with no quest card to advance in place — turf's /account username change — so
9
+ the celebration has to arrive on its own.
10
+
11
+ APP-SPECIFIC FLOW, engine chrome only. The engine lends `blocks/_card_header`
12
+ and `blocks/_seeds_bar` (a :leveling primitive — the bar is the engine's, the
13
+ NUMBERS are app-supplied Alpine expressions). What stays the host's: which
14
+ quests exist, what they pay, and where "Next Quest" goes.
15
+
16
+ THE FORK AT THE BOTTOM is the interesting part and the reason this is one card
17
+ with a toggle rather than two cards. A viewer who has NOT joined the newsletter
18
+ is offered it as the next quest — the celebration doubles as the funnel. One who
19
+ already has is sent to the contest, where the remaining quests live. In the real
20
+ card that gate is SERVER-rendered from current_user.subscribed_to_newsletter?,
21
+ precisely so the CTA never flashes the wrong option at someone already in.
22
+
23
+ Single root: the outer <div> is the host's required root.
24
+ %>
25
+ <div x-data="{ get props() { var c = $store.dsModals.current(); return (c && c.props) || {} } }">
26
+ <%= render "studio/modals/blocks/card_header",
27
+ size: :lg,
28
+ icon_emoji: "🌱",
29
+ title: "Great Username",
30
+ subtitle: "Nice — you earned 25 seeds by completing a quest. Complete more quests to earn a Free Entry Token." %>
31
+
32
+ <%# The engine's bar, fed demo numbers. A host passes its own live totals; the
33
+ bar decides on its own whether a level was crossed and runs the flourish. %>
34
+ <div class="mb-5">
35
+ <%= render "studio/modals/blocks/seeds_bar",
36
+ seeds_earned_key: "25",
37
+ seeds_total_key: "props.seedsTotal || 25",
38
+ free_entry_label: "Free Entry Token 🎟️" %>
39
+ </div>
40
+
41
+ <template x-if="props.subscribed">
42
+ <button type="button" @click="$store.dsModals.close()"
43
+ class="btn btn-primary btn-lg w-full">Next Quest</button>
44
+ </template>
45
+ <template x-if="!props.subscribed">
46
+ <%# Not subscribed: the celebration hands off to the newsletter quest. %>
47
+ <button type="button" @click="$store.dsModals.swap('join-newsletter', { demo: true, leveling: true })"
48
+ class="btn btn-primary btn-lg w-full">Next Quest</button>
49
+ </template>
50
+
51
+ <button type="button" @click="$store.dsModals.close()"
52
+ class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
53
+ Close
54
+ </button>
55
+ </div>
@@ -0,0 +1,52 @@
1
+ <%#
2
+ Living style guide specimen — the unsubscribe confirmation, and the handoff to
3
+ its goodbye beat.
4
+
5
+ APP-SPECIFIC FLOW, engine chrome only. The real card POSTs the host's
6
+ newsletter_unsubscribe_path and swaps to the goodbye card on success; the engine
7
+ owns no newsletter and no endpoint. Here the POST resolves locally so the guide
8
+ can walk the handoff with no backend — the SWAP is the thing worth reviewing,
9
+ and you cannot see a handoff by looking at either card alone.
10
+
11
+ THE BOTTOM LINK IS THE POINT OF THE CARD. "Close" is the never-mind out, and it
12
+ is deliberately the quiet option while the destructive action wears the primary
13
+ button. A confirmation dialog whose safe path is harder to find than its
14
+ destructive one is not a confirmation, it is a formality.
15
+
16
+ Single root: the outer <div> is the host's required root, and everything inside
17
+ the double-quoted x-data is single-quoted — an inner double quote closes the
18
+ attribute and mounts the component as a silent no-op.
19
+ %>
20
+ <div x-data="{
21
+ saving: false,
22
+ unsubscribe() {
23
+ if (this.saving) return;
24
+ this.saving = true;
25
+ var s = this;
26
+ // Demo: the real card awaits a POST. Hold briefly so the pending state
27
+ // is visible rather than skipped, then hand off exactly as it does.
28
+ setTimeout(function () {
29
+ s.saving = false;
30
+ $store.dsModals.swap('unsubscribe-goodbye');
31
+ }, 450);
32
+ }
33
+ }">
34
+ <%= render "studio/modals/blocks/card_header",
35
+ icon_emoji: "📭",
36
+ title: "Leave the newsletter?",
37
+ subtitle: "You'll stop getting sports news and contest updates. You can rejoin anytime." %>
38
+
39
+ <button type="button" @click="unsubscribe()" :disabled="saving"
40
+ class="btn btn-primary btn-lg w-full disabled:opacity-60">
41
+ <span x-show="!saving">Unsubscribe</span>
42
+ <span x-show="saving" class="inline-flex items-center justify-center gap-2" style="display: none;">
43
+ <span class="spinner" aria-hidden="true"></span>
44
+ Unsubscribing&hellip;
45
+ </span>
46
+ </button>
47
+
48
+ <button type="button" @click="$store.dsModals.close()"
49
+ class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
50
+ Close
51
+ </button>
52
+ </div>
@@ -0,0 +1,30 @@
1
+ <%#
2
+ Living style guide specimen — the final beat after a confirmed unsubscribe.
3
+
4
+ The smallest card in the guide, and it earns its place: it is the half of the
5
+ newsletter story the guide could not previously show. The Profile Leveling
6
+ section already walked the way IN (Join the Newsletter → Subscribed!); this and
7
+ its sibling walk the way OUT. A section that only ever demonstrates opt-in is
8
+ not showing the flow, it is showing the happy half of it.
9
+
10
+ APP-SPECIFIC, engine chrome only: the engine owns no newsletter. It lends
11
+ `blocks/_card_header` and the tone.
12
+
13
+ NOTE THE COPY IS NOT A GUILT TRIP — "Change your mind anytime from your
14
+ account" is a door left open, not a plea. Worth preserving if this is ever
15
+ reworded.
16
+
17
+ Single root: the outer <div> is the host's required root.
18
+ %>
19
+ <div x-data="{}">
20
+ <%= render "studio/modals/blocks/card_header",
21
+ size: :lg,
22
+ icon_emoji: "✌️",
23
+ title: "See you later",
24
+ subtitle: "You're off the list. Change your mind anytime from your account." %>
25
+
26
+ <button type="button" @click="$store.dsModals.close()"
27
+ class="btn btn-primary btn-lg w-full">
28
+ Close
29
+ </button>
30
+ </div>
@@ -0,0 +1,112 @@
1
+ <%#
2
+ Living style guide specimen — the onboarding chain's LAST step, "Set up your
3
+ wallet" (chain step 3 of 3).
4
+
5
+ This documents an APP-SPECIFIC flow. The engine deliberately does NOT own it:
6
+ turf-monster's app/views/modals/_wallet_setup.html.erb is ~576 lines of
7
+ extension-presence polling, a hosted 90-second walkthrough video, a Detailed
8
+ Guide route that resolves at render time, and a managed-wallet fallback for
9
+ grandfathered web2 accounts. None of that is shareable — it is one app's
10
+ answer to "how do we get this person a wallet".
11
+
12
+ What IS shared, and all this specimen composes, is the CHROME:
13
+ studio/modals/blocks/shell — title row + close
14
+ studio/modals/blocks/progress_pill — the 3-of-3 chain position
15
+ studio/modals/blocks/wallet_brand_sprite — inline brand marks, no per-app PNG
16
+ plus the house wallet-row idiom (brand tile, the wallet's own name, status on
17
+ the right, chevron) and pulse-cta, so a wallet reads identically here, in the
18
+ connect picker, and on the web3 step-up card.
19
+
20
+ ONE card, ONE id, a `detected` TOGGLE for the two ends of the real progression
21
+ (install -> waiting -> Installed -> connect). Deliberately not two specimens
22
+ sharing a modal id: that shape is what left the two web3-step-up cards unable
23
+ to tell each other apart in the glow, fixed in the same pass that added this.
24
+
25
+ WHO SEES IT — the half worth remembering. This card and the web3 step-up card
26
+ are OPPOSITE populations and no user can ever meet both. WalletSetupPolicy
27
+ exits at its own step 1 for an account that already holds a wallet; the step-up
28
+ card is only ever shown to accounts that DO hold one. "Get a wallet" vs "prove
29
+ the wallet you have".
30
+
31
+ Self-contained: no install detection, no video embed, no real routes. The
32
+ walkthrough is represented as a still placeholder rather than an <iframe>,
33
+ because the video is the app's asset and an embed here would reach off-host.
34
+
35
+ Single root: the outer <div> is the host's required root.
36
+ %>
37
+ <div x-data="{
38
+ get props() { var c = Alpine.store('dsModals').current(); return (c && c.props) || {}; },
39
+ get detected() { return !!this.props.detected; }
40
+ }"
41
+ class="relative">
42
+ <%= render "studio/modals/blocks/wallet_brand_sprite" %>
43
+
44
+ <%= render layout: "studio/modals/blocks/shell",
45
+ locals: { title: "Set up your wallet", modal_store: "dsModals" } do %>
46
+
47
+ <%# Chain position — the HOST's, not this page's. It is the only one of the
48
+ three onboarding steps that fills the pill, which is why the pill is here
49
+ even though the card sits in the Web3 section beside the wallet cards. %>
50
+ <div class="mb-3">
51
+ <%= render "studio/modals/blocks/progress_pill", current: 3, total: 3 %>
52
+ </div>
53
+
54
+ <p class="text-sm text-body text-center mb-4">
55
+ This app runs on the Solana public blockchain; you will need a wallet to
56
+ enter contests and collect winnings.
57
+ </p>
58
+
59
+ <%# THE wallet row — the house idiom, glowing because it is the one thing to
60
+ press. Same shape the connect picker and the step-up card use. The right
61
+ side is the only part that moves: INSTALL before the extension exists,
62
+ an Installed badge once it answers. %>
63
+ <button type="button" @click="$store.dsModals.close()"
64
+ style="--pulse-cta-color: var(--color-primary); --pulse-cta-strength: 0.4; --pulse-cta-scale: 1.02"
65
+ class="pulse-cta w-full flex items-center gap-3 p-3 rounded-xl bg-surface-alt border border-strong hover:bg-surface transition text-left">
66
+ <span class="w-9 h-9 rounded-lg overflow-hidden flex items-center justify-center shrink-0">
67
+ <svg class="w-9 h-9" aria-hidden="true"><use href="#se-wallet-phantom"></use></svg>
68
+ </span>
69
+ <span class="font-semibold text-heading">Phantom</span>
70
+ <span class="ml-auto flex items-center gap-2">
71
+ <span x-show="!detected" class="text-xs text-muted uppercase tracking-wide">Install</span>
72
+ <span x-show="detected" class="badge border-primary text-primary">Installed</span>
73
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-muted">
74
+ <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
75
+ </svg>
76
+ </span>
77
+ </button>
78
+
79
+ <%# The teaching half. A brand-new player does not know what a wallet IS, so
80
+ the card carries its own explainer rather than sending them away to find
81
+ one. Represented, not embedded — see the header. %>
82
+ <div class="mt-4 pt-4 border-t border-strong">
83
+ <h4 class="text-heading font-bold text-sm mb-1">New to Solana &#129300;</h4>
84
+ <p class="text-sm text-body mb-3">Set up your wallet in <strong>90 seconds</strong>.</p>
85
+
86
+ <div class="rounded-lg border border-subtle bg-inset aspect-video flex flex-col items-center justify-center gap-1">
87
+ <span class="text-2xl leading-none" aria-hidden="true">&#9654;&#65039;</span>
88
+ <span class="text-2xs text-muted">walkthrough &mdash; the app supplies the video</span>
89
+ </div>
90
+
91
+ <button type="button" @click="$store.dsModals.close()"
92
+ class="btn btn-primary btn-lg w-full mt-3 flex items-center justify-center gap-2">
93
+ Detailed Guide
94
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
95
+ <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
96
+ </svg>
97
+ </button>
98
+ </div>
99
+
100
+ <%# Dismissible by design, and the fine print says what dismissing costs. The
101
+ chain is skippable; the entry gate is not, and the card is honest that
102
+ the two are different things. %>
103
+ <button type="button" @click="$store.dsModals.close()"
104
+ class="block mx-auto mt-4 text-sm text-secondary hover:text-heading transition">
105
+ Maybe later
106
+ </button>
107
+ <p class="mt-2 text-center text-2xs text-muted">
108
+ Rather pay by card? Link a wallet first &mdash; an entry token needs
109
+ somewhere to land.
110
+ </p>
111
+ <% end %>
112
+ </div>
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.60.0"
2
+ VERSION = "0.60.1"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.60.0
4
+ version: 0.60.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-22 00:00:00.000000000 Z
11
+ date: 2026-08-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -401,8 +401,8 @@ files:
401
401
  - app/views/schema/index.html.erb
402
402
  - app/views/sessions/_sso_continue.html.erb
403
403
  - app/views/sessions/new.html.erb
404
- - app/views/studio/_age_verify_assets.html.erb
405
404
  - app/views/studio/_at_time_script.html.erb
405
+ - app/views/studio/_birthday_assets.html.erb
406
406
  - app/views/studio/_board_assets.html.erb
407
407
  - app/views/studio/_confirm_interstitial.html.erb
408
408
  - app/views/studio/_cropper_assets.html.erb
@@ -446,7 +446,8 @@ files:
446
446
  - app/views/studio/modals/_scoped_host.html.erb
447
447
  - app/views/studio/modals/_web3_step_up.html.erb
448
448
  - app/views/studio/modals/auth/_resend_footer.html.erb
449
- - app/views/studio/modals/blocks/_age_verify.html.erb
449
+ - app/views/studio/modals/blocks/_age_gate.html.erb
450
+ - app/views/studio/modals/blocks/_birthday.html.erb
450
451
  - app/views/studio/modals/blocks/_card_header.html.erb
451
452
  - app/views/studio/modals/blocks/_change_username.html.erb
452
453
  - app/views/studio/modals/blocks/_cta_redirect.html.erb
@@ -502,12 +503,18 @@ files:
502
503
  - app/views/style/_tricks.html.erb
503
504
  - app/views/style/board/_demo_card.html.erb
504
505
  - app/views/style/index.html.erb
505
- - app/views/style/modals/_age_verify.html.erb
506
+ - app/views/style/modals/_age_gate.html.erb
506
507
  - app/views/style/modals/_auth.html.erb
508
+ - app/views/style/modals/_birthday.html.erb
509
+ - app/views/style/modals/_cosign_rejected.html.erb
507
510
  - app/views/style/modals/_entry_tokens.html.erb
508
511
  - app/views/style/modals/_onchain_tx.html.erb
512
+ - app/views/style/modals/_quest_success.html.erb
513
+ - app/views/style/modals/_unsubscribe_confirm.html.erb
514
+ - app/views/style/modals/_unsubscribe_goodbye.html.erb
509
515
  - app/views/style/modals/_wallet_connect.html.erb
510
516
  - app/views/style/modals/_wallet_deposit.html.erb
517
+ - app/views/style/modals/_wallet_setup.html.erb
511
518
  - app/views/theme_settings/edit.html.erb
512
519
  - app/views/user_mailer/magic_link.html.erb
513
520
  - app/views/user_mailer/magic_link.text.erb
@@ -1,82 +0,0 @@
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 — the engine's one DOB field, shared with the profile
53
- page's birthday row. It lived here first and turf-monster forked a second
54
- copy; extracting it is what stops the two drifting. %>
55
- <div class="mb-4">
56
- <%= render "studio/fields/date_of_birth" %>
57
- </div>
58
-
59
- <%# Live too-young hint — shown once a full date is picked and it fails the
60
- app-supplied bar, so the user sees the problem before submitting. %>
61
- <template x-if="complete && computedAge !== null && minAge && computedAge < minAge">
62
- <p class="text-red-400 text-sm mb-3" x-text="'You must be ' + minAge + '+' + (stateCode ? ' in ' + stateCode : '') + '.'"></p>
63
- </template>
64
-
65
- <%# Server error (invalid date, or the authoritative too-young check). %>
66
- <template x-if="error">
67
- <p class="text-red-400 text-sm mb-3" x-text="error"></p>
68
- </template>
69
-
70
- <button type="button" @click="submit()"
71
- class="btn btn-primary btn-lg w-full"
72
- :disabled="!complete || submitting || (computedAge !== null && minAge && computedAge < minAge)">
73
- <span x-show="!submitting">Confirm &amp; Continue</span>
74
- <span x-show="submitting" class="inline-flex items-center justify-center gap-2" style="display: none;">
75
- <span class="cta-spinner" aria-hidden="true"></span>
76
- Verifying…
77
- </span>
78
- </button>
79
-
80
- <p class="text-[11px] text-muted text-center mt-3 leading-snug"><%= fine_print %></p>
81
- <% end %>
82
- </div>
@@ -1,17 +0,0 @@
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." %>