studio-engine 0.64.0 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 30f8faae31ecb02586f53952db4bc53e74020245ec5f8c5e65bd642fca083d01
4
- data.tar.gz: 975577b6f1160fea036860d099182484bc7a71faffdd825fbe979f873de24cb7
3
+ metadata.gz: 8914b74430f105820bbc5a41f44d6c573ff88492bcd882534f769fa2c2a11608
4
+ data.tar.gz: f3d1ffbec8dcb09ef354d21ab3786d2d5b9b0590a665ac95a949e7282668a66b
5
5
  SHA512:
6
- metadata.gz: d60165b7dd31a39d72a9856f623d17257c31625f626012e57830df0c54410b65a4b90199e54985dad86f027a1cedf7cba87b5d9d27538c4764c970c6a6130769
7
- data.tar.gz: d1f2cb9b37b5b92bc84ee8eb7cbc05cafd4be8d39aeda239b2a28d49e2b75e4ab8cde4051ee987efb963720aa806525c7c9a54a6057f78008fd96e70b40d34bf
6
+ metadata.gz: d52952922f19f864f97fd09297d8711c246ba54415bd40d60682569cbdfe0d51ccf7fb7673e7d921a361ab3657b35871f4999aad0baa80374dd3098cd3c267b2
7
+ data.tar.gz: 82afd75d6a4e57bfbe3708b59c1c1e7eec188cf2bcfd5921e3eeb50e006090d1f0839e5fc280be8ee60c8d6dc999bb07bb148e7e2220b7d061559048e14b6225
data/CHANGELOG.md CHANGED
@@ -4,6 +4,69 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Added
8
+
9
+ - **`data-pin` — the pinned stack publishes itself.** `--nav-h` / `--nav-bottom`
10
+ answer for one element. Everything else that pins has been re-deriving the
11
+ same geometry by hand: on `mcritchie-studio`'s `/deployments` the app strip
12
+ positions itself with `:style="{ top: offset + 'px' }"`, and the lane headers
13
+ compute *"site header height + strip height"* in Alpine behind **their own**
14
+ `ResizeObserver` on `.vt-pinned-header` — while `--nav-bottom`, which already
15
+ answers the first half, goes unused there. Same shape as the four copies of
16
+ the navbar collapse, one layer up.
17
+
18
+ Any element carrying `data-pin="<name>"` now publishes `--pin-<name>-h` and
19
+ `--pin-<name>-bottom`, from **one shared `ResizeObserver`**, through the same
20
+ rAF coalescer and the same no-op-write skip the header already uses — and one
21
+ measurement pass for the whole frame, every read taken before any write.
22
+
23
+ That last part was wrong in the first cut of this change and review caught it:
24
+ the frame published the header (writing two inherited properties on
25
+ `documentElement`) and *then* read the pins, so the reads-before-writes order
26
+ held inside each function and broke across them. Measured at 6× CPU throttle
27
+ over 175 frames, pin loop on vs off: frames past 20ms **62 vs 42**, p90 **34.2
28
+ vs 26.0ms**; at 10×, **84/180 vs 4/180**. The header is now a pin like any
29
+ other — measured once, writing `--nav-h` / `--nav-bottom` from that same
30
+ reading rather than a second one. A consumer composes layers in pure
31
+ CSS:
32
+
33
+ ```css
34
+ top: max(var(--pin-nav-bottom, 0px), var(--pin-apps-bottom, 0px));
35
+ ```
36
+
37
+ **`max()` rather than a declared stacking order.** A hidden layer measures 0
38
+ and drops out, so nothing has to say which layer sits above which, and
39
+ reordering a partial cannot break the arithmetic.
40
+
41
+ **A host-owned header still publishes the legacy pair.** The registry is built
42
+ from `[data-pin]`, so a header that does not carry the attribute would never be
43
+ measured and `--nav-h` / `--nav-bottom` would never publish — and **both live
44
+ consumers own their header** (`turf-monster` `layouts/_navbar`,
45
+ `mcritchie-studio` `layouts/application`), neither carrying `data-pin`. Review
46
+ measured the gear drawer **82px** out of place and the contest board **114px**,
47
+ with neither tracking the collapse; both apps pin a two-segment `~>` under 1.0,
48
+ so it would have landed on their next bundle update with no floor bump. The
49
+ header is adopted as the legacy pin whether or not it asks to be.
50
+
51
+ **A departed layer has its properties removed.** `display:none` and `x-show`
52
+ keep the node, so it measures 0 and drops out of a consumer's `max()` on its
53
+ own; a REMOVED node cannot be measured by anything, and its last value would
54
+ stand forever — review measured a removed 300px strip holding the property at
55
+ 300px against a real stack bottom of 160px. The registry remembers what it
56
+ published and clears whatever is no longer in the document.
57
+
58
+ The registry is **rebuilt from the document**, on attach and after
59
+ `turbo:before-stream-render` — a Turbo Stream replaces nodes without a
60
+ `turbo:load`, which is precisely the stale-node bug the board documents
61
+ against its own version.
62
+
63
+ `layouts/_navbar` carries `data-pin="nav"`, so every consuming app gets
64
+ `--pin-nav-bottom` for free. **`--nav-h` and `--nav-bottom` keep publishing
65
+ unchanged** — `studio/_sidebar_panel` and turf-monster's `scroll-margin-top`
66
+ read them, and `nav_offset_contract_test` still pins both to their exact
67
+ sources. This is additive.
68
+
69
+
7
70
  ### Changed
