studio-engine 0.102.0 → 0.103.0.rc2

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: 3892a67d3cb43b26a47398f6f1560d2bb6f8d00265815dd86532e77acef4e869
4
- data.tar.gz: 0ea0fb8690a31529d28cac58c22c695e6fe726d3ba5a30ecd9cd65d78b9f90d6
3
+ metadata.gz: 792f0e16ad48572393e4e8cf22f51d497e832af869b0d1bfe6dd5be6ac8da058
4
+ data.tar.gz: c862691168c2c8e782d4b4ec7d6d956058494b717fd2d4b2757f6d9f2bd60148
5
5
  SHA512:
6
- metadata.gz: 9e24a1c2ffc63f222044117d12716a455204a0a58e1548092083eb9955ffc29dda9f855a4bc14efea79d44437fd48b5abe6e8311deaadc94078e979640e46f3c
7
- data.tar.gz: a383db6a52bc983e52ead696868cc3d8f36d5e878606879a969faec430e12e5dd3733245c437e3204a516db9c4a0460000067083710799c407f4b3411a528b00
6
+ metadata.gz: 5d7b0b148093fca45e7ded99ad0056fc6a8a6e5dc698f1b85a4032414ae9a2a92b92ab94189ba3f7fc287b45fc40b1796e0b4807b910b08a99403b202d965c4a
7
+ data.tar.gz: 5c37ecd0f4c1f526cebe802cfc46a0dda47c4be3b1558ad57d2648de3fbfbf29fa122267551b5673f33000bf1edbaef0ba2a7154ab7f127c36fb099a14487015
data/CHANGELOG.md CHANGED
@@ -4,6 +4,96 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Added
8
+
9
+ - **`--color-primary-ink` and `text-primary-ink`: the primary colour as chip
10
+ text.** The theme resolver derives it per theme the way it derives the
11
+ danger, warning and success inks: the configured primary, shaded until it
12
+ clears WCAG AA 4.5:1 on every surface and on its own 10%, 15% and 20% tints.
13
+ `text-primary` and `--color-primary` stay the brand colour.
14
+
15
+ ### Changed
16
+
17
+ - **Primary chips are painted in the primary colour and clear AA in both
18
+ themes.** `Studio::BadgeComponent`'s `tone: :primary` drew heading-coloured
19
+ text (white in dark, near-black in light), and its `scheme: "primary"`, the
20
+ Marketing chip on `/admin/emails` and the sidebar's ADMIN chip drew the raw
21
+ primary, 2.2:1 to 3.6:1 on its tint. All of them now draw `text-primary-ink`.
22
+ A host that copies the tone's class string (the hub's `status_tone`) changes
23
+ when it adopts the class.
24
+ - **Every role ink clears AA on its 10%, 15% and 20% tints, as a browser
25
+ paints them.** The apps put the inks on the stronger tints too (turf's
26
+ "Entered" badge is `bg-primary/15`; the hub's workflow and verdict chips are
27
+ `bg-success/20 text-success-ink` and `bg-warning/20 text-warning-ink`), where
28
+ an ink tuned to the 10% chip read as low as 3.83:1 (default warning, dark, on
29
+ 20%). The search now counts 0.10, 26/255 (how Chromium composites a 10%
30
+ fill), 0.15 and 0.20, each also one unit off in every channel, because 8-bit
31
+ compositing rounds (Chromium painted a 20% danger tint one unit lighter than
32
+ the blend, taking a 4.51:1 ink to 4.47:1). The inks darken in light and
33
+ lighten in dark. Default theme, 0.102.0 to this release:
34
+
35
+ | Ink | Dark | Light |
36
+ |---|---|---|
37
+ | primary (new) | `#BDB7FE` | `#58519D` |
38
+ | success | `#8EB791` to `#96BC99` | `#2E6C32` to `#2A622D` |
39
+ | warning | `#FF966C` to `#FFAE8D` | `#994A2B` to `#8F4528` |
40
+ | danger | `#F58F8F` to `#F79E9E` | `#AC3131` to `#9E2D2D` |
41
+
42
+ A configured role colour moves the same way; a configured colour that
43
+ already clears every tint (mcritchie-industries' yellow warning) keeps its
44
+ exact hex.
45
+
46
+ ## 0.103.0 — 2026-10-10
47
+
48
+ ### Added
49
+
50
+ - **Seed assets reference production.** A non-production process (laptop,
51
+ desk, QA app) can record an `ImageCache` row that REFERENCES an object
52
+ already in the production bucket instead of uploading a copy into the dev
53
+ bucket. New:
54
+ - `Studio.s3_production_public_url`: production's public host, set in
55
+ every environment (e.g. `https://assets.turfmonster.media`). Default nil
56
+ turns references off.
57
+ - `image_caches.stored_in` (migration `AddStoredInToImageCaches`):
58
+ nullable string. nil or `"own"` is the own bucket, `"production"` a
59
+ reference. `ImageCache#url` resolves a reference on the production host
60
+ off production. In production both kinds resolve on the own host, which
61
+ is production's.
62
+ - `Studio::ImageCache.reference_or_cache!`: `cache!`'s arguments and
63
+ answer. Off production, with the setting, it HEADs each missing variant
64
+ in `<prefix>-production` (read-only) and records a reference when found,
65
+ else uploads to the own bucket as `cache!` does. A HEAD failing for any
66
+ reason but not-found raises. In production, or without the setting, it
67
+ is `cache!`.
68
+ - `Studio::S3.production_bucket`, `production_references?`,
69
+ `production_url`, `production_head`, `production_exists?` and
70
+ `production_download`: reads only. `ImageCache#download` and
71
+ `#delete_object!` act on the bucket that holds the row's object;
72
+ off production `delete_object!` deletes nothing for a reference.
73
+
74
+ **Design.** A per-row marker plus one configured host, because a row must
75
+ say whose object it is: 11 of 12 sampled Turf headshot keys were on
76
+ production and one (`kenneth-walker-iii`) was only on dev, so no app-wide
77
+ switch is right for every row. Rejected: storing absolute URLs in `s3_key`
78
+ (breaks every reader that treats it as a key, the uniqueness index and the
79
+ key namespace); an AR enum (raises at load on a host that has not migrated);
80
+ an HTTP HEAD on the public host (a CDN answer is not the bucket's, and the
81
+ dev token's Item Read on production makes the S3 HEAD available and
82
+ stubbable); requiring the column in `Studio::HostSchema` (would fail boot on
83
+ every consumer before it installs the migration). The existing guard
84
+ (`guard_production_bucket!`) still refuses any delete from a `*-production`
85
+ bucket off production, and the dev R2 tokens cannot write production.
86
+
87
+ **Consumer adoption.** Nothing changes until an app opts in, and production
88
+ behaves as before. To adopt: (1) `bin/rails studio_engine:install:migrations
89
+ db:migrate` and commit the copy and `db/schema.rb`; (2) set
90
+ `config.s3_production_public_url` to the production asset host in
91
+ `config/initializers/studio.rb`; (3) switch seeders from
92
+ `Studio::ImageCache.cache!` to `reference_or_cache!`; (4) replace any
93
+ `Studio::S3.download(key: row.s3_key)` or `Studio::S3.delete(key:
94
+ row.s3_key)` on an `ImageCache` row with `row.download` /
95
+ `row.delete_object!`.
96
+
7
97
  ## 0.102.0 — 2026-10-10
8
98
 
9
99
  ### Removed
data/README.md CHANGED
@@ -979,6 +979,33 @@ be a positive Integer.
979
979
  `Studio::S3.delete` moves the object to `trash/` for three days rather than
980
980
  deleting it; see [Trash and restore](#trash-and-restore).
981
981
 
982
+ ### Seed assets reference production
983
+
984
+ Seed assets (NFL headshots, Pokémon sprites) live in the **production** bucket.
985
+ A laptop, desk or QA app points at production's copy instead of uploading a
986
+ duplicate into the dev bucket, and uploads only what production lacks. Set
987
+ production's public host in every environment (it is not a secret):
988
+
989
+ ```ruby
990
+ config.s3_production_public_url = "https://assets.turfmonster.media"
991
+ ```
992
+
993
+ and have seeders call `Studio::ImageCache.reference_or_cache!` (same arguments
994
+ and answer as `cache!`). Off production it HEADs each missing variant in
995
+ `<prefix>-production` (read-only; the dev R2 token holds Item Read there).
996
+ Found: an `ImageCache` row with `stored_in = "production"`, whose `#url`
997
+ resolves on `s3_production_public_url`. Absent: an ordinary upload to the own
998
+ bucket. A HEAD failing for any reason but not-found raises. In production, or
999
+ with the setting unset, it is exactly `cache!`.
1000
+
1001
+ `stored_in` arrives by the engine migration `AddStoredInToImageCaches`
1002
+ (nullable; nil and `"own"` mean the own bucket, so existing rows keep their
1003
+ URLs). Read an object's bytes with `ImageCache#download` and remove it with
1004
+ `ImageCache#delete_object!`, never `Studio::S3.download/delete(key: row.s3_key)`:
1005
+ those read and delete the OWN bucket. Off production, `delete_object!` on a
1006
+ production reference deletes nothing, and `Studio::S3` has no write, copy or
1007
+ delete that targets the production bucket.
1008
+
982
1009
  An app with **no** bucket configured does not error — `/admin/emails` renders
983
1010
  read-only, showing the inherited defaults it is genuinely sending and naming the
984
1011
  one setting that turns uploads on.
@@ -20,7 +20,8 @@ module Studio
20
20
  # rest of the page uses for that status. Tokens only, so it reads in both themes.
21
21
  #
22
22
  # scheme: is the palette the components/badge partial took (SCHEMES), including
23
- # the stage-* pipeline ladder, and renders exactly what that partial rendered.
23
+ # the stage-* pipeline ladder. Its "primary" draws the primary ink; the rest
24
+ # render exactly what that partial rendered.
24
25
  # An unknown scheme renders FALLBACK, as the partial did.
25
26
  #
26
27
  # Pass tone: or scheme:, not both. Neither renders the neutral scheme.
@@ -34,7 +35,7 @@ module Studio
34
35
  success: "bg-success/10 text-success-ink border-success/40",
35
36
  warning: "bg-warning/10 text-warning-ink border-warning/40",
36
37
  danger: "bg-danger/10 text-danger-ink border-danger/40",
37
- primary: "bg-primary/10 text-heading border-primary/40",
38
+ primary: "bg-primary/10 text-primary-ink border-primary/40",
38
39
  muted: "bg-surface-alt text-muted border-subtle"
39
40
  }.freeze
40
41
 
@@ -44,7 +45,7 @@ module Studio
44
45
  "warning" => "bg-yellow-500/10 text-yellow-400 border-yellow-500/30",
45
46
  "info" => "bg-blue-500/10 text-blue-500 border-blue-500/30",
46
47
  "violet" => "bg-violet/10 text-violet border-violet/30",
47
- "primary" => "bg-primary/10 text-primary border-primary/30",
48
+ "primary" => "bg-primary/10 text-primary-ink border-primary/30",
48
49
  "orange" => "bg-orange-500/10 text-orange-500 border-orange-500/30",
49
50
  "emerald" => "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
50
51
  "gray" => "bg-gray-500/10 text-gray-400 border-gray-500/30",
@@ -1,4 +1,20 @@
1
1
  class ImageCache < ApplicationRecord
2
+ # Where a row's object lives (the `stored_in` column):
3
+ #
4
+ # nil / "own" this process's own bucket, at Studio.s3_public_url. Every row
5
+ # written before the column existed, and every upload.
6
+ # "production" a REFERENCE to production's copy, recorded by a
7
+ # non-production seeder (Studio::ImageCache.reference_or_cache!)
8
+ # instead of uploading a duplicate. It resolves on
9
+ # Studio.s3_production_public_url.
10
+ #
11
+ # A plain string, not an AR enum: the column arrives by an engine migration a
12
+ # host installs on its own schedule, and an enum declared on an absent column
13
+ # raises at load. Without the column every row reads as "own", as before.
14
+ STORED_IN = %w[own production].freeze
15
+ OWN = "own"
16
+ PRODUCTION = "production"
17
+
2
18
  # Optional so app-GLOBAL images (no owning record) can be cached too — e.g.
3
19
  # Studio::EmailCatalog stores the admin-managed email banners owner-less. Per-
4
20
  # record images (athlete/coach headshots) still set an owner; the
@@ -8,8 +24,41 @@ class ImageCache < ApplicationRecord
8
24
  validates :purpose, :variant, :s3_key, presence: true
9
25
  validates :s3_key, uniqueness: true
10
26
  validates :variant, uniqueness: { scope: [:owner_type, :owner_id, :purpose] }
27
+ validates :stored_in, inclusion: { in: STORED_IN }, allow_nil: true, if: -> { has_attribute?(:stored_in) }
28
+
29
+ def self.stored_in_column?
30
+ table_exists? && column_names.include?("stored_in")
31
+ end
32
+
33
+ def production_reference?
34
+ has_attribute?(:stored_in) && stored_in == PRODUCTION
35
+ end
11
36
 
37
+ # In production both kinds resolve on the app's own host, which IS
38
+ # production's, so a production app's URLs are what they always were.
12
39
  def url
13
- Studio::S3.url(key: s3_key)
40
+ production_reference? ? Studio::S3.production_url(key: s3_key) : Studio::S3.url(key: s3_key)
41
+ end
42
+
43
+ # The object's bytes, read from the bucket that holds them (read-only for a
44
+ # production reference). Use this, not Studio::S3.download(key: s3_key),
45
+ # which reads this process's own bucket.
46
+ def download(max_bytes: nil)
47
+ if production_reference?
48
+ Studio::S3.production_download(key: s3_key, max_bytes: max_bytes)
49
+ else
50
+ Studio::S3.download(key: s3_key, max_bytes: max_bytes)
51
+ end
52
+ end
53
+
54
+ # Removes the row's OBJECT (to trash, via Studio::S3.delete). A production
55
+ # reference outside production is not this process's object: nothing is
56
+ # deleted from either bucket (the own bucket may hold an unrelated object
57
+ # under the same key) and nil is returned. Destroying the row itself is
58
+ # always fine. In production a reference is an ordinary own object.
59
+ def delete_object!
60
+ return nil if production_reference? && !Studio::S3.production_environment?
61
+
62
+ Studio::S3.delete(key: s3_key)
14
63
  end
15
64
  end
@@ -27,7 +27,7 @@
27
27
  <div class="px-4 pt-4 pb-1 label-upper font-bold text-muted flex items-center gap-2">
28
28
  <span><%= section[:title] %></span>
29
29
  <% if section[:admin] %>
30
- <span class="rounded border border-primary/20 bg-primary/10 px-1.5 py-0.5 text-[10px] font-mono text-primary">ADMIN</span>
30
+ <span class="rounded border border-primary/20 bg-primary/10 px-1.5 py-0.5 text-[10px] font-mono text-primary-ink">ADMIN</span>
31
31
  <% end %>
32
32
  </div>
33
33
 
@@ -92,7 +92,7 @@
92
92
  <% if sidebar_admin && sections.any? { |section| section[:admin] } %>
93
93
  <div class="px-4 pt-4 pb-1 label-upper font-bold text-muted flex items-center gap-2">
94
94
  <span>Geo</span>
95
- <span class="rounded border border-primary/20 bg-primary/10 px-1.5 py-0.5 text-[10px] font-mono text-primary">ADMIN</span>
95
+ <span class="rounded border border-primary/20 bg-primary/10 px-1.5 py-0.5 text-[10px] font-mono text-primary-ink">ADMIN</span>
96
96
  </div>
97
97
  <%= render "components/geo_signpost", variant: :sidebar, close_action: close_action %>
98
98
  <% end %>
@@ -85,7 +85,7 @@
85
85
  <%= link_to entry.label, admin_email_path(entry.key),
86
86
  class: "font-semibold text-heading hover:text-primary underline-offset-2 hover:underline" %>
87
87
  <% if entry.marketing? %>
88
- <span class="badge bg-primary/10 text-primary border-primary/30 ml-1.5 whitespace-nowrap">Marketing</span>
88
+ <span class="badge bg-primary/10 text-primary-ink border-primary/30 ml-1.5 whitespace-nowrap">Marketing</span>
89
89
  <% end %>
90
90
  <% if entry.description.present? %>
91
91
  <p class="text-sm text-body mt-0.5 max-w-md"><%= entry.description %></p>
@@ -103,7 +103,7 @@
103
103
  </div>
104
104
 
105
105
  <div class="flex flex-wrap items-center gap-2 mt-3">
106
- <span class="badge <%= @entry.marketing? ? "bg-primary/10 text-primary border-primary/30" : "bg-inset text-muted border-subtle" %> whitespace-nowrap">
106
+ <span class="badge <%= @entry.marketing? ? "bg-primary/10 text-primary-ink border-primary/30" : "bg-inset text-muted border-subtle" %> whitespace-nowrap">
107
107
  <%= @entry.type.to_s.titleize %>
108
108
  </span>
109
109
  <span class="<%= badge_class %> whitespace-nowrap"><%= badge_label %></span>
@@ -124,7 +124,7 @@
124
124
  </div>
125
125
  <% end %>
126
126
  <% chrome_kickoff = capture do %>
127
- <span class="inline-flex items-center gap-1 rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-2xs font-semibold uppercase tracking-wide text-primary">kickoff slot</span>
127
+ <span class="inline-flex items-center gap-1 rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-2xs font-semibold uppercase tracking-wide text-primary-ink">kickoff slot</span>
128
128
  <% end %>
129
129
  <%
130
130
  # Per-column count_class colours (raw classes, the task_stage_count_classes shape),
@@ -0,0 +1,25 @@
1
+ # Adds image_caches.stored_in: where a row's object lives. nil (every existing
2
+ # row) and "own" mean this app's own bucket; "production" marks a non-production
3
+ # row that REFERENCES production's copy of a seed asset instead of a duplicate
4
+ # in the dev bucket (Studio::ImageCache.reference_or_cache!, ImageCache#url).
5
+ #
6
+ # Nullable with no default, so the add is a catalog-only change on any table
7
+ # size and no existing row is rewritten or changes its URL.
8
+ #
9
+ # image_caches is host-owned; on a host without the table this is a no-op, like
10
+ # AllowNullImageCacheOwner. Studio::HostSchema does not require the column: a
11
+ # host that has not installed this migration keeps working, and every row reads
12
+ # as "own".
13
+ class AddStoredInToImageCaches < ActiveRecord::Migration[7.2]
14
+ def up
15
+ return unless table_exists?(:image_caches)
16
+
17
+ add_column :image_caches, :stored_in, :string, if_not_exists: true
18
+ end
19
+
20
+ def down
21
+ return unless table_exists?(:image_caches)
22
+
23
+ remove_column :image_caches, :stored_in, if_exists: true
24
+ end
25
+ end
@@ -46,21 +46,107 @@ module Studio
46
46
  # Idempotent: variants already present in ImageCache are skipped. If
47
47
  # nothing is missing, the source is never read.
48
48
  def self.cache!(owner:, purpose:, key_prefix:, widths:, source_url: nil, source_path: nil, content_type: "image/png")
49
- raise ArgumentError, "either source_url or source_path is required" if source_url.nil? && source_path.nil?
49
+ ext, requested = prepare!(source_url: source_url, source_path: source_path, content_type: content_type, widths: widths)
50
50
 
51
- unless ALLOWED_CONTENT_TYPES.include?(content_type)
52
- raise UnsupportedContentType, "content_type #{content_type.inspect} not in allowlist (#{ALLOWED_CONTENT_TYPES.join(", ")})"
51
+ existing = ::ImageCache.where(owner: owner, purpose: purpose).index_by(&:variant)
52
+ missing = requested - existing.keys
53
+ return existing if missing.empty?
54
+
55
+ upload_variants!(existing, missing, owner: owner, purpose: purpose, key_prefix: key_prefix, ext: ext,
56
+ source_url: source_url, source_path: source_path, content_type: content_type)
57
+ end
58
+
59
+ # ── SEED ASSETS: REFERENCE PRODUCTION, UPLOAD ONLY WHAT IS NEW ────────────
60
+ #
61
+ # cache!'s signature and answer, for SEEDERS. Seed assets live in the
62
+ # production bucket; a laptop, desk or QA app should point at production's
63
+ # copy rather than upload a duplicate into the dev bucket. So, outside
64
+ # production, with Studio.s3_production_public_url set, each missing variant
65
+ # is first looked for in production by a read-only HEAD
66
+ # (Studio::S3.production_head) under the key cache! would write:
67
+ #
68
+ # found an ImageCache row with stored_in "production" is recorded. Its
69
+ # #url resolves on the production host. Nothing is uploaded.
70
+ # absent the variant is uploaded to this process's OWN bucket exactly as
71
+ # cache! does (stored_in nil = own). A key production lacks
72
+ # (kenneth-walker-iii, 2026-10-10) still gets an image.
73
+ #
74
+ # The source is fetched only when at least one variant has to be uploaded.
75
+ # A HEAD that fails for any reason but "not found" raises: a token without
76
+ # read on production must not quietly turn into a dev-bucket copy of
77
+ # everything.
78
+ #
79
+ # In production, or with s3_production_public_url unset, this IS cache!.
80
+ # Production never reads the knob, so production apps behave as before.
81
+ #
82
+ # Nothing here writes, copies or deletes in the production bucket.
83
+ def self.reference_or_cache!(owner:, purpose:, key_prefix:, widths:, source_url: nil, source_path: nil,
84
+ content_type: "image/png")
85
+ unless Studio::S3.production_references?
86
+ return cache!(owner: owner, purpose: purpose, key_prefix: key_prefix, widths: widths,
87
+ source_url: source_url, source_path: source_path, content_type: content_type)
53
88
  end
54
89
 
55
- validate_source_url!(source_url) if source_url
90
+ unless ::ImageCache.stored_in_column?
91
+ raise MissingStoredInColumn,
92
+ "image_caches.stored_in is missing: run `bin/rails studio_engine:install:migrations db:migrate` " \
93
+ "before referencing production seed assets"
94
+ end
56
95
 
57
- ext = EXT_BY_TYPE[content_type]
58
- requested = ["original", *widths.map(&:to_s)]
96
+ ext, requested = prepare!(source_url: source_url, source_path: source_path, content_type: content_type, widths: widths)
59
97
 
60
98
  existing = ::ImageCache.where(owner: owner, purpose: purpose).index_by(&:variant)
61
99
  missing = requested - existing.keys
62
100
  return existing if missing.empty?
63
101
 
102
+ to_upload = missing.reject do |variant|
103
+ s3_key = variant_key(key_prefix, variant, ext)
104
+ head = Studio::S3.production_head(key: s3_key)
105
+ next false if head.nil?
106
+
107
+ existing[variant] = ::ImageCache.create!(
108
+ owner: owner,
109
+ purpose: purpose,
110
+ variant: variant,
111
+ s3_key: s3_key,
112
+ stored_in: ::ImageCache::PRODUCTION,
113
+ source_url: source_url,
114
+ bytes: head.content_length,
115
+ content_type: head.content_type.to_s.empty? ? content_type : head.content_type
116
+ )
117
+ true
118
+ end
119
+ return existing if to_upload.empty?
120
+
121
+ upload_variants!(existing, to_upload, owner: owner, purpose: purpose, key_prefix: key_prefix, ext: ext,
122
+ source_url: source_url, source_path: source_path, content_type: content_type)
123
+ end
124
+
125
+ class MissingStoredInColumn < StandardError; end
126
+
127
+ # The key every variant is written under, by cache! and looked up under by
128
+ # reference_or_cache!. One definition, so the two can never disagree.
129
+ def self.variant_key(key_prefix, variant, ext)
130
+ "#{key_prefix}/#{variant}.#{ext}"
131
+ end
132
+
133
+ def self.prepare!(source_url:, source_path:, content_type:, widths:)
134
+ raise ArgumentError, "either source_url or source_path is required" if source_url.nil? && source_path.nil?
135
+
136
+ unless ALLOWED_CONTENT_TYPES.include?(content_type)
137
+ raise UnsupportedContentType, "content_type #{content_type.inspect} not in allowlist (#{ALLOWED_CONTENT_TYPES.join(", ")})"
138
+ end
139
+
140
+ validate_source_url!(source_url) if source_url
141
+
142
+ [EXT_BY_TYPE[content_type], ["original", *widths.map(&:to_s)]]
143
+ end
144
+ private_class_method :prepare!
145
+
146
+ # Reads the source once and uploads each of `missing` to this process's own
147
+ # bucket, recording a row per variant into `existing`, which it returns.
148
+ def self.upload_variants!(existing, missing, owner:, purpose:, key_prefix:, ext:, source_url:, source_path:,
149
+ content_type:)
64
150
  require "mini_magick"
65
151
 
66
152
  body = if source_path
@@ -72,7 +158,6 @@ module Studio
72
158
  missing.each do |variant|
73
159
  if variant == "original"
74
160
  payload = body
75
- s3_key = "#{key_prefix}/original.#{ext}"
76
161
  else
77
162
  img = MiniMagick::Image.read(body)
78
163
  # Cap ImageMagick resources per-invocation to prevent decompression-bomb DoS.
@@ -84,8 +169,8 @@ module Studio
84
169
  c.resize "#{variant}x"
85
170
  end
86
171
  payload = img.to_blob
87
- s3_key = "#{key_prefix}/#{variant}.#{ext}"
88
172
  end
173
+ s3_key = variant_key(key_prefix, variant, ext)
89
174
 
90
175
  Studio::S3.upload(
91
176
  key: s3_key,
@@ -107,6 +192,7 @@ module Studio
107
192
 
108
193
  existing
109
194
  end
195
+ private_class_method :upload_variants!
110
196
 
111
197
  # ── THE SSRF GUARD ────────────────────────────────────────────────────────
112
198
  #
data/lib/studio/s3.rb CHANGED
@@ -25,13 +25,18 @@ module Studio
25
25
  # is answered as the empty string it means. Tested against a stubbed
26
26
  # client only: no live store, R2 included, has been asked.
27
27
  def download(key:, max_bytes: nil)
28
+ read_object(bucket, key, max_bytes)
29
+ end
30
+
31
+ # The GET behind download and production_download.
32
+ def read_object(target, key, max_bytes)
28
33
  # 0 or a negative would build "bytes=0--1", which a store may read as
29
34
  # no range at all and answer with the whole object.
30
35
  unless max_bytes.nil? || (max_bytes.is_a?(Integer) && max_bytes.positive?)
31
36
  raise ArgumentError, "max_bytes must be a positive Integer, got #{max_bytes.inspect}"
32
37
  end
33
38
 
34
- opts = { bucket: bucket, key: full_key(key) }
39
+ opts = { bucket: target, key: full_key(key) }
35
40
  opts[:range] = "bytes=0-#{max_bytes - 1}" if max_bytes
36
41
  s3 = client
37
42
  # The rescue names an SDK constant, so it wraps only what runs after
@@ -84,10 +89,70 @@ module Studio
84
89
  end
85
90
 
86
91
  def exists?(key:)
87
- client.head_object(bucket: bucket, key: full_key(key))
88
- true
89
- rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::NoSuchKey
90
- false
92
+ !head(bucket, key).nil?
93
+ end
94
+
95
+ # ── PRODUCTION REFERENCES (read-only) ─────────────────────────────────
96
+ #
97
+ # Seed assets live in the PRODUCTION bucket, and a non-production process
98
+ # references them there instead of uploading its own copy (ImageCache's
99
+ # stored_in = "production"). Everything below READS production and
100
+ # nothing writes it: there is no upload, copy or delete that takes the
101
+ # production bucket, and delete/purge! still refuse a "*-production"
102
+ # bucket off production (guard_production_bucket!). The dev R2 tokens
103
+ # hold Item Read on production and Item Write on dev only, so the store
104
+ # refuses a write even if code here ever tried.
105
+
106
+ # The bucket production writes: "#{s3_bucket_prefix}-production" in every
107
+ # environment. In real production it is `bucket`.
108
+ def production_bucket
109
+ prefix = Studio.s3_bucket_prefix
110
+ raise NotConfigured, "Studio.s3_bucket_prefix not set in config/initializers/studio.rb" if prefix.nil? || prefix.empty?
111
+
112
+ "#{prefix}-production"
113
+ end
114
+
115
+ # Whether this process may record a production reference: it is not
116
+ # production itself (production's own uploads already ARE production's)
117
+ # and it knows production's public host (Studio.s3_production_public_url).
118
+ def production_references?
119
+ !production_environment? && !Studio.s3_production_public_url.to_s.empty?
120
+ end
121
+
122
+ # The public URL of production's copy of `key`. In production it is
123
+ # exactly url(key:). Elsewhere it is s3_production_public_url plus the
124
+ # key, under the same s3_key_prefix (the namespace is per app, not per
125
+ # environment); without that base it RAISES rather than answer with this
126
+ # environment's host, where the object does not exist.
127
+ def production_url(key:)
128
+ return url(key: key) if production_environment?
129
+
130
+ base = Studio.s3_production_public_url.to_s
131
+ if base.empty?
132
+ raise NotConfigured,
133
+ "Studio.s3_production_public_url not set: cannot resolve production's copy of #{key.inspect} " \
134
+ "from a #{environment} process"
135
+ end
136
+
137
+ "#{base.chomp("/")}/#{full_key(key)}"
138
+ end
139
+
140
+ # A read-only HEAD against the production bucket: the object's metadata
141
+ # (content_length, content_type) when production holds `key`, nil when it
142
+ # does not. Any other answer (403 from a token without Item Read on
143
+ # production, a network error) RAISES: a seeder that cannot ask must not
144
+ # read the silence as "absent" and quietly upload a copy.
145
+ def production_head(key:)
146
+ head(production_bucket, key)
147
+ end
148
+
149
+ def production_exists?(key:)
150
+ !production_head(key: key).nil?
151
+ end
152
+
153
+ # A read-only GET of production's copy, with download's max_bytes rules.
154
+ def production_download(key:, max_bytes: nil)
155
+ read_object(production_bucket, key, max_bytes)
91
156
  end
92
157
 
93
158
  # A RECOVERABLE delete: the object moves under trash/ in the same bucket
@@ -235,8 +300,22 @@ module Studio
235
300
  @client = nil
236
301
  end
237
302
 
303
+ private :read_object
304
+
238
305
  private
239
306
 
307
+ # HEAD of `key` in `target`: the response, or nil when the object is
308
+ # absent. `target` is resolved by the caller, so an unconfigured app
309
+ # raises NotConfigured before the rescue below names an SDK constant.
310
+ def head(target, key)
311
+ s3 = client
312
+ begin
313
+ s3.head_object(bucket: target, key: full_key(key))
314
+ rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::NoSuchKey
315
+ nil
316
+ end
317
+ end
318
+
240
319
  # WHICH BUCKET HALF THIS APP OWNS. Rails.env alone cannot answer it: no QA app
241
320
  # sets RAILS_ENV, so every QA app boots as `production` exactly like real
242
321
  # production. QA_ENV is the signal that separates them, and it is the one
@@ -117,6 +117,10 @@ module Studio
117
117
  "--color-danger-ink" => status_ink(danger, surfaces, direction: :lighten),
118
118
  "--color-warning-ink" => status_ink(warning, surfaces, direction: :lighten),
119
119
  "--color-success-ink" => status_ink(success, surfaces, direction: :lighten),
120
+ # The primary role as chip TEXT (`bg-primary/10 text-primary-ink`): the
121
+ # brand hue, shaded per theme until it clears AA on the surfaces and on
122
+ # its own tint. --color-primary itself stays the brand colour.
123
+ "--color-primary-ink" => status_ink(primary, surfaces, direction: :lighten),
120
124
  "--color-accent" => colors[:accent] || "#F72585"
121
125
  }.merge(fill_vars(primary, warning, danger))
122
126
  end
@@ -165,6 +169,16 @@ module Studio
165
169
  # How strong a status role's tint is where its ink is read on it: the
166
170
  # `bg-<role>/10 text-<role>-ink` badge and flash-panel pattern.
167
171
  STATUS_TINT = 0.10
172
+ # Every tint strength a role's ink is read on. The 10% chip, as written and
173
+ # as an 8-bit alpha channel holds it (26/255, a little stronger), and the
174
+ # 15% and 20% tints the apps also put an ink on: a selected or "Entered"
175
+ # badge (`bg-primary/15`), the hub's workflow and verdict chips
176
+ # (`bg-success/20 text-success-ink`, `bg-warning/20 text-warning-ink`),
177
+ # turf's signer roster (`bg-danger/15 text-danger-ink`). A stronger tint
178
+ # sits closer to the ink, so an ink tuned to the 10% chip alone read 3.89:1
179
+ # on a 20% one (default warning, dark). 0.15 and 0.20 are the stronger of
180
+ # each written alpha and its 8-bit value (38/255 is weaker, 51/255 equal).
181
+ TINT_ALPHAS = [STATUS_TINT, (STATUS_TINT * 255).round / 255.0, 0.15, 0.20].freeze
168
182
 
169
183
  # A status ink lands on the page surfaces AND on its own role's tint over
170
184
  # each of them (the `bg-warning/10 text-warning-ink` badge). The tint is
@@ -172,9 +186,28 @@ module Studio
172
186
  # to the bare surfaces alone comes out below AA inside its own badge
173
187
  # (default theme: warning 3.91:1 dark, success 4.00:1 dark, danger 4.10:1
174
188
  # light and 4.18:1 dark). Counting the tints as surfaces closes that.
189
+ #
190
+ # The tint is counted at every strength in TINT_ALPHAS. Chromium composites
191
+ # a 10% fill at 26/255, a little stronger than 0.10, and an ink tuned to
192
+ # 0.10 alone measured 4.48:1 on the painted pixels (danger, dark, on a
193
+ # #141516 base).
194
+ #
195
+ # Each tint is also counted one unit lighter and one unit darker in every
196
+ # channel. A browser composites in 8 bits and rounds the fill's share and
197
+ # the surface's share separately, so a painted tint can sit one unit off
198
+ # the exact blend: the default danger at 20% over #3C3853 blends to
199
+ # #5F3A50 and Chromium paints #603B50, which took an ink graded 4.51:1 here
200
+ # to 4.47:1 on screen. Contrast moves with luminance, and luminance with
201
+ # each channel, so the two all-channel neighbours bound every rounding.
175
202
  def status_ink(role, surfaces, direction:)
176
- tints = surfaces.map { |bg| ColorScale.blend(role, bg, STATUS_TINT) }
177
- contrast_ink(role, direction: direction, start: 0.0, target: 4.5, against: surfaces + tints)
203
+ tints = TINT_ALPHAS.flat_map { |alpha| surfaces.map { |bg| ColorScale.blend(role, bg, alpha) } }
204
+ painted = tints.flat_map { |tint| [tint, nudge(tint, 1), nudge(tint, -1)] }
205
+ contrast_ink(role, direction: direction, start: 0.0, target: 4.5, against: surfaces + painted)
206
+ end
207
+
208
+ # `hex` with every channel moved `units` (clamped to 0..255).
209
+ def nudge(hex, units)
210
+ ColorScale.rgb_to_hex(*ColorScale.hex_to_rgb(hex).map { |c| c + units })
178
211
  end
179
212
 
180
213
  # The lightest shade of `color` that carries a white label at AA: the
@@ -307,6 +340,7 @@ module Studio
307
340
  "--color-danger-ink" => status_ink(danger, surfaces, direction: :darken),
308
341
  "--color-warning-ink" => status_ink(warning, surfaces, direction: :darken),
309
342
  "--color-success-ink" => status_ink(success, surfaces, direction: :darken),
343
+ "--color-primary-ink" => status_ink(primary, surfaces, direction: :darken),
310
344
  "--color-accent" => colors[:accent] || "#F72585"
311
345
  }.merge(fill_vars(primary, warning, danger))
