studio-engine 0.52.2 → 0.53.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: 0b252a824ebae2c1a14b0efb4a89a056207f11550c6f21585d290b5469960134
4
- data.tar.gz: d45781ea692d918b89770e19675af88f68fb717f8906dc525ead01aefff02836
3
+ metadata.gz: 5ce8edffaa3298d94b27113b1add2064ed036620a6c8372e84e947cfbeefb895
4
+ data.tar.gz: d72221add8e7af3e036cb616779bf59fb9428573b478ecda56ebd804300387f5
5
5
  SHA512:
6
- metadata.gz: 12b68644186bbfd48b52d4ca073f305b04367538acea66b480e1313cfead58053c0abe3d2f2d49318728d0a99470df3d74f36e784fba5f09cdc926f0c68dd3cf
7
- data.tar.gz: 06a8baea9fa0164bf7cff3b823148d4c90f349b89949cedc0d3a8c6d165bf3c95314dd4eb0fe1929638c9434426618d076bc9e983d80cbff4778b988a1caf6ae
6
+ metadata.gz: f72e5c3f5c5e6c0f03deca8238fff54a8da590b384c0987689becee01ca791c645178ff32d5597664ed5320e5a82408f91d5d6b4cb3b8219be602536f6cb2dd7
7
+ data.tar.gz: d28e41b0a6c6c1a2b58fbcfe66787e2be955e353e101ac567d78d628f3de865dfbf81aeb30b3cf1796809fe087cee5e5bb2ab77317510f4ff951da5bea038d64
data/CHANGELOG.md CHANGED
@@ -4,6 +4,79 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Added
8
+
9
+ - **`Studio.after_newsletter_change`** — a seam so a host can react to a
10
+ subscribe or unsubscribe without the engine knowing what it does.
11
+
12
+ ```ruby
13
+ Studio.after_newsletter_change = lambda do |user, subscribed:, first_join:|
14
+ GrantWelcomeSeeds.call(user) if subscribed && first_join
15
+ end
16
+ ```
17
+
18
+ **Why it exists, concretely:** turf-monster pays a 25-seed welcome bonus
19
+ on-chain, gated on `first_newsletter_join?` — literally
20
+ `joined_email_list_at.nil?`. The engine's subscribe action set that column and
21
+ told nobody, so a subscribe from `/profile` granted no seeds **and** made the
22
+ once-ever bonus unclaimable forever. Turf had to hold the newsletter row off
23
+ its profile page entirely; this is what lets it come back.
24
+
25
+ **`first_join:` is computed BEFORE the write**, because the write is what sets
26
+ the column — asked afterwards it is always false, and no host could ever pay a
27
+ welcome bonus.
28
+
29
+ **A raising callback cannot undo the subscription.** The subscription is the
30
+ durable fact and the reaction is not: turf's grant goes over RPC to a chain
31
+ that is sometimes unreachable, and a failed bonus must not cost someone their
32
+ place on the mailing list, or roll the write back inside `rescue_and_log`. It
33
+ is rescued and logged.
34
+
35
+ Fires on both verbs, so a host that only cares about joins ignores the flag.
36
+ The default is inert.
37
+
38
+
39
+ ### Fixed
40
+
41
+ - **The birthday calendar keeps the side it opened on.** `place()` runs on every
42
+ scroll and resize, and it also **chose** the side — so the popover snapped
43
+ between above and below the moment the available space crossed the threshold.
44
+ Measured on turf-monster at 60px scroll steps: ABOVE, ABOVE, ABOVE, ABOVE, then
45
+ below — a **254px jump between two steps**, mid-scroll, while the reader was
46
+ looking at it.
47
+
48
+ The side is now decided once, by `chooseSide()`, when the popover opens.
49
+ `place()` only moves it.
50
+
51
+ This is a different bug from the height fix that shipped alongside it: that one
52
+ corrected **where** a flipped popover lands, and never asked whether the side
53
+ should be re-decided at all. The flip spec added with it opens and asserts
54
+ once, so a side that changes **later** was invisible to it.
55
+
56
+
57
+ ### Fixed
58
+
59
+ - **The birthday calendar measures itself instead of guessing, so a flipped
60
+ popover stays attached to its field.** `place()` used a hardcoded
61
+ `estimatedHeight = 340` for both the flip DECISION and the flipped COORDINATE.
62
+ The real popover is about 245px, so whenever the field sat near the bottom of
63
+ the viewport the calendar opened roughly 95px above where it belonged — visibly
64
+ detached, and jumping on every scroll, because scroll re-runs the same
65
+ arithmetic.
66
+
67
+ It now reads the popover's own `offsetHeight` through an `x-ref`, with the
68
+ constant surviving only as the fallback for the first call before the element
69
+ is shown. `toggle()` places twice: once so it never appears at 0,0 for a frame,
70
+ then again in `$nextTick` — the call that can actually measure, since
71
+ `offsetHeight` is 0 while `x-show` still has it hidden.
72
+
73
+ **No earlier spec had ever run the flip branch.** The lab's field sits near the
74
+ top with room below, so every placement assertion exercised the below-branch
75
+ only, and "it fits on screen" cannot catch this — a popover 95px too high still
76
+ fits. The new spec forces the flip, asserts the flip actually happened, and then
77
+ asserts the popover's bottom edge sits against the trigger's top edge.
78
+
79
+
7
80
  ### Changed
