studio-engine 0.41.0 → 0.43.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 (35) 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 +152 -6
  8. data/app/mailers/studio/newsletter_mailer.rb +47 -0
  9. data/app/mailers/user_mailer.rb +55 -1
  10. data/app/models/studio/email_setting.rb +206 -0
  11. data/app/services/studio/banner.rb +191 -0
  12. data/app/services/studio/email_catalog.rb +387 -19
  13. data/app/services/studio/email_preview_target.rb +195 -0
  14. data/app/views/layouts/branded_mailer.html.erb +55 -6
  15. data/app/views/studio/emails/_banner_editor.html.erb +199 -0
  16. data/app/views/studio/emails/_banner_preview.html.erb +62 -0
  17. data/app/views/studio/emails/_banner_scale.html.erb +56 -0
  18. data/app/views/studio/emails/_recipient_picker.html.erb +63 -0
  19. data/app/views/studio/emails/_recipient_repaint.html.erb +122 -0
  20. data/app/views/studio/emails/_row.html.erb +64 -82
  21. data/app/views/studio/emails/index.html.erb +34 -8
  22. data/app/views/studio/emails/orphan.html.erb +45 -0
  23. data/app/views/studio/emails/show.html.erb +468 -34
  24. data/app/views/studio/mailers/_layered_banner.html.erb +136 -0
  25. data/app/views/studio/modals/_image_upload.html.erb +55 -3
  26. data/app/views/studio/newsletter_mailer/subscribed.html.erb +40 -0
  27. data/app/views/studio/newsletter_mailer/subscribed.text.erb +13 -0
  28. data/app/views/user_mailer/magic_link.html.erb +49 -12
  29. data/db/migrate/20260812000000_create_studio_email_settings.rb +26 -0
  30. data/db/migrate/20260812210000_add_copy_to_studio_email_settings.rb +32 -0
  31. data/db/migrate/20260812220000_add_subject_to_studio_email_settings.rb +15 -0
  32. data/db/migrate/20260813010000_add_body_cta_footer_to_studio_email_settings.rb +32 -0
  33. data/lib/studio/version.rb +1 -1
  34. data/lib/studio.rb +12 -0
  35. metadata +22 -2
@@ -0,0 +1,195 @@
1
+ module Studio
2
+ # WHO the email manager previews an email as.
3
+ #
4
+ # The banner's header is per-recipient ("Welcome Mason!"), so "what does this
5
+ # email look like" has no answer until you say who is receiving it. The page
6
+ # offers a small set of real people to stand in, and every variable on the page
7
+ # — first name, email — comes from the chosen one.
8
+ #
9
+ # ## Why real records rather than made-up samples
10
+ #
11
+ # A fabricated "Sample User <sample@example.com>" previews a person who cannot
12
+ # receive mail, and it hides the failures that actually happen: an account with
13
+ # no display name, a name that is one word, a name long enough to wrap the
14
+ # banner. Reading the host's own users puts those in front of the operator.
15
+ #
16
+ # ## Why an ADMIN and a MEMBER
17
+ #
18
+ # They differ in the ways that break email. An admin is usually the operator
19
+ # themselves — a full name on file, an internal address. A member is whoever
20
+ # signed up: often no name at all, which is the case that renders "Welcome !"
21
+ # if the fallback header is wrong. Being able to flip between them is how that
22
+ # gets caught here rather than in someone's inbox.
23
+ #
24
+ # Nil-safe throughout. The host's User model may be absent, may not respond to
25
+ # `admin?`, may have no rows. The preview degrades to a synthetic stand-in
26
+ # rather than taking the manager down — this is a page for looking at pictures.
27
+ class EmailPreviewTarget
28
+ ATTRIBUTES = %i[id label name email admin avatar_url avatar_color].freeze
29
+ attr_reader(*ATTRIBUTES)
30
+
31
+ def initialize(id:, label:, name: nil, email: nil, admin: false,
32
+ avatar_url: nil, avatar_color: nil, initials: nil)
33
+ @id = id.to_s
34
+ @label = label
35
+ @name = name.presence
36
+ @email = email.presence
37
+ @admin = admin
38
+ @avatar_url = avatar_url.presence
39
+ @avatar_color = avatar_color.presence || DEFAULT_AVATAR_COLOR
40
+ @initials = initials.presence
41
+ end
42
+
43
+ # The HOST's own initials when it has them, so this circle matches the one in
44
+ # the navbar rather than being a second opinion about the same person.
45
+ # Falls back to the address when there is no name, because "no name on file"
46
+ # is the ordinary case for a member and an empty circle identifies nobody.
47
+ def initials
48
+ return @initials if @initials
49
+
50
+ source = name.presence || email.to_s.split("@").first.to_s
51
+ parts = source.split(/[\s._-]+/).reject(&:empty?)
52
+ return "?" if parts.empty?
53
+
54
+ (parts.length == 1 ? parts.first[0, 2] : parts.first(2).map { |part| part[0] }.join).upcase
55
+ end
56
+
57
+ DEFAULT_AVATAR_COLOR = "#6b7280".freeze
58
+
59
+ def admin? = !!@admin
60
+ def first_name = name.to_s.strip.split.first
61
+ def to_param = id
62
+
63
+ # A stand-in used only when the host has no matching user. Named as a sample
64
+ # so nobody mistakes the preview for a real account.
65
+ def sample? = id.start_with?("sample-")
66
+
67
+ class << self
68
+ # Every target the page can offer, admin first.
69
+ #
70
+ # The NAMELESS sample is always offered, even when a real member exists.
71
+ # It is the only way to see what the name-free fallback header sends, and
72
+ # the accounts an app actually parks tend to be real people with real names
73
+ # — McRitchie Studio's member is Mack McRitchie. The alternative was
74
+ # stripping a real person's name to make a preview reachable, which trades
75
+ # a worse app for a better test.
76
+ def all
77
+ [find_admin, find_member, sample_member].compact.uniq(&:id)
78
+ end
79
+
80
+ # The one to preview as. Falls back to the first available rather than
81
+ # erroring on a stale id from a bookmarked URL.
82
+ def resolve(id)
83
+ list = all
84
+ list.find { |target| target.id == id.to_s } || list.first
85
+ end
86
+
87
+ private
88
+
89
+ def find_admin
90
+ record = first_user { |scope| admins(scope) }
91
+ return sample_admin if record.nil?
92
+
93
+ from_record(record, id_prefix: "admin", label: "Admin", admin: true)
94
+ end
95
+
96
+ def find_member
97
+ record = first_user { |scope| members(scope) }
98
+ return nil if record.nil?
99
+
100
+ from_record(record, id_prefix: "member", label: "Member", admin: false)
101
+ end
102
+
103
+ # The host's User model, or nil. `safe_constantize` rather than defined?()
104
+ # so an app without one simply has no records to offer.
105
+ def user_model
106
+ model = "User".safe_constantize
107
+ return nil unless model.respond_to?(:all) && model.respond_to?(:column_names)
108
+
109
+ model
110
+ end
111
+
112
+ def first_user
113
+ model = user_model
114
+ return nil if model.nil?
115
+
116
+ yield(model)
117
+ rescue StandardError
118
+ # A missing table, a mid-migration column, a host User that is not an
119
+ # ActiveRecord at all. None of them should cost more than the dropdown.
120
+ nil
121
+ end
122
+
123
+ # `admin` may be a column, a method, or absent entirely. Only the column
124
+ # can be queried; anything else is filtered in Ruby over a bounded slice,
125
+ # because a preview must not table-scan a production users table.
126
+ def admins(model)
127
+ return model.where(admin: true).first if model.column_names.include?("admin")
128
+
129
+ model.limit(50).detect { |user| user.try(:admin?) }
130
+ end
131
+
132
+ def members(model)
133
+ return model.where(admin: [false, nil]).first if model.column_names.include?("admin")
134
+
135
+ model.limit(50).detect { |user| !user.try(:admin?) }
136
+ end
137
+
138
+ def from_record(record, id_prefix:, label:, admin:)
139
+ new(id: "#{id_prefix}-#{record.id}", label: label, admin: admin,
140
+ name: display_name_for(record), email: record.try(:email),
141
+ avatar_url: avatar_url_for(record), avatar_color: record.try(:avatar_color),
142
+ initials: record.try(:avatar_initials))
143
+ end
144
+
145
+ # ActiveStorage, defensively. A host may not attach avatars at all, the blob
146
+ # may be missing, and url helpers need a host that a background job does not
147
+ # have — none of which should cost more than a plain initials circle.
148
+ def avatar_url_for(record)
149
+ avatar = record.try(:avatar)
150
+ return nil unless avatar.respond_to?(:attached?) && avatar.attached?
151
+
152
+ Rails.application.routes.url_helpers.rails_blob_path(avatar, only_path: true)
153
+ rescue StandardError
154
+ nil
155
+ end
156
+
157
+ # "Is there a REAL name on file", not "what should we call this person".
158
+ #
159
+ # display_name is asked LAST and only when the record has no name attribute
160
+ # at all. It is a presentation helper: McRitchie Studio's synthesises one
161
+ # from the email address, so member@… came back as "Member" and the
162
+ # nameless member — the entire reason that option exists — previewed as
163
+ # somebody with a name. A blank `name` is the honest nil.
164
+ def display_name_for(record)
165
+ return record.name.presence if record.respond_to?(:name)
166
+ return record.full_name.presence if record.respond_to?(:full_name)
167
+
168
+ record.try(:display_name).presence
169
+ end
170
+
171
+ # The app's own domain, read off the configured from-address rather than
172
+ # a new config knob — a sample address on some other domain would look
173
+ # like a real account somewhere else.
174
+ def sample_domain
175
+ Studio.mailer_from.to_s[/@([^\s>]+)/, 1].presence || "example.com"
176
+ rescue StandardError
177
+ "example.com"
178
+ end
179
+
180
+ def sample_admin
181
+ new(id: "sample-admin", label: "Admin (sample)", admin: true,
182
+ name: "Alex McRitchie", email: "alex@#{sample_domain}")
183
+ end
184
+
185
+ # Deliberately NAMELESS, and always present. This is the only recipient that
186
+ # exercises the name-free fallback header — the case a magic link hits every
187
+ # time it reaches someone with no account yet, and the one nobody thinks to
188
+ # check because whoever is previewing has a name on file.
189
+ def sample_member
190
+ new(id: "sample-member", label: "No name on file", admin: false,
191
+ name: nil, email: "someone@#{sample_domain}")
192
+ end
193
+ end
194
+ end
195
+ end
@@ -1,17 +1,34 @@
1
1
  <%#
2
- Shared branded transactional email shell. A full-bleed banner (set @banner_url,
3
- e.g. from Studio::EmailCatalog.resolved_url(:magic_link)) sits flush at the top and sets
4
- the 600px width; each email view supplies the body via yield. Bannerless is
5
- fine the card still renders. Lifted from turf-monster so every Studio app
6
- shares one branded look. An app can override by defining its own
2
+ Shared branded transactional email shell. A full-bleed banner sits flush at the
3
+ top and sets the 600px width; each email view supplies the body via yield.
4
+ Bannerless is fine the card still renders. Lifted from turf-monster so every
5
+ Studio app shares one branded look. An app can override by defining its own
7
6
  layouts/branded_mailer.html.erb.
7
+
8
+ TWO banner shapes, in priority order:
9
+
10
+ @banner — a Studio::Banner, built by Studio::Banner.for and drawn by the
11
+ studio/mailers/layered_banner partial. LAYERED: a
12
+ background image with the header, sub-text and logo as live HTML on top.
13
+ This is the shape that supports an ANIMATED background together with
14
+ per-recipient text, because the text is never drawn into the picture.
15
+
16
+ @banner_url — a plain image URL, rendered as an <img>. The original shape,
17
+ UNCHANGED: every mailer already shipping sets this and renders exactly as
18
+ before. A layered banner is opt-in, never a migration.
8
19
  %>
9
20
  <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0;padding:24px 0;background:#f4f5f7;">
10
21
  <tr>
11
22
  <td align="center" style="padding:0 12px;">
12
23
  <table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;background:#ffffff;border-radius:14px;overflow:hidden;box-shadow:0 1px 4px rgba(15,23,42,0.08);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
13
24
 
14
- <% if @banner_url.present? %>
25
+ <% if @banner.respond_to?(:renderable?) && @banner.renderable? %>
26
+ <tr>
27
+ <td style="padding:0;line-height:0;">
28
+ <%= render "studio/mailers/layered_banner", banner: @banner %>
29
+ </td>
30
+ </tr>
31
+ <% elsif @banner_url.present? %>
15
32
  <tr>
16
33
  <td style="padding:0;line-height:0;">
17
34
  <img src="<%= @banner_url %>" width="600" alt="<%= @banner_alt || Studio.app_name %>"
@@ -25,6 +42,38 @@
25
42
  <%= yield %>
26
43
  </td>
27
44
  </tr>
45
+
46
+ <%# THE SHARED FOOTER — the same on every email this app sends, which is
47
+ why it lives in the layout and is stored once rather than per email.
48
+
49
+ FULL-BLEED AND DARK. The band closes the white card: a light sign-off
50
+ floating under the body reads as part of the message rather than the
51
+ end of it. bgcolor as well as the CSS, because Outlook renders through
52
+ Word and drops background-color on a td often enough that the
53
+ attribute is the only reliable half.
54
+
55
+ Renders nothing when the operator has cleared both fields. %>
56
+ <% footer = Studio::EmailCatalog.footer %>
57
+ <% if footer[:logo_url].present? || footer[:discord_url].present? %>
58
+ <tr>
59
+ <td align="center" bgcolor="<%= Studio::EmailCatalog::FOOTER_BACKGROUND %>"
60
+ <%# color as well as the background: with images blocked, the alt
61
+ text is all that is left, and a client's near-black default on
62
+ a near-black band is invisible. %>
63
+ style="background-color:<%= Studio::EmailCatalog::FOOTER_BACKGROUND %>;padding:28px 36px;color:#F5F3FF;">
64
+ <% if footer[:logo_url].present? %>
65
+ <%# height as well as width: Outlook collapses an alt placeholder
66
+ that has no height, so a blocked image leaves nothing at all. %>
67
+ <img src="<%= footer[:logo_url] %>" width="132" height="32" alt="<%= Studio.app_name %>"
68
+ style="display:block;margin:0 auto 14px;width:132px;height:auto;border:0;color:#F5F3FF;" />
69
+ <% end %>
70
+ <% if footer[:discord_url].present? %>
71
+ <a href="<%= footer[:discord_url] %>"
72
+ style="font-size:13px;color:#C9C2FF;text-decoration:none;">Join us on Discord</a>
73
+ <% end %>
74
+ </td>
75
+ </tr>
76
+ <% end %>
28
77
  </table>
29
78
  </td>
30
79
  </tr>
@@ -0,0 +1,199 @@
1
+ <%# The live banner editor: typing repaints the banner, and ONE Save button in
2
+ the header reports whether there is anything to save.
3
+
4
+ WHY THE PREVIEW IS PAINTED IN THE BROWSER AND NOT RE-RENDERED
5
+ The banner is already server-rendered by the mailer's own partial, which is
6
+ what makes it trustworthy. This does not replace it — it edits the text
7
+ nodes, the logo and the tint of that same rendered banner in place. The
8
+ shape, the typography and the scaling stay the server's. Re-rendering a
9
+ second banner in JavaScript would be a second implementation of the email,
10
+ and it would drift.
11
+
12
+ WHY THE PLACEHOLDER RULE IS REPEATED HERE
13
+ Studio::Banner fills {name} and {app} server-side. Repeating that in script
14
+ is a real duplication and worth naming: the alternative is a round trip per
15
+ keystroke. The rule is small and pinned on both sides — banner_copy_test.rb
16
+ for Ruby, e2e/email_banner_editor.spec.js for the browser. If they ever
17
+ disagree, the server is right.
18
+
19
+ TOUCHED VS DIRTY — they are different questions and the button answers both.
20
+ `touched` is "has anyone typed here", and it decides whether the button is
21
+ SHOWN. `dirty` is "is anything actually different from what is saved", and it
22
+ decides whether the button is ENABLED. Typing "A" into the subject and
23
+ deleting it again leaves touched true and dirty false: the button stays
24
+ visible and goes back to disabled, which is what tells the operator their
25
+ edit was undone rather than silently swallowed.
26
+ %>
27
+ <style>
28
+ /* The tint slider, in the app's own palette.
29
+
30
+ appearance:none and explicit track/thumb rules rather than `accent-color`:
31
+ accent-color tints a native slider but leaves its proportions to the
32
+ browser, so the control looked like a system widget dropped into the page —
33
+ a blue rail in a purple app. These rules give it the same rail, radius and
34
+ primary colour as everything else on the card.
35
+
36
+ The FILLED portion is a gradient driven by --range-fill, which the component
37
+ sets from the current value. Firefox has ::-moz-range-progress and WebKit
38
+ has nothing equivalent, so one gradient serves both rather than two
39
+ divergent implementations. */
40
+ .studio-range {
41
+ -webkit-appearance: none;
42
+ appearance: none;
43
+ width: 100%;
44
+ height: 6px;
45
+ border-radius: 999px;
46
+ background: linear-gradient(to right,
47
+ var(--color-primary, #8E82FE) var(--range-fill, 40%),
48
+ var(--color-inset, rgba(148, 163, 184, 0.35)) var(--range-fill, 40%));
49
+ outline: none;
50
+ cursor: pointer;
51
+ }
52
+ .studio-range::-webkit-slider-thumb {
53
+ -webkit-appearance: none;
54
+ appearance: none;
55
+ width: 16px;
56
+ height: 16px;
57
+ border-radius: 999px;
58
+ background: var(--color-primary, #8E82FE);
59
+ border: 2px solid var(--color-surface, #ffffff);
60
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.35);
61
+ }
62
+ .studio-range::-moz-range-thumb {
63
+ width: 16px;
64
+ height: 16px;
65
+ border-radius: 999px;
66
+ background: var(--color-primary, #8E82FE);
67
+ border: 2px solid var(--color-surface, #ffffff);
68
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.35);
69
+ }
70
+ .studio-range::-moz-range-track { height: 6px; border-radius: 999px; background: transparent; }
71
+ .studio-range:focus-visible { box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary, #8E82FE) 35%, transparent); }
72
+ </style>
73
+ <script>
74
+ (function () {
75
+ function editor(config) {
76
+ return {
77
+ form: Object.assign({}, config.values),
78
+ initial: Object.assign({}, config.values),
79
+ targets: config.targets || [],
80
+ targetId: config.targetId,
81
+ touched: false,
82
+ pickerOpen: false,
83
+
84
+ init() {
85
+ this.paint();
86
+ this.$watch("form", () => { this.touched = true; this.paint(); });
87
+ this.$watch("targetId", () => this.paint());
88
+ },
89
+
90
+ target() {
91
+ return this.targets.find((t) => t.id === this.targetId) || this.targets[0] || {};
92
+ },
93
+
94
+ firstName() {
95
+ return (this.target().name || "").trim().split(/\s+/)[0] || "";
96
+ },
97
+
98
+ // {app} is substituted on both paths because it does not depend on the
99
+ // recipient — a header reading "Welcome to {app}" must not show a raw
100
+ // brace here while the inbox shows the app's name.
101
+ resolve(template, fallback) {
102
+ var first = this.firstName();
103
+ var app = config.appName || "";
104
+ var text = (template || "").replace(/\{app\}/g, app);
105
+ if (first) return text.replace(/\{name\}/g, first);
106
+ if (text.indexOf("{name}") !== -1) return (fallback || "").replace(/\{app\}/g, app);
107
+ return text;
108
+ },
109
+
110
+ headerText() { return this.resolve(this.form.header, this.form.header_fallback); },
111
+ subjectText() { return this.resolve(this.form.subject, this.form.subject); },
112
+
113
+ // Field by field rather than JSON.stringify, whose output depends on key
114
+ // order — two equal objects can stringify differently.
115
+ //
116
+ // No type coercion here, deliberately. An earlier version normalised
117
+ // booleans on the theory that a checkbox holds true while the server
118
+ // echoes "1"; a mutation run showed removing it changed nothing, because
119
+ // the payload sends real booleans for the two checkboxes and strings for
120
+ // everything else. Unproven defensive code in a comparison that decides
121
+ // whether a Save button appears is worse than none.
122
+ dirty() {
123
+ return Object.keys(this.initial).some(function (k) {
124
+ return (this.form[k] ?? "") !== (this.initial[k] ?? "");
125
+ }, this);
126
+ },
127
+
128
+ // The logo has THREE states, and a URL field alone can only express two.
129
+ // "standard" inherits, "custom" uses what this app uploaded, "hidden" is
130
+ // the deliberate no-logo answer that blank cannot say.
131
+ logoMode() {
132
+ if (this.form.hide_logo === true || this.form.hide_logo === "1") return "hidden";
133
+ if (config.uploadedLogo) return "custom";
134
+ return "standard";
135
+ },
136
+
137
+ setLogoMode(mode) {
138
+ this.form.hide_logo = mode === "hidden";
139
+ },
140
+
141
+ currentLogo() {
142
+ if (this.logoMode() === "hidden") return "";
143
+ return config.uploadedLogo || config.inheritedLogo || "";
144
+ },
145
+
146
+ // The slider's own fill, as a percentage string for --range-fill.
147
+ scrimFill() {
148
+ var percent = parseInt(this.form.scrim_percent, 10);
149
+ if (isNaN(percent)) percent = config.defaultScrim;
150
+ return Math.min(Math.max(percent, 0), 100) + "%";
151
+ },
152
+
153
+ scrimCss() {
154
+ var percent = parseInt(this.form.scrim_percent, 10);
155
+ if (isNaN(percent)) percent = config.defaultScrim;
156
+ percent = Math.min(Math.max(percent, 0), 100);
157
+ return "rgba(24,16,64," + (percent / 100) + ")";
158
+ },
159
+
160
+ paint() {
161
+ var root = this.$root;
162
+ var header = root.querySelector("[data-banner-header]");
163
+ var subtext = root.querySelector("[data-banner-subtext]");
164
+ var logo = root.querySelector("[data-banner-logo]");
165
+ var scrim = root.querySelector("[data-banner-scrim]");
166
+
167
+ if (header) header.textContent = this.headerText();
168
+ if (subtext) subtext.textContent = this.form.subtext || "";
169
+ if (scrim) scrim.style.backgroundColor = this.scrimCss();
170
+ if (logo) {
171
+ var url = this.currentLogo();
172
+ if (url) {
173
+ if (logo.getAttribute("src") !== url) logo.setAttribute("src", url);
174
+ logo.style.display = "block";
175
+ } else {
176
+ logo.style.display = "none";
177
+ }
178
+ }
179
+ },
180
+
181
+ // Every visible field is x-model bound and carries no name; the hidden
182
+ // form below mirrors them. One submit writes the whole page.
183
+ save() {
184
+ if (!this.dirty()) return;
185
+ this.$refs.saveForm.requestSubmit();
186
+ }
187
+ };
188
+ }
189
+
190
+ window.emailBannerEditor = editor;
191
+ if (typeof Alpine !== "undefined") {
192
+ Alpine.data("emailBannerEditor", editor);
193
+ } else {
194
+ document.addEventListener("alpine:init", function () {
195
+ Alpine.data("emailBannerEditor", editor);
196
+ });
197
+ }
198
+ })();
199
+ </script>
@@ -0,0 +1,62 @@
1
+ <%# THE banner preview — one component, every size.
2
+
3
+ locals:
4
+ banner: a Studio::Banner (required)
5
+ ratio: the frame's aspect ratio (default: the banner's own)
6
+ max_width: cap the frame in px, or nil to fill its column
7
+ isolate: render inside an iframe (default true)
8
+
9
+ WHY IT IS A COMPONENT
10
+ There were two copies of this — the list row and the email's own page — and
11
+ they had already drifted: one carried the "does this app actually send a
12
+ layered banner" guard and the other did not, so the detail page previewed
13
+ live text over artwork no inbox receives. One component means a fix lands
14
+ everywhere, which is the same argument as rendering through the mailer's own
15
+ partial rather than a lookalike.
16
+
17
+ WHY THE IFRAME
18
+ The banner is the EMAIL's markup — a <table>. Dropping one into a list row
19
+ nests rows inside rows, and every host asserting "one row per email" counted
20
+ three per layered email. A separate document keeps the host page's markup
21
+ contract intact. Pass isolate: false where nesting is not a concern and a
22
+ plain preview is cheaper.
23
+
24
+ WHY THE RESET
25
+ A srcdoc document is a fresh page with the browser's default stylesheet, so
26
+ it inherits `body { margin: 8px }` — which showed as a band of padding around
27
+ every thumbnail. The email itself never sees this: the reset lives here, not
28
+ in the partial the mailer renders.
29
+ %>
30
+ <%
31
+ banner = local_assigns.fetch(:banner)
32
+ ratio = local_assigns.fetch(:ratio, banner.width.to_f / banner.height)
33
+ max_width = local_assigns.fetch(:max_width, Studio::Banner::DEFAULT_WIDTH)
34
+ isolate = local_assigns.fetch(:isolate, true)
35
+
36
+ markup = render("studio/mailers/layered_banner", banner: banner, preview: true)
37
+ document = <<~HTML
38
+ <!doctype html><html><head><meta charset="utf-8">
39
+ <style>html,body{margin:0;padding:0;background:transparent;}</style>
40
+ </head><body>#{markup}</body></html>
41
+ HTML
42
+ frame_style = ["aspect-ratio: #{ratio}"]
43
+ frame_style << "max-width: #{max_width}px" if max_width
44
+ %>
45
+ <div class="rounded-lg overflow-hidden border border-subtle" data-email-banner-frame
46
+ style="<%= frame_style.join("; ") %>;">
47
+ <% if isolate %>
48
+ <iframe class="pointer-events-none block" scrolling="no" tabindex="-1" loading="lazy"
49
+ data-email-banner-preview data-banner-width="<%= banner.width %>"
50
+ title="<%= banner.header.presence || "Email banner" %>"
51
+ style="width:<%= banner.width %>px;height:<%= banner.height %>px;border:0;display:block;transform-origin:top left;"
52
+ <%# ESCAPED EXPLICITLY: render returns an html_safe buffer, so a bare
53
+ output tag injects it RAW and closes the iframe at the banner's
54
+ first quote. to_str drops the safe flag so escaping happens. %>
55
+ srcdoc="<%= ERB::Util.html_escape(document.to_str) %>"></iframe>
56
+ <% else %>
57
+ <div data-email-banner-preview data-banner-width="<%= banner.width %>"
58
+ style="width:<%= banner.width %>px;transform-origin:top left;">
59
+ <%= markup %>
60
+ </div>
61
+ <% end %>
62
+ </div>
@@ -0,0 +1,56 @@
1
+ <%# Scales the 600px email banner down to whatever width its frame has.
2
+
3
+ Why this needs script at all: the banner is the EMAIL's own markup, a fixed
4
+ 600px table, and it must stay that way — an inbox has no responsive layout,
5
+ so making it fluid here would mean previewing markup no one receives. The
6
+ frame beside it is fluid. Nothing in CSS converts a fixed-width box into a
7
+ fluid one without knowing the container's width, so the width is read and a
8
+ transform applied.
9
+
10
+ What it does NOT do: move anything. It sets a scale on a box whose height is
11
+ already reserved by `aspect-ratio`, so no reflow follows and there is no
12
+ paint-time jump — the failure mode of measuring layout in script.
13
+
14
+ Degrades to an unscaled, clipped preview if script never runs. That is the
15
+ behaviour this replaced, so a failure here costs the improvement, not the page.
16
+ %>
17
+ <script>
18
+ (function () {
19
+ function fit(preview) {
20
+ var frame = preview.parentElement;
21
+ if (!frame) return;
22
+ var natural = parseFloat(preview.dataset.bannerWidth) || 600;
23
+ var available = frame.clientWidth;
24
+ if (!available) return;
25
+ // Never scale UP past the real size: at 1:1 this preview is exactly the
26
+ // 600px the recipient sees, and blowing it past that would show the
27
+ // operator a banner larger than any inbox renders.
28
+ var scale = Math.min(available / natural, 1);
29
+ preview.style.transform = "scale(" + scale + ")";
30
+ }
31
+
32
+ function fitAll() {
33
+ document.querySelectorAll("[data-email-banner-preview]").forEach(fit);
34
+ }
35
+
36
+ // ResizeObserver rather than a resize listener: the frame changes width when
37
+ // a sidebar opens or the grid reflows, neither of which resizes the window.
38
+ var observer = typeof ResizeObserver === "function" ? new ResizeObserver(fitAll) : null;
39
+
40
+ function start() {
41
+ fitAll();
42
+ if (!observer) return;
43
+ observer.disconnect();
44
+ document.querySelectorAll("[data-email-banner-frame]").forEach(function (frame) {
45
+ observer.observe(frame);
46
+ });
47
+ }
48
+
49
+ // turbo:load as well as DOMContentLoaded — a Turbo visit swaps the body
50
+ // without firing DOMContentLoaded, which would leave the preview at its
51
+ // unscaled 600px until a full reload.
52
+ document.addEventListener("DOMContentLoaded", start);
53
+ document.addEventListener("turbo:load", start);
54
+ if (document.readyState !== "loading") start();
55
+ })();
56
+ </script>
@@ -0,0 +1,63 @@
1
+ <%# locals: (targets:, target:) — the "preview as" control, shared by the emails
2
+ list and one email's own page.
3
+
4
+ Extracted rather than copied. The list repaints EVERY row's banner and
5
+ subject from this one selection; the detail page repaints one. Two copies of
6
+ an avatar listbox would be two places for the fallback circle to rot, and the
7
+ fallback is the branch that renders for a member with no avatar — the common
8
+ case.
9
+
10
+ A custom listbox rather than a select, because a native option cannot carry
11
+ an image, and the avatar is the whole point: it is how you tell at a glance
12
+ which account you are looking at. %>
13
+ <div class="relative" @keydown.escape="pickerOpen = false" @click.outside="pickerOpen = false">
14
+ <button type="button" @click="pickerOpen = !pickerOpen" data-preview-target
15
+ class="input-field w-full py-2 flex items-center gap-3 text-left"
16
+ :aria-expanded="pickerOpen" aria-haspopup="listbox">
17
+ <template x-if="target().avatar_url">
18
+ <img :src="target().avatar_url" alt="" data-target-avatar
19
+ class="w-8 h-8 rounded-full object-cover shrink-0">
20
+ </template>
21
+ <template x-if="!target().avatar_url">
22
+ <span data-target-initials
23
+ class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0"
24
+ :style="'background-color: ' + (target().avatar_color || '#6b7280')"
25
+ x-text="target().initials"></span>
26
+ </template>
27
+ <span class="min-w-0">
28
+ <span class="block text-heading truncate" x-text="target().name || target().label"></span>
29
+ <span class="block text-2xs text-muted truncate" x-text="target().email"></span>
30
+ </span>
31
+ <svg class="w-4 h-4 text-muted ml-auto shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
32
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
33
+ </svg>
34
+ </button>
35
+
36
+ <ul x-show="pickerOpen" x-cloak role="listbox"
37
+ class="absolute z-20 mt-1 w-full rounded-lg border border-subtle bg-surface shadow-lg overflow-hidden">
38
+ <template x-for="option in targets" :key="option.id">
39
+ <li>
40
+ <button type="button" role="option" data-preview-option :data-option-id="option.id"
41
+ @click="targetId = option.id; pickerOpen = false"
42
+ :aria-selected="option.id === targetId"
43
+ class="w-full px-3 py-2 flex items-center gap-3 text-left hover:bg-inset"
44
+ :class="option.id === targetId ? 'bg-inset' : ''">
45
+ <template x-if="option.avatar_url">
46
+ <img :src="option.avatar_url" alt="" data-option-avatar
47
+ class="w-8 h-8 rounded-full object-cover shrink-0">
48
+ </template>
49
+ <template x-if="!option.avatar_url">
50
+ <span data-option-initials
51
+ class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0"
52
+ :style="'background-color: ' + (option.avatar_color || '#6b7280')"
53
+ x-text="option.initials"></span>
54
+ </template>
55
+ <span class="min-w-0">
56
+ <span class="block text-heading truncate" x-text="option.name || option.label"></span>
57
+ <span class="block text-2xs text-muted truncate" x-text="option.email"></span>
58
+ </span>
59
+ </button>
60
+ </li>
61
+ </template>
62
+ </ul>
63
+ </div>