studio-engine 0.123.0 → 0.125.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 +95 -0
- data/README.md +16 -1
- data/app/assets/javascripts/studio/page_transition_reveal.js +75 -0
- data/app/assets/stylesheets/studio/page_transition_full_load.css +10 -0
- data/app/assets/tailwind/studio_engine/engine.css +216 -11
- data/app/controllers/studio/marketing_kit_controller.rb +24 -0
- data/app/helpers/studio/site_footer_helper.rb +15 -2
- 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/page_header.js +24 -0
- data/app/javascript/studio/page_transitions.js +263 -0
- data/app/javascript/studio/pinned_stack.js +12 -1
- data/app/javascript/studio/stimulus.js +6 -1
- data/app/views/components/_link_sidebar.html.erb +6 -3
- 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 +17 -2
- 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/show.html.erb +10 -18
- data/app/views/studio/admin_surveys/index.html.erb +2 -2
- data/app/views/studio/agent_chat/_form.html.erb +1 -1
- data/app/views/studio/agent_chat/_header.html.erb +1 -1
- 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/style/_tricks.html.erb +35 -0
- data/app/views/style/index.html.erb +17 -28
- data/docs/SITE_FOOTER.md +113 -2
- data/lib/studio/engine.rb +5 -2
- data/lib/studio/marketing_kit.rb +304 -0
- data/lib/studio/site_footer.rb +22 -6
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +83 -1
- metadata +13 -1
|
@@ -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
|
#
|
|
@@ -171,13 +171,16 @@ module Studio
|
|
|
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
|
-
|
|
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)
|
|
175
176
|
raw = declared.respond_to?(:call) ? declared.call(view) : declared
|
|
176
177
|
return nil if raw.nil? || raw == false
|
|
177
178
|
|
|
178
179
|
facts = symbolize(raw)
|
|
179
180
|
site_name = text(facts[:name]) || text(name)
|
|
180
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) }
|
|
181
184
|
|
|
182
185
|
{
|
|
183
186
|
name: site_name,
|
|
@@ -190,8 +193,9 @@ module Studio
|
|
|
190
193
|
email: text(facts[:email]),
|
|
191
194
|
address: address_for(facts, address),
|
|
192
195
|
social: Array(facts[:social]).filter_map { |row| social(row) },
|
|
193
|
-
columns:
|
|
194
|
-
legal:
|
|
196
|
+
columns: columns,
|
|
197
|
+
legal: legal,
|
|
198
|
+
marketing_kit: marketing_kit_link(facts, marketing_kit, columns, legal),
|
|
195
199
|
booking_label: text(booking[:label]) || DEFAULT_BOOKING_LABEL,
|
|
196
200
|
booking_title: text(booking[:title])
|
|
197
201
|
}
|
|
@@ -417,6 +421,18 @@ module Studio
|
|
|
417
421
|
end
|
|
418
422
|
end
|
|
419
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
|
+
|
|
420
436
|
# A column's width hint, or nil for the default share. Anything that is not a
|
|
421
437
|
# number between MIN_COLUMN_WIDTH and MAX_COLUMN_WIDTH is no hint.
|
|
422
438
|
MIN_COLUMN_WIDTH = 0.5
|
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
|
|
@@ -1805,7 +1823,8 @@ module Studio
|
|
|
1805
1823
|
booking_path: booking_path_for(view), address: site_footer_address,
|
|
1806
1824
|
chat: SiteFooter.chat_row(view, path: site_footer_chat_path,
|
|
1807
1825
|
label: site_footer_chat_label,
|
|
1808
|
-
allowed: site_footer_chat_allowed.nil? ? agent_chat_allowed : site_footer_chat_allowed)
|
|
1826
|
+
allowed: site_footer_chat_allowed.nil? ? agent_chat_allowed : site_footer_chat_allowed),
|
|
1827
|
+
marketing_kit: MarketingKit.page_path(view, host_route_set))
|
|
1809
1828
|
end
|
|
1810
1829
|
|
|
1811
1830
|
# The host's route set, for the default footer's legal links. nil outside a
|
|
@@ -1816,6 +1835,65 @@ module Studio
|
|
|
1816
1835
|
::Rails.application.routes
|
|
1817
1836
|
end
|
|
1818
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
|
+
|
|
1819
1897
|
# Navbar links resolved for a view context (lib/studio/navbar_links.rb).
|
|
1820
1898
|
def self.navbar_links_for(view)
|
|
1821
1899
|
NavbarLinks.resolve(navbar_links, view)
|
|
@@ -2112,6 +2190,10 @@ module Studio
|
|
|
2112
2190
|
get "schedule", to: "studio/bookings#show", as: :studio_booking
|
|
2113
2191
|
end
|
|
2114
2192
|
|
|
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
|
+
|
|
2115
2197
|
# The living style guide (StyleController#index).
|
|
2116
2198
|
get "admin/style", to: "style#index", as: :admin_style
|
|
2117
2199
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.125.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -424,9 +424,11 @@ files:
|
|
|
424
424
|
- app/assets/javascripts/studio/alpine.js
|
|
425
425
|
- app/assets/javascripts/studio/canvas_confetti.js
|
|
426
426
|
- app/assets/javascripts/studio/leaflet.js
|
|
427
|
+
- app/assets/javascripts/studio/page_transition_reveal.js
|
|
427
428
|
- app/assets/javascripts/studio/session.js
|
|
428
429
|
- app/assets/javascripts/studio/sortable.js
|
|
429
430
|
- app/assets/stylesheets/studio/leaflet.css
|
|
431
|
+
- app/assets/stylesheets/studio/page_transition_full_load.css
|
|
430
432
|
- app/assets/stylesheets/studio/sticky_table_header.css
|
|
431
433
|
- app/assets/tailwind/studio_engine/engine-motion.css
|
|
432
434
|
- app/assets/tailwind/studio_engine/engine.css
|
|
@@ -466,6 +468,7 @@ files:
|
|
|
466
468
|
- app/controllers/studio/links_controller.rb
|
|
467
469
|
- app/controllers/studio/local_emails_controller.rb
|
|
468
470
|
- app/controllers/studio/local_reviews_controller.rb
|
|
471
|
+
- app/controllers/studio/marketing_kit_controller.rb
|
|
469
472
|
- app/controllers/studio/models_controller.rb
|
|
470
473
|
- app/controllers/studio/onboarding_controller.rb
|
|
471
474
|
- app/controllers/studio/profiles_controller.rb
|
|
@@ -503,6 +506,7 @@ files:
|
|
|
503
506
|
- app/javascript/studio/controllers/agent_chat_voice_controller.js
|
|
504
507
|
- app/javascript/studio/controllers/board_controller.js
|
|
505
508
|
- app/javascript/studio/controllers/booking_controller.js
|
|
509
|
+
- app/javascript/studio/controllers/copy_text_controller.js
|
|
506
510
|
- app/javascript/studio/controllers/email_banner_scale_controller.js
|
|
507
511
|
- app/javascript/studio/controllers/footer_map_controller.js
|
|
508
512
|
- app/javascript/studio/controllers/geo_settings_controller.js
|
|
@@ -514,6 +518,7 @@ files:
|
|
|
514
518
|
- app/javascript/studio/controllers/microphone_chip_controller.js
|
|
515
519
|
- app/javascript/studio/controllers/modal_host_controller.js
|
|
516
520
|
- app/javascript/studio/controllers/nav_collapse_controller.js
|
|
521
|
+
- app/javascript/studio/controllers/page_header_controller.js
|
|
517
522
|
- app/javascript/studio/controllers/style_modals_controller.js
|
|
518
523
|
- app/javascript/studio/controllers/style_section_nav_controller.js
|
|
519
524
|
- app/javascript/studio/controllers/survey_controller.js
|
|
@@ -545,6 +550,8 @@ files:
|
|
|
545
550
|
- app/javascript/studio/modal_host.js
|
|
546
551
|
- app/javascript/studio/nav_collapse.js
|
|
547
552
|
- app/javascript/studio/newsletter_modal.js
|
|
553
|
+
- app/javascript/studio/page_header.js
|
|
554
|
+
- app/javascript/studio/page_transitions.js
|
|
548
555
|
- app/javascript/studio/pinned_stack.js
|
|
549
556
|
- app/javascript/studio/profile_form.js
|
|
550
557
|
- app/javascript/studio/sticky_table_header.js
|
|
@@ -635,6 +642,7 @@ files:
|
|
|
635
642
|
- app/views/studio/_hold_button.html.erb
|
|
636
643
|
- app/views/studio/_hold_button_guard.html.erb
|
|
637
644
|
- app/views/studio/_leveling_activity_assets.html.erb
|
|
645
|
+
- app/views/studio/_page_header.html.erb
|
|
638
646
|
- app/views/studio/_session_stamp.html.erb
|
|
639
647
|
- app/views/studio/admin_models/_arenas_table.html.erb
|
|
640
648
|
- app/views/studio/admin_models/_sessions_table.html.erb
|
|
@@ -699,6 +707,9 @@ files:
|
|
|
699
707
|
- app/views/studio/loader/_overlay.html.erb
|
|
700
708
|
- app/views/studio/local_emails/index.html.erb
|
|
701
709
|
- app/views/studio/mailers/_layered_banner.html.erb
|
|
710
|
+
- app/views/studio/marketing_kit/_assets.html.erb
|
|
711
|
+
- app/views/studio/marketing_kit/_kit.html.erb
|
|
712
|
+
- app/views/studio/marketing_kit/show.html.erb
|
|
702
713
|
- app/views/studio/modals/_crop_photo.html.erb
|
|
703
714
|
- app/views/studio/modals/_host.html.erb
|
|
704
715
|
- app/views/studio/modals/_image_upload.html.erb
|
|
@@ -871,6 +882,7 @@ files:
|
|
|
871
882
|
- lib/studio/log_rotation.rb
|
|
872
883
|
- lib/studio/mail_sender.rb
|
|
873
884
|
- lib/studio/mail_transport.rb
|
|
885
|
+
- lib/studio/marketing_kit.rb
|
|
874
886
|
- lib/studio/name_parts.rb
|
|
875
887
|
- lib/studio/navbar_account_sidebar.rb
|
|
876
888
|
- lib/studio/navbar_brand.rb
|