studio-engine 0.107.0.rc1 → 0.108.0.rc1

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: dd203cdc5c017f4b75ad4f8a1d1af735e55a0791b33e457db3352bf4c250066c
4
- data.tar.gz: b6afd7f7ea6cfe36293c5151caa9a4c53ffb31def0c0b3be752551a52e9655e9
3
+ metadata.gz: 01a56dee2b229822d24c392ac0f3c62f4b2a1d8c0328a2e84966dd4aa73630cc
4
+ data.tar.gz: 4b906a31d12a9fe5aae18b11f222fda647a1b7dba53627173c681f9da1fbb0b0
5
5
  SHA512:
6
- metadata.gz: 8efd7d7240c1498a9bf3ba8486e7dcd46c52e174242aeea6e0560abfcded3a5a40530418316463834d5b02bedb4975dc2d16a3fdea2e1d980c3cf1dafbc2a89e
7
- data.tar.gz: 11a034ae73a7112c6d2d1a46c639616230794a64ef88ae620552a3cb027c09cc5406812c78821c5658a0287bf9e44c26b5e417212be6b6292e5292fbd419d161
6
+ metadata.gz: 944aa8ae14a3a9b5a213a4e3594969a2343849ac972a9eba576e9a2fc784eb4c77ca082b947d3f0238478cd9e2e2bd9e5ee3d976365fe0a4cf47c721fbd13f9d
7
+ data.tar.gz: 472497c4720a4365117f33135f57cd7b9eefdbc2ee7c339f3f9354b9226ae1e05c1dae7fb424c47f1b7efa18302f4ca468e9ac59e4f7abf1767750f9dda051aa
data/CHANGELOG.md CHANGED
@@ -4,6 +4,61 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.108.0 — 2026-10-15
8
+
9
+ ### Changed
10
+
11
+ - **The navbar collapse no longer moves the page.** `studio/nav_collapse`
12
+ holds a header that sits in the page's flow at its expanded footprint: the
13
+ header carries `margin-bottom: calc(var(--nav-hold) * var(--nav-p) +
14
+ var(--nav-hold-fix))`, which grows in the same style recalc that shrinks it,
15
+ so the document never changes height. Before, every frame of the collapse
16
+ reflowed the page: content ran ahead of the finger, a quick scroll back to the
17
+ top pushed the page down for ~10 frames after the scroll stopped, and near the
18
+ bottom of a page the browser clamped the scroll back (measured on the hub's
19
+ `/links`: 344 to 305 with nobody touching it). Held, the progress follows the
20
+ scroll position exactly: the 5px-per-frame cap and the short-page guard apply
21
+ only to a header out of flow (fixed or absolute), which never moved the page.
22
+ The collapse stays continuous; endpoints and the ramp are unchanged. No markup
23
+ change: every header on the controller (the engine navbar, and forks through
24
+ the `navCollapse()` shim) gets it.
25
+ - **The pinned stack stops restyling the whole page while you scroll.**
26
+ `studio/pinned_stack` publishes the header's live geometry as inherited
27
+ custom properties on `<html>`, and every write there restyles the entire
28
+ document: 8.7ms a write on turf-monster's 2,000-element contest page, every
29
+ frame the header's bottom edge moved, which dropped frames on a 120Hz screen.
30
+ While a scroll is in motion it now writes the values only onto the elements
31
+ that read them live (an inline style or a stylesheet rule that positions off
32
+ them, found by a scan, or `[data-pin-follow]`), and writes `<html>` once the
33
+ scroll has been still for 160ms. Restyling during the collapse on that page
34
+ fell from 226ms to 25ms over 27 frames. `scroll-margin`/`scroll-padding`
35
+ readers are served by the settled write; structural changes (a layer arriving
36
+ or leaving) still write `<html>` at once. A SCRIPT that reads these values
37
+ off `<html>` (`getComputedStyle(document.documentElement)`) sees them up to
38
+ 160ms late during a scroll; read them off the element that positions by them
39
+ instead. Known readers: turf-monster's contest chat panel and cyvasse's game
40
+ controller, both of which read at an event, not per frame. Followers release
41
+ their copies before Turbo caches the page, and the collapse controller clears
42
+ its own state when a header is restored from that cache, so Back never stacks
43
+ a second hold.
44
+
45
+ ### Added
46
+
47
+ - **`Studio::Storage`: object storage on Cloudflare R2, on by default for an app
48
+ that names its bucket.** One copy of the `StorageBackend` module the hub,
49
+ turf-monster and mcritchie-industries each hand-wrote. An app with
50
+ `config.s3_bucket_prefix` set now gets, with no initializer of its own: a boot
51
+ check (a retired `ACTIVE_STORAGE_BACKEND` / `STUDIO_S3_BACKEND` value raises in
52
+ every environment; a missing R2 variable raises in production and QA, naming
53
+ each), `Studio::S3` pointed at R2 when the app left `s3_endpoint` unset, and
54
+ Active Storage services `amazon` and `amazon_dev` on `StudioTrashS3`, merged
55
+ under the app's own `config/storage.yml` (an app's service of the same name
56
+ wins). New config: `manage_storage` (default true; false opts out) and
57
+ `storage_public_url_required` (default false; true makes `R2_PUBLIC_URL`
58
+ required). An app still carrying its own `StorageBackend` keeps its own
59
+ connection and services; only the switch check is new to it. README.md,
60
+ *Managed storage*.
61
+
7
62
  ## 0.107.0 — 2026-10-14
8
63
 
9
64
  ### Changed
data/README.md CHANGED
@@ -966,6 +966,49 @@ config.s3_key_prefix = "mcritchie-industries/"
966
966
  logical keys and never see it. Unset (the default) leaves keys byte-identical to
967
967
  what every already-shipped app wrote.
968
968
 
969
+ ### Managed storage — `Studio::Storage` (on by default)
970
+
971
+ Object storage is Cloudflare R2, and only R2: AWS S3 was retired on 2026-10-10.
972
+ An app that names its bucket (`config.s3_bucket_prefix`) gets all of this from
973
+ the engine, with no initializer of its own:
974
+
975
+ - **A boot check** (`studio.storage`, after the host's initializers). Both
976
+ switches, `ACTIVE_STORAGE_BACKEND` and `STUDIO_S3_BACKEND`, may be unset or
977
+ `r2`; a retired value (`s3`, `mirror_to_r2`, `mirror_to_s3`) or any other
978
+ value refuses the boot in every environment. In production (QA boots
979
+ `RAILS_ENV=production` too) a missing `R2_ENDPOINT`, `R2_ACCESS_KEY_ID` or
980
+ `R2_SECRET_ACCESS_KEY` refuses the boot, naming every missing variable and the
981
+ 1Password item `r2.<prefix>`. Elsewhere, none set boots on a placeholder
982
+ connection (`https://r2-not-configured.invalid`, which never resolves, and
983
+ placeholder keys), and some set raises: half a configuration is a mistake.
984
+ - **`Studio::S3` on R2**: endpoint, region `auto`, keys and public URL, filled in
985
+ only when the app left `s3_endpoint` unset. A public URL the app set is kept.
986
+ - **Active Storage services** `amazon` (this environment's bucket) and
987
+ `amazon_dev` (always the dev bucket), both `service: StudioTrashS3` on R2 with
988
+ `request_checksum_calculation` / `response_checksum_validation` set to
989
+ `when_required`. The bucket is `<prefix>-production` on real production only;
990
+ QA (`QA_ENV`), development, test and CI resolve `<prefix>-dev`, the same
991
+ resolution `Studio::S3` uses. They are merged UNDER the app's own
992
+ `config/storage.yml`: a service of the same name there wins, and an app with
993
+ no storage file still gets both.
994
+
995
+ ```ruby
996
+ # config/initializers/studio.rb: the whole adoption
997
+ config.s3_bucket_prefix = "turf-monster"
998
+ config.storage_public_url_required = true # only an app that serves public object URLs
999
+ ```
1000
+
1001
+ `storage_public_url_required` makes `R2_PUBLIC_URL` as required as the keys
1002
+ (a keyless process then gets the placeholder host as its public URL). Leave it
1003
+ false for an app whose objects are private and served by signed URL.
1004
+ `config.manage_storage = false` opts out entirely.
1005
+
1006
+ For app code: `Studio::Storage.configured?` (every required variable set, so
1007
+ false on placeholders), `.remote_in_development?` (any R2 variable set),
1008
+ `.credential_hint`, `.bucket` / `.dev_bucket`, and `.connection`. Every method
1009
+ takes the environment hash and `strict:` as arguments, so each rule can be
1010
+ tested without booting.
1011
+
969
1012
  ### S3-compatible storage (Cloudflare R2)
970
1013
 
971
1014
  `Studio::S3` talks to any S3-compatible endpoint. The McRitchie fleet is moving
@@ -979,8 +1022,10 @@ config.s3_secret_access_key = ENV["R2_SECRET_ACCESS_KEY"]
979
1022
  config.s3_public_url = ENV["R2_PUBLIC_URL"] # custom domain on this env's bucket
980
1023
  ```
981
1024
 
982
- All four are nil by default, and nil is AWS exactly as before. The keys must be
983
- set as a pair. R2 serves nothing anonymously from its S3 endpoint, so with an
1025
+ All four are nil by default. For an app that names its bucket, the engine fills
1026
+ them from the R2 variables at boot ([Managed storage](#managed-storage--studiostorage-on-by-default));
1027
+ setting `s3_endpoint` yourself keeps the connection yours. With managed storage
1028
+ off, nil is AWS exactly as before. The keys must be set as a pair. R2 serves nothing anonymously from its S3 endpoint, so with an
984
1029
  endpoint and no `s3_public_url`, `Studio::S3.url` raises `NotConfigured` and
985
1030
  `upload` writes but returns `nil`; serve private objects with `signed_url`.
986
1031
 
@@ -1120,17 +1165,25 @@ Name every `assets.<domain>` host the bucket answers on. If the bucket has the
1120
1165
 
1121
1166
  ### Adopt it in an app
1122
1167
 
1123
- Active Storage: change the R2 service in `config/storage.yml` from `service: S3`
1124
- to `service: StudioTrashS3`. Every other key stays as it is:
1168
+ Active Storage: name your bucket and the engine supplies the trash services.
1169
+ With `config.s3_bucket_prefix` set, `amazon` and `amazon_dev` are
1170
+ `service: StudioTrashS3` on R2 with no storage.yml entry of your own
1171
+ ([Managed storage](#managed-storage--studiostorage-on-by-default)); select one in
1172
+ `config/environments/*.rb` as usual (`config.active_storage.service = :amazon`).
1173
+
1174
+ A service under another name, or an app with `manage_storage = false`, names the
1175
+ service itself in `config/storage.yml`:
1125
1176
 
1126
1177
  ```yaml
1127
- r2:
1178
+ archive:
1128
1179
  service: StudioTrashS3
1129
- bucket: my-app-production
1180
+ bucket: my-app-archive
1130
1181
  endpoint: <%= ENV["R2_ENDPOINT"] %>
1131
1182
  region: auto
1132
1183
  access_key_id: <%= ENV["R2_ACCESS_KEY_ID"] %>
1133
1184
  secret_access_key: <%= ENV["R2_SECRET_ACCESS_KEY"] %>
1185
+ request_checksum_calculation: when_required
1186
+ response_checksum_validation: when_required
1134
1187
  ```
1135
1188
 
1136
1189
  An app with its own S3 service subclass (turf-monster's
@@ -518,7 +518,9 @@ body.modal-open .studio-app-banner {
518
518
  single frame on a flick — measured 39px in turf-monster, the full
519
519
  178 -> 139. navCollapse() converts this to --nav-p units off
520
520
  --nav-ramp's 3x relation to the collapse total, so one number covers
521
- every band. */
521
+ every band. It binds only a header OUT of the page's flow: a header
522
+ in flow is held (studio/nav_collapse, the hold), so the page cannot
523
+ move, and its progress follows the scroll exactly. */
522
524
  --nav-max-step: 5px;
523
525
  --nav-pad: calc(1.5rem - 1rem * var(--nav-p, 0)); /* py-6 -> py-2 */
524
526
  --nav-logo-size: calc(3rem - 1rem * var(--nav-p, 0)); /* 48px -> 32px */
@@ -22,7 +22,25 @@
22
22
  // sizing opinion, so an app whose navbar collapses to different endpoints than
23
23
  // the engine's adopts the mechanism without touching its markup.
24
24
  //
25
- // Four details are load-bearing:
25
+ // Five details are load-bearing:
26
+ //
27
+ // the hold: the header keeps its EXPANDED footprint in the page's flow. As it
28
+ // shrinks, the controller gives the difference back as margin below it, so
29
+ // the document never changes height and nothing under the header moves on
30
+ // its own. Before the hold, the collapse reflowed the whole page every frame:
31
+ // content ran ahead of the finger while collapsing, a flick back to the top
32
+ // pushed the page DOWN for ~10 frames after the scroll had stopped, and near
33
+ // the bottom of a page the browser clamped scrollY back 4px a frame as the
34
+ // document shrank under it. Held, the page scrolls exactly as far as the
35
+ // finger moved it, and only the header itself changes size. A header that
36
+ // is not in flow (fixed or absolute) is left alone: it never moved the page.
37
+ //
38
+ // (Held, the two guards below exist only for an UNHELD header. The document
39
+ // no longer shortens, so the short-page flap cannot happen, and the per-frame
40
+ // cap, which slowed the header so the page it dragged would not jump, now
41
+ // only makes the header lag the scroll. Worse, a lagging header opens a gap
42
+ // above the content on a fast scroll back to the top. Held, the progress is
43
+ // the scroll position, every frame.)
26
44
  //
27
45
  // passive + rAF: the listener never blocks the compositor and coalesces a
28
46
  // burst of scroll events (iOS momentum fires far above 60Hz) into one write
@@ -31,7 +49,9 @@
31
49
  // frame, and it would leak the live page's scroll progress into the preview
32
50
  // headers on /navbar.
33
51
  //
34
- // the smoothstep: collapsing a sticky, IN-FLOW header pulls the page up,
52
+ // the smoothstep (written for an UNHELD header; held, the page does not
53
+ // move, and the smoothstep only shapes how the header itself shrinks):
54
+ // collapsing a sticky, IN-FLOW header pulls the page up,
35
55
  // so during the collapse content moves by the scroll AND by the shrink:
36
56
  // faster than the finger, always. That is inherent; reclaiming the vertical
37
57
  // space is the point. What is tunable is the shape of the burst. --nav-ramp
@@ -64,11 +84,13 @@ export const DEFAULT_MAX_STEP = 5;
64
84
  // scrollHeight document.documentElement.scrollHeight
65
85
  // innerHeight window.innerHeight
66
86
  // reduce prefers-reduced-motion matches
87
+ // held the header holds its footprint (see the hold above): no
88
+ // short-page guard, no per-frame cap
67
89
  //
68
90
  // Answers { p, settling }: the progress to publish, and whether the frame
69
91
  // clamp left it short of where the scroll position wants it (so the caller
70
92
  // must schedule another frame).
71
- export function collapseFrame({ y, p, ramp, maxPx, scrollHeight, innerHeight, reduce }) {
93
+ export function collapseFrame({ y, p, ramp, maxPx, scrollHeight, innerHeight, reduce, held }) {
72
94
  // The height the document WOULD have with the navbar expanded. The
73
95
  // add-back is the whole trick; see the guard note above.
74
96
  var roomExpanded = scrollHeight - innerHeight + ramp * p;
@@ -76,7 +98,7 @@ export function collapseFrame({ y, p, ramp, maxPx, scrollHeight, innerHeight, re
76
98
  // WHERE THE COLLAPSE WANTS TO BE, from scroll position alone.
77
99
  var target;
78
100
  var snap = false;
79
- if (roomExpanded < ramp + 24) {
101
+ if (!held && roomExpanded < ramp + 24) {
80
102
  target = 0;
81
103
  snap = true;
82
104
  } else if (reduce) {
@@ -85,6 +107,8 @@ export function collapseFrame({ y, p, ramp, maxPx, scrollHeight, innerHeight, re
85
107
  } else {
86
108
  var t = Math.min(1, y / ramp);
87
109
  target = t * t * (3 - 2 * t);
110
+ // Held, the progress IS the scroll position: nothing to cap.
111
+ if (held) snap = true;
88
112
  }
89
113
 
90
114
  // THE RATE LIMIT: how far the collapse may travel in ONE frame.
@@ -115,7 +139,7 @@ export function collapseFrame({ y, p, ramp, maxPx, scrollHeight, innerHeight, re
115
139
  if (snap) {
116
140
  // A guard refusal and a reduced-motion state are DECISIONS, not
117
141
  // motion; ramping them would animate the very thing each exists to
118
- // avoid.
142
+ // avoid. A held header follows the scroll exactly.
119
143
  next = target;
120
144
  } else {
121
145
  var maxStep = (3 * maxPx) / ramp;
@@ -135,6 +159,30 @@ export function shadowLit(lit, y) {
135
159
  return lit ? y > 5 : y > 60;
136
160
  }
137
161
 
162
+ // THE HOLD, in two parts.
163
+ //
164
+ // THE LINEAR PART IS CSS. The header carries
165
+ // margin-bottom: calc(base + var(--nav-hold) * var(--nav-p) + var(--nav-hold-fix))
166
+ // where --nav-hold is its rest height minus its collapsed height. That margin
167
+ // grows in the SAME style recalc that shrinks the header, so the document
168
+ // never shortens, not even for one layout. (Setting the margin from script
169
+ // after the shrink was the first version of this, and it lost: the shrink's
170
+ // own layout shortened the document, and on a short page the browser clamped
171
+ // scrollY before the margin came back. Measured: 211 -> 167.)
172
+ //
173
+ // THE RESIDUE IS SCRIPT. A collapse need not be exactly linear in --nav-p
174
+ // (the tallest thing in the row can change mid-ramp), and the scrolled state
175
+ // may add a border. holdFix answers what the linear part missed, so the
176
+ // footprint is the rest height exactly: rest - (height + hold * p).
177
+ export function holdFix({ rest, height, hold, p }) {
178
+ return rest - height - hold * p;
179
+ }
180
+
181
+ // Only a header in the page's flow moves the page as it shrinks.
182
+ export function inFlow(position) {
183
+ return position !== 'fixed' && position !== 'absolute';
184
+ }
185
+
138
186
  // --nav-ramp and --nav-max-step off the header's computed style, each with
139
187
  // its default when the band declares none.
140
188
  export function readTuning(style) {
@@ -162,12 +210,60 @@ export class NavCollapse {
162
210
  this._onScroll = this.onScroll.bind(this);
163
211
  this._onResize = this.onResize.bind(this);
164
212
  this._apply = this.apply.bind(this);
213
+ this.held = false;
214
+ this.rest = 0;
215
+ this.hold = 0;
216
+ this.fix = 0;
217
+ this.lastHeight = null;
218
+ this._observer = null;
219
+ this._remeasure = this.remeasure.bind(this);
165
220
  }
166
221
 
167
222
  start() {
168
223
  this.stopped = false;
169
224
  this._reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
170
- this.tuning = readTuning(getComputedStyle(this.el));
225
+ // A HEADER RESTORED FROM TURBO'S SNAPSHOT CACHE arrives as it was left:
226
+ // its scrolled classes on, and this controller's own hold still inline
227
+ // (Turbo copies the page before the old controller disconnects). Read as
228
+ // the header's own, that hold was counted twice on Back: a permanent band
229
+ // under the header, 39-43px on turf-monster's contest page. So the
230
+ // controller clears what it owns before it reads anything.
231
+ this.onScrolled(false);
232
+ this.el.style.marginBottom = '';
233
+ this.el.style.removeProperty('--nav-hold');
234
+ this.el.style.removeProperty('--nav-hold-fix');
235
+ this.el.style.removeProperty('--nav-p');
236
+ var style = getComputedStyle(this.el);
237
+ this.tuning = readTuning(style);
238
+ this.held = inFlow(style.position);
239
+ if (this.held) {
240
+ var base = parseFloat(style.marginBottom) || 0;
241
+ this.el.style.marginBottom =
242
+ 'calc(' + base + 'px + var(--nav-hold, 0px) * var(--nav-p, 0) + var(--nav-hold-fix, 0px))';
243
+ this.measure();
244
+ // The header's height also moves for reasons that are not --nav-p: the
245
+ // scrolled state's bottom border (1px), a webfont landing, a
246
+ // Turbo-streamed badge. The observer re-applies the hold for any of
247
+ // them, in the frame they land. Only a header AT REST (unscrolled,
248
+ // expanded) redefines the rest height, because that is the one state
249
+ // whose height the footprint must equal: scrolled, a change is
250
+ // something the header is doing, not what it rests at.
251
+ if (typeof ResizeObserver === 'function') {
252
+ var self = this;
253
+ this._observer = new ResizeObserver(function () {
254
+ if (self.stopped) return;
255
+ var h = self.el.getBoundingClientRect().height;
256
+ if (self.lastHeight !== null && Math.abs(h - self.lastHeight) <= 0.1) return;
257
+ self.lastHeight = h;
258
+ // At rest the header's height IS its rest height, and a change
259
+ // there is new content: measure both endpoints again.
260
+ if (self.atRest() && Math.abs(h - self.rest) > 0.1) self.measure();
261
+ else self.correct(h);
262
+ });
263
+ this._observer.observe(this.el);
264
+ }
265
+ if (document.fonts && document.fonts.ready) document.fonts.ready.then(this._remeasure);
266
+ }
171
267
  this.apply();
172
268
 
173
269
  window.addEventListener('scroll', this._onScroll, { passive: true });
@@ -181,6 +277,13 @@ export class NavCollapse {
181
277
  this.stopped = true;
182
278
  window.removeEventListener('scroll', this._onScroll);
183
279
  window.removeEventListener('resize', this._onResize);
280
+ if (this._observer) this._observer.disconnect();
281
+ this._observer = null;
282
+ if (this.held) {
283
+ this.el.style.marginBottom = '';
284
+ this.el.style.removeProperty('--nav-hold');
285
+ this.el.style.removeProperty('--nav-hold-fix');
286
+ }
184
287
  if (this._reduce && this._reduce.removeEventListener) {
185
288
  this._reduce.removeEventListener('change', this._apply);
186
289
  }
@@ -194,9 +297,69 @@ export class NavCollapse {
194
297
 
195
298
  onResize() {
196
299
  this.tuning = readTuning(getComputedStyle(this.el));
300
+ if (this.held) this.measure();
197
301
  this.onScroll();
198
302
  }
199
303
 
304
+ atRest() {
305
+ return this.p === 0 && window.scrollY <= 0;
306
+ }
307
+
308
+ // The header's height at a given --nav-p, read by putting it there for the
309
+ // length of one synchronous layout. Nothing paints in between, so the
310
+ // reader never sees it. The margin is pinned to the rest height while it
311
+ // reads, so the document only ever GROWS during the read: growing cannot
312
+ // clamp the scroll position, shrinking can.
313
+ heightAt(p) {
314
+ var style = this.el.style;
315
+ var was = style.getPropertyValue('--nav-p');
316
+ style.setProperty('--nav-p', String(p));
317
+ var h = this.el.getBoundingClientRect().height;
318
+ if (was) style.setProperty('--nav-p', was); else style.removeProperty('--nav-p');
319
+ return h;
320
+ }
321
+
322
+ // Both endpoints, then the linear hold between them and the residue at the
323
+ // current progress. Both are read UNSCROLLED: the rest height is the
324
+ // header's height at the top of the page, where the scrolled state (and any
325
+ // border it carries) is off. The residue absorbs that state while it is on.
326
+ measure() {
327
+ var style = this.el.style;
328
+ var lit = this.scrolled;
329
+ if (lit) this.onScrolled(false);
330
+ style.setProperty('--nav-hold-fix', '0px');
331
+ this.fix = 0;
332
+ style.setProperty('--nav-hold', '0px');
333
+ var pinned = style.marginBottom;
334
+ style.marginBottom = '0px';
335
+ var rest = this.heightAt(0);
336
+ style.marginBottom = rest + 'px';
337
+ var collapsed = this.heightAt(1);
338
+ style.marginBottom = pinned;
339
+ if (lit) this.onScrolled(true);
340
+ this.rest = rest;
341
+ this.hold = Math.max(0, rest - collapsed);
342
+ style.setProperty('--nav-hold', this.hold + 'px');
343
+ this.correct(this.el.getBoundingClientRect().height);
344
+ }
345
+
346
+ remeasure() {
347
+ if (this.stopped || !this.held) return;
348
+ this.measure();
349
+ }
350
+
351
+ // Write the residue the linear hold missed, when it moved by more than a
352
+ // rounding error. The observer calls this in the frame the height changed,
353
+ // before paint.
354
+ correct(height) {
355
+ this.lastHeight = height;
356
+ var fix = holdFix({ rest: this.rest, height: height, hold: this.hold, p: this.p });
357
+ if (Math.abs(fix - this.fix) > 0.1) {
358
+ this.fix = fix;
359
+ this.el.style.setProperty('--nav-hold-fix', fix.toFixed(2) + 'px');
360
+ }
361
+ }
362
+
200
363
  apply() {
201
364
  this.queued = false;
202
365
  if (this.stopped) return;
@@ -210,7 +373,8 @@ export class NavCollapse {
210
373
  maxPx: this.tuning.maxPx,
211
374
  scrollHeight: document.documentElement.scrollHeight,
212
375
  innerHeight: window.innerHeight,
213
- reduce: this._reduce.matches
376
+ reduce: this._reduce.matches,
377
+ held: this.held
214
378
  });
215
379
 
216
380
  if (frame.p !== this.p) {
@@ -85,6 +85,69 @@ export function composed(pins) {
85
85
 
86
86
  var started = false
87
87
 
88
+ // THE READERS OF THE STACK, found rather than declared.
89
+ //
90
+ // Every value this publisher writes is an inherited custom property on
91
+ // documentElement, so every write restyles the WHOLE document, whether or not
92
+ // an element reads it. On a scroll frame that is the hot path: the header's
93
+ // bottom edge moves every frame while an ambient bar scrolls away and while
94
+ // the navbar collapses. Measured on turf-monster's contest page (about 2,000
95
+ // elements): one root write cost 8.7ms of style recalc against 0.2ms for the
96
+ // same write on the header, and the collapse dropped every second or third
97
+ // frame on a 120Hz screen. The hub's 478-element /links paid 1ms.
98
+ //
99
+ // Only a handful of elements read these values. So while a scroll is in
100
+ // motion the publisher writes them onto those elements alone, and writes the
101
+ // root once the scroll settles. A reader is any element that
102
+ // · carries [data-pin-follow], the explicit opt-in;
103
+ // · uses one in its inline style (studio/_sidebar_panel does); or
104
+ // · matches a stylesheet rule that uses one (scanned once per document).
105
+ // The document's root and body are never followers: a rule there (a custom
106
+ // property derived from --nav-h on :root) is served by the settled write.
107
+ //
108
+ // A reader follows LIVE only when it uses a value for something that paints.
109
+ // scroll-margin and scroll-padding matter only at the moment of an anchor
110
+ // jump, and the settled root serves them: turf-monster's contest board carries
111
+ // `scroll-margin-top: var(--nav-h)` on a 1,200-element subtree, and following
112
+ // it live cost the whole page's restyle back.
113
+ export var READS_THE_STACK = /var\(\s*--(pin-|nav-bottom|nav-h\b)/;
114
+
115
+ export function readsLive(cssText) {
116
+ var decls = String(cssText || '').split(';');
117
+ for (var i = 0; i < decls.length; i++) {
118
+ var at = decls[i].indexOf(':');
119
+ if (at < 0) continue;
120
+ var name = decls[i].slice(0, at).trim().toLowerCase();
121
+ if (/^scroll-(margin|padding)/.test(name)) continue;
122
+ if (READS_THE_STACK.test(decls[i].slice(at + 1))) return true;
123
+ }
124
+ return false;
125
+ }
126
+
127
+ export function followerSelectors(sheets) {
128
+ var found = ['[data-pin-follow]', '[style*="var(--pin-"]', '[style*="var(--nav-bottom"]', '[style*="var(--nav-h"]'];
129
+ function walk(rules) {
130
+ for (var i = 0; rules && i < rules.length; i++) {
131
+ var rule = rules[i];
132
+ if (rule.selectorText && rule.style && readsLive(rule.style.cssText)) {
133
+ var parts = rule.selectorText.split(',');
134
+ for (var j = 0; j < parts.length; j++) {
135
+ var sel = parts[j].trim();
136
+ if (sel && found.indexOf(sel) === -1) found.push(sel);
137
+ }
138
+ }
139
+ if (rule.cssRules) walk(rule.cssRules);
140
+ }
141
+ }
142
+ for (var k = 0; k < sheets.length; k++) {
143
+ var rules = null;
144
+ // A cross-origin sheet refuses cssRules; it cannot hold this app's readers.
145
+ try { rules = sheets[k].cssRules; } catch (e) { rules = null; }
146
+ walk(rules);
147
+ }
148
+ return found;
149
+ }
150
+
88
151
  export function startPinnedStack() {
89
152
  if (started || !window.ResizeObserver) return;
90
153
  started = true;
@@ -109,6 +172,73 @@ export function startPinnedStack() {
109
172
  // when one departs. The REGISTRY ITSELF is never cached; see readPins().
110
173
  var observed = [];
111
174
 
175
+ // THE MOTION WRITE (see READS_THE_STACK above). lastScrollAt marks the
176
+ // gesture; within SETTLE_MS of it a publish writes the followers, and a
177
+ // timer writes the root once the scroll has been still that long.
178
+ var SETTLE_MS = 160;
179
+ var lastScrollAt = -Infinity;
180
+ var settleTimer = null;
181
+ var selectors = null;
182
+ // The scan is redone when the document's stylesheets change (a Turbo visit
183
+ // into a page with its own), keyed on their hrefs and count.
184
+ var scannedSheets = null;
185
+ var followed = [];
186
+ var followedProps = [];
187
+
188
+ function followers() {
189
+ var key = [].map.call(document.styleSheets, function (sheet) { return sheet.href || 'inline'; }).join('|');
190
+ if (!selectors || key !== scannedSheets) {
191
+ selectors = followerSelectors(document.styleSheets);
192
+ scannedSheets = key;
193
+ }
194
+ var out = [];
195
+ for (var i = 0; i < selectors.length; i++) {
196
+ var nodes;
197
+ // A selector the scan lifted may not be queryable (a pseudo-element).
198
+ try { nodes = document.querySelectorAll(selectors[i]); } catch (e) { continue; }
199
+ for (var j = 0; j < nodes.length; j++) {
200
+ var n = nodes[j];
201
+ if (n === document.documentElement || n === document.body || n === document.head) continue;
202
+ // The inline matches are substring guesses; the declarations decide.
203
+ if (selectors[i].charAt(0) === '[' && selectors[i] !== '[data-pin-follow]' && !readsLive(n.getAttribute('style'))) continue;
204
+ if (out.indexOf(n) === -1) out.push(n);
205
+ }
206
+ }
207
+ return out;
208
+ }
209
+
210
+ function writeFollowers(props) {
211
+ var targets = followers();
212
+ // An element that stopped reading the stack (or left the page) gives its
213
+ // copies back, so it inherits the root again.
214
+ for (var f = 0; f < followed.length; f++) {
215
+ if (targets.indexOf(followed[f]) !== -1) continue;
216
+ for (var g = 0; g < followedProps.length; g++) followed[f].style.removeProperty(followedProps[g][0]);
217
+ }
218
+ for (var i = 0; i < targets.length; i++) {
219
+ for (var k = 0; k < props.length; k++) {
220
+ if (targets[i].style.getPropertyValue(props[k][0]) !== props[k][1]) targets[i].style.setProperty(props[k][0], props[k][1]);
221
+ }
222
+ }
223
+ followed = targets;
224
+ followedProps = props;
225
+ }
226
+
227
+ // Once the root holds the settled values, the followers inherit them again.
228
+ function releaseFollowers() {
229
+ for (var i = 0; i < followed.length; i++) {
230
+ for (var k = 0; k < followedProps.length; k++) followed[i].style.removeProperty(followedProps[k][0]);
231
+ }
232
+ followed = [];
233
+ followedProps = [];
234
+ }
235
+
236
+ function settle() {
237
+ settleTimer = null;
238
+ publishAll();
239
+ releaseFollowers();
240
+ }
241
+
112
242
  // THE REGISTRY IS DERIVED, NEVER CACHED — and that is a correctness rule.
113
243
  //
114
244
  // This used to build a `pins` array once and hold each element by reference
@@ -174,6 +304,22 @@ export function startPinnedStack() {
174
304
  if (!force) {
175
305
  for (i = 0; i < pins.length; i++) { if (!last.hasOwnProperty(pins[i].name)) { force = true; break; } }
176
306
  }
307
+
308
+ // IN MOTION, THE FOLLOWERS ONLY. A structural change still writes the
309
+ // root at once: it is rare, and a layer arriving must reach every reader.
310
+ if (!force && performance.now() - lastScrollAt < SETTLE_MS) {
311
+ var props = [];
312
+ for (i = 0; i < pins.length; i++) {
313
+ p = pins[i];
314
+ props.push(['--pin-' + p.name + '-h', p.h + 'px'], ['--pin-' + p.name + '-bottom', p.bottom + 'px'], ['--pin-' + p.name + '-top', p.top + 'px']);
315
+ if (p.legacy) props.push(['--nav-h', p.h + 'px'], ['--nav-bottom', p.bottom + 'px']);
316
+ }
317
+ props.push(['--pin-stack-bottom', stack + 'px']);
318
+ writeFollowers(props);
319
+ if (settleTimer) clearTimeout(settleTimer);
320
+ settleTimer = setTimeout(settle, SETTLE_MS);
321
+ return;
322
+ }
177
323
  for (i = 0; i < pins.length; i++) {
178
324
  p = pins[i];
179
325
  seen[p.name] = true;
@@ -287,6 +433,12 @@ export function startPinnedStack() {
287
433
  function invalidate() { forceWrite = true; publishAll(); }
288
434
  document.addEventListener('DOMContentLoaded', invalidate);
289
435
  document.addEventListener('turbo:load', invalidate);
436
+ // Turbo copies the page into its snapshot cache before it leaves: give the
437
+ // followers' copies back first, so a restored page carries none of them.
438
+ document.addEventListener('turbo:before-cache', function () {
439
+ if (settleTimer) { clearTimeout(settleTimer); settleTimer = null; }
440
+ releaseFollowers();
441
+ });
290
442
  // A Turbo Stream replaces nodes WITHOUT a turbo:load. The registry is derived
291
443
  // per publish, so a stale reference is no longer possible either way — but the
292
444
  // OBSERVER still has to be moved onto the incoming node, and the stack has to
@@ -297,6 +449,8 @@ export function startPinnedStack() {
297
449
  // Registered ONCE at start, not inside a re-scan, so a Turbo nav
298
450
  // re-publishes instead of stacking another listener per visit.
299
451
  window.addEventListener('scroll', schedule, { passive: true });
452
+ // The gesture's clock for the motion write (see READS_THE_STACK).
453
+ window.addEventListener('scroll', function () { lastScrollAt = performance.now(); }, { passive: true });
300
454
  window.addEventListener('resize', schedule, { passive: true });
301
455
 
302
456
  // A deferred module runs after the body is parsed, so the layers are already
data/lib/studio/engine.rb CHANGED
@@ -42,6 +42,30 @@ module Studio
42
42
  # silently assigns the OrderedOptions and rotation then dies inside a
43
43
  # rescued comparison. The behavioral test caught that; a config read-back
44
44
  # would have called it green.
45
+ # STORAGE, PART 1: THE ACTIVE STORAGE SERVICES (Studio::Storage). Active
46
+ # Storage builds its service registry in an on_load(:active_storage_blob)
47
+ # hook its "active_storage.services" initializer registers, reading
48
+ # config.active_storage.service_configurations, or config/storage.yml when
49
+ # that is nil. Hooks run in registration order, so this one, registered
50
+ # BEFORE that initializer, runs first and hands it the app's own services
51
+ # with the engine's `amazon` / `amazon_dev` merged under them (an app's
52
+ # service of the same name wins). Lazy like Active Storage's own read: it
53
+ # runs when Blob first loads, after every host initializer, so it sees the
54
+ # host's s3_bucket_prefix. An app still configuring Studio::S3 itself
55
+ # (Studio::Storage.owns_connection? false) keeps its services as they are.
56
+ # Declared first in this class so Railtie's
57
+ # implicit `after: <previous initializer>` chaining ties it to nothing.
58
+ initializer "studio.storage.services", before: "active_storage.services" do |app|
59
+ ActiveSupport.on_load(:active_storage_blob) do
60
+ next unless Studio::Storage.enabled? && Studio::Storage.owns_connection?
61
+
62
+ storage = app.config.active_storage
63
+ storage.service_configurations = Studio::Storage.merge_services(
64
+ storage.service_configurations || Studio::Storage.storage_file_configurations(Rails.root, Rails.env)
65
+ )
66
+ end
67
+ end
68
+
45
69
  initializer "studio.logger", before: :initialize_logger, after: :load_environment_hook do |app|
46
70
  cap = Studio::LogRotation.cap_for(
47
71
  env: Rails.env,
@@ -254,6 +278,23 @@ module Studio
254
278
  end
255
279
  end
256
280
 
281
+ # STORAGE, PART 2: THE BOOT CHECK and Studio::S3 on R2 (Studio::Storage),
282
+ # for an app that names its bucket. After the host's config/initializers,
283
+ # where s3_bucket_prefix and storage_public_url_required are set, and before
284
+ # anything serves. Raising here fails a release phase's db:migrate instead of
285
+ # crash-looping web and worker afterwards. Both switches are always checked;
286
+ # the connection is checked and applied only when the host left s3_endpoint
287
+ # unset, so an app still carrying its own StorageBackend keeps its own rules.
288
+ # Declared LAST among this class's initializers: Railtie chains each
289
+ # initializer `after:` the one declared before it, so anything declared
290
+ # below this would be dragged after the host's initializers too.
291
+ initializer "studio.storage", after: :load_config_initializers do
292
+ next unless Studio::Storage.enabled?
293
+
294
+ Studio::Storage.validate!(check_connection: false)
295
+ Studio::Storage.apply_studio_s3_settings!
296
+ end
297
+
257
298
  # `studio_engine:install:migrations` with no ActiveRecord. Rails defines it
258
299
  # for every engine that ships db/migrate, and it invokes
259
300
  # `railties:install:migrations`, which only ActiveRecord's tasks define, so
@@ -0,0 +1,321 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "environment_banner"
4
+
5
+ module Studio
6
+ # OBJECT STORAGE IS CLOUDFLARE R2, AND ONLY R2. One copy of the rules the hub,
7
+ # turf-monster and mcritchie-industries each carried as a hand-written
8
+ # `StorageBackend` module (AWS S3 was retired on 2026-10-10, so there is no S3
9
+ # stage and no mirror stage left to select). README.md, "Managed storage".
10
+ #
11
+ # ON BY DEFAULT for every app that names its bucket (`config.s3_bucket_prefix`).
12
+ # Such an app gets, with no initializer of its own:
13
+ #
14
+ # - the BOOT CHECK (Studio::Engine, "studio.storage"): a retired or unknown
15
+ # switch value, or a missing R2 variable in production, refuses the boot
16
+ # by name. config/storage.yml is rendered lazily (a release-phase
17
+ # db:migrate never renders it), so without a boot check a bad value passes
18
+ # the release and crash-loops web and worker instead.
19
+ # - Studio::S3 on R2: endpoint, region "auto", keys and public URL, filled in
20
+ # when the app left `s3_endpoint` unset (an app that hand-configures the
21
+ # connection keeps it; the engine stands back).
22
+ # - Active Storage services `amazon` (this environment's bucket) and
23
+ # `amazon_dev` (always the dev bucket), both `service: StudioTrashS3` on R2.
24
+ # The names are kept because blob rows record them. A service of the same
25
+ # name in the app's own config/storage.yml WINS, so an app's own copy keeps
26
+ # working until it adopts this one.
27
+ #
28
+ # Opt out with `config.manage_storage = false`.
29
+ #
30
+ # THE TWO SWITCHES, ACTIVE_STORAGE_BACKEND and STUDIO_S3_BACKEND, staged the
31
+ # move and are still `r2` on the deployed apps. Unset or `r2` means R2. A
32
+ # retired value (s3, mirror_to_r2, mirror_to_s3) or any other value RAISES in
33
+ # every environment: it must never look like a successful selection of a store
34
+ # that no longer exists.
35
+ #
36
+ # THE CONNECTION: R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY (1Password
37
+ # item r2.<prefix>: the prod pair on production, the dev pair on QA and
38
+ # locally), plus R2_PUBLIC_URL, which is required only when the app sets
39
+ # `config.storage_public_url_required = true` (it serves public object URLs to
40
+ # strangers; an app with private objects only has no public domain).
41
+ #
42
+ # A MISSING VARIABLE:
43
+ # production (QA boots RAILS_ENV=production too) RAISES, naming every
44
+ # missing variable. A dyno that boots without storage fails at the first
45
+ # upload hours later; one that refuses to boot fails the deploy.
46
+ # elsewhere, NONE set (test, CI, a keyless desk) boots on a placeholder
47
+ # connection: a reserved `.invalid` host (RFC 2606, never resolves) and
48
+ # placeholder keys, so nothing reaches AWS through the SDK's default
49
+ # endpoint or credential chain, and the first real call fails naming
50
+ # r2-not-configured.invalid.
51
+ # elsewhere, SOME set RAISES, naming the missing ones: half a configuration
52
+ # is a mistake, not a choice.
53
+ #
54
+ # Dependency-free apart from EnvironmentBanner, so the unit suite requires it
55
+ # directly and every method takes its inputs as arguments.
56
+ module Storage
57
+ BACKEND = "r2"
58
+ SWITCHES = %w[ACTIVE_STORAGE_BACKEND STUDIO_S3_BACKEND].freeze
59
+ RETIRED_VALUES = %w[s3 mirror_to_r2 mirror_to_s3].freeze
60
+
61
+ CONNECTION_VARIABLES = %w[R2_ENDPOINT R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY].freeze
62
+ PUBLIC_URL_VARIABLE = "R2_PUBLIC_URL"
63
+ REGION = "auto"
64
+
65
+ # The engine's Active Storage service: S3Service whose delete MOVES the
66
+ # object under trash/<utc date>/<epoch ms>/<key>, so a purged or replaced
67
+ # attachment has a three-day grace period
68
+ # (lib/active_storage/service/studio_trash_s3_service.rb).
69
+ ACTIVE_STORAGE_SERVICE = "StudioTrashS3"
70
+ # The service names blob rows record. `amazon` is this environment's bucket;
71
+ # `amazon_dev` is the dev bucket in every environment (local desks).
72
+ SERVICE = "amazon"
73
+ DEV_SERVICE = "amazon_dev"
74
+
75
+ UNCONFIGURED_HOST = "r2-not-configured.invalid"
76
+ PLACEHOLDER_ENDPOINT = "https://#{UNCONFIGURED_HOST}"
77
+ PLACEHOLDER_KEY = "r2-not-configured"
78
+ PLACEHOLDER_PUBLIC_URL = "https://#{UNCONFIGURED_HOST}"
79
+
80
+ module_function
81
+
82
+ # Whether the engine manages this app's storage: it named its bucket and did
83
+ # not opt out.
84
+ def enabled?(prefix: Studio.s3_bucket_prefix, manage: manage_setting)
85
+ manage != false && !prefix.to_s.strip.empty?
86
+ end
87
+
88
+ # Whether a missing R2 variable refuses the boot. Production and QA both run
89
+ # RAILS_ENV=production. The one exception is Rails' own build-time flag:
90
+ # `SECRET_KEY_BASE_DUMMY=1 bin/rails assets:precompile` (a Dockerfile) boots
91
+ # production with no secrets on purpose and never touches storage, so it
92
+ # boots on the placeholder connection instead (moms-app's rule).
93
+ def strict?(env = ENV)
94
+ rails_production? && env["SECRET_KEY_BASE_DUMMY"].to_s.strip.empty?
95
+ end
96
+
97
+ def rails_production?
98
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.respond_to?(:production?) && Rails.env.production? ? true : false
99
+ end
100
+
101
+ def active_storage_stage(env = ENV)
102
+ stage(env, "ACTIVE_STORAGE_BACKEND")
103
+ end
104
+
105
+ def studio_s3_stage(env = ENV)
106
+ stage(env, "STUDIO_S3_BACKEND")
107
+ end
108
+
109
+ def stage(env, name)
110
+ value = env[name].to_s.strip
111
+ return BACKEND if value.empty? || value == BACKEND
112
+
113
+ if RETIRED_VALUES.include?(value)
114
+ raise ArgumentError, "#{name}=#{value.inspect} names a retired stage: AWS S3 was retired on 2026-10-10 " \
115
+ "and storage runs on Cloudflare R2 only. Unset #{name} or set it to #{BACKEND}."
116
+ end
117
+
118
+ raise ArgumentError, "#{name}=#{value.inspect} is not a storage backend; unset #{name} or set it to #{BACKEND}"
119
+ end
120
+
121
+ # The variables a complete connection needs.
122
+ def variables(public_url_required: public_url_required_setting)
123
+ public_url_required ? CONNECTION_VARIABLES + [PUBLIC_URL_VARIABLE] : CONNECTION_VARIABLES
124
+ end
125
+
126
+ # Whether this process holds a real R2 connection: every required variable
127
+ # set. False on CI and a keyless desk, where the connection is placeholders;
128
+ # code that must refuse up front rather than fail at its first call asks
129
+ # this, because the placeholder keys make "is a key set?" answer yes.
130
+ def configured?(env = ENV, public_url_required: public_url_required_setting)
131
+ variables(public_url_required: public_url_required).all? { |name| present?(env, name) }
132
+ end
133
+
134
+ # Whether development should talk to the remote dev bucket rather than Disk:
135
+ # whenever any R2 variable is set at all (a laptop's .env.development).
136
+ def remote_in_development?(env = ENV)
137
+ (CONNECTION_VARIABLES + [PUBLIC_URL_VARIABLE]).any? { |name| present?(env, name) }
138
+ end
139
+
140
+ # The R2 connection: { endpoint:, access_key_id:, secret_access_key:,
141
+ # public_url: }. public_url is nil when R2_PUBLIC_URL is unset and not
142
+ # required. See the module comment for what a missing variable does.
143
+ def connection(env = ENV, strict: strict?, public_url_required: public_url_required_setting,
144
+ prefix: Studio.s3_bucket_prefix)
145
+ names = variables(public_url_required: public_url_required)
146
+ missing = names.reject { |name| present?(env, name) }
147
+
148
+ if missing.empty?
149
+ return { endpoint: value(env, "R2_ENDPOINT"), access_key_id: value(env, "R2_ACCESS_KEY_ID"),
150
+ secret_access_key: value(env, "R2_SECRET_ACCESS_KEY"),
151
+ public_url: present?(env, PUBLIC_URL_VARIABLE) ? value(env, PUBLIC_URL_VARIABLE) : nil }
152
+ end
153
+
154
+ if !strict && missing.size == names.size
155
+ return { endpoint: PLACEHOLDER_ENDPOINT, access_key_id: PLACEHOLDER_KEY, secret_access_key: PLACEHOLDER_KEY,
156
+ public_url: public_url_required ? PLACEHOLDER_PUBLIC_URL : nil }
157
+ end
158
+
159
+ why = if strict
160
+ "production and QA refuse to boot without it"
161
+ else
162
+ "#{(names - missing).join(', ')} #{names.size - missing.size == 1 ? 'is' : 'are'} set, and half a " \
163
+ "configuration is a mistake: set all of #{names.join(', ')} or none"
164
+ end
165
+ raise ArgumentError, "#{missing.join(', ')} must be set: storage runs on Cloudflare R2 only, and #{why} " \
166
+ "(#{credential_source(prefix)})"
167
+ end
168
+
169
+ # The credentials an operator should check when Studio::S3 fails wholesale.
170
+ def credential_hint(public_url_required: public_url_required_setting)
171
+ "#{variables(public_url_required: public_url_required).join(' / ')} (local dev reads them from .env.development)"
172
+ end
173
+
174
+ # The Studio.configure settings for Studio::S3. s3_public_url is present only
175
+ # when there is one to give (see #connection).
176
+ def studio_s3_settings(env = ENV, strict: strict?, public_url_required: public_url_required_setting,
177
+ prefix: Studio.s3_bucket_prefix)
178
+ studio_s3_stage(env)
179
+ r2 = connection(env, strict: strict, public_url_required: public_url_required, prefix: prefix)
180
+ settings = { s3_endpoint: r2[:endpoint], s3_region: REGION,
181
+ s3_access_key_id: r2[:access_key_id], s3_secret_access_key: r2[:secret_access_key] }
182
+ settings[:s3_public_url] = r2[:public_url] if r2[:public_url]
183
+ settings
184
+ end
185
+
186
+ # WHICH BUCKET. Only real production gets "<prefix>-production". QA boots
187
+ # RAILS_ENV=production exactly like production, so QA_ENV
188
+ # (Studio.qa_environment?) is what sends it to "<prefix>-dev", the same
189
+ # resolution Studio::S3 picks its bucket by, which keeps both writers on one
190
+ # bucket. A laptop, a desk and CI resolve the dev bucket too.
191
+ def bucket(prefix = Studio.s3_bucket_prefix, production: production_environment?)
192
+ "#{require_prefix(prefix)}-#{production ? 'production' : 'dev'}"
193
+ end
194
+
195
+ def dev_bucket(prefix = Studio.s3_bucket_prefix)
196
+ bucket(prefix, production: false)
197
+ end
198
+
199
+ # Real production: Rails production and not a QA app.
200
+ def production_environment?(env = ENV)
201
+ rails_production? && !EnvironmentBanner.qa_environment?(env)
202
+ end
203
+
204
+ # One Active Storage service config on R2. Checksums only when required:
205
+ # Active Storage sends Content-MD5, aws-sdk-s3 >= 1.178 adds a CRC32 by
206
+ # default, and R2 refuses a request carrying both (measured 2026-09-28).
207
+ def service(bucket_name, r2)
208
+ { "service" => ACTIVE_STORAGE_SERVICE,
209
+ "endpoint" => r2.fetch(:endpoint), "access_key_id" => r2.fetch(:access_key_id),
210
+ "secret_access_key" => r2.fetch(:secret_access_key),
211
+ "region" => REGION, "bucket" => bucket_name,
212
+ "request_checksum_calculation" => "when_required",
213
+ "response_checksum_validation" => "when_required" }
214
+ end
215
+
216
+ # The services the engine supplies: { "amazon" => ..., "amazon_dev" => ... }.
217
+ def active_storage_services(env = ENV, prefix: Studio.s3_bucket_prefix, strict: strict?,
218
+ production: production_environment?(env))
219
+ active_storage_stage(env)
220
+ r2 = connection(env, strict: strict, public_url_required: false, prefix: prefix)
221
+ { SERVICE => service(bucket(prefix, production: production), r2),
222
+ DEV_SERVICE => service(dev_bucket(prefix), r2) }
223
+ end
224
+
225
+ # The app's own service configurations with the engine's merged UNDER them:
226
+ # a name the app's storage.yml already defines keeps the app's definition.
227
+ # Computes nothing (and so raises nothing) when the app defines both.
228
+ def merge_services(existing, env = ENV, prefix: Studio.s3_bucket_prefix, strict: strict?,
229
+ production: production_environment?(env))
230
+ existing = (existing || {}).to_h
231
+ defined = existing.keys.map(&:to_s)
232
+ return existing if [SERVICE, DEV_SERVICE].all? { |name| defined.include?(name) }
233
+
234
+ supplied = active_storage_services(env, prefix: prefix, strict: strict, production: production)
235
+ supplied.reject { |name, _| defined.include?(name) }.merge(existing)
236
+ end
237
+
238
+ # What Active Storage itself would read when the app sets no
239
+ # service_configurations: config/storage/<env>.yml, else config/storage.yml.
240
+ # An app with neither starts from no services, and gets the engine's.
241
+ def storage_file_configurations(root, rails_env)
242
+ file = File.join(root.to_s, "config/storage/#{rails_env}.yml")
243
+ file = File.join(root.to_s, "config/storage.yml") unless File.exist?(file)
244
+ return {} unless File.exist?(file)
245
+
246
+ require "active_support/configuration_file"
247
+ ActiveSupport::ConfigurationFile.parse(file) || {}
248
+ end
249
+
250
+ # Point Studio::S3 at R2, unless the app configured the connection itself
251
+ # (s3_endpoint already set). A public URL the app set is never overwritten.
252
+ # Returns whether it wrote anything, and remembers the answer for
253
+ # #owns_connection?.
254
+ def apply_studio_s3_settings!(config = Studio, env = ENV, strict: strict?,
255
+ public_url_required: public_url_required_setting)
256
+ @owns_connection = config.s3_endpoint.to_s.strip.empty?
257
+ return false unless @owns_connection
258
+
259
+ settings = studio_s3_settings(env, strict: strict, public_url_required: public_url_required,
260
+ prefix: config.s3_bucket_prefix)
261
+ settings.delete(:s3_public_url) unless config.s3_public_url.to_s.strip.empty?
262
+ settings.each { |name, setting| config.public_send("#{name}=", setting) }
263
+ true
264
+ end
265
+
266
+ # Whether the engine owns this app's R2 connection: the app left s3_endpoint
267
+ # unset. An app that configured it is still carrying its own StorageBackend,
268
+ # which also decides its own Active Storage services (turf-monster defines
269
+ # NONE in a keyless process, and its suite pins that), so the engine adds no
270
+ # service to it either. Answered by the boot initializer once it has run;
271
+ # before that (a host initializer loading Blob early), by s3_endpoint itself.
272
+ def owns_connection?(config = Studio)
273
+ return @owns_connection unless @owns_connection.nil?
274
+
275
+ config.s3_endpoint.to_s.strip.empty?
276
+ end
277
+
278
+ def reset_ownership!
279
+ @owns_connection = nil
280
+ end
281
+
282
+ # THE BOOT CHECK. Both switches, always. The connection, when the engine
283
+ # owns it (the app left s3_endpoint unset); an app still carrying its own
284
+ # copy checks its own.
285
+ def validate!(env = ENV, strict: strict?, public_url_required: public_url_required_setting,
286
+ prefix: Studio.s3_bucket_prefix, check_connection: true)
287
+ active_storage_stage(env)
288
+ studio_s3_stage(env)
289
+ connection(env, strict: strict, public_url_required: public_url_required, prefix: prefix) if check_connection
290
+ true
291
+ end
292
+
293
+ def credential_source(prefix)
294
+ name = prefix.to_s.strip
295
+ name.empty? ? "1Password item r2.<app>" : "1Password item r2.#{name}"
296
+ end
297
+
298
+ def present?(env, name)
299
+ !value(env, name).empty?
300
+ end
301
+
302
+ def value(env, name)
303
+ env[name].to_s.strip
304
+ end
305
+
306
+ def require_prefix(prefix)
307
+ name = prefix.to_s.strip
308
+ raise ArgumentError, "Studio.s3_bucket_prefix not set in config/initializers/studio.rb" if name.empty?
309
+
310
+ name
311
+ end
312
+
313
+ def manage_setting
314
+ Studio.respond_to?(:manage_storage) ? Studio.manage_storage : true
315
+ end
316
+
317
+ def public_url_required_setting
318
+ Studio.respond_to?(:storage_public_url_required) ? Studio.storage_public_url_required : false
319
+ end
320
+ end
321
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.107.0.rc1"
2
+ VERSION = "0.108.0.rc1"
3
3
  end
data/lib/studio.rb CHANGED
@@ -44,6 +44,7 @@ require "studio/username_generator"
44
44
  require "studio/public_user"
45
45
  require "studio/name_parts"
46
46
  require "studio/s3"
47
+ require "studio/storage"
47
48
  require "studio/image_cache"
48
49
  require "studio/host_schema"
49
50
  require "studio/link_token"
@@ -1110,6 +1111,17 @@ module Studio
1110
1111
  # production process ignores it, because its own bucket IS production.
1111
1112
  mattr_accessor :s3_production_public_url, default: nil
1112
1113
 
1114
+ # STORAGE IS MANAGED BY DEFAULT (Studio::Storage; README.md, "Managed storage"). An app that
1115
+ # names its bucket (s3_bucket_prefix) gets the R2 boot check, Studio::S3 on R2
1116
+ # and the `amazon` / `amazon_dev` StudioTrashS3 services with no initializer of
1117
+ # its own. false opts out entirely.
1118
+ mattr_accessor :manage_storage, default: true
1119
+
1120
+ # Whether R2_PUBLIC_URL is as required as the keys: true for an app that hands
1121
+ # public object URLs to strangers (og:images, email banners, headshots). An app
1122
+ # whose objects are private and served by signed URL leaves it false.
1123
+ mattr_accessor :storage_public_url_required, default: false
1124
+
1113
1125
  # Knowledge layer — the S3-backed document store + /admin/knowledge browser
1114
1126
  # (Studio::KnowledgeDoc). Routes are opt-in like every route surface.
1115
1127
  # 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.107.0.rc1
4
+ version: 0.108.0.rc1
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-14 00:00:00.000000000 Z
11
+ date: 2026-10-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -775,6 +775,7 @@ files:
775
775
  - lib/studio/session_state.rb
776
776
  - lib/studio/sidebar_sections.rb
777
777
  - lib/studio/site_footer.rb
778
+ - lib/studio/storage.rb
778
779
  - lib/studio/survey.rb
779
780
  - lib/studio/survey/breakdown.rb
780
781
  - lib/studio/survey/export.rb