studio-engine 0.29.1 → 0.30.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: db4fb7663508ced21fce80ca29ec063c300c2826e0dadc511c266219303e449d
4
- data.tar.gz: b7e7c5cb133164b6d2a0521d4f08291540a78b4fb01a0be3b840f5413e35d8bb
3
+ metadata.gz: be8862879ffbd78bccc84e617beb1bae213e57c76c91d421b1b64d8e9f5122ff
4
+ data.tar.gz: 8e8fbc3e9f4db83b65f4693aec67f58f183363e873869a89513c62215422345c
5
5
  SHA512:
6
- metadata.gz: 328965e3979861ddc61b3c312a665b42b4e17d4d35a45851c3c76e9c538904e8096ba20725aa81fd45ba3e0509e8cf1fa764bbac70483a26afb5d8273bb43341
7
- data.tar.gz: c3323dfb24072f240935a1c3300dcf694d9c5ba22097fe2c1dc27ed04d0ee68cfb18df95844fe182e71622ccc6ba151dcb8ef3c5cc3488b5ac9959535e16c0c3
6
+ metadata.gz: ae3fee306fed9857d3421c005ac4006f46dda013d95009a0cde4daf6fc010a6c2d604e8ea173d7806985807e8b5603c57e6cef392a2b2fd00943d3db050d645d
7
+ data.tar.gz: b629a74f76f684338f82e028e6a5833dd15289720da55fea4f25e08afa1549454bed48df1131f1338a8ac28880e9e86b9201765ab4c7aac043510eefe7a70797
data/CHANGELOG.md CHANGED
@@ -2,6 +2,104 @@
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.30.0 — 2026-08-08
6
+
7
+ **The hub's link sidebar becomes the engine's out-of-the-box navigation.** New
8
+ apps get a default navigation surface without forking the navbar: declare
9
+ `Studio.sidebar_sections` (a static Array or a callable receiving the view
10
+ context; sections flagged `admin: true` show to admins only) and the engine
11
+ navbar mounts a trigger in its desktop icon rail and mobile sub-navbar, then
12
+ renders the slide-out link panels after the header. The family ships as
13
+ `components/_link_sidebar` (dual desktop/mobile panels + the engine-owned
14
+ Alpine `sidebars` store bridge, Turbo- and bfcache-safe), the generic
15
+ `components/_sidebar_panel` shell, and `components/_link_sidebar_trigger` —
16
+ all lifted from the mcritchie-studio hub. `engine.css` gains the
17
+ `studio-link-sidebar-layer` stacking utility and the Alpine `[x-cloak]`
18
+ pre-init rule. **Upgrade-safe by default:** `sidebar_sections` defaults to
19
+ `[]`, which renders nothing — existing consumers see zero change until they
20
+ opt in (pinned by `test/integration/sidebar_navbar_render_test.rb`). The
21
+ `html { overflow-x: clip }` slide guard ships inside the sidebar partial, not
22
+ globally. When the viewer's resolved sections carry an `admin: true` entry,
23
+ the sidebar replaces the engine admin dropdown (both use the cog glyph — one
24
+ gear, not two); public-only sections keep the dropdown. Resolution rules live
25
+ in `lib/studio/sidebar_sections.rb` (pure Ruby, unit-tested); docs in
26
+ `NEW_APP_SETUP.md` §4/§13 and `NAVBAR_SETUP.md`.
27
+
28
+ **Off-chain apps lose the user nav's dead space.** The default second row
29
+ (wallet address + level progress bar) renders only when it has something to
30
+ show — a wallet, a server level, or the `show_logout_link` link. A plain
31
+ app's signed-in nav collapses to one line instead of an empty progress strip.
32
+ The engine navbar's fixed-width `.user-nav-col` (reserved to seat a balance)
33
+ likewise applies only when `balance_html` is passed; without one the column
34
+ becomes `.user-nav-fit` (shrink-to-fit, `max-width` capped for username
35
+ truncation). turf-monster's dense nav is untouched: its users carry wallet +
36
+ level, and it ships its own navbar. Pinned by
37
+ `test/integration/user_nav_collapse_render_test.rb` and the user_nav view
38
+ tests.
39
+
40
+ **The dev/QA environment banner is now an engine standard a host adopts with ONE
41
+ render call, and it carries the Local Inbox link.** Every app hand-rolled its own
42
+ yellow "<Env> Environment" strip — this gem's own `NEW_APP_SETUP.md` § 9 shipped
43
+ the `<div>` to copy — so no two apps agreed on when it showed or what it said, and
44
+ none of them linked `/_studio/local_emails`. The engine already had the pieces
45
+ (`studio/banners/_environment` and friends); it just could not decide *whether* to
46
+ render, so no host could use it. It can now:
47
+
48
+ ```erb
49
+ <%= render "studio/banners/environment" %>
50
+ ```
51
+
52
+ No `unless Rails.env.production?` around it, no host `show_environment_banner?`,
53
+ no forked buttons. The partial owns all three decisions — whether it appears
54
+ (everywhere except real production; a QA app is Rails-production but a review
55
+ target, so `QA_ENV=true` re-opens it), what it says (`"QA Environment ·
56
+ Non-production"` on QA), and whether the inbox is linkable.
57
+
58
+ **The inbox link tells the truth.** `Studio.local_inbox_reachable?` consults the
59
+ SAME gate `Studio::LocalEmailsController` enforces, so the banner can never
60
+ advertise a page that answers 404. Where the viewer resolves (a developer desk)
61
+ you get a link; where it does not (QA — Rails-production, remote requests) the
62
+ button degrades to an inert `role="status"` chip that still reports the connector
63
+ and whether mail is sending or captured. This generalizes the degradation
64
+ turf-monster had already built for itself.
65
+
66
+ Purely additive. `Studio.local_tool_enabled?` and `Studio.local_email_capture?`
67
+ are **unchanged** — their production hard-close still stands, which is also why
68
+ `LOCAL_EMAIL_CAPTURE=1` on a QA dyno does nothing and QA still sends real mail.
69
+
70
+ ### Added
71
+
72
+ - **`studio/banners/environment` self-gates** and accepts `preview:` (suppress
73
+ inside a navbar-preview render — a duplicate `vt-pinned-header` silently kills
74
+ view transitions), `extra:` (message segments), `devnet:`, and
75
+ `environment_label:`.
76
+ - **`Studio::EnvironmentBanner`** — the show/label rules as dependency-free Ruby,
77
+ unit-tested directly so the suite exercises the shipped code, not a copy.
78
+ - **`Studio.qa_environment?` / `.show_environment_banner?` /
79
+ `.environment_banner_message` / `.local_inbox_reachable?` / `.rails_env_name`.**
80
+ `qa_environment?` reads the existing `QA_ENV` the release conductor already sets
81
+ on every QA app — no new variable.
82
+ - **`studio/banners/button` gained `as: :status`** — the same chrome, inert, for a
83
+ fact we can report but not navigate to.
84
+
85
+ ### Fixed
86
+
87
+ - **`studio/banners/button` dropped every attribute on a block render.**
88
+ `tag.span(hash) { }` reads the hash as CONTENT, so the options never reached the
89
+ tag. Splatted as keywords now. Latent until `as: :status` became the first block
90
+ path to use it.
91
+
92
+ ### Docs
93
+
94
+ - **`NEW_APP_SETUP.md` § 9 no longer ships a hand-rolled banner to copy** — it
95
+ renders the shared partial, and documents the QA/email reality.
96
+ - **`NEW_APP_SETUP.md` § 5 now installs the engine migrations FIRST**
97
+ (`bin/rails studio:install:migrations`). Omitting them is silent:
98
+ `Studio::Email.deliver` records mail only when `studio_email_deliveries` exists
99
+ and otherwise falls back to a plain `deliver_later`, so the app drops every
100
+ captured email and shows an empty inbox. Exactly the mcritchie-industries bug,
101
+ fixed 2026-08-08.
102
+
5
103
  ## 0.29.1 — 2026-07-29
