studio-engine 0.116.2 → 0.118.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 +44 -0
- data/README.md +42 -1
- data/app/assets/tailwind/studio_engine/engine.css +44 -0
- data/app/helpers/studio_theme_helper.rb +1 -1
- data/app/mailers/application_mailer.rb +4 -1
- data/lib/studio/email_smoke.rb +9 -1
- data/lib/studio/engine.rb +15 -0
- data/lib/studio/mail_sender.rb +97 -0
- data/lib/studio/type_scale.rb +64 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +31 -3
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 48ec929ff6a1178b1f5d031f591f3e6f90f4e3257feb303063e40dcd9b142eeb
|
|
4
|
+
data.tar.gz: c4215b12c48ac7dbcb829e53faca80412007f2da8c0d785641412d375f847f74
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 898cba360f134fbc33f800b329e0914a82ed6ecb2af4306dc907361a246845d7974f70f80b2f3740bace96bd2ea7d08ff93b3a4523e27e3a30c223445e442c95
|
|
7
|
+
data.tar.gz: df32d0a535175c62b3390e8cbbbbc1df96d1be5ac4479711ee388cdbb3643914ec37ca3b04a43c79d1a4d12279145baa2777143079a633f7ba844bb141d9a99c
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,50 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.118.0 — 2026-10-28
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **The type scale: five text roles every app draws from.** `type-title` (6x),
|
|
12
|
+
`type-heading` (3x), `type-subhead` (1.5x), `type-body` (1x) and `type-fine`
|
|
13
|
+
(0.75x), each a multiple of `--type-base`, which the theme tag sets from
|
|
14
|
+
`Studio.type_base` (16). A `type-dense` container swaps in
|
|
15
|
+
`Studio.type_dense_base` (14) for admin and ops pages. Title and heading take
|
|
16
|
+
`Studio.type_display_weight` (800), the rest `Studio.type_text_weight` (400).
|
|
17
|
+
Nothing changes on a page until it uses a role. README "Type scale".
|
|
18
|
+
|
|
19
|
+
## 0.117.0 — 2026-10-28
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **The default sender names the app: `"<Studio.app_name> <team@mcritchie.studio>"`.**
|
|
24
|
+
With no `RESEND_MAILER_FROM` and no `resend_from:`,
|
|
25
|
+
`Studio.mailer_from_for_transport`, `Studio.marketing_from_for_transport`, the
|
|
26
|
+
engine's `ApplicationMailer` and `studio:email:smoke` now send as the app
|
|
27
|
+
(`Studio.default_mailer_from`) rather than as "McRitchie Studio"; an app that
|
|
28
|
+
never set `app_name` still sends as "McRitchie Studio". Every app sends from
|
|
29
|
+
team@mcritchie.studio; a client's own sending domain is a paid tier upgrade
|
|
30
|
+
set through `RESEND_MAILER_FROM`. `Studio.resend_mailer_from` defaults to
|
|
31
|
+
nil and reads that default; setting it still wins. The retired `ses_from:`
|
|
32
|
+
stays ignored, so an initializer passing only `ses_from:` now gets the app's
|
|
33
|
+
own name. Set `app_name` before calling the helper in the configure block.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **Production refuses an example.com sender.** The engine's after_initialize
|
|
38
|
+
raises `Studio::MailSender::PlaceholderSenderError` in production when
|
|
39
|
+
`ApplicationMailer` or `UserMailer` resolves to an example.com, .net or .org
|
|
40
|
+
address: the Rails scaffold's `app/mailers/application_mailer.rb`
|
|
41
|
+
(`default from: "from@example.com"`) shadows the engine's, and Resend refused
|
|
42
|
+
every magic link in voicebox and moms-app. It runs in rake tasks too, so
|
|
43
|
+
Heroku's `assets:precompile` fails the build and the placeholder never
|
|
44
|
+
releases. The message names the fix. A sender proc that raises at boot is
|
|
45
|
+
logged and skipped. **Consumers still carrying the scaffold mailer must
|
|
46
|
+
delete it or re-point it at `Studio.mailer_from` before bumping.**
|
|
47
|
+
[`docs/NEW_APP_SETUP.md`](docs/NEW_APP_SETUP.md). Guarded by
|
|
48
|
+
`test/lib/studio/mail_sender_test.rb` and
|
|
49
|
+
`test/integration/mail_sender_boot_test.rb`.
|
|
50
|
+
|
|
7
51
|
## 0.116.2 — 2026-10-28
|
|
8
52
|
|
|
9
53
|
### Added
|
data/README.md
CHANGED
|
@@ -155,7 +155,7 @@ Studio.configure do |config|
|
|
|
155
155
|
config.welcome_message = ->(user) { "Welcome, #{user.display_name}!" }
|
|
156
156
|
config.auth_methods = %i[magic_link google]
|
|
157
157
|
config.registration_params = [:name, :email]
|
|
158
|
-
config.mailer_from = Studio.mailer_from_for_transport # RESEND_MAILER_FROM, else
|
|
158
|
+
config.mailer_from = Studio.mailer_from_for_transport # RESEND_MAILER_FROM, else "<app_name> <team@mcritchie.studio>"
|
|
159
159
|
config.theme_primary = "#4BAF50" # Override default violet
|
|
160
160
|
config.theme_logos = ["logo.svg"]
|
|
161
161
|
|
|
@@ -310,6 +310,47 @@ flag — it cannot know where the reader is sitting, so only the reader's machin
|
|
|
310
310
|
may assert one. A host that omits the script still gets working stamps, just
|
|
311
311
|
frozen in the app's timezone. Specimen: `/admin/style` → Tricks → Time stamps.
|
|
312
312
|
|
|
313
|
+
### Type scale — `type-*`
|
|
314
|
+
|
|
315
|
+
The one set of text sizes every Studio app uses (Alex McRitchie's standard,
|
|
316
|
+
2026-10-28; the hub's `/logos/<brand>` Type Scale card shows it per brand).
|
|
317
|
+
Each role is a multiple of one base:
|
|
318
|
+
|
|
319
|
+
| Utility | Multiple | Public base (16 px) | Dense base (14 px) | Weight |
|
|
320
|
+
|---|---|---|---|---|
|
|
321
|
+
| `type-title` | 6x | 96 px | 84 px | display |
|
|
322
|
+
| `type-heading` | 3x | 48 px | 42 px | display |
|
|
323
|
+
| `type-subhead` | 1.5x | 24 px | 21 px | text |
|
|
324
|
+
| `type-body` | 1x | 16 px | 14 px | text |
|
|
325
|
+
| `type-fine` | 0.75x | 12 px | 10.5 px | text |
|
|
326
|
+
|
|
327
|
+
```erb
|
|
328
|
+
<h1 class="type-title text-heading">Winter Run 2026</h1>
|
|
329
|
+
<p class="type-body text-body">Since 2016 ...</p>
|
|
330
|
+
|
|
331
|
+
<%# an admin or ops page: everything inside takes the dense base %>
|
|
332
|
+
<main class="type-dense">
|
|
333
|
+
<h1 class="type-heading text-heading">Releases</h1>
|
|
334
|
+
</main>
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
- **Public pages** use the public base. **Admin and ops pages** put
|
|
338
|
+
`type-dense` on their container: the roles inside keep their ratios on the
|
|
339
|
+
dense base.
|
|
340
|
+
- **Set the base and weights from the brand kit**, in
|
|
341
|
+
`config/initializers/studio.rb`: `Studio.type_base`,
|
|
342
|
+
`Studio.type_dense_base`, `Studio.type_display_weight`,
|
|
343
|
+
`Studio.type_text_weight`. The theme tag (`studio_theme_css_tag`) writes them
|
|
344
|
+
as `--type-base`, `--type-dense-base`, `--type-display-weight` and
|
|
345
|
+
`--type-text-weight`; a bad value raises, naming the setting.
|
|
346
|
+
- **A role sets size, weight and line height only.** Colour stays with
|
|
347
|
+
`text-heading`, `text-body` and the rest, which are colour utilities, not
|
|
348
|
+
sizes.
|
|
349
|
+
- **Nothing is smaller than `type-fine`.** A new page uses the roles, not
|
|
350
|
+
`text-xs` or `text-2xs`; existing pages move over app by app.
|
|
351
|
+
- The multiples live in `Studio::TypeScale::ROLES` and in `engine.css`;
|
|
352
|
+
`test/lib/type_scale_contract_test.rb` keeps the two the same.
|
|
353
|
+
|
|
313
354
|
### Tints and shades — `.scrim`
|
|
314
355
|
|
|
315
356
|
Text laid over media on a PAGE (a hero photo, a video, a banner) reads through
|
|
@@ -179,6 +179,50 @@ body.modal-open .studio-app-banner {
|
|
|
179
179
|
@apply bg-inset rounded-xl border border-subtle p-4 overflow-x-auto;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/* -- Type scale ------------------------------------------------------------
|
|
183
|
+
The one set of text sizes every app draws from (Studio::TypeScale, README
|
|
184
|
+
"Type scale"). Each role is a multiple of --type-base: title 6x, heading 3x,
|
|
185
|
+
subhead 1.5x, body 1x, fine print 0.75x. The theme tag sets the app's base
|
|
186
|
+
(Studio.type_base, 16px) and weights; `type-dense` swaps in the dense base
|
|
187
|
+
(Studio.type_dense_base, 14px) for everything inside it, for admin and ops
|
|
188
|
+
pages. The fallbacks are the engine defaults, for a page with no theme tag.
|
|
189
|
+
The multiples here must match Studio::TypeScale::ROLES
|
|
190
|
+
(test/lib/type_scale_contract_test.rb). */
|
|
191
|
+
|
|
192
|
+
@utility type-dense {
|
|
193
|
+
--type-base: var(--type-dense-base, 14px);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
@utility type-title {
|
|
197
|
+
font-size: calc(var(--type-base, 16px) * 6);
|
|
198
|
+
font-weight: var(--type-display-weight, 800);
|
|
199
|
+
line-height: 1.05;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
@utility type-heading {
|
|
203
|
+
font-size: calc(var(--type-base, 16px) * 3);
|
|
204
|
+
font-weight: var(--type-display-weight, 800);
|
|
205
|
+
line-height: 1.1;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
@utility type-subhead {
|
|
209
|
+
font-size: calc(var(--type-base, 16px) * 1.5);
|
|
210
|
+
font-weight: var(--type-text-weight, 400);
|
|
211
|
+
line-height: 1.25;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
@utility type-body {
|
|
215
|
+
font-size: calc(var(--type-base, 16px) * 1);
|
|
216
|
+
font-weight: var(--type-text-weight, 400);
|
|
217
|
+
line-height: 1.5;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
@utility type-fine {
|
|
221
|
+
font-size: calc(var(--type-base, 16px) * 0.75);
|
|
222
|
+
font-weight: var(--type-text-weight, 400);
|
|
223
|
+
line-height: 1.4;
|
|
224
|
+
}
|
|
225
|
+
|
|
182
226
|
/* -- Scrims: tints and shades ----------------------------------------------
|
|
183
227
|
A scrim is the wash laid over media (a photo, a video) so text reads on it.
|
|
184
228
|
It comes in two TONES, and the tone is a design decision, not a colour pick:
|
|
@@ -10,6 +10,6 @@ module StudioThemeHelper
|
|
|
10
10
|
Studio::ThemeResolver.new(colors).to_css
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
tag.style("#{css}\n#{Studio::UiPrimitives.css}".html_safe, nonce: content_security_policy_nonce)
|
|
13
|
+
tag.style("#{css}\n#{Studio::TypeScale.css}\n#{Studio::UiPrimitives.css}".html_safe, nonce: content_security_policy_nonce)
|
|
14
14
|
end
|
|
15
15
|
end
|
|
@@ -3,5 +3,8 @@ class ApplicationMailer < ActionMailer::Base
|
|
|
3
3
|
# the host's config/initializers/studio.rb, with an ENV fallback. No layout is
|
|
4
4
|
# forced — ActionMailer renders bare if the host ships no mailer layout, and a
|
|
5
5
|
# host that defines its own ApplicationMailer (e.g. turf-monster) wins outright.
|
|
6
|
-
|
|
6
|
+
#
|
|
7
|
+
# Unset, it is "<Studio.app_name> <team@mcritchie.studio>": every app sends
|
|
8
|
+
# from team@ under its own name (a client's own domain is a tier upgrade).
|
|
9
|
+
default from: -> { Studio.mailer_from.presence || ENV["MAILER_FROM"].presence || Studio.default_mailer_from }
|
|
7
10
|
end
|
data/lib/studio/email_smoke.rb
CHANGED
|
@@ -107,7 +107,7 @@ module Studio
|
|
|
107
107
|
def sender(env = ENV)
|
|
108
108
|
studio_value(:mailer_from) ||
|
|
109
109
|
env_value(env, "MAILER_FROM") ||
|
|
110
|
-
|
|
110
|
+
default_sender
|
|
111
111
|
end
|
|
112
112
|
|
|
113
113
|
def transport_label(action_mailer:, env: ENV)
|
|
@@ -128,6 +128,14 @@ module Studio
|
|
|
128
128
|
|
|
129
129
|
private
|
|
130
130
|
|
|
131
|
+
def default_sender
|
|
132
|
+
if defined?(Studio) && Studio.respond_to?(:default_mailer_from)
|
|
133
|
+
Studio.default_mailer_from
|
|
134
|
+
else
|
|
135
|
+
"McRitchie Studio <team@mcritchie.studio>"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
131
139
|
def body_for(app_name:, to:, from:, transport:, delivery_method:, perform_deliveries:, sent_at:)
|
|
132
140
|
<<~TEXT
|
|
133
141
|
Email smoke test for #{app_name}
|
data/lib/studio/engine.rb
CHANGED
|
@@ -347,6 +347,21 @@ module Studio
|
|
|
347
347
|
end
|
|
348
348
|
|
|
349
349
|
config.after_initialize do
|
|
350
|
+
# Refuse to boot in production when ApplicationMailer (or the magic-link
|
|
351
|
+
# UserMailer) sends from an example.com placeholder: the Rails scaffold's
|
|
352
|
+
# app/mailers/application_mailer.rb shadows the engine's and Resend
|
|
353
|
+
# refuses every send. Boot, not the email smoke: Heroku's build boots
|
|
354
|
+
# production for assets:precompile, so the build fails and the bad slug
|
|
355
|
+
# never releases, while the smoke is an operator step a deploy can skip.
|
|
356
|
+
# Rake tasks are checked too, on purpose. Studio::MailSender.
|
|
357
|
+
if defined?(::ActionMailer::Base)
|
|
358
|
+
Studio::MailSender.verify!(
|
|
359
|
+
mailer_classes: %w[ApplicationMailer UserMailer].filter_map(&:safe_constantize),
|
|
360
|
+
rails_env: Rails.env,
|
|
361
|
+
logger: Rails.logger
|
|
362
|
+
)
|
|
363
|
+
end
|
|
364
|
+
|
|
350
365
|
# Configure the IP -> location provider for every app that has the gem,
|
|
351
366
|
# so geo detection works the same way everywhere: HTTPS (without which
|
|
352
367
|
# ipinfo silently returns nothing), a 3s timeout, and a Rails.cache-backed
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# Who engine-sent mail comes from, and the production guard that refuses a
|
|
5
|
+
# placeholder sender.
|
|
6
|
+
#
|
|
7
|
+
# Every app sends from team@mcritchie.studio under its own name (Alex
|
|
8
|
+
# McRitchie's ruling, 2026-10-28); a client's own sending domain is a paid
|
|
9
|
+
# tier upgrade, set through RESEND_MAILER_FROM or `resend_from:`.
|
|
10
|
+
#
|
|
11
|
+
# The guard exists because `rails new` writes app/mailers/application_mailer.rb
|
|
12
|
+
# with `default from: "from@example.com"`. That class SHADOWS the engine's
|
|
13
|
+
# ApplicationMailer, so every mailer, the magic link included, inherits the
|
|
14
|
+
# placeholder, and Resend refuses every send. voicebox and moms-app both
|
|
15
|
+
# shipped it and nobody could sign in. Pure Ruby, so the unit suite exercises
|
|
16
|
+
# this file itself rather than a mirror of it.
|
|
17
|
+
module MailSender
|
|
18
|
+
TEAM_ADDRESS = "team@mcritchie.studio"
|
|
19
|
+
FALLBACK_NAME = "McRitchie Studio"
|
|
20
|
+
# The engine's own app_name default; an app that never named itself sends as
|
|
21
|
+
# the platform rather than as "Studio".
|
|
22
|
+
UNNAMED_APP = "Studio"
|
|
23
|
+
|
|
24
|
+
# RFC 2606 reserved second-level domains, and any host under them.
|
|
25
|
+
PLACEHOLDER_DOMAIN = /@(?:[a-z0-9-]+\.)*example\.(?:com|net|org)(?![a-z0-9.-])/i
|
|
26
|
+
# RFC 5322 "specials": a display name carrying one must be quoted.
|
|
27
|
+
DISPLAY_NAME_SPECIALS = /[()<>\[\]:;@\\,."]/
|
|
28
|
+
|
|
29
|
+
class PlaceholderSenderError < StandardError; end
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# "<app name> <team@mcritchie.studio>", the sender when nothing else is set.
|
|
34
|
+
def default_for(app_name)
|
|
35
|
+
name = app_name.to_s.strip
|
|
36
|
+
name = FALLBACK_NAME if name.empty? || name == UNNAMED_APP
|
|
37
|
+
"#{display_name(name)} <#{TEAM_ADDRESS}>"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def display_name(name)
|
|
41
|
+
return name unless name.match?(DISPLAY_NAME_SPECIALS)
|
|
42
|
+
|
|
43
|
+
%("#{name.gsub(/(["\\])/) { "\\#{Regexp.last_match(1)}" }}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# True when any address in `from` (a String or an Array of them) sits on an
|
|
47
|
+
# example.com / .net / .org domain.
|
|
48
|
+
def placeholder?(from)
|
|
49
|
+
Array(from).any? { |address| address.to_s.match?(PLACEHOLDER_DOMAIN) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The From: a mailer class would stamp on a message that sets none itself:
|
|
53
|
+
# its `default from:`, with a proc default evaluated the way ActionMailer
|
|
54
|
+
# evaluates it (inside a mailer instance).
|
|
55
|
+
def resolve(mailer_class)
|
|
56
|
+
from = mailer_class.default_params[:from]
|
|
57
|
+
return from unless from.respond_to?(:call)
|
|
58
|
+
|
|
59
|
+
mailer = mailer_class.send(:new)
|
|
60
|
+
from.arity == 1 ? mailer.instance_exec(mailer, &from) : mailer.instance_exec(&from)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Raises PlaceholderSenderError in production when any of `mailer_classes`
|
|
64
|
+
# resolves to an example.com sender. Outside production it does nothing, so
|
|
65
|
+
# a test or a desk that never sends real mail is never stopped by it.
|
|
66
|
+
#
|
|
67
|
+
# It runs at boot (the engine's after_initialize), and Heroku's build runs
|
|
68
|
+
# `assets:precompile` in production, so a placeholder fails the BUILD: the
|
|
69
|
+
# bad slug never releases and the running release keeps serving. A sender
|
|
70
|
+
# that cannot be resolved at boot (its proc raises) is logged and skipped:
|
|
71
|
+
# the guard refuses a placeholder it can see, and never a boot it cannot read.
|
|
72
|
+
def verify!(mailer_classes:, rails_env:, logger: nil)
|
|
73
|
+
return :skipped unless rails_env.to_s == "production"
|
|
74
|
+
|
|
75
|
+
Array(mailer_classes).compact.uniq.each do |mailer_class|
|
|
76
|
+
from =
|
|
77
|
+
begin
|
|
78
|
+
resolve(mailer_class)
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
logger&.warn("[mail] could not resolve #{mailer_class.name}'s default sender at boot (#{e.class}: #{e.message}); placeholder guard skipped it")
|
|
81
|
+
next
|
|
82
|
+
end
|
|
83
|
+
raise PlaceholderSenderError, refusal(mailer_class, from) if placeholder?(from)
|
|
84
|
+
end
|
|
85
|
+
:verified
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def refusal(mailer_class, from)
|
|
89
|
+
<<~MSG.strip
|
|
90
|
+
#{mailer_class.name} sends from #{Array(from).join(', ').inspect}, an example.com placeholder, and Resend refuses every such send (no magic link would arrive).
|
|
91
|
+
The Rails scaffold's app/mailers/application_mailer.rb shadows the engine's ApplicationMailer. Delete it, or set:
|
|
92
|
+
default from: -> { Studio.mailer_from.presence || "#{default_for(defined?(Studio) && Studio.respond_to?(:app_name) ? Studio.app_name : nil)}" }
|
|
93
|
+
and pass resend_from: (not ses_from:) to Studio.mailer_from_for_transport in config/initializers/studio.rb. See studio-engine docs/NEW_APP_SETUP.md.
|
|
94
|
+
MSG
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# THE TYPE SCALE — the one set of text sizes every Studio app draws from
|
|
5
|
+
# (task engine-type-scale-roles). Alex McRitchie ruled it the standard for
|
|
6
|
+
# every app on 2026-10-28; the hub's /logos/<brand> Type Scale card shows the
|
|
7
|
+
# same multiples per brand.
|
|
8
|
+
#
|
|
9
|
+
# Each role is a multiple of one base: title 6x, heading 3x, subhead 1.5x,
|
|
10
|
+
# body 1x, fine print 0.75x. engine.css draws the roles as the utilities
|
|
11
|
+
# `type-title`, `type-heading`, `type-subhead`, `type-body` and `type-fine`
|
|
12
|
+
# (not `text-heading` / `text-body`: those are colour utilities).
|
|
13
|
+
#
|
|
14
|
+
# An app has TWO bases:
|
|
15
|
+
# Studio.type_base (16) public and marketing pages: 96/48/24/16/12
|
|
16
|
+
# Studio.type_dense_base (14) admin and ops pages, under a `type-dense`
|
|
17
|
+
# container: 84/42/21/14/10.5
|
|
18
|
+
# The ratios never change; a page picks its base by where it sits.
|
|
19
|
+
#
|
|
20
|
+
# This module emits the per-app variables the utilities read, in the theme
|
|
21
|
+
# tag (StudioThemeHelper#studio_theme_css_tag):
|
|
22
|
+
#
|
|
23
|
+
# Studio::TypeScale.css
|
|
24
|
+
# # => ":root {\n --type-base: 16px;\n --type-dense-base: 14px; ..."
|
|
25
|
+
module TypeScale
|
|
26
|
+
# [utility, multiple, weight] — weight :display or :text picks the app's
|
|
27
|
+
# Studio.type_display_weight or Studio.type_text_weight.
|
|
28
|
+
ROLES = [
|
|
29
|
+
["type-title", 6, :display],
|
|
30
|
+
["type-heading", 3, :display],
|
|
31
|
+
["type-subhead", 1.5, :text],
|
|
32
|
+
["type-body", 1, :text],
|
|
33
|
+
["type-fine", 0.75, :text]
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
WEIGHTS = (100..900).step(100).to_a.freeze
|
|
37
|
+
|
|
38
|
+
def self.css
|
|
39
|
+
<<~CSS
|
|
40
|
+
:root {
|
|
41
|
+
--type-base: #{px(Studio.type_base, 'type_base')};
|
|
42
|
+
--type-dense-base: #{px(Studio.type_dense_base, 'type_dense_base')};
|
|
43
|
+
--type-display-weight: #{weight(Studio.type_display_weight, 'type_display_weight')};
|
|
44
|
+
--type-text-weight: #{weight(Studio.type_text_weight, 'type_text_weight')};
|
|
45
|
+
}
|
|
46
|
+
CSS
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The sizes a base gives, in pixels, largest first.
|
|
50
|
+
def self.sizes(base) = ROLES.to_h { |utility, multiple, _| [utility, base * multiple] }
|
|
51
|
+
|
|
52
|
+
def self.px(value, setting)
|
|
53
|
+
raise ArgumentError, "Studio.#{setting} must be a positive number of pixels, got #{value.inspect}" unless value.is_a?(Numeric) && value.positive?
|
|
54
|
+
|
|
55
|
+
"#{value.to_s.delete_suffix('.0')}px"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.weight(value, setting)
|
|
59
|
+
raise ArgumentError, "Studio.#{setting} must be one of #{WEIGHTS.join(', ')}, got #{value.inspect}" unless WEIGHTS.include?(value)
|
|
60
|
+
|
|
61
|
+
value.to_s
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -27,6 +27,7 @@ require "studio/color_scale"
|
|
|
27
27
|
require "studio/environment_banner"
|
|
28
28
|
require "studio/theme_resolver"
|
|
29
29
|
require "studio/ui_primitives"
|
|
30
|
+
require "studio/type_scale"
|
|
30
31
|
require "studio/js_literal"
|
|
31
32
|
require "studio/js_identifier"
|
|
32
33
|
require "studio/hold_button"
|
|
@@ -51,6 +52,7 @@ require "studio/agent_chat"
|
|
|
51
52
|
require "studio/username_generator"
|
|
52
53
|
require "studio/public_user"
|
|
53
54
|
require "studio/name_parts"
|
|
55
|
+
require "studio/mail_sender"
|
|
54
56
|
require "studio/s3"
|
|
55
57
|
require "studio/storage"
|
|
56
58
|
require "studio/image_cache"
|
|
@@ -1206,7 +1208,22 @@ module Studio
|
|
|
1206
1208
|
# Default From: for engine-sent mail (magic links). Apps set this to their
|
|
1207
1209
|
# verified sending address in config/initializers/studio.rb.
|
|
1208
1210
|
mattr_accessor :mailer_from, default: nil
|
|
1209
|
-
|
|
1211
|
+
# The Resend sender when neither RESEND_MAILER_FROM nor `resend_from:` is
|
|
1212
|
+
# given. Unset (nil), it reads "<Studio.app_name> <team@mcritchie.studio>"
|
|
1213
|
+
# (Studio::MailSender.default_for): every app sends from team@ under its own
|
|
1214
|
+
# name, and a client's own sending domain is a paid tier upgrade.
|
|
1215
|
+
mattr_writer :resend_mailer_from, default: nil
|
|
1216
|
+
|
|
1217
|
+
def self.resend_mailer_from
|
|
1218
|
+
value = @@resend_mailer_from
|
|
1219
|
+
value.nil? || value.to_s.strip.empty? ? default_mailer_from : value
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1222
|
+
# "<Studio.app_name> <team@mcritchie.studio>"; "McRitchie Studio" for an app
|
|
1223
|
+
# that never set app_name.
|
|
1224
|
+
def self.default_mailer_from
|
|
1225
|
+
Studio::MailSender.default_for(app_name)
|
|
1226
|
+
end
|
|
1210
1227
|
|
|
1211
1228
|
# Local/worktree email capture. nil means "auto": enabled when AGENT_WORKTREE
|
|
1212
1229
|
# is truthy, otherwise disabled. Production always disables capture.
|
|
@@ -1253,6 +1270,15 @@ module Studio
|
|
|
1253
1270
|
mattr_accessor :theme_danger, default: "#EF4444"
|
|
1254
1271
|
mattr_accessor :theme_accent, default: "#F72585"
|
|
1255
1272
|
|
|
1273
|
+
# Type scale (Studio::TypeScale): the body size every type-* role multiplies.
|
|
1274
|
+
# type_base for public pages, type_dense_base inside a `type-dense` container
|
|
1275
|
+
# (admin and ops pages). Weights: title and heading take the display weight,
|
|
1276
|
+
# subhead, body and fine print the text weight. Set them from the brand kit.
|
|
1277
|
+
mattr_accessor :type_base, default: 16
|
|
1278
|
+
mattr_accessor :type_dense_base, default: 14
|
|
1279
|
+
mattr_accessor :type_display_weight, default: 800
|
|
1280
|
+
mattr_accessor :type_text_weight, default: 400
|
|
1281
|
+
|
|
1256
1282
|
# S3 / object storage — host apps MUST set s3_bucket_prefix explicitly in
|
|
1257
1283
|
# config/initializers/studio.rb before any S3-touching code runs (ImageCache,
|
|
1258
1284
|
# Studio::S3.upload, etc.). Default is nil so external users don't accidentally
|
|
@@ -1410,8 +1436,10 @@ module Studio
|
|
|
1410
1436
|
|
|
1411
1437
|
# The sender for transactional mail. Resend is the only provider (SES was
|
|
1412
1438
|
# retired 2026-10-10): RESEND_MAILER_FROM, then resend_from, then
|
|
1413
|
-
# Studio.resend_mailer_from
|
|
1414
|
-
#
|
|
1439
|
+
# Studio.resend_mailer_from (unset: "<app_name> <team@mcritchie.studio>").
|
|
1440
|
+
# `ses_from:` is still ACCEPTED so existing consumer initializers keep
|
|
1441
|
+
# booting, and IGNORED: pass resend_from: instead. Set app_name BEFORE this
|
|
1442
|
+
# call in the configure block, since the default reads it.
|
|
1415
1443
|
def self.mailer_from_for_transport(env: ENV, ses_from: nil, resend_from: nil)
|
|
1416
1444
|
env_value(env, "RESEND_MAILER_FROM") || resend_from || resend_mailer_from
|
|
1417
1445
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.118.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -774,6 +774,7 @@ files:
|
|
|
774
774
|
- lib/studio/link_token.rb
|
|
775
775
|
- lib/studio/local_path.rb
|
|
776
776
|
- lib/studio/log_rotation.rb
|
|
777
|
+
- lib/studio/mail_sender.rb
|
|
777
778
|
- lib/studio/mail_transport.rb
|
|
778
779
|
- lib/studio/name_parts.rb
|
|
779
780
|
- lib/studio/navbar_account_sidebar.rb
|
|
@@ -811,6 +812,7 @@ files:
|
|
|
811
812
|
- lib/studio/survey/export.rb
|
|
812
813
|
- lib/studio/tailwind_entry.rb
|
|
813
814
|
- lib/studio/theme_resolver.rb
|
|
815
|
+
- lib/studio/type_scale.rb
|
|
814
816
|
- lib/studio/ui_primitives.rb
|
|
815
817
|
- lib/studio/username_generator.rb
|
|
816
818
|
- lib/studio/version.rb
|