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 +4 -4
- data/CHANGELOG.md +98 -0
- data/README.md +2 -2
- data/app/assets/tailwind/studio_engine/engine.css +18 -0
- data/app/helpers/studio_sidebar_helper.rb +22 -0
- data/app/views/components/_link_sidebar.html.erb +179 -0
- data/app/views/components/_link_sidebar_trigger.html.erb +21 -0
- data/app/views/components/_sidebar_panel.html.erb +63 -0
- data/app/views/components/_user_nav.html.erb +13 -7
- data/app/views/layouts/_navbar.html.erb +29 -5
- data/app/views/navbar/show.html.erb +4 -1
- data/app/views/studio/banners/_button.html.erb +10 -2
- data/app/views/studio/banners/_email_status_button.html.erb +25 -6
- data/app/views/studio/banners/_environment.html.erb +38 -13
- data/lib/studio/environment_banner.rb +56 -0
- data/lib/studio/sidebar_sections.rb +34 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +61 -1
- data/studio-engine.gemspec +5 -5
- metadata +13 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: be8862879ffbd78bccc84e617beb1bae213e57c76c91d421b1b64d8e9f5122ff
|
|
4
|
+
data.tar.gz: 8e8fbc3e9f4db83b65f4693aec67f58f183363e873869a89513c62215422345c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/
|
|
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/
|
|
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">×</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
|
-
|
|
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
|
-
<%
|
|
72
|
-
<%# Div 2 (Smaller): wallet address (left) + level (right) with progress
|
|
73
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
<%
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
3
|
-
devnet
|
|
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
|
-
<%
|
|
7
|
-
<%
|
|
8
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
data/lib/studio/version.rb
CHANGED
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/
|
|
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
|
data/studio-engine.gemspec
CHANGED
|
@@ -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/
|
|
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/
|
|
16
|
-
"source_code_uri" => "https://github.com/
|
|
17
|
-
"bug_tracker_uri" => "https://github.com/
|
|
18
|
-
"changelog_uri" => "https://github.com/
|
|
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.
|
|
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-
|
|
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/
|
|
352
|
+
homepage: https://github.com/McRitchie-Studio/studio-engine
|
|
347
353
|
licenses:
|
|
348
354
|
- MIT
|
|
349
355
|
metadata:
|
|
350
|
-
homepage_uri: https://github.com/
|
|
351
|
-
source_code_uri: https://github.com/
|
|
352
|
-
bug_tracker_uri: https://github.com/
|
|
353
|
-
changelog_uri: https://github.com/
|
|
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:
|