studio-engine 0.112.0 → 0.113.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: 363034059fcc4f430f765ade1c0c53516daa1fbcbe5842863fcd9962bc59496a
4
- data.tar.gz: a6e1cea97e9b160a900d9e64f9849977b2fdf6cc1c0c031b649cc6f119eb4f2f
3
+ metadata.gz: cb5c08a1649212ad529b140ab81676b78153ff763cd1d911afd4c8a1669b2258
4
+ data.tar.gz: ac8fe2cdd5e5602ece04d0d521c154ec527b21a82fc24fbe1d30c13c3173d00f
5
5
  SHA512:
6
- metadata.gz: 623438802be9c44991d5dda895e0a5f2135e33fd508a0d02a1d9f9f0a060d51c965f89c46992e6c509300e7eeb93e6a2e2ab070dd62e1050923e75d0726a8c16
7
- data.tar.gz: 2b995afde93666a8d6feb542b99f36bbb38830f3868e7874c285fec32d7bb171b3c2b425e5e9d42a2ab32d5a181a75ce1a130ebfb4cda048f0bc3e34622d2cc3
6
+ metadata.gz: f2281947106e13950178c156a5904b56361cb0d3c3dafdaac6bf938fa6c3f23ea42a425380d1b403a29324812395befe9fa36b5c30384a10dc2a31ec5e124011
7
+ data.tar.gz: 56bf5a1799f1ac520c0b8497e1c29ba0f052ef5c9f466aaefeca77ee87c0e554913198a2918946e06ab415699b4af5eb83e1372bcb7f2afcc202b227491b21a1
data/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.113.0 — 2026-10-24
8
+
9
+ ### Added
10
+
11
+ - **`Studio.navbar_items` and `Studio.navbar_avatar_badge`: partial slots in
12
+ the engine navbar.** `navbar_items` is a list of partials (a path, or
13
+ `{ partial:, locals: }`) the navbar renders after `navbar_links` in both the
14
+ desktop `<nav>` and the phone row, for what an app shows in the bar that is
15
+ not a link, such as Turf Monster's geo badge and wallet signal.
16
+ `navbar_avatar_badge` is one partial the signed-in user nav draws over the
17
+ avatar's upper-left corner, such as Turf Monster's free-entry sparkle. Its
18
+ seat is the account control's next sibling, so the control stays one tab stop
19
+ and the badge may carry its own button, and `engine.css` (*THE AVATAR BADGE*)
20
+ places it out of flow over the avatar, so the row gives it no width; it
21
+ tracks the one-row 40px avatar and scales with it on the scroll timeline.
22
+ Either setting may be a callable receiving the view. Unset (the defaults)
23
+ the navbar renders byte-identical to before, signed in and out. A bad static
24
+ setting raises `Studio::NavbarSlots::InvalidConfig` at assignment; a raising
25
+ callable renders nothing and is reported once. README, *Navbar items* and
26
+ *Avatar badge*.
27
+
7
28
  ## 0.112.0 — 2026-10-19
8
29
 
9
30
  ### Added
data/README.md CHANGED
@@ -766,6 +766,62 @@ end
766
766
  - A host on an older engine can set it safely with
767
767
  `Studio.navbar_signed_out_cta = … if Studio.respond_to?(:navbar_signed_out_cta=)`.
768
768
 
