studio-engine 0.111.1.rc1 → 0.111.2.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: 650a35a39685ece1959d6a403014344875cfd107f28dee2bd0594a66667592f3
4
- data.tar.gz: 413b0ffb720c3da0e51327700723b126fb6cc88d7404543bdbccde28764e4a21
3
+ metadata.gz: 8808038af9e2419c0af0177895740230d150828f028ae40be6824c877624b557
4
+ data.tar.gz: ca450685c591c1414842097435599e658e5a0a68cbf9b05f1405d7c338d35a88
5
5
  SHA512:
6
- metadata.gz: 8ffc0f710b353aea044308e4e16a687915e120dfee1950aa52c79c7be656a8b2a68d92df9c453182f2dd6b82980428ee0e7d2a4fc433ed20dfa1aeadd47c722d
7
- data.tar.gz: bc4149aecbb0ac3c38a3905dec253d44c30459d0b9ba4a624bf18abbdc05613c568015fd4ce827d6d2dc39576b1de6bc244ab989ba56df9e5b875ba93229917b
6
+ metadata.gz: ae13f4a634c55627b37809d8fb01b39c853a8bec472374aaf5afd30f3f57d84ca99b2d07a92132b8e8ee4ddf28e563ef2fe62befea927262830fb4e7f2065efa
7
+ data.tar.gz: d900c3a5fcb6aae1d4339fe0a74b078cc9876c11f810f751112e0b2018fbaf54f5205363d8b61d78474c502b501b4f110f9c9eea78e2faaa426c19e92c0bdf1b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,37 @@ 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.2 — 2026-10-18
8
+
9
+ ### Fixed
10
+
11
+ - **The sticky table header's clone sits on the pinned bar.** It placed its
12
+ `position: fixed` header row at `--nav-h`, re-read only on a scroll, a resize
13
+ or a header resize. On a scroll-timeline header the compact height is
14
+ published only once the nav-collapse controller starts, which resizes
15
+ nothing, so a page loaded at a restored scroll position kept the clone at the
16
+ expanded height (Turf Monster `/contests`: 96px under a 50px bar, rows showing
17
+ in the gap) until the first scroll. On a phone the root's `--nav-h` also
18
+ lagged the collapse, so the clone sat off the bar through a flick. The clone
19
+ now reads the stack's live bottom (`window.studioPinnedStack.read()`, closing
20
+ the expanded header's travel on the timeline as `data-pin-edge` does), falls
21
+ back to `--pin-stack-bottom`, and never reads `--nav-h`. It re-places on
22
+ `studio:pinned-stack-refresh`, on every stack publish, and synchronously when
23
+ any `data-pin` layer resizes.
24
+ - **The style guide's section nav sticks under the whole pinned stack**
25
+ (`--pin-stack-bottom`), not at the header's height, which put it under any
26
+ chrome stacked above the header.
27
+
28
+ ### Added
29
+
30
+ - **`studio:pinned-stack-published`.** `studio/pinned_stack` fires it on the
31
+ document after a publish that moved the stack, with the reading it just made
32
+ (`{ bottom, timeline, travel }`) as `detail`, so a script that positions off
33
+ the stack re-places in the same frame. `docs/NAVBAR_SETUP.md`, *Reading the
34
+ Pinned Stack*.
35
+ - **`layouts/studio/_head` takes a `sticky_table_headers:` local** that answers
36
+ for one render (nil defers to `config.sticky_table_headers`).
37
+
7
38
  ## 0.111.1 — 2026-10-16
8
39
 
9
40
  ### Added
@@ -4,18 +4,69 @@
4
4
  const MANUAL_TABLE_SELECTOR = "table[data-sticky-table-header]";
5
5
  const AUTO_TABLE_SELECTOR = "table";
6
6
  const SKIP_CONTAINER_SELECTOR = "[data-sticky-table-skip]";
7
+ // What studio/nav_collapse fires when a scroll-timeline header starts,
8
+ // re-measures its band, or stops: the header's PINNED height changed without
9
+ // its box resizing, so no ResizeObserver and no scroll hears of it.
10
+ const PINNED_STACK_REFRESH = "studio:pinned-stack-refresh";
11
+ // What studio/pinned_stack fires after every publish that moved the stack.
12
+ const PINNED_STACK_PUBLISHED = "studio:pinned-stack-published";
7
13
 
8
14
  let stickyTables = [];
9
15
  let scheduled = false;
10
16
  let bootScheduled = false;
11
- let navResizeObserver = null;
17
+ let pinResizeObserver = null;
12
18
  let tableMutationObserver = null;
13
19
 
14
- window.StudioStickyTableHeaders = { loaded: true };
20
+ // WHERE THE CLONE STARTS: THE PINNED STACK'S BOTTOM EDGE, read live.
21
+ //
22
+ // This used to read --nav-h off the root, and that was wrong twice. --nav-h
23
+ // is the header's HEIGHT, not its bottom edge, so chrome stacked above the
24
+ // header put the clone that much too high. And a root custom property is a
25
+ // SNAPSHOT this script re-read only on scroll, resize or a header resize: on
26
+ // a scroll-timeline header the compact height is published only once the
27
+ // nav-collapse controller starts, which resizes nothing, so a page loaded at
28
+ // a restored scroll position kept the clone at the expanded height (Turf
29
+ // Monster /contests: 96px against a 50px bar, rows showing in the gap) until
30
+ // the first scroll.
31
+ //
32
+ // window.studioPinnedStack.read() measures on call, the same reading the
33
+ // publisher makes. On a scroll-timeline header it reports where the stack
34
+ // sits once PINNED; at the top of the page the expanded header still reaches
35
+ // `travel` further down, and closes that gap as the page scrolls, exactly as
36
+ // engine.css's data-pin-edge does for a fixed reader in CSS. A host that never
37
+ // started the publisher falls back to --pin-stack-bottom, then to 0: a clone
38
+ // at the top of the viewport rather than none.
39
+ function pinnedEdge(reading) {
40
+ const r = reading || window.studioPinnedStack?.read?.();
41
+ if (!r) {
42
+ const raw = getComputedStyle(document.documentElement).getPropertyValue("--pin-stack-bottom");
43
+ return Number.parseFloat(raw) || 0;
44
+ }
45
+ const bottom = Number(r.bottom) || 0;
46
+ if (!r.timeline) return bottom;
47
+ return bottom + Math.max(0, (Number(r.travel) || 0) - Math.max(0, window.scrollY));
48
+ }
15
49
 
16
- function navOffset() {
17
- const raw = getComputedStyle(document.documentElement).getPropertyValue("--nav-h");
18
- return Number.parseFloat(raw) || 0;
50
+ // Where the clone goes for one table, as a pure function of what was measured:
51
+ // shown once the real header has scrolled up to the edge, and pushed up by the
52
+ // table's own bottom so it leaves with the table.
53
+ function placement({ edge, headerTop, tableBottom, cloneHeight }) {
54
+ const active = headerTop <= edge && tableBottom > edge;
55
+ return { active, top: active ? Math.min(edge, tableBottom - cloneHeight) : null };
56
+ }
57
+
58
+ window.StudioStickyTableHeaders = { loaded: true, pinnedEdge, placement };
59
+
60
+ // One pass over every table, reading the edge ONCE. Called from a frame
61
+ // callback (scroll, resize, a stack event) and SYNCHRONOUSLY from the
62
+ // ResizeObserver, which runs after layout and before paint: a script-mode
63
+ // header changes height every frame of its collapse (studio/nav_collapse
64
+ // writes --nav-p from its own frame callback, after ours), and a frame-late
65
+ // clone shows rows through the gap for that frame.
66
+ function updateAll(reading) {
67
+ if (!stickyTables.length) return;
68
+ const edge = pinnedEdge(reading);
69
+ stickyTables.slice().forEach((stickyTable) => stickyTable.update(edge));
19
70
  }
20
71
 
21
72
  function scheduleUpdate() {
@@ -23,7 +74,7 @@
23
74
  scheduled = true;
24
75
  requestAnimationFrame(() => {
25
76
  scheduled = false;
26
- stickyTables.forEach((stickyTable) => stickyTable.update());
77
+ updateAll();
27
78
  });
28
79
  }
29
80
 
@@ -80,19 +131,20 @@
80
131
  });
81
132
  }
82
133
 
