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
@@ -2,14 +2,52 @@
2
2
 
3
3
  A bare content wrapper like the index, so it renders inside each host's
4
4
  layout. Lifted from turf-monster's admin/emails/show (the prior art this
5
- work folds into the engine) and given the banner half: the same iframe over
6
- the real rendered email, plus what artwork is riding on top of it and where
7
- that artwork came from. %>
5
+ work folds into the engine) and given the banner half.
6
+
7
+ LAYOUT, AND WHY IT IS PAIRED
8
+ Two rows of two. Row one is WHO and WHAT SHOWS IN AN INBOX LIST: the subject
9
+ beside the person receiving it. Row two is WHAT THEY SEE: the two pictures
10
+ stacked on the left, the words that ride on them on the right. Every control
11
+ sits beside the thing it changes, and typing in one repaints the other with
12
+ no round trip.
13
+
14
+ NOTE: never write an ERB delimiter inside a comment like this one, in any
15
+ form. A comment ends at the first closing delimiter it contains, escaped or
16
+ not, and the remainder lands on the page as visible text. That shipped here
17
+ once. Name tags in prose instead of typing them. %>
8
18
  <% content_for(:title) { @entry.label } %>
9
19
  <%
10
20
  source = Studio::EmailCatalog.source(@entry.key)
11
21
  banner_url = Studio::EmailCatalog.preview_url(@entry.key)
12
22
  aspect = @entry.ratio
23
+ max_width = Studio::EmailCatalog::MAX_WIDTH
24
+ card_width = Studio::Banner::DEFAULT_WIDTH
25
+
26
+ saved = {
27
+ header: Studio::EmailCatalog.header_template(@entry.key).to_s,
28
+ header_fallback: Studio::EmailCatalog.header_fallback(@entry.key).to_s,
29
+ subtext: Studio::EmailCatalog.subtext(@entry.key).to_s,
30
+ subject: (Studio::EmailSetting.copy_for(@entry.key, :subject) || @entry.subject).to_s,
31
+ logo_url: Studio::EmailSetting.copy_for(@entry.key, :logo_url).to_s,
32
+ hide_logo: Studio::EmailSetting.hide_logo?(@entry.key),
33
+ scrim_percent: Studio::EmailCatalog.scrim_percent(@entry.key).to_s
34
+ }
35
+
36
+ editor_config = {
37
+ values: saved,
38
+ targetId: @target&.id,
39
+ inheritedLogo: Studio::EmailCatalog.logo_url(@entry.key).to_s,
40
+ uploadedLogo: Studio::EmailCatalog.uploaded_logo_url(@entry.key).to_s,
41
+ defaultScrim: (Studio::Banner::DEFAULT_SCRIM * 100).round,
42
+ appName: Studio.app_name.to_s,
43
+ # The avatar fields are part of this payload, not an afterthought: the picker
44
+ # renders entirely from it, so a field omitted here is a grey circle with no
45
+ # letter in it — which is exactly what shipped.
46
+ targets: (@targets || []).map do |t|
47
+ { id: t.id, label: t.label, name: t.name, email: t.email, admin: t.admin?,
48
+ avatar_url: t.avatar_url, avatar_color: t.avatar_color, initials: t.initials }
49
+ end
50
+ }
13
51
 
14
52
  badge_class, badge_label =
15
53
  case source
@@ -19,11 +57,28 @@
19
57
  else ["badge bg-warning/10 text-warning border-warning/30", "No image"]
20
58
  end
21
59
  %>
22
- <div class="max-w-5xl mx-auto px-4 pb-16">
60
+ <div class="max-w-5xl mx-auto px-4 pb-16"
61
+ x-data="emailBannerEditor(<%= editor_config.to_json %>)">
23
62
  <header class="pt-8 pb-5">
24
63
  <%= link_to "← All emails", admin_emails_path,
25
64
  class: "text-sm text-muted hover:text-heading underline underline-offset-2" %>
26
- <h1 class="text-3xl font-bold text-heading mt-2"><%= @entry.label %></h1>
65
+
66
+ <%# ONE save for the whole page, on the title's own row so it reads as
67
+ acting on this email rather than on the back-link above it. Shown only
68
+ once something has been typed, and disabled whenever the form matches
69
+ what is saved — so typing "A" and deleting it leaves a visible, disabled
70
+ button rather than an enabled one that would write nothing. %>
71
+ <div class="flex items-center justify-between gap-4 mt-2">
72
+ <h1 class="text-3xl font-bold text-heading"><%= @entry.label %></h1>
73
+ <div class="flex items-center gap-3 shrink-0">
74
+ <span class="text-2xs text-muted" x-show="touched && !dirty()" x-cloak>No changes</span>
75
+ <button type="button" @click="save()" x-show="touched" x-cloak
76
+ :disabled="!dirty()" data-save-all
77
+ :class="dirty() ? 'btn btn-primary btn-sm' : 'btn btn-primary btn-sm opacity-50 cursor-not-allowed'">
78
+ Save changes
79
+ </button>
80
+ </div>
81
+ </div>
27
82
 
28
83
  <div class="flex flex-wrap items-center gap-2 mt-3">
29
84
  <span class="badge <%= @entry.marketing? ? "bg-primary/10 text-primary border-primary/30" : "bg-inset text-muted border-subtle" %> whitespace-nowrap">
@@ -36,41 +91,256 @@
36
91
  <% if @entry.description.present? %>
37
92
  <p class="text-body max-w-2xl mt-3"><%= @entry.description %></p>
38
93
  <% end %>
94
+ </header>
39
95
 
40
- <% if @subject.present? %>
41
- <p class="text-sm text-muted mt-2">
42
- Subject: <strong class="text-heading"><%= @subject %></strong>
96
+ <%# ROW ONE — the subject line, and who is receiving it. %>
97
+ <div class="grid gap-6 md:grid-cols-2 mb-6">
98
+ <section class="card p-4">
99
+ <p class="label-upper mb-3">Example</p>
100
+ <%# The rendered subject sits with the person it is rendered FOR. It moved
101
+ out of the Subject card because what it shows depends on who is
102
+ selected below, not on the template above. %>
103
+ <p class="text-sm text-muted mb-3">
104
+ Sends as: <strong class="text-heading" x-text="subjectText()"></strong>
43
105
  </p>
44
- <% end %>
45
- </header>
106
+ <% if editor_config[:targets].any? %>
107
+ <%# A custom listbox rather than a <select>: a native option cannot carry
108
+ an image, and the avatar is the whole reason this exists — it is how
109
+ you tell at a glance which account you are looking at. Keyboard and
110
+ click-away are handled; the value lives in Alpine state, and the
111
+ hidden form carries it. %>
112
+ <div class="relative" @keydown.escape="pickerOpen = false" @click.outside="pickerOpen = false">
113
+ <button type="button" @click="pickerOpen = !pickerOpen" data-preview-target
114
+ class="input-field w-full py-2 flex items-center gap-3 text-left"
115
+ :aria-expanded="pickerOpen" aria-haspopup="listbox">
116
+ <template x-if="target().avatar_url">
117
+ <img :src="target().avatar_url" alt="" class="w-8 h-8 rounded-full object-cover shrink-0">
118
+ </template>
119
+ <template x-if="!target().avatar_url">
120
+ <span class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0"
121
+ :style="'background-color: ' + (target().avatar_color || '#6b7280')"
122
+ x-text="target().initials"></span>
123
+ </template>
124
+ <span class="min-w-0">
125
+ <span class="block text-heading truncate" x-text="target().name || target().label"></span>
126
+ <span class="block text-2xs text-muted truncate" x-text="target().email"></span>
127
+ </span>
128
+ <svg class="w-4 h-4 text-muted ml-auto shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
129
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
130
+ </svg>
131
+ </button>
46
132
 
47
- <%# The banner riding on top of this email, and where it came from. %>
48
- <section class="card p-4 mb-6">
49
- <p class="label-upper mb-3">Banner</p>
50
- <div class="rounded-lg overflow-hidden border border-subtle max-w-md"
51
- style="aspect-ratio: <%= aspect %>; background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));">
52
- <% if banner_url %>
53
- <%= image_tag banner_url, class: "w-full h-full object-cover",
54
- alt: "#{@entry.label} email banner" %>
55
- <% else %>
56
- <div class="w-full h-full flex items-center justify-center text-xs text-white/80 text-center px-4">
57
- This email sends without a banner.
133
+ <ul x-show="pickerOpen" x-cloak role="listbox"
134
+ class="absolute z-20 mt-1 w-full rounded-lg border border-subtle bg-surface shadow-lg overflow-hidden">
135
+ <template x-for="option in targets" :key="option.id">
136
+ <li>
137
+ <button type="button" role="option" data-preview-option
138
+ @click="targetId = option.id; pickerOpen = false"
139
+ :aria-selected="option.id === targetId"
140
+ class="w-full px-3 py-2 flex items-center gap-3 text-left hover:bg-inset"
141
+ :class="option.id === targetId ? 'bg-inset' : ''">
142
+ <template x-if="option.avatar_url">
143
+ <img :src="option.avatar_url" alt="" class="w-8 h-8 rounded-full object-cover shrink-0">
144
+ </template>
145
+ <template x-if="!option.avatar_url">
146
+ <span class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0"
147
+ :style="'background-color: ' + (option.avatar_color || '#6b7280')"
148
+ x-text="option.initials"></span>
149
+ </template>
150
+ <span class="min-w-0">
151
+ <span class="block text-heading truncate" x-text="option.name || option.label"></span>
152
+ <span class="block text-2xs text-muted truncate" x-text="option.email"></span>
153
+ </span>
154
+ </button>
155
+ </li>
156
+ </template>
157
+ </ul>
58
158
  </div>
159
+ <% else %>
160
+ <p class="text-sm text-muted">This app has no users to preview as yet.</p>
59
161
  <% end %>
60
- </div>
61
- <p class="text-sm text-muted mt-3">
162
+ </section>
163
+
164
+ <section class="card p-4">
165
+ <p class="label-upper mb-2">Subject</p>
166
+ <p class="text-sm text-body mb-3">
167
+ Variables:
168
+ <code class="font-mono text-2xs px-1 py-0.5 rounded bg-inset">{name}</code>
169
+ <code class="font-mono text-2xs px-1 py-0.5 rounded bg-inset">{app}</code>
170
+ </p>
171
+ <%= text_field_tag :subject, saved[:subject], id: "subject", name: nil,
172
+ "x-model": "form.subject", class: "input-field w-full py-2" %>
173
+ </section>
174
+ </div>
175
+
176
+ <%# ROW TWO — the pictures, and the words that ride on them. %>
177
+ <div class="grid gap-6 md:grid-cols-2 mb-6">
178
+ <section class="card p-4"
62
179
  <% if @uploads_available %>
63
- Change it on the <%= link_to "emails list", admin_emails_path, class: "underline underline-offset-2" %>.
180
+ x-data="imageUploadHost({
181
+ store: 'emailModals',
182
+ aspectRatio: <%= aspect %>,
183
+ maxWidth: <%= max_width %>,
184
+ transparent: false,
185
+ allowGifs: true,
186
+ filename: '<%= @entry.key %>.png',
187
+ saving: 'Saving banner…',
188
+ success: 'Banner updated',
189
+ successMessage: '<%= j @entry.label %> now uses this app\'s own image.',
190
+ failure: 'Couldn\'t save the banner'
191
+ })"
192
+ @crop-photo-confirmed.window="onCropConfirmed($event.detail)"
193
+ <% end %>>
194
+ <p class="label-upper mb-3">Banner</p>
195
+ <% if @banner %>
196
+ <div class="rounded-lg overflow-hidden border border-subtle"
197
+ style="aspect-ratio: <%= aspect %>; max-width: <%= card_width %>px;" data-email-banner-frame>
198
+ <div data-email-banner-preview data-banner-width="<%= card_width %>"
199
+ style="width:<%= card_width %>px;transform-origin:top left;">
200
+ <%= render "studio/mailers/layered_banner", banner: @banner, preview: true %>
201
+ </div>
202
+ </div>
64
203
  <% else %>
65
- <%= Studio.app_name %> has no object storage configured, so this image can be
66
- viewed but not replaced yet.
204
+ <div class="rounded-lg overflow-hidden border border-subtle flex items-center justify-center text-xs text-muted text-center px-4"
205
+ style="aspect-ratio: <%= aspect %>; max-width: <%= card_width %>px;">
206
+ This email has no layered banner, so it sends the artwork as a plain image.
207
+ </div>
67
208
  <% end %>
68
- </p>
69
- </section>
70
209
 
71
- <%# The email itself. An iframe because the response IS an email document —
72
- its own <html>, its own table layout, and no business inheriting the admin
73
- page's stylesheet. %>
210
+ <p class="label-upper mb-3 mt-6">Artwork</p>
211
+ <%# The picture ALONE the file an upload replaces. Hovering reveals the way
212
+ to change it, so the control sits on the thing it changes instead of in a
213
+ sentence pointing at another page. %>
214
+ <div class="relative group rounded-lg overflow-hidden border border-subtle" data-email-artwork-frame
215
+ style="aspect-ratio: <%= aspect %>; max-width: <%= card_width %>px; background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));">
216
+ <% if banner_url %>
217
+ <%= image_tag banner_url, class: "w-full h-full object-cover",
218
+ alt: "#{@entry.label} email artwork" %>
219
+ <% else %>
220
+ <div class="w-full h-full flex items-center justify-center text-xs text-white/80 text-center px-4">
221
+ This email sends without a banner.
222
+ </div>
223
+ <% end %>
224
+ <% if @uploads_available %>
225
+ <button type="button" @click="open()" data-modify-artwork
226
+ class="absolute inset-0 w-full h-full flex items-center justify-center gap-2
227
+ bg-black/55 text-white text-sm font-semibold opacity-0
228
+ group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150">
229
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
230
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
231
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
232
+ </svg>
233
+ Modify image
234
+ </button>
235
+ <% end %>
236
+ </div>
237
+ <% if @uploads_available %>
238
+ <%# The upload host writes the chosen file into THIS input and submits it.
239
+ Without the pair, imageUploadHost has no $refs.fileInput and the whole
240
+ flow dies at "Cannot set properties of undefined" — after the modal has
241
+ already closed, so it reads as a silent no-op. %>
242
+ <%= form_with url: admin_email_path(@entry.key), method: :patch,
243
+ html: { multipart: true, id: "email-banner-form-#{@entry.key.tr("_", "-")}", "x-ref": "form" } do %>
244
+ <%= file_field_tag :image, class: "hidden", "x-ref": "fileInput" %>
245
+ <% end %>
246
+ <% end %>
247
+ <p class="text-2xs text-muted mt-2">
248
+ <% if @uploads_available %>
249
+ GIFs keep their animation. Displayed at <%= card_width %>x<%= (card_width / aspect).round %>.
250
+ <% else %>
251
+ <%= Studio.app_name %> has no object storage configured, so this image can be
252
+ viewed but not replaced yet.
253
+ <% end %>
254
+ </p>
255
+
256
+ <%# The same artwork with the tint, the words and the logo on it — rendered
257
+ by the mailer's own partial, so it cannot drift from the email. The email
258
+ table is a fixed 600px because an inbox has no responsive layout; it is
259
+ scaled to this frame, and both frames are capped at 600 so they match at
260
+ every width. %>
261
+ </section>
262
+ <section class="card p-4">
263
+ <%# No name attributes and no form here. Every field is Alpine state, and
264
+ the hidden form at the bottom of the page mirrors all of it — one Save
265
+ writes the subject, the words, the logo and the tint together. %>
266
+ <div class="space-y-4">
267
+ <div>
268
+ <label class="text-sm text-muted block mb-1" for="header">Header</label>
269
+ <%= text_field_tag :header, saved[:header], id: "header", name: nil,
270
+ "x-model": "form.header", class: "input-field w-full py-2" %>
271
+ </div>
272
+ <div>
273
+ <label class="text-sm text-muted block mb-1" for="header_fallback">
274
+ Header for someone with no name on file
275
+ </label>
276
+ <%= text_field_tag :header_fallback, saved[:header_fallback], id: "header_fallback", name: nil,
277
+ "x-model": "form.header_fallback", class: "input-field w-full py-2" %>
278
+ <p class="text-2xs text-muted mt-1">
279
+ Used instead of the header above when we hold no name — a magic link is
280
+ often the first thing a stranger receives, and "Welcome !" is the
281
+ alternative.
282
+ </p>
283
+ </div>
284
+ <div>
285
+ <label class="text-sm text-muted block mb-1" for="subtext">Sub-text</label>
286
+ <%= text_field_tag :subtext, saved[:subtext], id: "subtext", name: nil,
287
+ "x-model": "form.subtext", class: "input-field w-full py-2" %>
288
+ </div>
289
+
290
+ <%# THE LOGO — three states, because a URL box can only express two.
291
+ Standard inherits the shared mark, Custom is what this app uploaded,
292
+ Hidden is the deliberate "no logo" that blank cannot say. %>
293
+ <div>
294
+ <span class="text-sm text-muted block mb-1">Logo</span>
295
+ <div class="flex flex-wrap items-center gap-2">
296
+ <button type="button" @click="setLogoMode('standard')" data-logo-standard
297
+ :class="logoMode() === 'standard' ? 'btn btn-primary btn-sm' : 'btn btn-outline btn-sm'">
298
+ Standard
299
+ </button>
300
+ <% if @uploads_available %>
301
+ <button type="button" @click="$dispatch('open-logo-upload')" data-logo-upload
302
+ :class="logoMode() === 'custom' ? 'btn btn-primary btn-sm' : 'btn btn-outline btn-sm'">
303
+ Upload new
304
+ </button>
305
+ <% end %>
306
+ <button type="button" @click="setLogoMode('hidden')" data-logo-hidden
307
+ :class="logoMode() === 'hidden' ? 'btn btn-primary btn-sm' : 'btn btn-outline btn-sm'">
308
+ None
309
+ </button>
310
+ </div>
311
+ <% if Studio::EmailCatalog.uploaded_logo_url(@entry.key).present? %>
312
+ <%= button_to "Back to the standard logo", admin_email_logo_path(@entry.key),
313
+ method: :patch, params: { remove: "1" },
314
+ class: "text-2xs text-muted underline underline-offset-2 mt-2" %>
315
+ <% end %>
316
+ </div>
317
+
318
+ <%# THE TINT lives here, beside the words it makes legible, and repaints
319
+ the banner as it changes. It was its own card with its own Save
320
+ button; that put the one control that trades legibility against
321
+ picture in a different place from the text it is protecting. %>
322
+ <div>
323
+ <label class="text-sm text-muted block mb-1" for="scrim_percent">
324
+ Tint — how far the artwork is darkened behind the text
325
+ </label>
326
+ <div class="flex items-center gap-3">
327
+ <input type="range" min="0" max="100" step="1" id="scrim_percent" data-scrim-range
328
+ x-model="form.scrim_percent" class="studio-range flex-1"
329
+ :style="'--range-fill: ' + scrimFill()">
330
+ <span class="font-mono text-2xs text-heading w-12 text-right"
331
+ x-text="(form.scrim_percent === '' ? <%= (Studio::Banner::DEFAULT_SCRIM * 100).round %> : form.scrim_percent) + '%'"></span>
332
+ </div>
333
+ <p class="text-2xs text-muted mt-1">
334
+ More tint means a more legible greeting; less means a brighter picture.
335
+ </p>
336
+ </div>
337
+ </div>
338
+ </section>
339
+ </div>
340
+
341
+ <%# The email itself. An iframe because the response IS an email document — its
342
+ own document shell, its own table layout, and no business inheriting the
343
+ admin page's stylesheet. %>
74
344
  <section class="card p-3 bg-inset">
75
345
  <p class="label-upper mb-3 px-1">Preview</p>
76
346
  <% if @entry.previewable? %>
@@ -89,4 +359,67 @@
89
359
  <p class="text-2xs text-danger mt-2 px-1 font-mono break-all"><%= @preview_error %></p>
90
360
  <% end %>
91
361
  </section>
362
+
363
+ <%# THE ONE WRITE. Every visible control above is Alpine state with no name
364
+ attribute; these hidden inputs mirror that state and this is the form the
365
+ header's Save submits. Done this way because a single <form> cannot wrap
366
+ the page — the artwork uploader is itself a form, and nesting forms is
367
+ invalid HTML that browsers silently repair by dropping one. %>
368
+ <%= form_with url: admin_email_copy_path(@entry.key), method: :patch,
369
+ html: { "x-ref": "saveForm", class: "hidden" } do %>
370
+ <input type="hidden" name="subject" :value="form.subject">
371
+ <input type="hidden" name="header" :value="form.header">
372
+ <input type="hidden" name="header_fallback" :value="form.header_fallback">
373
+ <input type="hidden" name="subtext" :value="form.subtext">
374
+ <input type="hidden" name="logo_url" :value="form.logo_url">
375
+ <input type="hidden" name="hide_logo" :value="form.hide_logo ? '1' : ''">
376
+ <input type="hidden" name="scrim_percent" :value="form.scrim_percent">
377
+ <% end %>
378
+
379
+ <%= render "studio/emails/banner_scale" %>
380
+ <%= render "studio/emails/banner_editor" %>
381
+
382
+ <% if @uploads_available %>
383
+ <%# Cropper + the page-scoped modal host, exactly as the index mounts them:
384
+ scoped_host rather than modals/host, because mcritchie-studio and
385
+ turf-monster both ship app copies of modals/host that shadow the engine's
386
+ in this non-isolated engine.
387
+
388
+ Optional chaining on current() is load-bearing: the outer template
389
+ unmounts one tick AFTER the stack empties, so a bare .id throws on every
390
+ modal close. %>
391
+ <%# The LOGO uploader — a second host, listening for its own event. Separate
392
+ from the artwork host because the two want different crops: artwork is
393
+ 3:1 and may be an animated GIF, a logo is a small transparent mark that
394
+ should keep its alpha and be cropped freely. %>
395
+ <div x-data="imageUploadHost({
396
+ store: 'emailModals',
397
+ aspectRatio: 0,
398
+ maxWidth: 600,
399
+ transparent: true,
400
+ allowGifs: false,
401
+ filename: '<%= @entry.key %>-logo.png',
402
+ saving: 'Saving logo…',
403
+ success: 'Logo updated',
404
+ successMessage: 'This email now uses its own logo.',
405
+ failure: 'Couldn\'t save the logo'
406
+ })"
407
+ @open-logo-upload.window="open()"
408
+ @crop-photo-confirmed.window="onCropConfirmed($event.detail)">
409
+ <%= form_with url: admin_email_logo_path(@entry.key), method: :patch,
410
+ html: { multipart: true, "x-ref": "form", class: "hidden" } do %>
411
+ <%= file_field_tag :image, class: "hidden", "x-ref": "fileInput" %>
412
+ <% end %>
413
+ </div>
414
+
415
+ <%= render "studio/cropper_assets" %>
416
+ <%= render "studio/modals/scoped_host", store: "emailModals" do %>
417
+ <template x-if="$store.emailModals.current()?.id === 'crop-photo'">
418
+ <div><%= render "studio/modals/crop_photo", store: "emailModals" %></div>
419
+ </template>
420
+ <template x-if="$store.emailModals.current()?.id === 'saving'">
421
+ <div><%= render "studio/modals/saving", store: "emailModals" %></div>
422
+ </template>
423
+ <% end %>
424
+ <% end %>
92
425
  </div>
@@ -0,0 +1,136 @@
1
+ <%#
2
+ The layered email banner: background image, live text on top.
3
+
4
+ locals: (banner:) — a Studio::Banner
5
+
6
+ The markup is deliberately old-fashioned. Outlook on Windows renders through
7
+ Word and ignores `background-image` on nearly everything, so the picture is
8
+ carried three ways at once:
9
+
10
+ * the `background` ATTRIBUTE on the td, which the widest set of clients honour
11
+ * the CSS `background-image`, for the ones that prefer it
12
+ * a VML `v:rect` / `v:fill` inside a `gte mso 9` conditional, which is the
13
+ only thing that reaches Outlook. Every other client never sees it.
14
+
15
+ `bgcolor` is the floor: if the image is blocked or still loading, the cell is a
16
+ brand colour with legible text on it rather than white-on-white.
17
+
18
+ The scrim is a coloured overlay cell between artwork and type. Background art
19
+ is chosen to look good, not to guarantee contrast, and white text on a pale
20
+ sky cannot be read. Set scrim: 0 on artwork dark enough to carry the type.
21
+ %>
22
+ <%
23
+ banner = local_assigns.fetch(:banner)
24
+
25
+ # PREVIEW HOOKS, off by default.
26
+ #
27
+ # The admin page edits this banner's words and repaints it as you type, which
28
+ # needs handles on the three text nodes. They are opt-in because this partial
29
+ # renders REAL EMAIL: shipping data- attributes into every inbox to serve an
30
+ # admin screen is the kind of thing that is invisible until a mail client
31
+ # chokes on it. With preview off the markup is byte-for-byte what it was, and
32
+ # a test asserts exactly that.
33
+ preview = local_assigns.fetch(:preview, false)
34
+ hook = ->(name) { preview ? %( data-banner-#{name}).html_safe : "" }
35
+
36
+ # TYPE AND RHYTHM SCALE WITH THE BANNER.
37
+ #
38
+ # Everything below is a proportion of the height, not a fixed pixel size.
39
+ # Hardcoding 42px type looked right at 300px tall and OVERFLOWED at 200 —
40
+ # the box shrank, the words did not, and the banner rendered 238px with the
41
+ # header clipped at both edges. Proportions mean the next height change is a
42
+ # one-line edit rather than a re-tune.
43
+ h = banner.height
44
+
45
+ header_size = (h * 0.15).round # 30px at 200, 45 at 300
46
+ header_line = (h * 0.17).round
47
+ subtext_size = (h * 0.075).round # 15px at 200
48
+ subtext_line = (h * 0.10).round
49
+ logo_width = (h * 0.60).round # 120px at 200
50
+ side_padding = (banner.width * 0.05).round
51
+
52
+ # The block is vertically centred, so the gap between sub-text and logo is
53
+ # what pushes the header toward the top edge and the logo toward the bottom.
54
+ #
55
+ # It has to ADAPT to the greeting. A gap that fills the banner for "Welcome
56
+ # Alex!" leaves "Welcome Bartholomew Fitzgerald-Montgomery!" clipped, because
57
+ # a wrapped header is a whole line taller while the box is not. The line
58
+ # estimate is deliberately crude — it only has to answer "one line or more",
59
+ # client fonts differ anyway, and being wrong costs breathing room rather
60
+ # than a broken banner.
61
+ gap_after_header = (h * 0.02).round
62
+ chars_per_line = 21
63
+ header_lines = [(banner.header.to_s.length / chars_per_line.to_f).ceil, 1].max
64
+ gap_before_logo = header_lines > 1 ? (h * 0.18).round : (h * 0.35).round
65
+ %>
66
+ <table role="presentation" width="<%= banner.width %>" cellpadding="0" cellspacing="0" border="0"
67
+ style="width:<%= banner.width %>px;border-collapse:collapse;">
68
+ <tr>
69
+ <td <%= %(background="#{banner.background_url}").html_safe if banner.background_url.present? %>
70
+ bgcolor="<%= Studio.theme_primary %>"
71
+ width="<%= banner.width %>" height="<%= banner.height %>" valign="middle"
72
+ style="width:<%= banner.width %>px;height:<%= banner.height %>px;<%= "background-image:url(#{banner.background_url});background-size:cover;background-position:center;background-repeat:no-repeat;" if banner.background_url.present? %>">
73
+ <% if banner.background_url.present? %>
74
+ <!--[if gte mso 9]>
75
+ <v:rect xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false"
76
+ style="width:<%= banner.width %>px;height:<%= banner.height %>px;">
77
+ <v:fill type="frame" src="<%= banner.background_url %>" color="<%= Studio.theme_primary %>" />
78
+ <v:textbox inset="0,0,0,0">
79
+ <![endif]-->
80
+ <% end %>
81
+ <div>
82
+ <table role="presentation" width="<%= banner.width %>" cellpadding="0" cellspacing="0" border="0"
83
+ style="width:<%= banner.width %>px;">
84
+ <tr>
85
+ <%# VERTICAL PADDING IS ZERO ON PURPOSE.
86
+ A table cell's padding ADDS to its declared height, so
87
+ height:300 plus 26px top and bottom rendered a 352px banner.
88
+ Dropping the height instead shrank the scrim to a band across
89
+ the middle, because the tinted cell then only covered its own
90
+ content. Full height with horizontal-only padding gives both:
91
+ exactly 300px, and a wash over the whole picture. valign does
92
+ the vertical centring that the padding was doing. %>
93
+ <td align="center" valign="middle" height="<%= banner.height %>"<%= hook.call("scrim") %>
94
+ <%# bgcolor is the OUTLOOK path and the CSS below is everywhere
95
+ else. Word ignores rgba(), so without the attribute the wash
96
+ vanishes there and white text sits on bare artwork — the exact
97
+ contrast case the scrim exists for, in the one client that
98
+ cannot be spot-checked. Every other client honours the rgba
99
+ declaration and overrides the flat attribute. %>
100
+ <%= %(bgcolor="#{banner.scrim_solid_hex}").html_safe if banner.scrim_opacity.positive? %>
101
+ style="height:<%= banner.height %>px;padding:0 <%= side_padding %>px;<%= "background-color:rgba(24,16,64,#{banner.scrim_opacity});" if banner.scrim_opacity.positive? %>">
102
+ <% if preview || banner.header.present? %>
103
+ <p<%= hook.call("header") %> style="margin:0 0 <%= gap_after_header %>px;font-family:Montserrat,'Segoe UI',Helvetica,Arial,sans-serif;font-size:<%= header_size %>px;line-height:<%= header_line %>px;font-weight:700;color:#ffffff;">
104
+ <%= banner.header %>
105
+ </p>
106
+ <% end %>
107
+ <% if preview || banner.subtext.present? %>
108
+ <p<%= hook.call("subtext") %> style="margin:0 0 <%= gap_before_logo %>px;font-family:Montserrat,'Segoe UI',Helvetica,Arial,sans-serif;font-size:<%= subtext_size %>px;line-height:<%= subtext_line %>px;color:#efeaff;">
109
+ <%= banner.subtext %>
110
+ </p>
111
+ <% end %>
112
+ <% if banner.logo_url.present? %>
113
+ <%= image_tag banner.logo_url, width: logo_width, alt: banner.logo_alt.to_s,
114
+ data: (preview ? { banner_logo: true } : {}),
115
+ style: "display:block;margin:0 auto;width:#{logo_width}px;height:auto;border:0;" %>
116
+ <% elsif preview %>
117
+ <%# A hidden placeholder so ticking "hide the logo" and unticking it
118
+ again repaints without a round trip. Preview only — an email
119
+ with no logo ships no img tag at all. %>
120
+ <img data-banner-logo width="<%= logo_width %>" alt=""
121
+ src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
122
+ style="display:none;margin:0 auto;width:<%= logo_width %>px;height:auto;border:0;">
123
+ <% end %>
124
+ </td>
125
+ </tr>
126
+ </table>
127
+ </div>
128
+ <% if banner.background_url.present? %>
129
+ <!--[if gte mso 9]>
130
+ </v:textbox>
131
+ </v:rect>
132
+ <![endif]-->
133
+ <% end %>
134
+ </td>
135
+ </tr>
136
+ </table>