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
@@ -23,8 +23,9 @@
23
23
  put — hosts no longer shadow this partial just to add the class. Non-preview
24
24
  branch ONLY: preview renders can repeat per page, and a duplicate
25
25
  view-transition-name silently disables every transition. With the flag off,
26
- nothing extra renders. %>
27
- <% pin_header = Studio.smooth_load && !is_preview %>
26
+ nothing extra renders. Studio.page_transition_direction turns the same flag
27
+ on (layouts/studio/_smooth_load). %>
28
+ <% pin_header = (Studio.smooth_load || @studio_page_transition_direction || Studio.page_transition_direction) && !is_preview %>
28
29
  <% balance_html = local_assigns.fetch(:balance_html, nil) %>
29
30
  <% extra_icons_html = local_assigns.fetch(:extra_icons_html, nil) %>
30
31
  <%# The user nav's slot locals, forwarded only when passed, so a navbar that
@@ -244,8 +245,9 @@
244
245
  <%= render "components/user_nav", balance_html: balance_html, extra_icons_html: extra_icons_html, **user_nav_slots %>
245
246
  <% else %>
246
247
  <div class="flex items-center justify-end gap-3">
247
- <%= sidebar_trigger_desktop if sidebar_active %>
248
+ <%# The signed-in rail's order: the theme toggle, then the gear. %>
248
249
  <span class="hidden md:flex"><%= render "components/theme_toggle_morph" %></span>
250
+ <%= sidebar_trigger_desktop if sidebar_active %>
249
251
  <% if signed_out_cta %>
250
252
  <%# sign_in_link: false drops the quiet link (the CTA is the sign-in);
251
253
  sign_in_path points it elsewhere; modal: opens a modal from the
@@ -277,6 +279,8 @@
277
279
  <% if signed_out_cta && signed_out_cta[:sign_in_link] != false %>
278
280
  <%= link_to Studio.sign_in_label, signed_out_cta[:sign_in_path] || login_path, class: "text-xs font-semibold text-secondary hover:text-primary whitespace-nowrap transition", data: { nav_sign_in: "" } %>
279
281
  <% end %>
282
+ <%# The rail's order at this width too: the theme toggle, then the gears. %>
283
+ <%= render "components/theme_toggle_morph" %>
280
284
  <%= render "components/link_sidebar_trigger" if sidebar_active %>
281
285
  <%# Studio.navbar_menu_trigger takes the admin dropdown's seat here too: its
282
286
  second render (the rail is the first), so the partial carries no ids. %>
@@ -286,7 +290,6 @@
286
290
  <% else %>
287
291
  <%= render "components/admin_dropdown" unless sidebar_active && studio_sidebar_replaces_admin_menu? %>
288
292
  <% end %>
289
- <%= render "components/theme_toggle_morph" %>
290
293
  </span>
291
294
  </div>
292
295
  </header>
@@ -208,6 +208,12 @@
208
208
  controllers, the pinned stack, and the Alpine shims. A module tag, so it
209
209
  carries the nonce. %>
210
210
  <%= javascript_import_module_tag "studio/application" %>
211
+ <%# THE PAGE TRANSITION, BY ITS OWN DOOR, only with the setting on: an app without
212
+ it fetches nothing and renders these bytes as before. Not in the boot graph, so
213
+ it is not preloaded either. Its listeners are bound before the first visit. %>
214
+ <% if @studio_page_transition_direction || Studio.page_transition_direction %>
215
+ <%= javascript_import_module_tag "studio/page_transitions" %>
216
+ <% end %>
211
217
  <%# THE STIMULUS APPLICATION, BY ITS OWN DOOR. studio/stimulus starts the engine's
212
218
  Stimulus application and registers the hold button on it. Its only imports
213
219
  are Stimulus, studio/lazy_controllers and the hold button's controller, so
@@ -8,8 +8,17 @@
8
8
  Transition styling ships in engine.css ("Smooth-load convention" block);
9
9
  pin an app's sticky header with the .vt-pinned-header utility (exactly one
10
10
  element per page — a duplicate view-transition-name silently disables the
11
- whole transition). %>
12
- <% if Studio.smooth_load %>
11
+ whole transition).
12
+
13
+ Studio.page_transition_direction is the one switch for the app-to-app page
14
+ transition (docs/PAGE_TRANSITIONS.md): set, it turns all of this on, whatever
15
+ Studio.smooth_load says. The ivar lets one request set it without writing the
16
+ process-wide setting (the e2e lab). %>
17
+ <% page_transition_direction = @studio_page_transition_direction || Studio.page_transition_direction %>
18
+ <% if Studio.smooth_load || page_transition_direction %>
13
19
  <meta name="view-transition" content="same-origin">
