studio-engine 0.110.0 → 0.111.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 +22 -0
- data/app/assets/tailwind/studio_engine/engine.css +94 -0
- data/app/javascript/studio/controllers/nav_collapse_controller.js +7 -1
- data/app/javascript/studio/nav_collapse.js +173 -0
- data/app/javascript/studio/pinned_stack.js +5 -1
- data/app/views/layouts/_navbar.html.erb +5 -1
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +11 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9d7cabff315fe9d235009b89cffbc4409acdec4cb52b5e99f750dbc09a10b2ba
|
|
4
|
+
data.tar.gz: 4f7b337c79de43cc0c1269570f0bc2947f8e55ceccef13281e2494ee85e70178
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a5a8f42972f22be43ad7e325b629bf7d2b5b4e374ffee170e531b0d6c730dbadecd96cebc5f8b36d7024dd476bbd424ee27892b9423c9d5af2f4ce5a2d8b1e19
|
|
7
|
+
data.tar.gz: 187c2bb411cc2f2a1312379e6998879f44f13bedcdbb5e2fe7d92ca94a11795c89a187d2c36d616ca72bfe3424a361ed1f9d308ac879418d8ebaca6f4e87fbe5
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@ 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.0 — 2026-10-16
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **The navbar can collapse on the browser's scroll timeline** (opt-in:
|
|
12
|
+
`config.navbar_scroll_timeline = true`, or `scroll_timeline: true` on one
|
|
13
|
+
render of `layouts/_navbar`). From 768px, in a browser with scroll-driven
|
|
14
|
+
animations, the header keeps one height and is sticky at minus its band (its
|
|
15
|
+
expanded height less its collapsed height), so the band scrolls away with the
|
|
16
|
+
page and the compact bar sticks. The logo, title, avatar and name shrink by
|
|
17
|
+
transforms on the page's scroll timeline, and the icon rail slides so the
|
|
18
|
+
gaps stay even. The compositor runs all of it with the scroll: no script per
|
|
19
|
+
frame, no relayout, so a busy page cannot make the header stutter. The
|
|
20
|
+
scrolled line and shadow are drawn by `::after` on the same timeline.
|
|
21
|
+
`studio/nav_collapse` gains a timeline mode for the header: it measures the
|
|
22
|
+
band and the scales once (on connect, resize, font load and a bar resize)
|
|
23
|
+
and keeps the `is-scrolled` marker; phones (under 768px) and browsers without
|
|
24
|
+
scroll timelines (Firefox today) keep the `--nav-p` collapse, and crossing
|
|
25
|
+
768px swaps modes. On the timeline, `--nav-h` is the compact bar's height,
|
|
26
|
+
so an anchor's `scroll-margin-top` clears what is pinned. Off by default: a
|
|
27
|
+
header without the opt-in renders and behaves byte-identical to before.
|
|
28
|
+
|
|
7
29
|
## 0.110.0 — 2026-10-15
|
|
8
30
|
|
|
9
31
|
### Added
|
|
@@ -752,6 +752,100 @@ body.modal-open .studio-app-banner {
|
|
|
752
752
|
}
|
|
753
753
|
}
|
|
754
754
|
|
|
755
|
+
/* THE SCROLL-DRIVEN COLLAPSE (opt-in: config.navbar_scroll_timeline, which puts
|
|
756
|
+
data-nav-timeline on layouts/_navbar's header).
|
|
757
|
+
|
|
758
|
+
The band table above interpolates real sizes off --nav-p, which
|
|
759
|
+
studio/nav_collapse writes from script once per frame. Every one of those
|
|
760
|
+
frames is a style-and-layout pass on the main thread, so the header can only
|
|
761
|
+
be as smooth as the page around it: on a busy page (the hub's /deployments,
|
|
762
|
+
2026-10-15) a flick dropped frames and the header stepped.
|
|
763
|
+
|
|
764
|
+
Here nothing changes size, so nothing reflows and no script runs per frame:
|
|
765
|
+
|
|
766
|
+
THE BOX IS FIXED at its expanded height, and sticky at minus the BAND (its
|
|
767
|
+
expanded height less its collapsed height, measured by the controller and
|
|
768
|
+
published as --nav-band). So the band scrolls away with the page by the
|
|
769
|
+
browser's own scrolling, and the compact bar is what sticks. The document
|
|
770
|
+
never changes height; the page cannot move by itself.
|
|
771
|
+
|
|
772
|
+
THE SHRINK IS A SCROLL TIMELINE on transforms: the row slides down half the
|
|
773
|
+
band so it stays centred on the visible bar, the brand scales from its left
|
|
774
|
+
edge, the avatar and name from the right (the name by the avatar's ratio,
|
|
775
|
+
the rule of 3), and the icon rail slides right by what the account gave up
|
|
776
|
+
so the gaps stay even. The compositor runs these with the scroll itself.
|
|
777
|
+
|
|
778
|
+
THE SCROLLED LINE AND SHADOW are drawn by ::after on the same timeline,
|
|
779
|
+
not by border-b (which would add a pixel of height and reflow).
|
|
780
|
+
|
|
781
|
+
--nav-start is where the header meets the top of the window: the height of
|
|
782
|
+
the bars above it in flow (studio/banners/_stack). Both it and the scales
|
|
783
|
+
are measured once (on connect, resize, font load, a bar resize), never per
|
|
784
|
+
frame; studio/nav_collapse's timeline mode owns them.
|
|
785
|
+
|
|
786
|
+
Only from 768px, and only where the browser has scroll timelines (Firefox
|
|
787
|
+
does not yet): the controller tests the same two conditions and runs the
|
|
788
|
+
--nav-p collapse otherwise, so the two can never both drive the header. */
|
|
789
|
+
@layer utilities {
|
|
790
|
+
@supports (animation-timeline: scroll()) {
|
|
791
|
+
@media (min-width: 768px) {
|
|
792
|
+
.nav-shell[data-nav-timeline] {
|
|
793
|
+
--nav-band: 40px;
|
|
794
|
+
top: calc(-1 * var(--nav-band));
|
|
795
|
+
}
|
|
796
|
+
.nav-shell[data-nav-timeline]::after {
|
|
797
|
+
content: "";
|
|
798
|
+
position: absolute;
|
|
799
|
+
inset: auto 0 0 0;
|
|
800
|
+
height: 1px;
|
|
801
|
+
background: var(--color-border);
|
|
802
|
+
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
|
803
|
+
pointer-events: none;
|
|
804
|
+
opacity: 0;
|
|
805
|
+
animation: nav-tl-lit linear both;
|
|
806
|
+
animation-timeline: scroll(root block);
|
|
807
|
+
animation-range: calc(var(--nav-start, 0px) + var(--nav-band) - 8px) calc(var(--nav-start, 0px) + var(--nav-band) + 16px);
|
|
808
|
+
}
|
|
809
|
+
/* The row slides down half the band to stay centred on the bar, so its
|
|
810
|
+
bottom padding (empty, transparent) overhangs the header by that much,
|
|
811
|
+
and an overhang still takes the pointer: it ate clicks on the top 20px
|
|
812
|
+
of the hub's pinned board lane headers. The row lets the pointer
|
|
813
|
+
through; its contents, which sit inside the bar, still take it. */
|
|
814
|
+
.nav-shell[data-nav-timeline] > .nav-row { pointer-events: none; }
|
|
815
|
+
.nav-shell[data-nav-timeline] > .nav-row > * { pointer-events: auto; }
|
|
816
|
+
.nav-shell[data-nav-timeline] > .nav-row {
|
|
817
|
+
animation: nav-tl-row linear both;
|
|
818
|
+
animation-timeline: scroll(root block);
|
|
819
|
+
animation-range: var(--nav-start, 0px) calc(var(--nav-start, 0px) + var(--nav-band));
|
|
820
|
+
}
|
|
821
|
+
.nav-shell[data-nav-timeline] .nav-logo-link,
|
|
822
|
+
.nav-shell[data-nav-timeline] [data-nav-account] > [aria-hidden],
|
|
823
|
+
.nav-shell[data-nav-timeline] [data-nav-account] > span:first-child,
|
|
824
|
+
.nav-shell[data-nav-timeline] [data-nav-tools] {
|
|
825
|
+
animation-timing-function: ease-in-out;
|
|
826
|
+
animation-fill-mode: both;
|
|
827
|
+
animation-timeline: scroll(root block);
|
|
828
|
+
animation-range: var(--nav-start, 0px) calc(var(--nav-start, 0px) + var(--nav-ramp));
|
|
829
|
+
}
|
|
830
|
+
.nav-shell[data-nav-timeline] .nav-logo-link { transform-origin: left center; animation-name: nav-tl-brand; }
|
|
831
|
+
.nav-shell[data-nav-timeline] [data-nav-account] > [aria-hidden] { transform-origin: right center; animation-name: nav-tl-avatar; }
|
|
832
|
+
.nav-shell[data-nav-timeline] [data-nav-account] > span:first-child { transform-origin: right center; animation-name: nav-tl-name; }
|
|
833
|
+
.nav-shell[data-nav-timeline] [data-nav-tools] { animation-name: nav-tl-tools; }
|
|
834
|
+
}
|
|
835
|
+
/* Reduced motion: the shrink snaps at the first pixel, as --nav-p's 0/1
|
|
836
|
+
snap does on the script path. */
|
|
837
|
+
@media (min-width: 768px) and (prefers-reduced-motion: reduce) {
|
|
838
|
+
.nav-shell[data-nav-timeline] { --nav-ramp: 1px; }
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
@keyframes nav-tl-row { to { transform: translateY(calc(var(--nav-band) / 2)); } }
|
|
843
|
+
@keyframes nav-tl-brand { to { transform: scale(var(--nav-brand-scale, 1)); } }
|
|
844
|
+
@keyframes nav-tl-avatar { to { transform: scale(var(--nav-avatar-scale, 1)); } }
|
|
845
|
+
@keyframes nav-tl-name { to { transform: translateX(var(--nav-avatar-shrink, 0px)) scale(var(--nav-avatar-scale, 1)); } }
|
|
846
|
+
@keyframes nav-tl-tools { to { transform: translateX(var(--nav-tools-shift, 0px)); } }
|
|
847
|
+
@keyframes nav-tl-lit { to { opacity: 1; } }
|
|
848
|
+
|
|
755
849
|
/* A board whose controller failed to load (studio/lazy_controllers marks its
|
|
756
850
|
section data-studio-controller-failed="board") shows the notice
|
|
757
851
|
studio/board/_board renders with an inline display: none. !important is what
|
|
@@ -16,7 +16,13 @@ export default class extends Controller {
|
|
|
16
16
|
connect() {
|
|
17
17
|
this.collapse = new NavCollapse(this.element, (lit) => {
|
|
18
18
|
if (!this.hasScrolledClass) return
|
|
19
|
-
|
|
19
|
+
// On the scroll timeline the line and shadow are the stylesheet's
|
|
20
|
+
// (::after, so no border adds a pixel of height); only the marker
|
|
21
|
+
// toggles, and the rest stay off.
|
|
22
|
+
const timeline = this.collapse && this.collapse.timeline
|
|
23
|
+
for (const name of this.scrolledClasses) {
|
|
24
|
+
this.element.classList.toggle(name, lit && (!timeline || name === "is-scrolled"))
|
|
25
|
+
}
|
|
20
26
|
})
|
|
21
27
|
this.collapse.start()
|
|
22
28
|
}
|
|
@@ -197,6 +197,63 @@ export function readTuning(style) {
|
|
|
197
197
|
};
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
// THE SCROLL-DRIVEN MODE (engine.css, THE SCROLL-DRIVEN COLLAPSE). A header
|
|
201
|
+
// carrying data-nav-timeline, from 768px, in a browser with scroll timelines,
|
|
202
|
+
// collapses in CSS on the compositor, and this controller stops driving --nav-p.
|
|
203
|
+
// What CSS cannot know it is told once, not per frame: the band that scrolls
|
|
204
|
+
// away, the scales, and where the header meets the window. The query is the
|
|
205
|
+
// stylesheet's own; the two must agree, or both would drive the header.
|
|
206
|
+
export var TIMELINE_QUERY = '(min-width: 768px)';
|
|
207
|
+
|
|
208
|
+
export function timelineSupported() {
|
|
209
|
+
return typeof CSS !== 'undefined' && !!CSS.supports && CSS.supports('animation-timeline: scroll()');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function timelineOn(el, wide) {
|
|
213
|
+
return el.hasAttribute('data-nav-timeline') && wide && timelineSupported();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// The timeline's custom properties, from the header measured at both ends of
|
|
217
|
+
// the band table (--nav-p 0 and 1). Layout sizes only (offsetHeight and
|
|
218
|
+
// offsetWidth), which a transform does not change, so the reading is the same
|
|
219
|
+
// at any scroll position.
|
|
220
|
+
//
|
|
221
|
+
// rest, collapsed the header's height expanded and collapsed
|
|
222
|
+
// brand0, brand1 the brand link's height expanded and collapsed
|
|
223
|
+
// avatar0, avatar1 the avatar's height expanded and collapsed (0: none)
|
|
224
|
+
// nameW the account name's width
|
|
225
|
+
//
|
|
226
|
+
// The name scales by the avatar's ratio (the rule of 3), and the icon rail
|
|
227
|
+
// slides right by what the name and avatar give up, so the gaps stay even.
|
|
228
|
+
// Without an avatar (signed out) nothing on the right moves.
|
|
229
|
+
export function timelineVars({ rest, collapsed, brand0, brand1, avatar0, avatar1, nameW }) {
|
|
230
|
+
var avatarScale = avatar0 > 0 ? avatar1 / avatar0 : 1;
|
|
231
|
+
var avatarShrink = avatar0 > 0 ? Math.max(0, avatar0 - avatar1) : 0;
|
|
232
|
+
return {
|
|
233
|
+
band: Math.max(0, rest - collapsed),
|
|
234
|
+
brandScale: brand0 > 0 ? brand1 / brand0 : 1,
|
|
235
|
+
avatarScale: avatarScale,
|
|
236
|
+
avatarShrink: avatarShrink,
|
|
237
|
+
toolsShift: avatar0 > 0 ? nameW * (1 - avatarScale) + avatarShrink : 0
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
var TIMELINE_PROPS = ['--nav-band', '--nav-start', '--nav-brand-scale', '--nav-avatar-scale',
|
|
242
|
+
'--nav-avatar-shrink', '--nav-tools-shift'];
|
|
243
|
+
|
|
244
|
+
// What the timeline mode leaves on the header. stopTimeline() clears it, and
|
|
245
|
+
// so does the --nav-p path's start(): a Turbo snapshot taken wide and restored
|
|
246
|
+
// below 768px (a phone rotated, then Back) arrives still carrying it, and
|
|
247
|
+
// pinned_stack would subtract the stale band from --nav-h.
|
|
248
|
+
export function clearTimeline(el) {
|
|
249
|
+
TIMELINE_PROPS.forEach(function (name) { el.style.removeProperty(name); });
|
|
250
|
+
el.removeAttribute('data-nav-timeline-on');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function round(n) {
|
|
254
|
+
return Math.round(n * 1000) / 1000;
|
|
255
|
+
}
|
|
256
|
+
|
|
200
257
|
// THE BINDING: one header, its listeners, and the frame loop. The Stimulus
|
|
201
258
|
// controller and the Alpine shim both drive this, so the two cannot drift.
|
|
202
259
|
// onScrolled(lit) fires when the shadow's hysteresis flips.
|
|
@@ -220,10 +277,22 @@ export class NavCollapse {
|
|
|
220
277
|
this.lastHeight = null;
|
|
221
278
|
this._observer = null;
|
|
222
279
|
this._remeasure = this.remeasure.bind(this);
|
|
280
|
+
this.timeline = false;
|
|
281
|
+
this._wide = null;
|
|
282
|
+
this._onWide = this.onWide.bind(this);
|
|
283
|
+
this._onTimelineScroll = this.onTimelineScroll.bind(this);
|
|
284
|
+
this._onTimelineResize = this.measureTimeline.bind(this);
|
|
285
|
+
this._bars = null;
|
|
223
286
|
}
|
|
224
287
|
|
|
225
288
|
start() {
|
|
226
289
|
this.stopped = false;
|
|
290
|
+
this._wide = window.matchMedia(TIMELINE_QUERY);
|
|
291
|
+
if (this._wide.addEventListener) this._wide.addEventListener('change', this._onWide);
|
|
292
|
+
if (timelineOn(this.el, this._wide.matches)) {
|
|
293
|
+
this.startTimeline();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
227
296
|
this._reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
228
297
|
// A HEADER RESTORED FROM TURBO'S SNAPSHOT CACHE arrives as it was left:
|
|
229
298
|
// its scrolled classes on, and this controller's own hold still inline
|
|
@@ -232,6 +301,7 @@ export class NavCollapse {
|
|
|
232
301
|
// under the header, 39-43px on turf-monster's contest page. So the
|
|
233
302
|
// controller clears what it owns before it reads anything.
|
|
234
303
|
this.onScrolled(false);
|
|
304
|
+
clearTimeline(this.el);
|
|
235
305
|
this.el.style.marginBottom = '';
|
|
236
306
|
this.el.style.removeProperty('--nav-hold');
|
|
237
307
|
this.el.style.removeProperty('--nav-hold-fix');
|
|
@@ -278,6 +348,12 @@ export class NavCollapse {
|
|
|
278
348
|
// every visit would stack another listener on window.
|
|
279
349
|
stop() {
|
|
280
350
|
this.stopped = true;
|
|
351
|
+
if (this._wide && this._wide.removeEventListener) this._wide.removeEventListener('change', this._onWide);
|
|
352
|
+
this._wide = null;
|
|
353
|
+
if (this.timeline) {
|
|
354
|
+
this.stopTimeline();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
281
357
|
window.removeEventListener('scroll', this._onScroll);
|
|
282
358
|
window.removeEventListener('resize', this._onResize);
|
|
283
359
|
if (this._observer) this._observer.disconnect();
|
|
@@ -292,6 +368,103 @@ export class NavCollapse {
|
|
|
292
368
|
}
|
|
293
369
|
}
|
|
294
370
|
|
|
371
|
+
// Crossing 768px swaps modes: undo the one running, start the one that
|
|
372
|
+
// now applies.
|
|
373
|
+
onWide() {
|
|
374
|
+
if (this.stopped) return;
|
|
375
|
+
this.stop();
|
|
376
|
+
this.start();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
startTimeline() {
|
|
380
|
+
this.timeline = true;
|
|
381
|
+
this.held = false;
|
|
382
|
+
this.p = 0;
|
|
383
|
+
// A header restored from Turbo's cache, or crossing 768px, can carry
|
|
384
|
+
// what the --nav-p path left inline; it goes.
|
|
385
|
+
this.el.style.marginBottom = '';
|
|
386
|
+
this.el.style.removeProperty('--nav-hold');
|
|
387
|
+
this.el.style.removeProperty('--nav-hold-fix');
|
|
388
|
+
this.el.style.removeProperty('--nav-p');
|
|
389
|
+
this.el.setAttribute('data-nav-timeline-on', '');
|
|
390
|
+
this.scrolled = false;
|
|
391
|
+
this.onScrolled(false);
|
|
392
|
+
this.measureTimeline();
|
|
393
|
+
this.onTimelineScroll();
|
|
394
|
+
window.addEventListener('scroll', this._onTimelineScroll, { passive: true });
|
|
395
|
+
window.addEventListener('resize', this._onTimelineResize, { passive: true });
|
|
396
|
+
var bars = document.querySelector('[data-studio-bar-stack]');
|
|
397
|
+
if (bars && typeof ResizeObserver === 'function') {
|
|
398
|
+
this._bars = new ResizeObserver(this._onTimelineResize);
|
|
399
|
+
this._bars.observe(bars);
|
|
400
|
+
}
|
|
401
|
+
if (document.fonts && document.fonts.ready) document.fonts.ready.then(this._onTimelineResize);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
stopTimeline() {
|
|
405
|
+
this.timeline = false;
|
|
406
|
+
window.removeEventListener('scroll', this._onTimelineScroll);
|
|
407
|
+
window.removeEventListener('resize', this._onTimelineResize);
|
|
408
|
+
if (this._bars) this._bars.disconnect();
|
|
409
|
+
this._bars = null;
|
|
410
|
+
clearTimeline(this.el);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Reads the header at both ends of the band table and publishes what the
|
|
414
|
+
// timeline needs. Runs on start, resize, font load and a bar resize; a
|
|
415
|
+
// scroll never calls it. Writes only a value that changed.
|
|
416
|
+
measureTimeline() {
|
|
417
|
+
if (this.stopped || !this.timeline) return;
|
|
418
|
+
var el = this.el;
|
|
419
|
+
var brand = el.querySelector('.nav-logo-link');
|
|
420
|
+
var avatar = el.querySelector('[data-nav-account] > [aria-hidden]');
|
|
421
|
+
var name = el.querySelector('[data-nav-account] > span:first-child');
|
|
422
|
+
var read = function () {
|
|
423
|
+
return {
|
|
424
|
+
h: el.offsetHeight,
|
|
425
|
+
brand: brand ? brand.offsetHeight : 0,
|
|
426
|
+
avatar: avatar ? avatar.offsetHeight : 0
|
|
427
|
+
};
|
|
428
|
+
};
|
|
429
|
+
el.style.removeProperty('--nav-p');
|
|
430
|
+
var open = read();
|
|
431
|
+
el.style.setProperty('--nav-p', '1');
|
|
432
|
+
var shut = read();
|
|
433
|
+
el.style.removeProperty('--nav-p');
|
|
434
|
+
var vars = timelineVars({
|
|
435
|
+
rest: open.h, collapsed: shut.h,
|
|
436
|
+
brand0: open.brand, brand1: shut.brand,
|
|
437
|
+
avatar0: open.avatar, avatar1: shut.avatar,
|
|
438
|
+
nameW: name ? name.offsetWidth : 0
|
|
439
|
+
});
|
|
440
|
+
// Where the header meets the top of the window: the bars above it in flow.
|
|
441
|
+
var bars = document.querySelector('[data-studio-bar-stack]');
|
|
442
|
+
var start = bars && (bars.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)
|
|
443
|
+
? bars.offsetHeight : 0;
|
|
444
|
+
var props = {
|
|
445
|
+
'--nav-band': round(vars.band) + 'px',
|
|
446
|
+
'--nav-start': start + 'px',
|
|
447
|
+
'--nav-brand-scale': String(round(vars.brandScale)),
|
|
448
|
+
'--nav-avatar-scale': String(round(vars.avatarScale)),
|
|
449
|
+
'--nav-avatar-shrink': round(vars.avatarShrink) + 'px',
|
|
450
|
+
'--nav-tools-shift': round(vars.toolsShift) + 'px'
|
|
451
|
+
};
|
|
452
|
+
for (var key in props) {
|
|
453
|
+
if (el.style.getPropertyValue(key) !== props[key]) el.style.setProperty(key, props[key]);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// The scrolled marker on the controller's own hysteresis, written only when
|
|
458
|
+
// it flips. No frame is scheduled: the collapse itself is the stylesheet's.
|
|
459
|
+
onTimelineScroll() {
|
|
460
|
+
if (this.stopped || !this.timeline) return;
|
|
461
|
+
var lit = shadowLit(this.scrolled, Math.max(0, window.scrollY));
|
|
462
|
+
if (lit !== this.scrolled) {
|
|
463
|
+
this.scrolled = lit;
|
|
464
|
+
this.onScrolled(lit);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
295
468
|
onScroll() {
|
|
296
469
|
if (this.queued) return;
|
|
297
470
|
this.queued = true;
|
|
@@ -288,7 +288,11 @@ export function startPinnedStack() {
|
|
|
288
288
|
// own. That is what lets a layer come and go without anyone declaring a
|
|
289
289
|
// stacking order, and it is load-bearing rather than incidental.
|
|
290
290
|
var r = el.getBoundingClientRect();
|
|
291
|
-
|
|
291
|
+
// A header on the scroll timeline (studio/nav_collapse) keeps its expanded
|
|
292
|
+
// box, and its top band scrolls away above the window; what pins is the
|
|
293
|
+
// compact bar, so that is its height here. An inline read, no layout.
|
|
294
|
+
var band = el.hasAttribute('data-nav-timeline-on') ? parseFloat(el.style.getPropertyValue('--nav-band')) || 0 : 0;
|
|
295
|
+
return { el: el, name: name, legacy: legacy, h: el.offsetHeight - band, bottom: Math.max(0, r.bottom) };
|
|
292
296
|
}
|
|
293
297
|
|
|
294
298
|
|
|
@@ -63,7 +63,11 @@
|
|
|
63
63
|
through ($store.devMode on the user nav, the sidebar trigger's @click):
|
|
64
64
|
Alpine initializes directives only inside a component, and the header was
|
|
65
65
|
that component while navCollapse() lived on it. %>
|
|
66
|
-
|
|
66
|
+
<%# data-nav-timeline opts into the scroll-driven collapse (Studio.navbar_scroll_timeline,
|
|
67
|
+
or a scroll_timeline: local for one render; engine.css, THE SCROLL-DRIVEN
|
|
68
|
+
COLLAPSE). Never on a preview: the /navbar page drives --nav-p itself. %>
|
|
69
|
+
<% scroll_timeline = local_assigns.fetch(:scroll_timeline, Studio.navbar_scroll_timeline) %>
|
|
70
|
+
<header data-pin="nav"<% unless is_preview %> x-data data-studio-controller="nav-collapse" data-nav-collapse-scrolled-class="shadow-lg border-b border-subtle is-scrolled"<%= " data-nav-timeline" if scroll_timeline %><% end %>
|
|
67
71
|
<%# top-0, a STATIC value, and never a custom property. Any bars render as
|
|
68
72
|
this header's sibling in normal flow (studio/banners/_stack), so they
|
|
69
73
|
already occupy their own height above it and there is nothing to
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -248,6 +248,17 @@ module Studio
|
|
|
248
248
|
|
|
249
249
|
mattr_reader :sign_in_label, default: NavbarIdentity::DEFAULT_SIGN_IN_LABEL
|
|
250
250
|
|
|
251
|
+
# ---- Navbar scroll timeline ----
|
|
252
|
+
# true opts layouts/_navbar's header into the scroll-driven collapse
|
|
253
|
+
# (engine.css, THE SCROLL-DRIVEN COLLAPSE): from 768px, in a browser with
|
|
254
|
+
# scroll timelines, the header keeps one height, its top band scrolls away,
|
|
255
|
+
# and the shrink runs as compositor transforms with no script per frame.
|
|
256
|
+
# Phones and other browsers keep the --nav-p collapse. false, the default,
|
|
257
|
+
# renders the header byte-identical to before.
|
|
258
|
+
#
|
|
259
|
+
# config.navbar_scroll_timeline = true
|
|
260
|
+
mattr_accessor :navbar_scroll_timeline, default: false
|
|
261
|
+
|
|
251
262
|
# ---- Navbar wordmark ----
|
|
252
263
|
# A brand lockup image (icon plus words as one artwork) drawn in place of the
|
|
253
264
|
# "Navbar Logo" and the app_name text. nil, the default, renders the navbar
|