studio-engine 0.61.1 → 0.62.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: '0082dfd62bcd08f1a3a540bc351c6171c27a563d85aa50d7492c1f0bb1f0271a'
4
- data.tar.gz: 920156a74de7a1bbc1953a29ef01cac6b79c7d5aaad81a9f223d64d72924702d
3
+ metadata.gz: 3af6e5f7754c621df6bba0ef0972d018d48f0a08ede302530b5d5922e43328cf
4
+ data.tar.gz: e54f8c9cfbbd02cf9473e7e68ac6150a3a7c8da4ab861070da662184e1ee082d
5
5
  SHA512:
6
- metadata.gz: ff49493b81e76496c8977d2403d1b3855e8146db9f4c452a0f81707bf2aebca3c8607202d90e3c91c91c5e8e56b152b5113750b3c051dbf83c5e9340484406ea
7
- data.tar.gz: 525b285e971042834e9761b78cd359b7fc24a431bdf6e06ba6a1082fd80603ae1b7f7e1f8fbb68d69d22a174da53c429a6da505b4c534066df03b76bf7d3fbc3
6
+ metadata.gz: '08ae8d20d1a6e862e6985a2589221a87207d0ed562d0a6b696563b34d301cc77e6eef0bbc828074b09f104550041cbab78e529d8e553253b4b0de7dc5cb38432'
7
+ data.tar.gz: 1b785c9cab657acc47e1fd8d56109d91727086bf8b2aeb98bf0ebc1938bf550c3f6233fd4daa885daeab35a4b2a77b04b8f842cb14c10b7fec7f6cdcaab745b9
data/CHANGELOG.md CHANGED
@@ -233,6 +233,31 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
233
233
 
234
234
  ### Fixed
235
235
 
236
+ - **The age gate's back link now returns the date the person already entered.**
237
+ `blocks/_age_gate`'s "Update your Birthday" swapped back to `blocks/_birthday`
238
+ with an EMPTY props object, discarding the `dobYear`/`dobMonth`/`dobDay` parts
239
+ the birthday factory had just handed across the store — and the factory started
240
+ its three fields at `""` and never read them anyway. So the correction path came
241
+ back BLANK: a mistyped year cost all three picks, on a card whose own header
242
+ comment promised "a correction, not a restart".
243
+
244
+ Both sides of the seam moved. `back()` forwards the three date parts — and only
245
+ those three, deliberately: `minAge`, `state` and `message` describe the refusal,
246
+ and `validates` is load-bearing by its ABSENCE (the style guide specimen reads an
247
+ absent prop as validating, and a forwarded stale one would let the refusal pick
248
+ the next card's mode). `window.birthdayModal` gained an `init` that re-picks the
249
+ three selects from the props of the modal entry it mounted in, clamping a day
250
+ that the restored month cannot hold.
251
+
252
+ **The gate is not weakened.** Restoring the date restores the DATE and never the
253
+ verdict: the card comes back submittable and the app's endpoint re-decides on the
254
+ next submit exactly as it did on the first. `e2e/birthday_gate.spec.js` asserts a
255
+ restored under-age date is refused again.
256
+
257
+ **No consumer action.** A host that renders `blocks/_age_gate` and
258
+ `studio/_birthday_assets` gets this by upgrading; nothing in the call signature
259
+ changed and no host wiring moves.
260
+
236
261
  - **The Geo signpost now reaches every app whose admin chrome the engine owns.**
237
262
  The row added last release went into `components/_admin_dropdown` alone, on the
238
263
  premise that the shared dropdown reaches every app from one change. It does
@@ -29,6 +29,13 @@
29
29
  age-gate card instead. `_finish` is unchanged: the success path still flips
30
30
  session.ageVerified and dispatches `age-verified`, which is the hook consuming
31
31
  apps already listen on, so nothing downstream had to change.
32
+
33
+ AND THE HANDOFF RETURNS (2026-08-26). `_reject` sends dobYear/dobMonth/dobDay
34
+ across the store; the gate card's back() hands the same three parts back; and
35
+ `init` re-picks the three selects from them, so the correction the gate card
36
+ offers costs one scroll rather than all three fields. The date is restored,
37
+ never the VERDICT: the card comes back submittable and the app's endpoint
38
+ re-decides on the next submit exactly as it did on the first.
32
39
  %>
33
40
  <script>
34
41
  (function () {
@@ -89,6 +96,56 @@
89
96
  if (this.day && parseInt(this.day, 10) > this.dayOptions.length) this.day = "";
90
97
  this.error = "";
91
98
  },
99
+ // THE RETURN TRIP. _reject writes dobYear/dobMonth/dobDay onto the store
100
+ // on the way out to the age-gate card, and that card's back() hands the
101
+ // same three parts straight back — so a card mounted from a return trip
102
+ // finds the person's date on its own modal entry and re-picks the three
103
+ // selects. Without this the factory started at "" whatever it was handed,
104
+ // and a mistyped year cost all three fields (fixed 2026-08-26).
105
+ //
106
+ // Read off the STORE rather than taken as an opt: that is where this
107
+ // factory already meets the store (_reject writes to the same object
108
+ // through the same guard), the ERB locals in blocks/_birthday are
109
+ // server-side and cannot carry a client value anyway, and it means a
110
+ // consuming app that renders the card needs no change at all.
111
+ //
112
+ // STRINGS, because that is what the selects produce: the options render
113
+ // :value="m.n" — a number stringified by the DOM — and x-model writes
114
+ // that string back. Seeding a Number would leave the field holding a type
115
+ // the person's own pick never produces.
116
+ _returnedDob() {
117
+ var props;
118
+ try {
119
+ var entry = Alpine.store(this.store).current();
120
+ props = (entry && entry.props) || {};
121
+ } catch (e) {
122
+ return null;
123
+ }
124
+ var y = parseInt(props.dobYear, 10),
125
+ m = parseInt(props.dobMonth, 10),
126
+ d = parseInt(props.dobDay, 10);
127
+ if (!y || !m || !d) return null;
128
+ return { year: String(y), month: String(m), day: String(d) };
129
+ },
130
+ // Alpine calls init() on the data object at MOUNT, synchronously, inside
131
+ // the x-data directive and before it walks the three <select>s below
132
+ // (Alpine 3.16.1 as vendored: `a.init && T(t, a.init)`). So this is the
133
+ // fields' initial state rather than a later mutation of them, and x-model
134
+ // paints the selects from it. Measured in chromium: all three hold the
135
+ // restored values on the FIRST frame the card exists, six runs out of six
136
+ // — no deferral needed, and e2e/birthday_gate.spec.js is what watches it.
137
+ init() {
138
+ var dob = this._returnedDob();
139
+ if (!dob) return;
140
+ this.year = dob.year;
141
+ this.month = dob.month;
142
+ // Day LAST, and clamped: dayOptions is only the restored month's list
143
+ // once month and year are set, and an impossible pair from a hand-built
144
+ // props bag (Feb 30) must land empty rather than leave the select
145
+ // holding a value its own options do not contain — which would read as
146
+ // complete and submit a date nobody picked.
147
+ this.day = parseInt(dob.day, 10) <= this.dayOptions.length ? dob.day : "";
148
+ },
92
149
  // Success path: flip a session flag if present, close via the app's store,
93
150
  // and let the app resume its own flow (the entry gate listens for this).
94
151
  _finish() {
@@ -16,7 +16,14 @@
16
16
  the back link — the thing they might NEED. A wrong birthday is an ordinary
17
17
  typo (a mis-picked year is one scroll away), and a gate with no
18
18
  way back turns a typo into a locked door. swap()s back to the
19
- birthday card, so this is a correction, not a restart.
19
+ birthday card CARRYING THE DATE, so a mistyped year costs one
20
+ scroll and not all three fields. That is what makes this a
21
+ correction rather than a restart: back() forwards the dobYear /
22
+ dobMonth / dobDay parts _reject handed across the store on the
23
+ way in, and the birthday factory seeds its three selects from
24
+ them. Until 2026-08-26 it swapped with an EMPTY props object and
25
+ the card came back blank, which is the restart this paragraph
26
+ disclaimed.
20
27
 
21
28
  DELIBERATELY NOT DISMISSIBLE-BY-DEFAULT-ONLY: the host still decides via
22
29
  props.dismissible at open() time, exactly like every other card. What this
@@ -146,7 +153,26 @@
146
153
  // sibling cards that poll: modals/_web3_step_up, blocks/_cta_redirect,
147
154
  // blocks/_seeds_bar.
148
155
  destroy() { this.stop(); },
149
- back() { $store.<%= modal_store %>.swap('<%= j birthday_modal_id.to_s %>', {}); }
156
+ // THE DATE RIDES BACK. _reject put these three parts on the store on the
157
+ // way here so this card could say WHEN; handing them back is what makes
158
+ // the return trip a correction instead of a blank form. This swapped with
159
+ // an empty object until 2026-08-26, and a mistyped year cost all three
160
+ // picks.
161
+ //
162
+ // THE THREE PARTS ONLY, deliberately not the whole props bag. minAge,
163
+ // state and message describe the REFUSAL: message would land as an error
164
+ // line on a card nobody has submitted yet, and `validates` is load-bearing
165
+ // by its ABSENCE — the style guide specimen reads an absent prop as
166
+ // validating, so forwarding a stale one would let this card pick the next
167
+ // card's mode (style/modals/_birthday.html.erb).
168
+ back() {
169
+ var p = this.props;
170
+ $store.<%= modal_store %>.swap('<%= j birthday_modal_id.to_s %>', {
171
+ dobYear: p.dobYear || null,
172
+ dobMonth: p.dobMonth || null,
173
+ dobDay: p.dobDay || null
174
+ });
175
+ }
150
176
  }"
151
177
  <% if countdown %>x-init="start()"<% end %>
152
178
  class="relative">
@@ -211,7 +237,9 @@
211
237
 
212
238
  <% if birthday_modal_id.present? %>
213
239
  <%# The correction path. A mis-picked year is one scroll away from a right
214
- one, and without this the typo is indistinguishable from the verdict. %>
240
+ one, and without this the typo is indistinguishable from the verdict.
241
+ back() carries the date across, so what comes back is the same three
242
+ picks with the wrong one still selected. %>
215
243
  <button type="button" @click="back()"
216
244
  class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition underline-offset-2 hover:underline">
217
245
  <%= back_label %>
@@ -23,6 +23,23 @@
23
23
 
24
24
  Locals (all optional; sensible props.* / static defaults):
25
25
  modal_store — Alpine store name backing props/close. Default "modals".
26
+ props_expr — Alpine expression yielding the PROPS OBJECT. Default reads
27
+ $store.<modal_store>.current().props, which is the shape
28
+ the engine's own host uses. A consumer whose store is a
29
+ DIRECT-FIELD object with no .current() — turf-monster's
30
+ $store.solanaModal is one — passes that store itself here.
31
+ Without it every props.* default below resolves to {} and
32
+ the card renders complete with none of its numbers.
33
+ above_seeds — partial path rendered INSIDE the card, above the seeds bar
34
+ (e.g. a contest kickoff countdown). Named locals rather
35
+ than a second block: _success_card's yield is already
36
+ spent on the leveling enrichment, and blocks/_card_header
37
+ documents the Rails hazard where a partial's block_given?
38
+ reads TRUE off a layout yield with no block passed.
39
+ above_seeds_locals — locals for it. Default {}.
40
+ slot_position — passed through to _success_card. Default :above_cta here,
41
+ because a celebration's button is where the card ENDS:
42
+ anything under it is furniture behind the exit.
26
43
  title_key — Alpine expr for the headline. Default "props.title || 'Good Luck'".
27
44
  subtitle — static sub-headline. Default "Entry Confirmed".
28
45
  subtitle_key — Alpine expr for the sub-headline (priority over subtitle).
@@ -37,6 +54,11 @@
37
54
  %>
38
55
  <%
39
56
  modal_store = local_assigns.fetch(:modal_store, "modals")
57
+ props_expr = local_assigns.fetch(:props_expr,
58
+ "(function () { var c = $store.#{modal_store}.current(); return (c && c.props) || {}; })()")
59
+ above_seeds = local_assigns[:above_seeds]
60
+ above_seeds_locals = local_assigns.fetch(:above_seeds_locals, {})
61
+ slot_position = local_assigns.fetch(:slot_position, :above_cta)
40
62
  title_key = local_assigns.fetch(:title_key, "props.title || 'Good Luck'")
41
63
  subtitle = local_assigns.fetch(:subtitle, "Entry Confirmed")
42
64
  subtitle_key = local_assigns[:subtitle_key]
@@ -59,7 +81,8 @@
59
81
  cta_href_key: lobby_url_key,
60
82
  cta_drain: true,
61
83
  auto_redirect_url_key: lobby_url_key,
62
- confetti: true
84
+ confetti: true,
85
+ slot_position: slot_position
63
86
  }
64
87
  if subtitle_key
65
88
  sc_locals[:message_key] = subtitle_key
@@ -70,8 +93,15 @@
70
93
  <%# props getter + clusterParam live on the root scope so _success_card's nested
71
94
  Alpine expressions (props.txSignature, props.lobbyUrl, clusterParam) resolve up
72
95
  the scope chain. clusterParam feeds _success_card's branded tx-link URL. %>
73
- <div x-data="{ get props() { var c = $store.<%= modal_store %>.current(); return (c && c.props) || {}; }, clusterParam: '<%= cluster_param %>' }">
96
+ <div x-data="{ get props() { return <%= props_expr %>; }, clusterParam: '<%= cluster_param %>' }">
74
97
  <%= render "studio/modals/blocks/success_card", sc_locals do %>
98
+ <%# The app's own enrichment, above the seeds bar. turf puts its kickoff
99
+ countdown here — "when does it start" reads before the celebratory seeds
100
+ animation, not after it. %>
101
+ <% if above_seeds.present? %>
102
+ <%= render above_seeds, **above_seeds_locals %>
103
+ <% end %>
104
+
75
105
  <% if Studio.feature?(:leveling) %>
76
106
  <%# Leveling enrichment — mounts only when this entry awarded seeds. %>
77
107
  <template x-if="(<%= seeds_earned_key %>) > 0">
@@ -35,6 +35,8 @@
35
35
  on the left and a launch arrow on the
36
36
  right. The "canonical" tx link.
37
37
 
38
+ slot_position: :below_cta (default) or :above_cta — where the block
39
+ renders. See the note on the slot itself.
38
40
  cta_label: primary CTA button text.
39
41
  cta_href_key: Alpine expression for the button's href.
40
42
  Mutually exclusive with cta_event.
@@ -70,6 +72,7 @@
70
72
  fire_confetti = !!local_assigns[:confetti]
71
73
  large_title = !!local_assigns[:large_title]
72
74
  use_drain = has_redirect && !!local_assigns[:cta_drain]
75
+ slot_position = local_assigns.fetch(:slot_position, :below_cta).to_sym
73
76
 
74
77
  data_attr = "{
75
78
  _remaining: #{redirect_secs},
@@ -182,6 +185,18 @@
182
185
  </p>
183
186
  <% end %>
184
187
 
188
+ <%# The block, when the caller wants it ABOVE the button.
189
+ WHY THE POSITION IS A CHOICE. Below the CTA is right for an afterthought —
190
+ a badge, a stat that ticks. It is wrong for anything the person is meant to
191
+ READ before deciding, because the button is where a celebration card ends:
192
+ put a countdown under it and it is furniture behind the exit. turf's entry
193
+ card has always run its kickoff timer and seeds bar above the button for
194
+ that reason, and this is what lets the engine's card do the same instead of
195
+ the app forking one to keep the order. %>
196
+ <% if block_given? && slot_position == :above_cta %>
197
+ <div class="mb-4"><%= yield %></div>
198
+ <% end %>
199
+
185
200
  <%# Primary CTA — three flavors:
186
201
  1. cta_drain + auto_redirect: countdown drains over the button
187
202
  2. cta_href_key: anchor
@@ -219,10 +234,10 @@
219
234
  </button>
220
235
  <% end %>
221
236
 
222
- <%# Optional inline block appended below the CTA so callers can
223
- slot in product-specific celebration UI (seeds bar, badges,
224
- stat increments) without forking the partial. %>
225
- <% if block_given? %>
237
+ <%# ...or below it, which stays the DEFAULT: every existing caller renders here
238
+ and none of them asked to move. A partial can only yield once, so exactly
239
+ one of these two branches runs. %>
240
+ <% if block_given? && slot_position != :above_cta %>
226
241
  <div class="mt-3"><%= yield %></div>
227
242
  <% end %>
228
243
  </div>
@@ -15,14 +15,31 @@
15
15
  the name and returns it only for a known brand, else null → letter fallback).
16
16
 
17
17
  The sprite is visually hidden (zero-box, absolute) and aria-hidden; only the
18
- <use> references paint. Each <symbol> carries the OFFICIAL brand asset embedded
18
+ <use> references paint. Each BRAND <symbol> carries the OFFICIAL asset embedded
19
19
  inline as a base64 PNG data-URI <image> — byte-identical to the real logos each
20
20
  wallet ships (matching Turf Monster's public/wallet-*.png), so no hand-drawn
21
- approximation drifts from the brand. The letter tile stays the fallback for any
22
- wallet not in this set.
21
+ approximation drifts from the brand.
22
+
23
+ se-wallet-default is the ONE exception, and deliberately so. It stands for "a
24
+ wallet, brand unknown", which has no official artwork to be faithful to — so it
25
+ is drawn in PATHS rather than embedded as a raster. That buys two things a PNG
26
+ could not: it scales to any size without a second asset, and it paints in
27
+ currentColor, so a caller can tint it to whatever it is standing in for. Use it
28
+ whenever a brand is absent or unrecognised, instead of hiding the avatar and
29
+ giving the surface two different shapes.
23
30
  %>
24
31
  <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"
25
32
  style="position:absolute;width:0;height:0;overflow:hidden" data-wallet-brand-sprite>
33
+ <%# Default — a wallet, brand unknown. Paths, not a raster: it scales and it
34
+ takes currentColor. The clasp is an evenodd hole rather than a second fill,
35
+ so the whole mark is one colour and cannot half-tint. %>
36
+ <symbol id="se-wallet-default" viewBox="0 0 24 24">
37
+ <path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"
38
+ d="M6.5 4H17a1 1 0 1 1 0 2H6.5a.5.5 0 0 0 0 1H18a2 2 0 0 1 2 2v8a2 2 0
39
+ 0 1-2 2H6.5A2.5 2.5 0 0 1 4 16.5v-10A2.5 2.5 0 0 1 6.5 4Zm9.5
40
+ 8.2a1.3 1.3 0 1 0 0 2.6 1.3 1.3 0 0 0 0-2.6Z"/>
41
+ </symbol>
42
+
26
43
  <%# Phantom — official brand mark (light-purple tile, cream ghost). %>
27
44
  <symbol id="se-wallet-phantom" viewBox="0 0 460 460">
28
45
  <image width="460" height="460" href="data:image/png;base64,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"/>
@@ -23,11 +23,17 @@
23
23
 
24
24
  Why absent is not "no bar": the only card that can REACH the age gate is the
25
25
  validating one — the no-bar card carries no min_age, so the factory's _reject
26
- never fires. So every arrival with no props is a return trip from the gate,
27
- and `back()` swaps with EMPTY props by design (blocks/_age_gate.html.erb).
28
- Reading undefined as falsy sent that return trip to the NO-BAR card, where
29
- resubmitting the SAME under-age date was accepted — the guide demonstrating an
30
- age gate being bypassed. Caught in review, 2026-08-25.
26
+ never fires. So every arrival with no `validates` is a return trip from the
27
+ gate, and `back()` never sends one: it forwards the dobYear/dobMonth/dobDay
28
+ parts and NOTHING ELSE, deliberately, so the refusal cannot pick this
29
+ specimen's mode (blocks/_age_gate.html.erb). Reading undefined as falsy sent
30
+ that return trip to the NO-BAR card, where resubmitting the SAME under-age
31
+ date was accepted — the guide demonstrating an age gate being bypassed.
32
+ Caught in review, 2026-08-25.
33
+
34
+ So the default is still load-bearing, and it is load-bearing in the same way
35
+ after the gate started carrying the date back (2026-08-26): the props bag on
36
+ a return trip is no longer empty, but `validates` is still absent from it.
31
37
 
32
38
  The engine primitive is right and is NOT changed: in a real host the mode
33
39
  comes from ERB locals, not from a prop, so this is a two-mode SPECIMEN's
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.61.1"
2
+ VERSION = "0.62.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.61.1
4
+ version: 0.62.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie