studio-engine 0.122.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -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 +11 -0
  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 +1 -0
  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_recorder.js +23 -2
  22. data/app/models/studio/user_session.rb +76 -0
  23. data/app/models/theme_setting.rb +3 -0
  24. data/app/views/studio/admin_models/_sessions_table.html.erb +28 -0
  25. data/app/views/studio/admin_models/show.html.erb +22 -11
  26. data/app/views/studio/agent_chat/_chat.html.erb +17 -2
  27. data/app/views/studio/agent_chat/_form.html.erb +3 -2
  28. data/app/views/studio/agent_chat/_voice.html.erb +54 -0
  29. data/app/views/studio/voice/_microphone_help.html.erb +6 -3
  30. data/app/views/style/_theme.html.erb +75 -12
  31. data/app/views/style/index.html.erb +28 -30
  32. data/db/migrate/20261103120000_create_studio_user_sessions.rb +16 -0
  33. data/docs/SITE_FOOTER.md +33 -0
  34. data/lib/studio/agent_chat.rb +83 -5
  35. data/lib/studio/site_footer.rb +45 -2
  36. data/lib/studio/ui_primitives.rb +4 -2
  37. data/lib/studio/version.rb +1 -1
  38. data/lib/studio.rb +64 -6
  39. metadata +11 -4
  40. data/app/views/studio/admin_models/index.html.erb +0 -31
  41. data/app/views/theme_settings/edit.html.erb +0 -304
