studio-engine 0.88.0 → 0.90.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e16f65ea44055886fea1ffb0743a9415caa612f858caa0ff7dabb7c87beeeb15
4
- data.tar.gz: 9690ad40a9e766646995ddfa1c69c8c22191e3509bb944d5c184ea5c1a1b21e2
3
+ metadata.gz: c11c676d845c483117f043e7925f84cc0d7128dfa170740dd3d3c1c484438259
4
+ data.tar.gz: a873622a9676a51d1864612008e09a4e7f8822a371e275b5e0586ea9b252ad9e
5
5
  SHA512:
6
- metadata.gz: ae4786abebc9f1d9dc86cc3992f4a6f4225c4fceaa20b73fca12ddd43afad1e4d6ef2807a8d3302bdf2d811792995fdac42961a02c6b70637f8923e841e61402
7
- data.tar.gz: 3ba6c43faf9e1f7a03596b70924fc099469d8c95ce967f63e4a2ba8f4f0da0112db6068e701e739fa7e3ce5435bd07ee456b2982fec91ebb9561a4bc3dd18b09
6
+ metadata.gz: eb5630d5317933c839f352af196f567be745f13c53ba90c503f373ae0869f2084ce0bff41a47489056a85cf36731dd39974f30f7ab553b9f6b43d920f353a6a4
7
+ data.tar.gz: a4651700a47b3700f99ed6c00f9e56d5e9682b8771843d4156ca1566b66844a03e1d7a846889f3d5283bf301d537e301bc59284149f0ac97a9f0ad8e6fcbed7c
data/CHANGELOG.md CHANGED
@@ -4,6 +4,44 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.90.0 — 2026-10-06
8
+
9
+ ### Removed
10
+
11
+ - **`components/card`, `components/input` and `components/progress_bar`.** No
12
+ app renders these partials. The `card` and `input-field` utilities they wrapped
13
+ still ship; write the classes on your own markup.
14
+ - **`Studio::Impersonation`** and its four settings,
15
+ `impersonation_target_session_key`, `impersonation_actor_session_key`,
16
+ `impersonation_started_at_session_key` and `impersonation_max_minutes`. No app
17
+ includes the concern; each owns its Act As session. The
18
+ `studio/banners/impersonation` banner stays.
19
+ - **`Studio::Broadcastable`.** No app includes it. Wrap a broadcast in
20
+ `Studio::Cable.safe_broadcast { broadcast_replace_to ... }` instead
21
+ (`docs/CABLE.md`).
22
+ - **`Studio::UserProfile`.** No app includes it; each `User` defines
23
+ `display_name`, `avatar_initials` and `avatar_color` itself. The initial-circle
24
+ palette moves to `Studio::AVATAR_COLORS`.
25
+ - **`/admin/email_images`**, `Studio::EmailImagesController`, its view and the
26
+ `admin_email_images_path` and `admin_email_image_path` helpers. `/admin/emails`
27
+ replaces the page, and no app links or tests it.
28
+
29
+ ## 0.89.0 — 2026-10-05
30
+
31
+ ### Changed
32
+
33
+ - **A survey opens on question 1.** The intro screen and its Start button are
34
+ gone: a respondent lands on "Question 1 of N" with the progress bar started,
35
+ and question 1 shows no Back button. The definition's `intro` becomes a quiet
36
+ lead line above question 1, and its `title` stays the page's `h1`; when the
37
+ title repeats question 1 (`Studio::Survey#title_repeats_first_question?`), the
38
+ stepper hides it visually so the same words are not painted twice. Number keys
39
+ and Enter work from the first paint. Returning respondents still resume on
40
+ their first unanswered question, and the no-JavaScript form shows the title
41
+ and intro at the top. No host change is needed.
42
+ - **The survey's "Press a number to choose" hint** no longer shows at phone width
43
+ (under 640px), as well as on touch screens.
44
+
7
45
  ## 0.88.0 — 2026-10-05
8
46
 
9
47
  ### Added
data/README.md CHANGED
@@ -34,7 +34,7 @@ resolved.
34
34
  - **Session drift**: every page carries a session stamp and loads `window.StudioSession`, which notices when another tab signs in or out, the session expires, or the server revokes it; `session:changed` / `session:mismatch` events, cross-tab sync, an identity-source plug-in interface, and `expectChange` holds for deliberate switches. The rehydrate endpoint (`GET /session/state`) is opt-in. See [`docs/SESSION_DRIFT.md`](docs/SESSION_DRIFT.md).
35
35
  - **Theme system**: Dynamic CSS custom properties generated from 7 role colors (primary, dark, light, success, accent, warning, danger). Dark/light mode toggle. Admin theme editor at `/admin/theme`.
36
36
  - **UI primitives**: Shared component partials and CSS primitives such as `components/emoji_swap` for nav/sidebar emoji hover transitions.
37
- - **Operator tooling**: Shared `studio/banners/environment` banner with Dev Mode + email connector controls, `studio/banners/impersonation`, and an opt-in `Studio::Impersonation` concern for Act As session conventions.
37
+ - **Operator tooling**: Shared `studio/banners/environment` banner with Dev Mode + email connector controls and `studio/banners/impersonation`.
38
38
  - **Sluggable concern**: `before_save :set_slug` with `to_param` for human-readable URLs
39
39
  - **ThemeSetting model**: Per-app DB overrides with fallback to config defaults
40
40
  - **Geo**: `Studio::GeoDetection` places every visitor (IP → country + subdivision, session-cached), `Studio::GeoSetting` stores the operator's blocked countries and regions, `require_geo_allowed` locks whichever surfaces an app chooses, and the shared badge + `/admin/geo` manager ship with it. See [`docs/GEO.md`](docs/GEO.md).
@@ -169,22 +169,8 @@ with their own users and return route:
169
169
  stop_path: admin_stop_impersonating_path %>
170
170
  ```
171
171
 
172
- The engine also provides an optional `Studio::Impersonation` concern for the
173
- session convention:
174
-
175
- ```ruby
176
- class ApplicationController < ActionController::Base
177
- include Studio::ErrorHandling
178
- include Studio::Impersonation
179
- end
180
- ```
181
-
182
- The concern adds `true_user`, `impersonated_user`, `impersonating?`,
183
- `start_impersonation_session(target_user, actor:)`, and
184
- `clear_impersonation_session`. Consumer apps still own the authorization rule,
185
- audit log, enter/exit controller actions, and any app-specific safeguards such
186
- as binding session-token checks to `true_user` or disabling wallet-only
187
- privileges while impersonating.
172
+ Each app owns its Act As session, authorization rule, audit log and enter/exit
173
+ actions; the engine supplies only the banner.
188
174
 
189
175
  ## UI Primitives
190
176
 
@@ -702,8 +688,7 @@ matching slot is absent, so existing call sites render unchanged.
702
688
  Every consuming app can render the same admin page — **`/admin/emails`** —
703
689
  listing each transactional email it sends with the banner riding at the top of
704
690
  that email, and whether that banner is the **inherited default** or an
705
- **app-owned override**. Supersedes the old `/admin/email_images`, which is
706
- deprecated but still renders for one release.
691
+ **app-owned override**.
707
692
 
708
693
  **The page is opt-in:**
709
694
 
@@ -7,7 +7,7 @@ module Studio
7
7
  # It lists Studio::EmailCatalog's registry — one row per registered email, each
8
8
  # showing its live banner and whether that banner is the INHERITED engine
9
9
  # default or an APP-OWNED override — and writes an override through the shared
10
- # crop modal. Replaces /admin/email_images, which now redirects here.
10
+ # crop modal.
11
11
  #
12
12
  # An app whose host never set Studio.s3_bucket_prefix cannot store an override.
13
13
  # That is a read-only page, not an error: uploads_available? gates the write
@@ -762,8 +762,8 @@ module Studio
762
762
  # THE SAME QUESTION background_url ASKS, so it must ask it the same way.
763
763
  # This guard read engine_artwork? while background_url read layered?, and the
764
764
  # two answer differently for a host that registers its own background: the
765
- # mailer sent the layered banner while /admin/email_images and the detail
766
- # page's "Artwork" frame both drew the flat asset. That frame is where
765
+ # mailer sent the layered banner while the detail page's "Artwork" frame
766
+ # drew the flat asset. That frame is where
767
767
  # "Modify image" lives, and an upload writes the row background_url reads
768
768
  # FIRST — so the operator was shown one picture and told it was the one the
769
769
  # button would replace.
@@ -20,8 +20,7 @@ module Studio
20
20
  # stays green. A host that did reference it gets a NameError, not a silent
21
21
  # wrong answer.
22
22
  #
23
- # Delete it once no consumer's main names it — the same staged retirement
24
- # /admin/email_images is on.
23
+ # Delete it once no consumer's main names it.
25
24
  #
26
25
  # Deliberately explicit rather than method_missing: a typo should still raise
27
26
  # NoMethodError here, and the delegated surface should be readable as a list.
@@ -19,7 +19,7 @@
19
19
  <%= render "components/avatar", user: @public_user, size: "xl", alt: username %>
20
20
  <% else %>
21
21
  <%# Drawn from the username alone: a host's avatar_initials / avatar_color may read the name or email (cyvasse's do). %>
22
- <% colors = Studio::UserProfile::AVATAR_COLORS %>
22
+ <% colors = Studio::AVATAR_COLORS %>
23
23
  <div class="w-28 h-28 text-4xl rounded-full flex items-center justify-center font-bold text-white"
24
24
  style="background-color: <%= colors[Digest::MD5.hexdigest(username).hex % colors.size] %>"><%= username[0].upcase %></div>
25
25
  <% end %>
@@ -19,7 +19,9 @@
19
19
 
20
20
  var form = root.querySelector("[data-survey-form]");
21
21
  var steps = Array.prototype.slice.call(root.querySelectorAll("[data-survey-step]"));
22
- var total = steps.length - 1;
22
+ // Every step is a question: the survey opens on question 1, no intro screen.
23
+ var total = steps.length;
24
+ var head = root.querySelector("[data-survey-head]");
23
25
  var back = root.querySelector("[data-survey-back]");
24
26
  var next = root.querySelector("[data-survey-next]");
25
27
  var submit = root.querySelector("[data-survey-submit]");
@@ -128,21 +130,23 @@
128
130
  }
129
131
  });
130
132
  current = index;
131
- var onIntro = index === 0;
133
+ var first = index === 0;
132
134
  var last = index === steps.length - 1;
133
- back.hidden = onIntro;
135
+ // Nothing comes before question 1, so it has no Back. Next keeps the
136
+ // right edge on its own (margin-left: auto), so the row does not shift.
137
+ back.hidden = first;
134
138
  next.hidden = last;
135
- next.textContent = onIntro ? "Start" : "Next";
136
139
  submit.hidden = !last;
140
+ // The title and intro lead question 1 only; later screens are the question alone.
141
+ if (head) head.hidden = !first;
137
142
  // The number-key shortcut only means something on a choice or scale.
138
143
  var step = steps[index];
139
144
  hint.hidden = !(step.dataset.key && (isChoice(step) || isMulti(step)));
140
145
 
141
- var position = onIntro ? 0 : index;
146
+ var position = index + 1;
142
147
  fill.style.width = (total ? (position / total) * 100 : 0) + "%";
143
148
  bar.setAttribute("aria-valuenow", String(position));
144
- // The intro already says how many questions there are.
145
- count.textContent = onIntro ? "" : "Question " + index + " of " + total;
149
+ count.textContent = "Question " + position + " of " + total;
146
150
 
147
151
  var focusTarget = steps[index].querySelector("[data-survey-focus]");
148
152
  if (focusTarget && direction) focusTarget.focus({ preventScroll: true });
@@ -182,7 +186,7 @@
182
186
  // The first required question still missing an answer, for a Submit that
183
187
  // jumps ahead (Enter on the last step, or a mid-survey reload).
184
188
  form.addEventListener("submit", function (event) {
185
- for (var i = 1; i < steps.length; i++) {
189
+ for (var i = 0; i < steps.length; i++) {
186
190
  if (steps[i].dataset.required === "true" && !answered(steps[i])) {
187
191
  event.preventDefault();
188
192
  go(i);
@@ -230,7 +234,7 @@
230
234
  timers[step.dataset.key] = setTimeout(function () { save(step); }, 900);
231
235
  });
232
236
 
233
- root.addEventListener("keydown", function (event) {
237
+ function onKey(event) {
234
238
  if (event.defaultPrevented || event.altKey) return;
235
239
  // A tap on an already-chosen option fires pointerdown but no change, so
236
240
  // the flag would outlive it and make the next arrow key advance.
@@ -256,15 +260,29 @@
256
260
  option.focus({ preventScroll: true });
257
261
  pointerPick = isChoice(step);
258
262
  option.dispatchEvent(new Event("change", { bubbles: true }));
259
- });
263
+ }
264
+
265
+ root.addEventListener("keydown", onKey);
266
+ // The survey opens on question 1 with nothing focused, so a first key
267
+ // lands on <body>, outside the root. Take those keys too, and only those:
268
+ // a key aimed at any other control on the page is not the survey's. The
269
+ // listener retires itself once a Turbo visit has taken this root away.
270
+ function onPageKey(event) {
271
+ if (!root.isConnected) { document.removeEventListener("keydown", onPageKey); return; }
272
+ var target = event.target;
273
+ if (target === document.body || target === document.documentElement) onKey(event);
274
+ }
275
+ document.addEventListener("keydown", onPageKey);
260
276
 
261
277
  var resume = root.dataset.resumeIndex;
262
- var start = resume === "" || resume === undefined ? 0 : parseInt(resume, 10) + 1;
278
+ // resume-index is the question's own index, and every step is a question.
279
+ var start = resume === "" || resume === undefined ? 0 : parseInt(resume, 10);
280
+ if (!(start >= 0 && start < steps.length)) start = 0;
263
281
  var hasErrors = root.dataset.hasErrors === "true";
264
- // A fresh visitor sees the intro; a returning one lands on their next
282
+ // A fresh visitor lands on question 1; a returning one on their next
265
283
  // question, and a failed submit on the first question it flagged.
266
284
  if (restored >= 0) start = restored;
267
- render(start > 0 ? start : 0, null);
285
+ render(start, null);
268
286
  if (restored < 0 && (start > 0 || hasErrors)) {
269
287
  var focusTarget = steps[current].querySelector("[data-survey-focus]");
270
288
  if (focusTarget) focusTarget.focus({ preventScroll: true });
@@ -18,6 +18,15 @@
18
18
  .studio-survey__title { margin: 0 0 .75rem; font-size: 1.75rem; line-height: 1.2; font-weight: 800; color: var(--color-text); outline: none; }
19
19
  .studio-survey__intro-copy { margin: 0 0 1rem; font-size: 1.0625rem; line-height: 1.55; color: var(--color-text-body); white-space: pre-line; }
20
20
  .studio-survey__meta { margin: 0; font-size: .8125rem; color: var(--color-text-muted); }
21
+ .studio-survey__head { margin: 0 0 2.25rem; }
22
+ /* Stepped, the head leads question 1 quietly: a small title (or none, when it
23
+ repeats question 1, kept for screen readers), the intro as one lead line,
24
+ and no meta, since the count above already says "Question 1 of N". */
25
+ .studio-survey.is-enhanced .studio-survey__head { margin-bottom: 1.25rem; }
26
+ .studio-survey.is-enhanced .studio-survey__title { margin: 0 0 .375rem; font-size: 1rem; line-height: 1.4; font-weight: 700; color: var(--color-text-secondary); }
27
+ .studio-survey.is-enhanced .studio-survey__head--echo .studio-survey__title { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
28
+ .studio-survey.is-enhanced .studio-survey__intro-copy { margin: 0; font-size: .9375rem; line-height: 1.5; color: var(--color-text-secondary); }
29
+ .studio-survey.is-enhanced .studio-survey__meta { display: none; }
21
30
  .studio-survey__question { display: block; width: 100%; padding: 0; margin: 0 0 .5rem; font-size: 1.375rem; line-height: 1.3; font-weight: 700; color: var(--color-text); outline: none; }
22
31
  .studio-survey__qnum { display: block; margin-bottom: .375rem; font-size: .75rem; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--color-text-muted); }
23
32
  .studio-survey.is-enhanced .studio-survey__qnum { display: none; }
@@ -68,7 +77,9 @@
68
77
  .studio-survey__forward { margin-left: auto; }
69
78
  .studio-survey__status { min-height: 1.25rem; margin: .5rem 0 0; font-size: .75rem; text-align: center; color: var(--color-text-muted); }
70
79
  .studio-survey__hint { margin: .25rem 0 0; font-size: .75rem; text-align: center; color: var(--color-text-muted); }
71
- @media (hover: none), (pointer: coarse) { .studio-survey__hint { display: none; } }
80
+ /* The hint speaks to a keyboard. A touch screen has none, and a phone-width
81
+ window is a phone more often than not, so neither shows it. */
82
+ @media (hover: none), (pointer: coarse), (max-width: 639px) { .studio-survey__hint { display: none; } }
72
83
 
73
84
  .studio-survey__card { padding: 2rem 1.5rem; text-align: center; border: 1px solid var(--color-border); border-radius: 1.25rem; background: var(--color-surface); }
74
85
  .studio-survey__burst { font-size: 3rem; line-height: 1; margin-bottom: .75rem; }
@@ -86,7 +97,7 @@
86
97
  @media (min-width: 640px) {
87
98
  .studio-survey { padding-top: 3rem; }
88
99
  .studio-survey__question { font-size: 1.625rem; }
89
- .studio-survey__title { font-size: 2.125rem; }
100
+ .studio-survey:not(.is-enhanced) .studio-survey__title { font-size: 2.125rem; }
90
101
  .studio-survey__scale { gap: .75rem; }
91
102
  .studio-survey__tile { min-height: 5.5rem; }
92
103
  .studio-survey__face { font-size: 2.5rem; }
@@ -1,9 +1,10 @@
1
1
  <%# /surveys/:slug — the survey (Studio::SurveysController#show, docs/SURVEYS.md).
2
2
 
3
3
  ONE FORM, TWO PRESENTATIONS. Without JavaScript every question renders in
4
- order and the Submit button posts the lot. The script below adds
5
- .is-enhanced and turns the same markup into one question per screen, with a
6
- progress bar, Back and Next, number-key and Enter shortcuts, and an
4
+ order under the title and intro, and the Submit button posts the lot. The
5
+ script below adds .is-enhanced and turns the same markup into one question
6
+ per screen, opening on question 1 with no intro screen, with a progress
7
+ bar, Back (from question 2 on) and Next, number-key and Enter shortcuts, and an
7
8
  autosave on each answer. Submit is still the same POST either way, so the
8
9
  server's validation is the one rule. %>
9
10
  <% total = @survey.questions.size %>
@@ -28,10 +29,14 @@
28
29
 
29
30
  <%= form_with url: studio_survey_path(@survey.slug), method: :post, local: true,
30
31
  html: { class: "studio-survey__form", data: { survey_form: true } } do %>
31
- <section class="studio-survey__step" data-survey-step data-intro aria-labelledby="studio-survey-title">
32
- <h1 class="studio-survey__title" id="studio-survey-title" tabindex="-1" data-survey-focus><%= @survey.title %></h1>
32
+ <%# The head is not a step: the survey opens straight on question 1. The
33
+ title stays the page's h1 for a screen reader; when it says what
34
+ question 1 asks, the stepper hides it visually rather than paint the
35
+ same words twice. The intro is a quiet lead line over question 1. %>
36
+ <header class="studio-survey__head<%= " studio-survey__head--echo" if @survey.title_repeats_first_question? %>" data-survey-head>
37
+ <h1 class="studio-survey__title" id="studio-survey-title"><%= @survey.title %></h1>
33
38
  <% if @survey.intro %>
34
- <p class="studio-survey__intro-copy"><%= @survey.intro %></p>
39
+ <p class="studio-survey__intro-copy" data-survey-intro><%= @survey.intro %></p>
35
40
  <% end %>
36
41
  <p class="studio-survey__meta">
37
42
  <%= pluralize(total, "question") %> · about <%= pluralize(minutes, "minute") %><% if @survey.required_keys.any? %> · <span aria-hidden="true">*</span> required<% end %>
@@ -39,7 +44,7 @@
39
44
  <% if @errors.any? %>
40
45
  <p class="studio-survey__error" role="alert">A few answers need another look before this can be sent.</p>
41
46
  <% end %>
42
- </section>
47
+ </header>
43
48
 
44
49
  <% @survey.questions.each_with_index do |question, index| %>
45
50
  <%= render "studio/surveys/question", question: question, index: index, total: total,
@@ -48,7 +53,7 @@
48
53
 
49
54
  <div class="studio-survey__nav">
50
55
  <button type="button" class="btn btn-neutral" data-survey-back hidden>Back</button>
51
- <button type="button" class="btn btn-primary studio-survey__forward" data-survey-next hidden>Start</button>
56
+ <button type="button" class="btn btn-primary studio-survey__forward" data-survey-next hidden>Next</button>
52
57
  <button type="submit" class="btn btn-primary studio-survey__forward" data-survey-submit>Submit</button>
53
58
  </div>
54
59
  <p class="studio-survey__status" aria-live="polite" data-survey-status></p>
data/lib/studio/engine.rb CHANGED
@@ -113,8 +113,8 @@ module Studio
113
113
  # skip :require_authentication, its mailers and jobs subclass ActionMailer
114
114
  # and ActiveJob, and the two concerns roots (app/controllers/concerns,
115
115
  # app/models/concerns) are roots of their OWN to Zeitwerk: excluding
116
- # app/models does not exclude app/models/concerns, and Sluggable and
117
- # Studio::UserProfile sit there. They stay lazily loadable, and nothing in a
116
+ # app/models does not exclude app/models/concerns, and Sluggable sits
117
+ # there. They stay lazily loadable, and nothing in a
118
118
  # host that draws no Studio.routes asks for them.
119
119
  #
120
120
  # Read off the engine's own eager-load paths rather than listed, so a root
data/lib/studio/survey.rb CHANGED
@@ -241,6 +241,15 @@ module Studio
241
241
  def keys = questions.map(&:key)
242
242
  def required_keys = questions.select(&:required?).map(&:key)
243
243
 
244
+ # True when the title says what the first question asks ("How was your
245
+ # first game?" twice). The survey opens on that question, so the stepper
246
+ # keeps the title as the page's heading for a screen reader but does not
247
+ # paint it twice. Compared on letters and digits alone, ignoring case.
248
+ def title_repeats_first_question?
249
+ squash = ->(text) { text.to_s.downcase.gsub(/[^[:alnum:]]/, "") }
250
+ squash.call(title) == squash.call(questions.first.label)
251
+ end
252
+
244
253
  private
245
254
 
246
255
  def validate!
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.88.0"
2
+ VERSION = "0.90.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -839,6 +839,10 @@ module Studio
839
839
  # Studio::ProfilesController to get it would couple the two for no reason.
840
840
  FIRST_NAME_MAX_LENGTH = 40
841
841
 
842
+ # The house palette for initial-circle backgrounds. /u/:username hashes the
843
+ # username into it, so one person draws the same circle on every render.
844
+ AVATAR_COLORS = %w[#EF4444 #F97316 #EAB308 #22C55E #06B6D4 #3B82F6 #8B5CF6 #EC4899].freeze
845
+
842
846
  # How long a WHOLE typed answer may be — one string holding a full name, which
843
847
  # the onboarding step stores as users.name and SPLITS into the two halves
844
848
  # above (Studio::NameParts).
@@ -914,14 +918,6 @@ module Studio
914
918
  true
915
919
  end
916
920
 
917
- # Optional admin Act As / impersonation session conventions. Consumers that
918
- # include Studio::Impersonation get current_user layered over true_user with
919
- # these session keys, but still own authorization, audit logging, and routes.
920
- mattr_accessor :impersonation_target_session_key, default: :impersonated_user_id
921
- mattr_accessor :impersonation_actor_session_key, default: :true_admin_id
922
- mattr_accessor :impersonation_started_at_session_key, default: :impersonation_started_at
923
- mattr_accessor :impersonation_max_minutes, default: 30
924
-
925
921
  # Cap for the host app's local (development + test) log file, in bytes.
926
922
  # nil means "use the engine's defaults" — Studio::Engine::DEVELOPMENT_LOG_MAX_BYTES
927
923
  # and ::TEST_LOG_MAX_BYTES. An Integer sets your own cap; `false` opts out and
@@ -1530,9 +1526,6 @@ module Studio
1530
1526
  # stores this app's own override; destroy drops it back to the inherited
1531
1527
  # default. Surfaced from each app's admin sidebar.
1532
1528
  #
1533
- # /admin/email_images redirects here but KEEPS its admin_email_images_path
1534
- # helper, so a shipped host sidebar link on the old helper still resolves
1535
- # (same treatment as /admin/design_system -> /admin/style).
1536
1529
  # OPT-IN, and it has to be. turf-monster ALREADY owns /admin/emails —
1537
1530
  # `namespace :admin { get "emails", as: :emails }` (its EmailCatalog
1538
1531
  # manager) — which claims the SAME path and the SAME helper names,
@@ -1592,17 +1585,6 @@ module Studio
1592
1585
  as: :onboarding_skip_first_name
1593
1586
  end
1594
1587
 
1595
- # DEPRECATED, kept for ONE release. Not a redirect: consumer-ci.yml runs
1596
- # each consumer's DEFAULT-BRANCH suite against this engine, and both
1597
- # mcritchie-studio and turf-monster have tests on `main` that GET this page
1598
- # and PATCH through admin_email_image_path. Redirecting (or deleting) here
1599
- # reddens their lanes the moment the PR opens, and no change inside the
1600
- # engine PR can fix it. Each app's adoption task moves its link + tests; a
1601
- # later engine minor deletes these two routes with the controller and view.
1602
- get "admin/email_images", to: "studio/email_images#index", as: :admin_email_images
1603
- patch "admin/email_images/:variant", to: "studio/email_images#update", as: :admin_email_image,
1604
- constraints: { variant: /[a-z_]+/ }
1605
-
1606
1588
  # Surveys (docs/SURVEYS.md). Both halves OPT-IN — see
1607
1589
  # Studio.draw_survey_routes and Studio.draw_admin_survey_routes.
1608
1590
  if Studio.draw_survey_routes
@@ -40,7 +40,7 @@ Gem::Specification.new do |spec|
40
40
  spec.add_dependency "mini_magick", "~> 5.0"
41
41
  spec.add_dependency "resend", "~> 1.1"
42
42
  # Realtime: the redis cable/cache/Sidekiq adapter (Studio::Redis) + Turbo Streams
43
- # broadcasting (Studio::Broadcastable). `redis` is the dependency whose ABSENCE
43
+ # broadcasting (Studio::Cable). `redis` is the dependency whose ABSENCE
44
44
  # 500'd a host app's task board — declaring it here makes that impossible to repeat.
45
45
  #
46
46
  # The `< 6` ceiling is ActionCable's, not ours. Its redis pubsub adapter
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.88.0
4
+ version: 0.90.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-10-05 00:00:00.000000000 Z
11
+ date: 2026-10-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -323,7 +323,6 @@ files:
323
323
  - app/controllers/concerns/studio/board/reorderable.rb
324
324
  - app/controllers/concerns/studio/error_handling.rb
325
325
  - app/controllers/concerns/studio/geo_detection.rb
326
- - app/controllers/concerns/studio/impersonation.rb
327
326
  - app/controllers/concerns/studio/link_consumption.rb
328
327
  - app/controllers/concerns/studio/link_preview_bots.rb
329
328
  - app/controllers/concerns/studio/magic_link_issuing.rb
@@ -338,7 +337,6 @@ files:
338
337
  - app/controllers/solana_sessions_controller.rb
339
338
  - app/controllers/studio/admin_surveys_controller.rb
340
339
  - app/controllers/studio/bookings_controller.rb
341
- - app/controllers/studio/email_images_controller.rb
342
340
  - app/controllers/studio/emails_controller.rb
343
341
  - app/controllers/studio/geo_settings_controller.rb
344
342
  - app/controllers/studio/knowledge_docs_controller.rb
@@ -373,8 +371,6 @@ files:
373
371
  - app/mailers/user_mailer.rb
374
372
  - app/models/concerns/sluggable.rb
375
373
  - app/models/concerns/studio/board/rankable.rb
376
- - app/models/concerns/studio/broadcastable.rb
377
- - app/models/concerns/studio/user_profile.rb
378
374
  - app/models/current.rb
379
375
  - app/models/error_log.rb
380
376
  - app/models/image_cache.rb
@@ -399,18 +395,15 @@ files:
399
395
  - app/views/components/_avatar.html.erb
400
396
  - app/views/components/_avatar_cropper.html.erb
401
397
  - app/views/components/_badge.html.erb
402
- - app/views/components/_card.html.erb
403
398
  - app/views/components/_copy_button.html.erb
404
399
  - app/views/components/_emoji_swap.html.erb
405
400
  - app/views/components/_empty_state.html.erb
406
401
  - app/views/components/_geo_badge.html.erb
407
402
  - app/views/components/_geo_signpost.html.erb
408
403
  - app/views/components/_google_logo.html.erb
409
- - app/views/components/_input.html.erb
410
404
  - app/views/components/_json_debug.html.erb
411
405
  - app/views/components/_link_sidebar.html.erb
412
406
  - app/views/components/_link_sidebar_trigger.html.erb
413
- - app/views/components/_progress_bar.html.erb
414
407
  - app/views/components/_sidebar_panel.html.erb
415
408
  - app/views/components/_theme_toggle.html.erb
416
409
  - app/views/components/_theme_toggle_morph.html.erb
@@ -458,7 +451,6 @@ files:
458
451
  - app/views/studio/booking/_frame.html.erb
459
452
  - app/views/studio/booking/_popup.html.erb
460
453
  - app/views/studio/bookings/show.html.erb
461
- - app/views/studio/email_images/index.html.erb
462
454
  - app/views/studio/emails/_banner_editor.html.erb
463
455
  - app/views/studio/emails/_banner_preview.html.erb
464
456
  - app/views/studio/emails/_banner_scale.html.erb
@@ -1,90 +0,0 @@
1
- require "time"
2
-
3
- module Studio
4
- module Impersonation
5
- extend ActiveSupport::Concern
6
-
7
- included do
8
- if respond_to?(:helper_method)
9
- helper_method :true_user, :impersonated_user, :impersonating?, :impersonation_started_at
10
- end
11
- end
12
-
13
- private
14
-
15
- # The real session owner. While acting as another user, this remains the
16
- # admin/operator account whose session token should be verified.
17
- def true_user
18
- return @true_user if defined?(@true_user)
19
-
20
- @true_user = User.find_by(id: session[Studio.session_key])
21
- end
22
-
23
- # The currently visible user. Consumers with extra wallet or privilege state
24
- # can override this and still call true_user/impersonating? from the concern.
25
- def current_user
26
- return @current_user if defined?(@current_user)
27
-
28
- @current_user = impersonating? ? impersonated_user : true_user
29
- end
30
-
31
- def impersonated_user
32
- return @impersonated_user if defined?(@impersonated_user)
33
-
34
- @impersonated_user = User.find_by(id: session[Studio.impersonation_target_session_key])
35
- end
36
-
37
- def impersonating?
38
- return @impersonating if defined?(@impersonating)
39
-
40
- @impersonating = compute_impersonating?
41
- end
42
-
43
- def start_impersonation_session(target_user, actor: true_user)
44
- session[Studio.impersonation_actor_session_key] = actor.id
45
- session[Studio.impersonation_target_session_key] = target_user.id
46
- session[Studio.impersonation_started_at_session_key] = Time.current.iso8601
47
- reset_impersonation_memoization
48
- end
49
-
50
- def clear_impersonation_session
51
- session.delete(Studio.impersonation_actor_session_key)
52
- session.delete(Studio.impersonation_target_session_key)
53
- session.delete(Studio.impersonation_started_at_session_key)
54
- reset_impersonation_memoization
55
- end
56
-
57
- def impersonation_started_at
58
- raw = session[Studio.impersonation_started_at_session_key]
59
- return nil if raw.blank?
60
-
61
- return raw if raw.is_a?(Time)
62
-
63
- zone = Time.zone if Time.respond_to?(:zone)
64
- zone ? zone.parse(raw.to_s) : Time.parse(raw.to_s)
65
- rescue ArgumentError, TypeError
66
- nil
67
- end
68
-
69
- def compute_impersonating?
70
- return false if session[Studio.impersonation_target_session_key].blank?
71
- return false unless true_user&.respond_to?(:admin?) && true_user.admin?
72
- return false unless impersonation_started_at
73
- return false if impersonation_started_at < Studio.impersonation_max_minutes.to_i.minutes.ago
74
-
75
- target = impersonated_user
76
- target.present? &&
77
- (!target.respond_to?(:admin?) || !target.admin?) &&
78
- target.id != true_user.id
79
- rescue StandardError
80
- false
81
- end
82
-
83
- def reset_impersonation_memoization
84
- remove_instance_variable(:@current_user) if defined?(@current_user)
85
- remove_instance_variable(:@true_user) if defined?(@true_user)
86
- remove_instance_variable(:@impersonated_user) if defined?(@impersonated_user)
87
- remove_instance_variable(:@impersonating) if defined?(@impersonating)
88
- end
89
- end
90
- end
@@ -1,76 +0,0 @@
1
- module Studio
2
- # DEPRECATED — superseded by Studio::EmailsController (/admin/emails), which
3
- # shows every registered email with its live image, says whether that image is
4
- # the inherited default or app-owned, and uploads through the crop modal.
5
- #
6
- # Kept alive for ONE release on purpose, not by neglect. consumer-ci.yml checks
7
- # out each consumer repo with no `ref:` — i.e. its DEFAULT BRANCH — and runs
8
- # that suite against the engine PR. mcritchie-studio and turf-monster both have
9
- # tests on `main` that drive this page and its admin_email_image_path helper,
10
- # so deleting it here would redden their lanes from the moment the PR opens,
11
- # and nothing inside the engine PR could fix it: the consumer PRs would have to
12
- # travel accepted -> release -> main in BOTH apps before this could go green.
13
- #
14
- # So the retirement is staged: this release adds /admin/emails and leaves this
15
- # page working; each app's adoption task moves its link and its tests; a later
16
- # engine minor deletes this file, its view, and its two routes once no
17
- # consumer's main references them.
18
- #
19
- # It is not frozen in its broken state, though — see #index.
20
- class EmailImagesController < ApplicationController
21
- before_action :require_admin
22
-
23
- MAX_BYTES = 8.megabytes
24
-
25
- def index
26
- @variants = Studio::EmailCatalog.variants
27
- end
28
-
29
- # The canonical page this one is being retired in favour of, or nil when this
30
- # app has not drawn it. Rendered as a banner at the top of the old view so an
31
- # admin who lands here by a stale link is walked forward.
32
- #
33
- # The nil case is REAL, not defensive padding: /admin/emails is opt-in
34
- # (Studio.draw_admin_emails_routes, default off because turf-monster owns the
35
- # name), so on any app that has not opted in the helper does not exist and a
36
- # bare call raises NameError — which 500s this page. Consumer CI caught
37
- # exactly that on mcritchie-studio.
38
- #
39
- # Asks the ROUTER rather than the config flag: the flag is read at route-draw
40
- # time, so a host that flips it afterwards would still have no route, and the
41
- # router is the thing that actually knows.
42
- def successor_path
43
- return nil unless Rails.application.routes.named_routes.key?(:admin_emails)
44
-
45
- admin_emails_path
46
- end
47
- helper_method :successor_path
48
-
49
- # PATCH /admin/email_images/:variant — upload/replace a banner.
50
- def update
51
- variant = params[:variant].to_s
52
- return head :not_found unless Studio::EmailCatalog.known?(variant)
53
-
54
- file = params[:image]
55
- unless valid_image?(file)
56
- message = file.blank? ? "Choose an image to upload." : "Use a PNG, JPG, or WebP under 8 MB."
57
- return redirect_to admin_email_images_path, alert: message, status: :see_other
58
- end
59
-
60
- rescue_and_log do
61
- Studio::EmailCatalog.store(variant, io: file, content_type: file.content_type)
62
- redirect_to admin_email_images_path, notice: "#{Studio::EmailCatalog.label(variant)} banner updated."
63
- end
64
- rescue StandardError
65
- redirect_to admin_email_images_path, alert: "Couldn't save the image. Please try again.", status: :see_other
66
- end
67
-
68
- private
69
-
70
- def valid_image?(file)
71
- file.respond_to?(:content_type) &&
72
- file.content_type.to_s.start_with?("image/") &&
73
- file.respond_to?(:size) && file.size.to_i.positive? && file.size <= MAX_BYTES
74
- end
75
- end
76
- end
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Studio
4
- # Mixin for ActiveRecord models that broadcast Turbo Streams. It wraps
5
- # turbo-rails' broadcast_*_to methods in Studio::Cable.safe_broadcast, so a cable
6
- # failure (a Redis hiccup, a missing/misconfigured adapter) can NEVER break the
7
- # model save / after_commit that triggered the broadcast — the SEV-1 guard, in
8
- # one place. The host model already has the raw broadcast_*_to methods (turbo-rails
9
- # includes Turbo::Broadcastable into ActiveRecord::Base); these are the SAFE
10
- # variants every app should broadcast through.
11
- #
12
- # class Task < ApplicationRecord
13
- # include Studio::Broadcastable
14
- # after_create_commit { safe_broadcast_replace_to [:board], target: "card_#{id}",
15
- # partial: "tasks/card", locals: { task: self } }
16
- # end
17
- module Broadcastable
18
- extend ActiveSupport::Concern
19
-
20
- def safe_broadcast_replace_to(*args, **kwargs, &block)
21
- Studio::Cable.safe_broadcast { broadcast_replace_to(*args, **kwargs, &block) }
22
- end
23
-
24
- def safe_broadcast_update_to(*args, **kwargs, &block)
25
- Studio::Cable.safe_broadcast { broadcast_update_to(*args, **kwargs, &block) }
26
- end
27
-
28
- def safe_broadcast_append_to(*args, **kwargs, &block)
29
- Studio::Cable.safe_broadcast { broadcast_append_to(*args, **kwargs, &block) }
30
- end
31
-
32
- def safe_broadcast_prepend_to(*args, **kwargs, &block)
33
- Studio::Cable.safe_broadcast { broadcast_prepend_to(*args, **kwargs, &block) }
34
- end
35
-
36
- def safe_broadcast_remove_to(*args, **kwargs, &block)
37
- Studio::Cable.safe_broadcast { broadcast_remove_to(*args, **kwargs, &block) }
38
- end
39
- end
40
- end
@@ -1,111 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest"
4
-
5
- module Studio
6
- # The four things every Studio app's User has had to define for itself: how to
7
- # name a person on screen, and how to draw them when they have no picture.
8
- #
9
- # WHY THIS EXISTS. The engine's own components/_avatar partial calls
10
- # `display_name`, `avatar_initials` and `avatar_color` — and the engine has
11
- # never provided any of them. Every consumer wrote its own; mcritchie-industries'
12
- # user.rb even carries a comment explaining that it must, because the engine's
13
- # nav renders the partial. Three copies, and by 2026-08-14 they had drifted:
14
- #
15
- # mcritchie-studio name → email prefix (capitalized) → wallet → "anon"
16
- # mcritchie-industries name → email prefix (raw) → "User"
17
- # turf-monster username → name → email prefix (cap) → wallet → "anon"
18
- #
19
- # Same intent, three answers, and two different words for the same empty state.
20
- # That is the drift a concern removes.
21
- #
22
- # ON THE MERGED CHAIN. It is turf-monster's — the richest of the three — with
23
- # `first_name` folded in after `name`, because /profile now lets people set that
24
- # field and a name someone just typed should be the name they see. Apps missing
25
- # a link in the chain simply skip it; the chain is respond_to?-guarded end to
26
- # end, so mcritchie-industries' eight-column users table walks it without
27
- # raising. One consequence worth stating rather than burying: an MI user with no
28
- # name now falls back to "anon" instead of "User", and an email-derived name is
29
- # capitalized. That is MI adopting the house standard, and its adoption task
30
- # owns the change.
31
- #
32
- # EVERY METHOD IS OVERRIDABLE. These are plain instance methods from an included
33
- # module, so a host that defines its own `display_name` in the class body wins
34
- # outright. Standardizing the default is the goal; forbidding an app from having
35
- # an opinion is not.
36
- module UserProfile
37
- extend ActiveSupport::Concern
38
-
39
- # The house palette for initial-circle backgrounds. Identical in all three
40
- # apps today, which is the clearest possible signal it belonged here.
41
- AVATAR_COLORS = %w[#EF4444 #F97316 #EAB308 #22C55E #06B6D4 #3B82F6 #8B5CF6 #EC4899].freeze
42
-
43
- # What to call this person on screen.
44
- def display_name
45
- studio_profile_username.presence ||
46
- studio_profile_attr(:name).presence ||
47
- studio_profile_attr(:first_name).presence ||
48
- studio_profile_email_prefix.presence ||
49
- studio_profile_wallet.presence ||
50
- "anon"
51
- end
52
-
53
- # One character for the initials circle. Deliberately NOT derived from
54
- # display_name: that chain ends in a wallet address or the word "anon", and
55
- # "a" for every anonymous account is worse than a neutral mark.
56
- def avatar_initials
57
- source = studio_profile_username.presence ||
58
- studio_profile_attr(:name).presence ||
59
- studio_profile_attr(:first_name).presence ||
60
- studio_profile_email_prefix.presence
61
-
62
- # `source[0]`, not ActiveSupport's `String#first`: this module is included
63
- # into a host model, and nothing here should depend on which core_ext that
64
- # host happens to have loaded. Plain Ruby costs nothing and cannot vanish.
65
- source.presence.to_s[0]&.upcase || "?"
66
- end
67
-
68
- # A stable colour for this account — same person, same circle, every render.
69
- # Hashed rather than random for exactly that reason, and keyed off identity
70
- # fields so it survives a display-name change.
71
- def avatar_color
72
- key = studio_profile_username.presence ||
73
- studio_profile_attr(:name).presence ||
74
- studio_profile_attr(:email).presence ||
75
- id.to_s
76
-
77
- AVATAR_COLORS[Digest::MD5.hexdigest(key.to_s).hex % AVATAR_COLORS.size]
78
- end
79
-
80
- private
81
-
82
- # Read an attribute only if this host's model actually has it. The whole
83
- # tolerance of the chain lives in this one method.
84
- def studio_profile_attr(attribute)
85
- return nil unless respond_to?(attribute)
86
-
87
- public_send(attribute)
88
- end
89
-
90
- # `username` is turf-monster's on-chain handle and does not exist in the hub
91
- # apps. It leads the chain where it exists because it is what that app's
92
- # people call each other.
93
- def studio_profile_username
94
- studio_profile_attr(:username)
95
- end
96
-
97
- def studio_profile_email_prefix
98
- email = studio_profile_attr(:email)
99
- return nil if email.blank?
100
-
101
- email.to_s.split("@").first.to_s.capitalize
102
- end
103
-
104
- # Defers to the host's own truncation rather than reformatting an address
105
- # here — mcritchie-studio and turf-monster both define `truncated_solana`,
106
- # and turf's has to pick between a web2 and a web3 address to do it.
107
- def studio_profile_wallet
108
- studio_profile_attr(:truncated_solana)
109
- end
110
- end
111
- end
@@ -1,4 +0,0 @@
1
- <%# locals: (padding: "p-6") %>
2
- <div class="card <%= padding %>">
3
- <%= yield %>
4
- </div>
@@ -1,13 +0,0 @@
1
- <%# locals: (form:, field:, label: nil, type: "text", placeholder: nil, rows: nil) %>
2
- <div class="mb-5">
3
- <% if label.present? %>
4
- <label class="block text-sm text-secondary mb-2 font-medium"><%= label %></label>
5
- <% end %>
6
- <% if type.to_s == "textarea" %>
7
- <%= form.text_area field, rows: rows || 4, class: "input-field", placeholder: placeholder %>
8
- <% elsif type.to_s == "password" %>
9
- <%= form.password_field field, class: "input-field", placeholder: placeholder %>
10
- <% else %>
11
- <%= form.text_field field, class: "input-field", placeholder: placeholder %>
12
- <% end %>
13
- </div>
@@ -1,9 +0,0 @@
1
- <%# locals: (percent:, height: "h-3", color: "var(--color-cta)", label: nil, animated: false) %>
2
- <div class="w-full <%= height %> rounded-full bg-surface-alt overflow-hidden">
3
- <div class="h-full rounded-full<%= ' transition-all duration-500 ease-out' if animated %>"
4
- style="width: <%= [percent.to_f, 100].min %>%; background-color: <%= color %>">
5
- </div>
6
- </div>
7
- <% if label %>
8
- <p class="text-xs text-muted mt-1"><%= label %></p>
9
- <% end %>
@@ -1,74 +0,0 @@
1
- <%# DEPRECATED page — superseded by /admin/emails (studio/emails/index).
2
- Kept for one release so consumer suites on their default branch keep passing;
3
- see Studio::EmailImagesController for the full staging note. %>
4
- <% content_for(:title) { "Email images" } %>
5
- <div class="max-w-2xl mx-auto px-4 py-8">
6
- <header class="mb-6">
7
- <h1 class="text-2xl font-bold">Email images</h1>
8
- <p class="text-sm text-muted mt-1">
9
- The banner shown at the top of branded transactional emails. Recommended
10
- width 600px (it's displayed full-bleed at the top of the email card).
11
- </p>
12
- </header>
13
-
14
- <%# Only when this app actually DREW /admin/emails — it is opt-in, and pointing
15
- at a route that does not exist raises NameError and 500s this page. %>
16
- <% if successor_path %>
17
- <div class="mb-6 rounded-xl border border-subtle bg-inset px-4 py-3">
18
- <p class="text-sm font-semibold text-heading">There's a better page now</p>
19
- <p class="text-sm text-body mt-1">
20
- <%= link_to "Emails", successor_path, class: "underline underline-offset-2" %>
21
- lists every email this app sends with its live banner, says whether that
22
- banner is the shared default or this app's own, and crops on upload.
23
- This page is going away.
24
- </p>
25
- </div>
26
- <% end %>
27
-
28
- <% flash.each do |type, message| %>
29
- <div class="mb-4 rounded-lg px-4 py-3 text-sm bg-surface border <%= type.to_s == "alert" ? "border-danger/30 text-danger-ink" : "border-success/30 text-success-ink" %>">
30
- <%= message %>
31
- </div>
32
- <% end %>
33
-
34
- <div class="space-y-6">
35
- <% @variants.each do |variant, label| %>
36
- <%# preview_url, not url: this page's ORIGINAL BUG was reading only the S3
37
- override, so it announced "No image yet" for an email that was visibly
38
- sending a banner from a committed repo asset. Even on its way out it
39
- should tell the truth about what ships. %>
40
- <% current_url = Studio::EmailCatalog.preview_url(variant) %>
41
- <%# NOT `== :default` — that value split into :app_asset / :engine_default.
42
- Comparing against a value nothing returns would silently never show
43
- this note again. Anything that is not an upload is still inherited
44
- from a file, which is all this retiring page needs to say. %>
45
- <% inherited = current_url.present? && Studio::EmailCatalog.source(variant) != :app %>
46
- <section class="rounded-xl border border-subtle p-5">
47
- <h2 class="font-semibold mb-3"><%= label %></h2>
48
-
49
- <div class="rounded-lg overflow-hidden border border-subtle mb-3" style="aspect-ratio: 600 / 240; background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));">
50
- <% if current_url %>
51
- <%= image_tag current_url, class: "w-full h-full object-cover", alt: "#{label} email banner" %>
52
- <% else %>
53
- <div class="w-full h-full flex items-center justify-center text-xs text-white/80 text-center px-4">
54
- No image yet — emails send without a banner until you upload one.
55
- </div>
56
- <% end %>
57
- </div>
58
-
59
- <% if inherited %>
60
- <p class="text-xs text-muted mb-3">
61
- This is the shared default that ships with the engine. Uploading here
62
- makes the image this app's own.
63
- </p>
64
- <% end %>
65
-
66
- <%= form_with url: admin_email_image_path(variant), method: :patch, html: { multipart: true }, class: "flex flex-wrap items-center gap-3" do %>
67
- <%= file_field_tag :image, accept: "image/png,image/jpeg,image/webp",
68
- class: "text-sm file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-white file:cursor-pointer" %>
69
- <%= submit_tag(current_url ? "Replace image" : "Upload image", class: "btn btn-primary btn-sm") %>
70
- <% end %>
71
- </section>
72
- <% end %>
73
- </div>
74
- </div>