studio-engine 0.129.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +192 -0
  3. data/README.md +48 -6
  4. data/app/assets/tailwind/studio_engine/engine-motion.css +322 -56
  5. data/app/assets/tailwind/studio_engine/engine.css +737 -19
  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/fizz_helper.rb +5 -0
  15. data/app/helpers/studio/loader_helper.rb +79 -2
  16. data/app/javascript/studio/agent_chat.js +294 -6
  17. data/app/javascript/studio/carousel.js +99 -0
  18. data/app/javascript/studio/controllers/carousel_controller.js +138 -0
  19. data/app/javascript/studio/controllers/infinite_scroll_controller.js +17 -0
  20. data/app/javascript/studio/controllers/pull_to_refresh_controller.js +18 -0
  21. data/app/javascript/studio/controllers/style_section_nav_controller.js +4 -4
  22. data/app/javascript/studio/controllers/talk_to_agent_controller.js +20 -0
  23. data/app/javascript/studio/infinite_scroll.js +142 -0
  24. data/app/javascript/studio/loader.js +6 -1
  25. data/app/javascript/studio/page_transitions.js +65 -8
  26. data/app/javascript/studio/pinned_stack.js +112 -4
  27. data/app/javascript/studio/pull_to_refresh.js +147 -0
  28. data/app/javascript/studio/stimulus.js +12 -1
  29. data/app/javascript/studio/talk_to_agent.js +116 -0
  30. data/app/views/components/_geo_signpost.html.erb +2 -2
  31. data/app/views/components/_link_sidebar.html.erb +2 -2
  32. data/app/views/components/_link_sidebar_trigger.html.erb +1 -1
  33. data/app/views/components/_sidebar_panel.html.erb +1 -1
  34. data/app/views/error_logs/index.html.erb +2 -8
  35. data/app/views/studio/_carousel.html.erb +97 -0
  36. data/app/views/studio/_fizz.html.erb +26 -0
  37. data/app/views/studio/_fizz_layer.html.erb +4 -1
  38. data/app/views/studio/_hold_button.html.erb +1 -2
  39. data/app/views/studio/_page_header.html.erb +55 -13
  40. data/app/views/studio/_section_header.html.erb +23 -0
  41. data/app/views/studio/agent_chat/_chat.html.erb +7 -4
  42. data/app/views/studio/agent_chat/_talk_modal.html.erb +58 -0
  43. data/app/views/studio/agent_chat/_turn.html.erb +1 -1
  44. data/app/views/studio/loader/_infinite_scroll.html.erb +42 -0
  45. data/app/views/studio/loader/_loader.html.erb +20 -3
  46. data/app/views/studio/loader/_pull_to_refresh.html.erb +20 -0
  47. data/app/views/studio/loader/_skeleton.html.erb +37 -0
  48. data/app/views/style/_components.html.erb +3 -5
  49. data/app/views/style/_host.html.erb +3 -6
  50. data/app/views/style/_loaders.html.erb +118 -0
  51. data/app/views/style/_loaders_feed_page.html.erb +16 -0
  52. data/app/views/style/_modal_specimen.html.erb +5 -5
  53. data/app/views/style/_modals.html.erb +15 -5
  54. data/app/views/style/_site_footer.html.erb +1 -1
  55. data/app/views/style/_specimen.html.erb +21 -10
  56. data/app/views/style/_tasks.html.erb +3 -5
  57. data/app/views/style/_theme.html.erb +2 -2
  58. data/app/views/style/_tricks.html.erb +218 -217
  59. data/app/views/style/index.html.erb +21 -11
  60. data/app/views/style/loaders_feed.html.erb +7 -0
  61. data/lib/studio/crumbs.rb +150 -0
  62. data/lib/studio/loader.rb +30 -2
  63. data/lib/studio/version.rb +1 -1
  64. data/lib/studio.rb +3 -0
  65. metadata +22 -2
@@ -11,10 +11,10 @@ import { activeIndex } from "studio/style_section_nav"
11
11
 
12
12
  const ACTIVE = "btn-primary"
13
13
  const REST = "btn-neutral"
14
- // A jump lands a section by its scroll-margin (--nav-h + 5rem), read while the
15
- // header may still be tall; the header then collapses, so the row rises and the
16
- // section does not. The full margin keeps the clicked section past the line,
17
- // with the collapse's height to spare, for any later scroll or resize.
14
+ // A jump lands a section by its scroll margin, the stack's pinned heights, read while the
15
+ // header may still be tall; the header then collapses, so the row rises and the section
16
+ // does not (76px on the hub at phone width). The slack keeps the clicked section past the
17
+ // line, for any later scroll or resize.
18
18
  const LINE_SLACK = 80
19
19
 
20
20
  export default class extends Controller {
@@ -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) {
@@ -37,6 +37,11 @@
37
37
  // SIZE something as tall as the header; use --nav-bottom to START something
38
38
  // underneath it. Same distinction for --pin-<name>-h vs --pin-<name>-bottom.
39
39
  //
40
+ // THE SECTION LAYER (studio/_section_header, docs/PAGE_HEADER.md) IS THE ONE LAYER
41
+ // THAT IS NOT A data-pin. A page has many section headers and only one is stuck, so
42
+ // each carries `data-pin-section`, and together they publish as one layer, `section`.
43
+ // See stuckSection below.
44
+ //
40
45
  // Started once per document by studio/application. The listeners it adds live
41
46
  // for the document, and a Turbo visit republishes through them rather than
42
47
  // starting it again.
@@ -152,6 +157,62 @@ export function documentIndex(list, el) {
152
157
  return list.length;
153
158
  }
154
159
 
160
+ // THE SECTION LAYER: MANY HEADERS, ONE LAYER, ONLY THE STUCK ONE COUNTS.
161
+ //
162
+ // A section header is sticky inside its own <section>, so the browser does the
163
+ // swap: the next section's header arrives as the last one is pushed out. It joins
164
+ // the stack so that a table's sticky header and any other reader of
165
+ // --pin-stack-bottom sit under it. It cannot be a plain [data-pin]: composed() takes
166
+ // the lowest edge of every layer, so a header waiting below the fold would drag
167
+ // --pin-stack-bottom down the page, and headers sharing a name would overwrite each
168
+ // other's values.
169
+ //
170
+ // So all of them are one layer named `section`, always in the registry while any is
171
+ // on the page. The roster never changes per swap, so a swap is not a structural write,
172
+ // and --pin-section-top, which every header positions off, stays published when none
173
+ // is stuck. That layer's height is 0 until one is stuck, and then it is the part of the
174
+ // bar still below the layers above, so a header being pushed out gives its room back
175
+ // frame by frame.
176
+ //
177
+ // rects are { top, bottom, parentTop, parentBottom }, in page order; line is
178
+ // --pin-section-top. A header is stuck once it has left its place in the flow and part
179
+ // of it still shows below the line. Its section's top is that place, since it is the
180
+ // section's first child and the section has no top padding.
181
+ //
182
+ // NEITHER FACT READS THE HEADER'S OWN EDGE AGAINST THE LINE. While the navbar collapses
183
+ // the line moves first and the header's `top` follows a publish later, so a comparison
184
+ // would drop the bar for the whole collapse, and a bottom read off the rect would be
185
+ // published one step stale, with nothing left to correct it once the page is still. So
186
+ // the bar's bottom is what sticky will make it: one height under the line, or the end of
187
+ // its section while that pushes it out.
188
+ export function sectionBottom(r, line) {
189
+ return Math.min(line + (r.bottom - r.top), r.parentBottom);
190
+ }
191
+
192
+ export function stuckSection(rects, line) {
193
+ var found = -1;
194
+ for (var i = 0; i < rects.length; i++) {
195
+ var r = rects[i];
196
+ if (r.top - r.parentTop > 0.5 && sectionBottom(r, line) > line + 0.5) found = i;
197
+ }
198
+ return found;
199
+ }
200
+
201
+ function measureSections(nodes, line) {
202
+ // Below the short-screen floor, or before the publisher runs, the CSS leaves these in
203
+ // the flow (engine.css, @utility section-header). A header scrolling past the line
204
+ // there is not stuck, so it must not count.
205
+ if (window.getComputedStyle(nodes[0]).position !== 'sticky') return null;
206
+ var rects = [];
207
+ for (var i = 0; i < nodes.length; i++) {
208
+ var r = nodes[i].getBoundingClientRect();
209
+ var parent = nodes[i].parentElement.getBoundingClientRect();
210
+ rects.push({ top: r.top, bottom: r.bottom, parentTop: parent.top, parentBottom: parent.bottom });
211
+ }
212
+ var at = stuckSection(rects, line);
213
+ return at < 0 ? null : { el: nodes[at], bottom: sectionBottom(rects[at], line) };
214
+ }
215
+
155
216
  // THE REGISTRY IS DERIVED, NEVER CACHED — and that is a correctness rule.
156
217
  //
157
218
  // This used to build a `pins` array once and hold each element by reference
@@ -168,8 +229,9 @@ export function documentIndex(list, el) {
168
229
  // reference IMPOSSIBLE rather than merely unlikely: there is no window between
169
230
  // a DOM patch and a re-scan in which this can be pointed at the wrong node.
170
231
  //
171
- // Returns { pins, stack, timeline }: every layer measured and composed, the
172
- // stack's bottom, and whether the header is on the scroll timeline.
232
+ // Returns { pins, stack, timeline, travel, sections, stuck }: every layer measured and
233
+ // composed, the stack's bottom, whether the header is on the scroll timeline and how
234
+ // far it travels, the section headers, and which of them is stuck.
173
235
  export function readStack() {
174
236
  var header = document.querySelector('header');
175
237
  var timeline = onTimeline(header);
@@ -191,11 +253,28 @@ export function readStack() {
191
253
  // never publish at all. Both live consumers own their header, so it is
192
254
  // adopted as a pin even when it does not ask to be.
193
255
  if (header && !headerPinned) list.splice(documentIndex(list, header), 0, measurePin(header, 'nav', true, timeline));
256
+ // In the registry at height 0 first, so the composition says where its line is.
257
+ var sections = document.querySelectorAll('[data-pin-section]');
258
+ var section = null;
259
+ if (sections.length) {
260
+ section = { el: sections[0], name: 'section', legacy: false, h: 0, bottom: 0 };
261
+ list.splice(documentIndex(list, sections[0]), 0, section);
262
+ }
263
+ var stack = timeline ? pinnedAt(list) : composed(list);
264
+ var stuck = section ? measureSections(sections, section.top) : null;
265
+ if (stuck) {
266
+ section.el = stuck.el;
267
+ section.h = stuck.bottom - section.top;
268
+ section.bottom = stuck.bottom;
269
+ stack = timeline ? pinnedAt(list) : composed(list);
270
+ }
194
271
  return {
195
272
  pins: list,
196
- stack: timeline ? pinnedAt(list) : composed(list),
273
+ stack: stack,
197
274
  timeline: timeline,
198
- travel: timeline ? timelineTravel(header) : 0
275
+ travel: timeline ? timelineTravel(header) : 0,
276
+ sections: sections,
277
+ stuck: stuck ? stuck.el : null
199
278
  };
200
279
  }
201
280
 
@@ -414,6 +493,9 @@ export function startPinnedStack() {
414
493
  settleTimer = null;
415
494
  publishAll();
416
495
  releaseFollowers();
496
+ // That publish measured the layers while they still held their last copies. Given back,
497
+ // they move to the root's values, so their edges are read once more.
498
+ schedule();
417
499
  }
418
500
 
419
501
  // The registry and the measurement are module-level (readStack, measurePin
@@ -505,12 +587,38 @@ export function startPinnedStack() {
505
587
  }
506
588
  }
507
589
 
590
+ // WHO WEARS THE BAR, written after every read of the publish. The box does not change
591
+ // with the attribute, so this resizes nothing and moves nothing. The root's attribute
592
+ // squares the page row's corners against the bar under it (engine.css, glass).
593
+ function syncSections(reading) {
594
+ for (var i = 0; i < reading.sections.length; i++) {
595
+ var node = reading.sections[i];
596
+ var on = node === reading.stuck;
597
+ if (node.hasAttribute('data-stuck') !== on) node.toggleAttribute('data-stuck', on);
598
+ }
599
+ var root = document.documentElement;
600
+ if (root.hasAttribute('data-pin-section-stuck') !== !!reading.stuck) root.toggleAttribute('data-pin-section-stuck', !!reading.stuck);
601
+ }
602
+
508
603
  var lastPublished = null;
604
+ var lastTops = null;
509
605
  function publishAll() {
606
+ // What lets a section header stick at all (engine.css): with no publisher its bar
607
+ // would never come, and it would lay a clear title over the page. Before the read,
608
+ // so the first publish on a restored scroll already sees it sticky.
609
+ var html = document.documentElement;
610
+ if (!html.hasAttribute('data-pin-live')) html.setAttribute('data-pin-live', '');
510
611
  var reading = readStack();
511
612
  var pins = reading.pins;
512
613
  write(pins, reading.stack, reading.timeline, reading.travel);
614
+ syncSections(reading);
513
615
  syncObserver(pins);
616
+ // A LAYER UNDER A LAYER SETTLES ONE PUBLISH LATER. A new --pin-page-top moves the page
617
+ // row without resizing it, so nothing wakes the publisher to read the row's new edge,
618
+ // and the layer under the row would keep a stale line once the page is still. So a
619
+ // publish that moved any layer's top asks for one more frame. It ends when none moved.
620
+ var tops = pins.map(function (p) { return p.top; }).join('|');
621
+ if (tops !== lastTops) { lastTops = tops; schedule(); }
514
622
  var key = publishedKey(reading);
515
623
  if (key !== lastPublished) {
516
624
  lastPublished = key;
@@ -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
+ }