8
71
 
9
72
  - **The navbar collapse primitive is broadcastable, rate-limited, and cheap per
@@ -46,7 +46,11 @@
46
46
  so it cannot move content under the reader.
47
47
  Preview keeps nav-shell (the calc()s have to resolve) but no x-data — the
48
48
  /navbar review page drives --nav-p from its own Scrolled toggle. %>
49
- <header <%= 'x-data="navCollapse()"'.html_safe unless is_preview %>
49
+ <%# data-pin names this header as a layer of the pinned stack, so
50
+ layouts/studio/_head publishes --pin-nav-h and --pin-nav-bottom for it and
51
+ anything below can position off it in pure CSS. The legacy --nav-h /
52
+ --nav-bottom keep publishing unchanged; this is additive. %>
53
+ <header data-pin="nav" <%= 'x-data="navCollapse()"'.html_safe unless is_preview %>
50
54
  <%# top-0, a STATIC value, and never a custom property. Any bars render as
51
55
  this header's sibling in normal flow (studio/banners/_stack), so they
52
56
  already occupy their own height above it and there is nothing to
@@ -200,74 +200,168 @@
200
200
  (function () {
201
201
  if (!window.ResizeObserver) return;
202
202
  var ro = null;
203
- var current = null;
204
203
  var queued = false;
205
- var lastH = null;
206
- var lastBottom = null;
207
- function publish(header) {
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.
209
+ //
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.
218
+ //
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) {
208
232
  var style = document.documentElement.style;
209
- // SKIP THE NO-OP WRITE. --nav-h and --nav-bottom are INHERITED custom
210
- // properties on documentElement, so every write invalidates style for the
211
- // whole document. Most republishes carry the same number — a scroll with
212
- // the header already pinned, a resize that missed this element — and
213
- // those cost nothing now.
214
- // BOTH READS BEFORE EITHER WRITE. Writing a custom property on
215
- // documentElement invalidates style, so a read taken after one write
216
- // forces a fresh layout — the thrash this whole change exists to remove.
217
- var h = header.offsetHeight;
218
- // Scroll-dependent where --nav-h is not: chrome above the header scrolls
219
- // away while an overlay is open (nothing here locks body scroll), so this
220
- // is republished on scroll. Clamped at 0 — once the sticky header is
221
- // pinned at top:0 the two values converge, which is why a consumer whose
222
- // header already starts at the viewport top sees no change at all.
223
- var bottom = Math.max(0, header.getBoundingClientRect().bottom);
224
- if (h !== lastH) {
225
- lastH = h;
226
- style.setProperty('--nav-h', h + 'px');
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');
227
238
  }
228
- if (bottom !== lastBottom) {
229
- lastBottom = bottom;
230
- style.setProperty('--nav-bottom', bottom + 'px');
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');
231
243
  }
232
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
+ }
233
251
  function schedule() {
234
- if (queued || !current) return;
252
+ if (queued || !pins.length) return;
235
253
  queued = true;
236
- window.requestAnimationFrame(function () { queued = false; if (current) publish(current); });
254
+ window.requestAnimationFrame(function () { queued = false; publishAll(); });
237
255
  }
238
- function attach() {
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 = {};
239
268
  var header = document.querySelector('header');
240
- if (!header) {
241
- // A page with no <header> — a bare error page, a print view. RELEASE the
242
- // old node instead of returning: the scroll listener outlives the visit,
243
- // and publishing from a DETACHED element yields an all-zero rect, which
244
- // would drive --nav-h to 0px. Before this listener existed nothing ran
245
- // after detach, so --nav-h simply kept its last value; keep that promise.
246
- if (ro) { ro.disconnect(); ro = null; }
247
- current = null;
248
- return;
269
+ var headerPinned = false;
270
+ var nodes = document.querySelectorAll('[data-pin]');
271
+ for (var i = 0; i < nodes.length; i++) {
272
+ var name = nodes[i].getAttribute('data-pin');
273
+ // An unnamed pin would write `--pin--h`, a valid property name and a
274
+ // silent nonsense one. Skip it rather than publish it.
275
+ if (!name) continue;
276
+ seen[name] = true;
277
+ 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]);
288
+ }
289
+ // 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
293
+ // 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');
301
+ }
249
302
  }
