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
@@ -0,0 +1,20 @@
1
+ // talk-to-agent: the talk-to-agent modal (studio/agent_chat/_talk_modal), on the
2
+ // engine's Stimulus application (studio/stimulus registers it lazily, the first
3
+ // time a page renders the modal). It sits on the partial's wrapper:
4
+ //
5
+ // <div data-studio-controller="talk-to-agent" data-talk-to-agent-soul="alex" ...>
6
+ //
7
+ // The click handling and the panel fill are studio/talk_to_agent.
8
+ import { Controller } from "@hotwired/stimulus"
9
+ import { mountTalkToAgent } from "studio/talk_to_agent"
10
+
11
+ export default class extends Controller {
12
+ connect() {
13
+ this.talk = mountTalkToAgent(this.element)
14
+ }
15
+
16
+ disconnect() {
17
+ if (this.talk) this.talk.destroy()
18
+ this.talk = null
19
+ }
20
+ }
@@ -0,0 +1,142 @@
1
+ // studio/infinite_scroll: the next page loads as its end comes near
2
+ // (docs/LOADER.md, "Infinite scroll"). The markup is studio_infinite_scroll:
3
+ //
4
+ // <div class="studio-infinite-scroll" data-studio-controller="infinite-scroll">
5
+ // ...page 1's items...
6
+ // <turbo-frame id="posts-page-2" data-infinite-scroll-next>
7
+ // <a href="/posts?page=2" data-infinite-scroll-more>Load more</a>
8
+ // </turbo-frame>
9
+ // <p role="status" aria-live="polite" data-infinite-scroll-status></p>
10
+ // </div>
11
+ //
12
+ // THE BUTTON IS THE PRIMITIVE. "Load more" is an ordinary link inside the next
13
+ // page's frame, so a click loads that frame with Turbo, and with no script at all
14
+ // it opens the next page. This module only clicks it early: when the frame comes
15
+ // within NEAR_PX of the viewport (IntersectionObserver, watching the button's
16
+ // row, since the frame itself takes no box), it points the frame at
17
+ // the link's URL. The button stays in view the whole time, for a keyboard, a
18
+ // screen reader or a browser with no observer.
19
+ //
20
+ // announce once a page lands, the status line says how many items came
21
+ // ("12 more loaded"), and that the list is done when no next frame
22
+ // came with them. It is polite: it waits for the reader to pause.
23
+ // focus a page the reader asked for with the keyboard moves focus to
24
+ // its first new item, since the button they pressed is gone.
25
+ //
26
+ // It imports nothing; test/javascript/infinite_scroll.test.mjs loads it as a
27
+ // data: module.
28
+
29
+ export const NEAR_PX = 600
30
+ export const NEXT = "turbo-frame[data-infinite-scroll-next]"
31
+ export const MORE = "[data-infinite-scroll-more]"
32
+ export const STATUS = "[data-infinite-scroll-status]"
33
+ export const DEFAULT_ANNOUNCE = "%{count} more loaded"
34
+
35
+ // The status line for a page of `count` items. `done` adds the end label.
36
+ export function announcement(template, count, done, endLabel) {
37
+ const said = String(template || DEFAULT_ANNOUNCE).replace("%{count}", String(count))
38
+ return done && endLabel ? `${said}. ${endLabel}` : said
39
+ }
40
+
41
+ // The items a landed frame brought: its element children, less the next frame
42
+ // and the end line, which are the list's machinery rather than its content.
43
+ export function newItems(frame) {
44
+ return Array.from(frame.children || []).filter((el) =>
45
+ !(el.matches && (el.matches(NEXT) || el.matches(".studio-infinite-scroll-end") || el.matches(".studio-infinite-scroll-more"))))
46
+ }
47
+
48
+ // The one frame still waiting for its page: not loaded, not loading.
49
+ export function waitingFrame(root) {
50
+ const frames = Array.from(root.querySelectorAll(NEXT))
51
+ return frames.find((frame) => !frame.hasAttribute("src") && !frame.hasAttribute("complete")) || null
52
+ }
53
+
54
+ // What the observer watches for a frame: its button row, else its link.
55
+ export function sentinelOf(frame) {
56
+ return frame.querySelector(".studio-infinite-scroll-more") || frame.querySelector(MORE)
57
+ }
58
+
59
+ // Points the frame at its link's URL, once. Answers whether it started a load.
60
+ export function loadNext(frame) {
61
+ if (!frame || frame.hasAttribute("src")) return false
62
+ const link = frame.querySelector(MORE)
63
+ const url = link && link.getAttribute("href")
64
+ if (!url) return false
65
+ frame.setAttribute("src", url)
66
+ return true
67
+ }
68
+
69
+ function say(status, text, win) {
70
+ if (!status) return
71
+ // Emptied first, so the same words said twice are heard twice.
72
+ status.textContent = ""
73
+ const put = () => { status.textContent = text }
74
+ if (win && typeof win.requestAnimationFrame === "function") win.requestAnimationFrame(put)
75
+ else put()
76
+ }
77
+
78
+ // Wires one list. Answers a stop function for the controller's disconnect.
79
+ export function watchInfiniteScroll(root, win) {
80
+ win = win || window
81
+ let watcher = null
82
+ let watched = null
83
+ let byKeyboard = false
84
+
85
+ // The frame takes no box (display: contents), and an observer sees only
86
+ // boxes, so the watch is on its sentinel: the button's row inside it.
87
+ function arm() {
88
+ const frame = waitingFrame(root)
89
+ const sentinel = frame && sentinelOf(frame)
90
+ if (sentinel === watched) return
91
+ if (watcher && watched) watcher.unobserve(watched)
92
+ watched = sentinel
93
+ if (!sentinel || !watcher) return
94
+ watcher.observe(sentinel)
95
+ }
96
+
97
+ if (typeof win.IntersectionObserver === "function") {
98
+ watcher = new win.IntersectionObserver((entries) => {
99
+ entries.forEach((entry) => {
100
+ if (!entry.isIntersecting) return
101
+ watcher.unobserve(entry.target)
102
+ const frame = entry.target.closest(NEXT)
103
+ // A Tab onto "Load more" scrolls it into view, so the observer loads the
104
+ // page before any click: focus inside the frame is that keyboard user.
105
+ const active = win.document && win.document.activeElement
106
+ byKeyboard = !!(frame && active && frame.contains(active))
107
+ loadNext(frame)
108
+ })
109
+ }, { rootMargin: `0px 0px ${NEAR_PX}px 0px` })
110
+ }
111
+
112
+ // A click on the button with no pointer behind it is a keyboard press.
113
+ function onClick(event) {
114
+ const link = event.target && event.target.closest && event.target.closest(MORE)
115
+ if (link && root.contains(link)) byKeyboard = event.detail === 0
116
+ }
117
+
118
+ function onLoad(event) {
119
+ const frame = event.target
120
+ if (!frame || !frame.matches || !frame.matches(NEXT) || !root.contains(frame)) return
121
+ const items = newItems(frame)
122
+ const done = !frame.querySelector(NEXT)
123
+ say(root.querySelector(STATUS), announcement(root.getAttribute("data-infinite-scroll-announce"), items.length, done,
124
+ root.getAttribute("data-infinite-scroll-end")), win)
125
+ if (byKeyboard && items[0]) {
126
+ if (!items[0].hasAttribute("tabindex")) items[0].setAttribute("tabindex", "-1")
127
+ items[0].focus({ preventScroll: false })
128
+ }
129
+ byKeyboard = false
130
+ arm()
131
+ }
132
+
133
+ root.addEventListener("click", onClick, true)
134
+ root.addEventListener("turbo:frame-load", onLoad)
135
+ arm()
136
+
137
+ return function stop() {
138
+ root.removeEventListener("click", onClick, true)
139
+ root.removeEventListener("turbo:frame-load", onLoad)
140
+ if (watcher) watcher.disconnect()
141
+ }
142
+ }
@@ -270,8 +270,13 @@ export function setPercent(el, value) {
270
270
  if (!el) return
271
271
  var loader = el.classList && el.classList.contains('studio-loader') ? el : el.querySelector('.studio-loader')
272
272
  if (!loader) return
273
+ var percent = clampPercent(value)
273
274
  loader.setAttribute('data-loader-percent', '')
274
- loader.style.setProperty('--studio-loader-percent', String(clampPercent(value)))
275
+ loader.style.setProperty('--studio-loader-percent', String(percent))
276
+ // A bar is a progressbar: a percent makes it determinate and is its value.
277
+ if (loader.getAttribute && loader.getAttribute('role') === 'progressbar') loader.setAttribute('aria-valuenow', String(percent))
278
+ var sweep = loader.querySelector && loader.querySelector('.progress-meter--indeterminate')
279
+ if (sweep) sweep.classList.remove('progress-meter--indeterminate')
275
280
  }
276
281
 
277
282
  // A submitter that carries a pending loader.
@@ -20,6 +20,11 @@ export const STYLES = Object.freeze({
20
20
  slide: Object.freeze({ left: "slide-left", right: "slide-right", top: "slide-top", bottom: "slide-bottom" }),
21
21
  unfold: Object.freeze({ left: "unfold-right", right: "unfold-left", top: "unfold-bottom", bottom: "unfold-top" }),
22
22
  })
23
+ // A Back plays the app's move the other way: the reverse direction, in the same style. So a
24
+ // slide-left goes back as a slide-right, and an unfold-right as an unfold-left.
25
+ export const REVERSE = Object.freeze({ left: "right", right: "left", top: "bottom", bottom: "top" })
26
+ // A link that leads back up the trail: an ancestor in the crumbs, or the phone's parent link.
27
+ export const BACK_LINK = "a[data-page-transition-back]"
23
28
  // Mirrors the default in lib/studio.rb, for a page with no style meta.
24
29
  export const DEFAULT_STYLE = "slide"
25
30
 
@@ -32,17 +37,48 @@ export const FULL_LOAD_STYLE_KEY = "studio-page-transition-style"
32
37
  const MAX_READY_IMAGES = 12
33
38
  const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"
34
39
 
35
- export function styleFor(direction, style) {
40
+ export function styleFor(direction, style, back = false) {
36
41
  const moves = STYLES[Object.hasOwn(STYLES, style) ? style : DEFAULT_STYLE]
37
- return Object.hasOwn(moves, direction) ? moves[direction] : null
42
+ if (!Object.hasOwn(moves, direction)) return null
43
+ return moves[back ? REVERSE[direction] : direction]
44
+ }
45
+
46
+ // Whether a visit goes back. Turbo marks <html> with data-turbo-visit-direction for each visit,
47
+ // before turbo:visit fires: "back" for a restore to an earlier history entry, and "forward"
48
+ // for a restore to a later one. So the browser's Forward keeps the app's direction. An advance
49
+ // goes back only when the reader clicked a back link to that same address.
50
+ export function visitIsBack({ action, turboDirection, url, backHref }) {
51
+ if (action === "restore") return turboDirection === "back"
52
+ return sameAddress(url, backHref)
53
+ }
54
+
55
+ // Whether a full load goes back: a traverse to an earlier history entry, or a back link.
56
+ export function fullLoadIsBack(activation, backHref) {
57
+ if (!activation || !activation.entry) return false
58
+ if (activation.navigationType === "traverse") {
59
+ const from = activation.from
60
+ return !!from && typeof from.index === "number" && from.index >= 0 && activation.entry.index < from.index
61
+ }
62
+ return sameAddress(activation.entry.url, backHref)
63
+ }
64
+
65
+ export function sameAddress(a, b) {
66
+ if (!a || !b) return false
67
+ try {
68
+ const one = new URL(a, "https://x.invalid")
69
+ const two = new URL(b, "https://x.invalid")
70
+ return one.origin === two.origin && one.pathname === two.pathname && one.search === two.search
71
+ } catch {
72
+ return false
73
+ }
38
74
  }
39
75
 
40
76
  // The style for one visit, STILL, or null to leave the swap alone (the engine's fade plays,
41
77
  // and a morph between two pages stands). An advance visit to the same path can bring a new
42
78
  // page, as a launch panel does when it gives way to the chat at its own address, so a name
43
79
  // the two share still morphs. A refresh replaces, and stays silent.
44
- export function planVisit({ direction, style, from, to, transitions, sharedName, advance }) {
45
- const move = styleFor(direction, style)
80
+ export function planVisit({ direction, style, from, to, transitions, sharedName, advance, back }) {
81
+ const move = styleFor(direction, style, back)
46
82
  if (!transitions || !from || !to || !move) return null
47
83
  if (from === to) return advance && sharedName ? null : STILL
48
84
  return sharedName ? null : move
@@ -51,11 +87,11 @@ export function planVisit({ direction, style, from, to, transitions, sharedName,
51
87
  // The style a full load should play, or null for a plain load: planVisit's own rule, so the
52
88
  // two paths cannot differ. No morph, since the old page cannot read the new page's names
53
89
  // before its picture, and a browser with no activation entry has nothing to compare.
54
- export function planFullLoad({ direction, style, from, activation }) {
90
+ export function planFullLoad({ direction, style, from, activation, back = false }) {
55
91
  const url = activation && activation.entry && activation.entry.url
56
92
  if (!url) return null
57
93
  const to = new URL(url).pathname
58
- const move = planVisit({ direction, style, from, to, transitions: true, sharedName: false })
94
+ const move = planVisit({ direction, style, from, to, transitions: true, sharedName: false, back })
59
95
  return move === STILL ? null : move
60
96
  }
61
97
 
@@ -150,6 +186,11 @@ export function installPageTransitions(doc) {
150
186
  let target = null
151
187
  let advance = false
152
188
  let rendered = false
189
+ // The address of the back link the reader clicked last, if any. Matched by address, so a
190
+ // click that never became a visit (a modifier click, a cancelled visit) cannot turn the
191
+ // next visit around.
192
+ let backHref = null
193
+ let back = false
153
194
 
154
195
  const nameTheMorph = (shared) => {
155
196
  if (shared) root.dataset.pageTransitionMorph = ""
@@ -161,6 +202,12 @@ export function installPageTransitions(doc) {
161
202
  markHeld(doc)
162
203
  known.set(from, morphNames(doc))
163
204
 
205
+ // In capture, ahead of Turbo's own click listener, so the mark is down before the visit.
206
+ doc.addEventListener("click", (event) => {
207
+ const link = event.target && event.target.closest ? event.target.closest("a[href]") : null
208
+ backHref = link && link.matches(BACK_LINK) ? link.href : null
209
+ }, { capture: true })
210
+
164
211
  doc.addEventListener("turbo:visit", (event) => {
165
212
  root.dataset.pageTransitionVisit = ""
166
213
  // Turbo opens its view transition before it reads turbo-visit-control. A response it will
@@ -171,7 +218,12 @@ export function installPageTransitions(doc) {
171
218
  // A GET that redirects: Turbo renders the response, then replaces to the final address
172
219
  // with that same response, all before turbo:load. The page has landed, so nothing moves.
173
220
  const action = event.detail.action
174
- if (rendered && action === "replace") from = target
221
+ const redirected = rendered && action === "replace"
222
+ if (redirected) from = target
223
+ // No guard keeps the first half's way: Turbo's replace (willRender: false) still fires
224
+ // turbo:before-render, but `from` is now `target`, so planVisit plays STILL either way.
225
+ back = visitIsBack({ action, turboDirection: root.dataset.turboVisitDirection, url: event.detail.url, backHref })
226
+ backHref = null
175
227
  rendered = false
176
228
  advance = action === "advance"
177
229
  predict(known.get(target))
@@ -207,6 +259,7 @@ export function installPageTransitions(doc) {
207
259
  transitions: !!doc.startViewTransition && !win.matchMedia(REDUCED_MOTION).matches,
208
260
  sharedName,
209
261
  advance,
262
+ back,
210
263
  })
211
264
  nameTheMorph(sharedName && style !== STILL)
212
265
  if (style) root.dataset.pageTransition = style
@@ -226,6 +279,7 @@ export function installPageTransitions(doc) {
226
279
  target = null
227
280
  advance = false
228
281
  rendered = false
282
+ back = false
229
283
  known.set(from, morphNames(doc))
230
284
  if (!visited) return
231
285
  delete root.dataset.pageTransition
@@ -244,7 +298,10 @@ export function installPageTransitions(doc) {
244
298
  // The last moment on the old document. The browser takes its picture after this.
245
299
  win.addEventListener("pageswap", (event) => {
246
300
  const transition = event.viewTransition
247
- const style = transition && planFullLoad({ direction: readDirection(doc), style: readStyle(doc), from, activation: event.activation })
301
+ const activation = event.activation
302
+ const style = transition && planFullLoad({
303
+ direction: readDirection(doc), style: readStyle(doc), from, activation, back: fullLoadIsBack(activation, backHref),
304
+ })
248
305
  const left = leaveStyle(win, style)
249
306
  if (!transition) return
250
307
  if (!left) {
@@ -0,0 +1,147 @@
1
+ // studio/pull_to_refresh: pull the page down from its top to reload it
2
+ // (docs/LOADER.md, "Pull to refresh"). Phone widths and touch only: the
3
+ // controller arms itself where PHONE_QUERY matches and does nothing anywhere
4
+ // else, so a desktop, a mouse or a keyboard never meets it. The markup is
5
+ // studio_pull_to_refresh:
6
+ //
7
+ // <div class="studio-pull-refresh" data-studio-controller="pull-to-refresh"
8
+ // data-pull-to-refresh-frame="feed"> (optional: reload one frame)
9
+ // <span class="studio-pull-refresh-indicator" hidden>...</span>
10
+ // <p class="sr-only" role="status" data-pull-to-refresh-status></p>
11
+ // ...the page...
12
+ // </div>
13
+ //
14
+ // the pull a touch that starts with the page at its top and moves down
15
+ // drags the indicator; --studio-pull is the distance over
16
+ // THRESHOLD_PX, eased so it resists the finger.
17
+ // the release past the threshold it refreshes: the named frame's reload(),
18
+ // else a Turbo visit to this URL with action "replace" (which the
19
+ // page-load overlay never shows for). Short of it, it settles back.
20
+ // the end turbo:frame-load or turbo:load clears the indicator; so does
21
+ // MAX_SPIN_MS, so a refresh that fails never leaves it spinning.
22
+ //
23
+ // The browser's own pull (Chrome on Android reloads on overscroll) is turned
24
+ // off with overscroll-behavior-y while the controller is armed, so one pull
25
+ // never refreshes twice.
26
+ //
27
+ // It imports nothing; test/javascript/pull_to_refresh.test.mjs loads it as a
28
+ // data: module.
29
+
30
+ export const PHONE_QUERY = "(pointer: coarse) and (max-width: 640px)"
31
+ export const THRESHOLD_PX = 72
32
+ export const MAX_SPIN_MS = 10000
33
+
34
+ // How far a finger `dy` pixels down has pulled, 0..1.2 of the threshold. The
35
+ // first half follows the finger; past it the pull resists.
36
+ export function pullProgress(dy) {
37
+ if (!(dy > 0)) return 0
38
+ const raw = dy / THRESHOLD_PX
39
+ const eased = raw <= 0.5 ? raw : 0.5 + (raw - 0.5) * 0.55
40
+ return Math.min(1.2, Math.round(eased * 1000) / 1000)
41
+ }
42
+
43
+ // A release refreshes once the eased pull reaches the threshold.
44
+ export function shouldRefresh(progress) {
45
+ return progress >= 1
46
+ }
47
+
48
+ export function phoneTouch(win) {
49
+ return !!(win && typeof win.matchMedia === "function" && win.matchMedia(PHONE_QUERY).matches)
50
+ }
51
+
52
+ // Whether a touch on `target` began inside a scroller below `root` that is not
53
+ // at its top: that drag scrolls the scroller back up, so it is not a pull.
54
+ export function insideScroller(target, root) {
55
+ for (let el = target; el && el !== root; el = el.parentElement) if (el.scrollTop > 0) return true
56
+ return false
57
+ }
58
+
59
+ // Wires one container. Answers a stop function, or null where it does not arm.
60
+ export function watchPullToRefresh(root, win, doc) {
61
+ win = win || window
62
+ doc = doc || document
63
+ if (!phoneTouch(win)) return null
64
+
65
+ const indicator = root.querySelector(".studio-pull-refresh-indicator")
66
+ const status = root.querySelector("[data-pull-to-refresh-status]")
67
+ const label = root.getAttribute("data-pull-to-refresh-label") || "Refreshing"
68
+ const html = doc.documentElement
69
+ const previousOverscroll = html.style.overscrollBehaviorY
70
+ html.style.overscrollBehaviorY = "contain"
71
+
72
+ let startY = null
73
+ let progress = 0
74
+ let spinTimer = null
75
+
76
+ function setPull(value) {
77
+ progress = value
78
+ if (indicator) {
79
+ indicator.hidden = value === 0 && root.getAttribute("data-pull-state") !== "refreshing"
80
+ indicator.style.setProperty("--studio-pull", String(Math.min(1, value)))
81
+ }
82
+ }
83
+
84
+ function finish(event) {
85
+ const frameId = root.getAttribute("data-pull-to-refresh-frame")
86
+ if (event && event.type === "turbo:frame-load" && (!frameId || event.target.id !== frameId)) return
87
+ if (root.getAttribute("data-pull-state") !== "refreshing") return
88
+ clearTimeout(spinTimer)
89
+ root.setAttribute("data-pull-state", "settling")
90
+ if (status) status.textContent = ""
91
+ setPull(0)
92
+ }
93
+
94
+ function refresh() {
95
+ root.setAttribute("data-pull-state", "refreshing")
96
+ if (indicator) indicator.hidden = false
97
+ if (status) status.textContent = label
98
+ spinTimer = setTimeout(finish, MAX_SPIN_MS)
99
+ const frameId = root.getAttribute("data-pull-to-refresh-frame")
100
+ const frame = frameId && doc.getElementById(frameId)
101
+ if (frame && typeof frame.reload === "function") frame.reload()
102
+ else if (win.Turbo && typeof win.Turbo.visit === "function") win.Turbo.visit(win.location.href, { action: "replace" })
103
+ else win.location.reload()
104
+ }
105
+
106
+ function onStart(event) {
107
+ if (root.getAttribute("data-pull-state") === "refreshing") return
108
+ if (event.touches.length !== 1 || (win.scrollY || 0) > 0 || insideScroller(event.target, root)) { startY = null; return }
109
+ startY = event.touches[0].clientY
110
+ root.setAttribute("data-pull-state", "pulling")
111
+ }
112
+
113
+ function onMove(event) {
114
+ if (startY === null) return
115
+ const dy = event.touches[0].clientY - startY
116
+ if (dy <= 0 || (win.scrollY || 0) > 0) { setPull(0); return }
117
+ // Holding the page still while the indicator follows the finger.
118
+ if (event.cancelable) event.preventDefault()
119
+ setPull(pullProgress(dy))
120
+ }
121
+
122
+ function onEnd() {
123
+ if (startY === null) return
124
+ startY = null
125
+ if (shouldRefresh(progress)) { refresh(); return }
126
+ root.setAttribute("data-pull-state", "settling")
127
+ setPull(0)
128
+ }
129
+
130
+ root.addEventListener("touchstart", onStart, { passive: true })
131
+ root.addEventListener("touchmove", onMove, { passive: false })
132
+ root.addEventListener("touchend", onEnd)
133
+ root.addEventListener("touchcancel", onEnd)
134
+ doc.addEventListener("turbo:frame-load", finish)
135
+ doc.addEventListener("turbo:load", finish)
136
+
137
+ return function stop() {
138
+ clearTimeout(spinTimer)
139
+ html.style.overscrollBehaviorY = previousOverscroll
140
+ root.removeEventListener("touchstart", onStart)
141
+ root.removeEventListener("touchmove", onMove)
142
+ root.removeEventListener("touchend", onEnd)
143
+ root.removeEventListener("touchcancel", onEnd)
144
+ doc.removeEventListener("turbo:frame-load", finish)
145
+ doc.removeEventListener("turbo:load", finish)
146
+ }
147
+ }
@@ -65,9 +65,16 @@ export const schema = {
65
65
  // says recording did not load (data-studio-controller-failed="voice-recorder").
66
66
  // So is the chat's mic: a mic whose controller failed says voice did not load,
67
67
  // and the box still types (data-studio-controller-failed="agent-chat-voice").
68
+ // So is the talk-to-agent modal: its links are real links, and with its
69
+ // controller failed each one opens its href (data-studio-controller-failed="talk-to-agent").
68
70
  // So is copy-text: a colour whose controller failed still shows its hex to select.
69
71
  // So is the page header: its tabs are links, and a row whose controller failed
70
72
  // keeps its bar at all times (data-studio-controller-failed="page-header").
73
+ // So is the carousel: its track is a native scroll row, and one whose controller
74
+ // failed keeps a scrollbar and hides the arrows and dots it cannot drive.
75
+ // So is infinite scroll: a list whose controller failed keeps its "Load more"
76
+ // link, which loads the next page by itself. So is pull to refresh: nothing
77
+ // depends on it, and the browser's own reload still works.
71
78
  export const LAZY = {
72
79
  "board": () => import("studio/controllers/board_controller"),
73
80
  "geo-settings": () => import("studio/controllers/geo_settings_controller"),
@@ -79,11 +86,15 @@ export const LAZY = {
79
86
  "knowledge-transcript": () => import("studio/controllers/knowledge_transcript_controller"),
80
87
  "agent-chat": () => import("studio/controllers/agent_chat_controller"),
81
88
  "agent-chat-voice": () => import("studio/controllers/agent_chat_voice_controller"),
89
+ "talk-to-agent": () => import("studio/controllers/talk_to_agent_controller"),
82
90
  "voice-profile": () => import("studio/controllers/voice_profile_controller"),
83
91
  "microphone-chip": () => import("studio/controllers/microphone_chip_controller"),
84
92
  "voice-recorder": () => import("studio/controllers/voice_recorder_controller"),
85
93
  "copy-text": () => import("studio/controllers/copy_text_controller"),
86
- "page-header": () => import("studio/controllers/page_header_controller")
94
+ "page-header": () => import("studio/controllers/page_header_controller"),
95
+ "carousel": () => import("studio/controllers/carousel_controller"),
96
+ "infinite-scroll": () => import("studio/controllers/infinite_scroll_controller"),
97
+ "pull-to-refresh": () => import("studio/controllers/pull_to_refresh_controller")
87
98
  }
88
99
 
89
100
  export const application = Application.start(document.documentElement, schema)
@@ -0,0 +1,116 @@
1
+ // studio/talk_to_agent: the talk-to-agent modal (studio/agent_chat/_talk_modal;
2
+ // docs/AGENT_CHAT.md, "Talk to an agent in a modal"). It wraps the shared agent
3
+ // chat, studio/agent_chat, in the page-scoped modal host; it is not a second
4
+ // chat.
5
+ //
6
+ // LINKS any element carrying data-studio-talk-to-agent opens the modal:
7
+ //
8
+ // <a href="/chat" data-studio-talk-to-agent data-talk-soul="alex"
9
+ // data-talk-topic="Pro" data-talk-opener="Tell me about Pro.">
10
+ //
11
+ // The click is taken only when the soul is this modal's, no modifier
12
+ // key is held and the host's Alpine store is there. Otherwise the
13
+ // link does what its href says, so a page whose modal failed to load
14
+ // still reaches the chat.
15
+ // OPEN the store opens TALK_MODAL_ID; the panel the host clones in asks
16
+ // for its chat (PANEL_EVENT), and this module fills it with a fresh
17
+ // copy of the chat from the partial's <template>, marked with the
18
+ // link's opener (data-agent-chat-opener, keyed by soul and topic)
19
+ // and the type-in. studio/agent_chat then loads the thread and sends
20
+ // the opener, so the agent is answering while the card animates in.
21
+ // CLOSE the host drops the panel; the chat's controller disconnects and
22
+ // stops polling. The thread stays on the hub: the next open shows it.
23
+ //
24
+ // It imports nothing, so test/javascript/talk_to_agent.test.mjs loads it as a
25
+ // data: module; studio/controllers/talk_to_agent_controller mounts it.
26
+
27
+ export const TALK_SELECTOR = "[data-studio-talk-to-agent]"
28
+ export const TALK_MODAL_ID = "talk-to-agent"
29
+ export const PANEL_EVENT = "studio:talk-to-agent:panel"
30
+ export const TOPIC_MAX = 80
31
+ export const OPENER_MAX = 500
32
+
33
+ function clean(value, max) {
34
+ return String(value ?? "").replace(/\s+/g, " ").trim().slice(0, max)
35
+ }
36
+
37
+ // What a link asks for: {soul, topic, opener}. The opener defaults to a plain
38
+ // line naming the topic; with no topic and no opener there is nothing to send.
39
+ export function talkFrom(link) {
40
+ const data = (link && link.dataset) || {}
41
+ const topic = clean(data.talkTopic, TOPIC_MAX)
42
+ const opener = clean(data.talkOpener, OPENER_MAX) || (topic ? `I'd like to talk about ${topic}.` : "")
43
+ return { soul: clean(data.talkSoul, 64), topic, opener }
44
+ }
45
+
46
+ // The key an opener is sent once under (per tab): the soul and the topic.
47
+ export function openerKey(soul, topic) {
48
+ return `${soul}:${topic || "general"}`
49
+ }
50
+
51
+ // Whether this click is the modal's to take: a plain primary click.
52
+ export function plainClick(event) {
53
+ if (!event) return false
54
+ if (event.defaultPrevented) return false
55
+ if (event.button != null && event.button !== 0) return false
56
+ return !(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
57
+ }
58
+
59
+ // The dialog's accessible name and the header's words.
60
+ export function dialogTitle(name, topic) {
61
+ return topic ? `Talk to ${name} about ${topic}` : `Talk to ${name}`
62
+ }
63
+
64
+ export function mountTalkToAgent(root, {
65
+ doc = globalThis.document,
66
+ store = (name) => (globalThis.Alpine && typeof globalThis.Alpine.store === "function" ? globalThis.Alpine.store(name) : null)
67
+ } = {}) {
68
+ const soul = root.dataset.talkToAgentSoul
69
+ const name = root.dataset.talkToAgentName || "the agent"
70
+ const storeName = root.dataset.talkToAgentStore
71
+ let current = null
72
+
73
+ function onClick(event) {
74
+ const link = event.target && event.target.closest ? event.target.closest(TALK_SELECTOR) : null
75
+ if (!link || !plainClick(event)) return
76
+ const asked = talkFrom(link)
77
+ if (asked.soul !== soul) return
78
+ const modals = store(storeName)
79
+ if (!modals || typeof modals.open !== "function") return
80
+ event.preventDefault()
81
+ current = asked
82
+ modals.open(TALK_MODAL_ID, { title: dialogTitle(name, asked.topic), topic: asked.topic })
83
+ }
84
+
85
+ // The host cloned the panel in: give it a fresh chat for the link that opened it.
86
+ function onPanel(event) {
87
+ const panel = event.target
88
+ const template = root.querySelector("template[data-talk-to-agent-chat]")
89
+ const slot = panel && panel.querySelector ? panel.querySelector("[data-talk-to-agent-slot]") : null
90
+ if (!template || !slot || !template.content || !template.content.firstElementChild) return
91
+ const asked = current || { topic: "", opener: "" }
92
+ const heading = panel.querySelector("[data-talk-to-agent-title]")
93
+ if (heading) heading.textContent = dialogTitle(name, asked.topic)
94
+ const chat = template.content.firstElementChild.cloneNode(true)
95
+ if (asked.opener) {
96
+ chat.dataset.agentChatOpener = asked.opener
97
+ chat.dataset.agentChatOpenerKey = openerKey(soul, asked.topic)
98
+ }
99
+ chat.dataset.agentChatReveal = "true"
100
+ slot.replaceChildren(chat)
101
+ }
102
+
103
+ doc && doc.addEventListener && doc.addEventListener("click", onClick)
104
+ root.addEventListener(PANEL_EVENT, onPanel)
105
+ root.dataset.talkToAgentReady = "true"
106
+
107
+ return {
108
+ open(link) { onClick({ target: link, button: 0, preventDefault() {} }) },
109
+ get current() { return current },
110
+ destroy() {
111
+ doc && doc.removeEventListener && doc.removeEventListener("click", onClick)
112
+ root.removeEventListener(PANEL_EVENT, onPanel)
113
+ delete root.dataset.talkToAgentReady
114
+ }
115
+ }
116
+ }
@@ -86,7 +86,7 @@
86
86
  line at rest, its description on hover or focus. %>
87
87
  <% if geo_live %>
88
88
  <%= link_to admin_geo_path,
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",
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-ink focus-visible:ring-inset transition",
90
90
  "data-sidebar-row-described": "",
91
91
  "aria-current": (Studio.sidebar_style == :glass && respond_to?(:request) && request&.path == admin_geo_path ? "page" : nil),
92
92
  "@click": geo_close do %>
@@ -104,7 +104,7 @@
104
104
  omission: the row's whole job in this state is to hand over the string, and
105
105
  select-none made it unselectable. role/aria-disabled give a screen reader
106
106
  the "unavailable destination" this paints for a sighted eye. %>
107
- <span class="flex items-center gap-3 px-4 py-3 text-sm text-muted cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-inset"
107
+ <span class="flex items-center gap-3 px-4 py-3 text-sm text-muted cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ink focus-visible:ring-inset"
108
108
  role="link"
109
109
  aria-disabled="true"
110
110
  tabindex="0"
@@ -79,7 +79,7 @@
79
79
  <% target = link[:target].presence %>
80
80
  <% line_icon = (studio_icon(link[:icon]) if glass && link[:icon].present?) %>
81
81
  <%= link_to link[:href],
82
- 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",
82
+ 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-ink focus-visible:ring-inset transition",
83
83
  target: target,
84
84
  rel: target == "_blank" ? "noopener" : nil,
85
85
  "data-sidebar-row-described": (link[:desc].present? ? "" : nil),
@@ -158,7 +158,7 @@
158
158
  <% if logged_in? %>
159
159
  <div class="border-t border-subtle p-2"<% if glass %> data-sidebar-glass-foot<% end %>>
160
160
  <%= link_to logout_path,
161
- class: "flex items-center gap-3 rounded-md 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 transition",
161
+ class: "flex items-center gap-3 rounded-md 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-ink transition",
162
162
  "@click": close_action do %>
163
163
  <% if glass %><span class="flex-shrink-0" data-sidebar-row-icon><%= studio_icon("log-out") %></span><% else %><span>👋</span><% end %><span>Log out</span>
164
164
  <% end %>