312
346
  end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.102.0"
2
+ VERSION = "0.103.0.rc2"
3
3
  end
data/lib/studio.rb CHANGED
@@ -1079,6 +1079,19 @@ module Studio
1079
1079
  mattr_accessor :s3_secret_access_key, default: nil
1080
1080
  mattr_accessor :s3_public_url, default: nil
1081
1081
 
1082
+ # SEED ASSETS LIVE IN PRODUCTION. The public base of THIS app's PRODUCTION
1083
+ # bucket (https://assets.turfmonster.media), set in every environment. A
1084
+ # non-production process (a laptop, a desk, a QA app) whose seeders call
1085
+ # Studio::ImageCache.reference_or_cache! records an ImageCache row that
1086
+ # REFERENCES production's copy of an object instead of uploading its own, and
1087
+ # that row's #url resolves on this host. Not a secret, so a literal in
1088
+ # config/initializers/studio.rb is the expected form.
1089
+ #
1090
+ # nil (the default) turns references off: reference_or_cache! is then exactly
1091
+ # cache!, and every row resolves on s3_public_url as it always has. A real
1092
+ # production process ignores it, because its own bucket IS production.
1093
+ mattr_accessor :s3_production_public_url, default: nil
1094
+
1082
1095
  # Knowledge layer — the S3-backed document store + /admin/knowledge browser
1083
1096
  # (Studio::KnowledgeDoc). Routes are opt-in like every route surface.
1084
1097
  # knowledge_agents is the roster of agent slugs the intake UI offers
@@ -103,6 +103,9 @@ module.exports = {
103
103
  'danger-ink': 'var(--color-danger-ink)',
104
104
  'warning-ink': 'var(--color-warning-ink)',
105
105
  'success-ink': 'var(--color-success-ink)',
106
+ // The primary role as chip text, derived the same way. text-primary
107
+ // stays the brand colour for links, icons and large text.
108
+ 'primary-ink': 'var(--color-primary-ink)',
106
109
  },
107
110
  // The status-role FILLS: badge tints, flash panels, borders. Registered
108
111
  // for backgrounds and borders ONLY (not `colors`), so that registering
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.102.0
4
+ version: 0.103.0.rc2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-10 00:00:00.000000000 Z
11
+ date: 2026-10-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -713,6 +713,7 @@ files:
713
713
  - db/migrate/20261006120002_ensure_theme_settings_table.rb
714
714
  - db/migrate/20261006120003_ensure_image_caches_table.rb
715
715
  - db/migrate/20261008120000_add_recording_to_studio_knowledge_docs.rb
716
+ - db/migrate/20261010120000_add_stored_in_to_image_caches.rb
716
717
  - docs/BOOKING.md
717
718
  - docs/SITE_FOOTER.md
718
719
  - lib/active_storage/service/studio_trash_s3_service.rb