studio-engine 0.86.0 → 0.88.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.
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "active_support/core_ext/object/blank"
5
+
6
+ module Studio
7
+ # A SURVEY, defined in app code (docs/SURVEYS.md).
8
+ #
9
+ # # config/surveys/first_game.rb — loaded by the engine on boot and reload
10
+ # Studio.define_survey "first-game" do
11
+ # title "How was your first game?"
12
+ # intro "Two minutes, six questions. It shapes what we build next."
13
+ # thank_you "We read every answer."
14
+ # next_action label: "Play another game", url: "/play"
15
+ # allow_anonymous true
16
+ #
17
+ # emoji_scale :overall, "How was your first game?", required: true
18
+ # rating :rules, "How clear were the rules?", low_label: "Lost", high_label: "Crystal clear"
19
+ # choice :found_us, "How did you find us?", options: ["Email", "A friend", "Search", "Other"]
20
+ # multi_choice :liked, "What did you enjoy?", options: ["The board", "The pace", "The art"]
21
+ # short_text :one_word, "Describe it in one word."
22
+ # long_text :anything_else, "Anything else?", help: "Bugs, ideas, complaints — all welcome."
23
+ # end
24
+ #
25
+ # The definition is the CURRENT shape of the survey, nothing more. A stored
26
+ # answer carries its own question key, type and label snapshot (and the chosen
27
+ # option's label), so editing or removing a question never rewrites what an
28
+ # earlier respondent was asked. `version` defaults to a digest of the questions
29
+ # and is stamped on every response.
30
+ class Survey
31
+ class DefinitionError < ArgumentError; end
32
+
33
+ SLUG_FORMAT = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
34
+ KEY_FORMAT = /\A[a-z][a-z0-9_]{0,62}\z/
35
+ # The same two shapes, unanchored, for route constraints.
36
+ ROUTE_SLUG = /[a-z0-9]+(?:-[a-z0-9]+)*/
37
+ ROUTE_KEY = /[a-z][a-z0-9_]{0,62}/
38
+ TYPES = %i[emoji_scale rating choice multi_choice short_text long_text].freeze
39
+ SCALE_TYPES = %i[emoji_scale rating].freeze
40
+ SELECT_TYPES = %i[emoji_scale rating choice].freeze
41
+ TEXT_TYPES = %i[short_text long_text].freeze
42
+
43
+ EMOJI_FACES = ["\u{1F61E}", "\u{1F615}", "\u{1F610}", "\u{1F642}", "\u{1F60D}"].freeze
44
+ DEFAULT_EMOJI_LABELS = ["Awful", "Not great", "Okay", "Good", "Loved it"].freeze
45
+ TEXT_LIMITS = { short_text: 280, long_text: 5000 }.freeze
46
+
47
+ Option = Struct.new(:value, :label, :face, keyword_init: true)
48
+
49
+ # One question. Immutable once built.
50
+ class Question
51
+ attr_reader :key, :type, :label, :help, :options, :low_label, :high_label, :placeholder, :max_length
52
+
53
+ def initialize(key:, type:, label:, required: false, help: nil, options: nil, labels: nil,
54
+ low_label: nil, high_label: nil, placeholder: nil, max_length: nil)
55
+ @key = key.to_s
56
+ @type = type.to_sym
57
+ @label = label.to_s.strip
58
+ @required = required == true
59
+ @help = help.to_s.strip.presence
60
+ @low_label = low_label.to_s.strip.presence
61
+ @high_label = high_label.to_s.strip.presence
62
+ @placeholder = placeholder.to_s.strip.presence
63
+ validate_shape!(options, labels, max_length)
64
+ @options = build_options(options, labels).freeze
65
+ @max_length = text? ? Integer(max_length || TEXT_LIMITS.fetch(@type)) : nil
66
+ freeze
67
+ end
68
+
69
+ def required? = @required
70
+ def scale? = SCALE_TYPES.include?(type)
71
+ def select? = SELECT_TYPES.include?(type)
72
+ def multi? = type == :multi_choice
73
+ def text? = TEXT_TYPES.include?(type)
74
+ def numbered? = !text?
75
+
76
+ def option(value)
77
+ options.find { |o| o.value.to_s == value.to_s }
78
+ end
79
+
80
+ def blank_value?(value)
81
+ value.nil? || (value.respond_to?(:empty?) && value.empty?) || value.to_s.strip.empty?
82
+ end
83
+
84
+ # [normalized_value, error_message]. A blank input normalizes to nil, which
85
+ # the response stores as "unanswered".
86
+ def normalize(raw)
87
+ raw = Array(raw).map(&:to_s).reject { |v| v.strip.empty? } if multi?
88
+ return [nil, nil] if blank_value?(raw)
89
+
90
+ case type
91
+ when :emoji_scale, :rating
92
+ int = Integer(raw.to_s.strip, exception: false)
93
+ return [nil, "Pick one of the options."] unless int && option(int)
94
+
95
+ [int, nil]
96
+ when :choice
97
+ opt = option(raw.to_s)
98
+ opt ? [opt.value, nil] : [nil, "Pick one of the options."]
99
+ when :multi_choice
100
+ values = raw.uniq
101
+ return [nil, "Pick from the listed options."] unless values.all? { |v| option(v) }
102
+
103
+ # Keep the definition's order, not the click order.
104
+ [options.map(&:value).select { |v| values.include?(v) }, nil]
105
+ else
106
+ text = raw.to_s.strip.gsub("\r\n", "\n")
107
+ return [nil, "Keep it under #{max_length} characters."] if text.length > max_length
108
+
109
+ [text, nil]
110
+ end
111
+ end
112
+
113
+ # The human snapshot of an answer: the option label(s), or the text itself.
114
+ def display(value)
115
+ return nil if value.nil?
116
+ return Array(value).map { |v| option(v)&.label || v.to_s } if multi?
117
+ return value.to_s if text?
118
+
119
+ option(value)&.label || value.to_s
120
+ end
121
+
122
+ def to_h
123
+ { key: key, type: type, label: label, required: required?, help: help,
124
+ options: options.map { |o| [o.value, o.label] }, low_label: low_label, high_label: high_label }
125
+ end
126
+
127
+ private
128
+
129
+ def validate_shape!(options, labels, max_length)
130
+ raise DefinitionError, "question key #{key.inspect} must match #{KEY_FORMAT.inspect}" unless key.match?(KEY_FORMAT)
131
+ raise DefinitionError, "question #{key}: unknown type #{type.inspect} (one of #{TYPES.join(", ")})" unless TYPES.include?(type)
132
+ raise DefinitionError, "question #{key}: label is required" if label.empty?
133
+
134
+ if %i[choice multi_choice].include?(type)
135
+ raise DefinitionError, "question #{key}: #{type} needs at least two options" if Array(options).size < 2
136
+ elsif options
137
+ raise DefinitionError, "question #{key}: options: is only for choice and multi_choice"
138
+ end
139
+
140
+ if labels && type != :emoji_scale
141
+ raise DefinitionError, "question #{key}: labels: is only for emoji_scale"
142
+ end
143
+ if labels && Array(labels).size != 5
144
+ raise DefinitionError, "question #{key}: emoji_scale takes exactly five labels"
145
+ end
146
+ if (low_label || high_label) && type != :rating
147
+ raise DefinitionError, "question #{key}: low_label/high_label are only for rating"
148
+ end
149
+ if max_length && !text?
150
+ raise DefinitionError, "question #{key}: max_length: is only for short_text and long_text"
151
+ end
152
+ return unless max_length && !(Integer(max_length, exception: false).to_i.between?(1, TEXT_LIMITS[:long_text]))
153
+
154
+ raise DefinitionError, "question #{key}: max_length must be 1..#{TEXT_LIMITS[:long_text]}"
155
+ end
156
+
157
+ def build_options(options, labels)
158
+ case type
159
+ when :emoji_scale
160
+ names = Array(labels || DEFAULT_EMOJI_LABELS).map { |l| l.to_s.strip }
161
+ raise DefinitionError, "question #{key}: emoji labels cannot be blank" if names.any?(&:empty?)
162
+
163
+ names.each_with_index.map { |name, i| Option.new(value: i + 1, label: name, face: EMOJI_FACES[i]) }
164
+ when :rating
165
+ (1..5).map { |n| Option.new(value: n, label: n.to_s) }
166
+ when :choice, :multi_choice
167
+ opts = Array(options).map { |o| coerce_option(o) }
168
+ values = opts.map(&:value)
169
+ raise DefinitionError, "question #{key}: option values must be unique" if values.uniq.size != values.size
170
+
171
+ opts
172
+ else
173
+ []
174
+ end
175
+ end
176
+
177
+ def coerce_option(raw)
178
+ label, value =
179
+ case raw
180
+ when Hash then [raw[:label] || raw["label"], raw[:value] || raw["value"]]
181
+ when Array then [raw[1], raw[0]]
182
+ else [raw, nil]
183
+ end
184
+ label = label.to_s.strip
185
+ raise DefinitionError, "question #{key}: option labels cannot be blank" if label.empty?
186
+
187
+ value = (value.presence || label.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_+|_+\z/, "")).to_s
188
+ raise DefinitionError, "question #{key}: option #{label.inspect} has no usable value" if value.empty?
189
+
190
+ Option.new(value: value, label: label)
191
+ end
192
+ end
193
+
194
+ # The block DSL. Every setter is a plain method so `title "x"` reads well.
195
+ class Builder
196
+ def initialize(slug)
197
+ @attrs = { slug: slug.to_s, questions: [] }
198
+ end
199
+
200
+ def title(text) = @attrs[:title] = text
201
+ def intro(text) = @attrs[:intro] = text
202
+ def thank_you(text) = @attrs[:thank_you] = text
203
+ def allow_anonymous(flag = true) = @attrs[:allow_anonymous] = flag
204
+ def version(value) = @attrs[:version] = value
205
+
206
+ def next_action(label:, url:)
207
+ @attrs[:next_action] = { label: label.to_s.strip, url: url.to_s.strip }
208
+ end
209
+
210
+ TYPES.each do |type|
211
+ define_method(type) do |key, label, **opts|
212
+ @attrs[:questions] << Question.new(key: key, type: type, label: label, **opts)
213
+ end
214
+ end
215
+
216
+ def build = Survey.new(**@attrs)
217
+ end
218
+
219
+ attr_reader :slug, :title, :intro, :thank_you, :next_action, :questions, :version
220
+
221
+ def initialize(slug:, title: nil, intro: nil, thank_you: nil, next_action: nil, allow_anonymous: false,
222
+ version: nil, questions: [])
223
+ @slug = slug.to_s
224
+ @title = title.to_s.strip
225
+ @intro = intro.to_s.strip.presence
226
+ @thank_you = thank_you.to_s.strip.presence || "We appreciate you taking the time."
227
+ @next_action = next_action
228
+ @allow_anonymous = allow_anonymous == true
229
+ @questions = questions.dup.freeze
230
+ validate!
231
+ @version = (version.presence || fingerprint).to_s
232
+ freeze
233
+ end
234
+
235
+ def allow_anonymous? = @allow_anonymous
236
+
237
+ def question(key)
238
+ questions.find { |q| q.key == key.to_s }
239
+ end
240
+
241
+ def keys = questions.map(&:key)
242
+ def required_keys = questions.select(&:required?).map(&:key)
243
+
244
+ private
245
+
246
+ def validate!
247
+ raise DefinitionError, "survey slug #{slug.inspect} must be lowercase words joined by hyphens" unless slug.match?(SLUG_FORMAT)
248
+ raise DefinitionError, "survey #{slug}: title is required" if title.empty?
249
+ raise DefinitionError, "survey #{slug}: needs at least one question" if questions.empty?
250
+
251
+ dupes = keys.group_by(&:itself).select { |_, v| v.size > 1 }.keys
252
+ raise DefinitionError, "survey #{slug}: duplicate question keys #{dupes.join(", ")}" if dupes.any?
253
+
254
+ validate_next_action!
255
+ end
256
+
257
+ def validate_next_action!
258
+ return if next_action.nil?
259
+
260
+ label, url = next_action.values_at(:label, :url)
261
+ raise DefinitionError, "survey #{slug}: next_action needs a label and a url" if label.to_s.empty? || url.to_s.empty?
262
+ # A relative path or an http(s) URL — never javascript: or a protocol-relative //host.
263
+ return if url.match?(%r{\A/(?!/)}) || url.match?(%r{\Ahttps?://}i)
264
+
265
+ raise DefinitionError, "survey #{slug}: next_action url must be a /path or an http(s) URL"
266
+ end
267
+
268
+ def fingerprint
269
+ Digest::SHA256.hexdigest(questions.map(&:to_h).inspect)[0, 12]
270
+ end
271
+
272
+ # --- registry --------------------------------------------------------------
273
+
274
+ @registry = {}
275
+ @mutex = Mutex.new
276
+
277
+ class << self
278
+ def define(slug, &block)
279
+ raise DefinitionError, "define_survey needs a block" unless block
280
+
281
+ builder = Builder.new(slug)
282
+ builder.instance_eval(&block)
283
+ survey = builder.build
284
+ @mutex.synchronize { @registry[survey.slug] = survey }
285
+ survey
286
+ end
287
+
288
+ def find(slug) = @registry[slug.to_s]
289
+
290
+ def all = @registry.values.sort_by(&:slug)
291
+
292
+ def reset! = @mutex.synchronize { @registry.clear }
293
+
294
+ # Loads every *.rb under the app's survey directory. `load`, not `require`,
295
+ # so a development reload picks up an edited definition.
296
+ def load_definitions!(dir)
297
+ return [] unless dir && File.directory?(dir.to_s)
298
+
299
+ Dir[File.join(dir.to_s, "**", "*.rb")].sort.each { |path| load path }
300
+ end
301
+ end
302
+ end
303
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.86.0"
2
+ VERSION = "0.88.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -49,6 +49,9 @@ require "studio/email_smoke"
49
49
  require "studio/mail_transport"
50
50
  require "studio/redis"
51
51
  require "studio/cable"
52
+ require "studio/survey"
53
+ require "studio/survey/breakdown"
54
+ require "studio/survey/export"
52
55
 
53
56
  module Studio
54
57
  mattr_accessor :app_name, default: "Studio"
@@ -582,10 +585,16 @@ module Studio
582
585
  # primitives: Google Calendar's appointment page inline, in a popup, and on a
583
586
  # page. See docs/SITE_FOOTER.md and docs/BOOKING.md.
584
587
 
585
- # The footer's facts: a Hash, or a callable receiving the view. nil (the
586
- # default) means this app has no footer, and `studio_site_footer` renders
587
- # nothing, so a layout can call it before the app declares one. Every key is
588
- # optional; shape and rules: lib/studio/site_footer.rb.
588
+ # The footer's facts. THE FOOTER IS ON BY DEFAULT: left unset (nil), the
589
+ # layout line `studio_site_footer` renders the DEFAULT footer, built from what
590
+ # the engine already knows (the site name, the footer or navbar logo, and a
591
+ # legal line linking the host's `privacy` and `terms` routes when it has
592
+ # them). No address, no map, no email, no link columns.
593
+ #
594
+ # nil (unset) the default footer
595
+ # false NO footer: the one opt-out
596
+ # a Hash, or a callable receiving the view: the app's own facts. Every key
597
+ # is optional; shape and rules: lib/studio/site_footer.rb.
589
598
  #
590
599
  # config.site_footer = ->(view) {
591
600
  # { tagline: "Everything, by example",
@@ -601,6 +610,35 @@ module Studio
601
610
  @@site_footer = declared
602
611
  end
603
612
 
613
+ # True unless the app turned the footer off (`config.site_footer = false`).
614
+ def self.site_footer_enabled?
615
+ site_footer != false
616
+ end
617
+
618
+ # True when the app wrote its own facts, as against taking the default footer
619
+ # (unset) or none (false).
620
+ def self.site_footer_declared?
621
+ !site_footer.nil? && site_footer != false
622
+ end
623
+
624
+ # THE LOCATION, AS ONE SETTING: the address the footer prints in its Location
625
+ # band, and maps when it carries lat: and lng:. nil (the default) is no
626
+ # location. It is how an app on the default footer adds one without writing
627
+ # the facts callable:
628
+ #
629
+ # config.site_footer_address = { street: "123 Example St", city_line: "Washington, DC 20024",
630
+ # lat: 38.8894, lng: -77.0352 }
631
+ #
632
+ # The keys are the facts' `address:` keys (docs/SITE_FOOTER.md). An app's own
633
+ # facts WIN: when they carry an address this is not read, and `address: false`
634
+ # there shows none.
635
+ mattr_reader :site_footer_address, default: nil
636
+
637
+ def self.site_footer_address=(declared)
638
+ SiteFooter.validate_address!(declared)
639
+ @@site_footer_address = declared || nil
640
+ end
641
+
604
642
  # Controllers whose pages keep the footer for a SIGNED-IN viewer, by
605
643
  # controller name ("landing") or path ("admin/reports"). A visitor gets the
606
644
  # footer on every page; a signed-in viewer gets it only here, because every
@@ -673,6 +711,48 @@ module Studio
673
711
  Booking.path_for(booking_path, view, drawn: draw_booking_routes)
674
712
  end
675
713
 
714
+ # ── Surveys (docs/SURVEYS.md) ──────────────────────────────────────────────
715
+ # The public survey pages, /surveys/:slug (Studio::SurveysController). OPT-IN:
716
+ # `/surveys` is a path an app may already own, and a duplicate helper name
717
+ # raises while the host's routes.rb loads, which kills every route in it.
718
+ #
719
+ # config.draw_survey_routes = true
720
+ mattr_accessor :draw_survey_routes, default: false
721
+
722
+ # The admin results panel, /admin/surveys (Studio::AdminSurveysController).
723
+ # OPT-IN for the same reason, and independent of the public pages, so an app
724
+ # can read results it collects elsewhere.
725
+ #
726
+ # config.draw_admin_survey_routes = true
727
+ mattr_accessor :draw_admin_survey_routes, default: false
728
+
729
+ # Where the app's survey definitions live, relative to Rails.root. Every *.rb
730
+ # in it is loaded at boot and on each development reload.
731
+ mattr_accessor :survey_definitions_path, default: "config/surveys"
732
+
733
+ # Attribution for an email arrival: ->(controller) { String | nil }. Called on
734
+ # every survey request; its answer is stamped on the response as email_ref the
735
+ # first time one is present. Cyvasse passes its EmailReferral ref here.
736
+ mattr_accessor :survey_ref_resolver, default: ->(_controller) {}
737
+
738
+ # Called once, after a response completes: ->(response) { ... }. A raise is
739
+ # logged and swallowed — the respondent still lands on the thank-you screen.
740
+ mattr_accessor :on_survey_completed, default: ->(_response) {}
741
+
742
+ # Define a survey in app code. See Studio::Survey for the DSL.
743
+ def self.define_survey(slug, &block)
744
+ Survey.define(slug, &block)
745
+ end
746
+
747
+ # Every defined survey, by slug.
748
+ def self.surveys
749
+ Survey.all
750
+ end
751
+
752
+ def self.survey(slug)
753
+ Survey.find(slug)
754
+ end
755
+
676
756
  # THE FRAME'S CROP AT REST: nil or false (the default) shows Google's whole
677
757
  # page; a Hash of the schedule's measured box crops the frame to the slot
678
758
  # picker until it is used. The numbers belong to ONE schedule, so there is no
@@ -1207,14 +1287,25 @@ module Studio
1207
1287
  end
1208
1288
 
1209
1289
  # The site footer's facts resolved for a view (lib/studio/site_footer.rb), or
1210
- # nil when the app declared none. The name defaults to the site identity's
1211
- # title and the logo to the navbar logo, so an app that has set those up says
1212
- # them once.
1290
+ # nil when the app turned the footer off (or its own callable answered nil).
1291
+ # Unset, the facts are the default footer's. The name defaults to the site
1292
+ # identity's title and the logo to the navbar logo, so an app that has set
1293
+ # those up says them once; Studio.site_footer_address is the address of any
1294
+ # facts that carry none.
1213
1295
  def self.site_footer_for(view)
1214
- return nil if site_footer.nil?
1296
+ return nil unless site_footer_enabled?
1215
1297
 
1216
- SiteFooter.resolve(site_footer, view, name: site_identity[:title], logo: logo_for("Footer Logo"),
1217
- booking_path: booking_path_for(view))
1298
+ declared = site_footer.nil? ? SiteFooter.default_facts(view, routes: host_route_set) : site_footer
1299
+ SiteFooter.resolve(declared, view, name: site_identity[:title], logo: logo_for("Footer Logo"),
1300
+ booking_path: booking_path_for(view), address: site_footer_address)
1301
+ end
1302
+
1303
+ # The host's route set, for the default footer's legal links. nil outside a
1304
+ # booted Rails app.
1305
+ def self.host_route_set
1306
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
1307
+
1308
+ ::Rails.application.routes
1218
1309
  end
1219
1310
 
1220
1311
  # Navbar links resolved for a view context (lib/studio/navbar_links.rb).
@@ -1512,6 +1603,25 @@ module Studio
1512
1603
  patch "admin/email_images/:variant", to: "studio/email_images#update", as: :admin_email_image,
1513
1604
  constraints: { variant: /[a-z_]+/ }
1514
1605
 
1606
+ # Surveys (docs/SURVEYS.md). Both halves OPT-IN — see
1607
+ # Studio.draw_survey_routes and Studio.draw_admin_survey_routes.
1608
+ if Studio.draw_survey_routes
1609
+ slug = { slug: Studio::Survey::ROUTE_SLUG }
1610
+ get "surveys/:slug", to: "studio/surveys#show", as: :studio_survey, constraints: slug
1611
+ post "surveys/:slug", to: "studio/surveys#submit", constraints: slug
1612
+ patch "surveys/:slug/answers/:key", to: "studio/surveys#answer", as: :studio_survey_answer,
1613
+ constraints: slug.merge(key: Studio::Survey::ROUTE_KEY)
1614
+ get "surveys/:slug/thanks", to: "studio/surveys#thanks", as: :studio_survey_thanks, constraints: slug
1615
+ end
1616
+
1617
+ if Studio.draw_admin_survey_routes
1618
+ get "admin/surveys", to: "studio/admin_surveys#index", as: :admin_surveys
1619
+ get "admin/surveys/:slug/export", to: "studio/admin_surveys#export", as: :admin_survey_export,
1620
+ constraints: { slug: %r{[^/.]+} }, defaults: { format: :csv }
1621
+ get "admin/surveys/:slug", to: "studio/admin_surveys#show", as: :admin_survey,
1622
+ constraints: { slug: %r{[^/.]+} }
1623
+ end
1624
+
1515
1625
  # Model-page protocol (v1) — a reusable per-record inspector. Drawn into
1516
1626
  # every consuming app: /models/:model/:id renders one record as pretty JSON
1517
1627
  # plus a copy/paste rails-console command; /models/:model/random bounces to
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.86.0
4
+ version: 0.88.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-03 00:00:00.000000000 Z
11
+ date: 2026-10-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -336,6 +336,7 @@ files:
336
336
  - app/controllers/schema_controller.rb
337
337
  - app/controllers/sessions_controller.rb
338
338
  - app/controllers/solana_sessions_controller.rb
339
+ - app/controllers/studio/admin_surveys_controller.rb
339
340
  - app/controllers/studio/bookings_controller.rb
340
341
  - app/controllers/studio/email_images_controller.rb
341
342
  - app/controllers/studio/emails_controller.rb
@@ -350,6 +351,7 @@ files:
350
351
  - app/controllers/studio/public_users_controller.rb
351
352
  - app/controllers/studio/session_states_controller.rb
352
353
  - app/controllers/studio/site_identities_controller.rb
354
+ - app/controllers/studio/surveys_controller.rb
353
355
  - app/controllers/style_controller.rb
354
356
  - app/controllers/theme_settings_controller.rb
355
357
  - app/helpers/studio/admin_models_table_helper.rb
@@ -386,6 +388,7 @@ files:
386
388
  - app/models/studio/link.rb
387
389
  - app/models/studio/model_page.rb
388
390
  - app/models/studio/site_identity.rb
391
+ - app/models/studio/survey_response.rb
389
392
  - app/models/theme_setting.rb
390
393
  - app/services/google_oauth_validator.rb
391
394
  - app/services/studio/banner.rb
@@ -439,6 +442,8 @@ files:
439
442
  - app/views/studio/admin_models/_teams_table.html.erb
440
443
  - app/views/studio/admin_models/index.html.erb
441
444
  - app/views/studio/admin_models/show.html.erb
445
+ - app/views/studio/admin_surveys/index.html.erb
446
+ - app/views/studio/admin_surveys/show.html.erb
442
447
  - app/views/studio/banners/_app_banner.html.erb
443
448
  - app/views/studio/banners/_button.html.erb
444
449
  - app/views/studio/banners/_dev_mode_button.html.erb
@@ -544,6 +549,13 @@ files:
544
549
  - app/views/studio/site_footer/_map_assets.html.erb
545
550
  - app/views/studio/site_footer/_social_icon.html.erb
546
551
  - app/views/studio/site_identities/edit.html.erb
552
+ - app/views/studio/surveys/_question.html.erb
553
+ - app/views/studio/surveys/_script.html.erb
554
+ - app/views/studio/surveys/_styles.html.erb
555
+ - app/views/studio/surveys/not_found.html.erb
556
+ - app/views/studio/surveys/show.html.erb
557
+ - app/views/studio/surveys/sign_in_required.html.erb
558
+ - app/views/studio/surveys/thanks.html.erb
547
559
  - app/views/style/_host.html.erb
548
560
  - app/views/style/_modal_specimen.html.erb
549
561
  - app/views/style/_modals.html.erb
@@ -585,6 +597,7 @@ files:
585
597
  - db/migrate/20260902000001_create_studio_knowledge_expectations.rb
586
598
  - db/migrate/20260902000002_add_expectation_to_studio_knowledge_docs.rb
587
599
  - db/migrate/20260930120000_create_studio_site_identities.rb
600
+ - db/migrate/20261005120000_create_studio_survey_responses.rb
588
601
  - docs/BOOKING.md
589
602
  - docs/SITE_FOOTER.md
590
603
  - lib/active_storage/service/studio_trash_s3_service.rb
@@ -627,6 +640,9 @@ files:
627
640
  - lib/studio/session_state.rb
628
641
  - lib/studio/sidebar_sections.rb
629
642
  - lib/studio/site_footer.rb
643
+ - lib/studio/survey.rb
644
+ - lib/studio/survey/breakdown.rb
645
+ - lib/studio/survey/export.rb
630
646
  - lib/studio/theme_resolver.rb
631
647
  - lib/studio/ui_primitives.rb
632
648
  - lib/studio/username_generator.rb