studio-engine 0.127.0 → 0.128.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -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 +519 -37
  6. data/app/controllers/studio/surveys_controller.rb +16 -3
  7. data/app/helpers/studio/loader_helper.rb +2 -2
  8. data/app/helpers/studio_icon_helper.rb +10 -0
  9. data/app/javascript/studio/agent_chat.js +14 -3
  10. data/app/javascript/studio/agent_chat_voice.js +42 -4
  11. data/app/javascript/studio/loader.js +130 -5
  12. data/app/javascript/studio/microphone_chip.js +28 -2
  13. data/app/javascript/studio/page_transitions.js +28 -19
  14. data/app/javascript/studio/survey.js +16 -3
  15. data/app/javascript/studio/theme_editor.js +4 -0
  16. data/app/javascript/studio/voice_recorder.js +18 -7
  17. data/app/models/studio/survey_response.rb +24 -17
  18. data/app/views/components/_geo_signpost.html.erb +1 -0
  19. data/app/views/components/_link_sidebar.html.erb +40 -12
  20. data/app/views/components/_sidebar_panel.html.erb +15 -5
  21. data/app/views/layouts/studio/_head.html.erb +18 -7
  22. data/app/views/layouts/studio/_smooth_load.html.erb +1 -0
  23. data/app/views/layouts/studio/component_preview.html.erb +1 -1
  24. data/app/views/studio/agent_chat/_turn.html.erb +3 -1
  25. data/app/views/studio/agent_chat/_voice.html.erb +28 -13
  26. data/app/views/studio/loader/_overlay.html.erb +4 -4
  27. data/app/views/style/_components.html.erb +1 -1
  28. data/app/views/style/_glass.html.erb +83 -0
  29. data/app/views/style/_host.html.erb +1 -1
  30. data/app/views/style/_modals.html.erb +11 -11
  31. data/app/views/style/_scrims.html.erb +1 -1
  32. data/app/views/style/_site_footer.html.erb +1 -1
  33. data/app/views/style/_tasks.html.erb +1 -1
  34. data/app/views/style/_theme.html.erb +56 -72
  35. data/app/views/style/_tricks.html.erb +13 -11
  36. data/app/views/style/_type_ladder.html.erb +33 -0
  37. data/app/views/style/index.html.erb +5 -1
  38. data/docs/SITE_FOOTER.md +1 -1
  39. data/lib/studio/agent_chat.rb +4 -2
  40. data/lib/studio/icons/README.md +12 -0
  41. data/lib/studio/icons/lucide/LICENSE +43 -0
  42. data/lib/studio/icons/lucide/activity.svg +15 -0
  43. data/lib/studio/icons/lucide/book-open.svg +16 -0
  44. data/lib/studio/icons/lucide/chart-column.svg +18 -0
  45. data/lib/studio/icons/lucide/diamond.svg +15 -0
  46. data/lib/studio/icons/lucide/door-stairwell.svg +19 -0
  47. data/lib/studio/icons/lucide/globe.svg +17 -0
  48. data/lib/studio/icons/lucide/hammer.svg +17 -0
  49. data/lib/studio/icons/lucide/inbox.svg +16 -0
  50. data/lib/studio/icons/lucide/key-round.svg +16 -0
  51. data/lib/studio/icons/lucide/layout-grid.svg +18 -0
  52. data/lib/studio/icons/lucide/log-out.svg +17 -0
  53. data/lib/studio/icons/lucide/message-circle.svg +15 -0
  54. data/lib/studio/icons/lucide/package.svg +18 -0
  55. data/lib/studio/icons/lucide/palette.svg +19 -0
  56. data/lib/studio/icons/lucide/plug.svg +18 -0
  57. data/lib/studio/icons/lucide/rocket.svg +18 -0
  58. data/lib/studio/icons/lucide/user.svg +16 -0
  59. data/lib/studio/icons.rb +52 -0
  60. data/lib/studio/loader.rb +7 -0
  61. data/lib/studio/sidebar_sections.rb +52 -4
  62. data/lib/studio/site_footer.rb +23 -2
  63. data/lib/studio/version.rb +1 -1
  64. data/lib/studio.rb +54 -2
  65. metadata +24 -1
@@ -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
@@ -24,6 +24,12 @@
24
24
  // just answered. The turn shows at once, the chat follows it, and it
25
25
  // polls now; a poll already in flight is followed by one more, so a
26
26
  // refresh is never swallowed.
27
+ // SENT a typed send the app took dispatches studio:agent-chat:sent on the
28
+ // chat's section, {detail: {turn}}; studio/agent_chat_voice clears its
29
+ // old line on it.
30
+ // HELD a turn the hub holds (status "held": stored, no automatic answer is
31
+ // coming) is settled and says so, and never asks for a resend. A hub
32
+ // that still reports it as "failed" gets the failed line.
27
33
  // TYPING the typing line shows while the hub says an answer is pending:
28
34
  // "thinking", then "typing" once a turn's status is "typing".
29
35
  //
@@ -38,6 +44,7 @@ export const OFFLINE_MS = 15000
38
44
  export const FOLLOW_PX = 20
39
45
  export const LIMIT = 50
40
46
  export const REFRESH_EVENT = "studio:agent-chat:refresh"
47
+ export const SENT_EVENT = "studio:agent-chat:sent"
41
48
  const UNSETTLED = new Set(["pending", "typing"])
42
49
 
43
50
  export function settled(turn) {
@@ -114,7 +121,8 @@ export function mountAgentChat(root, {
114
121
  doc = globalThis.document,
115
122
  now = () => Date.now(),
116
123
  setTimer = (fn, ms) => globalThis.setTimeout(fn, ms),
117
- clearTimer = (id) => globalThis.clearTimeout(id)
124
+ clearTimer = (id) => globalThis.clearTimeout(id),
125
+ CustomEventClass = globalThis.CustomEvent
118
126
  } = {}) {
119
127
  const $ = (selector) => root.querySelector(selector)
120
128
  const log = $("[data-agent-chat-log]")
@@ -171,6 +179,8 @@ export function mountAgentChat(root, {
171
179
  if (text && text.textContent !== String(turn.text ?? "")) text.textContent = String(turn.text ?? "")
172
180
  const failed = node.querySelector("[data-agent-chat-failed]")
173
181
  if (failed) failed.hidden = turn.status !== "failed"
182
+ const held = node.querySelector("[data-agent-chat-held]")
183
+ if (held) held.hidden = turn.status !== "held"
174
184
  }
175
185
  const waiting = state.pending || state.turns.some((turn) => !settled(turn))
176
186
  if (typing) typing.hidden = !waiting
@@ -221,7 +231,7 @@ export function mountAgentChat(root, {
221
231
  if (!state.alive) return undefined
222
232
  if (turn) {
223
233
  state.turns = mergeTurns(state.turns, [turn])
224
- state.pending = true
234
+ state.pending = turn.status !== "held"
225
235
  state.following = true
226
236
  state.waitingSince = now()
227
237
  setOffline(false)
@@ -260,12 +270,13 @@ export function mountAgentChat(root, {
260
270
  if (response.status === 201 && data.turn) {
261
271
  input.value = ""
262
272
  state.turns = mergeTurns(state.turns, [data.turn])
263
- state.pending = true
273
+ state.pending = data.turn.status !== "held"
264
274
  state.following = true
265
275
  setOffline(false)
266
276
  render()
267
277
  toEnd()
268
278
  if (status) status.textContent = "Sent."
279
+ root.dispatchEvent(new CustomEventClass(SENT_EVENT, { detail: { turn: data.turn } }))
269
280
  state.waitingSince = now()
270
281
  clearTimer(state.timer)
271
282
  state.timer = setTimer(poll, POLL_MS)
@@ -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}...`,
@@ -157,6 +164,19 @@ export function mountAgentChatVoice(root, {
157
164
  if (status) status.textContent = message || ""
158
165
  }
159
166
 
167
+ // Takes the line's height before it changes, and hands it back with no argument.
168
+ function holdLine(holding) {
169
+ if (!status || !status.style) return
170
+ status.style.height = holding && status.offsetHeight ? `${status.offsetHeight}px` : ""
171
+ status.style.overflow = holding && status.offsetHeight ? "hidden" : ""
172
+ }
173
+
174
+ // What the recorder just said on its own line, which _voice hides here.
175
+ function recorderLine(fallback) {
176
+ const line = root.querySelector("[data-voice-recorder-status]")
177
+ return (line && line.textContent) || fallback
178
+ }
179
+
160
180
  function embedder() {
161
181
  const config = parseEmbedder(root.getAttribute("data-agent-chat-voice-embedder"))
162
182
  if (!config) return Promise.resolve(null)
@@ -278,15 +298,31 @@ export function mountAgentChatVoice(root, {
278
298
  function onState(event) {
279
299
  const next = event.detail && event.detail.state
280
300
  if (next === "starting") {
301
+ holdLine(true)
281
302
  say("idle", "")
282
- } else if (next === "recording") {
303
+ return
304
+ }
305
+ if (next === "recording") {
283
306
  say("listening", MESSAGES.listening)
284
307
  embedder().then((loaded) => loaded && loaded.warm && loaded.warm()).catch(() => {})
285
- } else if (root.dataset.agentChatVoiceState === "listening") {
308
+ return
309
+ }
310
+ holdLine(false)
311
+ if (next === "tapped") say("tapped", recorderLine(MESSAGES.tapped))
312
+ else if (next === "failed") say("failed", recorderLine(MESSAGES.failed))
313
+ else if (next === "denied") say("refused", recorderLine(MESSAGES.failed))
314
+ else if (root.dataset.agentChatVoiceState === "listening") {
286
315
  say(working ? "transcribing" : "idle", working ? MESSAGES.transcribing : "")
287
316
  }
288
317
  }
289
318
 
319
+ // A later typed send (studio/agent_chat): "Sent. Spoken by ..." is about a take
320
+ // the thread has moved past. A take still in hand, or a hold, keeps its own line.
321
+ function onTypedSend() {
322
+ if (working || root.dataset.agentChatVoiceState === "listening") return
323
+ say("idle", "")
324
+ }
325
+
290
326
  // "I can't hear anything yet" on the one line: the recorder's own line is hidden
291
327
  // in the composer while it records, so a held button never moves for it.
292
328
  function onHint(event) {
@@ -304,6 +340,7 @@ export function mountAgentChatVoice(root, {
304
340
  root.addEventListener("studio:voice-recorder:silent", onSilent)
305
341
  root.addEventListener("studio:voice-recorder:state", onState)
306
342
  root.addEventListener("studio:voice-recorder:hint", onHint)
343
+ if (chat && chat.addEventListener) chat.addEventListener("studio:agent-chat:sent", onTypedSend)
307
344
 
308
345
  return {
309
346
  takeArrived,
@@ -313,6 +350,7 @@ export function mountAgentChatVoice(root, {
313
350
  root.removeEventListener("studio:voice-recorder:silent", onSilent)
314
351
  root.removeEventListener("studio:voice-recorder:state", onState)
315
352
  root.removeEventListener("studio:voice-recorder:hint", onHint)
353
+ if (chat && chat.removeEventListener) chat.removeEventListener("studio:agent-chat:sent", onTypedSend)
316
354
  }
317
355
  }
318
356
  }
@@ -9,6 +9,15 @@
9
9
  // data-studio-loader-overlay, outlasts the overlay's data-delay
10
10
  // ms; hidden when the page arrives. Its role=status label is
11
11
  // announced: the overlay never marks itself aria-busy.
12
+ // the pace one standard for every app: a visit under data-delay ms shows no
13
+ // overlay and waits for nothing; once the overlay of a visit has
14
+ // shown, it stays data-min-show ms. Turbo's hook for a pause,
15
+ // turbo:before-render, comes AFTER a view transition took the old
16
+ // page's picture, with rendering paused, so a hold there would
17
+ // freeze the loader and carry it out in that picture. So the wait
18
+ // is on the response, ahead of all of that: see holdVisitResponse.
19
+ // the door a host need not render the overlay. layouts/studio/_head carries
20
+ // it in a <template>, and this module adds it to the body at need.
12
21
  // pending a button holding studio_loader_pending. Turbo marks a
13
22
  // submitting form aria-busy itself; for a form Turbo does not
14
23
  // drive, the submitter is marked here, and cleared on pagehide
@@ -20,10 +29,13 @@
20
29
  // clears at once, on turbo:reload.
21
30
  // the API window.StudioLoader.show(caption) / hide() for any other long
22
31
  // wait, and setPercent(el, n) for a loader drawn with percent:.
32
+ // hide() is immediate. It ends any wait, because the caller owns
33
+ // that moment, and a caller's show() has no minimum.
23
34
  //
24
35
  // The logic is exported for test/javascript/loader.test.mjs.
25
36
 
26
37
  export var DEFAULT_DELAY_MS = 350
38
+ export var DEFAULT_MIN_SHOW_MS = 750
27
39
  export var MAX_WAIT_MS = 15000
28
40
 
29
41
  // Only a visit the user started earns the overlay: a click or Turbo.visit
@@ -39,6 +51,11 @@ export function overlayDelay(overlay) {
39
51
  return value >= 0 ? value : DEFAULT_DELAY_MS
40
52
  }
41
53
 
54
+ export function overlayMinShow(overlay) {
55
+ var value = overlay ? parseInt(overlay.getAttribute('data-min-show'), 10) : NaN
56
+ return value >= 0 ? value : DEFAULT_MIN_SHOW_MS
57
+ }
58
+
42
59
  // The percent a caller handed setPercent, clamped to a number in 0..100.
43
60
  export function clampPercent(value) {
44
61
  var n = Number(value)
@@ -58,6 +75,97 @@ function findOverlay(doc) {
58
75
  var pendingTimer = null
59
76
  var capTimer = null
60
77
  var autoShown = false
78
+ var shownAt = null
79
+ var deferredHide = null
80
+ var releaseHold = null
81
+ var visitSignal = null
82
+
83
+ // The host's own overlay, else the one layouts/studio/_head carries. Added once:
84
+ // querySelector cannot see into a <template>.
85
+ function ensureOverlay(doc) {
86
+ var overlay = findOverlay(doc)
87
+ if (overlay || !doc.body) return overlay
88
+ var template = doc.querySelector('template[data-studio-loader-overlay-template]')
89
+ if (!template) return null
90
+ doc.body.appendChild(doc.importNode(template.content, true))
91
+ return findOverlay(doc)
92
+ }
93
+
94
+ // What is left of the minimum for an overlay a visit showed. Zero for one that is
95
+ // not showing, and for one a caller opened with show().
96
+ export function minShowLeft(overlay, now) {
97
+ if (!overlay || overlay.hidden || shownAt === null) return 0
98
+ return Math.max(0, overlayMinShow(overlay) - (now - shownAt))
99
+ }
100
+
101
+ // A page that must stay its own document answers this way. Turbo loads it again by
102
+ // a full load, so this response paints nothing, and a wait here would only delay it.
103
+ export function answersWithFullLoad(html) {
104
+ return /<meta[^>]*(turbo-visit-control[^>]*reload|reload[^>]*turbo-visit-control)/i.test(html || '')
105
+ }
106
+
107
+ export function noteVisitRequest(event, doc) {
108
+ if (event.target === doc.documentElement) visitSignal = event.detail.fetchOptions.signal
109
+ }
110
+
111
+ function endHold() {
112
+ if (releaseHold) releaseHold()
113
+ }
114
+
115
+ // Turbo reads response.responseHTML, and only then records the response, caches the
116
+ // page and opens its view transition. So this is the last moment the page is live
117
+ // with the response in hand. The rest of the minimum runs here and the overlay goes,
118
+ // so the old picture has no overlay in it. It always ends: at the timer, at hide(),
119
+ // or at the next visit. A later hold (the transition's wait for images) comes after
120
+ // this one has ended, inside the render, so neither can cut the other short. Turbo
121
+ // renders even a cancelled visit's response, so that HTML never goes: a Back must win.
122
+ //
123
+ // Only a visit's own good HTML waits. A frame, a form and a prefetch have another
124
+ // target. A failure and a download pass straight through.
125
+ export function holdVisitResponse(event, doc) {
126
+ doc = doc || document
127
+ if (event.target !== doc.documentElement) return false
128
+ clearTimeout(pendingTimer)
129
+ pendingTimer = null
130
+ var response = event.detail && event.detail.fetchResponse
131
+ // A body that fails after its headers rejects here, and Turbo fires nothing for it.
132
+ if (response) Promise.resolve(response.responseHTML).catch(function () { hide(doc) })
133
+ if (!response || !response.succeeded || !response.isHTML) return false
134
+ var overlay = findOverlay(doc)
135
+ if (minShowLeft(overlay, Date.now()) <= 0) return false
136
+ var signal = visitSignal
137
+ var held = Promise.resolve(response.responseHTML).then(function (html) {
138
+ if (answersWithFullLoad(html)) return html
139
+ var left = minShowLeft(overlay, Date.now())
140
+ if (left <= 0) return html
141
+ return new Promise(function (resolve) {
142
+ var done = function (value) { if (!signal || !signal.aborted) resolve(value) }
143
+ var timer = setTimeout(function () {
144
+ releaseHold = null
145
+ hide(doc)
146
+ done(html)
147
+ }, left)
148
+ releaseHold = function () {
149
+ clearTimeout(timer)
150
+ releaseHold = null
151
+ done(html)
152
+ }
153
+ })
154
+ })
155
+ Object.defineProperty(response, 'responseHTML', { configurable: true, value: held })
156
+ return true
157
+ }
158
+
159
+ // A form's overlay ends with its submit, and no render waits on it, so this only
160
+ // keeps the overlay up for the rest of its minimum.
161
+ export function hideAfterMinimum(doc) {
162
+ doc = doc || document
163
+ var left = minShowLeft(findOverlay(doc), Date.now())
164
+ if (left <= 0) return hide(doc)
165
+ clearTimeout(deferredHide)
166
+ deferredHide = setTimeout(function () { hide(doc) }, left)
167
+ return true
168
+ }
61
169
 
62
170
  // The overlay's status is a live region; rewriting its label after the overlay
63
171
  // unhides makes a screen reader say it, where text already present when a
@@ -77,8 +185,11 @@ export function show(caption, doc) {
77
185
  doc = doc || document
78
186
  clearTimeout(pendingTimer)
79
187
  pendingTimer = null
188
+ clearTimeout(deferredHide)
189
+ deferredHide = null
80
190
  autoShown = false
81
- var overlay = findOverlay(doc)
191
+ shownAt = null
192
+ var overlay = ensureOverlay(doc)
82
193
  if (!overlay) return false
83
194
  var line = overlay.querySelector('[data-studio-loader-caption]')
84
195
  if (line) line.textContent = caption || ''
@@ -94,6 +205,10 @@ export function hide(doc) {
94
205
  doc = doc || document
95
206
  clearTimeout(pendingTimer)
96
207
  pendingTimer = null
208
+ clearTimeout(deferredHide)
209
+ deferredHide = null
210
+ shownAt = null
211
+ endHold()
97
212
  var overlay = findOverlay(doc)
98
213
  if (!overlay) return false
99
214
  overlay.removeAttribute('data-state')
@@ -134,10 +249,14 @@ function armCap(doc) {
134
249
  // slow Turbo visit keeps its overlay until the page arrives.
135
250
  export function showSoon(caption, doc) {
136
251
  doc = doc || document
137
- var overlay = findOverlay(doc)
252
+ var overlay = ensureOverlay(doc)
138
253
  if (!overlay || !overlay.hidden) return
139
254
  clearTimeout(pendingTimer)
140
- pendingTimer = setTimeout(function () { show(caption, doc); autoShown = true }, overlayDelay(overlay))
255
+ pendingTimer = setTimeout(function () {
256
+ show(caption, doc)
257
+ autoShown = true
258
+ shownAt = Date.now()
259
+ }, overlayDelay(overlay))
141
260
  }
142
261
 
143
262
  export function markBusy(button, doc) {
@@ -168,7 +287,13 @@ export function installStudioLoader(doc) {
168
287
  if (installed) return
169
288
  installed = true
170
289
 
171
- doc.addEventListener('turbo:visit', function (event) { if (userVisit(event)) showSoon('', doc) })
290
+ doc.addEventListener('turbo:visit', function (event) {
291
+ // The response a hold still waits on belongs to a visit that is over.
292
+ endHold()
293
+ if (userVisit(event)) showSoon('', doc)
294
+ })
295
+ doc.addEventListener('turbo:before-fetch-request', function (event) { noteVisitRequest(event, doc) })
296
+ doc.addEventListener('turbo:before-fetch-response', function (event) { holdVisitResponse(event, doc) })
172
297
  // turbo:reload: the response was not HTML (a download), or the visit failed,
173
298
  // and Turbo hands the URL to the browser; no page may ever replace this one.
174
299
  ;['turbo:load', 'turbo:render', 'turbo:before-cache', 'turbo:fetch-request-error', 'turbo:frame-load', 'turbo:reload'].forEach(function (name) {
@@ -189,7 +314,7 @@ export function installStudioLoader(doc) {
189
314
  })
190
315
  doc.addEventListener('turbo:submit-end', function (event) {
191
316
  var form = event.target
192
- if (form && form.hasAttribute && form.hasAttribute('data-studio-loader-overlay')) hide(doc)
317
+ if (form && form.hasAttribute && form.hasAttribute('data-studio-loader-overlay')) hideAfterMinimum(doc)
193
318
  })
194
319
 
195
320
  // A form Turbo does not drive navigates away on its own; mark the button and,
@@ -55,6 +55,7 @@ export function mountMicrophoneChip(root, { win = window } = {}) {
55
55
  let silentLabel = null
56
56
  let lastTakeSilent = false
57
57
  let helpSession = null
58
+ let keysDown = new Set()
58
59
  let permission = null
59
60
  let destroyed = false
60
61
 
@@ -296,6 +297,7 @@ export function mountMicrophoneChip(root, { win = window } = {}) {
296
297
  root.dataset.microphoneHelp = kind
297
298
 
298
299
  if (dialog.open) return
300
+ keysDown = new Set()
299
301
  helpSession = { returnFocus: returnTo || doc.activeElement, done: false }
300
302
  if (typeof dialog.showModal === "function") dialog.showModal()
301
303
  else dialog.setAttribute("open", "")
@@ -318,6 +320,20 @@ export function mountMicrophoneChip(root, { win = window } = {}) {
318
320
  if (target) target.focus()
319
321
  }
320
322
 
323
+ // A key held on the talk button when a refusal opens the dialog lands on Close, the
324
+ // focused button: its repeats would press Close (Enter), and its keyup would finish
325
+ // the press (Space). Only a key pressed since the dialog opened counts. Mouse and
326
+ // touch are not keys, and Esc is not one of these two.
327
+ function onDialogKey(event) {
328
+ if (event.key !== " " && event.key !== "Enter") return
329
+ if (event.type === "keydown") {
330
+ if (event.repeat) event.preventDefault()
331
+ else keysDown.add(event.key)
332
+ } else if (!keysDown.delete(event.key)) {
333
+ event.preventDefault()
334
+ }
335
+ }
336
+
321
337
  // ---- Wiring --------------------------------------------------------------
322
338
 
323
339
  function onClick(event) {
@@ -342,6 +358,8 @@ export function mountMicrophoneChip(root, { win = window } = {}) {
342
358
  if (event.key !== "Escape") return
343
359
  if (dialog && dialog.contains(event.target)) return
344
360
  if (!panel || panel.hidden) return
361
+ // The list's Esc, not a modal's or a page's around the chip.
362
+ event.stopPropagation()
345
363
  closePanel()
346
364
  if (chip) chip.focus()
347
365
  }
@@ -352,7 +370,11 @@ export function mountMicrophoneChip(root, { win = window } = {}) {
352
370
  listeners.forEach(([name, fn]) => win.addEventListener(name, fn))
353
371
  root.addEventListener("click", onClick)
354
372
  root.addEventListener("keydown", onKey)
355
- if (dialog) dialog.addEventListener("close", onDialogClose)
373
+ if (dialog) {
374
+ dialog.addEventListener("close", onDialogClose)
375
+ dialog.addEventListener("keydown", onDialogKey, true)
376
+ dialog.addEventListener("keyup", onDialogKey, true)
377
+ }
356
378
  if (media && media.addEventListener) media.addEventListener("devicechange", onDeviceChange)
357
379
  watchPermission()
358
380
  refresh()
@@ -365,7 +387,11 @@ export function mountMicrophoneChip(root, { win = window } = {}) {
365
387
  listeners.forEach(([name, fn]) => win.removeEventListener(name, fn))
366
388
  root.removeEventListener("click", onClick)
367
389
  root.removeEventListener("keydown", onKey)
368
- if (dialog) dialog.removeEventListener("close", onDialogClose)
390
+ if (dialog) {
391
+ dialog.removeEventListener("close", onDialogClose)
392
+ dialog.removeEventListener("keydown", onDialogKey, true)
393
+ dialog.removeEventListener("keyup", onDialogKey, true)
394
+ }
369
395
  if (media && media.removeEventListener) media.removeEventListener("devicechange", onDeviceChange)
370
396
  if (permission) permission.onchange = null
371
397
  if (dialog && dialog.open) dialog.close()
@@ -1,5 +1,5 @@
1
- // studio/page_transitions: the app-to-app page transition (Studio.page_transition_direction,
2
- // docs/PAGE_TRANSITIONS.md). Loaded by its own tag in layouts/studio/_head, only with the
1
+ // studio/page_transitions: the app-to-app page transition (Studio.page_transition_direction and
2
+ // Studio.page_transition_style, docs/PAGE_TRANSITIONS.md). Loaded by its own tag in layouts/studio/_head, only with the
3
3
  // setting on. It names a style on <html>, and engine.css draws it. It imports nothing.
4
4
  //
5
5
  // Turbo calls document.startViewTransition BEFORE turbo:before-render, and the browser takes
@@ -8,17 +8,20 @@
8
8
  // turbo:visit and from the response, never first at before-render.
9
9
  //
10
10
  // A full load is the other path to the same look. The new document draws the motion, so the old
11
- // one only plans it: at pageswap it names what it holds, draws the style here, and leaves it for
11
+ // one only plans it: at pageswap it names what it holds, reads the style here, and leaves it for
12
12
  // studio/page_transition_reveal, the classic script that applies it at pagereveal.
13
13
  //
14
14
  // The effects are from the Codrops Page Transitions demo; the keyframes were written fresh.
15
15
 
16
- export const DIRECTIONS = Object.freeze({
17
- left: Object.freeze(["fade-left", "unfold-right"]),
18
- right: Object.freeze(["fade-right", "unfold-left"]),
19
- top: Object.freeze(["fade-top", "unfold-bottom"]),
20
- bottom: Object.freeze(["fade-bottom", "unfold-top"]),
16
+ // The move each pair of settings plays: the first key is Studio.page_transition_style, the second
17
+ // is Studio.page_transition_direction. An unfold is named for the way the new page turns, which
18
+ // is the opposite of the way the page leaves.
19
+ export const STYLES = Object.freeze({
20
+ slide: Object.freeze({ left: "slide-left", right: "slide-right", top: "slide-top", bottom: "slide-bottom" }),
21
+ unfold: Object.freeze({ left: "unfold-right", right: "unfold-left", top: "unfold-bottom", bottom: "unfold-top" }),
21
22
  })
23
+ // Mirrors the default in lib/studio.rb, for a page with no style meta.
24
+ export const DEFAULT_STYLE = "slide"
22
25
 
23
26
  // A same-page visit (a refresh, a poll, the second half of a redirect): the transition still
24
27
  // runs, and draws nothing.
@@ -29,31 +32,31 @@ export const FULL_LOAD_STYLE_KEY = "studio-page-transition-style"
29
32
  const MAX_READY_IMAGES = 12
30
33
  const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"
31
34
 
32
- export function pickStyle(direction, random = Math.random) {
33
- if (!Object.hasOwn(DIRECTIONS, direction)) return null
34
- const pair = DIRECTIONS[direction]
35
- return pair[Math.floor(random() * pair.length)]
35
+ export function styleFor(direction, style) {
36
+ const moves = STYLES[Object.hasOwn(STYLES, style) ? style : DEFAULT_STYLE]
37
+ return Object.hasOwn(moves, direction) ? moves[direction] : null
36
38
  }
37
39
 
38
40
  // The style for one visit, STILL, or null to leave the swap alone (the engine's fade plays,
39
41
  // and a morph between two pages stands). An advance visit to the same path can bring a new
40
42
  // page, as a launch panel does when it gives way to the chat at its own address, so a name
41
43
  // the two share still morphs. A refresh replaces, and stays silent.
42
- export function planVisit({ direction, from, to, transitions, sharedName, advance }, random = Math.random) {
43
- if (!transitions || !from || !to || !Object.hasOwn(DIRECTIONS, direction)) return null
44
+ export function planVisit({ direction, style, from, to, transitions, sharedName, advance }) {
45
+ const move = styleFor(direction, style)
46
+ if (!transitions || !from || !to || !move) return null
44
47
  if (from === to) return advance && sharedName ? null : STILL
45
- return sharedName ? null : pickStyle(direction, random)
48
+ return sharedName ? null : move
46
49
  }
47
50
 
48
51
  // The style a full load should play, or null for a plain load: planVisit's own rule, so the
49
52
  // two paths cannot differ. No morph, since the old page cannot read the new page's names
50
53
  // before its picture, and a browser with no activation entry has nothing to compare.
51
- export function planFullLoad({ direction, from, activation }, random = Math.random) {
54
+ export function planFullLoad({ direction, style, from, activation }) {
52
55
  const url = activation && activation.entry && activation.entry.url
53
56
  if (!url) return null
54
57
  const to = new URL(url).pathname
55
- const style = planVisit({ direction, from, to, transitions: true, sharedName: false }, random)
56
- return style === STILL ? null : style
58
+ const move = planVisit({ direction, style, from, to, transitions: true, sharedName: false })
59
+ return move === STILL ? null : move
57
60
  }
58
61
 
59
62
  // Hands the style to the next document. Storage can refuse, and then the load is plain.
@@ -129,6 +132,11 @@ function readDirection(doc) {
129
132
  return meta ? meta.content : null
130
133
  }
131
134
 
135
+ function readStyle(doc) {
136
+ const meta = doc.querySelector('meta[name="page-transition-style"]')
137
+ return meta ? meta.content : null
138
+ }
139
+
132
140
  let installed = false
133
141
 
134
142
  export function installPageTransitions(doc) {
@@ -193,6 +201,7 @@ export function installPageTransitions(doc) {
193
201
  const sharedName = shareName(morphNames(doc), names)
194
202
  const style = planVisit({
195
203
  direction: readDirection(doc),
204
+ style: readStyle(doc),
196
205
  from,
197
206
  to: target,
198
207
  transitions: !!doc.startViewTransition && !win.matchMedia(REDUCED_MOTION).matches,
@@ -235,7 +244,7 @@ export function installPageTransitions(doc) {
235
244
  // The last moment on the old document. The browser takes its picture after this.
236
245
  win.addEventListener("pageswap", (event) => {
237
246
  const transition = event.viewTransition
238
- const style = transition && planFullLoad({ direction: readDirection(doc), from, activation: event.activation })
247
+ const style = transition && planFullLoad({ direction: readDirection(doc), style: readStyle(doc), from, activation: event.activation })
239
248
  const left = leaveStyle(win, style)
240
249
  if (!transition) return
241
250
  if (!left) {