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 +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +59 -6
- data/app/assets/tailwind/studio_engine/engine.css +3 -1
- data/app/javascript/studio/nav_collapse.js +171 -7
- data/app/javascript/studio/pinned_stack.js +154 -0
- data/lib/studio/engine.rb +41 -0
- data/lib/studio/storage.rb +321 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +12 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 01a56dee2b229822d24c392ac0f3c62f4b2a1d8c0328a2e84966dd4aa73630cc
|
|
4
|
+
data.tar.gz: 4b906a31d12a9fe5aae18b11f222fda647a1b7dba53627173c681f9da1fbb0b0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
983
|
-
|
|
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:
|
|
1124
|
-
|
|
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
|
-
|
|
1178
|
+
archive:
|
|
1128
1179
|
service: StudioTrashS3
|
|
1129
|
-
bucket: my-app-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/studio/version.rb
CHANGED
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.
|
|
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-
|
|
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
|