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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +95 -0
  3. data/README.md +16 -1
  4. data/app/assets/javascripts/studio/page_transition_reveal.js +75 -0
  5. data/app/assets/stylesheets/studio/page_transition_full_load.css +10 -0
  6. data/app/assets/tailwind/studio_engine/engine.css +216 -11
  7. data/app/controllers/studio/marketing_kit_controller.rb +24 -0
  8. data/app/helpers/studio/site_footer_helper.rb +15 -2
  9. data/app/javascript/studio/controllers/copy_text_controller.js +63 -0
  10. data/app/javascript/studio/controllers/page_header_controller.js +60 -0
  11. data/app/javascript/studio/page_header.js +24 -0
  12. data/app/javascript/studio/page_transitions.js +263 -0
  13. data/app/javascript/studio/pinned_stack.js +12 -1
  14. data/app/javascript/studio/stimulus.js +6 -1
  15. data/app/views/components/_link_sidebar.html.erb +6 -3
  16. data/app/views/components/_user_nav.html.erb +5 -4
  17. data/app/views/layouts/_navbar.html.erb +7 -4
  18. data/app/views/layouts/studio/_head.html.erb +17 -2
  19. data/app/views/layouts/studio/_smooth_load.html.erb +11 -2
  20. data/app/views/studio/_page_header.html.erb +65 -0
  21. data/app/views/studio/admin_models/show.html.erb +10 -18
  22. data/app/views/studio/admin_surveys/index.html.erb +2 -2
  23. data/app/views/studio/agent_chat/_form.html.erb +1 -1
  24. data/app/views/studio/agent_chat/_header.html.erb +1 -1
  25. data/app/views/studio/marketing_kit/_assets.html.erb +44 -0
  26. data/app/views/studio/marketing_kit/_kit.html.erb +84 -0
  27. data/app/views/studio/marketing_kit/show.html.erb +6 -0
  28. data/app/views/studio/site_footer/_assets.html.erb +1 -0
  29. data/app/views/studio/site_footer/_footer.html.erb +5 -0
  30. data/app/views/style/_tricks.html.erb +35 -0
  31. data/app/views/style/index.html.erb +17 -28
  32. data/docs/SITE_FOOTER.md +113 -2
  33. data/lib/studio/engine.rb +5 -2
  34. data/lib/studio/marketing_kit.rb +304 -0
  35. data/lib/studio/site_footer.rb +22 -6
  36. data/lib/studio/version.rb +1 -1
  37. data/lib/studio.rb +83 -1
  38. 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
@@ -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 booking page keeps the footer for a signed-in viewer.
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
- def resolve(declared, view, name: nil, logo: nil, booking_path: nil, address: nil, chat: 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)
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: with_chat(Array(facts[:columns]).filter_map { |row| column(row, booking_path) }, chat),
194
- legal: Array(facts[:legal]).filter_map { |row| link(row, booking_path) },
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
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.123.0"
2
+ VERSION = "0.125.0"
3
3
  end
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.123.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