14
20
  <meta name="turbo-cache-control" content="no-preview">
15
21
  <% end %>
22
+ <% if page_transition_direction %>
23
+ <meta name="page-transition-direction" content="<%= page_transition_direction %>">
24
+ <% end %>
@@ -0,0 +1,65 @@
1
+ <%# locals: (title:, meta: nil, meta_title: nil, label: nil, tabs: [], tabs_data: {}, actions_html: nil, data: {}, row_class: "-mx-4 px-4") %>
2
+ <%# THE PAGE HEADER: the second layer of the triple stack (docs/PAGE_HEADER.md).
3
+ navbar, then this row, then a table's sticky header, each below the last.
4
+
5
+ AT REST it has no background and no border: a title on the left, tabs on the
6
+ right. ONCE STUCK under the navbar it takes the bar. Its box never changes
7
+ between the two, so a state change moves nothing and republishes nothing.
8
+
9
+ title the page's one h1.
10
+ meta short text beside the title, such as the version. Hidden on a
11
+ phone, where the tabs need the width.
12
+ meta_title the meta's title attribute.
13
+ label the nav's accessible name. A row with tabs needs it.
14
+ tabs [{ label:, href:, current:, data: }]. `current` is the
15
+ aria-current value ("page" or "true"), and blank on the others.
16
+ tabs_data data attributes for the tabs' scroller.
17
+ actions_html a control on the right, for a row with no tabs.
18
+ data data attributes for the row, such as the page's own controller.
19
+ row_class the bleed. The default fits a container with px-4; pass "" in a
20
+ container with no gutter of its own.
21
+
22
+ NOT components/_page_header: a host's view wins over the engine's at the same
23
+ path, and the hub has its own with other locals.
24
+
25
+ THE ROW IS NEVER A <header>. studio/pinned_stack takes the document's first
26
+ <header> as the navbar, so on a page with no navbar this row would publish as
27
+ --nav-bottom.
28
+
29
+ IT JOINS THE STACK WITH data-pin="page" AND POSITIONS OFF --pin-page-top, the
30
+ bottom of everything above it. --pin-stack-bottom includes the row's own edge,
31
+ so a row positioned off it would chase itself down the page. Anything below,
32
+ such as the sticky table header's clone, sits under the row with no edit.
33
+
34
+ THE SENTINEL stays at the row's place in the flow, and the row's distance from
35
+ it is what "stuck" means, on every kind of header. It is absolutely positioned,
36
+ so a space-y parent adds nothing for it. It carries the controller, so the row
37
+ keeps its data-studio-controller for the page's own. So the row carries no top
38
+ margin: the space above it belongs to its container. %>
39
+ <div class="page-header-sentinel" data-studio-controller="page-header" aria-hidden="true"></div>
40
+ <%= content_tag(tabs.any? ? :nav : :div,
41
+ class: "page-header sticky #{row_class}".strip,
42
+ style: "top: var(--pin-page-top, var(--nav-bottom, var(--nav-h, 0px))); z-index: var(--z-sticky, 30)",
43
+ aria: { label: label.presence },
44
+ data: data.merge(pin: "page")) do %>
45
+ <div class="page-header-lead">
46
+ <h1 class="page-header-title"><%= title %></h1>
47
+ <% if meta.present? %>
48
+ <p class="page-header-meta label-upper"<%= %( title="#{ERB::Util.h(meta_title)}").html_safe if meta_title.present? %>><%= meta %></p>
49
+ <% end %>
50
+ </div>
51
+ <% if tabs.any? %>
52
+ <%= tag.div(class: "page-header-tabs", data: tabs_data) do %>
53
+ <div class="page-header-track">
54
+ <% tabs.each do |tab| %>
55
+ <%= link_to tab.fetch(:label), tab.fetch(:href),
56
+ class: "btn btn-sm #{tab[:current].present? ? 'btn-primary' : 'btn-neutral'}",
57
+ aria: { current: tab[:current].presence },
58
+ data: tab.fetch(:data, {}) %>
59
+ <% end %>
60
+ </div>
61
+ <% end %>
62
+ <% elsif actions_html.present? %>
63
+ <div class="page-header-actions"><%= actions_html %></div>
64
+ <% end %>
65
+ <% end %>
@@ -0,0 +1,28 @@
1
+ <%# locals: (records:) %>
2
+ <div class="overflow-x-auto">
3
+ <table id="models-sessions-table" class="w-full min-w-[640px] text-sm">
4
+ <thead class="bg-surface-alt text-muted text-xs uppercase tracking-wide">
5
+ <tr>
6
+ <th class="px-4 py-3 text-left font-semibold">User</th>
7
+ <th class="px-4 py-3 text-left font-semibold">Started</th>
8
+ <th class="px-4 py-3 text-left font-semibold">Last seen</th>
9
+ </tr>
10
+ </thead>
11
+ <tbody class="divide-y divide-subtle">
12
+ <% records.each do |row| %>
13
+ <tr>
14
+ <td class="px-4 py-3">
15
+ <% user = row.user %>
16
+ <span class="font-semibold text-heading"><%= user ? (user.email.presence || user.try(:display_name).presence || "User ##{user.id}") : "Deleted user" %></span>
17
+ <% if row.admin %><span class="ml-2 label-upper">admin</span><% end %>
18
+ </td>
19
+ <td class="px-4 py-3 text-muted whitespace-nowrap"><%= time_ago_in_words(row.started_at) %> ago</td>
20
+ <td class="px-4 py-3 text-muted whitespace-nowrap"><%= time_ago_in_words(row.last_seen_at) %> ago</td>
21
+ </tr>
22
+ <% end %>
23
+ <% if records.empty? %>
24
+ <tr><td colspan="3" class="px-4 py-6 text-muted">No sessions yet.</td></tr>
25
+ <% end %>
26
+ </tbody>
27
+ </table>
28
+ </div>
@@ -1,17 +1,20 @@
1
1
  <% content_for(:title, "#{@config.fetch(:label)} - Models - Admin") %>
