studio-engine 0.127.0 → 0.129.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +161 -0
  3. data/README.md +9 -3
  4. data/app/assets/javascripts/studio/page_transition_reveal.js +1 -1
  5. data/app/assets/tailwind/studio_engine/engine.css +572 -37
  6. data/app/controllers/concerns/studio/error_handling.rb +17 -0
  7. data/app/controllers/studio/surveys_controller.rb +16 -3
  8. data/app/helpers/studio/loader_helper.rb +2 -2
  9. data/app/helpers/studio_icon_helper.rb +10 -0
  10. data/app/javascript/studio/agent_chat.js +88 -6
  11. data/app/javascript/studio/agent_chat_voice.js +59 -5
  12. data/app/javascript/studio/alpine_stores.js +80 -22
  13. data/app/javascript/studio/loader.js +130 -5
  14. data/app/javascript/studio/microphone_chip.js +28 -2
  15. data/app/javascript/studio/page_transitions.js +28 -19
  16. data/app/javascript/studio/survey.js +16 -3
  17. data/app/javascript/studio/theme_editor.js +4 -0
  18. data/app/javascript/studio/voice_recorder.js +18 -7
  19. data/app/models/studio/survey_response.rb +24 -17
  20. data/app/views/components/_geo_signpost.html.erb +1 -0
  21. data/app/views/components/_link_sidebar.html.erb +40 -12
  22. data/app/views/components/_sidebar_panel.html.erb +15 -5
  23. data/app/views/layouts/studio/_head.html.erb +18 -7
  24. data/app/views/layouts/studio/_smooth_load.html.erb +1 -0
  25. data/app/views/layouts/studio/component_preview.html.erb +1 -1
  26. data/app/views/registrations/new.html.erb +4 -3
  27. data/app/views/sessions/new.html.erb +4 -3
  28. data/app/views/studio/agent_chat/_chat.html.erb +2 -1
  29. data/app/views/studio/agent_chat/_turn.html.erb +3 -1
  30. data/app/views/studio/agent_chat/_voice.html.erb +28 -13
  31. data/app/views/studio/loader/_overlay.html.erb +4 -4
  32. data/app/views/style/_components.html.erb +2 -4
  33. data/app/views/style/_glass.html.erb +83 -0
  34. data/app/views/style/_host.html.erb +2 -10
  35. data/app/views/style/_modals.html.erb +38 -231
  36. data/app/views/style/_scrims.html.erb +2 -9
  37. data/app/views/style/_site_footer.html.erb +5 -13
  38. data/app/views/style/_tasks.html.erb +16 -27
  39. data/app/views/style/_theme.html.erb +56 -78
  40. data/app/views/style/_tricks.html.erb +13 -11
  41. data/app/views/style/_type_ladder.html.erb +33 -0
  42. data/app/views/style/index.html.erb +5 -1
  43. data/docs/SITE_FOOTER.md +1 -1
  44. data/lib/studio/agent_chat.rb +4 -2
  45. data/lib/studio/icons/README.md +12 -0
  46. data/lib/studio/icons/lucide/LICENSE +43 -0
  47. data/lib/studio/icons/lucide/activity.svg +15 -0
  48. data/lib/studio/icons/lucide/book-open.svg +16 -0
  49. data/lib/studio/icons/lucide/chart-column.svg +18 -0
  50. data/lib/studio/icons/lucide/diamond.svg +15 -0
  51. data/lib/studio/icons/lucide/door-stairwell.svg +19 -0
  52. data/lib/studio/icons/lucide/globe.svg +17 -0
  53. data/lib/studio/icons/lucide/hammer.svg +17 -0
  54. data/lib/studio/icons/lucide/inbox.svg +16 -0
  55. data/lib/studio/icons/lucide/key-round.svg +16 -0
  56. data/lib/studio/icons/lucide/layout-grid.svg +18 -0
  57. data/lib/studio/icons/lucide/log-out.svg +17 -0
  58. data/lib/studio/icons/lucide/message-circle.svg +15 -0
  59. data/lib/studio/icons/lucide/package.svg +18 -0
  60. data/lib/studio/icons/lucide/palette.svg +19 -0
  61. data/lib/studio/icons/lucide/plug.svg +18 -0
  62. data/lib/studio/icons/lucide/rocket.svg +18 -0
  63. data/lib/studio/icons/lucide/user.svg +16 -0
  64. data/lib/studio/icons.rb +52 -0
  65. data/lib/studio/loader.rb +7 -0
  66. data/lib/studio/sidebar_sections.rb +52 -4
  67. data/lib/studio/site_footer.rb +23 -2
  68. data/lib/studio/version.rb +1 -1
  69. data/lib/studio.rb +54 -2
  70. metadata +24 -1
@@ -7,6 +7,8 @@ module Studio
7
7
  # methods only.
8
8
  include Studio::SessionDrift
9
9
 
10
+ INVALID_TOKEN_MESSAGE = "This page has expired. Reload it, then try again."
11
+
10
12
  included do
11
13
  # ORDER IS LOAD-BEARING — ActiveSupport::Rescuable resolves handlers with
12
14
  # `reverse_each`, so the LAST matching rescue_from registered wins. The
@@ -19,6 +21,7 @@ module Studio
19
21
  rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
20
22
  # A string, so including this concern does not load the model concern.
21
23
  rescue_from "Sluggable::SlugRefused", with: :handle_slug_refused
24
+ rescue_from ActionController::InvalidAuthenticityToken, with: :handle_invalid_authenticity_token
22
25
 
23
26
  before_action :require_authentication
24
27
  after_action :track_user_session
@@ -271,6 +274,20 @@ module Studio
271
274
  end
272
275
  end
273
276
 
277
+ # Layer 1: a bad or missing CSRF token on a fetch is a 422 the page can read, with
278
+ # no ErrorLog row: anyone can send one, and the catch-all answered it 500.
279
+ #
280
+ # HTML stays with the catch-all on purpose. Its redirect home hands a stale login or
281
+ # magic-link form a fresh token and a way on, where public/422.html is a dead end
282
+ # with no link (Back shows the same stale form). Its row is what the health report
283
+ # sees when a session config breaks every form at once.
284
+ def handle_invalid_authenticity_token(exception)
285
+ respond_to do |format|
286
+ format.json { render json: { error: INVALID_TOKEN_MESSAGE, reason: "invalid_token" }, status: :unprocessable_entity }
287
+ format.any { handle_unexpected_error(exception) }
288
+ end
289
+ end
290
+
274
291
  # Layer 1: Catch-all for unexpected errors — log + friendly response.
275
292
  # Skips logging if rescue_and_log already captured it.
276
293
  def handle_unexpected_error(exception)
@@ -32,11 +32,15 @@ module Studio
32
32
  return render(json: { error: "unknown question" }, status: :not_found) unless question
33
33
 
34
34
  response = open_response!
35
- return render(json: { error: "already complete" }, status: :conflict) unless response
35
+ return render_already_complete unless response
36
36
 
37
37
  ok, error = rescue_survey_write(response) { response.record_answer(question, params[:value]) }
38
38
  if ok
39
39
  render json: { saved: true, key: question.key, answered: response.answers.keys & @survey.keys }
40
+ elsif response.completed?
41
+ # A submit completed it after this request found it open: the same answer
42
+ # as the branch above, never a validation error for the visitor to read.
43
+ render_already_complete
40
44
  else
41
45
  render json: { saved: false, error: error }, status: :unprocessable_entity
42
46
  end
@@ -48,6 +52,10 @@ module Studio
48
52
 
49
53
  submitted = submitted_answers
50
54
  @errors = {}
55
+ completed = false
56
+ # One transaction, so record_answer's row lock holds from the first answer to
57
+ # the completion and a late autosave cannot change an answer between the two.
58
+ # The hook runs after the commit.
51
59
  Studio::SurveyResponse.transaction do
52
60
  @survey.questions.each do |question|
53
61
  next unless submitted.key?(question.key)
@@ -55,10 +63,11 @@ module Studio
55
63
  ok, error = response.record_answer(question, submitted[question.key])
56
64
  @errors[question.key] = error unless ok
57
65
  end
66
+ response.missing_required(@survey).each { |key| @errors[key] ||= "This one is required." }
67
+ completed = @errors.empty? && response.complete!(@survey)
58
68
  end
59
- response.missing_required(@survey).each { |key| @errors[key] ||= "This one is required." }
60
69
 
61
- if @errors.empty? && response.complete!(@survey)
70
+ if completed
62
71
  run_completed_hook(response)
63
72
  redirect_to studio_survey_thanks_path(@survey.slug), status: :see_other
64
73
  elsif response.completed?
@@ -77,6 +86,10 @@ module Studio
77
86
 
78
87
  private
79
88
 
89
+ def render_already_complete
90
+ render json: { error: "already complete" }, status: :conflict
91
+ end
92
+
80
93
  def load_survey
81
94
  @survey = Studio.survey(params[:slug])
82
95
  render :not_found, status: :not_found unless @survey
@@ -60,8 +60,8 @@ module Studio
60
60
  # and busy button after `max_wait` ms, so a response that became a download
61
61
  # never leaves them up; a Turbo visit is never capped.
62
62
  # Any studio_loader option (style:, icon:, art:) passes through.
63
- def studio_loader_overlay(label: "Loading", delay: 350, max_wait: 15_000, size: 96, **options)
64
- render "studio/loader/overlay", label: label, delay: delay.to_i, max_wait: max_wait.to_i, size: size, options: options
63
+ def studio_loader_overlay(label: "Loading", delay: Studio::Loader::OVERLAY_DELAY_MS, min_show: Studio::Loader::OVERLAY_MIN_SHOW_MS, max_wait: 15_000, size: 96, **options)
64
+ render "studio/loader/overlay", label: label, delay: delay.to_i, min_show: min_show.to_i, max_wait: max_wait.to_i, size: size, options: options
65
65
  end
66
66
 
67
67
  def studio_loader_size(size)
@@ -0,0 +1,10 @@
1
+ # studio_icon("rocket", size: 18): one line icon as inline SVG in currentColor, so it takes
2
+ # its color from the text around it and needs no asset request under a strict CSP. The set
3
+ # is Studio::Icons. An unknown name renders nothing (nil), so a caller can fall back:
4
+ #
5
+ # studio_icon(link[:icon]) || link[:emoji]
6
+ module StudioIconHelper
7
+ def studio_icon(name, size: 18, class: nil)
8
+ Studio::Icons.svg(name, size: size, css_class: binding.local_variable_get(:class))&.html_safe
9
+ end
10
+ end
@@ -18,12 +18,24 @@
18
18
  // until the app answers 201; a refusal shows its reason above them.
19
19
  // FOLLOW the thread stays at its end while the reader is within FOLLOW_PX of
20
20
  // it, and always after their own send (the hub's chat_follow rule).
21
+ // REVEAL the composer sticks over the window's bottom, so on a short window the
22
+ // thread's end sits under it. On load and after a new turn the page
23
+ // scrolls that end clear of the composer, with the same manners as
24
+ // FOLLOW: not for an empty thread, not for a reader who scrolled the page
25
+ // up (revealFollowing, until their own send), and never during a hold,
26
+ // when it waits for the button to be let go.
21
27
  // REFRESH the public hook for code outside the chat (studio/agent_chat_voice, a
22
28
  // host's own composer): dispatch studio:agent-chat:refresh on the
23
29
  // chat's section, optionally with {detail: {turn}}, a turn the app
24
30
  // just answered. The turn shows at once, the chat follows it, and it
25
31
  // polls now; a poll already in flight is followed by one more, so a
26
32
  // refresh is never swallowed.
33
+ // SENT a typed send the app took dispatches studio:agent-chat:sent on the
34
+ // chat's section, {detail: {turn}}; studio/agent_chat_voice clears its
35
+ // old line on it.
36
+ // HELD a turn the hub holds (status "held": stored, no automatic answer is
37
+ // coming) is settled and says so, and never asks for a resend. A hub
38
+ // that still reports it as "failed" gets the failed line.
27
39
  // TYPING the typing line shows while the hub says an answer is pending:
28
40
  // "thinking", then "typing" once a turn's status is "typing".
29
41
  //
@@ -37,7 +49,13 @@ export const IDLE_MS = 30000
37
49
  export const OFFLINE_MS = 15000
38
50
  export const FOLLOW_PX = 20
39
51
  export const LIMIT = 50
52
+ export const REVEAL_GAP_PX = 12
53
+ export const INVALID_TOKEN = "invalid_token"
54
+ export const EXPIRED_MESSAGE = "This page has expired. Copy your message, then reload the page to send it."
55
+ export const RECORDER_STATE_EVENT = "studio:voice-recorder:state"
56
+ const HOLDING = "[data-voice-recorder-state='recording'],[data-voice-recorder-state='starting']"
40
57
  export const REFRESH_EVENT = "studio:agent-chat:refresh"
58
+ export const SENT_EVENT = "studio:agent-chat:sent"
41
59
  const UNSETTLED = new Set(["pending", "typing"])
42
60
 
