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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +142 -0
- data/README.md +18 -3
- data/app/assets/tailwind/studio_engine/engine.css +210 -11
- data/app/controllers/concerns/studio/admin_models.rb +24 -16
- data/app/controllers/concerns/studio/error_handling.rb +13 -0
- data/app/controllers/studio/agent_chats_controller.rb +31 -4
- data/app/controllers/studio/marketing_kit_controller.rb +24 -0
- data/app/controllers/studio/voice_takes_controller.rb +11 -0
- data/app/controllers/style_controller.rb +2 -5
- data/app/controllers/theme_settings_controller.rb +8 -8
- data/app/helpers/studio/agent_chat_helper.rb +37 -0
- data/app/helpers/studio/site_footer_helper.rb +15 -2
- data/app/javascript/studio/agent_chat.js +42 -2
- data/app/javascript/studio/agent_chat_voice.js +318 -0
- data/app/javascript/studio/alpine_shims.js +1 -3
- data/app/javascript/studio/controllers/agent_chat_voice_controller.js +17 -0
- data/app/javascript/studio/controllers/copy_text_controller.js +63 -0
- data/app/javascript/studio/controllers/page_header_controller.js +60 -0
- data/app/javascript/studio/controllers/style_section_nav_controller.js +80 -0
- data/app/javascript/studio/microphone_chip.js +1 -0
- data/app/javascript/studio/page_header.js +24 -0
- data/app/javascript/studio/page_transitions.js +181 -0
- data/app/javascript/studio/pinned_stack.js +12 -1
- data/app/javascript/studio/stimulus.js +9 -1
- data/app/javascript/studio/style_guide.js +2 -0
- data/app/javascript/studio/style_section_nav.js +16 -0
- data/app/javascript/studio/theme_editor.js +6 -106
- data/app/javascript/studio/voice_recorder.js +23 -2
- data/app/models/studio/user_session.rb +76 -0
- data/app/models/theme_setting.rb +3 -0
- data/app/views/components/_link_sidebar.html.erb +5 -2
- data/app/views/components/_user_nav.html.erb +5 -4
- data/app/views/layouts/_navbar.html.erb +7 -4
- data/app/views/layouts/studio/_head.html.erb +6 -0
- data/app/views/layouts/studio/_smooth_load.html.erb +11 -2
- data/app/views/studio/_page_header.html.erb +65 -0
- data/app/views/studio/admin_models/_sessions_table.html.erb +28 -0
- data/app/views/studio/admin_models/show.html.erb +15 -12
- data/app/views/studio/admin_surveys/index.html.erb +2 -2
- data/app/views/studio/agent_chat/_chat.html.erb +17 -2
- data/app/views/studio/agent_chat/_form.html.erb +4 -3
- data/app/views/studio/agent_chat/_header.html.erb +1 -1
- data/app/views/studio/agent_chat/_voice.html.erb +54 -0
- data/app/views/studio/marketing_kit/_assets.html.erb +44 -0
- data/app/views/studio/marketing_kit/_kit.html.erb +84 -0
- data/app/views/studio/marketing_kit/show.html.erb +6 -0
- data/app/views/studio/site_footer/_assets.html.erb +1 -0
- data/app/views/studio/site_footer/_footer.html.erb +5 -0
- data/app/views/studio/voice/_microphone_help.html.erb +6 -3
- data/app/views/style/_theme.html.erb +75 -12
- data/app/views/style/_tricks.html.erb +35 -0
- data/app/views/style/index.html.erb +21 -34
- data/db/migrate/20261103120000_create_studio_user_sessions.rb +16 -0
- data/docs/SITE_FOOTER.md +146 -2
- data/lib/studio/agent_chat.rb +83 -5
- data/lib/studio/marketing_kit.rb +304 -0
- data/lib/studio/site_footer.rb +65 -6
- data/lib/studio/ui_primitives.rb +4 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +146 -6
- metadata +21 -4
- data/app/views/studio/admin_models/index.html.erb +0 -31
- 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 {
|
|
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,63 @@
|
|
|
1
|
+
// copy-text: one click puts a value on the clipboard, and says so. It sits on the
|
|
2
|
+
// element around the control (studio/marketing_kit/_kit):
|
|
3
|
+
//
|
|
4
|
+
// <div data-studio-controller="copy-text" data-copy-text-text-value="#8E82FE">
|
|
5
|
+
// <button type="button" data-studio-action="click->copy-text#copy">...</button>
|
|
6
|
+
// <span role="status" data-copy-text-target="status"></span>
|
|
7
|
+
// </div>
|
|
8
|
+
//
|
|
9
|
+
// The status is written only after the browser answers, so "Copied" is never
|
|
10
|
+
// shown for a copy that failed. navigator.clipboard exists only in a secure
|
|
11
|
+
// context and may refuse; the older selection copy then gets one try.
|
|
12
|
+
import { Controller } from "@hotwired/stimulus"
|
|
13
|
+
|
|
14
|
+
const SHOWN_MS = 1600
|
|
15
|
+
|
|
16
|
+
// True when `text` reached the clipboard. It never rejects.
|
|
17
|
+
export async function copyToClipboard(text, nav = navigator, doc = document) {
|
|
18
|
+
try {
|
|
19
|
+
await nav.clipboard.writeText(text)
|
|
20
|
+
return true
|
|
21
|
+
} catch {
|
|
22
|
+
return selectionCopy(text, doc)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function selectionCopy(text, doc) {
|
|
27
|
+
const field = doc.createElement("textarea")
|
|
28
|
+
field.value = text
|
|
29
|
+
field.setAttribute("readonly", "")
|
|
30
|
+
field.style.position = "fixed"
|
|
31
|
+
field.style.opacity = "0"
|
|
32
|
+
doc.body.appendChild(field)
|
|
33
|
+
try {
|
|
34
|
+
field.select()
|
|
35
|
+
return doc.execCommand("copy")
|
|
36
|
+
} catch {
|
|
37
|
+
return false
|
|
38
|
+
} finally {
|
|
39
|
+
field.remove()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default class extends Controller {
|
|
44
|
+
static targets = ["status"]
|
|
45
|
+
static values = {
|
|
46
|
+
text: String,
|
|
47
|
+
copied: { type: String, default: "Copied" },
|
|
48
|
+
failed: { type: String, default: "Could not copy" }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async copy() {
|
|
52
|
+
const copied = await copyToClipboard(this.textValue)
|
|
53
|
+
if (!this.hasStatusTarget) return
|
|
54
|
+
|
|
55
|
+
this.statusTarget.textContent = copied ? this.copiedValue : this.failedValue
|
|
56
|
+
clearTimeout(this.timer)
|
|
57
|
+
this.timer = setTimeout(() => { this.statusTarget.textContent = "" }, SHOWN_MS)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
disconnect() {
|
|
61
|
+
clearTimeout(this.timer)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// page-header: says when studio/_page_header is stuck, marks which ends of its
|
|
2
|
+
// tabs hide a tab, and shows the open tab on a narrow screen. It sits on the row's
|
|
3
|
+
// sentinel, so the row keeps its data-studio-controller for the page's own.
|
|
4
|
+
//
|
|
5
|
+
// <div class="page-header-sentinel" data-studio-controller="page-header"></div>
|
|
6
|
+
// <nav class="page-header sticky" data-pin="page">...</nav>
|
|
7
|
+
//
|
|
8
|
+
// A lazy controller (studio/stimulus): only a page with the row fetches it. The
|
|
9
|
+
// row's tabs are links, so they work without it. If it fails to load, the sentinel
|
|
10
|
+
// carries data-studio-controller-failed and engine.css gives the row its bar at
|
|
11
|
+
// all times, so a stuck row never lays a transparent title over the page.
|
|
12
|
+
import { Controller } from "@hotwired/stimulus"
|
|
13
|
+
import { hiddenEnds, isStuck, revealBy } from "studio/page_header"
|
|
14
|
+
|
|
15
|
+
export default class extends Controller {
|
|
16
|
+
connect() {
|
|
17
|
+
this.row = this.element.nextElementSibling
|
|
18
|
+
if (!this.row) return
|
|
19
|
+
this.tabs = this.row.querySelector(".page-header-tabs")
|
|
20
|
+
this.schedule = this.schedule.bind(this)
|
|
21
|
+
window.addEventListener("scroll", this.schedule, { passive: true })
|
|
22
|
+
window.addEventListener("resize", this.schedule, { passive: true })
|
|
23
|
+
this.tabs?.addEventListener("scroll", this.schedule, { passive: true })
|
|
24
|
+
this.revealOpenTab()
|
|
25
|
+
this.sync()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
disconnect() {
|
|
29
|
+
window.removeEventListener("scroll", this.schedule)
|
|
30
|
+
window.removeEventListener("resize", this.schedule)
|
|
31
|
+
this.tabs?.removeEventListener("scroll", this.schedule)
|
|
32
|
+
if (this.frame) cancelAnimationFrame(this.frame)
|
|
33
|
+
this.frame = null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
schedule() {
|
|
37
|
+
if (this.frame) return
|
|
38
|
+
this.frame = requestAnimationFrame(() => {
|
|
39
|
+
this.frame = null
|
|
40
|
+
this.sync()
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
sync() {
|
|
45
|
+
const stuck = isStuck(this.row.getBoundingClientRect().top, this.element.getBoundingClientRect().top)
|
|
46
|
+
const ends = this.tabs ? hiddenEnds(this.tabs) : null
|
|
47
|
+
|
|
48
|
+
this.row.toggleAttribute("data-stuck", stuck)
|
|
49
|
+
if (!ends) return
|
|
50
|
+
this.tabs.toggleAttribute("data-fade-start", ends.start)
|
|
51
|
+
this.tabs.toggleAttribute("data-fade-end", ends.end)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Moves the tabs only: scrollIntoView would also move the page.
|
|
55
|
+
revealOpenTab() {
|
|
56
|
+
const open = this.tabs?.querySelector("[aria-current]")
|
|
57
|
+
if (!open) return
|
|
58
|
+
this.tabs.scrollLeft += revealBy(this.tabs.getBoundingClientRect(), open.getBoundingClientRect())
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -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]")) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// studio/page_header: the page header's two measurements, as pure functions. It
|
|
2
|
+
// imports nothing; test/javascript/page_header.test.mjs loads it as a data: module.
|
|
3
|
+
|
|
4
|
+
// A sticky row is stuck once it has left its place in the flow. restTop is that
|
|
5
|
+
// place, measured by the sentinel beside it. The half pixel is rounding: at rest
|
|
6
|
+
// the two tops differ by a fraction at most, and a stuck row by at least a pixel.
|
|
7
|
+
export function isStuck(rowTop, restTop) {
|
|
8
|
+
return rowTop - restTop > 0.5
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Which ends of the tabs hide a tab, so a fade marks only an end with more to see.
|
|
12
|
+
export function hiddenEnds({ scrollLeft, clientWidth, scrollWidth }) {
|
|
13
|
+
return {
|
|
14
|
+
start: scrollLeft > 1,
|
|
15
|
+
end: scrollLeft + clientWidth < scrollWidth - 1
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// How far the tabs must scroll to show a tab, clear of the fade. 0 if it shows.
|
|
20
|
+
export function revealBy(box, tab, margin = 32) {
|
|
21
|
+
if (tab.left < box.left) return tab.left - box.left - margin
|
|
22
|
+
if (tab.right > box.right) return tab.right - box.right + margin
|
|
23
|
+
return 0
|
|
24
|
+
}
|