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
@@ -52,13 +52,24 @@
52
52
  # crop: the two crop-photo variants (:picker vs :crop), on props.cropReady.
53
53
  # celebrate: the input specimen (form) vs the "updated" (celebrate) specimen
54
54
  # of the SAME leveling modal id.
55
- ds_glow = lambda do |id, step: nil, step_default: "credentials", crop: nil, celebrate: nil, state: nil|
55
+ # provider: PRESENCE, not a value the two web3-step-up specimens differ
56
+ # only by whether a wallet BRAND is remembered, and the brand is
57
+ # a free-form string (phantom / solflare / backpack, or nil), so
58
+ # there is no fixed value to match on the way step: and state:
59
+ # match. true = the remembered-brand card, false = the picker
60
+ # fallback. Without this the pair shared one id with no
61
+ # discriminator, only the first card carried a glow_when, and
62
+ # opening the SECOND card lit the FIRST — the one bug a glow
63
+ # helper exists to prevent.
64
+ ds_glow = lambda do |id, step: nil, step_default: "credentials", crop: nil, celebrate: nil, state: nil,
65
+ provider: nil|
56
66
  cur = "$store.dsModals.current()"
57
67
  expr = "#{cur} && #{cur}.id === '#{id}'"
58
68
  expr += " && (#{cur}.props.step || '#{step_default}') === '#{step}'" if step
59
69
  expr += " && #{cur}.props.state === '#{state}'" if state
60
70
  expr += (crop == :crop ? " && !!#{cur}.props.cropReady" : " && !#{cur}.props.cropReady") unless crop.nil?
61
71
  expr += (celebrate ? " && !!#{cur}.props.celebrate" : " && !#{cur}.props.celebrate") unless celebrate.nil?
72
+ expr += (provider ? " && !!#{cur}.props.provider" : " && !#{cur}.props.provider") unless provider.nil?
62
73
  expr
63
74
  end
64
75
  %>
@@ -279,10 +290,10 @@
279
290
  defines the factory inline; no importmap change). %>
280
291
  <%= render "studio/cropper_assets" %>
281
292
 
282
- <%# ageVerifyModal factory — rendered here at page level (never inside the
293
+ <%# birthdayModal factory — rendered here at page level (never inside the
283
294
  overlay template, where a cloned script would not run) so the age-gate
284
295
  specimen opens live. A consumer app renders this in its layout the same way. %>
285
- <%= render "studio/age_verify_assets" %>
296
+ <%= render "studio/birthday_assets" %>
286
297
 
287
298
  <%# levelingActionModal factory — page level (never inside the overlay template,
288
299
  where a cloned script would not run) so the change-username + join-newsletter
@@ -309,7 +320,7 @@
309
320
  <div><%= render "style/modals/auth" %></div>
310
321
  </template>
311
322
 
312
- <%# --- Onboarding --- %>
323
+ <%# --- Onboarding step (its card now lives in Profile) --- %>
313
324
  <%# The REAL engine partial, mounted on the page-scoped host: what the
314
325
  style page shows is exactly what a consumer renders, not a copy. %>
315
326
  <template x-if="$store.dsModals.current().id === 'onboarding-first-name'">
@@ -318,8 +329,15 @@
318
329
  </template>
319
330
 
320
331
  <%# --- Eligibility + entry --- %>
321
- <template x-if="$store.dsModals.current().id === 'age-verify'">
322
- <div><%= render "style/modals/age_verify" %></div>
332
+ <%# The birthday card and the age-gate card it hands off to. TWO ids,
333
+ because they are two cards: one asks, the other answers when the
334
+ answer is no. Registered together so the handoff round-trips on this
335
+ page exactly as it does in an app. %>
336
+ <template x-if="$store.dsModals.current().id === 'birthday'">
337
+ <div><%= render "style/modals/birthday" %></div>
338
+ </template>
339
+ <template x-if="$store.dsModals.current().id === 'age-gate'">
340
+ <div><%= render "style/modals/age_gate" %></div>
323
341
  </template>
324
342
  <template x-if="$store.dsModals.current().id === 'entry-tokens'">
325
343
  <div><%= render "style/modals/entry_tokens" %></div>
@@ -336,7 +354,52 @@
336
354
  resolve_expr: "$store.dsModals.close()" %></div>
337
355
  </template>
338
356
 
357
+ <%# wallet-setup — registered here with the other cards this page opens.
358
+ Its CARD lives in the Web3 section (it is about getting a wallet); the
359
+ 3-of-3 pill it renders is the HOST's onboarding chain position, which
360
+ is not this page's business. The Onboarding section that used to own
361
+ it was folded into Profile on 2026-08-24. %>
362
+ <template x-if="$store.dsModals.current().id === 'wallet-setup'">
363
+ <div><%= render "style/modals/wallet_setup" %></div>
364
+ </template>
365
+
366
+ <%# --- Batch 3: the modals that had no card on EITHER page --- %>
367
+ <template x-if="$store.dsModals.current().id === 'it-begins'">
368
+ <div><%= render "style/modals/it_begins" %></div>
369
+ </template>
370
+ <template x-if="$store.dsModals.current().id === 'newsletter-email'">
371
+ <div><%= render "style/modals/newsletter_email" %></div>
372
+ </template>
373
+ <template x-if="$store.dsModals.current().id === 'email-change-pending'">
374
+ <div><%= render "style/modals/email_change_pending" %></div>
375
+ </template>
376
+ <template x-if="$store.dsModals.current().id === 'network-guard'">
377
+ <div><%= render "style/modals/network_guard" %></div>
378
+ </template>
379
+ <template x-if="$store.dsModals.current().id === 'rate-limit-general'">
380
+ <div><%= render "style/modals/rate_limit_general" %></div>
381
+ </template>
382
+ <template x-if="$store.dsModals.current().id === 'wallet-changed'">
383
+ <div><%= render "style/modals/wallet_changed" %></div>
384
+ </template>
385
+
386
+ <%# --- Newsletter exit + the off-page quest celebration --- %>
387
+ <template x-if="$store.dsModals.current().id === 'quest-success'">
388
+ <div><%= render "style/modals/quest_success" %></div>
389
+ </template>
390
+ <template x-if="$store.dsModals.current().id === 'unsubscribe-confirm'">
391
+ <div><%= render "style/modals/unsubscribe_confirm" %></div>
392
+ </template>
393
+ <%# Registered alongside its confirm card, because unsubscribe-confirm
394
+ SWAPS to it — a handoff to an unregistered id renders a blank shell. %>
395
+ <template x-if="$store.dsModals.current().id === 'unsubscribe-goodbye'">
396
+ <div><%= render "style/modals/unsubscribe_goodbye" %></div>
397
+ </template>
398
+
339
399
  <%# --- Web3 --- %>
400
+ <template x-if="$store.dsModals.current().id === 'cosign-rejected'">
401
+ <div><%= render "style/modals/cosign_rejected" %></div>
402
+ </template>
340
403
  <template x-if="$store.dsModals.current().id === 'wallet-connect'">
341
404
  <div><%= render "style/modals/wallet_connect" %></div>
342
405
  </template>
@@ -478,44 +541,6 @@
478
541
  </div>
479
542
  </template>
480
543
 
