studio-engine 0.130.0 → 0.131.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +151 -0
  3. data/README.md +43 -6
  4. data/app/assets/tailwind/studio_engine/engine-motion.css +174 -0
  5. data/app/assets/tailwind/studio_engine/engine.css +587 -0
  6. data/app/assets/tailwind/studio_engine/theme.css +4 -0
  7. data/app/controllers/concerns/studio/error_handling.rb +53 -15
  8. data/app/controllers/concerns/studio/magic_link_issuing.rb +4 -0
  9. data/app/controllers/magic_links_controller.rb +25 -2
  10. data/app/controllers/registrations_controller.rb +58 -22
  11. data/app/controllers/style_controller.rb +6 -0
  12. data/app/helpers/studio/agent_chat_helper.rb +14 -1
  13. data/app/helpers/studio/crumbs_helper.rb +34 -0
  14. data/app/helpers/studio/loader_helper.rb +79 -2
  15. data/app/javascript/studio/agent_chat.js +294 -6
  16. data/app/javascript/studio/carousel.js +99 -0
  17. data/app/javascript/studio/controllers/carousel_controller.js +138 -0
  18. data/app/javascript/studio/controllers/infinite_scroll_controller.js +17 -0
  19. data/app/javascript/studio/controllers/pull_to_refresh_controller.js +18 -0
  20. data/app/javascript/studio/controllers/talk_to_agent_controller.js +20 -0
  21. data/app/javascript/studio/infinite_scroll.js +142 -0
  22. data/app/javascript/studio/loader.js +6 -1
  23. data/app/javascript/studio/page_transitions.js +65 -8
  24. data/app/javascript/studio/pull_to_refresh.js +147 -0
  25. data/app/javascript/studio/stimulus.js +12 -1
  26. data/app/javascript/studio/talk_to_agent.js +116 -0
  27. data/app/views/components/_geo_signpost.html.erb +2 -2
  28. data/app/views/components/_link_sidebar.html.erb +2 -2
  29. data/app/views/components/_link_sidebar_trigger.html.erb +1 -1
  30. data/app/views/components/_sidebar_panel.html.erb +1 -1
  31. data/app/views/error_logs/index.html.erb +2 -8
  32. data/app/views/studio/_carousel.html.erb +97 -0
  33. data/app/views/studio/_page_header.html.erb +52 -11
  34. data/app/views/studio/agent_chat/_chat.html.erb +7 -4
  35. data/app/views/studio/agent_chat/_talk_modal.html.erb +58 -0
  36. data/app/views/studio/agent_chat/_turn.html.erb +1 -1
  37. data/app/views/studio/loader/_infinite_scroll.html.erb +42 -0
  38. data/app/views/studio/loader/_loader.html.erb +20 -3
  39. data/app/views/studio/loader/_pull_to_refresh.html.erb +20 -0
  40. data/app/views/studio/loader/_skeleton.html.erb +37 -0
  41. data/app/views/style/_loaders.html.erb +118 -0
  42. data/app/views/style/_loaders_feed_page.html.erb +16 -0
  43. data/app/views/style/_modals.html.erb +12 -0
  44. data/app/views/style/_specimen.html.erb +17 -10
  45. data/app/views/style/_tricks.html.erb +64 -2
  46. data/app/views/style/index.html.erb +14 -8
  47. data/app/views/style/loaders_feed.html.erb +7 -0
  48. data/lib/studio/crumbs.rb +150 -0
  49. data/lib/studio/loader.rb +30 -2
  50. data/lib/studio/version.rb +1 -1
  51. data/lib/studio.rb +3 -0
  52. metadata +20 -2
@@ -3,7 +3,7 @@
3
3
  // app for it (GET /agent_chat/turns, POST /agent_chat/messages), which proxies.
4
4
  //
5
5
  // The look is the ERB: each turn is a clone of a <template data-agent-chat-template>
6
- // (studio/agent_chat/_turn), filled with textContent, never HTML.
6
+ // (studio/agent_chat/_turn), filled with text nodes, never HTML (MARKDOWN below).
7
7
  //
8
8
  // LOAD on connect, the thread's latest turns; a failure shows the offline
9
9
  // notice with Try again (and retries itself, slowly).
@@ -38,6 +38,23 @@
38
38
  // that still reports it as "failed" gets the failed line.
39
39
  // TYPING the typing line shows while the hub says an answer is pending:
40
40
  // "thinking", then "typing" once a turn's status is "typing".
41
+ // OPENER data-agent-chat-opener on the section: words the chat sends as the
42
+ // visitor's first message once its thread has loaded, so the agent
43
+ // starts answering at once (studio/talk_to_agent sets it as its modal
44
+ // opens). Sent once per data-agent-chat-opener-key per tab
45
+ // (sessionStorage): opening the same topic again shows the thread and
46
+ // sends nothing. A send the app refuses puts the words in the box.
47
+ // A visitor who sends before the thread loads gets the opener posted
48
+ // first, then their words; it is never dropped and never sent twice.
49
+ // MARKDOWN an agent turn's words show a small, safe Markdown subset (bold,
50
+ // italics, code, http(s) links, lists, line breaks), built as nodes,
51
+ // never HTML; a visitor's words stay plain text (markdownBlocks).
52
+ // TYPE-IN data-agent-chat-reveal="true" on the section: an agent answer that
53
+ // arrives while the chat is open types itself in (revealPlan, about
54
+ // REVEAL_CPS characters a second, REVEAL_MIN_MS to REVEAL_MAX_MS), as
55
+ // the hub's agent_reveal does. The thread loaded on open, and every
56
+ // answer under prefers-reduced-motion, shows at once. While it types
57
+ // the log is aria-busy, so a screen reader hears the answer once.
41
58
  //
42
59
  // It imports nothing, so test/javascript/agent_chat.test.mjs loads it as a
43
60
  // data: module; studio/controllers/agent_chat_controller mounts it.
@@ -56,6 +73,11 @@ export const RECORDER_STATE_EVENT = "studio:voice-recorder:state"
56
73
  const HOLDING = "[data-voice-recorder-state='recording'],[data-voice-recorder-state='starting']"
57
74
  export const REFRESH_EVENT = "studio:agent-chat:refresh"
58
75
  export const SENT_EVENT = "studio:agent-chat:sent"
76
+ export const OPENER_STORAGE_PREFIX = "studio:agent-chat:opener:"
77
+ export const REVEAL_CPS = 420
78
+ export const REVEAL_MIN_MS = 450
79
+ export const REVEAL_MAX_MS = 2600
80
+ export const REVEAL_FRAME_MS = 32
59
81
  const UNSETTLED = new Set(["pending", "typing"])
60
82
 
61
83
  export function settled(turn) {
@@ -127,6 +149,156 @@ export function csrfToken(form, doc = globalThis.document) {
127
149
  return meta ? meta.getAttribute("content") : null
128
150
  }
129
151
 
152
+ // How a new answer of `length` characters types in: its duration in ms, clamped
153
+ // to REVEAL_MIN_MS..REVEAL_MAX_MS, and how many characters show `elapsed` ms in.
154
+ export function revealPlan(length, elapsed) {
155
+ const total = Math.max(0, Number(length) || 0)
156
+ const duration = Math.min(REVEAL_MAX_MS, Math.max(REVEAL_MIN_MS, Math.round((total / REVEAL_CPS) * 1000)))
157
+ const shown = elapsed >= duration ? total : Math.floor((total * Math.max(0, elapsed)) / duration)
158
+ return { duration, shown }
159
+ }
160
+
161
+ // The sessionStorage key an opener is remembered under; null with no key.
162
+ export function openerStorageKey(key) {
163
+ const name = String(key ?? "").trim()
164
+ return name ? `${OPENER_STORAGE_PREFIX}${name}` : null
165
+ }
166
+
167
+ // sessionStorage, or null where the browser refuses it (a private window, a
168
+ // sandboxed frame): the opener is then sent on every open.
169
+ function sessionStore() {
170
+ try { return globalThis.sessionStorage || null } catch (_error) { return null }
171
+ }
172
+
173
+ function prefersReducedMotion() {
174
+ try { return Boolean(globalThis.matchMedia && globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches) } catch (_error) { return false }
175
+ }
176
+
177
+ // MARKDOWN: an agent turn's words as a small, safe subset of Markdown, for agent
178
+ // turns only (a visitor's words stay plain text). markdownBlocks is pure: it turns
179
+ // the words into paragraphs and lists of inline pieces. renderMarkdown builds them
180
+ // as DOM nodes with createElement and text nodes, never as HTML, so any other
181
+ // markup in the words shows as the characters it is.
182
+ //
183
+ // **bold** __bold__ *italics* `code` [label](https://…)
184
+ // "- ", "* ", "+ " and "1. " lines make a list; a leading "> " is dropped;
185
+ // any other line break stays a line break.
186
+ //
187
+ // A link's target must be http: or https: (SAFE_LINK); anything else shows as
188
+ // its own text. No _underscore_ italics: snake_case words are common in answers,
189
+ // and the regex stays free of lookbehind, which older Safari cannot parse.
190
+ export const SAFE_LINK = /^https?:\/\/[^\s<>"'`]+$/i
191
+ const INLINE = /\*\*([^\s*](?:[^\n]*?[^\s*])?)\*\*|__([^\s_](?:[^\n]*?[^\s_])?)__|\[([^\]\n]+)\]\(([^()\s]+)\)|`([^`\n]+)`|\*([^*\s](?:[^*\n]*?[^*\s])?)\*/g
192
+ const BULLET = /^\s{0,3}[-*+]\s+(.*)$/
193
+ const NUMBERED = /^\s{0,3}\d{1,3}[.)]\s+(.*)$/
194
+ const QUOTE = /^\s{0,3}>\s?/
195
+
196
+ export function safeHref(url) {
197
+ const href = String(url ?? "").trim()
198
+ if (!SAFE_LINK.test(href)) return null
199
+ try {
200
+ const parsed = new URL(href)
201
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? href : null
202
+ } catch (_error) { return null }
203
+ }
204
+
205
+ // Inline pieces: {text} | {strong: [...]} | {em: [...]} | {code} | {link, href}.
206
+ export function markdownInline(text) {
207
+ const pieces = []
208
+ const words = String(text ?? "")
209
+ let at = 0
210
+ // A fresh regex per call: bold and italics recurse, and a shared /g regex's
211
+ // lastIndex would jump.
212
+ const inline = new RegExp(INLINE.source, "g")
213
+ for (let match = inline.exec(words); match; match = inline.exec(words)) {
214
+ const [whole, bold, bold2, label, url, code, em] = match
215
+ let piece = null
216
+ if (bold != null || bold2 != null) piece = { strong: markdownInline(bold ?? bold2) }
217
+ else if (label != null) { const href = safeHref(url); if (href) piece = { link: label, href } }
218
+ else if (code != null) piece = { code }
219
+ else piece = { em: markdownInline(em) }
220
+ if (!piece) continue
221
+ if (match.index > at) pieces.push({ text: words.slice(at, match.index) })
222
+ pieces.push(piece)
223
+ at = match.index + whole.length
224
+ }
225
+ if (at < words.length) pieces.push({ text: words.slice(at) })
226
+ return pieces
227
+ }
228
+
229
+ // Blocks: {lines: [inline, ...]} for a run of text lines, {list: "ul"|"ol", items: [inline, ...]}.
230
+ export function markdownBlocks(text) {
231
+ const blocks = []
232
+ for (const raw of String(text ?? "").split(/\r?\n/)) {
233
+ const line = raw.replace(QUOTE, "")
234
+ const bullet = line.match(BULLET)
235
+ const numbered = bullet ? null : line.match(NUMBERED)
236
+ const kind = bullet ? "ul" : numbered ? "ol" : null
237
+ const last = blocks.at(-1)
238
+ if (kind) {
239
+ const item = markdownInline((bullet || numbered)[1])
240
+ if (last && last.list === kind) last.items.push(item)
241
+ else blocks.push({ list: kind, items: [item] })
242
+ } else if (last && last.lines) {
243
+ last.lines.push(markdownInline(line))
244
+ } else {
245
+ blocks.push({ lines: [markdownInline(line)] })
246
+ }
247
+ }
248
+ return blocks
249
+ }
250
+
251
+ function inlineNodes(d, pieces) {
252
+ return pieces.map((piece) => {
253
+ if (piece.text != null) return d.createTextNode(piece.text)
254
+ if (piece.code != null) { const el = d.createElement("code"); el.textContent = piece.code; return el }
255
+ if (piece.link != null) {
256
+ const el = d.createElement("a")
257
+ el.setAttribute("href", piece.href)
258
+ el.setAttribute("target", "_blank")
259
+ el.setAttribute("rel", "noopener noreferrer nofollow")
260
+ el.style.textDecoration = "underline"
261
+ el.textContent = piece.link
262
+ return el
263
+ }
264
+ const el = d.createElement(piece.strong ? "strong" : "em")
265
+ el.append(...inlineNodes(d, piece.strong || piece.em))
266
+ return el
267
+ })
268
+ }
269
+
270
+ // Fills `el` with the words as Markdown nodes. Where the element cannot build
271
+ // nodes (no document), the words go in as text, as before.
272
+ export function renderMarkdown(el, words, doc = globalThis.document) {
273
+ const d = (el && el.ownerDocument) || doc
274
+ if (!el || !d || typeof d.createElement !== "function" || typeof el.replaceChildren !== "function") {
275
+ if (el) el.textContent = String(words ?? "")
276
+ return
277
+ }
278
+ const nodes = []
279
+ for (const block of markdownBlocks(words)) {
280
+ if (block.list) {
281
+ const list = d.createElement(block.list)
282
+ list.style.paddingLeft = "1.25rem"
283
+ list.style.listStyle = block.list === "ul" ? "disc" : "decimal"
284
+ for (const item of block.items) {
285
+ const li = d.createElement("li")
286
+ li.append(...inlineNodes(d, item))
287
+ list.append(li)
288
+ }
289
+ nodes.push(list)
290
+ } else {
291
+ // The text keeps its own line breaks (the turn's text is pre-wrap); a list
292
+ // sits on its own lines, so no break is added next to one.
293
+ block.lines.forEach((line, index) => {
294
+ if (index > 0) nodes.push(d.createTextNode("\n"))
295
+ nodes.push(...inlineNodes(d, line))
296
+ })
297
+ }
298
+ }
299
+ el.replaceChildren(...nodes)
300
+ }
301
+
130
302
  export function turnsUrl(base, { soul, after, limit = LIMIT }) {
131
303
  const params = new URLSearchParams({ soul, limit: String(limit) })
132
304
  if (after != null) params.set("after", after)
@@ -139,7 +311,9 @@ export function mountAgentChat(root, {
139
311
  now = () => Date.now(),
140
312
  setTimer = (fn, ms) => globalThis.setTimeout(fn, ms),
141
313
  clearTimer = (id) => globalThis.clearTimeout(id),
142
- CustomEventClass = globalThis.CustomEvent
314
+ CustomEventClass = globalThis.CustomEvent,
315
+ storage = sessionStore(),
316
+ reducedMotion = prefersReducedMotion
143
317
  } = {}) {
144
318
  const $ = (selector) => root.querySelector(selector)
145
319
  const log = $("[data-agent-chat-log]")
@@ -154,8 +328,11 @@ export function mountAgentChat(root, {
154
328
  const status = $("[data-agent-chat-status]")
155
329
  const soul = root.dataset.agentChatSoul
156
330
  const name = root.dataset.agentChatName || "The agent"
331
+ const opener = String(root.dataset.agentChatOpener || "").trim()
332
+ const openerKey = openerStorageKey(root.dataset.agentChatOpenerKey)
333
+ const typeIn = root.dataset.agentChatReveal === "true"
157
334
 
158
- const state = { turns: [], pending: false, waitingSince: null, offline: false, inFlight: false, pollAgain: false, sending: false, timer: null, alive: true, following: true, revealFollowing: true, revealOwed: false }
335
+ const state = { turns: [], pending: false, waitingSince: null, offline: false, inFlight: false, pollAgain: false, sending: false, timer: null, alive: true, following: true, revealFollowing: true, revealOwed: false, loaded: false, typing: new Map(), openerPhase: "owed", openerPost: null }
159
336
 
160
337
  const onScroll = () => { state.following = isFollowing(log) }
161
338
  log.addEventListener("scroll", onScroll, { passive: true })
@@ -237,7 +414,14 @@ export function mountAgentChat(root, {
237
414
  }
238
415
  node.dataset.status = turn.status || ""
239
416
  const text = node.querySelector("[data-agent-chat-text]")
240
- if (text && text.textContent !== String(turn.text ?? "")) text.textContent = String(turn.text ?? "")
417
+ const words = String(turn.text ?? "")
418
+ const agent = turn.role === "agent"
419
+ if (text && state.typing.has(key)) {
420
+ if (state.typing.get(key).words !== words) { stopTyping(key); showWords(text, words, agent) }
421
+ } else if (text && shownWords(text, agent) !== words) {
422
+ if (typeIn && state.loaded && agent && words && !reducedMotion()) startTyping(key, text, words)
423
+ else showWords(text, words, agent)
424
+ }
241
425
  const failed = node.querySelector("[data-agent-chat-failed]")
242
426
  if (failed) failed.hidden = turn.status !== "failed"
243
427
  const held = node.querySelector("[data-agent-chat-held]")
@@ -252,6 +436,48 @@ export function mountAgentChat(root, {
252
436
  }
253
437
  }
254
438
 
439
+ // What a turn's text element shows: an agent's words as Markdown, a visitor's
440
+ // as text. `shown` remembers the words behind each element, since an agent's
441
+ // rendered text no longer reads back as its Markdown.
442
+ const shown = new WeakMap()
443
+ function showWords(el, words, agent) {
444
+ shown.set(el, words)
445
+ if (agent) renderMarkdown(el, words, doc)
446
+ else el.textContent = words
447
+ }
448
+ // An agent turn the page rendered itself (studio/agent_chat/_turn prints raw
449
+ // words) has no entry, so it is rendered once as Markdown.
450
+ function shownWords(el, agent) {
451
+ if (shown.has(el)) return shown.get(el)
452
+ return agent ? null : el.textContent
453
+ }
454
+
455
+ // TYPE-IN: one answer's words, filled in REVEAL_FRAME_MS steps.
456
+ function startTyping(key, el, words) {
457
+ const started = now()
458
+ const entry = { words, timer: null }
459
+ state.typing.set(key, entry)
460
+ showWords(el, "", true)
461
+ if (log && log.setAttribute) log.setAttribute("aria-busy", "true")
462
+ const step = () => {
463
+ if (!state.alive || state.typing.get(key) !== entry) return
464
+ const { shown: count } = revealPlan(words.length, now() - started)
465
+ showWords(el, words.slice(0, count), true)
466
+ if (state.following) toEnd()
467
+ if (count >= words.length) { stopTyping(key); return }
468
+ entry.timer = setTimer(step, REVEAL_FRAME_MS)
469
+ }
470
+ step()
471
+ }
472
+
473
+ function stopTyping(key) {
474
+ const entry = state.typing.get(key)
475
+ if (!entry) return
476
+ clearTimer(entry.timer)
477
+ state.typing.delete(key)
478
+ if (!state.typing.size && log && log.removeAttribute) log.removeAttribute("aria-busy")
479
+ }
480
+
255
481
  function schedule() {
256
482
  if (!state.alive) return
257
483
  clearTimer(state.timer)
@@ -276,6 +502,10 @@ export function mountAgentChat(root, {
276
502
  state.pending = data.pending === true
277
503
  setOffline(false)
278
504
  render()
505
+ if (!state.loaded) {
506
+ state.loaded = true
507
+ sendOpener()
508
+ }
279
509
  } catch (_error) {
280
510
  setOffline(true)
281
511
  } finally {
@@ -315,10 +545,64 @@ export function mountAgentChat(root, {
315
545
  refresh(event.detail || {})
316
546
  }
317
547
 
548
+ // OPENER: the section's opener, once per key per tab. It goes once the thread
549
+ // has loaded, or at once, AHEAD of the visitor's words, when they send before the
550
+ // thread loads (submit). The tab remembers it as its POST starts (so a chat
551
+ // mounted again mid-flight cannot send it twice) and forgets it if that POST
552
+ // fails; nothing marks it without posting it. While another send is in flight it
553
+ // stays owed, unmarked, and the next send carries it first.
554
+ function sendOpener() {
555
+ if (!opener || state.openerPhase !== "owed") return state.openerPost || undefined
556
+ if (openerKey && storage) {
557
+ try {
558
+ if (storage.getItem(openerKey)) { state.openerPhase = "done"; return undefined }
559
+ } catch (_error) { /* a refused store sends it anyway */ }
560
+ }
561
+ // The thread already ends with these words from the visitor (another tab
562
+ // sent them): showing it is enough.
563
+ const last = state.turns.filter((turn) => turn.role !== "agent").at(-1)
564
+ if (last && String(last.text ?? "").trim() === opener) { state.openerPhase = "done"; return undefined }
565
+ if (state.sending) return undefined
566
+ state.openerPhase = "posting"
567
+ if (openerKey && storage) {
568
+ try { storage.setItem(openerKey, "1") } catch (_error) { /* a refused store sends it anyway */ }
569
+ }
570
+ state.openerPost = post(opener, { typed: false }).finally(() => {
571
+ state.openerPhase = "done"
572
+ state.openerPost = null
573
+ })
574
+ return state.openerPost
575
+ }
576
+
577
+ function forgetOpener() {
578
+ if (!openerKey || !storage) return
579
+ try { storage.removeItem(openerKey) } catch (_error) { /* nothing to forget */ }
580
+ }
581
+
582
+ // A refused or failed opener goes into the box, after anything already typed,
583
+ // so the visitor can send it themselves.
584
+ function keepWords(text) {
585
+ if (!input) return
586
+ const typed = (input.value || "").trim()
587
+ input.value = typed ? `${typed} ${text}` : text
588
+ }
589
+
318
590
  async function submit(event) {
319
591
  if (event) event.preventDefault()
320
592
  const text = (input.value || "").trim()
321
- if (!text || state.sending) return
593
+ if (!text) return
594
+ // The opener goes first, even before the thread has loaded, and a send
595
+ // waits for an opener already on its way: the visitor's words are never
596
+ // dropped for it, and they always follow it.
597
+ if (state.openerPhase === "owed") await sendOpener()
598
+ if (state.openerPost) await state.openerPost
599
+ if (state.sending) return
600
+ await post(text, { typed: true })
601
+ }
602
+
603
+ // One message to the app: the composer's words (typed) or the opener.
604
+ async function post(text, { typed }) {
605
+ if (state.sending) return
322
606
  state.sending = true
323
607
  if (send) send.disabled = true
324
608
  showError("")
@@ -333,7 +617,7 @@ export function mountAgentChat(root, {
333
617
  let data = {}
334
618
  try { data = await response.json() } catch (_e) { data = {} }
335
619
  if (response.status === 201 && data.turn) {
336
- input.value = ""
620
+ if (typed) input.value = ""
337
621
  state.turns = mergeTurns(state.turns, [data.turn])
338
622
  state.pending = data.turn.status !== "held"
339
623
  state.following = true
@@ -347,10 +631,12 @@ export function mountAgentChat(root, {
347
631
  clearTimer(state.timer)
348
632
  state.timer = setTimer(poll, POLL_MS)
349
633
  } else {
634
+ if (!typed) { forgetOpener(); keepWords(text) }
350
635
  showError(data.reason === INVALID_TOKEN ? EXPIRED_MESSAGE : data.error || `Couldn't send that to ${name}. Try again.`)
351
636
  if (status) status.textContent = ""
352
637
  }
353
638
  } catch (_error) {
639
+ if (!typed) { forgetOpener(); keepWords(text) }
354
640
  showError(`Can't reach ${name} right now. Your message is still here; try again in a moment.`)
355
641
  if (status) status.textContent = ""
356
642
  } finally {
@@ -391,9 +677,11 @@ export function mountAgentChat(root, {
391
677
  poll,
392
678
  refresh,
393
679
  submit,
680
+ sendOpener,
394
681
  destroy() {
395
682
  state.alive = false
396
683
  clearTimer(state.timer)
684
+ for (const key of Array.from(state.typing.keys())) stopTyping(key)
397
685
  log.removeEventListener("scroll", onScroll)
398
686
  form && form.removeEventListener("submit", submit)
399
687
  input && input.removeEventListener("keydown", onKeydown)
@@ -0,0 +1,99 @@
1
+ // studio/carousel: the arithmetic behind studio/_carousel. The track is a native
2
+ // scroll-snap row, so a swipe, a trackpad and a focused link all move it with no
3
+ // script; studio/controllers/carousel_controller only reads where the row is and
4
+ // moves it for the arrows, the dots and the keyboard.
5
+ //
6
+ // A STOP is a place the row can rest: the start of a slide, until the row can
7
+ // scroll no further, and then its end. Six slides three to a view rest at four
8
+ // stops, so the dots past the last stop are hidden.
9
+ //
10
+ // It imports nothing; test/javascript/carousel.test.mjs loads it as a data: module.
11
+
12
+ // Within this many pixels of an end counts as at it: a zoomed page scrolls in
13
+ // fractions.
14
+ export const TOLERANCE = 2
15
+
16
+ // Which ends of the row hide a slide.
17
+ export function hiddenEnds({ scrollLeft, clientWidth, scrollWidth }) {
18
+ const max = Math.max(0, scrollWidth - clientWidth)
19
+ return { start: scrollLeft > TOLERANCE, end: scrollLeft < max - TOLERANCE }
20
+ }
21
+
22
+ // The scroll position of each stop, from each slide's offset and the row's
23
+ // furthest scroll. The slides past the last stop share it.
24
+ export function stops(offsets, max) {
25
+ const reachable = offsets.filter((offset) => offset < max - TOLERANCE)
26
+ if (reachable.length === offsets.length) return reachable.length ? reachable : [0]
27
+ return [...reachable, max]
28
+ }
29
+
30
+ // The stop the row rests at, or is nearest to while it moves.
31
+ export function currentStop(scrollLeft, positions) {
32
+ if (positions.length === 0) return 0
33
+ const last = positions.length - 1
34
+ if (scrollLeft >= positions[last] - TOLERANCE) return last
35
+
36
+ let nearest = 0
37
+ positions.forEach((position, index) => {
38
+ if (Math.abs(position - scrollLeft) < Math.abs(positions[nearest] - scrollLeft)) nearest = index
39
+ })
40
+ return nearest
41
+ }
42
+
43
+ // How many whole slides a view shows: an arrow moves the row by that many, so
44
+ // the slide that peeked in at the edge is the first one after the press.
45
+ export function perPage(clientWidth, slideWidth, gap) {
46
+ if (!(slideWidth > 0)) return 1
47
+ return Math.max(1, Math.floor((clientWidth + gap + TOLERANCE) / (slideWidth + gap)))
48
+ }
49
+
50
+ // The stop an arrow or a key takes the row to, held to the first and last stop.
51
+ export function targetStop(action, current, page, count) {
52
+ const last = Math.max(0, count - 1)
53
+ const to = {
54
+ prev: current - page,
55
+ next: current + page,
56
+ first: 0,
57
+ last
58
+ }[action]
59
+ if (to === undefined) return current
60
+ return Math.min(last, Math.max(0, to))
61
+ }
62
+
63
+ // Keys inside these belong to the element, not the row: a form field, and a widget
64
+ // that moves its own selection with the arrow keys.
65
+ export const KEY_OWNERS = [
66
+ "input", "textarea", "select", "[contenteditable]:not([contenteditable='false'])",
67
+ "[role='slider']", "[role='spinbutton']", "[role='listbox']", "[role='combobox']",
68
+ "[role='tablist']", "[role='radiogroup']", "[role='menu']", "[role='menubar']",
69
+ "[role='grid']", "[role='tree']", "[role='treegrid']", "video", "audio"
70
+ ].join(", ")
71
+
72
+ // Whether a key pressed on `target` is the carousel `region`'s to handle: not
73
+ // when the target sits in a key owner, or in a carousel nested inside this one,
74
+ // which handles its own keys.
75
+ export function ownsKey(target, region, carousel = ".carousel") {
76
+ if (!target?.closest) return false
77
+ if (target.closest(KEY_OWNERS)) return false
78
+ return target.closest(carousel) === region
79
+ }
80
+
81
+ // The action a key asks for, or null for a key the carousel leaves alone.
82
+ export function keyAction(key) {
83
+ return { ArrowLeft: "prev", ArrowRight: "next", Home: "first", End: "last" }[key] || null
84
+ }
85
+
86
+ // The words the status line reads after a move: "Slides 4 to 6 of 9".
87
+ export function positionText(first, shown, total) {
88
+ const from = first + 1
89
+ const to = Math.min(total, first + shown)
90
+ return from === to ? `Slide ${from} of ${total}` : `Slides ${from} to ${to} of ${total}`
91
+ }
92
+
93
+ // The spotlight's stops: the scroll position that centres each slide, from each
94
+ // slide's offset and width in the track (its padding included), held to the row.
95
+ // Every slide is a stop, so every dot shows and an arrow moves one slide.
96
+ export function centerStops(slides, clientWidth, max) {
97
+ if (slides.length === 0) return [0]
98
+ return slides.map(({ left, width }) => Math.min(max, Math.max(0, left + width / 2 - clientWidth / 2)))
99
+ }
@@ -0,0 +1,138 @@
1
+ // carousel: the arrows, dots and keys of studio/_carousel. The track is a native
2
+ // scroll-snap row, so a swipe, a trackpad and a focused link move it with no
3
+ // script; this controller reads where the row rests and moves it on request.
4
+ //
5
+ // <section class="carousel" data-studio-controller="carousel" ...>
6
+ // <div class="carousel-track" data-carousel-target="track">slides</div>
7
+ // <div class="carousel-controls">dots, prev, next</div>
8
+ // </section>
9
+ //
10
+ // It marks the ends that hide a slide (the edge fade), sets aria-disabled on an
11
+ // arrow at its end, hides the dots past the last stop, and says where the row
12
+ // went in a polite status line, but only after a press: a swipe announces nothing.
13
+ //
14
+ // A lazy controller (studio/stimulus): only a page with a carousel fetches it.
15
+ // Until it connects the controls stay hidden and the row keeps a thin scrollbar,
16
+ // so a carousel whose controller failed is still a row that scrolls.
17
+ //
18
+ // A SPOTLIGHT row (class carousel-spotlight) snaps each slide to the centre: every
19
+ // slide is a stop, an arrow moves one slide, and the slide nearest the centre
20
+ // carries data-current, which the CSS styles where scroll-driven animations are
21
+ // missing and under reduced motion.
22
+ import { centerStops } from "studio/carousel"
23
+ import { Controller } from "@hotwired/stimulus"
24
+ import { currentStop, hiddenEnds, keyAction, ownsKey, perPage, positionText, stops, targetStop } from "studio/carousel"
25
+
26
+ export default class extends Controller {
27
+ static targets = ["track", "slide", "prev", "next", "dot", "status"]
28
+
29
+ connect() {
30
+ this.schedule = this.schedule.bind(this)
31
+ this.trackTarget.addEventListener("scroll", this.schedule, { passive: true })
32
+ if (typeof ResizeObserver === "function") {
33
+ this.observer = new ResizeObserver(this.schedule)
34
+ this.observer.observe(this.trackTarget)
35
+ }
36
+ this.element.setAttribute("data-carousel-ready", "")
37
+ this.sync()
38
+ }
39
+
40
+ disconnect() {
41
+ this.trackTarget.removeEventListener("scroll", this.schedule)
42
+ this.observer?.disconnect()
43
+ if (this.frame) cancelAnimationFrame(this.frame)
44
+ this.frame = null
45
+ }
46
+
47
+ schedule() {
48
+ if (this.frame) return
49
+ this.frame = requestAnimationFrame(() => {
50
+ this.frame = null
51
+ this.sync()
52
+ })
53
+ }
54
+
55
+ measure() {
56
+ const track = this.trackTarget
57
+ const slides = this.slideTargets
58
+ if (this.element.classList.contains("carousel-spotlight")) {
59
+ const max = Math.max(0, track.scrollWidth - track.clientWidth)
60
+ const boxes = slides.map((slide) => ({ left: slide.offsetLeft, width: slide.offsetWidth }))
61
+ return { positions: centerStops(boxes, track.clientWidth, max), page: 1 }
62
+ }
63
+ const origin = slides[0]?.offsetLeft || 0
64
+ const max = Math.max(0, track.scrollWidth - track.clientWidth)
65
+ const gap = parseFloat(getComputedStyle(track).columnGap) || 0
66
+ return {
67
+ positions: stops(slides.map((slide) => slide.offsetLeft - origin), max),
68
+ page: perPage(track.clientWidth, slides[0]?.offsetWidth, gap)
69
+ }
70
+ }
71
+
72
+ sync() {
73
+ const track = this.trackTarget
74
+ const { positions, page } = this.measure()
75
+ const ends = hiddenEnds(track)
76
+
77
+ this.positions = positions
78
+ this.page = page
79
+ this.current = currentStop(track.scrollLeft, positions)
80
+
81
+ this.element.toggleAttribute("data-carousel-static", positions.length < 2)
82
+ track.toggleAttribute("data-fade-start", ends.start)
83
+ track.toggleAttribute("data-fade-end", ends.end)
84
+ if (this.hasPrevTarget) this.prevTarget.setAttribute("aria-disabled", String(!ends.start))
85
+ if (this.hasNextTarget) this.nextTarget.setAttribute("aria-disabled", String(!ends.end))
86
+ if (this.element.classList.contains("carousel-spotlight")) {
87
+ this.slideTargets.forEach((slide, index) => slide.toggleAttribute("data-current", index === this.current))
88
+ }
89
+ this.dotTargets.forEach((dot, index) => {
90
+ dot.hidden = index >= positions.length
91
+ if (index === this.current) dot.setAttribute("aria-current", "true")
92
+ else dot.removeAttribute("aria-current")
93
+ })
94
+ }
95
+
96
+ prev() { this.move("prev") }
97
+
98
+ next() { this.move("next") }
99
+
100
+ goTo(event) {
101
+ this.sync()
102
+ this.scrollToStop(Number(event.currentTarget.dataset.carouselIndex))
103
+ }
104
+
105
+ key(event) {
106
+ // A form field, a widget that owns the arrow keys, or a nested carousel keeps them.
107
+ if (!ownsKey(event.target, this.element)) return
108
+ // Alt+Left is Back, Cmd+Left a line start: a held modifier leaves the key alone.
109
+ if (event.altKey || event.metaKey || event.ctrlKey || event.shiftKey) return
110
+ const action = keyAction(event.key)
111
+ if (!action) return
112
+ event.preventDefault()
113
+ this.move(action)
114
+ }
115
+
116
+ // A press that goes nowhere (an arrow at its end, Home at the start) scrolls
117
+ // nothing and says nothing: the status line speaks only when the row moves.
118
+ move(action) {
119
+ this.sync()
120
+ const to = targetStop(action, this.current, this.page, this.positions.length)
121
+ if (to === this.current) return
122
+ this.scrollToStop(to)
123
+ }
124
+
125
+ scrollToStop(index) {
126
+ const left = this.positions[index]
127
+ if (left === undefined) return
128
+
129
+ const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
130
+ this.trackTarget.scrollTo({ left, behavior: reduce ? "auto" : "smooth" })
131
+
132
+ if (!this.hasStatusTarget) return
133
+ const total = this.slideTargets.length
134
+ const last = index === this.positions.length - 1
135
+ const first = last ? Math.max(0, total - this.page) : index
136
+ this.statusTarget.textContent = positionText(first, this.page, total)
137
+ }
138
+ }
@@ -0,0 +1,17 @@
1
+ // infinite-scroll: the next page of a list loads as its end comes near, on the
2
+ // engine's Stimulus application (studio/stimulus registers it lazily). It sits
3
+ // on studio_infinite_scroll's wrapper. The behaviour is studio/infinite_scroll.
4
+ // If this controller fails to load, "Load more" is still a working link.
5
+ import { Controller } from "@hotwired/stimulus"
6
+ import { watchInfiniteScroll } from "studio/infinite_scroll"
7
+
8
+ export default class extends Controller {
9
+ connect() {
10
+ this.stop = watchInfiniteScroll(this.element, window)
11
+ }
12
+
13
+ disconnect() {
14
+ if (this.stop) this.stop()
15
+ this.stop = null
16
+ }
17
+ }
@@ -0,0 +1,18 @@
1
+ // pull-to-refresh: pull the page down from its top to reload it, on phones with
2
+ // touch only (studio/pull_to_refresh decides). On the engine's Stimulus
3
+ // application (studio/stimulus registers it lazily); it sits on
4
+ // studio_pull_to_refresh's wrapper. If it fails to load, nothing on the page
5
+ // depends on it: the browser's own reload still works.
6
+ import { Controller } from "@hotwired/stimulus"
7
+ import { watchPullToRefresh } from "studio/pull_to_refresh"
8
+
9
+ export default class extends Controller {
10
+ connect() {
11
+ this.stop = watchPullToRefresh(this.element, window, document)
12
+ }
13
+
14
+ disconnect() {
15
+ if (this.stop) this.stop()
16
+ this.stop = null
17
+ }
18
+ }