studio-engine 0.4.1

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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +61 -0
  3. data/Gemfile +18 -0
  4. data/LICENSE +21 -0
  5. data/README.md +93 -0
  6. data/app/controllers/concerns/studio/error_handling.rb +149 -0
  7. data/app/controllers/error_logs_controller.rb +16 -0
  8. data/app/controllers/navbar_controller.rb +6 -0
  9. data/app/controllers/omniauth_callbacks_controller.rb +17 -0
  10. data/app/controllers/registrations_controller.rb +25 -0
  11. data/app/controllers/schema_controller.rb +14 -0
  12. data/app/controllers/sessions_controller.rb +65 -0
  13. data/app/controllers/theme_settings_controller.rb +35 -0
  14. data/app/helpers/studio_theme_helper.rb +15 -0
  15. data/app/jobs/error_log_cleanup_job.rb +8 -0
  16. data/app/models/concerns/sluggable.rb +17 -0
  17. data/app/models/error_log.rb +45 -0
  18. data/app/models/image_cache.rb +11 -0
  19. data/app/models/theme_setting.rb +30 -0
  20. data/app/views/components/_admin_dropdown.html.erb +14 -0
  21. data/app/views/components/_avatar.html.erb +13 -0
  22. data/app/views/components/_avatar_cropper.html.erb +135 -0
  23. data/app/views/components/_badge.html.erb +35 -0
  24. data/app/views/components/_card.html.erb +4 -0
  25. data/app/views/components/_copy_button.html.erb +10 -0
  26. data/app/views/components/_empty_state.html.erb +7 -0
  27. data/app/views/components/_google_logo.html.erb +1 -0
  28. data/app/views/components/_input.html.erb +13 -0
  29. data/app/views/components/_json_debug.html.erb +14 -0
  30. data/app/views/components/_progress_bar.html.erb +9 -0
  31. data/app/views/components/_theme_toggle.html.erb +10 -0
  32. data/app/views/components/_theme_toggle_morph.html.erb +15 -0
  33. data/app/views/components/_user_nav.html.erb +136 -0
  34. data/app/views/error_logs/index.html.erb +76 -0
  35. data/app/views/error_logs/show.html.erb +65 -0
  36. data/app/views/layouts/_navbar.html.erb +83 -0
  37. data/app/views/layouts/studio/_flash.html.erb +256 -0
  38. data/app/views/layouts/studio/_head.html.erb +78 -0
  39. data/app/views/navbar/show.html.erb +147 -0
  40. data/app/views/registrations/new.html.erb +80 -0
  41. data/app/views/schema/index.html.erb +85 -0
  42. data/app/views/sessions/_sso_continue.html.erb +18 -0
  43. data/app/views/sessions/new.html.erb +79 -0
  44. data/app/views/theme_settings/edit.html.erb +376 -0
  45. data/lib/studio/color_scale.rb +80 -0
  46. data/lib/studio/engine.rb +12 -0
  47. data/lib/studio/image_cache.rb +152 -0
  48. data/lib/studio/s3.rb +72 -0
  49. data/lib/studio/theme_resolver.rb +99 -0
  50. data/lib/studio/username_generator.rb +21 -0
  51. data/lib/studio/version.rb +3 -0
  52. data/lib/studio-engine.rb +5 -0
  53. data/lib/studio.rb +134 -0
  54. data/studio-engine.gemspec +30 -0
  55. data/tailwind/studio.tailwind.config.js +94 -0
  56. metadata +189 -0
@@ -0,0 +1,152 @@
1
+ module Studio
2
+ module ImageCache
3
+ EXT_BY_TYPE = {
4
+ "image/png" => "png",
5
+ "image/jpeg" => "jpg",
6
+ "image/jpg" => "jpg",
7
+ "image/webp" => "webp",
8
+ "image/gif" => "gif"
9
+ }.freeze
10
+
11
+ ALLOWED_CONTENT_TYPES = EXT_BY_TYPE.keys.freeze
12
+
13
+ # Per-call cap on the bytes we'll fetch from a remote source_url. 50MB
14
+ # covers high-res photos comfortably; anything larger should be uploaded
15
+ # via source_path (local file) to bypass this cap intentionally.
16
+ MAX_REMOTE_BYTES = 50 * 1024 * 1024
17
+
18
+ class InvalidSourceURL < ArgumentError; end
19
+ class UnsupportedContentType < ArgumentError; end
20
+ class SourceTooLarge < StandardError; end
21
+
22
+ # Caches an image at S3 under a folder per owner. Every call stores the
23
+ # unmodified source as variant "original", plus one resized variant per
24
+ # entry in widths.
25
+ #
26
+ # Layout:
27
+ # {key_prefix}/original.{ext}
28
+ # {key_prefix}/{width}.{ext}
29
+ #
30
+ # Source: provide EITHER source_url (HTTP fetch) OR source_path (local
31
+ # file). source_url is recorded on each ImageCache row regardless — for
32
+ # source_path callers, pass the original URL too if you want it tracked.
33
+ #
34
+ # source_url is validated against SSRF: scheme must be http/https,
35
+ # host must not be loopback/private/link-local/metadata-IP, and
36
+ # well-known internal hostnames (localhost, *.local, *.internal) are
37
+ # rejected. This does NOT defend against DNS rebinding — strong
38
+ # protection there requires resolving DNS once then passing the
39
+ # resolved IP to the HTTP client.
40
+ #
41
+ # Idempotent: variants already present in ImageCache are skipped. If
42
+ # nothing is missing, the source is never read.
43
+ def self.cache!(owner:, purpose:, key_prefix:, widths:, source_url: nil, source_path: nil, content_type: "image/png")
44
+ raise ArgumentError, "either source_url or source_path is required" if source_url.nil? && source_path.nil?
45
+
46
+ unless ALLOWED_CONTENT_TYPES.include?(content_type)
47
+ raise UnsupportedContentType, "content_type #{content_type.inspect} not in allowlist (#{ALLOWED_CONTENT_TYPES.join(", ")})"
48
+ end
49
+
50
+ validate_source_url!(source_url) if source_url
51
+
52
+ ext = EXT_BY_TYPE[content_type]
53
+ requested = ["original", *widths.map(&:to_s)]
54
+
55
+ existing = ::ImageCache.where(owner: owner, purpose: purpose).index_by(&:variant)
56
+ missing = requested - existing.keys
57
+ return existing if missing.empty?
58
+
59
+ require "mini_magick"
60
+
61
+ body = if source_path
62
+ File.binread(source_path)
63
+ else
64
+ fetch_remote(source_url)
65
+ end
66
+
67
+ missing.each do |variant|
68
+ if variant == "original"
69
+ payload = body
70
+ s3_key = "#{key_prefix}/original.#{ext}"
71
+ else
72
+ img = MiniMagick::Image.read(body)
73
+ # Cap ImageMagick resources per-invocation to prevent decompression-bomb DoS.
74
+ img.combine_options do |c|
75
+ c.limit "memory", "256MB"
76
+ c.limit "map", "512MB"
77
+ c.limit "width", "16KP" # 16k pixel max width
78
+ c.limit "height", "16KP"
79
+ c.resize "#{variant}x"
80
+ end
81
+ payload = img.to_blob
82
+ s3_key = "#{key_prefix}/#{variant}.#{ext}"
83
+ end
84
+
85
+ Studio::S3.upload(
86
+ key: s3_key,
87
+ body: payload,
88
+ content_type: content_type,
89
+ cache_control: "public, max-age=31536000, immutable"
90
+ )
91
+
92
+ existing[variant] = ::ImageCache.create!(
93
+ owner: owner,
94
+ purpose: purpose,
95
+ variant: variant,
96
+ s3_key: s3_key,
97
+ source_url: source_url,
98
+ bytes: payload.bytesize,
99
+ content_type: content_type
100
+ )
101
+ end
102
+
103
+ existing
104
+ end
105
+
106
+ # SSRF guard for remote source_url. Raises InvalidSourceURL on anything
107
+ # that looks like an attempt to reach internal services.
108
+ def self.validate_source_url!(url)
109
+ require "uri"
110
+ uri = URI.parse(url)
111
+ unless %w[http https].include?(uri.scheme)
112
+ raise InvalidSourceURL, "URL scheme must be http or https, got #{uri.scheme.inspect}"
113
+ end
114
+
115
+ host = uri.host.to_s.downcase
116
+ raise InvalidSourceURL, "URL missing host: #{url.inspect}" if host.empty?
117
+
118
+ # Hostname-based blocklist (catches common internal hostnames before any DNS).
119
+ if host == "localhost" || host.end_with?(".local") || host.end_with?(".internal") || host.end_with?(".lan")
120
+ raise InvalidSourceURL, "URL points to internal hostname: #{host.inspect}"
121
+ end
122
+
123
+ # If the host is a literal IP address, check ranges.
124
+ bracketed = host.start_with?("[") && host.end_with?("]")
125
+ ip_host = bracketed ? host[1..-2] : host
126
+ ipv4_like = ip_host.match?(/\A\d{1,3}(\.\d{1,3}){3}\z/)
127
+ ipv6_like = bracketed || ip_host.include?(":")
128
+ if ipv4_like || ipv6_like
129
+ require "ipaddr"
130
+ begin
131
+ ip = IPAddr.new(ip_host)
132
+ rescue IPAddr::Error => e
133
+ raise InvalidSourceURL, "Malformed IP host #{ip_host.inspect}: #{e.message}"
134
+ end
135
+ if ip.loopback? || ip.private? || ip.link_local? || ip_host == "169.254.169.254" || ip.to_s == "0.0.0.0" || ip.to_s == "::"
136
+ raise InvalidSourceURL, "URL points to internal/private IP: #{ip_host}"
137
+ end
138
+ end
139
+
140
+ uri
141
+ end
142
+
143
+ def self.fetch_remote(source_url)
144
+ require "open-uri"
145
+ body = URI.open(source_url, read_timeout: 30, redirect: true).read
146
+ if body.bytesize > MAX_REMOTE_BYTES
147
+ raise SourceTooLarge, "remote payload #{body.bytesize} bytes exceeds cap #{MAX_REMOTE_BYTES}"
148
+ end
149
+ body
150
+ end
151
+ end
152
+ end
data/lib/studio/s3.rb ADDED
@@ -0,0 +1,72 @@
1
+ module Studio
2
+ module S3
3
+ class Error < StandardError; end
4
+ class NotConfigured < Error; end
5
+
6
+ class << self
7
+ def upload(key:, body:, content_type: nil, cache_control: nil)
8
+ opts = { bucket: bucket, key: key, body: body }
9
+ opts[:content_type] = content_type if content_type
10
+ opts[:cache_control] = cache_control if cache_control
11
+ client.put_object(**opts)
12
+ url(key: key)
13
+ end
14
+
15
+ def download(key:)
16
+ client.get_object(bucket: bucket, key: key).body.read
17
+ end
18
+
19
+ def url(key:)
20
+ "https://#{bucket}.s3.#{region}.amazonaws.com/#{key}"
21
+ end
22
+
23
+ def signed_url(key:, expires_in: 3600)
24
+ require "aws-sdk-s3"
25
+ Aws::S3::Presigner.new(client: client).presigned_url(:get_object, bucket: bucket, key: key, expires_in: expires_in)
26
+ end
27
+
28
+ def exists?(key:)
29
+ client.head_object(bucket: bucket, key: key)
30
+ true
31
+ rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::NoSuchKey
32
+ false
33
+ end
34
+
35
+ def delete(key:)
36
+ client.delete_object(bucket: bucket, key: key)
37
+ end
38
+
39
+ def list(prefix: nil, max: 1000)
40
+ resp = client.list_objects_v2(bucket: bucket, prefix: prefix, max_keys: max)
41
+ resp.contents.map(&:key)
42
+ end
43
+
44
+ def bucket
45
+ prefix = Studio.s3_bucket_prefix
46
+ raise NotConfigured, "Studio.s3_bucket_prefix not set in config/initializers/studio.rb" if prefix.nil? || prefix.empty?
47
+ "#{prefix}-#{environment}"
48
+ end
49
+
50
+ def region
51
+ Studio.s3_region
52
+ end
53
+
54
+ def client
55
+ @client ||= begin
56
+ require "aws-sdk-s3"
57
+ Aws::S3::Client.new(region: region)
58
+ end
59
+ end
60
+
61
+ def reset!
62
+ @client = nil
63
+ end
64
+
65
+ private
66
+
67
+ def environment
68
+ defined?(Rails) && Rails.env.production? ? "production" : "dev"
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,99 @@
1
+ module Studio
2
+ class ThemeResolver
3
+ ROLES = %i[primary dark light success warning danger accent].freeze
4
+
5
+ attr_reader :colors
6
+
7
+ # colors: hash of role => hex string (e.g. { primary: "#8E82FE", dark: "#1A1535", ... })
8
+ def initialize(colors = {})
9
+ @colors = colors.symbolize_keys
10
+ end
11
+
12
+ def to_css
13
+ dark_vars = dark_mode_vars.map { |k, v| " #{k}: #{v};" }.join("\n")
14
+ light_vars = light_mode_vars.map { |k, v| " #{k}: #{v};" }.join("\n")
15
+ palette_vars = primary_palette_vars.map { |k, v| " #{k}: #{v};" }.join("\n")
16
+
17
+ <<~CSS
18
+ :root, .dark {
19
+ #{dark_vars}
20
+ #{palette_vars}
21
+ }
22
+
23
+ html:not(.dark) {
24
+ #{light_vars}
25
+ #{palette_vars}
26
+ }
27
+ CSS
28
+ end
29
+
30
+ def dark_mode_vars
31
+ dark_base = colors[:dark] || "#1A1535"
32
+ primary = colors[:primary] || "#8E82FE"
33
+ border_rgb = ColorScale.lighten(dark_base, 0.30)
34
+
35
+ {
36
+ "--color-page" => dark_base,
37
+ "--color-surface" => ColorScale.lighten(dark_base, 0.15),
38
+ "--color-surface-alt" => ColorScale.darken(dark_base, 0.14),
39
+ "--color-inset" => ColorScale.darken(dark_base, 0.43),
40
+ "--color-text" => "#ffffff",
41
+ "--color-text-body" => "#e2e8f0",
42
+ "--color-text-secondary" => "#94a3b8",
43
+ "--color-text-muted" => "#64748b",
44
+ "--color-border" => ColorScale.with_opacity(border_rgb, 0.2),
45
+ "--color-border-strong" => ColorScale.with_opacity(border_rgb, 0.4),
46
+ "--color-shadow" => "transparent",
47
+ "--color-cta" => primary,
48
+ "--color-cta-hover" => ColorScale.darken(primary, 0.30),
49
+ "--color-success" => colors[:success] || "#4BAF50",
50
+ "--color-warning" => colors[:warning] || "#FF7C47",
51
+ "--color-danger" => colors[:danger] || "#EF4444"
52
+ }
53
+ end
54
+
55
+ # Generate --color-primary-{50..900} + RGB variants for Tailwind opacity support
56
+ def primary_palette_vars
57
+ primary = colors[:primary] || "#8E82FE"
58
+ scale = ColorScale.generate(primary)
59
+ vars = {}
60
+
61
+ scale.each do |shade, hex|
62
+ vars["--color-primary-#{shade}"] = hex
63
+ r, g, b = ColorScale.hex_to_rgb(hex)
64
+ vars["--color-primary-#{shade}-rgb"] = "#{r} #{g} #{b}"
65
+ end
66
+
67
+ # DEFAULT aliases
68
+ r, g, b = ColorScale.hex_to_rgb(primary)
69
+ vars["--color-primary"] = primary
70
+ vars["--color-primary-rgb"] = "#{r} #{g} #{b}"
71
+
72
+ vars
73
+ end
74
+
75
+ def light_mode_vars
76
+ light_base = colors[:light] || "#f8fafc"
77
+ primary = colors[:primary] || "#8E82FE"
78
+
79
+ {
80
+ "--color-page" => light_base,
81
+ "--color-surface" => "#ffffff",
82
+ "--color-surface-alt" => ColorScale.darken(light_base, 0.03),
83
+ "--color-inset" => ColorScale.darken(light_base, 0.08),
84
+ "--color-text" => "#0f172a",
85
+ "--color-text-body" => "#334155",
86
+ "--color-text-secondary" => "#64748b",
87
+ "--color-text-muted" => "#94a3b8",
88
+ "--color-border" => ColorScale.darken(light_base, 0.08),
89
+ "--color-border-strong" => ColorScale.darken(light_base, 0.15),
90
+ "--color-shadow" => "rgba(0,0,0,0.05)",
91
+ "--color-cta" => primary,
92
+ "--color-cta-hover" => ColorScale.darken(primary, 0.30),
93
+ "--color-success" => colors[:success] || "#4BAF50",
94
+ "--color-warning" => colors[:warning] || "#FF7C47",
95
+ "--color-danger" => colors[:danger] || "#EF4444"
96
+ }
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,21 @@
1
+ module Studio
2
+ class UsernameGenerator
3
+ def self.generate
4
+ 2.times do
5
+ candidate = build_name
6
+ return candidate unless User.exists?(username: candidate)
7
+ end
8
+ "#{build_name}-#{rand(1000..9999)}"
9
+ end
10
+
11
+ def self.build_name
12
+ plant = Faker::Food.send(%i[vegetables fruits].sample)
13
+ animal = Faker::Creature::Animal.name
14
+ "#{sanitize(plant)}-#{sanitize(animal)}"
15
+ end
16
+
17
+ def self.sanitize(str)
18
+ str.downcase.gsub(/[^a-z0-9]/, "-").gsub(/-+/, "-").gsub(/^-|-$/, "")
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,3 @@
1
+ module Studio
2
+ VERSION = "0.4.1"
3
+ end
@@ -0,0 +1,5 @@
1
+ # Entry point for the `studio-engine` gem. The actual code lives in
2
+ # `lib/studio.rb` (which exports the `Studio` module). This shim exists so
3
+ # `gem "studio-engine"` in a Gemfile loads correctly without consumers
4
+ # needing to add `require: "studio"`.
5
+ require_relative "studio"
data/lib/studio.rb ADDED
@@ -0,0 +1,134 @@
1
+ require "studio/version"
2
+ require "studio/engine"
3
+ require "studio/color_scale"
4
+ require "studio/theme_resolver"
5
+ require "studio/username_generator"
6
+ require "studio/s3"
7
+ require "studio/image_cache"
8
+
9
+ module Studio
10
+ mattr_accessor :app_name, default: "Studio"
11
+ mattr_accessor :session_key, default: :user_id
12
+ mattr_accessor :welcome_message, default: ->(user) { "Welcome, #{user.display_name}!" }
13
+ mattr_accessor :registration_params, default: [:name, :email, :password, :password_confirmation]
14
+ mattr_accessor :configure_new_user, default: ->(user) {}
15
+ mattr_accessor :configure_sso_user, default: ->(user) {}
16
+ mattr_accessor :sso_logo, default: nil
17
+ mattr_accessor :theme_logos, default: []
18
+
19
+ # Theme role colors (7 roles)
20
+ mattr_accessor :theme_primary, default: "#8E82FE"
21
+ mattr_accessor :theme_dark, default: "#1A1535"
22
+ mattr_accessor :theme_light, default: "#f8fafc"
23
+ mattr_accessor :theme_success, default: "#4BAF50"
24
+ mattr_accessor :theme_warning, default: "#FF7C47"
25
+ mattr_accessor :theme_danger, default: "#EF4444"
26
+ mattr_accessor :theme_accent, default: "#F72585"
27
+
28
+ # S3 / object storage — host apps MUST set s3_bucket_prefix explicitly in
29
+ # config/initializers/studio.rb before any S3-touching code runs (ImageCache,
30
+ # Studio::S3.upload, etc.). Default is nil so external users don't accidentally
31
+ # target someone else's bucket if they forget to configure.
32
+ #
33
+ # Bucket name resolves to "#{s3_bucket_prefix}-#{Rails.env.production? ? 'production' : 'dev'}".
34
+ mattr_accessor :s3_bucket_prefix, default: nil
35
+ mattr_accessor :s3_region, default: "us-east-2"
36
+
37
+ class S3ConfigError < StandardError; end
38
+
39
+ # Whether to validate the host app's User model at boot. See docs/USER_CONTRACT.md.
40
+ # Set to false in config/initializers/studio.rb to bypass (e.g. during migrations
41
+ # that intentionally break the contract).
42
+ mattr_accessor :validate_user_contract, default: true
43
+
44
+ # Only methods that consumers must explicitly define are checked here.
45
+ # Column accessors (#email, #name, #role) are NOT validated because
46
+ # ActiveRecord defines them lazily — they don't appear on `.instance_methods`
47
+ # until the schema is introspected (typically first record access). Missing
48
+ # columns are caught by the User table schema, not by this validator.
49
+ REQUIRED_USER_INSTANCE_METHODS = %i[authenticate admin? display_name].freeze
50
+ REQUIRED_USER_CLASS_METHODS = %i[find_by].freeze
51
+
52
+ class UserContractError < StandardError; end
53
+
54
+ def self.configure
55
+ yield self
56
+ end
57
+
58
+ # Verifies that the host app's User model satisfies the engine's expected
59
+ # contract. Raises Studio::UserContractError with a clear pointer to
60
+ # docs/USER_CONTRACT.md if anything required is missing. Called from
61
+ # Engine#after_initialize. Opt out via Studio.validate_user_contract = false.
62
+ def self.validate_user_contract!(user_class)
63
+ return unless validate_user_contract
64
+ return unless user_class.is_a?(Class)
65
+
66
+ missing = []
67
+ REQUIRED_USER_CLASS_METHODS.each do |m|
68
+ missing << "User.#{m}" unless user_class.respond_to?(m)
69
+ end
70
+ REQUIRED_USER_INSTANCE_METHODS.each do |m|
71
+ missing << "User##{m}" unless user_class.instance_methods.include?(m)
72
+ end
73
+
74
+ return if missing.empty?
75
+
76
+ raise UserContractError, <<~MSG
77
+ The studio-engine gem's expected User model contract is not satisfied.
78
+
79
+ Missing: #{missing.join(", ")}
80
+
81
+ See the USER_CONTRACT.md doc in the studio-engine repo for the full
82
+ contract + a minimal compliant example:
83
+ https://github.com/amcritchie/studio-engine/blob/main/docs/USER_CONTRACT.md
84
+
85
+ To bypass this check temporarily, set Studio.validate_user_contract = false
86
+ in config/initializers/studio.rb.
87
+ MSG
88
+ end
89
+
90
+ def self.theme_config
91
+ {
92
+ primary: theme_primary,
93
+ dark: theme_dark,
94
+ light: theme_light,
95
+ success: theme_success,
96
+ warning: theme_warning,
97
+ danger: theme_danger,
98
+ accent: theme_accent
99
+ }.compact
100
+ end
101
+
102
+ # Find a logo from theme_logos by title, with fallback chain:
103
+ # 1. Exact title match
104
+ # 2. "Navbar Logo" fallback
105
+ # 3. First logo in the list
106
+ def self.logo_for(title)
107
+ logos = theme_logos.map { |l| l.is_a?(Hash) ? l : { file: l, title: l } }
108
+ entry = logos.find { |l| l[:title] == title }
109
+ entry ||= logos.find { |l| l[:title] == "Navbar Logo" }
110
+ entry ||= logos.first
111
+ entry ? "/#{entry[:file]}" : nil
112
+ end
113
+
114
+ def self.routes(router)
115
+ router.instance_exec do
116
+ get "login", to: "sessions#new"
117
+ post "login", to: "sessions#create"
118
+ post "sso_continue", to: "sessions#sso_continue"
119
+ get "sso_login", to: "sessions#sso_login"
120
+ get "logout", to: "sessions#destroy"
121
+ get "signup", to: "registrations#new"
122
+ post "signup", to: "registrations#create"
123
+ get "auth/:provider/callback", to: "omniauth_callbacks#create"
124
+ get "auth/failure", to: "omniauth_callbacks#failure"
125
+ resources :error_logs, only: [:index, :show]
126
+
127
+ # Admin
128
+ get "admin/theme", to: "theme_settings#edit", as: :admin_theme
129
+ patch "admin/theme", to: "theme_settings#update", as: :admin_theme_update
130
+ post "admin/theme/regenerate", to: "theme_settings#regenerate", as: :admin_theme_regenerate
131
+ get "admin/schema", to: "schema#index", as: :admin_schema
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,30 @@
1
+ require_relative "lib/studio/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "studio-engine"
5
+ spec.version = Studio::VERSION
6
+ spec.authors = ["Alex McRitchie"]
7
+ spec.email = ["studio-engine@mcritchie.studio"]
8
+ spec.summary = "Shared Rails engine providing auth, SSO, error logging, theming, and S3-backed image caching"
9
+ spec.description = "Studio Engine is a non-isolated Rails engine that ships an opinionated authentication + SSO contract, a polymorphic ErrorLog model, a Sluggable concern, a 7-role dynamic theme system with CSS-custom-property generation, and an S3-backed ImageCache. Used in production across the McRitchie Studio + Turf Monster apps."
10
+ spec.homepage = "https://github.com/amcritchie/studio-engine"
11
+ spec.license = "MIT"
12
+ spec.required_ruby_version = ">= 3.0"
13
+
14
+ spec.metadata = {
15
+ "homepage_uri" => "https://github.com/amcritchie/studio-engine",
16
+ "source_code_uri" => "https://github.com/amcritchie/studio-engine",
17
+ "bug_tracker_uri" => "https://github.com/amcritchie/studio-engine/issues",
18
+ "changelog_uri" => "https://github.com/amcritchie/studio-engine/blob/main/CHANGELOG.md"
19
+ }
20
+
21
+ spec.files = Dir["lib/**/*", "app/**/*", "config/**/*", "tailwind/**/*", "Gemfile", "studio-engine.gemspec", "README.md", "CHANGELOG.md", "LICENSE"]
22
+ spec.require_paths = ["lib"]
23
+
24
+ spec.add_dependency "rails", ">= 7.0"
25
+ spec.add_dependency "tailwindcss-rails", "~> 2.7"
26
+ spec.add_dependency "faker", ">= 2.0"
27
+ spec.add_dependency "solid_queue"
28
+ spec.add_dependency "aws-sdk-s3", "~> 1.218"
29
+ spec.add_dependency "mini_magick", "~> 5.0"
30
+ end
@@ -0,0 +1,94 @@
1
+ // Shared Tailwind config for all Studio apps
2
+ // Apps spread from this in their own tailwind.config.js
3
+
4
+ module.exports = {
5
+ darkMode: 'class',
6
+ theme: {
7
+ fontFamily: {
8
+ sans: ['Montserrat', 'system-ui', 'sans-serif'],
9
+ mono: ['ui-monospace', 'SFMono-Regular', 'monospace'],
10
+ },
11
+ extend: {
12
+ colors: {
13
+ // Theme-aware semantic tokens (reference CSS variables)
14
+ page: 'var(--color-page)',
15
+ surface: 'var(--color-surface)',
16
+ 'surface-alt': 'var(--color-surface-alt)',
17
+ inset: 'var(--color-inset)',
18
+
19
+ // Dynamic primary palette (from theme role colors)
20
+ primary: {
21
+ DEFAULT: 'rgb(var(--color-primary-rgb) / <alpha-value>)',
22
+ 50: 'rgb(var(--color-primary-50-rgb) / <alpha-value>)',
23
+ 100: 'rgb(var(--color-primary-100-rgb) / <alpha-value>)',
24
+ 200: 'rgb(var(--color-primary-200-rgb) / <alpha-value>)',
25
+ 300: 'rgb(var(--color-primary-300-rgb) / <alpha-value>)',
26
+ 400: 'rgb(var(--color-primary-400-rgb) / <alpha-value>)',
27
+ 500: 'rgb(var(--color-primary-500-rgb) / <alpha-value>)',
28
+ 600: 'rgb(var(--color-primary-600-rgb) / <alpha-value>)',
29
+ 700: 'rgb(var(--color-primary-700-rgb) / <alpha-value>)',
30
+ 800: 'rgb(var(--color-primary-800-rgb) / <alpha-value>)',
31
+ 900: 'rgb(var(--color-primary-900-rgb) / <alpha-value>)',
32
+ },
33
+
34
+ mint: {
35
+ DEFAULT: '#06D6A0',
36
+ 50: '#e6faf4',
37
+ 100: '#b3f0de',
38
+ 200: '#80e6c8',
39
+ 300: '#4ddcb2',
40
+ 400: '#1ad29c',
41
+ 500: '#06D6A0',
42
+ 600: '#05b888',
43
+ 700: '#049a70',
44
+ 800: '#037c58',
45
+ 900: '#025e40',
46
+ },
47
+ navy: {
48
+ DEFAULT: '#1A1535',
49
+ 50: '#e8e7ed',
50
+ 100: '#b8b5c8',
51
+ 200: '#8883a3',
52
+ 300: '#58517e',
53
+ 400: '#3a3359',
54
+ 500: '#1A1535',
55
+ 600: '#16122e',
56
+ 700: '#120f27',
57
+ 800: '#0e0c20',
58
+ 900: '#0a0919',
59
+ },
60
+ violet: {
61
+ DEFAULT: '#8E82FE',
62
+ 50: '#f0eeff',
63
+ 100: '#EAE8FF',
64
+ 200: '#b2aafe',
65
+ 300: '#C5C0FE',
66
+ 400: '#8E82FE',
67
+ 500: '#8E82FE',
68
+ 600: '#6558e5',
69
+ 700: '#6558E0',
70
+ 800: '#3b2cb3',
71
+ 900: '#3D2FB5',
72
+ },
73
+ mist: '#F7F6FF',
74
+ lavender: '#E8E6F0',
75
+ slate: '#6B6580',
76
+ charcoal: '#2D2648',
77
+ midnight: '#120F28',
78
+ ember: '#FF8C69',
79
+ gold: '#FFD166',
80
+ magenta: '#F72585',
81
+ },
82
+ textColor: {
83
+ heading: 'var(--color-text)',
84
+ body: 'var(--color-text-body)',
85
+ secondary: 'var(--color-text-secondary)',
86
+ muted: 'var(--color-text-muted)',
87
+ },
88
+ borderColor: {
89
+ subtle: 'var(--color-border)',
90
+ strong: 'var(--color-border-strong)',
91
+ },
92
+ },
93
+ },
94
+ }