studio-engine 0.32.3 → 0.36.0
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 +166 -1
- data/README.md +29 -0
- data/app/controllers/studio/local_reviews_controller.rb +85 -1
- data/app/views/layouts/_navbar.html.erb +7 -1
- data/app/views/studio/banners/_app_banner.html.erb +15 -2
- data/app/views/studio/banners/_stack.html.erb +102 -0
- data/lib/studio/engine.rb +39 -0
- data/lib/studio/log_rotation.rb +49 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +43 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8c692fc135af1ba83c08b27398c875bc547ae07bbcc492540ffd83250fccf680
|
|
4
|
+
data.tar.gz: 891c561fe2bef1386137af4fbbc22547f38613efe8bd373bf6ec31894086feef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9cb8bb03d5adeec99336b5fab4d28b29c57be8686ca2afef58932ce866c321fe44f6ab0123609e6845151f938ab28790685bda6b9b21815ef3a3594d6c589921
|
|
7
|
+
data.tar.gz: 2c3be4aadf2a88d7838dab26b27c4a22604268138bba818f0b82890774f7bc5b81424d53a645d3fcc5ebf59a8f10197a9b37bd9cd44246c988d8dca3dfd2cd62
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,171 @@
|
|
|
2
2
|
|
|
3
3
|
The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Consumer Rails apps install the released RubyGems package with `gem "studio-engine", "~> 0.6"`; bumping the gem version and updating consumer lockfiles is a release.
|
|
4
4
|
|
|
5
|
+
## 0.36.0 — 2026-08-10
|
|
6
|
+
|
|
7
|
+
**The local-review link now signs the reviewer in as someone who can SEE the
|
|
8
|
+
page.** `/_studio/local_review` — the local half of the task board's WAITING
|
|
9
|
+
APPROVAL button — provisions the account before it mints, at the new
|
|
10
|
+
`Studio.local_review_role` (default `"admin"`).
|
|
11
|
+
|
|
12
|
+
The board hands this endpoint the operator's **production** email address. A
|
|
13
|
+
fresh worktree database has never seen it, so consuming the link took
|
|
14
|
+
`Studio::LinkConsumption#sign_up_new` and created the account at the default
|
|
15
|
+
role, `viewer`. `require_admin` on the page under review then redirected it to
|
|
16
|
+
`/`. The sign-in **succeeded** every time, which is what kept this quiet: the
|
|
17
|
+
button worked, the token was valid, and the operator simply arrived on the home
|
|
18
|
+
page having never seen the thing he was asked to review. A seeded admin
|
|
19
|
+
(`alex@mcritchie.studio`) worked fine, so only his real address ever hit it.
|
|
20
|
+
|
|
21
|
+
The endpoint now find-or-creates the reviewer and ensures the role BEFORE
|
|
22
|
+
minting, so the consume takes `sign_in_existing` onto an account that already
|
|
23
|
+
has rights. An existing account is promoted, never duplicated; an already-correct
|
|
24
|
+
one is not rewritten.
|
|
25
|
+
|
|
26
|
+
New public config:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
Studio.configure do |config|
|
|
30
|
+
config.local_review_role = "admin" # default; nil provisions without a role
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Set it to `nil` for an app whose review pages are not admin-gated — the account
|
|
35
|
+
is still provisioned (that is what avoids `sign_up_new`), but its role is left
|
|
36
|
+
to the host's own `configure_new_user`.
|
|
37
|
+
|
|
38
|
+
**The endpoint also answers "who is sitting at this desk?" when the caller names
|
|
39
|
+
nobody.** `?email=` is now optional, and an explicit one still wins. With none,
|
|
40
|
+
the reviewer resolves to `Studio.local_review_email`, and failing that to the
|
|
41
|
+
**first user already holding `Studio.local_review_role`, by id** (that query
|
|
42
|
+
falls back to `"admin"` when the setting is `nil`):
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
config.local_review_email = "someone@example.com" # nil (default) = derive
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
This exists so the board's WAITING APPROVAL CTA can be a **public, sign-in-free
|
|
49
|
+
redirect**. Requiring a board session to click it is what broke one-click review
|
|
50
|
+
in the first place; sending an email in that public URL would publish the
|
|
51
|
+
operator's address. The local stack is the machine the reviewer is sitting at,
|
|
52
|
+
so it is the right place to decide. A desk with nobody at that role mints
|
|
53
|
+
nothing and says so, rather than guessing: the derive only ever picks someone
|
|
54
|
+
who ALREADY holds the role, so it never promotes a stranger into it. (The
|
|
55
|
+
`?email=` path does promote the account it is handed — that is the point of it.)
|
|
56
|
+
|
|
57
|
+
**The floor is unchanged, and now asserted rather than assumed.** The endpoint
|
|
58
|
+
grants a role, so both gates in front of it are tested directly: the
|
|
59
|
+
developer-desk routes are drawn only outside production (proved by drawing
|
|
60
|
+
`Studio.routes` into a throwaway route set under a production env), and a
|
|
61
|
+
non-loopback request 404s **before** provisioning or minting anything.
|
|
62
|
+
Provisioning is best-effort: a host whose `User` rejects the write gets a logged
|
|
63
|
+
warning and a working link, never a 500.
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
|
|
67
|
+
- **`Studio.local_review_role`** — the role `/_studio/local_review` stamps on the
|
|
68
|
+
account it provisions before minting. Defaults to `"admin"`; `nil` provisions
|
|
69
|
+
the account without touching its role.
|
|
70
|
+
- **`Studio.local_review_email`** — who the local-review mint signs in when the
|
|
71
|
+
caller sends no `?email=`. `nil` (the default) derives the first admin in this
|
|
72
|
+
database, by id.
|
|
73
|
+
|
|
74
|
+
## 0.33.0 — 2026-08-10
|
|
75
|
+
|
|
76
|
+
Two changes ship together in this release: local log files are now capped, and
|
|
77
|
+
the pinned bars above the navbar become a composable **stack** that sits beside
|
|
78
|
+
the navbar rather than inside it.
|
|
79
|
+
|
|
80
|
+
**Local log files are now capped — 16 MB development, 8 MB test.** New
|
|
81
|
+
`studio.logger` initializer. Nothing to install, run, or remember: it rides the
|
|
82
|
+
gem, so every app, every future app, every worktree, and a freshly rebuilt Mac
|
|
83
|
+
all get it by construction.
|
|
84
|
+
|
|
85
|
+
Rails' own default is the reason this was needed. `config.load_defaults "7.1"`
|
|
86
|
+
sets `log_file_size` to **100 MB** for development *and* test, and each keeps one
|
|
87
|
+
rotated sibling — up to ~400 MB of log per checkout, times every worktree on the
|
|
88
|
+
machine. The logs were never unbounded; the ceiling was just far too high for a
|
|
89
|
+
machine that carries many desks. New ceiling: ~48 MB per checkout.
|
|
90
|
+
|
|
91
|
+
**Production is untouched.** The cap applies only where `Rails.env.local?`, so
|
|
92
|
+
every `production.rb` that hands its stream to STDOUT keeps doing exactly that,
|
|
93
|
+
and a host that names its own `config.logger` is never overridden.
|
|
94
|
+
|
|
95
|
+
**Ordering is the load-bearing part of this change.** Rails' `:initialize_logger`
|
|
96
|
+
is a *bootstrap* initializer — it runs before every railtie and engine
|
|
97
|
+
initializer and does `Rails.logger ||= config.logger || <default>`. An engine
|
|
98
|
+
that assigns `app.config.logger` from an ordinary initializer is therefore a
|
|
99
|
+
silent no-op: Rails has already built the logger. So `studio.logger` declares
|
|
100
|
+
`before: :initialize_logger` and sets `config.log_file_size`, the knob Rails
|
|
101
|
+
itself reads one moment later, leaving Rails owning the path, formatter, level,
|
|
102
|
+
and tagging. `after: :load_environment_hook` pins the other edge of the window,
|
|
103
|
+
which also keeps Railtie's implicit initializer chaining from dragging
|
|
104
|
+
`studio.assets` forward.
|
|
105
|
+
|
|
106
|
+
`test/integration/log_rotation_test.rb` asserts the **behavior**, not the
|
|
107
|
+
config: it boots real Rails apps and checks that the log file actually rotates
|
|
108
|
+
on disk once it passes the cap. It carries a mutation control — the same 17 MB
|
|
109
|
+
log with the cap switched off must *not* rotate — so the suite cannot be
|
|
110
|
+
satisfied by Rails' 100 MB default. Dropping the `before:` ordering reds it.
|
|
111
|
+
|
|
112
|
+
**New setting — `Studio.local_log_max_bytes`.** `false` opts out; an Integer
|
|
113
|
+
sets your own cap. Unlike every other `Studio.*` setting, it must be set in
|
|
114
|
+
`config/application.rb` (after `require "studio"`) or `config/environments/*.rb`
|
|
115
|
+
— `config/initializers/studio.rb` loads too late to be read.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
**The environment banner is now the navbar's SIBLING, not its child, and the
|
|
120
|
+
navbar sizes itself to whatever bars are actually there.** Nesting the banner
|
|
121
|
+
inside the navbar coupled two unrelated components: every new bar meant editing
|
|
122
|
+
the navbar, and there is already a second bar (impersonation), so this was never
|
|
123
|
+
a 0-or-1 problem.
|
|
124
|
+
|
|
125
|
+
Render the stack immediately before the navbar:
|
|
126
|
+
|
|
127
|
+
```erb
|
|
128
|
+
<%= render "studio/banners/stack" %>
|
|
129
|
+
<%= render "layouts/navbar" %>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The bars do not know the navbar exists; the navbar does not know which bars
|
|
133
|
+
rendered — only how tall they are, via `--studio-bars-h`.
|
|
134
|
+
|
|
135
|
+
**The height is MEASURED, not assumed.** A `ResizeObserver` on the stack
|
|
136
|
+
publishes its real height, so a bar that needs to be taller, a second bar, or one
|
|
137
|
+
that wraps on a narrow screen all work with **no change to any consuming app**.
|
|
138
|
+
A server-rendered estimate paints first so the common case never flashes, then the
|
|
139
|
+
observer replaces it with the truth. Verified in a real browser: growing the bar
|
|
140
|
+
32px moved the stack, the published property and the header's `top` together, on
|
|
141
|
+
the same frame.
|
|
142
|
+
|
|
143
|
+
**Backward-compatible.** An app that renders no stack gets
|
|
144
|
+
`var(--studio-bars-h, 0px)` — identical to the old `top-0`. Adoption is opt-in.
|
|
145
|
+
|
|
146
|
+
### Added
|
|
147
|
+
|
|
148
|
+
- **`studio/banners/stack`** — renders whichever bars apply (environment via
|
|
149
|
+
`Studio.show_environment_banner?`, impersonation when the host passes
|
|
150
|
+
`impersonated_user` / `admin_user` / `stop_path`) and publishes their measured
|
|
151
|
+
height. Takes `preview:` and forwards `devnet:` / `extra:` to the environment bar.
|
|
152
|
+
A preview render emits nothing at all, so a navbar-preview copy cannot duplicate
|
|
153
|
+
live chrome or publish a height.
|
|
154
|
+
- **`Studio.local_log_max_bytes`** — the opt-out (`false`) or override (an
|
|
155
|
+
Integer) for the new local log cap. Set it in `config/application.rb` or
|
|
156
|
+
`config/environments/*.rb`, never `config/initializers/studio.rb`.
|
|
157
|
+
|
|
158
|
+
### Changed
|
|
159
|
+
|
|
160
|
+
- **The navbar's sticky `top` reads `--studio-bars-h`** instead of hardcoding
|
|
161
|
+
`top-0`. It never branches on which bars rendered.
|
|
162
|
+
- **Banners are branded off the app's own theme tokens** — the environment bar
|
|
163
|
+
derives from `--color-warning` and impersonation from `--color-danger`, each
|
|
164
|
+
under a translucent gradient wash, replacing hardcoded hex stops. An app that
|
|
165
|
+
retunes its theme now gets a banner that follows it.
|
|
166
|
+
- **Local `development` and `test` logs rotate at 16 MB / 8 MB** via the new
|
|
167
|
+
`studio.logger` initializer, down from Rails' 100 MB default. Production is
|
|
168
|
+
untouched.
|
|
169
|
+
|
|
5
170
|
## 0.32.3 — 2026-08-09
|
|
6
171
|
|
|
7
172
|
**New public CSS custom property: `--nav-bottom`.** `_head.html.erb` now
|
|
@@ -48,7 +213,7 @@ rendered the desktop trigger on mobile beside the real one. Callers now own
|
|
|
48
213
|
the display class (bare renders default to `inline-flex`). Regression view
|
|
49
214
|
tests pin both.
|
|
50
215
|
|
|
51
|
-
## 0.32.1 —
|
|
216
|
+
## 0.32.1 — 2026-08-09
|
|
52
217
|
|
|
53
218
|
**Setup-guide corrections.** Docs only — no code, no behavior change.
|
|
54
219
|
|
data/README.md
CHANGED
|
@@ -54,6 +54,35 @@ Studio.configure do |config|
|
|
|
54
54
|
end
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
### Local log rotation (automatic — nothing to configure)
|
|
58
|
+
|
|
59
|
+
The engine caps the host app's **development** log at 16 MB and its **test** log
|
|
60
|
+
at 8 MB, keeping one rotated sibling each. There is nothing to install, run, or
|
|
61
|
+
remember: it rides the gem, so every checkout and every worktree is born with it.
|
|
62
|
+
|
|
63
|
+
It exists because Rails' own default is far too generous for a machine that
|
|
64
|
+
carries many worktrees. `config.load_defaults "7.1"` sets `log_file_size` to
|
|
65
|
+
**100 MB** for development *and* test, and each keeps a rotated sibling — up to
|
|
66
|
+
~400 MB of log per checkout.
|
|
67
|
+
|
|
68
|
+
**Production is untouched.** The cap applies only where `Rails.env.local?`, so
|
|
69
|
+
apps that hand their stream to STDOUT for the platform keep doing exactly that.
|
|
70
|
+
A host that names its own `config.logger` is never overridden.
|
|
71
|
+
|
|
72
|
+
To choose your own cap, or to opt out:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# config/application.rb (after `require "studio"`) or config/environments/development.rb
|
|
76
|
+
Studio.local_log_max_bytes = 64.megabytes # your own cap
|
|
77
|
+
Studio.local_log_max_bytes = false # opt out; Rails' 100 MB default returns
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**This one setting cannot go in `config/initializers/studio.rb`.** It is read
|
|
81
|
+
during boot — Rails builds the logger in a bootstrap initializer, long before
|
|
82
|
+
`config/initializers` is loaded — so an initializer would be too late and would
|
|
83
|
+
silently do nothing. Every *other* `Studio.*` setting belongs in the initializer
|
|
84
|
+
as usual.
|
|
85
|
+
|
|
57
86
|
Transactional mail transport is shared through `Studio::MailTransport`:
|
|
58
87
|
|
|
59
88
|
```ruby
|
|
@@ -19,6 +19,15 @@ module Studio
|
|
|
19
19
|
# exposure the inbox already carries, which hands every captured sign-in link
|
|
20
20
|
# to any local reader. It is a development desk convenience; it is not a
|
|
21
21
|
# sign-in path.
|
|
22
|
+
#
|
|
23
|
+
# It also PROVISIONS the account before minting, at Studio.local_review_role
|
|
24
|
+
# (default "admin"). The email the board hands over is the operator's
|
|
25
|
+
# PRODUCTION address, and a fresh worktree database has never seen it — so
|
|
26
|
+
# without this the consume takes Studio::LinkConsumption#sign_up_new, creates
|
|
27
|
+
# him at the default role ("viewer"), and `require_admin` on the page under
|
|
28
|
+
# review redirects him to "/". The sign-in SUCCEEDS and he never sees the
|
|
29
|
+
# page, which is what made the failure so quiet: a seeded admin works, so only
|
|
30
|
+
# the operator's real address ever hit it.
|
|
22
31
|
class LocalReviewsController < ApplicationController
|
|
23
32
|
include Studio::MagicLinkIssuing
|
|
24
33
|
|
|
@@ -27,9 +36,13 @@ module Studio
|
|
|
27
36
|
before_action :require_local_development!
|
|
28
37
|
|
|
29
38
|
def show
|
|
30
|
-
email =
|
|
39
|
+
email = reviewer_email
|
|
31
40
|
return redirect_to(login_path, alert: MISSING_EMAIL) unless email.match?(URI::MailTo::EMAIL_REGEXP)
|
|
32
41
|
|
|
42
|
+
# Provision BEFORE minting, so the consume finds an existing account and
|
|
43
|
+
# takes sign_in_existing rather than sign_up_new-at-the-default-role.
|
|
44
|
+
provision_reviewer(email)
|
|
45
|
+
|
|
33
46
|
# return_to is passed through raw: the store sanitizes it to a same-origin
|
|
34
47
|
# path on the way in (Studio::Link.create_magic_link calls
|
|
35
48
|
# Studio::LinkToken.sanitize_path). Re-sanitizing here would be a second
|
|
@@ -43,6 +56,77 @@ module Studio
|
|
|
43
56
|
|
|
44
57
|
MISSING_EMAIL = "Add ?email=<your address> to mint a local review link."
|
|
45
58
|
|
|
59
|
+
# WHO to sign in, in priority order:
|
|
60
|
+
#
|
|
61
|
+
# 1. `?email=` — an explicit caller still wins.
|
|
62
|
+
# 2. `Studio.local_review_email` — the app's declared desk operator.
|
|
63
|
+
# 3. the first admin in this database — the seeded operator, by id.
|
|
64
|
+
#
|
|
65
|
+
# The board deliberately stops at (1) being ABSENT. Its WAITING APPROVAL CTA
|
|
66
|
+
# is a public, sign-in-free redirect, so an email in that URL would be an
|
|
67
|
+
# address published on a public page for anyone to read. The local stack is
|
|
68
|
+
# the right place to answer "who is at this desk": it is the machine the
|
|
69
|
+
# reviewer is sitting at, and it already knows its own operator.
|
|
70
|
+
def reviewer_email
|
|
71
|
+
Studio::LinkToken.normalize_email(
|
|
72
|
+
params[:email].presence || Studio.local_review_email.presence || seeded_admin_email
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The database's own first admin. Ordered by id so a re-seeded desk resolves
|
|
77
|
+
# to the same person every time rather than to whoever was touched last.
|
|
78
|
+
# Rescued because this runs before the mint on every click: a host with no
|
|
79
|
+
# role column must fall through to MISSING_EMAIL, not a 500.
|
|
80
|
+
def seeded_admin_email
|
|
81
|
+
return nil unless User.respond_to?(:column_names) && User.column_names.include?("role")
|
|
82
|
+
|
|
83
|
+
User.where(role: Studio.local_review_role.presence || "admin").order(:id).first&.email
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
Rails.logger.warn("[Studio::LocalReviewsController] no default reviewer: #{e.class}: #{e.message}")
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Find-or-create the reviewer and ensure Studio.local_review_role, so the
|
|
90
|
+
# page under review actually RENDERS for whoever follows the link.
|
|
91
|
+
#
|
|
92
|
+
# Best-effort by design. Provisioning is an upgrade to the mint, not a
|
|
93
|
+
# precondition of it: a host with an extra User validation must not turn the
|
|
94
|
+
# review button into a 500 — it should still hand back a working sign-in
|
|
95
|
+
# link, exactly as it did before this method existed. So a failure is logged
|
|
96
|
+
# loudly and the mint proceeds. The end-to-end check that the operator
|
|
97
|
+
# actually lands ON the page is the real gate; this is the thing that makes
|
|
98
|
+
# it pass, not the thing that proves it.
|
|
99
|
+
# `User` is referenced bare, with no defined?/const_defined? guard: those do
|
|
100
|
+
# not agree with each other about a Zeitwerk autoload that has not fired
|
|
101
|
+
# yet, and a guard that reads "absent" on a cold boot would skip
|
|
102
|
+
# provisioning silently — the failure mode being fixed. Studio::LinkConsumption
|
|
103
|
+
# already requires every consuming app to have User, so a genuinely missing
|
|
104
|
+
# one is a NameError the rescue below logs like any other host mismatch.
|
|
105
|
+
def provision_reviewer(email)
|
|
106
|
+
user = User.find_by(email: email) || build_reviewer(email)
|
|
107
|
+
role = Studio.local_review_role.presence
|
|
108
|
+
user.role = role if role && user.respond_to?(:role=) && user.role != role
|
|
109
|
+
user.save! if user.new_record? || user.changed?
|
|
110
|
+
user
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
# No re-raise: see "best-effort" above. Logged at warn because a silent
|
|
113
|
+
# miss here reappears as the exact symptom this endpoint exists to remove
|
|
114
|
+
# — a successful sign-in that lands on the wrong page.
|
|
115
|
+
Rails.logger.warn(
|
|
116
|
+
"[Studio::LocalReviewsController] could not provision #{email}: #{e.class}: #{e.message}"
|
|
117
|
+
)
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# A brand-new reviewer goes through the host's own new-user hook first, so
|
|
122
|
+
# the account this endpoint creates is shaped like every other account in
|
|
123
|
+
# the app (usernames, defaults, associations) — then the role is stamped on
|
|
124
|
+
# top. Mirrors Studio::LinkConsumption#sign_up_new, which is the path this
|
|
125
|
+
# provisioning is standing in for.
|
|
126
|
+
def build_reviewer(email)
|
|
127
|
+
User.new(email: email).tap { |user| Studio.configure_new_user.call(user) }
|
|
128
|
+
end
|
|
129
|
+
|
|
46
130
|
def require_local_development!
|
|
47
131
|
head :not_found unless Studio.local_tool_enabled?(request_local: request.local?)
|
|
48
132
|
end
|
|
@@ -38,7 +38,13 @@
|
|
|
38
38
|
%>
|
|
39
39
|
|
|
40
40
|
<header x-data="{ scrolled: false }" <%= '@scroll.window="scrolled = scrolled ? (window.scrollY > 5) : (window.scrollY > 60)"'.html_safe unless is_preview %>
|
|
41
|
-
|
|
41
|
+
<%# top offsets by whatever studio/banners/_stack rendered above us. The
|
|
42
|
+
stack publishes --studio-bars-h on :root; with no stack the fallback
|
|
43
|
+
0px makes this identical to the old `top-0`, so an app that has not
|
|
44
|
+
adopted the stack is unaffected. The navbar never learns WHICH bars
|
|
45
|
+
rendered — only how tall they are. %>
|
|
46
|
+
style="<%= "top:var(--studio-bars-h, 0px);" unless is_preview %>"
|
|
47
|
+
class="<%= is_preview ? 'bg-page' : "#{'vt-pinned-header ' if pin_header}sticky z-50 bg-page transition-shadow duration-300" %>"
|
|
42
48
|
:class="scrolled && 'shadow-lg border-b border-subtle is-scrolled'">
|
|
43
49
|
<style>
|
|
44
50
|
.user-nav-col { width: 14rem; }
|
|
@@ -4,19 +4,32 @@
|
|
|
4
4
|
message = local_assigns.fetch(:message)
|
|
5
5
|
actions = local_assigns[:actions]
|
|
6
6
|
|
|
7
|
+
# Branded off the app's OWN theme tokens rather than hardcoded hexes, so a host
|
|
8
|
+
# that retunes --color-warning / --color-danger gets a banner that matches it.
|
|
9
|
+
# The gradient is a translucent black wash over the token — deliberately not
|
|
10
|
+
# color-mix(), which would need a modern-browser floor for pure decoration, and
|
|
11
|
+
# deliberately not two hardcoded stops, which cannot follow a themed colour.
|
|
7
12
|
tones = {
|
|
8
13
|
environment: {
|
|
9
|
-
background: "linear-gradient(90deg,
|
|
14
|
+
background: "linear-gradient(90deg, rgba(0,0,0,0.28) 0%, rgba(0,0,0,0.04) 100%), " \
|
|
15
|
+
"var(--color-warning, #FF7C47)",
|
|
10
16
|
color: "#ffffff",
|
|
11
17
|
shadow: "0 2px 8px rgba(0,0,0,0.25)"
|
|
12
18
|
},
|
|
13
19
|
impersonation: {
|
|
14
|
-
background: "linear-gradient(90deg,
|
|
20
|
+
background: "linear-gradient(90deg, rgba(0,0,0,0.30) 0%, rgba(0,0,0,0.06) 100%), " \
|
|
21
|
+
"var(--color-danger, #EF4444)",
|
|
15
22
|
color: "#ffffff",
|
|
16
23
|
shadow: "0 2px 8px rgba(0,0,0,0.25)"
|
|
17
24
|
}
|
|
18
25
|
}
|
|
19
26
|
|
|
27
|
+
# INTRINSIC height — content decides, and studio/banners/_stack MEASURES the
|
|
28
|
+
# result. An earlier cut declared a fixed height so the stack could compute its
|
|
29
|
+
# total server-side; that was rejected deliberately, because it cannot express
|
|
30
|
+
# a bar that needs to be taller, or two bars stacked, without every consuming
|
|
31
|
+
# app being reworked. The apps should respond to what the bar needs, not the
|
|
32
|
+
# other way round.
|
|
20
33
|
density_styles = {
|
|
21
34
|
compact: "font-size:12px; font-weight:700; padding:2px 12px;",
|
|
22
35
|
normal: "font-size:14px; font-weight:700; padding:8px 16px;"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
The bar stack — every pinned bar that sits ABOVE the navbar, as the navbar's
|
|
3
|
+
SIBLING rather than nested inside it.
|
|
4
|
+
|
|
5
|
+
Render it immediately before the navbar — the stack partial first, the navbar
|
|
6
|
+
partial second, as siblings. (Deliberately not written as ERB tags here: an
|
|
7
|
+
escaped tag inside an ERB comment ends the comment at its own closing marker
|
|
8
|
+
and dumps the rest of this block onto the page as text.)
|
|
9
|
+
|
|
10
|
+
Why a sibling and not nested (operator call, 2026-08-10): it composes. There is
|
|
11
|
+
already a second bar (impersonation), so this is not a 0-or-1 problem — it is
|
|
12
|
+
0, 1 or 2, and growing. Nesting each new bar inside the navbar coupled two
|
|
13
|
+
unrelated components and meant every new bar edited the navbar. Here the bars
|
|
14
|
+
do not know the navbar exists, and the navbar does not know which bars
|
|
15
|
+
rendered — only how tall they are, via --studio-bars-h.
|
|
16
|
+
|
|
17
|
+
HOW THE HEIGHT IS KNOWN. The stack MEASURES itself and publishes the result, so
|
|
18
|
+
a bar that needs to be taller — or a second bar, or a third — just works, and no
|
|
19
|
+
consuming app is reworked to allow it. The apps respond to what the bars need.
|
|
20
|
+
|
|
21
|
+
A server-rendered estimate (count × --studio-bar-unit) paints first so the
|
|
22
|
+
common case has no flash, then a ResizeObserver replaces it with the truth on
|
|
23
|
+
the same frame. An earlier cut declared a fixed bar height to avoid the
|
|
24
|
+
observer; that was rejected because it could only ever express the standard
|
|
25
|
+
case, which is the case that needed no help.
|
|
26
|
+
|
|
27
|
+
The property is published on :root from an inline <style>, deliberately. A
|
|
28
|
+
custom property set on this element would be invisible to the navbar, because
|
|
29
|
+
custom properties inherit DOWN, not ACROSS to siblings — and putting it on
|
|
30
|
+
<body> would need every host to change its layout.
|
|
31
|
+
|
|
32
|
+
Locals (all optional):
|
|
33
|
+
preview — true inside a navbar-preview render; renders nothing, so a
|
|
34
|
+
preview copy cannot duplicate live chrome or publish a height.
|
|
35
|
+
devnet — forwarded to the environment bar (chip / message segment).
|
|
36
|
+
extra — forwarded to the environment bar (message segments).
|
|
37
|
+
impersonated_user / admin_user / stop_path — render the impersonation bar
|
|
38
|
+
too; omit them and it is skipped.
|
|
39
|
+
%>
|
|
40
|
+
<%
|
|
41
|
+
preview = local_assigns.fetch(:preview, false)
|
|
42
|
+
|
|
43
|
+
impersonated_user = local_assigns[:impersonated_user]
|
|
44
|
+
admin_user = local_assigns[:admin_user]
|
|
45
|
+
stop_path = local_assigns[:stop_path]
|
|
46
|
+
show_impersonation = !preview && impersonated_user && admin_user && stop_path
|
|
47
|
+
|
|
48
|
+
show_environment = !preview && Studio.show_environment_banner?
|
|
49
|
+
|
|
50
|
+
bars = capture do
|
|
51
|
+
if show_environment
|
|
52
|
+
concat(render("studio/banners/environment",
|
|
53
|
+
devnet: local_assigns.fetch(:devnet, false),
|
|
54
|
+
extra: local_assigns.fetch(:extra, [])))
|
|
55
|
+
end
|
|
56
|
+
if show_impersonation
|
|
57
|
+
concat(render("studio/banners/impersonation",
|
|
58
|
+
impersonated_user: impersonated_user,
|
|
59
|
+
admin_user: admin_user,
|
|
60
|
+
stop_path: stop_path))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# count { } and not count(true): show_impersonation is a truthy USER, not the
|
|
65
|
+
# literal true, and count(true) compares with ==. That published a one-bar
|
|
66
|
+
# height for a two-bar stack — the navbar would have sat under a bar.
|
|
67
|
+
bar_count = [show_environment, show_impersonation].count { |bar| bar }
|
|
68
|
+
%>
|
|
69
|
+
<% if bar_count.positive? %>
|
|
70
|
+
<%# First-paint estimate only — the observer below replaces it with the measured
|
|
71
|
+
height. Expressed against a token so an app that retunes the unit still gets
|
|
72
|
+
a sensible pre-measurement value. %>
|
|
73
|
+
<%= tag.style safe_join([":root{--studio-bars-h:calc(#{bar_count} * var(--studio-bar-unit, 47px))}"]) %>
|
|
74
|
+
<div class="studio-bar-stack sticky top-0 z-[60] w-full" data-studio-bar-stack><%= bars %></div>
|
|
75
|
+
<script>
|
|
76
|
+
// Publishes the stack's REAL height, so the navbar offsets by what the bars
|
|
77
|
+
// actually need. Idempotent and re-bound on Turbo navigation — a stale
|
|
78
|
+
// observer pointed at a detached node silently stops updating, and the
|
|
79
|
+
// navbar would then sit at yesterday's offset.
|
|
80
|
+
(function () {
|
|
81
|
+
var apply = function () {
|
|
82
|
+
var el = document.querySelector("[data-studio-bar-stack]");
|
|
83
|
+
if (!el) {
|
|
84
|
+
document.documentElement.style.removeProperty("--studio-bars-h");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (window.__studioBarObserver) window.__studioBarObserver.disconnect();
|
|
88
|
+
var publish = function () {
|
|
89
|
+
var h = Math.round(el.getBoundingClientRect().height);
|
|
90
|
+
if (h > 0) document.documentElement.style.setProperty("--studio-bars-h", h + "px");
|
|
91
|
+
};
|
|
92
|
+
publish();
|
|
93
|
+
if (window.ResizeObserver) {
|
|
94
|
+
window.__studioBarObserver = new ResizeObserver(publish);
|
|
95
|
+
window.__studioBarObserver.observe(el);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
apply();
|
|
99
|
+
document.addEventListener("turbo:load", apply);
|
|
100
|
+
})();
|
|
101
|
+
</script>
|
|
102
|
+
<% end %>
|
data/lib/studio/engine.rb
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
|
+
require_relative "log_rotation"
|
|
2
|
+
|
|
1
3
|
module Studio
|
|
2
4
|
class Engine < ::Rails::Engine
|
|
5
|
+
# Cap the local (development + test) log files, for every host app and
|
|
6
|
+
# every future one, with nobody running anything.
|
|
7
|
+
#
|
|
8
|
+
# ORDERING IS THE WHOLE TRICK — do not demote this to a bare initializer.
|
|
9
|
+
# Rails' `:initialize_logger` is a BOOTSTRAP initializer, so it runs before
|
|
10
|
+
# every railtie/engine initializer and does `Rails.logger ||= config.logger
|
|
11
|
+
# || <default>`. An engine that assigns `app.config.logger` from an ordinary
|
|
12
|
+
# initializer is a silent NO-OP: Rails.logger is already built. Verified,
|
|
13
|
+
# not assumed — a probe boot kept Rails' 100 MB cap and its own file.
|
|
14
|
+
# So this hands Rails the size BEFORE Rails builds the logger, which is
|
|
15
|
+
# exactly the knob `:initialize_logger` reads:
|
|
16
|
+
# ActiveSupport::Logger.new(config.default_log_file, 1, config.log_file_size)
|
|
17
|
+
# and Rails keeps ownership of the path, formatter, level, and tagging.
|
|
18
|
+
#
|
|
19
|
+
# `after: :load_environment_hook` pins the lower edge of that window for two
|
|
20
|
+
# reasons: config/environments/*.rb is loaded by `:load_environment_config`
|
|
21
|
+
# (declared `before: :load_environment_hook`), so the host's own choices are
|
|
22
|
+
# already visible here; and it keeps Railtie's implicit `after: <previous
|
|
23
|
+
# initializer>` chaining from dragging `studio.assets` forward with us.
|
|
24
|
+
# WHICH cap (and whether to touch anything at all) is Studio::LogRotation's
|
|
25
|
+
# decision — Rails-free, and unit-tested a branch at a time.
|
|
26
|
+
#
|
|
27
|
+
# The host's escape hatch, `Studio.local_log_max_bytes`, is read here rather
|
|
28
|
+
# than from config.x.<key>: an unset config.x key returns an empty
|
|
29
|
+
# ActiveSupport::OrderedOptions, NOT nil, so `config.x.foo || default`
|
|
30
|
+
# silently assigns the OrderedOptions and rotation then dies inside a
|
|
31
|
+
# rescued comparison. The behavioral test caught that; a config read-back
|
|
32
|
+
# would have called it green.
|
|
33
|
+
initializer "studio.logger", before: :initialize_logger, after: :load_environment_hook do |app|
|
|
34
|
+
cap = Studio::LogRotation.cap_for(
|
|
35
|
+
env: Rails.env,
|
|
36
|
+
host_logger: app.config.logger,
|
|
37
|
+
override: Studio.local_log_max_bytes
|
|
38
|
+
)
|
|
39
|
+
app.config.log_file_size = cap if cap
|
|
40
|
+
end
|
|
41
|
+
|
|
3
42
|
initializer "studio.assets" do |app|
|
|
4
43
|
app.config.assets.precompile += %w[
|
|
5
44
|
studio/sticky_table_header.css
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The decision behind the engine's `studio.logger` initializer: given the
|
|
5
|
+
# environment and the host's own choices, how large may the local log file
|
|
6
|
+
# grow before it rotates — or should the engine keep its hands off entirely?
|
|
7
|
+
#
|
|
8
|
+
# Deliberately Rails-free so the whole decision table is unit-testable in
|
|
9
|
+
# milliseconds. The initializer in Studio::Engine owns the other half of the
|
|
10
|
+
# problem — running EARLY enough for the answer to matter — and that half is
|
|
11
|
+
# covered by test/integration/log_rotation_test.rb, which boots real apps and
|
|
12
|
+
# watches the file rotate on disk.
|
|
13
|
+
module LogRotation
|
|
14
|
+
# Deliberate values, not defaults. Rails' own `config.load_defaults "7.1"`
|
|
15
|
+
# sets log_file_size to 100 MB for development AND test, each keeping one
|
|
16
|
+
# rotated sibling — up to ~400 MB of log per checkout, times every worktree
|
|
17
|
+
# on the machine. 16 MB still holds a full day of local request logs; 8 MB
|
|
18
|
+
# covers a whole test run. Raising these is a decision, not a default.
|
|
19
|
+
DEVELOPMENT_MAX_BYTES = 16 * 1024 * 1024
|
|
20
|
+
TEST_MAX_BYTES = 8 * 1024 * 1024
|
|
21
|
+
|
|
22
|
+
# The environments whose logs are plain local files worth capping. Held here
|
|
23
|
+
# rather than delegating to Rails.env.local? so that a future Rails adding a
|
|
24
|
+
# third "local" environment cannot silently change where this engine writes
|
|
25
|
+
# a cap.
|
|
26
|
+
CAPPED_ENVIRONMENTS = %w[development test].freeze
|
|
27
|
+
|
|
28
|
+
# Returns the byte cap to apply, or nil for "leave the host's logging
|
|
29
|
+
# exactly as it is".
|
|
30
|
+
#
|
|
31
|
+
# env the Rails environment name
|
|
32
|
+
# host_logger the host's own config.logger, if it named one
|
|
33
|
+
# override Studio.local_log_max_bytes — false opts out, an Integer
|
|
34
|
+
# sets the host's own cap, nil means "use ours"
|
|
35
|
+
def self.cap_for(env:, host_logger: nil, override: nil)
|
|
36
|
+
# Production (and QA, which boots as production) hands its stream to the
|
|
37
|
+
# platform. Never point that at a file.
|
|
38
|
+
return nil unless CAPPED_ENVIRONMENTS.include?(env.to_s)
|
|
39
|
+
# A host that named its own logger has already decided.
|
|
40
|
+
return nil if host_logger
|
|
41
|
+
# Explicit opt-out; Rails' own default returns.
|
|
42
|
+
return nil if override == false
|
|
43
|
+
# Explicit host cap.
|
|
44
|
+
return override if override
|
|
45
|
+
|
|
46
|
+
env.to_s == "test" ? TEST_MAX_BYTES : DEVELOPMENT_MAX_BYTES
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "studio/version"
|
|
2
|
+
require "studio/log_rotation"
|
|
2
3
|
require "studio/engine"
|
|
3
4
|
require "studio/color_scale"
|
|
4
5
|
require "studio/environment_banner"
|
|
@@ -136,6 +137,18 @@ module Studio
|
|
|
136
137
|
mattr_accessor :impersonation_started_at_session_key, default: :impersonation_started_at
|
|
137
138
|
mattr_accessor :impersonation_max_minutes, default: 30
|
|
138
139
|
|
|
140
|
+
# Cap for the host app's local (development + test) log file, in bytes.
|
|
141
|
+
# nil means "use the engine's defaults" — Studio::Engine::DEVELOPMENT_LOG_MAX_BYTES
|
|
142
|
+
# and ::TEST_LOG_MAX_BYTES. An Integer sets your own cap; `false` opts out and
|
|
143
|
+
# leaves Rails' own 100 MB default alone.
|
|
144
|
+
#
|
|
145
|
+
# UNLIKE every other setting here, this one is read during BOOT — before
|
|
146
|
+
# config/initializers/studio.rb is loaded — so it must be set earlier than the
|
|
147
|
+
# usual seam: in config/application.rb (after `require "studio"`) or in
|
|
148
|
+
# config/environments/development.rb. Setting it in an initializer is too late
|
|
149
|
+
# and does nothing.
|
|
150
|
+
mattr_accessor :local_log_max_bytes, default: nil
|
|
151
|
+
|
|
139
152
|
# Default From: for engine-sent mail (magic links). Apps set this to their
|
|
140
153
|
# verified sending address in config/initializers/studio.rb.
|
|
141
154
|
mattr_accessor :mailer_from, default: nil
|
|
@@ -145,6 +158,36 @@ module Studio
|
|
|
145
158
|
# is truthy, otherwise disabled. Production always disables capture.
|
|
146
159
|
mattr_accessor :local_email_capture, default: nil
|
|
147
160
|
|
|
161
|
+
# The role Studio::LocalReviewsController stamps on the account it provisions
|
|
162
|
+
# for a local review (the board's WAITING APPROVAL button).
|
|
163
|
+
#
|
|
164
|
+
# It defaults to "admin" because the pages sent for review are overwhelmingly
|
|
165
|
+
# admin-gated, and the operator's address is his PRODUCTION one — an address a
|
|
166
|
+
# fresh worktree database has never seen, so the sign-in would otherwise CREATE
|
|
167
|
+
# him at the default role and `require_admin` would bounce him to "/". The
|
|
168
|
+
# sign-in succeeds and he never sees the page: the bug this knob exists to end.
|
|
169
|
+
#
|
|
170
|
+
# Set it to nil (or "") to provision the account WITHOUT touching its role —
|
|
171
|
+
# for an app whose review pages are not admin-gated, or whose role column
|
|
172
|
+
# means something else. Only ever reached behind local_tool_enabled?
|
|
173
|
+
# (non-production AND loopback), so it grants nothing a local reader could not
|
|
174
|
+
# already take from the local email inbox beside it.
|
|
175
|
+
mattr_accessor :local_review_role, default: "admin"
|
|
176
|
+
|
|
177
|
+
# WHO the local-review mint signs in when the caller names no `?email=`.
|
|
178
|
+
#
|
|
179
|
+
# The board's WAITING APPROVAL CTA is a public, sign-in-free redirect, so it
|
|
180
|
+
# sends no email — putting one in that URL would publish the operator's
|
|
181
|
+
# address on a public page. The local stack answers the question instead: it
|
|
182
|
+
# is the machine the reviewer is sitting at.
|
|
183
|
+
#
|
|
184
|
+
# nil (the default) means "derive": the first user in this database already
|
|
185
|
+
# holding local_review_role, by id — falling back to "admin" when that setting
|
|
186
|
+
# is itself nil. So a desk that switches local_review_role OFF has no role to
|
|
187
|
+
# derive FROM and should name its operator here explicitly, as should a desk
|
|
188
|
+
# whose operator is not the first seeded account at that role.
|
|
189
|
+
mattr_accessor :local_review_email, default: nil
|
|
190
|
+
|
|
148
191
|
# Theme role colors (7 roles)
|
|
149
192
|
mattr_accessor :theme_primary, default: "#8E82FE"
|
|
150
193
|
mattr_accessor :theme_dark, default: "#1A1535"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.36.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -266,6 +266,7 @@ files:
|
|
|
266
266
|
- app/views/studio/banners/_email_status_button.html.erb
|
|
267
267
|
- app/views/studio/banners/_environment.html.erb
|
|
268
268
|
- app/views/studio/banners/_impersonation.html.erb
|
|
269
|
+
- app/views/studio/banners/_stack.html.erb
|
|
269
270
|
- app/views/studio/board/_board.html.erb
|
|
270
271
|
- app/views/studio/board/_card_shell.html.erb
|
|
271
272
|
- app/views/studio/board/_column.html.erb
|
|
@@ -336,6 +337,7 @@ files:
|
|
|
336
337
|
- lib/studio/image_cache.rb
|
|
337
338
|
- lib/studio/link_resolution.rb
|
|
338
339
|
- lib/studio/link_token.rb
|
|
340
|
+
- lib/studio/log_rotation.rb
|
|
339
341
|
- lib/studio/mail_transport.rb
|
|
340
342
|
- lib/studio/redis.rb
|
|
341
343
|
- lib/studio/s3.rb
|