studio-engine 0.74.3 → 0.74.4

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2719af0f10410f44f1ac53e1a17fe244de4a9c4ecae8eb94715c75a69450839b
4
- data.tar.gz: 9c705e4cb7e9816594d2fc13732ea5449f1523e02645a1108bb4b1f1d50d887f
3
+ metadata.gz: 2ef2370af4941b05f724ac0eea748c555d6c70610d63194806deb812fbb4cc81
4
+ data.tar.gz: 5dd92c0a97e42fb9c29a5d644720c9cf1e7a83ce2db97e85c6ade6a5db35f297
5
5
  SHA512:
6
- metadata.gz: 18833874f77e97c16f56c20135ecdb897d8f7d47316c2d122947bfdbd71d23117f825d51fe3a62ff417099c1051d6ea0aead4650715f9212aa68b5520d266333
7
- data.tar.gz: 85568864c586c37ef1f17edefb9f23776b1f5f2089c09f331139000e88cd2dd1e47db031d01a456c3a9b267f8aced700b9221c04d0d4e1809c4381f4b0e5f020
6
+ metadata.gz: baac9edc9c8fe756d3bb44994539cce055bf2987d3e19109b076793a1c9d93c1ac3704fa4e38d60e839cd4a79f03128f75e05d25db7ef3789116361cb6ec3568
7
+ data.tar.gz: db54e015edb969381a2f5d26f3c8836aca367294051a944d669279e5ce249f0e0c4655777c247784462a07a4b691dac197fe273854c26d9d60c9fbd830990043
data/README.md CHANGED
@@ -238,6 +238,30 @@ the block:
238
238
  <% end %>