769
+ ### Navbar items — `Studio.navbar_items`
770
+
771
+ Partials the navbar renders after its links, for the things an app shows in
772
+ the bar that are not links: a location badge, a wallet signal. Empty (`[]`, the
773
+ default), the navbar renders the same bytes as before. Rules:
774
+ `lib/studio/navbar_slots.rb`.
775
+
776
+ ```ruby
777
+ Studio.configure do |config|
778
+ # A partial path, or { partial:, locals: }.
779
+ config.navbar_items = ["components/geo_badge",
780
+ { partial: "shared/wallet_signal", locals: { tone: "muted" } }]
781
+ # or a callable receiving the view; nil renders none for that render
782
+ # config.navbar_items = ->(view) { view.logged_in? ? ["shared/wallet_signal"] : [] }
783
+ end
784
+ ```
785
+
786
+ - Each item renders in BOTH rows: inside the desktop `<nav>` after the links
787
+ (from `md`), and in the phone row after its links (below `md`). So every item
788
+ is on the page twice; give its markup no `id`, and let it size itself for
789
+ either row.
790
+ - Items render signed in and signed out, and in `preview: true` renders, as the
791
+ links do.
792
+ - A bad static list raises `Studio::NavbarSlots::InvalidConfig` at assignment,
793
+ naming its index; a callable's bad answer raises it at render. A callable
794
+ that raises renders no items and is reported once per process per error
795
+ class, never a 500.
796
+ - A host on an older engine can set it safely with
797
+ `Studio.navbar_items = … if Studio.respond_to?(:navbar_items=)`.
798
+
799
+ ### Avatar badge — `Studio.navbar_avatar_badge`
800
+
801
+ One partial the signed-in user nav draws over the avatar's upper-left corner,
802
+ the way a notification dot rides a profile picture. `nil`, the default, renders
803
+ the user nav byte-identical to before. Rules: `lib/studio/navbar_slots.rb`.
804
+
805
+ ```ruby
806
+ Studio.configure do |config|
807
+ config.navbar_avatar_badge = "components/entry_token_badge"
808
+ # or { partial:, locals: }, or a callable receiving the view (nil for no badge)
809
+ end
810
+ ```
811
+
812
+ - The badge renders in a `[data-nav-avatar-badge]` seat right after the
813
+ account control, never inside it: the name and avatar stay one control and
814
+ one tab stop, and the badge may carry its own button.
815
+ - `engine.css` (*THE AVATAR BADGE*) seats it out of flow, so the row gives it
816
+ no width: an avatar-sized box over the avatar, z-index 20, the badge's root
817
+ 8px left and 4px up from the avatar's corner. The seat tracks the one-row
818
+ 40px avatar and scales with it on the scroll timeline. Only the badge takes
819
+ the pointer; the rest of the avatar still opens the account.
820
+ - Signed out it never renders. Validation and the raising-callable fallback
821
+ are `navbar_items`'s.
822
+ - A host on an older engine can set it safely with
823
+ `Studio.navbar_avatar_badge = … if Studio.respond_to?(:navbar_avatar_badge=)`.
824
+
769
825
  ### User nav slots
770
826
 
771
827
  `components/_user_nav.html.erb` renders the right-side navbar user section.
@@ -698,6 +698,32 @@ body.modal-open .studio-app-banner {
698
698
  }
699
699
  }
700
700
 
701
+ /* THE AVATAR BADGE (Studio.navbar_avatar_badge, components/_user_nav).
702
+ The seat is the account control's next sibling, and the account control
703
+ ends the user nav's flex row with the avatar at its right edge. So the
704
+ seat is an avatar-sized box pinned to the row's right edge and centred
705
+ on it, exactly over the avatar, and the badge rides its upper-left
706
+ corner, 8px out and 4px up. Absolute, so the row gives it no width and
707
+ no gap. z-index 20 lifts it over the avatar. The seat lets the pointer
708
+ through to the avatar under it; only the badge itself takes it. */
709
+ :has(> [data-nav-avatar-badge]) { position: relative; }
710
+ [data-nav-avatar-badge] {
711
+ position: absolute;
712
+ top: 0;
713
+ bottom: 0;
714
+ right: 0;
715
+ margin-block: auto;
716
+ width: 2rem;
717
+ height: 2rem;
718
+ z-index: 20;
719
+ pointer-events: none;
720
+ }
721
+ [data-nav-avatar-badge] > * {
722
+ position: absolute;
723
+ left: -0.5rem;
724
+ top: -0.25rem;
725
+ pointer-events: auto;
726
+ }
701
727
  /* THE ACCOUNT ROW (components/_user_nav), from 768px. Every app whose
702
728
  navbar renders the engine's user nav gets this; Turf Monster forks the
703
729
  partial, carries none of these hooks, and keeps its own two-row nav.
@@ -748,6 +774,11 @@ body.modal-open .studio-app-banner {
748
774
  height: var(--nav-avatar);
749
775
  font-size: 0.875rem;
750
776
  }
777
+ /* The badge seat tracks the one-row avatar it sits over. */
778
+ .nav-shell [data-nav-rows="1"] + [data-nav-account] + [data-nav-avatar-badge] {
779
+ width: var(--nav-avatar);
780
+ height: var(--nav-avatar);
781
+ }
751
782
  .nav-shell [data-nav-rows="1"] + [data-nav-account] [data-nav-name] {
752
783
  font-size: calc(var(--nav-avatar) / 3 / 0.7);
753
784
  }
