studio-engine 0.60.0 → 0.60.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,32 @@
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
+ <%# --- Newsletter exit + the off-page quest celebration --- %>
367
+ <template x-if="$store.dsModals.current().id === 'quest-success'">
368
+ <div><%= render "style/modals/quest_success" %></div>
369
+ </template>
370
+ <template x-if="$store.dsModals.current().id === 'unsubscribe-confirm'">
371
+ <div><%= render "style/modals/unsubscribe_confirm" %></div>
372
+ </template>
373
+ <%# Registered alongside its confirm card, because unsubscribe-confirm
374
+ SWAPS to it — a handoff to an unregistered id renders a blank shell. %>
375
+ <template x-if="$store.dsModals.current().id === 'unsubscribe-goodbye'">
376
+ <div><%= render "style/modals/unsubscribe_goodbye" %></div>
377
+ </template>
378
+
339
379
  <%# --- Web3 --- %>
380
+ <template x-if="$store.dsModals.current().id === 'cosign-rejected'">
381
+ <div><%= render "style/modals/cosign_rejected" %></div>
382
+ </template>
340
383
  <template x-if="$store.dsModals.current().id === 'wallet-connect'">
341
384
  <div><%= render "style/modals/wallet_connect" %></div>
342
385
  </template>
@@ -478,44 +521,6 @@
478
521
  </div>
479
522
  </template>
480
523
 
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
524
  <%# 1. AUTH suite %>
520
525
  <%# ===================================================================== %>
521
526
  <section class="space-y-5">
@@ -594,21 +599,128 @@
594
599
  </section>
595
600
 
596
601
  <%# ===================================================================== %>
597
- <%# 2. PROFILE — crop / upload, live via cropper_assets %>
602
+ <%# 2. PROFILE — who the account is, plus the avatar modals %>
598
603
  <%# ===================================================================== %>
604
+ <%# The Onboarding section folded in here on 2026-08-24. It held the post-auth
605
+ chain, but "onboarding" describes WHEN a card is shown, not what it is
606
+ about — and the cards are about the same subject this section already
607
+ owns: the person's name, their birthday, their picture. Grouping by
608
+ subject rather than by moment also stops the guide claiming a chain it
609
+ cannot show, now that the wallet step lives with the wallet cards. %>
599
610
  <section class="space-y-5">
600
611
  <div class="space-y-1">
601
612
  <h3 class="text-xl font-bold text-heading">Profile</h3>
602
613
  <p class="text-muted text-sm">
603
- The avatar / photo modals ship from the engine
614
+ Who the account IS &mdash; the name it goes by, the birthday that decides
615
+ eligibility, and the picture. The first three cards are asked at signup
616
+ (a host walks them post-auth), the last three are reachable forever from
617
+ the profile page; all six are engine primitives.
618
+ </p>
619
+ <p class="text-muted text-sm">
620
+ <strong>Birthday and Age gate are one flow, deliberately split into two
621
+ cards.</strong> The birthday card ASKS; when the answer is too young the
622
+ age-gate card ANSWERS, carrying a countdown to the day they qualify, a way
623
+ to watch instead, and a way back to fix a mistyped year. Until 2026-08-24
624
+ that refusal lived inside the birthday card as red text beside a disabled
625
+ button &mdash; a state with nothing to press. Open <strong>Birthday</strong>
626
+ with <strong>Age validation</strong> on and submit any date to walk the
627
+ handoff; switch the toggle off for the other mode, an app with no age bar
628
+ that just wants the date.
629
+ </p>
630
+ <p class="text-muted text-sm">
631
+ The avatar modals
604
632
  (<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.
633
+ <code class="font-mono text-2xs">studio/modals/_image_upload</code>)
634
+ open live here &mdash;
635
+ <code class="font-mono text-2xs">studio/cropper_assets</code> is rendered
636
+ above, so cropper.js loads and the modals mount on this page's host.
609
637
  </p>
610
638
  </div>
