studio-engine 0.111.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d7cabff315fe9d235009b89cffbc4409acdec4cb52b5e99f750dbc09a10b2ba
4
- data.tar.gz: 4f7b337c79de43cc0c1269570f0bc2947f8e55ceccef13281e2494ee85e70178
3
+ metadata.gz: 650a35a39685ece1959d6a403014344875cfd107f28dee2bd0594a66667592f3
4
+ data.tar.gz: 413b0ffb720c3da0e51327700723b126fb6cc88d7404543bdbccde28764e4a21
5
5
  SHA512:
6
- metadata.gz: a5a8f42972f22be43ad7e325b629bf7d2b5b4e374ffee170e531b0d6c730dbadecd96cebc5f8b36d7024dd476bbd424ee27892b9423c9d5af2f4ce5a2d8b1e19
7
- data.tar.gz: 187c2bb411cc2f2a1312379e6998879f44f13bedcdbb5e2fe7d92ca94a11795c89a187d2c36d616ca72bfe3424a361ed1f9d308ac879418d8ebaca6f4e87fbe5
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 already reports the header's live bottom edge for
155
- anything that needs to sit under chrome stacked above it. */
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
- if (this.stopped) return;
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
- // THE REGISTRY IS DERIVED, NEVER CACHED — and that is a correctness rule.
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 pins = readPins();
365
- write(pins, composed(pins));
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 already reports the
39
- header's live bottom edge and accounts for chrome above it.
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
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.111.0"
2
+ VERSION = "0.111.1.rc1"
3
3
  end
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.0
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-16 00:00:00.000000000 Z
11
+ date: 2026-10-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails