studio-engine 0.121.0 → 0.123.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +121 -0
  3. data/README.md +2 -2
  4. data/app/controllers/concerns/studio/admin_models.rb +24 -16
  5. data/app/controllers/concerns/studio/error_handling.rb +13 -0
  6. data/app/controllers/studio/agent_chats_controller.rb +31 -4
  7. data/app/controllers/studio/voice_takes_controller.rb +66 -2
  8. data/app/controllers/style_controller.rb +2 -5
  9. data/app/controllers/theme_settings_controller.rb +8 -8
  10. data/app/helpers/studio/agent_chat_helper.rb +37 -0
  11. data/app/javascript/studio/agent_chat.js +42 -2
  12. data/app/javascript/studio/agent_chat_voice.js +318 -0
  13. data/app/javascript/studio/alpine_shims.js +1 -3
  14. data/app/javascript/studio/controllers/agent_chat_voice_controller.js +17 -0
  15. data/app/javascript/studio/controllers/style_section_nav_controller.js +80 -0
  16. data/app/javascript/studio/microphone_chip.js +8 -3
  17. data/app/javascript/studio/stimulus.js +3 -0
  18. data/app/javascript/studio/style_guide.js +2 -0
  19. data/app/javascript/studio/style_section_nav.js +16 -0
  20. data/app/javascript/studio/theme_editor.js +6 -106
  21. data/app/javascript/studio/voice_profile.js +11 -1
  22. data/app/javascript/studio/voice_recorder.js +36 -4
  23. data/app/models/studio/user_session.rb +76 -0
  24. data/app/models/theme_setting.rb +3 -0
  25. data/app/views/studio/admin_models/_sessions_table.html.erb +28 -0
  26. data/app/views/studio/admin_models/show.html.erb +22 -11
  27. data/app/views/studio/agent_chat/_chat.html.erb +17 -2
  28. data/app/views/studio/agent_chat/_form.html.erb +3 -2
  29. data/app/views/studio/agent_chat/_voice.html.erb +54 -0
  30. data/app/views/studio/voice/_microphone_help.html.erb +9 -4
  31. data/app/views/studio/voice/_styles.html.erb +4 -0
  32. data/app/views/style/_theme.html.erb +75 -12
  33. data/app/views/style/index.html.erb +28 -30
  34. data/db/migrate/20261103120000_create_studio_user_sessions.rb +16 -0
  35. data/docs/SITE_FOOTER.md +33 -0
  36. data/lib/studio/agent_chat.rb +83 -5
  37. data/lib/studio/site_footer.rb +45 -2
  38. data/lib/studio/ui_primitives.rb +4 -2
  39. data/lib/studio/version.rb +1 -1
  40. data/lib/studio/voice_transcription.rb +100 -34
  41. data/lib/studio.rb +81 -8
  42. metadata +11 -4
  43. data/app/views/studio/admin_models/index.html.erb +0 -31
  44. data/app/views/theme_settings/edit.html.erb +0 -304
@@ -58,6 +58,7 @@ export const MESSAGES = {
58
58
  silent: "I couldn't hear that one. Check your microphone and read it again.",
59
59
  failed: "That one didn't work. Tap Record and read it again.",
60
60
  denied: "Allow the microphone to set up your voice.",
61
+ busy: "Your microphone is busy in another app. Close it there, then try again.",
61
62
  unsupported: "This browser can't record here. Try Safari or Chrome.",
62
63
  network: "We couldn't reach the server. Tap Save again.",
63
64
  notSaved: "That didn't save. Tap Save again."
@@ -65,6 +66,15 @@ export const MESSAGES = {
65
66
 
66
67
  export class SilentTakeError extends Error {}
67
68
 
69
+ // Why the microphone would not open: refused, held by another app
70
+ // (NotReadableError; Chrome's older TrackStartError), or anything else.
71
+ export function openFailure(error) {
72
+ const name = error && error.name
73
+ if (name === "NotAllowedError") return MESSAGES.denied
74
+ if (name === "NotReadableError" || name === "TrackStartError") return MESSAGES.busy
75
+ return MESSAGES.failed
76
+ }
77
+
68
78
  // The words for a state, with {n} the phrase being read (1-based).
69
79
  export function copyFor(state, step) {
70
80
  const [button, status] = COPY[state] || ["Try again", ""]
@@ -304,7 +314,7 @@ export function mountVoiceProfile(root, win = window) {
304
314
  try {
305
315
  stream = await win.navigator.mediaDevices.getUserMedia({ audio: true })
306
316
  } catch (error) {
307
- setState("ready", error && error.name === "NotAllowedError" ? MESSAGES.denied : MESSAGES.failed)
317
+ setState("ready", openFailure(error))
308
318
  return
309
319
  }
310
320
  if (destroyed || state !== "ready") { release(); return }
@@ -22,6 +22,7 @@
22
22
  // studio:voice-recorder:take detail { blob, mimeType, durationMs, peakRms, microphone: { label, deviceId } }
23
23
  // studio:voice-recorder:silent detail { durationMs, microphone } (nothing heard: no blob is handed over)
24
24
  // studio:voice-recorder:state detail { state }
25
+ // studio:voice-recorder:hint detail { kind, text } (kind "heard" or "quiet", while it records)
25
26
  // and, on window, studio/microphone's EVENTS, which the chip listens to.
26
27
  //
27
28
  // MODES: "toggle" (tap to start, tap to stop) or "hold" (press and hold to
@@ -34,7 +35,8 @@ import { createLevelMeter, SILENCE_RMS } from "studio/level_meter"
34
35
  export const EVENTS = {
35
36
  take: "studio:voice-recorder:take",
36
37
  silent: "studio:voice-recorder:silent",
37
- state: "studio:voice-recorder:state"
38
+ state: "studio:voice-recorder:state",
39
+ hint: "studio:voice-recorder:hint"
38
40
  }
39
41
 
40
42
  // What to ask MediaRecorder for, in order: webm/opus on Chrome and Firefox,
@@ -61,7 +63,8 @@ export const COPY = {
61
63
  silent: { button: "Record", status: "I couldn't hear anything. Check your microphone and try again.",
62
64
  named: "I couldn't hear anything from %{microphone}. Pick another microphone and try again." },
63
65
  failed: { button: "Try again", status: "That didn't record. Try again.", start: "I couldn't start recording. Try again.",
64
- missing: "I couldn't find a microphone. Plug one in and try again." },
66
+ missing: "I couldn't find a microphone. Plug one in and try again.",
67
+ busy: "Your microphone is busy in another app. Close it there, then try again." },
65
68
  denied: { button: "Record", status: "Allow the microphone to record." },
66
69
  unsupported: { button: "Record", status: "This browser can't record here. Try Safari or Chrome." }
67
70
  }
@@ -78,6 +81,16 @@ export function formatElapsed(ms) {
78
81
  return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`
79
82
  }
80
83
 
84
+ // The line for a microphone that would not open (NotAllowedError is "denied",
85
+ // not this). NotReadableError is a microphone another app holds (Chrome's
86
+ // older name, TrackStartError); everything else reads as no microphone found.
87
+ export const BUSY_ERRORS = ["NotReadableError", "TrackStartError"]
88
+
89
+ export function failedLine(error, copy = COPY) {
90
+ const busy = !!error && BUSY_ERRORS.includes(error.name)
91
+ return busy ? (copy.failed.busy || COPY.failed.busy) : copy.failed.missing
92
+ }
93
+
81
94
  // The page's words over the defaults, one state at a time.
82
95
  export function mergeCopy(overrides) {
83
96
  const merged = {}
@@ -114,6 +127,7 @@ export function mountVoiceRecorder(root, { win = window, open = openMicrophone,
114
127
  let microphone = { label: "", deviceId: null }
115
128
  let hinted = null
116
129
  let stopWanted = false
130
+ let keyHeld = false
117
131
  let destroyed = false
118
132
 
119
133
  function setState(next, message = null) {
@@ -148,10 +162,11 @@ export function mountVoiceRecorder(root, { win = window, open = openMicrophone,
148
162
  }
149
163
 
150
164
  function hint(kind) {
151
- if (state !== "recording" || hinted === kind || !copy.recording[kind]) return
165
+ if (state !== "recording" || hinted === kind || copy.recording[kind] == null) return
152
166
  hinted = kind
153
167
  const status = one("[data-voice-recorder-status]")
154
168
  if (status) status.textContent = copy.recording[kind]
169
+ root.dispatchEvent(new win.CustomEvent(EVENTS.hint, { bubbles: true, detail: { kind, text: copy.recording[kind] } }))
155
170
  }
156
171
 
157
172
  function onSample(rms) {
@@ -193,7 +208,7 @@ export function mountVoiceRecorder(root, { win = window, open = openMicrophone,
193
208
  if (destroyed) return
194
209
  const denied = error && error.name === "NotAllowedError"
195
210
  if (denied) announce(MICROPHONE_EVENTS.denied, {}, win)
196
- setState(denied ? "denied" : "failed", denied ? null : copy.failed.missing)
211
+ setState(denied ? "denied" : "failed", denied ? null : failedLine(error, copy))
197
212
  return
198
213
  }
199
214
  stream = opened.stream
@@ -291,6 +306,9 @@ export function mountVoiceRecorder(root, { win = window, open = openMicrophone,
291
306
  if (mode !== "hold") return
292
307
  const button = event.target.closest && event.target.closest("[data-voice-recorder-button]")
293
308
  if (!button || !root.contains(button)) return
309
+ // Only the main button holds: a right-click (or a Mac's control-click) is a
310
+ // context menu, not a take. Touch and pen report button 0.
311
+ if (event.button || event.ctrlKey) return
294
312
  event.preventDefault()
295
313
  if (button.setPointerCapture && event.pointerId != null) {
296
314
  try { button.setPointerCapture(event.pointerId) } catch (_) { /* a synthetic pointer */ }
@@ -307,12 +325,24 @@ export function mountVoiceRecorder(root, { win = window, open = openMicrophone,
307
325
  if (mode !== "hold" || event.repeat || ![" ", "Enter"].includes(event.key)) return
308
326
  if (!event.target.closest || !event.target.closest("[data-voice-recorder-button]")) return
309
327
  event.preventDefault()
328
+ keyHeld = true
310
329
  start()
311
330
  }
312
331
 
313
332
  function onKeyUp(event) {
314
333
  if (mode !== "hold" || ![" ", "Enter"].includes(event.key)) return
315
334
  event.preventDefault()
335
+ keyHeld = false
336
+ onPointerUp()
337
+ }
338
+
339
+ // A key held on the button when focus leaves it (Tab, another window): its
340
+ // keyup lands elsewhere and never reaches here, and the microphone would stay
341
+ // live to the max_ms cap and send a take on its own. A pointer hold is captured,
342
+ // so it has its own pointerup.
343
+ function onFocusOut() {
344
+ if (!keyHeld) return
345
+ keyHeld = false
316
346
  onPointerUp()
317
347
  }
318
348
 
@@ -322,6 +352,7 @@ export function mountVoiceRecorder(root, { win = window, open = openMicrophone,
322
352
  root.addEventListener("pointercancel", onPointerUp)
323
353
  root.addEventListener("keydown", onKeyDown)
324
354
  root.addEventListener("keyup", onKeyUp)
355
+ root.addEventListener("focusout", onFocusOut)
325
356
  root.dataset.voiceRecorderMode = mode
326
357
  paintLevel(0)
327
358
  if (supported()) setState("idle")
@@ -341,6 +372,7 @@ export function mountVoiceRecorder(root, { win = window, open = openMicrophone,
341
372
  root.removeEventListener("pointercancel", onPointerUp)
342
373
  root.removeEventListener("keydown", onKeyDown)
343
374
  root.removeEventListener("keyup", onKeyUp)
375
+ root.removeEventListener("focusout", onFocusOut)
344
376
  if (recorder && recorder.state !== "inactive") {
345
377
  try { recorder.stop() } catch (_) { /* already stopped */ }
346
378
  }
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # One signed-in browser session: who, whether they were an admin, when it
5
+ # started and when it was last seen. It is a cached record for the app's own
6
+ # dashboard, kept on purpose without an IP, a user agent or a path.
7
+ #
8
+ # A new row starts after IDLE without a request. An open row is written at most
9
+ # once per THROTTLE, and the cookie remembers when, so a request inside that
10
+ # window runs no query at all.
11
+ #
12
+ # Nil-safe, because the table comes from a migration the host runs: an app that
13
+ # has not run it records nothing and serves as before.
14
+ class UserSession < ApplicationRecord
15
+ self.table_name = "studio_user_sessions"
16
+
17
+ IDLE = 30.minutes
18
+ THROTTLE = 5.minutes
19
+
20
+ belongs_to :user, class_name: "::User", optional: true
21
+
22
+ scope :recent, -> { order(last_seen_at: :desc) }
23
+ scope :non_admin, -> { where(admin: false) }
24
+
25
+ # Per app, like Studio.session_key: apps on the hub's shared cookie
26
+ # (STUDIO_SSO_SHARED_COOKIE) must not read each other's mark.
27
+ def self.cookie_key = :"#{Studio.session_key}_session_mark"
28
+
29
+ # Off under test unless a test asks: the row a first request writes would count in
30
+
31
+ # every host's query-budget test, which measures the page and not this.
32
+
33
+ cattr_accessor :record_in_test, default: false
34
+
35
+
36
+ def self.recording?
37
+
38
+ !Rails.env.test? || record_in_test
39
+
40
+ end
41
+
42
+
43
+ def self.available?
44
+ connection.data_source_exists?(table_name)
45
+ rescue ActiveRecord::ActiveRecordError, NoMethodError
46
+ false
47
+ end
48
+
49
+ # Records this request for `user`, reading and writing `store` (the session).
50
+ # Answers the row it wrote, or nil. Never raises: a request must not fail
51
+ # because its bookkeeping did.
52
+ def self.track(store, user, now: Time.current)
53
+ return if user.nil? || !recording?
54
+
55
+ mark = store[cookie_key]
56
+ mark = mark.to_h.transform_keys(&:to_s) if mark.respond_to?(:to_h)
57
+ same_user = mark.is_a?(Hash) && mark["user"] == user.id
58
+ seen = same_user ? Time.zone.at(mark["at"].to_i) : nil
59
+ return if seen && seen > now - THROTTLE
60
+ return unless available?
61
+
62
+ row = same_user && seen > now - IDLE ? find_by(id: mark["id"], user_id: user.id) : nil
63
+ if row
64
+ row.update_columns(last_seen_at: now)
65
+ else
66
+ admin = user.respond_to?(:admin?) && user.admin?
67
+ row = create!(user_id: user.id, admin: admin ? true : false, started_at: now, last_seen_at: now)
68
+ end
69
+ store[cookie_key] = { "id" => row.id, "user" => user.id, "at" => now.to_i }
70
+ row
71
+ rescue StandardError => e
72
+ Rails.logger&.warn("[user_session] #{e.class}: #{e.message}")
73
+ nil
74
+ end
75
+ end
76
+ end
@@ -9,6 +9,9 @@ class ThemeSetting < ApplicationRecord
9
9
  end
10
10
 
11
11
  validates :app_name, presence: true, uniqueness: true
12
+ # Every page's head runs these through Studio::ColorScale, which reads six hex
13
+ # digits. A saved "#abc" would answer 500 on every page, the editor included.
14
+ validates(*ROLES.map { |role| db_column_for(role) }, format: { with: /\A#\h{6}\z/, message: "must be a #rrggbb colour" }, allow_blank: true)
12
15
 
13
16
  # Returns the ThemeSetting for the current app, or a new unsaved instance.
14
17
  def self.current
@@ -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,28 @@
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>
3
+ <div class="max-w-7xl mx-auto px-4 pb-8 space-y-6">
4
+ <%# The title and a tab per model, one sticky row, as on /admin/style. A tab is a
5
+ link, so the open one is the page's own model. %>
6
+ <nav class="sticky -mx-4 mt-6 px-4 py-2 bg-surface border-y border-subtle flex items-center gap-2"
7
+ style="top: var(--pin-stack-bottom, var(--nav-bottom, var(--nav-h, 0px))); z-index: var(--z-sticky, 30)"
8
+ aria-label="Models">
9
+ <h1 class="text-xl font-bold text-heading shrink-0">Models</h1>
10
+ <div class="overflow-x-auto min-w-0 flex-1">
11
+ <div class="flex items-center gap-2 min-w-max pl-4">
12
+ <% admin_model_configs.each do |key, config| %>
13
+ <% open = key.to_s == @key %>
14
+ <%= link_to config.fetch(:label), admin_model_path(key),
15
+ class: "btn btn-sm #{open ? 'btn-primary' : 'btn-neutral'}",
16
+ aria: { current: (open ? "page" : nil) } %>
17
+ <% end %>
18
+ </div>
12
19
  </div>
13
- <%= link_to "Back to models", admin_models_path, class: "btn btn-outline" %>
14
- </header>
20
+ </nav>
21
+
22
+ <p class="text-muted text-sm">
23
+ <%= @config.fetch(:description) %>.
24
+ Page <%= @page %> of <%= @total_pages %>, <%= @total_count %> total.
25
+ </p>
15
26
 
16
27
  <section class="card overflow-hidden">
17
28
  <%= render admin_models_table_partial(@key), records: @records %>
@@ -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,5 +1,5 @@
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",
@@ -20,4 +20,5 @@
20
20
  </svg>
21
21
  </button>
22
22
  </div>
23
+ <%= mic %>
23
24
  <% end %>
@@ -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>
@@ -27,7 +27,9 @@
27
27
  <line x1="19" y1="20" x2="33" y2="20"/><circle cx="23" cy="20" r="2.5" class="mock-knob"/>
28
28
  <line x1="19" y1="28" x2="33" y2="28"/><circle cx="29" cy="28" r="2.5" class="mock-knob"/>
29
29
  </g>
30
- <text x="48" y="29" class="mock-text" font-size="13"><%= request.host %></text>
30
+ <%# The address is a drawn bar, not this app's host: the drawing is the
31
+ same on every app, and renders without a request. %>
32
+ <rect x="48" y="19" width="132" height="10" rx="5" class="mock-address"/>
31
33
  <rect x="12" y="54" width="232" height="106" rx="10" class="mock-panel"/>
32
34
  <g class="mock-ink" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
33
35
  <rect x="26" y="68" width="8" height="13" rx="4"/><path d="M23 77a7 7 0 0 0 14 0M30 84v4"/>
@@ -56,8 +58,11 @@
56
58
 
57
59
  <p class="studio-mic-help-other" data-microphone-help-other hidden><%= help.fetch("other") %></p>
58
60
 
59
- <form method="dialog" class="studio-mic-help-actions">
60
- <button type="submit" class="btn btn-primary studio-mic-help-close" data-microphone-help-close><%= help.fetch("close") %></button>
61
- </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>
62
67
  </div>
63
68
  </dialog>
@@ -32,6 +32,9 @@
32
32
  border: 1px solid transparent; background: transparent; padding: .5rem .75rem; text-align: left; font-size: .875rem;
33
33
  color: var(--color-text, inherit); cursor: pointer; }
34
34
  .studio-mic-option:hover { background-color: rgb(var(--color-primary-500-rgb, 99 102 241) / .08); }
35
+ /* The same 3px ring as the chip on every control in the picker. */
36
+ .studio-mic-option:focus-visible, .studio-mic-help-link:focus-visible {
37
+ outline: 3px solid rgb(var(--color-primary-500-rgb, 99 102 241) / .5); outline-offset: 2px; }
35
38
  .studio-mic-option[aria-current="true"] { border-color: rgb(var(--color-primary-500-rgb, 99 102 241));
36
39
  background-color: rgb(var(--color-primary-500-rgb, 99 102 241) / .12); font-weight: 600; }
37
40
  .studio-mic-check { width: 1rem; flex-shrink: 0; font-weight: 700; color: rgb(var(--color-primary-500-rgb, 99 102 241)); }
@@ -64,6 +67,7 @@
64
67
  .studio-mic-mock .mock-ink { stroke: currentColor; }
65
68
  .studio-mic-mock .mock-knob { fill: currentColor; stroke: none; }
66
69
  .studio-mic-mock .mock-text { fill: currentColor; font-family: inherit; }
70
+ .studio-mic-mock .mock-address { fill: currentColor; fill-opacity: .3; }
67
71
  .studio-mic-mock .mock-on, .studio-mic-mock .mock-badge { fill: rgb(var(--color-primary-500-rgb, 99 102 241)); }
68
72
  .studio-mic-mock .mock-knob-on, .studio-mic-mock .mock-badge-text { fill: var(--color-surface, Canvas); }
69
73
 
@@ -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>