studio-engine 0.72.2 → 0.72.3

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: dffda6dc9fdd89e1770f0d456e28cfb9a674679d09bc1226816f3f30ed8cf255
4
- data.tar.gz: ef6f36d167f21886920a64cbc687ca0981c49bfb93117050cb74cabf60e8e875
3
+ metadata.gz: 13abdd4ef0f10262a6560c40b2bed302c16d9f3b3b21582f3569cf0a7517cdf8
4
+ data.tar.gz: 6f47a52a0ba75d8be96ff6200b75d30a1ff49d76db401581a17a0ea88b2dcd6c
5
5
  SHA512:
6
- metadata.gz: 56ce7f21bc05749d76d3a6325f040892ad288f4c82a1bef8e62d64cf0666cd3d88a019a9b9591a375a8d5519ce536d9d6e629ab8dda9f5faaf98b12da6019e8c
7
- data.tar.gz: ef82414cc1ed55acf4d35ccafea238e4c2defe39a2ce684ad1fb5e18d8f47dc928b1f07ba015be1fee6b847f357225d2e073b638f03da335270638275bc87f4a
6
+ metadata.gz: 4b82e9d95ec946a5ce8fdc2d82269eadd8e300bfe21e146163aa0105a3a96254065ac10b26dd6f87a29cff5a402de1ca6d9cb0cdb53ef7119604ee0102b841d5
7
+ data.tar.gz: f03181c1586eb6c147b50778614df087115b27d1e76eb0dee7236f6e73c68a4509dadde3448d04cb84ea16cad4d20ffbe9eef8fcb7e96eca4be34dbd4c080d52
data/CHANGELOG.md CHANGED
@@ -185,6 +185,61 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
185
185
 
186
186
  ### Changed
187
187
 
188
+ - **The pinned stack composes itself, and publishes in the frame the change
189
+ happened.** `data-pin` layers now also publish `--pin-stack-bottom` (the bottom
190
+ of the whole stack) and `--pin-<name>-top` (the bottom of everything above that
191
+ layer), and the publisher writes them from inside its ResizeObserver callback
192
+ rather than deferring to `requestAnimationFrame`.
193
+
194
+ **THE DEFECT.** A frame runs `rAF -> style/layout -> ResizeObserver -> paint`,
195
+ so a write made in the frame callback lands at the top of the NEXT frame. Every
196
+ frame in which a pinned layer appeared or disappeared therefore painted with the
197
+ previous frame's number. Measured in isolation by toggling a layer's `display`
198
+ and sampling what the changing frame paints: rAF-deferred wrong on 8/8 changing
199
+ frames, synchronous wrong on 0/8. On mcritchie-studio's production
200
+ `/deployments` it showed as the board's swim-lane headers slamming 99px and back
201
+ on a page nobody was touching — parked at a fixed scroll offset with document
202
+ height, row position and nav height all constant, the header's `top` went
203
+ 152 -> 53 -> 152 -> 53px in 240ms behind two Turbo broadcasts.
204
+
205
+ **AND CONSUMERS NO LONGER COMPOSE THE STACK.** `top: max(var(--pin-nav-bottom),
206
+ var(--pin-apps-bottom))` fails twice: it does not scale, because a fourth layer
207
+ means editing every consumer that ever wanted to sit under the stack; and it is
208
+ not sound, because a `max()` over two custom properties is only meaningful if
209
+ they were written in the same frame. `--pin-stack-bottom` is one value that
210
+ cannot disagree with itself. A layer that is ITSELF in the stack reads
211
+ `--pin-<name>-top` instead, which excludes its own edge — positioning off the
212
+ stack bottom would make it chase itself down the page. Stacking order is
213
+ document order, with `data-pin-order` overriding it for a layer whose DOM
214
+ position does not match where it sits on screen.
215
+
216
+ **THE REGISTRY IS DERIVED PER PUBLISH, never cached.** A Turbo Stream replaces a
217
+ pinned layer's node without a `turbo:load`, and a held reference then points at
218
+ the DETACHED predecessor, whose rect is all zeros — indistinguishable from a
219
+ layer that is legitimately hidden. Re-querying makes the stale reference
220
+ impossible rather than merely unlikely.
221
+
222
+ **THE COST, re-measured**, because the rule this reverses was itself justified by
223
+ a measurement (turf-monster, 6x CPU throttle, frames over 20ms 13/24 through the
224
+ collapse ramp vs 0/24 once the observer was coalesced). What was expensive there
225
+ was read-write thrash, not the observer: the publisher now takes one read pass
226
+ and one write pass per invocation, and in an RO callback layout is already clean,
227
+ so those reads force nothing. On the engine lab at the same 6x throttle, ~700
228
+ frames of ramp each way — `RO through rAF` median 8.3ms / p90 10.0ms / 0 frames
229
+ over 20ms; `RO synchronous` median 8.4-8.5ms / p90 11.0ms / 3-6 frames over 20ms;
230
+ ZERO frames over 32ms on either, so neither drops one.
231
+
232
+ **BACK-COMPAT.** `--nav-h`, `--nav-bottom` and `--pin-<name>-h` /
233
+ `--pin-<name>-bottom` publish exactly as before, from the same measurements, so
234
+ the 26 consumers across turf-monster, rolio and mcritchie-studio need no change
235
+ and no floor bump. A structural change (a Turbo patch, a fresh document, a layer
236
+ joining or leaving) now forces a full write, because the unchanged-write skip is
237
+ only safe while nothing else touches these properties — the lab caught
238
+ `--nav-h` staying empty through a re-scan on exactly the host-owned-header path
239
+ both live consumers use. The lane contract moves 131 -> 132
240
+ (`config/e2e_lane.yml`), re-derived with the lister.
241
+
242
+
188
243
  - **`Gemfile.lock` resolves solana-studio 0.5.7, and a gate now keeps it there.** The lock had sat on **0.5.3 for four patch releases** while BOTH consumers shipped 0.5.7 (turf-monster `~> 0.5.3`, mcritchie-studio `~> 0.5`). Nothing was red and nothing could have been: engine CI installs with `bundler-cache: true`, so it resolves from the lock and never fresh — the drift does not self-correct and never surfaces as flakiness. It matters because `test/views/style_web3_specimens_test.rb` exists to prove "the style guide renders the REAL gem cards" and reads them off whatever the LOCK resolved; four versions behind, that guard certifies a card no consumer receives. It still passes — only its MEANING changes. MEASURED on this span, the gem's whole `app/` tree was byte-identical 0.5.3 → 0.5.7 (only `CHANGELOG.md`, `README.md` and `version.rb` differ), so this instance cost nothing, which is exactly why it went four releases unnoticed.
189
244
  - **The constraint stays `>= 0.5.3`, deliberately.** 0.5.3 is a real FLOOR (0.5.2 shipped the credential partial without `solana_studio/modals/_wallet_connect`, which the web3 capability gate requires). Above it this engine claims no ceiling: it is the BASE half of the base/bolt-on split, and a pessimistic `~> 0.5.3` here would be NARROWER than mcritchie-studio's own `~> 0.5` — a dev-only dependency constraining a resolution it does not own. The defect was the LOCK, not the pin, so the fix is a gate rather than a tighter pin.
190
245
  - **`bin/gem-drift-check`** — fails when this engine's lock resolves a tracked gem OLDER than a consumer's, wired into `consumer-ci.yml` after the consumer bundle install. That lane is the only place two repos' lockfiles exist at once (engine at `studio/`, consumer beside it); `test/lib/consumer_ci_shard_contract_test.rb` records the same constraint for its own cross-repo contract. Direction is ONE-WAY on purpose: engine behind FAILS, engine level or ahead passes (the engine is the producer and may test an unreleased gem), and a consumer bundling no tracked gem is a SKIP, not a failure — `mcritchie_industries` is the base half working as designed. Stdlib-only, and it names its one-command remedy (`bundle update solana-studio`) in the failure.
@@ -182,186 +182,295 @@
182
182
  body { overflow-anchor: none; }
183
183
  </style>
184
184
  <script>
185
- // studio-engine: publish the sticky header's live geometry as CSS custom
186
- // properties so fixed/sticky elements can position off it without hardcoded
187
- // px. Tracks every resize, including scroll-collapse animations, and
188
- // re-attaches after Turbo nav.
185
+ // studio-engine: THE PINNED STACK. Publishes the live geometry of every layer
186
+ // of pinned page chrome as CSS custom properties, so anything fixed or sticky
187
+ // positions off it in CSS with no hardcoded px and no JS of its own.
189
188
  //
