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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +151 -0
- data/README.md +43 -6
- data/app/assets/tailwind/studio_engine/engine-motion.css +174 -0
- data/app/assets/tailwind/studio_engine/engine.css +587 -0
- data/app/assets/tailwind/studio_engine/theme.css +4 -0
- data/app/controllers/concerns/studio/error_handling.rb +53 -15
- data/app/controllers/concerns/studio/magic_link_issuing.rb +4 -0
- data/app/controllers/magic_links_controller.rb +25 -2
- data/app/controllers/registrations_controller.rb +58 -22
- data/app/controllers/style_controller.rb +6 -0
- data/app/helpers/studio/agent_chat_helper.rb +14 -1
- data/app/helpers/studio/crumbs_helper.rb +34 -0
- data/app/helpers/studio/loader_helper.rb +79 -2
- data/app/javascript/studio/agent_chat.js +294 -6
- data/app/javascript/studio/carousel.js +99 -0
- data/app/javascript/studio/controllers/carousel_controller.js +138 -0
- data/app/javascript/studio/controllers/infinite_scroll_controller.js +17 -0
- data/app/javascript/studio/controllers/pull_to_refresh_controller.js +18 -0
- data/app/javascript/studio/controllers/talk_to_agent_controller.js +20 -0
- data/app/javascript/studio/infinite_scroll.js +142 -0
- data/app/javascript/studio/loader.js +6 -1
- data/app/javascript/studio/page_transitions.js +65 -8
- data/app/javascript/studio/pull_to_refresh.js +147 -0
- data/app/javascript/studio/stimulus.js +12 -1
- data/app/javascript/studio/talk_to_agent.js +116 -0
- data/app/views/components/_geo_signpost.html.erb +2 -2
- data/app/views/components/_link_sidebar.html.erb +2 -2
- data/app/views/components/_link_sidebar_trigger.html.erb +1 -1
- data/app/views/components/_sidebar_panel.html.erb +1 -1
- data/app/views/error_logs/index.html.erb +2 -8
- data/app/views/studio/_carousel.html.erb +97 -0
- data/app/views/studio/_page_header.html.erb +52 -11
- data/app/views/studio/agent_chat/_chat.html.erb +7 -4
- data/app/views/studio/agent_chat/_talk_modal.html.erb +58 -0
- data/app/views/studio/agent_chat/_turn.html.erb +1 -1
- data/app/views/studio/loader/_infinite_scroll.html.erb +42 -0
- data/app/views/studio/loader/_loader.html.erb +20 -3
- data/app/views/studio/loader/_pull_to_refresh.html.erb +20 -0
- data/app/views/studio/loader/_skeleton.html.erb +37 -0
- data/app/views/style/_loaders.html.erb +118 -0
- data/app/views/style/_loaders_feed_page.html.erb +16 -0
- data/app/views/style/_modals.html.erb +12 -0
- data/app/views/style/_specimen.html.erb +17 -10
- data/app/views/style/_tricks.html.erb +64 -2
- data/app/views/style/index.html.erb +14 -8
- data/app/views/style/loaders_feed.html.erb +7 -0
- data/lib/studio/crumbs.rb +150 -0
- data/lib/studio/loader.rb +30 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +3 -0
- 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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 %>
|