studio-engine 0.59.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,10 +354,43 @@
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>
386
+ <%# web3-step-up — the REAL shared partial, not a style-guide specimen.
387
+ The other Web3 cards here render style/modals/* demos while each app
388
+ keeps its own live modal; this one renders studio/modals/* — the
389
+ same file a host renders in production — so what is reviewed here IS
390
+ the shipped card. modal_store mounts it on this page's own host. %>
391
+ <template x-if="$store.dsModals.current().id === 'web3-step-up'">
392
+ <div><%= render "studio/modals/web3_step_up", modal_store: "dsModals" %></div>
393
+ </template>
343
394
  <template x-if="$store.dsModals.current().id === 'onchain-tx'">
344
395
  <div><%= render "style/modals/onchain_tx" %></div>
345
396
  </template>
@@ -470,44 +521,6 @@
470
521
  </div>
471
522
  </template>
472
523
 
473
- <%# ===================================================================== %>
474
- <%# 0. ONBOARDING — the post-auth chain's shared step %>
475
- <%# ===================================================================== %>
476
- <section class="space-y-5">
477
- <div class="space-y-1">
478
- <h3 class="text-xl font-bold text-heading">Onboarding</h3>
479
- <p class="text-muted text-sm">
480
- The step every Studio app asks a brand-new account, ported from Turf
481
- Monster
482
- (<code class="font-mono text-2xs">studio/modals/onboarding/_first_name</code>).
483
- It is <strong>one step, not a flow</strong>: the host owns the SEQUENCE
484
- around it — Turf walks <strong>welcome &rarr; first name &rarr; age gate
485
- &rarr; wallet setup</strong>, while a hub app may ask nothing else — so
486
- this partial never opens another modal. It reports what remains on its
487
- <code class="font-mono text-2xs">done_event</code> and closes, and the host
488
- decides what comes next. Every endpoint and label is a local with a
489
- default, so mounting it under different routes needs no fork.
490
- <strong>Both</strong> the &times; and the "Skip for now" link SKIP (a close
491
- that silently abandons the chain is how a host ends up with a step nobody
492
- can answer again), and the field focuses itself on open.
493
- </p>
494
- </div>
495
- <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
496
- <%= render layout: "style/modal_specimen", locals: {
497
- label: "First name",
498
- 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),
499
- open_expr: "$store.dsModals.open('onboarding-first-name')",
500
- glow_when: ds_glow.call("onboarding-first-name")
501
- } do %>
502
- <p class="text-muted text-2xs">
503
- Saving or skipping dispatches
504
- <code class="font-mono">ds-onboarding-step-done</code> here rather than
505
- the app-level event, so the specimen cannot drive a real chain.
506
- </p>
507
- <% end %>
508
- </div>
509
- </section>
510
-
511
524
  <%# 1. AUTH suite %>
512
525
  <%# ===================================================================== %>
513
526
  <section class="space-y-5">
@@ -586,21 +599,128 @@
586
599
  </section>
587
600
 
588
601
  <%# ===================================================================== %>
589
- <%# 2. PROFILE — crop / upload, live via cropper_assets %>
602
+ <%# 2. PROFILE — who the account is, plus the avatar modals %>
590
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. %>
591
610
  <section class="space-y-5">
592
611
  <div class="space-y-1">
593
612
  <h3 class="text-xl font-bold text-heading">Profile</h3>
594
613
  <p class="text-muted text-sm">
595
- 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
596
632
  (<code class="font-mono text-2xs">studio/modals/_crop_photo</code>,
597
- <code class="font-mono text-2xs">studio/modals/_image_upload</code>) and
598
- open live here — <code class="font-mono text-2xs">studio/cropper_assets</code>
599
- is rendered above, so cropper.js loads and the modals mount on this page's
600
- 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.
601
637
  </p>
602
638
  </div>
603
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
+
604
724
  <%# Image upload FIRST — the empty picker (crop-photo opened with no imageUrl).
605
725
  cropReady: false is the defined starting sub-state; the crop factory
606
726
  flips it true once an image is picked, moving the glow to Crop photo. %>
@@ -762,17 +882,75 @@
762
882
  <span class="block h-1.5 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
763
883
  </div>
764
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 %>
765
943
  </div>
766
944
  </section>
767
945
 
768
946
  <%# ===================================================================== %>
769
- <%# 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. %>
770
948
  <%# Gated by :web3; off = disabled-but-present-yet-openable. %>
771
949
  <%# ===================================================================== %>
772
950
  <section class="space-y-5">
773
951
  <div class="space-y-1">
774
952
  <div class="flex flex-wrap items-center gap-3">
775
- <h3 class="text-xl font-bold text-heading">Web3 Contest</h3>
953
+ <h3 class="text-xl font-bold text-heading">Web3</h3>
776
954
  <% if web3_on %>
777
955
  <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">enabled on this app</span>
778
956
  <% else %>
@@ -784,10 +962,14 @@
784
962
  <code class="font-mono text-2xs">Studio.feature?(:web3)</code>. Web3 is
785
963
  <strong><%= web3_on ? "on" : "off" %></strong> here, so these render
786
964
  <%= web3_on ? "live." : "greyed and badged — but STILL openable as a preview." %>
787
- The first three cards are a <strong>walked flow</strong> &mdash;
788
- <strong>Connect Wallet &rarr; Processing on-chain transaction &rarr; On-chain
789
- success</strong> &mdash; and the live card <strong>glows</strong>, following the
790
- 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).
791
973
  The <strong>Processing</strong> card is a load modal: its
792
974
  <strong>success/error toggle</strong> chooses whether it resolves to success
793
975
  or the error state, after the <strong>minimum-visible duration</strong>
@@ -796,27 +978,145 @@
796
978
  marks inline from the engine</strong>
797
979
  (<code class="font-mono text-2xs">studio/modals/blocks/_wallet_brand_sprite</code>),
798
980
  so every app inherits them with no per-app icon files.
981
+ The two <strong>Sign Wallet</strong> cards are a different kind of thing
982
+ from the rest of this section: they render
983
+ <code class="font-mono text-2xs">studio/modals/_web3_step_up</code> &mdash;
984
+ the <strong>REAL shared partial a host renders in production</strong>,
985
+ not a style-guide copy of it &mdash; so what you review here is the
986
+ shipped card, and it cannot drift from what users see. They also carry the
987
+ <strong>standard web3 auth button</strong>: a wallet row (brand mark, the
988
+ wallet&rsquo;s own name, Installed badge, chevron) rather than a filled
989
+ CTA, glowing via <code class="font-mono text-2xs">pulse-cta</code> because
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.
799
998
  </p>
800
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. %>
801
1007
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
802
1008
  <%# 1. Connect wallet — the walk's entry; picking a wallet swaps to Processing. %>
803
1009
  <%= render layout: "style/modal_specimen", locals: {
804
1010
  label: "Connect wallet",
805
- 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')),
806
1012
  open_expr: "$store.dsModals.open('wallet-connect')",
807
1013
  glow_when: ds_glow.call("wallet-connect"),
808
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. %>
809
1020
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
810
- <span class="block h-2 w-20 rounded" style="background: var(--color-text); opacity: .18"></span>
811
- <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
812
- <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>
813
1113
  </div>
814
1114
  <% end %>
815
1115
 
816
1116
  <%# 2. Processing — the load modal; the success/error toggle picks the resolution. %>
817
1117
  <%= render layout: "style/modal_specimen", locals: {
818
1118
  label: "Processing on-chain tx",
819
- 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 })),
820
1120
  card_data: "opts: { demoError: false }",
821
1121
  toggles: [{ model: "opts.demoError", label: "Resolve to error" }],
822
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 })",
@@ -831,7 +1131,7 @@
831
1131
  <%# 3. On-chain success — the resolved success end-state (the walk's finish). %>
832
1132
  <%= render layout: "style/modal_specimen", locals: {
833
1133
  label: "On-chain success",
834
- 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' })),
835
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 })",
836
1136
  glow_when: ds_glow.call("onchain-tx", state: "success"),
837
1137
  disabled: !web3_on, openable: true } do %>
@@ -846,7 +1146,7 @@
846
1146
  <%# On-chain error — the resolved error end-state (the toggle's other branch). %>
847
1147
  <%= render layout: "style/modal_specimen", locals: {
848
1148
  label: "On-chain error",
849
- 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.' })),
850
1150
  open_expr: "$store.dsModals.open('onchain-tx', { state: 'error', title: 'Could not confirm', errorMessage: 'The transaction was rejected. Give it another try.' })",
851
1151
  glow_when: ds_glow.call("onchain-tx", state: "error"),
852
1152
  disabled: !web3_on, openable: true } do %>
@@ -856,10 +1156,30 @@
856
1156
  </div>
857
1157
  <% end %>
858
1158
 
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. %>
1164
+ <%= render layout: "style/modal_specimen", locals: {
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"),
1169
+ disabled: !web3_on, openable: true } do %>
1170
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1171
+ <div class="text-2xl leading-none">&#128737;&#65039;</div>
1172
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></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>
1176
+ </div>
1177
+ <% end %>
1178
+
859
1179
  <%# Wallet deposit — standalone top-up card (not part of the walk). %>
860
1180
  <%= render layout: "style/modal_specimen", locals: {
861
1181
  label: "Wallet deposit",
862
- 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…' })),
863
1183
  open_expr: "$store.dsModals.open('wallet-deposit', { neededCents: 500, usdcCents: 120, usdtCents: 0, address: 'Fo1L5demoWALLETaddr9xQ2' })",
864
1184
  disabled: !web3_on, openable: true } do %>
865
1185
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
@@ -875,7 +1195,7 @@
875
1195
  Rewards below (gated by :leveling). %>
876
1196
  <%= render layout: "style/modal_specimen", locals: {
877
1197
  label: "Entry confirmed",
878
- 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 })),
879
1199
  open_expr: "$store.dsModals.open('entry-confirmed', { txSignature: '5xTrDemoSignature1234567890abcXYZ', lobbyUrl: null, seedsEarned: 0, seedsTotal: 40 })",
880
1200
  disabled: !web3_on, openable: true } do %>
881
1201
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
@@ -891,7 +1211,7 @@
891
1211
  </section>
892
1212
 
893
1213
  <%# ===================================================================== %>
894
- <%# 3. CONTEST ENTRY & ELIGIBILITY — moved directly under Web3 Contest. The %>
1214
+ <%# 3. CONTEST ENTRY & ELIGIBILITY — moved directly under Web3. The %>
895
1215
  <%# walked entry flow (glow follows), plus the honest web2/web3 map. %>
896
1216
  <%# ===================================================================== %>
897
1217
  <section class="space-y-5">
@@ -905,17 +1225,20 @@
905
1225
  <% end %>
906
1226
  </div>
907
1227
  <p class="text-muted text-sm">
908
- The pre-entry gate and the full entry flow. The <strong>age gate</strong>
909
- (<code class="font-mono text-2xs">studio/modals/blocks/_age_verify</code>) is an
910
- engine primitive gated by
911
- <code class="font-mono text-2xs">Studio.feature?(:age_gate)</code> &mdash; a DOB
912
- 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
913
1236
  <strong>app-supplied</strong> (the engine hardcodes no policy). The entry flow is
914
1237
  a <strong>walked sequence</strong> &mdash; <strong>Entry tokens &rarr; Payment
915
1238
  processing &rarr; Entry Tokens Minted &rarr; Contest enter processing &rarr;
916
1239
  Contest entered</strong> &mdash; and the live card <strong>glows</strong>,
917
1240
  following the step machine (the two load steps honor the same min-duration
918
- convention as Web3 Contest above).
1241
+ convention as Web3 above).
919
1242
  </p>
920
1243
  <%# The web2/web3 finding — a real divergence, stated honestly, not invented. %>
921
1244
  <div class="mt-2 rounded-lg border border-subtle bg-surface p-3 text-xs text-secondary leading-snug">
@@ -938,23 +1261,14 @@
938
1261
  </div>
939
1262
  </div>
940
1263
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
941
- <%# Age gate engine primitive, gated on :age_gate (disabled-but-present). %>
942
- <%= render layout: "style/modal_specimen", locals: {
943
- label: "Age gate (DOB)",
944
- 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')),
945
- open_expr: "$store.dsModals.open('age-verify')",
946
- glow_when: ds_glow.call("age-verify"),
947
- disabled: !age_gate_on, openable: true, disabled_label: "age gate off" } do %>
948
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2 text-center">
949
- <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
950
- <div class="grid grid-cols-3 gap-1">
951
- <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
952
- <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
953
- <span class="h-6 rounded border" style="border-color: var(--color-border-strong)"></span>
954
- </div>
955
- <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
956
- </div>
957
- <% 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. %>
958
1272
 
959
1273
  <%# 1. Entry tokens — the picker (walk's entry). Picking a pack advances. %>
960
1274
  <%= render layout: "style/modal_specimen", locals: {