43
61
  export function settled(turn) {
@@ -95,6 +113,12 @@ export function isFollowing(log, px = FOLLOW_PX) {
95
113
  return log.scrollHeight - log.clientHeight - log.scrollTop <= px
96
114
  }
97
115
 
116
+ // How many pixels of the thread's end the composer (or the window's bottom) hides;
117
+ // zero or less when it is clear of both. Rects are the elements' own.
118
+ export function coveredPx(endRect, composerRect, windowHeight) {
119
+ return endRect.bottom - Math.min(composerRect.top, windowHeight)
120
+ }
121
+
98
122
  // The page's authenticity token: the form's own field, else the csrf meta.
99
123
  export function csrfToken(form, doc = globalThis.document) {
100
124
  const field = form && form.querySelector("input[name='authenticity_token']")
@@ -114,7 +138,8 @@ export function mountAgentChat(root, {
114
138
  doc = globalThis.document,
115
139
  now = () => Date.now(),
116
140
  setTimer = (fn, ms) => globalThis.setTimeout(fn, ms),
117
- clearTimer = (id) => globalThis.clearTimeout(id)
141
+ clearTimer = (id) => globalThis.clearTimeout(id),
142
+ CustomEventClass = globalThis.CustomEvent
118
143
  } = {}) {
119
144
  const $ = (selector) => root.querySelector(selector)
120
145
  const log = $("[data-agent-chat-log]")
@@ -130,13 +155,57 @@ export function mountAgentChat(root, {
130
155
  const soul = root.dataset.agentChatSoul
131
156
  const name = root.dataset.agentChatName || "The agent"
132
157
 
133
- const state = { turns: [], pending: false, waitingSince: null, offline: false, inFlight: false, pollAgain: false, sending: false, timer: null, alive: true, following: true }
158
+ const state = { turns: [], pending: false, waitingSince: null, offline: false, inFlight: false, pollAgain: false, sending: false, timer: null, alive: true, following: true, revealFollowing: true, revealOwed: false }
134
159
 
135
160
  const onScroll = () => { state.following = isFollowing(log) }
136
161
  log.addEventListener("scroll", onScroll, { passive: true })
137
162
 
138
163
  function toEnd() { log.scrollTop = log.scrollHeight }
139
164
 
165
+ // The last thing in the thread's column above the composer that is showing.
166
+ function threadEnd() {
167
+ return [offlineNote, typing].find((el) => el && el.hidden === false) || log
168
+ }
169
+
170
+ function windowHeight() {
171
+ return doc && doc.documentElement ? doc.documentElement.clientHeight : 0
172
+ }
173
+
174
+ function covered() {
175
+ const end = threadEnd()
176
+ if (!form || !end.getBoundingClientRect || !form.getBoundingClientRect) return 0
177
+ return coveredPx(end.getBoundingClientRect(), form.getBoundingClientRect(), windowHeight())
178
+ }
179
+
180
+ // scrollIntoView, not window.scrollBy: a host may scroll an inner container. The
181
+ // margin is what the composer takes off the bottom of the window once it sticks:
182
+ // its height and its sticky bottom, not its top now, because a composer lower down
183
+ // the page is not stuck until this scroll has run. Instant, so a host's smooth
184
+ // scrolling cannot leave the page moving when a hold begins.
185
+ function revealEnd() {
186
+ if (root.querySelector(HOLDING)) { state.revealOwed = true; return }
187
+ state.revealOwed = false
188
+ if (!state.turns.length || covered() <= 0) return
189
+ const end = threadEnd()
190
+ const stuckAt = parseFloat(globalThis.getComputedStyle?.(form).bottom) || 0
191
+ end.style.scrollMarginBottom = `${form.getBoundingClientRect().height + stuckAt + REVEAL_GAP_PX}px`
192
+ end.scrollIntoView({ block: "end", behavior: "instant" })
193
+ end.style.scrollMarginBottom = ""
194
+ }
195
+
196
+ // A scroll of the page or a host's container says whether the reader is still at
197
+ // the thread's end. The log's own scroll, the box's and the picker's say nothing:
198
+ // only something that holds the chat can move it. A reveal passes this too, and
199
+ // ends there.
200
+ const onPageScroll = (event) => {
201
+ if (!event.target || !event.target.contains || !event.target.contains(root)) return
202
+ state.revealFollowing = covered() <= FOLLOW_PX
203
+ }
204
+
205
+ const onRecorderState = () => {
206
+ if (state.revealOwed && !root.querySelector(HOLDING) && state.following && state.revealFollowing) revealEnd()
207
+ }
208
+
140
209
  function setOffline(offline) {
141
210
  state.offline = offline
142
211
  if (offlineNote) offlineNote.hidden = !offline
@@ -171,11 +240,16 @@ export function mountAgentChat(root, {
171
240
  if (text && text.textContent !== String(turn.text ?? "")) text.textContent = String(turn.text ?? "")
172
241
  const failed = node.querySelector("[data-agent-chat-failed]")
173
242
  if (failed) failed.hidden = turn.status !== "failed"
243
+ const held = node.querySelector("[data-agent-chat-held]")
244
+ if (held) held.hidden = turn.status !== "held"
174
245
  }
175
246
  const waiting = state.pending || state.turns.some((turn) => !settled(turn))
176
247
  if (typing) typing.hidden = !waiting
177
248
  if (phase) phase.dataset.phase = typingPhase(state.turns)
178
- if (follow) toEnd()
249
+ if (follow) {
250
+ toEnd()
251
+ if (state.revealFollowing) revealEnd()
252
+ }
179
253
  }
180
254
 
181
255
  function schedule() {
@@ -221,8 +295,9 @@ export function mountAgentChat(root, {
221
295
  if (!state.alive) return undefined
222
296
  if (turn) {
223
297
  state.turns = mergeTurns(state.turns, [turn])
224
- state.pending = true
298
+ state.pending = turn.status !== "held"
225
299
  state.following = true
300
+ state.revealFollowing = true
226
301
  state.waitingSince = now()
227
302
  setOffline(false)
228
303
  render()
@@ -260,17 +335,19 @@ export function mountAgentChat(root, {
260
335
  if (response.status === 201 && data.turn) {
261
336
  input.value = ""
262
337
  state.turns = mergeTurns(state.turns, [data.turn])
263
- state.pending = true
338
+ state.pending = data.turn.status !== "held"
264
339
  state.following = true
340
+ state.revealFollowing = true
265
341
  setOffline(false)
266
342
  render()
267
343
  toEnd()
268
344
  if (status) status.textContent = "Sent."
345
+ root.dispatchEvent(new CustomEventClass(SENT_EVENT, { detail: { turn: data.turn } }))
269
346
  state.waitingSince = now()
270
347
  clearTimer(state.timer)
271
348
  state.timer = setTimer(poll, POLL_MS)
272
349
  } else {
273
- showError(data.error || `Couldn't send that to ${name}. Try again.`)
350
+ showError(data.reason === INVALID_TOKEN ? EXPIRED_MESSAGE : data.error || `Couldn't send that to ${name}. Try again.`)
274
351
  if (status) status.textContent = ""
275
352
  }
276
353
  } catch (_error) {
@@ -302,7 +379,10 @@ export function mountAgentChat(root, {
302
379
  input && input.addEventListener("keydown", onKeydown)
303
380
  retry && retry.addEventListener("click", onRetry)
304
381
  root.addEventListener(REFRESH_EVENT, onRefresh)
382
+ root.addEventListener(RECORDER_STATE_EVENT, onRecorderState)
305
383
  doc && doc.addEventListener && doc.addEventListener("visibilitychange", onVisible)
384
+ // Scroll events do not bubble; capture hears the page's and any container's.
385
+ doc && doc.addEventListener && doc.addEventListener("scroll", onPageScroll, { capture: true, passive: true })
306
386
  root.dataset.agentChatReady = "true"
307
387
  poll()
308
388
 
@@ -319,7 +399,9 @@ export function mountAgentChat(root, {
319
399
  input && input.removeEventListener("keydown", onKeydown)
320
400
  retry && retry.removeEventListener("click", onRetry)
321
401
  root.removeEventListener(REFRESH_EVENT, onRefresh)
402
+ root.removeEventListener(RECORDER_STATE_EVENT, onRecorderState)
322
403
  doc && doc.removeEventListener && doc.removeEventListener("visibilitychange", onVisible)
404
+ doc && doc.removeEventListener && doc.removeEventListener("scroll", onPageScroll, { capture: true })
323
405
  delete root.dataset.agentChatReady
324
406
  }
325
407
  }
@@ -18,8 +18,14 @@
18
18
  // says who it kept.
19
19
  //
20
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.
21
+ // state on data-agent-chat-voice-state: listening, tapped, transcribing, queued,
22
+ // sending, sent, silent, no_speech, not_configured, rate_limited, refused, failed.
23
+ //
24
+ // THE HELD BUTTON DOES NOT MOVE. The line sits above the button and the recorder's
25
+ // own line is hidden here, so this one line says everything. _voice reserves its
26
+ // quiet-hint height, and holdLine fixes its height for the length of a hold: an old
27
+ // line clearing under the finger cannot pull the button down, and a take landing
28
+ // during a hold cannot push it up.
23
29
  //
24
30
  // NO WORDS ARE LOST. A transcript that could not be sent goes in the chat's box,
25
31
  // after anything already typed there. A take that lands while another is still
@@ -33,6 +39,7 @@ export const EMBED_WAIT_MS = 8000
33
39
 
34
40
  export const MESSAGES = {
35
41
  listening: "Listening...",
42
+ tapped: "Hold the button while you talk, then let go.",
36
43
  transcribing: "Turning that into words...",
37
44
  queued: "Got that one too. It's next.",
38
45
  sending: (name) => `Sending to ${name}...`,
@@ -43,6 +50,8 @@ export const MESSAGES = {
43
50
  notConfigured: "Voice isn't set up on this app yet, so I can't hear recordings. Type instead for now.",
44
51
  rateLimited: "That's a lot of voice messages in a row. Wait a moment, then hold to talk again.",
45
52
  signedOut: "Sign in again to talk.",
53
+ expired: "This page has expired. Reload it, then hold to talk again.",
54
+ expiredSend: "This page has expired. Copy your words from the box, then reload the page to send them.",
46
55
  unavailable: "I couldn't transcribe that just now. Try again in a moment.",
47
56
  failed: "That recording didn't go through. Try again.",
48
57
  offline: "Can't reach the server right now. Try again in a moment.",
@@ -58,11 +67,19 @@ export function retryHint(seconds) {
58
67
  return ` Try again in ${minutes} minute${minutes === 1 ? "" : "s"}.`
59
68
  }
60
69
 
70
+ // A refused CSRF token: the engine's own answer (Studio::ErrorHandling), or Rails'
71
+ // {status: 422, error: "Unprocessable Content"} from a host that re-raises it. Retrying
72
+ // on this page can never pass, and Rails' words mean nothing to a person.
73
+ export function tokenRefused(status, body = {}) {
74
+ return status === 422 && !!body && (body.reason === "invalid_token" || body.status === 422)
75
+ }
76
+
61
77
  // What POST /voice/takes's refusal means on the page: {state, message}.
62
78
  // `body` is the parsed JSON (or {}); `retryAfter` the header, if any.
63
79
  export function refusalFor(status, body = {}, retryAfter = null) {
64
80
  const said = body && typeof body.error === "string" && body.error.trim() ? body.error.trim() : null
65
81
  if (status === 401) return { state: "refused", message: MESSAGES.signedOut }
82
+ if (tokenRefused(status, body)) return { state: "refused", message: MESSAGES.expired }
66
83
  if (status === 429) {
67
84
  return { state: "rate_limited", message: MESSAGES.rateLimited + retryHint(retryAfter ?? (body && body.retry_after)) }
68
85
  }
@@ -157,6 +174,19 @@ export function mountAgentChatVoice(root, {
157
174
  if (status) status.textContent = message || ""
158
175
  }
159
176
 
177
+ // Takes the line's height before it changes, and hands it back with no argument.
178
+ function holdLine(holding) {
179
+ if (!status || !status.style) return
180
+ status.style.height = holding && status.offsetHeight ? `${status.offsetHeight}px` : ""
181
+ status.style.overflow = holding && status.offsetHeight ? "hidden" : ""
182
+ }
183
+
184
+ // What the recorder just said on its own line, which _voice hides here.
185
+ function recorderLine(fallback) {
186
+ const line = root.querySelector("[data-voice-recorder-status]")
187
+ return (line && line.textContent) || fallback
188
+ }
189
+
160
190
  function embedder() {
161
191
  const config = parseEmbedder(root.getAttribute("data-agent-chat-voice-embedder"))
162
192
  if (!config) return Promise.resolve(null)
@@ -215,6 +245,10 @@ export function mountAgentChatVoice(root, {
215
245
  return
216
246
  }
217
247
  keepWords(answer.text)
248
+ if (tokenRefused(response.status, data)) {
249
+ say("refused", MESSAGES.expiredSend)
250
+ return
251
+ }
218
252
  const reason = typeof data.error === "string" && data.error ? `${data.error} ` : ""
219
253
  say(response.status === 429 ? "rate_limited" : "failed", `${reason}${MESSAGES.notSent(name)}`)
220
254
  }
@@ -235,7 +269,9 @@ export function mountAgentChatVoice(root, {
235
269
  if (!response.ok || typeof body.text !== "string" || !body.text.trim()) {
236
270
  const retryAfter = response.headers && response.headers.get ? response.headers.get("Retry-After") : null
237
271
  const { state, message } = response.ok ? { state: "no_speech", message: MESSAGES.noSpeech } : refusalFor(response.status, body, retryAfter)
238
- say(state, message)
272
+ // A reload clears the box, so words already in it get the copy-first line.
273
+ const inBox = form && form.querySelector("[data-agent-chat-input]")?.value.trim()
274
+ say(state, inBox && tokenRefused(response.status, body) ? MESSAGES.expiredSend : message)
239
275
  return
240
276
  }
241
277
  await send(body)
@@ -278,15 +314,31 @@ export function mountAgentChatVoice(root, {
278
314
  function onState(event) {
279
315
  const next = event.detail && event.detail.state
280
316
  if (next === "starting") {
317
+ holdLine(true)
281
318
  say("idle", "")
282
- } else if (next === "recording") {
319
+ return
320
+ }
321
+ if (next === "recording") {
283
322
  say("listening", MESSAGES.listening)
284
323
  embedder().then((loaded) => loaded && loaded.warm && loaded.warm()).catch(() => {})
285
- } else if (root.dataset.agentChatVoiceState === "listening") {
324
+ return
325
+ }
326
+ holdLine(false)
327
+ if (next === "tapped") say("tapped", recorderLine(MESSAGES.tapped))
328
+ else if (next === "failed") say("failed", recorderLine(MESSAGES.failed))
329
+ else if (next === "denied") say("refused", recorderLine(MESSAGES.failed))
330
+ else if (root.dataset.agentChatVoiceState === "listening") {
286
331
  say(working ? "transcribing" : "idle", working ? MESSAGES.transcribing : "")
287
332
  }
288
333
  }
289
334
 
335
+ // A later typed send (studio/agent_chat): "Sent. Spoken by ..." is about a take
336
+ // the thread has moved past. A take still in hand, or a hold, keeps its own line.
337
+ function onTypedSend() {
338
+ if (working || root.dataset.agentChatVoiceState === "listening") return
339
+ say("idle", "")
340
+ }
341
+
290
342
  // "I can't hear anything yet" on the one line: the recorder's own line is hidden
291
343
  // in the composer while it records, so a held button never moves for it.
292
344
  function onHint(event) {
@@ -304,6 +356,7 @@ export function mountAgentChatVoice(root, {
304
356
  root.addEventListener("studio:voice-recorder:silent", onSilent)
305
357
  root.addEventListener("studio:voice-recorder:state", onState)
306
358
  root.addEventListener("studio:voice-recorder:hint", onHint)
359
+ if (chat && chat.addEventListener) chat.addEventListener("studio:agent-chat:sent", onTypedSend)
307
360
 
308
361
  return {
309
362
  takeArrived,
@@ -313,6 +366,7 @@ export function mountAgentChatVoice(root, {
313
366
  root.removeEventListener("studio:voice-recorder:silent", onSilent)
314
367
  root.removeEventListener("studio:voice-recorder:state", onState)
315
368
  root.removeEventListener("studio:voice-recorder:hint", onHint)
369
+ if (chat && chat.removeEventListener) chat.removeEventListener("studio:agent-chat:sent", onTypedSend)
316
370
  }
317
371
  }
318
372
  }
@@ -1,5 +1,5 @@
1
1
  // studio/alpine_stores: the Alpine stores every host body binds to,
2
- // $store.theme and $store.devMode, and the theme value and toggle behind them.
2
+ // $store.theme and $store.devMode, and the theme setter behind them.
3
3
  //
4
4
  // WHY THIS IS ITS OWN ENTRY POINT. Every host's <body> says
5
5
  // :class="{ 'dev-mode': $store.devMode }", and the engine's theme toggle reads
@@ -27,36 +27,92 @@
27
27
  // inline, nonced script in the head: it must run before the stylesheet paints,
28
28
  // which no deferred module can.
29
29
 
30
- // The stored theme, dark unless the reader chose light.
31
- export function storedTheme(storage) {
32
- return storage.getItem('theme') || 'dark'
30
+ // The theme the root's class shows. The store starts here, not from storage: the pre-paint script
31
+ // reads storage by its own rule, and it decides what the reader already sees.
32
+ function classTheme(root) {
33
+ return root.classList.contains('dark') ? 'dark' : 'light'
33
34
  }
34
35
 
35
- // Flips the root's `dark` class under a short transition class, stores the
36
- // result, and answers it.
37
- export function toggleTheme(root, storage, later) {
38
- root.classList.add('theme-transition')
39
- root.classList.toggle('dark')
40
- var value = root.classList.contains('dark') ? 'dark' : 'light'
41
- storage.setItem('theme', value)
42
- later(function () { root.classList.remove('theme-transition') }, 300)
43
- return value
36
+ // A visit owns the view transition. A second one would skip Turbo's and cut its motion short, so
37
+ // a toggle during a visit flips at once. data-turbo-visit-direction spans a visit; data-page-transition
38
+ // also spans a full load's arrival (studio/page_transitions).
39
+ function visiting(root) {
40
+ return root.dataset.turboVisitDirection !== undefined || root.dataset.pageTransition !== undefined
41
+ }
42
+
43
+ function canCrossfade(env) {
44
+ if (!env.doc || typeof env.doc.startViewTransition !== 'function') return false
45
+ if (env.win.matchMedia('(prefers-reduced-motion: reduce)').matches) return false
46
+ return !visiting(env.root)
47
+ }
48
+
49
+ // The theme store. set(value) is the one setter: the root class, the store value and localStorage
50
+ // change together, in one step, so they agree at every moment. Under a crossfade that step runs
51
+ // in the transition's callback, after the old picture: for up to a frame after set() all three
52
+ // still read the old theme. The pre-paint script is the one writer that cannot use this: it runs
53
+ // before any module does.
54
+ //
55
+ // data-theme-flip is on the root from before the old picture until the crossfade ends. engine.css
56
+ // reads it: the page is one picture, no element runs its own transition, and the page-transition
57
+ // motions lose. Without a crossfade it stays until the styles of the new theme have been computed.
58
+ export function themeStore(env) {
59
+ var root = env.root
60
+ var flips = 0
61
+ var wanted = null // the theme asked for, while its step has not run
62
+
63
+ return {
64
+ value: classTheme(root),
65
+ get isDark() { return this.value === 'dark' },
66
+ set: function (value) {
67
+ if (value !== 'dark' && value !== 'light') return
68
+ if (value === wanted) return
69
+ var self = this
70
+ var commit = function () {
71
+ root.classList.toggle('dark', value === 'dark')
72
+ self.value = value
73
+ // A full or blocked storage must not throw past the instant path's settle and strand data-theme-flip.
74
+ try { env.storage.setItem('theme', value) } catch (_) { /* the page still flips; the choice is not kept */ }
75
+ }
76
+ // Nothing to show, but the three may have drifted: a host can write the class directly.
77
+ if (wanted === null && value === classTheme(root)) return commit()
78
+
79
+ var mine = ++flips
80
+ // Only the newest flip acts, so a late callback cannot undo a later choice.
81
+ var apply = function () {
82
+ if (mine !== flips) return
83
+ wanted = null
84
+ commit()
85
+ }
86
+ var settle = function () {
87
+ if (mine === flips) delete root.dataset.themeFlip
88
+ }
89
+ wanted = value
90
+ root.dataset.themeFlip = ''
91
+ if (!canCrossfade(env)) {
92
+ apply()
93
+ if (env.win) env.win.getComputedStyle(root).color // no transition starts from these styles
94
+ return env.later(settle, 0)
95
+ }
96
+ var transition = env.doc.startViewTransition(apply)
97
+ // A skipped transition rejects ready; a throw in apply rejects the other two.
98
+ transition.finished.then(settle, settle)
99
+ transition.ready.catch(function () {})
100
+ transition.updateCallbackDone.catch(function () {})
101
+ },
102
+ toggle: function () {
103
+ this.set((wanted || classTheme(root)) === 'dark' ? 'light' : 'dark')
104
+ }
105
+ }
44
106
  }
45
107
 
46
108
  // Registers each store Alpine does not already have. env carries what the
47
- // stores read and write: { storage, root, later }.
109
+ // stores read and write: { storage, root, later, doc, win }.
48
110
  export function registerStudioStores(Alpine, env) {
49
111
  if (Alpine.store('devMode') === undefined) {
50
112
  Alpine.store('devMode', env.storage.getItem('devMode') === 'true')
51
113
  }
52
114
  if (Alpine.store('theme') === undefined) {
53
- Alpine.store('theme', {
54
- value: storedTheme(env.storage),
55
- get isDark() { return this.value === 'dark' },
56
- toggle: function () {
57
- this.value = toggleTheme(env.root, env.storage, env.later)
58
- }
59
- })
115
+ Alpine.store('theme', themeStore(env))
60
116
  }
61
117
  }
62
118
 
@@ -72,7 +128,9 @@ export function installStudioStores(doc, win) {
72
128
  registerStudioStores(win.Alpine, {
73
129
  storage: win.localStorage,
74
130
  root: doc.documentElement,
75
- later: function (fn, ms) { win.setTimeout(fn, ms) }
131
+ later: function (fn, ms) { win.setTimeout(fn, ms) },
132
+ doc: doc,
133
+ win: win
76
134
  })
77
135
  })
78
136
  }