studio-engine 0.36.0 → 0.38.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.
@@ -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.36.0"
2
+ VERSION = "0.38.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.
@@ -206,6 +218,19 @@ module Studio
206
218
  mattr_accessor :s3_bucket_prefix, default: nil
207
219
  mattr_accessor :s3_region, default: "us-east-2"
208
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
+
209
234
  class S3ConfigError < StandardError; end
210
235
 
211
236
  # Whether to validate the host app's User model at boot. See docs/USER_CONTRACT.md.
@@ -505,11 +530,57 @@ module Studio
505
530
  get "admin/style", to: "style#index", as: :admin_style
506
531
  get "admin/design_system", to: redirect("/admin/style"), as: :admin_design_system
507
532
 
508
- # Admin-managed transactional-email banner images (Studio::EmailImage).
509
- # index lists each managed email variant + its current banner; update
510
- # 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
+ # /raw is drawn BEFORE /:key so "raw" is never captured as a key.
563
+ get "admin/emails/:key/raw", to: "studio/emails#raw", as: :admin_email_raw,
564
+ constraints: { key: /[a-z0-9_]+/ }
565
+ get "admin/emails/:key", to: "studio/emails#show", as: :admin_email,
566
+ constraints: { key: /[a-z0-9_]+/ }
567
+ # Same path, same helper (admin_email_path) — a named route only needs
568
+ # to be declared once per name, and these share the show route's URL.
569
+ patch "admin/emails/:key", to: "studio/emails#update",
570
+ constraints: { key: /[a-z0-9_]+/ }
571
+ delete "admin/emails/:key", to: "studio/emails#destroy",
572
+ constraints: { key: /[a-z0-9_]+/ }
573
+ end
574
+
575
+ # DEPRECATED, kept for ONE release. Not a redirect: consumer-ci.yml runs
576
+ # each consumer's DEFAULT-BRANCH suite against this engine, and both
577
+ # mcritchie-studio and turf-monster have tests on `main` that GET this page
578
+ # and PATCH through admin_email_image_path. Redirecting (or deleting) here
579
+ # reddens their lanes the moment the PR opens, and no change inside the
580
+ # engine PR can fix it. Each app's adoption task moves its link + tests; a
581
+ # later engine minor deletes these two routes with the controller and view.
511
582
  get "admin/email_images", to: "studio/email_images#index", as: :admin_email_images
512
- patch "admin/email_images/:variant", to: "studio/email_images#update", as: :admin_email_image,
583
+ patch "admin/email_images/:variant", to: "studio/email_images#update", as: :admin_email_image,
513
584
  constraints: { variant: /[a-z_]+/ }
514
585
 
515
586
  # 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.36.0
4
+ version: 0.38.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
@@ -220,6 +223,7 @@ files:
220
223
  - app/models/studio/model_page.rb
221
224
  - app/models/theme_setting.rb
222
225
  - app/services/google_oauth_validator.rb
226
+ - app/services/studio/email_catalog.rb
223
227
  - app/services/studio/email_image.rb
224
228
  - app/views/components/_admin_dropdown.html.erb
225
229
  - app/views/components/_avatar.html.erb
@@ -271,6 +275,9 @@ files:
271
275
  - app/views/studio/board/_card_shell.html.erb
272
276
  - app/views/studio/board/_column.html.erb
273
277
  - app/views/studio/email_images/index.html.erb
278
+ - app/views/studio/emails/_row.html.erb
279
+ - app/views/studio/emails/index.html.erb
280
+ - app/views/studio/emails/show.html.erb
274
281
  - app/views/studio/links/confirm.html.erb
275
282
  - app/views/studio/local_emails/index.html.erb
276
283
  - app/views/studio/modals/_crop_photo.html.erb
@@ -278,6 +285,7 @@ files:
278
285
  - app/views/studio/modals/_image_upload.html.erb
279
286
  - app/views/studio/modals/_load_convention.html.erb
280
287
  - app/views/studio/modals/_saving.html.erb
288
+ - app/views/studio/modals/_scoped_host.html.erb
281
289
  - app/views/studio/modals/auth/_resend_footer.html.erb
282
290
  - app/views/studio/modals/blocks/_age_verify.html.erb
283
291
  - app/views/studio/modals/blocks/_card_header.html.erb