studio-engine 0.113.0 → 0.114.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.
@@ -345,6 +345,37 @@
345
345
  </div>
346
346
  </template>
347
347
 
348
+ <%# The blocks' consumer locals (engine-modal-blocks-carry-consumer): the
349
+ error card's Retry-After countdown and close, the success card's
350
+ message slot and close, and the username card's locked state. Each
351
+ closes THIS store, never the host's. %>
352
+ <template x-if="$store.dsModals.current().id === 'ds-error-countdown'">
353
+ <div><%= render "studio/modals/blocks/error_card",
354
+ title: "Easy there", icon_emoji: "✋",
355
+ message: "You are going a bit fast.",
356
+ countdown_key: "$store.dsModals.current().props.secondsLeft",
357
+ modal_store: "dsModals",
358
+ secondary_label: "Dismiss", secondary_close: true %></div>
359
+ </template>
360
+ <template x-if="$store.dsModals.current().id === 'ds-success-close'">
361
+ <div><%= render layout: "studio/modals/blocks/success_card",
362
+ locals: { title: "Confirm your email change", icon_emoji: "📬",
363
+ slot_position: :message, modal_store: "dsModals",
364
+ cta_label: "Got it", cta_close: true } do %>
365
+ We sent a confirmation link to <span class="text-heading font-medium">pat@example.com</span>
366
+ (your current address). Click it to change your email to
367
+ <span class="text-heading font-medium">pat@newmail.com</span>.
368
+ <% end %></div>
369
+ </template>
370
+ <template x-if="$store.dsModals.current().id === 'ds-username-locked'">
371
+ <div><%= render "studio/modals/blocks/change_username",
372
+ current_username: "turfmonster", submit_url: "#demo-change-username",
373
+ modal_store: "dsModals",
374
+ locked: { title: "Enter Contest First",
375
+ subtitle: "Your username is locked until you've played one contest.",
376
+ cta_label: "Browse Contests", cta_href: "#modals" } %></div>
377
+ </template>
378
+
348
379
  <%# --- Templates (copy-from archetypes) --- %>
349
380
  <template x-if="$store.dsModals.current().id === 'template-wizard'">
350
381
  <div><%= render "studio/modals/templates/wizard", modal_store: "dsModals" %></div>
@@ -1157,6 +1188,40 @@
1157
1188
  </div>
1158
1189
  <% end %>
1159
1190
 
1191
+ <%= render layout: "style/modal_specimen", locals: {
1192
+ label: "Error countdown",
1193
+ reference: %(the "Error" card block with its countdown (studio/modals/blocks/_error_card with countdown_key:) — the wait is read once from the expression the host names, a 429's Retry-After, counts down by studio/modal_countdown, reads in minutes above 90 seconds, and closes the store at zero; secondary_close: makes Dismiss close it by hand. Open with $store.dsModals.open('ds-error-countdown', { secondsLeft: 10 })),
1194
+ open_expr: "$store.dsModals.open('ds-error-countdown', { secondsLeft: 10 })" } do %>
1195
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1196
+ <div class="text-2xl leading-none">✋</div>
1197
+ <span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1198
+ <span class="block h-1.5 w-16 mx-auto rounded" style="background: var(--color-text); opacity: .3"></span>
1199
+ </div>
1200
+ <% end %>
1201
+
1202
+ <%= render layout: "style/modal_specimen", locals: {
1203
+ label: "Success, closes",
1204
+ reference: %(the "Success" card block with its message slot and a closing CTA (studio/modals/blocks/_success_card with slot_position: :message and cta_close:) — the caller's block takes the message's place, for copy richer than one string, and the CTA closes the store rather than following a link. Open with $store.dsModals.open('ds-success-close')),
1205
+ open_expr: "$store.dsModals.open('ds-success-close')" } do %>
1206
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1207
+ <div class="text-2xl leading-none">📬</div>
1208
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1209
+ <span class="block h-1.5 w-28 mx-auto rounded" style="background: var(--color-text); opacity: .12"></span>
1210
+ <span class="block h-4 w-full rounded" style="background: var(--color-cta)"></span>
1211
+ </div>
1212
+ <% end %>
1213
+
1214
+ <%= render layout: "style/modal_specimen", locals: {
1215
+ label: "Username locked",
1216
+ reference: %(the change-username block's locked state (studio/modals/blocks/_change_username with locked:) — the app decides the rename is not open yet and the card says why, with a link to where it unlocks and a Dismiss, in place of the input. Open with $store.dsModals.open('ds-username-locked')),
1217
+ open_expr: "$store.dsModals.open('ds-username-locked')" } do %>
1218
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
1219
+ <div class="mx-auto w-7 h-7 rounded-full" style="background: color-mix(in srgb, var(--color-danger) 15%, transparent)"></div>
1220
+ <span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
1221
+ <span class="block h-4 w-full rounded" style="background: var(--color-cta)"></span>
1222
+ </div>
1223
+ <% end %>
1224
+
1160
1225
  <%# rate-limit-general — the SOFT cap. Dismissible, apologetic, and it
1161
1226
  auto-closes when the wait elapses rather than making anyone
1162
1227
  acknowledge a penalty for clicking too fast. %>
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Resolves Studio.navbar_account_sidebar: the host sidebar the signed-in user
4
+ # nav's account control (the name and the avatar, one control) opens, in place
5
+ # of linking to the account page or opening the engine's link sidebar. Pure
6
+ # Ruby, so the unit suite covers it without the dummy app.
7
+ #
8
+ # Studio.navbar_account_sidebar is one of:
9
+ #
10
+ # nil the default: the account control links to the
11
+ # account page or opens the link sidebar, as before
12
+ # { flag: "gearOpen", the control is a button that toggles
13
+ # store: "sidebars", $store.<store>.<flag> (store defaults to "sidebars")
14
+ # controls: "gear-sidebar gear-sidebar-mobile" }
15
+ # and names the panels it opens in aria-controls
16
+ # (a String of ids or an Array of them)
17
+ # ->(view) { ... } a callable receiving the view; nil keeps the default
18
+ #
19
+ # The toggle expression is the engine's, built from these names, which must be
20
+ # plain JavaScript identifiers; no host JavaScript reaches the page as a string.
21
+ # :controls may not name the engine's own link sidebar (studio-link-sidebar,
22
+ # studio-link-sidebar-mobile): studio/link_sidebar would claim the click.
23
+ # The button also carries data-profile-image-toggle, so a host's capture-phase
24
+ # handler that claims its sidebar's triggers before Alpine binds (as
25
+ # studio/link_sidebar does for the link sidebar) claims this one too.
26
+ #
27
+ # A bad static Hash raises InvalidConfig at assignment; a callable's answer is
28
+ # checked when it resolves. A callable that RAISES renders the default and is
29
+ # reported once per process per error class, so a layout never 500s.
30
+ module Studio
31
+ module NavbarAccountSidebar
32
+ class InvalidConfig < ArgumentError; end
33
+
34
+ SETTING = "Studio.navbar_account_sidebar"
35
+ KEYS = %i[flag store controls].freeze
36
+ IDENTIFIER = /\A[A-Za-z_$][\w$]*\z/
37
+ DOM_ID = /\A[A-Za-z][\w:.-]*\z/
38
+ # The engine's link sidebar panels: components/_link_sidebar renders them, and
39
+ # app/javascript/studio/link_sidebar.js (LINK_SIDEBAR_PANELS) is the source of
40
+ # truth; test/lib/navbar_account_sidebar_test.rb holds this list to it.
41
+ # studio/link_sidebar claims a click on any trigger whose aria-controls names
42
+ # them, so a host sidebar control that named one would open the engine's
43
+ # panel, not the host's.
44
+ ENGINE_LINK_SIDEBAR_IDS = %w[studio-link-sidebar studio-link-sidebar-mobile].freeze
45
+
46
+ @reported = {}
47
+ @reported_lock = Mutex.new
48
+
49
+ module_function
50
+
51
+ # The sidebar for one render, as { store:, flag:, controls: } with plain
52
+ # Strings, or nil for the default account control.
53
+ def resolve(config, view)
54
+ return nil if config.nil?
55
+ return normalize(config) unless config.respond_to?(:call)
56
+
57
+ begin
58
+ answer = config.call(view)
59
+ rescue StandardError => e
60
+ report_once(e)
61
+ return nil
62
+ end
63
+ answer.nil? ? nil : normalize(answer)
64
+ end
65
+
66
+ def validate!(config)
67
+ normalize(config) unless config.nil? || config.respond_to?(:call)
68
+ nil
69
+ end
70
+
71
+ def normalize(config)
72
+ unless config.is_a?(Hash)
73
+ raise InvalidConfig, "#{SETTING} must be nil, a Hash { flag:, store:, controls: } " \
74
+ "or a callable returning one (got #{config.class})"
75
+ end
76
+
77
+ config = config.to_h { |key, value| [key.to_sym, value] }
78
+ unknown = config.keys - KEYS
79
+ raise InvalidConfig, "#{SETTING} has unknown key :#{unknown.first}" if unknown.any?
80
+
81
+ { store: identifier(config.fetch(:store, nil) || "sidebars", :store),
82
+ flag: identifier(config[:flag], :flag),
83
+ controls: controls(config[:controls]) }
84
+ end
85
+
86
+ def identifier(value, key)
87
+ name = value.to_s
88
+ unless (value.is_a?(String) || value.is_a?(Symbol)) && name.match?(IDENTIFIER)
89
+ raise InvalidConfig, "#{SETTING} :#{key} must be a plain JavaScript name (got #{value.inspect})"
90
+ end
91
+
92
+ String.new(name)
93
+ end
94
+
95
+ def controls(value)
96
+ ids = value.is_a?(Array) ? value : value.to_s.split
97
+ ids = ids.map { |id| id.is_a?(Symbol) ? id.to_s : id }
98
+ if ids.empty? || !ids.all? { |id| id.is_a?(String) && id.match?(DOM_ID) }
99
+ raise InvalidConfig, "#{SETTING} :controls must name the panel ids it opens (got #{value.inspect})"
100
+ end
101
+ if (engine = ids & ENGINE_LINK_SIDEBAR_IDS).any?
102
+ raise InvalidConfig, "#{SETTING} :controls names #{engine.join(' and ')}, the engine's link sidebar: " \
103
+ "studio/link_sidebar would take the click and open its panel, not the host's. " \
104
+ "Name the host's own panel ids, or leave the setting nil to keep the link sidebar"
105
+ end
106
+
107
+ ids.join(" ")
108
+ end
109
+
110
+ def reset_reported!
111
+ @reported_lock.synchronize { @reported.clear }
112
+ end
113
+
114
+ def report_once(error)
115
+ first = @reported_lock.synchronize { @reported[error.class.name] ? false : (@reported[error.class.name] = true) }
116
+ return unless first
117
+
118
+ if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
119
+ ::ErrorLog.capture!(error)
120
+ elsif defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
121
+ ::Rails.logger.warn("[studio-navbar] #{SETTING} failed, rendering the default account control: " \
122
+ "#{error.class}: #{error.message}")
123
+ end
124
+ rescue StandardError
125
+ nil # reporting is best-effort: the navbar still renders
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Studio.navbar_brand: the engine navbar's logo image and title words, for an
4
+ # app whose brand is not "the Navbar Logo theme image plus app_name with the
5
+ # last word in text-primary". Pure Ruby, so the unit suite covers it without
6
+ # the dummy app.
7
+ #
8
+ # nil, the default, renders the navbar byte-identical to before. A Hash:
9
+ #
10
+ # config.navbar_brand = {
11
+ # logo: "/icon-192.png", # the image (default: Studio.logo_for("Navbar Logo"))
12
+ # logo_size: 48, # width and height attributes, in px (optional)
13
+ # alt: "Turf Monster", # the image's alt (default: Studio.app_name)
14
+ # words: [{ text: "Turf", class: "text-heading" },
15
+ # { text: "Monster", class: "tm-wordmark" }] # the title, one span each
16
+ # }
17
+ #
18
+ # Every key is optional. words replaces the app_name split: each word is a
19
+ # String or { text:, class: }, drawn as its own truncating span in the title
20
+ # with the host's class and nothing else (no text-primary), so a split
21
+ # wordmark styles each half. Studio.navbar_wordmark, a one-piece lockup image,
22
+ # wins over this when both are set.
23
+ module Studio
24
+ module NavbarBrand
25
+ class InvalidConfig < ArgumentError; end
26
+
27
+ SETTING = "Studio.navbar_brand"
28
+ KEYS = %i[logo logo_size alt words].freeze
29
+ WORD_KEYS = %i[text class].freeze
30
+
31
+ module_function
32
+
33
+ # The config as a Hash with only the keys given, or nil when unset.
34
+ def normalize(config)
35
+ return nil if config.nil?
36
+ raise InvalidConfig, "#{SETTING} must be nil or a Hash (got #{config.class})" unless config.is_a?(Hash)
37
+
38
+ config = config.to_h { |key, value| [key.to_sym, value] }
39
+ unknown = config.keys - KEYS
40
+ raise InvalidConfig, "#{SETTING} has unknown key :#{unknown.first}" if unknown.any?
41
+
42
+ brand = {}
43
+ brand[:logo] = text(config[:logo], "#{SETTING}[:logo]") unless config[:logo].nil?
44
+ brand[:alt] = text(config[:alt], "#{SETTING}[:alt]") unless config[:alt].nil?
45
+ unless config[:logo_size].nil?
46
+ size = config[:logo_size]
47
+ unless size.is_a?(Integer) && size.positive?
48
+ raise InvalidConfig, "#{SETTING}[:logo_size] must be a positive Integer of px (got #{size.inspect})"
49
+ end
50
+
51
+ brand[:logo_size] = size
52
+ end
53
+ brand[:words] = words(config[:words]) unless config[:words].nil?
54
+ brand.freeze
55
+ end
56
+
57
+ def words(list)
58
+ unless list.is_a?(Array) && list.any?
59
+ raise InvalidConfig, "#{SETTING}[:words] must be a non-empty Array (got #{list.inspect})"
60
+ end
61
+
62
+ list.each_with_index.map do |word, index|
63
+ where = "#{SETTING}[:words][#{index}]"
64
+ word = { text: word } if word.is_a?(String)
65
+ raise InvalidConfig, "#{where} must be a String or a Hash { text:, class: } (got #{word.class})" unless word.is_a?(Hash)
66
+
67
+ word = word.to_h { |key, value| [key.to_sym, value] }
68
+ unknown = word.keys - WORD_KEYS
69
+ raise InvalidConfig, "#{where} has unknown key :#{unknown.first}" if unknown.any?
70
+
71
+ { text: text(word[:text], "#{where}[:text]"),
72
+ class: word[:class].nil? ? nil : text(word[:class], "#{where}[:class]") }.freeze
73
+ end.freeze
74
+ end
75
+
76
+ # A non-blank String, stripped. String.new drops an html_safe flag, so ERB
77
+ # escapes whatever came back.
78
+ def text(value, where)
79
+ raise InvalidConfig, "#{where} must be a non-blank String (got #{value.inspect})" unless value.is_a?(String) && !value.strip.empty?
80
+
81
+ String.new(value).strip.freeze
82
+ end
83
+ end
84
+ end
@@ -15,6 +15,21 @@
15
15
  # ->(view) { { label:, path: } } a callable receiving the view; answering nil
16
16
  # keeps the default for that render
17
17
  #
18
+ # Three optional keys (each absent, the CTA renders as it did in 0.113.0):
19
+ #
20
+ # modal: "auth", or { id: "auth", props: { step: "credentials" } }.
21
+ # The button opens that modal on the engine's modal host
22
+ # ($store.modals.open(id, props)), carries
23
+ # aria-haspopup="dialog", and path stays its href, the
24
+ # fallback with no script or no modal host. The expression is
25
+ # the engine's, built from this data: the id is a plain name
26
+ # and props must be JSON (strings, numbers, booleans, nil,
27
+ # arrays and hashes of them), so no host JavaScript reaches the
28
+ # page as a string.
29
+ # sign_in_link: false drops the quiet sign-in text link (desktop and phone
30
+ # row), for an app whose CTA IS its sign-in. Default true.
31
+ # sign_in_path: where the sign-in text link points. Default login_path.
32
+ #
18
33
  # It is separate from Studio.sign_in_label on purpose: that label also words the
19
34
  # engine's own auth pages (lib/studio/auth_labels.rb), so it cannot be bent into
20
35
  # a marketing CTA. The sign-in link always keeps it.
@@ -29,6 +44,9 @@ module Studio
29
44
  class InvalidConfig < ArgumentError; end
30
45
 
31
46
  KEYS = %i[label path].freeze
47
+ OPTIONAL_KEYS = %i[modal sign_in_link sign_in_path].freeze
48
+ MODAL_ID = /\A[A-Za-z_][\w-]*\z/
49
+ SETTING = "Studio.navbar_signed_out_cta"
32
50
 
33
51
  @reported = {}
34
52
  @reported_lock = Mutex.new
@@ -66,17 +84,68 @@ module Studio
66
84
  end
67
85
 
68
86
  config = config.to_h { |key, value| [key.to_sym, value] }
69
- unknown = config.keys - KEYS
70
- raise InvalidConfig, "Studio.navbar_signed_out_cta has unknown key :#{unknown.first}" if unknown.any?
87
+ unknown = config.keys - KEYS - OPTIONAL_KEYS
88
+ raise InvalidConfig, "#{SETTING} has unknown key :#{unknown.first}" if unknown.any?
71
89
 
72
- KEYS.to_h do |key|
73
- value = config[key]
74
- unless (value.is_a?(String) || value.is_a?(Symbol)) && !value.to_s.strip.empty?
75
- raise InvalidConfig, "Studio.navbar_signed_out_cta needs a non-blank :#{key} (got #{value.inspect})"
90
+ cta = KEYS.to_h { |key| [key, text(config[key], key)] }
91
+ cta[:modal] = modal(config[:modal]) if config.key?(:modal) && !config[:modal].nil?
92
+ if config.key?(:sign_in_link)
93
+ unless [true, false].include?(config[:sign_in_link])
94
+ raise InvalidConfig, "#{SETTING} :sign_in_link must be true or false (got #{config[:sign_in_link].inspect})"
76
95
  end
77
96
 
78
- # String.new drops an html_safe flag, so ERB escapes whatever came back.
79
- [key, String.new(value.to_s).strip]
97
+ cta[:sign_in_link] = config[:sign_in_link]
98
+ end
99
+ cta[:sign_in_path] = text(config[:sign_in_path], :sign_in_path) if config.key?(:sign_in_path) && !config[:sign_in_path].nil?
100
+ cta
101
+ end
102
+
103
+ # A non-blank String or Symbol, as a plain stripped String. String.new drops
104
+ # an html_safe flag, so ERB escapes whatever came back.
105
+ def text(value, key)
106
+ unless (value.is_a?(String) || value.is_a?(Symbol)) && !value.to_s.strip.empty?
107
+ raise InvalidConfig, "#{SETTING} needs a non-blank :#{key} (got #{value.inspect})"
108
+ end
109
+
110
+ String.new(value.to_s).strip
111
+ end
112
+
113
+ # The modal to open, as { id: String, props: Hash }.
114
+ def modal(value)
115
+ value = { id: value } if value.is_a?(String) || value.is_a?(Symbol)
116
+ unless value.is_a?(Hash)
117
+ raise InvalidConfig, "#{SETTING} :modal must be a modal id or a Hash { id:, props: } (got #{value.class})"
118
+ end
119
+
120
+ value = value.to_h { |key, entry| [key.to_sym, entry] }
121
+ unknown = value.keys - %i[id props]
122
+ raise InvalidConfig, "#{SETTING} :modal has unknown key :#{unknown.first}" if unknown.any?
123
+
124
+ id = value[:id].to_s
125
+ unless (value[:id].is_a?(String) || value[:id].is_a?(Symbol)) && id.match?(MODAL_ID)
126
+ raise InvalidConfig, "#{SETTING} :modal needs an :id that is a plain name (got #{value[:id].inspect})"
127
+ end
128
+
129
+ props = value.fetch(:props, nil) || {}
130
+ raise InvalidConfig, "#{SETTING} :modal :props must be a Hash (got #{props.class})" unless props.is_a?(Hash)
131
+
132
+ { id: String.new(id), props: json(props, "#{SETTING} :modal :props") }
133
+ end
134
+
135
+ # A JSON value: strings, numbers, booleans, nil, and arrays and hashes of
136
+ # them. Hash keys become Strings.
137
+ def json(value, where)
138
+ case value
139
+ when Hash
140
+ value.to_h do |key, entry|
141
+ raise InvalidConfig, "#{where} keys must be Strings or Symbols (got #{key.inspect})" unless key.is_a?(String) || key.is_a?(Symbol)
142
+
143
+ [String.new(key.to_s), json(entry, "#{where}[#{key.inspect}]")]
144
+ end
145
+ when Array then value.each_with_index.map { |entry, index| json(entry, "#{where}[#{index}]") }
146
+ when String, Symbol then String.new(value.to_s)
147
+ when Integer, Float, true, false, nil then value
148
+ else raise InvalidConfig, "#{where} must be JSON: strings, numbers, booleans, nil, arrays, hashes (got #{value.class})"
80
149
  end
81
150
  end
82
151
 
@@ -7,6 +7,12 @@
7
7
  # the desktop bar AND the phone row
8
8
  # Studio.navbar_avatar_badge one partial rendered over the signed-in avatar's
9
9
  # upper-left corner (components/_user_nav, Div 3)
10
+ # Studio.navbar_header_bars partials rendered INSIDE the pinned header, above
11
+ # its row, so they never scroll away (a control
12
+ # bar such as an impersonation escape)
13
+ # Studio.navbar_menu_trigger one partial the icon rail and the phone row
14
+ # render in place of the admin dropdown; it is
15
+ # rendered twice, so it carries no ids
10
16
  #
11
17
  # A partial spec is one of:
12
18
  #
@@ -14,7 +20,8 @@
14
20
  # { partial: "shared/wallet_signal", a path with locals
15
21
  # locals: { tone: "muted" } }
16
22
  #
17
- # navbar_items is an Array of specs, navbar_avatar_badge one spec or nil, and
23
+ # navbar_items and navbar_header_bars are Arrays of specs, navbar_avatar_badge
24
+ # and navbar_menu_trigger one spec or nil, and
18
25
  # either may instead be a callable receiving the view that answers the same
19
26
  # (nil meaning nothing for that render). Both defaults render nothing, so the
20
27
  # navbar stays byte-identical until an app opts in.
@@ -31,6 +38,8 @@ module Studio
31
38
  SPEC_KEYS = %i[partial locals].freeze
32
39
  ITEMS = "Studio.navbar_items"
33
40
  BADGE = "Studio.navbar_avatar_badge"
41
+ HEADER_BARS = "Studio.navbar_header_bars"
42
+ MENU_TRIGGER = "Studio.navbar_menu_trigger"
34
43
 
35
44
  @reported = {}
36
45
  @reported_lock = Mutex.new
@@ -38,37 +47,50 @@ module Studio
38
47
  module_function
39
48
 
40
49
  # 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)
50
+ def resolve_items(config, view, setting = ITEMS)
51
+ answer = call(config, view, setting) { return [] }
52
+ items(answer, setting)
44
53
  end
45
54
 
46
55
  # 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)
56
+ def resolve_badge(config, view, setting = BADGE)
57
+ answer = call(config, view, setting) { return nil }
58
+ answer.nil? ? nil : spec(answer, setting)
59
+ end
60
+
61
+ # The header bar specs for one render, in order; [] renders nothing.
62
+ def resolve_header_bars(config, view)
63
+ resolve_items(config, view, HEADER_BARS)
64
+ end
65
+
66
+ # The menu trigger spec for one render, or nil for the admin dropdown.
67
+ def resolve_menu_trigger(config, view)
68
+ resolve_badge(config, view, MENU_TRIGGER)
50
69
  end
51
70
 
52
71
  # Shape checks without a view: a static setting fails at boot, not on the
53
72
  # first page render. A callable is checked when it resolves.
54
- def validate_items!(config)
55
- items(config) unless config.respond_to?(:call)
73
+ def validate_items!(config, setting = ITEMS)
74
+ items(config, setting) unless config.respond_to?(:call)
56
75
  nil
57
76
  end
58
77
 
59
- def validate_badge!(config)
60
- spec(config, BADGE) unless config.nil? || config.respond_to?(:call)
78
+ def validate_badge!(config, setting = BADGE)
79
+ spec(config, setting) unless config.nil? || config.respond_to?(:call)
61
80
  nil
62
81
  end
63
82
 
64
- def items(list)
83
+ def validate_header_bars!(config) = validate_items!(config, HEADER_BARS)
84
+ def validate_menu_trigger!(config) = validate_badge!(config, MENU_TRIGGER)
85
+
86
+ def items(list, setting = ITEMS)
65
87
  return [] if list.nil?
66
88
  unless list.is_a?(Array)
67
- raise InvalidConfig, "#{ITEMS} must be an Array of partial specs or a callable returning one " \
89
+ raise InvalidConfig, "#{setting} must be an Array of partial specs or a callable returning one " \
68
90
  "(got #{list.class})"
69
91
  end
70
92
 
71
- list.each_with_index.map { |entry, index| spec(entry, "#{ITEMS}[#{index}]") }
93
+ list.each_with_index.map { |entry, index| spec(entry, "#{setting}[#{index}]") }
72
94
  end
73
95
 
74
96
  # One spec, as { partial: String, locals: Hash }.
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.113.0"
2
+ VERSION = "0.114.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -35,8 +35,10 @@ require "studio/booking"
35
35
  require "studio/navbar_links"
36
36
  require "studio/navbar_signed_out_cta"
37
37
  require "studio/navbar_slots"
38
+ require "studio/navbar_account_sidebar"
38
39
  require "studio/navbar_identity"
39
40
  require "studio/navbar_wordmark"
41
+ require "studio/navbar_brand"
40
42
  require "studio/auth_labels"
41
43
  require "studio/profile_sections"
42
44
  require "studio/profile_image"
@@ -335,6 +337,117 @@ module Studio
335
337
  @@navbar_avatar_badge = config
336
338
  end
337
339
 
340
+ # ---- Navbar header bars ----
341
+ # Partials the engine navbar renders INSIDE its pinned header, above the row,
342
+ # so they stay on screen while the page scrolls: a control an admin must not
343
+ # lose, such as an impersonation bar's "Return to <admin>" button. The bar
344
+ # stack (studio/banners/_stack) is the place for an ambient label; it renders
345
+ # above the header and scrolls away. Skipped on a preview render. The default
346
+ # [] renders byte-identical to before. An Array, or a callable receiving the
347
+ # view, of partial specs. Rules: lib/studio/navbar_slots.rb.
348
+ #
349
+ # config.navbar_header_bars = ->(view) { view.impersonating? ? ["shared/impersonation_banner"] : [] }
350
+ mattr_reader :navbar_header_bars, default: []
351
+
352
+ def self.navbar_header_bars=(config)
353
+ NavbarSlots.validate_header_bars!(config)
354
+ NavbarSlots.reset_reported!
355
+ @@navbar_header_bars = config
356
+ end
357
+
358
+ # ---- Navbar menu trigger ----
359
+ # One partial the signed-in user nav's icon rail (from md) and the navbar's
360
+ # phone row render in place of the admin dropdown: the control that opens a
361
+ # host's own menu, such as Turf Monster's gear sidebar trigger. nil, the
362
+ # default, keeps the admin dropdown, byte-identical to before. A partial spec,
363
+ # or a callable receiving the view. Rules: lib/studio/navbar_slots.rb.
364
+ #
365
+ # config.navbar_menu_trigger = "components/gear_sidebar_trigger"
366
+ mattr_reader :navbar_menu_trigger, default: nil
367
+
368
+ def self.navbar_menu_trigger=(config)
369
+ NavbarSlots.validate_menu_trigger!(config)
370
+ NavbarSlots.reset_reported!
371
+ @@navbar_menu_trigger = config
372
+ end
373
+
374
+ # ---- Navbar account sidebar ----
375
+ # The host sidebar the signed-in user nav's account control (name and
376
+ # avatar, one control) opens: a button toggling $store.<store>.<flag>, its
377
+ # aria-controls naming the panels. nil, the default, keeps the account link
378
+ # (or the link sidebar), byte-identical to before. A Hash, or a callable
379
+ # receiving the view. Pair it with Studio.navbar_menu_trigger for the gear.
380
+ # Rules: lib/studio/navbar_account_sidebar.rb.
381
+ #
382
+ # config.navbar_account_sidebar = { flag: "gearOpen", controls: %w[gear-sidebar gear-sidebar-mobile] }
383
+ mattr_reader :navbar_account_sidebar, default: nil
384
+
385
+ def self.navbar_account_sidebar=(config)
386
+ NavbarAccountSidebar.validate!(config)
387
+ NavbarAccountSidebar.reset_reported!
388
+ @@navbar_account_sidebar = config
389
+ end
390
+
391
+ # ---- Navbar phone name ----
392
+ # true shows the signed-in name beside the avatar below md too, capped at
393
+ # 4.5rem (6rem from 400px) and fading out at its right edge when it does not
394
+ # fit, instead of hiding it as screen-reader text. The fade is CSS only
395
+ # (engine.css, THE PHONE NAME): it runs on the name's own scroll timeline,
396
+ # which is inactive when the name fits, so a short name never fades. A
397
+ # browser without scroll timelines clips the name. false, the default,
398
+ # renders byte-identical to before.
399
+ #
400
+ # config.navbar_phone_name = true
401
+ mattr_reader :navbar_phone_name, default: false
402
+
403
+ def self.navbar_phone_name=(value)
404
+ @@navbar_phone_name = navbar_boolean!(:navbar_phone_name, value)
405
+ end
406
+
407
+ # ---- Navbar brand ----
408
+ # The navbar's logo image and title words, for an app whose brand is not the
409
+ # "Navbar Logo" image plus app_name with its last word in text-primary: a
410
+ # logo path and size, its alt, and the title as words, each its own span
411
+ # with the host's class (a split wordmark). nil, the default, renders
412
+ # byte-identical to before; every key is optional. navbar_wordmark wins when
413
+ # both are set. Rules: lib/studio/navbar_brand.rb.
414
+ #
415
+ # config.navbar_brand = { logo: "/icon-192.png", logo_size: 48,
416
+ # words: [{ text: "Turf" }, { text: "Monster", class: "tm-wordmark" }] }
417
+ mattr_reader :navbar_brand, default: nil
418
+
419
+ def self.navbar_brand=(config)
420
+ @@navbar_brand = NavbarBrand.normalize(config)
421
+ end
422
+
423
+ # ---- Navbar root hook and layer tier ----
424
+ # navbar_root_hook: true puts data-navbar-root on the header (live and
425
+ # preview), the hook a host's tests and scripts find the navbar by.
426
+ # navbar_z_tier: true layers the live header on the shared layer scale,
427
+ # z-[var(--z-nav)] (engine.css), in place of z-50, so a modal (--z-modal)
428
+ # covers it and the tiers stay one table. Both false by default, which
429
+ # renders byte-identical to before.
430
+ #
431
+ # config.navbar_root_hook = true
432
+ # config.navbar_z_tier = true
433
+ mattr_reader :navbar_root_hook, default: false
434
+ mattr_reader :navbar_z_tier, default: false
435
+
436
+ def self.navbar_root_hook=(value)
437
+ @@navbar_root_hook = navbar_boolean!(:navbar_root_hook, value)
438
+ end
439
+
440
+ def self.navbar_z_tier=(value)
441
+ @@navbar_z_tier = navbar_boolean!(:navbar_z_tier, value)
442
+ end
443
+
444
+ def self.navbar_boolean!(name, value)
445
+ return value if [true, false].include?(value)
446
+
447
+ raise ArgumentError, "Studio.#{name} must be true or false (got #{value.inspect})"
448
+ end
449
+ private_class_method :navbar_boolean!
450
+
338
451
  # ---- The shared profile page (/profile) ----
339
452
  # The rows that make up /profile. `nil` — the default — means "the engine's
340
453
  # standard page", NOT "no rows": a brand-new app with an empty initializer gets
@@ -1486,6 +1599,22 @@ module Studio
1486
1599
  NavbarSlots.resolve_badge(navbar_avatar_badge, view)
1487
1600
  end
1488
1601
 
1602
+ # The in-header bar partial specs for a view (lib/studio/navbar_slots.rb).
1603
+ def self.navbar_header_bars_for(view)
1604
+ NavbarSlots.resolve_header_bars(navbar_header_bars, view)
1605
+ end
1606
+
1607
+ # The menu trigger partial spec for a view, or nil (lib/studio/navbar_slots.rb).
1608
+ def self.navbar_menu_trigger_for(view)
1609
+ NavbarSlots.resolve_menu_trigger(navbar_menu_trigger, view)
1610
+ end
1611
+
1612
+ # The account control's host sidebar for a view, or nil
1613
+ # (lib/studio/navbar_account_sidebar.rb).
1614
+ def self.navbar_account_sidebar_for(view)
1615
+ NavbarAccountSidebar.resolve(navbar_account_sidebar, view)
1616
+ end
1617
+
1489
1618
  # The signed-in user's navbar name for a view (lib/studio/navbar_identity.rb).
1490
1619
  def self.navbar_user_name_for(user, view = nil)
1491
1620
  NavbarIdentity.user_name(user, view, config: navbar_user_name)