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
@@ -1,13 +1,12 @@
1
1
  <%# Theme section of the living style guide (admin/style) — the LANDING section.
2
- Theme owns "the colors": it leads with the 7 role tokens/swatches (moved here
3
- from the Tricks section), then folds in the /admin/theme editor as a second
4
- entry point and a live dark/light preview.
5
-
6
- Save-in-place: unlike the standalone /admin/theme page (which redirects to
7
- itself on save), THIS section intercepts Save + Regenerate and submits them
8
- via fetch — the page never navigates away, the live preview stays put, and
9
- the outcome is a toast. The fetch asks for JSON and the controller answers
10
- it 204 once it has written; the standalone page's redirect is unchanged.
2
+ Theme owns "the colors": it leads with the 7 role tokens/swatches, then the
3
+ editor with a live dark/light preview, then the logos, the semantic tokens and
4
+ the type. GET /admin/theme redirects here.
5
+
6
+ Save-in-place: this section intercepts Save + Regenerate and submits them via
7
+ fetch, so the page never navigates away, the live preview stays put, and the
8
+ outcome is a toast. The fetch asks for JSON and the controller answers it 204
9
+ once it has written.
11
10
  The editor is studio/theme_editor's dsThemeEditor factory, which
12
11
  studio/alpine_shims publishes.
13
12
 
@@ -63,8 +62,7 @@
63
62
  <div class="space-y-1">
64
63
  <h2 class="text-2xl font-bold text-heading">Theme</h2>
65
64
  <p class="text-muted text-sm max-w-2xl">
66
- The color foundation. The 7-role system, editable here and at
67
- <code class="font-mono text-2xs">/admin/theme</code>. Edits preview live; Save writes them
65
+ The color foundation. The 7-role system, editable here. Edits preview live; Save writes them
68
66
  and Regenerate clears the theme cache. Both persist <strong>in place</strong> — the page never
69
67
  leaves. Every specimen on this page restyles from these roles.
70
68
  </p>
@@ -171,4 +169,69 @@
171
169
  </div>
172
170
  </div>
173
171
  </div>