250
- current = header;
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);
334
+ }
335
+
336
+ published = seen;
337
+ publishAll();
338
+ }
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() {
251
351
  if (ro) ro.disconnect();
252
- // THROUGH schedule(), NOT STRAIGHT TO publish(). This callback used to
253
- // call publish() directly, which was fine while the header only resized
254
- // during a 300ms transition. navCollapse made it resize on EVERY scroll
255
- // frame, and each direct publish forced layout and then wrote two
256
- // inherited custom properties on documentElement. Measured in
257
- // turf-monster at 6x CPU throttle: frames over 20ms were 13/24 THROUGH
258
- // the collapse ramp versus 0/24 past it, median 26ms versus 8ms.
259
- // Ablating this observer alone took it to 0/24 and median 13ms — it cost
260
- // about 2.6x the reflow it was reacting to. schedule() already coalesces
261
- // to one publish per frame and is what the scroll and resize listeners
262
- // use; the ResizeObserver was the one path that bypassed it.
263
- ro = new ResizeObserver(schedule);
264
- ro.observe(header);
265
- publish(header);
352
+ registerPins();
353
+ if (!pins.length && ro) { ro.disconnect(); ro = null; }
266
354
  }
267
355
  document.addEventListener('DOMContentLoaded', attach);
268
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.
360
+ document.addEventListener('turbo:before-stream-render', function () {
361
+ window.requestAnimationFrame(registerPins);
362
+ });
269
363
  // Registered ONCE at script eval, not inside attach(), so a Turbo nav
270
- // re-points `current` instead of stacking another listener per visit.
364
+ // re-scans instead of stacking another listener per visit.
271
365
  window.addEventListener('scroll', schedule, { passive: true });
272
366
  window.addEventListener('resize', schedule, { passive: true });
273
367
  })();
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.64.0"
2
+ VERSION = "0.65.0"
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.64.0
4
+ version: 0.65.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie