studio-engine 0.74.2 → 0.74.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +24 -0
- data/app/views/studio/modals/_host.html.erb +5 -1
- data/app/views/studio/modals/_saving.html.erb +2 -1
- data/app/views/studio/modals/_scoped_host.html.erb +15 -1
- data/app/views/studio/modals/auth/_resend_footer.html.erb +3 -1
- data/app/views/studio/modals/blocks/_age_gate.html.erb +3 -1
- data/app/views/studio/modals/blocks/_birthday.html.erb +7 -0
- data/app/views/studio/modals/blocks/_close_x.html.erb +3 -1
- data/app/views/studio/modals/blocks/_cta_redirect.html.erb +3 -1
- data/app/views/studio/modals/blocks/_entry_confirmed.html.erb +3 -1
- data/app/views/studio/modals/blocks/_free_entry_earned.html.erb +3 -1
- data/app/views/studio/modals/blocks/_leveling_activity.html.erb +3 -1
- data/app/views/studio/modals/blocks/_onchain_success.html.erb +3 -1
- data/app/views/studio/modals/blocks/_shell.html.erb +3 -1
- data/app/views/studio/modals/onboarding/_first_name.html.erb +16 -30
- data/app/views/studio/modals/templates/_action.html.erb +2 -1
- data/app/views/studio/modals/templates/_form.html.erb +2 -1
- data/app/views/studio/modals/templates/_status.html.erb +2 -1
- data/app/views/studio/modals/templates/_success.html.erb +2 -1
- data/app/views/studio/modals/templates/_wizard.html.erb +2 -1
- data/app/views/style/_modals.html.erb +10 -146
- data/app/views/style/modals/_unsubscribe_confirm.html.erb +18 -8
- data/lib/studio/js_identifier.rb +88 -0
- data/lib/studio/js_literal.rb +3 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +1 -0
- metadata +3 -7
- data/app/views/style/modals/_ds_newsletter_success.html.erb +0 -67
- data/app/views/style/modals/_network_guard.html.erb +0 -55
- data/app/views/style/modals/_quest_success.html.erb +0 -55
- data/app/views/style/modals/_unsubscribe_goodbye.html.erb +0 -30
- data/app/views/style/modals/_wallet_deposit.html.erb +0 -56
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2ef2370af4941b05f724ac0eea748c555d6c70610d63194806deb812fbb4cc81
|
|
4
|
+
data.tar.gz: 5dd92c0a97e42fb9c29a5d644720c9cf1e7a83ce2db97e85c6ade6a5db35f297
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: baac9edc9c8fe756d3bb44994539cce055bf2987d3e19109b076793a1c9d93c1ac3704fa4e38d60e839cd4a79f03128f75e05d25db7ef3789116361cb6ec3568
|
|
7
|
+
data.tar.gz: db54e015edb969381a2f5d26f3c8836aca367294051a944d669279e5ce249f0e0c4655777c247784462a07a4b691dac197fe273854c26d9d60c9fbd830990043
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,52 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
6
6
|
|
|
7
7
|
### Fixed
|
|
8
8
|
|
|
9
|
+
- **A store name that is not a JS identifier half-worked, silently, in seventeen
|
|
10
|
+
partials — it now raises.** `$store.<%= modal_store %>.close()` splices the local
|
|
11
|
+
in as a bare NAME, so `modal_store: "my store"` emitted
|
|
12
|
+
`$store.my store.close()`: a SyntaxError that Alpine mounts as a no-op which still
|
|
13
|
+
renders every element. `Studio::JsIdentifier.validate!` is the new shared contract
|
|
14
|
+
(`/\A[A-Za-z_$][A-Za-z0-9_$]*\z/`), called once per partial at the fetch.
|
|
15
|
+
|
|
16
|
+
**THIS IS A BEHAVIOUR TIGHTENING, NOT A REPAIR, and it is breaking.** A host that
|
|
17
|
+
passed a non-identifier store name got a working page with a dead button; it now
|
|
18
|
+
gets an `ArgumentError` at render. That is the right trade — loud beats silent —
|
|
19
|
+
but it is a 500 where there was a 200. **Blast radius is zero, measured:** every
|
|
20
|
+
explicit store name in mcritchie-studio, turf-monster, rolio and this engine is
|
|
21
|
+
`modals`, `dsModals`, `emailModals` or `profileModals`, and no consumer app passes
|
|
22
|
+
one at all. **Rollback is one file:** make `validate!` return its argument instead
|
|
23
|
+
of raising and every partial is back to the old leniency, with no call site to
|
|
24
|
+
revisit.
|
|
25
|
+
|
|
26
|
+
**ESCAPING IS THE WRONG REPAIR HERE, which is why this is a second module rather
|
|
27
|
+
than a second method on `Studio::JsLiteral`.** `escape_javascript` also escapes
|
|
28
|
+
`$`, so the legal name `dsModals$2` comes back as `dsModals\$2` and
|
|
29
|
+
`$store.dsModals\$2.close()` is a different SyntaxError — the same dead card on a
|
|
30
|
+
value that was never hostile. A pattern rather than an allowlist, deliberately: an
|
|
31
|
+
allowlist would be a shared gem enumerating its own consumers, and the next app to
|
|
32
|
+
mount a page-scoped host would be refused by its own dependency.
|
|
33
|
+
|
|
34
|
+
**MEASURED, AND TWO OF THE LISTED SITES WERE LEFT ALONE.** Nineteen partials fetch
|
|
35
|
+
a store-name local. Counted on the tree: 36 splices of a store name in identifier
|
|
36
|
+
position — 32 in ERB, 4 assembled in Ruby — across 17 files. Rendering a hostile
|
|
37
|
+
value through all nineteen (rather than reading them) showed 17 emitting at least
|
|
38
|
+
one DEAD splice, one already refusing, and one unaffected. Seventeen now carry the
|
|
39
|
+
guard: 16 new, plus `modals/onboarding/_first_name` moved onto the shared one.
|
|
40
|
+
`modals/_crop_photo` is the unaffected one — its name is a JS string argument
|
|
41
|
+
resolved with `$store[name]`, a lookup where a space is as legal as a letter, and
|
|
42
|
+
it was already escaped, so demanding an identifier would break a host for nothing.
|
|
43
|
+
`blocks/_birthday` has no splice of its own — it hands the name to `Alpine.store()`
|
|
44
|
+
and forwards it to `blocks/_shell`, which validates — so a second guard there would
|
|
45
|
+
have been one that survives deletion.
|
|
46
|
+
|
|
47
|
+
**NOTHING MOVED FOR A LEGAL NAME.** All nineteen partials rendered before and
|
|
48
|
+
after, against the default, `dsModals` and `ds_Modals$2`, are byte-identical
|
|
49
|
+
(186,310 bytes each): a value matching the pattern carries no character ERB
|
|
50
|
+
escapes, and the validator returns a plain String so ERB's escaping stays armed
|
|
51
|
+
for the day the pattern widens. `modals/onboarding/_first_name` carried this
|
|
52
|
+
regex inline as the worked example and now calls the shared one — same pattern,
|
|
53
|
+
same error, same message.
|
|
54
|
+
|
|
9
55
|
- **The email banner assembled two attributes INCLUDING THEIR OWN QUOTES in Ruby,
|
|
10
56
|
so ERB escaping never ran on them.** `_layered_banner.html.erb` wrote
|
|
11
57
|
`background="..."` and `bgcolor="..."` as Ruby strings and marked them
|
data/README.md
CHANGED
|
@@ -238,6 +238,30 @@ the block:
|
|
|
238
238
|
<% end %>
|
|
239
239
|
```
|
|
240
240
|
|
|
241
|
+
#### Writing a modal's content partial — two rules the host imposes
|
|
242
|
+
|
|
243
|
+
Both of these fail SILENTLY. Nothing raises, nothing logs, and the card renders;
|
|
244
|
+
it just does less than it looks like it does.
|
|
245
|
+
|
|
246
|
+
**1. SINGLE ROOT.** A content partial's outer `<div>` is the host's required
|
|
247
|
+
root. Alpine's `<template x-if>` clones only the FIRST root element of its
|
|
248
|
+
content, so a second top-level sibling — a stray `<span>`, a trailing `<style>`
|
|
249
|
+
block, a comment-turned-node — is dropped on the floor. Bake anything extra
|
|
250
|
+
inside the wrapping `<div>`.
|
|
251
|
+
|
|
252
|
+
**2. NO DOUBLE QUOTE INSIDE `x-data`.** The attribute is double-quoted, so an
|
|
253
|
+
inner double quote CLOSES it. Alpine then mounts a component whose expression is
|
|
254
|
+
truncated mid-statement: every element still renders and every handler does
|
|
255
|
+
nothing. Single-quote everything inside `x-data`.
|
|
256
|
+
|
|
257
|
+
These are properties of the HOST, not of any one card, and they apply to every
|
|
258
|
+
consumer partial an app registers — not only to the specimens in this gem. They
|
|
259
|
+
are recorded here because they used to be recorded only in the style guide's
|
|
260
|
+
specimen headers, which made them deletable by a change that removed a specimen:
|
|
261
|
+
retiring the `style/modals/_network_guard` and `_wallet_deposit` mirrors on
|
|
262
|
+
2026-09-09 would otherwise have taken the single-root rule out of both this repo
|
|
263
|
+
and the app that inherited those cards.
|
|
264
|
+
|
|
241
265
|
**Wallet modals moved to `solana-studio`.** The Connect Wallet picker, the Web3
|
|
242
266
|
step-up card and the Phantom deep link used to ship here as
|
|
243
267
|
`studio/modals/wallet_connect`, `studio/modals/web3_step_up` and
|
|
@@ -28,7 +28,11 @@
|
|
|
28
28
|
the app rather than to one call site
|
|
29
29
|
|
|
30
30
|
Consumer integration example and API reference: the "Modal host"
|
|
31
|
-
section of the gem README
|
|
31
|
+
section of the gem README — which also carries the two rules this host
|
|
32
|
+
IMPOSES on the content partials registered in its block: a SINGLE ROOT
|
|
33
|
+
element (a template x-if clones only the first one; later siblings are
|
|
34
|
+
dropped) and NO double quote inside x-data (it closes the attribute and
|
|
35
|
+
Alpine mounts a silent no-op). Both fail without raising.
|
|
32
36
|
%>
|
|
33
37
|
<style>
|
|
34
38
|
/* Scroll lock applied by $store.modals._sync() when the stack is non-empty.
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
apps omit this; the /admin/style demo passes an expression
|
|
11
11
|
(e.g. close) so the specimen self-terminates after min_duration
|
|
12
12
|
instead of spinning forever. %>
|
|
13
|
-
|
|
13
|
+
<%# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier. %>
|
|
14
|
+
<% saving_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:store, "modals"), local: :store) %>
|
|
14
15
|
<div>
|
|
15
16
|
<%= render "studio/modals/blocks/processing_card",
|
|
16
17
|
title_key: "$store.#{saving_store}.current()?.props?.title || 'Saving…'",
|
|
@@ -41,12 +41,26 @@
|
|
|
41
41
|
chaining (`current()?.id`): the outer template unmounts one tick AFTER the
|
|
42
42
|
stack empties, so a bare `.id` throws on every close.
|
|
43
43
|
|
|
44
|
+
THE CONTENT PARTIALS OBEY THE SAME TWO RULES AS THE SHARED HOST'S, and both
|
|
45
|
+
fail silently: a SINGLE ROOT element (a template x-if clones only the first
|
|
46
|
+
one) and NO double quote inside x-data (it closes the attribute and Alpine
|
|
47
|
+
mounts a component that renders everything and does nothing). Stated in full
|
|
48
|
+
in the gem README's "Modal host" section.
|
|
49
|
+
|
|
50
|
+
AND EVERY ID REGISTERED HERE MUST BE REACHABLE, both ways round. A card that
|
|
51
|
+
opens an id no template registers shows an EMPTY panel; a template no card
|
|
52
|
+
opens is a modal nobody can reach. That pairing is the one thing a cabinet of
|
|
53
|
+
modals cannot check by looking at itself, so the living style guide holds it
|
|
54
|
+
in a test (test/views/style_guide_no_dangling_specimens_test.rb).
|
|
55
|
+
|
|
44
56
|
(No inline ERB example here on purpose. An ERB comment ends at its FIRST "%" +
|
|
45
57
|
">", so an example containing one silently closes the comment and dumps the
|
|
46
58
|
rest of it onto the page as visible text. That shipped once.)
|
|
47
59
|
%>
|
|
48
60
|
<%
|
|
49
|
-
|
|
61
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
62
|
+
scoped_store = Studio::JsIdentifier.validate!(
|
|
63
|
+
local_assigns.fetch(:store), local: :store)
|
|
50
64
|
card_class = local_assigns.fetch(:card_class,
|
|
51
65
|
"bg-surface rounded-xl border border-subtle shadow-2xl p-6 max-w-sm w-full")
|
|
52
66
|
%>
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
modal_store — Alpine store name backing close(). Default "modals".
|
|
13
13
|
%>
|
|
14
14
|
<%
|
|
15
|
-
|
|
15
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
16
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
17
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
16
18
|
%>
|
|
17
19
|
<button @click="resendMagicLink()" :disabled="props.submitting === 'magic-link' || (props.resendCooldown || 0) > 0"
|
|
18
20
|
class="block mx-auto text-sm text-primary hover:text-primary-300 underline underline-offset-2 disabled:opacity-50 disabled:no-underline">
|
|
@@ -62,7 +62,9 @@
|
|
|
62
62
|
birthday_modal_id = local_assigns.fetch(:birthday_modal_id, "birthday")
|
|
63
63
|
back_label = local_assigns.fetch(:back_label, "Update your Birthday")
|
|
64
64
|
title = local_assigns[:title]
|
|
65
|
-
|
|
65
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
66
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
67
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
66
68
|
%>
|
|
67
69
|
<div x-data="{
|
|
68
70
|
now: Date.now(),
|
|
@@ -80,6 +80,13 @@
|
|
|
80
80
|
fine_print = local_assigns.fetch(:fine_print,
|
|
81
81
|
validates ? "We use your date of birth only to confirm eligibility."
|
|
82
82
|
: "We use your date of birth only to know when to wish you a happy birthday.")
|
|
83
|
+
# NOT VALIDATED HERE, deliberately. This partial never splices the store name into
|
|
84
|
+
# identifier position: it reaches birthdayModal as a JS STRING (escaped below) and
|
|
85
|
+
# the factory looks it up with Alpine.store(name), where any character is legal.
|
|
86
|
+
# The identifier contract is owed by blocks/_shell, which this card renders one
|
|
87
|
+
# line further down and which validates it — so a hostile value still raises,
|
|
88
|
+
# once, at the site that actually splices it. A second guard here would be a
|
|
89
|
+
# redundant one: it would survive being deleted.
|
|
83
90
|
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
84
91
|
demo = local_assigns.fetch(:demo, false)
|
|
85
92
|
demo_underage = local_assigns.fetch(:demo_underage, false)
|
|
@@ -42,7 +42,9 @@
|
|
|
42
42
|
label — aria-label. Default "Close".
|
|
43
43
|
%>
|
|
44
44
|
<%
|
|
45
|
-
|
|
45
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
46
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
47
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
46
48
|
label = local_assigns.fetch(:label, "Close")
|
|
47
49
|
%>
|
|
48
50
|
<button type="button" @click="$store.<%= modal_store %>.close()"
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
href_key = local_assigns.fetch(:href_key)
|
|
26
26
|
label_html = local_assigns.fetch(:label_html, "Continue")
|
|
27
27
|
duration_seconds = local_assigns.fetch(:duration_seconds, 5)
|
|
28
|
-
|
|
28
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
29
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
30
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
29
31
|
%>
|
|
30
32
|
<div x-data="{
|
|
31
33
|
_redirectTimer: null,
|
|
@@ -60,7 +60,9 @@
|
|
|
60
60
|
that does nothing.
|
|
61
61
|
%>
|
|
62
62
|
<%
|
|
63
|
-
|
|
63
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
64
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
65
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
64
66
|
props_expr = local_assigns.fetch(:props_expr,
|
|
65
67
|
"(function () { var c = $store.#{modal_store}.current(); return (c && c.props) || {}; })()")
|
|
66
68
|
above_seeds = local_assigns[:above_seeds]
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
close_label — dismiss link text. Default "Close".
|
|
20
20
|
%>
|
|
21
21
|
<%
|
|
22
|
-
|
|
22
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
23
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
24
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
23
25
|
icon_emoji = local_assigns.fetch(:icon_emoji, "🎉")
|
|
24
26
|
title_key = local_assigns.fetch(:title_key, "props.level ? ('Level ' + props.level) : 'Free Entry Earned'")
|
|
25
27
|
subtitle = local_assigns.fetch(:subtitle, "You leveled up and earned a reward. Keep earning more with each level.")
|
|
@@ -91,7 +91,9 @@
|
|
|
91
91
|
free_entry_label = local_assigns.fetch(:free_entry_label, "Free Entry Earned 🎟️")
|
|
92
92
|
seeds_per_level = local_assigns.fetch(:seeds_per_level, 100)
|
|
93
93
|
leveling = local_assigns.fetch(:leveling, Studio.feature?(:leveling))
|
|
94
|
-
|
|
94
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
95
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
96
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
95
97
|
demo = local_assigns.fetch(:demo, false)
|
|
96
98
|
# consent_label — render a consent checkbox that gates the action (newsletter
|
|
97
99
|
# join). next_label / next_open — an optional "Next Quest" button in the
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
cta_label_key = local_assigns.fetch(:cta_label_key)
|
|
28
28
|
cta_href_key = local_assigns.fetch(:cta_href_key)
|
|
29
29
|
cluster_param = local_assigns.fetch(:cluster_param, "")
|
|
30
|
-
|
|
30
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
31
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
32
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
31
33
|
%>
|
|
32
34
|
<div>
|
|
33
35
|
<%= render "studio/modals/blocks/card_header",
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
<%
|
|
14
14
|
shell_title = local_assigns[:title]
|
|
15
15
|
shell_dismissible = local_assigns.fetch(:dismissible, true)
|
|
16
|
-
|
|
16
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
17
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
18
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
17
19
|
%>
|
|
18
20
|
<div>
|
|
19
21
|
<% if shell_title || shell_dismissible %>
|
|
@@ -104,8 +104,8 @@
|
|
|
104
104
|
sites: the props getter, finish's close, and the Ruby-built dismiss_action
|
|
105
105
|
emitted into the x's click handler. Escaping is the WRONG repair here: an
|
|
106
106
|
escaped identifier is a different SyntaxError and the same dead card.
|
|
107
|
-
Repair: refuse a value that is not a JS identifier, which
|
|
108
|
-
|
|
107
|
+
Repair: refuse a value that is not a JS identifier, which one call to
|
|
108
|
+
Studio::JsIdentifier.validate! does at the source, covering all three sites.
|
|
109
109
|
|
|
110
110
|
A PATTERN, NOT AN ALLOWLIST, and the reason is worth keeping: an allowlist would
|
|
111
111
|
be this engine enumerating its own consumers, so the next app to mount a
|
|
@@ -116,7 +116,9 @@
|
|
|
116
116
|
|
|
117
117
|
NOTE THE SAME LOCAL TAKES THE OTHER REPAIR ELSEWHERE. blocks/_birthday and
|
|
118
118
|
blocks/_leveling_activity pass modal_store in STRING position and escape it,
|
|
119
|
-
correctly. The shape decides the repair, never the name of the local.
|
|
119
|
+
correctly. The shape decides the repair, never the name of the local. And where
|
|
120
|
+
a store name reaches ONLY a string — blocks/_birthday hands it to Alpine.store(),
|
|
121
|
+
a lookup by string — no identifier is owed and none is demanded.
|
|
120
122
|
%>
|
|
121
123
|
<%
|
|
122
124
|
submit_path = local_assigns.fetch(:submit_path, "/onboarding/first_name")
|
|
@@ -163,37 +165,21 @@
|
|
|
163
165
|
typed_placeholder = !placeholder_names.nil?
|
|
164
166
|
max_length = local_assigns.fetch(:max_length, Studio::FULL_NAME_MAX_LENGTH)
|
|
165
167
|
progress = local_assigns.fetch(:progress, nil)
|
|
166
|
-
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
167
168
|
# VALIDATED, NOT ESCAPED — and that distinction is the whole point of this local.
|
|
168
169
|
# Every other value inside the x-data is a JS STRING and is repaired by escaping.
|
|
169
170
|
# This one is spliced in as a bare NAME (`$store.<name>.current()`), so the same
|
|
170
|
-
# repair breaks it: escape_javascript
|
|
171
|
-
#
|
|
172
|
-
#
|
|
171
|
+
# repair breaks it: escape_javascript backslash-escapes the apostrophe, and an
|
|
172
|
+
# escaped apostrophe is not a rescued identifier — it is a different SyntaxError,
|
|
173
|
+
# the same silently dead card reached a longer way.
|
|
173
174
|
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
# ASCII IdentifierName, which is what member access after a dot accepts. It
|
|
183
|
-
# admits every store name a host would plausibly write and rejects every
|
|
184
|
-
# character that could leave identifier position — quote, apostrophe, backslash,
|
|
185
|
-
# dot, space, semicolon, angle bracket.
|
|
186
|
-
#
|
|
187
|
-
# IT RAISES RATHER THAN FALLING BACK TO THE DEFAULT. A silent fallback is the
|
|
188
|
-
# worse of the two repairs: the card would mount, look perfect, and talk to a
|
|
189
|
-
# store that is not the host's — which is the SILENT-brick failure class this
|
|
190
|
-
# guard exists to leave behind. A host sees this the first time it renders.
|
|
191
|
-
unless modal_store.to_s.match?(/\A[A-Za-z_$][A-Za-z0-9_$]*\z/)
|
|
192
|
-
raise ArgumentError,
|
|
193
|
-
"modal_store must be a JS identifier (it is spliced into $store.<name>); " \
|
|
194
|
-
"got #{modal_store.inspect}. Escaping it would not help — an escaped " \
|
|
195
|
-
"identifier is a different SyntaxError, and the card would mount dead."
|
|
196
|
-
end
|
|
175
|
+
# THE REGEX AND THE ARGUMENT USED TO LIVE HERE, INLINE. This card was the first
|
|
176
|
+
# site to need them, so it carried the worked example; the engine has sixteen more
|
|
177
|
+
# splices of a store name in identifier position, and the contract now lives in ONE
|
|
178
|
+
# place. Studio::JsIdentifier holds the pattern, why it is a pattern and not an
|
|
179
|
+
# allowlist, and why it raises rather than falling back to the default. Behaviour
|
|
180
|
+
# here is unchanged: same pattern, same ArgumentError, same message.
|
|
181
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
182
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
197
183
|
done_event = local_assigns.fetch(:done_event, "onboarding-step-done")
|
|
198
184
|
# THE SAME HAZARD empty_error_js CARRIES, on the three remaining locals that land
|
|
199
185
|
# inside a JS SINGLE-quoted literal in the x-data below. A bare apostrophe in any
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
icon_color :primary / :success / :warning to card_header.
|
|
8
8
|
|
|
9
9
|
Local: modal_store — Alpine store name backing close(). Default "modals". %>
|
|
10
|
-
|
|
10
|
+
<%# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier. %>
|
|
11
|
+
<% modal_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:modal_store, "modals"), local: :modal_store) %>
|
|
11
12
|
<div x-data>
|
|
12
13
|
<%= render "studio/modals/blocks/card_header",
|
|
13
14
|
size: :lg,
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
+ close ×. Ported from turf-monster.
|
|
5
5
|
|
|
6
6
|
Local: modal_store — Alpine store name backing close(). Default "modals". %>
|
|
7
|
-
|
|
7
|
+
<%# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier. %>
|
|
8
|
+
<% modal_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:modal_store, "modals"), local: :modal_store) %>
|
|
8
9
|
<div x-data="{ value: '' }">
|
|
9
10
|
<%= render layout: "studio/modals/blocks/shell", locals: { title: "Form Template", modal_store: modal_store } do %>
|
|
10
11
|
<p class="text-sm text-muted mb-4">
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
at 1.6s so a viewer can step through both states.
|
|
7
7
|
|
|
8
8
|
Local: modal_store — Alpine store name backing close(). Default "modals". %>
|
|
9
|
-
|
|
9
|
+
<%# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier. %>
|
|
10
|
+
<% modal_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:modal_store, "modals"), local: :modal_store) %>
|
|
10
11
|
<div x-data="{
|
|
11
12
|
state: 'loading',
|
|
12
13
|
start() {
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
use studio/modals/blocks/_onchain_success instead. Ported from turf-monster.
|
|
6
6
|
|
|
7
7
|
Local: modal_store — Alpine store name backing close(). Default "modals". %>
|
|
8
|
-
|
|
8
|
+
<%# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier. %>
|
|
9
|
+
<% modal_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:modal_store, "modals"), local: :modal_store) %>
|
|
9
10
|
<div x-data>
|
|
10
11
|
<%= render "studio/modals/blocks/card_header",
|
|
11
12
|
size: :lg,
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
primary CTA pulling forward; Back as a subtle link, hidden on step 1.
|
|
9
9
|
|
|
10
10
|
Local: modal_store — Alpine store name backing close(). Default "modals". %>
|
|
11
|
-
|
|
11
|
+
<%# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier. %>
|
|
12
|
+
<% modal_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:modal_store, "modals"), local: :modal_store) %>
|
|
12
13
|
<div x-data="{ step: 1, total: 3 }">
|
|
13
14
|
<div class="relative mb-3 -mt-2">
|
|
14
15
|
<h3 class="text-heading font-bold text-lg leading-tight text-center pt-1">Wizard Template</h3>
|
|
@@ -468,7 +468,6 @@
|
|
|
468
468
|
resolve_expr: "$store.dsModals.close()" %></div>
|
|
469
469
|
</template>
|
|
470
470
|
|
|
471
|
-
|
|
472
471
|
<%# --- Batch 3: the modals that had no card on EITHER page --- %>
|
|
473
472
|
<template x-if="$store.dsModals.current().id === 'it-begins'">
|
|
474
473
|
<div><%= render "style/modals/it_begins" %></div>
|
|
@@ -479,9 +478,6 @@
|
|
|
479
478
|
<template x-if="$store.dsModals.current().id === 'email-change-pending'">
|
|
480
479
|
<div><%= render "style/modals/email_change_pending" %></div>
|
|
481
480
|
</template>
|
|
482
|
-
<template x-if="$store.dsModals.current().id === 'network-guard'">
|
|
483
|
-
<div><%= render "style/modals/network_guard" %></div>
|
|
484
|
-
</template>
|
|
485
481
|
<%# --- funding chrome primitives (extract-funding-modal-chrome) --- %>
|
|
486
482
|
<template x-if="$store.dsModals.current().id === 'ds-rail-row'">
|
|
487
483
|
<div><%= render "style/modals/ds_rail_row" %></div>
|
|
@@ -496,25 +492,14 @@
|
|
|
496
492
|
<template x-if="$store.dsModals.current().id === 'ds-onramp-hub'">
|
|
497
493
|
<div><%= render "style/modals/ds_onramp_hub" %></div>
|
|
498
494
|
</template>
|
|
499
|
-
<template x-if="$store.dsModals.current().id === 'ds-newsletter-success'">
|
|
500
|
-
<div><%= render "style/modals/ds_newsletter_success" %></div>
|
|
501
|
-
</template>
|
|
502
495
|
<template x-if="$store.dsModals.current().id === 'rate-limit-general'">
|
|
503
496
|
<div><%= render "style/modals/rate_limit_general" %></div>
|
|
504
497
|
</template>
|
|
505
498
|
|
|
506
|
-
<%# --- Newsletter exit
|
|
507
|
-
<template x-if="$store.dsModals.current().id === 'quest-success'">
|
|
508
|
-
<div><%= render "style/modals/quest_success" %></div>
|
|
509
|
-
</template>
|
|
499
|
+
<%# --- Newsletter exit --- %>
|
|
510
500
|
<template x-if="$store.dsModals.current().id === 'unsubscribe-confirm'">
|
|
511
501
|
<div><%= render "style/modals/unsubscribe_confirm" %></div>
|
|
512
502
|
</template>
|
|
513
|
-
<%# Registered alongside its confirm card, because unsubscribe-confirm
|
|
514
|
-
SWAPS to it — a handoff to an unregistered id renders a blank shell. %>
|
|
515
|
-
<template x-if="$store.dsModals.current().id === 'unsubscribe-goodbye'">
|
|
516
|
-
<div><%= render "style/modals/unsubscribe_goodbye" %></div>
|
|
517
|
-
</template>
|
|
518
503
|
|
|
519
504
|
<%# --- Web3 --- %>
|
|
520
505
|
<%# Both web3 sign-in cards render from SOLANA-STUDIO, and both are the
|
|
@@ -535,9 +520,6 @@
|
|
|
535
520
|
<template x-if="$store.dsModals.current().id === 'onchain-tx'">
|
|
536
521
|
<div><%= render "style/modals/onchain_tx" %></div>
|
|
537
522
|
</template>
|
|
538
|
-
<template x-if="$store.dsModals.current().id === 'wallet-deposit'">
|
|
539
|
-
<div><%= render "style/modals/wallet_deposit" %></div>
|
|
540
|
-
</template>
|
|
541
523
|
<%# entry-confirmed — the engine celebration composed via _success_card's
|
|
542
524
|
yield. One registration serves the generic (web3) + seeds (leveling)
|
|
543
525
|
specimens; the seeds bar self-gates on Studio.feature?(:leveling). %>
|
|
@@ -861,7 +843,6 @@
|
|
|
861
843
|
</div>
|
|
862
844
|
<% end %>
|
|
863
845
|
|
|
864
|
-
|
|
865
846
|
<%# Image upload FIRST — the empty picker (crop-photo opened with no imageUrl).
|
|
866
847
|
cropReady: false is the defined starting sub-state; the crop factory
|
|
867
848
|
flips it true once an image is picked, moving the glow to Crop photo. %>
|
|
@@ -1042,36 +1023,19 @@
|
|
|
1042
1023
|
</div>
|
|
1043
1024
|
<% end %>
|
|
1044
1025
|
|
|
1045
|
-
<%# ds-newsletter-success — the STANDALONE celebration, and its two forks. %>
|
|
1046
|
-
<%= render layout: "style/modal_specimen", locals: {
|
|
1047
|
-
label: "Subscribed! (standalone)",
|
|
1048
|
-
reference: %(the standalone newsletter celebration (style/modals/_ds_newsletter_success) — turf-monster's modals/_newsletter_success. Distinct from the card above, which is the leveling activity celebrating IN PLACE on the card the person was already on; this one arrives on its own — swapped in from the subscribe modal, or opened from the /account newsletter row — so it has to carry its own way forward. TWO FORKS, NEITHER VISIBLE IN THE MARKUP, which is why it is carded separately rather than folded in. THE SEEDS BAR IS FIRST-JOIN ONLY: a re-subscribe earns nothing, and the card HIDES the bar rather than showing a flat "0 seeds", which would read to the person as a bug about their own account rather than an accurate zero. THE CTA IS SERVER-DECIDED: with the username quest still open (current_user.next_quest == :username) the button nudges straight into it, otherwise it sends them to browse contests — showing only one of those hides the funnel this celebration doubles as. Both are toggles here so the cabinet can walk all four combinations; in the app the CTA fork is SERVER-rendered, precisely so the button never flashes the wrong option at someone whose quest is already done. Open with $store.dsModals.open('ds-newsletter-success', { firstJoin: true, questOpen: true })),
|
|
1049
|
-
card_data: "opts: { firstJoin: true, questOpen: true }",
|
|
1050
|
-
toggles: [{ model: "opts.firstJoin", label: "First join" },
|
|
1051
|
-
{ model: "opts.questOpen", label: "Quest open" }],
|
|
1052
|
-
open_expr: "$store.dsModals.open('ds-newsletter-success', { firstJoin: opts.firstJoin, questOpen: opts.questOpen })",
|
|
1053
|
-
glow_when: ds_glow.call("ds-newsletter-success") } do %>
|
|
1054
|
-
<div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
|
|
1055
|
-
<div class="text-2xl leading-none">🌱</div>
|
|
1056
|
-
<span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
|
|
1057
|
-
<%# the first-join seeds bar %>
|
|
1058
|
-
<span class="flex gap-1">
|
|
1059
|
-
<span class="flex-1 h-2 rounded" style="background: var(--color-cta)"></span>
|
|
1060
|
-
<span class="flex-1 h-2 rounded" style="background: var(--color-cta); opacity:.35"></span>
|
|
1061
|
-
<span class="flex-1 h-2 rounded" style="background: var(--color-cta); opacity:.18"></span>
|
|
1062
|
-
</span>
|
|
1063
|
-
<span class="block h-4 w-full rounded" style="background: var(--color-cta)"></span>
|
|
1064
|
-
</div>
|
|
1065
|
-
<% end %>
|
|
1066
|
-
|
|
1067
1026
|
<%# THE WAY OUT. The three cards above walk a user IN; a section that only
|
|
1068
1027
|
ever demonstrates opt-in is showing the happy half of a flow, not the
|
|
1069
|
-
flow.
|
|
1070
|
-
|
|
1028
|
+
flow. This is the exit.
|
|
1029
|
+
IT WAS A PAIR UNTIL 2026-09-09: the goodbye beat it hands off to was a
|
|
1030
|
+
mirror of turf's own card, and turf now cards the real one in its host
|
|
1031
|
+
section, so only the half the engine owns is left here. The handoff is
|
|
1032
|
+
still the interesting part of this card, and it is now REVIEWED ACROSS
|
|
1033
|
+
TWO GUIDES rather than walked in one — which is why the specimen below
|
|
1034
|
+
resolves by closing rather than swapping to an id nothing registers.
|
|
1071
1035
|
App-specific (the engine owns no newsletter); engine chrome only. %>
|
|
1072
1036
|
<%= render layout: "style/modal_specimen", locals: {
|
|
1073
1037
|
label: "Leave the newsletter?",
|
|
1074
|
-
reference: %(the unsubscribe CONFIRM card (style/modals/_unsubscribe_confirm) — turf-monster's modals/_unsubscribe_confirm, which POSTs newsletter_unsubscribe_path and swaps to the
|
|
1038
|
+
reference: %(the unsubscribe CONFIRM card (style/modals/_unsubscribe_confirm) — turf-monster's modals/_unsubscribe_confirm, which POSTs newsletter_unsubscribe_path and swaps to the GOODBYE beat on success. That beat is carded on turf-monster's own section of this guide, not here: its engine mirror was retired on 2026-09-09. So this specimen holds the pending state and then CLOSES — pressing Unsubscribe shows the spinner the real card shows, and stops where the guide stops. The destructive action wears the primary button and the never-mind out is the quiet Close link below it — a confirmation whose safe path is harder to find than its destructive one is a formality, not a confirmation. Open with $store.dsModals.open('unsubscribe-confirm')),
|
|
1075
1039
|
open_expr: "$store.dsModals.open('unsubscribe-confirm')",
|
|
1076
1040
|
glow_when: ds_glow.call("unsubscribe-confirm") } do %>
|
|
1077
1041
|
<div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
|
|
@@ -1083,45 +1047,6 @@
|
|
|
1083
1047
|
</div>
|
|
1084
1048
|
<% end %>
|
|
1085
1049
|
|
|
1086
|
-
<%= render layout: "style/modal_specimen", locals: {
|
|
1087
|
-
label: "See you later",
|
|
1088
|
-
reference: %(the unsubscribe GOODBYE beat (style/modals/_unsubscribe_goodbye) — where the confirm card lands. The smallest card in the guide, and the copy is a door left open rather than a plea: "Change your mind anytime from your account". Worth preserving if it is ever reworded. Open with $store.dsModals.open('unsubscribe-goodbye')),
|
|
1089
|
-
open_expr: "$store.dsModals.open('unsubscribe-goodbye')",
|
|
1090
|
-
glow_when: ds_glow.call("unsubscribe-goodbye") } do %>
|
|
1091
|
-
<div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
|
|
1092
|
-
<div class="text-2xl leading-none">✌️</div>
|
|
1093
|
-
<span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
|
|
1094
|
-
<span class="block h-1.5 w-28 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
|
|
1095
|
-
<span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
|
|
1096
|
-
</div>
|
|
1097
|
-
<% end %>
|
|
1098
|
-
|
|
1099
|
-
<%# THE SAME BEAT, REACHED FROM SOMEWHERE ELSE. Great Username (two cards up)
|
|
1100
|
-
celebrates INSIDE the modal that did the work. This one is what a host
|
|
1101
|
-
shows when the quest completed somewhere with no card to advance in
|
|
1102
|
-
place — turf's /account username change — so the celebration arrives on
|
|
1103
|
-
its own. The toggle is the fork at its foot, which is the whole reason
|
|
1104
|
-
it is one card and not two. %>
|
|
1105
|
-
<%= render layout: "style/modal_specimen", locals: {
|
|
1106
|
-
label: "Quest success (off-page)",
|
|
1107
|
-
reference: %(the off-page quest celebration (style/modals/_quest_success) — turf-monster's modals/_quest_success, shown when a quest completes away from any quest card. Composes the engine's card_header AND blocks/_seeds_bar (a :leveling primitive: the bar is the engine's, the numbers are app-supplied). Toggle "Subscribed" to switch the fork at the foot: NOT subscribed offers the newsletter as the next quest, so the celebration doubles as the funnel; subscribed sends them to the contest where the remaining quests live. In the real card that gate is SERVER-rendered from current_user.subscribed_to_newsletter?, precisely so the CTA never flashes the wrong option at someone already in. Open with $store.dsModals.open('quest-success', { subscribed: false })),
|
|
1108
|
-
card_data: "opts: { subscribed: false }",
|
|
1109
|
-
toggles: [{ model: "opts.subscribed", label: "Subscribed" }],
|
|
1110
|
-
open_expr: "$store.dsModals.open('quest-success', { subscribed: opts.subscribed, seedsTotal: 25 })",
|
|
1111
|
-
glow_when: ds_glow.call("quest-success") } do %>
|
|
1112
|
-
<div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
|
|
1113
|
-
<div class="text-2xl leading-none">🌱</div>
|
|
1114
|
-
<span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
|
|
1115
|
-
<%# the seeds bar, which is the card's middle and the engine's %>
|
|
1116
|
-
<span class="flex gap-1">
|
|
1117
|
-
<span class="flex-1 h-2 rounded" style="background: var(--color-cta)"></span>
|
|
1118
|
-
<span class="flex-1 h-2 rounded" style="background: var(--color-cta); opacity:.4"></span>
|
|
1119
|
-
<span class="flex-1 h-2 rounded" style="background: var(--color-cta); opacity:.2"></span>
|
|
1120
|
-
</span>
|
|
1121
|
-
<span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
|
|
1122
|
-
</div>
|
|
1123
|
-
<% end %>
|
|
1124
|
-
|
|
1125
1050
|
<%# newsletter-email — the branch a wallet-only account has to take: no
|
|
1126
1051
|
email on file, so the quest asks for one mid-flight. %>
|
|
1127
1052
|
<%= render layout: "style/modal_specimen", locals: {
|
|
@@ -1227,25 +1152,11 @@
|
|
|
1227
1152
|
</div>
|
|
1228
1153
|
<% end %>
|
|
1229
1154
|
|
|
1230
|
-
<%# WALLET SETUP — new 2026-08-24, and moved here from Onboarding the same
|
|
1231
|
-
day. It is grouped with the wallet cards rather than with the signup
|
|
1232
|
-
chain because that is what it IS: getting a wallet. The card still
|
|
1233
|
-
renders a 3-of-3 pill, which is the host's chain position and not this
|
|
1234
|
-
section's business.
|
|
1235
|
-
ONE card with a "Wallet detected" toggle rather than two cards sharing a
|
|
1236
|
-
modal id: the two Sign Wallet cards below are what that shape costs. %>
|
|
1237
|
-
|
|
1238
1155
|
<%# SIGN WALLET — the web2-session-on-a-wallet-account card. NOT part of the
|
|
1239
1156
|
walk above: that walk is one on-chain transaction, this is an AUTH state
|
|
1240
1157
|
that precedes any of it. Two specimens because the card has exactly two
|
|
1241
1158
|
shapes, and the second is what every wallet linked before a host recorded
|
|
1242
|
-
brands will see.
|
|
1243
|
-
|
|
1244
|
-
OPPOSITE POPULATION to the "Setup Wallet" card two up in this
|
|
1245
|
-
section, and worth stating because the two look alike: that one asks
|
|
1246
|
-
"get a wallet", this one asks "prove the wallet you already have".
|
|
1247
|
-
WalletSetupPolicy exits at its own step 1 for an account that holds a
|
|
1248
|
-
wallet, so no user can ever meet both. %>
|
|
1159
|
+
brands will see. %>
|
|
1249
1160
|
<%= render layout: "style/modal_specimen", locals: {
|
|
1250
1161
|
label: "Sign Wallet",
|
|
1251
1162
|
reference: %(the Web3 "Sign Wallet" card (solana-studio solana_studio/modals/_web3_step_up — the REAL shared partial, not a specimen copy) — shown to an account that holds a self-custody wallet but authenticated this session with a web2 credential. One wallet row, glowing, because it is the only thing to press. Open with $store.dsModals.open('web3-step-up', { provider: 'phantom', providerLabel: 'Phantom', walletHint: '7xKp…JZ2Q' })),
|
|
@@ -1355,25 +1266,6 @@
|
|
|
1355
1266
|
</div>
|
|
1356
1267
|
<% end %>
|
|
1357
1268
|
|
|
1358
|
-
<%# THE OTHER FAILURE, and it is a different KIND of failure from the one
|
|
1359
|
-
above. On-chain error is the chain saying no. This is the SERVER saying
|
|
1360
|
-
no — refusing to co-sign because the transaction it was handed did not
|
|
1361
|
-
match the entry it prepared. They sit together because a reviewer
|
|
1362
|
-
reading either alone would not know the other existed. %>
|
|
1363
|
-
|
|
1364
|
-
<%# Wallet deposit — standalone top-up card (not part of the walk). %>
|
|
1365
|
-
<%= render layout: "style/modal_specimen", locals: {
|
|
1366
|
-
label: "Wallet deposit",
|
|
1367
|
-
reference: %(the Web3 "Wallet deposit" top-up card (style/modals/_wallet_deposit) — open with $store.dsModals.open('wallet-deposit', { neededCents: 500, usdcCents: 120, usdtCents: 0, address: 'Fo1L5…' })),
|
|
1368
|
-
open_expr: "$store.dsModals.open('wallet-deposit', { neededCents: 500, usdcCents: 120, usdtCents: 0, address: 'Fo1L5demoWALLETaddr9xQ2' })",
|
|
1369
|
-
disabled: !web3_on, openable: true } do %>
|
|
1370
|
-
<div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
|
|
1371
|
-
<span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
|
|
1372
|
-
<span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
|
|
1373
|
-
<span class="block h-5 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
|
|
1374
|
-
</div>
|
|
1375
|
-
<% end %>
|
|
1376
|
-
|
|
1377
1269
|
<%# ds-wallet-topup — the FIRST funds modal, and a safety fork. %>
|
|
1378
1270
|
<%= render layout: "style/modal_specimen", locals: {
|
|
1379
1271
|
label: "Top up wallet",
|
|
@@ -1421,8 +1313,6 @@
|
|
|
1421
1313
|
</div>
|
|
1422
1314
|
<% end %>
|
|
1423
1315
|
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
1316
|
<%# Entry confirmed — the GENERIC web3 celebration (tx link + heading + drain
|
|
1427
1317
|
CTA). seedsEarned: 0 so no seeds bar even where leveling is on: this card
|
|
1428
1318
|
demos the clean success a web3-only app gets. The seeds variant lives in
|
|
@@ -1442,32 +1332,6 @@
|
|
|
1442
1332
|
</div>
|
|
1443
1333
|
<% end %>
|
|
1444
1334
|
|
|
1445
|
-
<%# network-guard — the explicit acknowledgement before a wallet request.
|
|
1446
|
-
Continue stays INERT until the box is ticked and Cancel carries equal
|
|
1447
|
-
weight: a confirmation whose safe path is harder to reach is theatre. %>
|
|
1448
|
-
<%= render layout: "style/modal_specimen", locals: {
|
|
1449
|
-
label: "Network guard",
|
|
1450
|
-
reference: %(the Web3 network confirmation (style/modals/_network_guard) — turf-monster's modals/_network_guard, shown before a wallet request when the app wants the network acknowledged. Composes the engine's blocks/_card_header and nothing else: the engine detects no network and no environment, so every label here is app-supplied and only ever DISPLAYED. A request signed against the wrong network looks identical to a correct one until it is irreversible, so the card NAMES both facts, keeps Continue inert until an explicit tick, and gives Cancel equal visual weight rather than burying it. Open with $store.dsModals.open('network-guard', { networkLabel: 'devnet', environmentLabel: 'QA' })),
|
|
1451
|
-
open_expr: "$store.dsModals.open('network-guard', { networkLabel: 'devnet', environmentLabel: 'QA' })",
|
|
1452
|
-
glow_when: ds_glow.call("network-guard"),
|
|
1453
|
-
disabled: !web3_on, openable: true } do %>
|
|
1454
|
-
<div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
|
|
1455
|
-
<div class="text-2xl leading-none">⚠️</div>
|
|
1456
|
-
<%# the two named facts, then the tick that gates Continue %>
|
|
1457
|
-
<span class="block h-1.5 w-full rounded" style="background: var(--color-text); opacity: .3"></span>
|
|
1458
|
-
<span class="block h-1.5 w-full rounded" style="background: var(--color-text); opacity: .3"></span>
|
|
1459
|
-
<span class="flex items-center gap-1.5">
|
|
1460
|
-
<span class="block h-2.5 w-2.5 rounded-sm border" style="border-color: var(--color-border-strong)"></span>
|
|
1461
|
-
<span class="block h-1.5 flex-1 rounded" style="background: var(--color-text); opacity: .18"></span>
|
|
1462
|
-
</span>
|
|
1463
|
-
<%# equal weight, deliberately %>
|
|
1464
|
-
<span class="grid grid-cols-2 gap-1.5">
|
|
1465
|
-
<span class="block h-4 rounded border" style="border-color: var(--color-border-strong)"></span>
|
|
1466
|
-
<span class="block h-4 rounded" style="background: var(--color-cta); opacity: .5"></span>
|
|
1467
|
-
</span>
|
|
1468
|
-
</div>
|
|
1469
|
-
<% end %>
|
|
1470
|
-
|
|
1471
1335
|
</div>
|
|
1472
1336
|
</section>
|
|
1473
1337
|
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
<%#
|
|
2
|
-
Living style guide specimen — the unsubscribe confirmation
|
|
3
|
-
its goodbye beat.
|
|
2
|
+
Living style guide specimen — the unsubscribe confirmation.
|
|
4
3
|
|
|
5
4
|
APP-SPECIFIC FLOW, engine chrome only. The real card POSTs the host's
|
|
6
|
-
newsletter_unsubscribe_path and swaps to the
|
|
7
|
-
owns no newsletter and no endpoint. Here the POST resolves locally so the
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
newsletter_unsubscribe_path and swaps to the GOODBYE card on success; the engine
|
|
6
|
+
owns no newsletter and no endpoint. Here the POST resolves locally so the
|
|
7
|
+
pending state is walkable with no backend, and then the card CLOSES.
|
|
8
|
+
|
|
9
|
+
IT USED TO SWAP, and the swap was the point.
|
|
10
|
+
`$store.dsModals.swap('unsubscribe-goodbye')` was correct only while this
|
|
11
|
+
guide also carried a goodbye MIRROR; that mirror was retired on 2026-09-09
|
|
12
|
+
(turf-monster cards the real goodbye beat in its own section of this guide),
|
|
13
|
+
and a swap to an id no template registers opens an EMPTY panel — the exact
|
|
14
|
+
failure the cabinet exists to catch, wearing a working card's clothes. Closing
|
|
15
|
+
is the honest stop: it shows every state this guide still owns and claims no
|
|
16
|
+
beat it cannot render. Do not restore the swap without restoring a
|
|
17
|
+
registration for its target.
|
|
10
18
|
|
|
11
19
|
THE BOTTOM LINK IS THE POINT OF THE CARD. "Close" is the never-mind out, and it
|
|
12
20
|
is deliberately the quiet option while the destructive action wears the primary
|
|
@@ -24,10 +32,12 @@
|
|
|
24
32
|
this.saving = true;
|
|
25
33
|
var s = this;
|
|
26
34
|
// Demo: the real card awaits a POST. Hold briefly so the pending state
|
|
27
|
-
// is visible rather than skipped
|
|
35
|
+
// is visible rather than skipped. The real card then swaps to the
|
|
36
|
+
// goodbye beat; this guide no longer carries that mirror, so it stops
|
|
37
|
+
// here rather than swapping to an unregistered id (see the header).
|
|
28
38
|
setTimeout(function () {
|
|
29
39
|
s.saving = false;
|
|
30
|
-
$store.dsModals.
|
|
40
|
+
$store.dsModals.close();
|
|
31
41
|
}, 450);
|
|
32
42
|
}
|
|
33
43
|
}">
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
module Studio
|
|
2
|
+
# ONE home for the CONTRACT a host-supplied value must meet when a partial
|
|
3
|
+
# splices it into JS **identifier position** — a bare NAME rather than a string:
|
|
4
|
+
#
|
|
5
|
+
# <button @click="$store.<%= modal_store %>.close()">
|
|
6
|
+
#
|
|
7
|
+
# THE SIBLING OF Studio::JsLiteral, AND ITS OPPOSITE. Both exist for the same
|
|
8
|
+
# silent failure: a host value that makes an Alpine expression a SyntaxError, so
|
|
9
|
+
# the component mounts as a NO-OP that still renders every element. Perfect
|
|
10
|
+
# markup, dead card, nothing raised and nothing logged. The two differ on the
|
|
11
|
+
# only question that matters — WHAT THE VALUE IS:
|
|
12
|
+
#
|
|
13
|
+
# STRING position — the value is DATA inside a JS literal. Any character is
|
|
14
|
+
# legal; the repair is to ESCAPE it (Studio::JsLiteral.in_attribute).
|
|
15
|
+
# IDENTIFIER position — the value is a NAME. Most characters are not legal at
|
|
16
|
+
# all; the repair is to REFUSE it, which is this module.
|
|
17
|
+
#
|
|
18
|
+
# ESCAPING IS ACTIVELY WRONG HERE, which is why this is a second module rather
|
|
19
|
+
# than a second method on the first. escape_javascript also escapes `$`, so a
|
|
20
|
+
# perfectly legal store name like `dsModals$2` comes back as `dsModals\$2` and
|
|
21
|
+
# `$store.dsModals\$2.close()` is a SyntaxError — the same dead card, reached a
|
|
22
|
+
# longer way, on a value that was never hostile. And for a value that IS hostile,
|
|
23
|
+
# `$store.a\'b.close()` is not a rescued identifier either. There is no escaping
|
|
24
|
+
# of identifier position; there is only validity.
|
|
25
|
+
#
|
|
26
|
+
# A PATTERN, NOT AN ALLOWLIST, and the reason outlives this engine. An allowlist
|
|
27
|
+
# would be a shared primitive enumerating its own CONSUMERS — "modals" and the
|
|
28
|
+
# style guide's "dsModals" today — so the next app to mount a page-scoped host
|
|
29
|
+
# would be refused by its own dependency until a gem release admitted the name.
|
|
30
|
+
# The real contract is narrower and stateless: the value is spliced into
|
|
31
|
+
# member-access position, so it must be an identifier, and WHICH identifier is
|
|
32
|
+
# none of the engine's business.
|
|
33
|
+
#
|
|
34
|
+
# IT RAISES RATHER THAN FALLING BACK TO A DEFAULT. A silent fallback is the worse
|
|
35
|
+
# of the two repairs: the card would mount, look perfect, and talk to a store that
|
|
36
|
+
# is not the host's — the same SILENT class this guard exists to leave behind,
|
|
37
|
+
# only now with a working-looking page in front of it. A host meets this the first
|
|
38
|
+
# time it renders, in development, with the local named.
|
|
39
|
+
#
|
|
40
|
+
# WHAT IT DOES NOT COVER. A value in string position that is ALSO a store name
|
|
41
|
+
# (blocks/_birthday's `store: '<%= j modal_store %>'`, which reaches
|
|
42
|
+
# `Alpine.store(name)` — a lookup by string, where any character is legal) needs no
|
|
43
|
+
# identifier. Validate where the value reaches a SPLICE, not everywhere the word
|
|
44
|
+
# "store" appears; test/views/js_identifier_locals_test.rb carries the census.
|
|
45
|
+
module JsIdentifier
|
|
46
|
+
# ASCII IdentifierName — what member access after a dot accepts. It admits every
|
|
47
|
+
# store name a host would plausibly write (`modals`, `dsModals`, `ds_Modals$2`)
|
|
48
|
+
# and rejects every character that could leave identifier position: quote,
|
|
49
|
+
# apostrophe, backslash, dot, space, semicolon, angle bracket, and the empty
|
|
50
|
+
# string.
|
|
51
|
+
#
|
|
52
|
+
# DELIBERATELY NOT THE FULL ECMAScript GRAMMAR, which admits most of Unicode.
|
|
53
|
+
# Widening it is a decision, not a bug fix — and it would have to be taken
|
|
54
|
+
# together with the marking note on #validate! below, because the value's safety
|
|
55
|
+
# under ERB is what the narrow set buys.
|
|
56
|
+
PATTERN = /\A[A-Za-z_$][A-Za-z0-9_$]*\z/
|
|
57
|
+
|
|
58
|
+
module_function
|
|
59
|
+
|
|
60
|
+
# Return +value+ as a String, or raise ArgumentError naming +local+.
|
|
61
|
+
#
|
|
62
|
+
# modal_store = Studio::JsIdentifier.validate!(
|
|
63
|
+
# local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
64
|
+
#
|
|
65
|
+
# +local+ is REQUIRED and has no default on purpose. Fifteen of the engine's
|
|
66
|
+
# call sites name this local `modal_store` and two name it `store`; a default
|
|
67
|
+
# that is right fifteen times out of seventeen is exactly how the other two
|
|
68
|
+
# would hand a host the wrong local to go and fix.
|
|
69
|
+
#
|
|
70
|
+
# RETURNS A PLAIN STRING, NOT AN html_safe ONE, and that is load-bearing rather
|
|
71
|
+
# than an omission. A value that satisfies PATTERN contains no character ERB
|
|
72
|
+
# escapes, so ERB's pass over it is a byte-for-byte no-op and the identifier
|
|
73
|
+
# reaches the browser exactly as the host wrote it — the marking would buy
|
|
74
|
+
# nothing today. What it would cost is the day PATTERN is widened: ERB's
|
|
75
|
+
# escaping is the second layer that would still be standing, and a value marked
|
|
76
|
+
# safe here skips it. So the pattern is the only thing holding the line, and it
|
|
77
|
+
# is holding it alone by choice, in one place.
|
|
78
|
+
def validate!(value, local:)
|
|
79
|
+
name = "#{value}"
|
|
80
|
+
return name if name.match?(PATTERN)
|
|
81
|
+
|
|
82
|
+
raise ArgumentError,
|
|
83
|
+
"#{local} must be a JS identifier (it is spliced into $store.<name>); " \
|
|
84
|
+
"got #{value.inspect}. Escaping it would not help — an escaped " \
|
|
85
|
+
"identifier is a different SyntaxError, and the card would mount dead."
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
data/lib/studio/js_literal.rb
CHANGED
|
@@ -80,8 +80,9 @@ module Studio
|
|
|
80
80
|
# NAME — `$store.<%= modal_store %>.close()` — must be VALIDATED, never escaped:
|
|
81
81
|
# escape_javascript also escapes `$`, so a legitimate store name like `dsModals$2`
|
|
82
82
|
# comes back mangled and the card dies anyway. The SHAPE of the splice decides the
|
|
83
|
-
# repair, never the name of the local.
|
|
84
|
-
#
|
|
83
|
+
# repair, never the name of the local. That repair is Studio::JsIdentifier, this
|
|
84
|
+
# module's sibling and its opposite; studio/modals/onboarding/_first_name carries
|
|
85
|
+
# the worked example of both in one partial.
|
|
85
86
|
module JsLiteral
|
|
86
87
|
extend ActionView::Helpers::JavaScriptHelper
|
|
87
88
|
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -9,6 +9,7 @@ require "studio/environment_banner"
|
|
|
9
9
|
require "studio/theme_resolver"
|
|
10
10
|
require "studio/ui_primitives"
|
|
11
11
|
require "studio/js_literal"
|
|
12
|
+
require "studio/js_identifier"
|
|
12
13
|
require "studio/sidebar_sections"
|
|
13
14
|
require "studio/profile_sections"
|
|
14
15
|
require "studio/profile_image"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.74.
|
|
4
|
+
version: 0.74.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -522,23 +522,18 @@ files:
|
|
|
522
522
|
- app/views/style/modals/_auth.html.erb
|
|
523
523
|
- app/views/style/modals/_birthday.html.erb
|
|
524
524
|
- app/views/style/modals/_ds_close_x.html.erb
|
|
525
|
-
- app/views/style/modals/_ds_newsletter_success.html.erb
|
|
526
525
|
- app/views/style/modals/_ds_onramp_hub.html.erb
|
|
527
526
|
- app/views/style/modals/_ds_rail_row.html.erb
|
|
528
527
|
- app/views/style/modals/_ds_wallet_topup.html.erb
|
|
529
528
|
- app/views/style/modals/_email_change_pending.html.erb
|
|
530
529
|
- app/views/style/modals/_entry_tokens.html.erb
|
|
531
530
|
- app/views/style/modals/_it_begins.html.erb
|
|
532
|
-
- app/views/style/modals/_network_guard.html.erb
|
|
533
531
|
- app/views/style/modals/_newsletter_email.html.erb
|
|
534
532
|
- app/views/style/modals/_onchain_tx.html.erb
|
|
535
|
-
- app/views/style/modals/_quest_success.html.erb
|
|
536
533
|
- app/views/style/modals/_rate_limit_general.html.erb
|
|
537
534
|
- app/views/style/modals/_unsubscribe_confirm.html.erb
|
|
538
|
-
- app/views/style/modals/_unsubscribe_goodbye.html.erb
|
|
539
535
|
- app/views/style/modals/_wallet_connect.html.erb
|
|
540
536
|
- app/views/style/modals/_wallet_connect_slot.html.erb
|
|
541
|
-
- app/views/style/modals/_wallet_deposit.html.erb
|
|
542
537
|
- app/views/theme_settings/edit.html.erb
|
|
543
538
|
- app/views/user_mailer/magic_link.html.erb
|
|
544
539
|
- app/views/user_mailer/magic_link.text.erb
|
|
@@ -568,6 +563,7 @@ files:
|
|
|
568
563
|
- lib/studio/geo/lookup.rb
|
|
569
564
|
- lib/studio/image_cache.rb
|
|
570
565
|
- lib/studio/ip_locations.rb
|
|
566
|
+
- lib/studio/js_identifier.rb
|
|
571
567
|
- lib/studio/js_literal.rb
|
|
572
568
|
- lib/studio/link_resolution.rb
|
|
573
569
|
- lib/studio/link_token.rb
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
<%#
|
|
2
|
-
Living style guide specimen — Subscribed!, the standalone newsletter
|
|
3
|
-
celebration.
|
|
4
|
-
|
|
5
|
-
APP-SPECIFIC, engine chrome (blocks/_card_header at :lg). Distinct from the
|
|
6
|
-
Profile Leveling "Subscribed!" beat, which is the leveling activity celebrating
|
|
7
|
-
IN PLACE on the card the person was already on. This one arrives on its own —
|
|
8
|
-
swapped in from the subscribe modal, or opened from the /account newsletter
|
|
9
|
-
row — so it has to carry its own way forward.
|
|
10
|
-
|
|
11
|
-
TWO FORKS, AND NEITHER IS VISIBLE TO A READER OF THE MARKUP. That is the whole
|
|
12
|
-
reason this card exists separately rather than being folded into the leveling
|
|
13
|
-
beat:
|
|
14
|
-
|
|
15
|
-
· THE SEEDS BAR IS FIRST-JOIN ONLY. A re-subscribe earns nothing, and the
|
|
16
|
-
card HIDES the bar rather than showing a flat "0 seeds" — which would read
|
|
17
|
-
to the person as a bug about their own account rather than as an accurate
|
|
18
|
-
zero.
|
|
19
|
-
· THE CTA IS SERVER-DECIDED. If the username quest is still open
|
|
20
|
-
(current_user.next_quest == :username) the button nudges straight into it;
|
|
21
|
-
otherwise it sends them off to browse contests. Showing only one of those
|
|
22
|
-
hides the funnel that this celebration doubles as.
|
|
23
|
-
|
|
24
|
-
Both are gated on props here so the cabinet can walk all four combinations.
|
|
25
|
-
In the app the CTA fork is SERVER-rendered, precisely so the button never
|
|
26
|
-
flashes the wrong option at someone whose quest is already done.
|
|
27
|
-
|
|
28
|
-
Single root: the outer <div> is the host's required root.
|
|
29
|
-
%>
|
|
30
|
-
<div x-data="{
|
|
31
|
-
get props() { var c = $store.dsModals.current(); return (c && c.props) || {} },
|
|
32
|
-
get firstJoin() { return !!this.props.firstJoin },
|
|
33
|
-
get questOpen() { return !!this.props.questOpen }
|
|
34
|
-
}">
|
|
35
|
-
<%= render "studio/modals/blocks/card_header",
|
|
36
|
-
size: :lg,
|
|
37
|
-
icon_emoji: "🌱",
|
|
38
|
-
title: "Subscribed!",
|
|
39
|
-
subtitle: "You're on the list. We'll keep the sports news and contest updates coming." %>
|
|
40
|
-
|
|
41
|
-
<%# First join only. The engine's :leveling seeds bar — the bar is the
|
|
42
|
-
engine's, the numbers are the app's. %>
|
|
43
|
-
<template x-if="firstJoin">
|
|
44
|
-
<div class="mb-5">
|
|
45
|
-
<%= render "studio/modals/blocks/seeds_bar",
|
|
46
|
-
seeds_earned_key: "25",
|
|
47
|
-
seeds_total_key: "125",
|
|
48
|
-
seeds_per_level: 100,
|
|
49
|
-
free_entry_label: "Free Entry Token 🎟️" %>
|
|
50
|
-
</div>
|
|
51
|
-
</template>
|
|
52
|
-
|
|
53
|
-
<template x-if="questOpen">
|
|
54
|
-
<button type="button" @click="$store.dsModals.close()"
|
|
55
|
-
class="btn btn-primary btn-lg w-full">Next Quest</button>
|
|
56
|
-
</template>
|
|
57
|
-
|
|
58
|
-
<template x-if="!questOpen">
|
|
59
|
-
<button type="button" @click="$store.dsModals.close()"
|
|
60
|
-
class="btn btn-primary btn-lg w-full">Check Out Contests</button>
|
|
61
|
-
</template>
|
|
62
|
-
|
|
63
|
-
<button type="button" @click="$store.dsModals.close()"
|
|
64
|
-
class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
|
|
65
|
-
Close
|
|
66
|
-
</button>
|
|
67
|
-
</div>
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
<%#
|
|
2
|
-
Living style guide specimen — the network confirmation before a wallet request.
|
|
3
|
-
|
|
4
|
-
APP-SPECIFIC, engine chrome only (`blocks/_card_header`). The engine knows
|
|
5
|
-
nothing about networks or environments; every label here is app-supplied and
|
|
6
|
-
only ever DISPLAYED.
|
|
7
|
-
|
|
8
|
-
THE CHECKBOX IS THE POINT, and it is not friction for its own sake. A wallet
|
|
9
|
-
request signed against the wrong network is the kind of mistake that looks
|
|
10
|
-
identical to a correct one until it is irreversible — mainnet money moved on a
|
|
11
|
-
devnet assumption, or the reverse. So the card does three things a plain
|
|
12
|
-
confirm cannot: it NAMES both the network and the environment, it makes
|
|
13
|
-
Continue INERT until the person has ticked an explicit acknowledgement, and it
|
|
14
|
-
gives Cancel equal visual weight rather than burying it.
|
|
15
|
-
|
|
16
|
-
A confirmation whose safe path is harder to reach than its risky one is a
|
|
17
|
-
formality. This one is deliberately not.
|
|
18
|
-
|
|
19
|
-
Single root; x-data single-quoted throughout.
|
|
20
|
-
%>
|
|
21
|
-
<div x-data="{
|
|
22
|
-
checked: false,
|
|
23
|
-
get props() { var c = $store.dsModals.current(); return (c && c.props) || {} }
|
|
24
|
-
}">
|
|
25
|
-
<%= render "studio/modals/blocks/card_header",
|
|
26
|
-
icon_emoji: "⚠️",
|
|
27
|
-
title_key: "props.title || 'Check Network'" do %>
|
|
28
|
-
<p class="text-sm text-body"
|
|
29
|
-
x-text="props.message || 'This request will be signed on the network shown below.'"></p>
|
|
30
|
-
<% end %>
|
|
31
|
-
|
|
32
|
-
<%# Both facts, named. The app supplies them; the engine never detects either. %>
|
|
33
|
-
<div class="rounded-lg border border-subtle bg-surface-alt p-3 mb-4 text-sm">
|
|
34
|
-
<div class="flex items-center justify-between gap-3">
|
|
35
|
-
<span class="text-secondary">App network</span>
|
|
36
|
-
<span class="font-semibold text-heading" x-text="props.networkLabel || 'devnet'"></span>
|
|
37
|
-
</div>
|
|
38
|
-
<div class="flex items-center justify-between gap-3 mt-2">
|
|
39
|
-
<span class="text-secondary">Environment</span>
|
|
40
|
-
<span class="font-semibold text-heading" x-text="props.environmentLabel || 'QA'"></span>
|
|
41
|
-
</div>
|
|
42
|
-
</div>
|
|
43
|
-
|
|
44
|
-
<label class="flex items-start gap-3 rounded-lg border border-subtle p-3 mb-4 cursor-pointer">
|
|
45
|
-
<input type="checkbox" x-model="checked" class="mt-1">
|
|
46
|
-
<span class="text-sm text-secondary">I understand this wallet request will use the network shown above.</span>
|
|
47
|
-
</label>
|
|
48
|
-
|
|
49
|
-
<%# Equal weight, deliberately. Cancel is not the quiet option here. %>
|
|
50
|
-
<div class="grid grid-cols-2 gap-3">
|
|
51
|
-
<button type="button" class="btn btn-secondary btn-lg" @click="$store.dsModals.close()">Cancel</button>
|
|
52
|
-
<button type="button" class="btn btn-primary btn-lg" :disabled="!checked"
|
|
53
|
-
@click="$store.dsModals.close()">Continue</button>
|
|
54
|
-
</div>
|
|
55
|
-
</div>
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
<%#
|
|
2
|
-
Living style guide specimen — the off-page quest celebration.
|
|
3
|
-
|
|
4
|
-
WHY IT EXISTS ALONGSIDE "Great Username", which looks almost identical: they
|
|
5
|
-
are the same beat reached two different ways, and the difference is where the
|
|
6
|
-
user is standing. The leveling-activity celebration fires INSIDE the modal that
|
|
7
|
-
did the work. This card is what a host shows when the quest completed somewhere
|
|
8
|
-
with no quest card to advance in place — turf's /account username change — so
|
|
9
|
-
the celebration has to arrive on its own.
|
|
10
|
-
|
|
11
|
-
APP-SPECIFIC FLOW, engine chrome only. The engine lends `blocks/_card_header`
|
|
12
|
-
and `blocks/_seeds_bar` (a :leveling primitive — the bar is the engine's, the
|
|
13
|
-
NUMBERS are app-supplied Alpine expressions). What stays the host's: which
|
|
14
|
-
quests exist, what they pay, and where "Next Quest" goes.
|
|
15
|
-
|
|
16
|
-
THE FORK AT THE BOTTOM is the interesting part and the reason this is one card
|
|
17
|
-
with a toggle rather than two cards. A viewer who has NOT joined the newsletter
|
|
18
|
-
is offered it as the next quest — the celebration doubles as the funnel. One who
|
|
19
|
-
already has is sent to the contest, where the remaining quests live. In the real
|
|
20
|
-
card that gate is SERVER-rendered from current_user.subscribed_to_newsletter?,
|
|
21
|
-
precisely so the CTA never flashes the wrong option at someone already in.
|
|
22
|
-
|
|
23
|
-
Single root: the outer <div> is the host's required root.
|
|
24
|
-
%>
|
|
25
|
-
<div x-data="{ get props() { var c = $store.dsModals.current(); return (c && c.props) || {} } }">
|
|
26
|
-
<%= render "studio/modals/blocks/card_header",
|
|
27
|
-
size: :lg,
|
|
28
|
-
icon_emoji: "🌱",
|
|
29
|
-
title: "Great Username",
|
|
30
|
-
subtitle: "Nice — you earned 25 seeds by completing a quest. Complete more quests to earn a Free Entry Token." %>
|
|
31
|
-
|
|
32
|
-
<%# The engine's bar, fed demo numbers. A host passes its own live totals; the
|
|
33
|
-
bar decides on its own whether a level was crossed and runs the flourish. %>
|
|
34
|
-
<div class="mb-5">
|
|
35
|
-
<%= render "studio/modals/blocks/seeds_bar",
|
|
36
|
-
seeds_earned_key: "25",
|
|
37
|
-
seeds_total_key: "props.seedsTotal || 25",
|
|
38
|
-
free_entry_label: "Free Entry Token 🎟️" %>
|
|
39
|
-
</div>
|
|
40
|
-
|
|
41
|
-
<template x-if="props.subscribed">
|
|
42
|
-
<button type="button" @click="$store.dsModals.close()"
|
|
43
|
-
class="btn btn-primary btn-lg w-full">Next Quest</button>
|
|
44
|
-
</template>
|
|
45
|
-
<template x-if="!props.subscribed">
|
|
46
|
-
<%# Not subscribed: the celebration hands off to the newsletter quest. %>
|
|
47
|
-
<button type="button" @click="$store.dsModals.swap('join-newsletter', { demo: true, leveling: true })"
|
|
48
|
-
class="btn btn-primary btn-lg w-full">Next Quest</button>
|
|
49
|
-
</template>
|
|
50
|
-
|
|
51
|
-
<button type="button" @click="$store.dsModals.close()"
|
|
52
|
-
class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
|
|
53
|
-
Close
|
|
54
|
-
</button>
|
|
55
|
-
</div>
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
<%#
|
|
2
|
-
Living style guide specimen — the final beat after a confirmed unsubscribe.
|
|
3
|
-
|
|
4
|
-
The smallest card in the guide, and it earns its place: it is the half of the
|
|
5
|
-
newsletter story the guide could not previously show. The Profile Leveling
|
|
6
|
-
section already walked the way IN (Join the Newsletter → Subscribed!); this and
|
|
7
|
-
its sibling walk the way OUT. A section that only ever demonstrates opt-in is
|
|
8
|
-
not showing the flow, it is showing the happy half of it.
|
|
9
|
-
|
|
10
|
-
APP-SPECIFIC, engine chrome only: the engine owns no newsletter. It lends
|
|
11
|
-
`blocks/_card_header` and the tone.
|
|
12
|
-
|
|
13
|
-
NOTE THE COPY IS NOT A GUILT TRIP — "Change your mind anytime from your
|
|
14
|
-
account" is a door left open, not a plea. Worth preserving if this is ever
|
|
15
|
-
reworded.
|
|
16
|
-
|
|
17
|
-
Single root: the outer <div> is the host's required root.
|
|
18
|
-
%>
|
|
19
|
-
<div x-data="{}">
|
|
20
|
-
<%= render "studio/modals/blocks/card_header",
|
|
21
|
-
size: :lg,
|
|
22
|
-
icon_emoji: "✌️",
|
|
23
|
-
title: "See you later",
|
|
24
|
-
subtitle: "You're off the list. Change your mind anytime from your account." %>
|
|
25
|
-
|
|
26
|
-
<button type="button" @click="$store.dsModals.close()"
|
|
27
|
-
class="btn btn-primary btn-lg w-full">
|
|
28
|
-
Close
|
|
29
|
-
</button>
|
|
30
|
-
</div>
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
<%#
|
|
2
|
-
Wallet deposit / top-up modal — design-system port of turf-monster's
|
|
3
|
-
modals/_wallet_deposit, wired to the living style guide's page-scoped host
|
|
4
|
-
($store.dsModals). Single-card design (no step machinery): the shortfall
|
|
5
|
-
math, a "Pay with Card" CTA, and a copy-your-address self-fund row.
|
|
6
|
-
|
|
7
|
-
Self-contained: the production Stripe form + CDP onramp button are replaced
|
|
8
|
-
with a neutral demo CTA, and the wallet address reads from props (a demo
|
|
9
|
-
value) rather than $store.session. Opened via
|
|
10
|
-
open('wallet-deposit', { neededCents, usdcCents, usdtCents, address }).
|
|
11
|
-
|
|
12
|
-
Single root: the outer <div> is the host's required root.
|
|
13
|
-
%>
|
|
14
|
-
<div x-data="{ get props() { return (Alpine.store('dsModals').current() || {}).props || {} } }"
|
|
15
|
-
class="relative">
|
|
16
|
-
|
|
17
|
-
<button @click="$store.dsModals.close()"
|
|
18
|
-
class="absolute top-0 right-0 -mt-2 -mr-2 text-secondary hover:text-heading text-xl leading-none z-10"
|
|
19
|
-
aria-label="Close">×</button>
|
|
20
|
-
|
|
21
|
-
<div class="text-center pt-1 mb-3">
|
|
22
|
-
<h3 class="text-heading font-bold text-lg leading-tight">Top Up Your Wallet</h3>
|
|
23
|
-
</div>
|
|
24
|
-
<p class="text-xs text-secondary mb-4 text-center">
|
|
25
|
-
You need $<span class="font-mono" x-text="((props.neededCents || 0) / 100).toFixed(2)"></span>
|
|
26
|
-
to enter.
|
|
27
|
-
You have $<span class="font-mono" x-text="((props.usdcCents || 0) / 100).toFixed(2)"></span> USDC
|
|
28
|
-
+ $<span class="font-mono" x-text="((props.usdtCents || 0) / 100).toFixed(2)"></span> USDT.
|
|
29
|
-
</p>
|
|
30
|
-
|
|
31
|
-
<div class="space-y-3">
|
|
32
|
-
<%# Demo pay CTA — the production modal posts to Stripe checkout in a new
|
|
33
|
-
tab; here it just closes (no real payment). %>
|
|
34
|
-
<button type="button" @click="$store.dsModals.close()" class="btn btn-primary btn-lg w-full">
|
|
35
|
-
Pay with Card
|
|
36
|
-
</button>
|
|
37
|
-
|
|
38
|
-
<%# Send-to-address — self-fund from another wallet. Address from props. %>
|
|
39
|
-
<div class="border-t border-strong pt-3" x-data="{ copied: false }">
|
|
40
|
-
<p class="text-xs text-secondary text-center mb-2">Or send USDC/USDT to your wallet:</p>
|
|
41
|
-
<div class="flex items-center gap-2">
|
|
42
|
-
<code class="flex-1 px-2 py-1 bg-surface rounded text-xs font-mono truncate"
|
|
43
|
-
x-text="props.address || 'Fo1L5…demo…9xQ2'"></code>
|
|
44
|
-
<button type="button"
|
|
45
|
-
@click="navigator.clipboard && navigator.clipboard.writeText(props.address || ''); copied = true; setTimeout(() => copied = false, 1500)"
|
|
46
|
-
class="btn btn-sm">
|
|
47
|
-
<span x-text="copied ? 'Copied' : 'Copy'"></span>
|
|
48
|
-
</button>
|
|
49
|
-
</div>
|
|
50
|
-
</div>
|
|
51
|
-
</div>
|
|
52
|
-
|
|
53
|
-
<p class="text-center text-xs text-muted mt-4">
|
|
54
|
-
Come back here once your deposit lands — we'll refresh the balance.
|
|
55
|
-
</p>
|
|
56
|
-
</div>
|