studio-engine 0.127.0 → 0.129.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +161 -0
  3. data/README.md +9 -3
  4. data/app/assets/javascripts/studio/page_transition_reveal.js +1 -1
  5. data/app/assets/tailwind/studio_engine/engine.css +572 -37
  6. data/app/controllers/concerns/studio/error_handling.rb +17 -0
  7. data/app/controllers/studio/surveys_controller.rb +16 -3
  8. data/app/helpers/studio/loader_helper.rb +2 -2
  9. data/app/helpers/studio_icon_helper.rb +10 -0
  10. data/app/javascript/studio/agent_chat.js +88 -6
  11. data/app/javascript/studio/agent_chat_voice.js +59 -5
  12. data/app/javascript/studio/alpine_stores.js +80 -22
  13. data/app/javascript/studio/loader.js +130 -5
  14. data/app/javascript/studio/microphone_chip.js +28 -2
  15. data/app/javascript/studio/page_transitions.js +28 -19
  16. data/app/javascript/studio/survey.js +16 -3
  17. data/app/javascript/studio/theme_editor.js +4 -0
  18. data/app/javascript/studio/voice_recorder.js +18 -7
  19. data/app/models/studio/survey_response.rb +24 -17
  20. data/app/views/components/_geo_signpost.html.erb +1 -0
  21. data/app/views/components/_link_sidebar.html.erb +40 -12
  22. data/app/views/components/_sidebar_panel.html.erb +15 -5
  23. data/app/views/layouts/studio/_head.html.erb +18 -7
  24. data/app/views/layouts/studio/_smooth_load.html.erb +1 -0
  25. data/app/views/layouts/studio/component_preview.html.erb +1 -1
  26. data/app/views/registrations/new.html.erb +4 -3
  27. data/app/views/sessions/new.html.erb +4 -3
  28. data/app/views/studio/agent_chat/_chat.html.erb +2 -1
  29. data/app/views/studio/agent_chat/_turn.html.erb +3 -1
  30. data/app/views/studio/agent_chat/_voice.html.erb +28 -13
  31. data/app/views/studio/loader/_overlay.html.erb +4 -4
  32. data/app/views/style/_components.html.erb +2 -4
  33. data/app/views/style/_glass.html.erb +83 -0
  34. data/app/views/style/_host.html.erb +2 -10
  35. data/app/views/style/_modals.html.erb +38 -231
  36. data/app/views/style/_scrims.html.erb +2 -9
  37. data/app/views/style/_site_footer.html.erb +5 -13
  38. data/app/views/style/_tasks.html.erb +16 -27
  39. data/app/views/style/_theme.html.erb +56 -78
  40. data/app/views/style/_tricks.html.erb +13 -11
  41. data/app/views/style/_type_ladder.html.erb +33 -0
  42. data/app/views/style/index.html.erb +5 -1
  43. data/docs/SITE_FOOTER.md +1 -1
  44. data/lib/studio/agent_chat.rb +4 -2
  45. data/lib/studio/icons/README.md +12 -0
  46. data/lib/studio/icons/lucide/LICENSE +43 -0
  47. data/lib/studio/icons/lucide/activity.svg +15 -0
  48. data/lib/studio/icons/lucide/book-open.svg +16 -0
  49. data/lib/studio/icons/lucide/chart-column.svg +18 -0
  50. data/lib/studio/icons/lucide/diamond.svg +15 -0
  51. data/lib/studio/icons/lucide/door-stairwell.svg +19 -0
  52. data/lib/studio/icons/lucide/globe.svg +17 -0
  53. data/lib/studio/icons/lucide/hammer.svg +17 -0
  54. data/lib/studio/icons/lucide/inbox.svg +16 -0
  55. data/lib/studio/icons/lucide/key-round.svg +16 -0
  56. data/lib/studio/icons/lucide/layout-grid.svg +18 -0
  57. data/lib/studio/icons/lucide/log-out.svg +17 -0
  58. data/lib/studio/icons/lucide/message-circle.svg +15 -0
  59. data/lib/studio/icons/lucide/package.svg +18 -0
  60. data/lib/studio/icons/lucide/palette.svg +19 -0
  61. data/lib/studio/icons/lucide/plug.svg +18 -0
  62. data/lib/studio/icons/lucide/rocket.svg +18 -0
  63. data/lib/studio/icons/lucide/user.svg +16 -0
  64. data/lib/studio/icons.rb +52 -0
  65. data/lib/studio/loader.rb +7 -0
  66. data/lib/studio/sidebar_sections.rb +52 -4
  67. data/lib/studio/site_footer.rb +23 -2
  68. data/lib/studio/version.rb +1 -1
  69. data/lib/studio.rb +54 -2
  70. metadata +24 -1
@@ -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) {
@@ -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, and a PATCH racing
200
- // it would land after completion and be refused.
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) and
18
- // unsupported (no MediaRecorder). Any of idle, captured, silent, failed and
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); everything else reads as no microphone found.
87
- export const BUSY_ERRORS = ["NotReadableError", "TrackStartError"]
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
- return [false, "This survey is already complete."] if completed?
92
-
93
- value, error = question.normalize(raw)
94
- return [false, error] if error
95
-
96
- next_answers = (answers || {}).dup
97
- if value.nil?
98
- next_answers.delete(question.key)
99
- else
100
- next_answers[question.key] = {
101
- "value" => value, "label" => question.label, "type" => question.type.to_s,
102
- "display" => question.display(value), "answered_at" => Time.current.utc.iso8601
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">