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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3769d755a39684b1488e69948a2c671c736c655400bf035d6c69e8db5c990a8e
4
- data.tar.gz: 2941057ba862ce883d57e58f56d9dfae793c6ad0781802af6147848583604ecf
3
+ metadata.gz: 32e6d36ae904992ea14289647084091b399296ebc27daf777e45620ad126b772
4
+ data.tar.gz: 2c026cd62fca795d60fe39976ca9b47313103afdee12bff1ce590961a5d9ff6f
5
5
  SHA512:
6
- metadata.gz: 443ae3e30df171f7ff4614a82785a0e40f231dc11d792d3e36a8160b9d13d112bd1e55fc420759a564b02ec6b69d0f64406c9d7265eca2bf2885edb8f7be58a4
7
- data.tar.gz: 7f1b7c14d366cb9cd26f7c0a3caf8c5135d13ac79b359e8ca095047602657bf3a4eb28d5127fe13fcb5175137fe9fbb8844b370b89c13340bdbf4540679b8cc0
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
+ }