studio-engine 0.74.1 → 0.74.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +140 -7
  3. data/app/views/studio/mailers/_layered_banner.html.erb +37 -2
  4. data/app/views/studio/modals/_saving.html.erb +2 -1
  5. data/app/views/studio/modals/_scoped_host.html.erb +3 -1
  6. data/app/views/studio/modals/auth/_resend_footer.html.erb +3 -1
  7. data/app/views/studio/modals/blocks/_age_gate.html.erb +3 -1
  8. data/app/views/studio/modals/blocks/_birthday.html.erb +7 -0
  9. data/app/views/studio/modals/blocks/_close_x.html.erb +3 -1
  10. data/app/views/studio/modals/blocks/_cta_redirect.html.erb +3 -1
  11. data/app/views/studio/modals/blocks/_entry_confirmed.html.erb +3 -1
  12. data/app/views/studio/modals/blocks/_free_entry_earned.html.erb +3 -1
  13. data/app/views/studio/modals/blocks/_leveling_activity.html.erb +3 -1
  14. data/app/views/studio/modals/blocks/_onchain_success.html.erb +3 -1
  15. data/app/views/studio/modals/blocks/_shell.html.erb +3 -1
  16. data/app/views/studio/modals/onboarding/_first_name.html.erb +16 -30
  17. data/app/views/studio/modals/templates/_action.html.erb +2 -1
  18. data/app/views/studio/modals/templates/_form.html.erb +2 -1
  19. data/app/views/studio/modals/templates/_status.html.erb +2 -1
  20. data/app/views/studio/modals/templates/_success.html.erb +2 -1
  21. data/app/views/studio/modals/templates/_wizard.html.erb +2 -1
  22. data/app/views/style/_modals.html.erb +4 -127
  23. data/lib/studio/js_identifier.rb +88 -0
  24. data/lib/studio/js_literal.rb +23 -2
  25. data/lib/studio/version.rb +1 -1
  26. data/lib/studio.rb +1 -0
  27. metadata +3 -7
  28. data/app/views/style/modals/_cosign_rejected.html.erb +0 -36
  29. data/app/views/style/modals/_ds_buy_entry_token.html.erb +0 -40
  30. data/app/views/style/modals/_ds_cdp_ramp.html.erb +0 -41
  31. data/app/views/style/modals/_wallet_changed.html.erb +0 -70
  32. data/app/views/style/modals/_wallet_setup.html.erb +0 -112
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d056d9eddc4bd13c863b5ec0f3e239e7591fc16ddd2170ac5192537f5a190326
4
- data.tar.gz: 289b7050571718689aa567db5f0434be21c41ee7074b4b49d26969f16b07a815
3
+ metadata.gz: 2719af0f10410f44f1ac53e1a17fe244de4a9c4ecae8eb94715c75a69450839b
4
+ data.tar.gz: 9c705e4cb7e9816594d2fc13732ea5449f1523e02645a1108bb4b1f1d50d887f
5
5
  SHA512:
6
- metadata.gz: 15c2e97be72e8fe2e4e67719a569f6d456f82e961fd36b11d0c5062d8ace97836316398eef5058bf9436cdec7ec4ed33aca31ca7decf70bcfe3569dd8665e693
7
- data.tar.gz: 6dd53cf3747fdb36271afaea5a76c0dc2ee615e25f394083b3219476a5222991999edf97474e1022e9f2f584350ba05b04cbfaa01d5fe0f28ce2995f3a06d6fa
6
+ metadata.gz: 18833874f77e97c16f56c20135ecdb897d8f7d47316c2d122947bfdbd71d23117f825d51fe3a62ff417099c1051d6ea0aead4650715f9212aa68b5520d266333
7
+ data.tar.gz: 85568864c586c37ef1f17edefb9f23776b1f5f2089c09f331139000e88cd2dd1e47db031d01a456c3a9b267f8aced700b9221c04d0d4e1809c4381f4b0e5f020
data/CHANGELOG.md CHANGED
@@ -6,6 +6,130 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
6
6
 
7
7
  ### Fixed
8
8
 