611
639
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
640
+ <%= render layout: "style/modal_specimen", locals: {
641
+ label: "First name",
642
+ 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),
643
+ open_expr: "$store.dsModals.open('onboarding-first-name')",
644
+ glow_when: ds_glow.call("onboarding-first-name")
645
+ } do %>
646
+ <%# Every other card in this section shows the shape of the modal it
647
+ opens; this one showed a paragraph of prose, so the row read as a
648
+ missing thumbnail rather than a deliberate difference. It is a
649
+ labelled text field and a submit — draw that. %>
650
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
651
+ <span class="block h-2 w-20 rounded" style="background: var(--color-text); opacity: .18"></span>
652
+ <span class="block h-7 w-full rounded-lg border" style="border-color: var(--color-border-strong)"></span>
653
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
654
+ <%# the skip link that sits under the button, and is also a skip %>
655
+ <span class="block h-1.5 w-14 mx-auto rounded" style="background: var(--color-text); opacity: .25"></span>
656
+ </div>
657
+ <p class="text-muted text-2xs mt-3">
658
+ Saving or skipping dispatches
659
+ <code class="font-mono">ds-onboarding-step-done</code> here rather than
660
+ the app-level event, so the specimen cannot drive a real chain.
661
+ </p>
662
+ <% end %>
663
+
664
+ <%# BIRTHDAY — the ask. ONE card, two modes, because the difference between
665
+ them is a LOCAL the host sets and not a different card:
666
+
667
+ age validation ON — the app has a bar, so the card says what it is
668
+ and an under-age date hands off to the age gate.
669
+ age validation OFF — the app just wants the date. McRitchie Studio
670
+ has no gate and still wants a birthday to wish
671
+ you a happy one; the card then drops the age
672
+ line, the eligibility wording and the handoff.
673
+
674
+ It was two cards until 2026-08-25 ("Birthday" and "Birthday (underage)"),
675
+ which read as two components and hid the fact that the second was the
676
+ first with a prop flipped. The toggle is the honest shape — the same one
677
+ Profile Leveling and the Processing card use.
678
+
679
+ THE BADGE SAYS "age validation", NOT "age gate off". The old badge
680
+ reported this app's Studio.feature?(:age_gate) flag, which is the wrong
681
+ question for a card whose whole point is that the RULE is per-app:
682
+ turf-monster's is jurisdiction-dependent, a hub app's may not exist. %>
683
+ <%= render layout: "style/modal_specimen", locals: {
684
+ label: "Birthday",
685
+ 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 })),
686
+ card_data: "opts: { validates: true }",
687
+ toggles: [{ model: "opts.validates", label: "Age validation" }],
688
+ open_expr: "$store.dsModals.open('birthday', { validates: opts.validates })",
689
+ glow_when: ds_glow.call("birthday"),
690
+ openable: true } do %>
691
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2 text-center">
692
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
693
+ <%# month / day / year %>
694
+ <div class="grid grid-cols-3 gap-1">
695
+ <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
696
+ <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
697
+ <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
698
+ </div>
699
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
700
+ </div>
701
+ <% end %>
702
+
703
+ <%# AGE GATE — the answer, when the answer is no. %>
704
+ <%= render layout: "style/modal_specimen", locals: {
705
+ label: "Age gate",
706
+ 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 })),
707
+ open_expr: "$store.dsModals.open('age-gate', { minAge: 21, state: 'CA', dobYear: new Date().getFullYear() - 16, dobMonth: 6, dobDay: 15 })",
708
+ glow_when: ds_glow.call("age-gate"),
709
+ openable: true } do %>
710
+ <%# A teddy bear, not the red X pill — the card is not an error state and
711
+ its thumbnail should not promise one. %>
712
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2 text-center">
713
+ <div class="text-2xl leading-none">&#129528;</div>
714
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
715
+ <%# the countdown line, which is the widest thing on the real card %>
716
+ <span class="block h-1.5 w-full rounded" style="background: var(--color-text); opacity: .3"></span>
717
+ <%# primary = watch instead; secondary = go fix the date %>
718
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
719
+ <span class="block h-1.5 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .25"></span>
720
+ </div>
721
+ <% end %>
722
+
723
+
612
724
  <%# Image upload FIRST — the empty picker (crop-photo opened with no imageUrl).
613
725
  cropReady: false is the defined starting sub-state; the crop factory
614
726
  flips it true once an image is picked, moving the glow to Crop photo. %>
@@ -770,17 +882,75 @@
770
882
  <span class="block h-1.5 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
771
883
  </div>
772
884
  <% end %>
885
+
886
+ <%# THE WAY OUT. The three cards above walk a user IN; a section that only
887
+ ever demonstrates opt-in is showing the happy half of a flow, not the
888
+ flow. These two are the exit, and they are a PAIR — the confirm card
889
+ swaps to the goodbye beat, so reviewing either alone misses the handoff.
890
+ App-specific (the engine owns no newsletter); engine chrome only. %>
891
+ <%= render layout: "style/modal_specimen", locals: {
892
+ label: "Leave the newsletter?",
893
+ 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')),
894
+ open_expr: "$store.dsModals.open('unsubscribe-confirm')",
895
+ glow_when: ds_glow.call("unsubscribe-confirm") } do %>
896
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
897
+ <div class="text-2xl leading-none">&#128237;</div>
898
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
899
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
900
+ <%# the quiet out, deliberately smaller than the destructive action %>
901
+ <span class="block h-1.5 w-12 mx-auto rounded" style="background: var(--color-text); opacity: .25"></span>
902
+ </div>
903
+ <% end %>
904
+
905
+ <%= render layout: "style/modal_specimen", locals: {
906
+ label: "See you later",
907
+ 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')),
908
+ open_expr: "$store.dsModals.open('unsubscribe-goodbye')",
909
+ glow_when: ds_glow.call("unsubscribe-goodbye") } do %>
910
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
911
+ <div class="text-2xl leading-none">&#9996;&#65039;</div>
912
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
913
+ <span class="block h-1.5 w-28 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
914
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
915
+ </div>
916
+ <% end %>
917
+
918
+ <%# THE SAME BEAT, REACHED FROM SOMEWHERE ELSE. Great Username (two cards up)
919
+ celebrates INSIDE the modal that did the work. This one is what a host
920
+ shows when the quest completed somewhere with no card to advance in
921
+ place — turf's /account username change — so the celebration arrives on
922
+ its own. The toggle is the fork at its foot, which is the whole reason
923
+ it is one card and not two. %>
924
+ <%= render layout: "style/modal_specimen", locals: {
925
+ label: "Quest success (off-page)",
926
+ 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 })),
927
+ card_data: "opts: { subscribed: false }",
928
+ toggles: [{ model: "opts.subscribed", label: "Subscribed" }],
929
+ open_expr: "$store.dsModals.open('quest-success', { subscribed: opts.subscribed, seedsTotal: 25 })",
930
+ glow_when: ds_glow.call("quest-success") } do %>
931
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
932
+ <div class="text-2xl leading-none">&#127793;</div>
933
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
934
+ <%# the seeds bar, which is the card's middle and the engine's %>
935
+ <span class="flex gap-1">
936
+ <span class="flex-1 h-2 rounded" style="background: var(--color-cta)"></span>
937
+ <span class="flex-1 h-2 rounded" style="background: var(--color-cta); opacity:.4"></span>
938
+ <span class="flex-1 h-2 rounded" style="background: var(--color-cta); opacity:.2"></span>
939
+ </span>
940
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
941
+ </div>
942
+ <% end %>
773
943
  </div>
774
944
  </section>
775
945
 
776
946
  <%# ===================================================================== %>
777
- <%# 2. WEB3 CONTEST — the walked wallet -> on-chain flow (glow follows it). %>
947
+ <%# 2. WEB3 — the wallet + on-chain modals, in the order a player meets them. %>
778
948
  <%# Gated by :web3; off = disabled-but-present-yet-openable. %>
779
949
  <%# ===================================================================== %>
780
950
  <section class="space-y-5">
781
951
  <div class="space-y-1">
782
952
  <div class="flex flex-wrap items-center gap-3">
783
- <h3 class="text-xl font-bold text-heading">Web3 Contest</h3>
953
+ <h3 class="text-xl font-bold text-heading">Web3</h3>
784
954
  <% if web3_on %>
785
955
  <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">enabled on this app</span>
