Make each page refresh feel less Jenky with Smooth Fade Page Transitions

This commit is contained in:
johnnyq
2026-08-27 22:44:53 -04:00
parent 6d5152d1e6
commit f980893cb1
7 changed files with 280 additions and 60 deletions

View File

@@ -1380,3 +1380,122 @@ select.select2[multiple]:not(.tomselected) {
box-shadow: none !important;
}
}
/* ===========================================================================
CROSS-DOCUMENT VIEW TRANSITIONS
===========================================================================
Crossfades between full page loads on same-origin navigation. No JS: the
browser snapshots the outgoing page, loads the incoming one, and animates
between the two snapshots.
Both documents have to carry the at-rule for a navigation to animate, which
is why it lives here - every ITFlow entry point loads this stylesheet, so
agent, admin, client portal, guest, login and setup all opt in together.
Browsers without support ignore the entire block and navigate exactly as
they did before. Nothing below is load-bearing.
Skipped automatically, no handling needed: reloads, address-bar and
bookmark navigations, cross-origin redirects (the M365 OAuth callback, the
Stripe return legs) and anything that never commits a navigation, such as
an authenticated file download. A navigation slower than roughly four
seconds is dropped back to an ordinary page load. */
@view-transition {
navigation: auto;
}
/* Shorter than the 0.25s UA default - this should read as a soft handoff
rather than an animation you sit through. Overriding `animation` alone
keeps the UA stylesheet's mix-blend-mode: plus-lighter, which is what lets
regions that are identical on both pages crossfade without visibly dipping
through a lighter frame in the middle. */
::view-transition-old(root) {
animation: itflow-vt-fade-out 120ms ease both;
}
::view-transition-new(root) {
animation: itflow-vt-fade-in 160ms ease both;
}
@keyframes itflow-vt-fade-out {
to {
opacity: 0;
}
}
@keyframes itflow-vt-fade-in {
from {
opacity: 0;
}
}
/* The header and side nav are deliberately NOT given a view-transition-name.
The first version of this block did, and froze both with animation:none so
they would sit still through the crossfade. That was a mistake. A named
element gets its own ::view-transition-group, and a frozen group holds the
OLD geometry for the whole transition while the incoming snapshot is
stretched to fit it, snapping to its real size only at the end. Geometry
here is not constant the way I assumed: .app-header and .app-sidebar are
fixed to the viewport, and the viewport width changes by the scrollbar width
whenever you navigate between a page long enough to scroll and one that is
not. Hence some navigations looking fine and others not.
Leaving them in the root snapshot is both simpler and better. The UA
stylesheet crossfades ::view-transition-old/new with
mix-blend-mode: plus-lighter precisely so that regions which are identical
on both pages hold steady rather than dipping through a lighter frame in the
middle, and the chrome is identical apart from which nav item carries
.active. Overriding `animation` alone above keeps that blend mode. */
/* Keep the layout viewport a constant width.
This is what was making some navigations judder and others not. AdminLTE
sizes .app-wrapper, .app-header, .app-main and .app-footer against 100vw,
and 100vw INCLUDES the scrollbar, so the usable width is 100vw minus
whatever the scrollbar takes. Nothing reserves that space, so the vertical
scrollbar - and with it the layout width - appears and disappears purely on
whether a given page happens to be taller than the window.
For a view transition that is not cosmetic. ::view-transition-group(root) is
sized to the viewport, and the UA animates the group's width and height from
the outgoing value to the incoming one, stretching BOTH snapshots to fit for
the whole duration and snapping at the end. Navigate between a page that
scrolls and one that does not and every glyph on the page smears sideways by
the scrollbar width and then jumps back.
The client side nav is where this shows worst, because
agent/includes/inc_client_top_head.php renders #clientHeader expanded on
client_overview.php and collapsed everywhere else - so moving between
Overview and any sibling page flips the scrollbar on and off almost every
time.
scrollbar-gutter reserves the space permanently, so the width never moves.
The max-width correction has to come with it: with the gutter always
present, an unqualified 100vw now overruns the layout viewport on EVERY
page rather than only on tall ones, which would trade an intermittent
horizontal jump for a permanent horizontal scrollbar. 100% is the width of
the grid area these four already occupy, which is what the rule wanted.
AdminLTE made the same correction upstream in 4.8.3. */
html {
scrollbar-gutter: stable;
}
.app-wrapper,
.app-header,
.app-main,
.app-footer {
max-width: 100%;
}
/* AdminLTE ships its own reduced-motion handling, but it injects a
#adminlte-reduce-motion style block keyed on *, *::before and *::after.
The ::view-transition pseudo tree is not a descendant of any element those
match, so it is not covered and needs its own opt-out. */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}

View File

@@ -28,6 +28,21 @@ if (basename(dirname($_SERVER['REQUEST_URI'])) === 'guest') { ?>
<!-- REQUIRED SCRIPTS -->
<?php /* Tom Select goes FIRST, ahead of Bootstrap itself. Order is load-bearing:
until js/tom_select.js runs, the browser is showing the raw <select>
controls it painted while parsing the body, so every byte in front of
it is time spent looking at the wrong widget. Nothing here is needed to
turn a <select> into a Tom Select, so nothing goes in front of it.
includes/header.php preloads both files. See js/tom_select.js.
Bootstrap moving down is safe: it has always loaded in the footer, so
no markup above this point could ever have used the `bootstrap` global
at parse time. Every call site is inside an event handler or waits for
DOMContentLoaded - includes/inc_alert_feedback.php says so in its own
comment - and all of those still run well after this block. */ ?>
<script src="/libs/tom-select/js/tom-select.complete.min.js"></script>
<script src="/js/tom_select.js"></script>
<!-- Bootstrap 5 -->
<script src="/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="/js/http.js"></script>
@@ -35,7 +50,6 @@ if (basename(dirname($_SERVER['REQUEST_URI'])) === 'guest') { ?>
<!-- Custom js-->
<script src="/libs/chart.js/chart.umd.min.js"></script>
<script src="/libs/flatpickr/js/flatpickr.min.js"></script>
<script src="/libs/tom-select/js/tom-select.complete.min.js"></script>
<script src="/libs/imask/js/imask.min.js"></script>
<script src="/libs/tinymce/tinymce.min.js" referrerpolicy="origin"></script>
<script src="/libs/clipboardjs/clipboard.min.js"></script>

View File

@@ -40,6 +40,16 @@ header("X-Frame-Options: DENY");
<link rel="icon" type="image/x-icon" href="/uploads/favicon.ico">
<?php } ?>
<?php /* The Tom Select pair is the first thing includes/footer.php runs,
because until it does the browser is showing raw <select>
controls. Preloading here starts both fetches during head parse,
in parallel with the stylesheets, so the bytes are already warm
when the parser reaches the tag instead of being requested only
at that point. Hints only - the <script> tags in footer.php are
what actually load them. */ ?>
<link rel="preload" as="script" href="/libs/tom-select/js/tom-select.complete.min.js">
<link rel="preload" as="script" href="/js/tom_select.js">
<!-- Font Awesome -->
<link rel="stylesheet" href="/libs/fontawesome-free/css/all.min.css">

View File

@@ -1,5 +1,6 @@
<script src="/js/http.js"></script>
<script src="/js/autocomplete.js"></script>
<script src="/js/tom_select.js"></script>
<script src="/js/app.js"></script>
<?php

View File

@@ -140,10 +140,16 @@ function itflowInit() {
}, 5000);
})();
// Initialize Tom Select (replaces Select2). Every instance is reachable
// afterwards as element.tomselect, which is how the helpers below reach it.
// Enhance any select that is not enhanced yet. On a normal page load
// js/tom_select.js has already done the work and every element here is
// guarded, so this is a no-op; it earns its place on ajax modals, whose
// markup does not exist until includes/modal_footer.php re-executes this
// file. Scoped to `select.select2` deliberately - a bare '.select2' also
// matches the .ts-wrapper divs left by earlier enhancement, because Tom
// Select copies the source element's classes onto them. See
// js/tom_select.js.
itflowStep('tom-select', function () {
document.querySelectorAll('.select2').forEach(function (el) {
document.querySelectorAll('select.select2').forEach(function (el) {
initTomSelect(el);
});
});
@@ -767,62 +773,12 @@ function flashTooltip(button, message) {
}, 1000);
}
/**
* Tom Select integration.
*
* Replaces Select2. Tom Select is vanilla JS and exposes its instance on the
* element as `el.tomselect`, so nothing here needs jQuery.
*
* Select2 concepts and their equivalents, for anyone reading this later:
* $(el).select2({tags:true}) -> create: true
* $(el).val(null).trigger('change') -> el.tomselect.clear()
* $(el).trigger('change.select2') -> el.tomselect.sync() (options replaced)
* $(el).on('select2:select', fn) -> el.tomselect.on('change', fn)
/*
* The Tom Select layer (initTomSelect / refreshTomSelect / clearTomSelect /
* setTomSelectValue) lives in js/tom_select.js, which footer.php loads right
* after the library so the enhancement happens before the page's heavy libs
* are parsed. Those functions are global; this file only calls them.
*/
function initTomSelect(el, options) {
if (!el || el.tomselect) {
return el ? el.tomselect : null;
}
var settings = Object.assign({
create: false,
allowEmptyOption: true,
plugins: el.multiple ? ['remove_button'] : [],
placeholder: el.getAttribute('data-placeholder') || undefined
}, options || {});
return new TomSelect(el, settings);
}
/** Re-read the <option> list after it has been replaced server-side. */
function refreshTomSelect(el) {
if (el && el.tomselect) {
el.tomselect.sync();
}
}
/** Clear a selection (single or multiple) without firing a server round-trip. */
function clearTomSelect(el) {
if (el && el.tomselect) {
el.tomselect.clear(true);
}
}
/**
* Set a select's value from code. A plain el.value = x (or jQuery .val()) does
* not repaint a Tom Select widget - the underlying <select> changes but the
* visible control does not. Falls back to a native change event when the
* element was never enhanced.
*/
function setTomSelectValue(el, value) {
if (!el) {
return;
}
if (el.tomselect) {
el.tomselect.setValue(value);
return;
}
el.value = value;
el.dispatchEvent(new Event('change', { bubbles: true }));
}
/**
* Show a Bootstrap toast from JavaScript.

119
js/tom_select.js Normal file
View File

@@ -0,0 +1,119 @@
/**
* Tom Select integration.
*
* Replaces Select2. Tom Select is vanilla JS and exposes its instance on the
* element as `el.tomselect`, so nothing here needs jQuery.
*
* Select2 concepts and their equivalents, for anyone reading this later:
* $(el).select2({tags:true}) -> create: true
* $(el).val(null).trigger('change') -> el.tomselect.clear()
* $(el).trigger('change.select2') -> el.tomselect.sync() (options replaced)
* $(el).on('select2:select', fn) -> el.tomselect.on('change', fn)
*
* WHY THIS IS ITS OWN FILE RATHER THAN PART OF js/app.js
*
* The visible flash on page load was a timing problem, not a styling one. The
* browser paints the native <select> as soon as it parses it, and the swap to
* Tom Select could not happen until app.js ran - which is dead last in
* includes/footer.php, behind roughly a megabyte of parser-blocking library
* code (tinymce 486K, intl-tel-input 316K, DataTables 123K, sweetalert2 49K,
* adminlte 28K and the rest). Every byte of that had to download, parse and
* execute first, and the native controls sat on screen for the whole of it.
*
* Splitting the Tom Select layer out lets footer.php load it immediately after
* the library itself, ahead of everything that has nothing to do with it. Only
* bootstrap.bundle and http.js are still in front of it.
*
* The sweep at the bottom runs synchronously rather than on DOMContentLoaded,
* on purpose: DOMContentLoaded does not fire until every parser-blocking
* script in the body has run, which is the exact wait this file exists to
* avoid. Running inline is safe because this script tag sits after the closing
* .app-wrapper div, so the page markup above it is already parsed.
*
* js/app.js keeps its own tom-select step. That is not redundant - it is what
* enhances selects inside ajax modals, whose markup does not exist yet at this
* point and whose footer (includes/modal_footer.php) re-executes app.js. On a
* normal page load that later pass finds everything already enhanced and the
* guard in initTomSelect makes it a no-op.
*/
function initTomSelect(el, options) {
if (!el || el.tomselect) {
return el ? el.tomselect : null;
}
/*
* Only ever enhance a real form control.
*
* Tom Select copies the source element's classes onto its own wrapper, so
* an enhanced <select class="form-select select2"> leaves a sibling
* <div class="ts-wrapper form-select select2 ..."> behind it. That means a
* querySelectorAll('.select2') run AFTER any enhancement matches the
* wrapper too, and the wrapper has no .tomselect of its own so the guard
* above waves it through. Constructing Tom Select on a <div> yields a
* second, empty control - no options, no placeholder - stacked on top of
* the working one, which reads as "the select is blank".
*
* The sweeps below and in js/app.js are scoped to `select.select2` so this
* cannot happen, but the check belongs here as well: initTomSelect is
* called directly from agent/js/share_modal.js and anywhere else that
* grows a call site later.
*/
if (el.tagName !== 'SELECT' && el.tagName !== 'INPUT') {
return null;
}
var settings = Object.assign({
create: false,
allowEmptyOption: true,
plugins: el.multiple ? ['remove_button'] : [],
placeholder: el.getAttribute('data-placeholder') || undefined
}, options || {});
return new TomSelect(el, settings);
}
/** Re-read the <option> list after it has been replaced server-side. */
function refreshTomSelect(el) {
if (el && el.tomselect) {
el.tomselect.sync();
}
}
/** Clear a selection (single or multiple) without firing a server round-trip. */
function clearTomSelect(el) {
if (el && el.tomselect) {
el.tomselect.clear(true);
}
}
/**
* Set a select's value from code. A plain el.value = x (or jQuery .val()) does
* not repaint a Tom Select widget - the underlying <select> changes but the
* visible control does not. Falls back to a native change event when the
* element was never enhanced.
*/
function setTomSelectValue(el, value) {
if (!el) {
return;
}
if (el.tomselect) {
el.tomselect.setValue(value);
return;
}
el.value = value;
el.dispatchEvent(new Event('change', { bubbles: true }));
}
/**
* Enhance every .select2 already in the document.
*
* Wrapped in try/catch for the same reason app.js wraps its init steps: one
* malformed select must not stop the rest of the page's scripts from running.
*/
(function () {
try {
document.querySelectorAll('select.select2').forEach(function (el) {
initTomSelect(el);
});
} catch (e) {
console.error('itflow init [tom-select-eager] failed:', e);
}
})();

View File

@@ -1451,9 +1451,10 @@ if (isset($_POST['add_telemetry'])) {
<!-- jQuery -->
<!-- Bootstrap 5 -->
<script src="/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
<!-- Custom js-->
<script src='/libs/tom-select/js/tom-select.complete.min.js'></script>
<script src="/js/tom_select.js"></script>
<script src="/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
<!-- AdminLTE App -->
<script src="/libs/adminlte/js/adminlte.min.js"></script>