studio-engine 0.37.0 → 0.39.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 +4 -4
- data/CHANGELOG.md +93 -2
- data/README.md +53 -23
- data/app/controllers/studio/email_images_controller.rb +4 -4
- data/app/controllers/studio/emails_controller.rb +55 -11
- data/app/mailers/user_mailer.rb +1 -1
- data/app/models/image_cache.rb +1 -1
- data/app/services/studio/email_catalog.rb +450 -0
- data/app/services/studio/email_image.rb +56 -317
- data/app/views/layouts/_navbar.html.erb +9 -7
- data/app/views/layouts/branded_mailer.html.erb +1 -1
- data/app/views/studio/banners/_app_banner.html.erb +6 -3
- data/app/views/studio/banners/_stack.html.erb +34 -50
- data/app/views/studio/email_images/index.html.erb +2 -2
- data/app/views/studio/emails/_row.html.erb +8 -3
- data/app/views/studio/emails/index.html.erb +4 -4
- data/app/views/studio/emails/show.html.erb +91 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +10 -3
- metadata +3 -1
|
@@ -1,329 +1,68 @@
|
|
|
1
1
|
module Studio
|
|
2
|
-
#
|
|
3
|
-
#
|
|
2
|
+
# DEPRECATED NAME — this is Studio::EmailCatalog now. Every method here just
|
|
3
|
+
# forwards; there is no behavior in this file.
|
|
4
4
|
#
|
|
5
|
-
#
|
|
6
|
-
# a line of description. The only real asset is its banner image, which is why
|
|
7
|
-
# the registry lives here rather than in a model. The branded mailer resolves
|
|
8
|
-
# the live banner with .url; /admin/emails lists the registry and writes an
|
|
9
|
-
# override with .store.
|
|
5
|
+
# The METHOD surface is kept complete, for two reasons:
|
|
10
6
|
#
|
|
11
|
-
#
|
|
7
|
+
# 1. Engine 0.33 shipped this module as the registry's public name, so any
|
|
8
|
+
# app already on 0.33 is calling it.
|
|
9
|
+
# 2. consumer-ci.yml runs each consumer's DEFAULT BRANCH suite against an
|
|
10
|
+
# engine PR, and mcritchie-studio's test/integration/studio_email_image_test.rb
|
|
11
|
+
# on `main` calls .url, .store and .record directly. Dropping any of them
|
|
12
|
+
# reddens that lane from the moment the PR opens, and nothing inside the
|
|
13
|
+
# engine PR can reach the consumer's main to fix it.
|
|
12
14
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
15
|
+
# ONE thing did not survive: the `VARIANTS` CONSTANT. It was a frozen literal
|
|
16
|
+
# hash, and the registry it stood for is now built at runtime, so a constant
|
|
17
|
+
# cannot express it. The `variants` METHOD returns the same key => label shape
|
|
18
|
+
# and is the supported replacement. Checked before dropping it: no consumer's
|
|
19
|
+
# `main` names the constant — only .url, .store and .record — so consumer CI
|
|
20
|
+
# stays green. A host that did reference it gets a NameError, not a silent
|
|
21
|
+
# wrong answer.
|
|
16
22
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# adopts. See the note on #url; getting this wrong swaps a host's committed
|
|
20
|
-
# artwork for the engine placeholder in live email.
|
|
23
|
+
# Delete it once no consumer's main names it — the same staged retirement
|
|
24
|
+
# /admin/email_images is on.
|
|
21
25
|
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
# permission. Uploading on an app's /admin/emails writes to THAT app's bucket
|
|
25
|
-
# and THAT app's ImageCache row — which is exactly "the asset now belongs to
|
|
26
|
-
# this app". Every app has its own bucket and its own image_caches table, so an
|
|
27
|
-
# override never leaks between apps.
|
|
28
|
-
#
|
|
29
|
-
# ## Registering
|
|
30
|
-
#
|
|
31
|
-
# The engine pre-registers the two every Studio app sends (STANDARD below), so
|
|
32
|
-
# hosts inherit them without declaring anything. A host adds its own workflows
|
|
33
|
-
# from an initializer, mirroring Studio::ModelPage.register:
|
|
34
|
-
#
|
|
35
|
-
# # config/initializers/studio_emails.rb
|
|
36
|
-
# Rails.application.config.to_prepare do
|
|
37
|
-
# Studio::EmailImage.register("winnings", label: "Contest winnings",
|
|
38
|
-
# description: "Sent when a player wins a contest.")
|
|
39
|
-
# end
|
|
40
|
-
#
|
|
41
|
-
# Re-registering a key updates it in place and keeps its position, so a host
|
|
42
|
-
# can relabel an inherited email without reordering the page.
|
|
26
|
+
# Deliberately explicit rather than method_missing: a typo should still raise
|
|
27
|
+
# NoMethodError here, and the delegated surface should be readable as a list.
|
|
43
28
|
module EmailImage
|
|
44
|
-
PURPOSE =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
# (resolved through the host's asset pipeline); nil means the email has no
|
|
48
|
-
# inherited artwork and renders bannerless until someone uploads one.
|
|
49
|
-
Entry = Struct.new(:key, :label, :description, :default_asset, keyword_init: true) do
|
|
50
|
-
def to_s = key
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# The emails EVERY Studio app sends. Pre-registered, so a host inherits both
|
|
54
|
-
# without declaring anything.
|
|
55
|
-
STANDARD = [
|
|
56
|
-
{
|
|
57
|
-
key: "magic_link",
|
|
58
|
-
label: "Magic-link sign-in",
|
|
59
|
-
description: "Passwordless sign-in link. Sent whenever someone asks to sign in by email.",
|
|
60
|
-
default_asset: "emails/magic-link.png"
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
key: "email_change_confirmation",
|
|
64
|
-
label: "Email change confirmation",
|
|
65
|
-
description: "Confirms a new address before the change takes effect.",
|
|
66
|
-
default_asset: "emails/email-change-confirmation.png"
|
|
67
|
-
}
|
|
68
|
-
].freeze
|
|
69
|
-
|
|
70
|
-
# Banners render full-bleed at 600px in a 600px card. 1200x600 is the
|
|
71
|
-
# right cut: 2:1, retina-sharp at render width, and small enough to stay
|
|
72
|
-
# out of an inbox clipping limit.
|
|
73
|
-
ASPECT_RATIO = 2.0
|
|
74
|
-
MAX_WIDTH = 1200
|
|
29
|
+
PURPOSE = EmailCatalog::PURPOSE
|
|
30
|
+
ASPECT_RATIO = EmailCatalog::ASPECT_RATIO
|
|
31
|
+
MAX_WIDTH = EmailCatalog::MAX_WIDTH
|
|
75
32
|
|
|
76
33
|
module_function
|
|
77
34
|
|
|
78
|
-
#
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def registered?(key) = known?(key)
|
|
111
|
-
|
|
112
|
-
def label(key)
|
|
113
|
-
entry(key)&.label || key.to_s.humanize
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
# Legacy shape — key => label. Kept because it is the API the pre-registry
|
|
117
|
-
# admin page and any host that read VARIANTS were written against.
|
|
118
|
-
def variants
|
|
119
|
-
registry.transform_values(&:label)
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
# Drops host registrations back to the standard two. For tests and for
|
|
123
|
-
# to_prepare re-registration.
|
|
124
|
-
def reset!
|
|
125
|
-
@registry = nil
|
|
126
|
-
registry
|
|
127
|
-
nil
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def registry
|
|
131
|
-
@registry ||= STANDARD.each_with_object({}) do |attrs, out|
|
|
132
|
-
out[attrs[:key]] = Entry.new(**attrs)
|
|
133
|
-
end
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
# --- Resolution --------------------------------------------------------
|
|
137
|
-
|
|
138
|
-
# Where the live banner for this email comes from:
|
|
139
|
-
# :app — this app uploaded its own (ImageCache row in its bucket)
|
|
140
|
-
# :default — the inherited engine default (gem asset)
|
|
141
|
-
# :none — no image at all; the email sends bannerless
|
|
142
|
-
def source(key)
|
|
143
|
-
return :app if record(key)
|
|
144
|
-
return :default if default_asset_path(key)
|
|
145
|
-
|
|
146
|
-
:none
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
def app_owned?(key) = source(key) == :app
|
|
150
|
-
|
|
151
|
-
# THIS APP'S OWN image only — nil when nothing has been uploaded here.
|
|
152
|
-
#
|
|
153
|
-
# This is the PRE-REGISTRY contract, kept EXACTLY: `url` has always meant
|
|
154
|
-
# "the admin-managed override, or nil", and callers were written to fall back
|
|
155
|
-
# themselves. turf-monster's mailer is the live example:
|
|
156
|
-
#
|
|
157
|
-
# @banner_url = Studio::EmailImage.url(:magic_link) || email_banner_url("magic-link-banner.jpg")
|
|
158
|
-
#
|
|
159
|
-
# Making `url` resolve to the engine default would make that `||` dead code
|
|
160
|
-
# and silently replace turf-monster's own branded 1200x600 banner with the
|
|
161
|
-
# engine's PLACEHOLDER in real sign-in email. A method whose signature is
|
|
162
|
-
# unchanged but whose return value flips from nil to a value is not additive.
|
|
163
|
-
# So the new two-layer resolution lives in resolved_url, and every existing
|
|
164
|
-
# caller keeps the behavior it was written against until its app adopts.
|
|
165
|
-
def url(key)
|
|
166
|
-
record(key)&.url
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
# What ACTUALLY SHIPS on this email — the two-layer resolution. Absolute, so
|
|
170
|
-
# it resolves from an inbox. App-owned override first, then the inherited
|
|
171
|
-
# engine default, then nil (the mailer renders bannerless).
|
|
172
|
-
#
|
|
173
|
-
# This is what a mailer should call once its app has adopted the registry.
|
|
174
|
-
# The engine's own UserMailer already does, which is what gives an app with
|
|
175
|
-
# an empty bucket branded email on day one.
|
|
176
|
-
def resolved_url(key)
|
|
177
|
-
url(key) || default_url(key)
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
# What the ADMIN PAGE previews. Same two layers as resolved_url, but a
|
|
181
|
-
# default stays a root-relative asset path so it renders correctly on
|
|
182
|
-
# whatever host and port this app is being viewed on (an absolute mailer
|
|
183
|
-
# asset_host is set for the inbox, not for a browser on localhost:3042).
|
|
184
|
-
def preview_url(key)
|
|
185
|
-
url(key) || default_asset_path(key)
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
# The ImageCache row holding this app's override, or nil (nothing uploaded /
|
|
189
|
-
# table not installed yet). Nil-safe so the mailer renders before any upload.
|
|
190
|
-
def record(key)
|
|
191
|
-
return nil unless table_ready?
|
|
192
|
-
|
|
193
|
-
::ImageCache.find_by(owner: nil, purpose: PURPOSE, variant: key.to_s)
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
# Root-relative path to the inherited default asset, or nil when the email
|
|
197
|
-
# has no default registered or the host's pipeline cannot resolve it.
|
|
198
|
-
def default_asset_path(key)
|
|
199
|
-
asset = entry(key)&.default_asset
|
|
200
|
-
return nil if asset.nil? || asset.empty?
|
|
201
|
-
|
|
202
|
-
path = ActionController::Base.helpers.asset_path(asset)
|
|
203
|
-
path.presence
|
|
204
|
-
rescue StandardError
|
|
205
|
-
nil
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
# Absolute URL to the inherited default asset — what a mailer needs. Uses
|
|
209
|
-
# action_mailer.asset_host (set per env), falling back to the mailer's
|
|
210
|
-
# default_url_options host. Returns the bare path if neither is configured,
|
|
211
|
-
# which still renders in the local inbox preview.
|
|
212
|
-
def default_url(key)
|
|
213
|
-
path = default_asset_path(key)
|
|
214
|
-
return nil if path.nil?
|
|
215
|
-
return path if path.start_with?("http")
|
|
216
|
-
|
|
217
|
-
host = mailer_asset_host
|
|
218
|
-
host ? "#{host}#{path}" : path
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
# --- Upload ------------------------------------------------------------
|
|
222
|
-
|
|
223
|
-
# Whether THIS app can accept an upload. False when the host never set
|
|
224
|
-
# Studio.s3_bucket_prefix — /admin/emails then shows inherited defaults
|
|
225
|
-
# read-only rather than 500ing on the first upload.
|
|
226
|
-
def uploads_available?
|
|
227
|
-
Studio::S3.configured? && table_ready?
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
# Upload bytes to this app's bucket + upsert its ImageCache row (replacing
|
|
231
|
-
# any prior object). Returns the ::ImageCache. Raises on failure after
|
|
232
|
-
# cleaning up the new object.
|
|
233
|
-
def store(key, io:, content_type: nil)
|
|
234
|
-
s3_key = "email_banners/#{key}-#{SecureRandom.hex(4)}#{ext_for(content_type)}"
|
|
235
|
-
Studio::S3.upload(key: s3_key, body: io.read, content_type: content_type,
|
|
236
|
-
cache_control: "public, max-age=300")
|
|
237
|
-
record = ::ImageCache.find_or_initialize_by(owner: nil, purpose: PURPOSE, variant: key.to_s)
|
|
238
|
-
previous = record.s3_key
|
|
239
|
-
record.update!(s3_key: s3_key)
|
|
240
|
-
delete_object(previous) if previous.present? && previous != s3_key
|
|
241
|
-
record
|
|
242
|
-
rescue StandardError
|
|
243
|
-
delete_object(s3_key)
|
|
244
|
-
raise
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
# Drop this app's override and fall back to the inherited default. Returns
|
|
248
|
-
# true when a row was removed.
|
|
249
|
-
def revert(key)
|
|
250
|
-
row = record(key)
|
|
251
|
-
return false if row.nil?
|
|
252
|
-
|
|
253
|
-
previous = row.s3_key
|
|
254
|
-
row.destroy!
|
|
255
|
-
delete_object(previous) if previous.present?
|
|
256
|
-
true
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
# --- Internals ---------------------------------------------------------
|
|
260
|
-
|
|
261
|
-
# Reference ImageCache directly so Zeitwerk autoloads it — defined?() does NOT
|
|
262
|
-
# trigger autoload, so it would read "undefined" for a not-yet-loaded const.
|
|
263
|
-
def table_ready?
|
|
264
|
-
::ImageCache.table_exists?
|
|
265
|
-
rescue NameError, ActiveRecord::ActiveRecordError
|
|
266
|
-
false
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
# The origin an email's banner URL hangs off. action_mailer.asset_host when
|
|
270
|
-
# the host sets one (turf-monster does, per env); otherwise built from the
|
|
271
|
-
# mailer's default_url_options.
|
|
272
|
-
#
|
|
273
|
-
# That fallback has to reconstruct a real origin, not just the hostname.
|
|
274
|
-
# default_url_options is routinely {host: "localhost", port: 3001} — taking
|
|
275
|
-
# :host alone and prefixing "https://" yields https://localhost, which is the
|
|
276
|
-
# wrong scheme AND the wrong port, and the banner comes back
|
|
277
|
-
# ERR_CONNECTION_REFUSED. Caught by opening the preview page on a worktree
|
|
278
|
-
# stack; every dev/QA preview took that path.
|
|
279
|
-
def mailer_asset_host
|
|
280
|
-
configured = Rails.application.config.action_mailer.asset_host.presence
|
|
281
|
-
return configured if configured
|
|
282
|
-
|
|
283
|
-
options = ActionMailer::Base.default_url_options || {}
|
|
284
|
-
host = options[:host].presence
|
|
285
|
-
return nil if host.nil?
|
|
286
|
-
return host if host.start_with?("http")
|
|
287
|
-
|
|
288
|
-
"#{mailer_protocol(options, host)}://#{host}#{mailer_port_suffix(options)}"
|
|
289
|
-
rescue StandardError
|
|
290
|
-
nil
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
# Honor an explicit :protocol. Otherwise https — EXCEPT on loopback, which is
|
|
294
|
-
# a dev stack with no TLS. Defaulting the other way would downgrade every
|
|
295
|
-
# production app that sets only {host: "mcritchie.studio"}.
|
|
296
|
-
def mailer_protocol(options, host)
|
|
297
|
-
explicit = options[:protocol].presence
|
|
298
|
-
return explicit.to_s.sub(%r{://\z}, "") if explicit
|
|
299
|
-
|
|
300
|
-
LOOPBACK_HOSTS.include?(host.downcase) ? "http" : "https"
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
# Ports are part of the origin, and omitting one sends the reader to :443.
|
|
304
|
-
# The scheme defaults are left off so a normal URL stays normal.
|
|
305
|
-
def mailer_port_suffix(options)
|
|
306
|
-
port = options[:port]
|
|
307
|
-
return "" if port.blank? || [80, 443].include?(port.to_i)
|
|
308
|
-
|
|
309
|
-
":#{port}"
|
|
310
|
-
end
|
|
311
|
-
|
|
312
|
-
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 0.0.0.0 ::1].freeze
|
|
313
|
-
|
|
314
|
-
def ext_for(content_type)
|
|
315
|
-
case content_type.to_s
|
|
316
|
-
when %r{png} then ".png"
|
|
317
|
-
when %r{jpe?g} then ".jpg"
|
|
318
|
-
when %r{webp} then ".webp"
|
|
319
|
-
else ".png"
|
|
320
|
-
end
|
|
321
|
-
end
|
|
322
|
-
|
|
323
|
-
def delete_object(key)
|
|
324
|
-
Studio::S3.delete(key: key)
|
|
325
|
-
rescue StandardError
|
|
326
|
-
nil
|
|
327
|
-
end
|
|
35
|
+
# Registry
|
|
36
|
+
def register(...) = EmailCatalog.register(...)
|
|
37
|
+
def entries = EmailCatalog.entries
|
|
38
|
+
def entry(key) = EmailCatalog.entry(key)
|
|
39
|
+
def keys = EmailCatalog.keys
|
|
40
|
+
def known?(key) = EmailCatalog.known?(key)
|
|
41
|
+
def registered?(key) = EmailCatalog.registered?(key)
|
|
42
|
+
def label(key) = EmailCatalog.label(key)
|
|
43
|
+
def variants = EmailCatalog.variants
|
|
44
|
+
def reset! = EmailCatalog.reset!
|
|
45
|
+
|
|
46
|
+
# Image resolution. `url` is the pre-registry contract — this app's own
|
|
47
|
+
# image or nil — and must stay that way; see the note on EmailCatalog#url.
|
|
48
|
+
def url(key) = EmailCatalog.url(key)
|
|
49
|
+
def resolved_url(key) = EmailCatalog.resolved_url(key)
|
|
50
|
+
def preview_url(key) = EmailCatalog.preview_url(key)
|
|
51
|
+
def source(key) = EmailCatalog.source(key)
|
|
52
|
+
def app_owned?(key) = EmailCatalog.app_owned?(key)
|
|
53
|
+
def record(key) = EmailCatalog.record(key)
|
|
54
|
+
def default_url(key) = EmailCatalog.default_url(key)
|
|
55
|
+
def default_asset_path(key) = EmailCatalog.default_asset_path(key)
|
|
56
|
+
|
|
57
|
+
# Writes
|
|
58
|
+
def store(key, io:, content_type: nil) = EmailCatalog.store(key, io: io, content_type: content_type)
|
|
59
|
+
def revert(key) = EmailCatalog.revert(key)
|
|
60
|
+
def uploads_available? = EmailCatalog.uploads_available?
|
|
61
|
+
def table_ready? = EmailCatalog.table_ready?
|
|
62
|
+
|
|
63
|
+
# Origin resolution. Delegated because 0.34's suite asserted these directly.
|
|
64
|
+
def mailer_asset_host = EmailCatalog.mailer_asset_host
|
|
65
|
+
def mailer_protocol(options, host) = EmailCatalog.mailer_protocol(options, host)
|
|
66
|
+
def mailer_port_suffix(options) = EmailCatalog.mailer_port_suffix(options)
|
|
328
67
|
end
|
|
329
68
|
end
|
|
@@ -38,13 +38,15 @@
|
|
|
38
38
|
%>
|
|
39
39
|
|
|
40
40
|
<header x-data="{ scrolled: false }" <%= '@scroll.window="scrolled = scrolled ? (window.scrollY > 5) : (window.scrollY > 60)"'.html_safe unless is_preview %>
|
|
41
|
-
<%# top
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
<%# top-0, a STATIC value, and never a custom property. Any bars render as
|
|
42
|
+
this header's sibling in normal flow (studio/banners/_stack), so they
|
|
43
|
+
already occupy their own height above it and there is nothing to
|
|
44
|
+
offset by. An offset read from a runtime-published property is what
|
|
45
|
+
made this header jump: it painted at a server estimate, moved when the
|
|
46
|
+
measurement landed, moved again on a webfont swap, and drew twice
|
|
47
|
+
during a view transition composited at two different tops. Only CSS
|
|
48
|
+
can set this top now, so none of that is reachable. %>
|
|
49
|
+
class="<%= is_preview ? 'bg-page' : "#{'vt-pinned-header ' if pin_header}sticky top-0 z-50 bg-page transition-shadow duration-300" %>"
|
|
48
50
|
:class="scrolled && 'shadow-lg border-b border-subtle is-scrolled'">
|
|
49
51
|
<style>
|
|
50
52
|
.user-nav-col { width: 14rem; }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<%#
|
|
2
2
|
Shared branded transactional email shell. A full-bleed banner (set @banner_url,
|
|
3
|
-
e.g. from Studio::
|
|
3
|
+
e.g. from Studio::EmailCatalog.resolved_url(:magic_link)) sits flush at the top and sets
|
|
4
4
|
the 600px width; each email view supplies the body via yield. Bannerless is
|
|
5
5
|
fine — the card still renders. Lifted from turf-monster so every Studio app
|
|
6
6
|
shares one branded look. An app can override by defining its own
|
|
@@ -24,9 +24,12 @@
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
# INTRINSIC height — content decides, and
|
|
28
|
-
#
|
|
29
|
-
#
|
|
27
|
+
# INTRINSIC height — content decides, and the LAYOUT absorbs the result: the
|
|
28
|
+
# stack sits in normal flow, so a bar simply occupies the height it needs and
|
|
29
|
+
# nothing has to measure it. (It once did measure, publishing --studio-bars-h;
|
|
30
|
+
# that jumped, and 0.39.0 removed it.) An earlier cut declared a fixed height
|
|
31
|
+
# so the stack could compute its total server-side; that was rejected
|
|
32
|
+
# deliberately — and the reason still holds — because it cannot express
|
|
30
33
|
# a bar that needs to be taller, or two bars stacked, without every consuming
|
|
31
34
|
# app being reworked. The apps should respond to what the bar needs, not the
|
|
32
35
|
# other way round.
|
|
@@ -12,22 +12,31 @@
|
|
|
12
12
|
0, 1 or 2, and growing. Nesting each new bar inside the navbar coupled two
|
|
13
13
|
unrelated components and meant every new bar edited the navbar. Here the bars
|
|
14
14
|
do not know the navbar exists, and the navbar does not know which bars
|
|
15
|
-
rendered —
|
|
15
|
+
rendered — it simply starts below whatever space they take.
|
|
16
16
|
|
|
17
|
-
HOW THE
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
HOW THE SPACE IS RESERVED. By the LAYOUT, and by nothing else. The stack sits
|
|
18
|
+
in normal flow directly above the navbar, so the bars occupy their own height
|
|
19
|
+
the way any block does, and the navbar — sticky at top:0 — starts underneath
|
|
20
|
+
them with no offset to compute. A taller bar, or a second, or a third, simply
|
|
21
|
+
takes more room and pushes the navbar down. Nothing measures, nothing
|
|
22
|
+
publishes, nothing repaints.
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
the
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
This replaced a measured custom property (--studio-bars-h: a server-rendered
|
|
25
|
+
estimate of count times a fixed unit, overwritten by a ResizeObserver reading
|
|
26
|
+
the real height). It worked, and it JUMPED: the header painted at the estimate
|
|
27
|
+
and moved when the measurement landed, then moved again whenever a webfont
|
|
28
|
+
changed the bar's real height — and during a view transition the outgoing and
|
|
29
|
+
incoming headers composited at two different tops, so the navbar visibly drew
|
|
30
|
+
twice. A position that only CSS can set cannot do any of that.
|
|
26
31
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
THE TRADE, recorded so it is a decision and not a regression: the bars scroll
|
|
33
|
+
away with the page instead of pinning. Only the navbar stays. Two pinned
|
|
34
|
+
siblings of unknown height cannot stack in CSS alone — one has to measure the
|
|
35
|
+
other — so pinning the bars is what cost the offset variable. The navbar keeps
|
|
36
|
+
its pin; the bars are ambient labels, seen on every page load because a Turbo
|
|
37
|
+
visit lands at the top. An overlay that must clear the chrome should position
|
|
38
|
+
off --nav-bottom (published in layouts/studio/_head), which already reports the
|
|
39
|
+
header's live bottom edge and accounts for chrome above it.
|
|
31
40
|
|
|
32
41
|
Locals (all optional):
|
|
33
42
|
preview — true inside a navbar-preview render; renders nothing, so a
|
|
@@ -61,42 +70,17 @@
|
|
|
61
70
|
end
|
|
62
71
|
end
|
|
63
72
|
|
|
64
|
-
# count
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
|
|
73
|
+
# No count is kept. The old code counted bars to compute an offset, and had to
|
|
74
|
+
# use count { } rather than count(true) because show_impersonation is a truthy
|
|
75
|
+
# USER object, not the literal true — a mistake that once published a one-bar
|
|
76
|
+
# height for a two-bar stack. Normal flow needs no count at all: whatever
|
|
77
|
+
# rendered takes the room it needs.
|
|
78
|
+
any_bar = show_environment || show_impersonation
|
|
68
79
|
%>
|
|
69
|
-
<% if
|
|
70
|
-
<%#
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
<div class="studio-bar-stack
|
|
75
|
-
<script>
|
|
76
|
-
// Publishes the stack's REAL height, so the navbar offsets by what the bars
|
|
77
|
-
// actually need. Idempotent and re-bound on Turbo navigation — a stale
|
|
78
|
-
// observer pointed at a detached node silently stops updating, and the
|
|
79
|
-
// navbar would then sit at yesterday's offset.
|
|
80
|
-
(function () {
|
|
81
|
-
var apply = function () {
|
|
82
|
-
var el = document.querySelector("[data-studio-bar-stack]");
|
|
83
|
-
if (!el) {
|
|
84
|
-
document.documentElement.style.removeProperty("--studio-bars-h");
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
if (window.__studioBarObserver) window.__studioBarObserver.disconnect();
|
|
88
|
-
var publish = function () {
|
|
89
|
-
var h = Math.round(el.getBoundingClientRect().height);
|
|
90
|
-
if (h > 0) document.documentElement.style.setProperty("--studio-bars-h", h + "px");
|
|
91
|
-
};
|
|
92
|
-
publish();
|
|
93
|
-
if (window.ResizeObserver) {
|
|
94
|
-
window.__studioBarObserver = new ResizeObserver(publish);
|
|
95
|
-
window.__studioBarObserver.observe(el);
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
apply();
|
|
99
|
-
document.addEventListener("turbo:load", apply);
|
|
100
|
-
})();
|
|
101
|
-
</script>
|
|
80
|
+
<% if any_bar %>
|
|
81
|
+
<%# Normal flow, deliberately. No sticky, no z-index, no measured offset: the
|
|
82
|
+
navbar is the only pinned chrome, so the bars simply take their own height
|
|
83
|
+
above it. Not sticky also keeps the paint order right — the pinned navbar
|
|
84
|
+
is positioned, so it draws over these bars as they scroll up behind it. %>
|
|
85
|
+
<div class="studio-bar-stack w-full" data-studio-bar-stack><%= bars %></div>
|
|
102
86
|
<% end %>
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
override, so it announced "No image yet" for an email that was visibly
|
|
38
38
|
sending a banner from a committed repo asset. Even on its way out it
|
|
39
39
|
should tell the truth about what ships. %>
|
|
40
|
-
<% current_url = Studio::
|
|
41
|
-
<% inherited = current_url.present? && Studio::
|
|
40
|
+
<% current_url = Studio::EmailCatalog.preview_url(variant) %>
|
|
41
|
+
<% inherited = current_url.present? && Studio::EmailCatalog.source(variant) == :default %>
|
|
42
42
|
<section class="rounded-xl border border-subtle p-5">
|
|
43
43
|
<h2 class="font-semibold mb-3"><%= label %></h2>
|
|
44
44
|
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
the same unit turf-monster's og:image uploader uses, on the page-scoped
|
|
6
6
|
`emailModals` store. %>
|
|
7
7
|
<%
|
|
8
|
-
source = Studio::
|
|
9
|
-
banner_url = Studio::
|
|
8
|
+
source = Studio::EmailCatalog.source(entry.key)
|
|
9
|
+
banner_url = Studio::EmailCatalog.preview_url(entry.key)
|
|
10
10
|
form_id = "email-banner-form-#{entry.key.dasherize}"
|
|
11
11
|
|
|
12
12
|
# `badge` is a shape-only utility in engine.css — the state color comes from
|
|
@@ -59,7 +59,12 @@
|
|
|
59
59
|
</td>
|
|
60
60
|
|
|
61
61
|
<td class="px-4 py-4">
|
|
62
|
-
|
|
62
|
+
<%# The name is the link to the email's own page — banner + live preview. %>
|
|
63
|
+
<%= link_to entry.label, admin_email_path(entry.key),
|
|
64
|
+
class: "font-semibold text-heading hover:text-primary underline-offset-2 hover:underline" %>
|
|
65
|
+
<% if entry.marketing? %>
|
|
66
|
+
<span class="badge bg-primary/10 text-primary border-primary/30 ml-1.5 whitespace-nowrap">Marketing</span>
|
|
67
|
+
<% end %>
|
|
63
68
|
<% if entry.description.present? %>
|
|
64
69
|
<p class="text-sm text-body mt-0.5 max-w-md"><%= entry.description %></p>
|
|
65
70
|
<% end %>
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
partial in this non-isolated engine. %>
|
|
21
21
|
<% content_for(:title) { "Emails" } %>
|
|
22
22
|
<%
|
|
23
|
-
aspect = Studio::
|
|
24
|
-
max_width = Studio::
|
|
25
|
-
app_owned = @entries.count { |entry| Studio::
|
|
23
|
+
aspect = Studio::EmailCatalog::ASPECT_RATIO
|
|
24
|
+
max_width = Studio::EmailCatalog::MAX_WIDTH
|
|
25
|
+
app_owned = @entries.count { |entry| Studio::EmailCatalog.source(entry.key) == :app }
|
|
26
26
|
%>
|
|
27
27
|
<div class="max-w-5xl mx-auto px-4 pb-16">
|
|
28
28
|
<header class="space-y-2 pt-8 pb-6">
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
Banners render full-bleed at 600px wide inside the email card. The crop is
|
|
89
89
|
fixed at <%= aspect.to_i %>:1 and saved at up to <%= max_width %>px, which is
|
|
90
90
|
retina-sharp in an inbox without tripping a clipping limit.
|
|
91
|
-
<% if @entries.any? { |entry| Studio::
|
|
91
|
+
<% if @entries.any? { |entry| Studio::EmailCatalog.source(entry.key) == :none } %>
|
|
92
92
|
An email with no image sends bannerless — the card still renders.
|
|
93
93
|
<% end %>
|
|
94
94
|
</p>
|