786
956
  <% else %>
@@ -792,10 +962,14 @@
792
962
  <code class="font-mono text-2xs">Studio.feature?(:web3)</code>. Web3 is
793
963
  <strong><%= web3_on ? "on" : "off" %></strong> here, so these render
794
964
  <%= 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).
965
+ The cards run in the order a player meets them &mdash; <strong>get a wallet,
966
+ prove it, spend from it</strong>: Connect wallet &rarr; Setup Wallet &rarr;
967
+ the two Sign Wallet states &rarr; Processing &rarr; success or error &rarr;
968
+ top-up &rarr; Entry confirmed. Inside that, <strong>Connect Wallet &rarr;
969
+ Processing on-chain transaction &rarr; On-chain success</strong> is a
970
+ <strong>walked flow</strong> and the live card <strong>glows</strong>,
971
+ following it as it advances (open Connect Wallet and pick a wallet to watch
972
+ it walk).
799
973
  The <strong>Processing</strong> card is a load modal: its
800
974
  <strong>success/error toggle</strong> chooses whether it resolves to success
801
975
  or the error state, after the <strong>minimum-visible duration</strong>
@@ -804,7 +978,7 @@
804
978
  marks inline from the engine</strong>
805
979
  (<code class="font-mono text-2xs">studio/modals/blocks/_wallet_brand_sprite</code>),
806
980
  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
981
+ The two <strong>Sign Wallet</strong> cards are a different kind of thing
808
982
  from the rest of this section: they render
809
983
  <code class="font-mono text-2xs">studio/modals/_web3_step_up</code> &mdash;
810
984
  the <strong>REAL shared partial a host renders in production</strong>,
@@ -813,28 +987,136 @@
813
987
  <strong>standard web3 auth button</strong>: a wallet row (brand mark, the
814
988
  wallet&rsquo;s own name, Installed badge, chevron) rather than a filled
815
989
  CTA, glowing via <code class="font-mono text-2xs">pulse-cta</code> because
816
- it is the one thing to press.
990
+ it is the one thing to press. Do not confuse them with <strong>Setup
991
+ Wallet</strong>, immediately before them: that one asks an account to
992
+ <strong>get</strong> a wallet, these ask an account to
993
+ <strong>prove</strong> the one it already has &mdash; opposite populations
994
+ (<code class="font-mono text-2xs">WalletSetupPolicy</code> exits at its own
995
+ step 1 for an account that holds a wallet), so no user ever meets both.
996
+ They sit next to each other anyway, because the pair is easiest to keep
997
+ straight when you can read them side by side.
817
998
  </p>
818
999
  </div>
1000
+ <%# ONE ordered run, read left to right as a player meets it: get a wallet,
1001
+ prove it, spend from it. This carried three labelled h4 runs for a day
1002
+ (The walk / Proving a wallet / Funding & confirmation) and they were
1003
+ removed on 2026-08-25 — the player order already groups the cards, and the
1004
+ headings were a second, competing structure over the same nine. What holds
1005
+ the order now is a test (style_page_test's WEB3_ORDER), not a label a
1006
+ reader has to trust. %>
819
1007
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
820
1008
  <%# 1. Connect wallet — the walk's entry; picking a wallet swaps to Processing. %>
821
1009
  <%= render layout: "style/modal_specimen", locals: {
822
1010
  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')),
1011
+ 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
1012
  open_expr: "$store.dsModals.open('wallet-connect')",
825
1013
  glow_when: ds_glow.call("wallet-connect"),
826
1014
  disabled: !web3_on, openable: true } do %>
1015
+ <%# A PICKER: brand tiles with names beside them, which is what this modal
1016
+ actually is. Two empty outlines said "form with two inputs" and were
1017
+ the least informative sketch in the section — the one card whose
1018
+ subject is a LIST of wallets showed no wallets at all. Three rows,
1019
+ because the picker ships Phantom / Solflare / Backpack marks inline. %>
827
1020
  <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>
1021
+ <span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1022
+ <% [0.9, 0.6, 0.45].each do |tint| %>
1023
+ <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)">
1024
+ <span class="w-3.5 h-3.5 rounded shrink-0" style="background: var(--color-primary); opacity: <%= tint %>"></span>
1025
+ <span class="block h-1.5 w-9 rounded" style="background: var(--color-text); opacity: .35"></span>
1026
+ <span class="block h-1.5 w-1.5 ml-auto rounded-full" style="background: var(--color-text); opacity: .25"></span>
1027
+ </span>
1028
+ <% end %>
1029
+ </div>
1030
+ <% end %>
1031
+
1032
+ <%# WALLET SETUP — new 2026-08-24, and moved here from Onboarding the same
1033
+ day. It is grouped with the wallet cards rather than with the signup
1034
+ chain because that is what it IS: getting a wallet. The card still
1035
+ renders a 3-of-3 pill, which is the host's chain position and not this
1036
+ section's business.
1037
+ ONE card with a "Wallet detected" toggle rather than two cards sharing a
1038
+ modal id: the two Sign Wallet cards below are what that shape costs. %>
1039
+ <%= render layout: "style/modal_specimen", locals: {
1040
+ label: "Setup Wallet",
1041
+ 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 })),
1042
+ card_data: "opts: { detected: false }",
1043
+ toggles: [{ model: "opts.detected", label: "Wallet detected" }],
1044
+ open_expr: "$store.dsModals.open('wallet-setup', { detected: opts.detected })",
1045
+ glow_when: ds_glow.call("wallet-setup"),
1046
+ disabled: !web3_on, openable: true } do %>
1047
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
1048
+ <span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1049
+ <%# the 3-of-3 pill, filled — the whole reason this card sits here %>
1050
+ <span class="flex gap-1">
1051
+ <span class="flex-1 h-1.5 rounded" style="background: var(--color-cta)"></span>
1052
+ <span class="flex-1 h-1.5 rounded" style="background: var(--color-cta)"></span>
1053
+ <span class="flex-1 h-1.5 rounded" style="background: var(--color-cta)"></span>
1054
+ </span>
1055
+ <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)">
1056
+ <span class="w-4 h-4 rounded shrink-0" style="background: var(--color-primary)"></span>
1057
+ <span class="block h-1.5 w-8 rounded" style="background: var(--color-text); opacity: .35"></span>
1058
+ </span>
1059
+ <%# the walkthrough placeholder, the card's second half %>
1060
+ <span class="block w-full rounded" style="height: 1.6rem; background: var(--color-text); opacity: .10"></span>
1061
+ </div>
1062
+ <% end %>
1063
+
1064
+ <%# SIGN WALLET — the web2-session-on-a-wallet-account card. NOT part of the
1065
+ walk above: that walk is one on-chain transaction, this is an AUTH state
1066
+ that precedes any of it. Two specimens because the card has exactly two
1067
+ shapes, and the second is what every wallet linked before a host recorded
1068
+ brands will see.
1069
+
1070
+ OPPOSITE POPULATION to the "Setup Wallet" card two up in this
1071
+ section, and worth stating because the two look alike: that one asks
1072
+ "get a wallet", this one asks "prove the wallet you already have".
1073
+ WalletSetupPolicy exits at its own step 1 for an account that holds a
1074
+ wallet, so no user can ever meet both. %>
1075
+ <%= render layout: "style/modal_specimen", locals: {
1076
+ label: "Sign Wallet",
1077
+ 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' })),
1078
+ open_expr: "$store.dsModals.open('web3-step-up', { provider: 'phantom', providerLabel: 'Phantom', walletHint: '7xKp…JZ2Q' })",
1079
+ glow_when: ds_glow.call("web3-step-up", provider: true),
1080
+ disabled: !web3_on, openable: true } do %>
1081
+ <%# A FILLED row: brand tile, the wallet's name, an Installed badge. The
1082
+ two step-up thumbnails used to differ by a single border-dashed class
1083
+ on a 20px row, which is not a difference anyone can see at thumbnail
1084
+ size — and these two cards are the pair most likely to be mistaken for
1085
+ duplicates, because they share a modal id AND a title. So the sketch
1086
+ now shows the actual distinction: this card KNOWS which wallet. %>
1087
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1088
+ <div class="text-2xl leading-none">🔐</div>
1089
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1090
+ <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)">
1091
+ <span class="w-4 h-4 rounded shrink-0" style="background: var(--color-primary)"></span>
1092
+ <span class="block h-1.5 w-8 rounded" style="background: var(--color-text); opacity: .35"></span>
1093
+ <span class="block h-2.5 w-7 ml-auto rounded-full border" style="border-color: var(--color-primary)"></span>
1094
+ </span>
1095
+ </div>
1096
+ <% end %>
1097
+
1098
+ <%= render layout: "style/modal_specimen", locals: {
1099
+ label: "Sign Wallet (no remembered brand)",
1100
+ 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', {})),
1101
+ open_expr: "$store.dsModals.open('web3-step-up', {})",
1102
+ glow_when: ds_glow.call("web3-step-up", provider: false),
1103
+ disabled: !web3_on, openable: true } do %>
1104
+ <%# An EMPTY slot: dashed outline, no brand tile, a "?" where the wallet's
1105
+ name would be. Reads at a glance as "we do not know which wallet",
1106
+ which is the whole difference between this card and the one before it. %>
1107
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1108
+ <div class="text-2xl leading-none">🔐</div>
1109
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1110
+ <span class="flex items-center justify-center h-7 w-full rounded-lg border border-dashed" style="border-color: var(--color-border-strong)">
1111
+ <span class="text-xs font-bold text-muted leading-none">?</span>
1112
+ </span>
831
1113
  </div>