174
- </section>
172
+
173
+ <% logos = Studio.respond_to?(:theme_logos) ? Array(Studio.theme_logos) : [] %>
174
+ <% if logos.any? %>
175
+ <div class="space-y-3">
176
+ <p class="label-upper">Logos</p>
177
+ <div class="grid gap-4 grid-cols-2 md:grid-cols-5">
178
+ <% logos.each do |logo| %>
179
+ <% file = logo.is_a?(Hash) ? logo[:file] : logo %>
180
+ <% title = logo.is_a?(Hash) ? logo[:title] : file %>
181
+ <div class="card p-4 space-y-3">
182
+ <p class="text-sm text-heading font-medium"><%= title %></p>
183
+ <div class="flex items-center justify-center w-20 h-20 rounded-lg" style="background-color: #ddd; background-image: linear-gradient(45deg, #777 25%, transparent 25%, transparent 75%, #777 75%), linear-gradient(45deg, #777 25%, transparent 25%, transparent 75%, #777 75%); background-size: 12px 12px; background-position: 0 0, 6px 6px;">
184
+ <img src="/<%= file %>" alt="<%= title %>" class="max-w-16 max-h-16 rounded">
185
+ </div>
186
+ <code class="font-mono text-2xs text-muted break-all"><%= file %></code>
187
+ </div>
188
+ <% end %>
189
+ </div>
190
+ </div>
191
+ <% end %>
192
+
193
+ <%# Semantic tokens: the utilities a page paints with, under the role colours. %>
194
+ <div class="space-y-3">
195
+ <p class="label-upper">Semantic tokens</p>
196
+ <div class="grid gap-3 grid-cols-2 md:grid-cols-5">
197
+ <% [["Page", "bg-page", :bg], ["Surface", "bg-surface", :bg], ["Surface Alt", "bg-surface-alt", :bg], ["Inset", "bg-inset", :bg],
198
+ ["Heading", "text-heading", :text], ["Body", "text-body", :text], ["Secondary", "text-secondary", :text], ["Muted", "text-muted", :text],
199
+ ["Border", "border-subtle", :border], ["Border Strong", "border-strong", :border]].each do |name, utility, kind| %>
200
+ <div class="card p-3 flex items-center gap-3">
201
+ <% case kind
202
+ when :bg %>
203
+ <div class="w-8 h-8 rounded-full border border-subtle shrink-0 <%= utility %>"></div>
204
+ <% when :text %>
205
+ <div class="w-8 h-8 rounded-full border border-subtle shrink-0 bg-surface flex items-center justify-center">
206
+ <span class="text-sm font-bold <%= utility %>">A</span>
207
+ </div>
208
+ <% else %>
209
+ <div class="w-8 h-8 rounded-full shrink-0 border-2 bg-transparent <%= utility %>"></div>
210
+ <% end %>
211
+ <div class="min-w-0">
212
+ <p class="text-heading text-sm font-semibold truncate"><%= name %></p>
213
+ <code class="font-mono text-2xs text-muted truncate block"><%= utility %></code>
214
+ </div>
215
+ </div>
216
+ <% end %>
217
+ </div>
218
+ </div>
219
+
220
+ <%# Type in this app's own font, named by role: the font itself is the host's. %>
221
+ <div class="space-y-3">
222
+ <p class="label-upper">Typography</p>
223
+ <div class="card p-6 space-y-4">
224
+ <p class="text-heading text-3xl font-extrabold">Heading</p>
225
+ <p class="text-heading text-xl font-bold">Subheading</p>
226
+ <p class="text-body text-base">Body text. The quick brown fox jumps over the lazy dog.</p>
227
+ <p class="text-secondary text-sm">Secondary text. Additional context and metadata.</p>
228
+ <p class="text-muted text-xs">Muted text. Timestamps, hints and fine print.</p>
229
+ <hr class="border-subtle">
230
+ <div class="flex flex-wrap gap-4">
231
+ <% [400, 500, 600, 700, 800, 900].each do |weight| %>
232
+ <span class="text-heading text-lg" style="font-weight: <%= weight %>"><%= weight %></span>
233
+ <% end %>
234
+ </div>
235
+ </div>
236
+ </div>
237
+ </section>
@@ -72,6 +72,41 @@
72
72
  </div>
73
73
  </section>
74
74
 
75
+ <!-- Page header -->
76
+ <section class="space-y-5" data-page-header-specimen>
77
+ <div class="space-y-1">
78
+ <h3 class="text-xl font-bold text-heading">Page header</h3>
79
+ <p class="text-muted text-sm">
80
+ <code class="font-mono text-2xs">studio/_page_header</code>: the title, optional meta beside
81
+ it and optional tabs on the right. This page's own title row is one: scroll to see it take the bar.
82
+ It joins the pinned stack, so a table's sticky header sits under it: navbar, page row, table
83
+ header, each below the last. The rule is in <code class="font-mono text-2xs">docs/PAGE_HEADER.md</code>.
84
+ </p>
85
+ </div>
86
+ <div class="grid gap-4 lg:grid-cols-2">
87
+ <%# The classes alone, not the partial: the partial's row is sticky, joins the
88
+ pinned stack and holds the page's h1, and a page has one of each. %>
89
+ <% [["At rest", {}], ["Stuck", { stuck: "" }]].each do |state, data| %>
90
+ <%= render layout: "style/specimen",
91
+ locals: { klass: "studio/_page_header (#{state.downcase})",
92
+ usage: %(<%= render "studio/page_header",\n title: "Models", meta: "v1.2.3", label: "Models",\n tabs: [{ label: "Users", href: users_path, current: "page" }, { label: "Teams", href: teams_path }] %\>) } do %>
93
+ <%= tag.div(class: "page-header w-full px-4", data: data) do %>
94
+ <div class="page-header-lead">
95
+ <p class="page-header-title">Models</p>
96
+ <p class="page-header-meta label-upper">v1.2.3</p>
97
+ </div>
98
+ <div class="page-header-tabs">
99
+ <div class="page-header-track">
100
+ <a href="#tricks" class="btn btn-sm btn-primary">Users</a>
101
+ <a href="#tricks" class="btn btn-sm btn-neutral">Teams</a>
102
+ </div>
103
+ </div>
104
+ <% end %>
105
+ <% end %>
106
+ <% end %>
107
+ </div>
108
+ </section>
109
+
75
110
  <!-- Surfaces, text and form -->
76
111
  <section class="space-y-5">
77
112
  <div class="space-y-1">
@@ -3,10 +3,10 @@
3
3
  application.html.erb, inheriting that app's navbar and theme, so every
4
4
  specimen restyles per app and in dark or light automatically.
5
5
 
6
- Five ENGINE sections, reached by the sticky section nav below and rendered
6
+ Five ENGINE sections, reached by the page header below and rendered
7
7
  as sibling partials: Theme is the landing section (the color foundation - it
8
- owns the role tokens/swatches, the folded-in /admin/theme editor, and the
9
- live preview), then Modals (the shared host + engine card blocks), Tricks
8
+ owns the role tokens/swatches, the theme editor, the live preview, and the
9
+ logos and type), then Modals (the shared host + engine card blocks), Tricks
10
10
  (the button/surface/motion/effect/leveling primitives, framed as a board of
11
11
  copy-paste-for-an-agent snippets), Tasks (the shared board primitive), and
12
12
  Components (the ViewComponent previews, linking into the gallery at
@@ -53,38 +53,25 @@
53
53
  ["components", "Components"]
54
54
  ]
55
55
  %>
56
- <div class="max-w-6xl mx-auto px-4 pb-16">
57
- <header class="space-y-2 pt-8 pb-6">
58
- <p class="label-upper">studio-engine &middot; v<%= Studio::VERSION %></p>
59
- <h1 class="text-3xl font-bold text-heading">Style</h1>
60
- <p class="text-body max-w-2xl">
61
- A living gallery of the shared UI primitives, rendered live in this app's
62
- theme. Everything below is styled by the engine's own
63
- <code class="font-mono text-2xs bg-inset px-1.5 py-0.5 rounded">engine.css</code>
64
- and
65
- <code class="font-mono text-2xs bg-inset px-1.5 py-0.5 rounded">engine-motion.css</code>
66
- layers - toggle this app between light and dark and every specimen follows.
67
- </p>
68
- </header>
56
+ <div class="max-w-6xl mx-auto px-4 pt-6 pb-16">
57
+ <%# The page's title and its section tabs: studio/_page_header, the row of the
58
+ triple stack (docs/PAGE_HEADER.md). It sits at --z-sticky, above --z-raised,
59
+ so the live hold button's portal bubbles scroll under it rather than over it.
69
60
 
70
- <%# Section nav — sticky under the whole pinned stack (--pin-stack-bottom; then
71
- --nav-bottom and --nav-h for a host on an older publisher; then 0). Not --nav-h first: that
72
- is the header's HEIGHT, and chrome stacked above the header would sit over
73
- the nav by its own height. A pill per
74
- section; the row scrolls horizontally on narrow screens, its edges softened
75
- by the .fade-edge-x primitive. Order leads with Theme (the color foundation).
76
- It sits at --z-sticky, above --z-raised, so the live hold button's portal
77
- bubbles scroll under it rather than over it. %>
78
- <nav class="sticky -mx-4 px-4 py-3 bg-surface border-y border-subtle"
79
- style="top: var(--pin-stack-bottom, var(--nav-bottom, var(--nav-h, 0px))); z-index: var(--z-sticky, 30)" aria-label="Style guide sections">
80
- <div class="fade-edge-x overflow-x-auto">
81
- <div class="flex items-center gap-2 min-w-max">
82
- <% sections.each do |anchor, label| %>
83
- <a href="#<%= anchor %>" class="btn btn-neutral btn-sm"><%= label %></a>
84
- <% end %>
85
- </div>
86
- </div>
87
- </nav>
61
+ The style-section-nav controller moves the highlight as the page scrolls
62
+ (studio/style_section_nav). Theme starts active, so the first paint is
63
+ right before the module loads. %>
64
+ <%= render "studio/page_header",
65
+ title: "Style",
66
+ meta: "v#{Studio::VERSION}",
67
+ meta_title: "studio-engine",
68
+ label: "Style guide sections",
69
+ data: { studio_controller: "style-section-nav" },
70
+ tabs_data: { style_section_nav_target: "scroller" },
71
+ tabs: sections.each_with_index.map { |(anchor, label), index|
72
+ { label: label, href: "##{anchor}", current: (index.zero? ? "true" : nil),
73
+ data: { style_section_nav_target: "link" } }
74
+ } %>
88
75
 
89
76
  <div class="space-y-16 pt-10">
90
77
  <%= render "style/theme" %>
@@ -0,0 +1,16 @@
1
+ # One row per signed-in browser session (Studio::UserSession). A small, cached
2
+ # record every app keeps the same way. No foreign key: a host's users table is
3
+ # its own, and a deleted user's sessions may stay as counts.
4
+ class CreateStudioUserSessions < ActiveRecord::Migration[7.2]
5
+ def change
6
+ create_table :studio_user_sessions do |t|
7
+ t.bigint :user_id, null: false
8
+ t.boolean :admin, null: false, default: false
9
+ t.datetime :started_at, null: false
10
+ t.datetime :last_seen_at, null: false
11
+ t.timestamps
12
+ t.index :user_id
13
+ t.index :last_seen_at
14
+ end
15
+ end
16
+ end
data/docs/SITE_FOOTER.md CHANGED
@@ -53,6 +53,9 @@ It prints only what the engine knows to be true of the app:
53
53
  when a GET reaches it and its path needs no arguments. A host with neither
54
54
  gets the © line alone.
55
55
 
56
+ - **A Marketing Kit link**, when the app has the page: every app that calls
57
+ `Studio.routes` does ([The marketing kit](#the-marketing-kit)).
58
+
56
59
  It has no address, no map, no phone, no email, no tagline, no social row and no
57
60
  link columns, and the page names no Leaflet. The engine cannot know those, and
58
61
  a guessed link is a dead one. For any of them, add a location (below) or
@@ -150,8 +153,9 @@ Leave `config.site_footer` unset and the same app gets
150
153
  [the default footer](#the-default-footer), its name read from `config.app_name`
151
154
  since there is no site-identity table to ask.
152
155
 
153
- Do not call `Studio.routes(self)`: everything it draws (sign-in, admin, error
154
- logs) needs a database. The app also needs an asset pipeline (Propshaft or
156
+ Do not call `Studio.routes(self)`: what it draws (sign-in, admin, error logs)
157
+ needs a database. The one route that does not is the marketing kit's, which such
158
+ an app can draw alone ([The marketing kit](#the-marketing-kit)). The app also needs an asset pipeline (Propshaft or
155
159
  Sprockets), as every Rails 8 app has, and a mapping of the theme tokens onto its
156
160
  own palette (see [Colours](#colours)).
157
161
 
@@ -200,6 +204,7 @@ Every key is optional. A missing key removes its part of the footer.
200
204
  | `columns` | Link columns under bold headings. A column may carry a width hint: `[ "Contact", links, { width: 1.5 } ]` | Brand only |
201
205
  | `legal` | The centred line above the © | The © alone |
202
206
  | `booking` | `{ label:, title: }` for the popup's heading and the frame's accessible name | "Schedule a call", and "Schedule a call with <name>" |
207
+ | `marketing_kit` | `false` removes the footer's link to the kit ([The marketing kit](#the-marketing-kit)) | The link, on an app with the page |
203
208
 
204
209
  Rows may be tuples, as above, or hashes (`{ label:, href: }`, `{ heading:, links: }`,
205
210
  `{ label:, icon:, url: }`). The rules a row can carry:
@@ -319,6 +324,136 @@ config.site_footer = ->(view) {
319
324
  only with a lockup (`studio/site_footer/_lockup_assets`). An engine older
320
325
  than the fact ignores the key, so a host can declare it before it upgrades.
321
326
 
327
+ ## The marketing kit
328
+
329
+ Every app has a public page at `/marketing-kit`, with its logos, colours and
330
+ typefaces, and the footer links it. Neither needs a line of setup.
331
+
332
+ **The page.** `Studio.routes` draws it by default, with no sign-in. The route's
333
+ helper is `studio_marketing_kit_path`.
334
+
335
+ | Setting | Meaning | Unset |
336
+ |---------|---------|-------|
337
+ | `config.draw_marketing_kit_routes` | `false` draws no page, and with no page the footer links none | `true` |
338
+ | `config.marketing_kit` | The kit: a Hash, or a callable taking the view | Derived from the theme (below) |
339
+
340
+ ```ruby
341
+ # config/initializers/studio.rb
342
+ config.marketing_kit = {
343
+ tagline: "Everything, by example",
344
+ logos: [ { title: "Icon", light: { png: "/brand/icon-light.png", svg: "/brand/icon-light.svg" },
345
+ dark: { png: "/brand/icon-dark.png", svg: "/brand/icon-dark.svg" } } ],
346
+ palette: [ { name: "Ink", hex: "#1A1535" } ],
347
+ typefaces: [ { role: "Headings", family: "Montserrat", weight: 800, name: "Montserrat ExtraBold" } ]
348
+ }
349
+ ```
350
+
351
+ Every key is optional, and each key stands alone: a kit that declares only
352
+ `tagline:` still derives its logos and colours. `logos: []` shows none. `name:`
353
+ overrides the site name in the page's first line.
354
+
355
+ | Key | With no declaration |
356
+ |-----|---------------------|
357
+ | `logos` | `config.navbar_wordmark` and the footer's `lockup`, each with its two tones, then one logo for each entry in `config.theme_logos` but the favicon. A theme entry names one file, so the rest is looked for beside it: a `-light`/`-dark` or `_light`/`_dark` sibling. A name with no tone in it is the light artwork. Every file's other format (`.png`, `.svg`) is looked for too |
358
+ | `palette` | The colours the app paints: its `/admin/theme` values over `Studio.theme_config` (primary, dark, light, success, warning, danger, accent) |
359
+ | `typefaces` | None. The engine cannot know them, so the section is gone |
360
+ | `tagline` | None |
361
+
362
+ - **`light` is the artwork for a light background**, and `dark` for a dark one.
363
+ - **A logo offers "Download Light" and "Download Dark".** Each downloads the PNG when
364
+ there is one, else the SVG. Make the PNG transparent. The tile shows the SVG when
365
+ there is one. Only PNG and SVG are offered.
366
+ - **A file that is not there is not an error.** Each file is looked for before it is
367
+ linked, in `public/` and, for a name with no leading `/`, in the asset pipeline. A
368
+ tone with no file has no tile and no link. A logo with no file has no card. An
369
+ `http(s)` URL is taken at its word and is not fetched, so a dead one shows a broken
370
+ image; a browser downloads a cross-origin file only if that host says so.
371
+ - **A colour copies on one click**, on the chip, the name or the hex, and shows
372
+ "Copied" on the chip. It runs from the lazy `copy-text` controller
373
+ (`studio/controllers/copy_text_controller`). Only a real hex is printed.
374
+ - **A tile's image carries `?kit`.** A host may repaint its own logo by the image's
375
+ exact `src` on a dark page, as the hub's `.dark img[src="/brand/icon-light.svg"]`
376
+ does, and that would turn a light tile's artwork dark. The download keeps the plain URL.
377
+ - **A callable that raises** shows locally. In production it is logged once and the
378
+ page shows what derives from the theme.
379
+
380
+ **A host's own page wins.** A GET route of the path `/marketing-kit` beats the
381
+ engine's, whichever side of `Studio.routes` it is drawn on. Drawn before, the engine
382
+ draws nothing; drawn after, the engine's route yields at request time. So the hub
383
+ keeps its page until it adopts this one.
384
+
385
+ **Sign-in.** `Studio::MarketingKitController` inherits the host's
386
+ `ApplicationController` for its layout and skips each gate it knows by name
387
+ (`HOST_GATES`): `require_authentication`, the hub's `require_admin_wall`,
388
+ `require_admin`, `require_login`, `authenticate_user!` and
389
+ `require_profile_completion`. A gate of another name needs one line:
390
+
391
+ ```ruby
392
+ # config/initializers/studio.rb
393
+ Rails.application.config.to_prepare do
394
+ Studio::MarketingKitController.skip_before_action :my_gate, raise: false
395
+ end
396
+ ```
397
+
398
+ A host whose test suite walks its route table sees `studio/marketing_kit#show` as a
399
+ public page; list it wherever that suite lists them.
400
+
401
+ **An app with no database** does not call `Studio.routes`, so it has no page and its
402
+ footer links none. To have both, draw the one route. The page reads no table and no
403
+ session:
404
+
405
+ ```ruby
406
+ # config/routes.rb
407
+ Studio.marketing_kit_routes(self)
408
+ ```
409
+
410
+ **The footer link** is "Marketing Kit", in the brand block, under the tagline and the
411
+ email. Every viewer who sees the footer sees it, on every app with the page, with
412
+ nothing declared. It is not in a link column or on the legal line, so an app's columns
413
+ and legal line stay exactly what it wrote.
414
+
415
+ - An app that already links the page in a column or on the legal line keeps its own
416
+ row and gets no second link. The comparison is on the path, so a query or a trailing
417
+ `/` still counts.
418
+ - `marketing_kit: false` in the facts shows no link.
419
+ - A signed-in viewer sees the footer on the page itself, as on the booking page.
420
+
421
+ To render the kit in a page of your own:
422
+ `render "studio/marketing_kit/kit", kit: Studio.marketing_kit_for(self)`.
423
+
424
+ ## The Chat link
425
+
426
+ The standard way to reach an app's agent chat ([`AGENT_CHAT.md`](AGENT_CHAT.md)):
427
+ a plain link at the end of the column headed **Contact** (any case), or a new
428
+ Contact column at the end if the facts have none. An app that sets nothing sees
429
+ no change.
430
+
431
+ ```ruby
432
+ config.site_footer_chat_path = "/chat" # or ->(view) { view.chat_path }
433
+ # config.site_footer_chat_label = "Chat" # the default
434
+ # config.site_footer_chat_allowed = ->(user) { user&.admin? } # default: Studio.agent_chat_allowed
435
+ ```
436
+
437
+ | Setting | Meaning | Unset |
438
+ |---------|---------|-------|
439
+ | `site_footer_chat_path` | A path, or a callable taking the view (route helpers do not exist yet in an initializer). It answers nil to show no link on this page | No link |
440
+ | `site_footer_chat_label` | The link's words | "Chat" |
441
+ | `site_footer_chat_allowed` | A callable taking the viewer: `current_user`, **nil when signed out**, so write `user&.admin?` | `Studio.agent_chat_allowed` (everyone, by default) |
442
+
443
+ - The link looks and behaves as any other column link (`_link.html.erb`). An href
444
+ the footer refuses (`javascript:`) adds nothing, and a Contact column that
445
+ already links the same path keeps its own row and label.
446
+ - A hook that raises hides the link and reports once through
447
+ `Studio::SiteFooter.reporter`, so `user.admin?` on a signed-out viewer shows the
448
+ link to no one and costs no page. Write `user&.admin?` all the same.
449
+ - **Hiding the link is not a gate.** `Studio.agent_chat_allowed` is
450
+ ([`AGENT_CHAT.md`](AGENT_CHAT.md), "Who may use it"); set that one, and the link
451
+ follows it.
452
+ - **An admin-only link needs a page that shows the footer to a signed-in viewer.**
453
+ A signed-in viewer sees the footer only on `site_footer_controllers` (see
454
+ *Where it shows*), so list the pages your admins will look at.
455
+ - Never cache the footer across viewers while the link is limited.
456
+
322
457
  ## Where it shows
323
458
 
324
459
  `studio_site_footer` asks `config.site_footer_visible`, one callable that
@@ -445,6 +580,7 @@ These class names are stable, and an app may style against them:
445
580
  | `.ftr-link`, `.ftr-link-solid`, `.ftr-link-disabled` | A link; an address kept on one line; a "coming soon" label. `--ftr-link-ink` on `.ftr` is the link colour |
446
581
  | `.ftr-location`, `.ftr-location-title`, `.ftr-address` | The Location band |
447
582
  | `.ftr-legal`, `.ftr-copyright` | The legal line and the © line |
583
+ | `.ftr-kit` | The Marketing Kit link's line in the brand block |
448
584
  | `.ftr-map` | The map |
449
585
  | `.ftr-lockup`, `.ftr-lockup-light`, `.ftr-lockup-dark` | The lockup's images; `--ftr-lockup-height` on `.ftr` is its height |
450
586
 
@@ -494,5 +630,13 @@ link. Those rules are more specific than the engine's and still apply.
494
630
  line inside its column at each, and the line heights. These lab pages load Turbo, so the map's remount after a Turbo visit is
495
631
  covered.
496
632
 
633
+ - `test/lib/studio/marketing_kit_test.rb`: what a kit resolves to, derived and
634
+ declared, and that a missing file or a malformed row costs nothing.
635
+ - `test/integration/marketing_kit_test.rb`: the page through a real signed-out
636
+ request in a host that gates every controller, the footer link and its no-double
637
+ rule, and a host route of the same path winning from either side.
638
+ - `e2e/marketing_kit.spec.js`: the hex on the clipboard, the confirmation, the
639
+ refused copy, and the downloads, in a browser.
640
+
497
641
  `/admin/style` shows the footer under Tricks, with this app's own facts, or a
498
642
  sample when it has declared none (the default footer or `false`).
@@ -16,8 +16,8 @@ module Studio
16
16
  #
17
17
  # THE CONTRACT (.agents/epics/agent-chat-primitive.md, "Contract"):
18
18
  #
19
- # POST /api/v1/agent_chat/messages soul, text, person, ip
20
- # 201 {turn} · 422 {error} · 429 capped · 404 soul has no web chat
19
+ # POST /api/v1/agent_chat/messages soul, text, person, ip, voice (optional)
20
+ # 201 {turn, voice} · 422 {error} · 429 capped · 404 soul has no web chat
21
21
  # GET /api/v1/agent_chat/turns soul, person, after, limit (<= 50)
22
22
  # 200 {turns: [...], pending: bool}
23
23
  #
@@ -27,6 +27,11 @@ module Studio
27
27
  # {id, role: "user"|"agent", text, created_at, status} with status
28
28
  # pending|typing|answered|failed.
29
29
  #
30
+ # `voice` marks a spoken message: {check, score, duration_ms}, what POST
31
+ # /voice/takes answered for the take (voice_param). NEVER A NAME: the hub
32
+ # names the speaker itself (the verified person's own name when check is
33
+ # "matched", else "Someone new") and may answer what it kept as `voice`.
34
+ #
30
35
  # AUTHENTICATION is the APP's own bearer credential (Studio.agent_chat_token,
31
36
  # else ENV["AGENT_CHAT_TOKEN"]), never a user's. It is sent only over TLS: a
32
37
  # plain-http hub URL is refused unless its host is this machine (a developer
@@ -83,6 +88,31 @@ module Studio
83
88
  MAX_TEXT_LENGTH = 4_000
84
89
  # A hub answer larger than this is not a chat answer.
85
90
  MAX_BODY_BYTES = 1_048_576
91
+ # The words beside the microphone, approved by Alex McRitchie on 2026-11-01.
92
+ # Verbatim here and on an app's privacy page, from this one constant.
93
+ VOICE_PRIVACY = "When you speak to an agent, your recording is sent to OpenAI to turn it " \
94
+ "into text, then deleted. We never keep the audio."
95
+
96
+ # The hold-to-talk button's words per recorder state (merged over
97
+ # Studio::VoiceCapture::RECORDER_COPY). The recorder says nothing while it
98
+ # records: the chat's own status line says "Listening...", so the button does
99
+ # not move under a held finger when the line appears.
100
+ VOICE_RECORDER_COPY = {
101
+ "idle" => { "button" => "Hold to talk" },
102
+ "recording" => { "button" => "Let go to send", "status" => "", "heard" => "" },
103
+ "captured" => { "button" => "Hold to talk", "status" => "" },
104
+ "silent" => { "button" => "Hold to talk" },
105
+ "failed" => { "button" => "Hold to try again" },
106
+ "denied" => { "button" => "Hold to talk" },
107
+ "unsupported" => { "button" => "Hold to talk" }
108
+ }.freeze
109
+
110
+ # The microphone chip's first words (the engine's say "tap Record").
111
+ VOICE_CHIP_COPY = {
112
+ "prompt" => { "chip" => "Microphone: hold to talk to allow",
113
+ "note" => "Hold the talk button once and allow the microphone. Then you can pick one here." }
114
+ }.freeze
115
+
86
116
  LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
87
117
  NETWORK_ERRORS = [
88
118
  Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error,
@@ -93,13 +123,49 @@ module Studio
93
123
  class << self
94
124
  # Stores one message from `person` to `soul` and answers its turn (a Hash
95
125
  # with string keys). Raises a Studio::AgentChat::Error.
96
- def post_message(soul:, text:, person:, ip: nil)
97
- body = { soul: soul.to_s, text: text.to_s, person: person_param(person), ip: ip.to_s.presence }.compact
126
+ #
127
+ # `voice` marks it as spoken (voice_param). When the hub answers what it
128
+ # kept, the returned Hash also carries it under "voice".
129
+ def post_message(soul:, text:, person:, ip: nil, voice: nil)
130
+ body = { soul: soul.to_s, text: text.to_s, person: person_param(person), ip: ip.to_s.presence,
131
+ voice: voice_param(voice) }.compact
98
132
  payload = request(:post, MESSAGES_PATH, body: body)
99
133
  turn = payload.is_a?(Hash) && payload["turn"].is_a?(Hash) ? payload["turn"] : payload
100
134
  raise Unavailable.new("the hub answered with no turn", status: 201) unless turn.is_a?(Hash)
101
135
 
102
- turn
136
+ kept = payload["voice"] if payload.is_a?(Hash)
137
+ kept.is_a?(Hash) ? turn.merge("voice" => kept) : turn
138
+ end
139
+
140
+ # The words of the chat's voice option: {"privacy", "recorder", "chip"},
141
+ # the `overrides` (voice_copy: on studio/agent_chat/chat) over the defaults.
142
+ # A blank privacy line is the default one: the line is never just dropped.
143
+ def voice_copy(overrides = nil)
144
+ words = (overrides || {}).to_h.transform_keys(&:to_s)
145
+ {
146
+ "privacy" => words["privacy"].presence || VOICE_PRIVACY,
147
+ "recorder" => Studio::VoiceCapture.merge(VOICE_RECORDER_COPY, words["recorder"]),
148
+ "chip" => Studio::VoiceCapture.merge(VOICE_CHIP_COPY, words["chip"])
149
+ }
150
+ end
151
+
152
+ # What a message's `voice` may say to the hub, as the hub reads it: `check`
153
+ # is one of the engine's own (else Invalid), `score` a cosine (-1..1, 4
154
+ # places) or nil, `duration_ms` 1..MAX_DURATION_MS or nil. Everything else,
155
+ # a browser-chosen name above all, stops here. nil or "" is a typed message.
156
+ def voice_param(voice)
157
+ return nil if voice.nil? || voice == ""
158
+
159
+ voice = voice.to_unsafe_h if voice.respond_to?(:to_unsafe_h)
160
+ raise Invalid.new("voice must be {check, score, duration_ms}", status: 422) unless voice.is_a?(Hash)
161
+
162
+ voice = voice.transform_keys(&:to_s)
163
+ check = voice["check"].to_s
164
+ unless Studio::VoiceTranscription::CHECKS.include?(check)
165
+ raise Invalid.new("voice.check is not one the engine answers", status: 422)
166
+ end
167
+
168
+ { check: check, score: voice_score(voice["score"]), duration_ms: voice_duration(voice["duration_ms"]) }
103
169
  end
104
170
 
105
171
  # The thread's turns after `after` (a turn id; nil for the latest), and
@@ -118,6 +184,18 @@ module Studio
118
184
 
119
185
  private
120
186
 
187
+ def voice_score(raw)
188
+ return nil unless raw.is_a?(Numeric) || raw.is_a?(String)
189
+
190
+ value = Float(raw, exception: false)
191
+ value&.finite? ? value.clamp(-1.0, 1.0).round(4) : nil
192
+ end
193
+
194
+ def voice_duration(raw)
195
+ value = raw.to_s.match?(/\A\d{1,9}\z/) ? raw.to_s.to_i : nil
196
+ value&.between?(1, Studio::VoiceTranscription::MAX_DURATION_MS) ? value : nil
197
+ end
198
+
121
199
  # {email:}, {anon_token:}, or both: a signed-in post that still holds the
122
200
  # visitor's anon token sends both, and the hub folds that anonymous thread
123
201
  # into the person's.