studio-engine 0.77.4 → 0.78.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: 9497ed872cea58d146d2859f8220ad71e3979b2b1603fb263622081c29e3144e
4
- data.tar.gz: 7de61f4d57e71e09bf6d5fa32acd0309625e547d52b0d4bbfc45c94f7bd60328
3
+ metadata.gz: 12e1056052d4d49cd70e0ca81e8a39fc4f1c370166d73ea319315337fc648f5f
4
+ data.tar.gz: 0d94f90bff68c27a6c86d07ad53904b330a3717121f5f944244d0153a2e53a63
5
5
  SHA512:
6
- metadata.gz: d443cc7d3b914255adef0e681d926c4bb88c92dd1cbe4814195a5a6c55132e95e58550ddb14d6277b717bf6a7778823620c87ab7aa3bba0499373b0db33063b3
7
- data.tar.gz: e4e6df7c16f17a790b9058030d2f3b1b519ca882ac13069ea69f641b76974ca9fabd47a0de8ec89d9e8333dfbd428a2fdbdf46334c5d4e046ba4ba737ee2c2fe
6
+ metadata.gz: cb929bb288de471829b57f90cfbf01fff7d4e51d68607da51b32df832ad4946662a0617a85002ec6244c23fb3d3d17db069528b090690378ba68a7f53669141a
7
+ data.tar.gz: bb9eb9c389d8d89887ba72b0bff91b080944b501c3564aed06b26b317f013445f6fd9636d327ba20e70cfd7e4d3065928b6d091eed8c22160e6b819de69825ba
data/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.78.0 — 2026-09-29
8
+
9
+ ### Added
10
+
11
+ - **`Studio.navbar_links` puts app links in the engine navbar.** An Array, or a
12
+ callable receiving the view, of `{ label:, href:, active:, badge: }` renders in
13
+ the desktop bar and the phone row, with `aria-current` on the active link and
14
+ an optional badge such as `"#12"`. An app that registers nothing renders the
15
+ same navbar bytes as before. See the README, *Navigation*.
16
+
7
17
  ## 0.77.4 — 2026-09-28
8
18
 
9
19
  ### Fixed
data/README.md CHANGED
@@ -583,6 +583,34 @@ undefined, so mount `studio/modals/host` before writing a trigger.
583
583
  Full contract, including what the partial may assume: the doc comment at the top
584
584
  of `app/views/style/_host.html.erb`.
585
585
 
586
+ ### Navigation — `Studio.sidebar_sections` and `Studio.navbar_links`
587
+
588
+ Two seams, both empty by default, so the navbar is unchanged until an app opts in.
589
+
590
+ - `sidebar_sections` fills the slide-out link sidebar (the cog trigger in the icon
591
+ rail). Rules: `lib/studio/sidebar_sections.rb`.
592
+ - `navbar_links` fills the navbar's own link slots: the desktop bar beside the
593
+ logo and the phone row under it. Rules: `lib/studio/navbar_links.rb`.
594
+
595
+ ```ruby
596
+ Studio.configure do |config|
597
+ # An Array, or a callable receiving the view (so a badge can read current_user).
598
+ config.navbar_links = ->(view) {
599
+ [ { label: "Contests", href: view.contests_path, active: %r{\A/contests} },
600
+ { label: "Rank", href: "/rank",
601
+ badge: (view.logged_in? ? "##{view.current_user.rank}" : nil) } ]
602
+ }
603
+ end
604
+ ```
605
+
606
+ - `label:` and `href:` are required; both are escaped.
607
+ - `active:` is `true`/`false`, a Regexp, or a callable taking `request.path`.
608
+ Omitted, a link is active when the path equals its href's path. The active
609
+ link gets `aria-current="page"` and the primary tone.
610
+ - `badge:` is optional short text, such as `"#12"`, shown as a pill in the link.
611
+ - A bad entry raises `Studio::NavbarLinks::InvalidLink` naming its index: at
612
+ assignment for a static Array, at render for a callable.
613
+
586
614
  ### User nav slots
587
615
 
588
616
  `components/_user_nav.html.erb` renders the right-side navbar user section.
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The navbar-links view seam. Links resolve once per render pass, since the
4
+ # host lambda may read current_user or the database.
5
+ module StudioNavbarHelper
6
+ NAVBAR_LINK_CLASS = "inline-flex items-center gap-1.5 whitespace-nowrap font-medium transition"
7
+ NAVBAR_BADGE_CLASS = "rounded-full border border-subtle px-1.5 py-0.5 text-[10px] leading-none " \
8
+ "font-semibold text-muted tabular-nums"
9
+
10
+ def studio_navbar_links
11
+ @studio_navbar_links ||= Studio.navbar_links_for(self)
12
+ end
13
+
14
+ # One resolved link. size is the text utility: text-sm on desktop, text-xs
15
+ # in the phone row, matching the links apps hand-wrote in forked navbars.
16
+ def studio_navbar_link_tag(link, size:)
17
+ tone = link[:active] ? "text-primary" : "text-secondary hover:text-primary"
18
+ link_to link[:href], class: "#{NAVBAR_LINK_CLASS} #{size} #{tone}",
19
+ aria: { current: ("page" if link[:active]) } do
20
+ badge = content_tag(:span, link[:badge], class: NAVBAR_BADGE_CLASS) if link[:badge]
21
+ safe_join([link[:label], badge].compact)
22
+ end
23
+ end
24
+ end
@@ -26,6 +26,9 @@
26
26
  Preview renders skip it too: the /admin/navbar page renders this partial