9
+ - **A store name that is not a JS identifier half-worked, silently, in seventeen
10
+ partials — it now raises.** `$store.<%= modal_store %>.close()` splices the local
11
+ in as a bare NAME, so `modal_store: "my store"` emitted
12
+ `$store.my store.close()`: a SyntaxError that Alpine mounts as a no-op which still
13
+ renders every element. `Studio::JsIdentifier.validate!` is the new shared contract
14
+ (`/\A[A-Za-z_$][A-Za-z0-9_$]*\z/`), called once per partial at the fetch.
15
+
16
+ **THIS IS A BEHAVIOUR TIGHTENING, NOT A REPAIR, and it is breaking.** A host that
17
+ passed a non-identifier store name got a working page with a dead button; it now
18
+ gets an `ArgumentError` at render. That is the right trade — loud beats silent —
19
+ but it is a 500 where there was a 200. **Blast radius is zero, measured:** every
20
+ explicit store name in mcritchie-studio, turf-monster, rolio and this engine is
21
+ `modals`, `dsModals`, `emailModals` or `profileModals`, and no consumer app passes
22
+ one at all. **Rollback is one file:** make `validate!` return its argument instead
23
+ of raising and every partial is back to the old leniency, with no call site to
24
+ revisit.
25
+
26
+ **ESCAPING IS THE WRONG REPAIR HERE, which is why this is a second module rather
27
+ than a second method on `Studio::JsLiteral`.** `escape_javascript` also escapes
28
+ `$`, so the legal name `dsModals$2` comes back as `dsModals\$2` and
29
+ `$store.dsModals\$2.close()` is a different SyntaxError — the same dead card on a
30
+ value that was never hostile. A pattern rather than an allowlist, deliberately: an
31
+ allowlist would be a shared gem enumerating its own consumers, and the next app to
32
+ mount a page-scoped host would be refused by its own dependency.
33
+
34
+ **MEASURED, AND TWO OF THE LISTED SITES WERE LEFT ALONE.** Nineteen partials fetch
35
+ a store-name local. Counted on the tree: 36 splices of a store name in identifier
36
+ position — 32 in ERB, 4 assembled in Ruby — across 17 files. Rendering a hostile
37
+ value through all nineteen (rather than reading them) showed 17 emitting at least
38
+ one DEAD splice, one already refusing, and one unaffected. Seventeen now carry the
39
+ guard: 16 new, plus `modals/onboarding/_first_name` moved onto the shared one.
40
+ `modals/_crop_photo` is the unaffected one — its name is a JS string argument
41
+ resolved with `$store[name]`, a lookup where a space is as legal as a letter, and
42
+ it was already escaped, so demanding an identifier would break a host for nothing.
43
+ `blocks/_birthday` has no splice of its own — it hands the name to `Alpine.store()`
44
+ and forwards it to `blocks/_shell`, which validates — so a second guard there would
45
+ have been one that survives deletion.
46
+
47
+ **NOTHING MOVED FOR A LEGAL NAME.** All nineteen partials rendered before and
48
+ after, against the default, `dsModals` and `ds_Modals$2`, are byte-identical
49
+ (186,310 bytes each): a value matching the pattern carries no character ERB
50
+ escapes, and the validator returns a plain String so ERB's escaping stays armed
51
+ for the day the pattern widens. `modals/onboarding/_first_name` carried this
52
+ regex inline as the worked example and now calls the shared one — same pattern,
53
+ same error, same message.
54
+
55
+ - **The email banner assembled two attributes INCLUDING THEIR OWN QUOTES in Ruby,
56
+ so ERB escaping never ran on them.** `_layered_banner.html.erb` wrote
57
+ `background="..."` and `bgcolor="..."` as Ruby strings and marked them
58
+ `html_safe` — the one shape ERB's attribute escaping can never reach. Both now
59
+ go through `tag.attributes`. This is the EMAIL half of the family PR #303 closed
60
+ in the modal/Alpine partials.
61
+
62
+ **THE RULE IS THE OPPOSITE OF #303'S, and that is the whole point.** There the
63
+ locals are Alpine EXPRESSIONS: they stay `html_safe` so a handler reaches the
64
+ browser byte-identical. A background URL and a colour are CONTENT, so they take
65
+ the FULL escaping a content attribute is owed — the treatment #303 gave the four
66
+ input constraints. Mark by what the value IS, not by which file it lives in.
67
+
68
+ **MEASURED, and worse than the sibling seams.** A double quote in
69
+ `background_url` did not merely inject an attribute, it ATE the rest of the tag:
70
+ the cell came back carrying `background`, `quote"`, a lone backslash, `<` and
71
+ `script`, having LOST `bgcolor`, `width`, `height`, `valign` and `style`
72
+ outright — five attributes gone from one quote. The failure is
73
+ a structurally wrecked email — no fallback colour, no dimensions, no
74
+ background-image CSS — not merely a tainted one.
75
+
76
+ **`bgcolor` WAS CLASSIFIED AND CLEARED BEFORE BEING TOUCHED**, because #303's
77
+ warning was that two of its six candidate sites were already safe.
78
+ `scrim_solid_hex` formats three integers the model has already clamped to 0-255,
79
+ so it returns a hash and six hex digits for every input — proven by rendering a
80
+ hostile scrim, not by reading the method. It is repaired anyway, and the reason
81
+ is measured: simulating the plausible refactor (the method learning to pass a
82
+ named colour through) injects an `onload` into every email with the attribute
83
+ hand-assembled, and merely renders a wrong colour with `tag.attributes` on it.
84
+
85
+ **NOTHING MOVED FOR REAL INPUT.** Seven banner shapes rendered before and after,
86
+ in both email and preview mode, are byte-identical. The one input whose bytes
87
+ change is a signed CDN URL, whose `&` now ships as `&amp;` — a correction, since
88
+ the CSS twin and the VML `src` on the SAME element already escaped it and the
89
+ hand-assembled `background` was the odd one out shipping a bare `&`. Verified in
90
+ a real browser: the client decodes the attribute, requests the URL with its
91
+ signature intact, and paints the banner.
92
+
93
+ - **The Rails guard sweep's non-vacuity check tested a COPY of the scanner, so
94
+ the scanner could go blind and the suite stayed green.**
95
+ `rails_guard_sweep_test.rb` is a source scan, and a scan is only trustworthy
96
+ with a control proving its predicate can still say NO. That control re-typed
97
+ the rule as string operations on a sample instead of calling the scan's own
98
+ path: the predicate existed twice, at line 78 and line 101. Measured — typing
99
+ one typo into line 78 ALONE (`\bRails\.` → `\bRailz\.`), which blinds the net so
100
+ it can never flag anything no matter what `lib/` contains, left all three tests
101
+ passing, 0 failures. The floor was being measured against a duplicate
102
+ implementation, so it certified the copy and never the instrument.
103
+
104
+ **THE REPAIR IS ONE DEFINITION, NOT A BETTER COPY.** The rule now lives in a
105
+ single `offending_line?`, and both the scan and every control call it. The same
106
+ mutation now reddens `test_the_predicate_still_flags_the_shape_that_was_fixed`
107
+ by name — while the scan itself still passes, which is precisely the "passes for
108
+ free" the control exists to catch.
109
+
110
+ **THE CONTROLS ARE SPLIT ONE PER CLAUSE**, because `offending_line?` decides on
111
+ four (comment exemption, mentions `defined?(Rails)`, not already asking
112
+ `Rails.respond_to?`, and calls a method on the constant) and one combined
113
+ assertion would go red without saying which clause went inert — the same
114
+ blindness as the copy, one level in. Each of the four now reddens its own named
115
+ test. The scan also asserts its EXIT-BLINDNESS FLOOR inside the test that trusts
116
+ the result — the three named files were swept, and more than 1,000 lines were
117
+ read — because a scan whose loop never runs reports zero offenders and passes
118
+ having proved nothing. Six mutations, applied one at a time, each verified red.
119
+
120
+ **TWO CLAIMS IN THE ENTRIES BELOW WERE FALSE, and are corrected in this same
121
+ change.** *The Rails guard sweep really is finished now* said a bare
122
+ `require "action_view"` is what defines the namespace-only `module Rails`. It is
123
+ not — measured, that require leaves `Rails` UNDEFINED and never loads
124
+ rails-html-sanitizer. What defines it is naming a constant under
125
+ `ActionView::Helpers`, which autoloads the helpers tree; in `studio/js_literal`
126
+ that is line 66's `extend ActionView::Helpers::JavaScriptHelper`. The same loose
127
+ sentence had propagated into three test files and is corrected there too. And
128
+ *`Studio::S3` no longer mistakes a Rails NAMESPACE* said `lib/studio.rb` used the
129
+ repaired form "in three places" — the sweep in this same unreleased block made it
130
+ FOUR, so a false completeness claim was shipping one sentence above the entry
131
+ announcing a fix for exactly that.
132
+
9
133
  - **A quote in a host local can no longer close an Alpine attribute and turn the
10
134
  rest of the element into markup.** Four partials built an attribute — its OWN
11
135
  QUOTES included — as a Ruby String and marked it `html_safe`, which is the one
@@ -81,9 +205,16 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
81
205
  `Studio.routes`. All three now ask `Rails.respond_to?` first.
82
206
 
83
207
  **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
208
+ `studio/js_literal` (line 11) BEFORE it requires `studio/mail_transport`
209
+ (line 25), and loading `js_literal` is what puts the namespace-only `module
210
+ Rails` in memory. **Not its `require "action_view"`** — measured, that require
211
+ alone leaves `Rails` UNDEFINED and never loads the sanitizer at all. It is line
212
+ 66, `extend ActionView::Helpers::JavaScriptHelper`: naming anything under
213
+ `ActionView::Helpers` autoloads the helpers tree, which requires
214
+ rails-html-sanitizer, and that gem is what ships `module Rails` — with
215
+ `Rails.methods(false) == []`, no singleton methods at all. The distinction is
216
+ not pedantry: a file that requires `action_view` and never reaches into
217
+ `Helpers` does not arm this trap. Measured at this branch's head, a bare
87
218
  `Studio::MailTransport.configure!(env: {}, action_mailer: mailer)` died with
88
219
  `NoMethodError: undefined method 'env' for module Rails`. No shipped app can
