studio-engine 0.74.0 → 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: 6a6961114226763cef0af1b1500c982609412516d95c78c0985f63012882e92e
4
- data.tar.gz: 4694c467547769140156eac7824a72fd0d510d84f0c30637cba40a87c3344187
3
+ metadata.gz: 6694226dcc9e100149692ef60162478d4927a69280343c5047c0222517f1f68c
4
+ data.tar.gz: 8e6b5c07f8bb5fa60466049f321c7be0a09168fb750766202a5d122734cb6699
5
5
  SHA512:
6
- metadata.gz: 509affb42c8bf58a11289e0e0c16cfb2b298625456284b0d56775373fba4ac225c9bc6ba6bd695acb028a44a517836989204dfbb97bd96b1ed69ca2f1e9e7023
7
- data.tar.gz: 36ed3258453f8025f591335348f989e333a6d805de78979e325c2a005a197d74c17f04995c6fcf6776bbebb2dae9d4404c483f0effe1e095bf29f2e02947ca83
6
+ metadata.gz: 6ec39596a087d56cc64f3a211ea349bf6a00b7a21c6eb1d1c70b36136dda3f3ab7f8fe1a1f61b26593661283f12707501727d9f3d5119f01b3fa28995ece7bd7
7
+ data.tar.gz: 3d65093bf62c9088976d9ea56e756cabd7e01e65aaae06205419e337a0d8b6a1a1b93c60b93631bf724d254f01bcdc5fb0c7360c50525c1f519b35b6b7b49946
data/CHANGELOG.md CHANGED
@@ -6,6 +6,196 @@ 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
+
87
+ - **A quote in a host local can no longer close an Alpine attribute and turn the
88
+ rest of the element into markup.** Four partials built an attribute — its OWN
89
+ QUOTES included — as a Ruby String and marked it `html_safe`, which is the one
90
+ shape ERB's attribute escaping can never reach: the marking tells ERB to stand
91
+ down, so a double quote in the local ended the attribute and the HTML parser read
92
+ the remainder as new attributes. Measured, not reasoned: one hostile
93
+ `auto_redirect_seconds` turned the success card's root into an element carrying
94
+ attributes named `quote"`, `\`, `<` and `script`, with its `class` swallowed
95
+ whole. This is strictly worse than the JS-string-literal class fixed in
96
+ *Host-supplied locals can no longer brick an Alpine component* below, which at
97
+ least stayed inside its attribute.
98
+
99
+ **THE REPAIR IS NOT ESCAPING.** These locals are Alpine EXPRESSIONS by contract —
100
+ the caller is handing the engine JS on purpose — so `escape_javascript` would
101
+ break the feature it was protecting. The partials stop writing the quotes and let
102
+ ActionView write them: `tag.attributes("x-init": expr.html_safe)`. `tag_option`
103
+ runs `gsub('"', "&quot;")` on the finished value UNCONDITIONALLY, on the line
104
+ AFTER the escape branch it skips for a marked String, so the expression keeps its
105
+ apostrophes, angle brackets and backslashes byte-for-byte and still cannot end the
106
+ attribute. `blocks/_rail_row:112` already did exactly this and was the worked
107
+ example.
108
+
109
+ **WHAT MOVED — twelve splices in four files.** `blocks/_success_card` (the
110
+ Ruby-built `x-data` countdown object, and the `x-init` call list that splices
111
+ `auto_redirect_url_key`), `blocks/_processing_card` (one assembled `x-data`/`x-init`
112
+ pair carrying TWO locals — `resolve_expr` and `min_duration`, which only LOOKS
113
+ numeric because just its guard calls `.to_i`), `blocks/_leveling_activity` (the
114
+ Next Quest `@click`, plus `minlength` / `maxlength` / `pattern` / `title` on the
115
+ form input), and `components/_sidebar_panel` (`@click.outside`,
116
+ `@keydown.escape.window` and `@turbo:before-cache.window` on the root of the shell
117
+ every link menu hangs from). No default or in-repo value contains a character tag
118
+ encoding touches, so every shipped page renders byte-for-byte what it did — a
119
+ render-level test asserts that directly.
120
+
121
+ **THE MARKING NOW FOLLOWS WHAT THE VALUE IS.** Code stays `html_safe` and reaches
122
+ the browser unchanged; CONTENT does not. The four input constraint attributes are
123
+ a length, a regex and a tooltip, so they take the full escaping a content
124
+ attribute is owed — which also closes a second latent bug the marking carried, a
125
+ bare `&` in a `title` or `pattern` reaching the browser as the start of an entity.
126
+
127
+ **TWO OF THE SIX SITES ON THE ORIGINAL LIST WERE ALREADY SAFE and were left
128
+ alone.** `blocks/_rail_row:112` routes its handler through `content_tag`, and
129
+ `board/_card_shell:54` escapes both halves of its arbitrary-attribute passthrough
130
+ through ERB. Both were re-verified by rendering them with a hostile value, not by
131
+ reading them. Filing a fix for either would have bought a guard that can never
132
+ bite.
133
+
134
+ **HOW THE GUARDS ASSERT.** `test/views/assembled_attribute_locals_test.rb` reads
135
+ every seam through Nokogiri's HTML5 parser — the algorithm a browser runs — and
136
+ asserts on the DECODED attribute value, never on the template's output bytes,
137
+ because ERB entity-escapes an unmarked value too and a raw-markup structural
138
+ assertion therefore cannot fail. (HTML5 rather than HTML4 on purpose: HTML4
139
+ silently drops an `@click`, so every Alpine assertion made through it reads nil on
140
+ a correct page.) Each seam makes two claims — the element carries exactly the
141
+ attributes a benign value gives it, and the browser recovers the whole expression
142
+ — plus a non-vacuity control that fails if the partial ever stops splicing the
143
+ local at all. All twelve were mutated back to their assembled form ONE AT A TIME
144
+ and each was killed by its own test; the shared `_processing_card` call was also
145
+ mutated per-local. `test/lib/studio/attribute_encoding_contract_test.rb` pins the
146
+ ActionView property the whole repair stands on, so a future Rails moving that
147
+ `gsub` reports itself as one dependency change rather than twelve partial bugs.
148
+
149
+ **STILL OPEN, and named rather than quietly folded in:**
150
+ `studio/mailers/_layered_banner:69,100` assemble `background=` and `bgcolor=` the
151
+ same way. Same shape, different family — email attributes, no Alpine, no JS — so
152
+ they are left for their own ticket rather than widened into this one.
153
+
154
+ - **The Rails guard sweep really is finished now, and a test says so instead of a
155
+ comment.** Fixing `Studio::S3` left THREE sites still guarding a Rails method
156
+ call on a bare `defined?(Rails)`: both keyword defaults in
157
+ `Studio::MailTransport.configure!` (`rails_env:` reading `Rails.env`, `logger:`
158
+ reading `Rails.logger`) and the developer-desk route guard in
159
+ `Studio.routes`. All three now ask `Rails.respond_to?` first.
160
+
161
+ **THE MAIL TRANSPORT ONE WAS ARMED, not theoretical.** `lib/studio.rb` requires
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
172
+ `Studio::MailTransport.configure!(env: {}, action_mailer: mailer)` died with
173
+ `NoMethodError: undefined method 'env' for module Rails`. No shipped app can
174
+ reach it (every host configures mail from an initializer, where `Rails.env`
175
+ exists, and all four in-repo callers pass `rails_env:` explicitly) — the cost
176
+ lands on the next gem unit test that omits the kwarg, which is the same
177
+ half-hour the `Studio::S3` fix already paid once.
178
+
179
+ **THE TWO DEFAULTS ARE SEPARATE STRAGGLERS**, and the first hid the second: Ruby
180
+ evaluates only the defaults a caller omitted, and `rails_env:` is declared above
181
+ `logger:`, so a bare call raised out of `Rails.env` and never reached
182
+ `Rails.logger`. `test/lib/studio/mail_transport_namespace_only_rails_test.rb`
183
+ supplies one argument and omits the other in each test, so each guard is pinned
184
+ independently rather than behind its neighbour.
185
+
186
+ **THE ROUTE GUARD IS PINNED BY A SCAN, deliberately.** `Studio.routes` only
187
+ loads inside a real Rails application, where `Rails.env` exists — so the
188
+ condition under test cannot be constructed where the file loads, and no
189
+ behavioural test can reach it. `test/lib/studio/rails_guard_sweep_test.rb` is
190
+ the tree-wide net instead, and it asserts both that it really read the files and
191
+ that its predicate still flags the original shape, so it cannot pass for free.
192
+
193
+ **The completeness claim itself was the other half of the bug.** `lib/studio/s3.rb`
194
+ and this changelog both said that fix "was the straggler" — while one of the
195
+ remaining three sat in `lib/studio.rb`, the very file the comment cited as
196
+ already clean. Both claims are corrected, and the comment now points at the test
197
+ rather than restating that the sweep is done.
198
+
9
199
  - **The app census in these comments was short by the most important app.** Ten
10
200
  sites said this engine is mounted by SIX apps and that THREE of them bundle no
11
201
  `solana-studio`. Measured 2026-09-07 by the criterion that reproduces it — a
@@ -362,8 +552,12 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
362
552
  long before any application does — ships a namespace-only `module Rails` with no
363
553
  singleton methods, so that guard reads true and the next call raises
364
554
  `NoMethodError: undefined method 'env' for module Rails`. It now asks
365
- `Rails.respond_to?(:env)`, which is the form `lib/studio.rb` already uses in three
366
- places; this was the straggler. 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
367
561
  real application — but the engine's own pure-Ruby unit lane can, and it did:
368
562
  adding one `require` for a file that needs `action_view` turned an untouched
369
563
  `email_catalog_test` red with two errors about email uploads.
@@ -34,9 +34,19 @@
34
34
  <% if id.present? %>id="<%= id %>"<% end %>
35
35
  x-cloak
36
36
  x-show="<%= open %>"
37
- <%= %(#{outside_action.present? ? "@click.outside=\"#{outside_action}\"" : ""}).html_safe %>
38
- <%= %(#{escape_action.present? ? "@keydown.escape.window=\"#{escape_action}\"" : ""}).html_safe %>
39
- <%= %(#{close_action.present? ? "@turbo:before-cache.window=\"#{close_action}\"" : ""}).html_safe %>
37
+ <%# THE THREE DISMISSAL HANDLERS, EMITTED BY tag.attributes. Each was built as
38
+ %(@name="#{action}").html_safe — the attribute's OWN quotes assembled in Ruby
39
+ and then marked safe, which is the one shape ERB's escaping can never reach. A
40
+ double quote in any of the three actions closed the attribute and everything
41
+ after it became markup on the panel's root element.
42
+
43
+ The actions stay html_safe: they are Alpine EXPRESSIONS the host wrote (see the
44
+ @click at the close button below, and `open` above). tag_option's
45
+ gsub('"', "&quot;") runs on the finished value whether or not it is marked, so
46
+ the expression reaches Alpine unchanged and cannot end the attribute. %>
47
+ <%= tag.attributes("@click.outside": outside_action.html_safe) if outside_action.present? %>
48
+ <%= tag.attributes("@keydown.escape.window": escape_action.html_safe) if escape_action.present? %>
49
+ <%= tag.attributes("@turbo:before-cache.window": close_action.html_safe) if close_action.present? %>
40
50
  x-transition:enter="transition ease-out duration-300"
41
51
  x-transition:enter-start="translate-x-full"
42
52
  x-transition:enter-end="translate-x-0"
@@ -32,6 +32,20 @@
32
32
  attrs: Hash of extra root attributes ({ "x-data" => "…", "data-foo" => 1 }),
33
33
  values are HTML-escaped — the rebase seam for a card that carries
34
34
  its own Alpine/data-* on the root.
35
+
36
+ NOT A CANDIDATE FOR THE ASSEMBLED-ATTRIBUTE SWEEP: nothing here is
37
+ marked html_safe, so ERB escapes both halves. A double quote in a
38
+ VALUE becomes &quot; and the parser decodes it back — the value half
39
+ cannot break out. Verified 2026-09-08 by rendering, not reading.
40
+
41
+ THE NAME HALF IS A DIFFERENT QUESTION, and entity decoding is not
42
+ what answers it. html_escape does not touch a SPACE, and a space
43
+ ENDS an attribute name: a name of `data-y=1 x-init` renders as
44
+ `data-y=1 x-init="…"` and the parser builds a real SECOND attribute
45
+ — measured, an injected x-init Alpine will run. What makes this seam
46
+ safe today is that nothing passes attrs: at all — zero call sites in
47
+ studio-engine, turf-monster and mcritchie-studio. Its first caller
48
+ owes an allow-list on the NAME before this emits it.
35
49
  %>
36
50
  <%
37
51
  id = local_assigns.fetch(:id)
@@ -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;">
@@ -138,10 +138,23 @@
138
138
  <% if input %>
139
139
  <input type="<%= input_type %>" x-model="value" class="input-field w-full"
140
140
  placeholder="<%= placeholder %>"
141
- <%= "minlength=\"#{min_length}\"".html_safe if min_length.to_i.positive? %>
142
- <%= "maxlength=\"#{max_length}\"".html_safe if max_length.present? %>
143
- <%= "pattern=\"#{pattern}\"".html_safe if pattern.present? %>
144
- <%= "title=\"#{pattern_title}\"".html_safe if pattern_title.present? %>
141
+ <%# FOUR CONSTRAINT ATTRIBUTES, ENCODED AND — unlike the Alpine
142
+ handlers in this file — DELIBERATELY NOT html_safe. Each was
143
+ assembled as `attr="#{value}"`.html_safe, which handed ERB a
144
+ String it must not touch, so a double quote in any of the four
145
+ closed the attribute and the rest became markup. minlength
146
+ looks numeric and is not: only the GUARD calls .to_i, the
147
+ interpolation splices the local raw.
148
+
149
+ These are CONTENT, not code — a length, a regex, a tooltip
150
+ sentence — so they take the full escaping a content attribute
151
+ is owed and the parser decodes it back. That also repairs a
152
+ second latent bug the marking carried: a bare & in a title or
153
+ pattern used to reach the browser as the start of an entity. %>
154
+ <%= tag.attributes(minlength: min_length) if min_length.to_i.positive? %>
155
+ <%= tag.attributes(maxlength: max_length) if max_length.present? %>
156
+ <%= tag.attributes(pattern: pattern) if pattern.present? %>
157
+ <%= tag.attributes(title: pattern_title) if pattern_title.present? %>
145
158
  autofocus>
146
159
  <% end %>
147
160
  <% if consent_label.present? %>
@@ -180,7 +193,12 @@
180
193
  </div>
181
194
 
182
195
  <% if next_label.present? && next_open.present? %>
183
- <button type="button" @click="<%= next_open %>" class="btn btn-primary btn-lg w-full">
196
+ <%# next_open is html_safe (:102) because it is a developer-authored Alpine
197
+ EXPRESSION — so ERB steps aside here and a double quote in it used to
198
+ close the @click attribute outright. tag.attributes writes the quotes and
199
+ entity-encodes any of its own that appear in the value, leaving the
200
+ expression otherwise byte-identical. %>
201
+ <button type="button" <%= tag.attributes("@click": next_open) %> class="btn btn-primary btn-lg w-full">
184
202
  <%= next_label %>
185
203
  </button>
186
204
  <% end %>
@@ -41,8 +41,27 @@
41
41
  # JS sides of the convention agree on the same floor.
42
42
  min_duration = local_assigns[:min_duration] || 1400
43
43
  resolve_expr = local_assigns[:resolve_expr]
44
+
45
+ # THE AUTO-RESOLVE PAIR, ENCODED RATHER THAN ASSEMBLED. This line used to build
46
+ # ` x-data="{}" x-init="…"` as one html_safe String, quotes and all, so ERB had
47
+ # nothing left to escape: a double quote in EITHER local — min_duration as much as
48
+ # resolve_expr, since min_duration is interpolated raw and only its GUARD calls
49
+ # .to_i — closed the attribute and turned the remainder into markup.
50
+ #
51
+ # tag.attributes writes the quotes instead. The x-init value stays html_safe
52
+ # because it is CODE the caller handed us, and tag_option's unconditional
53
+ # gsub('"', "&quot;") still fires on an html_safe value, so the JS arrives intact
54
+ # and the attribute cannot end early. safe_join supplies the separating space
55
+ # without a second hand-marked String.
56
+ resolve_attrs =
57
+ if resolve_expr
58
+ safe_join([" ", tag.attributes(
59
+ "x-data": "{}",
60
+ "x-init": "window.StudioModals.holdAtLeast(#{min_duration}).then(() => { #{resolve_expr} })".html_safe
61
+ )])
62
+ end
44
63
  %>
45
- <div class="text-center py-6"<%= " x-data=\"{}\" x-init=\"window.StudioModals.holdAtLeast(#{min_duration}).then(() => { #{resolve_expr} })\"".html_safe if resolve_expr %>>
64
+ <div class="text-center py-6"<%= resolve_attrs %>>
46
65
  <div class="mx-auto <%= spinner_class %> rounded-full border-<%= color %>/30 border-t-<%= color %> animate-spin mb-5"></div>
47
66
  <% if local_assigns[:title_key] %>
48
67
  <p class="text-base font-bold text-heading mb-1" x-text="<%= title_key %>"></p>
@@ -122,9 +122,26 @@
122
122
  init_calls = []
123
123
  init_calls << "fireConfetti()" if fire_confetti
124
124
  init_calls << "startCountdown(#{local_assigns[:auto_redirect_url_key]})" if has_redirect
125
+ init_expr = init_calls.join("; ").html_safe
125
126
  %>
126
- <div x-data="<%= data_attr %>"
127
- <%= "x-init=\"#{init_calls.join('; ')}\"".html_safe if init_calls.any? %>
127
+ <%# BOTH ALPINE ATTRIBUTES ARE EMITTED BY tag.attributes, NOT ASSEMBLED BY HAND.
128
+ Each carries Ruby-built JS that splices a host local — redirect_secs into the
129
+ x-data object, auto_redirect_url_key into startCountdown() — and both blobs are
130
+ html_safe, because they are CODE and the caller is owed the JS it wrote.
131
+
132
+ THAT MARKING USED TO BE THE HOLE. html_safe tells ERB to step aside, so a double
133
+ quote in either local closed the ATTRIBUTE and everything after it became markup:
134
+ x-data="{ _remaining: it's a " quote" … — measured, not reasoned.
135
+
136
+ tag.attributes closes it without touching the JS. ActionView's tag_option runs
137
+ gsub('"', "&quot;") on the finished value UNCONDITIONALLY, outside the escape
138
+ branch it skips for an html_safe string (actionview tag_helper.rb, the line after
139
+ the escape ternary), so the attribute cannot be closed early and the HTML parser
140
+ decodes the entity back before Alpine ever sees it. Every other character reaches
141
+ the attribute byte-for-byte as it does today. blocks/_rail_row:112 is the same
142
+ move on a whole tag and its comment carries the rest of the reasoning. %>
143
+ <div <%= tag.attributes("x-data": data_attr) %>
144
+ <%= tag.attributes("x-init": init_expr) if init_calls.any? %>
128
145
  class="text-center py-6">
129
146
 
130
147
  <%# Icon — emoji takes priority over the default circular green check %>
@@ -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
 
@@ -43,6 +43,39 @@ module Studio
43
43
  # safe). The name says `in_attribute` so that the next person reaching for this in
44
44
  # a <script> has to stop and notice the difference.
45
45
  #
46
+ # AND IT IS THE WRONG REPAIR FOR AN ATTRIBUTE THE PARTIAL ASSEMBLES ITSELF —
47
+ # `<%= "x-init=\"#{expr}\"".html_safe %>`, where the attribute's OWN QUOTES are
48
+ # built in Ruby. Nothing here helps: escaping the value would break the Alpine
49
+ # EXPRESSION the caller deliberately handed the engine, and the marking has already
50
+ # told ERB to stand down, so a double quote closes the ATTRIBUTE and the remainder
51
+ # is parsed as MARKUP. The repair is to stop writing the quotes and let ActionView
52
+ # write them — `tag.attributes("x-init": expr.html_safe)` — because tag_option
53
+ # quote-escapes the finished value UNCONDITIONALLY, outside the escape branch it
54
+ # skips for a marked String. The expression survives byte-for-byte and cannot end
55
+ # the attribute. modals/blocks/_rail_row:112 and components/_sidebar_panel carry
56
+ # the worked examples; test/lib/studio/attribute_encoding_contract_test.rb pins the
57
+ # ActionView behaviour the whole thing rests on.
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
+ #
46
79
  # AND IT IS THE WRONG REPAIR FOR IDENTIFIER POSITION. A local spliced in as a bare
47
80
  # NAME — `$store.<%= modal_store %>.close()` — must be VALIDATED, never escaped:
48
81
  # escape_javascript also escapes `$`, so a legitimate store name like `dsModals$2`
@@ -6,9 +6,9 @@ module Studio
6
6
 
7
7
  class << self
8
8
  def configure!(env: ENV,
9
- rails_env: defined?(Rails) ? Rails.env : "development",
9
+ rails_env: defined?(Rails) && Rails.respond_to?(:env) ? Rails.env : "development",
10
10
  action_mailer: defined?(ActionMailer) ? ActionMailer::Base : nil,
11
- logger: defined?(Rails) ? Rails.logger : nil,
11
+ logger: defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil,
12
12
  mailer_from: defined?(Studio) && Studio.respond_to?(:mailer_from) ? Studio.mailer_from : nil,
13
13
  resend_loader: method(:load_resend!),
14
14
  resend_configurer: method(:configure_resend!))
data/lib/studio/s3.rb CHANGED
@@ -128,10 +128,18 @@ module Studio
128
128
  # before any Rails APPLICATION does — defines a namespace-only `module
129
129
  # Rails` with no singleton methods on it. Against that, a bare
130
130
  # `defined?(Rails)` reads TRUE and the very next call dies with
131
- # NoMethodError: undefined method `env` for module Rails. lib/studio.rb
132
- # already spells the guard this way in three places; this was the
133
- # straggler, and it turned an unrelated green suite red the first time a
134
- # unit test pulled action_view in.
131
+ # NoMethodError: undefined method `env` for module Rails. It turned an
132
+ # unrelated green suite red the first time a unit test pulled action_view
133
+ # in.
134
+ #
135
+ # THIS WAS NOT THE LAST ONE, though it was described that way when it
136
+ # landed. Three sites still carried the bare form afterwards — both
137
+ # keyword defaults in lib/studio/mail_transport.rb and the developer-desk
138
+ # route guard in lib/studio.rb — and the claim that the sweep was finished
139
+ # is how they survived a second review. The whole tree is swept now, and
140
+ # test/lib/studio/rails_guard_sweep_test.rb is what keeps it that way; do
141
+ # not restate completeness here, because a comment cannot notice the next
142
+ # straggler and that test can.
135
143
  defined?(Rails) && Rails.respond_to?(:env) && Rails.env&.production? ? "production" : "dev"
136
144
  end
137
145
  end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.74.0"
2
+ VERSION = "0.74.2"
3
3
  end
data/lib/studio.rb CHANGED
@@ -856,7 +856,7 @@ module Studio
856
856
  # Developer-desk tools. Drawn outside production, and each controller
857
857
  # re-checks Studio.local_tool_enabled? per request (loopback only) — the
858
858
  # route being absent is the outer gate, not the only one.
859
- unless defined?(Rails) && Rails.env.production?
859
+ unless defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
860
860
  get "_studio/local_emails", to: "studio/local_emails#index", as: :studio_local_emails
861
861
  get "_studio/local_review", to: "studio/local_reviews#show", as: :studio_local_review
862
862
  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.0
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>