studio-engine 0.92.2 → 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.
@@ -1,12 +1,21 @@
1
- <script>
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
- </script>
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 below
124
- is unchanged and rides the same global. %>
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
- <script>
134
- window.fireSuccessConfetti = function() {
135
- if (typeof confetti === 'undefined') return;
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
- <script>
194
- // studio-engine: THE PINNED STACK. Publishes the live geometry of every layer
195
- // of pinned page chrome as CSS custom properties, so anything fixed or sticky
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
- <script>
508
- // studio-engine: navCollapse — the scroll-linked navbar collapse.
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 %>