89
220
  reach it (every host configures mail from an initializer, where `Rails.env`
@@ -467,10 +598,12 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
467
598
  long before any application does — ships a namespace-only `module Rails` with no
468
599
  singleton methods, so that guard reads true and the next call raises
469
600
  `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
601
+ `Rails.respond_to?(:env)`, which is the form `lib/studio.rb` now uses in FOUR
602
+ places: the three it already carried (lines 701, 707 and 742) plus the route
603
+ guard at line 859. **This entry originally called it "the straggler"; that was
604
+ wrong** three more sites carried the bare form, line 859 among them, and all
605
+ three are swept in *The Rails guard sweep really is finished now* above, in this
606
+ same unreleased block. No shipped app can reach it — every host boots a
474
607
  real application — but the engine's own pure-Ruby unit lane can, and it did:
475
608
  adding one `require` for a file that needs `action_view` turned an untouched
476
609
  `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;">
@@ -10,7 +10,8 @@
10
10
  apps omit this; the /admin/style demo passes an expression
11
11
  (e.g. close) so the specimen self-terminates after min_duration
12
12
  instead of spinning forever. %>
13
- <% saving_store = local_assigns.fetch(:store, "modals") %>
13
+ <%# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier. %>
14
+ <% saving_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:store, "modals"), local: :store) %>
14
15
  <div>
15
16
  <%= render "studio/modals/blocks/processing_card",
16
17
  title_key: "$store.#{saving_store}.current()?.props?.title || 'Saving…'",
@@ -46,7 +46,9 @@
46
46
  rest of it onto the page as visible text. That shipped once.)
47
47
  %>
48
48
  <%
49
- scoped_store = local_assigns.fetch(:store)
49
+ # IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
50
+ scoped_store = Studio::JsIdentifier.validate!(
51
+ local_assigns.fetch(:store), local: :store)
50
52
  card_class = local_assigns.fetch(:card_class,
51
53
  "bg-surface rounded-xl border border-subtle shadow-2xl p-6 max-w-sm w-full")
52
54
  %>
@@ -12,7 +12,9 @@
12
12
  modal_store — Alpine store name backing close(). Default "modals".
13
13
  %>
14
14
  <%
