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.
@@ -1,329 +1,68 @@
1
1
  module Studio
2
- # The transactional-email registry, and the banner image each registered email
3
- # ships with.
2
+ # DEPRECATED NAME this is Studio::EmailCatalog now. Every method here just
3
+ # forwards; there is no behavior in this file.
4
4
  #
5
- # A registered email is MOSTLY SYMBOLIC of a workflow — a key, a human label,
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
- # ## Two layers: inherited default, app-owned override
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
- # .resolved_url(key) => app's own ImageCache row (its S3 bucket) # app-owned
14
- # -> the engine's default gem asset # inherited
15
- # -> nil # no image
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
- # `.url(key)` stays the PRE-REGISTRY contract this app's own image or nil
18
- # so every caller written before the registry keeps its behavior until its app
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
- # Defaults RIDE THE GEM (app/assets/images/emails/*), so a brand-new app with
23
- # an empty bucket sends good-looking email on day one and needs no cross-app S3
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 = "email_banner".freeze
45
-
46
- # A registered email. `default_asset` is a logical asset path inside the gem
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
- # --- Registry ----------------------------------------------------------
79
-
80
- # Register (or update) an email workflow. Returns the key.
81
- def register(key, label: nil, description: nil, default_asset: nil)
82
- key = key.to_s
83
- existing = registry[key]
84
- registry[key] = Entry.new(
85
- key: key,
86
- label: label || existing&.label || key.humanize,
87
- description: description || existing&.description,
88
- default_asset: default_asset.nil? ? existing&.default_asset : default_asset.presence
89
- )
90
- key
91
- end
92
-
93
- # Every registered email, in display order: the standard two first, then the
94
- # host's own in declaration order.
95
- def entries
96
- registry.values
97
- end
98
-
99
- def entry(key)
100
- registry[key.to_s]
101
- end
102
-
103
- def keys
104
- registry.keys
105
- end
106
-
107
- def known?(key)
108
- registry.key?(key.to_s)
109
- end
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 offsets by whatever studio/banners/_stack rendered above us. The
42
- stack publishes --studio-bars-h on :root; with no stack the fallback
43
- 0px makes this identical to the old `top-0`, so an app that has not
44
- adopted the stack is unaffected. The navbar never learns WHICH bars
45
- rendered only how tall they are. %>
46
- style="<%= "top:var(--studio-bars-h, 0px);" unless is_preview %>"
47
- class="<%= is_preview ? 'bg-page' : "#{'vt-pinned-header ' if pin_header}sticky z-50 bg-page transition-shadow duration-300" %>"
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::EmailImage.url(:magic_link)) sits flush at the top and sets
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 studio/banners/_stack MEASURES the
28
- # result. An earlier cut declared a fixed height so the stack could compute its
29
- # total server-side; that was rejected deliberately, because it cannot express
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 — only how tall they are, via --studio-bars-h.
15
+ rendered — it simply starts below whatever space they take.
16
16
 
17
- HOW THE HEIGHT IS KNOWN. The stack MEASURES itself and publishes the result, so
18
- a bar that needs to be taller — or a second bar, or a third just works, and no
19
- consuming app is reworked to allow it. The apps respond to what the bars need.
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
- A server-rendered estimate (count × --studio-bar-unit) paints first so the
22
- common case has no flash, then a ResizeObserver replaces it with the truth on
23
- the same frame. An earlier cut declared a fixed bar height to avoid the
24
- observer; that was rejected because it could only ever express the standard
25
- case, which is the case that needed no help.
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
- The property is published on :root from an inline <style>, deliberately. A
28
- custom property set on this element would be invisible to the navbar, because
29
- custom properties inherit DOWN, not ACROSS to siblingsand putting it on
30
- <body> would need every host to change its layout.
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 { } and not count(true): show_impersonation is a truthy USER, not the
65
- # literal true, and count(true) compares with ==. That published a one-bar
66
- # height for a two-bar stackthe navbar would have sat under a bar.
67
- bar_count = [show_environment, show_impersonation].count { |bar| bar }
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 bar_count.positive? %>
70
- <%# First-paint estimate only the observer below replaces it with the measured
71
- height. Expressed against a token so an app that retunes the unit still gets
72
- a sensible pre-measurement value. %>
73
- <%= tag.style safe_join([":root{--studio-bars-h:calc(#{bar_count} * var(--studio-bar-unit, 47px))}"]) %>
74
- <div class="studio-bar-stack sticky top-0 z-[60] w-full" data-studio-bar-stack><%= bars %></div>
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::EmailImage.preview_url(variant) %>
41
- <% inherited = current_url.present? && Studio::EmailImage.source(variant) == :default %>
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::EmailImage.source(entry.key)
9
- banner_url = Studio::EmailImage.preview_url(entry.key)
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
- <p class="font-semibold text-heading"><%= entry.label %></p>
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::EmailImage::ASPECT_RATIO
24
- max_width = Studio::EmailImage::MAX_WIDTH
25
- app_owned = @entries.count { |entry| Studio::EmailImage.source(entry.key) == :app }
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::EmailImage.source(entry.key) == :none } %>
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>