studio-engine 0.72.3 → 0.74.0

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: 13abdd4ef0f10262a6560c40b2bed302c16d9f3b3b21582f3569cf0a7517cdf8
4
- data.tar.gz: 6f47a52a0ba75d8be96ff6200b75d30a1ff49d76db401581a17a0ea88b2dcd6c
3
+ metadata.gz: 6a6961114226763cef0af1b1500c982609412516d95c78c0985f63012882e92e
4
+ data.tar.gz: 4694c467547769140156eac7824a72fd0d510d84f0c30637cba40a87c3344187
5
5
  SHA512:
6
- metadata.gz: 4b82e9d95ec946a5ce8fdc2d82269eadd8e300bfe21e146163aa0105a3a96254065ac10b26dd6f87a29cff5a402de1ca6d9cb0cdb53ef7119604ee0102b841d5
7
- data.tar.gz: f03181c1586eb6c147b50778614df087115b27d1e76eb0dee7236f6e73c68a4509dadde3448d04cb84ea16cad4d20ffbe9eef8fcb7e96eca4be34dbd4c080d52
6
+ metadata.gz: 509affb42c8bf58a11289e0e0c16cfb2b298625456284b0d56775373fba4ac225c9bc6ba6bd695acb028a44a517836989204dfbb97bd96b1ed69ca2f1e9e7023
7
+ data.tar.gz: 36ed3258453f8025f591335348f989e333a6d805de78979e325c2a005a197d74c17f04995c6fcf6776bbebb2dae9d4404c483f0effe1e095bf29f2e02947ca83
data/CHANGELOG.md CHANGED
@@ -4,6 +4,51 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Fixed
8
+
9
+ - **The app census in these comments was short by the most important app.** Ten
10
+ sites said this engine is mounted by SIX apps and that THREE of them bundle no
11
+ `solana-studio`. Measured 2026-09-07 by the criterion that reproduces it — a
12
+ checkout whose own `config/routes.rb` calls `Studio.routes(self)`. A Gemfile
13
+ grep returns seven instead, catching `solana-studio`'s dev-group pin and the
14
+ `mcritchie-studio-ai-builder-cache` clone of the hub. **Five** apps mount it —
15
+ `mcritchie-studio`, `turf-monster`, `acquisition-studio`,
16
+ `mcritchie-industries`, `moms-app` — and **four** of those bundle no
17
+ `solana-studio`. `turf-monster` is the only one that does.
18
+
19
+ **The omission was the HUB.** Every roster of BASE consumers named
20
+ `acquisition-studio`, `mcritchie-industries` and `moms-app` and left out
21
+ `mcritchie-studio` — the app a developer reading those comments is most likely
22
+ to be sitting in, and one whose `/admin/style` is exactly what the
23
+ missing-template gate protects. The error ran in the direction that
24
+ UNDERSTATES the risk the comments exist to explain.
25
+
26
+ **Two near misses are what make a naive count wrong.** `chain-ops` bundles
27
+ `solana-studio` and NOT this engine, so it is not in this set at all;
28
+ `acquisition-studio` is a retired prototype whose `Gemfile` still pins the
29
+ engine, so it is. `rolio` bundles neither and is not a consumer.
30
+
31
+ **Corrected at every site, because a partial correction is a contradiction:**
32
+ `app/views/style/_modals.html.erb`, `test/dummy/config/application.rb`,
33
+ `Gemfile`, `test/views/style_web3_specimens_test.rb`,
34
+ `test/views/style_host_section_test.rb` (both sites),
35
+ `app/views/layouts/_navbar.html.erb`,
36
+ `app/assets/tailwind/studio_engine/engine.css`,
37
+ `test/views/nav_collapse_contract_test.rb`, `test/lib/vendored_alpine_test.rb`,
38
+ `test/lib/vendored_montserrat_test.rb`, and two earlier Unreleased entries
39
+ below. The navbar-fork claim rides the same denominator — three of FIVE fork
40
+ it, not three of six, and the named three are unchanged — and the
41
+ Sprockets/propshaft split is two of five rather than "exactly half the fleet",
42
+ a phrase that encoded an even total no census supports.
43
+
44
+ **What was already right, and stays.** The five-consumer roster in
45
+ `lib/studio.rb`'s `draw_profile_routes` note (each checked 2026-08-14) named
46
+ the correct five all along; this change brings the rest of the repo to it. The
47
+ "all six consumer DATABASES" figures elsewhere in this file are three apps
48
+ times two environments, not an app count.
49
+
50
+ Comment and prose only. No behaviour changes.
51
+
7
52
  ### Added
8
53
 
9
54
  - **The first-name card now says WHICH path finished it, and can type its
@@ -80,7 +125,7 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
80
125
  that ships no such partial gets **nothing** — no pill, no heading, no
81
126
  container — and that is asserted as a whole-document comparison rather than a
82
127
  handful of refutes: the page an app WITH a section gets, minus that section
83
- and its pill, must equal the page a base app gets. Five of the six apps
128
+ and its pill, must equal the page a base app gets. Four of the five apps
84
129
  mounting this engine will ship no host section, and their page is unchanged.
85
130
 
86
131
  **A host specimen drives `$store.modals`, not `dsModals`, and the distinction
@@ -272,6 +317,57 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
272
317
 
