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 +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +4 -19
- data/app/controllers/studio/emails_controller.rb +1 -1
- data/app/services/studio/email_catalog.rb +2 -2
- data/app/services/studio/email_image.rb +1 -2
- data/app/views/studio/public_users/show.html.erb +1 -1
- data/app/views/studio/surveys/_script.html.erb +31 -13
- data/app/views/studio/surveys/_styles.html.erb +13 -2
- data/app/views/studio/surveys/show.html.erb +13 -8
- data/lib/studio/engine.rb +2 -2
- data/lib/studio/survey.rb +9 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +4 -22
- data/studio-engine.gemspec +1 -1
- metadata +2 -10
- data/app/controllers/concerns/studio/impersonation.rb +0 -90
- data/app/controllers/studio/email_images_controller.rb +0 -76
- data/app/models/concerns/studio/broadcastable.rb +0 -40
- data/app/models/concerns/studio/user_profile.rb +0 -111
- data/app/views/components/_card.html.erb +0 -4
- data/app/views/components/_input.html.erb +0 -13
- data/app/views/components/_progress_bar.html.erb +0 -9
- data/app/views/studio/email_images/index.html.erb +0 -74
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c11c676d845c483117f043e7925f84cc0d7128dfa170740dd3d3c1c484438259
|
|
4
|
+
data.tar.gz: a873622a9676a51d1864612008e09a4e7f8822a371e275b5e0586ea9b252ad9e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
173
|
-
|
|
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**.
|
|
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.
|
|
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
|
|
766
|
-
#
|
|
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
|
|
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::
|
|
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
|
-
|
|
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
|
|
133
|
+
var first = index === 0;
|
|
132
134
|
var last = index === steps.length - 1;
|
|
133
|
-
|
|
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 =
|
|
146
|
+
var position = index + 1;
|
|
142
147
|
fill.style.width = (total ? (position / total) * 100 : 0) + "%";
|
|
143
148
|
bar.setAttribute("aria-valuenow", String(position));
|
|
144
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
5
|
-
.is-enhanced and turns the same markup into one question
|
|
6
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
</
|
|
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>
|
|
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
|
|
117
|
-
#
|
|
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!
|
data/lib/studio/version.rb
CHANGED
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
|
data/studio-engine.gemspec
CHANGED
|
@@ -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::
|
|
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.
|
|
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-
|
|
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,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>
|