@@ -829,6 +860,7 @@ body.modal-open .studio-app-banner {
829
860
  }
830
861
  .nav-shell[data-nav-timeline] .nav-logo-link,
831
862
  .nav-shell[data-nav-timeline] [data-nav-account] > [aria-hidden],
863
+ .nav-shell[data-nav-timeline] [data-nav-avatar-badge],
832
864
  .nav-shell[data-nav-timeline] [data-nav-account] > span:first-child,
833
865
  .nav-shell[data-nav-timeline] [data-nav-tools],
834
866
  .nav-shell[data-nav-timeline] [data-nav-links] {
@@ -838,7 +870,8 @@ body.modal-open .studio-app-banner {
838
870
  animation-range: var(--nav-start, 0px) calc(var(--nav-start, 0px) + var(--nav-ramp));
839
871
  }
840
872
  .nav-shell[data-nav-timeline] .nav-logo-link { transform-origin: left center; animation-name: nav-tl-brand; }
841
- .nav-shell[data-nav-timeline] [data-nav-account] > [aria-hidden] { transform-origin: right center; animation-name: nav-tl-avatar; }
873
+ .nav-shell[data-nav-timeline] [data-nav-account] > [aria-hidden],
874
+ .nav-shell[data-nav-timeline] [data-nav-avatar-badge] { transform-origin: right center; animation-name: nav-tl-avatar; }
842
875
  .nav-shell[data-nav-timeline] [data-nav-account] > span:first-child { transform-origin: right center; animation-name: nav-tl-name; }
843
876
  .nav-shell[data-nav-timeline] [data-nav-tools] { animation-name: nav-tl-tools; }
844
877
  .nav-shell[data-nav-timeline] [data-nav-links] { animation-name: nav-tl-links; }
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
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.
3
+ # The navbar's view seams: links, items, the avatar badge and the signed-out
4
+ # CTA. Each resolves once per render pass, since a host lambda may read
5
+ # current_user or the database.
5
6
  module StudioNavbarHelper
6
7
  NAVBAR_LINK_CLASS = "inline-flex items-center gap-1.5 whitespace-nowrap font-medium transition"
7
8
  NAVBAR_BADGE_CLASS = "rounded-full border border-subtle px-1.5 py-0.5 text-[10px] leading-none " \
@@ -20,6 +21,24 @@ module StudioNavbarHelper
20
21
  @studio_navbar_signed_out_cta = Studio.navbar_signed_out_cta_for(self)
21
22
  end
22
23
 
24
+ # Studio.navbar_items as partial specs ({ partial:, locals: }), [] for none.
25
+ # Resolved once per render pass: the desktop bar and the phone row both ask.
26
+ def studio_navbar_items
27
+ @studio_navbar_items ||= Studio.navbar_items_for(self)
28
+ end
29
+
30
+ # Studio.navbar_avatar_badge as one partial spec, or nil for no badge.
31
+ def studio_navbar_avatar_badge
32
+ return @studio_navbar_avatar_badge if defined?(@studio_navbar_avatar_badge)
33
+
34
+ @studio_navbar_avatar_badge = Studio.navbar_avatar_badge_for(self)
35
+ end
36
+
37
+ # One resolved partial spec, rendered through the normal partial pipeline.
38
+ def studio_navbar_slot_tag(spec)
39
+ render partial: spec[:partial], locals: spec[:locals]
40
+ end
41
+
23
42
  # One resolved link. size is the text utility: text-sm on desktop, text-xs
24
43
  # in the phone row, matching the links apps hand-wrote in forked navbars.
25
44
  def studio_navbar_link_tag(link, size:)
@@ -14,6 +14,8 @@
14
14
  Config, not locals: the signed-in name is Studio.navbar_user_name
15
15
  (default display_name) and the signed-out button reads
16
16
  Studio.sign_in_label (default "Log in"); lib/studio/navbar_identity.rb.
17
+ Studio.navbar_avatar_badge (default nil) draws one partial over the
18
+ avatar; lib/studio/navbar_slots.rb.
17
19
 
18
20
  Legacy string locals (deprecated): balance_html, extra_icons_html,
19
21
  div2_html — pre-rendered HTML injected via raw. Still honored when
@@ -235,6 +237,19 @@
235
237
  <% else %>
236
238
  <span class="<%= account_classes %>" data-nav-account x-bind:style="$store.devMode && 'background: lightgreen'"><%= account_inner %></span>
237
239
  <% end %>
240
+ <%# Studio.navbar_avatar_badge: one partial over the avatar's upper-left
241
+ corner, the way a notification dot rides a profile picture. Its seat
242
+ is a SIBLING of the account control, never inside it: the control stays
243
+ one tab stop, and the badge may carry its own button (a button inside a
244
+ link or button is invalid and reaches a screen reader as nothing).
245
+ The seat comes right after the control, and engine.css (THE AVATAR
246
+ BADGE) places it out of flow over the avatar, which ends the row, so the
247
+ row gives it no width. nil (the default) renders the same bytes as
248
+ before. %>
249
+ <% avatar_badge = respond_to?(:studio_navbar_avatar_badge) ? studio_navbar_avatar_badge : nil %>
250
+ <% if avatar_badge %>
251
+ <div data-nav-avatar-badge><%= studio_navbar_slot_tag(avatar_badge) %></div>
252
+ <% end %>
238
253
  <% else %>
239
254
  <%= render "components/admin_dropdown", display_class: "hidden md:block" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
240
255
  <%# Desktop only, as above: the phone row carries the toggle below md. %>
@@ -31,6 +31,9 @@
31
31
  <%# Studio.navbar_links: links for the desktop bar and phone row.
32
32
  Empty by default, which renders the same bytes as before. %>
33
33
  <% navbar_links = respond_to?(:studio_navbar_links) ? studio_navbar_links : [] %>
34
+ <%# Studio.navbar_items: partials after the links, in both rows. Empty by
35
+ default, which renders the same bytes as before. %>
36
+ <% navbar_items = respond_to?(:studio_navbar_items) ? studio_navbar_items : [] %>
34
37
  <%# Studio.navbar_signed_out_cta: signed out, the primary button can carry a
35
38
  call to action instead of sign-in. nil (the default) renders the same bytes
36
39
  as before. Set, sign-in stays one tap away as a quiet text link: beside the
@@ -181,7 +184,7 @@
181
184
  <<%= brand_tag %> 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></<%= brand_tag %>>
182
185
  <% end %>
183
186
  <% end %>
184
- <%# Desktop nav — Studio.navbar_links. data-nav-links, only where the
187
+ <%# Desktop nav — Studio.navbar_links, then Studio.navbar_items. data-nav-links, only where the
185
188
  header is on the scroll timeline (so an opted-out header stays
186
189
  byte-identical): it slides left by what the scaled brand gives up
187
190
  (engine.css, THE SCROLL-DRIVEN COLLAPSE). %>
@@ -189,6 +192,9 @@
189
192
  <% navbar_links.each do |link| %>
190
193
  <%= studio_navbar_link_tag(link, size: "text-sm") %>
191
194
  <% end %>
195
+ <% navbar_items.each do |item| %>
196
+ <%= studio_navbar_slot_tag(item) %>
197
+ <% end %>
192
198
  </nav>
193
199
  </div>
194
200
  </div>
@@ -212,7 +218,7 @@
212
218
  <% end %>
213
219
  </div>
214
220
  </div>
215
- <%# Mobile sub-navbar: compact row hidden on desktop — Studio.navbar_links on the left %>
221
+ <%# Mobile sub-navbar: compact row hidden on desktop — Studio.navbar_links, then Studio.navbar_items, on the left %>
216
222
  <div class="flex md:hidden items-center gap-3 px-4 py-1.5 border-t border-subtle bg-surface-alt">
217
223
  <% if navbar_links.any? %>
218
224
  <nav aria-label="Main" class="flex items-center gap-3 overflow-x-auto">
@@ -221,6 +227,9 @@
221
227
  <% end %>
222
228
  </nav>
223
229
  <% end %>
230
+ <% navbar_items.each do |item| %>
231
+ <%= studio_navbar_slot_tag(item) %>
232
+ <% end %>
224
233
  <span class="ml-auto flex items-center gap-3">
225
234
  <% if signed_out_cta %>
226
235
  <%= link_to Studio.sign_in_label, login_path, class: "text-xs font-semibold text-secondary hover:text-primary whitespace-nowrap transition", data: { nav_sign_in: "" } %>
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Resolves the engine navbar's two partial slots. Pure Ruby, so the unit suite
4
+ # covers it without the dummy app.
5
+ #
6
+ # Studio.navbar_items partials rendered after Studio.navbar_links, in
7
+ # the desktop bar AND the phone row
8
+ # Studio.navbar_avatar_badge one partial rendered over the signed-in avatar's
9
+ # upper-left corner (components/_user_nav, Div 3)
10
+ #
11
+ # A partial spec is one of:
12
+ #
13
+ # "components/geo_badge" a partial path
14
+ # { partial: "shared/wallet_signal", a path with locals
15
+ # locals: { tone: "muted" } }
16
+ #
17
+ # navbar_items is an Array of specs, navbar_avatar_badge one spec or nil, and
18
+ # either may instead be a callable receiving the view that answers the same
19
+ # (nil meaning nothing for that render). Both defaults render nothing, so the
20
+ # navbar stays byte-identical until an app opts in.
21
+ #
22
+ # A bad static setting raises InvalidConfig at assignment; a callable's answer
23
+ # is checked when it resolves and raises the same error. A callable that RAISES
24
+ # is a runtime fault, not a shape fault: the slot renders nothing and the
25
+ # failure is reported once per process per setting per error class, so a layout
26
+ # never 500s over a badge. (Studio.navbar_signed_out_cta follows the same rules.)
27
+ module Studio
28
+ module NavbarSlots
29
+ class InvalidConfig < ArgumentError; end
30
+
31
+ SPEC_KEYS = %i[partial locals].freeze
32
+ ITEMS = "Studio.navbar_items"
33
+ BADGE = "Studio.navbar_avatar_badge"
34
+
35
+ @reported = {}
36
+ @reported_lock = Mutex.new
37
+
38
+ module_function
39
+
40
+ # The item specs for one render, in order; [] renders nothing.
41
+ def resolve_items(config, view)
42
+ answer = call(config, view, ITEMS) { return [] }
43
+ items(answer)
44
+ end
45
+
46
+ # The badge spec for one render, or nil for no badge.
47
+ def resolve_badge(config, view)
48
+ answer = call(config, view, BADGE) { return nil }
49
+ answer.nil? ? nil : spec(answer, BADGE)
50
+ end
51
+
52
+ # Shape checks without a view: a static setting fails at boot, not on the
53
+ # first page render. A callable is checked when it resolves.
54
+ def validate_items!(config)
55
+ items(config) unless config.respond_to?(:call)
56
+ nil
57
+ end
58
+
59
+ def validate_badge!(config)
60
+ spec(config, BADGE) unless config.nil? || config.respond_to?(:call)
61
+ nil
62
+ end
63
+
64
+ def items(list)
65
+ return [] if list.nil?
66
+ unless list.is_a?(Array)
67
+ raise InvalidConfig, "#{ITEMS} must be an Array of partial specs or a callable returning one " \
68
+ "(got #{list.class})"
69
+ end
70
+
71
+ list.each_with_index.map { |entry, index| spec(entry, "#{ITEMS}[#{index}]") }
72
+ end
73
+
74
+ # One spec, as { partial: String, locals: Hash }.
75
+ def spec(entry, where)
76
+ entry = { partial: entry } if entry.is_a?(String) || entry.is_a?(Symbol)
77
+ unless entry.is_a?(Hash)
78
+ raise InvalidConfig, "#{where} must be a partial path or a Hash { partial:, locals: } (got #{entry.class})"
79
+ end
80
+
81
+ entry = entry.to_h { |key, value| [key.to_sym, value] }
82
+ unknown = entry.keys - SPEC_KEYS
83
+ raise InvalidConfig, "#{where} has unknown key :#{unknown.first}" if unknown.any?
84
+
85
+ partial = entry[:partial]
86
+ unless (partial.is_a?(String) || partial.is_a?(Symbol)) && !partial.to_s.strip.empty?
87
+ raise InvalidConfig, "#{where} needs a non-blank :partial (got #{partial.inspect})"
88
+ end
89
+
90
+ locals = entry.fetch(:locals, nil) || {}
91
+ raise InvalidConfig, "#{where} :locals must be a Hash (got #{locals.class})" unless locals.is_a?(Hash)
92
+
93
+ { partial: partial.to_s.strip, locals: locals.to_h { |key, value| [key.to_sym, value] } }
94
+ end
95
+
96
+ # Forget what was reported, so a reconfigured callable reports its own failure.
97
+ def reset_reported!
98
+ @reported_lock.synchronize { @reported.clear }
99
+ end
100
+
101
+ # The setting itself, or a callable's answer for this view. A raise is
102
+ # reported and hands control to the block, which returns the fallback.
103
+ def call(config, view, setting)
104
+ return config unless config.respond_to?(:call)
105
+
106
+ config.call(view)
107
+ rescue StandardError => e
108
+ report_once(e, setting)
109
+ yield
110
+ end
111
+
112
+ def report_once(error, setting)
113
+ key = "#{setting} #{error.class.name}"
114
+ first = @reported_lock.synchronize { @reported[key] ? false : (@reported[key] = true) }
115
+ return unless first
116
+
117
+ if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
118
+ ::ErrorLog.capture!(error)
119
+ elsif defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
120
+ ::Rails.logger.warn("[studio-navbar] #{setting} failed, rendering nothing in its place: " \
121
+ "#{error.class}: #{error.message}")
122
+ end
123
+ rescue StandardError
124
+ nil # reporting is best-effort: the navbar still renders
125
+ end
126
+ end
127
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.112.0"
2
+ VERSION = "0.113.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -34,6 +34,7 @@ require "studio/site_footer"
34
34
  require "studio/booking"
35
35
  require "studio/navbar_links"
36
36
  require "studio/navbar_signed_out_cta"
37
+ require "studio/navbar_slots"
37
38
  require "studio/navbar_identity"
38
39
  require "studio/navbar_wordmark"
39
40
  require "studio/auth_labels"
@@ -299,6 +300,41 @@ module Studio
299
300
  @@navbar_signed_out_cta = config
300
301
  end
301
302
 
303
+ # ---- Navbar items ----
304
+ # Partials the engine navbar renders after Studio.navbar_links, in the desktop
305
+ # bar AND the phone row (so each renders twice per page: give its markup no
306
+ # id). The default [] renders nothing, byte-identical to before. An Array, or
307
+ # a callable receiving the view, of partial specs: a partial path, or
308
+ # { partial:, locals: }. Rules: lib/studio/navbar_slots.rb.
309
+ #
310
+ # config.navbar_items = ["components/geo_badge", "shared/wallet_signal"]
311
+ # config.navbar_items = ->(view) { view.logged_in? ? ["shared/wallet_signal"] : [] }
312
+ mattr_reader :navbar_items, default: []
313
+
314
+ def self.navbar_items=(config)
315
+ NavbarSlots.validate_items!(config)
316
+ NavbarSlots.reset_reported!
317
+ @@navbar_items = config
318
+ end
319
+
320
+ # ---- Navbar avatar badge ----
321
+ # One partial the signed-in user nav renders over the avatar's upper-left
322
+ # corner, above it and out of flow, so the row gives it no width. It sits
323
+ # beside the account control, never inside it, so the control stays one tab
324
+ # stop and the badge may carry its own button. nil, the default, renders
325
+ # byte-identical to before. A partial spec, or a callable receiving the view
326
+ # (nil for no badge that render). Rules: lib/studio/navbar_slots.rb.
327
+ #
328
+ # config.navbar_avatar_badge = "components/entry_token_badge"
329
+ # config.navbar_avatar_badge = { partial: "components/entry_token_badge", locals: { size: :sm } }
330
+ mattr_reader :navbar_avatar_badge, default: nil
331
+
332
+ def self.navbar_avatar_badge=(config)
333
+ NavbarSlots.validate_badge!(config)
334
+ NavbarSlots.reset_reported!
335
+ @@navbar_avatar_badge = config
336
+ end
337
+
302
338
  # ---- The shared profile page (/profile) ----
303
339
  # The rows that make up /profile. `nil` — the default — means "the engine's
304
340
  # standard page", NOT "no rows": a brand-new app with an empty initializer gets
@@ -1440,6 +1476,16 @@ module Studio
1440
1476
  NavbarSignedOutCta.resolve(navbar_signed_out_cta, view)
1441
1477
  end
1442
1478
 
1479
+ # The navbar item partial specs for a view (lib/studio/navbar_slots.rb).
1480
+ def self.navbar_items_for(view)
1481
+ NavbarSlots.resolve_items(navbar_items, view)
1482
+ end
1483
+
1484
+ # The avatar badge partial spec for a view, or nil (lib/studio/navbar_slots.rb).
1485
+ def self.navbar_avatar_badge_for(view)
1486
+ NavbarSlots.resolve_badge(navbar_avatar_badge, view)
1487
+ end
1488
+
1443
1489
  # The signed-in user's navbar name for a view (lib/studio/navbar_identity.rb).
1444
1490
  def self.navbar_user_name_for(user, view = nil)
1445
1491
  NavbarIdentity.user_name(user, view, config: navbar_user_name)
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.112.0
4
+ version: 0.113.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-10-20 00:00:00.000000000 Z
11
+ date: 2026-10-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -764,6 +764,7 @@ files:
764
764
  - lib/studio/navbar_identity.rb
765
765
  - lib/studio/navbar_links.rb
766
766
  - lib/studio/navbar_signed_out_cta.rb
767
+ - lib/studio/navbar_slots.rb
767
768
  - lib/studio/navbar_wordmark.rb
768
769
  - lib/studio/newsletter.rb
769
770
  - lib/studio/oauth_identity.rb