27
27
  repeatedly and the panels carry page-unique ids. %>
28
28
  <% sidebar_active = !is_preview && respond_to?(:studio_sidebar?) && studio_sidebar? %>
29
+ <%# Studio.navbar_links: links for the desktop bar and phone row.
30
+ Empty by default, which renders the same bytes as before. %>
31
+ <% navbar_links = respond_to?(:studio_navbar_links) ? studio_navbar_links : [] %>
29
32
  <% if sidebar_active %>
30
33
  <% sidebar_trigger_desktop = capture { render "components/link_sidebar_trigger", class_name: "hidden md:inline-flex" } %>
31
34
  <% extra_icons_html = safe_join([extra_icons_html, sidebar_trigger_desktop].compact) %>
@@ -141,8 +144,11 @@
141
144
  spans drawing outside it, which is the bug rather than the fix. %>
142
145
  <h1 class="nav-title font-extrabold text-heading tracking-tight min-w-0"><span class="truncate"><%= prefix %></span><span class="text-primary truncate"><%= last_word %></span></h1>
143
146
  <% end %>
144
- <%# Desktop nav — apps should override this partial to add links here %>
145
- <nav class="hidden md:flex items-center gap-4">
147
+ <%# Desktop nav — Studio.navbar_links %>
148
+ <nav class="hidden md:flex items-center gap-4"<%= ' aria-label="Main"'.html_safe if navbar_links.any? %>>
149
+ <% navbar_links.each do |link| %>
150
+ <%= studio_navbar_link_tag(link, size: "text-sm") %>
151
+ <% end %>
146
152
  </nav>
147
153
  </div>
148
154
  </div>
@@ -161,8 +167,15 @@
161
167
  <% end %>
162
168
  </div>
163
169
  </div>
164
- <%# Mobile sub-navbar: compact row hidden on desktop — apps should override to add links %>
170
+ <%# Mobile sub-navbar: compact row hidden on desktop — Studio.navbar_links on the left %>
165
171
  <div class="flex md:hidden items-center gap-3 px-4 py-1.5 border-t border-subtle bg-surface-alt">
172
+ <% if navbar_links.any? %>
173
+ <nav aria-label="Main" class="flex items-center gap-3 overflow-x-auto">
174
+ <% navbar_links.each do |link| %>
175
+ <%= studio_navbar_link_tag(link, size: "text-xs") %>
176
+ <% end %>
177
+ </nav>
178
+ <% end %>
166
179
  <span class="ml-auto flex items-center gap-3">
167
180
  <%= render "components/link_sidebar_trigger" if sidebar_active %>
168
181
  <%= render "components/admin_dropdown" unless sidebar_active && studio_sidebar_replaces_admin_menu? %>
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Resolves Studio.navbar_links, the links the engine navbar renders in its
4
+ # desktop bar and phone row. Pure Ruby, so the unit suite covers it without
5
+ # the dummy app.
6
+ #
7
+ # Declared as an Array or a callable (receives the view context, so a badge can
8
+ # read current_user). Each entry resolves to:
9
+ #
10
+ # { label: "Contests", href: "/contests", active: true, badge: "#12" }
11
+ #
12
+ # `active` may be true/false, a Regexp or a callable matched against
13
+ # request.path; omitted, it is true when the path equals the href's path.
14
+ # `badge` is optional short text. A bad entry raises InvalidLink by index.
15
+ module Studio
16
+ module NavbarLinks
17
+ class InvalidLink < ArgumentError; end
18
+
19
+ KEYS = %i[label href active badge].freeze
20
+
21
+ module_function
22
+
23
+ def resolve(declared, view)
24
+ links = declared.respond_to?(:call) ? declared.call(view) : declared
25
+ path = request_path(view)
26
+ entries(links).each_with_index.map do |link, index|
27
+ link = check!(link, index)
28
+ { label: link[:label].to_s, href: link[:href].to_s,
29
+ active: active?(link, path), badge: badge(link[:badge]) }
30
+ end
31
+ end
32
+
33
+ # Shape check without a request: a static list fails at boot, not on the
34
+ # first page render. A callable is checked when it resolves.
35
+ def validate!(declared)
36
+ return nil if declared.respond_to?(:call)
37
+
38
+ entries(declared).each_with_index { |link, index| check!(link, index) }
39
+ nil
40
+ end
41
+
42
+ def entries(links)
43
+ return [] if links.nil?
44
+ return links if links.is_a?(Array)
45
+
46
+ raise InvalidLink, "Studio.navbar_links must be an Array or a callable returning an Array " \
47
+ "(got #{links.class})"
48
+ end
49
+
50
+ def check!(link, index)
51
+ where = "Studio.navbar_links[#{index}]"
52
+ raise InvalidLink, "#{where} must be a Hash (got #{link.class})" unless link.is_a?(Hash)
53
+
54
+ link = link.to_h { |key, value| [key.to_sym, value] }
55
+ unknown = link.keys - KEYS
56
+ raise InvalidLink, "#{where} has unknown key :#{unknown.first}" if unknown.any?
57
+
58
+ %i[label href].each do |key|
59
+ raise InvalidLink, "#{where} needs a :#{key}" if link[key].to_s.strip.empty?
60
+ end
61
+ unless [nil, true, false].include?(link[:active]) || link[:active].is_a?(Regexp) ||
62
+ link[:active].respond_to?(:call)
63
+ raise InvalidLink, "#{where} :active must be true, false, a Regexp or a callable"
64
+ end
65
+ unless link[:badge].nil? || [String, Symbol, Numeric].any? { |type| link[:badge].is_a?(type) }
66
+ raise InvalidLink, "#{where} :badge must be short text"
67
+ end
68
+
69
+ link
70
+ end
71
+
72
+ def active?(link, path)
73
+ case (rule = link[:active])
74
+ when true, false then rule
75
+ when nil then !path.nil? && path == link[:href].to_s.split(/[?#]/).first
76
+ when Regexp then !path.nil? && rule.match?(path)
77
+ else !path.nil? && rule.call(path) ? true : false
78
+ end
79
+ end
80
+
81
+ def badge(value)
82
+ text = value.to_s.strip
83
+ text.empty? ? nil : text
84
+ end
85
+
86
+ def request_path(view)
87
+ return nil unless view.respond_to?(:request)
88
+
89
+ view.request&.path
90
+ end
91
+ end
92
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.77.4"
2
+ VERSION = "0.78.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -12,6 +12,7 @@ require "studio/js_literal"
12
12
  require "studio/js_identifier"
13
13
  require "studio/partial_block"
14
14
  require "studio/sidebar_sections"
15
+ require "studio/navbar_links"
15
16
  require "studio/profile_sections"
16
17
  require "studio/profile_image"
17
18
  require "studio/oauth_identity"
@@ -174,6 +175,23 @@ module Studio
174
175
  # end
175
176
  mattr_accessor :sidebar_sections, default: []
176
177
 
178
+ # ---- Navbar links ----
179
+ # Links for the engine navbar's desktop bar and phone row. The default []
180
+ # renders nothing, so the navbar stays byte-identical until an app opts in.
181
+ # An Array, or a callable receiving the view, of
182
+ # { label:, href:, active: (bool, Regexp or ->(path)), badge: (optional) }.
183
+ # Rules: lib/studio/navbar_links.rb.
184
+ #
185
+ # config.navbar_links = ->(view) {
186
+ # [ { label: "Contests", href: view.contests_path, active: %r{\A/contests} },
187
+ # { label: "Rank", href: "/rank", badge: ("#12" if view.logged_in?) } ] }
188
+ mattr_reader :navbar_links, default: []
189
+
190
+ def self.navbar_links=(links)
191
+ NavbarLinks.validate!(links)
192
+ @@navbar_links = links
193
+ end
194
+
177
195
  # ---- The shared profile page (/profile) ----
178
196
  # The rows that make up /profile. `nil` — the default — means "the engine's
179
197
  # standard page", NOT "no rows": a brand-new app with an empty initializer gets
@@ -877,6 +895,11 @@ module Studio
877
895
  SidebarSections.resolve(sidebar_sections, view)
878
896
  end
879
897
 
898
+ # Navbar links resolved for a view context (lib/studio/navbar_links.rb).
899
+ def self.navbar_links_for(view)
900
+ NavbarLinks.resolve(navbar_links, view)
901
+ end
902
+
880
903
  # The engine's standard /profile rows. Hosts compose against this rather than
881
904
  # restating a literal list, so a later release that adds a standard row
882
905
  # delivers it to every app that used the seam as intended.
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.77.4
4
+ version: 0.78.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-09-28 00:00:00.000000000 Z
11
+ date: 2026-09-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -345,6 +345,7 @@ files:
345
345
  - app/helpers/studio/fizz_helper.rb
346
346
  - app/helpers/studio/geo_helper.rb
347
347
  - app/helpers/studio_email_delivery_helper.rb
348
+ - app/helpers/studio_navbar_helper.rb
348
349
  - app/helpers/studio_sidebar_helper.rb
349
350
  - app/helpers/studio_theme_helper.rb
350
351
  - app/jobs/error_log_cleanup_job.rb
@@ -572,6 +573,7 @@ files:
572
573
  - lib/studio/log_rotation.rb
573
574
  - lib/studio/mail_transport.rb
574
575
  - lib/studio/name_parts.rb
576
+ - lib/studio/navbar_links.rb
575
577
  - lib/studio/newsletter.rb
576
578
  - lib/studio/oauth_identity.rb
577
579
  - lib/studio/partial_block.rb