studio-engine 0.93.0 → 0.94.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 +4 -4
- data/CHANGELOG.md +61 -0
- data/app/javascript/studio/alpine_shims.js +45 -0
- data/app/javascript/studio/alpine_stores.js +82 -0
- data/app/javascript/studio/application.js +37 -0
- data/app/javascript/studio/controllers/nav_collapse_controller.js +28 -0
- data/app/javascript/studio/head_chrome.js +79 -0
- data/app/javascript/studio/nav_collapse.js +238 -0
- data/app/javascript/studio/pinned_stack.js +306 -0
- data/app/javascript/studio/vendor/stimulus.js +5 -0
- data/app/views/layouts/_navbar.html.erb +14 -6
- data/app/views/layouts/studio/_head.html.erb +59 -567
- data/config/importmap.rb +14 -3
- data/lib/studio/engine.rb +26 -0
- data/lib/studio/version.rb +1 -1
- metadata +9 -1
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
<%# The request's CSP nonce, for the one inline script left in this head and
|
|
2
|
+
for javascript_import_module_tag below (importmap-rails nonces its own tags).
|
|
3
|
+
nil when the host sets no nonce generator, and then no attribute renders. %>
|
|
4
|
+
<% studio_script_nonce = (content_security_policy_nonce if respond_to?(:content_security_policy_nonce)) %>
|
|
5
|
+
<%# PRE-PAINT THEME. Inline, and the only inline script this head keeps: it adds
|
|
6
|
+
`dark` before the first paint, which no deferred module can do. The toggle
|
|
7
|
+
lives in studio/alpine_stores. %>
|
|
8
|
+
<%= tag.script(nonce: studio_script_nonce) do %>
|
|
2
9
|
(function(){
|
|
3
10
|
if (localStorage.getItem('theme') !== 'light') {
|
|
4
11
|
document.documentElement.classList.add('dark');
|
|
5
12
|
}
|
|
6
13
|
})();
|
|
7
|
-
|
|
14
|
+
<% end %>
|
|
8
15
|
|
|
9
16
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
17
|
+
<%# Studio.nav_spinner_min_ms, read by the nav spinner in studio/head_chrome. %>
|
|
18
|
+
<meta name="studio-nav-spinner-min-ms" content="<%= Studio.nav_spinner_min_ms.to_i %>">
|
|
10
19
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
11
20
|
<%= csrf_meta_tags %>
|
|
12
21
|
<%= csp_meta_tag %>
|
|
@@ -97,31 +106,11 @@
|
|
|
97
106
|
}
|
|
98
107
|
</style>
|
|
99
108
|
|
|
100
|
-
<%# Alpine is VENDORED into the engine (studio/alpine.js) and shipped through the
|
|
101
|
-
asset pipeline — no CDN, CSP-safe (same-origin :self), mirroring canvas_confetti
|
|
102
|
-
and sortable below. PINNED AT 3.16.1.
|
|
103
|
-
|
|
104
|
-
WHAT THIS REPLACED, and why it is worth the 47KB in the repo: the tag here was
|
|
105
|
-
|
|
106
|
-
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js">
|
|
107
|
-
|
|
108
|
-
— a FLOATING major range, fetched from a third party at page load, for the
|
|
109
|
-
library every chip, drawer, modal and board filter in the fleet depends on.
|
|
110
|
-
Three consequences, none of them visible in a diff: two CI runs on ONE SHA could
|
|
111
|
-
execute different Alpine builds; production interactivity depended on jsDelivr
|
|
112
|
-
being reachable and honest; and an upgrade arrived with no review, because
|
|
113
|
-
nothing changed in the repo when it did. Bumping is now a deliberate act —
|
|
114
|
-
replace the file, change the version in this comment, and the diff shows it.
|
|
115
|
-
|
|
116
|
-
Investigated as a suspect in the board-filter flake (three PRs reddened in one
|
|
117
|
-
day, 2026-08-15). This removes a real source of run-to-run variance; it does NOT
|
|
118
|
-
claim to be that flake's cause, and the remaining suspect there is untouched. %>
|
|
119
|
-
<%= javascript_include_tag "studio/alpine", defer: true, "data-turbo-track": "reload" %>
|
|
120
109
|
<%# canvas-confetti is VENDORED into the engine (studio/canvas_confetti.js) and
|
|
121
110
|
shipped through the asset pipeline — no CDN, CSP-safe (same-origin :self),
|
|
122
111
|
zero-per-app dependency. Defines the global `confetti`; studio_confetti.js
|
|
123
|
-
then builds window.studioConfetti (burst/cannons). fireSuccessConfetti
|
|
124
|
-
|
|
112
|
+
then builds window.studioConfetti (burst/cannons). fireSuccessConfetti
|
|
113
|
+
(studio/head_chrome) rides the same global. %>
|
|
125
114
|
<%= javascript_include_tag "studio/canvas_confetti", "data-turbo-track": "reload" %>
|
|
126
115
|
<%= javascript_include_tag "studio/studio_confetti", "data-turbo-track": "reload" %>
|
|
127
116
|
<%# SortableJS is VENDORED into the engine (studio/sortable.js) and shipped through
|
|
@@ -130,57 +119,9 @@
|
|
|
130
119
|
drives it for drag-reorder kanban AND depth-chart boards. Loaded here (deferred)
|
|
131
120
|
so any board consumer has it with zero per-app wiring, mirroring canvas_confetti. %>
|
|
132
121
|
<%= javascript_include_tag "studio/sortable", defer: true, "data-turbo-track": "reload" %>
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
var colors = window.CONFETTI_COLORS || ['#4BAF50', '#8E82FE', '#06D6A0', '#FF7C47', '#FFD700', '#00BFFF', '#FF6B9D', '#C084FC'];
|
|
137
|
-
confetti({ particleCount: 150, spread: 100, origin: { x: 0.5, y: 0.5 }, colors: colors, zIndex: 9999, startVelocity: 45, gravity: 0.8, ticks: 300, scalar: 1.2 });
|
|
138
|
-
setTimeout(function() { confetti({ particleCount: 80, angle: 60, spread: 60, origin: { x: 0, y: 0.6 }, colors: colors, zIndex: 9999, startVelocity: 55, gravity: 1, ticks: 250 }); }, 150);
|
|
139
|
-
setTimeout(function() { confetti({ particleCount: 80, angle: 120, spread: 60, origin: { x: 1, y: 0.6 }, colors: colors, zIndex: 9999, startVelocity: 55, gravity: 1, ticks: 250 }); }, 150);
|
|
140
|
-
setTimeout(function() { confetti({ particleCount: 100, spread: 160, origin: { x: 0.5, y: 0.3 }, colors: colors, zIndex: 9999, startVelocity: 30, gravity: 1.2, ticks: 200, scalar: 0.8 }); }, 400);
|
|
141
|
-
};
|
|
142
|
-
</script>
|
|
143
|
-
<script>
|
|
144
|
-
// Nav spinner — scale morph between theme toggle and loading spinner
|
|
145
|
-
// Minimum display time prevents quick flashes; per-app via
|
|
146
|
-
// Studio.nav_spinner_min_ms (smooth-load apps drop it to ~300).
|
|
147
|
-
window._navSpinnerShownAt = 0;
|
|
148
|
-
window._navSpinnerMinMs = <%= Studio.nav_spinner_min_ms.to_i %>;
|
|
149
|
-
window.showNavSpinner = function() {
|
|
150
|
-
window._navSpinnerShownAt = Date.now();
|
|
151
|
-
document.querySelectorAll('.nav-toggle-icon').forEach(function(e) { e.style.opacity = '0'; e.style.transform = 'scale(0) rotate(90deg)'; });
|
|
152
|
-
document.querySelectorAll('.nav-spinner-icon').forEach(function(e) { e.style.opacity = '1'; e.style.transform = 'scale(1) rotate(0deg)'; });
|
|
153
|
-
};
|
|
154
|
-
window.hideNavSpinner = function() {
|
|
155
|
-
var elapsed = Date.now() - window._navSpinnerShownAt;
|
|
156
|
-
var remaining = Math.max(0, window._navSpinnerMinMs - elapsed);
|
|
157
|
-
setTimeout(function() {
|
|
158
|
-
document.querySelectorAll('.nav-toggle-icon').forEach(function(e) { e.style.opacity = '1'; e.style.transform = 'scale(1) rotate(0deg)'; });
|
|
159
|
-
document.querySelectorAll('.nav-spinner-icon').forEach(function(e) { e.style.opacity = '0'; e.style.transform = 'scale(0) rotate(-90deg)'; });
|
|
160
|
-
}, remaining);
|
|
161
|
-
};
|
|
162
|
-
// Reset spinner state before Turbo caches the page
|
|
163
|
-
document.addEventListener('turbo:before-cache', function() {
|
|
164
|
-
document.querySelectorAll('.nav-toggle-icon').forEach(function(e) { e.style.opacity = '1'; e.style.transform = 'scale(1) rotate(0deg)'; });
|
|
165
|
-
document.querySelectorAll('.nav-spinner-icon').forEach(function(e) { e.style.opacity = '0'; e.style.transform = 'scale(0) rotate(-90deg)'; });
|
|
166
|
-
});
|
|
167
|
-
</script>
|
|
168
|
-
<script>
|
|
169
|
-
document.addEventListener('alpine:init', () => {
|
|
170
|
-
Alpine.store('devMode', localStorage.getItem('devMode') === 'true');
|
|
171
|
-
Alpine.store('theme', {
|
|
172
|
-
value: localStorage.getItem('theme') || 'dark',
|
|
173
|
-
get isDark() { return this.value === 'dark'; },
|
|
174
|
-
toggle() {
|
|
175
|
-
document.documentElement.classList.add('theme-transition');
|
|
176
|
-
document.documentElement.classList.toggle('dark');
|
|
177
|
-
this.value = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
|
178
|
-
localStorage.setItem('theme', this.value);
|
|
179
|
-
setTimeout(() => document.documentElement.classList.remove('theme-transition'), 300);
|
|
180
|
-
}
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
</script>
|
|
122
|
+
<%# The theme and devMode stores are studio/alpine_stores (its module tag is below).
|
|
123
|
+
showNavSpinner / hideNavSpinner and fireSuccessConfetti are studio/head_chrome,
|
|
124
|
+
installed by studio/alpine_shims. %>
|
|
184
125
|
|
|
185
126
|
<style>
|
|
186
127
|
/* studio-engine: a sticky navbar that shrinks on scroll changes layout
|
|
@@ -190,300 +131,9 @@
|
|
|
190
131
|
navbar resize. */
|
|
191
132
|
body { overflow-anchor: none; }
|
|
192
133
|
</style>
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
// positions off it in CSS with no hardcoded px and no JS of its own.
|
|
197
|
-
//
|
|
198
|
-
// A layer joins by carrying `data-pin="<name>"`. Nothing else is required:
|
|
199
|
-
// no registration call, no declared stacking order, no consumer edit.
|
|
200
|
-
//
|
|
201
|
-
// WHAT A CONSUMER READS — prefer the first two; they are the reason this is a
|
|
202
|
-
// primitive rather than an arithmetic helper:
|
|
203
|
-
//
|
|
204
|
-
// --pin-stack-bottom the bottom of the WHOLE stack. What something sitting
|
|
205
|
-
// BENEATH all the pinned chrome positions off.
|
|
206
|
-
// --pin-<name>-top the bottom of everything ABOVE that layer. What a layer
|
|
207
|
-
// that is ITSELF in the stack positions off.
|
|
208
|
-
// --pin-<name>-h that layer's HEIGHT.
|
|
209
|
-
// --pin-<name>-bottom that layer's own BOTTOM EDGE, in viewport coordinates.
|
|
210
|
-
// --nav-h / --nav-bottom the <header>'s height and bottom edge, under their
|
|
211
|
-
// legacy names, from the same measurement.
|
|
212
|
-
//
|
|
213
|
-
// DO NOT COMPOSE THE STACK IN A CONSUMER. `top: max(var(--pin-nav-bottom),
|
|
214
|
-
// var(--pin-apps-bottom))` is what this replaced, and it fails twice: it does
|
|
215
|
-
// not scale, because a fourth layer means editing every consumer that ever
|
|
216
|
-
// wanted to sit under the stack; and it is not sound, because a max() over two
|
|
217
|
-
// custom properties is only meaningful if they were written in the same frame.
|
|
218
|
-
// --pin-stack-bottom is one value that cannot disagree with itself.
|
|
219
|
-
//
|
|
220
|
-
// --nav-h AND --nav-bottom ARE NOT THE SAME NUMBER. They are equal only when
|
|
221
|
-
// the header sits at the top of the viewport, which is why one was long
|
|
222
|
-
// mistaken for the other. An app that stacks chrome ABOVE the header — an
|
|
223
|
-
// environment banner rendered before the navbar, as mcritchie-industries does —
|
|
224
|
-
// pushes the header down, and a `fixed` overlay positioned at --nav-h then
|
|
225
|
-
// rides UP over the header by exactly the height of that chrome. Use --nav-h to
|
|
226
|
-
// SIZE something as tall as the header; use --nav-bottom to START something
|
|
227
|
-
// underneath it. Same distinction for --pin-<name>-h vs --pin-<name>-bottom.
|
|
228
|
-
(function () {
|
|
229
|
-
if (!window.ResizeObserver) return;
|
|
230
|
-
var ro = null;
|
|
231
|
-
var queued = false;
|
|
232
|
-
// WHAT WAS LAST PUBLISHED, per layer name: { h, bottom, top }. It does two
|
|
233
|
-
// jobs. It is the record of which names this publisher has written, so a
|
|
234
|
-
// DEPARTED layer can be cleared. And it is what lets an UNCHANGED value skip
|
|
235
|
-
// its write — these are INHERITED custom properties on documentElement, so
|
|
236
|
-
// even a write of the same number invalidates style for the whole document,
|
|
237
|
-
// and a scroll frame touches this four times per layer.
|
|
238
|
-
var last = {};
|
|
239
|
-
var lastStack = null;
|
|
240
|
-
// SKIPPING AN UNCHANGED WRITE IS ONLY SAFE WHILE NOTHING ELSE TOUCHES THESE
|
|
241
|
-
// PROPERTIES. On a scroll frame that holds, and skipping is most of why this
|
|
242
|
-
// is cheap. On a STRUCTURAL change it does not: a layer arriving or leaving,
|
|
243
|
-
// a Turbo patch, a fresh document — anything there may have cleared a
|
|
244
|
-
// property out from under the cache, and a skip would then never write it
|
|
245
|
-
// again. So a structural change forces a full write, once.
|
|
246
|
-
var forceWrite = true;
|
|
247
|
-
// The nodes currently under observation, held only to know what to unobserve
|
|
248
|
-
// when one departs. The REGISTRY ITSELF is never cached; see readPins().
|
|
249
|
-
var observed = [];
|
|
250
|
-
|
|
251
|
-
// THE REGISTRY IS DERIVED, NEVER CACHED — and that is a correctness rule.
|
|
252
|
-
//
|
|
253
|
-
// This used to build a `pins` array once and hold each element by reference
|
|
254
|
-
// until something re-registered it. A Turbo Stream replaces a pinned layer's
|
|
255
|
-
// node WITHOUT a turbo:load, so between the patch and the re-scan the held
|
|
256
|
-
// reference pointed at the DETACHED predecessor — and a detached node's rect
|
|
257
|
-
// is all zeros, which is indistinguishable from a layer that is legitimately
|
|
258
|
-
// hidden. Measured on mcritchie-studio's /deployments: --pin-apps-bottom
|
|
259
|
-
// published 0px while the live strip stood at display:block, bottom 152px,
|
|
260
|
-
// and the board's lane headers slammed 99px up and back on every broadcast.
|
|
261
|
-
//
|
|
262
|
-
// Re-querying costs one querySelectorAll over a handful of nodes per publish.
|
|
263
|
-
// That is cheaper than the class of bug it removes, because it makes a stale
|
|
264
|
-
// reference IMPOSSIBLE rather than merely unlikely: there is no window between
|
|
265
|
-
// a DOM patch and a re-scan in which this can be pointed at the wrong node.
|
|
266
|
-
function readPins() {
|
|
267
|
-
var header = document.querySelector('header');
|
|
268
|
-
var nodes = document.querySelectorAll('[data-pin]');
|
|
269
|
-
var list = [];
|
|
270
|
-
var headerPinned = false;
|
|
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
|
-
if (nodes[i] === header) headerPinned = true;
|
|
277
|
-
list.push(measure(nodes[i], name, nodes[i] === header));
|
|
278
|
-
}
|
|
279
|
-
// THE HOST-OWNED HEADER, and the back-compat promise that depends on it.
|
|
280
|
-
// A header that does not carry [data-pin] is not in the registry, so the
|
|
281
|
-
// legacy --nav-h / --nav-bottom — which studio/_sidebar_panel, turf's
|
|
282
|
-
// contest board and mcritchie-studio's heartbeat CSS all read — would
|
|
283
|
-
// never publish at all. Both live consumers own their header, so it is
|
|
284
|
-
// adopted as a pin even when it does not ask to be.
|
|
285
|
-
if (header && !headerPinned) list.push(measure(header, 'nav', true));
|
|
286
|
-
return list;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// ONE READ PER ELEMENT PER PUBLISH, and every read before every write.
|
|
290
|
-
// A read after a write to the root forces a fresh layout, which is the
|
|
291
|
-
// thrash this publisher exists without. Measured by review at 6x CPU
|
|
292
|
-
// throttle over 175 frames: frames past 20ms 62 vs 42, median 17.7 vs
|
|
293
|
-
// 14.9ms, p90 34.2 vs 26.0ms; at 10x, 84/180 vs 4/180.
|
|
294
|
-
function measure(el, name, legacy) {
|
|
295
|
-
// A hidden layer needs no special case: display:none gives offsetHeight 0
|
|
296
|
-
// and an all-zero rect, so it measures 0 and drops out of the stack on its
|
|
297
|
-
// own. That is what lets a layer come and go without anyone declaring a
|
|
298
|
-
// stacking order, and it is load-bearing rather than incidental.
|
|
299
|
-
var r = el.getBoundingClientRect();
|
|
300
|
-
return { el: el, name: name, legacy: legacy, h: el.offsetHeight, bottom: Math.max(0, r.bottom) };
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// THE STACK, COMPOSED HERE RATHER THAN BY EVERY CONSUMER.
|
|
304
|
-
//
|
|
305
|
-
// Consumers used to write `top: max(var(--pin-nav-bottom), var(--pin-apps-bottom))`
|
|
306
|
-
// — every layer enumerated, in every consumer. That is two failures at once.
|
|
307
|
-
// It does not SCALE: a fourth pinned layer means editing every consumer that
|
|
308
|
-
// ever wanted to sit under the stack. And it is not SOUND: max() over two
|
|
309
|
-
// independently-written custom properties is only meaningful if they were
|
|
310
|
-
// written in the same frame, and any skew between them is expressed instantly
|
|
311
|
-
// as layout. Composing here means a consumer reads ONE value that cannot
|
|
312
|
-
// disagree with itself.
|
|
313
|
-
//
|
|
314
|
-
// --pin-stack-bottom the bottom of the WHOLE pinned stack. What something
|
|
315
|
-
// sitting beneath all the chrome positions off.
|
|
316
|
-
// --pin-<name>-top the bottom of everything ABOVE that layer. What a
|
|
317
|
-
// layer that is ITSELF in the stack positions off —
|
|
318
|
-
// --pin-stack-bottom would include the layer's own
|
|
319
|
-
// edge, so a layer positioned off it would chase itself
|
|
320
|
-
// down the page.
|
|
321
|
-
//
|
|
322
|
-
// ORDER is document order, which is the order the layers were declared and
|
|
323
|
-
// therefore the order they read in. `data-pin-order` overrides it for a layer
|
|
324
|
-
// whose DOM position does not match where it sits on screen; it is a plain
|
|
325
|
-
// number, lower is higher up, and it needs to exist on only the layer that
|
|
326
|
-
// disagrees rather than on all of them.
|
|
327
|
-
function order(p) {
|
|
328
|
-
var raw = parseFloat(p.el.getAttribute('data-pin-order'));
|
|
329
|
-
return isNaN(raw) ? null : raw;
|
|
330
|
-
}
|
|
331
|
-
function composed(pins) {
|
|
332
|
-
var i, j, stack = 0;
|
|
333
|
-
for (i = 0; i < pins.length; i++) {
|
|
334
|
-
if (pins[i].bottom > stack) stack = pins[i].bottom;
|
|
335
|
-
var above = 0;
|
|
336
|
-
for (j = 0; j < pins.length; j++) {
|
|
337
|
-
if (i === j) continue;
|
|
338
|
-
var oi = order(pins[i]), oj = order(pins[j]);
|
|
339
|
-
// Both ordered: compare the numbers. Otherwise fall back to document
|
|
340
|
-
// order, which querySelectorAll already returns them in.
|
|
341
|
-
var jIsAbove = (oi !== null && oj !== null) ? oj < oi : j < i;
|
|
342
|
-
if (jIsAbove && pins[j].bottom > above) above = pins[j].bottom;
|
|
343
|
-
}
|
|
344
|
-
pins[i].top = above;
|
|
345
|
-
}
|
|
346
|
-
return stack;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function write(pins, stack) {
|
|
350
|
-
var style = document.documentElement.style;
|
|
351
|
-
var seen = {};
|
|
352
|
-
var i, p, prev;
|
|
353
|
-
// A ROSTER CHANGE IS STRUCTURAL TOO, and it is not always announced by an
|
|
354
|
-
// event: an x-show that adds the node, a layer rendered by something other
|
|
355
|
-
// than a stream. Compare the names against what was published last.
|
|
356
|
-
var force = forceWrite;
|
|
357
|
-
forceWrite = false;
|
|
358
|
-
if (!force) {
|
|
359
|
-
for (i = 0; i < pins.length; i++) { if (!last.hasOwnProperty(pins[i].name)) { force = true; break; } }
|
|
360
|
-
}
|
|
361
|
-
for (i = 0; i < pins.length; i++) {
|
|
362
|
-
p = pins[i];
|
|
363
|
-
seen[p.name] = true;
|
|
364
|
-
prev = last[p.name] || {};
|
|
365
|
-
if (force || p.h !== prev.h) {
|
|
366
|
-
style.setProperty('--pin-' + p.name + '-h', p.h + 'px');
|
|
367
|
-
if (p.legacy) style.setProperty('--nav-h', p.h + 'px');
|
|
368
|
-
}
|
|
369
|
-
if (force || p.bottom !== prev.bottom) {
|
|
370
|
-
style.setProperty('--pin-' + p.name + '-bottom', p.bottom + 'px');
|
|
371
|
-
if (p.legacy) style.setProperty('--nav-bottom', p.bottom + 'px');
|
|
372
|
-
}
|
|
373
|
-
if (force || p.top !== prev.top) style.setProperty('--pin-' + p.name + '-top', p.top + 'px');
|
|
374
|
-
last[p.name] = { h: p.h, bottom: p.bottom, top: p.top };
|
|
375
|
-
}
|
|
376
|
-
if (force || stack !== lastStack) {
|
|
377
|
-
style.setProperty('--pin-stack-bottom', stack + 'px');
|
|
378
|
-
lastStack = stack;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// CLEAR WHAT LEFT. A layer goes away three ways: display:none and x-show
|
|
382
|
-
// both keep the node, so it measures 0 and drops out of the stack on its
|
|
383
|
-
// own. REMOVAL does not — nothing is left to measure, and without this the
|
|
384
|
-
// last published value stands forever while the consumer sits at the height
|
|
385
|
-
// of a strip that is gone. Review measured a removed 300px strip holding
|
|
386
|
-
// --pin-apps-bottom at 300px against a real stack bottom of 160px.
|
|
387
|
-
for (var name in last) {
|
|
388
|
-
if (last.hasOwnProperty(name) && !seen[name]) {
|
|
389
|
-
style.removeProperty('--pin-' + name + '-h');
|
|
390
|
-
style.removeProperty('--pin-' + name + '-bottom');
|
|
391
|
-
style.removeProperty('--pin-' + name + '-top');
|
|
392
|
-
delete last[name];
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function publishAll() {
|
|
398
|
-
var pins = readPins();
|
|
399
|
-
write(pins, composed(pins));
|
|
400
|
-
syncObserver(pins);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// Observe whatever is pinned RIGHT NOW. observe() on an already-observed node
|
|
404
|
-
// is a no-op, so this only ever adds the new; unobserve() drops the departed
|
|
405
|
-
// so a replaced node cannot keep waking us from outside the document.
|
|
406
|
-
function syncObserver(pins) {
|
|
407
|
-
if (!pins.length) {
|
|
408
|
-
if (ro) { ro.disconnect(); ro = null; observed = []; }
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
if (!ro) ro = new ResizeObserver(onResize);
|
|
412
|
-
var i, next = [];
|
|
413
|
-
for (i = 0; i < pins.length; i++) { next.push(pins[i].el); ro.observe(pins[i].el); }
|
|
414
|
-
for (i = 0; i < observed.length; i++) {
|
|
415
|
-
if (next.indexOf(observed[i]) === -1) ro.unobserve(observed[i]);
|
|
416
|
-
}
|
|
417
|
-
observed = next;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// PUBLISH IN THE ResizeObserver CALLBACK, SYNCHRONOUSLY. This is the whole
|
|
421
|
-
// fix, and it is a statement about WHEN a frame does its work rather than
|
|
422
|
-
// about what this code computes.
|
|
423
|
-
//
|
|
424
|
-
// A frame runs: rAF callbacks -> style/layout -> ResizeObserver callbacks ->
|
|
425
|
-
// paint. This publisher used to be woken by the observer and then DEFER the
|
|
426
|
-
// write to requestAnimationFrame, which lands at the top of the NEXT frame —
|
|
427
|
-
// so the frame in which a layer actually appeared or disappeared painted with
|
|
428
|
-
// the previous frame's number, every time. The RO callback runs after layout
|
|
429
|
-
// and before paint, which is exactly the last moment a write can still land
|
|
430
|
-
// in the frame that caused it.
|
|
431
|
-
//
|
|
432
|
-
// A/B measured in isolation, toggling a pinned layer's display and sampling
|
|
433
|
-
// what the changing frame paints: rAF-deferred wrong on 8/8 changing frames,
|
|
434
|
-
// RO-synchronous wrong on 0/8.
|
|
435
|
-
//
|
|
436
|
-
// This cannot loop. The values written here move CONSUMERS, and a consumer is
|
|
437
|
-
// not a pin — nothing this writes resizes anything it observes. A pinned layer
|
|
438
|
-
// that positions off --pin-<name>-top MOVES without RESIZING, and the observer
|
|
439
|
-
// fires on size, so it settles in one pass rather than ringing.
|
|
440
|
-
// The one way a SYNCHRONOUS publish could misbehave: a consumer that both
|
|
441
|
-
// reads a published value and is itself a pin, sized off what it reads
|
|
442
|
-
// (`height: calc(100vh - var(--pin-apps-top))`). That resizes an observed
|
|
443
|
-
// node from inside the observer's own callback, and left alone the browser
|
|
444
|
-
// reports "ResizeObserver loop completed with undelivered notifications" and
|
|
445
|
-
// drops the frame. So re-entry is counted, and past a couple of settling
|
|
446
|
-
// passes this hands the rest to the frame callback: the pathological page
|
|
447
|
-
// degrades to the OLD one-frame-late behaviour instead of wedging, and the
|
|
448
|
-
// ordinary page never reaches the branch. Reset on the next frame, so the
|
|
449
|
-
// count measures one frame's settling rather than the session's.
|
|
450
|
-
var depth = 0, resetQueued = false;
|
|
451
|
-
function onResize() {
|
|
452
|
-
if (depth >= 3) { schedule(); return; }
|
|
453
|
-
depth++;
|
|
454
|
-
if (!resetQueued) {
|
|
455
|
-
resetQueued = true;
|
|
456
|
-
window.requestAnimationFrame(function () { resetQueued = false; depth = 0; });
|
|
457
|
-
}
|
|
458
|
-
publishAll();
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// SCROLL STILL NEEDS THE FRAME CALLBACK. A scroll changes a sticky header's
|
|
462
|
-
// bottom EDGE without changing its size, so the observer never sees it and
|
|
463
|
-
// there is nothing to be synchronous with — rAF is the right clock, and it
|
|
464
|
-
// coalesces the burst iOS momentum fires far above 60Hz.
|
|
465
|
-
function schedule() {
|
|
466
|
-
if (queued) return;
|
|
467
|
-
queued = true;
|
|
468
|
-
window.requestAnimationFrame(function () { queued = false; publishAll(); });
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
function invalidate() { forceWrite = true; publishAll(); }
|
|
472
|
-
document.addEventListener('DOMContentLoaded', invalidate);
|
|
473
|
-
document.addEventListener('turbo:load', invalidate);
|
|
474
|
-
// A Turbo Stream replaces nodes WITHOUT a turbo:load. The registry is derived
|
|
475
|
-
// per publish, so a stale reference is no longer possible either way — but the
|
|
476
|
-
// OBSERVER still has to be moved onto the incoming node, and the stack has to
|
|
477
|
-
// be recomposed for a layer that arrived or left. Both happen in publishAll.
|
|
478
|
-
document.addEventListener('turbo:before-stream-render', function () {
|
|
479
|
-
window.requestAnimationFrame(invalidate);
|
|
480
|
-
});
|
|
481
|
-
// Registered ONCE at script eval, not inside a re-scan, so a Turbo nav
|
|
482
|
-
// re-publishes instead of stacking another listener per visit.
|
|
483
|
-
window.addEventListener('scroll', schedule, { passive: true });
|
|
484
|
-
window.addEventListener('resize', schedule, { passive: true });
|
|
485
|
-
})();
|
|
486
|
-
</script>
|
|
134
|
+
<%# THE PINNED STACK: studio/pinned_stack publishes --pin-<name>-h, -bottom and
|
|
135
|
+
-top for every [data-pin] layer, --pin-stack-bottom, and the legacy --nav-h /
|
|
136
|
+
--nav-bottom. Started by studio/application. %>
|
|
487
137
|
|
|
488
138
|
<style>
|
|
489
139
|
/* studio-engine: --nav-p, the sticky header's COLLAPSE PROGRESS, 0 (expanded)
|
|
@@ -504,204 +154,9 @@
|
|
|
504
154
|
initial-value: 0;
|
|
505
155
|
}
|
|
506
156
|
</style>
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
// Publishes --nav-p on the <header> once per animation frame from
|
|
511
|
-
// window.scrollY. The navbar's own stylesheet derives every collapsing
|
|
512
|
-
// dimension from it with calc(), so the header moves only in a frame the
|
|
513
|
-
// finger moved it, and stops the instant the finger does.
|
|
514
|
-
//
|
|
515
|
-
// It replaces `@scroll.window="scrolled = scrolled ? (scrollY > 5) : (scrollY
|
|
516
|
-
// > 60)"` plus `transition-all duration-300`. That pair let the FINGER set a
|
|
517
|
-
// step and an ease curve own everything after it. Measured in turf-monster at
|
|
518
|
-
// 390x844 before the change: the header ran 178px -> 139px and 34 of those
|
|
519
|
-
// 39px of document reflow landed AFTER the scroll had stopped, over 232ms, at
|
|
520
|
-
// up to 3px per frame of content nobody asked to move -- plus a 1px REVERSE
|
|
521
|
-
// lurch in the frame the class flipped, where a discrete text-3xl -> text-xl
|
|
522
|
-
// swap collided with the stylesheet's own `transition: font-size`.
|
|
523
|
-
//
|
|
524
|
-
// ADOPTING IT: put `nav-shell` and `x-data="navCollapse()"` on the header,
|
|
525
|
-
// give each breakpoint band a `--nav-ramp`, and write the collapsing
|
|
526
|
-
// dimensions as calc()s off --nav-p. This file ships NO sizing opinion, so an
|
|
527
|
-
// app whose navbar collapses to different endpoints than the engine's adopts
|
|
528
|
-
// the mechanism without touching its markup.
|
|
529
|
-
//
|
|
530
|
-
// Four details are load-bearing:
|
|
531
|
-
//
|
|
532
|
-
// passive + rAF — the listener never blocks the compositor and coalesces a
|
|
533
|
-
// burst of scroll events (iOS momentum fires far above 60Hz) into one write
|
|
534
|
-
// per frame. The write lands on the HEADER, not :root: an inherited custom
|
|
535
|
-
// property written on :root dirties style for the whole document every
|
|
536
|
-
// frame, and it would leak the live page's scroll progress into the preview
|
|
537
|
-
// headers on /navbar.
|
|
538
|
-
//
|
|
539
|
-
// the smoothstep — collapsing a sticky, IN-FLOW header pulls the page up,
|
|
540
|
-
// so during the collapse content moves by the scroll AND by the shrink:
|
|
541
|
-
// faster than the finger, always. That is inherent; reclaiming the vertical
|
|
542
|
-
// space is the point. What is tunable is the shape of the burst. --nav-ramp
|
|
543
|
-
// is sized at 3x the band's collapse total and the ramp is smoothstepped,
|
|
544
|
-
// whose slope is zero at both ends, so content speed LEAVES 1x, peaks near
|
|
545
|
-
// 1.5x mid-ramp, and returns to 1x with no velocity step. A linear ramp
|
|
546
|
-
// equal to the collapse hits 2x and steps straight back to 1x.
|
|
547
|
-
//
|
|
548
|
-
// the short-page guard — collapsing shortens the document by the collapse
|
|
549
|
-
// total. On a page with barely more than that to scroll, the collapse
|
|
550
|
-
// deletes the very scroll room that triggered it, the browser clamps
|
|
551
|
-
// scrollY to 0, and the navbar flaps open and shut forever. roomExpanded
|
|
552
|
-
// adds back the shrink ALREADY applied, so the measurement cannot chase
|
|
553
|
-
// itself as it collapses.
|
|
554
|
-
//
|
|
555
|
-
// reduced motion — scroll-linked motion has no clock left to slow down, but
|
|
556
|
-
// resizing type under a moving finger is itself the motion some readers are
|
|
557
|
-
// asking us to drop. Under the query --nav-p snaps 0/1 on the old
|
|
558
|
-
// 60/5 hysteresis instead of interpolating.
|
|
559
|
-
window.navCollapse = function () {
|
|
560
|
-
return {
|
|
561
|
-
scrolled: false,
|
|
562
|
-
p: 0,
|
|
563
|
-
_ramp: 144,
|
|
564
|
-
_maxPx: 5,
|
|
565
|
-
_reduce: null,
|
|
566
|
-
_onScroll: null,
|
|
567
|
-
_onResize: null,
|
|
568
|
-
_onReduce: null,
|
|
569
|
-
init: function () {
|
|
570
|
-
var self = this;
|
|
571
|
-
var el = this.$el;
|
|
572
|
-
var queued = false;
|
|
573
|
-
|
|
574
|
-
this._reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
575
|
-
|
|
576
|
-
function readRamp() {
|
|
577
|
-
var style = getComputedStyle(el);
|
|
578
|
-
var raw = parseFloat(style.getPropertyValue('--nav-ramp'));
|
|
579
|
-
self._ramp = raw > 0 ? raw : 144;
|
|
580
|
-
var step = parseFloat(style.getPropertyValue('--nav-max-step'));
|
|
581
|
-
self._maxPx = step > 0 ? step : 5;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
function apply() {
|
|
585
|
-
queued = false;
|
|
586
|
-
var ramp = self._ramp;
|
|
587
|
-
// Clamped: rubber-band overscroll reports a NEGATIVE scrollY, and a
|
|
588
|
-
// negative progress inflates the navbar past its expanded size.
|
|
589
|
-
var y = Math.max(0, window.scrollY);
|
|
590
|
-
|
|
591
|
-
// The height the document WOULD have with the navbar expanded. The
|
|
592
|
-
// add-back is the whole trick — see the guard note above.
|
|
593
|
-
var roomExpanded = document.documentElement.scrollHeight
|
|
594
|
-
- window.innerHeight
|
|
595
|
-
+ ramp * self.p;
|
|
596
|
-
|
|
597
|
-
// WHERE THE COLLAPSE WANTS TO BE, from scroll position alone.
|
|
598
|
-
var target;
|
|
599
|
-
var snap = false;
|
|
600
|
-
if (roomExpanded < ramp + 24) {
|
|
601
|
-
target = 0;
|
|
602
|
-
snap = true;
|
|
603
|
-
} else if (self._reduce.matches) {
|
|
604
|
-
target = (self.p > 0 ? y > 5 : y > 60) ? 1 : 0;
|
|
605
|
-
snap = true;
|
|
606
|
-
} else {
|
|
607
|
-
var t = Math.min(1, y / ramp);
|
|
608
|
-
target = t * t * (3 - 2 * t);
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
// THE RATE LIMIT — how far the collapse may travel in ONE frame.
|
|
612
|
-
//
|
|
613
|
-
// Position-linked progress fixed motion that OUTLIVED the gesture. It
|
|
614
|
-
// also guaranteed the opposite defect: if scrollY moves 90px between
|
|
615
|
-
// two frames, so does the header's whole range. Measured in
|
|
616
|
-
// turf-monster at 390x844, worst single-frame header height change by
|
|
617
|
-
// scroll profile —
|
|
618
|
-
//
|
|
619
|
-
// 8px/frame (slow, deliberate) 3.9px
|
|
620
|
-
// 24px/frame (normal swipe) 14.1px
|
|
621
|
-
// momentum flick 39.0px <- the ENTIRE collapse
|
|
622
|
-
// hard flick 39.0px
|
|
623
|
-
//
|
|
624
|
-
// 39px is the full 178 -> 139. It was found on a phone: slow
|
|
625
|
-
// scrolling looked right, a flick jumped. The measurements behind the
|
|
626
|
-
// original primitive all walked a constant 8px/frame, which is
|
|
627
|
-
// exactly the case that already worked.
|
|
628
|
-
//
|
|
629
|
-
// So the TARGET stays position-linked and the STEP is clamped. What
|
|
630
|
-
// makes this safe is that a slow scroll never REACHES the clamp —
|
|
631
|
-
// under --nav-max-step of header travel per frame the branch below
|
|
632
|
-
// returns `target` untouched, so the slow feel is not approximated,
|
|
633
|
-
// it is the same arithmetic.
|
|
634
|
-
//
|
|
635
|
-
// maxStep is derived, not tuned per band: engine.css sizes --nav-ramp
|
|
636
|
-
// at 3x the band's collapse total, so a cap of MAX px/frame is
|
|
637
|
-
// 3*MAX/ramp in --nav-p units. Mobile (ramp 120) gives 4.9px/frame,
|
|
638
|
-
// desktop (ramp 144) 5.0px. Retune --nav-ramp without keeping that 3x
|
|
639
|
-
// relation and this cap silently drifts with it.
|
|
640
|
-
var p;
|
|
641
|
-
if (snap) {
|
|
642
|
-
// A guard refusal and a reduced-motion state are DECISIONS, not
|
|
643
|
-
// motion; ramping them would animate the very thing each exists to
|
|
644
|
-
// avoid.
|
|
645
|
-
p = target;
|
|
646
|
-
} else {
|
|
647
|
-
var maxStep = (3 * self._maxPx) / ramp;
|
|
648
|
-
var delta = target - self.p;
|
|
649
|
-
p = Math.abs(delta) <= maxStep
|
|
650
|
-
? target
|
|
651
|
-
: self.p + (delta > 0 ? maxStep : -maxStep);
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
if (p !== self.p) {
|
|
655
|
-
self.p = p;
|
|
656
|
-
el.style.setProperty('--nav-p', p.toFixed(4));
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
// KEEP FRAMES COMING WHILE CATCHING UP. Nothing else will schedule
|
|
660
|
-
// one: the finger is off, so no more scroll events arrive, and
|
|
661
|
-
// without this the collapse freezes wherever the clamp left it. It
|
|
662
|
-
// converges LINEARLY and lands exactly — about 8 frames (~133ms) at
|
|
663
|
-
// the mobile --nav-ramp of 120, and about 10 (~167ms) at the default
|
|
664
|
-
// 144, from a hard flick — rather than crawling asymptotically the
|
|
665
|
-
// way the 300ms ease this replaced did.
|
|
666
|
-
if (!snap && p !== target) {
|
|
667
|
-
queued = true;
|
|
668
|
-
requestAnimationFrame(apply);
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
// The shadow is the one thing still on a clock, and it may stay
|
|
672
|
-
// there: box-shadow paints, it never reflows, so it cannot move
|
|
673
|
-
// content. Hysteresis keeps it from strobing at the boundary.
|
|
674
|
-
var lit = self.scrolled ? y > 5 : y > 60;
|
|
675
|
-
if (lit !== self.scrolled) self.scrolled = lit;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
this._onScroll = function () {
|
|
679
|
-
if (queued) return;
|
|
680
|
-
queued = true;
|
|
681
|
-
requestAnimationFrame(apply);
|
|
682
|
-
};
|
|
683
|
-
this._onResize = function () { readRamp(); self._onScroll(); };
|
|
684
|
-
this._onReduce = apply;
|
|
685
|
-
|
|
686
|
-
readRamp();
|
|
687
|
-
apply();
|
|
688
|
-
|
|
689
|
-
window.addEventListener('scroll', this._onScroll, { passive: true });
|
|
690
|
-
window.addEventListener('resize', this._onResize, { passive: true });
|
|
691
|
-
if (this._reduce.addEventListener) this._reduce.addEventListener('change', this._onReduce);
|
|
692
|
-
},
|
|
693
|
-
destroy: function () {
|
|
694
|
-
// A Turbo visit tears the header down and builds a new one; without
|
|
695
|
-
// this every visit would stack another listener on window.
|
|
696
|
-
if (this._onScroll) window.removeEventListener('scroll', this._onScroll);
|
|
697
|
-
if (this._onResize) window.removeEventListener('resize', this._onResize);
|
|
698
|
-
if (this._reduce && this._reduce.removeEventListener && this._onReduce) {
|
|
699
|
-
this._reduce.removeEventListener('change', this._onReduce);
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
};
|
|
703
|
-
};
|
|
704
|
-
</script>
|
|
157
|
+
<%# The collapse itself is studio/nav_collapse, bound by the engine's nav-collapse
|
|
158
|
+
controller (and by the navCollapse() Alpine shim for headers that still say
|
|
159
|
+
x-data="navCollapse()"). %>
|
|
705
160
|
|
|
706
161
|
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
|
|
707
162
|
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
|
@@ -709,6 +164,43 @@
|
|
|
709
164
|
<%= stylesheet_link_tag "studio/sticky_table_header", "data-turbo-track": "reload" %>
|
|
710
165
|
<% end %>
|
|
711
166
|
<%= javascript_importmap_tags %>
|
|
167
|
+
<%# THE ENGINE'S BROWSER BOOT (studio/application): its Stimulus application,
|
|
168
|
+
the pinned stack, and the Alpine shims. A module tag, so it carries the nonce. %>
|
|
169
|
+
<%= javascript_import_module_tag "studio/application" %>
|
|
170
|
+
<%# THE STORES, BY THEIR OWN DOOR. Every host's <body> binds $store.devMode and the
|
|
171
|
+
theme toggle binds $store.theme, so a boot that fails to load (a missing digest,
|
|
172
|
+
a network drop, a throw anywhere in its graph) must not take the stores with it.
|
|
173
|
+
studio/alpine_stores imports nothing and installs them on alpine:init; it is
|
|
174
|
+
also in the boot graph, so this resolves to the same preloaded module and
|
|
175
|
+
registers nothing twice. A module tag, so it carries the nonce: never a bare
|
|
176
|
+
inline fallback (e2e/stores_survive_boot_failure.spec.js). %>
|
|
177
|
+
<%= javascript_import_module_tag "studio/alpine_stores" %>
|
|
178
|
+
<%# ALPINE LOADS AFTER THE MODULE TAGS, and the order is the contract. Deferred
|
|
179
|
+
classic scripts and module scripts execute in document order, so by the time
|
|
180
|
+
Alpine starts, studio/application has defined the shims it evaluates
|
|
181
|
+
(x-data="navCollapse()") and studio/alpine_stores has registered the
|
|
182
|
+
`alpine:init` listener that installs $store.theme and $store.devMode. Moved
|
|
183
|
+
above the module tags, Alpine would evaluate those before they exist (e2e/stimulus_boot.spec.js). %>
|
|
184
|
+
<%# Alpine is VENDORED into the engine (studio/alpine.js) and shipped through the
|
|
185
|
+
asset pipeline — no CDN, CSP-safe (same-origin :self), mirroring canvas_confetti
|
|
186
|
+
and sortable below. PINNED AT 3.16.1.
|
|
187
|
+
|
|
188
|
+
WHAT THIS REPLACED, and why it is worth the 47KB in the repo: the tag here was
|
|
189
|
+
|
|
190
|
+
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js">
|
|
191
|
+
|
|
192
|
+
— a FLOATING major range, fetched from a third party at page load, for the
|
|
193
|
+
library every chip, drawer, modal and board filter in the fleet depends on.
|
|
194
|
+
Three consequences, none of them visible in a diff: two CI runs on ONE SHA could
|
|
195
|
+
execute different Alpine builds; production interactivity depended on jsDelivr
|
|
196
|
+
being reachable and honest; and an upgrade arrived with no review, because
|
|
197
|
+
nothing changed in the repo when it did. Bumping is now a deliberate act —
|
|
198
|
+
replace the file, change the version in this comment, and the diff shows it.
|
|
199
|
+
|
|
200
|
+
Investigated as a suspect in the board-filter flake (three PRs reddened in one
|
|
201
|
+
day, 2026-08-15). This removes a real source of run-to-run variance; it does NOT
|
|
202
|
+
claim to be that flake's cause, and the remaining suspect there is untouched. %>
|
|
203
|
+
<%= javascript_include_tag "studio/alpine", defer: true, "data-turbo-track": "reload" %>
|
|
712
204
|
<% if Studio.sticky_table_headers %>
|
|
713
205
|
<%= javascript_include_tag "studio/sticky_table_header", defer: true, "data-turbo-track": "reload" %>
|
|
714
206
|
<% end %>
|