481
- <%# ===================================================================== %>
482
- <%# 0. ONBOARDING — the post-auth chain's shared step %>
483
- <%# ===================================================================== %>
484
- <section class="space-y-5">
485
- <div class="space-y-1">
486
- <h3 class="text-xl font-bold text-heading">Onboarding</h3>
487
- <p class="text-muted text-sm">
488
- The step every Studio app asks a brand-new account, ported from Turf
489
- Monster
490
- (<code class="font-mono text-2xs">studio/modals/onboarding/_first_name</code>).
491
- It is <strong>one step, not a flow</strong>: the host owns the SEQUENCE
492
- around it — Turf walks <strong>welcome &rarr; first name &rarr; age gate
493
- &rarr; wallet setup</strong>, while a hub app may ask nothing else — so
494
- this partial never opens another modal. It reports what remains on its
495
- <code class="font-mono text-2xs">done_event</code> and closes, and the host
496
- decides what comes next. Every endpoint and label is a local with a
497
- default, so mounting it under different routes needs no fork.
498
- <strong>Both</strong> the &times; and the "Skip for now" link SKIP (a close
499
- that silently abandons the chain is how a host ends up with a step nobody
500
- can answer again), and the field focuses itself on open.
501
- </p>
502
- </div>
503
- <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
504
- <%= render layout: "style/modal_specimen", locals: {
505
- label: "First name",
506
- reference: %(the shared onboarding first-name step (studio-engine studio/modals/onboarding/_first_name) — open with $store.dsModals.open('onboarding-first-name'). Posts to submit_path/skip_path (defaults /onboarding/first_name and /onboarding/skip_first_name) and dispatches its done_event with the steps the SERVER says remain),
507
- open_expr: "$store.dsModals.open('onboarding-first-name')",
508
- glow_when: ds_glow.call("onboarding-first-name")
509
- } do %>
510
- <p class="text-muted text-2xs">
511
- Saving or skipping dispatches
512
- <code class="font-mono">ds-onboarding-step-done</code> here rather than
513
- the app-level event, so the specimen cannot drive a real chain.
514
- </p>
515
- <% end %>
516
- </div>
517
- </section>
518
-
519
544
  <%# 1. AUTH suite %>
520
545
  <%# ===================================================================== %>
521
546
  <section class="space-y-5">
@@ -594,21 +619,128 @@
594
619
  </section>
595
620
 
596
621
  <%# ===================================================================== %>
597
- <%# 2. PROFILE — crop / upload, live via cropper_assets %>
622
+ <%# 2. PROFILE — who the account is, plus the avatar modals %>
598
623
  <%# ===================================================================== %>
624
+ <%# The Onboarding section folded in here on 2026-08-24. It held the post-auth
625
+ chain, but "onboarding" describes WHEN a card is shown, not what it is
626
+ about — and the cards are about the same subject this section already
627
+ owns: the person's name, their birthday, their picture. Grouping by
628
+ subject rather than by moment also stops the guide claiming a chain it
629
+ cannot show, now that the wallet step lives with the wallet cards. %>
599
630
  <section class="space-y-5">
600
631
  <div class="space-y-1">
601
632
  <h3 class="text-xl font-bold text-heading">Profile</h3>
602
633
  <p class="text-muted text-sm">
603
- The avatar / photo modals ship from the engine
634
+ Who the account IS &mdash; the name it goes by, the birthday that decides
635
+ eligibility, and the picture. The first three cards are asked at signup
636
+ (a host walks them post-auth), the last three are reachable forever from
637
+ the profile page; all six are engine primitives.
638
+ </p>
639
+ <p class="text-muted text-sm">
640
+ <strong>Birthday and Age gate are one flow, deliberately split into two
641
+ cards.</strong> The birthday card ASKS; when the answer is too young the
642
+ age-gate card ANSWERS, carrying a countdown to the day they qualify, a way
643
+ to watch instead, and a way back to fix a mistyped year. Until 2026-08-24
644
+ that refusal lived inside the birthday card as red text beside a disabled
645
+ button &mdash; a state with nothing to press. Open <strong>Birthday</strong>
646
+ with <strong>Age validation</strong> on and submit any date to walk the
647
+ handoff; switch the toggle off for the other mode, an app with no age bar
648
+ that just wants the date.
649
+ </p>
650
+ <p class="text-muted text-sm">
651
+ The avatar modals
604
652
  (<code class="font-mono text-2xs">studio/modals/_crop_photo</code>,
605
- <code class="font-mono text-2xs">studio/modals/_image_upload</code>) and
606
- open live here — <code class="font-mono text-2xs">studio/cropper_assets</code>
607
- is rendered above, so cropper.js loads and the modals mount on this page's
608
- host.
653
+ <code class="font-mono text-2xs">studio/modals/_image_upload</code>)
654
+ open live here &mdash;
655
+ <code class="font-mono text-2xs">studio/cropper_assets</code> is rendered
656
+ above, so cropper.js loads and the modals mount on this page's host.
609
657
  </p>
610
658
  </div>