83
- update() {
134
+ update(edge = pinnedEdge()) {
84
135
  if (!document.body.contains(this.table) || !this.table.tHead || !this.scroller) {
85
136
  this.destroy();
86
137
  stickyTables = stickyTables.filter((stickyTable) => stickyTable !== this);
87
138
  return;
88
139
  }
89
140
 
90
- const offset = navOffset();
91
141
  const headerRect = this.table.tHead.getBoundingClientRect();
92
142
  const tableRect = this.table.getBoundingClientRect();
93
143
  const scrollerRect = this.scroller.getBoundingClientRect();
94
144
  const cloneHeight = headerRect.height || this.cloneShell.getBoundingClientRect().height;
95
- const active = headerRect.top <= offset && tableRect.bottom > offset;
145
+ const { active, top } = placement({
146
+ edge, headerTop: headerRect.top, tableBottom: tableRect.bottom, cloneHeight
147
+ });
96
148
 
97
149
  if (!active) {
98
150
  this.cloneShell.style.display = "none";
@@ -102,7 +154,6 @@
102
154
 
103
155
  this.syncColumnWidths();
104
156
 
105
- const top = Math.min(offset, tableRect.bottom - cloneHeight);
106
157
  const translateX = tableRect.left - scrollerRect.left;
107
158
 
108
159
  this.cloneShell.style.display = "block";
@@ -119,12 +170,17 @@
119
170
  function bootStickyTables() {
120
171
  startTableObserver();
121
172
 
122
- if (navResizeObserver) navResizeObserver.disconnect();
173
+ // EVERY LAYER OF THE PINNED STACK, not the header alone: a bar above the
174
+ // header that grows or goes moves the edge too. Re-observed on every boot.
175
+ if (pinResizeObserver) pinResizeObserver.disconnect();
176
+ pinResizeObserver = null;
123
177
  if (window.ResizeObserver) {
178
+ const layers = new Set(document.querySelectorAll("[data-pin]"));
124
179
  const header = document.querySelector("header");
125
- if (header) {
126
- navResizeObserver = new ResizeObserver(scheduleUpdate);
127
- navResizeObserver.observe(header);
180
+ if (header) layers.add(header);
181
+ if (layers.size) {
182
+ pinResizeObserver = new ResizeObserver(() => updateAll());
183
+ layers.forEach((layer) => pinResizeObserver.observe(layer));
128
184
  }
129
185
  }
130
186
 
@@ -187,13 +243,19 @@
187
243
  document.addEventListener("turbo:load", bootStickyTables);
188
244
  document.addEventListener("DOMContentLoaded", bootStickyTables);
189
245
  document.addEventListener("turbo:before-cache", () => {
190
- if (navResizeObserver) navResizeObserver.disconnect();
246
+ if (pinResizeObserver) pinResizeObserver.disconnect();
191
247
  if (tableMutationObserver) tableMutationObserver.disconnect();
192
- navResizeObserver = null;
248
+ pinResizeObserver = null;
193
249
  tableMutationObserver = null;
194
250
  stickyTables.forEach((stickyTable) => stickyTable.destroy());
195
251
  stickyTables = [];
196
252
  });
253
+ // A STACK REPUBLISH RE-PLACES THE CLONE. The refresh is the timeline header
254
+ // changing its pinned height (on start, which is the restored-scroll case);
255
+ // the publish carries the reading the publisher just made, so the clone moves
256
+ // in the same frame and with the same number.
257
+ document.addEventListener(PINNED_STACK_REFRESH, scheduleUpdate);
258
+ document.addEventListener(PINNED_STACK_PUBLISHED, (event) => updateAll(event.detail || undefined));
197
259
  window.addEventListener("scroll", scheduleUpdate, { passive: true });
198
260
  window.addEventListener("resize", scheduleUpdate, { passive: true });
199
261
  })();
@@ -232,6 +232,23 @@ if (typeof window !== 'undefined') {
232
232
  // without its box resizing, which the ResizeObserver cannot see.
233
233
  export var REFRESH_EVENT = 'studio:pinned-stack-refresh';
234
234
 
235
+ // What this publisher fires on the document after a publish that MOVED the
236
+ // stack: its bottom, whether the header is on the scroll timeline, or its
237
+ // travel. The detail is the reading it just made, { bottom, timeline, travel },
238
+ // the same shape window.studioPinnedStack.read() gives. It is for a SCRIPT that
239
+ // positions off the stack (studio/sticky_table_header's clone): a value written
240
+ // to the root reaches CSS on its own, but a script has to be told, and without
241
+ // this it heard only of a scroll, a resize or a box it observed itself.
242
+ // Fired synchronously from inside the publish, so a listener that places
243
+ // something lands it in the same frame. Not fired when nothing moved, so a
244
+ // scroll frame on the timeline (where the stack never moves) fires nothing.
245
+ export var PUBLISHED_EVENT = 'studio:pinned-stack-published';
246
+
247
+ // The key a publish is compared on, so an unchanged stack fires nothing.
248
+ export function publishedKey(reading) {
249
+ return reading.stack + '|' + (reading.timeline ? 1 : 0) + '|' + reading.travel;
250
+ }
251
+
235
252
  var started = false
236
253
 
237
254
  // THE READERS OF THE STACK, found rather than declared.
@@ -477,11 +494,19 @@ export function startPinnedStack() {
477
494
  }
478
495
  }
479
496
 
497
+ var lastPublished = null;
480
498
  function publishAll() {
481
499
  var reading = readStack();
482
500
  var pins = reading.pins;
483
501
  write(pins, reading.stack, reading.timeline, reading.travel);
484
502
  syncObserver(pins);
503
+ var key = publishedKey(reading);
504
+ if (key !== lastPublished) {
505
+ lastPublished = key;
506
+ document.dispatchEvent(new CustomEvent(PUBLISHED_EVENT, {
507
+ detail: { bottom: reading.stack, timeline: reading.timeline, travel: reading.travel }
508
+ }));
509
+ }
485
510
  }
486
511
 
487
512
  // Observe whatever is pinned RIGHT NOW. observe() on an already-observed node
@@ -180,7 +180,10 @@
180
180
 
181
181
  <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
182
182
  <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
183
- <% if Studio.sticky_table_headers %>
183
+ <%# config.sticky_table_headers, unless the caller answers for this render with
184
+ the sticky_table_headers local (true or false; nil defers to the config). %>
185
+ <% sticky_table_headers = local_assigns[:sticky_table_headers].nil? ? Studio.sticky_table_headers : local_assigns[:sticky_table_headers] %>
186
+ <% if sticky_table_headers %>
184
187
  <%= stylesheet_link_tag "studio/sticky_table_header", "data-turbo-track": "reload" %>
185
188
  <% end %>
186
189
  <%= javascript_importmap_tags %>
@@ -253,6 +256,6 @@
253
256
  day, 2026-08-15). This removes a real source of run-to-run variance; it does NOT
254
257
  claim to be that flake's cause, and the remaining suspect there is untouched. %>
255
258
  <%= javascript_include_tag "studio/alpine", defer: true, "data-turbo-track": "reload" %>
256
- <% if Studio.sticky_table_headers %>
259
+ <% if sticky_table_headers %>
257
260
  <%= javascript_include_tag "studio/sticky_table_header", defer: true, "data-turbo-track": "reload" %>
258
261
  <% end %>
@@ -9,10 +9,10 @@
9
9
  push every row down by 56px whether or not it is showing. Fixed takes it out
10
10
  of flow: nothing moves, and the bar simply is not there until it is.
11
11
 
12
- Pinned to `var(--nav-h, 0px)` — the height the host navbar publishes, which
13
- /admin/style already uses to stick its section nav. The 0px fallback means an
14
- app that publishes nothing gets a bar at the top of the viewport rather than
15
- a broken one.
12
+ Pinned to the header's bottom edge, `var(--nav-bottom, var(--nav-h, 0px))`
13
+ (studio/profiles/_identity_styles says why the edge and not the height). The
14
+ 0px fallback means an app that publishes nothing gets a bar at the top of the
15
+ viewport rather than a broken one.
16
16
 
17
17
  THE IDENTITY HALF IS aria-hidden, and only that half. It duplicates content
18
18
  that is already on the page and already reachable: the full card above is the
@@ -66,13 +66,16 @@
66
66
  </p>
67
67
  </header>
68
68
 
69
- <%# Section nav — sticky under the host navbar (--nav-h, 0 fallback). A pill per
69
+ <%# Section nav — sticky under the whole pinned stack (--pin-stack-bottom; then
70
+ --nav-bottom and --nav-h for a host on an older publisher; then 0). Not --nav-h first: that
71
+ is the header's HEIGHT, and chrome stacked above the header would sit over
72
+ the nav by its own height. A pill per
70
73
  section; the row scrolls horizontally on narrow screens, its edges softened
71
74
  by the .fade-edge-x primitive. Order leads with Theme (the color foundation).
72
75
  It sits at --z-sticky, above --z-raised, so the live hold button's portal
73
76
  bubbles scroll under it rather than over it. %>
74
77
  <nav class="sticky -mx-4 px-4 py-3 bg-surface border-y border-subtle"
75
- style="top: var(--nav-h, 0px); z-index: var(--z-sticky, 30)" aria-label="Style guide sections">
78
+ style="top: var(--pin-stack-bottom, var(--nav-bottom, var(--nav-h, 0px))); z-index: var(--z-sticky, 30)" aria-label="Style guide sections">
76
79
  <div class="fade-edge-x overflow-x-auto">
77
80
  <div class="flex items-center gap-2 min-w-max">
78
81
  <% sections.each do |anchor, label| %>
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.111.1.rc1"
2
+ VERSION = "0.111.2.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.1.rc1
4
+ version: 0.111.2.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-17 00:00:00.000000000 Z
11
+ date: 2026-10-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails