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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e56dfd4be2d6f429f329626c4aebb8b22aa9e0069cfdf8905a02fab4bc47ab4c
4
- data.tar.gz: 71b6a6d3dafa57d3b25e9cb791049ae9b4a975efa7d14b08b961492b2b870d6f
3
+ metadata.gz: 8c692fc135af1ba83c08b27398c875bc547ae07bbcc492540ffd83250fccf680
4
+ data.tar.gz: 891c561fe2bef1386137af4fbbc22547f38613efe8bd373bf6ec31894086feef
5
5
  SHA512:
6
- metadata.gz: ea74d63cf741530df52e0fbe490a3fe48817845235f12aa911e094d67fe6012e3b9f1d139f95d8f5240d467fa079a2746f6a3985c256a3daee948136752e3c9c
7
- data.tar.gz: 75146aa7893ca074ada0c58b5ecfb20641615432c63090d3d8bd39bd0b6f6e480a6d7e558bbf45ac8003144a6fd224222fd7eeaac297571b522e255e43343472
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 — Unreleased
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 = Studio::LinkToken.normalize_email(params[: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
- class="<%= is_preview ? 'bg-page' : "#{'vt-pinned-header ' if pin_header}sticky top-0 z-50 bg-page transition-shadow duration-300" %>"
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, #9d174d 0%, #f72585 100%)",
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, #b91c1c 0%, #d97706 100%)",
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
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.32.3"
2
+ VERSION = "0.36.0"
3
3
  end
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.32.3
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