8
81
 
9
82
  - **The edit page's avatar is now the control; its corner badge is gone.**
@@ -166,11 +166,17 @@ module Studio
166
166
  current_user.email = value
167
167
  end
168
168
 
169
+ # BEFORE THE WRITE, because the write is what sets the column — asking
170
+ # afterwards always answers false, and "is this their first ever join" is
171
+ # exactly the question a once-ever welcome bonus asks.
172
+ first_join = !Studio::Newsletter.ever_joined?(current_user)
173
+
169
174
  rescue_and_log(target: current_user) do
170
175
  # left_email_list_at is CLEARED rather than left in place. `subscribed?`
171
176
  # compares the two dates, so a stale leave date in the future of the join
172
177
  # would read as unsubscribed the moment the clock disagreed.
173
178
  current_user.update!(joined_email_list_at: Time.current, left_email_list_at: nil)
179
+ notify_newsletter_change(subscribed: true, first_join: first_join)
174
180
  redirect_to profile_path, notice: "You're on the list."
175
181
  end
176
182
  end
@@ -191,12 +197,34 @@ module Studio
191
197
 
192
198
  rescue_and_log(target: current_user) do
193
199
  current_user.update!(left_email_list_at: Time.current)
200
+ notify_newsletter_change(subscribed: false, first_join: false)
194
201
  redirect_to profile_path, notice: "You've been unsubscribed."
195
202
  end
196
203
  end
197
204
 
198
205
  private
199
206
 
207
+ # Tell the host, and NEVER let it break the subscription.
208
+ #
209
+ # The subscription is the durable fact; the reaction is not. turf-monster's
210
+ # callback grants seeds ON-CHAIN over RPC to a node that is sometimes
211
+ # unreachable, and a failed bonus must not cost someone their place on the
212
+ # mailing list — or, worse, roll the write back inside rescue_and_log and
213
+ # leave them looking unsubscribed.
214
+ #
215
+ # Rescued and logged rather than re-raised for that reason. The host's own
216
+ # controller already treats its grant as deferred and backfillable; this
217
+ # keeps that true when the same grant is reached from here.
218
+ def notify_newsletter_change(subscribed:, first_join:)
219
+ callback = Studio.after_newsletter_change
220
+ return unless callback.respond_to?(:call)
221
+
222
+ callback.call(current_user, subscribed: subscribed, first_join: first_join)
223
+ rescue StandardError => e
224
+ Rails.logger.warn("[studio][newsletter] after_newsletter_change raised for " \
225
+ "user=#{current_user&.id}: #{e.class}: #{e.message.to_s[0, 200]}")
226
+ end
227
+
200
228
  # Would the page render this row for this viewer?
201
229
  #
202
230
  # A HIDDEN ROW IS NOT A GUARD. Studio::ProfileSections drops a row this host
@@ -66,6 +66,7 @@
66
66
  </button>
67
67
 
68
68
  <div x-show="open" x-cloak
69
+ x-ref="popover"
69
70
  @click.outside="open = false"
70
71
  @keydown.escape.window="open = false"
71
72
  @scroll.window="place()" @resize.window="place()"
@@ -23,6 +23,9 @@
23
23
  window.studioBirthdayPicker = function (initial) {
24
24
  return {
25
25
  open: false,
26
+ // Which side this open chose. Set by chooseSide() when the popover opens and
27
+ // then left alone — see place() for why re-deciding it is the bug.
28
+ flipped: false,
26
29
  value: initial || "",
27
30
  viewYear: 0,
28
31
  viewMonth: 0,
@@ -117,27 +120,75 @@
117
120
 
118
121
  toggle() {
119
122
  this.open = !this.open;
120
- if (this.open) {
121
- this.syncViewFromValue();
123
+ if (!this.open) return;
124
+
125
+ this.syncViewFromValue();
126
+
127
+ // Placed twice, deliberately. The first call stops the popover appearing
128
+ // at 0,0 for a frame; the second is the one that can MEASURE, because
129
+ // offsetHeight is 0 while x-show still has the element hidden — and it is
130
+ // also the only place the SIDE is chosen, for the life of this open.
131
+ this.place();
132
+ this.$nextTick(function () {
133
+ this.chooseSide();
122
134
  this.place();
123
- }
135
+ }.bind(this));
124
136
  },
125
137
 
126
138
  // Placed from the trigger's rect because the popover is fixed. Flips above
127
139
  // the trigger when there is not room below, so it never opens off-screen on
128
140
  // a short viewport.
141
+ //
142
+ // THE HEIGHT IS MEASURED, NOT GUESSED, and that distinction is the bug the
143
+ // operator reported. This used a hardcoded `estimatedHeight = 340` for both
144
+ // the decision AND the flipped coordinate. The real popover is about 245px,
145
+ // so a flip landed ~95px too high and the calendar floated visibly detached
146
+ // from its field — and since scroll re-runs this, it kept jumping. The
147
+ // arithmetic was legible in the screenshot: trigger at 640, popover at 300,
148
+ // and 640 - 340 - 4 = 316.
149
+ //
150
+ // The element is rendered under x-show (display is toggled, it stays in the
151
+ // DOM), so offsetHeight is readable as soon as it is shown. The constant
152
+ // survives only as the fallback for the first call, before the ref exists.
129
153
  place() {
130
154
  var el = this.$refs.trigger;
131
155
  if (!el) return;
156
+
132
157
  var r = el.getBoundingClientRect();
133
- var estimatedHeight = 340;
134
- var below = window.innerHeight - r.bottom;
158
+ var popover = this.$refs.popover;
159
+ var height = (popover && popover.offsetHeight) || 340;
160
+ var GAP = 4;
135
161
 
136
162
  this.left = r.left;
137
163
  this.width = r.width;
138
- this.top = below < estimatedHeight && r.top > estimatedHeight
139
- ? r.top - estimatedHeight - 4
140
- : r.bottom + 4;
164
+
165
+ // THE SIDE IS NOT RE-DECIDED HERE. place() runs on every scroll and
166
+ // resize event, and choosing the side each time makes the popover SNAP
167
+ // between above and below the moment the available space crosses the
168
+ // threshold. Measured on turf-monster at 60px scroll steps: ABOVE, ABOVE,
169
+ // ABOVE, ABOVE, then below — a 254px jump between two steps, mid-scroll,
170
+ // while the reader was looking at it. That is what "the date picker
171
+ // freaks out on scroll" was.
172
+ //
173
+ // chooseSide() runs ONCE per open; this only moves the popover.
174
+ this.top = this.flipped ? r.top - height - GAP : r.bottom + GAP;
175
+ },
176
+
177
+ // Which side to open on, decided once and then left alone. Flips above only
178
+ // when there is not room below AND there is room above — with neither, it
179
+ // stays below, attached to its field rather than pinned to the top of the
180
+ // viewport.
181
+ chooseSide() {
182
+ var el = this.$refs.trigger;
183
+ if (!el) return;
184
+
185
+ var r = el.getBoundingClientRect();
186
+ var popover = this.$refs.popover;
187
+ var height = (popover && popover.offsetHeight) || 340;
188
+ var GAP = 4;
189
+ var below = window.innerHeight - r.bottom;
190
+
191
+ this.flipped = below < height + GAP && r.top > height + GAP;
141
192
  }
142
193
  };
143
194
  };
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.52.2"
2
+ VERSION = "0.53.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -29,6 +29,29 @@ module Studio
29
29
  mattr_accessor :registration_params, default: [:name, :email, :password, :password_confirmation]
30
30
  mattr_accessor :configure_new_user, default: ->(user) {}
31
31
  mattr_accessor :configure_sso_user, default: ->(user) {}
32
+
33
+ # Called after a newsletter subscribe or unsubscribe on /profile, so a host can
34
+ # react without the engine knowing anything about what it does.
35
+ #
36
+ # THE REASON THIS EXISTS, stated concretely: turf-monster pays a 25-seed welcome
37
+ # bonus on-chain, gated on `first_newsletter_join?` — literally
38
+ # `joined_email_list_at.nil?`. The engine's subscribe action sets that column
39
+ # and told nobody, so a subscribe from /profile granted no seeds AND made the
40
+ # once-ever bonus unclaimable forever. Turf had to hold the row off its profile
41
+ # page entirely. This is what lets it come back.
42
+ #
43
+ # Studio.after_newsletter_change = lambda do |user, subscribed:, first_join:|
44
+ # GrantWelcomeSeeds.call(user) if subscribed && first_join
45
+ # end
46
+ #
47
+ # `first_join:` is computed BEFORE the write, because the write is what sets the
48
+ # column — asking afterwards always answers false.
49
+ #
50
+ # A raising callback CANNOT undo the subscription: the subscription is the
51
+ # durable fact and the reaction is not. Turf's grant goes over RPC to a chain
52
+ # that is sometimes unreachable, and a failed bonus must not cost someone their
53
+ # place on the mailing list.
54
+ mattr_accessor :after_newsletter_change, default: ->(_user, subscribed:, first_join:) {}
32
55
  mattr_accessor :sso_logo, default: nil
33
56
  mattr_accessor :wallet_address_method, default: nil
34
57
  mattr_accessor :theme_logos, default: []
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.52.2
4
+ version: 0.53.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie