studio-engine 0.123.0 → 0.125.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 +95 -0
- data/README.md +16 -1
- data/app/assets/javascripts/studio/page_transition_reveal.js +75 -0
- data/app/assets/stylesheets/studio/page_transition_full_load.css +10 -0
- data/app/assets/tailwind/studio_engine/engine.css +216 -11
- data/app/controllers/studio/marketing_kit_controller.rb +24 -0
- data/app/helpers/studio/site_footer_helper.rb +15 -2
- data/app/javascript/studio/controllers/copy_text_controller.js +63 -0
- data/app/javascript/studio/controllers/page_header_controller.js +60 -0
- data/app/javascript/studio/page_header.js +24 -0
- data/app/javascript/studio/page_transitions.js +263 -0
- data/app/javascript/studio/pinned_stack.js +12 -1
- data/app/javascript/studio/stimulus.js +6 -1
- data/app/views/components/_link_sidebar.html.erb +6 -3
- data/app/views/components/_user_nav.html.erb +5 -4
- data/app/views/layouts/_navbar.html.erb +7 -4
- data/app/views/layouts/studio/_head.html.erb +17 -2
- data/app/views/layouts/studio/_smooth_load.html.erb +11 -2
- data/app/views/studio/_page_header.html.erb +65 -0
- data/app/views/studio/admin_models/show.html.erb +10 -18
- data/app/views/studio/admin_surveys/index.html.erb +2 -2
- data/app/views/studio/agent_chat/_form.html.erb +1 -1
- data/app/views/studio/agent_chat/_header.html.erb +1 -1
- data/app/views/studio/marketing_kit/_assets.html.erb +44 -0
- data/app/views/studio/marketing_kit/_kit.html.erb +84 -0
- data/app/views/studio/marketing_kit/show.html.erb +6 -0
- data/app/views/studio/site_footer/_assets.html.erb +1 -0
- data/app/views/studio/site_footer/_footer.html.erb +5 -0
- data/app/views/style/_tricks.html.erb +35 -0
- data/app/views/style/index.html.erb +17 -28
- data/docs/SITE_FOOTER.md +113 -2
- data/lib/studio/engine.rb +5 -2
- data/lib/studio/marketing_kit.rb +304 -0
- data/lib/studio/site_footer.rb +22 -6
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +83 -1
- metadata +13 -1
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// page-header: says when studio/_page_header is stuck, marks which ends of its
|
|
2
|
+
// tabs hide a tab, and shows the open tab on a narrow screen. It sits on the row's
|
|
3
|
+
// sentinel, so the row keeps its data-studio-controller for the page's own.
|
|
4
|
+
//
|
|
5
|
+
// <div class="page-header-sentinel" data-studio-controller="page-header"></div>
|
|
6
|
+
// <nav class="page-header sticky" data-pin="page">...</nav>
|
|
7
|
+
//
|
|
8
|
+
// A lazy controller (studio/stimulus): only a page with the row fetches it. The
|
|
9
|
+
// row's tabs are links, so they work without it. If it fails to load, the sentinel
|
|
10
|
+
// carries data-studio-controller-failed and engine.css gives the row its bar at
|
|
11
|
+
// all times, so a stuck row never lays a transparent title over the page.
|
|
12
|
+
import { Controller } from "@hotwired/stimulus"
|
|
13
|
+
import { hiddenEnds, isStuck, revealBy } from "studio/page_header"
|
|
14
|
+
|
|
15
|
+
export default class extends Controller {
|
|
16
|
+
connect() {
|
|
17
|
+
this.row = this.element.nextElementSibling
|
|
18
|
+
if (!this.row) return
|
|
19
|
+
this.tabs = this.row.querySelector(".page-header-tabs")
|
|
20
|
+
this.schedule = this.schedule.bind(this)
|
|
21
|
+
window.addEventListener("scroll", this.schedule, { passive: true })
|
|
22
|
+
window.addEventListener("resize", this.schedule, { passive: true })
|
|
23
|
+
this.tabs?.addEventListener("scroll", this.schedule, { passive: true })
|
|
24
|
+
this.revealOpenTab()
|
|
25
|
+
this.sync()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
disconnect() {
|
|
29
|
+
window.removeEventListener("scroll", this.schedule)
|
|
30
|
+
window.removeEventListener("resize", this.schedule)
|
|
31
|
+
this.tabs?.removeEventListener("scroll", this.schedule)
|
|
32
|
+
if (this.frame) cancelAnimationFrame(this.frame)
|
|
33
|
+
this.frame = null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
schedule() {
|
|
37
|
+
if (this.frame) return
|
|
38
|
+
this.frame = requestAnimationFrame(() => {
|
|
39
|
+
this.frame = null
|
|
40
|
+
this.sync()
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
sync() {
|
|
45
|
+
const stuck = isStuck(this.row.getBoundingClientRect().top, this.element.getBoundingClientRect().top)
|
|
46
|
+
const ends = this.tabs ? hiddenEnds(this.tabs) : null
|
|
47
|
+
|
|
48
|
+
this.row.toggleAttribute("data-stuck", stuck)
|
|
49
|
+
if (!ends) return
|
|
50
|
+
this.tabs.toggleAttribute("data-fade-start", ends.start)
|
|
51
|
+
this.tabs.toggleAttribute("data-fade-end", ends.end)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Moves the tabs only: scrollIntoView would also move the page.
|
|
55
|
+
revealOpenTab() {
|
|
56
|
+
const open = this.tabs?.querySelector("[aria-current]")
|
|
57
|
+
if (!open) return
|
|
58
|
+
this.tabs.scrollLeft += revealBy(this.tabs.getBoundingClientRect(), open.getBoundingClientRect())
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// studio/page_header: the page header's two measurements, as pure functions. It
|
|
2
|
+
// imports nothing; test/javascript/page_header.test.mjs loads it as a data: module.
|
|
3
|
+
|
|
4
|
+
// A sticky row is stuck once it has left its place in the flow. restTop is that
|
|
5
|
+
// place, measured by the sentinel beside it. The half pixel is rounding: at rest
|
|
6
|
+
// the two tops differ by a fraction at most, and a stuck row by at least a pixel.
|
|
7
|
+
export function isStuck(rowTop, restTop) {
|
|
8
|
+
return rowTop - restTop > 0.5
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Which ends of the tabs hide a tab, so a fade marks only an end with more to see.
|
|
12
|
+
export function hiddenEnds({ scrollLeft, clientWidth, scrollWidth }) {
|
|
13
|
+
return {
|
|
14
|
+
start: scrollLeft > 1,
|
|
15
|
+
end: scrollLeft + clientWidth < scrollWidth - 1
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// How far the tabs must scroll to show a tab, clear of the fade. 0 if it shows.
|
|
20
|
+
export function revealBy(box, tab, margin = 32) {
|
|
21
|
+
if (tab.left < box.left) return tab.left - box.left - margin
|
|
22
|
+
if (tab.right > box.right) return tab.right - box.right + margin
|
|
23
|
+
return 0
|
|
24
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// studio/page_transitions: the app-to-app page transition (Studio.page_transition_direction,
|
|
2
|
+
// docs/PAGE_TRANSITIONS.md). Loaded by its own tag in layouts/studio/_head, only with the
|
|
3
|
+
// setting on. It names a style on <html>, and engine.css draws it. It imports nothing.
|
|
4
|
+
//
|
|
5
|
+
// Turbo calls document.startViewTransition BEFORE turbo:before-render, and the browser takes
|
|
6
|
+
// the old page's picture before Turbo's callback runs. So what the old picture holds is
|
|
7
|
+
// settled ahead of the visit: the held banner stack and the morph names are decided here on
|
|
8
|
+
// turbo:visit and from the response, never first at before-render.
|
|
9
|
+
//
|
|
10
|
+
// A full load is the other path to the same look. The new document draws the motion, so the old
|
|
11
|
+
// one only plans it: at pageswap it names what it holds, draws the style here, and leaves it for
|
|
12
|
+
// studio/page_transition_reveal, the classic script that applies it at pagereveal.
|
|
13
|
+
//
|
|
14
|
+
// The effects are from the Codrops Page Transitions demo; the keyframes were written fresh.
|
|
15
|
+
|
|
16
|
+
export const DIRECTIONS = Object.freeze({
|
|
17
|
+
left: Object.freeze(["fade-left", "unfold-right"]),
|
|
18
|
+
right: Object.freeze(["fade-right", "unfold-left"]),
|
|
19
|
+
top: Object.freeze(["fade-top", "unfold-bottom"]),
|
|
20
|
+
bottom: Object.freeze(["fade-bottom", "unfold-top"]),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// A same-page visit (a refresh, a poll, the second half of a redirect): the transition still
|
|
24
|
+
// runs, and draws nothing.
|
|
25
|
+
export const STILL = "still"
|
|
26
|
+
export const READY_CAP_MS = 500
|
|
27
|
+
// Read by app/assets/javascripts/studio/page_transition_reveal.js too.
|
|
28
|
+
export const FULL_LOAD_STYLE_KEY = "studio-page-transition-style"
|
|
29
|
+
const MAX_READY_IMAGES = 12
|
|
30
|
+
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"
|
|
31
|
+
|
|
32
|
+
export function pickStyle(direction, random = Math.random) {
|
|
33
|
+
if (!Object.hasOwn(DIRECTIONS, direction)) return null
|
|
34
|
+
const pair = DIRECTIONS[direction]
|
|
35
|
+
return pair[Math.floor(random() * pair.length)]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The style for one visit, STILL, or null to leave the swap alone (the engine's fade plays,
|
|
39
|
+
// and a morph between two pages stands). An advance visit to the same path can bring a new
|
|
40
|
+
// page, as a launch panel does when it gives way to the chat at its own address, so a name
|
|
41
|
+
// the two share still morphs. A refresh replaces, and stays silent.
|
|
42
|
+
export function planVisit({ direction, from, to, transitions, sharedName, advance }, random = Math.random) {
|
|
43
|
+
if (!transitions || !from || !to || !Object.hasOwn(DIRECTIONS, direction)) return null
|
|
44
|
+
if (from === to) return advance && sharedName ? null : STILL
|
|
45
|
+
return sharedName ? null : pickStyle(direction, random)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The style a full load should play, or null for a plain load: planVisit's own rule, so the
|
|
49
|
+
// two paths cannot differ. No morph, since the old page cannot read the new page's names
|
|
50
|
+
// before its picture, and a browser with no activation entry has nothing to compare.
|
|
51
|
+
export function planFullLoad({ direction, from, activation }, random = Math.random) {
|
|
52
|
+
const url = activation && activation.entry && activation.entry.url
|
|
53
|
+
if (!url) return null
|
|
54
|
+
const to = new URL(url).pathname
|
|
55
|
+
const style = planVisit({ direction, from, to, transitions: true, sharedName: false }, random)
|
|
56
|
+
return style === STILL ? null : style
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Hands the style to the next document. Storage can refuse, and then the load is plain.
|
|
60
|
+
export function leaveStyle(win, style) {
|
|
61
|
+
try {
|
|
62
|
+
if (style) win.sessionStorage.setItem(FULL_LOAD_STYLE_KEY, style)
|
|
63
|
+
else win.sessionStorage.removeItem(FULL_LOAD_STYLE_KEY)
|
|
64
|
+
return !!style
|
|
65
|
+
} catch {
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A slow image must not hold a click: past the cap the swap goes ahead.
|
|
71
|
+
export function withCap(promise, ms, timer = setTimeout) {
|
|
72
|
+
return Promise.race([promise, new Promise((done) => timer(done, ms))])
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function morphNames(root) {
|
|
76
|
+
return new Set([...root.querySelectorAll("[data-vt-morph]")].map((el) => el.getAttribute("data-vt-morph")))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function shareName(here, there) {
|
|
80
|
+
return !!there && [...here].some((name) => there.has(name))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Exactly one element takes the stack's view-transition name, however many a page renders:
|
|
84
|
+
// a duplicate name silently cancels the whole transition.
|
|
85
|
+
export function markHeld(root) {
|
|
86
|
+
const stack = root.querySelector(".studio-bar-stack")
|
|
87
|
+
if (stack) stack.setAttribute("data-vt-held", "")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function eagerImages(body) {
|
|
91
|
+
return [...body.querySelectorAll("img")]
|
|
92
|
+
.filter((img) => img.getAttribute("loading") !== "lazy" && (img.getAttribute("src") || img.getAttribute("srcset")))
|
|
93
|
+
.slice(0, MAX_READY_IMAGES)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The incoming body is inert, so each image is copied here to load, <picture> and all, so
|
|
97
|
+
// the browser chooses the same source. Marked decoding=sync, the real one paints at once.
|
|
98
|
+
function loadImage(doc, img) {
|
|
99
|
+
img.decoding = "sync"
|
|
100
|
+
const source = img.closest("picture") || img
|
|
101
|
+
const copy = doc.importNode(source, true)
|
|
102
|
+
const loader = copy.tagName === "IMG" ? copy : copy.querySelector("img")
|
|
103
|
+
return loader.decode().catch(() => {})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readyForSwap(doc, body) {
|
|
107
|
+
const fonts = doc.fonts ? doc.fonts.ready.catch(() => {}) : null
|
|
108
|
+
// At least one tick, so Turf's one-tick hold for its snapshot stays honest.
|
|
109
|
+
const tick = new Promise((done) => setTimeout(done, 0))
|
|
110
|
+
return Promise.all([...eagerImages(body).map((img) => loadImage(doc, img)), fonts, tick])
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Turbo's resume is a promise's resolve, so the FIRST caller starts the render. Another
|
|
114
|
+
// listener's short hold (Turf's turbo_snapshot_cache resumes on a timeout of 0) would cut
|
|
115
|
+
// this wait to nothing, so a listener that runs after this one gets a resume that does
|
|
116
|
+
// nothing, and this wait releases the render. It always does: cap, failure or success.
|
|
117
|
+
export function holdRender(event, wait, cap = READY_CAP_MS) {
|
|
118
|
+
const resume = event.detail && event.detail.resume
|
|
119
|
+
if (typeof resume !== "function") return false
|
|
120
|
+
event.preventDefault()
|
|
121
|
+
event.detail.resume = () => {}
|
|
122
|
+
const release = () => resume()
|
|
123
|
+
withCap(Promise.resolve().then(wait), cap).then(release, release)
|
|
124
|
+
return true
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readDirection(doc) {
|
|
128
|
+
const meta = doc.querySelector('meta[name="page-transition-direction"]')
|
|
129
|
+
return meta ? meta.content : null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let installed = false
|
|
133
|
+
|
|
134
|
+
export function installPageTransitions(doc) {
|
|
135
|
+
if (installed || !readDirection(doc)) return false
|
|
136
|
+
installed = true
|
|
137
|
+
|
|
138
|
+
const win = doc.defaultView
|
|
139
|
+
const root = doc.documentElement
|
|
140
|
+
const known = new Map()
|
|
141
|
+
let from = win.location.pathname
|
|
142
|
+
let target = null
|
|
143
|
+
let advance = false
|
|
144
|
+
let rendered = false
|
|
145
|
+
|
|
146
|
+
const nameTheMorph = (shared) => {
|
|
147
|
+
if (shared) root.dataset.pageTransitionMorph = ""
|
|
148
|
+
else delete root.dataset.pageTransitionMorph
|
|
149
|
+
}
|
|
150
|
+
const predict = (names) => nameTheMorph((target !== from || advance) && shareName(morphNames(doc), names))
|
|
151
|
+
|
|
152
|
+
root.dataset.pageTransitionDirection = readDirection(doc)
|
|
153
|
+
markHeld(doc)
|
|
154
|
+
known.set(from, morphNames(doc))
|
|
155
|
+
|
|
156
|
+
doc.addEventListener("turbo:visit", (event) => {
|
|
157
|
+
root.dataset.pageTransitionVisit = ""
|
|
158
|
+
// Turbo opens its view transition before it reads turbo-visit-control. A response it will
|
|
159
|
+
// not render gets no turbo:before-render, so this stands, and that transition draws nothing.
|
|
160
|
+
// The full load after it plays the one motion. Left alone while a motion is still running.
|
|
161
|
+
if (!root.dataset.pageTransition) root.dataset.pageTransition = STILL
|
|
162
|
+
target = new URL(event.detail.url).pathname
|
|
163
|
+
// A GET that redirects: Turbo renders the response, then replaces to the final address
|
|
164
|
+
// with that same response, all before turbo:load. The page has landed, so nothing moves.
|
|
165
|
+
const action = event.detail.action
|
|
166
|
+
if (rendered && action === "replace") from = target
|
|
167
|
+
rendered = false
|
|
168
|
+
advance = action === "advance"
|
|
169
|
+
predict(known.get(target))
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// A page never visited: read its names from the response, ahead of the old page's picture.
|
|
173
|
+
// Only a page with names of its own can share one, so every other page skips the parse.
|
|
174
|
+
doc.addEventListener("turbo:before-fetch-response", (event) => {
|
|
175
|
+
if (event.target && event.target.tagName === "TURBO-FRAME") return
|
|
176
|
+
if (!morphNames(doc).size) return
|
|
177
|
+
const { fetchResponse } = event.detail
|
|
178
|
+
Promise.resolve(fetchResponse.responseHTML).then((html) => {
|
|
179
|
+
if (!html) return
|
|
180
|
+
const names = morphNames(new win.DOMParser().parseFromString(html, "text/html"))
|
|
181
|
+
const path = new URL(fetchResponse.response.url).pathname
|
|
182
|
+
known.set(path, names)
|
|
183
|
+
if (target && (path === target || fetchResponse.redirected)) predict(names)
|
|
184
|
+
}).catch(() => {})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// In capture, so the hold is in place before any other listener takes Turbo's resume.
|
|
188
|
+
doc.addEventListener("turbo:before-render", (event) => {
|
|
189
|
+
const { newBody } = event.detail
|
|
190
|
+
markHeld(newBody)
|
|
191
|
+
const names = morphNames(newBody)
|
|
192
|
+
if (target) known.set(target, names)
|
|
193
|
+
const sharedName = shareName(morphNames(doc), names)
|
|
194
|
+
const style = planVisit({
|
|
195
|
+
direction: readDirection(doc),
|
|
196
|
+
from,
|
|
197
|
+
to: target,
|
|
198
|
+
transitions: !!doc.startViewTransition && !win.matchMedia(REDUCED_MOTION).matches,
|
|
199
|
+
sharedName,
|
|
200
|
+
advance,
|
|
201
|
+
})
|
|
202
|
+
nameTheMorph(sharedName && style !== STILL)
|
|
203
|
+
if (style) root.dataset.pageTransition = style
|
|
204
|
+
else delete root.dataset.pageTransition
|
|
205
|
+
if (style && style !== STILL) holdRender(event, () => readyForSwap(doc, newBody))
|
|
206
|
+
}, { capture: true })
|
|
207
|
+
|
|
208
|
+
// A refused form renders with no visit and fires no turbo:load, so only a render inside a
|
|
209
|
+
// visit counts. Otherwise the flag would outlive that render and eat the next page's move.
|
|
210
|
+
doc.addEventListener("turbo:render", () => { rendered = !!target })
|
|
211
|
+
|
|
212
|
+
// Turbo fires this after the transition has finished. With no visit, it is this document's
|
|
213
|
+
// own first load, and a full load's motion can still be playing: its script cleans up.
|
|
214
|
+
doc.addEventListener("turbo:load", () => {
|
|
215
|
+
const visited = target !== null
|
|
216
|
+
from = win.location.pathname
|
|
217
|
+
target = null
|
|
218
|
+
advance = false
|
|
219
|
+
rendered = false
|
|
220
|
+
known.set(from, morphNames(doc))
|
|
221
|
+
if (!visited) return
|
|
222
|
+
delete root.dataset.pageTransition
|
|
223
|
+
delete root.dataset.pageTransitionVisit
|
|
224
|
+
nameTheMorph(false)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// A visit that never renders: the stack must not keep its name at rest.
|
|
228
|
+
for (const name of ["turbo:fetch-request-error", "turbo:reload"]) {
|
|
229
|
+
doc.addEventListener(name, () => {
|
|
230
|
+
delete root.dataset.pageTransitionVisit
|
|
231
|
+
if (name !== "turbo:reload") delete root.dataset.pageTransition
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// The last moment on the old document. The browser takes its picture after this.
|
|
236
|
+
win.addEventListener("pageswap", (event) => {
|
|
237
|
+
const transition = event.viewTransition
|
|
238
|
+
const style = transition && planFullLoad({ direction: readDirection(doc), from, activation: event.activation })
|
|
239
|
+
const left = leaveStyle(win, style)
|
|
240
|
+
if (!transition) return
|
|
241
|
+
if (!left) {
|
|
242
|
+
transition.skipTransition()
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
markHeld(doc)
|
|
246
|
+
root.dataset.pageTransitionVisit = ""
|
|
247
|
+
nameTheMorph(false)
|
|
248
|
+
// The loader settles at pagehide, which comes after the picture.
|
|
249
|
+
if (win.StudioLoader) win.StudioLoader.hide()
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
// Back from the back/forward cache: a visit that left by a full load is over.
|
|
253
|
+
win.addEventListener("pageshow", (event) => {
|
|
254
|
+
if (!event.persisted) return
|
|
255
|
+
from = win.location.pathname
|
|
256
|
+
target = null
|
|
257
|
+
advance = false
|
|
258
|
+
rendered = false
|
|
259
|
+
})
|
|
260
|
+
return true
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (typeof document !== "undefined") installPageTransitions(document)
|
|
@@ -141,6 +141,17 @@ export function measurePin(el, name, legacy, timeline) {
|
|
|
141
141
|
return { el: el, name: name, legacy: legacy, h: el.offsetHeight - band, bottom: Math.max(0, r.bottom) };
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
// WHERE AN ADOPTED HEADER GOES: its place in the document, since order is
|
|
145
|
+
// document order (composed). Appended, it read as below every layer, so a layer
|
|
146
|
+
// under it (studio/_page_header) composed a top of 0 and pinned over it.
|
|
147
|
+
export function documentIndex(list, el) {
|
|
148
|
+
var FOLLOWING = 4; // Node.DOCUMENT_POSITION_FOLLOWING
|
|
149
|
+
for (var i = 0; i < list.length; i++) {
|
|
150
|
+
if (el.compareDocumentPosition(list[i].el) & FOLLOWING) return i;
|
|
151
|
+
}
|
|
152
|
+
return list.length;
|
|
153
|
+
}
|
|
154
|
+
|
|
144
155
|
// THE REGISTRY IS DERIVED, NEVER CACHED — and that is a correctness rule.
|
|
145
156
|
//
|
|
146
157
|
// This used to build a `pins` array once and hold each element by reference
|
|
@@ -179,7 +190,7 @@ export function readStack() {
|
|
|
179
190
|
// contest board and mcritchie-studio's heartbeat CSS all read — would
|
|
180
191
|
// never publish at all. Both live consumers own their header, so it is
|
|
181
192
|
// adopted as a pin even when it does not ask to be.
|
|
182
|
-
if (header && !headerPinned) list.
|
|
193
|
+
if (header && !headerPinned) list.splice(documentIndex(list, header), 0, measurePin(header, 'nav', true, timeline));
|
|
183
194
|
return {
|
|
184
195
|
pins: list,
|
|
185
196
|
stack: timeline ? pinnedAt(list) : composed(list),
|
|
@@ -65,6 +65,9 @@ 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 copy-text: a colour whose controller failed still shows its hex to select.
|
|
69
|
+
// So is the page header: its tabs are links, and a row whose controller failed
|
|
70
|
+
// keeps its bar at all times (data-studio-controller-failed="page-header").
|
|
68
71
|
export const LAZY = {
|
|
69
72
|
"board": () => import("studio/controllers/board_controller"),
|
|
70
73
|
"geo-settings": () => import("studio/controllers/geo_settings_controller"),
|
|
@@ -78,7 +81,9 @@ export const LAZY = {
|
|
|
78
81
|
"agent-chat-voice": () => import("studio/controllers/agent_chat_voice_controller"),
|
|
79
82
|
"voice-profile": () => import("studio/controllers/voice_profile_controller"),
|
|
80
83
|
"microphone-chip": () => import("studio/controllers/microphone_chip_controller"),
|
|
81
|
-
"voice-recorder": () => import("studio/controllers/voice_recorder_controller")
|
|
84
|
+
"voice-recorder": () => import("studio/controllers/voice_recorder_controller"),
|
|
85
|
+
"copy-text": () => import("studio/controllers/copy_text_controller"),
|
|
86
|
+
"page-header": () => import("studio/controllers/page_header_controller")
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
export const application = Application.start(document.documentElement, schema)
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
this automatically when sections are declared. %>
|
|
7
7
|
<% sections = studio_sidebar_sections %>
|
|
8
8
|
<% sidebar_admin = respond_to?(:admin?) && admin? %>
|
|
9
|
+
<%# A signed-out visitor's panel has no title: its links say what it is. It keeps its
|
|
10
|
+
accessible name (aria_label) and its close button. %>
|
|
11
|
+
<% sidebar_title = if sidebar_admin then "Admin Menu" elsif respond_to?(:logged_in?) && logged_in? then "Links" end %>
|
|
9
12
|
<% close_action = "$store.sidebars.linkTreeOpen = false" %>
|
|
10
13
|
|
|
11
14
|
<style>
|
|
@@ -34,7 +37,7 @@
|
|
|
34
37
|
<% section[:links].each do |link| %>
|
|
35
38
|
<% target = link[:target].presence %>
|
|
36
39
|
<%= link_to link[:href],
|
|
37
|
-
class: "group flex items-start 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",
|
|
40
|
+
class: "group flex #{link[:desc].present? ? "items-start" : "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",
|
|
38
41
|
target: target,
|
|
39
42
|
rel: target == "_blank" ? "noopener" : nil,
|
|
40
43
|
"@click": close_action do %>
|
|
@@ -113,7 +116,7 @@
|
|
|
113
116
|
|
|
114
117
|
<%= render "components/sidebar_panel",
|
|
115
118
|
id: "studio-link-sidebar",
|
|
116
|
-
title:
|
|
119
|
+
title: sidebar_title,
|
|
117
120
|
open: "$store.sidebars.linkTreeOpen",
|
|
118
121
|
body_html: body_html,
|
|
119
122
|
footer_html: footer_html,
|
|
@@ -126,7 +129,7 @@
|
|
|
126
129
|
|
|
127
130
|
<%= render "components/sidebar_panel",
|
|
128
131
|
id: "studio-link-sidebar-mobile",
|
|
129
|
-
title:
|
|
132
|
+
title: sidebar_title,
|
|
130
133
|
open: "$store.sidebars.linkTreeOpen",
|
|
131
134
|
body_html: body_html,
|
|
132
135
|
footer_html: footer_html,
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
{ partial: "path", locals: { ... } } when the partial needs locals:
|
|
6
6
|
balance_slot — balance display, rendered at the start of the
|
|
7
7
|
top row (e.g. a wallet balance link)
|
|
8
|
-
extra_icons_slot — app-specific icon buttons, rendered
|
|
9
|
-
|
|
8
|
+
extra_icons_slot — app-specific icon buttons, rendered between the
|
|
9
|
+
theme toggle and the admin gear
|
|
10
10
|
div2_slot — replaces the default second row (the wallet
|
|
11
11
|
address + level progress bar) entirely
|
|
12
12
|
(e.g. a seeds bar)
|
|
@@ -329,13 +329,14 @@
|
|
|
329
329
|
<div data-nav-avatar-badge><%= studio_navbar_slot_tag(avatar_badge) %></div>
|
|
330
330
|
<% end %>
|
|
331
331
|
<% else %>
|
|
332
|
+
<%# The signed-in rail's order: the theme toggle, then the gear. Desktop
|
|
333
|
+
only, as above: the phone row carries the toggle below md. %>
|
|
334
|
+
<span class="hidden md:flex" data-nav-theme-toggle="desktop"><%= render "components/theme_toggle_morph" %></span>
|
|
332
335
|
<% if menu_trigger %>
|
|
333
336
|
<span class="hidden md:block" data-nav-menu-trigger><%= studio_navbar_slot_tag(menu_trigger) %></span>
|
|
334
337
|
<% else %>
|
|
335
338
|
<%= render "components/admin_dropdown", display_class: "hidden md:block" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
|
|
336
339
|
<% end %>
|
|
337
|
-
<%# Desktop only, as above: the phone row carries the toggle below md. %>
|
|
338
|
-
<span class="hidden md:flex" data-nav-theme-toggle="desktop"><%= render "components/theme_toggle_morph" %></span>
|
|
339
340
|
<div class="flex items-center gap-3 ml-auto">
|
|
340
341
|
<%= link_to Studio.sign_in_label, login_path, class: "text-heading hover:text-primary text-sm font-semibold transition" %>
|
|
341
342
|
<%= link_to "Sign up", signup_path, class: "btn btn-primary btn-sm" %>
|
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
put — hosts no longer shadow this partial just to add the class. Non-preview
|
|
24
24
|
branch ONLY: preview renders can repeat per page, and a duplicate
|
|
25
25
|
view-transition-name silently disables every transition. With the flag off,
|
|
26
|
-
nothing extra renders.
|
|
27
|
-
|
|
26
|
+
nothing extra renders. Studio.page_transition_direction turns the same flag
|
|
27
|
+
on (layouts/studio/_smooth_load). %>
|
|
28
|
+
<% pin_header = (Studio.smooth_load || @studio_page_transition_direction || Studio.page_transition_direction) && !is_preview %>
|
|
28
29
|
<% balance_html = local_assigns.fetch(:balance_html, nil) %>
|
|
29
30
|
<% extra_icons_html = local_assigns.fetch(:extra_icons_html, nil) %>
|
|
30
31
|
<%# The user nav's slot locals, forwarded only when passed, so a navbar that
|
|
@@ -244,8 +245,9 @@
|
|
|
244
245
|
<%= render "components/user_nav", balance_html: balance_html, extra_icons_html: extra_icons_html, **user_nav_slots %>
|
|
245
246
|
<% else %>
|
|
246
247
|
<div class="flex items-center justify-end gap-3">
|
|
247
|
-
|
|
248
|
+
<%# The signed-in rail's order: the theme toggle, then the gear. %>
|
|
248
249
|
<span class="hidden md:flex"><%= render "components/theme_toggle_morph" %></span>
|
|
250
|
+
<%= sidebar_trigger_desktop if sidebar_active %>
|
|
249
251
|
<% if signed_out_cta %>
|
|
250
252
|
<%# sign_in_link: false drops the quiet link (the CTA is the sign-in);
|
|
251
253
|
sign_in_path points it elsewhere; modal: opens a modal from the
|
|
@@ -277,6 +279,8 @@
|
|
|
277
279
|
<% if signed_out_cta && signed_out_cta[:sign_in_link] != false %>
|
|
278
280
|
<%= link_to Studio.sign_in_label, signed_out_cta[:sign_in_path] || login_path, class: "text-xs font-semibold text-secondary hover:text-primary whitespace-nowrap transition", data: { nav_sign_in: "" } %>
|
|
279
281
|
<% end %>
|
|
282
|
+
<%# The rail's order at this width too: the theme toggle, then the gears. %>
|
|
283
|
+
<%= render "components/theme_toggle_morph" %>
|
|
280
284
|
<%= render "components/link_sidebar_trigger" if sidebar_active %>
|
|
281
285
|
<%# Studio.navbar_menu_trigger takes the admin dropdown's seat here too: its
|
|
282
286
|
second render (the rail is the first), so the partial carries no ids. %>
|
|
@@ -286,7 +290,6 @@
|
|
|
286
290
|
<% else %>
|
|
287
291
|
<%= render "components/admin_dropdown" unless sidebar_active && studio_sidebar_replaces_admin_menu? %>
|
|
288
292
|
<% end %>
|
|
289
|
-
<%= render "components/theme_toggle_morph" %>
|
|
290
293
|
</span>
|
|
291
294
|
</div>
|
|
292
295
|
</header>
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
<%# The session-drift stamp + browser store. Renders nothing unless the controller
|
|
31
31
|
includes Studio::ErrorHandling. See studio/_session_stamp.
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
AN ENGINE SCRIPT THAT STAYS CLASSIC, AND SYNCHRONOUS. Host code
|
|
34
34
|
registers identity sources with window.StudioSession as its own modules
|
|
35
35
|
EVALUATE (turf-monster registers its identity source from a module at
|
|
36
36
|
import; CHANGELOG, "Held"), and the host's entry point is the module tag
|
|
@@ -39,6 +39,14 @@
|
|
|
39
39
|
silently be null. It becomes a module when those consumers wait for it. %>
|
|
40
40
|
<%= render "studio/session_stamp" %>
|
|
41
41
|
<%= render "layouts/studio/smooth_load" %>
|
|
42
|
+
<%# A FULL PAGE LOAD PLAYS THE SAME TRANSITION, only with the setting on: an app without it
|
|
43
|
+
gets neither tag. The sheet carries the at-rule that takes no selector, so it cannot sit in
|
|
44
|
+
the engine's bundle. The script is classic and synchronous, the second one here, because
|
|
45
|
+
pagereveal fires before the first render, ahead of every module. docs/PAGE_TRANSITIONS.md. %>
|
|
46
|
+
<% if @studio_page_transition_direction || Studio.page_transition_direction %>
|
|
47
|
+
<%= stylesheet_link_tag "studio/page_transition_full_load" %>
|
|
48
|
+
<%= javascript_include_tag "studio/page_transition_reveal", nonce: studio_script_nonce %>
|
|
49
|
+
<% end %>
|
|
42
50
|
<%# The favicon's URL carries its content version (Studio::PublicFileVersion),
|
|
43
51
|
so a redrawn favicon is a new URL no browser has cached. %>
|
|
44
52
|
<link rel="icon" type="image/png" href="<%= Studio::PublicFileVersion.path("/favicon.png") %>">
|
|
@@ -197,7 +205,8 @@
|
|
|
197
205
|
take every bare specifier on the page down with it. The ones below that load by
|
|
198
206
|
URL (type="module" with a src) are external files, not inline scripts: they
|
|
199
207
|
carry no body and need no nonce under a policy that allows 'self'. The session
|
|
200
|
-
store (studio/_session_stamp, above)
|
|
208
|
+
store (studio/_session_stamp, above) and the full-load script above are the two
|
|
209
|
+
classic scripts, and each says why.
|
|
201
210
|
A MODULE LOADED BY URL MUST IMPORT NO BARE SPECIFIER. javascript_include_tag
|
|
202
211
|
also sends a `Link: rel=modulepreload` response header, which the browser
|
|
203
212
|
acts on before it parses this map, so a bare import in that module can
|
|
@@ -208,6 +217,12 @@
|
|
|
208
217
|
controllers, the pinned stack, and the Alpine shims. A module tag, so it
|
|
209
218
|
carries the nonce. %>
|
|
210
219
|
<%= javascript_import_module_tag "studio/application" %>
|
|
220
|
+
<%# THE PAGE TRANSITION, BY ITS OWN DOOR, only with the setting on: an app without
|
|
221
|
+
it fetches nothing and renders these bytes as before. Not in the boot graph, so
|
|
222
|
+
it is not preloaded either. Its listeners are bound before the first visit. %>
|
|
223
|
+
<% if @studio_page_transition_direction || Studio.page_transition_direction %>
|
|
224
|
+
<%= javascript_import_module_tag "studio/page_transitions" %>
|
|
225
|
+
<% end %>
|
|
211
226
|
<%# THE STIMULUS APPLICATION, BY ITS OWN DOOR. studio/stimulus starts the engine's
|
|
212
227
|
Stimulus application and registers the hold button on it. Its only imports
|
|
213
228
|
are Stimulus, studio/lazy_controllers and the hold button's controller, so
|
|
@@ -8,8 +8,17 @@
|
|
|
8
8
|
Transition styling ships in engine.css ("Smooth-load convention" block);
|
|
9
9
|
pin an app's sticky header with the .vt-pinned-header utility (exactly one
|
|
10
10
|
element per page — a duplicate view-transition-name silently disables the
|
|
11
|
-
whole transition).
|
|
12
|
-
|
|
11
|
+
whole transition).
|
|
12
|
+
|
|
13
|
+
Studio.page_transition_direction is the one switch for the app-to-app page
|
|
14
|
+
transition (docs/PAGE_TRANSITIONS.md): set, it turns all of this on, whatever
|
|
15
|
+
Studio.smooth_load says. The ivar lets one request set it without writing the
|
|
16
|
+
process-wide setting (the e2e lab). %>
|
|
17
|
+
<% page_transition_direction = @studio_page_transition_direction || Studio.page_transition_direction %>
|
|
18
|
+
<% if Studio.smooth_load || page_transition_direction %>
|
|
13
19
|
<meta name="view-transition" content="same-origin">
|
|
14
20
|
<meta name="turbo-cache-control" content="no-preview">
|
|
15
21
|
<% end %>
|
|
22
|
+
<% if page_transition_direction %>
|
|
23
|
+
<meta name="page-transition-direction" content="<%= page_transition_direction %>">
|
|
24
|
+
<% end %>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<%# locals: (title:, meta: nil, meta_title: nil, label: nil, tabs: [], tabs_data: {}, actions_html: nil, data: {}, row_class: "-mx-4 px-4") %>
|
|
2
|
+
<%# THE PAGE HEADER: the second layer of the triple stack (docs/PAGE_HEADER.md).
|
|
3
|
+
navbar, then this row, then a table's sticky header, each below the last.
|
|
4
|
+
|
|
5
|
+
AT REST it has no background and no border: a title on the left, tabs on the
|
|
6
|
+
right. ONCE STUCK under the navbar it takes the bar. Its box never changes
|
|
7
|
+
between the two, so a state change moves nothing and republishes nothing.
|
|
8
|
+
|
|
9
|
+
title the page's one h1.
|
|
10
|
+
meta short text beside the title, such as the version. Hidden on a
|
|
11
|
+
phone, where the tabs need the width.
|
|
12
|
+
meta_title the meta's title attribute.
|
|
13
|
+
label the nav's accessible name. A row with tabs needs it.
|
|
14
|
+
tabs [{ label:, href:, current:, data: }]. `current` is the
|
|
15
|
+
aria-current value ("page" or "true"), and blank on the others.
|
|
16
|
+
tabs_data data attributes for the tabs' scroller.
|
|
17
|
+
actions_html a control on the right, for a row with no tabs.
|
|
18
|
+
data data attributes for the row, such as the page's own controller.
|
|
19
|
+
row_class the bleed. The default fits a container with px-4; pass "" in a
|
|
20
|
+
container with no gutter of its own.
|
|
21
|
+
|
|
22
|
+
NOT components/_page_header: a host's view wins over the engine's at the same
|
|
23
|
+
path, and the hub has its own with other locals.
|
|
24
|
+
|
|
25
|
+
THE ROW IS NEVER A <header>. studio/pinned_stack takes the document's first
|
|
26
|
+
<header> as the navbar, so on a page with no navbar this row would publish as
|
|
27
|
+
--nav-bottom.
|
|
28
|
+
|
|
29
|
+
IT JOINS THE STACK WITH data-pin="page" AND POSITIONS OFF --pin-page-top, the
|
|
30
|
+
bottom of everything above it. --pin-stack-bottom includes the row's own edge,
|
|
31
|
+
so a row positioned off it would chase itself down the page. Anything below,
|
|
32
|
+
such as the sticky table header's clone, sits under the row with no edit.
|
|
33
|
+
|
|
34
|
+
THE SENTINEL stays at the row's place in the flow, and the row's distance from
|
|
35
|
+
it is what "stuck" means, on every kind of header. It is absolutely positioned,
|
|
36
|
+
so a space-y parent adds nothing for it. It carries the controller, so the row
|
|
37
|
+
keeps its data-studio-controller for the page's own. So the row carries no top
|
|
38
|
+
margin: the space above it belongs to its container. %>
|
|
39
|
+
<div class="page-header-sentinel" data-studio-controller="page-header" aria-hidden="true"></div>
|
|
40
|
+
<%= content_tag(tabs.any? ? :nav : :div,
|
|
41
|
+
class: "page-header sticky #{row_class}".strip,
|
|
42
|
+
style: "top: var(--pin-page-top, var(--nav-bottom, var(--nav-h, 0px))); z-index: var(--z-sticky, 30)",
|
|
43
|
+
aria: { label: label.presence },
|
|
44
|
+
data: data.merge(pin: "page")) do %>
|
|
45
|
+
<div class="page-header-lead">
|
|
46
|
+
<h1 class="page-header-title"><%= title %></h1>
|
|
47
|
+
<% if meta.present? %>
|
|
48
|
+
<p class="page-header-meta label-upper"<%= %( title="#{ERB::Util.h(meta_title)}").html_safe if meta_title.present? %>><%= meta %></p>
|
|
49
|
+
<% end %>
|
|
50
|
+
</div>
|
|
51
|
+
<% if tabs.any? %>
|
|
52
|
+
<%= tag.div(class: "page-header-tabs", data: tabs_data) do %>
|
|
53
|
+
<div class="page-header-track">
|
|
54
|
+
<% tabs.each do |tab| %>
|
|
55
|
+
<%= link_to tab.fetch(:label), tab.fetch(:href),
|
|
56
|
+
class: "btn btn-sm #{tab[:current].present? ? 'btn-primary' : 'btn-neutral'}",
|
|
57
|
+
aria: { current: tab[:current].presence },
|
|
58
|
+
data: tab.fetch(:data, {}) %>
|
|
59
|
+
<% end %>
|
|
60
|
+
</div>
|
|
61
|
+
<% end %>
|
|
62
|
+
<% elsif actions_html.present? %>
|
|
63
|
+
<div class="page-header-actions"><%= actions_html %></div>
|
|
64
|
+
<% end %>
|
|
65
|
+
<% end %>
|