studio-engine 0.122.0 → 0.124.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +142 -0
- data/README.md +18 -3
- data/app/assets/tailwind/studio_engine/engine.css +210 -11
- data/app/controllers/concerns/studio/admin_models.rb +24 -16
- data/app/controllers/concerns/studio/error_handling.rb +13 -0
- data/app/controllers/studio/agent_chats_controller.rb +31 -4
- data/app/controllers/studio/marketing_kit_controller.rb +24 -0
- data/app/controllers/studio/voice_takes_controller.rb +11 -0
- data/app/controllers/style_controller.rb +2 -5
- data/app/controllers/theme_settings_controller.rb +8 -8
- data/app/helpers/studio/agent_chat_helper.rb +37 -0
- data/app/helpers/studio/site_footer_helper.rb +15 -2
- data/app/javascript/studio/agent_chat.js +42 -2
- data/app/javascript/studio/agent_chat_voice.js +318 -0
- data/app/javascript/studio/alpine_shims.js +1 -3
- data/app/javascript/studio/controllers/agent_chat_voice_controller.js +17 -0
- data/app/javascript/studio/controllers/copy_text_controller.js +63 -0
- data/app/javascript/studio/controllers/page_header_controller.js +60 -0
- data/app/javascript/studio/controllers/style_section_nav_controller.js +80 -0
- data/app/javascript/studio/microphone_chip.js +1 -0
- data/app/javascript/studio/page_header.js +24 -0
- data/app/javascript/studio/page_transitions.js +181 -0
- data/app/javascript/studio/pinned_stack.js +12 -1
- data/app/javascript/studio/stimulus.js +9 -1
- data/app/javascript/studio/style_guide.js +2 -0
- data/app/javascript/studio/style_section_nav.js +16 -0
- data/app/javascript/studio/theme_editor.js +6 -106
- data/app/javascript/studio/voice_recorder.js +23 -2
- data/app/models/studio/user_session.rb +76 -0
- data/app/models/theme_setting.rb +3 -0
- data/app/views/components/_link_sidebar.html.erb +5 -2
- data/app/views/components/_user_nav.html.erb +5 -4
- data/app/views/layouts/_navbar.html.erb +7 -4
- data/app/views/layouts/studio/_head.html.erb +6 -0
- data/app/views/layouts/studio/_smooth_load.html.erb +11 -2
- data/app/views/studio/_page_header.html.erb +65 -0
- data/app/views/studio/admin_models/_sessions_table.html.erb +28 -0
- data/app/views/studio/admin_models/show.html.erb +15 -12
- data/app/views/studio/admin_surveys/index.html.erb +2 -2
- data/app/views/studio/agent_chat/_chat.html.erb +17 -2
- data/app/views/studio/agent_chat/_form.html.erb +4 -3
- data/app/views/studio/agent_chat/_header.html.erb +1 -1
- data/app/views/studio/agent_chat/_voice.html.erb +54 -0
- data/app/views/studio/marketing_kit/_assets.html.erb +44 -0
- data/app/views/studio/marketing_kit/_kit.html.erb +84 -0
- data/app/views/studio/marketing_kit/show.html.erb +6 -0
- data/app/views/studio/site_footer/_assets.html.erb +1 -0
- data/app/views/studio/site_footer/_footer.html.erb +5 -0
- data/app/views/studio/voice/_microphone_help.html.erb +6 -3
- data/app/views/style/_theme.html.erb +75 -12
- data/app/views/style/_tricks.html.erb +35 -0
- data/app/views/style/index.html.erb +21 -34
- data/db/migrate/20261103120000_create_studio_user_sessions.rb +16 -0
- data/docs/SITE_FOOTER.md +146 -2
- data/lib/studio/agent_chat.rb +83 -5
- data/lib/studio/marketing_kit.rb +304 -0
- data/lib/studio/site_footer.rb +65 -6
- data/lib/studio/ui_primitives.rb +4 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +146 -6
- metadata +21 -4
- data/app/views/studio/admin_models/index.html.erb +0 -31
- data/app/views/theme_settings/edit.html.erb +0 -304
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# THE MARKETING KIT (docs/SITE_FOOTER.md, "The marketing kit"): the public page
|
|
4
|
+
# at /marketing-kit, with the app's logos, colours and typefaces for anyone to
|
|
5
|
+
# use. Studio.marketing_kit declares it, as a Hash or a callable taking the view:
|
|
6
|
+
#
|
|
7
|
+
# config.marketing_kit = {
|
|
8
|
+
# tagline: "Everything, by example",
|
|
9
|
+
# logos: [ { title: "Icon", light: { png: "/brand/icon-light.png", svg: "/brand/icon-light.svg" },
|
|
10
|
+
# dark: { png: "/brand/icon-dark.png", svg: "/brand/icon-dark.svg" } } ],
|
|
11
|
+
# palette: [ { name: "Ink", hex: "#1A1535" } ],
|
|
12
|
+
# typefaces: [ { role: "Headings", family: "Montserrat", weight: 800, name: "Montserrat ExtraBold" } ]
|
|
13
|
+
# }
|
|
14
|
+
#
|
|
15
|
+
# Every key is optional. A missing `logos` derives from the app's brand artwork
|
|
16
|
+
# (Studio.navbar_wordmark, the footer's lockup, Studio.theme_logos) and a missing
|
|
17
|
+
# `palette` from the colours it paints (ThemeSetting, else Studio.theme_config),
|
|
18
|
+
# so a new app has a working page with no setup. The engine knows no typeface or tagline, so those parts are
|
|
19
|
+
# simply absent until declared.
|
|
20
|
+
#
|
|
21
|
+
# A FILE THAT IS NOT THERE IS NOT AN ERROR. Each file is looked for before it is
|
|
22
|
+
# linked: a tone with no file has no tile and no link, and a logo with no file
|
|
23
|
+
# has no card. So the page shows no broken image, and it does not raise.
|
|
24
|
+
#
|
|
25
|
+
# `light` means the artwork made for a light background; `dark`, for a dark one.
|
|
26
|
+
module Studio
|
|
27
|
+
module MarketingKit
|
|
28
|
+
PATH = "/marketing-kit"
|
|
29
|
+
LABEL = "Marketing Kit"
|
|
30
|
+
CONTROLLER = "studio/marketing_kit"
|
|
31
|
+
ROUTE_NAME = :studio_marketing_kit
|
|
32
|
+
|
|
33
|
+
LIGHT_GROUND = "#FFFFFF"
|
|
34
|
+
FALLBACK_DARK_GROUND = "#111827"
|
|
35
|
+
|
|
36
|
+
HEX = /\A#(?:\h{3}|\h{4}|\h{6}|\h{8})\z/
|
|
37
|
+
# What may sit inside a style attribute: a family list, and a CSS weight.
|
|
38
|
+
FONT_FAMILY = /\A[\w\s"',.-]+\z/
|
|
39
|
+
FONT_WEIGHT = /\A(?:[1-9]00|normal|bold|lighter|bolder)\z/
|
|
40
|
+
|
|
41
|
+
DOWNLOAD_LABELS = { light: "Download Light", dark: "Download Dark" }.freeze
|
|
42
|
+
FORMATS = %i[png svg].freeze
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
def validate!(declared)
|
|
47
|
+
return if declared.nil? || declared == false || declared.is_a?(Hash) || declared.respond_to?(:call)
|
|
48
|
+
|
|
49
|
+
raise ArgumentError,
|
|
50
|
+
"Studio.marketing_kit must be nil (derive it), a Hash, or a callable receiving the view " \
|
|
51
|
+
"(got #{declared.class}). See docs/SITE_FOOTER.md."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The kit the page prints.
|
|
55
|
+
#
|
|
56
|
+
# logos: Studio.theme_logos, for a kit that declares no `logos`
|
|
57
|
+
# artwork: brand artwork of one file per theme, [ title, { light:, dark: } ]
|
|
58
|
+
# rows that come before them (the wordmark, the lockup)
|
|
59
|
+
# theme: the colours the app paints, for a kit that declares no `palette`
|
|
60
|
+
# name: the site's name, for a kit that declares none
|
|
61
|
+
# dark_ground: the dark tile's background
|
|
62
|
+
# locate: a callable answering a file's URL, or nil when it is not there
|
|
63
|
+
def resolve(declared, view, logos: [], artwork: [], theme: {}, name: nil, dark_ground: nil,
|
|
64
|
+
locate: ->(file) { locate(file, view) })
|
|
65
|
+
raw = declared.respond_to?(:call) ? declared.call(view) : declared
|
|
66
|
+
validate!(raw)
|
|
67
|
+
facts = raw ? SiteFooter.symbolize(raw) : {}
|
|
68
|
+
ground = hex(dark_ground) || FALLBACK_DARK_GROUND
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
name: SiteFooter.text(facts[:name]) || SiteFooter.text(name),
|
|
72
|
+
tagline: SiteFooter.text(facts[:tagline]),
|
|
73
|
+
logos: logo_cards(facts.key?(:logos) ? facts[:logos] : derived_artwork(artwork) + derived_logos(logos), locate, ground),
|
|
74
|
+
palette: swatches(facts.key?(:palette) ? facts[:palette] : derived_palette(theme)),
|
|
75
|
+
typefaces: typefaces(facts[:typefaces])
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# ---- logos ---------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
# One card per logo that has a file, each with one tile per tone that has one.
|
|
82
|
+
# Two theme entries for one file (a Navbar Logo that is also the Auth Logo)
|
|
83
|
+
# are one card.
|
|
84
|
+
def logo_cards(declared, locate, dark_ground)
|
|
85
|
+
Array(declared).filter_map do |row|
|
|
86
|
+
next unless row.respond_to?(:to_h) && !row.is_a?(Array)
|
|
87
|
+
|
|
88
|
+
logo = SiteFooter.symbolize(row)
|
|
89
|
+
tones = { light: LIGHT_GROUND, dark: dark_ground }.filter_map do |tone, ground|
|
|
90
|
+
tile(logo[:title], tone, ground, logo[tone], locate)
|
|
91
|
+
end
|
|
92
|
+
{ title: SiteFooter.text(logo[:title]), tones: tones } unless tones.empty?
|
|
93
|
+
end.uniq { |card| card[:tones].map { |tone| tone[:href] } }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A download is the PNG when there is one, else the SVG. The tile shows the
|
|
97
|
+
# SVG when there is one, since it stays sharp at any size.
|
|
98
|
+
def tile(title, tone, ground, files, locate)
|
|
99
|
+
found = formats(files).transform_values { |file| locate.call(file) }.compact
|
|
100
|
+
href = found[:png] || found[:svg]
|
|
101
|
+
return nil if href.nil?
|
|
102
|
+
|
|
103
|
+
{ tone: tone, ground: ground, label: DOWNLOAD_LABELS.fetch(tone), href: href,
|
|
104
|
+
src: tile_src(found[:svg] || found[:png]),
|
|
105
|
+
alt: [SiteFooter.text(title), "for a #{tone} background"].compact.join(", ") }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# { png:, svg: }, or one path whose extension says which it is.
|
|
109
|
+
def formats(files)
|
|
110
|
+
return SiteFooter.symbolize(files).slice(*FORMATS) if files.is_a?(Hash)
|
|
111
|
+
return {} unless files.is_a?(String)
|
|
112
|
+
|
|
113
|
+
format = File.extname(files.split(/[?#]/).first.to_s).delete_prefix(".").downcase.to_sym
|
|
114
|
+
FORMATS.include?(format) ? { format => files } : {}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# ?kit keeps a tile's tone. A host may repaint its own logo by the image's
|
|
118
|
+
# exact src on a dark page (the hub's `.dark img[src="/brand/icon-light.svg"]`),
|
|
119
|
+
# which would turn the light tile's artwork into the dark one. The download
|
|
120
|
+
# keeps the plain URL.
|
|
121
|
+
def tile_src(url)
|
|
122
|
+
return url if url.match?(%r{\Ahttps?://}i)
|
|
123
|
+
|
|
124
|
+
"#{url}#{url.include?('?') ? '&' : '?'}kit"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Artwork that already names its file per tone, as the wordmark and the
|
|
128
|
+
# lockup do. Each file may have its other format beside it.
|
|
129
|
+
def derived_artwork(rows)
|
|
130
|
+
Array(rows).filter_map do |title, files|
|
|
131
|
+
next unless files.respond_to?(:to_h) && !files.is_a?(Array)
|
|
132
|
+
|
|
133
|
+
tones = SiteFooter.symbolize(files).slice(:light, :dark).compact
|
|
134
|
+
{ title: title }.merge(tones.transform_values { |file| both_formats(file.to_s.delete_suffix(File.extname(file.to_s))) })
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The logos Studio.theme_logos implies. It names one file per logo and says
|
|
139
|
+
# nothing about tone, so the rest is looked for beside it: the same stem as
|
|
140
|
+
# the other format, and a -light/-dark (or _light/_dark) sibling. A stem with
|
|
141
|
+
# no such hint is the light artwork.
|
|
142
|
+
def derived_logos(theme_logos)
|
|
143
|
+
entries = Array(theme_logos).map { |entry| entry.is_a?(Hash) ? SiteFooter.symbolize(entry) : { file: entry } }
|
|
144
|
+
entries.filter_map do |entry|
|
|
145
|
+
file = SiteFooter.text(entry[:file])
|
|
146
|
+
next if file.nil?
|
|
147
|
+
|
|
148
|
+
title = SiteFooter.text(entry[:title]) || humanize(file)
|
|
149
|
+
next if title.match?(/favicon/i)
|
|
150
|
+
|
|
151
|
+
{ title: title }.merge(tone_candidates(file))
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def tone_candidates(file)
|
|
156
|
+
stem = file.delete_suffix(File.extname(file))
|
|
157
|
+
base, separator, hint = stem.match(/\A(.+)([-_])(light|dark)\z/)&.captures
|
|
158
|
+
|
|
159
|
+
return { light: both_formats(stem), dark: both_formats("#{stem}-dark") } if hint.nil?
|
|
160
|
+
|
|
161
|
+
other = hint == "light" ? "dark" : "light"
|
|
162
|
+
{ hint.to_sym => both_formats(stem), other.to_sym => both_formats("#{base}#{separator}#{other}") }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def both_formats(stem) = { png: "#{stem}.png", svg: "#{stem}.svg" }
|
|
166
|
+
|
|
167
|
+
def humanize(file)
|
|
168
|
+
File.basename(file, File.extname(file)).tr("-_", " ").split.map(&:capitalize).join(" ")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# ---- colours and type ----------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def derived_palette(theme)
|
|
174
|
+
theme.to_h.map { |name, value| { name: name.to_s.capitalize, hex: value } }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Only a real hex is printed: it goes into a style attribute and the clipboard.
|
|
178
|
+
def swatches(declared)
|
|
179
|
+
Array(declared).filter_map do |row|
|
|
180
|
+
next unless row.respond_to?(:to_h) && !row.is_a?(Array)
|
|
181
|
+
|
|
182
|
+
swatch = SiteFooter.symbolize(row)
|
|
183
|
+
value = hex(swatch[:hex])
|
|
184
|
+
{ name: SiteFooter.text(swatch[:name]), hex: value } unless value.nil?
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def hex(value)
|
|
189
|
+
string = value.to_s.strip
|
|
190
|
+
string.match?(HEX) ? string : nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# A face with no family cannot be shown. A family or weight CSS would not
|
|
194
|
+
# read leaves the specimen in the page's own type, with its label intact.
|
|
195
|
+
def typefaces(declared)
|
|
196
|
+
Array(declared).filter_map do |row|
|
|
197
|
+
next unless row.respond_to?(:to_h) && !row.is_a?(Array)
|
|
198
|
+
|
|
199
|
+
face = SiteFooter.symbolize(row)
|
|
200
|
+
family = SiteFooter.text(face[:family])
|
|
201
|
+
next if family.nil?
|
|
202
|
+
|
|
203
|
+
{ role: SiteFooter.text(face[:role]), family: (family if family.match?(FONT_FAMILY)),
|
|
204
|
+
weight: face[:weight].to_s[FONT_WEIGHT], name: SiteFooter.text(face[:name]) || family }
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# ---- finding a file ------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
# The URL of a file, or nil when it is not there. An http(s) URL is taken at
|
|
211
|
+
# its word, since looking would cost the page a network call. A path is
|
|
212
|
+
# looked for in public/; a bare name, then in the asset pipeline.
|
|
213
|
+
def locate(file, view = nil)
|
|
214
|
+
value = SiteFooter.text(file)
|
|
215
|
+
return nil if value.nil?
|
|
216
|
+
return value if value.match?(%r{\Ahttps?://[^\s]+\z}i)
|
|
217
|
+
return nil unless local?(value)
|
|
218
|
+
|
|
219
|
+
path = "/#{value.delete_prefix('/')}"
|
|
220
|
+
return path if public_file?(path)
|
|
221
|
+
|
|
222
|
+
value.start_with?("/") ? nil : pipeline_path(value, view)
|
|
223
|
+
rescue StandardError
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# No scheme, no host, no control character and no climbing out of public/.
|
|
228
|
+
def local?(value)
|
|
229
|
+
return false if value.match?(/[\x00-\x1f\x7f\s]/) || value.start_with?("//")
|
|
230
|
+
return false if value.match?(/\A[a-z][a-z0-9+.-]*:/i)
|
|
231
|
+
|
|
232
|
+
!value.split(/[?#]/).first.to_s.split("/").include?("..")
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def public_file?(path)
|
|
236
|
+
!PublicFileVersion.version(path.split(/[?#]/).first).nil?
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def pipeline_path(name, view)
|
|
240
|
+
return nil unless view.respond_to?(:asset_path) && in_pipeline?(name)
|
|
241
|
+
|
|
242
|
+
view.asset_path(name)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Propshaft answers through its load path; Sprockets through its environment
|
|
246
|
+
# or, precompiled, its manifest. asset_path alone cannot say: a host may have
|
|
247
|
+
# it fall back to a plain path for a file that is not there.
|
|
248
|
+
def in_pipeline?(name)
|
|
249
|
+
return false unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
250
|
+
|
|
251
|
+
app = ::Rails.application
|
|
252
|
+
assets = app.respond_to?(:assets) ? app.assets : nil
|
|
253
|
+
return !assets.load_path.find(name).nil? if assets.respond_to?(:load_path)
|
|
254
|
+
return !assets.find_asset(name).nil? if assets.respond_to?(:find_asset)
|
|
255
|
+
|
|
256
|
+
manifest = app.respond_to?(:assets_manifest) ? app.assets_manifest : nil
|
|
257
|
+
manifest.respond_to?(:assets) && manifest.assets.key?(name)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# ---- the route -----------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
# True when the route set holds a GET of /marketing-kit that is not the
|
|
263
|
+
# engine's: the host's own page, which wins. Asked for every footer on a page
|
|
264
|
+
# that has no engine route, and by the engine route's constraint, so the
|
|
265
|
+
# answer is kept per route set until that set changes size.
|
|
266
|
+
def host_route?(routes)
|
|
267
|
+
table = routes.routes
|
|
268
|
+
known = host_route_memo[routes]
|
|
269
|
+
return known.last if known && known.first == table.size
|
|
270
|
+
|
|
271
|
+
answer = table.any? { |route| host_page_route?(route) }
|
|
272
|
+
host_route_memo[routes] = [table.size, answer]
|
|
273
|
+
answer
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def host_route_memo
|
|
277
|
+
@host_route_memo ||= {}.compare_by_identity
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def host_page_route?(route)
|
|
281
|
+
return false if route.defaults[:controller] == CONTROLLER
|
|
282
|
+
return false unless route.path.spec.to_s.delete_suffix("(.:format)") == PATH
|
|
283
|
+
|
|
284
|
+
verbs = route.verb.to_s
|
|
285
|
+
verbs.empty? || verbs.split("|").include?("GET")
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# The engine's route gives way to a host route drawn AFTER Studio.routes.
|
|
289
|
+
# (One drawn before it wins by order, and then the engine draws nothing.)
|
|
290
|
+
class YieldToHostRoute
|
|
291
|
+
def initialize(routes) = @routes = routes
|
|
292
|
+
def matches?(_request) = !MarketingKit.host_route?(@routes)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Where the footer links: the page's path, or nil when this app has no page.
|
|
296
|
+
def page_path(view, routes)
|
|
297
|
+
return nil if routes.nil?
|
|
298
|
+
|
|
299
|
+
SiteFooter.host_path(view, ROUTE_NAME, routes) || (PATH if host_route?(routes))
|
|
300
|
+
rescue StandardError
|
|
301
|
+
nil
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
data/lib/studio/site_footer.rb
CHANGED
|
@@ -31,8 +31,8 @@ module Studio
|
|
|
31
31
|
DEFAULT_BOOKING_LABEL = "Schedule a call"
|
|
32
32
|
DEFAULT_ZOOM = 15
|
|
33
33
|
|
|
34
|
-
# The engine's own
|
|
35
|
-
OWN_CONTROLLERS = %w[studio/bookings].freeze
|
|
34
|
+
# The engine's own public pages keep the footer for a signed-in viewer.
|
|
35
|
+
OWN_CONTROLLERS = %w[studio/bookings studio/marketing_kit].freeze
|
|
36
36
|
|
|
37
37
|
# The only schemes a footer href may carry. Anything else, notably
|
|
38
38
|
# `javascript:` and `data:`, is never written into an href: the label is
|
|
@@ -124,7 +124,7 @@ module Studio
|
|
|
124
124
|
# THE DEFAULT FOOTER'S FACTS: what an app that sets nothing gets. Only what
|
|
125
125
|
# is known to be true of the host: a legal line linking the routes in
|
|
126
126
|
# DEFAULT_LEGAL that the host really has. The name and the logo come from
|
|
127
|
-
# `resolve`'s own fallbacks. No address, no email, no tagline, no social row
|
|
127
|
+
# `resolve`'s own fallbacks, and so does the Marketing Kit link. No address, no email, no tagline, no social row
|
|
128
128
|
# and no link columns: the engine cannot know them, and a guessed link is a
|
|
129
129
|
# dead one.
|
|
130
130
|
#
|
|
@@ -170,13 +170,17 @@ module Studio
|
|
|
170
170
|
# booking_path: the booking page's path (Studio.booking_path_for), or nil
|
|
171
171
|
# address: Studio.site_footer_address: the address of facts that
|
|
172
172
|
# carry none of their own
|
|
173
|
-
|
|
173
|
+
# chat: the Chat entry's [label, path] (`chat_row`), or nil
|
|
174
|
+
# marketing_kit: the kit page's path (Studio::MarketingKit.page_path), or nil
|
|
175
|
+
def resolve(declared, view, name: nil, logo: nil, booking_path: nil, address: nil, chat: nil, marketing_kit: nil)
|
|
174
176
|
raw = declared.respond_to?(:call) ? declared.call(view) : declared
|
|
175
177
|
return nil if raw.nil? || raw == false
|
|
176
178
|
|
|
177
179
|
facts = symbolize(raw)
|
|
178
180
|
site_name = text(facts[:name]) || text(name)
|
|
179
181
|
booking = symbolize(facts[:booking] || {})
|
|
182
|
+
columns = with_chat(Array(facts[:columns]).filter_map { |row| column(row, booking_path) }, chat)
|
|
183
|
+
legal = Array(facts[:legal]).filter_map { |row| link(row, booking_path) }
|
|
180
184
|
|
|
181
185
|
{
|
|
182
186
|
name: site_name,
|
|
@@ -189,8 +193,9 @@ module Studio
|
|
|
189
193
|
email: text(facts[:email]),
|
|
190
194
|
address: address_for(facts, address),
|
|
191
195
|
social: Array(facts[:social]).filter_map { |row| social(row) },
|
|
192
|
-
columns:
|
|
193
|
-
legal:
|
|
196
|
+
columns: columns,
|
|
197
|
+
legal: legal,
|
|
198
|
+
marketing_kit: marketing_kit_link(facts, marketing_kit, columns, legal),
|
|
194
199
|
booking_label: text(booking[:label]) || DEFAULT_BOOKING_LABEL,
|
|
195
200
|
booking_title: text(booking[:title])
|
|
196
201
|
}
|
|
@@ -374,6 +379,60 @@ module Studio
|
|
|
374
379
|
{ heading: heading, links: links, width: column_width(options[:width]) }
|
|
375
380
|
end
|
|
376
381
|
|
|
382
|
+
# The column the standard Chat entry joins, made if the app has none.
|
|
383
|
+
CONTACT_HEADING = "Contact"
|
|
384
|
+
|
|
385
|
+
# The Chat entry for this viewer as a [label, path] row, or nil: no path
|
|
386
|
+
# means the app has no chat, and `allowed` (a callable taking the viewer, nil
|
|
387
|
+
# when signed out) can hide it, e.g. admins only while testing. A hook that
|
|
388
|
+
# raises hides the entry and logs; the footer is on every page, and a link
|
|
389
|
+
# meant for a few must not show because its gate broke.
|
|
390
|
+
def chat_row(view, path:, label: nil, allowed: nil)
|
|
391
|
+
href = path.respond_to?(:call) ? path.call(view) : path
|
|
392
|
+
return nil if text(href).nil?
|
|
393
|
+
return nil unless chat_allowed?(view, allowed)
|
|
394
|
+
|
|
395
|
+
[text(label) || "Chat", href]
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def chat_allowed?(view, allowed)
|
|
399
|
+
return true if allowed.nil?
|
|
400
|
+
|
|
401
|
+
user = view.respond_to?(:current_user) ? view.current_user : nil
|
|
402
|
+
(allowed.respond_to?(:call) ? allowed.call(user) : allowed) ? true : false
|
|
403
|
+
rescue StandardError => error
|
|
404
|
+
SiteFooter.reporter.call("[studio.site_footer] site_footer_chat_allowed raised #{error.class}: the Chat link is hidden")
|
|
405
|
+
false
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# `columns` with the Chat entry last in the Contact column (any case), which
|
|
409
|
+
# is added at the end if there is none. A refused href adds nothing, and an
|
|
410
|
+
# app that already links the same path keeps its own row.
|
|
411
|
+
def with_chat(columns, chat)
|
|
412
|
+
row = chat && link(chat)
|
|
413
|
+
return columns if row.nil? || row[:href].nil?
|
|
414
|
+
|
|
415
|
+
at = columns.index { |column| column[:heading].to_s.casecmp?(CONTACT_HEADING) }
|
|
416
|
+
return columns + [{ heading: CONTACT_HEADING, links: [row], width: nil }] if at.nil?
|
|
417
|
+
return columns if columns[at][:links].any? { |known| known[:href] == row[:href] }
|
|
418
|
+
|
|
419
|
+
columns.each_with_index.map do |column, index|
|
|
420
|
+
index == at ? column.merge(links: column[:links] + [row]) : column
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# THE MARKETING KIT LINK, in the brand block: every app with the page gets it
|
|
425
|
+
# without declaring it. An app that links the page itself keeps its own row
|
|
426
|
+
# and gets no second one, and `marketing_kit: false` in the facts shows none.
|
|
427
|
+
def marketing_kit_link(facts, path, columns, legal)
|
|
428
|
+
return nil if path.nil? || (facts.key?(:marketing_kit) && !facts[:marketing_kit])
|
|
429
|
+
|
|
430
|
+
declared = columns.flat_map { |column| column[:links] } + legal
|
|
431
|
+
return nil if declared.any? { |row| Studio::Booking.same_path?(row[:href], path) }
|
|
432
|
+
|
|
433
|
+
link([ Studio::MarketingKit::LABEL, path ])
|
|
434
|
+
end
|
|
435
|
+
|
|
377
436
|
# A column's width hint, or nil for the default share. Anything that is not a
|
|
378
437
|
# number between MIN_COLUMN_WIDTH and MAX_COLUMN_WIDTH is no hint.
|
|
379
438
|
MIN_COLUMN_WIDTH = 0.5
|
data/lib/studio/ui_primitives.rb
CHANGED
|
@@ -10,8 +10,10 @@ module Studio
|
|
|
10
10
|
align-items: center;
|
|
11
11
|
justify-content: center;
|
|
12
12
|
vertical-align: middle;
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
/* In em so a larger glyph fits: at text-lg on a 16px scale a fixed 1.25rem
|
|
14
|
+
box clipped 4px off a 24px emoji. The floor keeps the navbar size. */
|
|
15
|
+
width: max(1.5rem, 1.25em);
|
|
16
|
+
height: max(1.25rem, 1.25em);
|
|
15
17
|
overflow: hidden;
|
|
16
18
|
}
|
|
17
19
|
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -34,6 +34,7 @@ require "studio/hold_button"
|
|
|
34
34
|
require "studio/partial_block"
|
|
35
35
|
require "studio/sidebar_sections"
|
|
36
36
|
require "studio/site_footer"
|
|
37
|
+
require "studio/marketing_kit"
|
|
37
38
|
require "studio/booking"
|
|
38
39
|
require "studio/navbar_links"
|
|
39
40
|
require "studio/navbar_signed_out_cta"
|
|
@@ -159,6 +160,23 @@ module Studio
|
|
|
159
160
|
# no-preview holds the old page until the fresh response arrives.
|
|
160
161
|
mattr_accessor :smooth_load, default: false
|
|
161
162
|
|
|
163
|
+
# ---- App-to-app page transitions -----------------------------------------
|
|
164
|
+
# One of :left, :right, :top, :bottom, or nil (the default: nothing changes).
|
|
165
|
+
# The way this app's pages move on every page change (docs/PAGE_TRANSITIONS.md).
|
|
166
|
+
# It is THE one switch: a set direction also turns smooth-load on, whatever
|
|
167
|
+
# Studio.smooth_load says, because the view transition is what it styles.
|
|
168
|
+
PAGE_TRANSITION_DIRECTIONS = %i[left right top bottom].freeze
|
|
169
|
+
mattr_reader :page_transition_direction, default: nil
|
|
170
|
+
|
|
171
|
+
def self.page_transition_direction=(value)
|
|
172
|
+
direction = value.presence&.to_sym
|
|
173
|
+
unless direction.nil? || PAGE_TRANSITION_DIRECTIONS.include?(direction)
|
|
174
|
+
raise ArgumentError, "Studio.page_transition_direction must be one of " \
|
|
175
|
+
"#{PAGE_TRANSITION_DIRECTIONS.join(', ')} or nil (got #{value.inspect})"
|
|
176
|
+
end
|
|
177
|
+
@@page_transition_direction = direction
|
|
178
|
+
end
|
|
179
|
+
|
|
162
180
|
# Minimum ms the nav spinner stays visible once shown (_head.html.erb).
|
|
163
181
|
# Apps wrapping multi-second operations in the spinner (e.g. Solana RPC)
|
|
164
182
|
# keep the high default to avoid flicker; smooth-load apps typically drop
|
|
@@ -1471,7 +1489,7 @@ module Studio
|
|
|
1471
1489
|
mattr_accessor :agent_chat_souls, default: []
|
|
1472
1490
|
mattr_accessor :agent_chat_hub_url, default: "https://mcritchie.studio"
|
|
1473
1491
|
# The app's bearer credential for the hub. Leave nil to read
|
|
1474
|
-
# ENV["AGENT_CHAT_TOKEN"] (1Password
|
|
1492
|
+
# ENV["AGENT_CHAT_TOKEN"] (1Password agent-chat.<slug>.applications).
|
|
1475
1493
|
mattr_accessor :agent_chat_token, default: nil
|
|
1476
1494
|
# Which signed-in users the hub may know by email. An email the user only
|
|
1477
1495
|
# CLAIMS (the profile page saves a new address and clears email_verified_at;
|
|
@@ -1486,6 +1504,50 @@ module Studio
|
|
|
1486
1504
|
Array(agent_chat_souls).map { |soul| soul.to_s.strip }.reject(&:empty?).uniq
|
|
1487
1505
|
end
|
|
1488
1506
|
|
|
1507
|
+
# Who may use the chat at all: a callable taking the user, nil when signed out.
|
|
1508
|
+
# The server's gate, not only the look: Studio::AgentChatsController answers a
|
|
1509
|
+
# refused viewer 403 on both calls before any hub call, and the chat partial
|
|
1510
|
+
# renders nothing for them. The footer's Chat link follows it too unless
|
|
1511
|
+
# site_footer_chat_allowed is set, so one setting covers both. Default: everyone
|
|
1512
|
+
# (a public chat). While testing:
|
|
1513
|
+
#
|
|
1514
|
+
# config.agent_chat_allowed = ->(user) { user&.admin? }
|
|
1515
|
+
mattr_accessor :agent_chat_allowed, default: ->(_user) { true }
|
|
1516
|
+
|
|
1517
|
+
# The one reading of Studio.agent_chat_allowed. A hook that raises answers false
|
|
1518
|
+
# and logs: `user.admin?` on a signed-out visitor closes the chat, never opens it.
|
|
1519
|
+
def self.agent_chat_allowed?(user)
|
|
1520
|
+
hook = agent_chat_allowed
|
|
1521
|
+
(hook.respond_to?(:call) ? hook.call(user) : hook) ? true : false
|
|
1522
|
+
rescue StandardError => e
|
|
1523
|
+
Rails.logger&.warn("[studio] agent_chat_allowed raised #{e.class}: treated as not allowed") if defined?(Rails.logger)
|
|
1524
|
+
false
|
|
1525
|
+
end
|
|
1526
|
+
|
|
1527
|
+
# Who may talk by voice (docs/AGENT_CHAT.md, "Talking by voice"): a callable
|
|
1528
|
+
# taking the signed-in user. It decides who sees the chat's microphone, who
|
|
1529
|
+
# may post a take to POST /voice/takes (each one is a paid vendor call), and
|
|
1530
|
+
# whether the proxy forwards a message's `voice`. A signed-out visitor is
|
|
1531
|
+
# never asked about: no user, no voice. While testing, an app narrows it:
|
|
1532
|
+
#
|
|
1533
|
+
# config.voice_chat_allowed = ->(user) { user.admin? }
|
|
1534
|
+
#
|
|
1535
|
+
# A user Studio.agent_chat_allowed refuses may not talk either, whatever this says.
|
|
1536
|
+
mattr_accessor :voice_chat_allowed, default: ->(user) { user.present? }
|
|
1537
|
+
|
|
1538
|
+
# The one reading of Studio.voice_chat_allowed. False for no user, and for a user
|
|
1539
|
+
# the chat itself refuses, whatever the hook says. A hook that raises answers false and logs: the safe answer on a
|
|
1540
|
+
# paid, sign-in-only control, and a page never falls over because of it.
|
|
1541
|
+
def self.voice_chat_allowed?(user)
|
|
1542
|
+
return false if user.nil? || !agent_chat_allowed?(user)
|
|
1543
|
+
|
|
1544
|
+
hook = voice_chat_allowed
|
|
1545
|
+
(hook.respond_to?(:call) ? hook.call(user) : hook) ? true : false
|
|
1546
|
+
rescue StandardError => e
|
|
1547
|
+
Rails.logger&.warn("[studio] voice_chat_allowed raised #{e.class}: treated as not allowed") if defined?(Rails.logger)
|
|
1548
|
+
false
|
|
1549
|
+
end
|
|
1550
|
+
|
|
1489
1551
|
class S3ConfigError < StandardError; end
|
|
1490
1552
|
|
|
1491
1553
|
# Whether to validate the host app's User model at boot. See docs/USER_CONTRACT.md.
|
|
@@ -1733,6 +1795,20 @@ module Studio
|
|
|
1733
1795
|
SidebarSections.resolve(sidebar_sections, view)
|
|
1734
1796
|
end
|
|
1735
1797
|
|
|
1798
|
+
# The standard Chat entry in the footer's Contact column (docs/SITE_FOOTER.md,
|
|
1799
|
+
# "The Chat link"). OFF until the app names a path: no path, no entry.
|
|
1800
|
+
#
|
|
1801
|
+
# config.site_footer_chat_path = "/chat" # or ->(view) { view.chat_path }
|
|
1802
|
+
# config.site_footer_chat_label = "Chat" # the default
|
|
1803
|
+
# config.site_footer_chat_allowed = ->(user) { user&.admin? } # default: Studio.agent_chat_allowed
|
|
1804
|
+
#
|
|
1805
|
+
# site_footer_chat_allowed takes the viewer, nil when signed out; a hook that
|
|
1806
|
+
# raises hides the entry and logs (SiteFooter.chat_row). Unset, the link follows
|
|
1807
|
+
# Studio.agent_chat_allowed, so the link shows to whom the chat answers.
|
|
1808
|
+
mattr_accessor :site_footer_chat_path, default: nil
|
|
1809
|
+
mattr_accessor :site_footer_chat_label, default: "Chat"
|
|
1810
|
+
mattr_accessor :site_footer_chat_allowed, default: nil
|
|
1811
|
+
|
|
1736
1812
|
# The site footer's facts resolved for a view (lib/studio/site_footer.rb), or
|
|
1737
1813
|
# nil when the app turned the footer off (or its own callable answered nil).
|
|
1738
1814
|
# Unset, the facts are the default footer's. The name defaults to the site
|
|
@@ -1744,7 +1820,11 @@ module Studio
|
|
|
1744
1820
|
|
|
1745
1821
|
declared = site_footer.nil? ? SiteFooter.default_facts(view, routes: host_route_set) : site_footer
|
|
1746
1822
|
SiteFooter.resolve(declared, view, name: site_identity[:title], logo: logo_for("Footer Logo"),
|
|
1747
|
-
booking_path: booking_path_for(view), address: site_footer_address
|
|
1823
|
+
booking_path: booking_path_for(view), address: site_footer_address,
|
|
1824
|
+
chat: SiteFooter.chat_row(view, path: site_footer_chat_path,
|
|
1825
|
+
label: site_footer_chat_label,
|
|
1826
|
+
allowed: site_footer_chat_allowed.nil? ? agent_chat_allowed : site_footer_chat_allowed),
|
|
1827
|
+
marketing_kit: MarketingKit.page_path(view, host_route_set))
|
|
1748
1828
|
end
|
|
1749
1829
|
|
|
1750
1830
|
# The host's route set, for the default footer's legal links. nil outside a
|
|
@@ -1755,6 +1835,65 @@ module Studio
|
|
|
1755
1835
|
::Rails.application.routes
|
|
1756
1836
|
end
|
|
1757
1837
|
|
|
1838
|
+
# The marketing kit: the public page at /marketing-kit (lib/studio/marketing_kit.rb,
|
|
1839
|
+
# docs/SITE_FOOTER.md). A Hash, or a callable receiving the view. Unset, the logos
|
|
1840
|
+
# and colours derive from the app's own brand artwork and the colours it paints,
|
|
1841
|
+
# so a new app has a working page with no setup.
|
|
1842
|
+
mattr_reader :marketing_kit, default: nil
|
|
1843
|
+
|
|
1844
|
+
def self.marketing_kit=(declared)
|
|
1845
|
+
MarketingKit.validate!(declared)
|
|
1846
|
+
@@marketing_kit = declared
|
|
1847
|
+
end
|
|
1848
|
+
|
|
1849
|
+
# Whether Studio.routes draws /marketing-kit (Studio::MarketingKitController).
|
|
1850
|
+
#
|
|
1851
|
+
# ON BY DEFAULT: every app should have a kit. The helper is
|
|
1852
|
+
# studio_marketing_kit_path, not marketing_kit_path, because the hub already
|
|
1853
|
+
# owns that name, and a second claim on it raises `Invalid route name, already
|
|
1854
|
+
# in use` and takes down the host's route set (see draw_admin_emails_routes).
|
|
1855
|
+
# The PATH can be shared: a host's own /marketing-kit wins, whichever side of
|
|
1856
|
+
# Studio.routes it is drawn on (Studio.marketing_kit_routes).
|
|
1857
|
+
mattr_accessor :draw_marketing_kit_routes, default: true
|
|
1858
|
+
|
|
1859
|
+
# The kit for a view. `declared` lets a page render a kit other than the app's.
|
|
1860
|
+
def self.marketing_kit_for(view, declared: marketing_kit)
|
|
1861
|
+
colors = marketing_kit_colors
|
|
1862
|
+
MarketingKit.resolve(declared, view, logos: theme_logos, theme: colors,
|
|
1863
|
+
artwork: [["Navbar logo", navbar_wordmark], ["Footer logo", marketing_kit_lockup(view)]],
|
|
1864
|
+
name: site_identity[:title], dark_ground: colors[:dark])
|
|
1865
|
+
end
|
|
1866
|
+
|
|
1867
|
+
# What the app paints: an /admin/theme edit included, as studio_theme_css_tag
|
|
1868
|
+
# reads it. An app with no database, or no table yet, paints its config.
|
|
1869
|
+
def self.marketing_kit_colors
|
|
1870
|
+
return theme_config unless active_record? && defined?(::ThemeSetting)
|
|
1871
|
+
|
|
1872
|
+
::ThemeSetting.current.resolved_colors
|
|
1873
|
+
rescue StandardError
|
|
1874
|
+
theme_config
|
|
1875
|
+
end
|
|
1876
|
+
|
|
1877
|
+
# The footer's lockup, already resolved for this request where the view is one.
|
|
1878
|
+
def self.marketing_kit_lockup(view)
|
|
1879
|
+
view.studio_site_footer_facts&.dig(:lockup) if view.respond_to?(:studio_site_footer_facts)
|
|
1880
|
+
end
|
|
1881
|
+
|
|
1882
|
+
# Draws the page into `router`, unless the host's own /marketing-kit got there
|
|
1883
|
+
# first: then this draws nothing, and the host's page is the only route. A host
|
|
1884
|
+
# route drawn later is handled at request time, by the route's constraint.
|
|
1885
|
+
#
|
|
1886
|
+
# Studio.routes calls this. An app with no database cannot call Studio.routes
|
|
1887
|
+
# (docs/SITE_FOOTER.md, "An app with no database") and calls this alone.
|
|
1888
|
+
def self.marketing_kit_routes(router)
|
|
1889
|
+
router.instance_exec do
|
|
1890
|
+
next if MarketingKit.host_route?(@set)
|
|
1891
|
+
|
|
1892
|
+
get MarketingKit::PATH.delete_prefix("/"), to: "#{MarketingKit::CONTROLLER}#show", as: MarketingKit::ROUTE_NAME,
|
|
1893
|
+
format: false, constraints: MarketingKit::YieldToHostRoute.new(@set)
|
|
1894
|
+
end
|
|
1895
|
+
end
|
|
1896
|
+
|
|
1758
1897
|
# Navbar links resolved for a view context (lib/studio/navbar_links.rb).
|
|
1759
1898
|
def self.navbar_links_for(view)
|
|
1760
1899
|
NavbarLinks.resolve(navbar_links, view)
|
|
@@ -2051,11 +2190,12 @@ module Studio
|
|
|
2051
2190
|
get "schedule", to: "studio/bookings#show", as: :studio_booking
|
|
2052
2191
|
end
|
|
2053
2192
|
|
|
2054
|
-
# The
|
|
2055
|
-
#
|
|
2056
|
-
|
|
2193
|
+
# The marketing kit (/marketing-kit): public, ON by default. A host's own
|
|
2194
|
+
# page of the same path wins. See Studio.marketing_kit_routes.
|
|
2195
|
+
Studio.marketing_kit_routes(self) if Studio.draw_marketing_kit_routes
|
|
2196
|
+
|
|
2197
|
+
# The living style guide (StyleController#index).
|
|
2057
2198
|
get "admin/style", to: "style#index", as: :admin_style
|
|
2058
|
-
get "admin/design_system", to: redirect("/admin/style"), as: :admin_design_system
|
|
2059
2199
|
|
|
2060
2200
|
# The component gallery: Lookbook over the engine's ViewComponent previews.
|
|
2061
2201
|
# The router refuses anyone but a signed-in admin, so a visitor and a
|