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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +161 -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 +572 -37
- data/app/controllers/concerns/studio/error_handling.rb +17 -0
- 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 +88 -6
- data/app/javascript/studio/agent_chat_voice.js +59 -5
- data/app/javascript/studio/alpine_stores.js +80 -22
- 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/registrations/new.html.erb +4 -3
- data/app/views/sessions/new.html.erb +4 -3
- data/app/views/studio/agent_chat/_chat.html.erb +2 -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 +2 -4
- data/app/views/style/_glass.html.erb +83 -0
- data/app/views/style/_host.html.erb +2 -10
- data/app/views/style/_modals.html.erb +38 -231
- data/app/views/style/_scrims.html.erb +2 -9
- data/app/views/style/_site_footer.html.erb +5 -13
- data/app/views/style/_tasks.html.erb +16 -27
- data/app/views/style/_theme.html.erb +56 -78
- 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
|
@@ -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) {
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
//
|
|
10
10
|
// AUTOSAVE IS BEST EFFORT: the final Submit posts the whole form, so a failed
|
|
11
11
|
// autosave loses nothing. Without this module the page is the plain form, every
|
|
12
|
-
// question on one page, and it submits the same answers.
|
|
12
|
+
// question on one page, and it submits the same answers. So a submit cancels every
|
|
13
|
+
// save still in flight: one left running could reach the server after the POST.
|
|
13
14
|
//
|
|
14
15
|
// It imports nothing; test/javascript/survey.test.mjs loads it as a data:
|
|
15
16
|
// module. Bound to the page by the survey controller.
|
|
@@ -84,6 +85,7 @@ export function enhanceSurvey(root, win = window, doc = document) {
|
|
|
84
85
|
var reduce = win.matchMedia && win.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
85
86
|
var saved = {};
|
|
86
87
|
var timers = {};
|
|
88
|
+
var requests = [];
|
|
87
89
|
var current = -1;
|
|
88
90
|
var pointerPick = false;
|
|
89
91
|
var submitting = false;
|
|
@@ -121,12 +123,20 @@ export function enhanceSurvey(root, win = window, doc = document) {
|
|
|
121
123
|
}
|
|
122
124
|
var url = root.dataset.answerBase + encodeURIComponent(key);
|
|
123
125
|
status.textContent = "Saving…";
|
|
126
|
+
var request = new win.AbortController();
|
|
127
|
+
requests.push(request);
|
|
124
128
|
return win.fetch(url, {
|
|
125
129
|
method: "PATCH",
|
|
126
130
|
body: body,
|
|
127
131
|
credentials: "same-origin",
|
|
132
|
+
signal: request.signal,
|
|
128
133
|
headers: { "Accept": "application/json", "X-CSRF-Token": csrf }
|
|
129
134
|
}).then(function (res) {
|
|
135
|
+
// Completed elsewhere, such as in a second tab. There is nothing to fix here.
|
|
136
|
+
if (res.status === 409) {
|
|
137
|
+
status.textContent = "";
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
130
140
|
// fetch resolves on a 4xx/5xx — the status is the only signal.
|
|
131
141
|
if (!res.ok) {
|
|
132
142
|
return res.json().catch(function () { return {}; }).then(function (data) {
|
|
@@ -139,6 +149,8 @@ export function enhanceSurvey(root, win = window, doc = document) {
|
|
|
139
149
|
status.textContent = "Saved";
|
|
140
150
|
return true;
|
|
141
151
|
}).catch(function () {
|
|
152
|
+
// The submit cancelled it, which is not the visitor being offline.
|
|
153
|
+
if (submitting) return false;
|
|
142
154
|
status.textContent = "Offline? Your answers still go in when you submit.";
|
|
143
155
|
return false;
|
|
144
156
|
});
|
|
@@ -196,8 +208,8 @@ export function enhanceSurvey(root, win = window, doc = document) {
|
|
|
196
208
|
setError(step, "");
|
|
197
209
|
}
|
|
198
210
|
if (current === steps.length - 1) {
|
|
199
|
-
// No autosave here: the POST carries every answer
|
|
200
|
-
//
|
|
211
|
+
// No autosave here: the POST carries every answer. The submit handler
|
|
212
|
+
// cancels any earlier save still in flight.
|
|
201
213
|
submitForm();
|
|
202
214
|
} else {
|
|
203
215
|
if (step.dataset.key) save(step);
|
|
@@ -222,6 +234,7 @@ export function enhanceSurvey(root, win = window, doc = document) {
|
|
|
222
234
|
}
|
|
223
235
|
Object.keys(timers).forEach(function (key) { win.clearTimeout(timers[key]); });
|
|
224
236
|
submitting = true;
|
|
237
|
+
requests.forEach(function (request) { request.abort(); });
|
|
225
238
|
submit.disabled = true;
|
|
226
239
|
submit.textContent = "Sending…";
|
|
227
240
|
});
|
|
@@ -82,7 +82,11 @@ export function dsThemeEditor(seed) {
|
|
|
82
82
|
}, seed || {}),
|
|
83
83
|
saving: false,
|
|
84
84
|
|
|
85
|
+
// The preview block shows once a role has been edited: before that it only repeats the page.
|
|
86
|
+
edited: false,
|
|
87
|
+
|
|
85
88
|
updatePreview() {
|
|
89
|
+
this.edited = true
|
|
86
90
|
applyProperties(document.documentElement.style, stylePreviewProperties(this.colors))
|
|
87
91
|
},
|
|
88
92
|
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
// </div>
|
|
15
15
|
//
|
|
16
16
|
// STATES, on data-voice-recorder-state: idle -> starting -> recording ->
|
|
17
|
-
// captured | silent | failed, plus denied (the microphone was refused)
|
|
18
|
-
// unsupported (no MediaRecorder)
|
|
19
|
-
// denied takes a new press.
|
|
17
|
+
// captured | silent | failed, plus denied (the microphone was refused),
|
|
18
|
+
// unsupported (no MediaRecorder) and, in hold mode, tapped (let go too soon to be a
|
|
19
|
+
// take). Any of idle, captured, silent, failed, denied and tapped takes a new press.
|
|
20
20
|
//
|
|
21
21
|
// EVENTS, dispatched on the root (they bubble):
|
|
22
22
|
// studio:voice-recorder:take detail { blob, mimeType, durationMs, peakRms, microphone: { label, deviceId } }
|
|
@@ -49,7 +49,12 @@ export const MIME_CANDIDATES = [
|
|
|
49
49
|
"audio/ogg;codecs=opus"
|
|
50
50
|
]
|
|
51
51
|
|
|
52
|
-
export const READY = ["idle", "captured", "silent", "failed", "denied"]
|
|
52
|
+
export const READY = ["idle", "captured", "silent", "failed", "denied", "tapped"]
|
|
53
|
+
|
|
54
|
+
// Hold mode: a take shorter than this is a tap, not a hold. It says how to use the
|
|
55
|
+
// button and tells the chip nothing: the frames a tap records are too few to judge
|
|
56
|
+
// the microphone by, and two silent takes in a row would open the help.
|
|
57
|
+
export const TAP_MS = 300
|
|
53
58
|
|
|
54
59
|
// How long a take may stay silent before the status asks whether the
|
|
55
60
|
// microphone is on.
|
|
@@ -66,6 +71,7 @@ export const COPY = {
|
|
|
66
71
|
missing: "I couldn't find a microphone. Plug one in and try again.",
|
|
67
72
|
busy: "Your microphone is busy in another app. Close it there, then try again." },
|
|
68
73
|
denied: { button: "Record", status: "Allow the microphone to record." },
|
|
74
|
+
tapped: { button: "Record", status: "Hold the button while you talk, then let go." },
|
|
69
75
|
unsupported: { button: "Record", status: "This browser can't record here. Try Safari or Chrome." }
|
|
70
76
|
}
|
|
71
77
|
|
|
@@ -83,8 +89,9 @@ export function formatElapsed(ms) {
|
|
|
83
89
|
|
|
84
90
|
// The line for a microphone that would not open (NotAllowedError is "denied",
|
|
85
91
|
// not this). NotReadableError is a microphone another app holds (Chrome's
|
|
86
|
-
// older name, TrackStartError
|
|
87
|
-
|
|
92
|
+
// older name, TrackStartError; Firefox can say AbortError); everything else
|
|
93
|
+
// reads as no microphone found.
|
|
94
|
+
export const BUSY_ERRORS = ["NotReadableError", "TrackStartError", "AbortError"]
|
|
88
95
|
|
|
89
96
|
export function failedLine(error, copy = COPY) {
|
|
90
97
|
const busy = !!error && BUSY_ERRORS.includes(error.name)
|
|
@@ -216,7 +223,7 @@ export function mountVoiceRecorder(root, { win = window, open = openMicrophone,
|
|
|
216
223
|
// Destroyed, or let go of (hold mode) before the microphone opened.
|
|
217
224
|
if (destroyed || stopWanted) {
|
|
218
225
|
release()
|
|
219
|
-
if (!destroyed) setState("idle")
|
|
226
|
+
if (!destroyed) setState(mode === "hold" ? "tapped" : "idle")
|
|
220
227
|
return
|
|
221
228
|
}
|
|
222
229
|
chunks = []
|
|
@@ -275,6 +282,10 @@ export function mountVoiceRecorder(root, { win = window, open = openMicrophone,
|
|
|
275
282
|
recorder = null
|
|
276
283
|
release()
|
|
277
284
|
if (destroyed) return
|
|
285
|
+
if (mode === "hold" && durationMs < TAP_MS) {
|
|
286
|
+
setState("tapped")
|
|
287
|
+
return
|
|
288
|
+
}
|
|
278
289
|
if (blob.size === 0 || !heard) {
|
|
279
290
|
setState("silent", silentMessage(copy.silent.named, microphone.label))
|
|
280
291
|
announce(MICROPHONE_EVENTS.silent, { label: microphone.label }, win)
|
|
@@ -87,25 +87,32 @@ module Studio
|
|
|
87
87
|
|
|
88
88
|
# Stores one answer (or clears it, for a blank). Returns [ok, error]. Never
|
|
89
89
|
# touches a completed response: a finished survey is a record, not a draft.
|
|
90
|
+
#
|
|
91
|
+
# THE ROW LOCK IS THE GUARD, not the loaded copy. This writes the whole answers
|
|
92
|
+
# hash, so a copy loaded before a submit completed the row would put its stale
|
|
93
|
+
# hash over the finished answers, and two saves at once would lose each other's
|
|
94
|
+
# keys. with_lock reloads under the lock, so both checks read the row as it is.
|
|
90
95
|
def record_answer(question, raw)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
96
|
+
with_lock do
|
|
97
|
+
next [false, "This survey is already complete."] if completed?
|
|
98
|
+
|
|
99
|
+
value, error = question.normalize(raw)
|
|
100
|
+
next [false, error] if error
|
|
101
|
+
|
|
102
|
+
next_answers = (answers || {}).dup
|
|
103
|
+
if value.nil?
|
|
104
|
+
next_answers.delete(question.key)
|
|
105
|
+
else
|
|
106
|
+
next_answers[question.key] = {
|
|
107
|
+
"value" => value, "label" => question.label, "type" => question.type.to_s,
|
|
108
|
+
"display" => question.display(value), "answered_at" => Time.current.utc.iso8601
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
self.answers = next_answers
|
|
112
|
+
self.current_key = question.key
|
|
113
|
+
save!
|
|
114
|
+
[true, nil]
|
|
104
115
|
end
|
|
105
|
-
self.answers = next_answers
|
|
106
|
-
self.current_key = question.key
|
|
107
|
-
save!
|
|
108
|
-
[true, nil]
|
|
109
116
|
end
|
|
110
117
|
|
|
111
118
|
def missing_required(survey)
|
|
@@ -88,6 +88,7 @@
|
|
|
88
88
|
<%= link_to admin_geo_path,
|
|
89
89
|
class: "group flex items-center gap-3 px-4 py-3 text-sm text-body hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-inset transition",
|
|
90
90
|
"data-sidebar-row-described": "",
|
|
91
|
+
"aria-current": (Studio.sidebar_style == :glass && respond_to?(:request) && request&.path == admin_geo_path ? "page" : nil),
|
|
91
92
|
"@click": geo_close do %>
|
|
92
93
|
<span class="studio-emoji-box text-lg leading-none flex-shrink-0">🌍</span>
|
|
93
94
|
<span class="relative min-w-0 flex-1">
|