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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 506b8003a5848c60123a2707d1d6a13a847e9bcb5a64f153357f5313d7916c21
4
- data.tar.gz: f5c669e411552093d0a8809d125b51edeaf14579c852d8fd87929f18ff6cf387
3
+ metadata.gz: 48ec929ff6a1178b1f5d031f591f3e6f90f4e3257feb303063e40dcd9b142eeb
4
+ data.tar.gz: c4215b12c48ac7dbcb829e53faca80412007f2da8c0d785641412d375f847f74
5
5
  SHA512:
6
- metadata.gz: 128d8cf927f3a12da9133867bb17d4fabb59e033f7dc72f860af62d1d8eb23e4c33c622b6892138b1a2cc10af666ad4941d9782ae96158247fb4c8980644cd26
7
- data.tar.gz: c1b006da8088c23fd26bd7a32e7daef52b631b2dd0ea8e3f41646daad20e77a9b4445dc327cd3189ae1cd15265694fd8c8d62147229d98785ec8290e4a72942f
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 the shared sender
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
- default from: -> { Studio.mailer_from || ENV["MAILER_FROM"] || "McRitchie Studio <team@mcritchie.studio>" }
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
@@ -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
- "McRitchie Studio <team@mcritchie.studio>"
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
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.116.2"
2
+ VERSION = "0.118.0"
3
3
  end
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
- mattr_accessor :resend_mailer_from, default: "McRitchie Studio <team@mcritchie.studio>"
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. `ses_from:` is still ACCEPTED so existing
1414
- # consumer initializers keep booting, and IGNORED; drop it at your leisure.
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.116.2
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