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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +142 -0
  3. data/README.md +18 -3
  4. data/app/assets/tailwind/studio_engine/engine.css +210 -11
  5. data/app/controllers/concerns/studio/admin_models.rb +24 -16
  6. data/app/controllers/concerns/studio/error_handling.rb +13 -0
  7. data/app/controllers/studio/agent_chats_controller.rb +31 -4
  8. data/app/controllers/studio/marketing_kit_controller.rb +24 -0
  9. data/app/controllers/studio/voice_takes_controller.rb +11 -0
  10. data/app/controllers/style_controller.rb +2 -5
  11. data/app/controllers/theme_settings_controller.rb +8 -8
  12. data/app/helpers/studio/agent_chat_helper.rb +37 -0
  13. data/app/helpers/studio/site_footer_helper.rb +15 -2
  14. data/app/javascript/studio/agent_chat.js +42 -2
  15. data/app/javascript/studio/agent_chat_voice.js +318 -0
  16. data/app/javascript/studio/alpine_shims.js +1 -3
  17. data/app/javascript/studio/controllers/agent_chat_voice_controller.js +17 -0
  18. data/app/javascript/studio/controllers/copy_text_controller.js +63 -0
  19. data/app/javascript/studio/controllers/page_header_controller.js +60 -0
  20. data/app/javascript/studio/controllers/style_section_nav_controller.js +80 -0
  21. data/app/javascript/studio/microphone_chip.js +1 -0
  22. data/app/javascript/studio/page_header.js +24 -0
  23. data/app/javascript/studio/page_transitions.js +181 -0
  24. data/app/javascript/studio/pinned_stack.js +12 -1
  25. data/app/javascript/studio/stimulus.js +9 -1
  26. data/app/javascript/studio/style_guide.js +2 -0
  27. data/app/javascript/studio/style_section_nav.js +16 -0
  28. data/app/javascript/studio/theme_editor.js +6 -106
  29. data/app/javascript/studio/voice_recorder.js +23 -2
  30. data/app/models/studio/user_session.rb +76 -0
  31. data/app/models/theme_setting.rb +3 -0
  32. data/app/views/components/_link_sidebar.html.erb +5 -2
  33. data/app/views/components/_user_nav.html.erb +5 -4
  34. data/app/views/layouts/_navbar.html.erb +7 -4
  35. data/app/views/layouts/studio/_head.html.erb +6 -0
  36. data/app/views/layouts/studio/_smooth_load.html.erb +11 -2
  37. data/app/views/studio/_page_header.html.erb +65 -0
  38. data/app/views/studio/admin_models/_sessions_table.html.erb +28 -0
  39. data/app/views/studio/admin_models/show.html.erb +15 -12
  40. data/app/views/studio/admin_surveys/index.html.erb +2 -2
  41. data/app/views/studio/agent_chat/_chat.html.erb +17 -2
  42. data/app/views/studio/agent_chat/_form.html.erb +4 -3
  43. data/app/views/studio/agent_chat/_header.html.erb +1 -1
  44. data/app/views/studio/agent_chat/_voice.html.erb +54 -0
  45. data/app/views/studio/marketing_kit/_assets.html.erb +44 -0
  46. data/app/views/studio/marketing_kit/_kit.html.erb +84 -0
  47. data/app/views/studio/marketing_kit/show.html.erb +6 -0
  48. data/app/views/studio/site_footer/_assets.html.erb +1 -0
  49. data/app/views/studio/site_footer/_footer.html.erb +5 -0
  50. data/app/views/studio/voice/_microphone_help.html.erb +6 -3
  51. data/app/views/style/_theme.html.erb +75 -12
  52. data/app/views/style/_tricks.html.erb +35 -0
  53. data/app/views/style/index.html.erb +21 -34
  54. data/db/migrate/20261103120000_create_studio_user_sessions.rb +16 -0
  55. data/docs/SITE_FOOTER.md +146 -2
  56. data/lib/studio/agent_chat.rb +83 -5
  57. data/lib/studio/marketing_kit.rb +304 -0
  58. data/lib/studio/site_footer.rb +65 -6
  59. data/lib/studio/ui_primitives.rb +4 -2
  60. data/lib/studio/version.rb +1 -1
  61. data/lib/studio.rb +146 -6
  62. metadata +21 -4
  63. data/app/views/studio/admin_models/index.html.erb +0 -31
  64. 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
@@ -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
  #
@@ -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
- def resolve(declared, view, name: nil, logo: nil, booking_path: nil, address: nil)
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: Array(facts[:columns]).filter_map { |row| column(row, booking_path) },
193
- 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),
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
@@ -10,8 +10,10 @@ module Studio
10
10
  align-items: center;
11
11
  justify-content: center;
12
12
  vertical-align: middle;
13
- width: 1.5rem;
14
- height: 1.25rem;
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
 
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.122.0"
2
+ VERSION = "0.124.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
@@ -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 apps.<slug>.agent-chat).
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 living style guide. Canonical at /admin/style (StyleController#index);
2055
- # /admin/design_system redirects here but KEEPS its admin_design_system_path
2056
- # helper so a shipped host sidebar link on the old helper still resolves.
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