@@ -0,0 +1,318 @@
1
+ // studio/agent_chat_voice: hold to talk in the shared agent chat's composer
2
+ // (studio/agent_chat/_voice; docs/AGENT_CHAT.md, "Talking by voice").
3
+ //
4
+ // THE FLOW, for one take the engine recorder hands over
5
+ // (studio:voice-recorder:take {blob, mimeType, durationMs}):
6
+ //
7
+ // 1. embed it ON THIS DEVICE when the page names an embedder (the person has
8
+ // a saved voice; data-agent-chat-voice-embedder is the /profile voice row's
9
+ // own config), with studio/voice_profile's embedderFor. Any failure, or a
10
+ // model slower than EMBED_WAIT_MS, sends the take without a vector: the
11
+ // server then answers "Someone new" (check no_embedding), never an error.
12
+ // 2. POST it to /voice/takes (multipart, X-CSRF-Token), which answers
13
+ // {text, speaker, score, identified, check, duration_ms} and keeps nothing.
14
+ // 3. send `text` to the chat's own form action with `voice` {check, score,
15
+ // duration_ms} and hand the turn to the chat (its public refresh hook,
16
+ // studio:agent-chat:refresh), so the turn and the typing line appear. The
17
+ // browser never sends a name: the server names the speaker, and the line
18
+ // says who it kept.
19
+ //
20
+ // Every refusal is a line in [data-agent-chat-voice-status] (set as text) and a
21
+ // state on data-agent-chat-voice-state: listening, transcribing, queued, sending,
22
+ // sent, silent, no_speech, not_configured, rate_limited, refused, failed.
23
+ //
24
+ // NO WORDS ARE LOST. A transcript that could not be sent goes in the chat's box,
25
+ // after anything already typed there. A take that lands while another is still
26
+ // being turned into words waits its turn, in order, and the line says so.
27
+ //
28
+ // It imports nothing at load (studio/voice_profile, and the model behind it, only
29
+ // when a person with a saved voice talks), so test/javascript/agent_chat_voice.test.mjs
30
+ // loads it as a data: module.
31
+
32
+ export const EMBED_WAIT_MS = 8000
33
+
34
+ export const MESSAGES = {
35
+ listening: "Listening...",
36
+ transcribing: "Turning that into words...",
37
+ queued: "Got that one too. It's next.",
38
+ sending: (name) => `Sending to ${name}...`,
39
+ sent: (speaker) => `Sent. Spoken by ${speaker}.`,
40
+ sentPlain: "Sent.",
41
+ silent: (label) => `I couldn't hear anything${label ? ` from ${label}` : ""}. Check your microphone and hold to talk again.`,
42
+ noSpeech: "I couldn't hear any words in that. Check your microphone and try again.",
43
+ notConfigured: "Voice isn't set up on this app yet, so I can't hear recordings. Type instead for now.",
44
+ rateLimited: "That's a lot of voice messages in a row. Wait a moment, then hold to talk again.",
45
+ signedOut: "Sign in again to talk.",
46
+ unavailable: "I couldn't transcribe that just now. Try again in a moment.",
47
+ failed: "That recording didn't go through. Try again.",
48
+ offline: "Can't reach the server right now. Try again in a moment.",
49
+ notSent: (name) => `Couldn't send that to ${name}. Your words are in the box; press send to try again.`
50
+ }
51
+
52
+ // "Try again in 40 seconds." from a Retry-After header or a retry_after field.
53
+ export function retryHint(seconds) {
54
+ const n = Math.round(Number(seconds))
55
+ if (!Number.isFinite(n) || n <= 0) return ""
56
+ if (n < 60) return ` Try again in ${n} second${n === 1 ? "" : "s"}.`
57
+ const minutes = Math.ceil(n / 60)
58
+ return ` Try again in ${minutes} minute${minutes === 1 ? "" : "s"}.`
59
+ }
60
+
61
+ // What POST /voice/takes's refusal means on the page: {state, message}.
62
+ // `body` is the parsed JSON (or {}); `retryAfter` the header, if any.
63
+ export function refusalFor(status, body = {}, retryAfter = null) {
64
+ const said = body && typeof body.error === "string" && body.error.trim() ? body.error.trim() : null
65
+ if (status === 401) return { state: "refused", message: MESSAGES.signedOut }
66
+ if (status === 429) {
67
+ return { state: "rate_limited", message: MESSAGES.rateLimited + retryHint(retryAfter ?? (body && body.retry_after)) }
68
+ }
69
+ if (status === 422 && body && body.reason === "no_speech") return { state: "no_speech", message: said || MESSAGES.noSpeech }
70
+ if (status === 503 && body && body.reason === "not_configured") return { state: "not_configured", message: said || MESSAGES.notConfigured }
71
+ if (status === 503) return { state: "failed", message: said || MESSAGES.unavailable }
72
+ if ([403, 413, 415, 422].includes(status)) return { state: "refused", message: said || MESSAGES.failed }
73
+ return { state: "failed", message: MESSAGES.failed }
74
+ }
75
+
76
+ // The message's `voice`, from a 200 answer: what the take measured, never a name.
77
+ export function voiceMetadata(answer = {}) {
78
+ const score = typeof answer.score === "number" && Number.isFinite(answer.score) ? answer.score : null
79
+ const duration = Number.isInteger(answer.duration_ms) ? answer.duration_ms : null
80
+ return { check: String(answer.check || "no_embedding"), score, duration_ms: duration }
81
+ }
82
+
83
+ // The multipart body POST /voice/takes reads (docs/VOICE_TRANSCRIPTION.md).
84
+ export function takeForm(detail, { embedding = null, model = null } = {}, FormDataClass = globalThis.FormData) {
85
+ const form = new FormDataClass()
86
+ form.append("voice[audio]", detail.blob, "take")
87
+ if (detail.mimeType) form.append("voice[mime_type]", detail.mimeType)
88
+ form.append("voice[duration_ms]", String(Math.max(1, Math.round(Number(detail.durationMs) || 0))))
89
+ if (Array.isArray(embedding) && embedding.length && model) {
90
+ form.append("voice[embedding]", JSON.stringify(embedding))
91
+ form.append("voice[model]", model)
92
+ }
93
+ return form
94
+ }
95
+
96
+ export function parseEmbedder(text) {
97
+ if (!text) return null
98
+ try {
99
+ const config = JSON.parse(text)
100
+ return config && config.name && config.id ? config : null
101
+ } catch (_) {
102
+ return null
103
+ }
104
+ }
105
+
106
+ // The words in the box, after anything already there: a person's draft is theirs.
107
+ export function wordsInBox(current, text) {
108
+ return current && current.trim() ? `${current.trimEnd()}\n${text}` : text
109
+ }
110
+
111
+ // The page's token (csrf_meta_tags), else the chat form's. In that order: the
112
+ // meta tag's is good for any action, and the form's is per-form under
113
+ // load_defaults 8.1, bound to POST /agent_chat/messages, so POST /voice/takes
114
+ // refuses it.
115
+ export function csrfToken(form, doc = globalThis.document) {
116
+ const meta = doc && doc.querySelector("meta[name='csrf-token']")
117
+ const content = meta && meta.getAttribute("content")
118
+ if (content) return content
119
+ const field = form && form.querySelector("input[name='authenticity_token']")
120
+ return field && field.value ? field.value : null
121
+ }
122
+
123
+ // Resolves to `promise`'s value, or `fallback` once `ms` have passed.
124
+ export function within(promise, ms, fallback = null, setTimer = (fn, t) => globalThis.setTimeout(fn, t)) {
125
+ return Promise.race([promise, new Promise((resolve) => setTimer(() => resolve(fallback), ms))])
126
+ }
127
+
128
+ async function readJson(response) {
129
+ try { return await response.json() } catch (_) { return {} }
130
+ }
131
+
132
+ // Mounts one voice section, which sits inside the chat's form. Returns
133
+ // { takeArrived, destroy } for tests.
134
+ export function mountAgentChatVoice(root, {
135
+ fetch: fetcher = (...args) => globalThis.fetch(...args),
136
+ doc = globalThis.document,
137
+ loadEmbedder = async (config) => {
138
+ const { embedderFor } = await import("studio/voice_profile")
139
+ return embedderFor(config)
140
+ },
141
+ setTimer = (fn, ms) => globalThis.setTimeout(fn, ms),
142
+ FormDataClass = globalThis.FormData,
143
+ CustomEventClass = globalThis.CustomEvent
144
+ } = {}) {
145
+ const status = root.querySelector("[data-agent-chat-voice-status]")
146
+ const form = root.closest("[data-agent-chat-form]")
147
+ const chat = root.closest("[data-agent-chat-soul]")
148
+ const name = root.dataset.agentChatVoiceName || "the agent"
149
+ const soul = root.dataset.agentChatVoiceSoul
150
+ const embedders = new Map()
151
+ const waiting = []
152
+ let working = false
153
+ let alive = true
154
+
155
+ function say(state, message) {
156
+ root.dataset.agentChatVoiceState = state
157
+ if (status) status.textContent = message || ""
158
+ }
159
+
160
+ function embedder() {
161
+ const config = parseEmbedder(root.getAttribute("data-agent-chat-voice-embedder"))
162
+ if (!config) return Promise.resolve(null)
163
+ if (!embedders.has(config.id)) {
164
+ const loading = Promise.resolve().then(() => loadEmbedder(config)).catch(() => null)
165
+ embedders.set(config.id, loading)
166
+ loading.then((loaded) => { if (!loaded) embedders.delete(config.id) })
167
+ }
168
+ return embedders.get(config.id)
169
+ }
170
+
171
+ async function embed(blob) {
172
+ try {
173
+ const loaded = await within(embedder(), EMBED_WAIT_MS, null, setTimer)
174
+ if (!loaded) return {}
175
+ const vector = await within(loaded.embed(blob), EMBED_WAIT_MS, null, setTimer)
176
+ return Array.isArray(vector) && vector.length ? { embedding: vector, model: loaded.id } : {}
177
+ } catch (_) {
178
+ return {}
179
+ }
180
+ }
181
+
182
+ function keepWords(text) {
183
+ const input = form && form.querySelector("[data-agent-chat-input]")
184
+ if (input) input.value = wordsInBox(input.value, text)
185
+ }
186
+
187
+ function headers(extra = {}) {
188
+ const token = csrfToken(form, doc)
189
+ return { Accept: "application/json", ...extra, ...(token ? { "X-CSRF-Token": token } : {}) }
190
+ }
191
+
192
+ async function send(answer) {
193
+ if (!form) {
194
+ say("failed", MESSAGES.notSent(name))
195
+ return
196
+ }
197
+ say("sending", MESSAGES.sending(name))
198
+ let response
199
+ try {
200
+ response = await fetcher(form.getAttribute("action"), {
201
+ method: "POST", headers: headers({ "Content-Type": "application/json" }), credentials: "same-origin",
202
+ body: JSON.stringify({ soul, text: answer.text, voice: voiceMetadata(answer) })
203
+ })
204
+ } catch (_) {
205
+ keepWords(answer.text)
206
+ say("failed", MESSAGES.notSent(name))
207
+ return
208
+ }
209
+ const data = await readJson(response)
210
+ if (response.status === 201 && data.turn) {
211
+ // The speaker the server kept, which is what the agent is told.
212
+ const kept = data.voice && typeof data.voice.speaker === "string" ? data.voice.speaker : null
213
+ say("sent", kept ? MESSAGES.sent(kept) : MESSAGES.sentPlain)
214
+ if (chat) chat.dispatchEvent(new CustomEventClass("studio:agent-chat:refresh", { detail: { turn: data.turn } }))
215
+ return
216
+ }
217
+ keepWords(answer.text)
218
+ const reason = typeof data.error === "string" && data.error ? `${data.error} ` : ""
219
+ say(response.status === 429 ? "rate_limited" : "failed", `${reason}${MESSAGES.notSent(name)}`)
220
+ }
221
+
222
+ async function handleTake(detail) {
223
+ say("transcribing", MESSAGES.transcribing)
224
+ const vector = await embed(detail.blob)
225
+ let response
226
+ try {
227
+ response = await fetcher(root.dataset.agentChatVoiceTakesUrl || "/voice/takes", {
228
+ method: "POST", headers: headers(), credentials: "same-origin", body: takeForm(detail, vector, FormDataClass)
229
+ })
230
+ } catch (_) {
231
+ say("failed", MESSAGES.offline)
232
+ return
233
+ }
234
+ const body = await readJson(response)
235
+ if (!response.ok || typeof body.text !== "string" || !body.text.trim()) {
236
+ const retryAfter = response.headers && response.headers.get ? response.headers.get("Retry-After") : null
237
+ const { state, message } = response.ok ? { state: "no_speech", message: MESSAGES.noSpeech } : refusalFor(response.status, body, retryAfter)
238
+ say(state, message)
239
+ return
240
+ }
241
+ await send(body)
242
+ }
243
+
244
+ // One take at a time, in the order they were spoken. Resolves when the queue
245
+ // has drained, so a test can await it.
246
+ async function takeArrived(detail) {
247
+ if (working) {
248
+ waiting.push(detail)
249
+ say("queued", MESSAGES.queued)
250
+ return
251
+ }
252
+ working = true
253
+ try {
254
+ let next = detail
255
+ while (next && alive) {
256
+ await handleTake(next)
257
+ next = waiting.shift()
258
+ }
259
+ } finally {
260
+ working = false
261
+ }
262
+ }
263
+
264
+ function onTake(event) {
265
+ if (event.detail && event.detail.blob) takeArrived(event.detail)
266
+ }
267
+
268
+ // The one line for a silent take, naming the microphone (the recorder's own
269
+ // line for it is hidden in the composer, studio/agent_chat/_voice).
270
+ function onSilent(event) {
271
+ say("silent", MESSAGES.silent(event.detail && event.detail.microphone && event.detail.microphone.label))
272
+ }
273
+
274
+ // The recorder's state, on the one line: a new hold replaces whatever the last
275
+ // take left there ("Sent.", a refusal). A take still in hand speaks over it
276
+ // when it lands. Starting the model download here gets it ready while the
277
+ // person is still talking.
278
+ function onState(event) {
279
+ const next = event.detail && event.detail.state
280
+ if (next === "starting") {
281
+ say("idle", "")
282
+ } else if (next === "recording") {
283
+ say("listening", MESSAGES.listening)
284
+ embedder().then((loaded) => loaded && loaded.warm && loaded.warm()).catch(() => {})
285
+ } else if (root.dataset.agentChatVoiceState === "listening") {
286
+ say(working ? "transcribing" : "idle", working ? MESSAGES.transcribing : "")
287
+ }
288
+ }
289
+
290
+ // "I can't hear anything yet" on the one line: the recorder's own line is hidden
291
+ // in the composer while it records, so a held button never moves for it.
292
+ function onHint(event) {
293
+ const text = event.detail && event.detail.text
294
+ if (text) say("listening", text)
295
+ // Heard after the quiet hint: the recorder's "heard" is blank here, so this takes
296
+ // "I can't hear anything yet" back. No write when the line already says it.
297
+ else if (root.dataset.agentChatVoiceState === "listening" && status && status.textContent !== MESSAGES.listening) say("listening", MESSAGES.listening)
298
+ }
299
+
300
+ // A Turbo restore brings back whatever the line last said, with no take behind it.
301
+ say("idle", "")
302
+
303
+ root.addEventListener("studio:voice-recorder:take", onTake)
304
+ root.addEventListener("studio:voice-recorder:silent", onSilent)
305
+ root.addEventListener("studio:voice-recorder:state", onState)
306
+ root.addEventListener("studio:voice-recorder:hint", onHint)
307
+
308
+ return {
309
+ takeArrived,
310
+ destroy() {
311
+ alive = false
312
+ root.removeEventListener("studio:voice-recorder:take", onTake)
313
+ root.removeEventListener("studio:voice-recorder:silent", onSilent)
314
+ root.removeEventListener("studio:voice-recorder:state", onState)
315
+ root.removeEventListener("studio:voice-recorder:hint", onHint)
316
+ }
317
+ }
318
+ }
@@ -25,7 +25,7 @@ import {
25
25
  } from "studio/head_chrome"