15
- modal_store = local_assigns.fetch(:modal_store, "modals")
15
+ # IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
16
+ modal_store = Studio::JsIdentifier.validate!(
17
+ local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
16
18
  %>
17
19
  <button @click="resendMagicLink()" :disabled="props.submitting === 'magic-link' || (props.resendCooldown || 0) > 0"
18
20
  class="block mx-auto text-sm text-primary hover:text-primary-300 underline underline-offset-2 disabled:opacity-50 disabled:no-underline">
@@ -62,7 +62,9 @@
62
62
  birthday_modal_id = local_assigns.fetch(:birthday_modal_id, "birthday")
63
63
  back_label = local_assigns.fetch(:back_label, "Update your Birthday")
64
64
  title = local_assigns[:title]
65
- modal_store = local_assigns.fetch(:modal_store, "modals")
65
+ # IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
66
+ modal_store = Studio::JsIdentifier.validate!(
67
+ local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
66
68
  %>
67
69
  <div x-data="{
68
70
  now: Date.now(),
@@ -80,6 +80,13 @@
80
80
  fine_print = local_assigns.fetch(:fine_print,
81
81
  validates ? "We use your date of birth only to confirm eligibility."
82
82
  : "We use your date of birth only to know when to wish you a happy birthday.")
83
+ # NOT VALIDATED HERE, deliberately. This partial never splices the store name into
84
+ # identifier position: it reaches birthdayModal as a JS STRING (escaped below) and
85
+ # the factory looks it up with Alpine.store(name), where any character is legal.
86
+ # The identifier contract is owed by blocks/_shell, which this card renders one
87
+ # line further down and which validates it — so a hostile value still raises,
88
+ # once, at the site that actually splices it. A second guard here would be a
89
+ # redundant one: it would survive being deleted.
83
90
  modal_store = local_assigns.fetch(:modal_store, "modals")
84
91
  demo = local_assigns.fetch(:demo, false)
85
92
  demo_underage = local_assigns.fetch(:demo_underage, false)
@@ -42,7 +42,9 @@
42
42
  label — aria-label. Default "Close".
43
43
  %>
44
44
  <%
45
- modal_store = local_assigns.fetch(:modal_store, "modals")
45
+ # IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
46
+ modal_store = Studio::JsIdentifier.validate!(
47
+ local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
46
48
  label = local_assigns.fetch(:label, "Close")
47
49
  %>
48
50
  <button type="button" @click="$store.<%= modal_store %>.close()"
@@ -25,7 +25,9 @@
25
25
  href_key = local_assigns.fetch(:href_key)
26
26
  label_html = local_assigns.fetch(:label_html, "Continue")
27
27
  duration_seconds = local_assigns.fetch(:duration_seconds, 5)
28
- modal_store = local_assigns.fetch(:modal_store, "modals")
28
+ # IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
29
+ modal_store = Studio::JsIdentifier.validate!(
30
+ local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
29
31
  %>
30
32
  <div x-data="{
31
33
  _redirectTimer: null,
@@ -60,7 +60,9 @@
60
60
  that does nothing.
61
61
  %>
62
62
  <%
63
- modal_store = local_assigns.fetch(:modal_store, "modals")
63
+ # IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
64
+ modal_store = Studio::JsIdentifier.validate!(
65
+ local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
64
66
  props_expr = local_assigns.fetch(:props_expr,
65
67
  "(function () { var c = $store.#{modal_store}.current(); return (c && c.props) || {}; })()")
66
68
  above_seeds = local_assigns[:above_seeds]
@@ -19,7 +19,9 @@
19
19
  close_label — dismiss link text. Default "Close".
20
20
  %>
21
21
  <%
22
- modal_store = local_assigns.fetch(:modal_store, "modals")
22
+ # IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
23
+ modal_store = Studio::JsIdentifier.validate!(
24
+ local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
23
25
  icon_emoji = local_assigns.fetch(:icon_emoji, "🎉")
24
26
  title_key = local_assigns.fetch(:title_key, "props.level ? ('Level ' + props.level) : 'Free Entry Earned'")
25
27
  subtitle = local_assigns.fetch(:subtitle, "You leveled up and earned a reward. Keep earning more with each level.")
@@ -91,7 +91,9 @@
91
91
  free_entry_label = local_assigns.fetch(:free_entry_label, "Free Entry Earned 🎟️")
92
92
  seeds_per_level = local_assigns.fetch(:seeds_per_level, 100)
93
93
  leveling = local_assigns.fetch(:leveling, Studio.feature?(:leveling))
94
- modal_store = local_assigns.fetch(:modal_store, "modals")
94
+ # IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
95
+ modal_store = Studio::JsIdentifier.validate!(
96
+ local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
95
97
  demo = local_assigns.fetch(:demo, false)
96
98
  # consent_label — render a consent checkbox that gates the action (newsletter
97
99
  # join). next_label / next_open — an optional "Next Quest" button in the
@@ -27,7 +27,9 @@
27
27
  cta_label_key = local_assigns.fetch(:cta_label_key)
28
28
  cta_href_key = local_assigns.fetch(:cta_href_key)
29
29
  cluster_param = local_assigns.fetch(:cluster_param, "")
30
- modal_store = local_assigns.fetch(:modal_store, "modals")
30
+ # IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
31
+ modal_store = Studio::JsIdentifier.validate!(
32
+ local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
31
33
  %>
32
34
  <div>
33
35
  <%= render "studio/modals/blocks/card_header",
@@ -13,7 +13,9 @@
13
13
  <%
14
14
  shell_title = local_assigns[:title]
15
15
  shell_dismissible = local_assigns.fetch(:dismissible, true)
16
- modal_store = local_assigns.fetch(:modal_store, "modals")
16
+ # IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
17
+ modal_store = Studio::JsIdentifier.validate!(
18
+ local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
17
19
  %>
18
20
  <div>
19
21
  <% if shell_title || shell_dismissible %>
@@ -104,8 +104,8 @@
104
104
  sites: the props getter, finish's close, and the Ruby-built dismiss_action
105
105
  emitted into the x's click handler. Escaping is the WRONG repair here: an
106
106
  escaped identifier is a different SyntaxError and the same dead card.
107
- Repair: refuse a value that is not a JS identifier, which the Ruby block
108
- below does once, at the source, so all three sites are covered.
107
+ Repair: refuse a value that is not a JS identifier, which one call to
108
+ Studio::JsIdentifier.validate! does at the source, covering all three sites.
109
109
 
110
110
  A PATTERN, NOT AN ALLOWLIST, and the reason is worth keeping: an allowlist would
111
111
  be this engine enumerating its own consumers, so the next app to mount a
@@ -116,7 +116,9 @@
116
116
 
117
117
  NOTE THE SAME LOCAL TAKES THE OTHER REPAIR ELSEWHERE. blocks/_birthday and
118
118
  blocks/_leveling_activity pass modal_store in STRING position and escape it,
119
- correctly. The shape decides the repair, never the name of the local.
119
+ correctly. The shape decides the repair, never the name of the local. And where
120
+ a store name reaches ONLY a string — blocks/_birthday hands it to Alpine.store(),
121
+ a lookup by string — no identifier is owed and none is demanded.
120
122
  %>
121
123
  <%
122
124
  submit_path = local_assigns.fetch(:submit_path, "/onboarding/first_name")
@@ -163,37 +165,21 @@
163
165
  typed_placeholder = !placeholder_names.nil?
164
166
  max_length = local_assigns.fetch(:max_length, Studio::FULL_NAME_MAX_LENGTH)
165
167
  progress = local_assigns.fetch(:progress, nil)
166
- modal_store = local_assigns.fetch(:modal_store, "modals")
167
168
  # VALIDATED, NOT ESCAPED — and that distinction is the whole point of this local.
168
169
  # Every other value inside the x-data is a JS STRING and is repaired by escaping.
169
170
  # This one is spliced in as a bare NAME (`$store.<name>.current()`), so the same
170
- # repair breaks it: escape_javascript turns `a'b` into `a\'b`, and
171
- # `$store.a\'b.current()` is not a rescued identifier, it is a different
172
- # SyntaxError — the same silently dead card, reached a longer way.
171
+ # repair breaks it: escape_javascript backslash-escapes the apostrophe, and an
172
+ # escaped apostrophe is not a rescued identifier it is a different SyntaxError,
173
+ # the same silently dead card reached a longer way.
173
174
  #
174
- # A PATTERN RATHER THAN AN ALLOWLIST, deliberately. An allowlist would be this
175
- # engine enumerating its own CONSUMERS "modals" and the style guide's
176
- # "dsModals" today so the next app to mount a page-scoped host would be refused
177
- # by its own dependency until a gem release admitted the name. That is backwards
178
- # for a shared primitive. The real contract is narrower and stateless: this is
179
- # spliced into member-access position, so it must be a JS identifier, and WHICH
180
- # identifier is none of the engine's business.
181
- #
182
- # ASCII IdentifierName, which is what member access after a dot accepts. It
183
- # admits every store name a host would plausibly write and rejects every
184
- # character that could leave identifier position — quote, apostrophe, backslash,
185
- # dot, space, semicolon, angle bracket.
186
- #
187
- # IT RAISES RATHER THAN FALLING BACK TO THE DEFAULT. A silent fallback is the
188
- # worse of the two repairs: the card would mount, look perfect, and talk to a
189
- # store that is not the host's — which is the SILENT-brick failure class this
190
- # guard exists to leave behind. A host sees this the first time it renders.
191
- unless modal_store.to_s.match?(/\A[A-Za-z_$][A-Za-z0-9_$]*\z/)
192
- raise ArgumentError,
193
- "modal_store must be a JS identifier (it is spliced into $store.<name>); " \
194
- "got #{modal_store.inspect}. Escaping it would not help — an escaped " \
195
- "identifier is a different SyntaxError, and the card would mount dead."
196
- end
175
+ # THE REGEX AND THE ARGUMENT USED TO LIVE HERE, INLINE. This card was the first
176
+ # site to need them, so it carried the worked example; the engine has sixteen more
177
+ # splices of a store name in identifier position, and the contract now lives in ONE
178
+ # place. Studio::JsIdentifier holds the pattern, why it is a pattern and not an
179
+ # allowlist, and why it raises rather than falling back to the default. Behaviour
180
+ # here is unchanged: same pattern, same ArgumentError, same message.
181
+ modal_store = Studio::JsIdentifier.validate!(
182
+ local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
197
183
  done_event = local_assigns.fetch(:done_event, "onboarding-step-done")
198
184
  # THE SAME HAZARD empty_error_js CARRIES, on the three remaining locals that land
199
185
  # inside a JS SINGLE-quoted literal in the x-data below. A bare apostrophe in any
@@ -7,7 +7,8 @@
7
7
  icon_color :primary / :success / :warning to card_header.
8
8
 
9
9
  Local: modal_store — Alpine store name backing close(). Default "modals". %>
10
- <% modal_store = local_assigns.fetch(:modal_store, "modals") %>
10
+ <%# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier. %>
11
+ <% modal_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:modal_store, "modals"), local: :modal_store) %>
11
12
  <div x-data>
12
13
  <%= render "studio/modals/blocks/card_header",
13
14
  size: :lg,
@@ -4,7 +4,8 @@
4
4
  + close ×. Ported from turf-monster.
5
5
 
6
6
  Local: modal_store — Alpine store name backing close(). Default "modals". %>
7
- <% modal_store = local_assigns.fetch(:modal_store, "modals") %>
7
+ <%# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier. %>
8
+ <% modal_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:modal_store, "modals"), local: :modal_store) %>
8
9
  <div x-data="{ value: '' }">
9
10
  <%= render layout: "studio/modals/blocks/shell", locals: { title: "Form Template", modal_store: modal_store } do %>
10
11
  <p class="text-sm text-muted mb-4">
@@ -6,7 +6,8 @@
6
6
  at 1.6s so a viewer can step through both states.
7
7
 
8
8
  Local: modal_store — Alpine store name backing close(). Default "modals". %>
9
- <% modal_store = local_assigns.fetch(:modal_store, "modals") %>
9
+ <%# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier. %>
10
+ <% modal_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:modal_store, "modals"), local: :modal_store) %>
10
11
  <div x-data="{
11
12
  state: 'loading',
12
13
  start() {
@@ -5,7 +5,8 @@
5
5
  use studio/modals/blocks/_onchain_success instead. Ported from turf-monster.
6
6
 
7
7
  Local: modal_store — Alpine store name backing close(). Default "modals". %>
8
- <% modal_store = local_assigns.fetch(:modal_store, "modals") %>
8
+ <%# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier. %>
9
+ <% modal_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:modal_store, "modals"), local: :modal_store) %>
9
10
  <div x-data>
10
11
  <%= render "studio/modals/blocks/card_header",
11
12
  size: :lg,
@@ -8,7 +8,8 @@
8
8
  primary CTA pulling forward; Back as a subtle link, hidden on step 1.
9
9
 
10
10
  Local: modal_store — Alpine store name backing close(). Default "modals". %>
11
- <% modal_store = local_assigns.fetch(:modal_store, "modals") %>
11
+ <%# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier. %>
12
+ <% modal_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:modal_store, "modals"), local: :modal_store) %>
12
13
  <div x-data="{ step: 1, total: 3 }">
13
14
  <div class="relative mb-3 -mt-2">
14
15
  <h3 class="text-heading font-bold text-lg leading-tight text-center pt-1">Wizard Template</h3>
@@ -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
 
@@ -0,0 +1,88 @@
1
+ module Studio
2
+ # ONE home for the CONTRACT a host-supplied value must meet when a partial
3
+ # splices it into JS **identifier position** — a bare NAME rather than a string:
4
+ #
5
+ # <button @click="$store.<%= modal_store %>.close()">
6
+ #
7
+ # THE SIBLING OF Studio::JsLiteral, AND ITS OPPOSITE. Both exist for the same
8
+ # silent failure: a host value that makes an Alpine expression a SyntaxError, so
9
+ # the component mounts as a NO-OP that still renders every element. Perfect
10
+ # markup, dead card, nothing raised and nothing logged. The two differ on the
11
+ # only question that matters — WHAT THE VALUE IS:
12
+ #
13
+ # STRING position — the value is DATA inside a JS literal. Any character is
14
+ # legal; the repair is to ESCAPE it (Studio::JsLiteral.in_attribute).
15
+ # IDENTIFIER position — the value is a NAME. Most characters are not legal at
16
+ # all; the repair is to REFUSE it, which is this module.
17
+ #
18
+ # ESCAPING IS ACTIVELY WRONG HERE, which is why this is a second module rather
19
+ # than a second method on the first. escape_javascript also escapes `$`, so a
20
+ # perfectly legal store name like `dsModals$2` comes back as `dsModals\$2` and
21
+ # `$store.dsModals\$2.close()` is a SyntaxError — the same dead card, reached a
22
+ # longer way, on a value that was never hostile. And for a value that IS hostile,
23
+ # `$store.a\'b.close()` is not a rescued identifier either. There is no escaping
24
+ # of identifier position; there is only validity.
25
+ #
26
+ # A PATTERN, NOT AN ALLOWLIST, and the reason outlives this engine. An allowlist
27
+ # would be a shared primitive enumerating its own CONSUMERS — "modals" and the
28
+ # style guide's "dsModals" today — so the next app to mount a page-scoped host
29
+ # would be refused by its own dependency until a gem release admitted the name.
30
+ # The real contract is narrower and stateless: the value is spliced into
31
+ # member-access position, so it must be an identifier, and WHICH identifier is
32
+ # none of the engine's business.
33
+ #
34
+ # IT RAISES RATHER THAN FALLING BACK TO A DEFAULT. A silent fallback is the worse
35
+ # of the two repairs: the card would mount, look perfect, and talk to a store that
36
+ # is not the host's — the same SILENT class this guard exists to leave behind,
37
+ # only now with a working-looking page in front of it. A host meets this the first
38
+ # time it renders, in development, with the local named.
39
+ #
40
+ # WHAT IT DOES NOT COVER. A value in string position that is ALSO a store name
41
+ # (blocks/_birthday's `store: '<%= j modal_store %>'`, which reaches
42
+ # `Alpine.store(name)` — a lookup by string, where any character is legal) needs no
43
+ # identifier. Validate where the value reaches a SPLICE, not everywhere the word
44
+ # "store" appears; test/views/js_identifier_locals_test.rb carries the census.
45
+ module JsIdentifier
46
+ # ASCII IdentifierName — what member access after a dot accepts. It admits every
47
+ # store name a host would plausibly write (`modals`, `dsModals`, `ds_Modals$2`)
48
+ # and rejects every character that could leave identifier position: quote,
49
+ # apostrophe, backslash, dot, space, semicolon, angle bracket, and the empty
50
+ # string.
51
+ #
52
+ # DELIBERATELY NOT THE FULL ECMAScript GRAMMAR, which admits most of Unicode.
53
+ # Widening it is a decision, not a bug fix — and it would have to be taken
54
+ # together with the marking note on #validate! below, because the value's safety
55
+ # under ERB is what the narrow set buys.
56
+ PATTERN = /\A[A-Za-z_$][A-Za-z0-9_$]*\z/
57
+
58
+ module_function
59
+
60
+ # Return +value+ as a String, or raise ArgumentError naming +local+.
61
+ #
62
+ # modal_store = Studio::JsIdentifier.validate!(
63
+ # local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
64
+ #
65
+ # +local+ is REQUIRED and has no default on purpose. Fifteen of the engine's
66
+ # call sites name this local `modal_store` and two name it `store`; a default
67
+ # that is right fifteen times out of seventeen is exactly how the other two
68
+ # would hand a host the wrong local to go and fix.
69
+ #
70
+ # RETURNS A PLAIN STRING, NOT AN html_safe ONE, and that is load-bearing rather
71
+ # than an omission. A value that satisfies PATTERN contains no character ERB
72
+ # escapes, so ERB's pass over it is a byte-for-byte no-op and the identifier
73
+ # reaches the browser exactly as the host wrote it — the marking would buy
74
+ # nothing today. What it would cost is the day PATTERN is widened: ERB's
75
+ # escaping is the second layer that would still be standing, and a value marked
76
+ # safe here skips it. So the pattern is the only thing holding the line, and it
77
+ # is holding it alone by choice, in one place.
78
+ def validate!(value, local:)
79
+ name = "#{value}"
80
+ return name if name.match?(PATTERN)
81
+
82
+ raise ArgumentError,
83
+ "#{local} must be a JS identifier (it is spliced into $store.<name>); " \
84
+ "got #{value.inspect}. Escaping it would not help — an escaped " \
85
+ "identifier is a different SyntaxError, and the card would mount dead."
86
+ end
87
+ end
88
+ end
@@ -56,12 +56,33 @@ 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`
62
82
  # comes back mangled and the card dies anyway. The SHAPE of the splice decides the
63
- # repair, never the name of the local. studio/modals/onboarding/_first_name
64
- # carries the worked example of both.
83
+ # repair, never the name of the local. That repair is Studio::JsIdentifier, this
84
+ # module's sibling and its opposite; studio/modals/onboarding/_first_name carries
85
+ # the worked example of both in one partial.
65
86
  module JsLiteral
66
87
  extend ActionView::Helpers::JavaScriptHelper
67
88
 
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.74.1"
2
+ VERSION = "0.74.3"
3
3
  end
data/lib/studio.rb CHANGED
@@ -9,6 +9,7 @@ require "studio/environment_banner"
9
9
  require "studio/theme_resolver"
10
10
  require "studio/ui_primitives"
11
11
  require "studio/js_literal"
12
+ require "studio/js_identifier"
12
13
  require "studio/sidebar_sections"
13
14
  require "studio/profile_sections"
14
15
  require "studio/profile_image"
metadata CHANGED
@@ -1,14 +1,14 @@
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.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-08 00:00:00.000000000 Z
11
+ date: 2026-09-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -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
@@ -573,6 +568,7 @@ files:
573
568
  - lib/studio/geo/lookup.rb
574
569
  - lib/studio/image_cache.rb
575
570
  - lib/studio/ip_locations.rb
571
+ - lib/studio/js_identifier.rb
576
572
  - lib/studio/js_literal.rb
577
573
  - lib/studio/link_resolution.rb
578
574
  - lib/studio/link_token.rb
@@ -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>