studio-engine 0.93.0 → 0.94.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 +61 -0
- data/app/javascript/studio/alpine_shims.js +45 -0
- data/app/javascript/studio/alpine_stores.js +82 -0
- data/app/javascript/studio/application.js +37 -0
- data/app/javascript/studio/controllers/nav_collapse_controller.js +28 -0
- data/app/javascript/studio/head_chrome.js +79 -0
- data/app/javascript/studio/nav_collapse.js +238 -0
- data/app/javascript/studio/pinned_stack.js +306 -0
- data/app/javascript/studio/vendor/stimulus.js +5 -0
- data/app/views/layouts/_navbar.html.erb +14 -6
- data/app/views/layouts/studio/_head.html.erb +59 -567
- data/config/importmap.rb +14 -3
- data/lib/studio/engine.rb +26 -0
- data/lib/studio/version.rb +1 -1
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 32e6d36ae904992ea14289647084091b399296ebc27daf777e45620ad126b772
|
|
4
|
+
data.tar.gz: 2c026cd62fca795d60fe39976ca9b47313103afdee12bff1ce590961a5d9ff6f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ac374e2ec4e47bce46d2c17367c776dc33388ed8f6559ec1c6c8646eb336530d6c00d91047ebcf9f52afe89f06f7618db7693229b1e78bc47afba8fc3a6c7383
|
|
7
|
+
data.tar.gz: b2f4a82578a5f3e2dac0a9a649c992103921dc376ce439364a3f944e01f7880476a72a49dce125b24860214ff6180802cbf9ea11b3585f4c30d1dfee0edeecc7
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,67 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.94.0 — 2026-10-07
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **The engine boots its own Stimulus application** (`studio/application`),
|
|
12
|
+
imported on every page by `layouts/studio/_head` through
|
|
13
|
+
`javascript_import_module_tag`, which carries the request's CSP nonce. Engine
|
|
14
|
+
application reads its own attributes (`data-studio-controller`,
|
|
15
|
+
`data-studio-action`, `data-studio-target`), so a host's Stimulus application
|
|
16
|
+
and its lazy loader never see an engine controller. Stimulus 3.2.2 is vendored
|
|
17
|
+
(`studio/vendor/stimulus.js`) and pinned as `@hotwired/stimulus`; a host that
|
|
18
|
+
pins its own wins. The boot's module graph is preloaded
|
|
19
|
+
(`Studio::Engine.javascript_boot_graph`); every other engine pin is still
|
|
20
|
+
fetched only when imported.
|
|
21
|
+
- **`nav-collapse`**, the navbar collapse as a Stimulus controller
|
|
22
|
+
(`data-studio-controller="nav-collapse"`). The engine navbar uses it;
|
|
23
|
+
`data-nav-collapse-scrolled-class` names the
|
|
24
|
+
classes it toggles when the shadow's hysteresis flips.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **The head's behaviour moves from inline scripts to ES modules.**
|
|
29
|
+
`layouts/studio/_head` goes from 714 lines and seven inline scripts to 197
|
|
30
|
+
lines and one: the pre-paint theme script, which now carries the request's
|
|
31
|
+
CSP nonce. The navbar collapse is `studio/nav_collapse`, the pinned stack is
|
|
32
|
+
`studio/pinned_stack`, and the theme and devMode stores, nav spinner and
|
|
33
|
+
success confetti are `studio/head_chrome`, each with `node:test` unit tests.
|
|
34
|
+
`window.navCollapse`, `window.showNavSpinner`, `window.hideNavSpinner`,
|
|
35
|
+
`window.fireSuccessConfetti`, `$store.theme` and `$store.devMode` keep their
|
|
36
|
+
names as thin shims (`studio/alpine_shims`); a host that defines its own
|
|
37
|
+
`window.navCollapse` keeps it.
|
|
38
|
+
- **Alpine loads after the module tags.** So the shims exist before Alpine
|
|
39
|
+
starts, the head now loads Alpine after `javascript_importmap_tags`. A host
|
|
40
|
+
module therefore evaluates before `window.Alpine` exists: register Alpine
|
|
41
|
+
data and stores in an `alpine:init` listener (which now fires for host
|
|
42
|
+
modules), not at module evaluation. Inline scripts in the page are unaffected.
|
|
43
|
+
- **The engine navbar's header binds `data-studio-controller="nav-collapse"`**
|
|
44
|
+
in place of `x-data="navCollapse()"`, and keeps a bare `x-data` as the Alpine
|
|
45
|
+
scope its descendants bind through. A forked header may keep
|
|
46
|
+
`x-data="navCollapse()"`.
|
|
47
|
+
- Studio.nav_spinner_min_ms reaches the browser as
|
|
48
|
+
`<meta name="studio-nav-spinner-min-ms">`.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- **The theme and devMode Alpine stores survive a boot that fails to load.**
|
|
53
|
+
They move to `studio/alpine_stores`, a module that imports nothing, and
|
|
54
|
+
`layouts/studio/_head` imports it by its own `javascript_import_module_tag`
|
|
55
|
+
(nonced, before Alpine) as well as through the boot's graph. So when
|
|
56
|
+
`studio/application` fails to load, a host's
|
|
57
|
+
`:class="{ 'dev-mode': $store.devMode }"` body binding and the theme toggle
|
|
58
|
+
still find their stores instead of throwing. A store registers only where
|
|
59
|
+
Alpine has none of that name, so the two paths never register twice and a
|
|
60
|
+
host's own store wins. `storedTheme` and `toggleTheme` move from
|
|
61
|
+
`studio/head_chrome` to `studio/alpine_stores`.
|
|
62
|
+
|
|
63
|
+
### Breaking
|
|
64
|
+
|
|
65
|
+
- **`window._navSpinnerShownAt` and `window._navSpinnerMinMs` are removed.**
|
|
66
|
+
The spinner's state is private to `studio/head_chrome`; no consumer reads them.
|
|
67
|
+
|
|
7
68
|
## 0.93.0 — 2026-10-07
|
|
8
69
|
|
|
9
70
|
## 0.92.2 — 2026-10-07
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// studio/alpine_shims: the Alpine names and window globals consumers still
|
|
2
|
+
// bind to, each a thin delegate to the module that now owns the behaviour.
|
|
3
|
+
//
|
|
4
|
+
// They exist while a consumer binds to them, and go with the consumer phase of
|
|
5
|
+
// the Stimulus migration. Every binding site is listed on the task
|
|
6
|
+
// engine-behaviour-moves-to-stimulus.
|
|
7
|
+
//
|
|
8
|
+
// WHY THIS WORKS BEFORE ALPINE STARTS. layouts/studio/_head loads Alpine AFTER
|
|
9
|
+
// the module tags, and deferred classic scripts and module scripts execute in
|
|
10
|
+
// document order. So this module has run, and its `alpine:init` listener is
|
|
11
|
+
// registered, before Alpine evaluates its first x-data.
|
|
12
|
+
//
|
|
13
|
+
// THE STORES ARE NOT HERE. $store.theme and $store.devMode are
|
|
14
|
+
// studio/alpine_stores, which the head also loads by its own module tag, so a
|
|
15
|
+
// host's body binding survives a boot that failed to load. Importing it here
|
|
16
|
+
// keeps it in the boot graph, preloaded; it installs itself on evaluation.
|
|
17
|
+
import "studio/alpine_stores"
|
|
18
|
+
import { NavCollapse } from "studio/nav_collapse"
|
|
19
|
+
import {
|
|
20
|
+
showNavSpinner, hideNavSpinner, installSpinnerReset, fireSuccessConfetti
|
|
21
|
+
} from "studio/head_chrome"
|
|
22
|
+
|
|
23
|
+
// x-data="navCollapse()": the hub's own header. A host that defines its own
|
|
24
|
+
// window.navCollapse (turf-monster does, inline, before this runs) keeps it.
|
|
25
|
+
export function navCollapse() {
|
|
26
|
+
return {
|
|
27
|
+
scrolled: false,
|
|
28
|
+
init: function () {
|
|
29
|
+
var self = this
|
|
30
|
+
this._collapse = new NavCollapse(this.$el, function (lit) { self.scrolled = lit })
|
|
31
|
+
this._collapse.start()
|
|
32
|
+
},
|
|
33
|
+
destroy: function () {
|
|
34
|
+
if (this._collapse) this._collapse.stop()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function installAlpineShims() {
|
|
40
|
+
if (!window.navCollapse) window.navCollapse = navCollapse
|
|
41
|
+
if (!window.showNavSpinner) window.showNavSpinner = showNavSpinner
|
|
42
|
+
if (!window.hideNavSpinner) window.hideNavSpinner = hideNavSpinner
|
|
43
|
+
if (!window.fireSuccessConfetti) window.fireSuccessConfetti = fireSuccessConfetti
|
|
44
|
+
installSpinnerReset()
|
|
45
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// studio/alpine_stores: the Alpine stores every host body binds to,
|
|
2
|
+
// $store.theme and $store.devMode, and the theme value and toggle behind them.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS IS ITS OWN ENTRY POINT. Every host's <body> says
|
|
5
|
+
// :class="{ 'dev-mode': $store.devMode }", and the engine's theme toggle reads
|
|
6
|
+
// $store.theme. When those stores lived only inside studio/application's graph,
|
|
7
|
+
// any failure to load that graph (a missing digest after a deploy, a network
|
|
8
|
+
// drop, a throw in Stimulus or a controller) left Alpine evaluating the body
|
|
9
|
+
// binding against an undefined store, and it threw on every page.
|
|
10
|
+
//
|
|
11
|
+
// So the stores load by two paths, and either one alone is enough:
|
|
12
|
+
//
|
|
13
|
+
// 1. layouts/studio/_head imports this module with its own
|
|
14
|
+
// javascript_import_module_tag, which carries the request's CSP nonce.
|
|
15
|
+
// A failed studio/application does not stop it.
|
|
16
|
+
// 2. studio/alpine_shims imports it, so the boot graph carries it too and the
|
|
17
|
+
// module is preloaded with the rest of the boot.
|
|
18
|
+
//
|
|
19
|
+
// Both resolve to one module instance, and the install below is idempotent
|
|
20
|
+
// regardless: one alpine:init listener per document, and a store is registered
|
|
21
|
+
// only when Alpine does not have one of that name (so a host's own wins).
|
|
22
|
+
//
|
|
23
|
+
// THIS MODULE IMPORTS NOTHING, and test/javascript/alpine_stores.test.mjs holds
|
|
24
|
+
// it to that: an import would put another module's failure back in its path.
|
|
25
|
+
//
|
|
26
|
+
// The pre-paint half of the theme (adding `dark` before first paint) stays an
|
|
27
|
+
// inline, nonced script in the head: it must run before the stylesheet paints,
|
|
28
|
+
// which no deferred module can.
|
|
29
|
+
|
|
30
|
+
// The stored theme, dark unless the reader chose light.
|
|
31
|
+
export function storedTheme(storage) {
|
|
32
|
+
return storage.getItem('theme') || 'dark'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Flips the root's `dark` class under a short transition class, stores the
|
|
36
|
+
// result, and answers it.
|
|
37
|
+
export function toggleTheme(root, storage, later) {
|
|
38
|
+
root.classList.add('theme-transition')
|
|
39
|
+
root.classList.toggle('dark')
|
|
40
|
+
var value = root.classList.contains('dark') ? 'dark' : 'light'
|
|
41
|
+
storage.setItem('theme', value)
|
|
42
|
+
later(function () { root.classList.remove('theme-transition') }, 300)
|
|
43
|
+
return value
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Registers each store Alpine does not already have. env carries what the
|
|
47
|
+
// stores read and write: { storage, root, later }.
|
|
48
|
+
export function registerStudioStores(Alpine, env) {
|
|
49
|
+
if (Alpine.store('devMode') === undefined) {
|
|
50
|
+
Alpine.store('devMode', env.storage.getItem('devMode') === 'true')
|
|
51
|
+
}
|
|
52
|
+
if (Alpine.store('theme') === undefined) {
|
|
53
|
+
Alpine.store('theme', {
|
|
54
|
+
value: storedTheme(env.storage),
|
|
55
|
+
get isDark() { return this.value === 'dark' },
|
|
56
|
+
toggle: function () {
|
|
57
|
+
this.value = toggleTheme(env.root, env.storage, env.later)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Adds the alpine:init listener that registers the stores, once per document.
|
|
64
|
+
// It must run before Alpine starts: the head loads Alpine after the module
|
|
65
|
+
// tags, and deferred and module scripts run in document order.
|
|
66
|
+
var installedOn = new WeakSet()
|
|
67
|
+
|
|
68
|
+
export function installStudioStores(doc, win) {
|
|
69
|
+
if (installedOn.has(doc)) return
|
|
70
|
+
installedOn.add(doc)
|
|
71
|
+
doc.addEventListener('alpine:init', function () {
|
|
72
|
+
registerStudioStores(win.Alpine, {
|
|
73
|
+
storage: win.localStorage,
|
|
74
|
+
root: doc.documentElement,
|
|
75
|
+
later: function (fn, ms) { win.setTimeout(fn, ms) }
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (typeof document !== 'undefined' && typeof window !== 'undefined') {
|
|
81
|
+
installStudioStores(document, window)
|
|
82
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// studio/application: the engine's browser boot, imported on every page by
|
|
2
|
+
// layouts/studio/_head (javascript_import_module_tag, which carries the
|
|
3
|
+
// request's CSP nonce).
|
|
4
|
+
//
|
|
5
|
+
// The engine runs its own Stimulus application on its OWN attributes:
|
|
6
|
+
//
|
|
7
|
+
// data-studio-controller="nav-collapse" data-studio-action data-studio-target
|
|
8
|
+
//
|
|
9
|
+
// so a host's Stimulus application never sees an engine controller. That is not
|
|
10
|
+
// tidiness: stimulus-loading's lazy loader (cyvasse) imports
|
|
11
|
+
// "controllers/<identifier>_controller" for every data-controller it meets, and
|
|
12
|
+
// would log "Failed to autoload controller" for each engine element. Values and
|
|
13
|
+
// classes keep Stimulus' own naming (data-nav-collapse-scrolled-class).
|
|
14
|
+
// "@hotwired/stimulus" resolves to the engine's vendored copy unless the host
|
|
15
|
+
// pins its own.
|
|
16
|
+
//
|
|
17
|
+
// Everything this file imports statically is preloaded on every page
|
|
18
|
+
// (Studio::Engine.javascript_boot_graph), so the boot costs one round trip.
|
|
19
|
+
import { Application, defaultSchema } from "@hotwired/stimulus"
|
|
20
|
+
import NavCollapseController from "studio/controllers/nav_collapse_controller"
|
|
21
|
+
import { startPinnedStack } from "studio/pinned_stack"
|
|
22
|
+
import { installAlpineShims } from "studio/alpine_shims"
|
|
23
|
+
|
|
24
|
+
installAlpineShims()
|
|
25
|
+
startPinnedStack()
|
|
26
|
+
|
|
27
|
+
export const schema = {
|
|
28
|
+
...defaultSchema,
|
|
29
|
+
controllerAttribute: "data-studio-controller",
|
|
30
|
+
actionAttribute: "data-studio-action",
|
|
31
|
+
targetAttribute: "data-studio-target"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const application = Application.start(document.documentElement, schema)
|
|
35
|
+
application.register("nav-collapse", NavCollapseController)
|
|
36
|
+
|
|
37
|
+
export { application }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// nav-collapse: the scroll-linked navbar collapse on a <header>, on the engine's
|
|
2
|
+
// Stimulus application (studio/application, which reads data-studio-controller).
|
|
3
|
+
//
|
|
4
|
+
// <header class="nav-shell ..." data-studio-controller="nav-collapse"
|
|
5
|
+
// data-nav-collapse-scrolled-class="shadow-lg is-scrolled">
|
|
6
|
+
//
|
|
7
|
+
// The behaviour is studio/nav_collapse; this controller binds it to the
|
|
8
|
+
// element's lifetime and toggles the `scrolled` classes when the shadow's
|
|
9
|
+
// hysteresis flips. Registered by studio/application.
|
|
10
|
+
import { Controller } from "@hotwired/stimulus"
|
|
11
|
+
import { NavCollapse } from "studio/nav_collapse"
|
|
12
|
+
|
|
13
|
+
export default class extends Controller {
|
|
14
|
+
static classes = ["scrolled"]
|
|
15
|
+
|
|
16
|
+
connect() {
|
|
17
|
+
this.collapse = new NavCollapse(this.element, (lit) => {
|
|
18
|
+
if (!this.hasScrolledClass) return
|
|
19
|
+
for (const name of this.scrolledClasses) this.element.classList.toggle(name, lit)
|
|
20
|
+
})
|
|
21
|
+
this.collapse.start()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
disconnect() {
|
|
25
|
+
if (this.collapse) this.collapse.stop()
|
|
26
|
+
this.collapse = null
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// studio/head_chrome: the small behaviours every page's chrome carries. Each
|
|
2
|
+
// piece below used to be an inline <script> in layouts/studio/_head.
|
|
3
|
+
//
|
|
4
|
+
// nav spinner the scale morph between the theme toggle and a spinner
|
|
5
|
+
// confetti the success burst a completed flow fires
|
|
6
|
+
//
|
|
7
|
+
// The logic is exported for test/javascript/head_chrome.test.mjs. The window
|
|
8
|
+
// globals that consumers bind to are installed by studio/alpine_shims. The
|
|
9
|
+
// theme and its Alpine store are studio/alpine_stores, which loads even when
|
|
10
|
+
// this module does not.
|
|
11
|
+
|
|
12
|
+
// ---- NAV SPINNER ------------------------------------------------------------
|
|
13
|
+
//
|
|
14
|
+
// Minimum display time prevents quick flashes; per app via
|
|
15
|
+
// Studio.nav_spinner_min_ms, which the head publishes as
|
|
16
|
+
// <meta name="studio-nav-spinner-min-ms"> (smooth-load apps drop it to ~300).
|
|
17
|
+
|
|
18
|
+
// How long hide must still wait so the spinner shows for at least minMs.
|
|
19
|
+
export function spinnerHideDelay(shownAt, now, minMs) {
|
|
20
|
+
return Math.max(0, minMs - (now - shownAt))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function spinnerMinMs(doc) {
|
|
24
|
+
var meta = doc.querySelector('meta[name="studio-nav-spinner-min-ms"]')
|
|
25
|
+
var value = meta ? parseInt(meta.getAttribute('content'), 10) : 0
|
|
26
|
+
return value > 0 ? value : 0
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
var spinnerShownAt = 0
|
|
30
|
+
|
|
31
|
+
function showToggle(doc) {
|
|
32
|
+
doc.querySelectorAll('.nav-toggle-icon').forEach(function (e) { e.style.opacity = '1'; e.style.transform = 'scale(1) rotate(0deg)' })
|
|
33
|
+
doc.querySelectorAll('.nav-spinner-icon').forEach(function (e) { e.style.opacity = '0'; e.style.transform = 'scale(0) rotate(-90deg)' })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function showNavSpinner() {
|
|
37
|
+
spinnerShownAt = Date.now()
|
|
38
|
+
document.querySelectorAll('.nav-toggle-icon').forEach(function (e) { e.style.opacity = '0'; e.style.transform = 'scale(0) rotate(90deg)' })
|
|
39
|
+
document.querySelectorAll('.nav-spinner-icon').forEach(function (e) { e.style.opacity = '1'; e.style.transform = 'scale(1) rotate(0deg)' })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function hideNavSpinner() {
|
|
43
|
+
var wait = spinnerHideDelay(spinnerShownAt, Date.now(), spinnerMinMs(document))
|
|
44
|
+
setTimeout(function () { showToggle(document) }, wait)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Reset spinner state before Turbo caches the page. Installed once.
|
|
48
|
+
var spinnerResetInstalled = false
|
|
49
|
+
export function installSpinnerReset() {
|
|
50
|
+
if (spinnerResetInstalled) return
|
|
51
|
+
spinnerResetInstalled = true
|
|
52
|
+
document.addEventListener('turbo:before-cache', function () { showToggle(document) })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---- SUCCESS CONFETTI -------------------------------------------------------
|
|
56
|
+
//
|
|
57
|
+
// Rides the global `confetti` that studio/canvas_confetti defines; does
|
|
58
|
+
// nothing when it is absent. window.CONFETTI_COLORS overrides the palette.
|
|
59
|
+
|
|
60
|
+
export var SUCCESS_COLORS = ['#4BAF50', '#8E82FE', '#06D6A0', '#FF7C47', '#FFD700', '#00BFFF', '#FF6B9D', '#C084FC']
|
|
61
|
+
|
|
62
|
+
// The four bursts, each as [delay ms, confetti options].
|
|
63
|
+
export function successBursts(colors) {
|
|
64
|
+
return [
|
|
65
|
+
[0, { particleCount: 150, spread: 100, origin: { x: 0.5, y: 0.5 }, colors: colors, zIndex: 9999, startVelocity: 45, gravity: 0.8, ticks: 300, scalar: 1.2 }],
|
|
66
|
+
[150, { particleCount: 80, angle: 60, spread: 60, origin: { x: 0, y: 0.6 }, colors: colors, zIndex: 9999, startVelocity: 55, gravity: 1, ticks: 250 }],
|
|
67
|
+
[150, { particleCount: 80, angle: 120, spread: 60, origin: { x: 1, y: 0.6 }, colors: colors, zIndex: 9999, startVelocity: 55, gravity: 1, ticks: 250 }],
|
|
68
|
+
[400, { particleCount: 100, spread: 160, origin: { x: 0.5, y: 0.3 }, colors: colors, zIndex: 9999, startVelocity: 30, gravity: 1.2, ticks: 200, scalar: 0.8 }]
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function fireSuccessConfetti() {
|
|
73
|
+
if (typeof window.confetti === 'undefined') return
|
|
74
|
+
var fire = window.confetti
|
|
75
|
+
successBursts(window.CONFETTI_COLORS || SUCCESS_COLORS).forEach(function (burst) {
|
|
76
|
+
if (burst[0] === 0) fire(burst[1])
|
|
77
|
+
else setTimeout(function () { fire(burst[1]) }, burst[0])
|
|
78
|
+
})
|
|
79
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// studio/nav_collapse: the scroll-linked navbar collapse.
|
|
2
|
+
//
|
|
3
|
+
// Publishes --nav-p on the <header> once per animation frame from
|
|
4
|
+
// window.scrollY. The navbar's own stylesheet derives every collapsing
|
|
5
|
+
// dimension from it with calc(), so the header moves only in a frame the
|
|
6
|
+
// finger moved it, and stops the instant the finger does.
|
|
7
|
+
//
|
|
8
|
+
// It replaces `@scroll.window="scrolled = scrolled ? (scrollY > 5) : (scrollY
|
|
9
|
+
// > 60)"` plus `transition-all duration-300`. That pair let the FINGER set a
|
|
10
|
+
// step and an ease curve own everything after it. Measured in turf-monster at
|
|
11
|
+
// 390x844 before the change: the header ran 178px -> 139px and 34 of those
|
|
12
|
+
// 39px of document reflow landed AFTER the scroll had stopped, over 232ms, at
|
|
13
|
+
// up to 3px per frame of content nobody asked to move -- plus a 1px REVERSE
|
|
14
|
+
// lurch in the frame the class flipped, where a discrete text-3xl -> text-xl
|
|
15
|
+
// swap collided with the stylesheet's own `transition: font-size`.
|
|
16
|
+
//
|
|
17
|
+
// ADOPTING IT: put `nav-shell` and `data-studio-controller="nav-collapse"` on
|
|
18
|
+
// the header (studio/controllers/nav_collapse_controller), give each breakpoint
|
|
19
|
+
// band a `--nav-ramp`, and write the collapsing dimensions as calc()s off
|
|
20
|
+
// --nav-p. A header that still says `x-data="navCollapse()"` gets the same
|
|
21
|
+
// mechanism through the Alpine shim (studio/alpine_shims). This file ships NO
|
|
22
|
+
// sizing opinion, so an app whose navbar collapses to different endpoints than
|
|
23
|
+
// the engine's adopts the mechanism without touching its markup.
|
|
24
|
+
//
|
|
25
|
+
// Four details are load-bearing:
|
|
26
|
+
//
|
|
27
|
+
// passive + rAF: the listener never blocks the compositor and coalesces a
|
|
28
|
+
// burst of scroll events (iOS momentum fires far above 60Hz) into one write
|
|
29
|
+
// per frame. The write lands on the HEADER, not :root: an inherited custom
|
|
30
|
+
// property written on :root dirties style for the whole document every
|
|
31
|
+
// frame, and it would leak the live page's scroll progress into the preview
|
|
32
|
+
// headers on /navbar.
|
|
33
|
+
//
|
|
34
|
+
// the smoothstep: collapsing a sticky, IN-FLOW header pulls the page up,
|
|
35
|
+
// so during the collapse content moves by the scroll AND by the shrink:
|
|
36
|
+
// faster than the finger, always. That is inherent; reclaiming the vertical
|
|
37
|
+
// space is the point. What is tunable is the shape of the burst. --nav-ramp
|
|
38
|
+
// is sized at 3x the band's collapse total and the ramp is smoothstepped,
|
|
39
|
+
// whose slope is zero at both ends, so content speed LEAVES 1x, peaks near
|
|
40
|
+
// 1.5x mid-ramp, and returns to 1x with no velocity step. A linear ramp
|
|
41
|
+
// equal to the collapse hits 2x and steps straight back to 1x.
|
|
42
|
+
//
|
|
43
|
+
// the short-page guard: collapsing shortens the document by the collapse
|
|
44
|
+
// total. On a page with barely more than that to scroll, the collapse
|
|
45
|
+
// deletes the very scroll room that triggered it, the browser clamps
|
|
46
|
+
// scrollY to 0, and the navbar flaps open and shut forever. roomExpanded
|
|
47
|
+
// adds back the shrink ALREADY applied, so the measurement cannot chase
|
|
48
|
+
// itself as it collapses.
|
|
49
|
+
//
|
|
50
|
+
// reduced motion: scroll-linked motion has no clock left to slow down, but
|
|
51
|
+
// resizing type under a moving finger is itself the motion some readers are
|
|
52
|
+
// asking us to drop. Under the query --nav-p snaps 0/1 on the old
|
|
53
|
+
// 60/5 hysteresis instead of interpolating.
|
|
54
|
+
|
|
55
|
+
export const DEFAULT_RAMP = 144;
|
|
56
|
+
export const DEFAULT_MAX_STEP = 5;
|
|
57
|
+
|
|
58
|
+
// ONE FRAME OF THE COLLAPSE, as a pure function of what the frame measured.
|
|
59
|
+
//
|
|
60
|
+
// y window.scrollY, already clamped at 0
|
|
61
|
+
// p the progress published last frame, 0..1
|
|
62
|
+
// ramp --nav-ramp in px
|
|
63
|
+
// maxPx --nav-max-step in px of header travel per frame
|
|
64
|
+
// scrollHeight document.documentElement.scrollHeight
|
|
65
|
+
// innerHeight window.innerHeight
|
|
66
|
+
// reduce prefers-reduced-motion matches
|
|
67
|
+
//
|
|
68
|
+
// Answers { p, settling }: the progress to publish, and whether the frame
|
|
69
|
+
// clamp left it short of where the scroll position wants it (so the caller
|
|
70
|
+
// must schedule another frame).
|
|
71
|
+
export function collapseFrame({ y, p, ramp, maxPx, scrollHeight, innerHeight, reduce }) {
|
|
72
|
+
// The height the document WOULD have with the navbar expanded. The
|
|
73
|
+
// add-back is the whole trick; see the guard note above.
|
|
74
|
+
var roomExpanded = scrollHeight - innerHeight + ramp * p;
|
|
75
|
+
|
|
76
|
+
// WHERE THE COLLAPSE WANTS TO BE, from scroll position alone.
|
|
77
|
+
var target;
|
|
78
|
+
var snap = false;
|
|
79
|
+
if (roomExpanded < ramp + 24) {
|
|
80
|
+
target = 0;
|
|
81
|
+
snap = true;
|
|
82
|
+
} else if (reduce) {
|
|
83
|
+
target = (p > 0 ? y > 5 : y > 60) ? 1 : 0;
|
|
84
|
+
snap = true;
|
|
85
|
+
} else {
|
|
86
|
+
var t = Math.min(1, y / ramp);
|
|
87
|
+
target = t * t * (3 - 2 * t);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// THE RATE LIMIT: how far the collapse may travel in ONE frame.
|
|
91
|
+
//
|
|
92
|
+
// Position-linked progress fixed motion that OUTLIVED the gesture. It
|
|
93
|
+
// also guaranteed the opposite defect: if scrollY moves 90px between
|
|
94
|
+
// two frames, so does the header's whole range. Measured in
|
|
95
|
+
// turf-monster at 390x844, worst single-frame header height change by
|
|
96
|
+
// scroll profile:
|
|
97
|
+
//
|
|
98
|
+
// 8px/frame (slow, deliberate) 3.9px
|
|
99
|
+
// 24px/frame (normal swipe) 14.1px
|
|
100
|
+
// momentum flick 39.0px <- the ENTIRE collapse
|
|
101
|
+
// hard flick 39.0px
|
|
102
|
+
//
|
|
103
|
+
// So the TARGET stays position-linked and the STEP is clamped. What
|
|
104
|
+
// makes this safe is that a slow scroll never REACHES the clamp:
|
|
105
|
+
// under --nav-max-step of header travel per frame the branch below
|
|
106
|
+
// returns `target` untouched, so the slow feel is not approximated,
|
|
107
|
+
// it is the same arithmetic.
|
|
108
|
+
//
|
|
109
|
+
// maxStep is derived, not tuned per band: engine.css sizes --nav-ramp
|
|
110
|
+
// at 3x the band's collapse total, so a cap of MAX px/frame is
|
|
111
|
+
// 3*MAX/ramp in --nav-p units. Mobile (ramp 120) gives 4.9px/frame,
|
|
112
|
+
// desktop (ramp 144) 5.0px. Retune --nav-ramp without keeping that 3x
|
|
113
|
+
// relation and this cap silently drifts with it.
|
|
114
|
+
var next;
|
|
115
|
+
if (snap) {
|
|
116
|
+
// A guard refusal and a reduced-motion state are DECISIONS, not
|
|
117
|
+
// motion; ramping them would animate the very thing each exists to
|
|
118
|
+
// avoid.
|
|
119
|
+
next = target;
|
|
120
|
+
} else {
|
|
121
|
+
var maxStep = (3 * maxPx) / ramp;
|
|
122
|
+
var delta = target - p;
|
|
123
|
+
next = Math.abs(delta) <= maxStep
|
|
124
|
+
? target
|
|
125
|
+
: p + (delta > 0 ? maxStep : -maxStep);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { p: next, settling: !snap && next !== target };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The shadow is the one thing still on a clock, and it may stay there:
|
|
132
|
+
// box-shadow paints, it never reflows, so it cannot move content.
|
|
133
|
+
// Hysteresis keeps it from strobing at the boundary.
|
|
134
|
+
export function shadowLit(lit, y) {
|
|
135
|
+
return lit ? y > 5 : y > 60;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --nav-ramp and --nav-max-step off the header's computed style, each with
|
|
139
|
+
// its default when the band declares none.
|
|
140
|
+
export function readTuning(style) {
|
|
141
|
+
var raw = parseFloat(style.getPropertyValue('--nav-ramp'));
|
|
142
|
+
var step = parseFloat(style.getPropertyValue('--nav-max-step'));
|
|
143
|
+
return {
|
|
144
|
+
ramp: raw > 0 ? raw : DEFAULT_RAMP,
|
|
145
|
+
maxPx: step > 0 ? step : DEFAULT_MAX_STEP
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// THE BINDING: one header, its listeners, and the frame loop. The Stimulus
|
|
150
|
+
// controller and the Alpine shim both drive this, so the two cannot drift.
|
|
151
|
+
// onScrolled(lit) fires when the shadow's hysteresis flips.
|
|
152
|
+
export class NavCollapse {
|
|
153
|
+
constructor(el, onScrolled) {
|
|
154
|
+
this.el = el;
|
|
155
|
+
this.onScrolled = onScrolled || function () {};
|
|
156
|
+
this.p = 0;
|
|
157
|
+
this.scrolled = false;
|
|
158
|
+
this.queued = false;
|
|
159
|
+
this.stopped = false;
|
|
160
|
+
this.tuning = { ramp: DEFAULT_RAMP, maxPx: DEFAULT_MAX_STEP };
|
|
161
|
+
this._reduce = null;
|
|
162
|
+
this._onScroll = this.onScroll.bind(this);
|
|
163
|
+
this._onResize = this.onResize.bind(this);
|
|
164
|
+
this._apply = this.apply.bind(this);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
start() {
|
|
168
|
+
this.stopped = false;
|
|
169
|
+
this._reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
170
|
+
this.tuning = readTuning(getComputedStyle(this.el));
|
|
171
|
+
this.apply();
|
|
172
|
+
|
|
173
|
+
window.addEventListener('scroll', this._onScroll, { passive: true });
|
|
174
|
+
window.addEventListener('resize', this._onResize, { passive: true });
|
|
175
|
+
if (this._reduce.addEventListener) this._reduce.addEventListener('change', this._apply);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// A Turbo visit tears the header down and builds a new one; without this
|
|
179
|
+
// every visit would stack another listener on window.
|
|
180
|
+
stop() {
|
|
181
|
+
this.stopped = true;
|
|
182
|
+
window.removeEventListener('scroll', this._onScroll);
|
|
183
|
+
window.removeEventListener('resize', this._onResize);
|
|
184
|
+
if (this._reduce && this._reduce.removeEventListener) {
|
|
185
|
+
this._reduce.removeEventListener('change', this._apply);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
onScroll() {
|
|
190
|
+
if (this.queued) return;
|
|
191
|
+
this.queued = true;
|
|
192
|
+
requestAnimationFrame(this._apply);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
onResize() {
|
|
196
|
+
this.tuning = readTuning(getComputedStyle(this.el));
|
|
197
|
+
this.onScroll();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
apply() {
|
|
201
|
+
this.queued = false;
|
|
202
|
+
if (this.stopped) return;
|
|
203
|
+
// Clamped: rubber-band overscroll reports a NEGATIVE scrollY, and a
|
|
204
|
+
// negative progress inflates the navbar past its expanded size.
|
|
205
|
+
var y = Math.max(0, window.scrollY);
|
|
206
|
+
var frame = collapseFrame({
|
|
207
|
+
y: y,
|
|
208
|
+
p: this.p,
|
|
209
|
+
ramp: this.tuning.ramp,
|
|
210
|
+
maxPx: this.tuning.maxPx,
|
|
211
|
+
scrollHeight: document.documentElement.scrollHeight,
|
|
212
|
+
innerHeight: window.innerHeight,
|
|
213
|
+
reduce: this._reduce.matches
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
if (frame.p !== this.p) {
|
|
217
|
+
this.p = frame.p;
|
|
218
|
+
this.el.style.setProperty('--nav-p', frame.p.toFixed(4));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// KEEP FRAMES COMING WHILE CATCHING UP. Nothing else will schedule
|
|
222
|
+
// one: the finger is off, so no more scroll events arrive, and
|
|
223
|
+
// without this the collapse freezes wherever the clamp left it. It
|
|
224
|
+
// converges LINEARLY and lands exactly: about 8 frames (~133ms) at
|
|
225
|
+
// the mobile --nav-ramp of 120, and about 10 (~167ms) at the default
|
|
226
|
+
// 144, from a hard flick.
|
|
227
|
+
if (frame.settling) {
|
|
228
|
+
this.queued = true;
|
|
229
|
+
requestAnimationFrame(this._apply);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
var lit = shadowLit(this.scrolled, y);
|
|
233
|
+
if (lit !== this.scrolled) {
|
|
234
|
+
this.scrolled = lit;
|
|
235
|
+
this.onScrolled(lit);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|