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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -0
- data/README.md +9 -3
- data/app/assets/javascripts/studio/page_transition_reveal.js +1 -1
- data/app/assets/tailwind/studio_engine/engine.css +519 -37
- data/app/controllers/studio/surveys_controller.rb +16 -3
- data/app/helpers/studio/loader_helper.rb +2 -2
- data/app/helpers/studio_icon_helper.rb +10 -0
- data/app/javascript/studio/agent_chat.js +14 -3
- data/app/javascript/studio/agent_chat_voice.js +42 -4
- data/app/javascript/studio/loader.js +130 -5
- data/app/javascript/studio/microphone_chip.js +28 -2
- data/app/javascript/studio/page_transitions.js +28 -19
- data/app/javascript/studio/survey.js +16 -3
- data/app/javascript/studio/theme_editor.js +4 -0
- data/app/javascript/studio/voice_recorder.js +18 -7
- data/app/models/studio/survey_response.rb +24 -17
- data/app/views/components/_geo_signpost.html.erb +1 -0
- data/app/views/components/_link_sidebar.html.erb +40 -12
- data/app/views/components/_sidebar_panel.html.erb +15 -5
- data/app/views/layouts/studio/_head.html.erb +18 -7
- data/app/views/layouts/studio/_smooth_load.html.erb +1 -0
- data/app/views/layouts/studio/component_preview.html.erb +1 -1
- data/app/views/studio/agent_chat/_turn.html.erb +3 -1
- data/app/views/studio/agent_chat/_voice.html.erb +28 -13
- data/app/views/studio/loader/_overlay.html.erb +4 -4
- data/app/views/style/_components.html.erb +1 -1
- data/app/views/style/_glass.html.erb +83 -0
- data/app/views/style/_host.html.erb +1 -1
- data/app/views/style/_modals.html.erb +11 -11
- data/app/views/style/_scrims.html.erb +1 -1
- data/app/views/style/_site_footer.html.erb +1 -1
- data/app/views/style/_tasks.html.erb +1 -1
- data/app/views/style/_theme.html.erb +56 -72
- data/app/views/style/_tricks.html.erb +13 -11
- data/app/views/style/_type_ladder.html.erb +33 -0
- data/app/views/style/index.html.erb +5 -1
- data/docs/SITE_FOOTER.md +1 -1
- data/lib/studio/agent_chat.rb +4 -2
- data/lib/studio/icons/README.md +12 -0
- data/lib/studio/icons/lucide/LICENSE +43 -0
- data/lib/studio/icons/lucide/activity.svg +15 -0
- data/lib/studio/icons/lucide/book-open.svg +16 -0
- data/lib/studio/icons/lucide/chart-column.svg +18 -0
- data/lib/studio/icons/lucide/diamond.svg +15 -0
- data/lib/studio/icons/lucide/door-stairwell.svg +19 -0
- data/lib/studio/icons/lucide/globe.svg +17 -0
- data/lib/studio/icons/lucide/hammer.svg +17 -0
- data/lib/studio/icons/lucide/inbox.svg +16 -0
- data/lib/studio/icons/lucide/key-round.svg +16 -0
- data/lib/studio/icons/lucide/layout-grid.svg +18 -0
- data/lib/studio/icons/lucide/log-out.svg +17 -0
- data/lib/studio/icons/lucide/message-circle.svg +15 -0
- data/lib/studio/icons/lucide/package.svg +18 -0
- data/lib/studio/icons/lucide/palette.svg +19 -0
- data/lib/studio/icons/lucide/plug.svg +18 -0
- data/lib/studio/icons/lucide/rocket.svg +18 -0
- data/lib/studio/icons/lucide/user.svg +16 -0
- data/lib/studio/icons.rb +52 -0
- data/lib/studio/loader.rb +7 -0
- data/lib/studio/sidebar_sections.rb +52 -4
- data/lib/studio/site_footer.rb +23 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +54 -2
- 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
|
|
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
|
|
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:
|
|
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 =
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
252
|
+
var overlay = ensureOverlay(doc)
|
|
138
253
|
if (!overlay || !overlay.hidden) return
|
|
139
254
|
clearTimeout(pendingTimer)
|
|
140
|
-
pendingTimer = setTimeout(function () {
|
|
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) {
|
|
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'))
|
|
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)
|
|
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)
|
|
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,
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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 }
|
|
43
|
-
|
|
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 :
|
|
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 }
|
|
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
|
|
56
|
-
return
|
|
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) {
|