2
2
 
3
- <div class="max-w-7xl mx-auto px-4 py-8 space-y-6">
4
- <header class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
5
- <div>
6
- <p class="text-xs text-muted uppercase tracking-wide font-semibold">Admin Models</p>
7
- <h1 class="text-heading text-2xl font-bold mt-1"><%= @config.fetch(:label) %></h1>
8
- <p class="text-muted text-sm mt-1">
9
- <%= @config.fetch(:description) %>.
10
- Page <%= @page %> of <%= @total_pages %>, <%= @total_count %> total.
11
- </p>
12
- </div>
13
- <%= link_to "Back to models", admin_models_path, class: "btn btn-outline" %>
14
- </header>
3
+ <div class="max-w-7xl mx-auto px-4 pt-6 pb-8 space-y-6">
4
+ <%# The title and a tab per model: studio/_page_header, as on /admin/style. A
5
+ tab is a link, so the open one is the page's own model. The table's sticky
6
+ header sits under this row (docs/PAGE_HEADER.md). %>
7
+ <%= render "studio/page_header",
8
+ title: "Models",
9
+ label: "Models",
10
+ tabs: admin_model_configs.map { |key, config|
11
+ { label: config.fetch(:label), href: admin_model_path(key), current: (key.to_s == @key ? "page" : nil) }
12
+ } %>
13
+
14
+ <p class="text-muted text-sm">
15
+ <%= @config.fetch(:description) %>.
16
+ Page <%= @page %> of <%= @total_pages %>, <%= @total_count %> total.
17
+ </p>
15
18
 
16
19
  <section class="card overflow-hidden">
17
20
  <%= render admin_models_table_partial(@key), records: @records %>
@@ -3,8 +3,8 @@
3
3
  first answer, never on a page view. %>
4
4
  <% content_for(:title, "Surveys") %>
5
5
  <div class="max-w-4xl mx-auto px-4 py-6" data-admin-surveys>
6
- <h1 class="text-2xl font-extrabold text-heading mb-1">Surveys</h1>
7
- <p class="text-sm text-secondary mb-6">Defined in <code>config/surveys/</code>. A response counts as started at its first answer.</p>
6
+ <%= render "studio/page_header", title: "Surveys" %>
7
+ <p class="text-sm text-secondary mt-1 mb-6">Defined in <code>config/surveys/</code>. A response counts as started at its first answer.</p>
8
8
 
9
9
  <% if @rows.empty? %>
10
10
  <div class="empty-state">
@@ -1,4 +1,4 @@
1
- <%# locals: (soul:, name:, portrait: nil, role: nil, empty: nil, cards: nil) %>
1
+ <%# locals: (soul:, name:, portrait: nil, role: nil, empty: nil, cards: nil, voice: false, voice_copy: nil) %>
2
2
  <%#
3
3
  The shared agent chat (docs/AGENT_CHAT.md): one conversation with one agent,
4
4
  held by the hub, on any app's page.
@@ -13,15 +13,25 @@
13
13
  cards THE CARD SLOT: ready markup shown between the header and the
14
14
  thread (/build's intake cards). A local, never a bare yield: a
15
15
  partial rendered without a block yields the LAYOUT's body.
16
+ voice true puts hold to talk in the composer's mic slot, with the privacy
17
+ line under it. It still shows only where the app turned takes on
18
+ (Studio.voice_transcription) and only to a user
19
+ Studio.voice_chat_allowed lets talk; never to a signed-out visitor.
20
+ voice_copy the words: { privacy:, recorder:, chip: }, each optional, over the
21
+ defaults (Studio::AgentChat.voice_copy).
16
22
 
17
23
  Top to bottom, as the hub's /build: the portrait and name, the card slot, the
18
24
  thread, the typing line, the offline notice and the composer. The thread is
19
25
  drawn by studio/agent_chat (the "agent-chat" controller, loaded lazily) from
20
26
  the two <template>s below, which are studio/agent_chat/_turn: the look lives
21
27
  here, in ERB, and the script only fills it. ONE PAGE HOLDS ONE CHAT.
28
+
29
+ A viewer Studio.agent_chat_allowed refuses gets nothing here: the proxy would
30
+ answer them 403 on every poll.
22
31
  %>
23
32
  <% souls = Studio.agent_chat_soul_list %>
24
33
  <% raise ArgumentError, "studio/agent_chat/chat: soul #{soul.inspect} is not in Studio.agent_chat_souls (#{souls.inspect})" unless souls.include?(soul.to_s) %>
34
+ <% if Studio.agent_chat_allowed?(studio_agent_chat_viewer) %>
25
35
  <section class="w-full max-w-3xl mx-auto px-4 pt-4 sm:pt-8 flex flex-col gap-6 min-w-0"
26
36
  data-studio-controller="agent-chat" data-test="agent-chat"
27
37
  data-agent-chat-soul="<%= soul %>" data-agent-chat-name="<%= name %>"
@@ -45,8 +55,13 @@
45
55
  <p class="text-sm text-body min-w-0">Can't reach <%= name %> right now. Your messages are safe; try again in a moment.</p>
46
56
  <button type="button" class="btn btn-secondary btn-sm shrink-0" data-agent-chat-retry data-test="agent-chat-retry">Try again</button>
47
57
  </div>
48
- <%= render "studio/agent_chat/form", soul: soul, name: name %>
58
+ <% mic = render("studio/agent_chat/voice", soul: soul, name: name, copy: voice_copy) if voice && studio_agent_chat_voice? %>
59
+ <%= render "studio/agent_chat/form", soul: soul, name: name, mic: mic %>
60
+ <% if mic %>
61
+ <p class="px-3 text-center text-xs text-secondary break-words" data-test="agent-chat-voice-privacy"><%= Studio::AgentChat.voice_copy(voice_copy)["privacy"] %></p>
62
+ <% end %>
49
63
  </div>
50
64
  <template data-agent-chat-template="user"><%= render "studio/agent_chat/turn", turn: { "role" => "user" }, name: name %></template>
51
65
  <template data-agent-chat-template="agent"><%= render "studio/agent_chat/turn", turn: { "role" => "agent" }, name: name %></template>
52
66
  </section>
67
+ <% end %>
@@ -1,9 +1,9 @@
1
- <%# locals: (soul:, name:) %>
2
- <%# The chat's composer (studio/agent_chat/_chat), as the hub's agent_chats/_form: the text over a round send button at its bottom right, sticky at the window's bottom. Enter sends; Shift+Enter is a new line (studio/agent_chat). It posts to POST /agent_chat/messages with the page's authenticity token (form_with adds it; the script sends it as X-CSRF-Token). A send that fails keeps the words in the box and says why above them. %>
1
+ <%# locals: (soul:, name:, mic: nil) %>
2
+ <%# The chat's composer (studio/agent_chat/_chat), as the hub's agent_chats/_form: the text over a round send button at its bottom right, sticky at the window's bottom. Enter sends; Shift+Enter is a new line (studio/agent_chat). It posts to POST /agent_chat/messages with the page's authenticity token (form_with adds it; the script sends it as X-CSRF-Token). A send that fails keeps the words in the box and says why above them. `mic` is the mic slot: ready markup under the box (studio/agent_chat/_voice), nothing without it. %>
3
3
  <%= form_with url: studio_agent_chat_messages_path, method: :post, local: true,
4
4
  html: { style: "view-transition-name: agent-composer;" },
5
5
  class: "sticky bottom-3 z-10 rounded-3xl border border-subtle bg-surface shadow-lg focus-within:border-primary transition-colors p-2 flex flex-col gap-2",
6
- data: { agent_chat_form: true, turbo: false, test: "agent-chat-form" } do |f| %>
6
+ data: { agent_chat_form: true, turbo: false, test: "agent-chat-form", vt_morph: ("agent-composer" if @studio_page_transition_direction || Studio.page_transition_direction) } do |f| %>
7
7
  <%= hidden_field_tag :soul, soul, id: nil %>
8
8
  <p class="px-3 pt-1 text-xs text-danger-ink" role="alert" data-agent-chat-error hidden data-test="agent-chat-error"></p>
9
9
  <p class="sr-only" role="status" aria-live="polite" data-agent-chat-status data-test="agent-chat-status"></p>
@@ -20,4 +20,5 @@
20
20
  </svg>
21
21
  </button>
22
22
  </div>
23
+ <%= mic %>
23
24
  <% end %>
@@ -1,7 +1,7 @@
1
1
  <%# locals: (name:, portrait: nil, role: nil) %>
2
2
  <%# The chat's head (studio/agent_chat/_chat): the agent's portrait, a big circle centered on top, its name and role under it. `portrait` is ready markup; with none, the name's initial on a plain circle. The portrait keeps one view-transition name, as on the hub's agent pages. %>
3
3
  <div class="flex flex-col items-center gap-2 text-center min-w-0" data-test="agent-chat-header" data-agent-name="<%= name %>">
4
- <div class="rounded-full shadow-md overflow-hidden" style="view-transition-name: agent-portrait;" data-test="agent-chat-portrait">
4
+ <div class="rounded-full shadow-md overflow-hidden" style="view-transition-name: agent-portrait;"<% if @studio_page_transition_direction || Studio.page_transition_direction %> data-vt-morph="agent-portrait"<% end %> data-test="agent-chat-portrait">
5
5
  <% if portrait.present? %>
6
6
  <%= portrait %>
7
7
  <% else %>
@@ -0,0 +1,54 @@
1
+ <%# locals: (soul:, name:, copy: nil) %>
2
+ <%# Hold to talk, in the composer's mic slot (studio/agent_chat/_form;
3
+ docs/AGENT_CHAT.md, "Talking by voice"). The engine's microphone chip and
4
+ recorder capture the take; studio/agent_chat_voice (the lazy
5
+ "agent-chat-voice" controller) embeds it on this device when the page names
6
+ an embedder, posts it to POST /voice/takes and sends the words to the chat.
7
+ No audio is kept. Status lines are set as text.
8
+
9
+ Scoped CSS for the reason studio/voice/_styles gives: it must render in an
10
+ app whose Tailwind build never saw this file. The status line keeps its one
11
+ line even when empty, so a one-line status coming or going under a held finger
12
+ does not move the button. Two things still can at 390px outside a stuck composer,
13
+ and the pointer stays captured through both: the quiet hint wrapping to two lines,
14
+ and the chip's prompt shortening on the first hold. %>
15
+ <% words = Studio::AgentChat.voice_copy(copy) %>
16
+ <style>
17
+ .studio-agent-voice { display: grid; gap: .25rem; padding: 0 .25rem .25rem; min-width: 0; }
18
+ .studio-agent-voice-status { min-height: 1.25rem; margin: 0; padding: 0 .5rem; font-size: .875rem; line-height: 1.25rem; overflow-wrap: anywhere;
19
+ color: var(--color-text-body, inherit); }
20
+ .studio-agent-voice-failed { display: none; margin: 0; padding: 0 .5rem; font-size: .8125rem; color: var(--color-text-secondary, inherit); }
21
+ [data-studio-controller-failed~="agent-chat-voice"] > .studio-agent-voice-failed { display: block; }
22
+ /* One row where both fit; on a phone the chip takes the first line and the
23
+ talk button the whole of the second. */
24
+ .studio-agent-voice-row { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; min-width: 0; }
25
+ .studio-agent-voice-row .studio-mic { display: contents; }
26
+ .studio-agent-voice-row .studio-mic-chip { flex: 1 1 14rem; min-width: 0; }
27
+ .studio-agent-voice-row .studio-mic-panel { flex: 1 0 100%; max-width: none; order: 3; }
28
+ .studio-agent-voice-row .studio-mic-failed { flex: 1 0 100%; order: 4; }
29
+ .studio-agent-voice-row .studio-recorder { flex: 1 1 10rem; min-width: 0; flex-flow: row wrap; justify-content: flex-end; gap: .5rem; }
30
+ .studio-agent-voice-row .studio-recorder-button { flex: 1 1 10rem; min-width: 0; padding: .625rem 1rem; }
31
+ .studio-agent-voice-row .studio-recorder-timer { display: none; }
32
+ .studio-agent-voice-row [data-voice-recorder-state="recording"] .studio-recorder-timer { display: inline; }
33
+ .studio-agent-voice-row .studio-recorder-status { flex: 1 0 100%; min-height: 0; max-width: none; font-size: .875rem; }
34
+ /* The chat's status line speaks for these three; a second live line would say
35
+ them twice, and its coming and going would move a held button. */
36
+ .studio-agent-voice-row .studio-recorder-status:empty,
37
+ .studio-agent-voice-row [data-voice-recorder-state="recording"] .studio-recorder-status,
38
+ .studio-agent-voice-row [data-voice-recorder-state="silent"] .studio-recorder-status { display: none; }
39
+ .studio-agent-voice-row .studio-recorder-failed { flex: 1 0 100%; }
40
+ </style>
41
+ <div class="studio-agent-voice" role="group" aria-label="Talk to <%= name %>"
42
+ data-studio-controller="agent-chat-voice" data-agent-chat-voice data-test="agent-chat-voice"
43
+ data-agent-chat-voice-state="idle"
44
+ data-agent-chat-voice-soul="<%= soul %>" data-agent-chat-voice-name="<%= name %>"
45
+ data-agent-chat-voice-takes-url="<%= studio_voice_takes_path %>"
46
+ <%= tag.attributes("data-agent-chat-voice-embedder": studio_agent_chat_embedder(studio_agent_chat_viewer)) %>>
47
+ <p class="studio-agent-voice-status" role="status" aria-live="polite"
48
+ data-agent-chat-voice-status data-test="agent-chat-voice-status"></p>
49
+ <div class="studio-agent-voice-row">
50
+ <%= render "studio/voice/microphone_chip", copy: words["chip"] %>
51
+ <%= render "studio/voice/recorder", id: "#{soul}-voice-recorder", mode: "hold", copy: words["recorder"] %>
52
+ </div>
53
+ <p class="studio-agent-voice-failed">Voice didn't load on this page. Reload to try again, or type instead.</p>
54
+ </div>
@@ -0,0 +1,44 @@
1
+ <%# The kit's scoped styles, read from the theme's tokens with a fallback beside
2
+ each, like the footer's. Inline, so nothing joins the app's asset manifest or
3
+ its Tailwind build. %>
4
+ <style>
5
+ .mkit { --mkit-ink: var(--color-text, #111827); --mkit-soft: var(--color-text-secondary, #4b5563);
6
+ --mkit-line: var(--color-border, rgb(127 127 127 / .3)); --mkit-card: var(--color-surface, transparent);
7
+ --mkit-primary: var(--color-primary, currentColor);
8
+ --mkit-link: var(--color-primary-ink, var(--mkit-primary));
9
+ max-width: 64rem; margin-inline: auto; padding: 4rem 1rem; display: grid; gap: 3rem; }
10
+ .mkit-hero { max-width: 48rem; margin-inline: auto; text-align: center; }
11
+ .mkit-tagline { margin: 0 0 .5rem; color: var(--mkit-link); font-weight: 600; letter-spacing: .05em; text-transform: uppercase; }
12
+ .mkit-title { margin: 0; color: var(--mkit-ink); font-size: 2.25rem; line-height: 1.1; font-weight: 800; letter-spacing: -.025em; }
13
+ .mkit-lede { margin: 1rem 0 0; color: var(--mkit-soft); font-size: 1.125rem; line-height: 1.625; }
14
+ .mkit-heading { margin: 0 0 1rem; color: var(--mkit-ink); font-size: 1.25rem; font-weight: 700; }
15
+ .mkit-card { overflow: hidden; border: 1px solid var(--mkit-line); border-radius: .75rem; background: var(--mkit-card); }
16
+ .mkit-card-body { display: block; padding: 1rem; }
17
+ .mkit-name { display: block; margin: 0; color: var(--mkit-ink); font-weight: 600; }
18
+
19
+ .mkit-logos { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(min(100%, 18rem), 1fr)); }
20
+ .mkit-tiles { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); }
21
+ .mkit-tile { display: flex; align-items: center; justify-content: center; height: 8rem; padding: 1rem; }
22
+ .mkit-tile-img { max-width: 100%; max-height: 100%; object-fit: contain; }
23
+ .mkit-downloads { display: flex; flex-wrap: wrap; gap: .25rem 1rem; margin-top: .5rem; font-size: .875rem; }
24
+ .mkit-download { color: var(--mkit-link); text-decoration: underline; text-underline-offset: 2px; }
25
+ .mkit-download:hover { text-decoration-thickness: 2px; }
26
+
27
+ .mkit-palette { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); }
28
+ .mkit-swatch { position: relative; }
29
+ .mkit-swatch-button { display: block; width: 100%; padding: 0; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: copy; }
30
+ .mkit-swatch-button:focus-visible { outline: 2px solid var(--mkit-primary); outline-offset: -2px; }
31
+ .mkit-swatch:hover { border-color: var(--mkit-primary); }
32
+ .mkit-chip { display: block; height: 5rem; border-bottom: 1px solid var(--mkit-line); }
33
+ .mkit-hex { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .875rem; color: var(--mkit-soft); user-select: text; }
34
+ /* The confirmation sits on the chip, on a ground of its own, so it reads over any colour. */
35
+ .mkit-status { position: absolute; top: .5rem; right: .5rem; padding: .125rem .5rem; border-radius: 9999px;
36
+ background: #111827; color: #fff; font-size: .75rem; font-weight: 600; pointer-events: none; }
37
+ /* Empty, it shows nothing but stays in the tree: a live region that was display: none can miss its announcement. */
38
+ .mkit-status:empty { padding: 0; background: none; }
39
+
40
+ .mkit-face { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: .75rem; padding: 1rem; }
41
+ .mkit-face + .mkit-face { border-top: 1px solid var(--mkit-line); }
42
+ .mkit-specimen { margin: 0; color: var(--mkit-ink); font-size: 1.25rem; }
43
+ .mkit-face-label { margin: 0; color: var(--mkit-soft); font-size: .875rem; }
44
+ </style>
@@ -0,0 +1,84 @@
1
+ <%# The marketing kit: logos, colours and typefaces (a resolved `kit`, from
2
+ Studio::MarketingKit.resolve). Each part goes when it has nothing to show. The
3
+ layout is scoped CSS (.mkit-*), not Tailwind utilities, for the footer's
4
+ reason: a utility a consumer's build has not seen compiles to nothing. %>
5
+ <%= render "studio/marketing_kit/assets" %>
6
+ <div class="mkit" data-marketing-kit>
7
+ <header class="mkit-hero">
8
+ <% if kit[:tagline] %>
9
+ <p class="mkit-tagline"><%= kit[:tagline] %></p>
10
+ <% end %>
11
+ <h1 class="mkit-title"><%= Studio::MarketingKit::LABEL %></h1>
12
+ <p class="mkit-lede">The <%= kit[:name] ? "#{kit[:name]} " : "" %>logos, colours and typefaces. Use them when you write about us.</p>
13
+ </header>
14
+
15
+ <% if kit[:logos].any? %>
16
+ <section aria-labelledby="mkit-logos" data-test="kit-logos">
17
+ <h2 id="mkit-logos" class="mkit-heading">Logos</h2>
18
+ <div class="mkit-logos">
19
+ <% kit[:logos].each do |logo| %>
20
+ <div class="mkit-card">
21
+ <div class="mkit-tiles">
22
+ <% logo[:tones].each do |tone| %>
23
+ <div class="mkit-tile" style="background-color: <%= tone[:ground] %>">
24
+ <img src="<%= tone[:src] %>" alt="<%= tone[:alt] %>" class="mkit-tile-img">
25
+ </div>
26
+ <% end %>
27
+ </div>
28
+ <div class="mkit-card-body">
29
+ <% if logo[:title] %>
30
+ <p class="mkit-name"><%= logo[:title] %></p>
31
+ <% end %>
32
+ <div class="mkit-downloads">
33
+ <% logo[:tones].each do |tone| %>
34
+ <a href="<%= tone[:href] %>" download class="mkit-download"><%= tone[:label] %></a>
35
+ <% end %>
36
+ </div>
37
+ </div>
38
+ </div>
39
+ <% end %>
40
+ </div>
41
+ </section>
42
+ <% end %>
43
+
44
+ <% if kit[:palette].any? %>
45
+ <section aria-labelledby="mkit-colours" data-test="kit-colours">
46
+ <h2 id="mkit-colours" class="mkit-heading">Colours</h2>
47
+ <div class="mkit-palette">
48
+ <% kit[:palette].each do |swatch| %>
49
+ <%# One button holds the chip, the name and the hex, so a click on any of
50
+ them copies. The confirmation sits outside it: a button's children
51
+ are not announced, and a status must be. %>
52
+ <div class="mkit-card mkit-swatch" data-studio-controller="copy-text" data-copy-text-text-value="<%= swatch[:hex] %>">
53
+ <button type="button" class="mkit-swatch-button" data-studio-action="click->copy-text#copy"
54
+ aria-label="Copy <%= [ swatch[:name], swatch[:hex] ].compact.join(", ") %>">
55
+ <span class="mkit-chip" style="background-color: <%= swatch[:hex] %>"></span>
56
+ <span class="mkit-card-body">
57
+ <% if swatch[:name] %>
58
+ <span class="mkit-name"><%= swatch[:name] %></span>
59
+ <% end %>
60
+ <code class="mkit-hex"><%= swatch[:hex] %></code>
61
+ </span>
62
+ </button>
63
+ <span class="mkit-status" role="status" data-copy-text-target="status"></span>
64
+ </div>
65
+ <% end %>
66
+ </div>
67
+ </section>
68
+ <% end %>
69
+
70
+ <% if kit[:typefaces].any? %>
71
+ <section aria-labelledby="mkit-type" data-test="kit-type">
72
+ <h2 id="mkit-type" class="mkit-heading">Typefaces</h2>
73
+ <div class="mkit-card">
74
+ <% kit[:typefaces].each do |face| %>
75
+ <div class="mkit-face">
76
+ <% style = [ ("font-family: #{face[:family]}, sans-serif" if face[:family]), ("font-weight: #{face[:weight]}" if face[:weight]) ].compact.join("; ") %>
77
+ <p class="mkit-specimen"<% if style.present? %> style="<%= style %>"<% end %>><%= kit[:name] || face[:name] %></p>
78
+ <p class="mkit-face-label"><%= [ face[:role], face[:name] ].compact.join(" · ") %></p>
79
+ </div>
80
+ <% end %>
81
+ </div>
82
+ </section>
83
+ <% end %>
84
+ </div>
@@ -0,0 +1,6 @@
1
+ <%# /marketing-kit (Studio::MarketingKitController). The kit is a partial so a host
2
+ can render it in a page of its own. %>
3
+ <% kit = studio_marketing_kit %>
4
+ <% content_for(:title, [ Studio::MarketingKit::LABEL, kit[:name] ].compact.join(" · ")) %>
5
+ <% content_for(:full_width, true) %>
6
+ <%= render "studio/marketing_kit/kit", kit: kit %>
@@ -78,6 +78,7 @@
78
78
  .ftr-wordmark-accent { color: var(--ftr-primary); }
79
79
  .ftr-tagline { margin: 1.5rem 0 0; line-height: 1.625; color: var(--color-text-secondary, inherit); }
80
80
  .ftr-email { margin: .75rem 0 0; }
81
+ .ftr-kit { margin: .75rem 0 0; }
81
82
  .ftr-socials { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; margin: 2rem 0 0; padding: 0; list-style: none; }
82
83
  .ftr-social { display: inline-flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem;
83
84
  border-radius: 9999px; background: var(--ftr-primary); color: #fff; transition: transform .15s ease, filter .15s ease; }
@@ -44,6 +44,11 @@
44
44
  <% if facts[:email] %>
45
45
  <p class="ftr-email"><a href="mailto:<%= facts[:email] %>" class="ftr-link ftr-link-solid"><%= facts[:email] %></a></p>
46
46
  <% end %>
47
+ <%# The brand block, not a link column or the legal line: the kit belongs to the
48
+ brand, and an app's own columns and legal line stay exactly what it wrote. %>
49
+ <% if facts[:marketing_kit] %>
50
+ <p class="ftr-kit"><%= render "studio/site_footer/link", link: facts[:marketing_kit], booking: booking %></p>
51
+ <% end %>
47
52
  <% if facts[:social].any? %>
48
53
  <ul class="ftr-socials" aria-label="Social profiles">
49
54
  <% facts[:social].each do |profile| %>
@@ -58,8 +58,11 @@
58
58
 
59
59
  <p class="studio-mic-help-other" data-microphone-help-other hidden><%= help.fetch("other") %></p>
60
60
 
61
- <form method="dialog" class="studio-mic-help-actions">
62
- <button type="submit" class="btn btn-primary studio-mic-help-close" data-microphone-help-close><%= help.fetch("close") %></button>
63
- </form>
61
+ <%# A button, never <form method="dialog">: this chip can sit inside a form (the
62
+ chat's composer), where the parser drops a nested form and Close would
63
+ submit the outer one. studio/microphone_chip closes the dialog. %>
64
+ <div class="studio-mic-help-actions">
65
+ <button type="button" class="btn btn-primary studio-mic-help-close" data-microphone-help-close><%= help.fetch("close") %></button>
66
+ </div>
64
67
  </div>
65
68
  </dialog>