273
318
  ### Fixed
274
319
 
320
+ - **A host's apostrophe no longer kills the card it was passed to — now across the
321
+ blocks, not just the first-name step.** The same defect
322
+ `onboarding/_first_name` fixed one release ago turns out to be an engine-wide
323
+ idiom: a host-supplied local interpolated into a JS string literal inside a
324
+ JS-evaluating attribute. Repaired at eleven more splices.
325
+
326
+ **WHY IT IS WORTH A CHANGELOG LINE WHEN NOTHING VISIBLE CHANGES.** The failure
327
+ mode is silent. A bare apostrophe closes the JS literal, the whole expression
328
+ becomes a SyntaxError, and Alpine mounts the component as a NO-OP that still
329
+ renders every element — a card that looks perfect and whose buttons do nothing.
330
+ There is no error on screen, nothing in the server log, and no markup assertion
331
+ that can see it. Every value in every consumer resolves to a source literal or a
332
+ frozen constant today, so this is latent cover rather than a live fix; it is
333
+ worth doing because the next local to carry prose will look like an ordinary
334
+ change to whoever writes it.
335
+
336
+ **WHAT MOVED.** `blocks/_success_card` (`cta_event` at both CTA branches,
337
+ `secondary_event`), `blocks/_error_card` (`cta_event`, `secondary_event`),
338
+ `blocks/_entry_confirmed` and `blocks/_solana_tx_link` (`cluster_param`),
339
+ `modals/_crop_photo` (`store`), `studio/emails/show` (the two upload filenames
340
+ and the success sentence), and `profiles/_birthday_fields` (the date value).
341
+ No default or in-repo value contains a character either escaper touches, so
342
+ every shipped card renders byte-for-byte what it did.
343
+
344
+ **THE MECHANISM NOW HAS ONE HOME AND ONE GUARD.** `Studio::JsLiteral.in_attribute`
345
+ replaces the four inline copies in `_first_name`. TWO escapers have to run — one
346
+ for the JS literal, one for the HTML attribute — and the second only runs on a
347
+ value ERB still believes is unsafe, which is why the value is interpolated before
348
+ it is escaped. That subtlety was re-derived at every call site and had no test
349
+ anywhere; deleting it used to leave the suite green.
350
+
351
+ **NOT A FIX FOR IDENTIFIER POSITION, deliberately.** A local spliced in as a bare
352
+ NAME — `$store.<name>.close()` — must be VALIDATED, never escaped, because
353
+ `escape_javascript` also escapes `$` and mangles a legal store name. That fleet
354
+ (about 36 splices across 19 partials) and a third class found alongside it —
355
+ Ruby-ASSEMBLED JS emitted into an attribute, some of it already `html_safe` — are
356
+ scoped OUT of this change and carry their own tickets. The SHAPE of the splice
357
+ decides the repair, never the name of the local.
358
+
359
+ - **`Studio::S3` no longer mistakes a Rails NAMESPACE for a Rails application.**
360
+ `environment` guarded on `defined?(Rails)` and then called `Rails.env`.
361
+ rails-html-sanitizer — a transitive dependency of `action_view`, which arrives
362
+ long before any application does — ships a namespace-only `module Rails` with no
363
+ singleton methods, so that guard reads true and the next call raises
364
+ `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
367
+ real application — but the engine's own pure-Ruby unit lane can, and it did:
368
+ adding one `require` for a file that needs `action_view` turned an untouched
369
+ `email_catalog_test` red with two errors about email uploads.
370
+
275
371
  - **The style guide's two "Sign Wallet" thumbnails no longer crown themselves
276
372
  with a padlock the card stopped drawing, and the guide's lock no longer trails
277
373
  the release that removed it.** solana-studio 0.6.1 replaced the step-up card's
@@ -606,7 +702,7 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
606
702
 
607
703
  **The band table shipped from the wrong file.** The `.nav-shell` `--nav-*`
608
704
  sizes were inline in `layouts/_navbar.html.erb`, so only an app rendering that
609
- partial got them — and **three of six apps fork the navbar**
705
+ partial got them — and **three of five apps fork the navbar**
610
706
  (`turf-monster`, `mcritchie-studio`, `moms-app`), each left to hand-write its
611
707
  own. That is precisely how four independent copies of this collapse came to
612
708
  exist. They now ship from `engine.css`, which every engine-consuming app
data/Gemfile CHANGED
@@ -40,8 +40,9 @@ end
40
40
  #
41
41
  # DEVELOPMENT AND TEST ONLY, and deliberately NOT a gemspec runtime dependency.
42
42
  # Declaring it there would push a Solana stack onto every BASE consumer
43
- # (acquisition-studio, mcritchie-industries, moms-app mount this engine and
44
- # bundle no solana-studio), which is precisely the coupling the split removed.
43
+ # (mcritchie-studio, acquisition-studio, mcritchie-industries and moms-app all
44
+ # mount this engine and bundle no solana-studio four of the five consumers,
45
+ # the hub included), which is precisely the coupling the split removed.
45
46
  # It is here so the DUMMY app can resolve the gem's partials and the style guide
46
47
  # renders the real shipped cards rather than a fork of them.
47
48
  #
@@ -415,7 +415,7 @@ body.modal-open .studio-app-banner {
415
415
  /* === THE NAVBAR COLLAPSE BAND TABLE =====================================
416
416
  LAYER 2 OF THE PRIMITIVE, and it lives here rather than in
417
417
  layouts/_navbar.html.erb for one reason: every engine-consuming app imports
418
- this stylesheet, but only some render that partial. Three of six FORK the
418
+ this stylesheet, but only some render that partial. Three of five FORK the
419
419
  navbar (turf-monster, mcritchie-studio, moms-app), and while the table sat
420
420
  inline each fork had to hand-write its own — which is exactly how four
421
421
  independent copies of this collapse came to exist.
@@ -71,7 +71,7 @@
71
71
  @media (min-width: 400px) { .user-nav-col { width: 15rem; } .user-nav-fit { max-width: 15rem; } }
72
72
  @media (min-width: 768px) { .user-nav-col { width: 20rem; } .user-nav-fit { max-width: 20rem; } }
73
73
  /* The COLLAPSE band table used to live here, which meant only an app
74
- rendering THIS partial got it. Three of six apps fork the navbar, so each
74
+ rendering THIS partial got it. Three of five apps fork the navbar, so each
75
75
  was left to hand-write its own — the way four independent copies happened
76
76
  in the first place. It ships from engine.css now (every consuming app
77
77
  imports it), so a forking app OPTS IN with `nav-shell` +
@@ -137,6 +137,68 @@
137
137
  dbg(' ' + key, truncate(val, 40));
138
138
  });
139
139
 
140
+ // === THE REDIRECT TRANSPORT'S RETURN LEG ==============================
141
+ //
142
+ // TRIED FIRST, AND EVERYTHING BELOW IS LEFT EXACTLY AS IT WAS. The legacy
143
+ // block that follows handles ONE step — Phantom's signIn — with keys named
144
+ // phantom_dl_*, and it is what production mobile sign-in runs on TODAY. It is
145
+ // not touched, moved, or reworded here: a consumer that has not yet adopted
146
+ // the new transport must behave identically after this change, and the only
147
+ // honest way to promise that is to leave its code alone.
148
+ //
149
+ // GUARDED THE WAY THIS CODEBASE GUARDS EVERY OPTIONAL CAPABILITY: a host that
150
+ // does not load solana_studio/wallet_ops.js + wallet_journal.js has no
151
+ // SolanaStudio.walletOps, so this branch never runs and the legacy path is
152
+ // the whole program. An absent capability must not default to the permissive
153
+ // branch — the same rule the wallet picker's canDeepLink learned.
154
+ //
155
+ // WHY THE PENDING-JOURNAL CHECK IS PART OF THE GUARD, not just the globals:
156
+ // a host may load the new scripts while a signIn started under the OLD keys
157
+ // is still in flight — a user mid-round-trip across a deploy. Requiring an
158
+ // actual wallet_dl journal means that user finishes on the path they started,
159
+ // rather than being handed to a resumer with nothing to resume.
160
+ //
161
+ // WHAT THIS BRANCH DOES NOT DO: decrypt, verify, or know what the user was
162
+ // trying to accomplish. walletOps owns the step machine and the registered
163
+ // intent owns the outcome. This is a seam, and keeping it a seam is what lets
164
+ // the same callback serve sign-in and a contest entry without learning either.
165
+ var studio = window.SolanaStudio;
166
+ var hasResumer = !!(studio && studio.walletOps && typeof studio.walletOps.resume === 'function' &&
167
+ studio.walletJournal && typeof studio.walletJournal.peek === 'function');
168
+
169
+ if (hasResumer && studio.walletJournal.peek()) {
170
+ dbg('Transport', 'wallet_dl journal present — resuming through walletOps');
171
+
172
+ studio.walletOps.resume(params, {
173
+ navigate: function(url) { window.location.href = url; }
174
+ }).then(function(result) {
175
+ // SUSPENDED means resume already navigated to the next hop; this document
176
+ // is on its way out and must not also redirect, or the two race.
177
+ if (result && result.suspended) {
178
+ dbg('Transport', 'advanced to the next hop');
179
+ return;
180
+ }
181
+ if (result && result.done) {
182
+ dbg('Transport', 'intent complete');
183
+ // The registered intent owns where to land — it knows what the user was
184
+ // doing. Falling back to '/' rather than guessing a wallet-specific
185
+ // destination, which this file has no business knowing.
186
+ window.location.href = (result.value && result.value.redirect) || '/';
187
+ return;
188
+ }
189
+ // No pending request is a STATE, not a failure: a stale bookmark, a second
190
+ // tab, a reload after the journal was consumed. peek() said otherwise a
191
+ // moment ago, so this is a race, and the honest answer is to start over.
192
+ showError('No pending wallet request. Redirecting to login...');
193
+ }, function(err) {
194
+ // A user rejection arrives here with .rejected set — the wallet's own
195
+ // words, already mapped. Anything else is a genuine fault.
196
+ dbg('Transport ERROR', (err && err.message) || String(err));
197
+ showError((err && err.message) || 'Wallet request failed');
198
+ });
199
+ return;
200
+ }
201
+
140
202
  // Check for Phantom error
141
203
  if (params.get('errorCode')) {
142
204
  dbg('ERROR', 'errorCode=' + params.get('errorCode'));
@@ -197,6 +197,17 @@
197
197
 
198
198
  <%# ROW TWO — the pictures, and the words that ride on them. %>
199
199
  <div class="grid gap-6 md:grid-cols-2 mb-6">
200
+ <%# THE FILENAMES AND THE LABEL ARE ESCAPED ON THE WAY INTO THIS x-data. All
201
+ three come off the email REGISTRY entry rather than a form, so nothing
202
+ reaches them with a quote today — but they are the only values in the
203
+ attribute that are not authored right here, and bare interpolation of one
204
+ would close its JS literal and mount the whole upload host as a silent
205
+ no-op: an image picker that opens, crops, and never saves.
206
+
207
+ successMessage previously used the BARE `j` form. That is correct only
208
+ while the label is a plain String — `j` preserves an html_safe marking, and
209
+ a safe value would skip ERB's attribute-escaping half and let a raw double
210
+ quote out of the attribute. Studio::JsLiteral removes that condition. %>
200
211
  <section class="card p-4"
201
212
  <% if @uploads_available %>
202
213
  x-data="imageUploadHost({
@@ -205,10 +216,10 @@
205
216
  maxWidth: <%= max_width %>,
206
217
  transparent: false,
207
218
  allowGifs: true,
208
- filename: '<%= @entry.key %>.png',
219
+ filename: '<%= Studio::JsLiteral.in_attribute(@entry.key) %>.png',
209
220
  saving: 'Saving banner…',
210
221
  success: 'Banner updated',
211
- successMessage: '<%= j @entry.label %> now uses this app\'s own image.',
222
+ successMessage: '<%= Studio::JsLiteral.in_attribute(@entry.label) %> now uses this app\'s own image.',
212
223
  failure: 'Couldn\'t save the banner'
213
224
  })"
214
225
  @crop-photo-confirmed.window="onCropConfirmed($event.detail)"
@@ -513,7 +524,7 @@
513
524
  maxWidth: 600,
514
525
  transparent: true,
515
526
  allowGifs: false,
516
- filename: '<%= @entry.key %>-logo.png',
527
+ filename: '<%= Studio::JsLiteral.in_attribute(@entry.key) %>-logo.png',
517
528
  saving: 'Saving logo…',
518
529
  success: 'Logo updated',
519
530
  successMessage: 'This email now uses its own logo.',
@@ -26,7 +26,13 @@
26
26
  The living style guide passes store: "dsModals" for its page-scoped host.
27
27
  %>
28
28
  <% crop_store = local_assigns.fetch(:store, "modals") %>
29
- <div x-data="cropPhotoModal({ store: '<%= crop_store %>' })">
29
+ <%# STRING position, so it is ESCAPED rather than validated: the store name is
30
+ handed to cropPhotoModal as a JS string argument, not spliced in as a bare
31
+ identifier. An apostrophe would close that literal and mount the modal as a
32
+ silent no-op that still renders every element below. Studio::JsLiteral carries
33
+ why both escapers have to run. %>
34
+ <% crop_store_js = Studio::JsLiteral.in_attribute(crop_store) %>
35
+ <div x-data="cropPhotoModal({ store: '<%= crop_store_js %>' })">
30
36
  <h3 class="text-heading font-bold text-lg text-center mb-4">Crop Photo</h3>
31
37
 
32
38
  <template x-if="error">
@@ -71,6 +71,12 @@
71
71
  subtitle_key = local_assigns[:subtitle_key]
72
72
  tx_signature_key = local_assigns.fetch(:tx_signature_key, "props.txSignature")
73
73
  cluster_param = local_assigns.fetch(:cluster_param, "")
74
+ # STRING position — clusterParam is a JS string on the root scope that
75
+ # _success_card's tx-link concatenates into a URL. An apostrophe closes the
76
+ # literal and the whole x-data becomes a SyntaxError, which mounts this card and
77
+ # every nested block as a silent no-op. props_expr one line up is the OPPOSITE
78
+ # shape (an Alpine expression by contract) and is deliberately left alone.
79
+ cluster_param_js = Studio::JsLiteral.in_attribute(cluster_param)
74
80
  lobby_url_key = local_assigns.fetch(:lobby_url_key, "props.lobbyUrl")
75
81
  cta_label = local_assigns.fetch(:cta_label, "Continue")
76
82
  seeds_earned_key = local_assigns.fetch(:seeds_earned_key, "props.seedsEarned")
@@ -113,7 +119,7 @@
113
119
  <%# props getter + clusterParam live on the root scope so _success_card's nested
114
120
  Alpine expressions (props.txSignature, props.lobbyUrl, clusterParam) resolve up
115
121
  the scope chain. clusterParam feeds _success_card's branded tx-link URL. %>
116
- <div x-data="{ get props() { return <%= props_expr %>; }, clusterParam: '<%= cluster_param %>' }">
122
+ <div x-data="{ get props() { return <%= props_expr %>; }, clusterParam: '<%= cluster_param_js %>' }">
117
123
  <%= render "studio/modals/blocks/success_card", sc_locals do %>
118
124
  <%# The app's own enrichment, above the seeds bar. turf puts its kickoff
119
125
  countdown here — "when does it start" reads before the celebratory seeds
@@ -24,6 +24,15 @@
24
24
  <%
25
25
  icon_emoji = local_assigns[:icon_emoji] || "⏳" # ⏳
26
26
  cta_label = local_assigns[:cta_label] || 'Refresh'
27
+ # THE TWO EVENT NAMES ARE THE ONLY LOCALS HERE THAT LAND INSIDE JS. Both sit in a
28
+ # single-quoted literal inside a double-quoted @click, so an apostrophe in either
29
+ # closes the literal, the handler becomes a SyntaxError, and the button renders
30
+ # perfectly and does NOTHING when clicked — no error, no log, nothing a markup
31
+ # assertion can see. Escaped, not validated: an event name is a STRING to
32
+ # $dispatch, and any character is legal in one. Studio::JsLiteral says why the
33
+ # interpolated form is load-bearing.
34
+ cta_event_js = Studio::JsLiteral.in_attribute(local_assigns[:cta_event])
35
+ secondary_event_js = Studio::JsLiteral.in_attribute(local_assigns[:secondary_event])
27
36
  %>
28
37
  <%# role="alert" on the CARD, not on its message: the message is server-rendered
29
38
  static text, so it never "changes" for a live region to notice. The modal host
@@ -42,11 +51,11 @@
42
51
  <% if local_assigns[:cta_reload] %>
43
52
  <button @click="window.location.reload()" class="btn btn-outline btn-sm"><%= cta_label %></button>
44
53
  <% elsif local_assigns[:cta_event] %>
45
- <button @click="$dispatch('<%= cta_event %>')" class="btn btn-outline btn-sm"><%= cta_label %></button>
54
+ <button @click="$dispatch('<%= cta_event_js %>')" class="btn btn-outline btn-sm"><%= cta_label %></button>
46
55
  <% end %>
47
56
 
48
57
  <% if local_assigns[:secondary_label] && local_assigns[:secondary_event] %>
49
- <button @click="$dispatch('<%= secondary_event %>')"
58
+ <button @click="$dispatch('<%= secondary_event_js %>')"
50
59
  class="block mx-auto mt-3 text-xs text-secondary hover:text-heading underline underline-offset-2">
51
60
  <%= secondary_label %>
52
61
  </button>
@@ -17,10 +17,15 @@
17
17
  <%
18
18
  tx_signature_key = local_assigns.fetch(:tx_signature_key)
19
19
  cluster_param = local_assigns.fetch(:cluster_param, "")
20
+ # STRING position, inside the :href expression below. tx_signature_key beside it
21
+ # is an Alpine EXPRESSION by contract and is spliced in bare; this one is a
22
+ # literal suffix, so an apostrophe would close it and turn the whole :href
23
+ # binding into a SyntaxError — the link then renders with no href at all.
24
+ cluster_param_js = Studio::JsLiteral.in_attribute(cluster_param)
20
25
  %>
21
26
  <template x-if="(<%= tx_signature_key %>)">
22
27
  <div class="text-center mb-3 -mt-1">
23
- <a :href="'https://explorer.solana.com/tx/' + (<%= tx_signature_key %>) + '<%= cluster_param %>'"
28
+ <a :href="'https://explorer.solana.com/tx/' + (<%= tx_signature_key %>) + '<%= cluster_param_js %>'"
24
29
  target="_blank" rel="noopener"
25
30
  class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border border-subtle hover:border-primary/50 transition group no-underline"
26
31
  style="background: rgb(var(--color-primary-500-rgb) / 0.06);">
@@ -106,6 +106,19 @@
106
106
  }
107
107
  }".gsub(/\s+/, ' ').html_safe
108
108
 
109
+ # THE TWO EVENT NAMES, ESCAPED FOR THE @click THEY ARE SPLICED INTO. Each sits in
110
+ # a JS single-quoted literal inside a double-quoted attribute, so an apostrophe in
111
+ # either closes the literal and the handler becomes a SyntaxError — a CTA that
112
+ # renders exactly right and does nothing when clicked. This is the engine's
113
+ # most-rendered block, so it is also the widest reach that failure has.
114
+ #
115
+ # ESCAPED, NOT VALIDATED: an event name is a STRING argument to $dispatch and any
116
+ # character is legal in one. Contrast cta_href_key / props_expr below, which are
117
+ # Alpine EXPRESSIONS by contract and must not be touched. Studio::JsLiteral
118
+ # carries why the interpolated form is load-bearing.
119
+ cta_event_js = Studio::JsLiteral.in_attribute(local_assigns[:cta_event])
120
+ secondary_event_js = Studio::JsLiteral.in_attribute(local_assigns[:secondary_event])
121
+
109
122
  init_calls = []
110
123
  init_calls << "fireConfetti()" if fire_confetti
111
124
  init_calls << "startCountdown(#{local_assigns[:auto_redirect_url_key]})" if has_redirect
@@ -229,7 +242,7 @@
229
242
  <span class="relative z-10"><%= cta_label %></span>
230
243
  </a>
231
244
  <% elsif use_drain && local_assigns[:cta_event] %>
232
- <button @click="$dispatch('<%= cta_event %>')"
245
+ <button @click="$dispatch('<%= cta_event_js %>')"
233
246
  class="btn btn-primary btn-lg w-full relative overflow-hidden no-underline">
234
247
  <div class="absolute inset-0 pointer-events-none origin-left"
235
248
  style="background: rgba(255,255,255,0.18);"
@@ -246,12 +259,12 @@
246
259
  <% elsif local_assigns[:cta_href_key] %>
247
260
  <a :href="<%= cta_href_key %>" class="btn btn-primary btn-lg w-full"><%= cta_label %></a>
248
261
  <% elsif local_assigns[:cta_event] %>
249
- <button @click="$dispatch('<%= cta_event %>')" class="btn btn-primary btn-lg w-full"><%= cta_label %></button>
262
+ <button @click="$dispatch('<%= cta_event_js %>')" class="btn btn-primary btn-lg w-full"><%= cta_label %></button>
250
263
  <% end %>
251
264
  <% end %>
252
265
 
253
266
  <% if local_assigns[:secondary_label] && local_assigns[:secondary_event] %>
254
- <button @click="$dispatch('<%= secondary_event %>')"
267
+ <button @click="$dispatch('<%= secondary_event_js %>')"
255
268
  class="block mx-auto mt-3 text-xs text-secondary hover:text-heading underline underline-offset-2">
256
269
  <%= secondary_label %>
257
270
  </button>
@@ -94,11 +94,11 @@
94
94
  STRING position — empty_error, submit_path, skip_path, done_event — each sits
95
95
  inside a JS SINGLE-quoted literal. A bare apostrophe closes the literal, the
96
96
  expression becomes a SyntaxError, and the card is that silent no-op. Repair:
97
- escape_javascript, in the INTERPOLATED form. The wrapper is load-bearing:
98
- escape_javascript(SafeBuffer) answers true to html_safe, so ERB would skip
99
- its own attribute-escaping half and a raw double quote could still close the
100
- attribute. Wrapping the value in a plain interpolation first strips the safe
101
- marking, so both escapers run.
97
+ Studio::JsLiteral.in_attribute, which is where the mechanism now lives and is
98
+ tested. Two escapers have to run — one for the JS literal, one for the
99
+ attribute and the second only runs on a value ERB still believes is unsafe.
100
+ This card used to spell that out inline at four sites; the engine has eleven
101
+ more of them, so it is one method with one guard now.
102
102
 
103
103
  IDENTIFIER position — modal_store — is spliced in as a bare NAME, at three
104
104
  sites: the props getter, finish's close, and the Ruby-built dismiss_action
@@ -145,15 +145,15 @@
145
145
  # apostrophes. Interpolated raw, an error reading "We'll need a name" would
146
146
  # close the JS single-quoted string, make the whole expression a SyntaxError,
147
147
  # and mount the component as a SILENT NO-OP that still renders every element
148
- # below. escape_javascript covers the apostrophe, the double quote, the
149
- # backslash and the newline; the interpolation around it first strips any
150
- # html_safe marking, because a safe string would skip ERB's own attribute
151
- # escaping and could smuggle a raw double quote in the exact failure the
152
- # CRITICAL note above describes.
148
+ # below. Studio::JsLiteral.in_attribute covers the apostrophe, the double quote,
149
+ # the backslash and the newline, AND hands ERB a value it still believes is
150
+ # unsafe so the attribute's own escaping runs too — a safe string would skip that
151
+ # half and could smuggle a raw double quote in, the exact failure the CRITICAL
152
+ # note above describes.
153
153
  #
154
154
  # INERT ON BOTH DEFAULTS. Neither default string contains a character either
155
155
  # escaper touches, so the shipped card is byte-for-byte what it was.
156
- empty_error_js = escape_javascript("#{empty_error}")
156
+ empty_error_js = Studio::JsLiteral.in_attribute(empty_error)
157
157
  placeholder = local_assigns.fetch(:placeholder, "Alex")
158
158
  # OFF unless a host passes a non-empty array. Normalised to nil so that an
159
159
  # empty list behaves exactly like an absent local rather than emitting the
@@ -201,11 +201,11 @@
201
201
  # Alpine mounts the component as a SILENT NO-OP that still renders every element
202
202
  # — perfect-looking markup, dead card.
203
203
  #
204
- # THE INTERPOLATED FORM IS LOAD-BEARING, not a style choice:
205
- # escape_javascript(SafeBuffer).html_safe? is TRUE, so ERB would skip its own
206
- # attribute-escaping half and a raw double quote could still close the
207
- # double-quoted x-data. Wrapping in "#{}" first strips the html_safe marking, so
208
- # both escapers run: escape_javascript for the JS literal, ERB for the attribute.
204
+ # WHY A METHOD RATHER THAN escape_javascript AT EACH SITE: the second escaper is
205
+ # conditional. ERB skips a value that answers true to html_safe, and
206
+ # escape_javascript preserves its argument's marking, so the naive call silently
207
+ # loses the attribute half. Studio::JsLiteral.in_attribute owns that, and owns the
208
+ # single test that fails when it is removed.
209
209
  #
210
210
  # INERT ON EVERY DEFAULT. Two paths, an event name and a store name carry no
211
211
  # character either escaper touches, so the shipped card is byte-for-byte what it
@@ -213,9 +213,9 @@
213
213
  # host supplies these as prose today, but empty_error established that prose does
214
214
  # belong in this attribute, and the next local to carry an apostrophe will look
215
215
  # like an ordinary change to whoever writes it.
216
- submit_path_js = escape_javascript("#{submit_path}")
217
- skip_path_js = escape_javascript("#{skip_path}")
218
- done_event_js = escape_javascript("#{done_event}")
216
+ submit_path_js = Studio::JsLiteral.in_attribute(submit_path)
217
+ skip_path_js = Studio::JsLiteral.in_attribute(skip_path)
218
+ done_event_js = Studio::JsLiteral.in_attribute(done_event)
219
219
  field_id = local_assigns.fetch(:id, "onboarding-first-name")
220
220
  # The × mirrors the skip affordance it sits beside: it SKIPS while the step is
221
221
  # skippable, and merely CLOSES once it is required. Resolved here rather than in
@@ -37,9 +37,16 @@
37
37
  m = user.birth_month
38
38
  d = user.birth_day
39
39
  value = (y.present? && m.present? && d.present?) ? format("%04d-%02d-%02d", y, m, d) : nil
40
+ # ESCAPED FOR THE x-data BELOW. format() cannot produce a quote today, so this is
41
+ # latent cover rather than a live fix — but the local is read off a USER record,
42
+ # and the guard has to sit on the splice rather than on today's formatter. Bare,
43
+ # an apostrophe would close the JS literal and studioBirthdayFields would never
44
+ # evaluate, leaving three selects that render and never save. The <input value="">
45
+ # copy below needs no such treatment: ERB already escapes it, and it is not JS.
46
+ value_js = Studio::JsLiteral.in_attribute(value)
40
47
  %>
41
48
 
42
- <div x-data="studioBirthdayFields('<%= value %>')">
49
+ <div x-data="studioBirthdayFields('<%= value_js %>')">
43
50
  <%# --- the JS-less path ------------------------------------------------- %>
44
51
  <template x-if="!alpine">
45
52
  <input type="date" name="profile[birthday]" id="profile_birthday"
@@ -43,13 +43,30 @@
43
43
  # /auth/solana routes, Solana::SessionAuth, the phantom callback) and ships no
44
44
  # wallet UI at all.
45
45
  #
46
- # So resolution is a RUNTIME question here, not a build-time one. Three of the
47
- # six apps mounting this engine (acquisition-studio, mcritchie-industries,
48
- # moms-app) bundle no solana-studio, and an unconditional render would turn
49
- # their /admin/style into a missing-template 500. The gem is a development and
46
+ # So resolution is a RUNTIME question here, not a build-time one. FOUR of the
47
+ # five apps mounting this engine (mcritchie-studio, acquisition-studio,
48
+ # mcritchie-industries, moms-app) bundle no solana-studio turf-monster is
49
+ # the only one that does and an unconditional render would turn their
50
+ # /admin/style into a missing-template 500. The gem is a development and
50
51
  # test dependency of this engine precisely so the guide can show the REAL
51
52
  # cards; where it is absent the specimens stay listed but unopenable.
52
53
  #
54
+ # THE EXCEPTION IS THE DURABLE HALF; the count rots the day an app is born.
55
+ # Derived 2026-09-07 — and the CRITERION is recorded, not just the count,
56
+ # because the obvious grep does not reproduce it. The five are the checkouts
57
+ # whose own config/routes.rb calls `Studio.routes(self)`, the same five
58
+ # lib/studio.rb's draw_profile_routes note names. Grepping Gemfiles for
59
+ # `gem "studio-engine"` returns SEVEN: it also catches solana-studio, which
60
+ # pins the engine in `group :development, :test` for its dummy and draws no
61
+ # routes, and mcritchie-studio-ai-builder-cache, which is the HUB checked out
62
+ # twice (same Heroku remote; HEAD c7d5d326 is a commit inside
63
+ # mcritchie-studio) and would double-count it. This line used to read "three
64
+ # of the six" and to omit the HUB — the app a reader is likeliest to be
65
+ # sitting in, and one that bundles no solana-studio. Two further near misses:
66
+ # chain-ops bundles solana-studio WITHOUT this engine, so it is not in this
67
+ # set at all; acquisition-studio is a retired prototype whose Gemfile still
68
+ # pins the engine, so it is.
69
+ #
53
70
  # Same three-term lookup_context.exists? the modal host uses for host_extras
54
71
  # (studio/modals/_host.html.erb): name, prefixes, partial.
55
72
  web3_gem = lookup_context.exists?("wallet_connect", ["solana_studio/modals"], true)
@@ -0,0 +1,80 @@
1
+ require "action_view"
2
+
3
+ module Studio
4
+ # ONE home for the repair that keeps a host-supplied value from bricking an
5
+ # Alpine component when it is spliced into a JS-evaluating HTML attribute.
6
+ #
7
+ # THE FAILURE THIS EXISTS FOR is silent, which is the whole reason it is worth a
8
+ # module instead of a convention. A local sits inside a JS single-quoted literal
9
+ # in a double-quoted attribute:
10
+ #
11
+ # <button @click="$dispatch('<%= cta_event %>')">
12
+ #
13
+ # A bare apostrophe in `cta_event` closes that literal, the expression becomes a
14
+ # SyntaxError, and Alpine mounts the component as a NO-OP that still renders
15
+ # every element. Perfect-looking markup, dead card. Nothing raises, nothing logs
16
+ # a server-side warning, and every string assertion about the response bytes
17
+ # still passes — which is how this class of defect survives a review.
18
+ #
19
+ # BOTH ESCAPERS HAVE TO RUN, and that is the part a hand-written call gets wrong.
20
+ # There are two nested contexts, so there are two ways out of the attribute:
21
+ #
22
+ # * out of the JS STRING, with `'` — repaired by escape_javascript
23
+ # * out of the HTML ATTRIBUTE, with `"` — repaired by ERB's own escaping
24
+ #
25
+ # escape_javascript covers the first. ERB covers the second, but ONLY for a value
26
+ # it believes is unsafe, and `escape_javascript(SafeBuffer)` answers true to
27
+ # html_safe?. Hand an html_safe string straight to escape_javascript and ERB
28
+ # steps aside, a raw double quote reaches the attribute, and the attribute closes
29
+ # early — the same dead card by a longer route.
30
+ #
31
+ # So the interpolation wrapper below is LOAD-BEARING, not a style tic: `"#{value}"`
32
+ # produces a plain String, escape_javascript therefore returns a plain String, and
33
+ # ERB does its half on the way into the attribute. That subtlety was re-derived at
34
+ # every call site before this module existed, and it had no test cover anywhere;
35
+ # it now has exactly one implementation and one guard
36
+ # (test/lib/studio/js_literal_test.rb).
37
+ #
38
+ # DELIBERATELY NARROW — there is no script-body variant here, and adding one is
39
+ # not a copy of this method. Inside <script>…</script> the HTML parser does no
40
+ # entity decoding, so ERB's escaping is not a second layer of safety there, it is
41
+ # CORRUPTION: `\"` would arrive as `\&quot;` and break the JS this method exists
42
+ # to protect. A script body wants JS escaping ONLY (escape_javascript, marked
43
+ # safe). The name says `in_attribute` so that the next person reaching for this in
44
+ # a <script> has to stop and notice the difference.
45
+ #
46
+ # AND IT IS THE WRONG REPAIR FOR IDENTIFIER POSITION. A local spliced in as a bare
47
+ # NAME — `$store.<%= modal_store %>.close()` — must be VALIDATED, never escaped:
48
+ # escape_javascript also escapes `$`, so a legitimate store name like `dsModals$2`
49
+ # comes back mangled and the card dies anyway. The SHAPE of the splice decides the
50
+ # repair, never the name of the local. studio/modals/onboarding/_first_name
51
+ # carries the worked example of both.
52
+ module JsLiteral
53
+ extend ActionView::Helpers::JavaScriptHelper
54
+
55
+ module_function
56
+
57
+ # Escape +value+ for a JS string literal that lives inside an HTML attribute.
58
+ #
59
+ # Returns a plain (NOT html_safe) String on purpose — see the note above. The
60
+ # caller interpolates it inside the JS quotes, exactly as it would have
61
+ # interpolated the raw local:
62
+ #
63
+ # cta_event_js = Studio::JsLiteral.in_attribute(cta_event)
64
+ # # …
65
+ # <button @click="$dispatch('<%= cta_event_js %>')">
66
+ #
67
+ # nil becomes "" rather than "nil", because every current caller reaches this
68
+ # with an optional local and an empty JS string is the honest rendering of an
69
+ # absent value.
70
+ # ONE MECHANISM, DELIBERATELY. An earlier draft also re-stripped the marking on
71
+ # the way out, as belt-and-braces. That made both halves redundant, and a
72
+ # redundant guard is one that survives being deleted: removing the interpolation
73
+ # below left every test green, because the second stripper covered for it. There
74
+ # is one way this works now, and test/lib/studio/js_literal_test.rb fails when it
75
+ # is removed.
76
+ def in_attribute(value)
77
+ JsLiteral.escape_javascript("#{value}")
78
+ end
79
+ end
80
+ end
data/lib/studio/s3.rb CHANGED
@@ -123,7 +123,16 @@ module Studio
123
123
  def environment
124
124
  return "dev" if EnvironmentBanner.qa_environment?
125
125
 
126
- defined?(Rails) && Rails.env.production? ? "production" : "dev"
126
+ # ASKS FOR THE METHOD, NOT THE CONSTANT, and the difference is not
127
+ # theoretical. rails-html-sanitizer — which arrives with action_view, long
128
+ # before any Rails APPLICATION does — defines a namespace-only `module
129
+ # Rails` with no singleton methods on it. Against that, a bare
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.
135
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env&.production? ? "production" : "dev"
127
136
  end
128
137
  end
129
138
  end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.72.3"
2
+ VERSION = "0.74.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -8,6 +8,7 @@ require "studio/color_scale"
8
8
  require "studio/environment_banner"
9
9
  require "studio/theme_resolver"
10
10
  require "studio/ui_primitives"
11
+ require "studio/js_literal"
11
12
  require "studio/sidebar_sections"
12
13
  require "studio/profile_sections"
13
14
  require "studio/profile_image"
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.72.3
4
+ version: 0.74.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -573,6 +573,7 @@ files:
573
573
  - lib/studio/geo/lookup.rb
574
574
  - lib/studio/image_cache.rb
575
575
  - lib/studio/ip_locations.rb
576
+ - lib/studio/js_literal.rb
576
577
  - lib/studio/link_resolution.rb
577
578
  - lib/studio/link_token.rb
578
579
  - lib/studio/log_rotation.rb