studio-engine 0.60.0 → 0.60.2

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.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/app/views/studio/{_age_verify_assets.html.erb → _birthday_assets.html.erb} +79 -13
  4. data/app/views/studio/_board_assets.html.erb +1 -1
  5. data/app/views/studio/_leveling_activity_assets.html.erb +2 -2
  6. data/app/views/studio/fields/_date_of_birth.html.erb +4 -3
  7. data/app/views/studio/modals/blocks/_age_gate.html.erb +225 -0
  8. data/app/views/studio/modals/blocks/_birthday.html.erb +134 -0
  9. data/app/views/studio/profiles/_birthday_fields.html.erb +1 -1
  10. data/app/views/style/_modals.html.erb +521 -114
  11. data/app/views/style/modals/_age_gate.html.erb +21 -0
  12. data/app/views/style/modals/_birthday.html.erb +59 -0
  13. data/app/views/style/modals/_cosign_rejected.html.erb +36 -0
  14. data/app/views/style/modals/_email_change_pending.html.erb +39 -0
  15. data/app/views/style/modals/_it_begins.html.erb +46 -0
  16. data/app/views/style/modals/_network_guard.html.erb +55 -0
  17. data/app/views/style/modals/_newsletter_email.html.erb +57 -0
  18. data/app/views/style/modals/_quest_success.html.erb +55 -0
  19. data/app/views/style/modals/_rate_limit_general.html.erb +61 -0
  20. data/app/views/style/modals/_unsubscribe_confirm.html.erb +52 -0
  21. data/app/views/style/modals/_unsubscribe_goodbye.html.erb +30 -0
  22. data/app/views/style/modals/_wallet_changed.html.erb +70 -0
  23. data/app/views/style/modals/_wallet_setup.html.erb +112 -0
  24. data/lib/studio/version.rb +1 -1
  25. metadata +18 -5
  26. data/app/views/studio/modals/blocks/_age_verify.html.erb +0 -82
  27. data/app/views/style/modals/_age_verify.html.erb +0 -17
@@ -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,39 @@
1
+ <%#
2
+ Living style guide specimen — the email-change handoff.
3
+
4
+ APP-SPECIFIC, engine chrome only (`blocks/_card_header`). The engine sends no
5
+ confirmation mail and holds no pending change; turf's AccountsController#update
6
+ does both.
7
+
8
+ IT IS AN INFORMATIONAL HANDOFF, NOT A CELEBRATION, and the distinction is the
9
+ card's whole job. Nothing has happened yet: the change is HELD until the person
10
+ clicks a link sent to their CURRENT address, not the new one. A card that read
11
+ as success would tell them the opposite of the truth.
12
+
13
+ Deliberately distinct from the check-email card, which says "finish entering"
14
+ and carries a resend CTA wired to the contest board. This one has no resend and
15
+ no contest wiring — one button, which closes.
16
+
17
+ Both addresses are shown because the point of confirming to the OLD address is
18
+ that someone who did not make the request finds out. Seeing both is how they
19
+ notice.
20
+
21
+ Single root: the outer <div> is the host's required root.
22
+ %>
23
+ <div x-data="{ get props() { var c = $store.dsModals.current(); return (c && c.props) || {} } }">
24
+ <%= render "studio/modals/blocks/card_header",
25
+ icon_emoji: "📬",
26
+ title: "Confirm your email change" do %>
27
+ <p class="text-sm text-body">
28
+ We sent a confirmation link to
29
+ <span class="text-heading font-medium" x-text="props.currentEmail || 'you@example.com'"></span>
30
+ (your current address). Click the link in that email to confirm changing your
31
+ email to
32
+ <span class="text-heading font-medium" x-text="props.newEmail || 'new@example.com'"></span>.
33
+ Your email won&rsquo;t change until you do &mdash; the link expires in 30 minutes.
34
+ </p>
35
+ <% end %>
36
+
37
+ <button type="button" @click="$store.dsModals.close()"
38
+ class="btn btn-primary btn-lg w-full">Got it</button>
39
+ </div>
@@ -0,0 +1,46 @@
1
+ <%#
2
+ Living style guide specimen — the moment a contest locks.
3
+
4
+ APP-SPECIFIC, engine chrome only. What fires it is turf's: a contest's lock
5
+ countdown reaching zero (contests/_timestamp_countdown). The engine owns no
6
+ contests and no lock. It lends `blocks/_cta_redirect` — the DRAINING button,
7
+ which is the whole reason this card is worth a specimen.
8
+
9
+ WHY THE DRAIN MATTERS, and why a reviewer should watch it rather than read it:
10
+ this card interrupts someone mid-page at a moment they did not choose. A
11
+ dismissible modal would leave them staring at a stale board; a hard redirect
12
+ would yank the page out from under them. The drain is the middle — it announces
13
+ what is about to happen and gives them the length of the bar to opt out. That
14
+ behaviour is a TIMER, and a timer is not visible in markup.
15
+
16
+ Shortened to 4 seconds here (production runs 8) so the drain can be watched
17
+ twice without waiting. In the app the destination rides in on props.lobbyUrl,
18
+ set by whoever opened it.
19
+
20
+ Single root: the outer <div> is the host's required root.
21
+ %>
22
+ <div class="text-center">
23
+ <div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary/15 text-3xl">
24
+ &#127967;&#65039;
25
+ </div>
26
+ <h2 class="text-2xl font-extrabold text-heading">It Begins</h2>
27
+ <p class="mt-2 text-sm text-secondary">
28
+ Entries are locked &mdash; the contest is underway. Good luck.
29
+ </p>
30
+ <div class="mt-5">
31
+ <%# NULL destination, which is the block's documented gallery mode (see
32
+ blocks/_cta_redirect's header): the drain plays, the timer fires a no-op,
33
+ and a click falls through to closing the modal — so the specimen stays
34
+ alive to be opened again.
35
+ A truthy href here is NOT a harmless demo destination. It takes the
36
+ REDIRECT path, which sets `redirecting = true` and never resets it; and
37
+ because "#modals" is a same-document fragment, no reload ever clears the
38
+ component. The card ends as a permanently dead spinner: label and drain
39
+ bar hidden, anchor pointer-events-none and aria-disabled. %>
40
+ <%= render "studio/modals/blocks/cta_redirect",
41
+ href_key: "null",
42
+ label_html: "Enter the Lobby",
43
+ duration_seconds: 4,
44
+ modal_store: "dsModals" %>
45
+ </div>
46
+ </div>
@@ -0,0 +1,55 @@
1
+ <%#
2
+ Living style guide specimen — the network confirmation before a wallet request.
3
+
4
+ APP-SPECIFIC, engine chrome only (`blocks/_card_header`). The engine knows
5
+ nothing about networks or environments; every label here is app-supplied and
6
+ only ever DISPLAYED.
7
+
8
+ THE CHECKBOX IS THE POINT, and it is not friction for its own sake. A wallet
9
+ request signed against the wrong network is the kind of mistake that looks
10
+ identical to a correct one until it is irreversible — mainnet money moved on a
11
+ devnet assumption, or the reverse. So the card does three things a plain
12
+ confirm cannot: it NAMES both the network and the environment, it makes
13
+ Continue INERT until the person has ticked an explicit acknowledgement, and it
14
+ gives Cancel equal visual weight rather than burying it.
15
+
16
+ A confirmation whose safe path is harder to reach than its risky one is a
17
+ formality. This one is deliberately not.
18
+
19
+ Single root; x-data single-quoted throughout.
20
+ %>
21
+ <div x-data="{
22
+ checked: false,
23
+ get props() { var c = $store.dsModals.current(); return (c && c.props) || {} }
24
+ }">
25
+ <%= render "studio/modals/blocks/card_header",
26
+ icon_emoji: "⚠️",
27
+ title_key: "props.title || 'Check Network'" do %>
28
+ <p class="text-sm text-body"
29
+ x-text="props.message || 'This request will be signed on the network shown below.'"></p>
30
+ <% end %>
31
+
32
+ <%# Both facts, named. The app supplies them; the engine never detects either. %>
33
+ <div class="rounded-lg border border-subtle bg-surface-alt p-3 mb-4 text-sm">
34
+ <div class="flex items-center justify-between gap-3">
35
+ <span class="text-secondary">App network</span>
36
+ <span class="font-semibold text-heading" x-text="props.networkLabel || 'devnet'"></span>
37
+ </div>
38
+ <div class="flex items-center justify-between gap-3 mt-2">
39
+ <span class="text-secondary">Environment</span>
40
+ <span class="font-semibold text-heading" x-text="props.environmentLabel || 'QA'"></span>
41
+ </div>
42
+ </div>
43
+
44
+ <label class="flex items-start gap-3 rounded-lg border border-subtle p-3 mb-4 cursor-pointer">
45
+ <input type="checkbox" x-model="checked" class="mt-1">
46
+ <span class="text-sm text-secondary">I understand this wallet request will use the network shown above.</span>
47
+ </label>
48
+
49
+ <%# Equal weight, deliberately. Cancel is not the quiet option here. %>
50
+ <div class="grid grid-cols-2 gap-3">
51
+ <button type="button" class="btn btn-secondary btn-lg" @click="$store.dsModals.close()">Cancel</button>
52
+ <button type="button" class="btn btn-primary btn-lg" :disabled="!checked"
53
+ @click="$store.dsModals.close()">Continue</button>
54
+ </div>
55
+ </div>
@@ -0,0 +1,57 @@
1
+ <%#
2
+ Living style guide specimen — web3 email capture.
3
+
4
+ APP-SPECIFIC, engine chrome only (`blocks/_card_header`). The engine owns no
5
+ newsletter and no seeds.
6
+
7
+ WHY THIS CARD EXISTS AT ALL, which is the thing worth understanding: a web3
8
+ account signed in with a wallet has NO EMAIL ON FILE. Every other newsletter
9
+ path can assume one. This is the branch that cannot, so it asks — and it asks
10
+ at the last possible moment, mid-quest, rather than up front where it would
11
+ read as a signup wall on a product that deliberately does not have one.
12
+
13
+ It hands the address BACK rather than posting it: the real card closes itself
14
+ and calls props.onSubmit, so the caller owns the POST. That keeps one subscribe
15
+ path instead of two. Here the callback resolves locally.
16
+
17
+ Single root, and everything inside the double-quoted x-data is single-quoted —
18
+ an inner double quote closes the attribute and mounts a silent no-op.
19
+ %>
20
+ <div x-data="{
21
+ email: '',
22
+ submitted: false,
23
+ get valid() { return (this.email || '').trim().indexOf('@') > 0; },
24
+ submit() {
25
+ if (!this.valid) return;
26
+ // Demo: the real card closes and hands the address to props.onSubmit.
27
+ // Showing the acknowledgement instead keeps the specimen on screen.
28
+ this.submitted = true;
29
+ }
30
+ }">
31
+ <%= render "studio/modals/blocks/card_header",
32
+ size: :md,
33
+ icon_emoji: "📬",
34
+ title: "One more thing" do %>
35
+ <p class="text-sm text-body">
36
+ Drop your email to join the list and claim your 25 seeds. We only use it for
37
+ the newsletter.
38
+ </p>
39
+ <% end %>
40
+
41
+ <template x-if="!submitted">
42
+ <div>
43
+ <input type="email" x-model="email" @keydown.enter.prevent="submit()"
44
+ placeholder="you@example.com" autocomplete="email"
45
+ class="w-full bg-inset border border-subtle rounded-lg px-3 py-2 text-sm text-heading mb-3 focus:border-primary/50 focus:outline-none">
46
+ <button type="button" @click="submit()" :disabled="!valid"
47
+ class="btn btn-primary w-full disabled:opacity-60">Join &amp; Claim 25 Seeds</button>
48
+ </div>
49
+ </template>
50
+
51
+ <template x-if="submitted">
52
+ <p class="text-sm text-center text-secondary">
53
+ Handed <span class="text-heading font-medium" x-text="email"></span> back to the
54
+ caller &mdash; the app owns the POST from here.
55
+ </p>
56
+ </template>
57
+ </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,61 @@
1
+ <%#
2
+ Living style guide specimen — the soft rate-limit wait.
3
+
4
+ APP-SPECIFIC, engine chrome only (`blocks/_card_header`). The engine throttles
5
+ nothing; turf's authedFetch 429 interceptor opens this when a general-tier cap
6
+ trips (hammering "Mint USDC", say).
7
+
8
+ THE TONE IS THE DESIGN. This is a SOFT cap on someone who did nothing wrong —
9
+ they clicked too fast. So the card is dismissible, the copy is "Easy there"
10
+ rather than an error, and it AUTO-CLOSES when the wait elapses instead of
11
+ making them acknowledge a penalty. A hard-looking card here would punish
12
+ enthusiasm.
13
+
14
+ The countdown is seeded from the 429's own Retry-After, so the number is the
15
+ server's, not a guess.
16
+
17
+ TWO THINGS THIS CARD ALREADY GETS RIGHT, worth preserving if it is ever
18
+ rewritten — both were learned the hard way on blocks/_age_gate this week:
19
+ it clears its interval in a destroy() METHOD (Alpine 3 dispatches no "destroy"
20
+ DOM event, so an @destroy listener never fires and the timer outlives every
21
+ close), and it counts whole seconds rather than dividing milliseconds.
22
+
23
+ Single root; x-data is single-quoted throughout.
24
+ %>
25
+ <div x-data="{
26
+ secondsLeft: 0,
27
+ _timer: null,
28
+ get props() { var c = $store.dsModals.current(); return (c && c.props) || {} },
29
+ get waitDisplay() {
30
+ var s = this.secondsLeft || 0;
31
+ return s > 90 ? (Math.ceil(s / 60) + ' min') : (s + 's');
32
+ },
33
+ init() {
34
+ this.secondsLeft = this.props.secondsLeft || 20;
35
+ var self = this;
36
+ this._timer = setInterval(function () {
37
+ self.secondsLeft = self.secondsLeft - 1;
38
+ if (self.secondsLeft <= 0) {
39
+ self.secondsLeft = 0;
40
+ self.stop();
41
+ $store.dsModals.close();
42
+ }
43
+ }, 1000);
44
+ },
45
+ stop() { if (this._timer) { clearInterval(this._timer); this._timer = null; } },
46
+ destroy() { this.stop(); }
47
+ }">
48
+ <%= render "studio/modals/blocks/card_header",
49
+ icon_emoji: "✋",
50
+ title: "Easy there" do %>
51
+ <p class="text-sm text-body">
52
+ <span x-text="props.message || 'You are going a bit fast.'"></span>
53
+ <span class="block mt-1">
54
+ Try again in <span class="text-heading font-semibold" x-text="waitDisplay"></span>.
55
+ </span>
56
+ </p>
57
+ <% end %>
58
+
59
+ <button type="button" @click="$store.dsModals.close()"
60
+ class="block mx-auto mt-1 text-sm text-secondary hover:text-heading transition">Dismiss</button>
61
+ </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,70 @@
1
+ <%#
2
+ Living style guide specimen — the wallet switched underneath the session.
3
+
4
+ APP-SPECIFIC, engine chrome only (`blocks/_card_header`). The switch itself is
5
+ the host's: turf's wallet store owns continueSwitch, and the engine owns no
6
+ wallet.
7
+
8
+ THE SITUATION IT NAMES: the person changed accounts inside their wallet
9
+ extension while signed in here. Nothing is broken and nobody did anything
10
+ wrong — but the session and the extension now disagree about who this is, and
11
+ every on-chain action from here would use an identity the page is not showing.
12
+
13
+ SO IT SHOWS BOTH ADDRESSES, truncated the way every Solana UI truncates them
14
+ (4 leading, 4 trailing). Recognising your own address is the only way to tell a
15
+ deliberate switch from an accident, and it is the one check the app cannot make
16
+ for them.
17
+
18
+ The busy state matters: continuing re-derives a session against the new wallet,
19
+ which is a round trip. Without it the button looks dead and gets double-clicked.
20
+
21
+ Single root; x-data single-quoted throughout.
22
+ %>
23
+ <div x-data="{
24
+ busy: false,
25
+ get props() { var c = $store.dsModals.current(); return (c && c.props) || {} },
26
+ short(addr) {
27
+ addr = addr || '';
28
+ return addr.length > 12 ? (addr.slice(0, 6) + '...' + addr.slice(-4)) : addr;
29
+ },
30
+ go() {
31
+ if (this.busy) return;
32
+ this.busy = true;
33
+ var s = this;
34
+ // Demo: the real card awaits the host's wallet store. Hold briefly so
35
+ // the pending state is visible rather than skipped.
36
+ setTimeout(function () { s.busy = false; $store.dsModals.close(); }, 600);
37
+ }
38
+ }">
39
+ <%= render "studio/modals/blocks/card_header",
40
+ icon_emoji: "🔀",
41
+ title: "Wallet changed" do %>
42
+ <p class="text-sm text-body">
43
+ Your wallet is now on a different account than the one you signed in with.
44
+ Continue with the new one, or switch back in your wallet.
45
+ </p>
46
+ <% end %>
47
+
48
+ <div class="rounded-lg border border-subtle bg-surface-alt p-3 mb-4 text-sm">
49
+ <div class="flex items-center justify-between gap-3">
50
+ <span class="text-secondary">Signed in as</span>
51
+ <span class="font-mono text-heading" x-text="short(props.currentAddress) || '7xKp...JZ2Q'"></span>
52
+ </div>
53
+ <div class="flex items-center justify-between gap-3 mt-2">
54
+ <span class="text-secondary">Wallet now</span>
55
+ <span class="font-mono text-heading" x-text="short(props.newAddress) || '9mQt...Ld4V'"></span>
56
+ </div>
57
+ </div>
58
+
59
+ <button type="button" @click="go()" :disabled="busy"
60
+ class="btn btn-primary btn-lg w-full disabled:opacity-60">
61
+ <span x-show="!busy">Continue with this wallet</span>
62
+ <span x-show="busy" class="inline-flex items-center justify-center gap-2" style="display: none;">
63
+ <span class="spinner" aria-hidden="true"></span>
64
+ Switching&hellip;
65
+ </span>
66
+ </button>
67
+
68
+ <button type="button" @click="$store.dsModals.close()"
69
+ class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">Not now</button>
70
+ </div>