190
- // --nav-h the header's HEIGHT
191
- // --nav-bottom the header's BOTTOM EDGE, in viewport coordinates
189
+ // A layer joins by carrying `data-pin="<name>"`. Nothing else is required:
190
+ // no registration call, no declared stacking order, no consumer edit.
192
191
  //
193
- // They are equal only when the header sits at the top of the viewport, which
194
- // is why one was long mistaken for the other. An app that stacks chrome ABOVE
195
- // the header — an environment banner rendered before the navbar, as
196
- // mcritchie-industries does pushes the header down, and a `fixed` overlay
197
- // positioned at --nav-h then rides UP over the header by exactly the height of
198
- // that chrome. Use --nav-h to size something as tall as the header; use
199
- // --nav-bottom to start something underneath it.
192
+ // WHAT A CONSUMER READS prefer the first two; they are the reason this is a
193
+ // primitive rather than an arithmetic helper:
194
+ //
195
+ // --pin-stack-bottom the bottom of the WHOLE stack. What something sitting
196
+ // BENEATH all the pinned chrome positions off.
197
+ // --pin-<name>-top the bottom of everything ABOVE that layer. What a layer
198
+ // that is ITSELF in the stack positions off.
199
+ // --pin-<name>-h that layer's HEIGHT.
200
+ // --pin-<name>-bottom that layer's own BOTTOM EDGE, in viewport coordinates.
201
+ // --nav-h / --nav-bottom the <header>'s height and bottom edge, under their
202
+ // legacy names, from the same measurement.
203
+ //
204
+ // DO NOT COMPOSE THE STACK IN A CONSUMER. `top: max(var(--pin-nav-bottom),
205
+ // var(--pin-apps-bottom))` is what this replaced, and it fails twice: it does
206
+ // not scale, because a fourth layer means editing every consumer that ever
207
+ // wanted to sit under the stack; and it is not sound, because a max() over two
208
+ // custom properties is only meaningful if they were written in the same frame.
209
+ // --pin-stack-bottom is one value that cannot disagree with itself.
210
+ //
211
+ // --nav-h AND --nav-bottom ARE NOT THE SAME NUMBER. They are equal only when
212
+ // the header sits at the top of the viewport, which is why one was long
213
+ // mistaken for the other. An app that stacks chrome ABOVE the header — an
214
+ // environment banner rendered before the navbar, as mcritchie-industries does —
215
+ // pushes the header down, and a `fixed` overlay positioned at --nav-h then
216
+ // rides UP over the header by exactly the height of that chrome. Use --nav-h to
217
+ // SIZE something as tall as the header; use --nav-bottom to START something
218
+ // underneath it. Same distinction for --pin-<name>-h vs --pin-<name>-bottom.
200
219
  (function () {
201
220
  if (!window.ResizeObserver) return;
202
221
  var ro = null;
203
222
  var queued = false;
204
- var pins = [];
205
- // The names this publisher has written, so a DEPARTED layer can be cleared.
206
- var published = {};
207
-
208
- // ONE MEASUREMENT PER ELEMENT PER FRAME, and every read before every write.
223
+ // WHAT WAS LAST PUBLISHED, per layer name: { h, bottom, top }. It does two
224
+ // jobs. It is the record of which names this publisher has written, so a
225
+ // DEPARTED layer can be cleared. And it is what lets an UNCHANGED value skip
226
+ // its write — these are INHERITED custom properties on documentElement, so
227
+ // even a write of the same number invalidates style for the whole document,
228
+ // and a scroll frame touches this four times per layer.
229
+ var last = {};
230
+ var lastStack = null;
231
+ // SKIPPING AN UNCHANGED WRITE IS ONLY SAFE WHILE NOTHING ELSE TOUCHES THESE
232
+ // PROPERTIES. On a scroll frame that holds, and skipping is most of why this
233
+ // is cheap. On a STRUCTURAL change it does not: a layer arriving or leaving,
234
+ // a Turbo patch, a fresh document — anything there may have cleared a
235
+ // property out from under the cache, and a skip would then never write it
236
+ // again. So a structural change forces a full write, once.
237
+ var forceWrite = true;
238
+ // The nodes currently under observation, held only to know what to unobserve
239
+ // when one departs. The REGISTRY ITSELF is never cached; see readPins().
240
+ var observed = [];
241
+
242
+ // THE REGISTRY IS DERIVED, NEVER CACHED — and that is a correctness rule.
209
243
  //
210
- // publish() and publishPin() each kept reads-before-writes INTERNALLY, and
211
- // that was not enough: the frame ran publish(current) which WRITES two
212
- // inherited custom properties on documentElement and then the pin loop,
213
- // whose first act is a getBoundingClientRect(). A read after a write to the
214
- // root forces a fresh layout, so the thrash this file removed came straight
215
- // back one function boundary over. Measured by review at 6x CPU throttle
216
- // over 175 frames, pin loop on vs off: frames past 20ms 62 vs 42, median
217
- // 17.7 vs 14.9ms, p90 34.2 vs 26.0ms; at 10x, 84/180 vs 4/180.
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.
218
252
  //
219
- // The header was also measured TWICE once as `current`, once as pin
220
- // `nav` for values proven identical across 40 frames. It is a pin like
221
- // any other now; the only thing special about it is that it ALSO writes the
222
- // legacy --nav-h / --nav-bottom that studio/_sidebar_panel and
223
- // turf-monster's scroll-margin-top already read.
224
- function readPin(pin) {
225
- return {
226
- pin: pin,
227
- h: pin.el.offsetHeight,
228
- bottom: Math.max(0, pin.el.getBoundingClientRect().bottom)
229
- };
230
- }
231
- function writeReading(r) {
232
- var style = document.documentElement.style;
233
- var pin = r.pin;
234
- if (r.h !== pin.lastH) {
235
- pin.lastH = r.h;
236
- style.setProperty('--pin-' + pin.name + '-h', r.h + 'px');
237
- if (pin.legacy) style.setProperty('--nav-h', r.h + 'px');
238
- }
239
- if (r.bottom !== pin.lastBottom) {
240
- pin.lastBottom = r.bottom;
241
- style.setProperty('--pin-' + pin.name + '-bottom', r.bottom + 'px');
242
- if (pin.legacy) style.setProperty('--nav-bottom', r.bottom + 'px');
243
- }
244
- }
245
- function publishAll() {
246
- var readings = [];
247
- var i;
248
- for (i = 0; i < pins.length; i++) readings.push(readPin(pins[i]));
249
- for (i = 0; i < readings.length; i++) writeReading(readings[i]);
250
- }
251
- function schedule() {
252
- if (queued || !pins.length) return;
253
- queued = true;
254
- window.requestAnimationFrame(function () { queued = false; publishAll(); });
255
- }
256
- // ONE construction site. Two of them let attach() replace the observer that
257
- // registerPins() had already put the pins on, silently dropping every live
258
- // observation — caught by test_pins_share_one_observer_and_one_frame, which
259
- // counts them for exactly that reason.
260
- function ensureObserver() {
261
- if (!ro) ro = new ResizeObserver(schedule);
262
- return ro;
263
- }
264
- function registerPins() {
265
- ensureObserver();
266
- pins = [];
267
- var seen = {};
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() {
268
258
  var header = document.querySelector('header');
269
- var headerPinned = false;
270
259
  var nodes = document.querySelectorAll('[data-pin]');
260
+ var list = [];
261
+ var headerPinned = false;
271
262
  for (var i = 0; i < nodes.length; i++) {
272
263
  var name = nodes[i].getAttribute('data-pin');
273
264
  // An unnamed pin would write `--pin--h`, a valid property name and a
274
265
  // silent nonsense one. Skip it rather than publish it.
275
266
  if (!name) continue;
276
- seen[name] = true;
277
267
  if (nodes[i] === header) headerPinned = true;
278
- pins.push({
279
- el: nodes[i],
280
- name: name,
281
- // The header keeps publishing --nav-h / --nav-bottom, from the SAME
282
- // measurement, so it is never measured twice.
283
- legacy: nodes[i] === header,
284
- lastH: null,
285
- lastBottom: null
286
- });
287
- ro.observe(nodes[i]);
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
+ return { el: el, name: name, legacy: legacy, h: el.offsetHeight, bottom: Math.max(0, r.bottom) };
292
+ }
293
+
294
+ // THE STACK, COMPOSED HERE RATHER THAN BY EVERY CONSUMER.
295
+ //
296
+ // Consumers used to write `top: max(var(--pin-nav-bottom), var(--pin-apps-bottom))`
297
+ // — every layer enumerated, in every consumer. That is two failures at once.
298
+ // It does not SCALE: a fourth pinned layer means editing every consumer that
299
+ // ever wanted to sit under the stack. And it is not SOUND: max() over two
300
+ // independently-written custom properties is only meaningful if they were
301
+ // written in the same frame, and any skew between them is expressed instantly
302
+ // as layout. Composing here means a consumer reads ONE value that cannot
303
+ // disagree with itself.
304
+ //
305
+ // --pin-stack-bottom the bottom of the WHOLE pinned stack. What something
306
+ // sitting beneath all the chrome positions off.
307
+ // --pin-<name>-top the bottom of everything ABOVE that layer. What a
308
+ // layer that is ITSELF in the stack positions off —
309
+ // --pin-stack-bottom would include the layer's own
310
+ // edge, so a layer positioned off it would chase itself
311
+ // down the page.
312
+ //
313
+ // ORDER is document order, which is the order the layers were declared and
314
+ // therefore the order they read in. `data-pin-order` overrides it for a layer
315
+ // whose DOM position does not match where it sits on screen; it is a plain
316
+ // number, lower is higher up, and it needs to exist on only the layer that
317
+ // disagrees rather than on all of them.
318
+ function order(p) {
319
+ var raw = parseFloat(p.el.getAttribute('data-pin-order'));
320
+ return isNaN(raw) ? null : raw;
321
+ }
322
+ function composed(pins) {
323
+ var i, j, stack = 0;
324
+ for (i = 0; i < pins.length; i++) {
325
+ if (pins[i].bottom > stack) stack = pins[i].bottom;
326
+ var above = 0;
327
+ for (j = 0; j < pins.length; j++) {
328
+ if (i === j) continue;
329
+ var oi = order(pins[i]), oj = order(pins[j]);
330
+ // Both ordered: compare the numbers. Otherwise fall back to document
331
+ // order, which querySelectorAll already returns them in.
332
+ var jIsAbove = (oi !== null && oj !== null) ? oj < oi : j < i;
333
+ if (jIsAbove && pins[j].bottom > above) above = pins[j].bottom;
334
+ }
335
+ pins[i].top = above;
336
+ }
337
+ return stack;
338
+ }
339
+
340
+ function write(pins, stack) {
341
+ var style = document.documentElement.style;
342
+ var seen = {};
343
+ var i, p, prev;
344
+ // A ROSTER CHANGE IS STRUCTURAL TOO, and it is not always announced by an
345
+ // event: an x-show that adds the node, a layer rendered by something other
346
+ // than a stream. Compare the names against what was published last.
347
+ var force = forceWrite;
348
+ forceWrite = false;
349
+ if (!force) {
350
+ for (i = 0; i < pins.length; i++) { if (!last.hasOwnProperty(pins[i].name)) { force = true; break; } }
288
351
  }
352
+ for (i = 0; i < pins.length; i++) {
353
+ p = pins[i];
354
+ seen[p.name] = true;
355
+ prev = last[p.name] || {};
356
+ if (force || p.h !== prev.h) {
357
+ style.setProperty('--pin-' + p.name + '-h', p.h + 'px');
358
+ if (p.legacy) style.setProperty('--nav-h', p.h + 'px');
359
+ }
360
+ if (force || p.bottom !== prev.bottom) {
361
+ style.setProperty('--pin-' + p.name + '-bottom', p.bottom + 'px');
362
+ if (p.legacy) style.setProperty('--nav-bottom', p.bottom + 'px');
363
+ }
364
+ if (force || p.top !== prev.top) style.setProperty('--pin-' + p.name + '-top', p.top + 'px');
365
+ last[p.name] = { h: p.h, bottom: p.bottom, top: p.top };
366
+ }
367
+ if (force || stack !== lastStack) {
368
+ style.setProperty('--pin-stack-bottom', stack + 'px');
369
+ lastStack = stack;
370
+ }
371
+
289
372
  // CLEAR WHAT LEFT. A layer goes away three ways: display:none and x-show
290
- // both keep the node, so it measures 0 and drops out of a consumer's
291
- // max() on its own. REMOVAL does not — nothing is left to measure, the
292
- // last published value stands forever, and a consumer sits at the height
373
+ // both keep the node, so it measures 0 and drops out of the stack on its
374
+ // own. REMOVAL does not — nothing is left to measure, and without this the
375
+ // last published value stands forever while the consumer sits at the height
293
376
  // of a strip that is gone. Review measured a removed 300px strip holding
294
- // --pin-apps-bottom at 300px against a real stack bottom of 160px: a
295
- // permanent 140px error, and a direct contradiction of this primitive's
296
- // headline promise.
297
- for (var name2 in published) {
298
- if (published.hasOwnProperty(name2) && !seen[name2]) {
299
- document.documentElement.style.removeProperty('--pin-' + name2 + '-h');
300
- document.documentElement.style.removeProperty('--pin-' + name2 + '-bottom');
377
+ // --pin-apps-bottom at 300px against a real stack bottom of 160px.
378
+ for (var name in last) {
379
+ if (last.hasOwnProperty(name) && !seen[name]) {
380
+ style.removeProperty('--pin-' + name + '-h');
381
+ style.removeProperty('--pin-' + name + '-bottom');
382
+ style.removeProperty('--pin-' + name + '-top');
383
+ delete last[name];
301
384
  }
302
385
  }
303
- // THE HOST-OWNED HEADER, and the back-compat promise that depends on it.
304
- //
305
- // The registry is built from [data-pin], so a header that does not carry
306
- // the attribute is not in it — and --nav-h / --nav-bottom, which are
307
- // written from the legacy pin's reading, would never publish at all.
308
- // THIS ENGINE'S navbar carries data-pin, so every test here and the whole
309
- // e2e lab publish fine; the broken path exists only in a consumer that
310
- // owns its header, which is BOTH live consumers:
311
- //
312
- // turf-monster layouts/_navbar.html.erb (data-navbar-root)
313
- // mcritchie-studio layouts/application.html.erb (inline)
314
- //
315
- // Review measured it against turf's real header and its real sidebar
316
- // declaration: --nav-h and --nav-bottom unset, the gear drawer 82px out
317
- // of place, the contest board 114px, and neither tracking the collapse.
318
- // Both apps pin a two-segment ~> under 1.0, so it would have landed on
319
- // their next bundle update with no floor bump to warn anyone.
320
- //
321
- // So the header is adopted as a pin even when it does not ask to be. It
322
- // still publishes --pin-nav-* under that name, which is what an app gets
323
- // for free the moment it wants the general contract.
324
- if (header && !headerPinned) {
325
- pins.push({
326
- el: header,
327
- name: 'nav',
328
- legacy: true,
329
- lastH: null,
330
- lastBottom: null
331
- });
332
- seen.nav = true;
333
- ensureObserver().observe(header);
386
+ }
387
+
388
+ function publishAll() {
389
+ var pins = readPins();
390
+ write(pins, composed(pins));
391
+ syncObserver(pins);
392
+ }
393
+
394
+ // Observe whatever is pinned RIGHT NOW. observe() on an already-observed node
395
+ // is a no-op, so this only ever adds the new; unobserve() drops the departed
396
+ // so a replaced node cannot keep waking us from outside the document.
397
+ function syncObserver(pins) {
398
+ if (!pins.length) {
399
+ if (ro) { ro.disconnect(); ro = null; observed = []; }
400
+ return;
334
401
  }
402
+ if (!ro) ro = new ResizeObserver(onResize);
403
+ var i, next = [];
404
+ for (i = 0; i < pins.length; i++) { next.push(pins[i].el); ro.observe(pins[i].el); }
405
+ for (i = 0; i < observed.length; i++) {
406
+ if (next.indexOf(observed[i]) === -1) ro.unobserve(observed[i]);
407
+ }
408
+ observed = next;
409
+ }
335
410
 
336
- published = seen;
411
+ // PUBLISH IN THE ResizeObserver CALLBACK, SYNCHRONOUSLY. This is the whole
412
+ // fix, and it is a statement about WHEN a frame does its work rather than
413
+ // about what this code computes.
414
+ //
415
+ // A frame runs: rAF callbacks -> style/layout -> ResizeObserver callbacks ->
416
+ // paint. This publisher used to be woken by the observer and then DEFER the
417
+ // write to requestAnimationFrame, which lands at the top of the NEXT frame —
418
+ // so the frame in which a layer actually appeared or disappeared painted with
419
+ // the previous frame's number, every time. The RO callback runs after layout
420
+ // and before paint, which is exactly the last moment a write can still land
421
+ // in the frame that caused it.
422
+ //
423
+ // A/B measured in isolation, toggling a pinned layer's display and sampling
424
+ // what the changing frame paints: rAF-deferred wrong on 8/8 changing frames,
425
+ // RO-synchronous wrong on 0/8.
426
+ //
427
+ // This cannot loop. The values written here move CONSUMERS, and a consumer is
428
+ // not a pin — nothing this writes resizes anything it observes. A pinned layer
429
+ // that positions off --pin-<name>-top MOVES without RESIZING, and the observer
430
+ // fires on size, so it settles in one pass rather than ringing.
431
+ // The one way a SYNCHRONOUS publish could misbehave: a consumer that both
432
+ // reads a published value and is itself a pin, sized off what it reads
433
+ // (`height: calc(100vh - var(--pin-apps-top))`). That resizes an observed
434
+ // node from inside the observer's own callback, and left alone the browser
435
+ // reports "ResizeObserver loop completed with undelivered notifications" and
436
+ // drops the frame. So re-entry is counted, and past a couple of settling
437
+ // passes this hands the rest to the frame callback: the pathological page
438
+ // degrades to the OLD one-frame-late behaviour instead of wedging, and the
439
+ // ordinary page never reaches the branch. Reset on the next frame, so the
440
+ // count measures one frame's settling rather than the session's.
441
+ var depth = 0, resetQueued = false;
442
+ function onResize() {
443
+ if (depth >= 3) { schedule(); return; }
444
+ depth++;
445
+ if (!resetQueued) {
446
+ resetQueued = true;
447
+ window.requestAnimationFrame(function () { resetQueued = false; depth = 0; });
448
+ }
337
449
  publishAll();
338
450
  }
339
- // ATTACH IS NOW JUST A RE-SCAN. The header used to be tracked separately as
340
- // `current`, with its own observe and its own publish; it is a pin named
341
- // "nav" like any other, so registerPins() covers it. What survives is the
342
- // release promise: a page with no pinned chrome at all disconnects the
343
- // observer rather than leaving it on detached nodes, and the properties keep
344
- // their last value rather than being driven to 0 by an all-zero rect.
345
- //
346
- // A page that never has a <header> is served too — the observer is built by
347
- // ensureObserver() from registerPins(), not from a has-header branch. It was
348
- // only reachable from that branch before, so pins on a headerless layout were
349
- // permanently dead.
350
- function attach() {
351
- if (ro) ro.disconnect();
352
- registerPins();
353
- if (!pins.length && ro) { ro.disconnect(); ro = null; }
451
+
452
+ // SCROLL STILL NEEDS THE FRAME CALLBACK. A scroll changes a sticky header's
453
+ // bottom EDGE without changing its size, so the observer never sees it and
454
+ // there is nothing to be synchronous with rAF is the right clock, and it
455
+ // coalesces the burst iOS momentum fires far above 60Hz.
456
+ function schedule() {
457
+ if (queued) return;
458
+ queued = true;
459
+ window.requestAnimationFrame(function () { queued = false; publishAll(); });
354
460
  }
355
- document.addEventListener('DOMContentLoaded', attach);
356
- document.addEventListener('turbo:load', attach);
357
- // A Turbo Stream replaces nodes WITHOUT a turbo:load, which is how a
358
- // captured pin goes stale. Re-register on the next frame, once the stream
359
- // has actually patched the DOM.
461
+
462
+ function invalidate() { forceWrite = true; publishAll(); }
463
+ document.addEventListener('DOMContentLoaded', invalidate);
464
+ document.addEventListener('turbo:load', invalidate);
465
+ // A Turbo Stream replaces nodes WITHOUT a turbo:load. The registry is derived
466
+ // per publish, so a stale reference is no longer possible either way — but the
467
+ // OBSERVER still has to be moved onto the incoming node, and the stack has to
468
+ // be recomposed for a layer that arrived or left. Both happen in publishAll.
360
469
  document.addEventListener('turbo:before-stream-render', function () {
361
- window.requestAnimationFrame(registerPins);
470
+ window.requestAnimationFrame(invalidate);
362
471
  });
363
- // Registered ONCE at script eval, not inside attach(), so a Turbo nav
364
- // re-scans instead of stacking another listener per visit.
472
+ // Registered ONCE at script eval, not inside a re-scan, so a Turbo nav
473
+ // re-publishes instead of stacking another listener per visit.
365
474
  window.addEventListener('scroll', schedule, { passive: true });
366
475
  window.addEventListener('resize', schedule, { passive: true });
367
476
  })();
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.72.2"
2
+ VERSION = "0.72.3"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.72.2
4
+ version: 0.72.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie