studio-engine 0.113.0 → 0.114.0.rc1
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 +116 -0
- data/README.md +139 -0
- data/app/assets/tailwind/studio_engine/engine.css +52 -0
- data/app/helpers/studio_navbar_helper.rb +47 -2
- data/app/javascript/studio/alpine_scopes.js +9 -3
- data/app/javascript/studio/host_fetchers.js +16 -0
- data/app/javascript/studio/modal_countdown.js +95 -0
- data/app/javascript/studio/newsletter_modal.js +252 -0
- data/app/views/components/_sidebar_panel.html.erb +22 -3
- data/app/views/components/_user_nav.html.erb +37 -4
- data/app/views/layouts/_navbar.html.erb +60 -10
- data/app/views/studio/_alpine_scopes_guard.html.erb +9 -7
- data/app/views/studio/_cropper_assets.html.erb +15 -3
- data/app/views/studio/modals/blocks/_birthday.html.erb +18 -0
- data/app/views/studio/modals/blocks/_change_username.html.erb +39 -1
- data/app/views/studio/modals/blocks/_error_card.html.erb +57 -1
- data/app/views/studio/modals/blocks/_success_card.html.erb +45 -4
- data/app/views/studio/profiles/_newsletter_modals.html.erb +281 -19
- data/app/views/style/_modals.html.erb +65 -0
- data/lib/studio/navbar_account_sidebar.rb +128 -0
- data/lib/studio/navbar_brand.rb +84 -0
- data/lib/studio/navbar_signed_out_cta.rb +77 -8
- data/lib/studio/navbar_slots.rb +36 -14
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +129 -0
- metadata +6 -1
|
@@ -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, "
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
|
data/lib/studio/navbar_slots.rb
CHANGED
|
@@ -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
|
|
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,
|
|
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,
|
|
49
|
-
answer.nil? ? nil : spec(answer,
|
|
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,
|
|
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
|
|
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, "#{
|
|
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, "#{
|
|
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 }.
|
data/lib/studio/version.rb
CHANGED
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)
|