611
659
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
660
+ <%= render layout: "style/modal_specimen", locals: {
661
+ label: "First name",
662
+ reference: %(the shared onboarding first-name step (studio-engine studio/modals/onboarding/_first_name) — open with $store.dsModals.open('onboarding-first-name'). Posts to submit_path/skip_path (defaults /onboarding/first_name and /onboarding/skip_first_name) and dispatches its done_event with the steps the SERVER says remain),
663
+ open_expr: "$store.dsModals.open('onboarding-first-name')",
664
+ glow_when: ds_glow.call("onboarding-first-name")
665
+ } do %>
666
+ <%# Every other card in this section shows the shape of the modal it
667
+ opens; this one showed a paragraph of prose, so the row read as a
668
+ missing thumbnail rather than a deliberate difference. It is a
669
+ labelled text field and a submit — draw that. %>
670
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
671
+ <span class="block h-2 w-20 rounded" style="background: var(--color-text); opacity: .18"></span>
672
+ <span class="block h-7 w-full rounded-lg border" style="border-color: var(--color-border-strong)"></span>
673
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
674
+ <%# the skip link that sits under the button, and is also a skip %>
675
+ <span class="block h-1.5 w-14 mx-auto rounded" style="background: var(--color-text); opacity: .25"></span>
676
+ </div>
677
+ <p class="text-muted text-2xs mt-3">
678
+ Saving or skipping dispatches
679
+ <code class="font-mono">ds-onboarding-step-done</code> here rather than
680
+ the app-level event, so the specimen cannot drive a real chain.
681
+ </p>
682
+ <% end %>
683
+
684
+ <%# BIRTHDAY — the ask. ONE card, two modes, because the difference between
685
+ them is a LOCAL the host sets and not a different card:
686
+
687
+ age validation ON — the app has a bar, so the card says what it is
688
+ and an under-age date hands off to the age gate.
689
+ age validation OFF — the app just wants the date. McRitchie Studio
690
+ has no gate and still wants a birthday to wish
691
+ you a happy one; the card then drops the age
692
+ line, the eligibility wording and the handoff.
693
+
694
+ It was two cards until 2026-08-25 ("Birthday" and "Birthday (underage)"),
695
+ which read as two components and hid the fact that the second was the
696
+ first with a prop flipped. The toggle is the honest shape — the same one
697
+ Profile Leveling and the Processing card use.
698
+
699
+ THE BADGE SAYS "age validation", NOT "age gate off". The old badge
700
+ reported this app's Studio.feature?(:age_gate) flag, which is the wrong
701
+ question for a card whose whole point is that the RULE is per-app:
702
+ turf-monster's is jurisdiction-dependent, a hub app's may not exist. %>
703
+ <%= render layout: "style/modal_specimen", locals: {
704
+ label: "Birthday",
705
+ reference: %(the "Birthday" DOB card (studio-engine studio/modals/blocks/_birthday, renamed from _age_verify) — an engine primitive that ASKS for a date. Toggle "Age validation" to switch the two modes a host chooses between: ON, the app supplies min_age / state and an under-age date hands off to the age-gate card; OFF, there is no bar at all and the card is a plain birthday asker (a hub app wanting the date for a greeting). The engine hardcodes no policy either way — turf-monster's minimum is jurisdiction-dependent and resolved server-side. With validation ON, submitting takes the REFUSED branch so you can walk the handoff. Open with $store.dsModals.open('birthday', { validates: true })),
706
+ card_data: "opts: { validates: true }",
707
+ toggles: [{ model: "opts.validates", label: "Age validation" }],
708
+ open_expr: "$store.dsModals.open('birthday', { validates: opts.validates })",
709
+ glow_when: ds_glow.call("birthday"),
710
+ openable: true } do %>
711
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2 text-center">
712
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
713
+ <%# month / day / year %>
714
+ <div class="grid grid-cols-3 gap-1">
715
+ <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
716
+ <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
717
+ <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
718
+ </div>
719
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
720
+ </div>
721
+ <% end %>
722
+
723
+ <%# AGE GATE — the answer, when the answer is no. %>
724
+ <%= render layout: "style/modal_specimen", locals: {
725
+ label: "Age gate",
726
+ reference: %(the "Age gate" refusal card (studio-engine studio/modals/blocks/_age_gate, NEW) — where the birthday card hands off when the date is under the app's bar. Headed "Easy, Young'un" under a teddy bear rather than a red X, because a person who will be welcome later is not an error state. The body runs a LIVE countdown to the day they become eligible, built from the date the birthday card handed across — a refusal that only says "not yet" leaves someone to guess whether that is a week or a decade. Carries the two ways out: a primary CTA to WATCH the contest instead (app-supplied watch_url) and a back link that swaps to the birthday card, because a mis-picked year is a typo and a gate with no way back turns a typo into a locked door. Displays min_age / state; computes no eligibility of its own. Open with $store.dsModals.open('age-gate', { minAge: 21, state: 'CA', dobYear: ..., dobMonth: 6, dobDay: 15 })),
727
+ open_expr: "$store.dsModals.open('age-gate', { minAge: 21, state: 'CA', dobYear: new Date().getFullYear() - 16, dobMonth: 6, dobDay: 15 })",
728
+ glow_when: ds_glow.call("age-gate"),
729
+ openable: true } do %>
730
+ <%# A teddy bear, not the red X pill — the card is not an error state and
731
+ its thumbnail should not promise one. %>
732
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2 text-center">
733
+ <div class="text-2xl leading-none">&#129528;</div>
734
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
735
+ <%# the countdown line, which is the widest thing on the real card %>
736
+ <span class="block h-1.5 w-full rounded" style="background: var(--color-text); opacity: .3"></span>
737
+ <%# primary = watch instead; secondary = go fix the date %>
738
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
739
+ <span class="block h-1.5 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .25"></span>
740
+ </div>
741
+ <% end %>
742
+
743
+
612
744
  <%# Image upload FIRST — the empty picker (crop-photo opened with no imageUrl).
613
745
  cropReady: false is the defined starting sub-state; the crop factory
614
746
  flips it true once an image is picked, moving the glow to Crop photo. %>
@@ -651,6 +783,24 @@
651
783
  <span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
652
784
  </div>
653
785
  <% end %>
786
+
787
+ <%# email-change-pending — the informational handoff after an email change.
788
+ NOT a success card: nothing has changed yet, and the confirmation goes
789
+ to the CURRENT address so an unrequested change gets noticed. %>
790
+ <%= render layout: "style/modal_specimen", locals: {
791
+ label: "Email change pending",
792
+ reference: %(the Profile "Confirm your email change" handoff (style/modals/_email_change_pending) — turf-monster's modals/_email_change_pending. Composes the engine's blocks/_card_header; the pending change and the confirmation mail are the app's (AccountsController#update). It is an INFORMATIONAL handoff, not a celebration — nothing has changed yet, and a card that read as success would tell the person the opposite of the truth. The link goes to the CURRENT address precisely so someone who did NOT request the change finds out, which is why both addresses are shown. Distinct from the check-email card: no resend, no contest wiring, one button that closes. Open with $store.dsModals.open('email-change-pending', { currentEmail: 'you@example.com', newEmail: 'new@example.com' })),
793
+ open_expr: "$store.dsModals.open('email-change-pending', { currentEmail: 'you@example.com', newEmail: 'new@example.com' })",
794
+ glow_when: ds_glow.call("email-change-pending") } do %>
795
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
796
+ <div class="text-2xl leading-none">📬</div>
797
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
798
+ <%# both addresses, which is the card's point %>
799
+ <span class="block h-1.5 w-28 mx-auto rounded" style="background: var(--color-text); opacity: .3"></span>
800
+ <span class="block h-1.5 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .3"></span>
801
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
802
+ </div>
803
+ <% end %>
654
804
  </div>
655
805
  </section>
656
806
 
@@ -770,17 +920,91 @@
770
920
  <span class="block h-1.5 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
771
921
  </div>
772
922
  <% end %>
923
+
924
+ <%# THE WAY OUT. The three cards above walk a user IN; a section that only
925
+ ever demonstrates opt-in is showing the happy half of a flow, not the
926
+ flow. These two are the exit, and they are a PAIR — the confirm card
927
+ swaps to the goodbye beat, so reviewing either alone misses the handoff.
928
+ App-specific (the engine owns no newsletter); engine chrome only. %>
929
+ <%= render layout: "style/modal_specimen", locals: {
930
+ label: "Leave the newsletter?",
931
+ reference: %(the unsubscribe CONFIRM card (style/modals/_unsubscribe_confirm) — turf-monster's modals/_unsubscribe_confirm, which POSTs newsletter_unsubscribe_path and swaps to the goodbye beat on success. Here the POST resolves locally so the handoff is walkable with no backend: press Unsubscribe and you land on "See you later". The destructive action wears the primary button and the never-mind out is the quiet Close link below it — a confirmation whose safe path is harder to find than its destructive one is a formality, not a confirmation. Open with $store.dsModals.open('unsubscribe-confirm')),
932
+ open_expr: "$store.dsModals.open('unsubscribe-confirm')",
933
+ glow_when: ds_glow.call("unsubscribe-confirm") } do %>
934
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
935
+ <div class="text-2xl leading-none">&#128237;</div>
936
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
937
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
938
+ <%# the quiet out, deliberately smaller than the destructive action %>
939
+ <span class="block h-1.5 w-12 mx-auto rounded" style="background: var(--color-text); opacity: .25"></span>
940
+ </div>
941
+ <% end %>
942
+
943
+ <%= render layout: "style/modal_specimen", locals: {
944
+ label: "See you later",
945
+ reference: %(the unsubscribe GOODBYE beat (style/modals/_unsubscribe_goodbye) — where the confirm card lands. The smallest card in the guide, and the copy is a door left open rather than a plea: "Change your mind anytime from your account". Worth preserving if it is ever reworded. Open with $store.dsModals.open('unsubscribe-goodbye')),
946
+ open_expr: "$store.dsModals.open('unsubscribe-goodbye')",
947
+ glow_when: ds_glow.call("unsubscribe-goodbye") } do %>
948
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
949
+ <div class="text-2xl leading-none">&#9996;&#65039;</div>
950
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
951
+ <span class="block h-1.5 w-28 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
952
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
953
+ </div>
954
+ <% end %>
955
+
956
+ <%# THE SAME BEAT, REACHED FROM SOMEWHERE ELSE. Great Username (two cards up)
957
+ celebrates INSIDE the modal that did the work. This one is what a host
958
+ shows when the quest completed somewhere with no card to advance in
959
+ place — turf's /account username change — so the celebration arrives on
960
+ its own. The toggle is the fork at its foot, which is the whole reason
961
+ it is one card and not two. %>
962
+ <%= render layout: "style/modal_specimen", locals: {
963
+ label: "Quest success (off-page)",
964
+ reference: %(the off-page quest celebration (style/modals/_quest_success) — turf-monster's modals/_quest_success, shown when a quest completes away from any quest card. Composes the engine's card_header AND blocks/_seeds_bar (a :leveling primitive: the bar is the engine's, the numbers are app-supplied). Toggle "Subscribed" to switch the fork at the foot: NOT subscribed offers the newsletter as the next quest, so the celebration doubles as the funnel; subscribed sends them to the contest where the remaining quests live. In the real card that gate is SERVER-rendered from current_user.subscribed_to_newsletter?, precisely so the CTA never flashes the wrong option at someone already in. Open with $store.dsModals.open('quest-success', { subscribed: false })),
965
+ card_data: "opts: { subscribed: false }",
966
+ toggles: [{ model: "opts.subscribed", label: "Subscribed" }],
967
+ open_expr: "$store.dsModals.open('quest-success', { subscribed: opts.subscribed, seedsTotal: 25 })",
968
+ glow_when: ds_glow.call("quest-success") } do %>
969
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
970
+ <div class="text-2xl leading-none">&#127793;</div>
971
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
972
+ <%# the seeds bar, which is the card's middle and the engine's %>
973
+ <span class="flex gap-1">
974
+ <span class="flex-1 h-2 rounded" style="background: var(--color-cta)"></span>
975
+ <span class="flex-1 h-2 rounded" style="background: var(--color-cta); opacity:.4"></span>
976
+ <span class="flex-1 h-2 rounded" style="background: var(--color-cta); opacity:.2"></span>
977
+ </span>
978
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
979
+ </div>
980
+ <% end %>
981
+
982
+ <%# newsletter-email — the branch a wallet-only account has to take: no
983
+ email on file, so the quest asks for one mid-flight. %>
984
+ <%= render layout: "style/modal_specimen", locals: {
985
+ label: "Newsletter email",
986
+ reference: %(the web3 newsletter email capture (style/modals/_newsletter_email) — turf-monster's modals/_newsletter_email. Composes the engine's blocks/_card_header; the newsletter and the seeds are the app's. It exists because an account signed in with a WALLET has no email on file — every other newsletter path can assume one, and this is the branch that cannot. It asks mid-quest rather than up front, where it would read as a signup wall on a product that deliberately has none. The real card hands the address BACK to props.onSubmit and closes, so the app keeps ONE subscribe path instead of two; the specimen resolves locally so it stays on screen. Open with $store.dsModals.open('newsletter-email')),
987
+ open_expr: "$store.dsModals.open('newsletter-email')",
988
+ glow_when: ds_glow.call("newsletter-email") } do %>
989
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
990
+ <div class="text-2xl leading-none">📬</div>
991
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
992
+ <%# the ask itself — an email field, which no other newsletter card has %>
993
+ <span class="block h-5 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
994
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
995
+ </div>
996
+ <% end %>
773
997
  </div>
774
998
  </section>
775
999
 
776
1000
  <%# ===================================================================== %>
777
- <%# 2. WEB3 CONTEST — the walked wallet -> on-chain flow (glow follows it). %>
1001
+ <%# 2. WEB3 — the wallet + on-chain modals, in the order a player meets them. %>
778
1002
  <%# Gated by :web3; off = disabled-but-present-yet-openable. %>
779
1003
  <%# ===================================================================== %>
780
1004
  <section class="space-y-5">
781
1005
  <div class="space-y-1">
782
1006
  <div class="flex flex-wrap items-center gap-3">
783
- <h3 class="text-xl font-bold text-heading">Web3 Contest</h3>
1007
+ <h3 class="text-xl font-bold text-heading">Web3</h3>
784
1008
  <% if web3_on %>
785
1009
  <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">enabled on this app</span>
786
1010
  <% else %>
@@ -792,10 +1016,14 @@
792
1016
  <code class="font-mono text-2xs">Studio.feature?(:web3)</code>. Web3 is
793
1017
  <strong><%= web3_on ? "on" : "off" %></strong> here, so these render
794
1018
  <%= web3_on ? "live." : "greyed and badged — but STILL openable as a preview." %>
795
- The first three cards are a <strong>walked flow</strong> &mdash;
796
- <strong>Connect Wallet &rarr; Processing on-chain transaction &rarr; On-chain
797
- success</strong> &mdash; and the live card <strong>glows</strong>, following the
798
- flow as it advances (open Connect Wallet and pick a wallet to watch it walk).
1019
+ The cards run in the order a player meets them &mdash; <strong>get a wallet,
1020
+ prove it, spend from it</strong>: Connect wallet &rarr; Setup Wallet &rarr;
1021
+ the two Sign Wallet states &rarr; Processing &rarr; success or error &rarr;
1022
+ top-up &rarr; Entry confirmed. Inside that, <strong>Connect Wallet &rarr;
1023
+ Processing on-chain transaction &rarr; On-chain success</strong> is a
1024
+ <strong>walked flow</strong> and the live card <strong>glows</strong>,
1025
+ following it as it advances (open Connect Wallet and pick a wallet to watch
1026
+ it walk).
799
1027
  The <strong>Processing</strong> card is a load modal: its
800
1028
  <strong>success/error toggle</strong> chooses whether it resolves to success
801
1029
  or the error state, after the <strong>minimum-visible duration</strong>
@@ -804,7 +1032,7 @@
804
1032
  marks inline from the engine</strong>
805
1033
  (<code class="font-mono text-2xs">studio/modals/blocks/_wallet_brand_sprite</code>),
806
1034
  so every app inherits them with no per-app icon files.
807
- The two <strong>Web3 step-up</strong> cards are a different kind of thing
1035
+ The two <strong>Sign Wallet</strong> cards are a different kind of thing
808
1036
  from the rest of this section: they render
809
1037
  <code class="font-mono text-2xs">studio/modals/_web3_step_up</code> &mdash;
810
1038
  the <strong>REAL shared partial a host renders in production</strong>,
@@ -813,28 +1041,136 @@
813
1041
  <strong>standard web3 auth button</strong>: a wallet row (brand mark, the
814
1042
  wallet&rsquo;s own name, Installed badge, chevron) rather than a filled
815
1043
  CTA, glowing via <code class="font-mono text-2xs">pulse-cta</code> because
816
- it is the one thing to press.
1044
+ it is the one thing to press. Do not confuse them with <strong>Setup
1045
+ Wallet</strong>, immediately before them: that one asks an account to
1046
+ <strong>get</strong> a wallet, these ask an account to
1047
+ <strong>prove</strong> the one it already has &mdash; opposite populations
1048
+ (<code class="font-mono text-2xs">WalletSetupPolicy</code> exits at its own
1049
+ step 1 for an account that holds a wallet), so no user ever meets both.
1050
+ They sit next to each other anyway, because the pair is easiest to keep
1051
+ straight when you can read them side by side.
817
1052
  </p>
818
1053
  </div>
1054
+ <%# ONE ordered run, read left to right as a player meets it: get a wallet,
1055
+ prove it, spend from it. This carried three labelled h4 runs for a day
1056
+ (The walk / Proving a wallet / Funding & confirmation) and they were
1057
+ removed on 2026-08-25 — the player order already groups the cards, and the
1058
+ headings were a second, competing structure over the same nine. What holds
1059
+ the order now is a test (style_page_test's WEB3_ORDER), not a label a
1060
+ reader has to trust. %>
819
1061
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
820
1062
  <%# 1. Connect wallet — the walk's entry; picking a wallet swaps to Processing. %>
821
1063
  <%= render layout: "style/modal_specimen", locals: {
822
1064
  label: "Connect wallet",
823
- reference: %(the Web3 Contest "Connect wallet" picker (studio-engine style/modals/_wallet_connect) — the walk's entry: picking a wallet runs a brief connecting state then swaps to the on-chain Processing modal. Open with $store.dsModals.open('wallet-connect')),
1065
+ reference: %(the Web3 "Connect wallet" picker (studio-engine style/modals/_wallet_connect) — the walk's entry: picking a wallet runs a brief connecting state then swaps to the on-chain Processing modal. Open with $store.dsModals.open('wallet-connect')),
824
1066
  open_expr: "$store.dsModals.open('wallet-connect')",
825
1067
  glow_when: ds_glow.call("wallet-connect"),
826
1068
  disabled: !web3_on, openable: true } do %>
1069
+ <%# A PICKER: brand tiles with names beside them, which is what this modal
1070
+ actually is. Two empty outlines said "form with two inputs" and were
1071
+ the least informative sketch in the section — the one card whose
1072
+ subject is a LIST of wallets showed no wallets at all. Three rows,
1073
+ because the picker ships Phantom / Solflare / Backpack marks inline. %>
827
1074
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
828
- <span class="block h-2 w-20 rounded" style="background: var(--color-text); opacity: .18"></span>
829
- <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
830
- <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
1075
+ <span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1076
+ <% [0.9, 0.6, 0.45].each do |tint| %>
1077
+ <span class="flex items-center gap-1.5 h-6 w-full rounded-lg border px-1.5" style="border-color: var(--color-border-strong)">
1078
+ <span class="w-3.5 h-3.5 rounded shrink-0" style="background: var(--color-primary); opacity: <%= tint %>"></span>
1079
+ <span class="block h-1.5 w-9 rounded" style="background: var(--color-text); opacity: .35"></span>
1080
+ <span class="block h-1.5 w-1.5 ml-auto rounded-full" style="background: var(--color-text); opacity: .25"></span>
1081
+ </span>
1082
+ <% end %>
1083
+ </div>
1084
+ <% end %>
1085
+
1086
+ <%# WALLET SETUP — new 2026-08-24, and moved here from Onboarding the same
1087
+ day. It is grouped with the wallet cards rather than with the signup
1088
+ chain because that is what it IS: getting a wallet. The card still
1089
+ renders a 3-of-3 pill, which is the host's chain position and not this
1090
+ section's business.
1091
+ ONE card with a "Wallet detected" toggle rather than two cards sharing a
1092
+ modal id: the two Sign Wallet cards below are what that shape costs. %>
1093
+ <%= render layout: "style/modal_specimen", locals: {
1094
+ label: "Setup Wallet",
1095
+ reference: %(the "Set up your wallet" card (style/modals/_wallet_setup) — how an account with NO wallet gets one, and the host's third onboarding step (it renders a 3-of-3 pill, which is the host's chain position, not this section's). An APP-SPECIFIC flow: the engine owns only the chrome (shell, progress_pill, wallet_brand_sprite) and the house wallet-row idiom, while extension detection, the 90-second walkthrough video, the Detailed Guide route and the managed-wallet fallback stay in the host (turf-monster's own is ~576 lines). Toggle "Wallet detected" to swap the row between INSTALL and Installed. NOTE it is the OPPOSITE population to the two Sign Wallet cards immediately below — WalletSetupPolicy exits at its own step 1 for an account that already holds a wallet, so no user ever meets both. Open with $store.dsModals.open('wallet-setup', { detected: false })),
1096
+ card_data: "opts: { detected: false }",
1097
+ toggles: [{ model: "opts.detected", label: "Wallet detected" }],
1098
+ open_expr: "$store.dsModals.open('wallet-setup', { detected: opts.detected })",
1099
+ glow_when: ds_glow.call("wallet-setup"),
1100
+ disabled: !web3_on, openable: true } do %>
1101
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
1102
+ <span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1103
+ <%# the 3-of-3 pill, filled — the whole reason this card sits here %>
1104
+ <span class="flex gap-1">
1105
+ <span class="flex-1 h-1.5 rounded" style="background: var(--color-cta)"></span>
1106
+ <span class="flex-1 h-1.5 rounded" style="background: var(--color-cta)"></span>
1107
+ <span class="flex-1 h-1.5 rounded" style="background: var(--color-cta)"></span>
1108
+ </span>
1109
+ <span class="flex items-center gap-1.5 h-7 w-full rounded-lg border px-1.5" style="border-color: var(--color-border-strong)">
1110
+ <span class="w-4 h-4 rounded shrink-0" style="background: var(--color-primary)"></span>
1111
+ <span class="block h-1.5 w-8 rounded" style="background: var(--color-text); opacity: .35"></span>
1112
+ </span>
1113
+ <%# the walkthrough placeholder, the card's second half %>
1114
+ <span class="block w-full rounded" style="height: 1.6rem; background: var(--color-text); opacity: .10"></span>
1115
+ </div>
1116
+ <% end %>
1117
+
1118
+ <%# SIGN WALLET — the web2-session-on-a-wallet-account card. NOT part of the
1119
+ walk above: that walk is one on-chain transaction, this is an AUTH state
1120
+ that precedes any of it. Two specimens because the card has exactly two
1121
+ shapes, and the second is what every wallet linked before a host recorded
1122
+ brands will see.
1123
+
1124
+ OPPOSITE POPULATION to the "Setup Wallet" card two up in this
1125
+ section, and worth stating because the two look alike: that one asks
1126
+ "get a wallet", this one asks "prove the wallet you already have".
1127
+ WalletSetupPolicy exits at its own step 1 for an account that holds a
1128
+ wallet, so no user can ever meet both. %>
1129
+ <%= render layout: "style/modal_specimen", locals: {
1130
+ label: "Sign Wallet",
1131
+ reference: %(the Web3 "Sign Wallet" card (studio/modals/_web3_step_up — the REAL shared partial, not a specimen copy) — shown to an account that holds a self-custody wallet but authenticated this session with a web2 credential. One wallet row, glowing, because it is the only thing to press. Open with $store.dsModals.open('web3-step-up', { provider: 'phantom', providerLabel: 'Phantom', walletHint: '7xKp…JZ2Q' })),
1132
+ open_expr: "$store.dsModals.open('web3-step-up', { provider: 'phantom', providerLabel: 'Phantom', walletHint: '7xKp…JZ2Q' })",
1133
+ glow_when: ds_glow.call("web3-step-up", provider: true),
1134
+ disabled: !web3_on, openable: true } do %>
1135
+ <%# A FILLED row: brand tile, the wallet's name, an Installed badge. The
1136
+ two step-up thumbnails used to differ by a single border-dashed class
1137
+ on a 20px row, which is not a difference anyone can see at thumbnail
1138
+ size — and these two cards are the pair most likely to be mistaken for
1139
+ duplicates, because they share a modal id AND a title. So the sketch
1140
+ now shows the actual distinction: this card KNOWS which wallet. %>
1141
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1142
+ <div class="text-2xl leading-none">🔐</div>
1143
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1144
+ <span class="flex items-center gap-1.5 h-7 w-full rounded-lg border px-1.5" style="border-color: var(--color-border-strong)">
1145
+ <span class="w-4 h-4 rounded shrink-0" style="background: var(--color-primary)"></span>
1146
+ <span class="block h-1.5 w-8 rounded" style="background: var(--color-text); opacity: .35"></span>
1147
+ <span class="block h-2.5 w-7 ml-auto rounded-full border" style="border-color: var(--color-primary)"></span>
1148
+ </span>
1149
+ </div>
1150
+ <% end %>
1151
+
1152
+ <%= render layout: "style/modal_specimen", locals: {
1153
+ label: "Sign Wallet (no remembered brand)",
1154
+ reference: %(the Web3 "Sign Wallet" card with NO remembered brand (studio/modals/_web3_step_up) — every wallet linked before a host recorded brands lands here, so it is a live population and not a defensive branch. The row falls back to the picker rather than dead-ending. Open with $store.dsModals.open('web3-step-up', {})),
1155
+ open_expr: "$store.dsModals.open('web3-step-up', {})",
1156
+ glow_when: ds_glow.call("web3-step-up", provider: false),
1157
+ disabled: !web3_on, openable: true } do %>
1158
+ <%# An EMPTY slot: dashed outline, no brand tile, a "?" where the wallet's
1159
+ name would be. Reads at a glance as "we do not know which wallet",
1160
+ which is the whole difference between this card and the one before it. %>
1161
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1162
+ <div class="text-2xl leading-none">🔐</div>
1163
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1164
+ <span class="flex items-center justify-center h-7 w-full rounded-lg border border-dashed" style="border-color: var(--color-border-strong)">
1165
+ <span class="text-xs font-bold text-muted leading-none">?</span>
1166
+ </span>
831
1167
  </div>
832
1168
  <% end %>
833
1169
 
834
1170
  <%# 2. Processing — the load modal; the success/error toggle picks the resolution. %>
835
1171
  <%= render layout: "style/modal_specimen", locals: {
836
1172
  label: "Processing on-chain tx",
837
- reference: %(the Web3 Contest "Processing" on-chain load modal (style/modals/_onchain_tx via $store.dsSolanaModal) — holds the spinner at least MIN_LOAD_MS, then auto-resolves to success (or the error state when the toggle is set). Open with $store.dsModals.open('onchain-tx', { state: 'processing', demoResolve: true, demoError: false })),
1173
+ reference: %(the Web3 "Processing" on-chain load modal (style/modals/_onchain_tx via $store.dsSolanaModal) — holds the spinner at least MIN_LOAD_MS, then auto-resolves to success (or the error state when the toggle is set). Open with $store.dsModals.open('onchain-tx', { state: 'processing', demoResolve: true, demoError: false })),
838
1174
  card_data: "opts: { demoError: false }",
839
1175
  toggles: [{ model: "opts.demoError", label: "Resolve to error" }],
840
1176
  open_expr: "$store.dsModals.open('onchain-tx', { state: 'processing', title: 'Confirming on-chain', message: 'Waiting for the wallet signature…', demoResolve: true, demoError: opts.demoError })",
@@ -849,7 +1185,7 @@
849
1185
  <%# 3. On-chain success — the resolved success end-state (the walk's finish). %>
850
1186
  <%= render layout: "style/modal_specimen", locals: {
851
1187
  label: "On-chain success",
852
- reference: %(the Web3 Contest "On-chain success" state (style/modals/_onchain_tx + studio/modals/blocks/_onchain_success) — the resolved success card the walk lands on. Open with $store.dsModals.open('onchain-tx', { state: 'success', txSignature: '…', successTitle: 'Mint complete' })),
1188
+ reference: %(the Web3 "On-chain success" state (style/modals/_onchain_tx + studio/modals/blocks/_onchain_success) — the resolved success card the walk lands on. Open with $store.dsModals.open('onchain-tx', { state: 'success', txSignature: '…', successTitle: 'Mint complete' })),
853
1189
  open_expr: "$store.dsModals.open('onchain-tx', { state: 'success', txSignature: '5xTrDemoSignature1234567890', successTitle: 'Mint complete', successSubtitle: 'Your on-chain action confirmed.', ctaLabel: 'View wallet', ctaHref: null })",
854
1190
  glow_when: ds_glow.call("onchain-tx", state: "success"),
855
1191
  disabled: !web3_on, openable: true } do %>
@@ -864,7 +1200,7 @@
864
1200
  <%# On-chain error — the resolved error end-state (the toggle's other branch). %>
865
1201
  <%= render layout: "style/modal_specimen", locals: {
866
1202
  label: "On-chain error",
867
- reference: %(the Web3 Contest "On-chain error" state (style/modals/_onchain_tx) — the resolved error card the Processing toggle resolves to. Open with $store.dsModals.open('onchain-tx', { state: 'error', title: "Couldn't confirm", errorMessage: 'The transaction was rejected. Give it another try.' })),
1203
+ reference: %(the Web3 "On-chain error" state (style/modals/_onchain_tx) — the resolved error card the Processing toggle resolves to. Open with $store.dsModals.open('onchain-tx', { state: 'error', title: "Couldn't confirm", errorMessage: 'The transaction was rejected. Give it another try.' })),
868
1204
  open_expr: "$store.dsModals.open('onchain-tx', { state: 'error', title: 'Could not confirm', errorMessage: 'The transaction was rejected. Give it another try.' })",
869
1205
  glow_when: ds_glow.call("onchain-tx", state: "error"),
870
1206
  disabled: !web3_on, openable: true } do %>
@@ -874,40 +1210,30 @@
874
1210
  </div>
875
1211
  <% end %>
876
1212
 
877
- <%# Web3 step-up the web2-session-on-a-wallet-account card. NOT part of
878
- the three-card walk above: that walk is one on-chain transaction, this
879
- is an AUTH state that precedes any of it. Two specimens because the
880
- card has exactly two shapes, and the second is what every wallet linked
881
- before a host recorded brands will see. %>
882
- <%= render layout: "style/modal_specimen", locals: {
883
- label: "Web3 step-up (remembered wallet)",
884
- reference: %(the Web3 Contest "Web3 step-up" card (studio/modals/_web3_step_up — the REAL shared partial, not a specimen copy) — shown to an account that holds a self-custody wallet but authenticated this session with a web2 credential. One wallet row, glowing, because it is the only thing to press. Open with $store.dsModals.open('web3-step-up', { provider: 'phantom', providerLabel: 'Phantom', walletHint: '7xKp…JZ2Q' })),
885
- open_expr: "$store.dsModals.open('web3-step-up', { provider: 'phantom', providerLabel: 'Phantom', walletHint: '7xKp…JZ2Q' })",
886
- glow_when: ds_glow.call("web3-step-up"),
887
- disabled: !web3_on, openable: true } do %>
888
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
889
- <div class="text-2xl leading-none">🔐</div>
890
- <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
891
- <span class="block h-7 w-full rounded-lg border" style="border-color: var(--color-border-strong)"></span>
892
- </div>
893
- <% end %>
894
-
1213
+ <%# THE OTHER FAILURE, and it is a different KIND of failure from the one
1214
+ above. On-chain error is the chain saying no. This is the SERVER saying
1215
+ no refusing to co-sign because the transaction it was handed did not
1216
+ match the entry it prepared. They sit together because a reviewer
1217
+ reading either alone would not know the other existed. %>
895
1218
  <%= render layout: "style/modal_specimen", locals: {
896
- label: "Web3 step-up (no remembered wallet)",
897
- reference: %(the Web3 Contest "Web3 step-up" card with NO remembered brand (studio/modals/_web3_step_up) — every wallet linked before a host recorded brands lands here, so it is a live population and not a defensive branch. The row falls back to the picker rather than dead-ending. Open with $store.dsModals.open('web3-step-up', {})),
898
- open_expr: "$store.dsModals.open('web3-step-up', {})",
1219
+ label: "Co-sign refused",
1220
+ reference: %(the "Transaction not signed" card (style/modals/_cosign_rejected) — turf-monster's modals/_cosign_rejected, opened when POST confirm_onchain_entry answers 422 with code tx_rejected: the server refused to co-sign because the submitted transaction did not match the entry it had prepared. APP-SPECIFIC — the engine owns no entry preparation, no co-signing and no verdict; it lends card_header and nothing else. THE COPY IS DELIBERATELY VAGUE and should stay that way: good actors never see this card (it fires on a tampered or mismatched transaction), so naming WHICH server-side check failed would help an attacker probe the validation. What the copy does commit to is the part the one honest user needs — nothing was submitted, the funds are safe. Open with $store.dsModals.open('cosign-rejected')),
1221
+ open_expr: "$store.dsModals.open('cosign-rejected')",
1222
+ glow_when: ds_glow.call("cosign-rejected"),
899
1223
  disabled: !web3_on, openable: true } do %>
900
1224
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
901
- <div class="text-2xl leading-none">🔐</div>
1225
+ <div class="text-2xl leading-none">&#128737;&#65039;</div>
902
1226
  <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
903
- <span class="block h-7 w-full rounded-lg border border-dashed" style="border-color: var(--color-border-strong)"></span>
1227
+ <span class="block h-1.5 w-full rounded" style="background: var(--color-text); opacity: .3"></span>
1228
+ <span class="block h-1.5 w-28 mx-auto rounded" style="background: var(--color-text); opacity: .3"></span>
1229
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
904
1230
  </div>
905
1231
  <% end %>
906
1232
 
907
1233
  <%# Wallet deposit — standalone top-up card (not part of the walk). %>
908
1234
  <%= render layout: "style/modal_specimen", locals: {
909
1235
  label: "Wallet deposit",
910
- reference: %(the Web3 Contest "Wallet deposit" top-up card (style/modals/_wallet_deposit) — open with $store.dsModals.open('wallet-deposit', { neededCents: 500, usdcCents: 120, usdtCents: 0, address: 'Fo1L5…' })),
1236
+ reference: %(the Web3 "Wallet deposit" top-up card (style/modals/_wallet_deposit) — open with $store.dsModals.open('wallet-deposit', { neededCents: 500, usdcCents: 120, usdtCents: 0, address: 'Fo1L5…' })),
911
1237
  open_expr: "$store.dsModals.open('wallet-deposit', { neededCents: 500, usdcCents: 120, usdtCents: 0, address: 'Fo1L5demoWALLETaddr9xQ2' })",
912
1238
  disabled: !web3_on, openable: true } do %>
913
1239
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
@@ -923,7 +1249,7 @@
923
1249
  Rewards below (gated by :leveling). %>
924
1250
  <%= render layout: "style/modal_specimen", locals: {
925
1251
  label: "Entry confirmed",
926
- reference: %(the Web3 Contest "Entry confirmed" celebration (studio/modals/blocks/_entry_confirmed, composed via _success_card's yield — branded tx link + heading + drain CTA; the seeds bar is gated off by :leveling and stays hidden here) — open with $store.dsModals.open('entry-confirmed', { txSignature: '…', lobbyUrl: null, seedsEarned: 0 })),
1252
+ reference: %(the Web3 "Entry confirmed" celebration (studio/modals/blocks/_entry_confirmed, composed via _success_card's yield — branded tx link + heading + drain CTA; the seeds bar is gated off by :leveling and stays hidden here) — open with $store.dsModals.open('entry-confirmed', { txSignature: '…', lobbyUrl: null, seedsEarned: 0 })),
927
1253
  open_expr: "$store.dsModals.open('entry-confirmed', { txSignature: '5xTrDemoSignature1234567890abcXYZ', lobbyUrl: null, seedsEarned: 0, seedsTotal: 40 })",
928
1254
  disabled: !web3_on, openable: true } do %>
929
1255
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
@@ -935,11 +1261,62 @@
935
1261
  <span class="block h-4 w-full rounded" style="background: var(--color-cta)"></span>
936
1262
  </div>
937
1263
  <% end %>
1264
+
1265
+ <%# network-guard — the explicit acknowledgement before a wallet request.
1266
+ Continue stays INERT until the box is ticked and Cancel carries equal
1267
+ weight: a confirmation whose safe path is harder to reach is theatre. %>
1268
+ <%= render layout: "style/modal_specimen", locals: {
1269
+ label: "Network guard",
1270
+ reference: %(the Web3 network confirmation (style/modals/_network_guard) — turf-monster's modals/_network_guard, shown before a wallet request when the app wants the network acknowledged. Composes the engine's blocks/_card_header and nothing else: the engine detects no network and no environment, so every label here is app-supplied and only ever DISPLAYED. A request signed against the wrong network looks identical to a correct one until it is irreversible, so the card NAMES both facts, keeps Continue inert until an explicit tick, and gives Cancel equal visual weight rather than burying it. Open with $store.dsModals.open('network-guard', { networkLabel: 'devnet', environmentLabel: 'QA' })),
1271
+ open_expr: "$store.dsModals.open('network-guard', { networkLabel: 'devnet', environmentLabel: 'QA' })",
1272
+ glow_when: ds_glow.call("network-guard"),
1273
+ disabled: !web3_on, openable: true } do %>
1274
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1275
+ <div class="text-2xl leading-none">⚠️</div>
1276
+ <%# the two named facts, then the tick that gates Continue %>
1277
+ <span class="block h-1.5 w-full rounded" style="background: var(--color-text); opacity: .3"></span>
1278
+ <span class="block h-1.5 w-full rounded" style="background: var(--color-text); opacity: .3"></span>
1279
+ <span class="flex items-center gap-1.5">
1280
+ <span class="block h-2.5 w-2.5 rounded-sm border" style="border-color: var(--color-border-strong)"></span>
1281
+ <span class="block h-1.5 flex-1 rounded" style="background: var(--color-text); opacity: .18"></span>
1282
+ </span>
1283
+ <%# equal weight, deliberately %>
1284
+ <span class="grid grid-cols-2 gap-1.5">
1285
+ <span class="block h-4 rounded border" style="border-color: var(--color-border-strong)"></span>
1286
+ <span class="block h-4 rounded" style="background: var(--color-cta); opacity: .5"></span>
1287
+ </span>
1288
+ </div>
1289
+ <% end %>
1290
+
1291
+ <%# wallet-changed — the extension switched accounts underneath the session.
1292
+ Both addresses are shown because recognising your own is the only way to
1293
+ tell a deliberate switch from an accident. %>
1294
+ <%= render layout: "style/modal_specimen", locals: {
1295
+ label: "Wallet changed",
1296
+ reference: %(the Web3 "Wallet changed" reconciliation (style/modals/_wallet_changed) — turf-monster's modals/_wallet_changed, fired when the person switches accounts inside their wallet extension while signed in. Composes the engine's blocks/_card_header; the switch itself belongs to turf's wallet store (continueSwitch) and the engine owns no wallet. Nothing is broken — but the session and the extension now disagree about who this is, and every on-chain action from here would use an identity the page is not showing. So it prints BOTH addresses, truncated the Solana way, because recognising your own is the one check the app cannot make for you. Continuing re-derives a session against the new wallet, which is a round trip, so the button carries a busy state rather than looking dead. Open with $store.dsModals.open('wallet-changed', { currentAddress: '…', newAddress: '…' })),
1297
+ open_expr: "$store.dsModals.open('wallet-changed', { currentAddress: '7xKpDemoSignedInWallet9JZ2Q', newAddress: '9mQtDemoSwitchedWalletLd4V' })",
1298
+ glow_when: ds_glow.call("wallet-changed"),
1299
+ disabled: !web3_on, openable: true } do %>
1300
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1301
+ <div class="text-2xl leading-none">🔀</div>
1302
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1303
+ <%# the two truncated addresses — the comparison IS the card %>
1304
+ <span class="flex items-center justify-between gap-2">
1305
+ <span class="block h-1.5 w-10 rounded" style="background: var(--color-text); opacity: .18"></span>
1306
+ <span class="block h-1.5 w-12 rounded" style="background: var(--color-text); opacity: .35"></span>
1307
+ </span>
1308
+ <span class="flex items-center justify-between gap-2">
1309
+ <span class="block h-1.5 w-10 rounded" style="background: var(--color-text); opacity: .18"></span>
1310
+ <span class="block h-1.5 w-12 rounded" style="background: var(--color-text); opacity: .35"></span>
1311
+ </span>
1312
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
1313
+ </div>
1314
+ <% end %>
938
1315
  </div>
939
1316
  </section>
940
1317
 
941
1318
  <%# ===================================================================== %>
942
- <%# 3. CONTEST ENTRY & ELIGIBILITY — moved directly under Web3 Contest. The %>
1319
+ <%# 3. CONTEST ENTRY & ELIGIBILITY — moved directly under Web3. The %>
943
1320
  <%# walked entry flow (glow follows), plus the honest web2/web3 map. %>
944
1321
  <%# ===================================================================== %>
945
1322
  <section class="space-y-5">
@@ -953,17 +1330,20 @@
953
1330
  <% end %>
954
1331
  </div>
955
1332
  <p class="text-muted text-sm">
956
- The pre-entry gate and the full entry flow. The <strong>age gate</strong>
957
- (<code class="font-mono text-2xs">studio/modals/blocks/_age_verify</code>) is an
958
- engine primitive gated by
959
- <code class="font-mono text-2xs">Studio.feature?(:age_gate)</code> &mdash; a DOB
960
- modal whose minimum age, jurisdiction label, endpoint, and legal copy are all
1333
+ The pre-entry gate and the full entry flow. The age bar is enforced HERE,
1334
+ but its two cards &mdash;
1335
+ <code class="font-mono text-2xs">studio/modals/blocks/_birthday</code> (the
1336
+ ask) and <code class="font-mono text-2xs">_age_gate</code> (the refusal)
1337
+ &mdash; are shown in the <strong>Profile</strong> section, because they are
1338
+ about who the account is. Both are engine primitives gated by
1339
+ <code class="font-mono text-2xs">Studio.feature?(:age_gate)</code>, and their
1340
+ minimum age, jurisdiction label, endpoint, and legal copy are all
961
1341
  <strong>app-supplied</strong> (the engine hardcodes no policy). The entry flow is
962
1342
  a <strong>walked sequence</strong> &mdash; <strong>Entry tokens &rarr; Payment
963
1343
  processing &rarr; Entry Tokens Minted &rarr; Contest enter processing &rarr;
964
1344
  Contest entered</strong> &mdash; and the live card <strong>glows</strong>,
965
1345
  following the step machine (the two load steps honor the same min-duration
966
- convention as Web3 Contest above).
1346
+ convention as Web3 above).
967
1347
  </p>
968
1348
  <%# The web2/web3 finding — a real divergence, stated honestly, not invented. %>
969
1349
  <div class="mt-2 rounded-lg border border-subtle bg-surface p-3 text-xs text-secondary leading-snug">
@@ -986,23 +1366,14 @@
986
1366
  </div>
987
1367
  </div>
988
1368
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
989
- <%# Age gate engine primitive, gated on :age_gate (disabled-but-present). %>
990
- <%= render layout: "style/modal_specimen", locals: {
991
- label: "Age gate (DOB)",
992
- reference: %(the "Age gate" DOB modal (studio-engine studio/modals/blocks/_age_verify) an 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')),
993
- open_expr: "$store.dsModals.open('age-verify')",
994
- glow_when: ds_glow.call("age-verify"),
995
- disabled: !age_gate_on, openable: true, disabled_label: "age gate off" } do %>
996
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2 text-center">
997
- <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
998
- <div class="grid grid-cols-3 gap-1">
999
- <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
1000
- <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
1001
- <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
1002
- </div>
1003
- <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
1004
- </div>
1005
- <% end %>
1369
+ <%# The birthday / age-gate CARDS live in the Profile section they are
1370
+ about who the account is, and grouping them by subject beats grouping
1371
+ them by the moment they happen to be asked. ENFORCEMENT still lives
1372
+ here: the bar is checked at contest entry, which is what this section is
1373
+ named for. The demo walk is unbroken — confirming the birthday card
1374
+ still advances to Entry tokens below, via the page-level 'age-verified'
1375
+ wiring, which the rename did NOT touch precisely because consuming apps
1376
+ already listen on it. %>
1006
1377
 
1007
1378
  <%# 1. Entry tokens — the picker (walk's entry). Picking a pack advances. %>
1008
1379
  <%= render layout: "style/modal_specimen", locals: {
@@ -1073,6 +1444,25 @@
1073
1444
  <span class="block h-4 w-full rounded" style="background: var(--color-cta)"></span>
1074
1445
  </div>
1075
1446
  <% end %>
1447
+
1448
+ <%# it-begins — the moment a contest locks. The DRAINING CTA is the whole
1449
+ card: it interrupts someone mid-page at a moment they did not choose,
1450
+ and the drain is what gives them a say in what happens next. %>
1451
+ <%= render layout: "style/modal_specimen", locals: {
1452
+ label: "It Begins",
1453
+ reference: %(the contest-lock announcement (style/modals/_it_begins) — turf-monster's modals/_it_begins, fired when a contest's lock countdown reaches zero. APP-SPECIFIC: the engine owns no contests and no lock. It lends blocks/_cta_redirect — the DRAINING button, which is the whole reason this card is worth a specimen and the one part you have to WATCH rather than read, because a timer is not visible in markup. This card interrupts someone mid-page at a moment they did not choose: a dismissible modal would leave them on a stale board, a hard redirect would yank the page away, and the drain is the middle — it announces what is about to happen and gives them the length of the bar to opt out. Shortened to 4 seconds here (production runs 8) so the drain can be watched twice; it redirects NOWHERE — the block's null-destination gallery mode, so the drain completes and the card stays openable instead of ending as a stuck spinner. Open with $store.dsModals.open('it-begins')),
1454
+ open_expr: "$store.dsModals.open('it-begins')",
1455
+ glow_when: ds_glow.call("it-begins") } do %>
1456
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1457
+ <div class="text-2xl leading-none">🏟️</div>
1458
+ <span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1459
+ <span class="block h-1.5 w-28 mx-auto rounded" style="background: var(--color-text); opacity: .12"></span>
1460
+ <%# the CTA mid-drain — the bar empties, and leaving is the way out %>
1461
+ <span class="block h-5 w-full rounded overflow-hidden" style="background: color-mix(in srgb, var(--color-cta) 25%, transparent)">
1462
+ <span class="block h-full rounded" style="width: 55%; background: var(--color-cta)"></span>
1463
+ </span>
1464
+ </div>
1465
+ <% end %>
1076
1466
  </div>
1077
1467
  </section>
1078
1468
 
@@ -1135,6 +1525,23 @@
1135
1525
  <span class="block h-1.5 w-16 mx-auto rounded" style="background: var(--color-text); opacity: .12"></span>
1136
1526
  </div>
1137
1527
  <% end %>
1528
+
1529
+ <%# rate-limit-general — the SOFT cap. Dismissible, apologetic, and it
1530
+ auto-closes when the wait elapses rather than making anyone
1531
+ acknowledge a penalty for clicking too fast. %>
1532
+ <%= render layout: "style/modal_specimen", locals: {
1533
+ label: "Rate limited (soft)",
1534
+ reference: %(the soft rate-limit wait (style/modals/_rate_limit_general) — turf-monster's modals/_rate_limit_general, opened by the authedFetch 429 interceptor when a general-tier cap trips. Composes the engine's blocks/_card_header; the engine throttles nothing. THE TONE IS THE DESIGN: this is a soft cap on someone who did nothing wrong, so the card is dismissible, the copy is "Easy there" rather than an error, and it AUTO-CLOSES when the wait elapses instead of demanding an acknowledgement. The countdown is seeded from the 429's own Retry-After, so the number is the server's rather than a guess. It also clears its interval in a destroy() METHOD — Alpine 3 dispatches no "destroy" DOM event, so an @destroy listener never fires and the timer outlives every close. Shortened to 10 seconds here so the auto-close can be watched. Open with $store.dsModals.open('rate-limit-general', { secondsLeft: 10 })),
1535
+ open_expr: "$store.dsModals.open('rate-limit-general', { secondsLeft: 10 })",
1536
+ glow_when: ds_glow.call("rate-limit-general") } do %>
1537
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1538
+ <div class="text-2xl leading-none">✋</div>
1539
+ <span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1540
+ <span class="block h-1.5 w-28 mx-auto rounded" style="background: var(--color-text); opacity: .12"></span>
1541
+ <%# a dismiss link, not a button — the soft cap has no penalty to accept %>
1542
+ <span class="block h-1.5 w-12 mx-auto rounded" style="background: var(--color-text); opacity: .3"></span>
1543
+ </div>
1544
+ <% end %>
1138
1545
  </div>
1139
1546
  </section>
1140
1547