studio-engine 0.41.0 → 0.42.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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -0
  3. data/README.md +55 -1
  4. data/app/assets/images/emails/logo-horizontal.png +0 -0
  5. data/app/assets/images/emails/magic-link-background.gif +0 -0
  6. data/app/assets/images/emails/newsletter-subscribed-background.gif +0 -0
  7. data/app/controllers/studio/emails_controller.rb +136 -6
  8. data/app/mailers/studio/newsletter_mailer.rb +43 -0
  9. data/app/mailers/user_mailer.rb +48 -1
  10. data/app/models/studio/email_setting.rb +131 -0
  11. data/app/services/studio/banner.rb +186 -0
  12. data/app/services/studio/email_catalog.rb +248 -19
  13. data/app/services/studio/email_preview_target.rb +195 -0
  14. data/app/views/layouts/branded_mailer.html.erb +23 -6
  15. data/app/views/studio/emails/_banner_editor.html.erb +192 -0
  16. data/app/views/studio/emails/_banner_scale.html.erb +56 -0
  17. data/app/views/studio/emails/_recipient_picker.html.erb +63 -0
  18. data/app/views/studio/emails/_recipient_repaint.html.erb +122 -0
  19. data/app/views/studio/emails/_row.html.erb +68 -4
  20. data/app/views/studio/emails/index.html.erb +30 -4
  21. data/app/views/studio/emails/orphan.html.erb +45 -0
  22. data/app/views/studio/emails/show.html.erb +364 -31
  23. data/app/views/studio/mailers/_layered_banner.html.erb +136 -0
  24. data/app/views/studio/modals/_image_upload.html.erb +55 -3
  25. data/app/views/studio/newsletter_mailer/subscribed.html.erb +33 -0
  26. data/app/views/studio/newsletter_mailer/subscribed.text.erb +13 -0
  27. data/app/views/user_mailer/magic_link.html.erb +13 -4
  28. data/db/migrate/20260812000000_create_studio_email_settings.rb +26 -0
  29. data/db/migrate/20260812210000_add_copy_to_studio_email_settings.rb +32 -0
  30. data/db/migrate/20260812220000_add_subject_to_studio_email_settings.rb +15 -0
  31. data/lib/studio/version.rb +1 -1
  32. data/lib/studio.rb +12 -0
  33. metadata +20 -2
@@ -44,6 +44,16 @@
44
44
  maxWidth: 256,
45
45
  maxHeight: 256,
46
46
  transparent: true,
47
+ // GIFS BYPASS THE CROPPER ENTIRELY when allowed. They cannot go through
48
+ // it: confirm() paints the crop onto a canvas and calls toBlob(...,
49
+ // "image/png"), which keeps frame one and throws the animation away —
50
+ // silently, with a perfectly good-looking still as the result. So an
51
+ // allowed GIF is passed through as its ORIGINAL BYTES, uncropped, and the
52
+ // aspect it was authored at is the aspect that ships.
53
+ //
54
+ // Off by default because most upload sites (avatars, square thumbnails)
55
+ // genuinely want the crop, and an un-cropped image there is a bug.
56
+ allowGifs: false,
47
57
  autoCropArea: 0.9,
48
58
  // dispatch: keep the modal open after confirm so the opener's host can run
49
59
  // its own processing -> success flow (it replaces the modal). When false
@@ -57,6 +67,7 @@
57
67
  if (props.maxWidth) this.maxWidth = props.maxWidth;
58
68
  if (props.maxHeight) this.maxHeight = props.maxHeight;
59
69
  if (typeof props.transparent === "boolean") this.transparent = props.transparent;
70
+ if (typeof props.allowGifs === "boolean") this.allowGifs = props.allowGifs;
60
71
  if (props.dispatch) this.dispatch = true;
61
72
  // Carried through untouched and echoed back on confirm, so the host that
62
73
  // OPENED the cropper is the only one that acts on the result.
@@ -103,6 +114,17 @@
103
114
  return;
104
115
  }
105
116
  var self = this;
117
+
118
+ if (this.isAnimatedCandidate(file)) {
119
+ if (!this.allowGifs) {
120
+ this.error = "GIFs aren't accepted here. Use a PNG, JPG or WebP.";
121
+ return;
122
+ }
123
+ this.error = null;
124
+ this.passThrough(file);
125
+ return;
126
+ }
127
+
106
128
  var reader = new FileReader();
107
129
  reader.onload = function (e) {
108
130
  self.error = null;
@@ -112,6 +134,25 @@
112
134
  reader.readAsDataURL(file);
113
135
  },
114
136
 
137
+ // Type, not extension: a file renamed to .gif is still a PNG and belongs in
138
+ // the cropper, and a .GIF from a Windows machine is still a GIF.
139
+ isAnimatedCandidate(file) {
140
+ return (file.type || "").toLowerCase() === "image/gif";
141
+ },
142
+
143
+ // Hand the ORIGINAL file straight to the opener, no canvas in the path.
144
+ // Same event and the same owner token as a cropped confirm, so every host
145
+ // downstream is unchanged — the only difference is what is in the blob.
146
+ passThrough(file) {
147
+ try {
148
+ window.dispatchEvent(new CustomEvent("crop-photo-confirmed",
149
+ { detail: { blob: file, owner: this.owner || null,
150
+ filename: file.name, uncropped: true } }));
151
+ } catch (_) {}
152
+ if (this.cropper) { this.cropper.destroy(); this.cropper = null; }
153
+ if (!this.dispatch) this.$store[this._storeName].close();
154
+ },
155
+
115
156
  onFilePicked(event) {
116
157
  var file = event.target.files[0];
117
158
  event.target.value = "";
@@ -228,6 +269,7 @@
228
269
  if (opts.maxWidth) p.maxWidth = opts.maxWidth;
229
270
  if (opts.maxHeight) p.maxHeight = opts.maxHeight;
230
271
  if (opts.autoCropArea) p.autoCropArea = opts.autoCropArea;
272
+ if (typeof opts.allowGifs === "boolean") p.allowGifs = opts.allowGifs;
231
273
  if (extra) { for (var k in extra) { p[k] = extra[k]; } }
232
274
  return p;
233
275
  }
@@ -249,7 +291,12 @@
249
291
  onCropConfirmed(detail) {
250
292
  if (!detail) return;
251
293
  if (detail.owner && detail.owner !== ownerId) return;
252
- this.applyCrop(detail.blob);
294
+ // An uncropped pass-through (a GIF) carries its own name and type. The
295
+ // cropped path does not — it produced a PNG — so it keeps the caller's
296
+ // filename. Sending "banner.png" for GIF bytes would have the server
297
+ // store an animated file under a name and content type that say
298
+ // otherwise, and the inbox would be told it is a PNG.
299
+ this.applyCrop(detail.blob, detail.filename);
253
300
  },
254
301
  // Native picker: read the chosen image, then hand it to the modal.
255
302
  onFileSelected(event) {
@@ -262,10 +309,15 @@
262
309
  reader.readAsDataURL(file);
263
310
  event.target.value = "";
264
311
  },
265
- applyCrop(blob) {
312
+ applyCrop(blob, filename) {
266
313
  if (!blob) return;
314
+ // The blob's OWN type when it has one — a pass-through GIF arrives as the
315
+ // original File and already knows it is image/gif. Only the cropped path
316
+ // needs the png default, because that is what toBlob produced.
317
+ var type = blob.type || "image/png";
318
+ var name = filename || opts.filename || "image.png";
267
319
  var dt = new DataTransfer();
268
- dt.items.add(new File([blob], opts.filename || "image.png", { type: "image/png" }));
320
+ dt.items.add(new File([blob], name, { type: type }));
269
321
  this.$refs.fileInput.files = dt.files;
270
322
  window.submitFormWithProgress(this.$refs.form, opts);
271
323
  }
@@ -0,0 +1,33 @@
1
+ <%# Body only — branded_mailer supplies the banner + card. Colours mirror
2
+ user_mailer/magic_link: brand primary for anything actionable, so the two
3
+ emails read as coming from the same place. %>
4
+ <h1 style="margin:0 0 18px;font-size:22px;line-height:1.3;color:#1f2a1c;">You're on the list</h1>
5
+
6
+ <p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#3f4a3c;">
7
+ Thanks for subscribing to <strong style="color:#1f2a1c;"><%= @app_name %></strong>. We'll send
8
+ the occasional note about what we're building — no more often than it's worth your time.
9
+ </p>
10
+
11
+ <p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#3f4a3c;">
12
+ Nothing else to do. The next one lands in this inbox.
13
+ </p>
14
+
15
+ <p style="margin:28px 0 0;font-size:13px;line-height:1.5;color:#6b756a;text-align:center;">
16
+ We're sending to <strong style="color:#2f3a2c;"><%= @email %></strong>.
17
+ </p>
18
+
19
+ <% if @unsubscribe_url.present? %>
20
+ <%# Renders ONLY when the host wired a real URL. A newsletter needs a way out,
21
+ but an unsubscribe link that goes nowhere is worse than none — it spends
22
+ the reader's trust and then fails them. %>
23
+ <p style="margin:24px 0 0;font-size:12px;line-height:1.4;color:#8a948a;text-align:center;">
24
+ Changed your mind?
25
+ <a href="<%= @unsubscribe_url %>" style="color:<%= Studio.theme_primary %>;">Unsubscribe</a>.
26
+ </p>
27
+ <% end %>
28
+
29
+ <p style="margin:28px 0 0;font-size:12px;line-height:1.5;color:#8a948a;text-align:center;">
30
+ Didn't sign up? You can safely ignore this email and we won't send another.
31
+ </p>
32
+
33
+ <p style="margin:20px 0 0;font-size:13px;color:#6b756a;text-align:center;">— <%= @app_name %></p>
@@ -0,0 +1,13 @@
1
+ You're on the <%= @app_name %> list.
2
+
3
+ Thanks for subscribing. We'll send the occasional note about what we're
4
+ building — no more often than it's worth your time.
5
+
6
+ We're sending to <%= @email %>.
7
+ <% if @unsubscribe_url.present? %>
8
+ Changed your mind? Unsubscribe here:
9
+ <%= @unsubscribe_url %>
10
+ <% end %>
11
+ Didn't sign up? You can safely ignore this message and we won't send another.
12
+
13
+ — <%= @app_name %>
@@ -1,5 +1,10 @@
1
- <%# Body only — branded_mailer supplies the banner + card. Uses the theme success
2
- color for the button so it matches each app. %>
1
+ <%# Body only — branded_mailer supplies the banner + card.
2
+
3
+ The button is theme PRIMARY, not success. Success is the "it worked" green;
4
+ signing in has not happened yet, and the brand colour is what ties the button
5
+ to the banner above it. The copy-and-paste link is the SAME colour on purpose
6
+ — it is the button's fallback, and a green link under a purple button reads
7
+ as a mistake. %>
3
8
  <h1 style="margin:0 0 18px;font-size:22px;line-height:1.3;color:#1f2a1c;">Your sign-in link</h1>
4
9
 
5
10
  <p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#3f4a3c;">
@@ -8,7 +13,7 @@
8
13
  </p>
9
14
 
10
15
  <table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto;">
11
- <tr><td align="center" bgcolor="<%= Studio.theme_success %>" style="border-radius:10px;">
16
+ <tr><td align="center" bgcolor="<%= Studio.theme_primary %>" style="border-radius:10px;">
12
17
  <a href="<%= @magic_url %>" style="display:inline-block;padding:16px 40px;font-size:17px;font-weight:700;color:#ffffff;text-decoration:none;border-radius:10px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">Sign in to <%= @app_name %> 🪄</a>
13
18
  </td></tr>
14
19
  </table>
@@ -19,7 +24,11 @@
19
24
 
20
25
  <p style="margin:24px 0 0;font-size:12px;line-height:1.4;color:#8a948a;text-align:center;">
21
26
  Button not working? Copy and paste this link:<br>
22
- <a href="<%= @magic_url %>" style="font-size:9px;line-height:1.3;color:<%= Studio.theme_success %>;word-break:break-all;"><%= @magic_url %></a>
27
+ <%# 11px, not the old 9px: this is the link someone falls back to when the
28
+ button fails, so it has to be readable enough to select by hand. It stays
29
+ a step under the 12px label above it — the label explains, the URL is the
30
+ thing you drag across. %>
31
+ <a href="<%= @magic_url %>" style="font-size:11px;line-height:1.5;color:<%= Studio.theme_primary %>;word-break:break-all;"><%= @magic_url %></a>
23
32
  </p>
24
33
 
25
34
  <p style="margin:28px 0 0;font-size:12px;line-height:1.5;color:#8a948a;text-align:center;">
@@ -0,0 +1,26 @@
1
+ # Per-email, per-APP settings an operator can change from /admin/emails without
2
+ # a deploy — starting with the banner scrim.
3
+ #
4
+ # Separate from ImageCache (which holds the uploaded banner) because this is a
5
+ # NUMBER, not an asset, and separate from the registry (which is code) because
6
+ # the whole point is that an operator can tune it against real artwork and see
7
+ # the result. The registry stays the default; a row here is an override.
8
+ class CreateStudioEmailSettings < ActiveRecord::Migration[7.2]
9
+ def change
10
+ create_table :studio_email_settings do |t|
11
+ # The registry key — "magic_link", "contest_winnings". Not a foreign key:
12
+ # the catalogue is code, and an app may unregister an email without
13
+ # wanting its saved settings destroyed.
14
+ t.string :email_key, null: false
15
+
16
+ # Scrim as a PERCENT (0-100) rather than a float. It is what the operator
17
+ # types and what the page shows; storing the same units the human uses
18
+ # keeps the round-trip lossless and the value obvious in the console.
19
+ t.integer :scrim_percent
20
+
21
+ t.timestamps
22
+ end
23
+
24
+ add_index :studio_email_settings, :email_key, unique: true
25
+ end
26
+ end
@@ -0,0 +1,32 @@
1
+ # The banner's WORDS, editable by the operator alongside its tint.
2
+ #
3
+ # Separate migration rather than an edit to CreateStudioEmailSettings: that one
4
+ # has already been installed and run in a host, and rewriting an applied
5
+ # migration is how a schema quietly diverges from the file that claims to
6
+ # describe it.
7
+ #
8
+ # Every column is nullable, and nil means INHERIT — the registry default applies.
9
+ # That distinction is the whole design: "the operator has not set this" and "the
10
+ # operator set this to empty" are different answers, and a NOT NULL default
11
+ # would collapse them.
12
+ class AddCopyToStudioEmailSettings < ActiveRecord::Migration[7.2]
13
+ def change
14
+ change_table :studio_email_settings, bulk: true do |t|
15
+ # Supports a {name} placeholder, which is what keeps the field honest for
16
+ # an email whose greeting is per-recipient.
17
+ t.string :header
18
+
19
+ # Used when no name is known. A magic link may be the first contact we
20
+ # ever have with someone, so "Welcome {name}!" has to have somewhere to
21
+ # fall back to that is not "Welcome !".
22
+ t.string :header_fallback
23
+
24
+ t.string :subtext
25
+
26
+ # Blank inherits the registry logo. hide_logo is the deliberate "no logo"
27
+ # answer — without it, blank would have to mean both inherit and none.
28
+ t.string :logo_url
29
+ t.boolean :hide_logo, null: false, default: false
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,15 @@
1
+ # The subject line, editable beside the banner's words.
2
+ #
3
+ # Its OWN migration, and that is the point rather than an oversight. The subject
4
+ # column was first written into AddCopyToStudioEmailSettings, which a host had
5
+ # already run — so the column silently never appeared, and the manager offered a
6
+ # field backed by nothing. Editing an applied migration does not re-apply it; it
7
+ # only makes the file disagree with the database it claims to describe.
8
+ class AddSubjectToStudioEmailSettings < ActiveRecord::Migration[7.2]
9
+ def change
10
+ # Nullable, because nil means INHERIT the registry default — the same
11
+ # distinction every other copy column carries. A NOT NULL default would
12
+ # collapse "never set" into "set to empty".
13
+ add_column :studio_email_settings, :subject, :string
14
+ end
15
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.41.0"
2
+ VERSION = "0.42.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -570,6 +570,18 @@ module Studio
570
570
  constraints: { key: /[a-z0-9_]+/ }
571
571
  delete "admin/emails/:key", to: "studio/emails#destroy",
572
572
  constraints: { key: /[a-z0-9_]+/ }
573
+ # Operator-tunable per-email settings (the banner scrim today). Separate
574
+ # from #update, which takes an image upload.
575
+ patch "admin/emails/:key/settings", to: "studio/emails#settings",
576
+ as: :admin_email_settings, constraints: { key: /[a-z0-9_]+/ }
577
+ # The banner's words and logo. Its own route so writing a sentence and
578
+ # nudging the tint save independently.
579
+ patch "admin/emails/:key/copy", to: "studio/emails#copy",
580
+ as: :admin_email_copy, constraints: { key: /[a-z0-9_]+/ }
581
+ # The per-email logo. Separate from the banner upload above — different
582
+ # picture, different ImageCache purpose, independently revertible.
583
+ patch "admin/emails/:key/logo", to: "studio/emails#logo",
584
+ as: :admin_email_logo, constraints: { key: /[a-z0-9_]+/ }
573
585
  end
574
586
 
575
587
  # DEPRECATED, kept for ONE release. Not a redirect: consumer-ci.yml runs
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.41.0
4
+ version: 0.42.0
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-08-12 00:00:00.000000000 Z
11
+ date: 2026-08-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -169,7 +169,10 @@ files:
169
169
  - LICENSE
170
170
  - README.md
171
171
  - app/assets/images/emails/email-change-confirmation.gif
172
+ - app/assets/images/emails/logo-horizontal.png
173
+ - app/assets/images/emails/magic-link-background.gif
172
174
  - app/assets/images/emails/magic-link.gif
175
+ - app/assets/images/emails/newsletter-subscribed-background.gif
173
176
  - app/assets/images/resend-favicon.png
174
177
  - app/assets/images/ses-favicon.png
175
178
  - app/assets/javascripts/studio/canvas_confetti.js
@@ -210,6 +213,7 @@ files:
210
213
  - app/jobs/error_log_cleanup_job.rb
211
214
  - app/jobs/studio/email_delivery_job.rb
212
215
  - app/mailers/application_mailer.rb
216
+ - app/mailers/studio/newsletter_mailer.rb
213
217
  - app/mailers/user_mailer.rb
214
218
  - app/models/concerns/sluggable.rb
215
219
  - app/models/concerns/studio/board/rankable.rb
@@ -219,13 +223,16 @@ files:
219
223
  - app/models/image_cache.rb
220
224
  - app/models/session_context.rb
221
225
  - app/models/studio/email_delivery.rb
226
+ - app/models/studio/email_setting.rb
222
227
  - app/models/studio/enumeral.rb
223
228
  - app/models/studio/link.rb
224
229
  - app/models/studio/model_page.rb
225
230
  - app/models/theme_setting.rb
226
231
  - app/services/google_oauth_validator.rb
232
+ - app/services/studio/banner.rb
227
233
  - app/services/studio/email_catalog.rb
228
234
  - app/services/studio/email_image.rb
235
+ - app/services/studio/email_preview_target.rb
229
236
  - app/views/components/_admin_dropdown.html.erb
230
237
  - app/views/components/_avatar.html.erb
231
238
  - app/views/components/_avatar_cropper.html.erb
@@ -277,11 +284,17 @@ files:
277
284
  - app/views/studio/board/_card_shell.html.erb
278
285
  - app/views/studio/board/_column.html.erb
279
286
  - app/views/studio/email_images/index.html.erb
287
+ - app/views/studio/emails/_banner_editor.html.erb
288
+ - app/views/studio/emails/_banner_scale.html.erb
289
+ - app/views/studio/emails/_recipient_picker.html.erb
290
+ - app/views/studio/emails/_recipient_repaint.html.erb
280
291
  - app/views/studio/emails/_row.html.erb
281
292
  - app/views/studio/emails/index.html.erb
293
+ - app/views/studio/emails/orphan.html.erb
282
294
  - app/views/studio/emails/show.html.erb
283
295
  - app/views/studio/links/confirm.html.erb
284
296
  - app/views/studio/local_emails/index.html.erb
297
+ - app/views/studio/mailers/_layered_banner.html.erb
285
298
  - app/views/studio/modals/_crop_photo.html.erb
286
299
  - app/views/studio/modals/_host.html.erb
287
300
  - app/views/studio/modals/_image_upload.html.erb
@@ -315,6 +328,8 @@ files:
315
328
  - app/views/studio/modals/templates/_success.html.erb
316
329
  - app/views/studio/modals/templates/_wizard.html.erb
317
330
  - app/views/studio/models/show.html.erb
331
+ - app/views/studio/newsletter_mailer/subscribed.html.erb
332
+ - app/views/studio/newsletter_mailer/subscribed.text.erb
318
333
  - app/views/style/_modal_specimen.html.erb
319
334
  - app/views/style/_modals.html.erb
320
335
  - app/views/style/_specimen.html.erb
@@ -336,6 +351,9 @@ files:
336
351
  - db/migrate/20260620000001_create_studio_links.rb
337
352
  - db/migrate/20260620000002_allow_null_image_cache_owner.rb
338
353
  - db/migrate/20260623130000_create_studio_enumerals.rb
354
+ - db/migrate/20260812000000_create_studio_email_settings.rb
355
+ - db/migrate/20260812210000_add_copy_to_studio_email_settings.rb
356
+ - db/migrate/20260812220000_add_subject_to_studio_email_settings.rb
339
357
  - lib/studio-engine.rb
340
358
  - lib/studio.rb
341
359
  - lib/studio/cable.rb