studio-engine 0.33.0 → 0.37.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.
@@ -58,6 +58,9 @@
58
58
  if (props.maxHeight) this.maxHeight = props.maxHeight;
59
59
  if (typeof props.transparent === "boolean") this.transparent = props.transparent;
60
60
  if (props.dispatch) this.dispatch = true;
61
+ // Carried through untouched and echoed back on confirm, so the host that
62
+ // OPENED the cropper is the only one that acts on the result.
63
+ this.owner = props.owner || null;
61
64
  if (props.autoCropArea) this.autoCropArea = props.autoCropArea;
62
65
  if (props.imageUrl) {
63
66
  this.fromParent = true;
@@ -139,7 +142,15 @@
139
142
  var canvas = this.cropper.getCroppedCanvas(canvasOpts);
140
143
  canvas.toBlob(function (blob) {
141
144
  try {
142
- window.dispatchEvent(new CustomEvent("crop-photo-confirmed", { detail: { blob: blob } }));
145
+ // OWNER RIDES WITH THE BLOB. `crop-photo-confirmed` is a WINDOW event, so
146
+ // every imageUploadHost on the page hears it — and /admin/emails mounts
147
+ // one host PER ROW. Without an owner, one confirmed crop PATCHed every
148
+ // row's banner with the same image, destroying any app-owned banner
149
+ // already there; the FIRST upload an admin ever performed hit it, because
150
+ // the engine pre-registers two emails. `owner` is the token the opening
151
+ // host stamped on the crop props, echoed back untouched.
152
+ window.dispatchEvent(new CustomEvent("crop-photo-confirmed",
153
+ { detail: { blob: blob, owner: self.owner || null } }));
143
154
  } catch (_) {}
144
155
  if (self.cropper) { self.cropper.destroy(); self.cropper = null; }
145
156
  // dispatch mode: the opener's host owns the post-confirm flow
@@ -162,10 +173,11 @@
162
173
  // Swaps in the 'saving' card while the form uploads, then closes it (held
163
174
  // >= ~450ms so it doesn't flash) and toasts on completion. opts: { saving,
164
175
  // success, successMessage, failure, failureMessage, dismissible (default
165
- // false), toast (default true) }.
176
+ // false), toast (default true), store (Alpine store name, default "modals" —
177
+ // pass a page-scoped host's name, e.g. "emailModals") }.
166
178
  window.submitFormWithProgress = function (form, opts) {
167
179
  opts = opts || {};
168
- var store = window.Alpine && Alpine.store("modals");
180
+ var store = window.Alpine && Alpine.store(opts.store || "modals");
169
181
  var hold = (window.StudioModals && window.StudioModals.holdAtLeast)
170
182
  ? window.StudioModals.holdAtLeast(450)
171
183
  : { then: function (cb) { cb(); } };
@@ -199,9 +211,14 @@
199
211
  // it into the host's own hidden form input and submits immediately with a
200
212
  // loading card + toast (submitFormWithProgress). opts = crop config
201
213
  // (aspectRatio, maxWidth, maxHeight, transparent, autoCropArea) + save copy
202
- // (saving, success, successMessage, failure, dismissible, toast, filename).
214
+ // (saving, success, successMessage, failure, dismissible, toast, filename) +
215
+ // store (Alpine store name, default "modals"). Pass `store` when the crop and
216
+ // saving modals are mounted on a PAGE-SCOPED host rather than the app's shared
217
+ // one — /admin/emails does, so the uploader works even in a host app that
218
+ // renders no shared modal host at all.
203
219
  window.imageUploadHost = function (opts) {
204
220
  opts = opts || {};
221
+ var storeName = opts.store || "modals";
205
222
  function cropProps(extra) {
206
223
  var p = {
207
224
  aspectRatio: opts.aspectRatio || 1,
@@ -214,11 +231,25 @@
214
231
  if (extra) { for (var k in extra) { p[k] = extra[k]; } }
215
232
  return p;
216
233
  }
234
+ // A token unique to THIS host instance. Many hosts can be mounted on one page
235
+ // (one per row on /admin/emails) and they all hear the same window event, so a
236
+ // confirm has to say WHICH host opened the cropper.
237
+ var ownerId = "iuh-" + (window.__studioImageUploadHostSeq = (window.__studioImageUploadHostSeq || 0) + 1);
238
+
217
239
  return {
240
+ ownerId: ownerId,
218
241
  // Modal-as-picker: the crop modal itself is the file picker / drop target.
219
242
  open() {
220
- if (!window.Alpine || !Alpine.store("modals")) return;
221
- Alpine.store("modals").open("crop-photo", cropProps());
243
+ if (!window.Alpine || !Alpine.store(storeName)) return;
244
+ Alpine.store(storeName).open("crop-photo", cropProps({ owner: ownerId }));
245
+ },
246
+ // The window-event guard: a confirm addressed to a DIFFERENT host is not ours
247
+ // to act on. An owner-LESS confirm still applies, so every existing
248
+ // single-host page keeps working unchanged.
249
+ onCropConfirmed(detail) {
250
+ if (!detail) return;
251
+ if (detail.owner && detail.owner !== ownerId) return;
252
+ this.applyCrop(detail.blob);
222
253
  },
223
254
  // Native picker: read the chosen image, then hand it to the modal.
224
255
  onFileSelected(event) {
@@ -226,7 +257,7 @@
226
257
  if (!file) return;
227
258
  var reader = new FileReader();
228
259
  reader.onload = function (e) {
229
- Alpine.store("modals").open("crop-photo", cropProps({ imageUrl: e.target.result }));
260
+ Alpine.store(storeName).open("crop-photo", cropProps({ imageUrl: e.target.result, owner: ownerId }));
230
261
  };
231
262
  reader.readAsDataURL(file);
232
263
  event.target.value = "";
@@ -0,0 +1,195 @@
1
+ <%#
2
+ PAGE-SCOPED modal host — a self-contained modal shell mounted on its OWN
3
+ Alpine store, for a page that must bring its own modals rather than rely on
4
+ the app's shared host.
5
+
6
+ Why this exists as a separate partial rather than a local on
7
+ studio/modals/_host: this is a NON-ISOLATED engine, so an app view at the same
8
+ path SHADOWS the engine's. `mcritchie-studio` and `turf-monster` both ship
9
+ their own app/views/studio/modals/_host.html.erb — older, simpler copies that
10
+ know nothing of a store: local. A page rendering "studio/modals/host" in those
11
+ apps silently gets the fork, and its page-scoped store is never registered.
12
+ This partial's path is unforked in every app, so it renders the engine's code
13
+ everywhere. (The living style guide hand-rolled the same thing inline for the
14
+ same reason; it can rebase onto this later.)
15
+
16
+ Two independent problems it solves at once:
17
+
18
+ 1. Apps that DO have a shared host (mcritchie-studio, turf-monster) register
19
+ their own modal set. A page needing crop-photo would have to ask every
20
+ app to add it.
21
+ 2. Apps that have NO host at all (mcritchie-industries, moms-app) would have
22
+ nowhere to open a modal from.
23
+
24
+ Both go away when the page owns its host.
25
+
26
+ Animations come from the shared motion layer
27
+ (app/assets/tailwind/studio_engine/engine-motion.css) — the .modal-card-* and
28
+ .modal-backdrop-* classes — rather than an inline copy, so this stays small.
29
+ A consumer that does not bundle engine-motion.css still gets a working modal,
30
+ just without the spring.
31
+
32
+ Locals:
33
+ store — REQUIRED. Alpine store name, e.g. "emailModals". Must be unique
34
+ on the page; never "modals" (that is the shared host's).
35
+ card_class — optional card sizing/skin override. Default is the shared
36
+ host's max-w-sm card.
37
+
38
+ Usage: render this partial with a block, exactly like the shared host, and put
39
+ one `template x-if` per modal id inside it — see app/views/studio/emails/
40
+ index.html.erb for the live call site. Guard each registration with optional
41
+ chaining (`current()?.id`): the outer template unmounts one tick AFTER the
42
+ stack empties, so a bare `.id` throws on every close.
43
+
44
+ (No inline ERB example here on purpose. An ERB comment ends at its FIRST "%" +
45
+ ">", so an example containing one silently closes the comment and dumps the
46
+ rest of it onto the page as visible text. That shipped once.)
47
+ %>
48
+ <%
49
+ scoped_store = local_assigns.fetch(:store)
50
+ card_class = local_assigns.fetch(:card_class,
51
+ "bg-surface rounded-xl border border-subtle shadow-2xl p-6 max-w-sm w-full")
52
+ %>
53
+ <style>
54
+ /* Scroll lock, applied by the store's _sync() while the stack is non-empty.
55
+ Shipped here too because an app with no shared host never defines it. */
56
+ body.modal-open { overflow: hidden; }
57
+ </style>
58
+
59
+ <script>
60
+ (function() {
61
+ var STORE_NAME = '<%= scoped_store %>';
62
+ // Keep in sync with .modal-card-unmount in engine-motion.css — the entry is
63
+ // spliced off the stack only after the exit animation has played.
64
+ var CLOSE_ANIM_MS = 220;
65
+
66
+ // window.StudioModals.holdAtLeast(ms) — the minimum-visible-duration
67
+ // convention shared with the app-level host, so a spinner can't flash past
68
+ // the user. Defined defensively: this page may be the only host on it.
69
+ window.StudioModals = window.StudioModals || {};
70
+ window.StudioModals.holdAtLeast = window.StudioModals.holdAtLeast || function(minMs) {
71
+ var startedAt = Date.now();
72
+ return {
73
+ then: function(callback) {
74
+ var remaining = Math.max(0, minMs - (Date.now() - startedAt));
75
+ if (remaining === 0) { callback(); return; }
76
+ setTimeout(callback, remaining);
77
+ }
78
+ };
79
+ };
80
+
81
+ // Named, so the dual-guard below can call it directly on a Turbo visit.
82
+ // Alpine's deferred script fires alpine:init exactly ONCE per full document
83
+ // load; this registration lives in a PAGE BODY, so it was absent during that
84
+ // one init. Without the guard, a Turbo Drive visit to this page leaves the
85
+ // store undefined and every x-data on it throws.
86
+ function registerScopedStore() {
87
+ if (Alpine.store(STORE_NAME)) return;
88
+
89
+ Alpine.store(STORE_NAME, {
90
+ stack: [],
91
+
92
+ // open(id, props, opts) — opts.replace swaps the top entry in place
93
+ // (what submitFormWithProgress does to turn crop-photo into saving).
94
+ open: function(id, props, opts) {
95
+ props = props || {};
96
+ opts = opts || {};
97
+ var entry = { id: id, props: props };
98
+
99
+ if (opts.replace && this.stack.length > 0) {
100
+ this.stack.splice(this.stack.length - 1, 1, entry);
101
+ } else {
102
+ this.stack.push(entry);
103
+ }
104
+ this._sync();
105
+ },
106
+
107
+ swap: function(id, props, opts) {
108
+ opts = opts || {};
109
+ opts.replace = true;
110
+ this.open(id, props, opts);
111
+ },
112
+
113
+ // Flip _closing so the exit animation plays, then splice.
114
+ close: function() {
115
+ var entry = this.current();
116
+ if (!entry) return;
117
+
118
+ var self = this;
119
+ entry._closing = true;
120
+ setTimeout(function() {
121
+ var index = self.stack.indexOf(entry);
122
+ if (index !== -1) self.stack.splice(index, 1);
123
+ self._sync();
124
+ }, CLOSE_ANIM_MS);
125
+ },
126
+
127
+ closeAll: function() {
128
+ this.stack = [];
129
+ this._sync();
130
+ },
131
+
132
+ // Keeps dismissible: false entries — a still-in-flight upload must not
133
+ // silently lose the card its promise will resolve against.
134
+ closeAllDismissible: function() {
135
+ this.stack = this.stack.filter(function(entry) {
136
+ return entry.props && entry.props.dismissible === false;
137
+ });
138
+ this._sync();
139
+ },
140
+
141
+ isOpen: function(id) {
142
+ return this.stack.some(function(entry) { return entry.id === id; });
143
+ },
144
+
145
+ current: function() {
146
+ return this.stack.length ? this.stack[this.stack.length - 1] : null;
147
+ },
148
+
149
+ // Mount on open, unmount while closing. _settled pins the entry after
150
+ // its first render so a stale Alpine re-evaluation cannot re-fire the
151
+ // bounce-in keyframe mid-life (which reads as a flash).
152
+ cardClasses: function() {
153
+ var entry = this.current();
154
+ if (!entry) return '';
155
+ if (entry._closing) return 'modal-card-unmount';
156
+ if (!entry._settled) { entry._settled = true; }
157
+ return 'modal-card-mount';
158
+ },
159
+
160
+ _sync: function() {
161
+ document.body.classList.toggle('modal-open', this.stack.length > 0);
162
+ }
163
+ });
164
+ }
165
+
166
+ if (window.Alpine) { registerScopedStore(); }
167
+ else { document.addEventListener('alpine:init', registerScopedStore); }
168
+
169
+ // bfcache + Turbo snapshot cleanup, so a modal left open does not reappear
170
+ // on a back navigation.
171
+ function clearStaleScopedModals() {
172
+ if (!window.Alpine || !Alpine.store) return;
173
+
174
+ var store = Alpine.store(STORE_NAME);
175
+ if (store && typeof store.closeAllDismissible === 'function') store.closeAllDismissible();
176
+ }
177
+ window.addEventListener('pageshow', function(e) { if (e.persisted) clearStaleScopedModals(); });
178
+ document.addEventListener('turbo:before-cache', clearStaleScopedModals);
179
+ })();
180
+ </script>
181
+
182
+ <template x-if="$store.<%= scoped_store %>.current()">
183
+ <div class="fixed inset-0 z-[120] flex items-center justify-center p-4 modal-backdrop-mount"
184
+ :class="$store.<%= scoped_store %>.current()?._closing && 'modal-backdrop-unmount'"
185
+ style="background:rgba(0,0,0,0.6)"
186
+ role="dialog"
187
+ aria-modal="true"
188
+ @keydown.escape.window="$store.<%= scoped_store %>.current() && $store.<%= scoped_store %>.current().props.dismissible !== false && $store.<%= scoped_store %>.close()"
189
+ @click.self="$store.<%= scoped_store %>.current() && $store.<%= scoped_store %>.current().props.dismissible !== false && $store.<%= scoped_store %>.close()">
190
+ <div class="<%= card_class %>" :class="$store.<%= scoped_store %>.cardClasses()">
191
+ <%# Consumer-provided registrations — one <template x-if> per modal id. %>
192
+ <%= yield if block_given? %>
193
+ </div>
194
+ </div>
195
+ </template>
data/lib/studio/engine.rb CHANGED
@@ -47,6 +47,26 @@ module Studio
47
47
  studio/studio_confetti.js
48
48
  studio/sortable.js
49
49
  ]
50
+
51
+ # The INHERITED default email banners (Studio::EmailImage). They ride the
52
+ # gem so a brand-new app sends branded email on day one with an empty S3
53
+ # bucket. Enumerated from disk rather than listed by hand so adding a
54
+ # default is a one-file change. Sprockets hosts (mcritchie-studio,
55
+ # turf-monster) need the explicit precompile entry; propshaft hosts
56
+ # (mcritchie-industries, moms-app) serve everything on config.assets.paths
57
+ # and ignore this list.
58
+ # Named on the CLASS, not bare: an initializer block is instance_exec'd on
59
+ # an Engine INSTANCE, where a bare call resolves to nothing and boots red.
60
+ app.config.assets.precompile += Studio::Engine.default_email_banner_logical_paths
61
+ end
62
+
63
+ # Logical asset paths ("emails/magic-link.png") for every default banner the
64
+ # gem ships.
65
+ def self.default_email_banner_logical_paths
66
+ Dir[File.expand_path("../../app/assets/images/emails/*", __dir__)]
67
+ .select { |path| File.file?(path) }
68
+ .map { |path| "emails/#{File.basename(path)}" }
69
+ .sort
50
70
  end
51
71
 
52
72
  rake_tasks do
data/lib/studio/s3.rb CHANGED
@@ -5,7 +5,7 @@ module Studio
5
5
 
6
6
  class << self
7
7
  def upload(key:, body:, content_type: nil, cache_control: nil)
8
- opts = { bucket: bucket, key: key, body: body }
8
+ opts = { bucket: bucket, key: full_key(key), body: body }
9
9
  opts[:content_type] = content_type if content_type
10
10
  opts[:cache_control] = cache_control if cache_control
11
11
  client.put_object(**opts)
@@ -13,32 +13,34 @@ module Studio
13
13
  end
14
14
 
15
15
  def download(key:)
16
- client.get_object(bucket: bucket, key: key).body.read
16
+ client.get_object(bucket: bucket, key: full_key(key)).body.read
17
17
  end
18
18
 
19
19
  def url(key:)
20
- "https://#{bucket}.s3.#{region}.amazonaws.com/#{key}"
20
+ "https://#{bucket}.s3.#{region}.amazonaws.com/#{full_key(key)}"
21
21
  end
22
22
 
23
23
  def signed_url(key:, expires_in: 3600)
24
24
  require "aws-sdk-s3"
25
- Aws::S3::Presigner.new(client: client).presigned_url(:get_object, bucket: bucket, key: key, expires_in: expires_in)
25
+ Aws::S3::Presigner.new(client: client).presigned_url(:get_object, bucket: bucket, key: full_key(key), expires_in: expires_in)
26
26
  end
27
27
 
28
28
  def exists?(key:)
29
- client.head_object(bucket: bucket, key: key)
29
+ client.head_object(bucket: bucket, key: full_key(key))
30
30
  true
31
31
  rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::NoSuchKey
32
32
  false
33
33
  end
34
34
 
35
35
  def delete(key:)
36
- client.delete_object(bucket: bucket, key: key)
36
+ client.delete_object(bucket: bucket, key: full_key(key))
37
37
  end
38
38
 
39
+ # Returns LOGICAL keys (the app's key namespace stripped back off), so a
40
+ # caller can feed any result straight back into download/delete/url.
39
41
  def list(prefix: nil, max: 1000)
40
- resp = client.list_objects_v2(bucket: bucket, prefix: prefix, max_keys: max)
41
- resp.contents.map(&:key)
42
+ resp = client.list_objects_v2(bucket: bucket, prefix: full_key(prefix), max_keys: max)
43
+ resp.contents.map { |object| logical_key(object.key) }
42
44
  end
43
45
 
44
46
  def bucket
@@ -47,6 +49,40 @@ module Studio
47
49
  "#{prefix}-#{environment}"
48
50
  end
49
51
 
52
+ # Whether this app can touch object storage at all. Callers that must
53
+ # degrade rather than 500 (the /admin/emails uploader on an app whose
54
+ # bucket was never provisioned) ask this instead of rescuing NotConfigured.
55
+ def configured?
56
+ bucket
57
+ true
58
+ rescue NotConfigured
59
+ false
60
+ end
61
+
62
+ # Studio.s3_key_prefix, normalized to "" or "something/". The namespace a
63
+ # satellite app lives under when it shares another app's bucket.
64
+ def key_prefix
65
+ prefix = Studio.s3_key_prefix.to_s
66
+ return "" if prefix.empty?
67
+
68
+ prefix.end_with?("/") ? prefix : "#{prefix}/"
69
+ end
70
+
71
+ # Logical key -> the real object key in the bucket.
72
+ def full_key(key)
73
+ return key if key.nil?
74
+
75
+ "#{key_prefix}#{key}"
76
+ end
77
+
78
+ # The real object key -> logical key (inverse of full_key).
79
+ def logical_key(key)
80
+ prefix = key_prefix
81
+ return key if prefix.empty? || !key.to_s.start_with?(prefix)
82
+
83
+ key.to_s.delete_prefix(prefix)
84
+ end
85
+
50
86
  def region
51
87
  Studio.s3_region
52
88
  end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.33.0"
2
+ VERSION = "0.37.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -129,6 +129,18 @@ module Studio
129
129
  # its own routes (it can still reuse Studio::Link + Studio::LinkConsumption).
130
130
  mattr_accessor :draw_link_routes, default: true
131
131
 
132
+ # Draw the shared transactional-email page at /admin/emails
133
+ # (Studio::EmailsController). OFF by default because the path AND its helper
134
+ # names (admin_emails_path / admin_email_path) are already taken in
135
+ # turf-monster, where drawing them raises at route-load and kills every route
136
+ # in the app. A host opts in from config/initializers/studio.rb:
137
+ #
138
+ # config.draw_admin_emails_routes = true
139
+ #
140
+ # Gates only the PAGE. Studio::EmailImage's registry and its inherited-default
141
+ # resolution are always on, so an app sends branded email either way.
142
+ mattr_accessor :draw_admin_emails_routes, default: false
143
+
132
144
  # Optional admin Act As / impersonation session conventions. Consumers that
133
145
  # include Studio::Impersonation get current_user layered over true_user with
134
146
  # these session keys, but still own authorization, audit logging, and routes.
@@ -158,6 +170,36 @@ module Studio
158
170
  # is truthy, otherwise disabled. Production always disables capture.
159
171
  mattr_accessor :local_email_capture, default: nil
160
172
 
173
+ # The role Studio::LocalReviewsController stamps on the account it provisions
174
+ # for a local review (the board's WAITING APPROVAL button).
175
+ #
176
+ # It defaults to "admin" because the pages sent for review are overwhelmingly
177
+ # admin-gated, and the operator's address is his PRODUCTION one — an address a
178
+ # fresh worktree database has never seen, so the sign-in would otherwise CREATE
179
+ # him at the default role and `require_admin` would bounce him to "/". The
180
+ # sign-in succeeds and he never sees the page: the bug this knob exists to end.
181
+ #
182
+ # Set it to nil (or "") to provision the account WITHOUT touching its role —
183
+ # for an app whose review pages are not admin-gated, or whose role column
184
+ # means something else. Only ever reached behind local_tool_enabled?
185
+ # (non-production AND loopback), so it grants nothing a local reader could not
186
+ # already take from the local email inbox beside it.
187
+ mattr_accessor :local_review_role, default: "admin"
188
+
189
+ # WHO the local-review mint signs in when the caller names no `?email=`.
190
+ #
191
+ # The board's WAITING APPROVAL CTA is a public, sign-in-free redirect, so it
192
+ # sends no email — putting one in that URL would publish the operator's
193
+ # address on a public page. The local stack answers the question instead: it
194
+ # is the machine the reviewer is sitting at.
195
+ #
196
+ # nil (the default) means "derive": the first user in this database already
197
+ # holding local_review_role, by id — falling back to "admin" when that setting
198
+ # is itself nil. So a desk that switches local_review_role OFF has no role to
199
+ # derive FROM and should name its operator here explicitly, as should a desk
200
+ # whose operator is not the first seeded account at that role.
201
+ mattr_accessor :local_review_email, default: nil
202
+
161
203
  # Theme role colors (7 roles)
162
204
  mattr_accessor :theme_primary, default: "#8E82FE"
163
205
  mattr_accessor :theme_dark, default: "#1A1535"
@@ -176,6 +218,19 @@ module Studio
176
218
  mattr_accessor :s3_bucket_prefix, default: nil
177
219
  mattr_accessor :s3_region, default: "us-east-2"
178
220
 
221
+ # Optional key namespace INSIDE the bucket, so a satellite app can share an
222
+ # existing bucket instead of provisioning its own pair. Set it and every key a
223
+ # caller passes to Studio::S3 is stored/read under that prefix — callers keep
224
+ # passing logical keys ("email_banners/magic_link-ab12.jpg") and never see it:
225
+ #
226
+ # config.s3_bucket_prefix = "mcritchie-studio"
227
+ # config.s3_key_prefix = "mcritchie-industries/"
228
+ # # -> s3://mcritchie-studio-dev/mcritchie-industries/email_banners/...
229
+ #
230
+ # Default nil = no namespace, so every already-shipped app's keys are unchanged.
231
+ # A trailing slash is added if you leave it off.
232
+ mattr_accessor :s3_key_prefix, default: nil
233
+
179
234
  class S3ConfigError < StandardError; end
180
235
 
181
236
  # Whether to validate the host app's User model at boot. See docs/USER_CONTRACT.md.
@@ -475,11 +530,50 @@ module Studio
475
530
  get "admin/style", to: "style#index", as: :admin_style
476
531
  get "admin/design_system", to: redirect("/admin/style"), as: :admin_design_system
477
532
 
478
- # Admin-managed transactional-email banner images (Studio::EmailImage).
479
- # index lists each managed email variant + its current banner; update
480
- # uploads a replacement. Surfaced from each app's admin hub.
533
+ # The standard transactional-email page. Canonical at /admin/emails
534
+ # (Studio::EmailsController): index lists every registered email with its
535
+ # live banner and whether that banner is inherited or app-owned; update
536
+ # stores this app's own override; destroy drops it back to the inherited
537
+ # default. Surfaced from each app's admin sidebar.
538
+ #
539
+ # /admin/email_images redirects here but KEEPS its admin_email_images_path
540
+ # helper, so a shipped host sidebar link on the old helper still resolves
541
+ # (same treatment as /admin/design_system -> /admin/style).
542
+ # OPT-IN, and it has to be. turf-monster ALREADY owns /admin/emails —
543
+ # `namespace :admin { get "emails", as: :emails }` (its EmailCatalog
544
+ # manager) — which claims the SAME path and the SAME helper names,
545
+ # admin_emails_path and admin_email_path. Drawing these unconditionally
546
+ # raises `ArgumentError: Invalid route name, already in use: 'admin_emails'`
547
+ # while turf-monster's own routes.rb is loading, which takes down its
548
+ # ENTIRE route set (every admin_*_path in the app goes undefined) — not a
549
+ # shadowed page, a dead app. Confirmed on consumer CI, PR #86.
550
+ #
551
+ # A host cannot opt out of something that breaks it before its config is
552
+ # read, and consumer CI runs each consumer's `main` — so default-on cannot
553
+ # be fixed from inside the engine. Default-off, and each app's adoption
554
+ # task turns it on. Flip the default once no consumer's main owns the name.
555
+ #
556
+ # This gates only the PAGE. The registry and the two-layer image resolution
557
+ # are always on, and the engine's own UserMailer already calls
558
+ # Studio::EmailImage.resolved_url — so an app is branded on day one whether
559
+ # or not it draws the page.
560
+ if Studio.draw_admin_emails_routes
561
+ get "admin/emails", to: "studio/emails#index", as: :admin_emails
562
+ patch "admin/emails/:key", to: "studio/emails#update", as: :admin_email,
563
+ constraints: { key: /[a-z0-9_]+/ }
564
+ delete "admin/emails/:key", to: "studio/emails#destroy",
565
+ constraints: { key: /[a-z0-9_]+/ }
566
+ end
567
+
568
+ # DEPRECATED, kept for ONE release. Not a redirect: consumer-ci.yml runs
569
+ # each consumer's DEFAULT-BRANCH suite against this engine, and both
570
+ # mcritchie-studio and turf-monster have tests on `main` that GET this page
571
+ # and PATCH through admin_email_image_path. Redirecting (or deleting) here
572
+ # reddens their lanes the moment the PR opens, and no change inside the
573
+ # engine PR can fix it. Each app's adoption task moves its link + tests; a
574
+ # later engine minor deletes these two routes with the controller and view.
481
575
  get "admin/email_images", to: "studio/email_images#index", as: :admin_email_images
482
- patch "admin/email_images/:variant", to: "studio/email_images#update", as: :admin_email_image,
576
+ patch "admin/email_images/:variant", to: "studio/email_images#update", as: :admin_email_image,
483
577
  constraints: { variant: /[a-z_]+/ }
484
578
 
485
579
  # Model-page protocol (v1) — a reusable per-record inspector. Drawn into
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.33.0
4
+ version: 0.37.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-10 00:00:00.000000000 Z
11
+ date: 2026-08-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -168,6 +168,8 @@ files:
168
168
  - Gemfile
169
169
  - LICENSE
170
170
  - README.md
171
+ - app/assets/images/emails/email-change-confirmation.png
172
+ - app/assets/images/emails/magic-link.png
171
173
  - app/assets/images/resend-favicon.png
172
174
  - app/assets/images/ses-favicon.png
173
175
  - app/assets/javascripts/studio/canvas_confetti.js
@@ -193,6 +195,7 @@ files:
193
195
  - app/controllers/sessions_controller.rb
194
196
  - app/controllers/solana_sessions_controller.rb
195
197
  - app/controllers/studio/email_images_controller.rb
198
+ - app/controllers/studio/emails_controller.rb
196
199
  - app/controllers/studio/links_controller.rb
197
200
  - app/controllers/studio/local_emails_controller.rb
198
201
  - app/controllers/studio/local_reviews_controller.rb
@@ -271,6 +274,8 @@ files:
271
274
  - app/views/studio/board/_card_shell.html.erb
272
275
  - app/views/studio/board/_column.html.erb
273
276
  - app/views/studio/email_images/index.html.erb
277
+ - app/views/studio/emails/_row.html.erb
278
+ - app/views/studio/emails/index.html.erb
274
279
  - app/views/studio/links/confirm.html.erb
275
280
  - app/views/studio/local_emails/index.html.erb
276
281
  - app/views/studio/modals/_crop_photo.html.erb
@@ -278,6 +283,7 @@ files:
278
283
  - app/views/studio/modals/_image_upload.html.erb
279
284
  - app/views/studio/modals/_load_convention.html.erb
280
285
  - app/views/studio/modals/_saving.html.erb
286
+ - app/views/studio/modals/_scoped_host.html.erb
281
287
  - app/views/studio/modals/auth/_resend_footer.html.erb
282
288
  - app/views/studio/modals/blocks/_age_verify.html.erb
283
289
  - app/views/studio/modals/blocks/_card_header.html.erb