studio-engine 0.76.3 → 0.77.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c352381b0778909cd1798ba2d81abf435f1c2e668198ac38fdae33310d65d8c
4
- data.tar.gz: 6a80ed76a9748b246df94016572aa1b1b470360c8938390acaef92342a4d1350
3
+ metadata.gz: e8cc0d34a62b18829e9caa043f820a5dc6100fa9de2d990f32dd4f3439c5bd40
4
+ data.tar.gz: b5a7794c1c53bea3354f4b48e46887c9315df5fbff6d671436328fac9f95c2d3
5
5
  SHA512:
6
- metadata.gz: b57498e85f26bfe1ba051381c0411f0f5161513726ed9fbd6aa4769e0bd3767ec962d0f007ac09ad2326e9a97fb05d73e17e122ef5b779f6cc8e48dd923c3c24
7
- data.tar.gz: 0a17dcfaa72bd771c57cacb04ee9f2f3b82baf5839bc9b739216bc1d6fc1e96b456bc68b645b0865bca9569d64b1abd5327a4e779ff74c6af4cf740776f8f14e
6
+ metadata.gz: 65d91ace8c12010d262fb841a19c384ef85aa8418d6ec270cf30d2b68e28f73f1da5cdbfe9057cdefad831e44c519ea088468f36ce3a9467866102da47d08491
7
+ data.tar.gz: e56bd88f0517331aeb58ddac29ab027053a74189a97c78c3a55b9b8cd4f8827fa3492676b4604c0e779f96d7136073b11e8e4e624ed7e064654c037c676457c0
data/CHANGELOG.md CHANGED
@@ -4,6 +4,40 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.77.1 — 2026-09-27
8
+
9
+ ### Fixed
10
+
11
+ - **The engine header fits a phone.** `.user-nav-col` / `.user-nav-fit` capped their
12
+ two phone bands at a constant `14rem`/`15rem` — 224px is 57% of a 390px screen — on
13
+ a `flex-shrink-0` column, leaving the left column 166px for a 48px logo, its gap,
14
+ 32px of padding and the app's name. Every flex item defaults to `min-width: auto`,
15
+ so the name could not truncate either: it drew across the gap and on top of the user
16
+ column beside it. The bands are now `min(14rem, 46vw)` and `min(15rem, 46vw)`, with
17
+ `min-w-0` on each flex item between the nav row and the title and `truncate` on the
18
+ two title spans. `min()` resolves to the plain rem at ~487px and ~522px, so the
19
+ 768px band and every desktop width are unchanged; below ~344px the app name
20
+ truncates rather than spilling.
21
+ - **Consumers that render `layouts/_navbar` unforked inherit this on upgrade** —
22
+ `mcritchie-industries` and `acquisition-studio`. `mcritchie-studio` inlines its own
23
+ `<header>` and `turf-monster` forks the partial and declares its own
24
+ `.user-nav-col` widths, so each adopts the change separately.
25
+
26
+ ## 0.77.0 — 2026-09-26
27
+
28
+ ### Added
29
+
30
+ - **`Studio::S3` runs on Cloudflare R2, or any S3-compatible endpoint.** Four new
31
+ settings, all nil by default: `s3_endpoint`, `s3_access_key_id` +
32
+ `s3_secret_access_key` (passed to the client as a pair, so an app can hold its
33
+ R2 keys beside the `AWS_*` ones during a cutover; half a pair raises), and
34
+ `s3_public_url`, the base public objects are served from. Unset, every app gets
35
+ the same client and the same amazonaws.com URLs as before. On an endpoint with
36
+ no public base, `url` raises `NotConfigured` instead of building a URL R2 would
37
+ refuse, and `upload` still writes but returns `nil`. Proven live against an R2
38
+ bucket: upload, download, exists, list, signed URL (200) and delete, with the
39
+ anonymous endpoint read refused.
40
+
7
41
  ## 0.76.3 — 2026-09-23
8
42
 
9
43
  ### Fixed
data/README.md CHANGED
@@ -838,6 +838,24 @@ config.s3_key_prefix = "mcritchie-industries/"
838
838
  logical keys and never see it. Unset (the default) leaves keys byte-identical to
839
839
  what every already-shipped app wrote.
840
840
 
841
+ ### S3-compatible storage (Cloudflare R2)
842
+
843
+ `Studio::S3` talks to any S3-compatible endpoint. The McRitchie fleet is moving
844
+ to R2 (one account, McRitchie Studio's); an app switches in its initializer:
845
+
846
+ ```ruby
847
+ config.s3_endpoint = ENV["R2_ENDPOINT"] # https://<account>.r2.cloudflarestorage.com
848
+ config.s3_region = "auto"
849
+ config.s3_access_key_id = ENV["R2_ACCESS_KEY_ID"]
850
+ config.s3_secret_access_key = ENV["R2_SECRET_ACCESS_KEY"]
851
+ config.s3_public_url = ENV["R2_PUBLIC_URL"] # custom domain on this env's bucket
852
+ ```
853
+
854
+ All four are nil by default, and nil is AWS exactly as before. The keys must be
855
+ set as a pair. R2 serves nothing anonymously from its S3 endpoint, so with an
856
+ endpoint and no `s3_public_url`, `Studio::S3.url` raises `NotConfigured` and
857
+ `upload` writes but returns `nil`; serve private objects with `signed_url`.
858
+
841
859
  An app with **no** bucket configured does not error — `/admin/emails` renders
842
860
  read-only, showing the inherited defaults it is genuinely sending and naming the
843
861
  one setting that turns uploads on.
@@ -62,13 +62,44 @@
62
62
  class="nav-shell <%= is_preview ? 'bg-page' : "#{'vt-pinned-header ' if pin_header}sticky top-0 z-50 bg-page transition-shadow duration-300" %>"
63
63
  :class="scrolled && 'shadow-lg border-b border-subtle is-scrolled'">
64
64
  <style>
65
- .user-nav-col { width: 14rem; }
65
+ /* THE PHONE STEP IS A SHARE OF THE VIEWPORT, NOT A CONSTANT, and that is the
66
+ whole width fix. 14rem is 224px — 57% of a 390px screen — handed to a
67
+ flex-shrink-0 column, so the left column was left 166px for a 48px logo,
68
+ a 4px gap, 32px of padding and whatever the app is called. MEASURED on
69
+ /lab/bar_stack?signed_in=1 at 390x844, app_name "McRitchie Industries":
70
+ the title's own box ran to x=175.3 inside a column whose border box ends
71
+ at x=166, and "Industries" painted ON TOP of the wallet address in the
72
+ column beside it. documentElement.scrollWidth was 390 against a
73
+ clientWidth of 390 the entire time — the page did not scroll sideways,
74
+ so every document-level check of this header was green while the header
75
+ was visibly broken.
76
+
77
+ min() rather than a new breakpoint: the cap still resolves to 14rem on
78
+ anything at or above ~487px, so this band's desktop-side behaviour is
79
+ byte-identical and only the phone widths move. 46vw is not a taste
80
+ value — it is the largest share that still leaves the left column the
81
+ 191px a 48px logo, its gap, its padding and a ten-character word need at
82
+ 360px, the narrowest mainstream Android. Below that the title truncates
83
+ (see min-w-0 + truncate on the markup) instead of spilling.
84
+
85
+ BOTH LINES, and that is not redundancy. .user-nav-col is a hard width
86
+ (a host passing balance_html reserves the seat) and .user-nav-fit is a
87
+ cap (no balance, so the column hugs its content). They are two different
88
+ properties and a fix to one leaves the other exactly as broken. */
89
+ .user-nav-col { width: min(14rem, 46vw); }
66
90
  /* No balance content → no reserved column. The nav hugs its icons and
67
91
  name instead of floating in the balance's empty seat. max-width steps
68
92
  mirror the col's clamps: the item is flex-shrink-0, so without them a
69
93
  long nowrap username sizes the column past a narrow viewport. */
70
- .user-nav-fit { max-width: 14rem; }
71
- @media (min-width: 400px) { .user-nav-col { width: 15rem; } .user-nav-fit { max-width: 15rem; } }
94
+ .user-nav-fit { max-width: min(14rem, 46vw); }
95
+ /* THE SAME SHARE IN THE NEXT BAND UP, and leaving it out was measurably
96
+ worse than not fixing the first one. With only the base band capped, a
97
+ 412px screen (Pixel-class, and the 400px band's own territory) gave the
98
+ title 84px while a 390px screen gave it 107 — a WIDER phone showing LESS
99
+ of the app's name. Monotone or it is not fixed. min(15rem, 46vw) resolves
100
+ to the plain 15rem at ~522px and up, so everything above this band's
101
+ phone edge is unchanged. */
102
+ @media (min-width: 400px) { .user-nav-col { width: min(15rem, 46vw); } .user-nav-fit { max-width: min(15rem, 46vw); } }
72
103
  @media (min-width: 768px) { .user-nav-col { width: 20rem; } .user-nav-fit { max-width: 20rem; } }
73
104
  /* The COLLAPSE band table used to live here, which meant only an app
74
105
  rendering THIS partial got it. Three of five apps fork the navbar, so each
@@ -88,16 +119,27 @@
88
119
  </style>
89
120
  <div class="nav-row flex items-center">
90
121
  <%# Left side: logo + nav %>
91
- <div class="flex-1 px-4">
92
- <div class="flex items-center gap-6">
93
- <%= link_to root_path, class: "nav-logo-link inline-flex items-center gap-3 group" do %>
122
+ <%# min-w-0 THREE TIMES, on every flex item between the row and the title.
123
+ A flex item's default min-width is auto, which floors it at its own
124
+ min-content — so ONE of these left out puts the floor back and the
125
+ title spills across the gap again. The chain is: row item (.flex-1) ->
126
+ its inner flex -> the logo link. The <h1> is the fourth link and carries
127
+ its own min-w-0 below. %>
128
+ <div class="flex-1 min-w-0 px-4">
129
+ <div class="flex items-center gap-6 min-w-0">
130
+ <%= link_to root_path, class: "nav-logo-link inline-flex items-center gap-3 group min-w-0" do %>
94
131
  <% if logo_path %>
95
132
  <%# No w-*/h-* utility here: .nav-shell .nav-logo sizes it off --nav-p.
96
133
  transition-transform, NOT transition-all — the hover lift still
97
134
  eases, while width/height follow the finger with no clock. %>
98
135
  <img src="<%= logo_path %>" alt="<%= Studio.app_name %>" class="nav-logo rounded-full shadow-lg <%= "group-hover:scale-105 transition-transform duration-300" unless is_preview %>">
99
136
  <% end %>
100
- <h1 class="nav-title font-extrabold text-heading tracking-tight"><span><%= prefix %></span><span class="text-primary"><%= last_word %></span></h1>
137
+ <%# truncate on the SPANS, not the h1. .nav-title is itself a flex
138
+ container (a column below 768px, a baseline row above), so the
139
+ overflow that has to be clipped belongs to each word's own box —
140
+ `truncate` on the h1 would clip the flex CONTAINER and leave the
141
+ spans drawing outside it, which is the bug rather than the fix. %>
142
+ <h1 class="nav-title font-extrabold text-heading tracking-tight min-w-0"><span class="truncate"><%= prefix %></span><span class="text-primary truncate"><%= last_word %></span></h1>
101
143
  <% end %>
102
144
  <%# Desktop nav — apps should override this partial to add links here %>
103
145
  <nav class="hidden md:flex items-center gap-4">
@@ -41,21 +41,38 @@
41
41
  --nav-title-lead-size: calc(1.5rem - 0.35rem * var(--nav-p));
42
42
  }
43
43
 
44
+ /* THE USER-COLUMN WIDTH STEPS, re-keyed on the wrapper for the same reason
45
+ the --nav-p sizes above are: the shipped rules live in media queries, which
46
+ cannot fire for a CONTAINER at a fixed viewport width.
47
+
48
+ PERCENT HERE, vw THERE, AND THEY ARE THE SAME NUMBER. layouts/_navbar caps
49
+ the two phone bands at min(<rem>, 46vw). A literal 46vw inside a 390px
50
+ preview box on a 1400px monitor resolves to 644px, which would make the
51
+ preview show a header no phone has — so the share is expressed against the
52
+ CONTAINING BLOCK instead, which in this preview is the navbar row, i.e. the
53
+ simulated device width. Retune the cap in _navbar and retune these, or the
54
+ preview starts lying about the one dimension it exists to show.
55
+
56
+ REMOVED, 2026-09-27: three `.username-cap` rules, at 5/6/7rem. No element
57
+ in this engine or in any consuming app has ever carried that class —
58
+ components/_user_nav truncates the name with `truncate` and a min-w-0
59
+ parent instead. They were dead the day they were written and they read, to
60
+ the next person sizing this header, like a cap that was already in force.
61
+ (turf-monster's app/views/admin/navbar.html.erb carries the same three dead
62
+ rules; that is its own repo's to drop.) */
63
+
44
64
  /* Tiny (< 400px) */
45
65
  .navbar-preview.bp-tiny .nav-logo-link { gap: 0.25rem !important; }
46
- .navbar-preview.bp-tiny .user-nav-col { width: 14rem !important; }
47
- .navbar-preview.bp-tiny .user-nav-fit { max-width: 14rem !important; }
48
- .navbar-preview.bp-tiny .username-cap { max-width: 5rem !important; }
66
+ .navbar-preview.bp-tiny .user-nav-col { width: min(14rem, 46%) !important; }
67
+ .navbar-preview.bp-tiny .user-nav-fit { max-width: min(14rem, 46%) !important; }
49
68
 
50
69
  /* Small (400-767px) */
51
- .navbar-preview.bp-small .user-nav-col { width: 15rem !important; }
52
- .navbar-preview.bp-small .user-nav-fit { max-width: 15rem !important; }
53
- .navbar-preview.bp-small .username-cap { max-width: 6rem !important; }
70
+ .navbar-preview.bp-small .user-nav-col { width: min(15rem, 46%) !important; }
71
+ .navbar-preview.bp-small .user-nav-fit { max-width: min(15rem, 46%) !important; }
54
72
 
55
- /* Desktop (768px+) */
73
+ /* Desktop (768px+) — a bare rem, exactly as the shipped band declares it. */
56
74
  .navbar-preview.is-desktop .user-nav-col { width: 20rem !important; }
57
75
  .navbar-preview.is-desktop .user-nav-fit { max-width: 20rem !important; }
58
- .navbar-preview.is-desktop .username-cap { max-width: 7rem !important; }
59
76
 
60
77
  /* Scrolled state. The size half of this used to be TWELVE !important rules
61
78
  restating every collapsed value; the toggle now just sets --nav-p: 1 on
data/lib/studio/s3.rb CHANGED
@@ -15,17 +15,34 @@ module Studio
15
15
  opts[:content_type] = content_type if content_type
16
16
  opts[:cache_control] = cache_control if cache_control
17
17
  client.put_object(**opts)
18
- url(key: key)
18
+ public_url? ? url(key: key) : nil
19
19
  end
20
20
 
21
21
  def download(key:)
22
22
  client.get_object(bucket: bucket, key: full_key(key)).body.read
23
23
  end
24
24
 
25
+ # The PUBLIC URL of an object. On AWS (no endpoint) it is the bucket's
26
+ # virtual-hosted amazonaws.com URL, byte-identical to every version before
27
+ # endpoints existed. On an S3-compatible endpoint it is Studio.s3_public_url
28
+ # plus the key, and without that base it RAISES: R2's S3 endpoint answers no
29
+ # anonymous request, so any URL built from it would be a broken image in an
30
+ # inbox, not an error anyone sees. Private objects want signed_url.
25
31
  def url(key:)
32
+ base = Studio.s3_public_url.to_s
33
+ return "#{base.chomp("/")}/#{full_key(key)}" unless base.empty?
34
+ raise NotConfigured, "Studio.s3_public_url not set: #{endpoint} serves no public URL (use signed_url for private objects)" if endpoint
35
+
26
36
  "https://#{bucket}.s3.#{region}.amazonaws.com/#{full_key(key)}"
27
37
  end
28
38
 
39
+ # Whether url can answer. upload asks this so an app with only private
40
+ # objects (a data room on R2 with no public domain) can still write:
41
+ # upload returns nil there instead of raising AFTER the object landed.
42
+ def public_url?
43
+ endpoint.nil? || !Studio.s3_public_url.to_s.empty?
44
+ end
45
+
29
46
  def signed_url(key:, expires_in: 3600)
30
47
  require "aws-sdk-s3"
31
48
  Aws::S3::Presigner.new(client: client).presigned_url(:get_object, bucket: bucket, key: full_key(key), expires_in: expires_in)
@@ -93,11 +110,39 @@ module Studio
93
110
  Studio.s3_region
94
111
  end
95
112
 
113
+ # The S3-compatible endpoint (R2: https://<account>.r2.cloudflarestorage.com),
114
+ # or nil for AWS. Blank reads as unset, so an empty ENV var cannot point the
115
+ # client at "".
116
+ def endpoint
117
+ value = Studio.s3_endpoint.to_s
118
+ value.empty? ? nil : value
119
+ end
120
+
96
121
  def client
97
122
  @client ||= begin
98
123
  require "aws-sdk-s3"
99
- Aws::S3::Client.new(region: region)
124
+ Aws::S3::Client.new(**client_options)
125
+ end
126
+ end
127
+
128
+ # Only what is configured is passed, so an unconfigured app builds exactly
129
+ # the client it always did: region alone, the SDK's default credential chain.
130
+ # Keys are passed as a PAIR or not at all; half a pair is a configuration
131
+ # error, and falling back to the default chain would silently write with
132
+ # whatever AWS_* key the dyno happens to hold.
133
+ def client_options
134
+ opts = { region: region }
135
+ opts[:endpoint] = endpoint if endpoint
136
+ id = Studio.s3_access_key_id.to_s
137
+ secret = Studio.s3_secret_access_key.to_s
138
+ if id.empty? != secret.empty?
139
+ raise NotConfigured, "Studio.s3_access_key_id and s3_secret_access_key must be set together"
140
+ end
141
+ unless id.empty?
142
+ opts[:access_key_id] = id
143
+ opts[:secret_access_key] = secret
100
144
  end
145
+ opts
101
146
  end
102
147
 
103
148
  def reset!
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.76.3"
2
+ VERSION = "0.77.1"
3
3
  end
data/lib/studio.rb CHANGED
@@ -616,6 +616,30 @@ module Studio
616
616
  # A trailing slash is added if you leave it off.
617
617
  mattr_accessor :s3_key_prefix, default: nil
618
618
 
619
+ # S3-COMPATIBLE STORAGE (Cloudflare R2). All four default nil, and nil means
620
+ # AWS exactly as before: the SDK's own endpoint, its default credential chain
621
+ # (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY), and the virtual-hosted
622
+ # amazonaws.com URL. An app moves to R2 by setting them, typically from ENV:
623
+ #
624
+ # config.s3_endpoint = ENV["R2_ENDPOINT"] # https://<account>.r2.cloudflarestorage.com
625
+ # config.s3_region = "auto" # R2's only region
626
+ # config.s3_access_key_id = ENV["R2_ACCESS_KEY_ID"]
627
+ # config.s3_secret_access_key = ENV["R2_SECRET_ACCESS_KEY"]
628
+ # config.s3_public_url = ENV["R2_PUBLIC_URL"] # https://assets.example.com
629
+ #
630
+ # Explicit keys exist so an app can hold its R2 pair BESIDE its AWS pair
631
+ # during a cutover (Active Storage may still mirror to S3) without the two
632
+ # fighting over the same AWS_* variables.
633
+ #
634
+ # s3_public_url is the base public objects are served from — a custom domain
635
+ # or r2.dev URL attached to THIS environment's bucket. R2's S3 endpoint
636
+ # serves nothing anonymously, so with an endpoint and no public base there is
637
+ # no public URL to give, and Studio::S3.url says so rather than inventing one.
638
+ mattr_accessor :s3_endpoint, default: nil
639
+ mattr_accessor :s3_access_key_id, default: nil
640
+ mattr_accessor :s3_secret_access_key, default: nil
641
+ mattr_accessor :s3_public_url, default: nil
642
+
619
643
  # Knowledge layer — the S3-backed document store + /admin/knowledge browser
620
644
  # (Studio::KnowledgeDoc). Routes are opt-in like every route surface.
621
645
  # knowledge_agents is the roster of agent slugs the intake UI offers
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.76.3
4
+ version: 0.77.1
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-09-23 00:00:00.000000000 Z
11
+ date: 2026-09-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails