studio-engine 0.74.1 → 0.74.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d056d9eddc4bd13c863b5ec0f3e239e7591fc16ddd2170ac5192537f5a190326
4
- data.tar.gz: 289b7050571718689aa567db5f0434be21c41ee7074b4b49d26969f16b07a815
3
+ metadata.gz: 6694226dcc9e100149692ef60162478d4927a69280343c5047c0222517f1f68c
4
+ data.tar.gz: 8e6b5c07f8bb5fa60466049f321c7be0a09168fb750766202a5d122734cb6699
5
5
  SHA512:
6
- metadata.gz: 15c2e97be72e8fe2e4e67719a569f6d456f82e961fd36b11d0c5062d8ace97836316398eef5058bf9436cdec7ec4ed33aca31ca7decf70bcfe3569dd8665e693
7
- data.tar.gz: 6dd53cf3747fdb36271afaea5a76c0dc2ee615e25f394083b3219476a5222991999edf97474e1022e9f2f584350ba05b04cbfaa01d5fe0f28ce2995f3a06d6fa
6
+ metadata.gz: 6ec39596a087d56cc64f3a211ea349bf6a00b7a21c6eb1d1c70b36136dda3f3ab7f8fe1a1f61b26593661283f12707501727d9f3d5119f01b3fa28995ece7bd7
7
+ data.tar.gz: 3d65093bf62c9088976d9ea56e756cabd7e01e65aaae06205419e337a0d8b6a1a1b93c60b93631bf724d254f01bcdc5fb0c7360c50525c1f519b35b6b7b49946
data/CHANGELOG.md CHANGED
@@ -6,6 +6,84 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
6
6
 
7
7
  ### Fixed
8
8
 
9
+ - **The email banner assembled two attributes INCLUDING THEIR OWN QUOTES in Ruby,
10
+ so ERB escaping never ran on them.** `_layered_banner.html.erb` wrote
11
+ `background="..."` and `bgcolor="..."` as Ruby strings and marked them
12
+ `html_safe` — the one shape ERB's attribute escaping can never reach. Both now
13
+ go through `tag.attributes`. This is the EMAIL half of the family PR #303 closed
14
+ in the modal/Alpine partials.
15
+
16
+ **THE RULE IS THE OPPOSITE OF #303'S, and that is the whole point.** There the
17
+ locals are Alpine EXPRESSIONS: they stay `html_safe` so a handler reaches the
18
+ browser byte-identical. A background URL and a colour are CONTENT, so they take
19
+ the FULL escaping a content attribute is owed — the treatment #303 gave the four
20
+ input constraints. Mark by what the value IS, not by which file it lives in.
21
+
22
+ **MEASURED, and worse than the sibling seams.** A double quote in
23
+ `background_url` did not merely inject an attribute, it ATE the rest of the tag:
24
+ the cell came back carrying `background`, `quote"`, a lone backslash, `<` and
25
+ `script`, having LOST `bgcolor`, `width`, `height`, `valign` and `style`
26
+ outright — five attributes gone from one quote. The failure is
27
+ a structurally wrecked email — no fallback colour, no dimensions, no
28
+ background-image CSS — not merely a tainted one.
29
+
30
+ **`bgcolor` WAS CLASSIFIED AND CLEARED BEFORE BEING TOUCHED**, because #303's
31
+ warning was that two of its six candidate sites were already safe.
32
+ `scrim_solid_hex` formats three integers the model has already clamped to 0-255,
33
+ so it returns a hash and six hex digits for every input — proven by rendering a
34
+ hostile scrim, not by reading the method. It is repaired anyway, and the reason
35
+ is measured: simulating the plausible refactor (the method learning to pass a
36
+ named colour through) injects an `onload` into every email with the attribute
37
+ hand-assembled, and merely renders a wrong colour with `tag.attributes` on it.
38
+
39
+ **NOTHING MOVED FOR REAL INPUT.** Seven banner shapes rendered before and after,
40
+ in both email and preview mode, are byte-identical. The one input whose bytes
41
+ change is a signed CDN URL, whose `&` now ships as `&amp;` — a correction, since
42
+ the CSS twin and the VML `src` on the SAME element already escaped it and the
43
+ hand-assembled `background` was the odd one out shipping a bare `&`. Verified in
44
+ a real browser: the client decodes the attribute, requests the URL with its
45
+ signature intact, and paints the banner.
46
+
47
+ - **The Rails guard sweep's non-vacuity check tested a COPY of the scanner, so
48
+ the scanner could go blind and the suite stayed green.**
49
+ `rails_guard_sweep_test.rb` is a source scan, and a scan is only trustworthy
50
+ with a control proving its predicate can still say NO. That control re-typed
51
+ the rule as string operations on a sample instead of calling the scan's own
52
+ path: the predicate existed twice, at line 78 and line 101. Measured — typing
53
+ one typo into line 78 ALONE (`\bRails\.` → `\bRailz\.`), which blinds the net so
54
+ it can never flag anything no matter what `lib/` contains, left all three tests
55
+ passing, 0 failures. The floor was being measured against a duplicate
56
+ implementation, so it certified the copy and never the instrument.
57
+
58
+ **THE REPAIR IS ONE DEFINITION, NOT A BETTER COPY.** The rule now lives in a
59
+ single `offending_line?`, and both the scan and every control call it. The same
60
+ mutation now reddens `test_the_predicate_still_flags_the_shape_that_was_fixed`
61
+ by name — while the scan itself still passes, which is precisely the "passes for
62
+ free" the control exists to catch.
63
+
64
+ **THE CONTROLS ARE SPLIT ONE PER CLAUSE**, because `offending_line?` decides on
65
+ four (comment exemption, mentions `defined?(Rails)`, not already asking
66
+ `Rails.respond_to?`, and calls a method on the constant) and one combined
67
+ assertion would go red without saying which clause went inert — the same
68
+ blindness as the copy, one level in. Each of the four now reddens its own named
69
+ test. The scan also asserts its EXIT-BLINDNESS FLOOR inside the test that trusts
70
+ the result — the three named files were swept, and more than 1,000 lines were
71
+ read — because a scan whose loop never runs reports zero offenders and passes
72
+ having proved nothing. Six mutations, applied one at a time, each verified red.
73
+
74
+ **TWO CLAIMS IN THE ENTRIES BELOW WERE FALSE, and are corrected in this same
75
+ change.** *The Rails guard sweep really is finished now* said a bare
76
+ `require "action_view"` is what defines the namespace-only `module Rails`. It is
77
+ not — measured, that require leaves `Rails` UNDEFINED and never loads
78
+ rails-html-sanitizer. What defines it is naming a constant under
79
+ `ActionView::Helpers`, which autoloads the helpers tree; in `studio/js_literal`
80
+ that is line 66's `extend ActionView::Helpers::JavaScriptHelper`. The same loose
81
+ sentence had propagated into three test files and is corrected there too. And
82
+ *`Studio::S3` no longer mistakes a Rails NAMESPACE* said `lib/studio.rb` used the
83
+ repaired form "in three places" — the sweep in this same unreleased block made it
84
+ FOUR, so a false completeness claim was shipping one sentence above the entry
85
+ announcing a fix for exactly that.
86
+
9
87
  - **A quote in a host local can no longer close an Alpine attribute and turn the