832
1114
  <% end %>
833
1115
 
834
1116
  <%# 2. Processing — the load modal; the success/error toggle picks the resolution. %>
835
1117
  <%= render layout: "style/modal_specimen", locals: {
836
1118
  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 })),
1119
+ 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
1120
  card_data: "opts: { demoError: false }",
839
1121
  toggles: [{ model: "opts.demoError", label: "Resolve to error" }],
840
1122
  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 +1131,7 @@
849
1131
  <%# 3. On-chain success — the resolved success end-state (the walk's finish). %>
850
1132
  <%= render layout: "style/modal_specimen", locals: {
851
1133
  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' })),
1134
+ 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
1135
  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
1136
  glow_when: ds_glow.call("onchain-tx", state: "success"),
855
1137
  disabled: !web3_on, openable: true } do %>
@@ -864,7 +1146,7 @@
864
1146
  <%# On-chain error — the resolved error end-state (the toggle's other branch). %>
865
1147
  <%= render layout: "style/modal_specimen", locals: {
866
1148
  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.' })),
1149
+ 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
1150
  open_expr: "$store.dsModals.open('onchain-tx', { state: 'error', title: 'Could not confirm', errorMessage: 'The transaction was rejected. Give it another try.' })",
869
1151
  glow_when: ds_glow.call("onchain-tx", state: "error"),
870
1152
  disabled: !web3_on, openable: true } do %>
@@ -874,40 +1156,30 @@
874
1156
  </div>
875
1157
  <% end %>
876
1158
 
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. %>
1159
+ <%# THE OTHER FAILURE, and it is a different KIND of failure from the one
1160
+ above. On-chain error is the chain saying no. This is the SERVER saying
1161
+ no refusing to co-sign because the transaction it was handed did not
1162
+ match the entry it prepared. They sit together because a reviewer
1163
+ reading either alone would not know the other existed. %>
882
1164
  <%= 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"),
1165
+ label: "Co-sign refused",
1166
+ 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')),
1167
+ open_expr: "$store.dsModals.open('cosign-rejected')",
1168
+ glow_when: ds_glow.call("cosign-rejected"),
887
1169
  disabled: !web3_on, openable: true } do %>
888
1170
  <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>
1171
+ <div class="text-2xl leading-none">&#128737;&#65039;</div>
890
1172
  <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
-
895
- <%= 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', {})",
899
- disabled: !web3_on, openable: true } do %>
900
- <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>
902
- <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>
1173
+ <span class="block h-1.5 w-full rounded" style="background: var(--color-text); opacity: .3"></span>
1174
+ <span class="block h-1.5 w-28 mx-auto rounded" style="background: var(--color-text); opacity: .3"></span>
1175
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
904
1176
  </div>
905
1177
  <% end %>
906
1178
 
907
1179
  <%# Wallet deposit — standalone top-up card (not part of the walk). %>
908
1180
  <%= render layout: "style/modal_specimen", locals: {
909
1181
  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…' })),
1182
+ 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
1183
  open_expr: "$store.dsModals.open('wallet-deposit', { neededCents: 500, usdcCents: 120, usdtCents: 0, address: 'Fo1L5demoWALLETaddr9xQ2' })",
912
1184
  disabled: !web3_on, openable: true } do %>
913
1185
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
@@ -923,7 +1195,7 @@
923
1195
  Rewards below (gated by :leveling). %>
924
1196
  <%= render layout: "style/modal_specimen", locals: {
925
1197
  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 })),
1198
+ 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
1199
  open_expr: "$store.dsModals.open('entry-confirmed', { txSignature: '5xTrDemoSignature1234567890abcXYZ', lobbyUrl: null, seedsEarned: 0, seedsTotal: 40 })",
928
1200
  disabled: !web3_on, openable: true } do %>
929
1201
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
@@ -939,7 +1211,7 @@
939
1211
  </section>
940
1212
 
941
1213
  <%# ===================================================================== %>
942
- <%# 3. CONTEST ENTRY & ELIGIBILITY — moved directly under Web3 Contest. The %>
1214
+ <%# 3. CONTEST ENTRY & ELIGIBILITY — moved directly under Web3. The %>
943
1215
  <%# walked entry flow (glow follows), plus the honest web2/web3 map. %>
944
1216
  <%# ===================================================================== %>
945
1217
  <section class="space-y-5">
@@ -953,17 +1225,20 @@
953
1225
  <% end %>
954
1226
  </div>
955
1227
  <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
1228
+ The pre-entry gate and the full entry flow. The age bar is enforced HERE,
1229
+ but its two cards &mdash;
1230
+ <code class="font-mono text-2xs">studio/modals/blocks/_birthday</code> (the
1231
+ ask) and <code class="font-mono text-2xs">_age_gate</code> (the refusal)
1232
+ &mdash; are shown in the <strong>Profile</strong> section, because they are
1233
+ about who the account is. Both are engine primitives gated by
1234
+ <code class="font-mono text-2xs">Studio.feature?(:age_gate)</code>, and their
1235
+ minimum age, jurisdiction label, endpoint, and legal copy are all
961
1236
  <strong>app-supplied</strong> (the engine hardcodes no policy). The entry flow is
962
1237
  a <strong>walked sequence</strong> &mdash; <strong>Entry tokens &rarr; Payment
963
1238
  processing &rarr; Entry Tokens Minted &rarr; Contest enter processing &rarr;
964
1239
  Contest entered</strong> &mdash; and the live card <strong>glows</strong>,
965
1240
  following the step machine (the two load steps honor the same min-duration
966
- convention as Web3 Contest above).
1241
+ convention as Web3 above).
967
1242
  </p>
968
1243
  <%# The web2/web3 finding — a real divergence, stated honestly, not invented. %>
969
1244
  <div class="mt-2 rounded-lg border border-subtle bg-surface p-3 text-xs text-secondary leading-snug">
@@ -986,23 +1261,14 @@
986
1261
  </div>
987
1262
  </div>
988
1263
  <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 %>
1264
+ <%# The birthday / age-gate CARDS live in the Profile section they are
1265
+ about who the account is, and grouping them by subject beats grouping
1266
+ them by the moment they happen to be asked. ENFORCEMENT still lives
1267
+ here: the bar is checked at contest entry, which is what this section is
1268
+ named for. The demo walk is unbroken — confirming the birthday card
1269
+ still advances to Entry tokens below, via the page-level 'age-verified'
1270
+ wiring, which the rename did NOT touch precisely because consuming apps
1271
+ already listen on it. %>
1006
1272
 
1007
1273
  <%# 1. Entry tokens — the picker (walk's entry). Picking a pack advances. %>
1008
1274
  <%= render layout: "style/modal_specimen", locals: {