6
104
 
7
105
  **`Studio::Board::Rankable#reposition!` is now atomic — a mid-loop failure can no
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Shared Rails engine for McRitchie apps. Provides authentication, error handling, dynamic theming, and common concerns used by [McRitchie Studio](https://app.mcritchie.studio) and [Turf Monster](https://app.turfmonster.media).
4
4
 
5
- > **Part of the McRitchie ecosystem** — see [`ECOSYSTEM.md`](https://github.com/amcritchie/mcritchie-studio/blob/main/docs/ECOSYSTEM.md) for the 5-repo map; [`house-burn-down.md`](https://github.com/amcritchie/mcritchie-studio/blob/main/docs/agents/system/house-burn-down.md) for fresh-Mac recovery.
5
+ > **Part of the McRitchie ecosystem** — see [`ECOSYSTEM.md`](https://github.com/McRitchie-Studio/mcritchie-studio/blob/main/docs/ECOSYSTEM.md) for the 5-repo map; [`house-burn-down.md`](https://github.com/McRitchie-Studio/mcritchie-studio/blob/main/docs/agents/system/house-burn-down.md) for fresh-Mac recovery.
6
6
 
7
7
  ## Installation
8
8
 
@@ -280,4 +280,4 @@ For short local experiments, temporarily point a consumer Gemfile at `path: "../
280
280
  Use the docs in [`docs/`](./docs) for engine setup, release, email transport,
281
281
  and host-app contracts. Current cross-repo setup, ports, credentials, and
282
282
  workflow guidance live in McRitchie Studio's
283
- [`docs/agents/`](https://github.com/amcritchie/mcritchie-studio/tree/main/docs/agents).
283
+ [`docs/agents/`](https://github.com/McRitchie-Studio/mcritchie-studio/tree/main/docs/agents).
@@ -181,6 +181,24 @@
181
181
  @apply px-8 py-3 text-base rounded-xl shadow-sm;
182
182
  }
183
183
 
184
+ /* -- Link sidebar (out-of-the-box navigation) ------------------------------
185
+ Paired with components/_link_sidebar + _link_sidebar_trigger and the
186
+ Studio.sidebar_sections config. The layer sits above the sticky header
187
+ (z-50) and the modal host. Inert without the panel in the DOM, so apps
188
+ that declare no sections are unaffected. */
189
+
190
+ @utility studio-link-sidebar-layer {
191
+ z-index: 10000;
192
+ }
193
+
194
+ /* Alpine's pre-init cloak. Engine partials (sidebar panels, dropdowns, the
195
+ modal host) mark themselves x-cloak so they stay hidden until Alpine boots
196
+ and x-show takes over. Engine-owned so every consumer gets the rule without
197
+ carrying the one-liner app-side; a duplicate app copy is harmless. */
198
+ [x-cloak] {
199
+ display: none !important;
200
+ }
201
+
184
202
  /* -- Smooth-load convention ------------------------------------------------
185
203
  Paired with layouts/studio/_smooth_load.html.erb (the Studio.smooth_load
186
204
  metas that opt Turbo into document.startViewTransition). These rules are
@@ -0,0 +1,22 @@
1
+ # The link-sidebar's view seam. Exposed to host views automatically (the
2
+ # engine is non-isolated), so the navbar and any host layout can gate on
3
+ # studio_sidebar? without wiring. Sections resolve once per render pass —
4
+ # the resolver may call a host lambda that walks models or routes.
5
+ module StudioSidebarHelper
6
+ def studio_sidebar_sections
7
+ @studio_sidebar_sections ||= Studio.sidebar_sections_for(self)
8
+ end
9
+
10
+ def studio_sidebar?
11
+ studio_sidebar_sections.any?
12
+ end
13
+
14
+ # The admin dropdown and the sidebar trigger share the cog glyph — showing
15
+ # both reads as a double gear. When the viewer's resolved sections carry an
16
+ # admin-flagged entry, the sidebar IS the admin menu (its title says so) and
17
+ # the engine components skip the dropdown. Declaring only public sections
18
+ # keeps the dropdown, so admins never lose Theme/Navbar/Error Logs.
19
+ def studio_sidebar_replaces_admin_menu?
20
+ studio_sidebar_sections.any? { |section| section[:admin] }
21
+ end
22
+ end
@@ -0,0 +1,179 @@
1
+ <%# The link sidebar — the engine's out-of-the-box navigation panel (lifted
2
+ from the mcritchie-studio hub). Renders the host's Studio.sidebar_sections
3
+ as a slide-out link tree in dual desktop/mobile panels, and carries its
4
+ own store bridge so a single render is self-sufficient. Render it once,
5
+ outside any sticky/backdrop-filter header (a fixed panel inside one
6
+ positions against the header, not the viewport); the engine navbar does
7
+ this automatically when sections are declared. %>
8
+ <% sections = studio_sidebar_sections %>
9
+ <% sidebar_admin = respond_to?(:admin?) && admin? %>
10
+ <% close_action = "$store.sidebars.linkTreeOpen = false" %>
11
+
12
+ <style>
13
+ /* Clip horizontal overflow so the off-canvas start/end frame of the slide
14
+ never flashes a scrollbar. `clip` (not `hidden`) does not create a scroll
15
+ container, so sticky headers keep working. Scoped here — not engine.css —
16
+ so apps that declare no sections get zero global CSS change. */
17
+ html { overflow-x: clip; }
18
+ </style>
19
+
20
+ <script>
21
+ // Store bridge (engine-owned): registers the Alpine `sidebars` store and
22
+ // keeps it healthy across Turbo navigations and bfcache restores. The
23
+ // panel's open/closed state is driven ENTIRELY by the store flag via
24
+ // x-show + x-transition — this bridge only flips the flag, never `display`
25
+ // directly, because an imperative display:none bakes an inline style that
26
+ // makes Alpine skip its enter transition (the panel pops instead of
27
+ // sliding). On turbo:before-cache / bfcache the flag drops so a restored
28
+ // page is closed. The document-level capture handlers make the trigger
29
+ // work even for clicks that land before Alpine initializes.
30
+ (function () {
31
+ function ensureLinkSidebarStore() {
32
+ if (!window.Alpine) return null;
33
+
34
+ var sidebars = Alpine.store('sidebars');
35
+ if (!sidebars) {
36
+ Alpine.store('sidebars', { linkTreeOpen: false });
37
+ return Alpine.store('sidebars');
38
+ }
39
+
40
+ if (typeof sidebars.linkTreeOpen === 'undefined') {
41
+ sidebars.linkTreeOpen = false;
42
+ }
43
+
44
+ return sidebars;
45
+ }
46
+
47
+ function setLinkSidebarOpen(open) {
48
+ var sidebars = ensureLinkSidebarStore();
49
+ if (sidebars) sidebars.linkTreeOpen = open;
50
+ }
51
+
52
+ function closeLinkSidebar() {
53
+ setLinkSidebarOpen(false);
54
+ }
55
+
56
+ function handleLinkSidebarClick(event) {
57
+ var closeTrigger = event.target.closest('[data-link-sidebar-close]');
58
+ if (closeTrigger) {
59
+ event.preventDefault();
60
+ event.stopPropagation();
61
+ event.stopImmediatePropagation();
62
+ closeLinkSidebar();
63
+ return;
64
+ }
65
+
66
+ var trigger = event.target.closest('[data-link-sidebar-trigger], [data-username-display], [data-profile-image-toggle]');
67
+ if (trigger && trigger.matches('[aria-controls~="studio-link-sidebar"]')) {
68
+ var sidebars = ensureLinkSidebarStore();
69
+ if (!sidebars) return;
70
+
71
+ event.preventDefault();
72
+ event.stopPropagation();
73
+ event.stopImmediatePropagation();
74
+ sidebars.linkTreeOpen = !sidebars.linkTreeOpen;
75
+ return;
76
+ }
77
+
78
+ var sidebars = ensureLinkSidebarStore();
79
+ if (!sidebars || !sidebars.linkTreeOpen) return;
80
+ if (event.target.closest('#studio-link-sidebar, #studio-link-sidebar-mobile')) return;
81
+
82
+ closeLinkSidebar();
83
+ }
84
+
85
+ function handleLinkSidebarKeydown(event) {
86
+ if (event.key === 'Escape') closeLinkSidebar();
87
+ }
88
+
89
+ if (!window.__studioLinkSidebarBridge) {
90
+ window.__studioLinkSidebarBridge = true;
91
+ document.addEventListener('alpine:init', ensureLinkSidebarStore);
92
+ document.addEventListener('turbo:load', ensureLinkSidebarStore);
93
+ document.addEventListener('turbo:before-cache', closeLinkSidebar);
94
+ window.addEventListener('pageshow', function (event) {
95
+ if (event.persisted) closeLinkSidebar();
96
+ });
97
+ document.addEventListener('click', handleLinkSidebarClick, true);
98
+ document.addEventListener('keydown', handleLinkSidebarKeydown, true);
99
+ }
100
+
101
+ ensureLinkSidebarStore();
102
+ })();
103
+ </script>
104
+
105
+ <% body_html = capture do %>
106
+ <nav class="flex-1 min-h-0 overflow-y-auto py-2" aria-label="Studio links">
107
+ <% sections.each do |section| %>
108
+ <div class="px-4 pt-4 pb-1 label-upper font-bold text-muted flex items-center gap-2">
109
+ <span><%= section[:title] %></span>
110
+ <% if section[:admin] %>
111
+ <span class="rounded border border-primary/20 bg-primary/10 px-1.5 py-0.5 text-[10px] font-mono text-primary">ADMIN</span>
112
+ <% end %>
113
+ </div>
114
+
115
+ <% section[:links].each do |link| %>
116
+ <% target = link[:target].presence %>
117
+ <%= link_to link[:href],
118
+ class: "group flex items-start gap-3 px-4 py-3 text-sm text-body hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-inset transition",
119
+ target: target,
120
+ rel: target == "_blank" ? "noopener" : nil,
121
+ "@click": close_action do %>
122
+ <% if link[:hover_emoji].present? %>
123
+ <%= render "components/emoji_swap",
124
+ base: link[:emoji],
125
+ hover: link[:hover_emoji],
126
+ class_name: "text-lg leading-none" %>
127
+ <% else %>
128
+ <span class="text-lg leading-none flex-shrink-0"><%= link[:emoji] %></span>
129
+ <% end %>
130
+ <span class="min-w-0">
131
+ <span class="block font-medium truncate"><%= link[:label] %></span>
132
+ <% if link[:desc].present? %>
133
+ <span class="block text-xs text-muted leading-snug mt-0.5"><%= link[:desc] %></span>
134
+ <% end %>
135
+ </span>
136
+ <% end %>
137
+ <% end %>
138
+ <% end %>
139
+ </nav>
140
+ <% end %>
141
+
142
+ <% footer_html = capture do %>
143
+ <% if logged_in? %>
144
+ <div class="border-t border-subtle p-2">
145
+ <%= link_to logout_path,
146
+ class: "flex items-center gap-3 rounded-md px-4 py-3 text-sm text-body hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 transition",
147
+ "@click": close_action do %>
148
+ <span>👋</span><span>Log out</span>
149
+ <% end %>
150
+ </div>
151
+ <% end %>
152
+ <% end %>
153
+
154
+ <%= render "components/sidebar_panel",
155
+ id: "studio-link-sidebar",
156
+ title: sidebar_admin ? "Admin Menu" : "Links",
157
+ open: "$store.sidebars.linkTreeOpen",
158
+ body_html: body_html,
159
+ footer_html: footer_html,
160
+ close_action: close_action,
161
+ outside_action: close_action,
162
+ escape_action: close_action,
163
+ display_class: "hidden md:flex",
164
+ role: "navigation",
165
+ aria_label: sidebar_admin ? "Admin menu" : "Links menu" %>
166
+
167
+ <%= render "components/sidebar_panel",
168
+ id: "studio-link-sidebar-mobile",
169
+ title: sidebar_admin ? "Admin Menu" : "Links",
170
+ open: "$store.sidebars.linkTreeOpen",
171
+ body_html: body_html,
172
+ footer_html: footer_html,
173
+ close_action: close_action,
174
+ outside_action: close_action,
175
+ escape_action: close_action,
176
+ display_class: "flex md:hidden",
177
+ width_class: "w-full",
178
+ role: "navigation",
179
+ aria_label: sidebar_admin ? "Admin menu" : "Links menu" %>
@@ -0,0 +1,21 @@
1
+ <%# Link-sidebar toggle button. `class_name` prepends responsive placement
2
+ classes (the navbar mounts one instance `hidden md:inline-flex` in the
3
+ desktop icon rail and one in the mobile sub-navbar row). The click is
4
+ handled twice on purpose: the Alpine @click for normal operation, and the
5
+ document-level capture bridge in _link_sidebar for pre-Alpine clicks. %>
6
+ <% extra_classes = local_assigns.fetch(:class_name, "") %>
7
+ <% sidebar_admin = respond_to?(:admin?) && admin? %>
8
+ <button type="button"
9
+ @click.stop="$store.sidebars.linkTreeOpen = !$store.sidebars.linkTreeOpen"
10
+ class="<%= "#{extra_classes} " if extra_classes.present? %>inline-flex h-9 w-9 items-center justify-center rounded-md text-secondary hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 transition"
11
+ title="<%= sidebar_admin ? 'Admin Menu' : 'Links' %>"
12
+ aria-label="<%= sidebar_admin ? 'Toggle admin menu' : 'Toggle links menu' %>"
13
+ aria-controls="studio-link-sidebar studio-link-sidebar-mobile"
14
+ aria-haspopup="dialog"
15
+ :aria-expanded="$store.sidebars && $store.sidebars.linkTreeOpen ? 'true' : 'false'"
16
+ data-link-sidebar-trigger="true">
17
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
18
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
19
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
20
+ </svg>
21
+ </button>
@@ -0,0 +1,63 @@
1
+ <%# Shared fixed right-sidebar shell (lifted from the mcritchie-studio hub).
2
+ Alpine-driven: `open` is an Alpine expression; the panel slides in from
3
+ off-screen right under x-transition and positions beneath the sticky
4
+ header via the --nav-h custom property _head.html.erb publishes. %>
5
+ <% id = local_assigns.fetch(:id, nil) %>
6
+ <% title = local_assigns.fetch(:title, nil) %>
7
+ <% open = local_assigns.fetch(:open) %>
8
+ <% actions_html = local_assigns.fetch(:actions_html, nil) %>
9
+ <% body_html = local_assigns.fetch(:body_html, nil) %>
10
+ <% footer_html = local_assigns.fetch(:footer_html, nil) %>
11
+ <% close_action = local_assigns.fetch(:close_action, nil) %>
12
+ <% outside_action = local_assigns.fetch(:outside_action, nil) %>
13
+ <% escape_action = local_assigns.fetch(:escape_action, nil) %>
14
+ <% display_class = local_assigns.fetch(:display_class, "hidden md:flex") %>
15
+ <% width_class = local_assigns.fetch(:width_class, "w-80 max-w-full") %>
16
+ <% z_class = local_assigns.fetch(:z_class, "studio-link-sidebar-layer") %>
17
+ <% panel_class = local_assigns.fetch(:panel_class, "") %>
18
+ <% role = local_assigns.fetch(:role, "complementary") %>
19
+ <% aria_label = local_assigns.fetch(:aria_label, title) %>
20
+ <% labelledby = id.present? && title.present? ? "#{id}-title" : nil %>
21
+
22
+ <aside
23
+ <% if id.present? %>id="<%= id %>"<% end %>
24
+ x-cloak
25
+ x-show="<%= open %>"
26
+ <%= %(#{outside_action.present? ? "@click.outside=\"#{outside_action}\"" : ""}).html_safe %>
27
+ <%= %(#{escape_action.present? ? "@keydown.escape.window=\"#{escape_action}\"" : ""}).html_safe %>
28
+ <%= %(#{close_action.present? ? "@turbo:before-cache.window=\"#{close_action}\"" : ""}).html_safe %>
29
+ x-transition:enter="transition ease-out duration-300"
30
+ x-transition:enter-start="translate-x-full"
31
+ x-transition:enter-end="translate-x-0"
32
+ x-transition:leave="transition ease-in duration-200"
33
+ x-transition:leave-start="translate-x-0"
34
+ x-transition:leave-end="translate-x-full"
35
+ class="<%= display_class %> fixed right-0 <%= width_class %> bg-surface border-l border-subtle shadow-2xl <%= z_class %> flex-col <%= panel_class %>"
36
+ style="top:var(--nav-h, 6rem);height:calc(100% - var(--nav-h, 6rem))"
37
+ role="<%= role %>"
38
+ <% if labelledby %>aria-labelledby="<%= labelledby %>"<% elsif aria_label.present? %>aria-label="<%= aria_label %>"<% end %>>
39
+ <div class="flex flex-col h-full">
40
+ <% if title.present? || actions_html.present? || close_action.present? %>
41
+ <div class="flex items-center justify-between p-4 border-b border-subtle">
42
+ <% if title.present? %>
43
+ <h3 <% if labelledby %>id="<%= labelledby %>"<% end %> class="text-lg font-bold text-heading"><%= title %></h3>
44
+ <% end %>
45
+ <div class="flex items-center gap-2 ml-auto">
46
+ <%= actions_html if actions_html.present? %>
47
+ <% if close_action.present? %>
48
+ <button type="button"
49
+ @click="<%= close_action %>"
50
+ class="flex items-center justify-center w-8 h-8 rounded-full text-secondary hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 transition"
51
+ aria-label="Close sidebar"
52
+ data-link-sidebar-close="true">
53
+ <span class="text-xl leading-none">&times;</span>
54
+ </button>
55
+ <% end %>
56
+ </div>
57
+ </div>
58
+ <% end %>
59
+
60
+ <%= body_html if body_html.present? %>
61
+ <%= footer_html if footer_html.present? %>
62
+ </div>
63
+ </aside>
@@ -56,7 +56,9 @@
56
56
  <%= studio_nav_slot.call(balance_slot, balance_html) %>
57
57
  <div class="flex items-center gap-2 ml-auto self-end">
58
58
  <%= studio_nav_slot.call(extra_icons_slot, extra_icons_html) %>
59
- <%= render "components/admin_dropdown" %>
59
+ <%# Skipped when the link sidebar carries the admin menu — two cog
60
+ glyphs side by side read as a double gear. %>
61
+ <%= render "components/admin_dropdown" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
60
62
  <%= render "components/theme_toggle_morph" %>
61
63
  </div>
62
64
  <% account_link = defined?(account_path) ? account_path : "#" %>
@@ -65,12 +67,16 @@
65
67
  <% end %>
66
68
  </div>
67
69
  <% div2_content = studio_nav_slot.call(div2_slot, div2_html) %>
70
+ <% server_level = current_user.respond_to?(:level) && current_user.level.present? ? current_user.level : nil %>
71
+ <% wallet_connected = current_user.respond_to?(:truncated_solana) && current_user.try(:solana_connected?) %>
68
72
  <% if div2_content.present? %>
69
73
  <%# Div 2 replaced by the consumer's slot (div2_slot or legacy div2_html). %>
70
74
  <%= div2_content %>
71
- <% else %>
72
- <%# Div 2 (Smaller): wallet address (left) + level (right) with progress bar %>
73
- <% server_level = current_user.respond_to?(:level) && current_user.level.present? ? current_user.level : nil %>
75
+ <% elsif server_level || wallet_connected || show_logout_link %>
76
+ <%# Div 2 (Smaller): wallet address (left) + level (right) with progress
77
+ bar. Renders only when it has something to show an off-chain,
78
+ level-less user without a logout link gets NO second row, so the
79
+ nav collapses to one line instead of an empty progress strip. %>
74
80
  <div x-data="{
75
81
  barWidth: 0,
76
82
  displayLevel: <%= server_level || 1 %>,
@@ -123,7 +129,7 @@
123
129
  <%# Text layer 1: muted (visible outside bar) %>
124
130
  <div class="absolute inset-0 flex items-center justify-between px-1.5 font-mono text-muted z-[1] nav-bar-text">
125
131
  <span>
126
- <% if current_user.respond_to?(:truncated_solana) && current_user.try(:solana_connected?) %>
132
+ <% if wallet_connected %>
127
133
  <%= current_user.truncated_solana %>
128
134
  <% end %>
129
135
  </span>
@@ -136,7 +142,7 @@
136
142
  <%# Text layer 2: white (clip-path reveals where bar covers) %>
137
143
  <div class="absolute inset-0 flex items-center justify-between px-1.5 font-mono text-white z-[2] nav-bar-text" :style="{ 'clip-path': 'inset(0 ' + (100 - barWidth) + '% 0 0)' }">
138
144
  <span>
139
- <% if current_user.respond_to?(:truncated_solana) && current_user.try(:solana_connected?) %>
145
+ <% if wallet_connected %>
140
146
  <%= current_user.truncated_solana %>
141
147
  <% end %>
142
148
  </span>
@@ -167,7 +173,7 @@
167
173
  <%= render "components/avatar", user: current_user, size: "nav" %>
168
174
  <% end %>
169
175
  <% else %>
170
- <%= render "components/admin_dropdown" %>
176
+ <%= render "components/admin_dropdown" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
171
177
  <%= render "components/theme_toggle_morph" %>
172
178
  <div class="flex items-center gap-3 ml-auto">
173
179
  <%= link_to "Log in", login_path, class: "text-heading hover:text-primary text-sm font-semibold transition" %>
@@ -19,6 +19,17 @@
19
19
  <% balance_html = local_assigns.fetch(:balance_html, nil) %>
20
20
  <% extra_icons_html = local_assigns.fetch(:extra_icons_html, nil) %>
21
21
  <% show_logout_link = local_assigns.fetch(:show_logout_link, false) %>
22
+ <%# Out-of-the-box navigation (engine 0.30): when the host declares
23
+ Studio.sidebar_sections, the navbar mounts the link-sidebar trigger in its
24
+ icon rails and renders the slide-out panels after the header. With no
25
+ sections (the default) all of this is skipped — the navbar is unchanged.
26
+ Preview renders skip it too: the /admin/navbar page renders this partial
27
+ repeatedly and the panels carry page-unique ids. %>
28
+ <% sidebar_active = !is_preview && respond_to?(:studio_sidebar?) && studio_sidebar? %>
29
+ <% if sidebar_active %>
30
+ <% sidebar_trigger_desktop = capture { render "components/link_sidebar_trigger", class_name: "hidden md:inline-flex" } %>
31
+ <% extra_icons_html = safe_join([extra_icons_html, sidebar_trigger_desktop].compact) %>
32
+ <% end %>
22
33
  <%
23
34
  words = Studio.app_name.split
24
35
  last_word = words.pop
@@ -31,9 +42,14 @@
31
42
  :class="scrolled && 'shadow-lg border-b border-subtle is-scrolled'">
32
43
  <style>
33
44
  .user-nav-col { width: 14rem; }
45
+ /* No balance content → no reserved column. The nav hugs its icons and
46
+ name instead of floating in the balance's empty seat. max-width steps
47
+ mirror the col's clamps: the item is flex-shrink-0, so without them a
48
+ long nowrap username sizes the column past a narrow viewport. */
49
+ .user-nav-fit { max-width: 14rem; }
34
50
  .nav-title { display: flex; gap: 0.25em; align-items: baseline; }
35
- @media (min-width: 400px) { .user-nav-col { width: 15rem; } }
36
- @media (min-width: 768px) { .user-nav-col { width: 20rem; } }
51
+ @media (min-width: 400px) { .user-nav-col { width: 15rem; } .user-nav-fit { max-width: 15rem; } }
52
+ @media (min-width: 768px) { .user-nav-col { width: 20rem; } .user-nav-fit { max-width: 20rem; } }
37
53
  @media (max-width: 767px) {
38
54
  .nav-title { font-size: 1.25rem !important; flex-direction: column; gap: 0; line-height: 1.15; transition: font-size 0.3s; }
39
55
  .nav-title span:first-child { margin-bottom: -4px; }
@@ -69,12 +85,15 @@
69
85
  </nav>
70
86
  </div>
71
87
  </div>
72
- <%# Right side: user nav (matches sidebar width) %>
73
- <div class="user-nav-col flex-shrink-0 pl-0 pr-4 md:px-4">
88
+ <%# Right side: user nav. The fixed column (matches sidebar width) exists
89
+ to seat a balance in the top row; with no balance content it shrinks
90
+ to fit. %>
91
+ <div class="<%= balance_html.present? ? 'user-nav-col' : 'user-nav-fit' %> flex-shrink-0 pl-0 pr-4 md:px-4">
74
92
  <% if show_user %>
75
93
  <%= render "components/user_nav", balance_html: balance_html, extra_icons_html: extra_icons_html, show_logout_link: show_logout_link %>
76
94
  <% else %>
77
95
  <div class="flex items-center justify-end gap-3">
96
+ <%= sidebar_trigger_desktop if sidebar_active %>
78
97
  <span class="hidden md:flex"><%= render "components/theme_toggle_morph" %></span>
79
98
  <%= link_to "Log in", login_path, class: "btn btn-primary" %>
80
99
  </div>
@@ -84,8 +103,13 @@
84
103
  <%# Mobile sub-navbar: compact row hidden on desktop — apps should override to add links %>
85
104
  <div class="flex md:hidden items-center gap-3 px-4 py-1.5 border-t border-subtle bg-surface-alt">
86
105
  <span class="ml-auto flex items-center gap-3">
87
- <%= render "components/admin_dropdown" %>
106
+ <%= render "components/link_sidebar_trigger" if sidebar_active %>
107
+ <%= render "components/admin_dropdown" unless sidebar_active && studio_sidebar_replaces_admin_menu? %>
88
108
  <%= render "components/theme_toggle_morph" %>
89
109
  </span>
90
110
  </div>
91
111
  </header>
112
+ <%# The panels mount OUTSIDE the header: the sticky header's backdrop-filter
113
+ creates a containing block, which would pin a fixed panel to the header
114
+ instead of the viewport. %>
115
+ <%= render "components/link_sidebar" if sidebar_active %>
@@ -15,7 +15,7 @@
15
15
  .navbar-preview.is-mobile .hidden.md\:flex { display: none !important; }
16
16
  .navbar-preview.is-mobile .hidden.md\:block { display: none !important; }
17
17
  .navbar-preview.is-mobile .flex.md\:hidden { display: flex !important; }
18
- .navbar-preview.is-mobile .user-nav-col { padding-left: 0 !important; padding-right: 1rem !important; }
18
+ .navbar-preview.is-mobile .user-nav-col, .navbar-preview.is-mobile .user-nav-fit { padding-left: 0 !important; padding-right: 1rem !important; }
19
19
  .navbar-preview.is-mobile .nav-title { flex-direction: column !important; gap: 0 !important; line-height: 1.15 !important; }
20
20
  .navbar-preview.is-mobile .nav-title span:first-child { margin-bottom: -4px !important; }
21
21
  .navbar-preview.is-mobile .nav-title span:last-child { font-size: 1.5rem !important; }
@@ -26,15 +26,18 @@
26
26
  .navbar-preview.bp-tiny .nav-title span:last-child { font-size: 1.3rem !important; }
27
27
  .navbar-preview.bp-tiny .nav-logo-link { gap: 0.25rem !important; }
28
28
  .navbar-preview.bp-tiny .user-nav-col { width: 14rem !important; }
29
+ .navbar-preview.bp-tiny .user-nav-fit { max-width: 14rem !important; }
29
30
  .navbar-preview.bp-tiny .username-cap { max-width: 5rem !important; }
30
31
 
31
32
  /* Small (400-767px) */
32
33
  .navbar-preview.bp-small .nav-title { font-size: 1.25rem !important; }
33
34
  .navbar-preview.bp-small .user-nav-col { width: 15rem !important; }
35
+ .navbar-preview.bp-small .user-nav-fit { max-width: 15rem !important; }
34
36
  .navbar-preview.bp-small .username-cap { max-width: 6rem !important; }
35
37
 
36
38
  /* Desktop (768px+) */
37
39
  .navbar-preview.is-desktop .user-nav-col { width: 20rem !important; }
40
+ .navbar-preview.is-desktop .user-nav-fit { max-width: 20rem !important; }
38
41
  .navbar-preview.is-desktop .username-cap { max-width: 7rem !important; }
39
42
 
40
43
  /* Transitions for scrolled toggle */
@@ -2,6 +2,8 @@
2
2
  label = local_assigns[:label]
3
3
  content = local_assigns[:content] || label
4
4
  href = local_assigns[:href]
5
+ # :link · :button · :status — a status chip carries the same chrome but is
6
+ # inert (no href, no click), for facts we can report but not navigate to.
5
7
  as = local_assigns.fetch(:as, href.present? ? :link : :button).to_sym
6
8
  method = local_assigns[:method]
7
9
  tooltip = local_assigns[:tooltip]
@@ -36,7 +38,7 @@
36
38
  "background:#{default_background}",
37
39
  "border:1px solid #{default_border}",
38
40
  "color:#{default_color}",
39
- "cursor:pointer",
41
+ "cursor:#{as == :status ? "default" : "pointer"}",
40
42
  "font:inherit",
41
43
  "font-weight:700",
42
44
  "text-decoration:none",
@@ -73,7 +75,13 @@
73
75
  end
74
76
  %>
75
77
 
76
- <% if as == :button %>
78
+ <%# `tag.span(hash) { }` would read the hash as CONTENT and drop every attribute
79
+ on the floor — the options must be splatted as keywords when a block is given. %>
80
+ <% if as == :status %>
81
+ <%= tag.span(**attrs.merge(role: "status", tabindex: "0")) do %>
82
+ <%= content %><%= tooltip_content if tooltip.present? %>
83
+ <% end %>
84
+ <% elsif as == :button %>
77
85
  <%= button_tag attrs.merge(type: "button") do %>
78
86
  <%= content %><%= tooltip_content if tooltip.present? %>
79
87
  <% end %>
@@ -1,4 +1,15 @@
1
- <% details = email_delivery_banner_details %>
1
+ <%
2
+ details = email_delivery_banner_details
3
+
4
+ # Link the inbox only where it actually answers. Studio::LocalEmailsController
5
+ # gates on the SAME call, so this can never advertise a 404: on QA (Rails
6
+ # production, remote requests) the button degrades to a non-link status chip
7
+ # that still reports the connector and whether mail is sending or captured.
8
+ inbox_reachable = Studio.local_inbox_reachable?(
9
+ request_local: (respond_to?(:request) && request ? request.local? : false)
10
+ )
11
+ tooltip = inbox_reachable ? details.fetch(:tooltip) : "#{details.fetch(:tooltip)} · Inbox: local only"
12
+ %>
2
13
 
3
14
  <% content = capture do %>
4
15
  <span>Email</span>
@@ -16,8 +27,16 @@
16
27
  <% end %>
17
28
  <% end %>
18
29
 
19
- <%= render "studio/banners/button",
20
- href: "/_studio/local_emails",
21
- label: "Email",
22
- content: content,
23
- tooltip: details.fetch(:tooltip) %>
30
+ <% if inbox_reachable %>
31
+ <%= render "studio/banners/button",
32
+ href: Studio::EnvironmentBanner::INBOX_PATH,
33
+ label: "Email",
34
+ content: content,
35
+ tooltip: tooltip %>
36
+ <% else %>
37
+ <%= render "studio/banners/button",
38
+ as: :status,
39
+ label: "Email",
40
+ content: content,
41
+ tooltip: tooltip %>
42
+ <% end %>
@@ -1,18 +1,43 @@
1
+ <%#
2
+ The ONE environment banner every Studio app renders. Host layouts render this
3
+ partial and nothing else — it decides FOR ITSELF whether to appear, what to
4
+ say, and whether the local inbox is linkable. See docs/NEW_APP_SETUP.md § 9.
5
+
6
+ Locals (all optional):
7
+ preview — true inside a navbar-preview render; suppresses the
8
+ banner so a preview copy can't duplicate live chrome.
9
+ environment_label — override the whole message (rare; prefer `extra`).
10
+ extra — extra message segments, joined with " · " (e.g. "Devnet").
11
+ devnet — renders a DEVNET chip beside the buttons.
12
+ %>
1
13
  <%
2
- environment_label = local_assigns.fetch(:environment_label, "#{Rails.env.capitalize} Environment")
3
- devnet = local_assigns.fetch(:devnet, false)
14
+ preview = local_assigns.fetch(:preview, false)
15
+ devnet = local_assigns.fetch(:devnet, false)
16
+ extra = Array(local_assigns.fetch(:extra, []))
17
+ qa_environment = Studio.qa_environment?
18
+
19
+ # On QA the devnet fact belongs in the message (the chip's contrast is built
20
+ # for the dev strip); everywhere else it stays a chip beside the buttons.
21
+ message_extra = devnet && qa_environment ? extra + ["Devnet"] : extra
22
+ show_devnet_chip = devnet && !qa_environment
23
+
24
+ environment_label = local_assigns.fetch(:environment_label) do
25
+ Studio.environment_banner_message(extra: message_extra)
26
+ end
4
27
  %>
5
28
 
6
- <% actions = capture do %>
7
- <% if devnet %>
8
- <span class="rounded" style="font-size:10px; font-weight:700; padding:1px 6px; background:rgba(0,0,0,0.2);">DEVNET</span>
29
+ <% if !preview && Studio.show_environment_banner? %>
30
+ <% actions = capture do %>
31
+ <% if show_devnet_chip %>
32
+ <span class="rounded" style="font-size:10px; font-weight:700; padding:1px 6px; background:rgba(0,0,0,0.2);">DEVNET</span>
33
+ <% end %>
34
+ <%= render "studio/banners/dev_mode_button" %>
35
+ <%= render "studio/banners/email_status_button" %>
9
36
  <% end %>
10
- <%= render "studio/banners/dev_mode_button" %>
11
- <%= render "studio/banners/email_status_button" %>
12
- <% end %>
13
37
 
14
- <%= render "studio/banners/app_banner",
15
- tone: :environment,
16
- density: :normal,
17
- message: environment_label,
18
- actions: actions %>
38
+ <%= render "studio/banners/app_banner",
39
+ tone: :environment,
40
+ density: :normal,
41
+ message: environment_label,
42
+ actions: actions %>
43
+ <% end %>
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The shared dev/QA environment banner's decisions, as pure Ruby.
5
+ #
6
+ # Every Studio app used to hand-roll its own yellow "<Env> Environment" strip,
7
+ # so no two agreed on when it appeared, what it said, or whether it linked the
8
+ # local email inbox. Those three rules live here and `studio/banners/_environment`
9
+ # renders them, so a host adopts the standard with one `render` call.
10
+ #
11
+ # Dependency-free on purpose: the unit suite requires this file directly, so
12
+ # the tests exercise the SHIPPED rules rather than a copy of them.
13
+ module EnvironmentBanner
14
+ QA_ENV_VAR = "QA_ENV"
15
+
16
+ # The engine's local email inbox (Studio::LocalEmailsController).
17
+ INBOX_PATH = "/_studio/local_emails"
18
+
19
+ module_function
20
+
21
+ # True for stable QA apps. They run Rails in production mode but are
22
+ # non-production REVIEW targets and must say so. The release conductor sets
23
+ # QA_ENV=true on every QA app (mcritchie-studio/config/qa_environments.yml).
24
+ def qa_environment?(env = ENV)
25
+ truthy?(env[QA_ENV_VAR])
26
+ end
27
+
28
+ # Show the banner in every environment EXCEPT real production. A QA app is
29
+ # Rails-production but not real production, so QA_ENV re-opens it there.
30
+ def show?(rails_env:, qa_environment: qa_environment?)
31
+ return true unless rails_env.to_s == "production"
32
+
33
+ qa_environment
34
+ end
35
+
36
+ # "Development Environment" · "QA Environment · Non-production", plus any
37
+ # app-supplied suffix ("Devnet", ...).
38
+ def message(rails_env:, qa_environment: qa_environment?, extra: [])
39
+ head = if qa_environment
40
+ ["QA Environment", "Non-production"]
41
+ else
42
+ ["#{rails_env.to_s.capitalize} Environment"]
43
+ end
44
+
45
+ (head + present_parts(extra)).join(" · ")
46
+ end
47
+
48
+ def present_parts(extra)
49
+ Array(extra).map(&:to_s).reject { |part| part.strip.empty? }
50
+ end
51
+
52
+ def truthy?(value)
53
+ %w[1 true yes on].include?(value.to_s.strip.downcase)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Resolves Studio.sidebar_sections — the host's declared link-sidebar data —
4
+ # for a given view context. Pure Ruby (no Rails dependency) so the unit suite
5
+ # exercises the resolution rules without booting the dummy app.
6
+ #
7
+ # Declared sections may be a static Array or a callable (receives the view
8
+ # context) for dynamic data: route helpers, logged_in? walls, model-backed
9
+ # link lists. Each section normalizes to symbol keys:
10
+ #
11
+ # { title: "Site", admin: true, links: [
12
+ # { label: "Dashboard", href: "/admin", emoji: "📊",
13
+ # hover_emoji: "🔬", desc: "Users + logs", target: "_blank" } ] }
14
+ #
15
+ # Sections flagged admin: true resolve only for admin? viewers, so the
16
+ # trigger and panel stay invisible to everyone else even when the host
17
+ # declares nothing but admin links.
18
+ module Studio
19
+ module SidebarSections
20
+ module_function
21
+
22
+ def resolve(declared, view)
23
+ sections = declared.respond_to?(:call) ? declared.call(view) : declared
24
+ admin = view.respond_to?(:admin?) && view.admin?
25
+ Array(sections).map { |section| symbolize(section) }
26
+ .reject { |section| section[:admin] && !admin }
27
+ .map { |section| section.merge(links: Array(section[:links]).map { |link| symbolize(link) }) }
28
+ end
29
+
30
+ def symbolize(hash)
31
+ hash.to_h.each_with_object({}) { |(key, value), out| out[key.to_sym] = value }
32
+ end
33
+ end
34
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.29.1"
2
+ VERSION = "0.30.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -1,8 +1,10 @@
1
1
  require "studio/version"
2
2
  require "studio/engine"
3
3
  require "studio/color_scale"
4
+ require "studio/environment_banner"
4
5
  require "studio/theme_resolver"
5
6
  require "studio/ui_primitives"
7
+ require "studio/sidebar_sections"
6
8
  require "studio/username_generator"
7
9
  require "studio/s3"
8
10
  require "studio/image_cache"
@@ -61,6 +63,23 @@ module Studio
61
63
  # feature off (e.g. McRitchie Studio, which ships neither).
62
64
  mattr_accessor :features, default: []
63
65
 
66
+ # ---- Sidebar navigation ----
67
+ # Out-of-the-box navigation: the engine navbar mounts a link-sidebar trigger
68
+ # and slide-out panel when the host declares sections here. The default []
69
+ # renders NOTHING, so existing consumers see no change on upgrade until they
70
+ # opt in. Accepts a static Array of section hashes or a callable (receives
71
+ # the view context) for dynamic sections — route helpers, logged_in? walls.
72
+ # Sections flagged admin: true render only for admin? viewers. Shape and
73
+ # resolution rules: lib/studio/sidebar_sections.rb.
74
+ #
75
+ # Studio.configure do |config|
76
+ # config.sidebar_sections = ->(view) {
77
+ # [ { title: "Site", links: [
78
+ # { label: "Home", href: view.root_path, emoji: "🏠" } ] } ]
79
+ # }
80
+ # end
81
+ mattr_accessor :sidebar_sections, default: []
82
+
64
83
  # Magic-link (passwordless email) tuning. token_name keys the MessageVerifier
65
84
  # purpose; bump it to invalidate every outstanding link. See MagicLink service.
66
85
  mattr_accessor :magic_link_ttl, default: 15.minutes
@@ -238,6 +257,40 @@ module Studio
238
257
  env_truthy?(ENV["LOCAL_EMAIL_CAPTURE"]) || env_truthy?(ENV["AGENT_WORKTREE"])
239
258
  end
240
259
 
260
+ # ---- Shared environment banner ------------------------------------------
261
+ # Rules live in Studio::EnvironmentBanner (pure Ruby, unit-tested); these are
262
+ # the Rails-aware entry points `studio/banners/_environment` calls. A host
263
+ # renders that ONE partial instead of hand-rolling its own strip.
264
+
265
+ # True for a stable QA app: Rails-production, but a non-production review
266
+ # target that must identify itself as one. Keyed off QA_ENV, the signal the
267
+ # release conductor already sets on every QA app.
268
+ def self.qa_environment?
269
+ EnvironmentBanner.qa_environment?
270
+ end
271
+
272
+ def self.show_environment_banner?(rails_env: rails_env_name)
273
+ EnvironmentBanner.show?(rails_env: rails_env, qa_environment: qa_environment?)
274
+ end
275
+
276
+ def self.environment_banner_message(rails_env: rails_env_name, extra: [])
277
+ EnvironmentBanner.message(rails_env: rails_env, qa_environment: qa_environment?, extra: extra)
278
+ end
279
+
280
+ # Whether the local email inbox is actually REACHABLE for this request, which
281
+ # is the only honest reason to render a link to it. Deliberately the same
282
+ # gate the controller enforces (local_tool_enabled?), so the banner can never
283
+ # advertise a page that answers 404 — QA gets a status chip instead.
284
+ def self.local_inbox_reachable?(request_local:)
285
+ local_tool_enabled?(request_local: request_local)
286
+ end
287
+
288
+ def self.rails_env_name
289
+ return "development" unless defined?(Rails) && Rails.respond_to?(:env)
290
+
291
+ Rails.env.to_s
292
+ end
293
+
241
294
  def self.user_wallet_address(user)
242
295
  return nil unless user
243
296
 
@@ -278,7 +331,7 @@ module Studio
278
331
 
279
332
  See the USER_CONTRACT.md doc in the studio-engine repo for the full
280
333
  contract + a minimal compliant example:
281
- https://github.com/amcritchie/studio-engine/blob/main/docs/USER_CONTRACT.md
334
+ https://github.com/McRitchie-Studio/studio-engine/blob/main/docs/USER_CONTRACT.md
282
335
 
283
336
  To bypass this check temporarily, set Studio.validate_user_contract = false
284
337
  in config/initializers/studio.rb.
@@ -309,6 +362,13 @@ module Studio
309
362
  entry ? "/#{entry[:file]}" : nil
310
363
  end
311
364
 
365
+ # Sidebar sections resolved for a view context: a callable config is called
366
+ # with the view, keys symbolize, and admin-only sections drop for non-admin
367
+ # viewers. Rendering gates on `.any?`, so [] keeps the navbar untouched.
368
+ def self.sidebar_sections_for(view)
369
+ SidebarSections.resolve(sidebar_sections, view)
370
+ end
371
+
312
372
  def self.env_truthy?(value)
313
373
  %w[1 true yes on].include?(value.to_s.strip.downcase)
314
374
  end
@@ -7,15 +7,15 @@ Gem::Specification.new do |spec|
7
7
  spec.email = ["studio-engine@mcritchie.studio"]
8
8
  spec.summary = "Shared Rails engine providing auth, SSO, error logging, theming, and S3-backed image caching"
9
9
  spec.description = "Studio Engine is a non-isolated Rails engine that ships an opinionated authentication + SSO contract, a polymorphic ErrorLog model, a Sluggable concern, a 7-role dynamic theme system with CSS-custom-property generation, and an S3-backed ImageCache. Used in production across the McRitchie Studio + Turf Monster apps."
10
- spec.homepage = "https://github.com/amcritchie/studio-engine"
10
+ spec.homepage = "https://github.com/McRitchie-Studio/studio-engine"
11
11
  spec.license = "MIT"
12
12
  spec.required_ruby_version = ">= 3.0"
13
13
 
14
14
  spec.metadata = {
15
- "homepage_uri" => "https://github.com/amcritchie/studio-engine",
16
- "source_code_uri" => "https://github.com/amcritchie/studio-engine/tree/main",
17
- "bug_tracker_uri" => "https://github.com/amcritchie/studio-engine/issues",
18
- "changelog_uri" => "https://github.com/amcritchie/studio-engine/blob/main/CHANGELOG.md"
15
+ "homepage_uri" => "https://github.com/McRitchie-Studio/studio-engine",
16
+ "source_code_uri" => "https://github.com/McRitchie-Studio/studio-engine/tree/main",
17
+ "bug_tracker_uri" => "https://github.com/McRitchie-Studio/studio-engine/issues",
18
+ "changelog_uri" => "https://github.com/McRitchie-Studio/studio-engine/blob/main/CHANGELOG.md"
19
19
  }
20
20
 
21
21
  spec.files = Dir["lib/**/*", "app/**/*", "config/**/*", "db/**/*", "tailwind/**/*", "Gemfile", "studio-engine.gemspec", "README.md", "CHANGELOG.md", "LICENSE"]
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.29.1
4
+ version: 0.30.0
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-07-31 00:00:00.000000000 Z
11
+ date: 2026-08-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -201,6 +201,7 @@ files:
201
201
  - app/controllers/theme_settings_controller.rb
202
202
  - app/helpers/studio/admin_models_table_helper.rb
203
203
  - app/helpers/studio_email_delivery_helper.rb
204
+ - app/helpers/studio_sidebar_helper.rb
204
205
  - app/helpers/studio_theme_helper.rb
205
206
  - app/jobs/error_log_cleanup_job.rb
206
207
  - app/jobs/studio/email_delivery_job.rb
@@ -232,7 +233,10 @@ files:
232
233
  - app/views/components/_google_logo.html.erb
233
234
  - app/views/components/_input.html.erb
234
235
  - app/views/components/_json_debug.html.erb
236
+ - app/views/components/_link_sidebar.html.erb
237
+ - app/views/components/_link_sidebar_trigger.html.erb
235
238
  - app/views/components/_progress_bar.html.erb
239
+ - app/views/components/_sidebar_panel.html.erb
236
240
  - app/views/components/_theme_toggle.html.erb
237
241
  - app/views/components/_theme_toggle_morph.html.erb
238
242
  - app/views/components/_user_nav.html.erb
@@ -330,11 +334,13 @@ files:
330
334
  - lib/studio/email.rb
331
335
  - lib/studio/email_smoke.rb
332
336
  - lib/studio/engine.rb
337
+ - lib/studio/environment_banner.rb
333
338
  - lib/studio/image_cache.rb
334
339
  - lib/studio/link_token.rb
335
340
  - lib/studio/mail_transport.rb
336
341
  - lib/studio/redis.rb
337
342
  - lib/studio/s3.rb
343
+ - lib/studio/sidebar_sections.rb
338
344
  - lib/studio/theme_resolver.rb
339
345
  - lib/studio/ui_primitives.rb
340
346
  - lib/studio/username_generator.rb
@@ -343,14 +349,14 @@ files:
343
349
  - lib/tasks/studio_ses.rake
344
350
  - studio-engine.gemspec
345
351
  - tailwind/studio.tailwind.config.js
346
- homepage: https://github.com/amcritchie/studio-engine
352
+ homepage: https://github.com/McRitchie-Studio/studio-engine
347
353
  licenses:
348
354
  - MIT
349
355
  metadata:
350
- homepage_uri: https://github.com/amcritchie/studio-engine
351
- source_code_uri: https://github.com/amcritchie/studio-engine/tree/main
352
- bug_tracker_uri: https://github.com/amcritchie/studio-engine/issues
353
- changelog_uri: https://github.com/amcritchie/studio-engine/blob/main/CHANGELOG.md
356
+ homepage_uri: https://github.com/McRitchie-Studio/studio-engine
357
+ source_code_uri: https://github.com/McRitchie-Studio/studio-engine/tree/main
358
+ bug_tracker_uri: https://github.com/McRitchie-Studio/studio-engine/issues
359
+ changelog_uri: https://github.com/McRitchie-Studio/studio-engine/blob/main/CHANGELOG.md
354
360
  post_install_message:
355
361
  rdoc_options: []
356
362
  require_paths: