studio-engine 0.117.0 → 0.119.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Studio.loader: the branded loader every app draws from its OWN icon and theme
4
+ # colours (docs/LOADER.md). Pure Ruby apart from the art reader, so the unit
5
+ # suite covers the config without the dummy app.
6
+ #
7
+ # The config is a style name, or a Hash when the style needs more:
8
+ #
9
+ # config.loader = :breathe # the default
10
+ # config.loader = { style: :orbit, icon: "/brand/icon-dark.svg" }
11
+ # config.loader = { style: :sparkle, art: "/brand/loader-sparkle.svg" }
12
+ #
13
+ # GENERIC styles draw the icon as an image and nothing else, so they work on any
14
+ # app with no per-app art: breathe, liquid, orbit, steel.
15
+ #
16
+ # SIGNATURE styles animate PARTS of the icon, which no image can expose, so the
17
+ # app names an ART file: an SVG in the icon's own viewBox holding only the
18
+ # animated layers, each marked with data-loader:
19
+ #
20
+ # weld_bead one <path data-loader="outline" d="..."/> the line the bead runs
21
+ # sparkle each <path data-loader="spark" d="..."/> in the sweep order
22
+ #
23
+ # The icon is drawn underneath as an image; the art is inlined over it. An art
24
+ # file that is missing, outside public/, or has no marked layer falls back to
25
+ # breathe, so a typo can never blank the loader.
26
+ module Studio
27
+ module Loader
28
+ class InvalidConfig < ArgumentError; end
29
+
30
+ SETTING = "Studio.loader"
31
+ GENERIC_STYLES = %i[breathe liquid orbit steel].freeze
32
+ ART_STYLES = { weld_bead: "outline", sparkle: "spark" }.freeze
33
+ STYLES = (GENERIC_STYLES + ART_STYLES.keys).freeze
34
+ DEFAULT_STYLE = :breathe
35
+ KEYS = %i[style icon art].freeze
36
+
37
+ # The icon the loader draws when the app names none: the navbar brand's
38
+ # logo, else the theme's "Navbar Logo" (Studio.logo_for), which every app
39
+ # already declares.
40
+ def self.default_icon
41
+ brand = Studio.respond_to?(:navbar_brand) ? Studio.navbar_brand : nil
42
+ (brand && brand[:logo]) || Studio.logo_for("Navbar Logo")
43
+ end
44
+
45
+ module_function
46
+
47
+ # The config as { style:, icon:, art: } (icon and art nil when not given).
48
+ def normalize(config)
49
+ config = { style: config } if config.is_a?(Symbol) || config.is_a?(String)
50
+ config = { style: DEFAULT_STYLE } if config.nil?
51
+ raise InvalidConfig, "#{SETTING} must be a style Symbol or a Hash (got #{config.class})" unless config.is_a?(Hash)
52
+
53
+ config = config.to_h { |key, value| [key.to_sym, value] }
54
+ unknown = config.keys - KEYS
55
+ raise InvalidConfig, "#{SETTING} has unknown key :#{unknown.first} (takes #{KEYS.join(', ')})" if unknown.any?
56
+
57
+ style = style!(config[:style] || DEFAULT_STYLE)
58
+ icon = path!(config[:icon], "#{SETTING}[:icon]")
59
+ art = path!(config[:art], "#{SETTING}[:art]")
60
+ if ART_STYLES.key?(style) && art.nil?
61
+ raise InvalidConfig, "#{SETTING} style :#{style} animates parts of the icon, so it needs an art: file " \
62
+ "(an SVG of <path data-loader=\"#{ART_STYLES[style]}\"> layers; docs/LOADER.md)"
63
+ end
64
+
65
+ { style: style, icon: icon, art: art }.freeze
66
+ end
67
+
68
+ def style!(value)
69
+ style = value.to_s.strip.tr("-", "_").to_sym
70
+ return style if STYLES.include?(style)
71
+
72
+ raise InvalidConfig, "#{SETTING} style must be one of #{STYLES.map(&:inspect).join(', ')} (got #{value.inspect})"
73
+ end
74
+
75
+ def path!(value, label)
76
+ return nil if value.nil?
77
+ return value.strip if value.is_a?(String) && !value.strip.empty?
78
+
79
+ raise InvalidConfig, "#{label} must be a non-blank path (got #{value.inspect})"
80
+ end
81
+
82
+ def art_style?(style) = ART_STYLES.key?(style)
83
+
84
+ # ---- The art file ------------------------------------------------------
85
+ #
86
+ # Read from the host's public/ (the path is the URL path the app would serve
87
+ # it at), parsed once per file version. Returns
88
+ # { view_box: "0 0 w h", width: Float, layers: [Nokogiri::XML::Element] }
89
+ # with only the layers marked for `style`, or nil when there are none.
90
+ @art_cache = {}
91
+ @art_mutex = Mutex.new
92
+
93
+ class << self
94
+ attr_reader :art_cache
95
+ end
96
+
97
+ def art(path, style, root: public_root)
98
+ mark = ART_STYLES[style]
99
+ file = public_file(path, root)
100
+ return nil unless mark && file
101
+
102
+ key = [file, File.mtime(file).to_f, mark]
103
+ Loader.instance_variable_get(:@art_mutex).synchronize do
104
+ Loader.art_cache[key] ||= parse_art(File.read(file), mark)
105
+ end
106
+ rescue SystemCallError
107
+ nil
108
+ end
109
+
110
+ def parse_art(source, mark)
111
+ require "nokogiri"
112
+ doc = Nokogiri::XML(source)
113
+ svg = doc.root
114
+ return nil unless svg&.name == "svg"
115
+
116
+ layers = doc.xpath("//*[@data-loader='#{mark}']").select { |node| node.name == "path" && node["d"].to_s.strip != "" }
117
+ return nil if layers.empty?
118
+
119
+ view_box = svg["viewBox"].to_s.strip
120
+ numbers = view_box.split(/[\s,]+/).map { |n| Float(n, exception: false) }
121
+ return nil unless numbers.size == 4 && numbers.all? && numbers[2].positive? && numbers[3].positive?
122
+
123
+ {
124
+ view_box: numbers.map { |n| format_number(n) }.join(" "),
125
+ width: numbers[2],
126
+ layers: layers.map { |node| { d: node["d"].strip, transform: inherited_transform(node) } }.freeze
127
+ }.freeze
128
+ end
129
+
130
+ # The transforms of the layer and every group above it, outermost first, so a
131
+ # layer copied out of a <g transform> keeps its place.
132
+ def inherited_transform(node)
133
+ chain = []
134
+ while node.respond_to?(:name) && node.name != "svg" && node.name != "document"
135
+ chain.unshift(node["transform"]) if node["transform"].to_s.strip != ""
136
+ node = node.parent
137
+ end
138
+ chain.empty? ? nil : chain.join(" ")
139
+ end
140
+
141
+ def format_number(n) = (n % 1).zero? ? n.to_i.to_s : n.to_s
142
+
143
+ def public_root
144
+ defined?(::Rails) && ::Rails.respond_to?(:public_path) && ::Rails.public_path ? ::Rails.public_path.to_s : nil
145
+ end
146
+
147
+ # The file behind a public URL path, refusing anything that escapes public/.
148
+ def public_file(path, root)
149
+ return nil if root.nil? || path.nil? || path.match?(%r{\A[a-z]+:}i)
150
+
151
+ clean = path.split(/[?#]/).first.to_s.delete_prefix("/")
152
+ full = File.expand_path(clean, root)
153
+ return nil unless full.start_with?(File.expand_path(root) + File::SEPARATOR)
154
+
155
+ File.file?(full) ? full : nil
156
+ end
157
+ end
158
+ 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.117.0"
2
+ VERSION = "0.119.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"
@@ -41,10 +42,12 @@ require "studio/navbar_account_sidebar"
41
42
  require "studio/navbar_identity"
42
43
  require "studio/navbar_wordmark"
43
44
  require "studio/navbar_brand"
45
+ require "studio/loader"
44
46
  require "studio/auth_labels"
45
47
  require "studio/profile_sections"
46
48
  require "studio/profile_image"
47
49
  require "studio/oauth_identity"
50
+ require "studio/google_sign_in"
48
51
  require "studio/newsletter"
49
52
  require "studio/health_report"
50
53
  require "studio/agent_chat"
@@ -184,6 +187,26 @@ module Studio
184
187
  # config.features = %i[web3]
185
188
  mattr_accessor :auth_methods, default: %i[magic_link google]
186
189
 
190
+ # ---- Google sign-in, ready out of the box (Studio::GoogleSignIn) ----------
191
+ # :google in auth_methods draws the button; the engine registers the strategy
192
+ # and a default User.from_omniauth. What is left is the OAuth client, and
193
+ # google_ready? says whether the app has one: GOOGLE_CLIENT_ID and
194
+ # GOOGLE_CLIENT_SECRET both set. While it is false the button opens a modal
195
+ # whose "Request Google setup" mails google_setup_request_to, once per app
196
+ # per day, with the redirect URI to register. docs/GOOGLE_AUTH_SETUP.md.
197
+ def self.google_ready?(env: ENV)
198
+ GoogleSignIn.ready?(env: env)
199
+ end
200
+
201
+ mattr_accessor :google_setup_request_to, default: "team@mcritchie.studio"
202
+
203
+ # The host Google's callback lives on, e.g. "voicebox.mcritchie.studio". It
204
+ # names the redirect URI the setup-request email asks ops to register, and in
205
+ # production it pins OmniAuth's callback host. nil, the default, falls back to
206
+ # ENV["APP_HOST"], then the first plain hostname in config.hosts
207
+ # (Studio::GoogleSignIn.canonical_host). Never read from the request.
208
+ mattr_accessor :google_canonical_host, default: nil
209
+
187
210
  # ---- Capability features --------------------------------------------------
188
211
  # Coarse app-level capability switches an app opts into. Distinct from
189
212
  # auth_methods (which sign-in methods the login views render) — these gate
@@ -282,6 +305,27 @@ module Studio
282
305
  @@navbar_wordmark = NavbarWordmark.normalize(config)
283
306
  end
284
307
 
308
+ # ---- Branded loader ----
309
+ # The loader every page load, long wait and pending button draws: the app's
310
+ # own icon in its own theme colours, no per-app code. A style name, or a Hash
311
+ # with the icon (default: the navbar brand logo, else theme_logos' "Navbar
312
+ # Logo") and, for a signature style, the art file of marked layers. The
313
+ # default is :breathe. Rules: lib/studio/loader.rb; docs/LOADER.md.
314
+ #
315
+ # config.loader = :orbit
316
+ # config.loader = { style: :sparkle, icon: "/brand/icon-dark.svg",
317
+ # art: "/brand/loader-sparkle.svg" }
318
+ mattr_reader :loader, default: Loader.normalize(nil)
319
+
320
+ def self.loader=(config)
321
+ @@loader = Loader.normalize(config)
322
+ end
323
+
324
+ # The icon the loader draws: the configured one, else Loader.default_icon.
325
+ def self.loader_icon
326
+ loader[:icon] || Loader.default_icon
327
+ end
328
+
285
329
  def self.sign_in_label=(label)
286
330
  NavbarIdentity.validate_label!(label)
287
331
  @@sign_in_label = label
@@ -1269,6 +1313,15 @@ module Studio
1269
1313
  mattr_accessor :theme_danger, default: "#EF4444"
1270
1314
  mattr_accessor :theme_accent, default: "#F72585"
1271
1315
 
1316
+ # Type scale (Studio::TypeScale): the body size every type-* role multiplies.
1317
+ # type_base for public pages, type_dense_base inside a `type-dense` container
1318
+ # (admin and ops pages). Weights: title and heading take the display weight,
1319
+ # subhead, body and fine print the text weight. Set them from the brand kit.
1320
+ mattr_accessor :type_base, default: 16
1321
+ mattr_accessor :type_dense_base, default: 14
1322
+ mattr_accessor :type_display_weight, default: 800
1323
+ mattr_accessor :type_text_weight, default: 400
1324
+
1272
1325
  # S3 / object storage — host apps MUST set s3_bucket_prefix explicitly in
1273
1326
  # config/initializers/studio.rb before any S3-touching code runs (ImageCache,
1274
1327
  # Studio::S3.upload, etc.). Default is nil so external users don't accidentally
@@ -1726,6 +1779,16 @@ module Studio
1726
1779
  get "auth/:provider/callback", to: "omniauth_callbacks#create"
1727
1780
  get "auth/failure", to: "omniauth_callbacks#failure"
1728
1781
 
1782
+ # "Request Google setup", the button in the Google modal an app shows
1783
+ # while its OAuth client is unset (Studio::GoogleSignIn). Mails the team
1784
+ # once per app per day, and answers 404 once the
1785
+ # button posts; unauthenticated, since the person asking cannot
1786
+ # sign in yet. Drawn whenever the Google button can be.
1787
+ if Studio.auth_method?(:google)
1788
+ post "_studio/google_setup_request", to: "studio/google_setup_requests#create",
1789
+ as: :studio_google_setup_request
1790
+ end
1791
+
1729
1792
  # The daily health report as JSON, for the hub's central 07:00 pull
1730
1793
  # (Studio::HealthReportsController). Drawn in EVERY environment, production
1731
1794
  # included: the controller is the gate, answering 404 until the app names
@@ -63,6 +63,14 @@ Gem::Specification.new do |spec|
63
63
  # allows accepts redis 6. test/lib/gemspec_redis_bound_test.rb pins it.
64
64
  spec.add_dependency "redis", ">= 4.0.1", "< 6"
65
65
  spec.add_dependency "turbo-rails", ">= 1.0"
66
+ # Google sign-in ships ready (Studio::GoogleSignIn). Every app draws the
67
+ # "Sign in with Google" button, so every app carries the strategy: the engine
68
+ # registers google_oauth2 itself unless the host's own initializer already did.
69
+ # Bounds match the consumers' locks (omniauth 2.1, google-oauth2 1.2,
70
+ # rails_csrf_protection 1.0 and 2.0).
71
+ spec.add_dependency "omniauth", ">= 2.1", "< 3"
72
+ spec.add_dependency "omniauth-google-oauth2", ">= 1.1", "< 2"
73
+ spec.add_dependency "omniauth-rails_csrf_protection", ">= 1.0", "< 3"
66
74
  # IP -> location for Studio::Geo. A dependency rather than a host concern
67
75
  # because the point of the geo primitive is that EVERY app has the capacity:
68
76
  # an app that has to add a gem before it can place a visitor does not.
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.117.0
4
+ version: 0.119.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -174,6 +174,66 @@ dependencies:
174
174
  - - ">="
175
175
  - !ruby/object:Gem::Version
176
176
  version: '1.0'
177
+ - !ruby/object:Gem::Dependency
178
+ name: omniauth
179
+ requirement: !ruby/object:Gem::Requirement
180
+ requirements:
181
+ - - ">="
182
+ - !ruby/object:Gem::Version
183
+ version: '2.1'
184
+ - - "<"
185
+ - !ruby/object:Gem::Version
186
+ version: '3'
187
+ type: :runtime
188
+ prerelease: false
189
+ version_requirements: !ruby/object:Gem::Requirement
190
+ requirements:
191
+ - - ">="
192
+ - !ruby/object:Gem::Version
193
+ version: '2.1'
194
+ - - "<"
195
+ - !ruby/object:Gem::Version
196
+ version: '3'
197
+ - !ruby/object:Gem::Dependency
198
+ name: omniauth-google-oauth2
199
+ requirement: !ruby/object:Gem::Requirement
200
+ requirements:
201
+ - - ">="
202
+ - !ruby/object:Gem::Version
203
+ version: '1.1'
204
+ - - "<"
205
+ - !ruby/object:Gem::Version
206
+ version: '2'
207
+ type: :runtime
208
+ prerelease: false
209
+ version_requirements: !ruby/object:Gem::Requirement
210
+ requirements:
211
+ - - ">="
212
+ - !ruby/object:Gem::Version
213
+ version: '1.1'
214
+ - - "<"
215
+ - !ruby/object:Gem::Version
216
+ version: '2'
217
+ - !ruby/object:Gem::Dependency
218
+ name: omniauth-rails_csrf_protection
219
+ requirement: !ruby/object:Gem::Requirement
220
+ requirements:
221
+ - - ">="
222
+ - !ruby/object:Gem::Version
223
+ version: '1.0'
224
+ - - "<"
225
+ - !ruby/object:Gem::Version
226
+ version: '3'
227
+ type: :runtime
228
+ prerelease: false
229
+ version_requirements: !ruby/object:Gem::Requirement
230
+ requirements:
231
+ - - ">="
232
+ - !ruby/object:Gem::Version
233
+ version: '1.0'
234
+ - - "<"
235
+ - !ruby/object:Gem::Version
236
+ version: '3'
177
237
  - !ruby/object:Gem::Dependency
178
238
  name: geocoder
179
239
  requirement: !ruby/object:Gem::Requirement
@@ -400,6 +460,7 @@ files:
400
460
  - app/controllers/studio/component_previews_controller.rb
401
461
  - app/controllers/studio/emails_controller.rb
402
462
  - app/controllers/studio/geo_settings_controller.rb
463
+ - app/controllers/studio/google_setup_requests_controller.rb
403
464
  - app/controllers/studio/health_reports_controller.rb
404
465
  - app/controllers/studio/knowledge_docs_controller.rb
405
466
  - app/controllers/studio/links_controller.rb
@@ -419,6 +480,7 @@ files:
419
480
  - app/helpers/studio/fizz_helper.rb
420
481
  - app/helpers/studio/geo_helper.rb
421
482
  - app/helpers/studio/link_preview_helper.rb
483
+ - app/helpers/studio/loader_helper.rb
422
484
  - app/helpers/studio/public_user_helper.rb
423
485
  - app/helpers/studio/site_footer_helper.rb
424
486
  - app/helpers/studio_email_delivery_helper.rb
@@ -466,6 +528,7 @@ files:
466
528
  - app/javascript/studio/leveling_activity.js
467
529
  - app/javascript/studio/link_preview_card.js
468
530
  - app/javascript/studio/link_sidebar.js
531
+ - app/javascript/studio/loader.js
469
532
  - app/javascript/studio/local_path.js
470
533
  - app/javascript/studio/modal_countdown.js
471
534
  - app/javascript/studio/modal_host.js
@@ -486,12 +549,14 @@ files:
486
549
  - app/jobs/studio/email_delivery_job.rb
487
550
  - app/jobs/studio/health_report_job.rb
488
551
  - app/mailers/application_mailer.rb
552
+ - app/mailers/studio/google_setup_mailer.rb
489
553
  - app/mailers/studio/health_report_mailer.rb
490
554
  - app/mailers/studio/newsletter_mailer.rb
491
555
  - app/mailers/studio/profile_mailer.rb
492
556
  - app/mailers/user_mailer.rb
493
557
  - app/models/concerns/sluggable.rb
494
558
  - app/models/concerns/studio/board/rankable.rb
559
+ - app/models/concerns/studio/omniauth_user.rb
495
560
  - app/models/current.rb
496
561
  - app/models/error_log.rb
497
562
  - app/models/image_cache.rb
@@ -591,6 +656,10 @@ files:
591
656
  - app/views/studio/emails/show.html.erb
592
657
  - app/views/studio/fields/_date_of_birth.html.erb
593
658
  - app/views/studio/geo_settings/edit.html.erb
659
+ - app/views/studio/google_setup_mailer/setup_requested.html.erb
660
+ - app/views/studio/google_setup_mailer/setup_requested.text.erb
661
+ - app/views/studio/google_sign_in/_button.html.erb
662
+ - app/views/studio/google_sign_in/_setup_modal.html.erb
594
663
  - app/views/studio/health_report_mailer/daily.html.erb
595
664
  - app/views/studio/health_report_mailer/daily.text.erb
596
665
  - app/views/studio/knowledge_docs/_access_chips.html.erb
@@ -610,6 +679,8 @@ files:
610
679
  - app/views/studio/knowledge_docs/preview.html.erb
611
680
  - app/views/studio/knowledge_docs/show.html.erb
612
681
  - app/views/studio/links/confirm.html.erb
682
+ - app/views/studio/loader/_loader.html.erb
683
+ - app/views/studio/loader/_overlay.html.erb
613
684
  - app/views/studio/local_emails/index.html.erb
614
685
  - app/views/studio/mailers/_layered_banner.html.erb
615
686
  - app/views/studio/modals/_crop_photo.html.erb
@@ -755,6 +826,7 @@ files:
755
826
  - lib/studio/geo.rb
756
827
  - lib/studio/geo/countries.rb
757
828
  - lib/studio/geo/lookup.rb
829
+ - lib/studio/google_sign_in.rb
758
830
  - lib/studio/health_report.rb
759
831
  - lib/studio/hold_button.rb
760
832
  - lib/studio/host_schema.rb
@@ -772,6 +844,7 @@ files:
772
844
  - lib/studio/link_preview.rb
773
845
  - lib/studio/link_resolution.rb
774
846
  - lib/studio/link_token.rb
847
+ - lib/studio/loader.rb
775
848
  - lib/studio/local_path.rb
776
849
  - lib/studio/log_rotation.rb
777
850
  - lib/studio/mail_sender.rb
@@ -812,6 +885,7 @@ files:
812
885
  - lib/studio/survey/export.rb
813
886
  - lib/studio/tailwind_entry.rb
814
887
  - lib/studio/theme_resolver.rb
888
+ - lib/studio/type_scale.rb
815
889
  - lib/studio/ui_primitives.rb
816
890
  - lib/studio/username_generator.rb
817
891
  - lib/studio/version.rb