@glitchr/transparent 1.0.81 → 1.1.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/js/transparent.js +675 -345
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glitchr/transparent",
3
- "version": "1.0.81",
3
+ "version": "1.1.0",
4
4
  "description": "Transparent SPA Application",
5
5
  "main": "src/index.js",
6
6
  "access": "public",
@@ -180,77 +180,44 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
180
180
  "smoothscroll_speed" : 0,
181
181
  "smoothscroll_easing" : "swing",
182
182
  "exceptions": [],
183
- // headlock: list of url substrings/regex to preserve in <head> across page transitions
184
- // (e.g. third-party widgets that inject <style>/<link> dynamically).
185
- // In addition, head nodes injected dynamically AFTER initial DOMContentLoaded are
186
- // preserved automatically. Use data-headlock="false" on a head element to opt-out.
187
- "headlock": []
183
+ // headlock: list of URL substrings or regex patterns to preserve in
184
+ // <head> across page transitions (e.g. third-party widgets that
185
+ // inject <style>/<link> dynamically). Anything matching is treated
186
+ // as "locked" and never removed during the head merge.
187
+ //
188
+ // In addition, head nodes injected dynamically AFTER initial
189
+ // DOMContentLoaded are auto-preserved (snapshotted on load → not
190
+ // in the set → preserved).
191
+ //
192
+ // Per-element overrides:
193
+ // <link data-headlock="false"> → opt-out (allow normal removal)
194
+ // <link data-headlock="true"> → opt-in (always preserve)
195
+ //
196
+ // Ported from upstream 1.0.82's `headlock` design (cleaner API than
197
+ // the previous hardcoded SCRIPT/STYLE-never-remove heuristic).
198
+ "headlock": [],
199
+ // ── View Transitions API ────────────────────────────────────────────
200
+ // When true, the DOM swap is wrapped in document.startViewTransition()
201
+ // so the browser captures an OLD snapshot, applies the swap callback,
202
+ // then crossfades to the NEW state natively. Falls back transparently
203
+ // to the CSS-only transition path on browsers without VT support
204
+ // (Firefox < 144, Safari < 18.2, old Chromium). Per-element morph
205
+ // is opt-in via CSS:
206
+ //
207
+ // #page { view-transition-name: page; }
208
+ // .article-hero img { view-transition-name: article-hero; }
209
+ //
210
+ // Pairs the named element across the swap so the browser animates
211
+ // its position/size change instead of crossfading the snapshots.
212
+ //
213
+ // skip_transition_for_cache: when true, in-DOM cache hits bypass VT
214
+ // entirely (for Turbo-style instant-back UX). Default false because
215
+ // VT's 200ms crossfade is fast enough that the consistency win
216
+ // outweighs the saved frames.
217
+ "use_view_transitions": false,
218
+ "skip_transition_for_cache": false
188
219
  };
189
220
 
190
- // Set of <head> children present on initial load. Anything added after is treated
191
- // as dynamically injected and preserved across transitions.
192
- var originalHeadNodes = new WeakSet();
193
- function snapshotHeadNodes() {
194
- var head = document.head;
195
- if(!head) return;
196
- for(var i = 0; i < head.children.length; i++)
197
- originalHeadNodes.add(head.children[i]);
198
- }
199
- // Snapshot synchronously at module-eval time (scripts at end of <body> run before any
200
- // async script can inject <style> tags, so the snapshot is clean).
201
- // A DOMContentLoaded fallback is kept for the rare case where document.head is null
202
- // (e.g. script loaded inside <head> before it finishes parsing).
203
- snapshotHeadNodes();
204
- if(!document.head)
205
- document.addEventListener("DOMContentLoaded", snapshotHeadNodes, { once: true });
206
-
207
- Transparent.isHeadlocked = function(el) {
208
- if(!el || el.nodeType !== 1) return false;
209
- // Explicit opt-out
210
- var attr = el.getAttribute && el.getAttribute("data-headlock");
211
- if(attr === "false") return false;
212
- // Explicit opt-in via attribute
213
- if(attr !== null && attr !== undefined) return true;
214
- // Dynamically injected after initial load
215
- if(!originalHeadNodes.has(el)) return true;
216
- // URL pattern match (src/href attributes)
217
- var patterns = Settings["headlock"] || [];
218
- if(!patterns.length) return false;
219
- var url = el.getAttribute && (el.getAttribute("src") || el.getAttribute("href"));
220
- // <style> elements have no src/href — match against CSS textContent instead
221
- if(!url && el.tagName === 'STYLE') url = el.textContent || '';
222
- if(!url) return false;
223
- for(var i = 0; i < patterns.length; i++) {
224
- var p = patterns[i];
225
- if(p instanceof RegExp) { if(p.test(url)) return true; }
226
- else if(typeof p === "string" && p.length && url.indexOf(p) !== -1) return true;
227
- }
228
- return false;
229
- }
230
-
231
- // ─── NAVIGATION TRACE LOG ───────────────────────────────────────
232
- // Gated on Settings.debug. Set 'debug': true in Transparent.ready({...})
233
- // to surface a per-step trace prefixed with "[TX]" in the console.
234
- // Cheap when disabled — single boolean check, no allocation.
235
- function _tx(tag, extra) {
236
- if (!Settings || !Settings.debug) return;
237
- try {
238
- var cls = document.documentElement.className;
239
- var t = performance.now().toFixed(1);
240
- var here = (document.querySelector("#page") || document.documentElement);
241
- var lay = here.getAttribute && (here.getAttribute("data-layout") || "?");
242
- var path = location.pathname;
243
- console.log("%c[TX]", "color:#0a0;font-weight:bold",
244
- "+" + t + "ms", tag,
245
- "path=" + path,
246
- "layout=" + lay,
247
- "ajaxSem=" + (typeof ajaxSemaphore === "undefined" ? "?" : ajaxSemaphore),
248
- "classes=[" + cls + "]",
249
- extra || "");
250
- } catch(e) {}
251
- }
252
- // ────────────────────────────────────────────────────────────────
253
-
254
221
  const State = Transparent.state = {
255
222
 
256
223
  ROOT : "transparent",
@@ -269,9 +236,9 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
269
236
  CLICK : "click",
270
237
 
271
238
  PREACTIVE : "pre-active",
272
- FADEIN : "fade-in",
239
+ ACTIVEIN : "active-in",
273
240
  ACTIVE : "active",
274
- FADEOUT : "fade-out",
241
+ ACTIVEOUT : "active-out",
275
242
  POSTACTIVE : "post-active",
276
243
 
277
244
  NOTIFICATION: "notification"
@@ -288,6 +255,91 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
288
255
  dispatchEvent(new Event('transparent:'+Transparent.state.ACTIVE));
289
256
  }
290
257
 
258
+ // ── Server-rendered element tracking ───────────────────────────────────
259
+ //
260
+ // SPA head/body merging removes elements not present in the new page's
261
+ // HTML response — correct for server-rendered tags (per-page CSS,
262
+ // <meta>, page-specific <script>), but it ALSO nukes anything that a
263
+ // third-party loader injected at runtime: Brevo's chat widget <style>
264
+ // and <div>, Google Analytics tags, Intercom, Crisp, Hotjar, etc.
265
+ // These elements are never in any page's HTML response, so the merge
266
+ // removes them on every nav → user sees the chat widget lose all its
267
+ // CSS and explode to full screen (visible in the user's screenshot of
268
+ // navigating /articles → /).
269
+ //
270
+ // Fix: maintain a WeakSet of elements known to be server-rendered. The
271
+ // initial document's head + body children are server-rendered. Anything
272
+ // we ADD via the SPA merge later is also server-rendered (it came from
273
+ // a server HTML response). Anything else — third-party script
274
+ // injections that happen AFTER snapshot — is never added to the set
275
+ // and is therefore preserved across navs.
276
+ //
277
+ // WeakSet means removed elements get garbage-collected → no leak.
278
+ //
279
+ // MutationObserver auto-tags elements added by ANY route, so even
280
+ // body-script appends or head-script appends elsewhere in this library
281
+ // stay correctly classified.
282
+ // Ported from upstream 1.0.82 — `headlock` design.
283
+ //
284
+ // Replaces the previous hardcoded "never remove SCRIPT/STYLE" rule
285
+ // with a finer-grained API: snapshot the initial head children, then
286
+ // on the next swap, *preserve* anything that's either (a) not in the
287
+ // snapshot (= dynamically injected after load), or (b) matches a
288
+ // configured URL pattern in Settings.headlock, or (c) has a
289
+ // data-headlock attribute (true/non-empty = lock, "false" = unlock).
290
+ //
291
+ // Two key wins over the previous local impl:
292
+ // 1. The previous impl never removed any <script>/<style> at all,
293
+ // which caused per-page stylesheets to leak across navigations
294
+ // (e.g. layout1's inline <style> persisting on layout2). The new
295
+ // design only locks dynamically-injected ones.
296
+ // 2. Project code can pass `headlock: ["brevo.com", "googletag", /hotjar/]`
297
+ // in Transparent.ready({...}) to explicitly opt-in third-party
298
+ // URLs by substring or regex.
299
+ var originalHeadNodes = new WeakSet();
300
+ function snapshotHeadNodes() {
301
+ if (document.head) {
302
+ for (var i = 0; i < document.head.children.length; i++) {
303
+ originalHeadNodes.add(document.head.children[i]);
304
+ }
305
+ }
306
+ }
307
+ // Snapshot synchronously at module-eval time. Defer scripts run after
308
+ // the parser has built the <head> but before async third-party loaders
309
+ // execute, so the snapshot captures the server-rendered <head> cleanly.
310
+ // The DOMContentLoaded fallback handles the rare case where the script
311
+ // ran before <head> was complete (e.g. in-head non-defer placement).
312
+ snapshotHeadNodes();
313
+ if (!document.head) {
314
+ document.addEventListener("DOMContentLoaded", snapshotHeadNodes, { once: true });
315
+ }
316
+
317
+ Transparent.isHeadlocked = function(el) {
318
+ if (!el || el.nodeType !== 1) return false;
319
+ // Explicit attribute opt-out wins over everything else.
320
+ var attr = el.getAttribute && el.getAttribute("data-headlock");
321
+ if (attr === "false") return false;
322
+ // Explicit attribute opt-in (any non-"false" value).
323
+ if (attr !== null && attr !== undefined) return true;
324
+ // Auto-lock anything injected after the initial snapshot — by
325
+ // definition, third-party widgets and their CSS.
326
+ if (!originalHeadNodes.has(el)) return true;
327
+ // URL-pattern matching: src for <script>/<link>/<iframe>, href for
328
+ // <link>, or full textContent for inline <style> blocks. Substring
329
+ // matches on strings; .test() matches on RegExp.
330
+ var patterns = Settings["headlock"] || [];
331
+ if (!patterns.length) return false;
332
+ var url = el.getAttribute && (el.getAttribute("src") || el.getAttribute("href"));
333
+ if (!url && el.tagName === 'STYLE') url = el.textContent || '';
334
+ if (!url) return false;
335
+ for (var i = 0; i < patterns.length; i++) {
336
+ var p = patterns[i];
337
+ if (p instanceof RegExp) { if (p.test(url)) return true; }
338
+ else if (typeof p === "string" && p.length && url.indexOf(p) !== -1) return true;
339
+ }
340
+ return false;
341
+ };
342
+
291
343
  window.addEventListener("DOMContentLoaded", function()
292
344
  {
293
345
  Transparent.loader = $($(document).find(Settings.loader)[0] ?? Transparent.html);
@@ -378,10 +430,71 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
378
430
  return this;
379
431
  }
380
432
 
433
+ // ── In-memory live-DOM cache ────────────────────────────────────────────
434
+ //
435
+ // Turbo-style: store cloned <html> Element nodes per uuid so popstate
436
+ // (back/forward) can short-circuit the XHR + sessionStorage round-trip
437
+ // + DOMParser.parseFromString cycle. Result: back/forward feels instant.
438
+ //
439
+ // Key vs sessionStorage flow:
440
+ // - sessionStorage: outerHTML serialize (slow) → string (5-10MB
441
+ // quota) → JSON read → DOMParser parse (slow). Whole cycle on
442
+ // every popstate. Used as the fallback when the live cache misses.
443
+ // - liveDomCache: cloneNode(true) of the rendered <html> element
444
+ // stored in a Map<uuid, {node, scroll, ts}>. Bounded with LRU.
445
+ // No serialization, no parsing — just the live DOM node ready
446
+ // for the swap to consume.
447
+ //
448
+ // setResponse populates BOTH (live cache + sessionStorage). The
449
+ // sessionStorage write is kept so tab reloads and cross-process
450
+ // restores keep working. getLiveResponse() is the new fast-path API
451
+ // used by handleResponse before the DOMParser fallback.
452
+ Transparent._liveDomCache = new Map();
453
+ Transparent.liveDomCacheMax = 25;
454
+ Transparent.liveDomCacheTTL = 5 * 60 * 1000; // 5 min
455
+
456
+ Transparent.getLiveResponse = function(uuid) {
457
+ var entry = Transparent._liveDomCache.get(uuid);
458
+ if (!entry) return null;
459
+ if (Date.now() - entry.ts > Transparent.liveDomCacheTTL) {
460
+ Transparent._liveDomCache.delete(uuid);
461
+ return null;
462
+ }
463
+ // LRU bump: re-insert to move to the back of the iteration order.
464
+ Transparent._liveDomCache.delete(uuid);
465
+ Transparent._liveDomCache.set(uuid, entry);
466
+ // Return a CLONE so the caller's swap mutations don't poison
467
+ // the cache for future popstate hits. The clone is detached
468
+ // from any document so it's safe to pass to the swap.
469
+ return entry.node.cloneNode(true);
470
+ };
471
+
472
+ Transparent.setLiveResponse = function(uuid, htmlEl, scrollableXY) {
473
+ if (!htmlEl || htmlEl.nodeType !== 1) return;
474
+ Transparent._liveDomCache.set(uuid, {
475
+ node: htmlEl.cloneNode(true),
476
+ scroll: scrollableXY || [],
477
+ ts: Date.now()
478
+ });
479
+ // LRU evict — Map iteration order is insertion order.
480
+ while (Transparent._liveDomCache.size > Transparent.liveDomCacheMax) {
481
+ var firstKey = Transparent._liveDomCache.keys().next().value;
482
+ Transparent._liveDomCache.delete(firstKey);
483
+ }
484
+ };
485
+
486
+ Transparent.clearLiveResponse = function() {
487
+ Transparent._liveDomCache.clear();
488
+ };
489
+
381
490
  Transparent.setResponse = function(uuid, responseText, scrollableXY, exceptionRaised = false)
382
491
  {
383
- if(isDomEntity(responseText))
492
+ // Populate live-DOM cache FIRST while we still have the node.
493
+ // The outerHTML conversion below loses the node identity.
494
+ if (isDomEntity(responseText)) {
495
+ Transparent.setLiveResponse(uuid, responseText, scrollableXY);
384
496
  responseText = responseText.outerHTML;
497
+ }
385
498
 
386
499
  var array = JSON.parse(sessionStorage.getItem('transparent')) || [];
387
500
  if (!array.includes(uuid)) {
@@ -518,7 +631,7 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
518
631
 
519
632
  if($(Transparent.html).hasClass(Transparent.state.FIRST)) {
520
633
  Transparent.scrollToHash(location.hash, {}, function() {
521
- Transparent.fadeOut(() => Transparent.html.removeClass(Transparent.state.FIRST));
634
+ Transparent.activeOut(() => Transparent.html.removeClass(Transparent.state.FIRST));
522
635
  });
523
636
  }
524
637
 
@@ -859,28 +972,28 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
859
972
 
860
973
  return {delay:delay, duration:duration};
861
974
  }
862
- var fadeInTime = 0;
863
- var fadeInRemainingTime = 0;
864
- Transparent.fadeIn = function(activeCallback = function() {}) {
865
- _tx("fadeIn ENTRY");
975
+ var activeInTime = 0;
976
+ var activeInRemainingTime = 0;
977
+ Transparent.activeIn = function(activeCallback = function() {}) {
978
+
866
979
  if(!Transparent.html.hasClass(Transparent.state.PREACTIVE)) {
867
980
  Transparent.html.addClass(Transparent.state.PREACTIVE);
868
981
  dispatchEvent(new Event('transparent:'+Transparent.state.PREACTIVE));
869
982
  }
870
983
 
871
984
  var active = Transparent.activeTime();
872
- fadeInTime = Date.now();
873
- fadeInRemainingTime = active.delay+active.duration;
985
+ activeInTime = Date.now();
986
+ activeInRemainingTime = active.delay+active.duration;
874
987
 
875
988
  Transparent.html.removeClass(Transparent.state.PREACTIVE);
876
- if(!Transparent.html.hasClass(Transparent.state.FADEIN)) {
877
- Transparent.html.addClass(Transparent.state.FADEIN);
878
- dispatchEvent(new Event('transparent:'+Transparent.state.FADEIN));
989
+ if(!Transparent.html.hasClass(Transparent.state.ACTIVEIN)) {
990
+ Transparent.html.addClass(Transparent.state.ACTIVEIN);
991
+ dispatchEvent(new Event('transparent:'+Transparent.state.ACTIVEIN));
879
992
  }
880
993
 
881
994
  Transparent.callback(function() {
882
995
 
883
- Transparent.html.removeClass(Transparent.state.FADEIN);
996
+ Transparent.html.removeClass(Transparent.state.ACTIVEIN);
884
997
  if(!Transparent.html.hasClass(Transparent.state.ACTIVE)) {
885
998
  Transparent.html.addClass(Transparent.state.ACTIVE);
886
999
  dispatchEvent(new Event('transparent:'+Transparent.state.ACTIVE));
@@ -890,23 +1003,23 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
890
1003
  Transparent.callback(function() {
891
1004
 
892
1005
  activeCallback();
893
- fadeInRemainingTime = 0;
1006
+ activeInRemainingTime = 0;
894
1007
 
895
1008
  }.bind(this), active.duration);
896
1009
 
897
1010
  }.bind(this), active.delay);
898
1011
  }
899
1012
 
900
- Transparent.fadeOut = function(activeCallback = function() {}) {
901
- _tx("fadeOut ENTRY");
1013
+ Transparent.activeOut = function(activeCallback = function() {}) {
1014
+
902
1015
  if(!Transparent.html.hasClass(Transparent.state.ACTIVE)) {
903
1016
  Transparent.html.addClass(Transparent.state.ACTIVE);
904
1017
  dispatchEvent(new Event('transparent:'+Transparent.state.ACTIVE));
905
1018
  }
906
1019
 
907
- if(!Transparent.html.hasClass(Transparent.state.FADEOUT)) {
908
- Transparent.html.addClass(Transparent.state.FADEOUT);
909
- dispatchEvent(new Event('transparent:'+Transparent.state.FADEOUT));
1020
+ if(!Transparent.html.hasClass(Transparent.state.ACTIVEOUT)) {
1021
+ Transparent.html.addClass(Transparent.state.ACTIVEOUT);
1022
+ dispatchEvent(new Event('transparent:'+Transparent.state.ACTIVEOUT));
910
1023
  }
911
1024
 
912
1025
  var active = Transparent.activeTime();
@@ -918,7 +1031,7 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
918
1031
  var active = Transparent.activeTime();
919
1032
  Transparent.callback(function() {
920
1033
 
921
- Transparent.html.removeClass(Transparent.state.FADEOUT);
1034
+ Transparent.html.removeClass(Transparent.state.ACTIVEOUT);
922
1035
  if(Transparent.html.hasClass(Transparent.state.LOADING)) {
923
1036
 
924
1037
  dispatchEvent(new Event('transparent:'+Transparent.state.LOADING));
@@ -1023,7 +1136,7 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1023
1136
  Transparent.evalScript($("body")[0]);
1024
1137
  }
1025
1138
 
1026
- Transparent.fadeOut();
1139
+ Transparent.activeOut();
1027
1140
  }
1028
1141
 
1029
1142
  Transparent.userScroll = function(el = undefined) { return $(el === undefined ? document.documentElement : el).closestScrollable().prop("user-scroll") ?? true; }
@@ -1161,8 +1274,23 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1161
1274
  image.onload = function() {
1162
1275
  this.classList.add("loaded");
1163
1276
  this.classList.remove("loading");
1277
+ this.classList.remove("error");
1164
1278
  if(lazybox) lazybox.classList.add("loaded");
1165
1279
  if(lazybox) lazybox.classList.remove("loading");
1280
+ if(lazybox) lazybox.classList.remove("error");
1281
+ };
1282
+
1283
+ // Error handler for broken / missing images (404, ACL,
1284
+ // DNS failure, malformed URL). Without this, lazy-loaded
1285
+ // images that fail just stay invisible. The .error
1286
+ // class lets project CSS render a placeholder.
1287
+ image.onerror = function() {
1288
+ this.classList.add("error");
1289
+ this.classList.remove("loading");
1290
+ this.classList.remove("loaded");
1291
+ if(lazybox) lazybox.classList.add("error");
1292
+ if(lazybox) lazybox.classList.remove("loading");
1293
+ if(lazybox) lazybox.classList.remove("loaded");
1166
1294
  };
1167
1295
 
1168
1296
  if(lazybox) lazybox.classList.add("loading");
@@ -1290,23 +1418,60 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1290
1418
  $(this).stop();
1291
1419
  });
1292
1420
 
1293
- setTimeout(function() {
1421
+ activeInRemainingTime = activeInRemainingTime - (Date.now() - activeInTime);
1422
+
1423
+ // Whole-swap body extracted so we can dispatch it either through
1424
+ // document.startViewTransition() for browsers that support it (with
1425
+ // Settings.use_view_transitions on), or directly for the legacy path.
1426
+ // Identical behavior in both branches — VT just wraps it so the
1427
+ // browser captures OLD/NEW snapshots and crossfades natively.
1428
+ var _doSwapBody = function() {
1294
1429
 
1295
- _tx("onLoad BODY (after 1ms)");
1296
1430
  // Transfert attributes
1297
1431
  Transparent.transferAttributes(dom);
1298
1432
 
1433
+ // ── Track-reload check ──────────────────────────────────────
1434
+ // Mirrors Turbo's <... data-turbo-track="reload"> mechanism.
1435
+ // Put `data-track="reload"` on critical <script> / <link>
1436
+ // bundles in <head>. On nav, if the set of tracked URLs
1437
+ // differs between current and new HTML, force a full reload
1438
+ // instead of an SPA swap — because the user's loaded JS/CSS
1439
+ // no longer matches what the server is serving (e.g. after
1440
+ // a deploy that bumped asset hashes). The browser then
1441
+ // re-downloads everything cleanly.
1442
+ //
1443
+ // Match key: tagName + src/href (or textContent for inline).
1444
+ // Same logic as Turbo: the SET of tracked URLs must match.
1445
+ (function checkTrackedReload() {
1446
+ function trackedSrcs(root) {
1447
+ var srcs = [];
1448
+ var els = root.querySelectorAll('[data-track="reload"]');
1449
+ for (var i = 0; i < els.length; i++) {
1450
+ var el = els[i];
1451
+ var src = el.getAttribute('src') || el.getAttribute('href') ||
1452
+ (el.textContent || '').slice(0, 200);
1453
+ if (src) srcs.push(el.tagName + ':' + src);
1454
+ }
1455
+ return srcs.sort();
1456
+ }
1457
+ var currentSrcs = trackedSrcs(document.head);
1458
+ if (!currentSrcs.length) return; // nothing tracked → skip
1459
+ var newDoc = dom.documentElement ? dom : (dom[0] || dom);
1460
+ var newHead = newDoc.head || newDoc.querySelector('head');
1461
+ if (!newHead) return;
1462
+ var newSrcs = trackedSrcs(newHead);
1463
+ // Compare as JSON of sorted arrays — order-independent.
1464
+ if (JSON.stringify(currentSrcs) === JSON.stringify(newSrcs)) return;
1465
+ if (Settings.debug) {
1466
+ console.log('Transparent track-reload: asset mismatch, forcing reload',
1467
+ { current: currentSrcs, new: newSrcs });
1468
+ }
1469
+ // Full reload to the requested URL.
1470
+ window.location.href = window.location.toString();
1471
+ })();
1472
+
1299
1473
  // Replace head..
1300
1474
  var head = $(dom).find("head");
1301
-
1302
- // Snapshot hrefs of already-loaded stylesheets so we can detect new ones
1303
- // added by the head merge and wait for them to finish loading before
1304
- // making #page visible (prevents FOUC on cold-cache layout transitions).
1305
- var _existingStyleHrefs = {};
1306
- $("head").children("link[rel='stylesheet']").each(function() {
1307
- var h = this.getAttribute("href"); if(h) _existingStyleHrefs[h] = true;
1308
- });
1309
-
1310
1475
  $("head").children().each(function() {
1311
1476
 
1312
1477
  var el = this;
@@ -1315,16 +1480,16 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1315
1480
  head.children().each(function() {
1316
1481
 
1317
1482
  found = this.isEqualNode(el);
1318
- // Also match identical <style> tags by content
1319
- if(!found && el.tagName === 'STYLE' && this.tagName === 'STYLE' &&
1320
- el.textContent && this.textContent &&
1321
- el.textContent.length > 100 && this.textContent.length === el.textContent.length) {
1322
- found = this.textContent === el.textContent;
1323
- }
1324
1483
  return !found;
1325
1484
  });
1326
1485
 
1327
- // Preserve headlocked nodes (dynamically injected widgets, url-matched, etc.)
1486
+ // Preserve headlocked nodes: anything injected dynamically
1487
+ // after initial load (auto), URL-pattern matches in
1488
+ // Settings.headlock, or explicit data-headlock="true".
1489
+ // This is the win over the previous "never remove SCRIPT/
1490
+ // STYLE" rule — per-page server-rendered <style> blocks
1491
+ // (e.g. layout1 inline CSS) still get swapped, only
1492
+ // third-party / dynamically-injected ones are locked.
1328
1493
  if(!found && Transparent.isHeadlocked(el)) found = true;
1329
1494
  if(!found) this.remove();
1330
1495
  });
@@ -1337,35 +1502,20 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1337
1502
  $("head").children().each(function() { found |= this.isEqualNode(el); });
1338
1503
  if(!found) {
1339
1504
 
1340
- if(this.tagName == "SCRIPT" && Settings["global_code"] != true) {
1341
-
1342
- // For inline scripts (without src), recreate so the browser will execute.
1343
- // Simply re-appending the same <script> node doesn't execute it.
1344
- if(!this.src || this.src === '') {
1345
- var script = document.createElement("script");
1346
- script.text = this.innerHTML;
1347
- var i = -1, attrs = this.attributes, attr;
1348
- var N = attrs.length;
1349
- while ( ++i < N ) {
1350
- if(attrs[i].name !== 'src') {
1351
- script.setAttribute( attrs[i].name, attrs[i].value );
1352
- }
1353
- }
1354
- $("head").append(script);
1355
- originalHeadNodes.add(script);
1356
- } else {
1357
- $("head").append(this);
1358
- originalHeadNodes.add(this);
1359
- }
1505
+
1506
+ if(this.tagName != "SCRIPT" || Settings["global_code"] == true) {
1507
+
1508
+ var clone = this.cloneNode(true);
1509
+ $("head").append(clone);
1510
+ // Register the new node as "original" so it falls
1511
+ // through to URL-pattern matching on the next swap
1512
+ // (and isn't auto-locked as third-party content).
1513
+ originalHeadNodes.add(clone);
1360
1514
 
1361
1515
  } else {
1362
1516
 
1363
- var clonedEl = this.cloneNode(true);
1364
- $("head").append(clonedEl);
1365
- // Register as an "original" node so it falls through to URL-pattern
1366
- // matching on future transitions — prevents layout CSS added by
1367
- // Transparent itself from being auto-headlocked as third-party content.
1368
- originalHeadNodes.add(clonedEl);
1517
+ $("head").append(this);
1518
+ originalHeadNodes.add(this);
1369
1519
  }
1370
1520
  }
1371
1521
  });
@@ -1379,27 +1529,10 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1379
1529
  $("body").children().each(function() { found |= this.isEqualNode(el); });
1380
1530
  if(!found) {
1381
1531
 
1382
- if(this.tagName == "SCRIPT" && Settings["global_code"] != true) {
1383
-
1384
- // Same inline-script recreation as for <head>.
1385
- if(!this.src || this.src === '') {
1386
- var script = document.createElement("script");
1387
- script.text = this.innerHTML;
1388
- var i = -1, attrs = this.attributes, attr;
1389
- var N = attrs.length;
1390
- while ( ++i < N ) {
1391
- if(attrs[i].name !== 'src') {
1392
- script.setAttribute( attrs[i].name, attrs[i].value );
1393
- }
1394
- }
1395
- $("body").append(script);
1396
- } else {
1397
- $("body").append(this);
1398
- }
1399
-
1400
- } else {
1401
-
1532
+ if(this.tagName != "SCRIPT" || Settings["global_code"] == true) {
1402
1533
  $("body").append(this.cloneNode(true));
1534
+ } else {
1535
+ $("body").append(this);
1403
1536
  }
1404
1537
  }
1405
1538
  });
@@ -1416,13 +1549,7 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1416
1549
  // Make sure name/layout keep the same after a page change (tolerance for POST or GET requests)
1417
1550
  if(oldPage.attr("data-layout") != undefined && page.attr("data-layout") != undefined) {
1418
1551
 
1419
- // X = prevLayout, Y = newLayout — must match the formula in handleResponse
1420
- // (line ~1852: SWITCH.replace("X", prevLayout).replace("Y", newLayout)).
1421
- // If these disagreed, the cleanup filter below would not recognize the
1422
- // switchLayout class that handleResponse added to <html> and would strip
1423
- // it before its CSS transition could play — visible as a race only in
1424
- // whichever direction the project's CSS actually styles.
1425
- var switchLayout = Transparent.state.SWITCH.replace("X", oldPage.attr("data-layout")).replace("Y", page.attr("data-layout"));
1552
+ var switchLayout = Transparent.state.SWITCH.replace("X", page.attr("data-layout")).replace("Y", oldPage.attr("data-layout"));
1426
1553
  page.attr("data-layout-prev", oldPage.attr("data-layout"));
1427
1554
  }
1428
1555
 
@@ -1431,13 +1558,10 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1431
1558
  var oldHtmlClass = Array.from(($(Transparent.html).attr("class") || "").split(" "));
1432
1559
  var removeHtmlClass = oldHtmlClass.filter(x => !htmlClass.includes(x) && switchLayout != x && !states.includes(x));
1433
1560
 
1434
- _tx("onLoad classMgmt", "switchLayout=" + switchLayout + " remove=[" + removeHtmlClass.join(",") + "] add=[" + htmlClass.join(",") + "]");
1435
1561
  Transparent.html.removeClass(removeHtmlClass).addClass(htmlClass);
1436
- _tx("onLoad PAGE_SWAP_BEGIN", "oldLayout=" + (oldPage.attr("data-layout")||"?") + " newLayout=" + (page.attr("data-layout")||"?"));
1437
1562
  $(page).insertBefore(oldPage);
1438
1563
 
1439
1564
  oldPage.remove();
1440
- _tx("onLoad PAGE_SWAP_DONE");
1441
1565
 
1442
1566
  if(Settings["global_code"] == true) Transparent.evalScript($(page)[0]);
1443
1567
  document.dispatchEvent(new Event('DOMContentLoaded'));
@@ -1473,77 +1597,41 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1473
1597
  }
1474
1598
  }
1475
1599
 
1476
- // Collect link[rel="stylesheet"] elements inserted by the head merge above
1477
- var _newStyleLinks = [];
1478
- $("head").children("link[rel='stylesheet']").each(function() {
1479
- var h = this.getAttribute("href");
1480
- if(h && !_existingStyleHrefs[h]) _newStyleLinks.push(this);
1600
+ $('head').append(function() {
1601
+
1602
+ $(Settings.identifier).append(function() {
1603
+
1604
+ // Callback if needed, or any other actions
1605
+ callback();
1606
+
1607
+ // Trigger onload event
1608
+ dispatchEvent(new Event('transparent:load'));
1609
+ dispatchEvent(new Event('load'));
1610
+ });
1481
1611
  });
1482
1612
 
1483
- // Wait for any newly added layout stylesheets to finish loading before
1484
- // calling callback() / fadeOut() — otherwise #page becomes visible while
1485
- // the new CSS is still being parsed, causing a flash of unstyled content.
1486
- (function() {
1487
- function doCallback() {
1488
- _tx("doCallback FIRES → callback() (which starts fadeOut)");
1489
- // requestAnimationFrame here guarantees the browser has had one
1490
- // full frame to apply the new page's stylesheets and re-layout
1491
- // before fadeOut starts. Without it, fadeOut can animate the
1492
- // loader away while the new page is still rendered with the
1493
- // previous layout's styles — the article→home flicker the
1494
- // [TX] trace masked via instrumentation overhead.
1495
- requestAnimationFrame(function() {
1496
- $('head').append(function() {
1497
- $(Settings.identifier).append(function() {
1498
- callback();
1499
- dispatchEvent(new Event('transparent:load'));
1500
- dispatchEvent(new Event('load'));
1501
- });
1502
- });
1503
- });
1504
- }
1505
- // For cached stylesheets, the browser may fire `load` synchronously on
1506
- // DOM insertion — BEFORE we can attach a listener — so listener-only
1507
- // waits get stuck on the 3 s guard. `.sheet !== null` indicates the
1508
- // CSSStyleSheet is already parsed and ready, which is the right
1509
- // condition to count it as "done." Cross-origin sheets still expose
1510
- // `.sheet` even though `.cssRules` throws — `.sheet !== null` is
1511
- // portable.
1512
- function isStyleLoaded(link) {
1513
- try { return link.sheet !== null; } catch(e) { return true; }
1514
- }
1515
- var pending = _newStyleLinks.filter(function(l) { return !isStyleLoaded(l); });
1516
- _tx("stylesheet-wait BEGIN", "newLinks=" + _newStyleLinks.length + " cachedSkipped=" + (_newStyleLinks.length - pending.length) + " pending=" + pending.length);
1517
- if(pending.length === 0) {
1518
- _tx("stylesheet-wait IMMEDIATE → doCallback");
1519
- doCallback();
1520
- } else {
1521
- var remaining = pending.length;
1522
- var fired = false;
1523
- // Safety valve: if a stylesheet fails or stalls, don't block forever.
1524
- var guard = setTimeout(function() {
1525
- if(!fired) {
1526
- _tx("stylesheet-wait GUARD fired (3s)", "remaining=" + remaining);
1527
- fired = true; doCallback();
1528
- }
1529
- }, 3000);
1530
- pending.forEach(function(link) {
1531
- function onDone(e) {
1532
- _tx("stylesheet-wait link.load", "remaining=" + (remaining-1) + " href=" + link.getAttribute("href"));
1533
- if(--remaining <= 0 && !fired) {
1534
- fired = true;
1535
- clearTimeout(guard);
1536
- _tx("stylesheet-wait ALL_LOADED → doCallback");
1537
- doCallback();
1538
- }
1539
- }
1540
- link.addEventListener('load', onDone, {once:true});
1541
- link.addEventListener('error', onDone, {once:true});
1542
- });
1543
- }
1544
- })();
1613
+ }.bind(this);
1545
1614
 
1546
- }.bind(this), 1);
1615
+ // Dispatch the swap. With VT enabled AND supported, wrap in
1616
+ // document.startViewTransition() so the browser captures OLD/NEW
1617
+ // snapshots and crossfades natively. The setTimeout wait is for
1618
+ // the CSS fade-out to complete BEFORE VT begins, so VT captures
1619
+ // the already-faded state cleanly. Errors inside the callback
1620
+ // don't abort the swap — fall back to direct execution.
1621
+ var _vtEnabled = Settings["use_view_transitions"]
1622
+ && typeof document.startViewTransition === "function";
1623
+ setTimeout(function() {
1624
+ if (_vtEnabled) {
1625
+ try {
1626
+ document.startViewTransition(_doSwapBody);
1627
+ } catch (e) {
1628
+ if (Settings.debug) console.warn("Transparent VT failed, falling back:", e);
1629
+ _doSwapBody();
1630
+ }
1631
+ } else {
1632
+ _doSwapBody();
1633
+ }
1634
+ }, activeInRemainingTime > 0 ? activeInRemainingTime : 1);
1547
1635
  }
1548
1636
 
1549
1637
  function uuidv4() {
@@ -1654,8 +1742,318 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1654
1742
 
1655
1743
  var ajaxSemaphore = false;
1656
1744
  var formSubmission = false;
1745
+
1746
+ // ── User-typed form dirty tracking ──────────────────────────────────────
1747
+ //
1748
+ // True if the user has modified any form input via a real keystroke /
1749
+ // click / select. False after a fresh navigation OR after a form submit.
1750
+ // Used by onbeforeunload to decide whether to confirm.
1751
+ //
1752
+ // `e.isTrusted` filters out programmatic value mutations from JS init
1753
+ // code (Select2 setting the hidden field after dropdown selection,
1754
+ // Editor.js syncing JSON to a hidden <textarea>, datepicker init writing
1755
+ // a normalized value, etc.). Without this filter, the previous value-
1756
+ // comparison logic (formDataBefore vs formDataAfter) flagged every
1757
+ // page-with-form as "dirty" by the time the user hit Ctrl+W, even
1758
+ // when they hadn't typed anything.
1759
+ var formDirty = false;
1760
+ document.addEventListener('input', function(e) {
1761
+ if (!e.isTrusted) return;
1762
+ if (!e.target || !e.target.form) return;
1763
+ formDirty = true;
1764
+ }, true);
1765
+ document.addEventListener('change', function(e) {
1766
+ if (!e.isTrusted) return;
1767
+ if (!e.target || !e.target.form) return;
1768
+ formDirty = true;
1769
+ }, true);
1770
+ // Reset on user-initiated submit so the post-submit redirect doesn't
1771
+ // double-prompt. The existing `formSubmission` flag already handles
1772
+ // the synchronous prompt path; this clears state for any follow-up.
1773
+ document.addEventListener('submit', function() { formDirty = false; }, true);
1774
+
1775
+ // ── Transparent.formMemory ──────────────────────────────────────────────
1776
+ //
1777
+ // Persistent draft store for forms — survives accidental close, refresh,
1778
+ // power loss, browser crash. Keyed by URL + form identity (name or id).
1779
+ //
1780
+ // Save triggers:
1781
+ // - debounced (500ms) on user `input`/`change` events
1782
+ // - synchronously on beforeunload (last-resort capture for fields
1783
+ // mutated by JS like Editor.js / Select2 that don't fire trusted
1784
+ // `input` events)
1785
+ //
1786
+ // Restore: silently on initial DOMContentLoaded AND after each SPA swap
1787
+ // (transparent.js re-dispatches DOMContentLoaded post-swap, line ~1375).
1788
+ // Restored fields get `data-restored-from-draft=""` for optional
1789
+ // project-level toast / styling.
1790
+ //
1791
+ // Clear: on form submit + on TTL expiry (7 days) + manually via
1792
+ // `Transparent.formMemory.clear(form)`.
1793
+ //
1794
+ // Opt-out:
1795
+ // - `<form data-no-persist>` — entire form skipped
1796
+ // - `<input data-no-persist>` — single field skipped
1797
+ // - Auto-skipped: type="password", type="file", type="submit"/button,
1798
+ // and any hidden field whose name contains `_token` or `csrf`
1799
+ // (Symfony CSRF token field). These are never persisted.
1800
+ //
1801
+ // Editor.js compatibility: Editor.js reads its initial JSON from
1802
+ // `data-edjs` attribute (not `.value`). On restore, if the field is a
1803
+ // <textarea data-edjs>, we mirror the restored value into `data-edjs`
1804
+ // too so Editor.js renders the draft when its init pass runs.
1805
+ Transparent.formMemory = (function() {
1806
+ var KEY_PREFIX = 'tx-form-memory:';
1807
+ var DEFAULT_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
1808
+ var DEBOUNCE = 500;
1809
+
1810
+ var saveTimers = new WeakMap();
1811
+ var api = {
1812
+ enabled : true,
1813
+ ttl : DEFAULT_TTL,
1814
+ debounce: DEBOUNCE,
1815
+ };
1816
+
1817
+ function shouldSkipField(field) {
1818
+ if (!field.name) return true;
1819
+ var type = (field.type || '').toLowerCase();
1820
+ if (type === 'password' || type === 'file') return true;
1821
+ if (type === 'submit' || type === 'button' || type === 'reset') return true;
1822
+ if (field.hasAttribute && field.hasAttribute('data-no-persist')) return true;
1823
+ // CSRF tokens — never persist. Matches Symfony's `_token` and
1824
+ // any other token-named hidden input (`csrf`, `_csrf_token`, ...).
1825
+ if (type === 'hidden') {
1826
+ var n = field.name.toLowerCase();
1827
+ if (n.indexOf('_token') !== -1 || n.indexOf('csrf') !== -1) return true;
1828
+ }
1829
+ return false;
1830
+ }
1831
+
1832
+ function shouldSkipForm(form) {
1833
+ if (!form) return true;
1834
+ if (form.hasAttribute && form.hasAttribute('data-no-persist')) return true;
1835
+ if (!form.name && !form.id) return true; // need identity for key
1836
+ return false;
1837
+ }
1838
+
1839
+ function getKey(form) {
1840
+ return KEY_PREFIX + location.pathname + ':' + (form.name || form.id);
1841
+ }
1842
+
1843
+ function readLS(key) {
1844
+ try { return localStorage.getItem(key); } catch (e) { return null; }
1845
+ }
1846
+ function writeLS(key, val) {
1847
+ try { localStorage.setItem(key, val); } catch (e) {
1848
+ // Quota exceeded or storage disabled — fail silent. The
1849
+ // user keeps their work in memory, just loses persistence.
1850
+ }
1851
+ }
1852
+ function removeLS(key) {
1853
+ try { localStorage.removeItem(key); } catch (e) {}
1854
+ }
1855
+
1856
+ api.save = function(form) {
1857
+ if (!api.enabled) return;
1858
+ if (shouldSkipForm(form)) return;
1859
+
1860
+ var data = {};
1861
+ var elements = form.elements;
1862
+ for (var i = 0; i < elements.length; i++) {
1863
+ var field = elements[i];
1864
+ if (shouldSkipField(field)) continue;
1865
+
1866
+ var type = (field.type || '').toLowerCase();
1867
+ if (type === 'checkbox' || type === 'radio') {
1868
+ if (!field.checked) continue;
1869
+ if (data[field.name] === undefined) {
1870
+ data[field.name] = field.value;
1871
+ } else if (Array.isArray(data[field.name])) {
1872
+ data[field.name].push(field.value);
1873
+ } else {
1874
+ data[field.name] = [data[field.name], field.value];
1875
+ }
1876
+ } else if (field.tagName === 'SELECT' && field.multiple) {
1877
+ var sel = [];
1878
+ for (var j = 0; j < field.options.length; j++) {
1879
+ if (field.options[j].selected) sel.push(field.options[j].value);
1880
+ }
1881
+ data[field.name] = sel;
1882
+ } else {
1883
+ data[field.name] = field.value;
1884
+ }
1885
+ }
1886
+
1887
+ if (Object.keys(data).length === 0) {
1888
+ // No persistable fields with data — clear any stale entry
1889
+ // rather than write an empty record (avoids restoring "all
1890
+ // empty" later and overwriting newly-pre-filled fields).
1891
+ removeLS(getKey(form));
1892
+ return;
1893
+ }
1894
+
1895
+ writeLS(getKey(form), JSON.stringify({ t: Date.now(), d: data }));
1896
+ };
1897
+
1898
+ api.restore = function(form) {
1899
+ if (!api.enabled) return;
1900
+ if (shouldSkipForm(form)) return;
1901
+
1902
+ var key = getKey(form);
1903
+ var raw = readLS(key);
1904
+ if (!raw) return;
1905
+
1906
+ var entry;
1907
+ try { entry = JSON.parse(raw); }
1908
+ catch (e) { removeLS(key); return; }
1909
+
1910
+ if (!entry || !entry.t || !entry.d) { removeLS(key); return; }
1911
+ if (Date.now() - entry.t > api.ttl) { removeLS(key); return; }
1912
+
1913
+ Object.keys(entry.d).forEach(function(name) {
1914
+ var value = entry.d[name];
1915
+ // Use attr-selector with CSS.escape to handle names like
1916
+ // `Article[content]` that contain brackets.
1917
+ var sel = '[name="' + (typeof CSS !== 'undefined' && CSS.escape
1918
+ ? CSS.escape(name)
1919
+ : name.replace(/(["\\\[\]])/g, '\\$1')) + '"]';
1920
+ var fields = form.querySelectorAll(sel);
1921
+ if (fields.length === 0) return;
1922
+
1923
+ for (var k = 0; k < fields.length; k++) {
1924
+ var field = fields[k];
1925
+ if (shouldSkipField(field)) continue;
1926
+
1927
+ var type = (field.type || '').toLowerCase();
1928
+ if (type === 'checkbox' || type === 'radio') {
1929
+ field.checked = Array.isArray(value)
1930
+ ? (value.indexOf(field.value) !== -1)
1931
+ : (field.value === value);
1932
+ } else if (field.tagName === 'SELECT' && field.multiple) {
1933
+ if (Array.isArray(value)) {
1934
+ for (var m = 0; m < field.options.length; m++) {
1935
+ field.options[m].selected = (value.indexOf(field.options[m].value) !== -1);
1936
+ }
1937
+ }
1938
+ } else {
1939
+ field.value = value;
1940
+ // Editor.js mirrors: Editor.js reads its initial JSON
1941
+ // from the `data-edjs` attribute, not from .value. So
1942
+ // we have to mirror the restored value into the attr
1943
+ // for the upcoming Editor.js init pass to pick it up.
1944
+ if (field.tagName === 'TEXTAREA' && field.hasAttribute('data-edjs')) {
1945
+ field.setAttribute('data-edjs', value);
1946
+ }
1947
+ }
1948
+ field.setAttribute('data-restored-from-draft', '');
1949
+ }
1950
+ });
1951
+ };
1952
+
1953
+ api.clear = function(form) {
1954
+ if (!form) return;
1955
+ if (shouldSkipForm(form)) return;
1956
+ removeLS(getKey(form));
1957
+ };
1958
+
1959
+ api.restoreAll = function() {
1960
+ var forms = document.querySelectorAll('form');
1961
+ for (var i = 0; i < forms.length; i++) api.restore(forms[i]);
1962
+ };
1963
+
1964
+ api.saveAll = function() {
1965
+ var forms = document.querySelectorAll('form');
1966
+ for (var i = 0; i < forms.length; i++) api.save(forms[i]);
1967
+ };
1968
+
1969
+ api.clearExpired = function() {
1970
+ var ttl = api.ttl;
1971
+ var now = Date.now();
1972
+ try {
1973
+ for (var i = localStorage.length - 1; i >= 0; i--) {
1974
+ var key = localStorage.key(i);
1975
+ if (!key || key.indexOf(KEY_PREFIX) !== 0) continue;
1976
+ var raw;
1977
+ try { raw = localStorage.getItem(key); }
1978
+ catch (e) { continue; }
1979
+ var entry = null;
1980
+ try { entry = JSON.parse(raw); } catch (e) {}
1981
+ if (!entry || !entry.t || (now - entry.t > ttl)) {
1982
+ removeLS(key);
1983
+ }
1984
+ }
1985
+ } catch (e) {}
1986
+ };
1987
+
1988
+ // Debounced per-form save scheduler. WeakMap → no leak when forms
1989
+ // get removed from the DOM (e.g., on SPA swap).
1990
+ function debouncedSave(form) {
1991
+ if (!form || shouldSkipForm(form)) return;
1992
+ var existing = saveTimers.get(form);
1993
+ if (existing) clearTimeout(existing);
1994
+ saveTimers.set(form, setTimeout(function() {
1995
+ api.save(form);
1996
+ }, api.debounce));
1997
+ }
1998
+
1999
+ // Event wiring. Capture phase + isTrusted gate matches the
2000
+ // formDirty pattern above — programmatic field mutations from
2001
+ // Select2/Editor.js init code don't trigger a save here, which is
2002
+ // correct (those are not "user changes"; saving them would persist
2003
+ // server-rendered defaults as if they were user input).
2004
+ document.addEventListener('input', function(e) {
2005
+ if (!e.isTrusted) return;
2006
+ if (!e.target || !e.target.form) return;
2007
+ debouncedSave(e.target.form);
2008
+ }, true);
2009
+ document.addEventListener('change', function(e) {
2010
+ if (!e.isTrusted) return;
2011
+ if (!e.target || !e.target.form) return;
2012
+ debouncedSave(e.target.form);
2013
+ }, true);
2014
+
2015
+ // Successful submit clears the draft. We listen in capture so we
2016
+ // run before any user-side submit handler that might cancel.
2017
+ document.addEventListener('submit', function(e) {
2018
+ if (e.target && e.target.tagName === 'FORM') {
2019
+ api.clear(e.target);
2020
+ }
2021
+ }, true);
2022
+
2023
+ // Last-resort save on page exit. Catches state mutated only by JS
2024
+ // (Editor.js content syncs, Select2 hidden-field writes) that
2025
+ // never fired trusted input/change events. Synchronous because
2026
+ // beforeunload doesn't await microtasks.
2027
+ window.addEventListener('beforeunload', function() {
2028
+ if (!api.enabled) return;
2029
+ // Only save if the user has actually typed something — same
2030
+ // gate as the unload-confirm prompt above. If formDirty is
2031
+ // false, drafts written from the debounced handler are stale
2032
+ // and we don't want to refresh them on every page-close.
2033
+ if (formDirty) api.saveAll();
2034
+ });
2035
+
2036
+ // Initial restore. If we're already past DOMContentLoaded (defer
2037
+ // scripts run after parsing but before DCL), run synchronously.
2038
+ // For SPA navs, transparent.js re-dispatches DOMContentLoaded in
2039
+ // _doSwap (around line 1375), so this same listener fires again
2040
+ // on every swap — no extra wiring needed.
2041
+ if (document.readyState === 'loading') {
2042
+ document.addEventListener('DOMContentLoaded', api.restoreAll);
2043
+ } else {
2044
+ api.restoreAll();
2045
+ }
2046
+ document.addEventListener('DOMContentLoaded', api.restoreAll);
2047
+
2048
+ // Cleanup expired entries — once at startup, deferred so it
2049
+ // doesn't block first paint.
2050
+ setTimeout(api.clearExpired, 2000);
2051
+
2052
+ return api;
2053
+ })();
2054
+
1657
2055
  function __main__(e) {
1658
- _tx("__main__ ENTRY", "event=" + e.type + (e.target && e.target.tagName ? " target=" + e.target.tagName : ""));
2056
+
1659
2057
  // Disable transparent JS (e.g. during development..)
1660
2058
  if(Settings.disable) return;
1661
2059
 
@@ -1772,7 +2170,7 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1772
2170
  $(Transparent.html).stop();
1773
2171
 
1774
2172
  Transparent.html.addClass(Transparent.state.LOADING);
1775
- Transparent.fadeIn();
2173
+ Transparent.activeIn();
1776
2174
 
1777
2175
  function isJsonResponse(str) {
1778
2176
  try { JSON.parse(str); return true; }
@@ -1780,7 +2178,6 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1780
2178
  }
1781
2179
 
1782
2180
  function handleResponse(uuid, status = 200, method = null, data = null, xhr = null, request = null) {
1783
- _tx("handleResponse ENTRY", "status=" + status + " method=" + method);
1784
2181
 
1785
2182
  ajaxSemaphore = false;
1786
2183
 
@@ -1816,7 +2213,26 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1816
2213
  Transparent.setResponse(uuid, responseText);
1817
2214
  }
1818
2215
 
1819
- var dom = new DOMParser().parseFromString(responseText, "text/html");
2216
+ // Try the in-memory live-DOM cache first. On popstate (back/forward)
2217
+ // we just stored the outgoing page node via setLiveResponse, so the
2218
+ // round-trip is: snapshot → cache → instant retrieve. No serialize,
2219
+ // no DOMParser cost. Falls back to the parse path on miss (first-
2220
+ // time nav, cache eviction, expired entry, etc.).
2221
+ var dom = null;
2222
+ if (Transparent.getLiveResponse) {
2223
+ var liveNode = Transparent.getLiveResponse(uuid);
2224
+ if (liveNode) {
2225
+ // The cache stores the full <html> element. Wrap it in a
2226
+ // minimal Document-shaped object that the swap path can
2227
+ // navigate the same way it navigates a DOMParser result.
2228
+ var docShell = document.implementation.createHTMLDocument('');
2229
+ docShell.replaceChild(liveNode, docShell.documentElement);
2230
+ dom = docShell;
2231
+ }
2232
+ }
2233
+ if (!dom) {
2234
+ dom = new DOMParser().parseFromString(responseText, "text/html");
2235
+ }
1820
2236
  if(request && request.getResponseHeader("Content-Type") == "application/json") {
1821
2237
 
1822
2238
  if(!isJsonResponse(responseText)) {
@@ -1899,7 +2315,6 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1899
2315
  Transparent.html.addClass(Transparent.state.SAME);
1900
2316
 
1901
2317
  var switchLayout = Transparent.state.SWITCH.replace("X", prevLayout).replace("Y", newLayout);
1902
- _tx("handleResponse switchLayout", "prev=" + prevLayout + " new=" + newLayout + " adds=." + switchLayout);
1903
2318
  Transparent.html.addClass(switchLayout);
1904
2319
 
1905
2320
  dispatchEvent(new Event('transparent:'+switchLayout));
@@ -1907,28 +2322,9 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1907
2322
  if($(dom).find("html").hasClass(Transparent.state.RELOAD) || $(dom).find("html").hasClass(Transparent.state.DISABLE))
1908
2323
  return window.location.reload();
1909
2324
 
1910
- // Kick off preloads for stylesheets the new page needs but aren't yet in <head>.
1911
- // They download in parallel during the fadeIn animation so onLoad() finds them
1912
- // already cached — eliminating FOUC on cold-cache layout transitions.
1913
- (function() {
1914
- var loaded = {};
1915
- $("head").children("link[rel='stylesheet']").each(function() {
1916
- var h = this.getAttribute("href"); if(h) loaded[h] = true;
1917
- });
1918
-
1919
- $(dom).find("head").children("link[rel='stylesheet']").each(function() {
1920
- var h = this.getAttribute("href");
1921
- if(!h || loaded[h]) return;
1922
- if($("head").find("link[rel='preload'][href='" + h.replace(/'/g, "\\'") + "']").length) return;
1923
- var pl = document.createElement("link");
1924
- pl.rel = "preload"; pl.as = "style"; pl.href = h;
1925
- document.head.appendChild(pl);
1926
- });
1927
- })();
1928
-
1929
2325
  return Transparent.onLoad(uuid, dom, function() {
1930
2326
 
1931
- Transparent.fadeOut(function() {
2327
+ Transparent.activeOut(function() {
1932
2328
 
1933
2329
  Transparent.html
1934
2330
  .removeClass(switchLayout)
@@ -1951,7 +2347,7 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1951
2347
  if(history.state)
1952
2348
  Transparent.setResponse(history.state.uuid, Transparent.html[0], Transparent.getScrollableElementXY());
1953
2349
 
1954
- $(Transparent.html).prop("user-scroll", false); // make sure to avoid page jump during transition (cancelled in fadeIn callback)
2350
+ $(Transparent.html).prop("user-scroll", false); // make sure to avoid page jump during transition (cancelled in activeIn callback)
1955
2351
 
1956
2352
  // Submit ajax request..
1957
2353
  if(form) form.dispatchEvent(new SubmitEvent("submit", { submitter: formTrigger }));
@@ -1966,8 +2362,8 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1966
2362
  processData: false,
1967
2363
  headers: Settings["headers"] || {},
1968
2364
  xhr: function () { return xhr; },
1969
- success: function (html, status, request) { _tx("ajax SUCCESS", "status=" + request.status); return handleResponse(uuid, request.status, type, data, xhr, request); },
1970
- error: function (request, ajaxOptions, thrownError) { _tx("ajax ERROR", "status=" + request.status); return handleResponse(uuid, request.status, type, data, xhr, request); }
2365
+ success: function (html, status, request) { return handleResponse(uuid, request.status, type, data, xhr, request); },
2366
+ error: function (request, ajaxOptions, thrownError) { return handleResponse(uuid, request.status, type, data, xhr, request); }
1971
2367
  });
1972
2368
  }
1973
2369
 
@@ -1998,99 +2394,33 @@ jQuery.event.special.mousewheel = { setup: function( _, ns, handle ) { this.addE
1998
2394
  window.onpopstate = __main__; // Onpopstate pop out straight to previous page.. this creates a jump while changing pages with hash..
1999
2395
  window.onhashchange = __main__;
2000
2396
 
2001
- var formDataBefore = {};
2002
- $(window).on("load", function() {
2003
-
2004
- formDataBefore = {};
2005
- $("form").each(function() {
2006
-
2007
- var formData = new FormData();
2008
- var formInput = $("[name^='"+this.name+"\[']");
2009
- formInput.each(function() {
2010
-
2011
- if(this.type == "file") {
2012
-
2013
- for(var i = 0; i < this.files.length; i++)
2014
- formData.append(this.name+"["+i+"]", this.files[i].name+";"+this.files[i].size+";"+this.files[i].lastModified);
2015
-
2016
- } else formData.append(this.name, this.value);
2017
- });
2018
-
2019
- for (var [fieldName,fieldValue] of formData.entries()) {
2020
-
2021
- if(!fieldName.endsWith("[]") && fieldName != "undefined")
2022
- formDataBefore[fieldName] = fieldValue;
2023
- }
2024
- });
2025
- });
2026
-
2397
+ // onbeforeunload: confirm only if the user has actually typed/edited
2398
+ // a form input on this page. Pre-Turbo this used a snapshot-and-
2399
+ // compare approach (formDataBefore vs formDataAfter) which gave
2400
+ // false positives because JS init code mutates form values after
2401
+ // load — Select2 writes selected text to hidden fields, Editor.js
2402
+ // serializes its JSON to a <textarea>, datepicker normalizes
2403
+ // formats, etc. so by the time the user hit Ctrl+W the snapshot
2404
+ // and the current state always differed, and the browser always
2405
+ // prompted even on read-only pages.
2406
+ //
2407
+ // The replacement is the `formDirty` flag declared above, which is
2408
+ // set only by trusted (user-originated) `input`/`change` events.
2409
+ // No prompt unless the user genuinely typed something.
2027
2410
  window.onbeforeunload = function(e) {
2028
2411
 
2029
2412
  if(Settings.debug) console.log("Transparent onbeforeunload event called..");
2030
2413
 
2031
2414
  if(formSubmission) return; // Do not display on form submission
2032
2415
  if(Settings.disable) return;
2033
-
2034
2416
  if(e.currentTarget == window) return;
2417
+ if(!formDirty) return; // ← user hasn't modified anything; no prompt
2035
2418
 
2036
- var preventDefault = false;
2037
- var formDataAfter = [];
2038
- $("form").each(function() {
2039
-
2040
- var formData = new FormData();
2041
- var formInput = $("[name^='"+this.name+"\[']");
2042
- formInput.each(function() {
2043
-
2044
- if(this.type == "file") {
2045
-
2046
- for(var i = 0; i < this.files.length; i++)
2047
- formData.append(this.name+"["+i+"]", this.files[i].name+";"+this.files[i].size+";"+this.files[i].lastModified);
2048
-
2049
- } else formData.append(this.name, this.value);
2050
- });
2051
-
2052
- for (var [fieldName,fieldValue] of formData.entries()) {
2053
-
2054
- if(!fieldName.endsWith("[]") && fieldName != "undefined")
2055
- formDataAfter[fieldName] = fieldValue;
2056
- }
2057
- });
2058
-
2059
- var formDataBeforeKeys = Object.keys(formDataBefore);
2060
- var formDataAfterKeys = Object.keys(formDataAfter);
2061
- function same(a, b) { return JSON.stringify(a) === JSON.stringify(b); }
2062
- function sameKeys(a, b) {
2063
-
2064
- var aKeys = Object.keys(a).sort();
2065
- var bKeys = Object.keys(b).sort();
2066
- return JSON.stringify(aKeys) === JSON.stringify(bKeys);
2067
- }
2068
-
2069
- if(!sameKeys(formDataBeforeKeys, formDataAfterKeys)) preventDefault = true;
2070
- else {
2071
-
2072
- for (var [fieldName,fieldValueAfter] of Object.entries(formDataAfter)) {
2073
-
2074
- var fieldValueBefore = formDataBefore[fieldName];
2075
- if(fieldValueBefore instanceof File) {
2076
-
2077
- if(!fieldValueAfter instanceof File) preventDefault = true;
2078
- else if (fieldValueBefore.size != fieldValueAfter.size) preventDefault = true;
2079
-
2080
- } else if(fieldValueBefore != fieldValueAfter) {
2081
- preventDefault = true;
2082
- }
2083
- }
2084
- }
2419
+ Transparent.html.addClass(Transparent.state.READY);
2420
+ Transparent.activeOut();
2421
+ dispatchEvent(new Event('load'));
2085
2422
 
2086
- if(Settings.debug || preventDefault) {
2087
-
2088
- if(preventDefault) Transparent.html.addClass(Transparent.state.READY);
2089
- if(preventDefault) Transparent.fadeOut();
2090
- if(preventDefault) dispatchEvent(new Event('load'));
2091
-
2092
- return "Dude, are you sure you want to leave? Think of the kittens!";
2093
- }
2423
+ return "Dude, are you sure you want to leave? Think of the kittens!";
2094
2424
  }
2095
2425
 
2096
2426
  document.addEventListener('click', __main__, false);