239
239
  ```
240
240
 
241
+ #### Writing a modal's content partial — two rules the host imposes
242
+
243
+ Both of these fail SILENTLY. Nothing raises, nothing logs, and the card renders;
244
+ it just does less than it looks like it does.
245
+
246
+ **1. SINGLE ROOT.** A content partial's outer `<div>` is the host's required
247
+ root. Alpine's `<template x-if>` clones only the FIRST root element of its
248
+ content, so a second top-level sibling — a stray `<span>`, a trailing `<style>`
249
+ block, a comment-turned-node — is dropped on the floor. Bake anything extra
250
+ inside the wrapping `<div>`.
251
+
252
+ **2. NO DOUBLE QUOTE INSIDE `x-data`.** The attribute is double-quoted, so an
253
+ inner double quote CLOSES it. Alpine then mounts a component whose expression is
254
+ truncated mid-statement: every element still renders and every handler does
255
+ nothing. Single-quote everything inside `x-data`.
256
+
257
+ These are properties of the HOST, not of any one card, and they apply to every
258
+ consumer partial an app registers — not only to the specimens in this gem. They
259
+ are recorded here because they used to be recorded only in the style guide's
260
+ specimen headers, which made them deletable by a change that removed a specimen:
261
+ retiring the `style/modals/_network_guard` and `_wallet_deposit` mirrors on
262
+ 2026-09-09 would otherwise have taken the single-root rule out of both this repo
263
+ and the app that inherited those cards.
264
+
241
265
  **Wallet modals moved to `solana-studio`.** The Connect Wallet picker, the Web3
242
266
  step-up card and the Phantom deep link used to ship here as
243
267
  `studio/modals/wallet_connect`, `studio/modals/web3_step_up` and
@@ -28,7 +28,11 @@
28
28
  the app rather than to one call site
29
29
 
30
30
  Consumer integration example and API reference: the "Modal host"
31
- section of the gem README.
31
+ section of the gem README — which also carries the two rules this host
32
+ IMPOSES on the content partials registered in its block: a SINGLE ROOT
33
+ element (a template x-if clones only the first one; later siblings are
34
+ dropped) and NO double quote inside x-data (it closes the attribute and
35
+ Alpine mounts a silent no-op). Both fail without raising.
32
36
  %>
33
37
  <style>
34
38
  /* Scroll lock applied by $store.modals._sync() when the stack is non-empty.
@@ -41,6 +41,18 @@
41
41
  chaining (`current()?.id`): the outer template unmounts one tick AFTER the
42
42
  stack empties, so a bare `.id` throws on every close.
43
43
 
44
+ THE CONTENT PARTIALS OBEY THE SAME TWO RULES AS THE SHARED HOST'S, and both
45
+ fail silently: a SINGLE ROOT element (a template x-if clones only the first
46
+ one) and NO double quote inside x-data (it closes the attribute and Alpine
47
+ mounts a component that renders everything and does nothing). Stated in full
48
+ in the gem README's "Modal host" section.
49
+
50
+ AND EVERY ID REGISTERED HERE MUST BE REACHABLE, both ways round. A card that
51
+ opens an id no template registers shows an EMPTY panel; a template no card
52
+ opens is a modal nobody can reach. That pairing is the one thing a cabinet of
53
+ modals cannot check by looking at itself, so the living style guide holds it
54
+ in a test (test/views/style_guide_no_dangling_specimens_test.rb).
55
+
44
56
  (No inline ERB example here on purpose. An ERB comment ends at its FIRST "%" +
45
57
  ">", so an example containing one silently closes the comment and dumps the
46
58
  rest of it onto the page as visible text. That shipped once.)
@@ -468,7 +468,6 @@
468
468
  resolve_expr: "$store.dsModals.close()" %></div>
469
469
  </template>
470
470
 
471
-
472
471
  <%# --- Batch 3: the modals that had no card on EITHER page --- %>
473
472
  <template x-if="$store.dsModals.current().id === 'it-begins'">
474
473
  <div><%= render "style/modals/it_begins" %></div>
@@ -479,9 +478,6 @@
479
478
  <template x-if="$store.dsModals.current().id === 'email-change-pending'">
480
479
  <div><%= render "style/modals/email_change_pending" %></div>
481
480
  </template>
482
- <template x-if="$store.dsModals.current().id === 'network-guard'">
483
- <div><%= render "style/modals/network_guard" %></div>
484
- </template>
485
481
  <%# --- funding chrome primitives (extract-funding-modal-chrome) --- %>
486
482
  <template x-if="$store.dsModals.current().id === 'ds-rail-row'">
487
483
  <div><%= render "style/modals/ds_rail_row" %></div>
@@ -496,25 +492,14 @@
496
492
  <template x-if="$store.dsModals.current().id === 'ds-onramp-hub'">
497
493
  <div><%= render "style/modals/ds_onramp_hub" %></div>
498
494
  </template>
499
- <template x-if="$store.dsModals.current().id === 'ds-newsletter-success'">
500
- <div><%= render "style/modals/ds_newsletter_success" %></div>
501
- </template>
502
495
  <template x-if="$store.dsModals.current().id === 'rate-limit-general'">
503
496
  <div><%= render "style/modals/rate_limit_general" %></div>
504
497
  </template>
505
498
 
506
- <%# --- Newsletter exit + the off-page quest celebration --- %>
507
- <template x-if="$store.dsModals.current().id === 'quest-success'">
508
- <div><%= render "style/modals/quest_success" %></div>
509
- </template>
499
+ <%# --- Newsletter exit --- %>
510
500
  <template x-if="$store.dsModals.current().id === 'unsubscribe-confirm'">
511
501
  <div><%= render "style/modals/unsubscribe_confirm" %></div>
512
502
  </template>
513
- <%# Registered alongside its confirm card, because unsubscribe-confirm
514
- SWAPS to it — a handoff to an unregistered id renders a blank shell. %>
515
- <template x-if="$store.dsModals.current().id === 'unsubscribe-goodbye'">
516
- <div><%= render "style/modals/unsubscribe_goodbye" %></div>
517
- </template>
518
503
 
519
504
  <%# --- Web3 --- %>
520
505
  <%# Both web3 sign-in cards render from SOLANA-STUDIO, and both are the
@@ -535,9 +520,6 @@
535
520
  <template x-if="$store.dsModals.current().id === 'onchain-tx'">
536
521
  <div><%= render "style/modals/onchain_tx" %></div>
537
522
  </template>
538
- <template x-if="$store.dsModals.current().id === 'wallet-deposit'">
539
- <div><%= render "style/modals/wallet_deposit" %></div>
540
- </template>
541
523
  <%# entry-confirmed — the engine celebration composed via _success_card's
542
524
  yield. One registration serves the generic (web3) + seeds (leveling)
543
525
  specimens; the seeds bar self-gates on Studio.feature?(:leveling). %>
@@ -861,7 +843,6 @@
861
843
  </div>
862
844
  <% end %>
863
845
 
864
-
865
846
  <%# Image upload FIRST — the empty picker (crop-photo opened with no imageUrl).
866
847
  cropReady: false is the defined starting sub-state; the crop factory
867
848
  flips it true once an image is picked, moving the glow to Crop photo. %>
@@ -1042,36 +1023,19 @@
1042
1023
  </div>
1043
1024
  <% end %>
1044
1025
 
1045
- <%# ds-newsletter-success — the STANDALONE celebration, and its two forks. %>
1046
- <%= render layout: "style/modal_specimen", locals: {
1047
- label: "Subscribed! (standalone)",
1048
- reference: %(the standalone newsletter celebration (style/modals/_ds_newsletter_success) — turf-monster's modals/_newsletter_success. Distinct from the card above, which is the leveling activity celebrating IN PLACE on the card the person was already on; this one arrives on its own — swapped in from the subscribe modal, or opened from the /account newsletter row — so it has to carry its own way forward. TWO FORKS, NEITHER VISIBLE IN THE MARKUP, which is why it is carded separately rather than folded in. THE SEEDS BAR IS FIRST-JOIN ONLY: a re-subscribe earns nothing, and the card HIDES the bar rather than showing a flat "0 seeds", which would read to the person as a bug about their own account rather than an accurate zero. THE CTA IS SERVER-DECIDED: with the username quest still open (current_user.next_quest == :username) the button nudges straight into it, otherwise it sends them to browse contests — showing only one of those hides the funnel this celebration doubles as. Both are toggles here so the cabinet can walk all four combinations; in the app the CTA fork is SERVER-rendered, precisely so the button never flashes the wrong option at someone whose quest is already done. Open with $store.dsModals.open('ds-newsletter-success', { firstJoin: true, questOpen: true })),
1049
- card_data: "opts: { firstJoin: true, questOpen: true }",
1050
- toggles: [{ model: "opts.firstJoin", label: "First join" },
1051
- { model: "opts.questOpen", label: "Quest open" }],
1052
- open_expr: "$store.dsModals.open('ds-newsletter-success', { firstJoin: opts.firstJoin, questOpen: opts.questOpen })",
1053
- glow_when: ds_glow.call("ds-newsletter-success") } do %>
1054
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1055
- <div class="text-2xl leading-none">🌱</div>
1056
- <span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1057
- <%# the first-join seeds bar %>
1058
- <span class="flex gap-1">
1059
- <span class="flex-1 h-2 rounded" style="background: var(--color-cta)"></span>
1060
- <span class="flex-1 h-2 rounded" style="background: var(--color-cta); opacity:.35"></span>
1061
- <span class="flex-1 h-2 rounded" style="background: var(--color-cta); opacity:.18"></span>
1062
- </span>
1063
- <span class="block h-4 w-full rounded" style="background: var(--color-cta)"></span>
1064
- </div>
1065
- <% end %>
1066
-
1067
1026
  <%# THE WAY OUT. The three cards above walk a user IN; a section that only
1068
1027
  ever demonstrates opt-in is showing the happy half of a flow, not the
1069
- flow. These two are the exit, and they are a PAIR — the confirm card
1070
- swaps to the goodbye beat, so reviewing either alone misses the handoff.
1028
+ flow. This is the exit.
1029
+ IT WAS A PAIR UNTIL 2026-09-09: the goodbye beat it hands off to was a
1030
+ mirror of turf's own card, and turf now cards the real one in its host
1031
+ section, so only the half the engine owns is left here. The handoff is
1032
+ still the interesting part of this card, and it is now REVIEWED ACROSS
1033
+ TWO GUIDES rather than walked in one — which is why the specimen below
1034
+ resolves by closing rather than swapping to an id nothing registers.
1071
1035
  App-specific (the engine owns no newsletter); engine chrome only. %>
1072
1036
  <%= render layout: "style/modal_specimen", locals: {
1073
1037
  label: "Leave the newsletter?",
1074
- 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')),
1038
+ 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. That beat is carded on turf-monster's own section of this guide, not here: its engine mirror was retired on 2026-09-09. So this specimen holds the pending state and then CLOSES — pressing Unsubscribe shows the spinner the real card shows, and stops where the guide stops. 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')),
1075
1039
  open_expr: "$store.dsModals.open('unsubscribe-confirm')",
1076
1040
  glow_when: ds_glow.call("unsubscribe-confirm") } do %>
1077
1041
  <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
@@ -1083,45 +1047,6 @@
1083
1047
  </div>
1084
1048
  <% end %>
1085
1049
 
1086
- <%= render layout: "style/modal_specimen", locals: {
1087
- label: "See you later",
1088
- 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')),
1089
- open_expr: "$store.dsModals.open('unsubscribe-goodbye')",
1090
- glow_when: ds_glow.call("unsubscribe-goodbye") } do %>
1091
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1092
- <div class="text-2xl leading-none">&#9996;&#65039;</div>
1093
- <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1094
- <span class="block h-1.5 w-28 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1095
- <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
1096
- </div>
1097
- <% end %>
1098
-
1099
- <%# THE SAME BEAT, REACHED FROM SOMEWHERE ELSE. Great Username (two cards up)
1100
- celebrates INSIDE the modal that did the work. This one is what a host
1101
- shows when the quest completed somewhere with no card to advance in
1102
- place — turf's /account username change — so the celebration arrives on
1103
- its own. The toggle is the fork at its foot, which is the whole reason
1104
- it is one card and not two. %>
1105
- <%= render layout: "style/modal_specimen", locals: {
1106
- label: "Quest success (off-page)",
1107
- 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 })),
1108
- card_data: "opts: { subscribed: false }",
1109
- toggles: [{ model: "opts.subscribed", label: "Subscribed" }],
1110
- open_expr: "$store.dsModals.open('quest-success', { subscribed: opts.subscribed, seedsTotal: 25 })",
1111
- glow_when: ds_glow.call("quest-success") } do %>
1112
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1113
- <div class="text-2xl leading-none">&#127793;</div>
1114
- <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1115
- <%# the seeds bar, which is the card's middle and the engine's %>
1116
- <span class="flex gap-1">
1117
- <span class="flex-1 h-2 rounded" style="background: var(--color-cta)"></span>
1118
- <span class="flex-1 h-2 rounded" style="background: var(--color-cta); opacity:.4"></span>
1119
- <span class="flex-1 h-2 rounded" style="background: var(--color-cta); opacity:.2"></span>
1120
- </span>
1121
- <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
1122
- </div>
1123
- <% end %>
1124
-
1125
1050
  <%# newsletter-email — the branch a wallet-only account has to take: no
1126
1051
  email on file, so the quest asks for one mid-flight. %>
1127
1052
  <%= render layout: "style/modal_specimen", locals: {
@@ -1227,25 +1152,11 @@
1227
1152
  </div>
1228
1153
  <% end %>
1229
1154
 
1230
- <%# WALLET SETUP — new 2026-08-24, and moved here from Onboarding the same
1231
- day. It is grouped with the wallet cards rather than with the signup
1232
- chain because that is what it IS: getting a wallet. The card still
1233
- renders a 3-of-3 pill, which is the host's chain position and not this
1234
- section's business.
1235
- ONE card with a "Wallet detected" toggle rather than two cards sharing a
1236
- modal id: the two Sign Wallet cards below are what that shape costs. %>
1237
-
1238
1155
  <%# SIGN WALLET — the web2-session-on-a-wallet-account card. NOT part of the
1239
1156
  walk above: that walk is one on-chain transaction, this is an AUTH state
1240
1157
  that precedes any of it. Two specimens because the card has exactly two
1241
1158
  shapes, and the second is what every wallet linked before a host recorded
1242
- brands will see.
1243
-
1244
- OPPOSITE POPULATION to the "Setup Wallet" card two up in this
1245
- section, and worth stating because the two look alike: that one asks
1246
- "get a wallet", this one asks "prove the wallet you already have".
1247
- WalletSetupPolicy exits at its own step 1 for an account that holds a
1248
- wallet, so no user can ever meet both. %>
1159
+ brands will see. %>
1249
1160
  <%= render layout: "style/modal_specimen", locals: {
1250
1161
  label: "Sign Wallet",
1251
1162
  reference: %(the Web3 "Sign Wallet" card (solana-studio solana_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' })),
@@ -1355,25 +1266,6 @@
1355
1266
  </div>
1356
1267
  <% end %>
1357
1268
 
1358
- <%# THE OTHER FAILURE, and it is a different KIND of failure from the one
1359
- above. On-chain error is the chain saying no. This is the SERVER saying
1360
- no — refusing to co-sign because the transaction it was handed did not
1361
- match the entry it prepared. They sit together because a reviewer
1362
- reading either alone would not know the other existed. %>
1363
-
1364
- <%# Wallet deposit — standalone top-up card (not part of the walk). %>
1365
- <%= render layout: "style/modal_specimen", locals: {
1366
- label: "Wallet deposit",
1367
- 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…' })),
1368
- open_expr: "$store.dsModals.open('wallet-deposit', { neededCents: 500, usdcCents: 120, usdtCents: 0, address: 'Fo1L5demoWALLETaddr9xQ2' })",
1369
- disabled: !web3_on, openable: true } do %>
1370
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1371
- <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1372
- <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
1373
- <span class="block h-5 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
1374
- </div>
1375
- <% end %>
1376
-
1377
1269
  <%# ds-wallet-topup — the FIRST funds modal, and a safety fork. %>
1378
1270
  <%= render layout: "style/modal_specimen", locals: {
1379
1271
  label: "Top up wallet",
@@ -1421,8 +1313,6 @@
1421
1313
  </div>
1422
1314
  <% end %>
1423
1315
 
1424
-
1425
-
1426
1316
  <%# Entry confirmed — the GENERIC web3 celebration (tx link + heading + drain
1427
1317
  CTA). seedsEarned: 0 so no seeds bar even where leveling is on: this card
1428
1318
  demos the clean success a web3-only app gets. The seeds variant lives in
@@ -1442,32 +1332,6 @@
1442
1332
  </div>
1443
1333
  <% end %>
1444
1334
 
1445
- <%# network-guard — the explicit acknowledgement before a wallet request.
1446
- Continue stays INERT until the box is ticked and Cancel carries equal
1447
- weight: a confirmation whose safe path is harder to reach is theatre. %>
1448
- <%= render layout: "style/modal_specimen", locals: {
1449
- label: "Network guard",
1450
- reference: %(the Web3 network confirmation (style/modals/_network_guard) — turf-monster's modals/_network_guard, shown before a wallet request when the app wants the network acknowledged. Composes the engine's blocks/_card_header and nothing else: the engine detects no network and no environment, so every label here is app-supplied and only ever DISPLAYED. A request signed against the wrong network looks identical to a correct one until it is irreversible, so the card NAMES both facts, keeps Continue inert until an explicit tick, and gives Cancel equal visual weight rather than burying it. Open with $store.dsModals.open('network-guard', { networkLabel: 'devnet', environmentLabel: 'QA' })),
1451
- open_expr: "$store.dsModals.open('network-guard', { networkLabel: 'devnet', environmentLabel: 'QA' })",
1452
- glow_when: ds_glow.call("network-guard"),
1453
- disabled: !web3_on, openable: true } do %>
1454
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1455
- <div class="text-2xl leading-none">⚠️</div>
1456
- <%# the two named facts, then the tick that gates Continue %>
1457
- <span class="block h-1.5 w-full rounded" style="background: var(--color-text); opacity: .3"></span>
1458
- <span class="block h-1.5 w-full rounded" style="background: var(--color-text); opacity: .3"></span>
1459
- <span class="flex items-center gap-1.5">
1460
- <span class="block h-2.5 w-2.5 rounded-sm border" style="border-color: var(--color-border-strong)"></span>
1461
- <span class="block h-1.5 flex-1 rounded" style="background: var(--color-text); opacity: .18"></span>
1462
- </span>
1463
- <%# equal weight, deliberately %>
1464
- <span class="grid grid-cols-2 gap-1.5">
1465
- <span class="block h-4 rounded border" style="border-color: var(--color-border-strong)"></span>
1466
- <span class="block h-4 rounded" style="background: var(--color-cta); opacity: .5"></span>
1467
- </span>
1468
- </div>
1469
- <% end %>
1470
-
1471
1335
  </div>
1472
1336
  </section>
1473
1337
 
@@ -1,12 +1,20 @@
1
1
  <%#
2
- Living style guide specimen — the unsubscribe confirmation, and the handoff to
3
- its goodbye beat.
2
+ Living style guide specimen — the unsubscribe confirmation.
4
3
 
5
4
  APP-SPECIFIC FLOW, engine chrome only. The real card POSTs the host's
6
- newsletter_unsubscribe_path and swaps to the goodbye card on success; the engine
7
- owns no newsletter and no endpoint. Here the POST resolves locally so the guide
8
- can walk the handoff with no backend the SWAP is the thing worth reviewing,
9
- and you cannot see a handoff by looking at either card alone.
5
+ newsletter_unsubscribe_path and swaps to the GOODBYE card on success; the engine
6
+ owns no newsletter and no endpoint. Here the POST resolves locally so the
7
+ pending state is walkable with no backend, and then the card CLOSES.
8
+
9
+ IT USED TO SWAP, and the swap was the point.
10
+ `$store.dsModals.swap('unsubscribe-goodbye')` was correct only while this
11
+ guide also carried a goodbye MIRROR; that mirror was retired on 2026-09-09
12
+ (turf-monster cards the real goodbye beat in its own section of this guide),
13
+ and a swap to an id no template registers opens an EMPTY panel — the exact
14
+ failure the cabinet exists to catch, wearing a working card's clothes. Closing
15
+ is the honest stop: it shows every state this guide still owns and claims no
16
+ beat it cannot render. Do not restore the swap without restoring a
17
+ registration for its target.
10
18
 
11
19
  THE BOTTOM LINK IS THE POINT OF THE CARD. "Close" is the never-mind out, and it
12
20
  is deliberately the quiet option while the destructive action wears the primary
@@ -24,10 +32,12 @@
24
32
  this.saving = true;
25
33
  var s = this;
26
34
  // Demo: the real card awaits a POST. Hold briefly so the pending state
27
- // is visible rather than skipped, then hand off exactly as it does.
35
+ // is visible rather than skipped. The real card then swaps to the
36
+ // goodbye beat; this guide no longer carries that mirror, so it stops
37
+ // here rather than swapping to an unregistered id (see the header).
28
38
  setTimeout(function () {
29
39
  s.saving = false;
30
- $store.dsModals.swap('unsubscribe-goodbye');
40
+ $store.dsModals.close();
31
41
  }, 450);
32
42
  }
33
43
  }">
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.74.3"
2
+ VERSION = "0.74.4"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.74.3
4
+ version: 0.74.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -522,23 +522,18 @@ files:
522
522
  - app/views/style/modals/_auth.html.erb
523
523
  - app/views/style/modals/_birthday.html.erb
524
524
  - app/views/style/modals/_ds_close_x.html.erb
525
- - app/views/style/modals/_ds_newsletter_success.html.erb
526
525
  - app/views/style/modals/_ds_onramp_hub.html.erb
527
526
  - app/views/style/modals/_ds_rail_row.html.erb
528
527
  - app/views/style/modals/_ds_wallet_topup.html.erb
529
528
  - app/views/style/modals/_email_change_pending.html.erb
530
529
  - app/views/style/modals/_entry_tokens.html.erb
531
530
  - app/views/style/modals/_it_begins.html.erb
532
- - app/views/style/modals/_network_guard.html.erb
533
531
  - app/views/style/modals/_newsletter_email.html.erb
534
532
  - app/views/style/modals/_onchain_tx.html.erb
535
- - app/views/style/modals/_quest_success.html.erb
536
533
  - app/views/style/modals/_rate_limit_general.html.erb
537
534
  - app/views/style/modals/_unsubscribe_confirm.html.erb
538
- - app/views/style/modals/_unsubscribe_goodbye.html.erb
539
535
  - app/views/style/modals/_wallet_connect.html.erb
540
536
  - app/views/style/modals/_wallet_connect_slot.html.erb
541
- - app/views/style/modals/_wallet_deposit.html.erb
542
537
  - app/views/theme_settings/edit.html.erb
543
538
  - app/views/user_mailer/magic_link.html.erb
544
539
  - app/views/user_mailer/magic_link.text.erb
@@ -1,67 +0,0 @@
1
- <%#
2
- Living style guide specimen — Subscribed!, the standalone newsletter
3
- celebration.
4
-
5
- APP-SPECIFIC, engine chrome (blocks/_card_header at :lg). Distinct from the
6
- Profile Leveling "Subscribed!" beat, which is the leveling activity celebrating
7
- IN PLACE on the card the person was already on. This one arrives on its own —
8
- swapped in from the subscribe modal, or opened from the /account newsletter
9
- row — so it has to carry its own way forward.
10
-
11
- TWO FORKS, AND NEITHER IS VISIBLE TO A READER OF THE MARKUP. That is the whole
12
- reason this card exists separately rather than being folded into the leveling
13
- beat:
14
-
15
- · THE SEEDS BAR IS FIRST-JOIN ONLY. A re-subscribe earns nothing, and the
16
- card HIDES the bar rather than showing a flat "0 seeds" — which would read
17
- to the person as a bug about their own account rather than as an accurate
18
- zero.
19
- · THE CTA IS SERVER-DECIDED. If the username quest is still open
20
- (current_user.next_quest == :username) the button nudges straight into it;
21
- otherwise it sends them off to browse contests. Showing only one of those
22
- hides the funnel that this celebration doubles as.
23
-
24
- Both are gated on props here so the cabinet can walk all four combinations.
25
- In the app the CTA fork is SERVER-rendered, precisely so the button never
26
- flashes the wrong option at someone whose quest is already done.
27
-
28
- Single root: the outer <div> is the host's required root.
29
- %>
30
- <div x-data="{
31
- get props() { var c = $store.dsModals.current(); return (c && c.props) || {} },
32
- get firstJoin() { return !!this.props.firstJoin },
33
- get questOpen() { return !!this.props.questOpen }
34
- }">
35
- <%= render "studio/modals/blocks/card_header",
36
- size: :lg,
37
- icon_emoji: "🌱",
38
- title: "Subscribed!",
39
- subtitle: "You're on the list. We'll keep the sports news and contest updates coming." %>
40
-
41
- <%# First join only. The engine's :leveling seeds bar — the bar is the
42
- engine's, the numbers are the app's. %>
43
- <template x-if="firstJoin">
44
- <div class="mb-5">
45
- <%= render "studio/modals/blocks/seeds_bar",
46
- seeds_earned_key: "25",
47
- seeds_total_key: "125",
48
- seeds_per_level: 100,
49
- free_entry_label: "Free Entry Token 🎟️" %>
50
- </div>
51
- </template>
52
-
53
- <template x-if="questOpen">
54
- <button type="button" @click="$store.dsModals.close()"
55
- class="btn btn-primary btn-lg w-full">Next Quest</button>
56
- </template>
57
-
58
- <template x-if="!questOpen">
59
- <button type="button" @click="$store.dsModals.close()"
60
- class="btn btn-primary btn-lg w-full">Check Out Contests</button>
61
- </template>
62
-
63
- <button type="button" @click="$store.dsModals.close()"
64
- class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
65
- Close
66
- </button>
67
- </div>
@@ -1,55 +0,0 @@
1
- <%#
2
- Living style guide specimen — the network confirmation before a wallet request.
3
-
4
- APP-SPECIFIC, engine chrome only (`blocks/_card_header`). The engine knows
5
- nothing about networks or environments; every label here is app-supplied and
6
- only ever DISPLAYED.
7
-
8
- THE CHECKBOX IS THE POINT, and it is not friction for its own sake. A wallet
9
- request signed against the wrong network is the kind of mistake that looks
10
- identical to a correct one until it is irreversible — mainnet money moved on a
11
- devnet assumption, or the reverse. So the card does three things a plain
12
- confirm cannot: it NAMES both the network and the environment, it makes
13
- Continue INERT until the person has ticked an explicit acknowledgement, and it
14
- gives Cancel equal visual weight rather than burying it.
15
-
16
- A confirmation whose safe path is harder to reach than its risky one is a
17
- formality. This one is deliberately not.
18
-
19
- Single root; x-data single-quoted throughout.
20
- %>
21
- <div x-data="{
22
- checked: false,
23
- get props() { var c = $store.dsModals.current(); return (c && c.props) || {} }
24
- }">
25
- <%= render "studio/modals/blocks/card_header",
26
- icon_emoji: "⚠️",
27
- title_key: "props.title || 'Check Network'" do %>
28
- <p class="text-sm text-body"
29
- x-text="props.message || 'This request will be signed on the network shown below.'"></p>
30
- <% end %>
31
-
32
- <%# Both facts, named. The app supplies them; the engine never detects either. %>
33
- <div class="rounded-lg border border-subtle bg-surface-alt p-3 mb-4 text-sm">
34
- <div class="flex items-center justify-between gap-3">
35
- <span class="text-secondary">App network</span>
36
- <span class="font-semibold text-heading" x-text="props.networkLabel || 'devnet'"></span>
37
- </div>
38
- <div class="flex items-center justify-between gap-3 mt-2">
39
- <span class="text-secondary">Environment</span>
40
- <span class="font-semibold text-heading" x-text="props.environmentLabel || 'QA'"></span>
41
- </div>
42
- </div>
43
-
44
- <label class="flex items-start gap-3 rounded-lg border border-subtle p-3 mb-4 cursor-pointer">
45
- <input type="checkbox" x-model="checked" class="mt-1">
46
- <span class="text-sm text-secondary">I understand this wallet request will use the network shown above.</span>
47
- </label>
48
-
49
- <%# Equal weight, deliberately. Cancel is not the quiet option here. %>
50
- <div class="grid grid-cols-2 gap-3">
51
- <button type="button" class="btn btn-secondary btn-lg" @click="$store.dsModals.close()">Cancel</button>
52
- <button type="button" class="btn btn-primary btn-lg" :disabled="!checked"
53
- @click="$store.dsModals.close()">Continue</button>
54
- </div>
55
- </div>
@@ -1,55 +0,0 @@
1
- <%#
2
- Living style guide specimen — the off-page quest celebration.
3
-
4
- WHY IT EXISTS ALONGSIDE "Great Username", which looks almost identical: they
5
- are the same beat reached two different ways, and the difference is where the
6
- user is standing. The leveling-activity celebration fires INSIDE the modal that
7
- did the work. This card is what a host shows when the quest completed somewhere
8
- with no quest card to advance in place — turf's /account username change — so
9
- the celebration has to arrive on its own.
10
-
11
- APP-SPECIFIC FLOW, engine chrome only. The engine lends `blocks/_card_header`
12
- and `blocks/_seeds_bar` (a :leveling primitive — the bar is the engine's, the
13
- NUMBERS are app-supplied Alpine expressions). What stays the host's: which
14
- quests exist, what they pay, and where "Next Quest" goes.
15
-
16
- THE FORK AT THE BOTTOM is the interesting part and the reason this is one card
17
- with a toggle rather than two cards. A viewer who has NOT joined the newsletter
18
- is offered it as the next quest — the celebration doubles as the funnel. One who
19
- already has is sent to the contest, where the remaining quests live. In the real
20
- card that gate is SERVER-rendered from current_user.subscribed_to_newsletter?,
21
- precisely so the CTA never flashes the wrong option at someone already in.
22
-
23
- Single root: the outer <div> is the host's required root.
24
- %>
25
- <div x-data="{ get props() { var c = $store.dsModals.current(); return (c && c.props) || {} } }">
26
- <%= render "studio/modals/blocks/card_header",
27
- size: :lg,
28
- icon_emoji: "🌱",
29
- title: "Great Username",
30
- subtitle: "Nice — you earned 25 seeds by completing a quest. Complete more quests to earn a Free Entry Token." %>
31
-
32
- <%# The engine's bar, fed demo numbers. A host passes its own live totals; the
33
- bar decides on its own whether a level was crossed and runs the flourish. %>
34
- <div class="mb-5">
35
- <%= render "studio/modals/blocks/seeds_bar",
36
- seeds_earned_key: "25",
37
- seeds_total_key: "props.seedsTotal || 25",
38
- free_entry_label: "Free Entry Token 🎟️" %>
39
- </div>
40
-
41
- <template x-if="props.subscribed">
42
- <button type="button" @click="$store.dsModals.close()"
43
- class="btn btn-primary btn-lg w-full">Next Quest</button>
44
- </template>
45
- <template x-if="!props.subscribed">
46
- <%# Not subscribed: the celebration hands off to the newsletter quest. %>
47
- <button type="button" @click="$store.dsModals.swap('join-newsletter', { demo: true, leveling: true })"
48
- class="btn btn-primary btn-lg w-full">Next Quest</button>
49
- </template>
50
-
51
- <button type="button" @click="$store.dsModals.close()"
52
- class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
53
- Close
54
- </button>
55
- </div>
@@ -1,30 +0,0 @@
1
- <%#
2
- Living style guide specimen — the final beat after a confirmed unsubscribe.
3
-
4
- The smallest card in the guide, and it earns its place: it is the half of the
5
- newsletter story the guide could not previously show. The Profile Leveling
6
- section already walked the way IN (Join the Newsletter → Subscribed!); this and
7
- its sibling walk the way OUT. A section that only ever demonstrates opt-in is
8
- not showing the flow, it is showing the happy half of it.
9
-
10
- APP-SPECIFIC, engine chrome only: the engine owns no newsletter. It lends
11
- `blocks/_card_header` and the tone.
12
-
13
- NOTE THE COPY IS NOT A GUILT TRIP — "Change your mind anytime from your
14
- account" is a door left open, not a plea. Worth preserving if this is ever
15
- reworded.
16
-
17
- Single root: the outer <div> is the host's required root.
18
- %>
19
- <div x-data="{}">
20
- <%= render "studio/modals/blocks/card_header",
21
- size: :lg,
22
- icon_emoji: "✌️",
23
- title: "See you later",
24
- subtitle: "You're off the list. Change your mind anytime from your account." %>
25
-
26
- <button type="button" @click="$store.dsModals.close()"
27
- class="btn btn-primary btn-lg w-full">
28
- Close
29
- </button>
30
- </div>
@@ -1,56 +0,0 @@
1
- <%#
2
- Wallet deposit / top-up modal — design-system port of turf-monster's
3
- modals/_wallet_deposit, wired to the living style guide's page-scoped host
4
- ($store.dsModals). Single-card design (no step machinery): the shortfall
5
- math, a "Pay with Card" CTA, and a copy-your-address self-fund row.
6
-
7
- Self-contained: the production Stripe form + CDP onramp button are replaced
8
- with a neutral demo CTA, and the wallet address reads from props (a demo
9
- value) rather than $store.session. Opened via
10
- open('wallet-deposit', { neededCents, usdcCents, usdtCents, address }).
11
-
12
- Single root: the outer <div> is the host's required root.
13
- %>
14
- <div x-data="{ get props() { return (Alpine.store('dsModals').current() || {}).props || {} } }"
15
- class="relative">
16
-
17
- <button @click="$store.dsModals.close()"
18
- class="absolute top-0 right-0 -mt-2 -mr-2 text-secondary hover:text-heading text-xl leading-none z-10"
19
- aria-label="Close">&times;</button>
20
-
21
- <div class="text-center pt-1 mb-3">
22
- <h3 class="text-heading font-bold text-lg leading-tight">Top Up Your Wallet</h3>
23
- </div>
24
- <p class="text-xs text-secondary mb-4 text-center">
25
- You need $<span class="font-mono" x-text="((props.neededCents || 0) / 100).toFixed(2)"></span>
26
- to enter.
27
- You have $<span class="font-mono" x-text="((props.usdcCents || 0) / 100).toFixed(2)"></span> USDC
28
- + $<span class="font-mono" x-text="((props.usdtCents || 0) / 100).toFixed(2)"></span> USDT.
29
- </p>
30
-
31
- <div class="space-y-3">
32
- <%# Demo pay CTA — the production modal posts to Stripe checkout in a new
33
- tab; here it just closes (no real payment). %>
34
- <button type="button" @click="$store.dsModals.close()" class="btn btn-primary btn-lg w-full">
35
- Pay with Card
36
- </button>
37
-
38
- <%# Send-to-address — self-fund from another wallet. Address from props. %>
39
- <div class="border-t border-strong pt-3" x-data="{ copied: false }">
40
- <p class="text-xs text-secondary text-center mb-2">Or send USDC/USDT to your wallet:</p>
41
- <div class="flex items-center gap-2">
42
- <code class="flex-1 px-2 py-1 bg-surface rounded text-xs font-mono truncate"
43
- x-text="props.address || 'Fo1L5…demo…9xQ2'"></code>
44
- <button type="button"
45
- @click="navigator.clipboard && navigator.clipboard.writeText(props.address || ''); copied = true; setTimeout(() => copied = false, 1500)"
46
- class="btn btn-sm">
47
- <span x-text="copied ? 'Copied' : 'Copy'"></span>
48
- </button>
49
- </div>
50
- </div>
51
- </div>
52
-
53
- <p class="text-center text-xs text-muted mt-4">
54
- Come back here once your deposit lands &mdash; we'll refresh the balance.
55
- </p>
56
- </div>