studio-engine 0.111.0.rc1 → 0.111.1.rc1
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 +34 -0
- data/app/assets/tailwind/studio_engine/engine.css +34 -2
- data/app/javascript/studio/nav_collapse.js +36 -4
- data/app/javascript/studio/pinned_stack.js +181 -60
- data/app/views/components/_sidebar_panel.html.erb +7 -1
- data/app/views/layouts/studio/_head.html.erb +4 -1
- data/app/views/studio/banners/_stack.html.erb +4 -2
- data/lib/studio/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 650a35a39685ece1959d6a403014344875cfd107f28dee2bd0594a66667592f3
|
|
4
|
+
data.tar.gz: 413b0ffb720c3da0e51327700723b126fb6cc88d7404543bdbccde28764e4a21
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8ffc0f710b353aea044308e4e16a687915e120dfee1950aa52c79c7be656a8b2a68d92df9c453182f2dd6b82980428ee0e7d2a4fc433ed20dfa1aeadd47c722d
|
|
7
|
+
data.tar.gz: bc4149aecbb0ac3c38a3905dec253d44c30459d0b9ba4a624bf18abbdc05613c568015fd4ce827d6d2dc39576b1de6bc244ab989ba56df9e5b875ba93229917b
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,40 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.111.1 — 2026-10-16
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **A live read of the pinned stack for scripts.** `studio/pinned_stack`
|
|
12
|
+
exports `stackBottom()` and `stackReading()`, and sets
|
|
13
|
+
`window.studioPinnedStack` (`bottom()`, `read()`), measured on call from the
|
|
14
|
+
same reading the publisher makes. The root's `--pin-stack-bottom` can lag a
|
|
15
|
+
scroll by up to 160ms (in motion only CSS readers are written), so a script
|
|
16
|
+
reading it mid-scroll got a stale edge; Cyvasse's phone dock did.
|
|
17
|
+
`docs/NAVBAR_SETUP.md`, *Reading the Pinned Stack*.
|
|
18
|
+
- **`data-pin-edge`**: a fixed reader of the stack follows the header's edge on
|
|
19
|
+
the scroll timeline, in CSS. `components/_sidebar_panel` carries it.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **On a scroll-timeline header the pinned stack writes nothing per scroll
|
|
24
|
+
frame.** The compact bar does not move while the page scrolls, so the stack
|
|
25
|
+
publishes where its layers sit once pinned, and writes on a resize, a band
|
|
26
|
+
re-measure (`studio/nav_collapse` fires `studio:pinned-stack-refresh`) or a
|
|
27
|
+
change of layers only. Sticky readers sit at the compact bar; the follower
|
|
28
|
+
write path goes unused there. The publisher marks the root
|
|
29
|
+
`data-pin-timeline` and publishes `--pin-stack-travel` while it is on.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- **A timeline header resized across 768px while scrolled no longer collapses
|
|
34
|
+
above the window.** The resize queued a frame on the `--nav-p` path, the
|
|
35
|
+
query's change then swapped to the scroll timeline, and the frame wrote
|
|
36
|
+
`--nav-p` onto the timeline header and eased it to 1: a 48px box translated
|
|
37
|
+
up by the band. `studio/nav_collapse` now cancels that frame on the swap, and
|
|
38
|
+
its frame writes nothing in timeline mode. Narrowing while scrolled hands the
|
|
39
|
+
header back to a live `--nav-p` path.
|
|
40
|
+
|
|
7
41
|
## 0.111.0 — 2026-10-16
|
|
8
42
|
|
|
9
43
|
### Added
|
|
@@ -151,8 +151,10 @@
|
|
|
151
151
|
navbar, before it — not inside <header>. A pinned header carrying a z-index
|
|
152
152
|
is a stacking context, and a descendant's z-index is clamped inside it, so a
|
|
153
153
|
bar nested in the header composites at the HEADER's level no matter what it
|
|
154
|
-
asks for. --nav-bottom
|
|
155
|
-
|
|
154
|
+
asks for. --nav-bottom reports the header's bottom edge for anything that
|
|
155
|
+
needs to sit under chrome stacked above it: its live edge, or on a
|
|
156
|
+
scroll-timeline header its PINNED edge, which a fixed reader closes with
|
|
157
|
+
data-pin-edge (THE PINNED EDGE ON THE SCROLL TIMELINE, below). */
|
|
156
158
|
body.modal-open .studio-bar-stack,
|
|
157
159
|
body.modal-open .studio-app-banner {
|
|
158
160
|
position: sticky;
|
|
@@ -839,6 +841,36 @@ body.modal-open .studio-app-banner {
|
|
|
839
841
|
}
|
|
840
842
|
}
|
|
841
843
|
}
|
|
844
|
+
/* THE PINNED EDGE ON THE SCROLL TIMELINE (studio/pinned_stack).
|
|
845
|
+
On a timeline header the pinned stack publishes where the stack sits once it
|
|
846
|
+
has PINNED (the compact bar), and writes nothing per scroll frame. A sticky
|
|
847
|
+
reader needs nothing more: it pins at that value. A FIXED reader is on screen
|
|
848
|
+
at the top of the page too, where the expanded header (and any bars above it)
|
|
849
|
+
still reach --pin-stack-travel further down. It opts in with data-pin-edge
|
|
850
|
+
and closes that gap here, on the same scroll timeline the header collapses
|
|
851
|
+
on, with no script per frame. The publisher puts data-pin-timeline on the
|
|
852
|
+
root, and the travel beside it, only while the header is on the timeline, so
|
|
853
|
+
off it this rule matches nothing and the reader's own style stands.
|
|
854
|
+
components/_sidebar_panel opts in; it positions off --nav-bottom. */
|
|
855
|
+
@layer utilities {
|
|
856
|
+
@supports (animation-timeline: scroll()) {
|
|
857
|
+
html[data-pin-timeline] [data-pin-edge] {
|
|
858
|
+
animation: studio-pin-edge linear both;
|
|
859
|
+
animation-timeline: scroll(root block);
|
|
860
|
+
animation-range: 0px var(--pin-stack-travel, 0px);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
@keyframes studio-pin-edge {
|
|
865
|
+
from {
|
|
866
|
+
top: calc(var(--nav-bottom, 0px) + var(--pin-stack-travel, 0px));
|
|
867
|
+
height: calc(100% - var(--nav-bottom, 0px) - var(--pin-stack-travel, 0px));
|
|
868
|
+
}
|
|
869
|
+
to {
|
|
870
|
+
top: var(--nav-bottom, 0px);
|
|
871
|
+
height: calc(100% - var(--nav-bottom, 0px));
|
|
872
|
+
}
|
|
873
|
+
}
|
|
842
874
|
@keyframes nav-tl-row { to { transform: translateY(calc(var(--nav-band) / 2)); } }
|
|
843
875
|
@keyframes nav-tl-brand { to { transform: scale(var(--nav-brand-scale, 1)); } }
|
|
844
876
|
@keyframes nav-tl-avatar { to { transform: scale(var(--nav-avatar-scale, 1)); } }
|
|
@@ -246,8 +246,20 @@ var TIMELINE_PROPS = ['--nav-band', '--nav-start', '--nav-brand-scale', '--nav-a
|
|
|
246
246
|
// below 768px (a phone rotated, then Back) arrives still carrying it, and
|
|
247
247
|
// pinned_stack would subtract the stale band from --nav-h.
|
|
248
248
|
export function clearTimeline(el) {
|
|
249
|
+
var was = el.hasAttribute('data-nav-timeline-on');
|
|
249
250
|
TIMELINE_PROPS.forEach(function (name) { el.style.removeProperty(name); });
|
|
250
251
|
el.removeAttribute('data-nav-timeline-on');
|
|
252
|
+
if (was) refreshPinnedStack();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// studio/pinned_stack's REFRESH_EVENT. The header's PINNED height (its box
|
|
256
|
+
// less the band) and the stack's travel change when timeline mode starts,
|
|
257
|
+
// re-measures or stops, and none of that resizes the box, so the stack's
|
|
258
|
+
// ResizeObserver cannot hear it. Named here rather than imported so neither
|
|
259
|
+
// module has to load the other.
|
|
260
|
+
export var PINNED_STACK_REFRESH = 'studio:pinned-stack-refresh';
|
|
261
|
+
function refreshPinnedStack() {
|
|
262
|
+
if (typeof document !== 'undefined') document.dispatchEvent(new CustomEvent(PINNED_STACK_REFRESH));
|
|
251
263
|
}
|
|
252
264
|
|
|
253
265
|
function round(n) {
|
|
@@ -264,6 +276,7 @@ export class NavCollapse {
|
|
|
264
276
|
this.p = 0;
|
|
265
277
|
this.scrolled = false;
|
|
266
278
|
this.queued = false;
|
|
279
|
+
this._frame = 0;
|
|
267
280
|
this.stopped = false;
|
|
268
281
|
this.tuning = { ramp: DEFAULT_RAMP, maxPx: DEFAULT_MAX_STEP };
|
|
269
282
|
this._reduce = null;
|
|
@@ -348,6 +361,7 @@ export class NavCollapse {
|
|
|
348
361
|
// every visit would stack another listener on window.
|
|
349
362
|
stop() {
|
|
350
363
|
this.stopped = true;
|
|
364
|
+
this.cancelFrame();
|
|
351
365
|
if (this._wide && this._wide.removeEventListener) this._wide.removeEventListener('change', this._onWide);
|
|
352
366
|
this._wide = null;
|
|
353
367
|
if (this.timeline) {
|
|
@@ -368,6 +382,19 @@ export class NavCollapse {
|
|
|
368
382
|
}
|
|
369
383
|
}
|
|
370
384
|
|
|
385
|
+
// A frame the --nav-p path queued (a resize, a scroll, the settle loop)
|
|
386
|
+
// must not land after a stop. Crossing 768px fires this path's resize
|
|
387
|
+
// listener BEFORE the query's change, so the frame is queued, the swap
|
|
388
|
+
// runs, start() clears `stopped`, and the frame then wrote --nav-p onto
|
|
389
|
+
// the timeline header and eased it to 1: a 48px box translated up by the
|
|
390
|
+
// band, above the window (task timeline-header-resize-collapse). Cancel
|
|
391
|
+
// it, and clear `queued` so the next mode can schedule its own.
|
|
392
|
+
cancelFrame() {
|
|
393
|
+
if (this._frame && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(this._frame);
|
|
394
|
+
this._frame = 0;
|
|
395
|
+
this.queued = false;
|
|
396
|
+
}
|
|
397
|
+
|
|
371
398
|
// Crossing 768px swaps modes: undo the one running, start the one that
|
|
372
399
|
// now applies.
|
|
373
400
|
onWide() {
|
|
@@ -449,9 +476,11 @@ export class NavCollapse {
|
|
|
449
476
|
'--nav-avatar-shrink': round(vars.avatarShrink) + 'px',
|
|
450
477
|
'--nav-tools-shift': round(vars.toolsShift) + 'px'
|
|
451
478
|
};
|
|
479
|
+
var changed = false;
|
|
452
480
|
for (var key in props) {
|
|
453
|
-
if (el.style.getPropertyValue(key) !== props[key]) el.style.setProperty(key, props[key]);
|
|
481
|
+
if (el.style.getPropertyValue(key) !== props[key]) { el.style.setProperty(key, props[key]); changed = true; }
|
|
454
482
|
}
|
|
483
|
+
if (changed) refreshPinnedStack();
|
|
455
484
|
}
|
|
456
485
|
|
|
457
486
|
// The scrolled marker on the controller's own hysteresis, written only when
|
|
@@ -468,7 +497,7 @@ export class NavCollapse {
|
|
|
468
497
|
onScroll() {
|
|
469
498
|
if (this.queued) return;
|
|
470
499
|
this.queued = true;
|
|
471
|
-
requestAnimationFrame(this._apply);
|
|
500
|
+
this._frame = requestAnimationFrame(this._apply);
|
|
472
501
|
}
|
|
473
502
|
|
|
474
503
|
onResize() {
|
|
@@ -538,7 +567,10 @@ export class NavCollapse {
|
|
|
538
567
|
|
|
539
568
|
apply() {
|
|
540
569
|
this.queued = false;
|
|
541
|
-
|
|
570
|
+
this._frame = 0;
|
|
571
|
+
// Timeline mode owns the header: --nav-p is the stylesheet's there, and
|
|
572
|
+
// this path writes nothing, whatever frame is still in flight.
|
|
573
|
+
if (this.stopped || this.timeline) return;
|
|
542
574
|
// Clamped: rubber-band overscroll reports a NEGATIVE scrollY, and a
|
|
543
575
|
// negative progress inflates the navbar past its expanded size.
|
|
544
576
|
var y = Math.max(0, window.scrollY);
|
|
@@ -566,7 +598,7 @@ export class NavCollapse {
|
|
|
566
598
|
// 144, from a hard flick.
|
|
567
599
|
if (frame.settling) {
|
|
568
600
|
this.queued = true;
|
|
569
|
-
requestAnimationFrame(this._apply);
|
|
601
|
+
this._frame = requestAnimationFrame(this._apply);
|
|
570
602
|
}
|
|
571
603
|
|
|
572
604
|
var lit = shadowLit(this.scrolled, y);
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
// --nav-h / --nav-bottom the <header>'s height and bottom edge, under their
|
|
18
18
|
// legacy names, from the same measurement.
|
|
19
19
|
//
|
|
20
|
+
// THOSE ARE FOR CSS. A SCRIPT reads window.studioPinnedStack.bottom() (or the
|
|
21
|
+
// stackBottom export), measured on call: the root's values can lag a scroll by
|
|
22
|
+
// up to SETTLE_MS (see THE LIVE READ below).
|
|
23
|
+
//
|
|
20
24
|
// DO NOT COMPOSE THE STACK IN A CONSUMER. `top: max(var(--pin-nav-bottom),
|
|
21
25
|
// var(--pin-apps-bottom))` is what this replaced, and it fails twice: it does
|
|
22
26
|
// not scale, because a fourth layer means editing every consumer that ever
|
|
@@ -83,6 +87,151 @@ export function composed(pins) {
|
|
|
83
87
|
return stack;
|
|
84
88
|
}
|
|
85
89
|
|
|
90
|
+
// THE STACK ON A SCROLL-TIMELINE HEADER (studio/nav_collapse's timeline mode,
|
|
91
|
+
// data-nav-timeline-on). That header keeps its expanded box and is sticky at
|
|
92
|
+
// minus its band, so what pins is the compact bar, and its height never changes
|
|
93
|
+
// while the page scrolls. Every layer of the stack is chrome that pins at the
|
|
94
|
+
// bottom of the layers above it, so on such a page the stack is a fact of
|
|
95
|
+
// HEIGHTS, not of where the window happens to be: each visible layer sits at
|
|
96
|
+
// its composed top and ends one height below it. That is where a sticky reader
|
|
97
|
+
// (a board's lane headers) lands the moment it pins, and it does not move per
|
|
98
|
+
// scroll frame, so nothing is written per scroll frame either: the publisher
|
|
99
|
+
// writes on resize and on a roster change, and the follower path goes unused.
|
|
100
|
+
//
|
|
101
|
+
// A hidden layer (height 0) still drops out at 0. Settles in one pass per
|
|
102
|
+
// layer of depth; bounded by the layer count so a contradictory data-pin-order
|
|
103
|
+
// cannot spin.
|
|
104
|
+
export function pinnedAt(pins) {
|
|
105
|
+
var i, pass, stack = 0;
|
|
106
|
+
for (i = 0; i < pins.length; i++) pins[i].bottom = 0;
|
|
107
|
+
for (pass = 0; pass <= pins.length; pass++) {
|
|
108
|
+
stack = composed(pins);
|
|
109
|
+
var moved = false;
|
|
110
|
+
for (i = 0; i < pins.length; i++) {
|
|
111
|
+
var bottom = pins[i].h > 0 ? pins[i].top + pins[i].h : 0;
|
|
112
|
+
if (bottom !== pins[i].bottom) { pins[i].bottom = bottom; moved = true; }
|
|
113
|
+
}
|
|
114
|
+
if (!moved) break;
|
|
115
|
+
}
|
|
116
|
+
return stack;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function onTimeline(header) {
|
|
120
|
+
return !!header && header.hasAttribute('data-nav-timeline-on');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ONE READ PER ELEMENT PER PUBLISH, and every read before every write.
|
|
124
|
+
// A read after a write to the root forces a fresh layout, which is the
|
|
125
|
+
// thrash this publisher exists without. Measured by review at 6x CPU
|
|
126
|
+
// throttle over 175 frames: frames past 20ms 62 vs 42, median 17.7 vs
|
|
127
|
+
// 14.9ms, p90 34.2 vs 26.0ms; at 10x, 84/180 vs 4/180.
|
|
128
|
+
export function measurePin(el, name, legacy, timeline) {
|
|
129
|
+
// A header on the scroll timeline keeps its expanded box, and its top band
|
|
130
|
+
// scrolls away above the window; what pins is the compact bar, so that is
|
|
131
|
+
// its height here. An inline read, no layout.
|
|
132
|
+
var band = el.hasAttribute('data-nav-timeline-on') ? parseFloat(el.style.getPropertyValue('--nav-band')) || 0 : 0;
|
|
133
|
+
// On the timeline the bottom comes from the heights (pinnedAt), so the rect
|
|
134
|
+
// is not read at all.
|
|
135
|
+
if (timeline) return { el: el, name: name, legacy: legacy, h: el.offsetHeight - band, bottom: 0 };
|
|
136
|
+
// A hidden layer needs no special case: display:none gives offsetHeight 0
|
|
137
|
+
// and an all-zero rect, so it measures 0 and drops out of the stack on its
|
|
138
|
+
// own. That is what lets a layer come and go without anyone declaring a
|
|
139
|
+
// stacking order, and it is load-bearing rather than incidental.
|
|
140
|
+
var r = el.getBoundingClientRect();
|
|
141
|
+
return { el: el, name: name, legacy: legacy, h: el.offsetHeight - band, bottom: Math.max(0, r.bottom) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// THE REGISTRY IS DERIVED, NEVER CACHED — and that is a correctness rule.
|
|
145
|
+
//
|
|
146
|
+
// This used to build a `pins` array once and hold each element by reference
|
|
147
|
+
// until something re-registered it. A Turbo Stream replaces a pinned layer's
|
|
148
|
+
// node WITHOUT a turbo:load, so between the patch and the re-scan the held
|
|
149
|
+
// reference pointed at the DETACHED predecessor — and a detached node's rect
|
|
150
|
+
// is all zeros, which is indistinguishable from a layer that is legitimately
|
|
151
|
+
// hidden. Measured on mcritchie-studio's /deployments: --pin-apps-bottom
|
|
152
|
+
// published 0px while the live strip stood at display:block, bottom 152px,
|
|
153
|
+
// and the board's lane headers slammed 99px up and back on every broadcast.
|
|
154
|
+
//
|
|
155
|
+
// Re-querying costs one querySelectorAll over a handful of nodes per publish.
|
|
156
|
+
// That is cheaper than the class of bug it removes, because it makes a stale
|
|
157
|
+
// reference IMPOSSIBLE rather than merely unlikely: there is no window between
|
|
158
|
+
// a DOM patch and a re-scan in which this can be pointed at the wrong node.
|
|
159
|
+
//
|
|
160
|
+
// Returns { pins, stack, timeline }: every layer measured and composed, the
|
|
161
|
+
// stack's bottom, and whether the header is on the scroll timeline.
|
|
162
|
+
export function readStack() {
|
|
163
|
+
var header = document.querySelector('header');
|
|
164
|
+
var timeline = onTimeline(header);
|
|
165
|
+
var nodes = document.querySelectorAll('[data-pin]');
|
|
166
|
+
var list = [];
|
|
167
|
+
var headerPinned = false;
|
|
168
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
169
|
+
var name = nodes[i].getAttribute('data-pin');
|
|
170
|
+
// An unnamed pin would write `--pin--h`, a valid property name and a
|
|
171
|
+
// silent nonsense one. Skip it rather than publish it.
|
|
172
|
+
if (!name) continue;
|
|
173
|
+
if (nodes[i] === header) headerPinned = true;
|
|
174
|
+
list.push(measurePin(nodes[i], name, nodes[i] === header, timeline));
|
|
175
|
+
}
|
|
176
|
+
// THE HOST-OWNED HEADER, and the back-compat promise that depends on it.
|
|
177
|
+
// A header that does not carry [data-pin] is not in the registry, so the
|
|
178
|
+
// legacy --nav-h / --nav-bottom — which studio/_sidebar_panel, turf's
|
|
179
|
+
// contest board and mcritchie-studio's heartbeat CSS all read — would
|
|
180
|
+
// never publish at all. Both live consumers own their header, so it is
|
|
181
|
+
// adopted as a pin even when it does not ask to be.
|
|
182
|
+
if (header && !headerPinned) list.push(measurePin(header, 'nav', true, timeline));
|
|
183
|
+
return {
|
|
184
|
+
pins: list,
|
|
185
|
+
stack: timeline ? pinnedAt(list) : composed(list),
|
|
186
|
+
timeline: timeline,
|
|
187
|
+
travel: timeline ? timelineTravel(header) : 0
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// HOW FAR THE PAGE SCROLLS BEFORE THE TIMELINE HEADER PINS: the bars above it
|
|
192
|
+
// in flow (--nav-start) plus the band that scrolls away (--nav-band), both
|
|
193
|
+
// written inline by studio/nav_collapse. Inline reads, no layout. A FIXED
|
|
194
|
+
// reader (data-pin-edge) starts this far below the stack's bottom at the top
|
|
195
|
+
// of the page and closes the gap on the scroll timeline, in CSS (engine.css,
|
|
196
|
+
// THE PINNED EDGE ON THE SCROLL TIMELINE).
|
|
197
|
+
export function timelineTravel(header) {
|
|
198
|
+
var px = function (name) { return parseFloat(header.style.getPropertyValue(name)) || 0; };
|
|
199
|
+
return Math.max(0, px('--nav-start') + px('--nav-band'));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// THE LIVE READ, for SCRIPT readers. The custom properties are for CSS; a
|
|
203
|
+
// script that reads --pin-stack-bottom off documentElement can be up to
|
|
204
|
+
// SETTLE_MS late, because in motion the publisher writes only the elements
|
|
205
|
+
// that read the stack in CSS and writes the root once the scroll settles.
|
|
206
|
+
// Cyvasse's phone dock read the root mid-scroll and sat 30px off. This
|
|
207
|
+
// measures on call, from the same reading the publisher would make now, so it
|
|
208
|
+
// is never stale and never writes anything.
|
|
209
|
+
//
|
|
210
|
+
// import { stackBottom } from "studio/pinned_stack"
|
|
211
|
+
// window.studioPinnedStack.bottom() // the same, for a script with no import
|
|
212
|
+
// window.studioPinnedStack.read() // { bottom, timeline, layers: { nav: { h, top, bottom } } }
|
|
213
|
+
export function stackBottom() {
|
|
214
|
+
return readStack().stack;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function stackReading() {
|
|
218
|
+
var r = readStack();
|
|
219
|
+
var layers = {};
|
|
220
|
+
for (var i = 0; i < r.pins.length; i++) {
|
|
221
|
+
layers[r.pins[i].name] = { h: r.pins[i].h, top: r.pins[i].top, bottom: r.pins[i].bottom };
|
|
222
|
+
}
|
|
223
|
+
return { bottom: r.stack, timeline: r.timeline, travel: r.travel, layers: layers };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (typeof window !== 'undefined') {
|
|
227
|
+
window.studioPinnedStack = { bottom: stackBottom, read: stackReading };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// What studio/nav_collapse fires on the document when its timeline mode
|
|
231
|
+
// starts, re-measures its band, or stops: the header's pinned height changed
|
|
232
|
+
// without its box resizing, which the ResizeObserver cannot see.
|
|
233
|
+
export var REFRESH_EVENT = 'studio:pinned-stack-refresh';
|
|
234
|
+
|
|
86
235
|
var started = false
|
|
87
236
|
|
|
88
237
|
// THE READERS OF THE STACK, found rather than declared.
|
|
@@ -239,64 +388,11 @@ export function startPinnedStack() {
|
|
|
239
388
|
releaseFollowers();
|
|
240
389
|
}
|
|
241
390
|
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
// This used to build a `pins` array once and hold each element by reference
|
|
245
|
-
// until something re-registered it. A Turbo Stream replaces a pinned layer's
|
|
246
|
-
// node WITHOUT a turbo:load, so between the patch and the re-scan the held
|
|
247
|
-
// reference pointed at the DETACHED predecessor — and a detached node's rect
|
|
248
|
-
// is all zeros, which is indistinguishable from a layer that is legitimately
|
|
249
|
-
// hidden. Measured on mcritchie-studio's /deployments: --pin-apps-bottom
|
|
250
|
-
// published 0px while the live strip stood at display:block, bottom 152px,
|
|
251
|
-
// and the board's lane headers slammed 99px up and back on every broadcast.
|
|
252
|
-
//
|
|
253
|
-
// Re-querying costs one querySelectorAll over a handful of nodes per publish.
|
|
254
|
-
// That is cheaper than the class of bug it removes, because it makes a stale
|
|
255
|
-
// reference IMPOSSIBLE rather than merely unlikely: there is no window between
|
|
256
|
-
// a DOM patch and a re-scan in which this can be pointed at the wrong node.
|
|
257
|
-
function readPins() {
|
|
258
|
-
var header = document.querySelector('header');
|
|
259
|
-
var nodes = document.querySelectorAll('[data-pin]');
|
|
260
|
-
var list = [];
|
|
261
|
-
var headerPinned = false;
|
|
262
|
-
for (var i = 0; i < nodes.length; i++) {
|
|
263
|
-
var name = nodes[i].getAttribute('data-pin');
|
|
264
|
-
// An unnamed pin would write `--pin--h`, a valid property name and a
|
|
265
|
-
// silent nonsense one. Skip it rather than publish it.
|
|
266
|
-
if (!name) continue;
|
|
267
|
-
if (nodes[i] === header) headerPinned = true;
|
|
268
|
-
list.push(measure(nodes[i], name, nodes[i] === header));
|
|
269
|
-
}
|
|
270
|
-
// THE HOST-OWNED HEADER, and the back-compat promise that depends on it.
|
|
271
|
-
// A header that does not carry [data-pin] is not in the registry, so the
|
|
272
|
-
// legacy --nav-h / --nav-bottom — which studio/_sidebar_panel, turf's
|
|
273
|
-
// contest board and mcritchie-studio's heartbeat CSS all read — would
|
|
274
|
-
// never publish at all. Both live consumers own their header, so it is
|
|
275
|
-
// adopted as a pin even when it does not ask to be.
|
|
276
|
-
if (header && !headerPinned) list.push(measure(header, 'nav', true));
|
|
277
|
-
return list;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// ONE READ PER ELEMENT PER PUBLISH, and every read before every write.
|
|
281
|
-
// A read after a write to the root forces a fresh layout, which is the
|
|
282
|
-
// thrash this publisher exists without. Measured by review at 6x CPU
|
|
283
|
-
// throttle over 175 frames: frames past 20ms 62 vs 42, median 17.7 vs
|
|
284
|
-
// 14.9ms, p90 34.2 vs 26.0ms; at 10x, 84/180 vs 4/180.
|
|
285
|
-
function measure(el, name, legacy) {
|
|
286
|
-
// A hidden layer needs no special case: display:none gives offsetHeight 0
|
|
287
|
-
// and an all-zero rect, so it measures 0 and drops out of the stack on its
|
|
288
|
-
// own. That is what lets a layer come and go without anyone declaring a
|
|
289
|
-
// stacking order, and it is load-bearing rather than incidental.
|
|
290
|
-
var r = el.getBoundingClientRect();
|
|
291
|
-
// A header on the scroll timeline (studio/nav_collapse) keeps its expanded
|
|
292
|
-
// box, and its top band scrolls away above the window; what pins is the
|
|
293
|
-
// compact bar, so that is its height here. An inline read, no layout.
|
|
294
|
-
var band = el.hasAttribute('data-nav-timeline-on') ? parseFloat(el.style.getPropertyValue('--nav-band')) || 0 : 0;
|
|
295
|
-
return { el: el, name: name, legacy: legacy, h: el.offsetHeight - band, bottom: Math.max(0, r.bottom) };
|
|
296
|
-
}
|
|
391
|
+
// The registry and the measurement are module-level (readStack, measurePin
|
|
392
|
+
// above) so the live read and the publisher make the same reading.
|
|
297
393
|
|
|
298
|
-
|
|
299
|
-
function write(pins, stack) {
|
|
394
|
+
var lastTravel = null;
|
|
395
|
+
function write(pins, stack, timeline, travel) {
|
|
300
396
|
var style = document.documentElement.style;
|
|
301
397
|
var seen = {};
|
|
302
398
|
var i, p, prev;
|
|
@@ -309,9 +405,30 @@ export function startPinnedStack() {
|
|
|
309
405
|
for (i = 0; i < pins.length; i++) { if (!last.hasOwnProperty(pins[i].name)) { force = true; break; } }
|
|
310
406
|
}
|
|
311
407
|
|
|
408
|
+
// ON THE SCROLL TIMELINE the stack does not move with the scroll (see
|
|
409
|
+
// pinnedAt), so a scroll frame finds nothing changed and the root path
|
|
410
|
+
// below writes nothing. The follower path is for values that DO move per
|
|
411
|
+
// frame; it goes unused here, and any copies a --nav-p frame left go back.
|
|
412
|
+
if (timeline) {
|
|
413
|
+
if (settleTimer) { clearTimeout(settleTimer); settleTimer = null; }
|
|
414
|
+
if (followed.length) releaseFollowers();
|
|
415
|
+
}
|
|
416
|
+
// The fixed readers' scroll-timeline path (data-pin-edge, engine.css) is
|
|
417
|
+
// switched on by this attribute and sized by --pin-stack-travel. Both are
|
|
418
|
+
// written only when they change: on a resize or a mode swap.
|
|
419
|
+
var root = document.documentElement;
|
|
420
|
+
if (timeline) {
|
|
421
|
+
if (force || travel !== lastTravel) { style.setProperty('--pin-stack-travel', travel + 'px'); lastTravel = travel; }
|
|
422
|
+
if (!root.hasAttribute('data-pin-timeline')) root.setAttribute('data-pin-timeline', '');
|
|
423
|
+
} else if (lastTravel !== null || root.hasAttribute('data-pin-timeline')) {
|
|
424
|
+
style.removeProperty('--pin-stack-travel');
|
|
425
|
+
root.removeAttribute('data-pin-timeline');
|
|
426
|
+
lastTravel = null;
|
|
427
|
+
}
|
|
428
|
+
|
|
312
429
|
// IN MOTION, THE FOLLOWERS ONLY. A structural change still writes the
|
|
313
430
|
// root at once: it is rare, and a layer arriving must reach every reader.
|
|
314
|
-
if (!force && performance.now() - lastScrollAt < SETTLE_MS) {
|
|
431
|
+
if (!force && !timeline && performance.now() - lastScrollAt < SETTLE_MS) {
|
|
315
432
|
var props = [];
|
|
316
433
|
for (i = 0; i < pins.length; i++) {
|
|
317
434
|
p = pins[i];
|
|
@@ -361,8 +478,9 @@ export function startPinnedStack() {
|
|
|
361
478
|
}
|
|
362
479
|
|
|
363
480
|
function publishAll() {
|
|
364
|
-
var
|
|
365
|
-
|
|
481
|
+
var reading = readStack();
|
|
482
|
+
var pins = reading.pins;
|
|
483
|
+
write(pins, reading.stack, reading.timeline, reading.travel);
|
|
366
484
|
syncObserver(pins);
|
|
367
485
|
}
|
|
368
486
|
|
|
@@ -450,6 +568,9 @@ export function startPinnedStack() {
|
|
|
450
568
|
document.addEventListener('turbo:before-stream-render', function () {
|
|
451
569
|
window.requestAnimationFrame(invalidate);
|
|
452
570
|
});
|
|
571
|
+
// The timeline header's pinned height moves when its band is measured,
|
|
572
|
+
// which changes no box size, so the observer never hears of it.
|
|
573
|
+
document.addEventListener(REFRESH_EVENT, publishAll);
|
|
453
574
|
// Registered ONCE at start, not inside a re-scan, so a Turbo nav
|
|
454
575
|
// re-publishes instead of stacking another listener per visit.
|
|
455
576
|
window.addEventListener('scroll', schedule, { passive: true });
|
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
It is reachable only when a host OVERRIDES layouts/studio/_head, in which
|
|
13
13
|
case --nav-bottom is never published and this degrades to the previous,
|
|
14
14
|
BUGGY geometry. That is a deliberate floor, not a safe default: it keeps an
|
|
15
|
-
overriding host at today's behaviour instead of collapsing to 6rem.
|
|
15
|
+
overriding host at today's behaviour instead of collapsing to 6rem.
|
|
16
|
+
|
|
17
|
+
data-pin-edge: on a header on the scroll timeline the stack publishes the
|
|
18
|
+
PINNED bar and nothing per frame, so at the top of the page this fixed panel
|
|
19
|
+
would cover the expanded header. engine.css (THE PINNED EDGE ON THE SCROLL
|
|
20
|
+
TIMELINE) moves it with the header on the scroll timeline instead. %>
|
|
16
21
|
<% id = local_assigns.fetch(:id, nil) %>
|
|
17
22
|
<% title = local_assigns.fetch(:title, nil) %>
|
|
18
23
|
<% open = local_assigns.fetch(:open) %>
|
|
@@ -55,6 +60,7 @@
|
|
|
55
60
|
x-transition:leave-end="translate-x-full"
|
|
56
61
|
class="<%= display_class %> fixed right-0 <%= width_class %> bg-surface border-l border-subtle shadow-2xl <%= z_class %> flex-col <%= panel_class %>"
|
|
57
62
|
style="top:var(--nav-bottom, var(--nav-h, 6rem));height:calc(100% - var(--nav-bottom, var(--nav-h, 6rem)))"
|
|
63
|
+
data-pin-edge
|
|
58
64
|
role="<%= role %>"
|
|
59
65
|
<% if labelledby %>aria-labelledby="<%= labelledby %>"<% elsif aria_label.present? %>aria-label="<%= aria_label %>"<% end %>>
|
|
60
66
|
<div class="flex flex-col h-full">
|
|
@@ -150,7 +150,10 @@
|
|
|
150
150
|
</style>
|
|
151
151
|
<%# THE PINNED STACK: studio/pinned_stack publishes --pin-<name>-h, -bottom and
|
|
152
152
|
-top for every [data-pin] layer, --pin-stack-bottom, and the legacy --nav-h /
|
|
153
|
-
--nav-bottom. Started by studio/application.
|
|
153
|
+
--nav-bottom. Started by studio/application. Those are for CSS: a script
|
|
154
|
+
reads window.studioPinnedStack.bottom(), measured on call, because the root
|
|
155
|
+
values lag a scroll in motion by up to 160ms (docs/NAVBAR_SETUP.md, Reading
|
|
156
|
+
the Pinned Stack). %>
|
|
154
157
|
|
|
155
158
|
<style>
|
|
156
159
|
/* studio-engine: --nav-p, the sticky header's COLLAPSE PROGRESS, 0 (expanded)
|
|
@@ -35,8 +35,10 @@
|
|
|
35
35
|
other — so pinning the bars is what cost the offset variable. The navbar keeps
|
|
36
36
|
its pin; the bars are ambient labels, seen on every page load because a Turbo
|
|
37
37
|
visit lands at the top. An overlay that must clear the chrome should position
|
|
38
|
-
off --nav-bottom (published in layouts/studio/_head), which
|
|
39
|
-
header's
|
|
38
|
+
off --nav-bottom (published in layouts/studio/_head), which reports the
|
|
39
|
+
header's bottom edge and accounts for chrome above it. On a scroll-timeline
|
|
40
|
+
header that is the PINNED edge, so a fixed overlay also carries data-pin-edge
|
|
41
|
+
(engine.css, THE PINNED EDGE ON THE SCROLL TIMELINE).
|
|
40
42
|
|
|
41
43
|
Locals (all optional):
|
|
42
44
|
preview — true inside a navbar-preview render; renders nothing, so a
|
data/lib/studio/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.111.
|
|
4
|
+
version: 0.111.1.rc1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|