26
26
  import { studioBoard, loadSortable, sortableShim } from "studio/board"
27
27
  import { levelingActionModal } from "studio/leveling_activity"
28
- import { themeEditor, dsThemeEditor } from "studio/theme_editor"
28
+ import { dsThemeEditor } from "studio/theme_editor"
29
29
  import { emailBannerEditor, emailRecipients } from "studio/email_banner"
30
30
 
31
31
  // x-data="navCollapse()": the hub's own header. A host that defines its own
@@ -60,9 +60,7 @@ export function installAlpineShims() {
60
60
  // it. SortableJS replaces this object when it arrives; a page that loads its
61
61
  // own copy (turf-monster's slate page) replaces it the same way.
62
62
  if (!window.Sortable) window.Sortable = sortableShim(() => loadSortable())
63
- // x-data="themeEditor({...})" (theme_settings/edit) and
64
63
  // x-data="dsThemeEditor({...})" (style/_theme). Alpine passes the seed alone.
65
- if (!window.themeEditor) window.themeEditor = (seed) => themeEditor(seed)
66
64
  if (!window.dsThemeEditor) window.dsThemeEditor = (seed) => dsThemeEditor(seed)
67
65
  // x-data="emailBannerEditor({...})" (studio/emails/show) and
68
66
  // x-data="emailRecipients({...})" (studio/emails/index).
@@ -0,0 +1,17 @@
1
+ // agent-chat-voice: hold to talk in the shared agent chat's composer
2
+ // (studio/agent_chat/_voice), on the engine's Stimulus application (studio/stimulus
3
+ // registers it lazily, the first time a page renders the mic). The take's journey
4
+ // is studio/agent_chat_voice.
5
+ import { Controller } from "@hotwired/stimulus"
6
+ import { mountAgentChatVoice } from "studio/agent_chat_voice"
7
+
8
+ export default class extends Controller {
9
+ connect() {
10
+ this.voice = mountAgentChatVoice(this.element)
11
+ }
12
+
13
+ disconnect() {
14
+ if (this.voice) this.voice.destroy()
15
+ this.voice = null
16
+ }
17
+ }
@@ -0,0 +1,80 @@
1
+ // style-section-nav: the style guide's sticky row (style/index). Its tabs follow
2
+ // the section the reader is in, by scroll and by a tab's own jump.
3
+ //
4
+ // <nav data-studio-controller="style-section-nav">
5
+ // <div data-style-section-nav-target="scroller">
6
+ // <a href="#theme" data-style-section-nav-target="link">Theme</a>
7
+ //
8
+ // Registered by studio/style_guide, which only the style guide loads.
9
+ import { Controller } from "@hotwired/stimulus"
10
+ import { activeIndex } from "studio/style_section_nav"
11
+
12
+ const ACTIVE = "btn-primary"
13
+ const REST = "btn-neutral"
14
+ // A jump lands a section by its scroll-margin (--nav-h + 5rem), read while the
15
+ // header may still be tall; the header then collapses, so the row rises and the
16
+ // section does not. The full margin keeps the clicked section past the line,
17
+ // with the collapse's height to spare, for any later scroll or resize.
18
+ const LINE_SLACK = 80
19
+
20
+ export default class extends Controller {
21
+ static targets = ["link", "scroller"]
22
+
23
+ connect() {
24
+ this.schedule = this.schedule.bind(this)
25
+ window.addEventListener("scroll", this.schedule, { passive: true })
26
+ window.addEventListener("resize", this.schedule)
27
+ this.sync()
28
+ }
29
+
30
+ disconnect() {
31
+ window.removeEventListener("scroll", this.schedule)
32
+ window.removeEventListener("resize", this.schedule)
33
+ if (this.frame) cancelAnimationFrame(this.frame)
34
+ this.frame = null
35
+ }
36
+
37
+ schedule() {
38
+ if (this.frame) return
39
+ this.frame = requestAnimationFrame(() => {
40
+ this.frame = null
41
+ this.sync()
42
+ })
43
+ }
44
+
45
+ sync() {
46
+ const links = this.linkTargets
47
+ const tops = links.map((link) => {
48
+ const section = document.getElementById(link.getAttribute("href").slice(1))
49
+ return section ? section.getBoundingClientRect().top : Infinity
50
+ })
51
+ const line = this.element.getBoundingClientRect().bottom + LINE_SLACK
52
+ const page = document.documentElement
53
+ const atPageEnd = page.scrollTop > 0 && window.innerHeight + page.scrollTop >= page.scrollHeight - 2
54
+ this.highlight(links[activeIndex(tops, line, atPageEnd)])
55
+ }
56
+
57
+ highlight(current) {
58
+ if (!current || current === this.current) return
59
+ this.current = current
60
+ this.linkTargets.forEach((link) => {
61
+ const on = link === current
62
+ link.classList.toggle(ACTIVE, on)
63
+ link.classList.toggle(REST, !on)
64
+ if (on) link.setAttribute("aria-current", "true")
65
+ else link.removeAttribute("aria-current")
66
+ })
67
+ this.reveal(current)
68
+ }
69
+
70
+ // Keeps the active tab in view on a narrow screen. Moves the row only:
71
+ // scrollIntoView would also move the page the reader is scrolling.
72
+ reveal(link) {
73
+ if (!this.hasScrollerTarget) return
74
+ const row = this.scrollerTarget
75
+ const box = row.getBoundingClientRect()
76
+ const tab = link.getBoundingClientRect()
77
+ if (tab.left < box.left) row.scrollLeft -= box.left - tab.left + 32
78
+ else if (tab.right > box.right) row.scrollLeft += tab.right - box.right + 32
79
+ }
80
+ }
@@ -325,6 +325,7 @@ export function mountMicrophoneChip(root, { win = window } = {}) {
325
325
  if (!target.closest) return
326
326
  // A click on the backdrop lands on the dialog element itself.
327
327
  if (dialog && target === dialog) { dialog.close(); return }
328
+ if (dialog && target.closest("[data-microphone-help-close]")) { dialog.close(); return }
328
329
  const option = target.closest("[data-microphone-option]")
329
330
  if (option && root.contains(option)) { pick(option); return }
330
331
  if (target.closest("[data-microphone-help-link]")) {
@@ -63,6 +63,8 @@ export const schema = {
63
63
  // So are the voice capture pair (docs/VOICE_CAPTURE.md): a chip whose controller
64
64
  // failed still shows its prompt line, and a recorder whose controller failed
65
65
  // says recording did not load (data-studio-controller-failed="voice-recorder").
66
+ // So is the chat's mic: a mic whose controller failed says voice did not load,
67
+ // and the box still types (data-studio-controller-failed="agent-chat-voice").
66
68
  export const LAZY = {
67
69
  "board": () => import("studio/controllers/board_controller"),
68
70
  "geo-settings": () => import("studio/controllers/geo_settings_controller"),
@@ -73,6 +75,7 @@ export const LAZY = {
73
75
  "survey": () => import("studio/controllers/survey_controller"),
74
76
  "knowledge-transcript": () => import("studio/controllers/knowledge_transcript_controller"),
75
77
  "agent-chat": () => import("studio/controllers/agent_chat_controller"),
78
+ "agent-chat-voice": () => import("studio/controllers/agent_chat_voice_controller"),
76
79
  "voice-profile": () => import("studio/controllers/voice_profile_controller"),
77
80
  "microphone-chip": () => import("studio/controllers/microphone_chip_controller"),
78
81
  "voice-recorder": () => import("studio/controllers/voice_recorder_controller")
@@ -9,5 +9,7 @@
9
9
  // demos the moment the page is up finds them there.
10
10
  import { application } from "studio/stimulus"
11
11
  import StyleModalsController from "studio/controllers/style_modals_controller"
12
+ import StyleSectionNavController from "studio/controllers/style_section_nav_controller"
12
13
 
13
14
  application.register("style-modals", StyleModalsController)
15
+ application.register("style-section-nav", StyleSectionNavController)
@@ -0,0 +1,16 @@
1
+ // studio/style_section_nav: which section of the style guide the reader is in.
2
+ // It imports nothing; test/javascript/style_section_nav.test.mjs loads it as a
3
+ // data: module.
4
+ //
5
+ // `tops` is each section's top in viewport pixels, in page order. A section is
6
+ // current once its top reaches the bottom of the sticky row (`line`). Before
7
+ // the first one reaches it, the first is current. At the end of the page the
8
+ // last is current, because a short last section can never reach the line.
9
+ export function activeIndex(tops, line, atPageEnd = false) {
10
+ if (tops.length === 0) return -1
11
+ if (atPageEnd) return tops.length - 1
12
+
13
+ let active = 0
14
+ tops.forEach((top, index) => { if (top <= line) active = index })
15
+ return active
16
+ }
@@ -1,72 +1,18 @@
1
- // studio/theme_editor: the two live-preview theme editors, as the Alpine
2
- // factories their templates bind.
1
+ // studio/theme_editor: the live-preview theme editor, as the Alpine factory its
2
+ // template binds.
3
3
  //
4
- // themeEditor(colors) theme_settings/edit, the standalone /admin/theme page
5
4
  // dsThemeEditor(seed) style/_theme, the Theme section of the style guide
6
5
  //
7
- // Both are seeded with the server-resolved role colours and write the themeable
6
+ // It is seeded with the server-resolved role colours and writes the themeable
8
7
  // role variables on the document as the operator types, so every specimen on
9
8
  // the page restyles at once. The inputs still carry the real values.
10
9
  //
11
- // studio/alpine_shims publishes both as window globals before Alpine starts,
12
- // because x-data="themeEditor(...)" is evaluated the moment Alpine does.
10
+ // studio/alpine_shims publishes it as a window global before Alpine starts,
11
+ // because x-data="dsThemeEditor(...)" is evaluated the moment Alpine does.
13
12
  //
14
13
  // It imports nothing; test/javascript/theme_editor.test.mjs loads it as a
15
14
  // data: module.
16
15
 
17
- // The seven roles the standalone editor edits, in the order it lists them.
18
- export const ROLES = ["primary", "success", "accent", "warning", "danger", "dark", "light"]
19
-
20
- // A typed colour as #rrggbb: a missing # is added and a 3-digit hex is doubled.
21
- // Answers null for anything that is not a 3- or 6-digit hex.
22
- export function normalizeHex(value) {
23
- let hex = String(value == null ? "" : value).trim()
24
- if (!hex.startsWith("#")) hex = "#" + hex
25
- if (/^#[0-9a-fA-F]{3}$/.test(hex)) {
26
- hex = "#" + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]
27
- }
28
- return /^#[0-9a-fA-F]{6}$/.test(hex) ? hex : null
29
- }
30
-
31
- // `hex` (#rrggbb) moved `amount` (0..1) of the way to white.
32
- export function lightenColor(hex, amount) {
33
- hex = hex.replace("#", "")
34
- const r = parseInt(hex.substring(0, 2), 16)
35
- const g = parseInt(hex.substring(2, 4), 16)
36
- const b = parseInt(hex.substring(4, 6), 16)
37
- const nr = Math.round(r + (255 - r) * amount)
38
- const ng = Math.round(g + (255 - g) * amount)
39
- const nb = Math.round(b + (255 - b) * amount)
40
- return "#" + [nr, ng, nb].map((c) => c.toString(16).padStart(2, "0")).join("")
41
- }
42
-
43
- // The standalone editor's colours: every role present, blank when the server
44
- // resolved none.
45
- export function seededColors(seed) {
46
- const colors = {}
47
- ROLES.forEach((role) => { colors[role] = (seed && seed[role]) || "" })
48
- return colors
49
- }
50
-
51
- // What the standalone editor writes on the root for `colors`, as
52
- // [property, value] pairs; a null value removes the property.
53
- //
54
- // btn-warning and btn-danger paint --color-<role>-fill first (the resolver
55
- // emits it for the engine defaults), so a typed colour sets it too or the
56
- // specimens keep the default fill. A cleared colour drops the override.
57
- export function previewProperties(colors) {
58
- const properties = [
59
- ["--color-cta", colors.primary],
60
- ["--color-success", colors.success],
61
- ["--color-warning", colors.warning || "#FF7C47"],
62
- ["--color-danger", colors.danger || "#EF4444"]
63
- ]
64
- for (const role of ["warning", "danger"]) {
65
- properties.push([`--color-${role}-fill`, colors[role] ? colors[role] : null])
66
- }
67
- return properties
68
- }
69
-
70
16
  // What the style guide's editor writes on the root for `colors`. It sets only
71
17
  // what has a value and removes nothing.
72
18
  export function stylePreviewProperties(colors) {
@@ -87,52 +33,6 @@ function applyProperties(style, properties) {
87
33
  }
88
34
  }
89
35
 
90
- // x-data="themeEditor({...})": theme_settings/edit.
91
- export function themeEditor(seed) {
92
- return {
93
- colors: seededColors(seed),
94
-
95
- resetColor(role, defaultVal) {
96
- this.colors[role] = defaultVal
97
- this.updatePreview()
98
- },
99
-
100
- normalizeHex(role) {
101
- const hex = normalizeHex(this.colors[role])
102
- if (hex) this.colors[role] = hex
103
- this.updatePreview()
104
- },
105
-
106
- // Live CSS variables on the document, for immediate feedback.
107
- updatePreview() {
108
- applyProperties(document.documentElement.style, previewProperties(this.colors))
109
- },
110
-
111
- darkPreviewStyle() {
112
- return { backgroundColor: this.colors.dark, borderRadius: "0" }
113
- },
114
-
115
- darkSurfaceStyle() {
116
- return {
117
- backgroundColor: this.lightenColor(this.colors.dark, 0.3),
118
- border: "1px solid rgba(255,255,255,0.1)"
119
- }
120
- },
121
-
122
- lightPreviewStyle() {
123
- return { backgroundColor: this.colors.light, borderRadius: "0" }
124
- },
125
-
126
- lightSurfaceStyle() {
127
- return { backgroundColor: "#ffffff", border: "1px solid #e2e8f0" }
128
- },
129
-
130
- lightenColor(hex, amount) {
131
- return lightenColor(hex, amount)
132
- }
133
- }
134
- }
135
-
136
36
  // The method a form really sends, and its body without Rails' _method override.
137
37
  // The editor's form carries _method=patch, and sending the real method means
138
38
  // nothing depends on Rack::MethodOverride parsing a multipart fetch body.
@@ -170,7 +70,7 @@ export function failureReason(response) {
170
70
  //
171
71
  // SAVE IN PLACE. Save and Regenerate submit by fetch and stay on the style
172
72
  // guide: the form's @submit.prevent stops the browser navigating to
173
- // /admin/theme, the live preview persists, and the outcome is a toast (the
73
+ // its save endpoint, the live preview persists, and the outcome is a toast (the
174
74
  // engine's `toast` window event). The form's own POST still works when Alpine
175
75
  // never loads.
176
76
  export function dsThemeEditor(seed) {