10
88
  rest of the element into markup.** Four partials built an attribute — its OWN
11
89
  QUOTES included — as a Ruby String and marked it `html_safe`, which is the one
@@ -81,9 +159,16 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
81
159
  `Studio.routes`. All three now ask `Rails.respond_to?` first.
82
160
 
83
161
  **THE MAIL TRANSPORT ONE WAS ARMED, not theoretical.** `lib/studio.rb` requires
84
- `studio/js_literal` (line 11) whose first line is `require "action_view"`,
85
- which is what defines the namespace-only `module Rails` — BEFORE it requires
86
- `studio/mail_transport` (line 25). Measured at this branch's head, a bare
162
+ `studio/js_literal` (line 11) BEFORE it requires `studio/mail_transport`
163
+ (line 25), and loading `js_literal` is what puts the namespace-only `module
164
+ Rails` in memory. **Not its `require "action_view"`** — measured, that require
165
+ alone leaves `Rails` UNDEFINED and never loads the sanitizer at all. It is line
166
+ 66, `extend ActionView::Helpers::JavaScriptHelper`: naming anything under
167
+ `ActionView::Helpers` autoloads the helpers tree, which requires
168
+ rails-html-sanitizer, and that gem is what ships `module Rails` — with
169
+ `Rails.methods(false) == []`, no singleton methods at all. The distinction is
170
+ not pedantry: a file that requires `action_view` and never reaches into
171
+ `Helpers` does not arm this trap. Measured at this branch's head, a bare
87
172
  `Studio::MailTransport.configure!(env: {}, action_mailer: mailer)` died with
88
173
  `NoMethodError: undefined method 'env' for module Rails`. No shipped app can
89
174
  reach it (every host configures mail from an initializer, where `Rails.env`
@@ -467,10 +552,12 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
467
552
  long before any application does — ships a namespace-only `module Rails` with no
468
553
  singleton methods, so that guard reads true and the next call raises
469
554
  `NoMethodError: undefined method 'env' for module Rails`. It now asks
470
- `Rails.respond_to?(:env)`, which is the form `lib/studio.rb` already uses in three
471
- places. **This entry originally called it "the straggler"; that was wrong**
472
- three more sites carried the bare form, swept in *The Rails guard sweep really is
473
- finished now* above. No shipped app can reach it every host boots a
555
+ `Rails.respond_to?(:env)`, which is the form `lib/studio.rb` now uses in FOUR
556
+ places: the three it already carried (lines 701, 707 and 742) plus the route
557
+ guard at line 859. **This entry originally called it "the straggler"; that was
558
+ wrong** three more sites carried the bare form, line 859 among them, and all
559
+ three are swept in *The Rails guard sweep really is finished now* above, in this
560
+ same unreleased block. No shipped app can reach it — every host boots a
474
561
  real application — but the engine's own pure-Ruby unit lane can, and it did:
475
562
  adding one `require` for a file that needs `action_view` turned an untouched
476
563
  `email_catalog_test` red with two errors about email uploads.
@@ -66,7 +66,25 @@
66
66
  <table role="presentation" width="<%= banner.width %>" cellpadding="0" cellspacing="0" border="0"
67
67
  style="width:<%= banner.width %>px;border-collapse:collapse;">
68
68
  <tr>
69
- <td <%= %(background="#{banner.background_url}").html_safe if banner.background_url.present? %>
69
+ <%# THE BACKGROUND ATTRIBUTE, EMITTED BY tag.attributes. It was assembled as a
70
+ Ruby string carrying its OWN quotes and then marked html_safe, which is the
71
+ one shape ERB's attribute escaping can never reach.
72
+
73
+ MEASURED on the unrepaired template, not reasoned about: a double quote in
74
+ background_url closed the attribute, and the browser read the remainder of
75
+ the tag as markup. The td came back carrying attributes named `quote"`,
76
+ `\`, `<` and `script`, and LOST bgcolor, width, height, valign and style
77
+ outright. That is worse than an injected attribute — the cell loses the
78
+ fallback colour and both dimensions the whole banner is laid out from, so
79
+ the failure is a wrecked email, not just a tainted one.
80
+
81
+ The URL is CONTENT, not code, so it is passed UNMARKED and takes the full
82
+ escaping a content attribute is owed. That also closes the latent half: a
83
+ bare ampersand in a signed-URL query string used to reach the client as the
84
+ start of an entity. Contrast the Alpine handlers elsewhere in this engine,
85
+ which stay html_safe precisely because they are expressions the host wrote
86
+ on purpose. The rule follows what the value IS, not which file it is in. %>
87
+ <td <%= tag.attributes(background: banner.background_url) if banner.background_url.present? %>
70
88
  bgcolor="<%= Studio.theme_primary %>"
71
89
  width="<%= banner.width %>" height="<%= banner.height %>" valign="middle"
72
90
  style="width:<%= banner.width %>px;height:<%= banner.height %>px;<%= "background-image:url(#{banner.background_url});background-size:cover;background-position:center;background-repeat:no-repeat;" if banner.background_url.present? %>">
@@ -97,7 +115,24 @@
97
115
  contrast case the scrim exists for, in the one client that
98
116
  cannot be spot-checked. Every other client honours the rgba
99
117
  declaration and overrides the flat attribute. %>
100
- <%= %(bgcolor="#{banner.scrim_solid_hex}").html_safe if banner.scrim_opacity.positive? %>
118
+ <%# SAME SHAPE AS THE background ABOVE, AND — stated honestly — NOT
119
+ THE SAME LIVE DEFECT. This one was assembled with its own quotes
120
+ and marked html_safe too, but the value cannot currently carry a
121
+ quote to exploit it: scrim_solid_hex formats three integers the
122
+ model has already clamped to 0-255, so it returns a hash sign
123
+ and six hex digits for EVERY input. Checked by rendering rather
124
+ than by reading, which is the only way that claim is worth
125
+ anything: a scrim of `0.5" onload="alert(1)` still came back as
126
+ the hex #4C4860, with all five of this cell's attributes intact.
127
+
128
+ It is converted anyway because the SHAPE is the hazard. Safety
129
+ here rests on an implementation detail one refactor away from
130
+ changing — a scrim that ever learned to accept a named colour
131
+ would reopen it silently — and leaving a hand-assembled
132
+ attribute beside a repaired one is how the wrong pattern gets
133
+ copied next. Same encoder, same reasoning, zero byte movement
134
+ for a hex triplet. %>
135
+ <%= tag.attributes(bgcolor: banner.scrim_solid_hex) if banner.scrim_opacity.positive? %>
101
136
  style="height:<%= banner.height %>px;padding:0 <%= side_padding %>px;<%= "background-color:rgba(24,16,64,#{banner.scrim_opacity});" if banner.scrim_opacity.positive? %>">
102
137
  <% if preview || banner.header.present? %>
103
138
  <p<%= hook.call("header") %> style="margin:0 0 <%= gap_after_header %>px;font-family:Montserrat,'Segoe UI',Helvetica,Arial,sans-serif;font-size:<%= header_size %>px;line-height:<%= header_line %>px;font-weight:700;color:#ffffff;">
@@ -468,14 +468,6 @@
468
468
  resolve_expr: "$store.dsModals.close()" %></div>
469
469
  </template>
470
470
 
471
- <%# wallet-setup — registered here with the other cards this page opens.
472
- Its CARD lives in the Web3 section (it is about getting a wallet); the
473
- 3-of-3 pill it renders is the HOST's onboarding chain position, which
474
- is not this page's business. The Onboarding section that used to own
475
- it was folded into Profile on 2026-08-24. %>
476
- <template x-if="$store.dsModals.current().id === 'wallet-setup'">
477
- <div><%= render "style/modals/wallet_setup" %></div>
478
- </template>
479
471
 
480
472
  <%# --- Batch 3: the modals that had no card on EITHER page --- %>
481
473
  <template x-if="$store.dsModals.current().id === 'it-begins'">
@@ -504,21 +496,12 @@
504
496
  <template x-if="$store.dsModals.current().id === 'ds-onramp-hub'">
505
497
  <div><%= render "style/modals/ds_onramp_hub" %></div>
506
498
  </template>
507
- <template x-if="$store.dsModals.current().id === 'ds-buy-entry-token'">
508
- <div><%= render "style/modals/ds_buy_entry_token" %></div>
509
- </template>
510
- <template x-if="$store.dsModals.current().id === 'ds-cdp-ramp'">
511
- <div><%= render "style/modals/ds_cdp_ramp" %></div>
512
- </template>
513
499
  <template x-if="$store.dsModals.current().id === 'ds-newsletter-success'">
514
500
  <div><%= render "style/modals/ds_newsletter_success" %></div>
515
501
  </template>
516
502
  <template x-if="$store.dsModals.current().id === 'rate-limit-general'">
517
503
  <div><%= render "style/modals/rate_limit_general" %></div>
518
504
  </template>
519
- <template x-if="$store.dsModals.current().id === 'wallet-changed'">
520
- <div><%= render "style/modals/wallet_changed" %></div>
521
- </template>
522
505
 
523
506
  <%# --- Newsletter exit + the off-page quest celebration --- %>
524
507
  <template x-if="$store.dsModals.current().id === 'quest-success'">
@@ -534,9 +517,6 @@
534
517
  </template>
535
518
 
536
519
  <%# --- Web3 --- %>
537
- <template x-if="$store.dsModals.current().id === 'cosign-rejected'">
538
- <div><%= render "style/modals/cosign_rejected" %></div>
539
- </template>
540
520
  <%# Both web3 sign-in cards render from SOLANA-STUDIO, and both are the
541
521
  REAL shipped partials rather than style-guide forks — the same files
542
522
  turf-monster renders in production, so what is reviewed here IS the
@@ -1187,9 +1167,9 @@
1187
1167
  listed for reference but <strong>cannot be opened here</strong>. Nothing
1188
1168
  registers them, so a trigger would open an empty panel.
1189
1169
  <% end %>
1190
- The cards run in the order a player meets them &mdash; <strong>get a wallet,
1191
- prove it, spend from it</strong>: Connect wallet &rarr; Setup Wallet &rarr;
1192
- the two Sign Wallet states &rarr; Processing &rarr; success or error &rarr;
1170
+ The cards run in the order a player meets them &mdash; <strong>connect a
1171
+ wallet, prove it, spend from it</strong>: Connect wallet &rarr; the two
1172
+ Sign Wallet states &rarr; Processing &rarr; success or error &rarr;
1193
1173
  top-up &rarr; Entry confirmed. Inside that, <strong>Connect Wallet &rarr;
1194
1174
  Processing on-chain transaction &rarr; On-chain success</strong> is a
1195
1175
  <strong>walked flow</strong> and the live card <strong>glows</strong>,
@@ -1212,14 +1192,7 @@
1212
1192
  <strong>standard web3 auth button</strong>: a wallet row (brand mark, the
1213
1193
  wallet&rsquo;s own name, Installed badge, chevron) rather than a filled
1214
1194
  CTA, glowing via <code class="font-mono text-2xs">pulse-cta</code> because
1215
- it is the one thing to press. Do not confuse them with <strong>Setup
1216
- Wallet</strong>, immediately before them: that one asks an account to
1217
- <strong>get</strong> a wallet, these ask an account to
1218
- <strong>prove</strong> the one it already has &mdash; opposite populations
1219
- (<code class="font-mono text-2xs">WalletSetupPolicy</code> exits at its own
1220
- step 1 for an account that holds a wallet), so no user ever meets both.
1221
- They sit next to each other anyway, because the pair is easiest to keep
1222
- straight when you can read them side by side.
1195
+ it is the one thing to press.
1223
1196
  </p>
1224
1197
  </div>
1225
1198
  <%# ONE ordered run, read left to right as a player meets it: get a wallet,
@@ -1261,30 +1234,6 @@
1261
1234
  section's business.
1262
1235
  ONE card with a "Wallet detected" toggle rather than two cards sharing a
1263
1236
  modal id: the two Sign Wallet cards below are what that shape costs. %>
1264
- <%= render layout: "style/modal_specimen", locals: {
1265
- label: "Setup Wallet",
1266
- 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 })),
1267
- card_data: "opts: { detected: false }",
1268
- toggles: [{ model: "opts.detected", label: "Wallet detected" }],
1269
- open_expr: "$store.dsModals.open('wallet-setup', { detected: opts.detected })",
1270
- glow_when: ds_glow.call("wallet-setup"),
1271
- disabled: !web3_on, openable: true } do %>
1272
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
1273
- <span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1274
- <%# the 3-of-3 pill, filled — the whole reason this card sits here %>
1275
- <span class="flex gap-1">
1276
- <span class="flex-1 h-1.5 rounded" style="background: var(--color-cta)"></span>
1277
- <span class="flex-1 h-1.5 rounded" style="background: var(--color-cta)"></span>
1278
- <span class="flex-1 h-1.5 rounded" style="background: var(--color-cta)"></span>
1279
- </span>
1280
- <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)">
1281
- <span class="w-4 h-4 rounded shrink-0" style="background: var(--color-primary)"></span>
1282
- <span class="block h-1.5 w-8 rounded" style="background: var(--color-text); opacity: .35"></span>
1283
- </span>
1284
- <%# the walkthrough placeholder, the card's second half %>
1285
- <span class="block w-full rounded" style="height: 1.6rem; background: var(--color-text); opacity: .10"></span>
1286
- </div>
1287
- <% end %>
1288
1237
 
1289
1238
  <%# SIGN WALLET — the web2-session-on-a-wallet-account card. NOT part of the
1290
1239
  walk above: that walk is one on-chain transaction, this is an AUTH state
@@ -1411,20 +1360,6 @@
1411
1360
  no — refusing to co-sign because the transaction it was handed did not
1412
1361
  match the entry it prepared. They sit together because a reviewer
1413
1362
  reading either alone would not know the other existed. %>
1414
- <%= render layout: "style/modal_specimen", locals: {
1415
- label: "Co-sign refused",
1416
- 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')),
1417
- open_expr: "$store.dsModals.open('cosign-rejected')",
1418
- glow_when: ds_glow.call("cosign-rejected"),
1419
- disabled: !web3_on, openable: true } do %>
1420
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1421
- <div class="text-2xl leading-none">&#128737;&#65039;</div>
1422
- <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1423
- <span class="block h-1.5 w-full rounded" style="background: var(--color-text); opacity: .3"></span>
1424
- <span class="block h-1.5 w-28 mx-auto rounded" style="background: var(--color-text); opacity: .3"></span>
1425
- <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
1426
- </div>
1427
- <% end %>
1428
1363
 
1429
1364
  <%# Wallet deposit — standalone top-up card (not part of the walk). %>
1430
1365
  <%= render layout: "style/modal_specimen", locals: {
@@ -1486,41 +1421,7 @@
1486
1421
  </div>
1487
1422
  <% end %>
1488
1423
 
1489
- <%# ds-buy-entry-token — reached with a lineup picked and no way to pay. %>
1490
- <%= render layout: "style/modal_specimen", locals: {
1491
- label: "Buy entry token",
1492
- reference: %(the entry-token purchase (style/modals/_ds_buy_entry_token) — turf-monster's modals/_buy_entry_token, built from blocks/_close_x and two blocks/_rail_row. Reached when a player has picked a lineup and has no way to pay for it, which is why the copy says the lineup is SAVED: losing their picks is the one thing they are afraid of at that moment. The ranking is a PRODUCT decision rather than a visual one — Coinflow leads and carries the "New" badge because it mints a spendable entry token directly, the shortest path from here to being in the contest, while Stripe swaps into the auth wizard's tokens picker, the older and longer path, and defers. Open with $store.dsModals.open('ds-buy-entry-token')),
1493
- open_expr: "$store.dsModals.open('ds-buy-entry-token')",
1494
- glow_when: ds_glow.call("ds-buy-entry-token"),
1495
- disabled: !web3_on, openable: true } do %>
1496
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
1497
- <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1498
- <span class="flex items-center gap-1.5 rounded-md p-1.5" style="border: 2px solid var(--color-primary)">
1499
- <span class="block h-4 w-4 rounded" style="background: var(--color-cta)"></span>
1500
- <span class="block h-1.5 flex-1 rounded" style="background: var(--color-text); opacity: .3"></span>
1501
- </span>
1502
- <span class="flex items-center gap-1.5 rounded-md p-1.5 border border-subtle">
1503
- <span class="block h-4 w-4 rounded" style="background: var(--color-text); opacity: .25"></span>
1504
- <span class="block h-1.5 flex-1 rounded" style="background: var(--color-text); opacity: .18"></span>
1505
- </span>
1506
- </div>
1507
- <% end %>
1508
1424
 
1509
- <%# ds-cdp-ramp — ONE of thirteen states, and the card says so. %>
1510
- <%= render layout: "style/modal_specimen", locals: {
1511
- label: "Coinbase ramp (preflight)",
1512
- reference: %(the Coinbase ramp's BUY PREFLIGHT (style/modals/_ds_cdp_ramp) — turf-monster's modals/_cdp_ramp. DELIBERATELY PARTIAL: that modal is a TWO-FLOW step machine (buy and cash-out) across THIRTEEN states — preflight, opening, waiting, awaiting-cdp, send, settling and the rest — and this cards exactly one of them, the buy preflight, which is the state both funding cards hand off to and therefore the only one a player reaches without already being mid-flow. The other twelve are uncarded BY DECISION (operator, 2026-08-26), which is a different thing from an oversight — a cabinet that shows one state of a thirteen-state machine without admitting it teaches a reader that the machine is simpler than it is. /tasks/port-cdp-ramp-specimen holds the full state map if that decision reverses. Open with $store.dsModals.open('ds-cdp-ramp')),
1513
- open_expr: "$store.dsModals.open('ds-cdp-ramp')",
1514
- glow_when: ds_glow.call("ds-cdp-ramp"),
1515
- disabled: !web3_on, openable: true } do %>
1516
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2 relative">
1517
- <span class="absolute top-1 right-2 text-secondary leading-none">&times;</span>
1518
- <span class="block h-2 w-28 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1519
- <span class="block h-1.5 w-full rounded" style="background: var(--color-text); opacity: .12"></span>
1520
- <span class="block h-1.5 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .12"></span>
1521
- <span class="block h-4 w-full rounded" style="background: var(--color-cta)"></span>
1522
- </div>
1523
- <% end %>
1524
1425
 
1525
1426
  <%# Entry confirmed — the GENERIC web3 celebration (tx link + heading + drain
1526
1427
  CTA). seedsEarned: 0 so no seeds bar even where leveling is on: this card
@@ -1567,30 +1468,6 @@
1567
1468
  </div>
1568
1469
  <% end %>
1569
1470
 
1570
- <%# wallet-changed — the extension switched accounts underneath the session.
1571
- Both addresses are shown because recognising your own is the only way to
1572
- tell a deliberate switch from an accident. %>
1573
- <%= render layout: "style/modal_specimen", locals: {
1574
- label: "Wallet changed",
1575
- reference: %(the Web3 "Wallet changed" reconciliation (style/modals/_wallet_changed) — turf-monster's modals/_wallet_changed, fired when the person switches accounts inside their wallet extension while signed in. Composes the engine's blocks/_card_header; the switch itself belongs to turf's wallet store (continueSwitch) and the engine owns no wallet. Nothing is broken — but the session and the extension now disagree about who this is, and every on-chain action from here would use an identity the page is not showing. So it prints BOTH addresses, truncated the Solana way, because recognising your own is the one check the app cannot make for you. Continuing re-derives a session against the new wallet, which is a round trip, so the button carries a busy state rather than looking dead. Open with $store.dsModals.open('wallet-changed', { currentAddress: '…', newAddress: '…' })),
1576
- open_expr: "$store.dsModals.open('wallet-changed', { currentAddress: '7xKpDemoSignedInWallet9JZ2Q', newAddress: '9mQtDemoSwitchedWalletLd4V' })",
1577
- glow_when: ds_glow.call("wallet-changed"),
1578
- disabled: !web3_on, openable: true } do %>
1579
- <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1580
- <div class="text-2xl leading-none">🔀</div>
1581
- <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1582
- <%# the two truncated addresses — the comparison IS the card %>
1583
- <span class="flex items-center justify-between gap-2">
1584
- <span class="block h-1.5 w-10 rounded" style="background: var(--color-text); opacity: .18"></span>
1585
- <span class="block h-1.5 w-12 rounded" style="background: var(--color-text); opacity: .35"></span>
1586
- </span>
1587
- <span class="flex items-center justify-between gap-2">
1588
- <span class="block h-1.5 w-10 rounded" style="background: var(--color-text); opacity: .18"></span>
1589
- <span class="block h-1.5 w-12 rounded" style="background: var(--color-text); opacity: .35"></span>
1590
- </span>
1591
- <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
1592
- </div>
1593
- <% end %>
1594
1471
  </div>
1595
1472
  </section>
1596
1473
 
@@ -56,6 +56,26 @@ module Studio
56
56
  # the worked examples; test/lib/studio/attribute_encoding_contract_test.rb pins the
57
57
  # ActionView behaviour the whole thing rests on.
58
58
  #
59
+ # THE MARKING IS A JUDGEMENT ABOUT THE VALUE, NOT ABOUT THE FILE, and it is the
60
+ # half of that repair most easily got backwards. `tag.attributes` takes both kinds
61
+ # and does the right thing with each:
62
+ #
63
+ # EXPRESSION (an Alpine handler, a JS number, a store path) — pass it MARKED.
64
+ # Only the double quotes move, so the code the caller deliberately handed the
65
+ # engine still parses. Escaping it would be corruption of the same kind the
66
+ # script-body note above describes.
67
+ # CONTENT (a URL, a colour, a length, a regex, a tooltip sentence) — pass it
68
+ # UNMARKED and let it take the full escaping a content attribute is owed. The
69
+ # parser hands the value back decoded, and a bare `&` stops reaching the client
70
+ # as the start of an entity.
71
+ #
72
+ # Both repairs live in this engine and neither is the default: the four input
73
+ # constraints in modals/blocks/_leveling_activity are the content case beside the
74
+ # handlers in the same file, and studio/mailers/_layered_banner is the content case
75
+ # in an EMAIL, where the consumer is a mail client rather than a browser and the
76
+ # margin for a malformed attribute is smaller. Choosing by which file a value sits
77
+ # in, rather than by what the value is, gets one of those two wrong every time.
78
+ #
59
79
  # AND IT IS THE WRONG REPAIR FOR IDENTIFIER POSITION. A local spliced in as a bare
60
80
  # NAME — `$store.<%= modal_store %>.close()` — must be VALIDATED, never escaped:
61
81
  # escape_javascript also escapes `$`, so a legitimate store name like `dsModals$2`
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.74.1"
2
+ VERSION = "0.74.2"
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.1
4
+ version: 0.74.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -521,9 +521,6 @@ files:
521
521
  - app/views/style/modals/_age_gate.html.erb
522
522
  - app/views/style/modals/_auth.html.erb
523
523
  - app/views/style/modals/_birthday.html.erb
524
- - app/views/style/modals/_cosign_rejected.html.erb
525
- - app/views/style/modals/_ds_buy_entry_token.html.erb
526
- - app/views/style/modals/_ds_cdp_ramp.html.erb
527
524
  - app/views/style/modals/_ds_close_x.html.erb
528
525
  - app/views/style/modals/_ds_newsletter_success.html.erb
529
526
  - app/views/style/modals/_ds_onramp_hub.html.erb
@@ -539,11 +536,9 @@ files:
539
536
  - app/views/style/modals/_rate_limit_general.html.erb
540
537
  - app/views/style/modals/_unsubscribe_confirm.html.erb
541
538
  - app/views/style/modals/_unsubscribe_goodbye.html.erb
542
- - app/views/style/modals/_wallet_changed.html.erb
543
539
  - app/views/style/modals/_wallet_connect.html.erb
544
540
  - app/views/style/modals/_wallet_connect_slot.html.erb
545
541
  - app/views/style/modals/_wallet_deposit.html.erb
546
- - app/views/style/modals/_wallet_setup.html.erb
547
542
  - app/views/theme_settings/edit.html.erb
548
543
  - app/views/user_mailer/magic_link.html.erb
549
544
  - app/views/user_mailer/magic_link.text.erb
@@ -1,36 +0,0 @@
1
- <%#
2
- Living style guide specimen — the "we did not co-sign that" refusal.
3
-
4
- APP-SPECIFIC FLOW, engine chrome only. What this card names is turf-monster's:
5
- POST confirm_onchain_entry answered 422 with code `tx_rejected`, meaning the
6
- server refused to co-sign because the submitted transaction did not match the
7
- entry it had prepared. The engine owns no entry preparation, no co-signing and
8
- no verdict — it lends `blocks/_card_header` and nothing else, exactly as
9
- style/modals/_entry_tokens borrows chrome for a flow the engine does not own.
10
-
11
- WHY THE COPY IS VAGUE, and why a reviewer should not "improve" it: good actors
12
- never see this card. It fires on a tampered or mismatched transaction, so the
13
- copy is deliberately reassuring and deliberately silent about WHICH
14
- server-side check failed — naming the check would help an attacker probe the
15
- validation. The reassurance ("nothing was submitted and your funds are safe")
16
- is the part that matters to the one honest user who ever hits it.
17
-
18
- Single root: the outer <div> is the host's required root.
19
- %>
20
- <div x-data="{}">
21
- <%= render "studio/modals/blocks/card_header",
22
- icon_emoji: "🛡️",
23
- title: "Transaction not signed" do %>
24
- <p class="text-sm text-body">
25
- For your security we didn&rsquo;t co-sign this transaction &mdash; it
26
- didn&rsquo;t match the entry we prepared, so nothing was submitted and your
27
- funds are safe. Please close this and try entering again. If it keeps
28
- happening, <span class="text-primary">contact support</span>.
29
- </p>
30
- <% end %>
31
-
32
- <%# One button, because there is exactly one thing to do. The host also closes
33
- on escape and click-outside; this card is dismissible by design. %>
34
- <button type="button" @click="$store.dsModals.close()"
35
- class="btn btn-primary btn-lg w-full">Got it</button>
36
- </div>
@@ -1,40 +0,0 @@
1
- <%#
2
- Living style guide specimen — Buy an Entry Token.
3
-
4
- APP-SPECIFIC flow, engine chrome throughout (blocks/_close_x + two
5
- blocks/_rail_row). Reached when a player has picked a lineup and has no way to
6
- pay for it, which is why the copy says the lineup is SAVED: the one thing they
7
- are afraid of at that moment is losing their picks.
8
-
9
- TWO RAILS, RANKED, and the ranking is a product decision rather than a visual
10
- one. Coinflow leads and carries the "New" badge because it mints a spendable
11
- entry token directly — it is the shortest path from here to being in the
12
- contest. Stripe still swaps into the auth wizard's tokens picker, which is the
13
- older, longer path, so it defers.
14
-
15
- Single root: the outer <div> is the host's required root.
16
- %>
17
- <div class="relative">
18
- <%= render "studio/modals/blocks/close_x", modal_store: "dsModals" %>
19
-
20
- <div class="text-center pt-1 mb-2">
21
- <h3 class="text-heading font-bold text-lg leading-tight">Buy an Entry Token</h3>
22
- </div>
23
- <p class="text-xs text-body mb-4 text-center">
24
- 1 token = 1 contest entry. Your lineup is saved &mdash; pick how to pay.
25
- </p>
26
-
27
- <div class="space-y-3">
28
- <%= render "studio/modals/blocks/rail_row",
29
- emphasis: :primary, on_click: "$store.dsModals.close()",
30
- icon_label: "CF", icon_bg: "#14B8A6",
31
- title: "Coinflow · $19", subtitle: "Buy 1 entry with a card · fastest",
32
- badge: "New", data: { ds_rail: "coinflow" } %>
33
-
34
- <%= render "studio/modals/blocks/rail_row",
35
- on_click: "$store.dsModals.close()",
36
- icon_label: "S", icon_bg: "#635BFF",
37
- title: "Card · $19", subtitle: "Buy an entry token with a card",
38
- data: { ds_rail: "stripe" } %>
39
- </div>
40
- </div>
@@ -1,41 +0,0 @@
1
- <%#
2
- Living style guide specimen — the Coinbase ramp, BUY PREFLIGHT only.
3
-
4
- DELIBERATELY PARTIAL, and saying so is the point. turf's modals/_cdp_ramp is a
5
- TWO-FLOW step machine — buy and cash-out — across thirteen states: preflight,
6
- opening, waiting, awaiting-cdp, send, settling and the rest. This cards ONE of
7
- them: the buy preflight, which is the state both funding cards hand off to and
8
- therefore the only one a player reaches without already being mid-flow.
9
-
10
- The other twelve are uncarded BY DECISION (operator, 2026-08-26: minimal
11
- migration is fine here). That is a different thing from an oversight, and the
12
- reference blurb on this card says so, because a cabinet that shows one state of
13
- a thirteen-state machine without admitting it teaches a reader that the machine
14
- is simpler than it is. /tasks/port-cdp-ramp-specimen holds the full state map
15
- if the decision reverses.
16
-
17
- APP-SPECIFIC, engine chrome: blocks/_close_x plus the centred title this whole
18
- funding family uses.
19
-
20
- Single root: the outer <div> is the host's required root.
21
- %>
22
- <div class="relative">
23
- <%= render "studio/modals/blocks/close_x", modal_store: "dsModals" %>
24
-
25
- <div class="text-center pt-1 mb-3">
26
- <h3 class="text-heading font-bold text-lg leading-tight">Buy USDC with Coinbase</h3>
27
- </div>
28
-
29
- <p class="text-xs text-body mb-4 text-center">
30
- You&rsquo;ll finish the purchase in a Coinbase tab. We&rsquo;ll update here
31
- automatically once the USDC lands in your wallet.
32
- </p>
33
-
34
- <button type="button" @click="$store.dsModals.close()"
35
- class="btn btn-primary btn-lg w-full">Continue to Coinbase</button>
36
-
37
- <button type="button" @click="$store.dsModals.close()"
38
- class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
39
- Not now
40
- </button>
41
- </div>
@@ -1,70 +0,0 @@
1
- <%#
2
- Living style guide specimen — the wallet switched underneath the session.
3
-
4
- APP-SPECIFIC, engine chrome only (`blocks/_card_header`). The switch itself is
5
- the host's: turf's wallet store owns continueSwitch, and the engine owns no
6
- wallet.
7
-
8
- THE SITUATION IT NAMES: the person changed accounts inside their wallet
9
- extension while signed in here. Nothing is broken and nobody did anything
10
- wrong — but the session and the extension now disagree about who this is, and
11
- every on-chain action from here would use an identity the page is not showing.
12
-
13
- SO IT SHOWS BOTH ADDRESSES, truncated the way every Solana UI truncates them
14
- (4 leading, 4 trailing). Recognising your own address is the only way to tell a
15
- deliberate switch from an accident, and it is the one check the app cannot make
16
- for them.
17
-
18
- The busy state matters: continuing re-derives a session against the new wallet,
19
- which is a round trip. Without it the button looks dead and gets double-clicked.
20
-
21
- Single root; x-data single-quoted throughout.
22
- %>
23
- <div x-data="{
24
- busy: false,
25
- get props() { var c = $store.dsModals.current(); return (c && c.props) || {} },
26
- short(addr) {
27
- addr = addr || '';
28
- return addr.length > 12 ? (addr.slice(0, 6) + '...' + addr.slice(-4)) : addr;
29
- },
30
- go() {
31
- if (this.busy) return;
32
- this.busy = true;
33
- var s = this;
34
- // Demo: the real card awaits the host's wallet store. Hold briefly so
35
- // the pending state is visible rather than skipped.
36
- setTimeout(function () { s.busy = false; $store.dsModals.close(); }, 600);
37
- }
38
- }">
39
- <%= render "studio/modals/blocks/card_header",
40
- icon_emoji: "🔀",
41
- title: "Wallet changed" do %>
42
- <p class="text-sm text-body">
43
- Your wallet is now on a different account than the one you signed in with.
44
- Continue with the new one, or switch back in your wallet.
45
- </p>
46
- <% end %>
47
-
48
- <div class="rounded-lg border border-subtle bg-surface-alt p-3 mb-4 text-sm">
49
- <div class="flex items-center justify-between gap-3">
50
- <span class="text-secondary">Signed in as</span>
51
- <span class="font-mono text-heading" x-text="short(props.currentAddress) || '7xKp...JZ2Q'"></span>
52
- </div>
53
- <div class="flex items-center justify-between gap-3 mt-2">
54
- <span class="text-secondary">Wallet now</span>
55
- <span class="font-mono text-heading" x-text="short(props.newAddress) || '9mQt...Ld4V'"></span>
56
- </div>
57
- </div>
58
-
59
- <button type="button" @click="go()" :disabled="busy"
60
- class="btn btn-primary btn-lg w-full disabled:opacity-60">
61
- <span x-show="!busy">Continue with this wallet</span>
62
- <span x-show="busy" class="inline-flex items-center justify-center gap-2" style="display: none;">
63
- <span class="spinner" aria-hidden="true"></span>
64
- Switching&hellip;
65
- </span>
66
- </button>
67
-
68
- <button type="button" @click="$store.dsModals.close()"
69
- class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">Not now</button>
70
- </div>
@@ -1,112 +0,0 @@
1
- <%#
2
- Living style guide specimen — the onboarding chain's LAST step, "Set up your
3
- wallet" (chain step 3 of 3).
4
-
5
- This documents an APP-SPECIFIC flow. The engine deliberately does NOT own it:
6
- turf-monster's app/views/modals/_wallet_setup.html.erb is ~576 lines of
7
- extension-presence polling, a hosted 90-second walkthrough video, a Detailed
8
- Guide route that resolves at render time, and a managed-wallet fallback for
9
- grandfathered web2 accounts. None of that is shareable — it is one app's
10
- answer to "how do we get this person a wallet".
11
-
12
- What IS shared, and all this specimen composes, is the CHROME:
13
- studio/modals/blocks/shell — title row + close
14
- studio/modals/blocks/progress_pill — the 3-of-3 chain position
15
- studio/modals/blocks/wallet_brand_sprite — inline brand marks, no per-app PNG
16
- plus the house wallet-row idiom (brand tile, the wallet's own name, status on
17
- the right, chevron) and pulse-cta, so a wallet reads identically here, in the
18
- connect picker, and on the web3 step-up card.
19
-
20
- ONE card, ONE id, a `detected` TOGGLE for the two ends of the real progression
21
- (install -> waiting -> Installed -> connect). Deliberately not two specimens
22
- sharing a modal id: that shape is what left the two web3-step-up cards unable
23
- to tell each other apart in the glow, fixed in the same pass that added this.
24
-
25
- WHO SEES IT — the half worth remembering. This card and the web3 step-up card
26
- are OPPOSITE populations and no user can ever meet both. WalletSetupPolicy
27
- exits at its own step 1 for an account that already holds a wallet; the step-up
28
- card is only ever shown to accounts that DO hold one. "Get a wallet" vs "prove
29
- the wallet you have".
30
-
31
- Self-contained: no install detection, no video embed, no real routes. The
32
- walkthrough is represented as a still placeholder rather than an <iframe>,
33
- because the video is the app's asset and an embed here would reach off-host.
34
-
35
- Single root: the outer <div> is the host's required root.
36
- %>
37
- <div x-data="{
38
- get props() { var c = Alpine.store('dsModals').current(); return (c && c.props) || {}; },
39
- get detected() { return !!this.props.detected; }
40
- }"
41
- class="relative">
42
- <%= render "studio/modals/blocks/wallet_brand_sprite" %>
43
-
44
- <%= render layout: "studio/modals/blocks/shell",
45
- locals: { title: "Set up your wallet", modal_store: "dsModals" } do %>
46
-
47
- <%# Chain position — the HOST's, not this page's. It is the only one of the
48
- three onboarding steps that fills the pill, which is why the pill is here
49
- even though the card sits in the Web3 section beside the wallet cards. %>
50
- <div class="mb-3">
51
- <%= render "studio/modals/blocks/progress_pill", current: 3, total: 3 %>
52
- </div>
53
-
54
- <p class="text-sm text-body text-center mb-4">
55
- This app runs on the Solana public blockchain; you will need a wallet to
56
- enter contests and collect winnings.
57
- </p>
58
-
59
- <%# THE wallet row — the house idiom, glowing because it is the one thing to
60
- press. Same shape the connect picker and the step-up card use. The right
61
- side is the only part that moves: INSTALL before the extension exists,
62
- an Installed badge once it answers. %>
63
- <button type="button" @click="$store.dsModals.close()"
64
- style="--pulse-cta-color: var(--color-primary); --pulse-cta-strength: 0.4; --pulse-cta-scale: 1.02"
65
- class="pulse-cta w-full flex items-center gap-3 p-3 rounded-xl bg-surface-alt border border-strong hover:bg-surface transition text-left">
66
- <span class="w-9 h-9 rounded-lg overflow-hidden flex items-center justify-center shrink-0">
67
- <svg class="w-9 h-9" aria-hidden="true"><use href="#se-wallet-phantom"></use></svg>
68
- </span>
69
- <span class="font-semibold text-heading">Phantom</span>
70
- <span class="ml-auto flex items-center gap-2">
71
- <span x-show="!detected" class="text-xs text-muted uppercase tracking-wide">Install</span>
72
- <span x-show="detected" class="badge border-primary text-primary">Installed</span>
73
- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-muted">
74
- <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
75
- </svg>
76
- </span>
77
- </button>
78
-
79
- <%# The teaching half. A brand-new player does not know what a wallet IS, so
80
- the card carries its own explainer rather than sending them away to find
81
- one. Represented, not embedded — see the header. %>
82
- <div class="mt-4 pt-4 border-t border-strong">
83
- <h4 class="text-heading font-bold text-sm mb-1">New to Solana &#129300;</h4>
84
- <p class="text-sm text-body mb-3">Set up your wallet in <strong>90 seconds</strong>.</p>
85
-
86
- <div class="rounded-lg border border-subtle bg-inset aspect-video flex flex-col items-center justify-center gap-1">
87
- <span class="text-2xl leading-none" aria-hidden="true">&#9654;&#65039;</span>
88
- <span class="text-2xs text-muted">walkthrough &mdash; the app supplies the video</span>
89
- </div>
90
-
91
- <button type="button" @click="$store.dsModals.close()"
92
- class="btn btn-primary btn-lg w-full mt-3 flex items-center justify-center gap-2">
93
- Detailed Guide
94
- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
95
- <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
96
- </svg>
97
- </button>
98
- </div>
99
-
100
- <%# Dismissible by design, and the fine print says what dismissing costs. The
101
- chain is skippable; the entry gate is not, and the card is honest that
102
- the two are different things. %>
103
- <button type="button" @click="$store.dsModals.close()"
104
- class="block mx-auto mt-4 text-sm text-secondary hover:text-heading transition">
105
- Maybe later
106
- </button>
107
- <p class="mt-2 text-center text-2xs text-muted">
108
- Rather pay by card? Link a wallet first &mdash; an entry token needs
109
- somewhere to land.
110
- </p>
111
- <% end %>
112
- </div>