stx-router 0.2.406 → 0.2.407

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.
package/dist/client.js CHANGED
@@ -40,9 +40,9 @@ function stripRouterLogs(source) {
40
40
  }
41
41
  return out.join("");
42
42
  }
43
- function minifyRouterScript(source) {
43
+ function minifyRouterScript(source, mangle = !1) {
44
44
  try {
45
- return new Bun.Transpiler({ loader: "js", minifyWhitespace: !0 }).transformSync(source).replace(/\}(var |let |const |function )/g, "};$1").trim();
45
+ return new Bun.Transpiler(mangle ? { loader: "js", minify: { whitespace: !0, identifiers: !0 } } : { loader: "js", minifyWhitespace: !0 }).transformSync(source).replace(/\}(var |let |const |function )/g, "};$1").trim();
46
46
  } catch {
47
47
  return source;
48
48
  }
@@ -55,7 +55,7 @@ function routerSource() {
55
55
  if(window.__stxRouter&&window.__stxRouter.__rev===ROUTER_REV)return;
56
56
 
57
57
  // \u2500\u2500 Configuration \u2500\u2500
58
- var defaults={container:'main',loadingClass:'stx-navigating',viewTransitions:true,cache:true,scrollToTop:true,prefetch:true,progress:true,progressColor:'#78dce8',progressHeight:'2px',interceptAllLinks:false,prefetchCacheMax:50,routeFocus:true,announceRoute:true,interceptForms:false,cssLoadTimeout:1500,scrollRestoration:true};
58
+ var defaults={container:'main',loadingClass:'stx-navigating',viewTransitions:true,cache:true,scrollToTop:true,prefetch:true,progress:true,progressColor:'#78dce8',progressHeight:'2px',interceptAllLinks:false,prefetchCacheMax:50,routeFocus:true,announceRoute:true,interceptForms:false,cssLoadTimeout:1500,scrollRestoration:true,screens:'auto',screenLimit:8,navDuration:350,swipeBack:true,swipeEdge:20,prefetchVisible:true,prefetchVisibleMax:12,revalidate:true,revalidateAfter:3000};
59
59
  var o=Object.assign({},defaults,window.__stxRouterConfig||{},window.STX_ROUTER_OPTIONS||{});
60
60
  var containerSel=o.container;
61
61
  var debug=!!o.debug;
@@ -91,6 +91,12 @@ function routerSource() {
91
91
  // (which carries a scroll token too): going back from a pushed entry stays
92
92
  // in the app, from the first it leaves it (or, in a phone app, does nothing).
93
93
  var PUSHED_MARK='__stxPushed';
94
+ // How far above the entry the app opened with this one sits, and which tab
95
+ // it belongs to. A tab switch rewinds to depth 0 before it writes the next
96
+ // tab's entries, which is what keeps Back from ever crossing tabs.
97
+ var DEPTH='__stxDepth';
98
+ var TAB='__stxTab';
99
+ var histDepth=(history.state&&history.state[DEPTH])|0;
94
100
  var scrollSeq=0;
95
101
  var pendingScroll=null;
96
102
  var restoreScroll=!!o.scrollRestoration;
@@ -105,11 +111,44 @@ function routerSource() {
105
111
  return parts.length===2?[parseFloat(parts[0])||0,parseFloat(parts[1])||0]:null;
106
112
  }catch(e){return null}
107
113
  }
114
+ // Inner scrollers, marked data-stx-scroll: a screen whose list scrolls
115
+ // inside its own box rather than the window lost its place on Back exactly
116
+ // the way the window used to. Keyed by the attribute's value, or by order
117
+ // for an unnamed one.
118
+ function scrollerKey(el,i){return el.getAttribute('data-stx-scroll')||('#'+i)}
119
+ function innerScroll(root){
120
+ var m=null;
121
+ if(root&&root.querySelectorAll)root.querySelectorAll('[data-stx-scroll]').forEach(function(el,i){
122
+ if(el.scrollTop||el.scrollLeft){m=m||{};m[scrollerKey(el,i)]=[el.scrollLeft||0,el.scrollTop||0]}
123
+ });
124
+ return m;
125
+ }
126
+ function applyInner(root,m){
127
+ if(!m||!root||!root.querySelectorAll)return;
128
+ root.querySelectorAll('[data-stx-scroll]').forEach(function(el,i){
129
+ var at=m[scrollerKey(el,i)];
130
+ if(at){el.scrollLeft=at[0];el.scrollTop=at[1]}
131
+ });
132
+ }
108
133
  // Called while the outgoing entry is still the current one, so the position
109
134
  // read here belongs to the token being written.
110
135
  function rememberScroll(){
136
+ if(active&&active.el&&!active.el.hasAttribute(HIDDEN))saveScroll(active);
111
137
  if(!restoreScroll)return;
112
- try{window.sessionStorage.setItem(SCROLL_STORE+scrollToken,(window.pageXOffset||window.scrollX||0)+','+(window.pageYOffset||window.scrollY||0))}catch(e){}
138
+ try{
139
+ window.sessionStorage.setItem(SCROLL_STORE+scrollToken,(window.pageXOffset||window.scrollX||0)+','+(window.pageYOffset||window.scrollY||0));
140
+ // Taken as the swap began when there is one: by the time the history
141
+ // is written the outgoing page's scrollers have been replaced.
142
+ var inner=innerSnap!==undefined?innerSnap:innerScroll(activeRoot());
143
+ innerSnap=undefined;
144
+ if(inner)window.sessionStorage.setItem(SCROLL_STORE+scrollToken+':i',JSON.stringify(inner));
145
+ else window.sessionStorage.removeItem(SCROLL_STORE+scrollToken+':i');
146
+ }catch(e){}
147
+ }
148
+ var pendingInner=null;
149
+ var innerSnap;
150
+ function readInner(token){
151
+ try{return JSON.parse(window.sessionStorage.getItem(SCROLL_STORE+token+':i')||'null')}catch(e){return null}
113
152
  }
114
153
  // One place decides where a swap leaves the viewport, so the fragment path
115
154
  // and the whole-document path cannot drift apart.
@@ -118,10 +157,16 @@ function routerSource() {
118
157
  var at=pendingScroll;
119
158
  pendingScroll=null;
120
159
  window.scrollTo({left:at[0],top:at[1],behavior:'instant'});
160
+ // Again once the page has bound: a scroller's content is often drawn
161
+ // by a :for that has not run yet, and a box with nothing in it cannot
162
+ // be scrolled anywhere.
163
+ var inner=pendingInner;pendingInner=null;
164
+ if(inner){applyInner(activeRoot(),inner);setTimeout(function(){applyInner(activeRoot(),inner)},60)}
121
165
  return;
122
166
  }
167
+ pendingInner=null;
123
168
  if(o.scrollToTop&&!hash){window.scrollTo({top:0,behavior:'instant'});return}
124
- if(hash){var el=qs(hash);if(el)el.scrollIntoView({behavior:'smooth'})}
169
+ if(hash){var el=findIn(hash);if(el)el.scrollIntoView({behavior:'smooth'})}
125
170
  }
126
171
  // Keeps whatever another script put in the entry; only adds the token.
127
172
  function stampScrollToken(){
@@ -129,6 +174,7 @@ function routerSource() {
129
174
  var current=history.state;
130
175
  if(current&&typeof current==='object')for(var key in current)state[key]=current[key];
131
176
  state[SCROLL_TOKEN]=scrollToken;
177
+ state[DEPTH]=histDepth;
132
178
  try{history.replaceState(state,'',location.href)}catch(e){}
133
179
  }
134
180
  if(restoreScroll){
@@ -182,9 +228,24 @@ function routerSource() {
182
228
  function isBuildSkew(incoming){
183
229
  return !!(loadedBuild&&incoming&&incoming!==loadedBuild);
184
230
  }
185
- function reloadForSkew(url,incoming){
186
- log('[router] build skew: page is',loadedBuild,'server is',incoming,'\u2014 full navigation');
187
- location.href=url;
231
+ // A navigation the user asked for loads its destination as a document: no
232
+ // fragment of the newer build can be hydrated by this runtime, and a tap is
233
+ // the one moment a page load is expected anyway. Anything else that notices
234
+ // the newer build (a background revalidation, the offline worker) must not
235
+ // reload a screen the user is in the middle of using, so the reload waits
236
+ // until the page is hidden, and a cold start picks the build up regardless.
237
+ var skewDeferred=false;
238
+ function reloadForSkew(url,incoming,background){
239
+ log('[router] build skew: page is',loadedBuild,'server is',incoming,background?'\u2014 reload when hidden':'\u2014 full navigation');
240
+ if(!background){location.href=url;return}
241
+ if(skewDeferred)return;
242
+ skewDeferred=true;
243
+ var reloadHidden=function(){
244
+ if(document.visibilityState!=='hidden')return;
245
+ document.removeEventListener('visibilitychange',reloadHidden);
246
+ location.reload();
247
+ };
248
+ document.addEventListener('visibilitychange',reloadHidden);
188
249
  }
189
250
  // A link marked data-stx-transition="none" swaps without the cross-fade. A
190
251
  // tab bar is the case: iOS switches tabs instantly, and the fade read as a
@@ -331,6 +392,9 @@ function routerSource() {
331
392
  }
332
393
 
333
394
  var cache={};
395
+ // When each entry was fetched, so a cache hit knows whether it is worth
396
+ // checking behind the swap (revalidate).
397
+ var cacheAt={};
334
398
  var prefetching={};
335
399
  var awaitingKey='';
336
400
  var isNavigating=false;
@@ -352,6 +416,7 @@ function routerSource() {
352
416
  layoutGroupCache[key]=group;
353
417
  titleCache[key]=title||'';
354
418
  attrsCache[key]=cattrs||'';
419
+ cacheAt[key]=Date.now();
355
420
  while(cacheOrder.length>o.prefetchCacheMax){
356
421
  var oldest=cacheOrder.shift();
357
422
  delete cache[oldest];
@@ -359,6 +424,7 @@ function routerSource() {
359
424
  delete layoutGroupCache[oldest];
360
425
  delete titleCache[oldest];
361
426
  delete attrsCache[oldest];
427
+ delete cacheAt[oldest];
362
428
  }
363
429
  }
364
430
  // Read a prefetch response into the shape setCache stores, or null when this
@@ -462,6 +528,7 @@ function routerSource() {
462
528
  delete layoutGroupCache[key];
463
529
  delete titleCache[key];
464
530
  delete attrsCache[key];
531
+ delete cacheAt[key];
465
532
  }
466
533
 
467
534
  // Extract top-level CSS blocks (rules AND @media blocks with nested braces).
@@ -871,10 +938,17 @@ function routerSource() {
871
938
  // pushes, and the string 'replace' replaces. Threading a fourth argument
872
939
  // through swap() to each of the three history sites would have been the
873
940
  // alternative (#1807).
941
+ //
942
+ // 'tab' is a tab's first screen: it takes the place of the entry the app
943
+ // opened with (selectTab has rewound to it), under a token of its own so
944
+ // the tab it replaced keeps its positions.
945
+ var scrollSaved=false;
874
946
  function writeHistory(mode,href){
875
947
  // The position belongs to the entry being left, and scrollToken still
876
- // names it here.
877
- rememberScroll();
948
+ // names it here -- unless the screen swap already saved it, before the
949
+ // outgoing screen was lifted out of the page and the window clamped.
950
+ if(!scrollSaved)rememberScroll();
951
+ scrollSaved=false;
878
952
  if(mode==='replace'){
879
953
  // A replaced entry is still the one it replaces: pushed by the app or
880
954
  // the one the app opened with.
@@ -884,22 +958,43 @@ function routerSource() {
884
958
  return;
885
959
  }
886
960
  scrollToken=newScrollToken();
961
+ if(mode==='tab'){histDepth=0;history.replaceState(tokenState(),'',href);return}
962
+ histDepth++;
887
963
  var pushed=tokenState();
888
964
  pushed[PUSHED_MARK]=true;
889
965
  history.pushState(pushed,'',href);
890
966
  }
891
- function tokenState(){var state={};state[SCROLL_TOKEN]=scrollToken;return state}
967
+ function tokenState(){var state={};state[SCROLL_TOKEN]=scrollToken;state[DEPTH]=histDepth;if(curTab)state[TAB]=curTab;return state}
892
968
 
893
969
  // Second arg accepts the legacy pushState boolean OR an options object
894
970
  // { replace, instant }. Callers inside this file still pass the boolean.
895
971
  // instant is what data-stx-transition="none" is to a link: no cross-fade.
972
+ //
973
+ // The newest navigation wins. One already on its way when another starts
974
+ // (a second tap, Back while a page is still loading) is aborted and its
975
+ // answer ignored: it used to be the other way round, the later one was
976
+ // dropped, and a Back during a load left the address bar on the popped
977
+ // entry while the screen went on to show the page being left.
978
+ var navSeq=0;
979
+ var navAbort=null;
980
+ // What the next swap needs to know about where it is going: set by the
981
+ // caller right before navigate() (popstate, a tab switch) and carried to
982
+ // swap() through pendingCtx, the same way pendingContainerAttrs is.
983
+ var navCtxNext=null;
984
+ var pendingCtx=null;
985
+ function routerFetch(url,headers){
986
+ var init={headers:headers};
987
+ if(navAbort)init.signal=navAbort.signal;
988
+ return fetch(url,init);
989
+ }
896
990
  function navigate(url,pushState,force){
991
+ var ctx=navCtxNext||{};
992
+ navCtxNext=null;
897
993
  if(pushState&&typeof pushState==='object'){if(pushState.instant)instantNext=true;pushState=pushState.replace?'replace':true}
898
994
  // Lang-picker passes force=true with an already-localized path (/en/...).
899
995
  // Re-localizing would map it back to the *current* locale and no-op.
900
996
  if(!force) url=withCurrentLocale(url);
901
997
  log('[router] navigate() called:',url,'isNavigating:',isNavigating);
902
- if(isNavigating)return Promise.resolve(false);
903
998
  // Resolve against the current document URL, matching native <a> semantics.
904
999
  // With location.origin as the base, any relative href ('?status=resolved',
905
1000
  // '#anchor', './sibling') resolved to the SITE ROOT \u2014 so filter tabs,
@@ -909,9 +1004,9 @@ function routerSource() {
909
1004
 
910
1005
  if(t.origin!==location.origin){location.href=url;return Promise.resolve(false)}
911
1006
 
912
- if(t.pathname===location.pathname&&t.hash){
1007
+ if(t.pathname===location.pathname&&t.hash&&pushState!=='tab'){
913
1008
  if(pushState!==false)writeHistory(pushState,t.href);
914
- var el=qs(t.hash);
1009
+ var el=findIn(t.hash);
915
1010
  if(el)el.scrollIntoView({behavior:'smooth'});
916
1011
  return Promise.resolve(true);
917
1012
  }
@@ -922,7 +1017,7 @@ function routerSource() {
922
1017
  // entry \u2014 without this allowance, hitting back would early-return
923
1018
  // and the visible page content would be left frozen on the
924
1019
  // forward-navigation page.
925
- if(pushState!==false&&t.href===location.href&&!t.hash&&!force)return Promise.resolve(false);
1020
+ if(pushState!==false&&pushState!=='tab'&&t.href===location.href&&!t.hash&&!force)return Promise.resolve(false);
926
1021
 
927
1022
  // Its prefetch is on the way: wait for that answer rather than asking the
928
1023
  // server a second time. A later tap elsewhere wins.
@@ -932,11 +1027,17 @@ function routerSource() {
932
1027
  return prefetching[awaitedKey].then(function(){
933
1028
  if(awaitingKey!==awaitedKey)return false;
934
1029
  awaitingKey='';
1030
+ navCtxNext=ctx;
935
1031
  return navigate(url,pushState,force);
936
1032
  });
937
1033
  }
938
1034
  awaitingKey='';
939
1035
 
1036
+ if(navAbort){try{navAbort.abort()}catch(e){}}
1037
+ navAbort=typeof AbortController==='function'?new AbortController():null;
1038
+ var id=++navSeq;
1039
+ ctx.id=id;
1040
+ if(!ctx.direction)ctx.direction=pushState==='replace'?'replace':pushState==='tab'?'tab':pushState===false?'pop':'push';
940
1041
  isNavigating=true;
941
1042
  instantNav=instantNext;
942
1043
  instantNext=false;
@@ -947,7 +1048,22 @@ function routerSource() {
947
1048
  var targetPath=cacheKey(url);
948
1049
  var targetHash=t.hash;
949
1050
 
950
- function done(){isNavigating=false;document.body.classList.remove(o.loadingClass);finishProgress()}
1051
+ function done(){
1052
+ if(id!==navSeq)return;
1053
+ isNavigating=false;navAbort=null;
1054
+ document.body.classList.remove(o.loadingClass);finishProgress();
1055
+ settleDirection();
1056
+ }
1057
+ function stale(){return id!==navSeq}
1058
+ // A failed fetch keeps the app: the destination shows a way to try again
1059
+ // in place of its content, where a document load used to be the answer
1060
+ // to everything -- offline that load is the offline page, or nothing.
1061
+ function failed(err){
1062
+ if(stale()||(err&&err.name==='AbortError'))return false;
1063
+ console.error('[router] fetch error:',err);
1064
+ return showNavError(url,err,pushState,targetHash,ctx);
1065
+ }
1066
+ function swapWith(html,key){pendingCtx=ctx;return swap(html,key,pushState,targetHash)}
951
1067
 
952
1068
  if(o.cache&&cache[targetPath]&&!force){
953
1069
  // Cache hit \u2192 promote in LRU order so this entry survives eviction
@@ -956,22 +1072,24 @@ function routerSource() {
956
1072
  if(checkLayoutChange(layoutCache[targetPath],url,layoutGroupCache[targetPath])){
957
1073
  // Layout changed \u2014 fetch full page and do full document swap
958
1074
  log('[router] cache hit but layout changed \u2014 fetching full page');
959
- return fetch(url,{headers:{'Accept':'text/html'}}).then(function(r){
960
- if(!r.ok)throw new Error(r.status);
1075
+ return routerFetch(url,{'Accept':'text/html'}).then(function(r){
1076
+ if(!r.ok)throw httpError(r.status);
961
1077
  return r.text();
962
1078
  }).then(function(html){
1079
+ if(stale())return false;
963
1080
  log('[router] full page fetched from cache path, len:',html.length);
964
1081
  pendingLayoutDecl={layout:layoutCache[targetPath]||'',group:layoutGroupCache[targetPath]||''};
965
- return swap(html,targetPath,pushState,targetHash);
966
- }).catch(function(err){
967
- console.error('[router] full page fetch error:',err);
968
- location.href=url;
969
- }).finally(done);
1082
+ return swapWith(html,targetPath);
1083
+ }).catch(failed).finally(done);
970
1084
  }
971
1085
  pendingContainerAttrs=attrsCache[targetPath]||'';
972
1086
  pendingLayoutDecl=null;
973
- return Promise.resolve(swap(cache[targetPath],targetPath,pushState,targetHash)).then(function(){
1087
+ return Promise.resolve(swapWith(cache[targetPath],targetPath)).then(function(swapped){
1088
+ if(swapped===false)return false;
974
1089
  applyTitle(titleCache[targetPath]);
1090
+ // Shown from the cache at once; asked again behind it, so a page that
1091
+ // changed since says so (stx:updated) instead of staying stale.
1092
+ revalidate(targetPath,url);
975
1093
  return true;
976
1094
  }).finally(done);
977
1095
  }
@@ -981,8 +1099,9 @@ else {
981
1099
  // the page content. Custom app-shell containers need full documents so
982
1100
  // the router does not inject a <main> fragment into the wrong element.
983
1101
  var wantsFragment=shouldUseFragmentResponse();
984
- return fetch(url,{headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}}).then(function(r){
985
- if(!r.ok)throw new Error(r.status);
1102
+ return routerFetch(url,wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}).then(function(r){
1103
+ if(stale())return null;
1104
+ if(!r.ok)throw httpError(r.status);
986
1105
  // A route guard answered with a redirect and fetch followed it
987
1106
  // transparently, so r.ok is the DESTINATION's 200 and this markup
988
1107
  // belongs to r.url \u2014 not to the path that was requested. Without
@@ -1013,9 +1132,9 @@ else {
1013
1132
  // Layout change? Fetch the FULL page (no X-STX-Router header) and do full document swap
1014
1133
  if(isFragment&&checkLayoutChange(newLayout,url,newGroup)){
1015
1134
  log('[router] layout change \u2014 fetching full page for document swap');
1016
- return fetch(url,{headers:{'Accept':'text/html'}}).then(function(fullRes){
1135
+ return routerFetch(url,{'Accept':'text/html'}).then(function(fullRes){
1017
1136
  log('[router] full page fetched:',fullRes.status,'ok:',fullRes.ok);
1018
- if(!fullRes.ok)throw new Error(fullRes.status);
1137
+ if(!fullRes.ok)throw httpError(fullRes.status);
1019
1138
  return fullRes.text().then(function(html){
1020
1139
  log('[router] full page html length:',html.length);
1021
1140
  return{html:html,isFragment:false,layout:newLayout,layoutGroup:newGroup,title:newTitle};
@@ -1024,23 +1143,51 @@ else {
1024
1143
  }
1025
1144
  return r.text().then(function(html){return{html:html,isFragment:isFragment,layout:newLayout,layoutGroup:newGroup,title:newTitle,containerAttrs:newCAttrs,runtime:newRuntime}});
1026
1145
  }).then(function(result){
1027
- if(!result)return;
1146
+ if(!result||stale())return false;
1028
1147
  if(result.isFragment)result.html=fragmentMarker(result.runtime)+result.html;
1029
1148
  if(o.cache)setCache(targetPath,result.html,result.layout,result.layoutGroup,result.title,result.containerAttrs);
1030
1149
  pendingContainerAttrs=result.isFragment?(result.containerAttrs||''):'';
1031
1150
  pendingLayoutDecl=result.isFragment?null:{layout:result.layout||'',group:result.layoutGroup||''};
1032
- return Promise.resolve(swap(result.html,targetPath,pushState,targetHash)).then(function(){
1151
+ return Promise.resolve(swapWith(result.html,targetPath)).then(function(swapped){
1152
+ if(swapped===false)return false;
1033
1153
  // Fragment swaps carry no <head>; apply the title from the header.
1034
1154
  // Full-document swaps already set document.title from the <title> tag.
1035
1155
  if(result.isFragment)applyTitle(result.title);
1036
1156
  return true;
1037
1157
  });
1038
- }).catch(function(err){
1039
- console.error('[router] fetch error:',err);
1040
- location.href=url;
1041
- }).finally(done);
1158
+ }).catch(failed).finally(done);
1042
1159
  }
1043
1160
  }
1161
+ function httpError(status){var err=new Error(String(status));err.status=status;return err}
1162
+
1163
+ // \u2500\u2500 A screen that could not be loaded \u2500\u2500
1164
+ // Shown in the routed container (in a screen of its own when screens are
1165
+ // retained, so Back returns to the one before it), with the address bar at
1166
+ // the destination, so "Try again" and coming back online both retry it in
1167
+ // place. A page that wants to draw its own cancels stx:navigate-error.
1168
+ function escAttr(v){return String(v).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;')}
1169
+ function showNavError(url,err,pushState,hash,ctx){
1170
+ var status=err&&err.status||0;
1171
+ var offline=!status&&navigator.onLine===false;
1172
+ var ev=new CustomEvent('stx:navigate-error',{cancelable:true,detail:{url:url,status:status,offline:offline,error:err}});
1173
+ window.dispatchEvent(ev);
1174
+ if(ev.defaultPrevented)return false;
1175
+ var title=offline?'You are offline':'This page could not be loaded';
1176
+ var text=offline?'It will load as soon as you are back online.':status?'Something went wrong on the way. Try again in a moment.':'Check your connection and try again.';
1177
+ var html='<!--stx-fragment rt=0--><div class="stx-retry" role="alert" data-stx-retry="'+escAttr(url)+'"><p class="stx-retry-title">'+title+'</p><p class="stx-retry-text">'+text+'</p><button type="button" class="stx-retry-button" data-stx-retry-button>Try again</button></div>';
1178
+ pendingContainerAttrs='';
1179
+ pendingLayoutDecl=null;
1180
+ pendingCtx=ctx;
1181
+ return Promise.resolve(swap(html,cacheKey(url),pushState,hash)).then(function(){return false});
1182
+ }
1183
+ function retryShown(){var root=activeRoot();var el=root&&root.querySelector?root.querySelector('[data-stx-retry]'):null;return el?el.getAttribute('data-stx-retry'):''}
1184
+ document.addEventListener('click',function(e){
1185
+ var b=e.target&&e.target.closest?e.target.closest('[data-stx-retry-button]'):null;
1186
+ if(!b)return;
1187
+ var holder=b.closest('[data-stx-retry]');
1188
+ if(holder)navigate(holder.getAttribute('data-stx-retry'),'replace',true);
1189
+ });
1190
+ window.addEventListener('online',function(){var u=retryShown();if(u)navigate(u,'replace',true)});
1044
1191
 
1045
1192
  // A full document is only safe to partial-swap into the stx shell if it is
1046
1193
  // itself stx-rendered. Pages served by another engine mounted on the same
@@ -1086,7 +1233,7 @@ else {
1086
1233
 
1087
1234
  function announceRoute(){
1088
1235
  if(o.announceRoute===false)return;
1089
- var c=getContainer();
1236
+ var c=activeRoot();
1090
1237
  var h=c&&c.querySelector?c.querySelector('h1'):null;
1091
1238
  // The heading names the destination better than the title, which often
1092
1239
  // carries a site-name suffix. Falls back to the title, then gives up
@@ -1236,7 +1383,12 @@ else {
1236
1383
  var declaredRuntime=fragMark&&fragMark[1]?fragMark[1]:'';
1237
1384
  if(isFragment)html=html.slice(fragMark[0].length);
1238
1385
  if(!isFragment&&!isStxDocument(html)){log('[router] non-stx document \u2014 full navigation to:',url);location.href=url;return Promise.resolve(false)}
1386
+ var ctx=pendingCtx||{direction:pushState==='replace'?'replace':pushState==='tab'?'tab':pushState===false?'pop':'push'};
1387
+ pendingCtx=null;
1239
1388
  var currentContent=getContainer();
1389
+ // The routed container. currentContent is where the page is written: the
1390
+ // container itself, or with screens retained, the new screen inside it.
1391
+ var host=currentContent;
1240
1392
  log('[router] swap: isFragment='+isFragment+' container='+!!currentContent+' tag='+(currentContent&&currentContent.tagName)+' selector='+containerSel+' htmlLen='+html.length);
1241
1393
  if(!currentContent){log('[router] no container \u2014 falling back');location.href=url;return Promise.resolve(false)}
1242
1394
 
@@ -1353,14 +1505,16 @@ else {
1353
1505
  // Swap content \u2014 apply the destination container's own attributes first
1354
1506
  // so the incoming markup lands in a correctly-laid-out container instead
1355
1507
  // of flashing (or sticking) unstyled.
1356
- applyContainerAttrs(currentContent,pendingContainerAttrs);
1508
+ if(scr)scr.cattrs=pendingContainerAttrs;
1509
+ applyContainerAttrs(host,pendingContainerAttrs);
1357
1510
  currentContent.innerHTML=cleanFrag;
1358
1511
  // Remove old page scripts
1359
1512
  qsa('script[data-stx-page]').forEach(function(s){s.remove()});
1360
1513
  if(pushState!==false)writeHistory(pushState,url+(hash||''));
1514
+ if(scr)screenCommit(scr,url+(hash||''));
1361
1515
  refreshCurrentLinks();
1362
1516
  applyScroll(hash);
1363
- window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url}}));
1517
+ window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url,direction:ctx.direction}}));
1364
1518
  // Before page scripts run, so a page that focuses its own control on
1365
1519
  // mount still wins \u2014 its script executes after this.
1366
1520
  focusAfterNavigation(hash);
@@ -1465,17 +1619,28 @@ else {
1465
1619
  if(fragPending.length)Promise.all(fragPending).then(runFragScripts).catch(runFragScripts);
1466
1620
  else runFragScripts();
1467
1621
  }
1622
+ // With screens retained, the outgoing page is kept (or disposed once it
1623
+ // has animated away) by screenBegin rather than cleaned up here.
1624
+ var scr=null;
1625
+ var useScreens=screensOn();
1468
1626
  function startFragSwap(){
1469
- if(window.stx&&window.stx._cleanupContainer)window.stx._cleanupContainer(currentContent);
1627
+ if(ctx.id&&ctx.id!==navSeq)return Promise.resolve(false);
1628
+ if(pushState!==false)innerSnap=innerScroll(activeRoot());
1629
+ if(useScreens)settleAnimation();
1630
+ else if(window.stx&&window.stx._cleanupContainer)window.stx._cleanupContainer(currentContent);
1470
1631
  return new Promise(function(resolve,reject){
1471
1632
  function completeFragSwap(){
1472
1633
  try{
1634
+ setDirection(ctx.direction);
1635
+ if(useScreens){scr=screenBegin(host,ctx,html,pushState!==false);currentContent=scr.el}
1473
1636
  doFragSwap();
1637
+ if(scr)screenEnd(scr);
1474
1638
  resolve(true);
1475
1639
  }catch(err){reject(err)}
1476
1640
  }
1477
1641
  if(instantNav&&swapRunsInOneTask(html,url)){runInOneTask(completeFragSwap)}
1478
1642
  else if(instantNav&&runInstantSwap(completeFragSwap)){}
1643
+ else if(useScreens){completeFragSwap()}
1479
1644
  else if(runViewTransition(completeFragSwap)){}
1480
1645
  else if(instantNav){completeFragSwap()}
1481
1646
  else{currentContent.style.transition='opacity 0.12s ease-out';currentContent.style.opacity='0';setTimeout(function(){completeFragSwap();currentContent.style.opacity='1';setTimeout(function(){currentContent.style.transition=''},150)},120)}
@@ -1527,7 +1692,7 @@ else {
1527
1692
  function doSwap(){
1528
1693
  // \u2500\u2500 Swap <head> styles \u2500\u2500
1529
1694
  // Inject new styles FIRST, then remove old to prevent unstyled flash
1530
- var keepIds={'stx-view-transitions':1,'stx-r-css':1};
1695
+ var keepIds={'stx-r-css':1};
1531
1696
  var curStyles=qsa('head style');
1532
1697
  var newStyles=doc.querySelectorAll('head style');
1533
1698
 
@@ -1861,7 +2026,7 @@ else {
1861
2026
  else curRoot.removeAttribute('data-stx-html-attrs');
1862
2027
  }
1863
2028
 
1864
- window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url}}));
2029
+ window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url,direction:ctx.direction}}));
1865
2030
 
1866
2031
  // Execute page scripts FIRST \u2014 they define setup functions and set _latestSetup
1867
2032
  function execScripts(){
@@ -1915,6 +2080,13 @@ else {
1915
2080
  }
1916
2081
 
1917
2082
  function startSwap(){
2083
+ if(ctx.id&&ctx.id!==navSeq)return Promise.resolve(false);
2084
+ if(pushState!==false)innerSnap=innerScroll(activeRoot());
2085
+ setDirection(ctx.direction);
2086
+ // A whole document is written into the container as it is, so retained
2087
+ // screens go first: they would otherwise outlive the page they belong to.
2088
+ collapseScreens();
2089
+ currentContent=getContainer();
1918
2090
  // Clean up existing signals/effects
1919
2091
  if(window.stx&&window.stx._cleanupContainer){
1920
2092
  window.stx._cleanupContainer(currentContent);
@@ -1950,6 +2122,748 @@ else {
1950
2122
  return afterCss(docCssReady,url,startSwap);
1951
2123
  }
1952
2124
 
2125
+ // \u2500\u2500 Screens \u2500\u2500
2126
+ // A phone app keeps the screens it has shown. Going back finds the list
2127
+ // exactly where it was left, and switching tabs finds the other tab as it
2128
+ // was: the same DOM, its scroll, its signals still live. Re-rendering each
2129
+ // of them from the server on every visit is what made an stx app read as a
2130
+ // web page in a shell.
2131
+ //
2132
+ // So with screens on, each page is written into a screen of its own,
2133
+ // <div data-stx-screen>, inside the routed container. Leaving one by a push
2134
+ // or a tab switch hides it instead of disposing it; Back and the tab bar
2135
+ // show it again without running anything. Each tab owns a stack of them,
2136
+ // mirrored by the history entries above the one the app opened with.
2137
+ //
2138
+ // The runtime binds whichever element carries data-stx-content before it
2139
+ // falls back to the router's container, so the screen being shown wears
2140
+ // that marker and a navigation binds the new screen alone, leaving the
2141
+ // retained ones as they are. A container that is itself [data-stx-content]
2142
+ // (the app-shell mode) keeps the old behaviour, as does a page without the
2143
+ // signals runtime, which has nothing to retain.
2144
+ //
2145
+ // On when the page has tab links (data-stx-nav="tab"), or always with
2146
+ // screens:true; never with screens:false.
2147
+ var SCREEN='data-stx-screen';
2148
+ var HIDDEN='data-stx-screen-hidden';
2149
+ var TAB_SEL='[data-stx-nav="tab"],[data-native-tab]';
2150
+ var tabs={};
2151
+ var curTab='';
2152
+ var active=null;
2153
+ var shownSeq=0;
2154
+ var screensLive=false;
2155
+ function screensOn(){
2156
+ if(screensLive)return true;
2157
+ if(o.screens===false||o.screens==='false'||!shouldUseFragmentResponse())return false;
2158
+ var stx=window.stx;
2159
+ if(!stx||typeof stx._cleanupContainer!=='function')return false;
2160
+ var host=getContainer();
2161
+ if(!host||(host.closest&&host.closest('[data-stx-content]')))return false;
2162
+ return o.screens===true||!!qs(TAB_SEL);
2163
+ }
2164
+ // Where the current page lives: its screen, or the container.
2165
+ function activeRoot(){return active&&active.el&&active.el.isConnected?active.el:getContainer()}
2166
+ // A selector looked up in the page on screen first: a retained screen
2167
+ // carries the same ids as the page that rendered it.
2168
+ function findIn(sel){
2169
+ var root=activeRoot(),el=null;
2170
+ try{el=root&&root.querySelector?root.querySelector(sel):null;if(!el)el=qs(sel)}catch(e){}
2171
+ return el;
2172
+ }
2173
+ function stackOf(id){return tabs[id]||(tabs[id]=[])}
2174
+ function newEntry(url,token,depth){return{url:url,key:cacheKey(url),token:token,depth:depth,tab:curTab,el:null,sx:0,sy:0,inner:null,destroys:[],cattrs:'',shown:0}}
2175
+ function tabId(href){return cacheKey(withCurrentLocale(href))}
2176
+ function tabLinks(){return qsa(TAB_SEL)}
2177
+ // The tab the page on screen belongs to: the one the tab bar has current,
2178
+ // which the sticky nav keeps lit for a screen opened from it.
2179
+ function currentTabId(){
2180
+ var links=tabLinks(),best='',i;
2181
+ for(i=0;i<links.length;i++)if(links[i].hasAttribute('data-stx-nav-current'))return tabId(links[i].getAttribute('href')||'');
2182
+ for(i=0;i<links.length;i++){
2183
+ var st=linkState(links[i].getAttribute('href')||'',links[i].getAttribute('data-stx-active-match'));
2184
+ if(st&&(st.exact||st.matched))return tabId(links[i].getAttribute('href')||'');
2185
+ if(st&&st.active&&!best)best=tabId(links[i].getAttribute('href')||'');
2186
+ }
2187
+ return best;
2188
+ }
2189
+ // Which tab link the bar shows as current. Set by hand on a switch: the
2190
+ // sticky nav would otherwise keep the old tab lit over the other tab's
2191
+ // pushed screen, since neither tab's own URL is on screen.
2192
+ function markTab(id){
2193
+ tabLinks().forEach(function(a){
2194
+ if(tabId(a.getAttribute('href')||'')===id)a.setAttribute('data-stx-nav-current','');
2195
+ else a.removeAttribute('data-stx-nav-current');
2196
+ });
2197
+ }
2198
+ function hostAttrString(host){
2199
+ var out=[];
2200
+ if(host&&host.getAttributeNames)host.getAttributeNames().forEach(function(n){
2201
+ if(n==='data-stx-cattrs'||n==='data-stx-route-focus'||n==='data-stx-animating'||(n==='tabindex'&&host.hasAttribute('data-stx-route-focus')))return;
2202
+ out.push(n+'="'+escAttr(host.getAttribute(n))+'"');
2203
+ });
2204
+ return encodeURIComponent(out.join(' '));
2205
+ }
2206
+ // The page the app opened with becomes the first screen the first time a
2207
+ // screen is needed. Moved, not re-rendered: its bindings stay on its nodes.
2208
+ function adoptInitial(host){
2209
+ if(active&&active.el&&active.el.isConnected)return;
2210
+ screensLive=true;
2211
+ var el=ce('div');
2212
+ el.setAttribute(SCREEN,'');
2213
+ while(host.firstChild)el.appendChild(host.firstChild);
2214
+ host.appendChild(el);
2215
+ if(host.__stx_disposers){el.__stx_disposers=host.__stx_disposers;host.__stx_disposers=null}
2216
+ if(!curTab)curTab=currentTabId();
2217
+ active=newEntry(location.pathname+location.search+location.hash,scrollToken,histDepth);
2218
+ active.el=el;
2219
+ active.cattrs=hostAttrString(host);
2220
+ active.shown=++shownSeq;
2221
+ stackOf(curTab)[histDepth]=active;
2222
+ markActive(el);
2223
+ }
2224
+ function markActive(el){
2225
+ var host=getContainer();
2226
+ if(host)Array.prototype.forEach.call(host.querySelectorAll('['+SCREEN+'][data-stx-content]'),function(other){if(other!==el)other.removeAttribute('data-stx-content')});
2227
+ el.setAttribute('data-stx-content','');
2228
+ el.removeAttribute(HIDDEN);
2229
+ }
2230
+ function saveScroll(e){
2231
+ e.sx=window.pageXOffset||window.scrollX||0;
2232
+ e.sy=window.pageYOffset||window.scrollY||0;
2233
+ e.inner=innerScroll(e.el);
2234
+ }
2235
+ // Everything that is global to the page on screen, kept with it while it is
2236
+ // hidden: its page-level onDestroy callbacks (the runtime runs whatever is
2237
+ // queued on the next stx:load, which would tear a retained page down), its
2238
+ // setup, its server data and its route params.
2239
+ function saveEntry(e){
2240
+ var stx=window.stx||{};
2241
+ e.title=document.title;
2242
+ e.setup=stx._latestSetup;
2243
+ e.data=window.__STX_DATA__;
2244
+ e.config=window.__STX_RUNTIME_CONFIG__;
2245
+ e.params=stx._rp||window.__stx_rp||{};
2246
+ if(stx._destroyCallbacks)e.destroys=e.destroys.concat(stx._destroyCallbacks.splice(0));
2247
+ }
2248
+ function restoreEntry(e){
2249
+ var stx=window.stx||{};
2250
+ if(e.title)document.title=e.title;
2251
+ if(e.setup)stx._latestSetup=e.setup;
2252
+ window.__STX_DATA__=e.data||{};
2253
+ window.__STX_RUNTIME_CONFIG__=e.config||{};
2254
+ if(stx._destroyCallbacks&&e.destroys.length)Array.prototype.push.apply(stx._destroyCallbacks,e.destroys);
2255
+ e.destroys=[];
2256
+ applyContainerAttrs(getContainer(),e.cattrs);
2257
+ }
2258
+ // A new page must not share the outgoing page's scope object, or its setup
2259
+ // merges into the one the retained screen's bindings read. Cleaning an
2260
+ // empty element resets that scope and nothing else.
2261
+ function resetScope(){
2262
+ var stx=window.stx;
2263
+ if(!stx||!stx._cleanupContainer)return;
2264
+ var keep=stx._mountCallbacks?stx._mountCallbacks.splice(0):[];
2265
+ stx._cleanupContainer(ce('div'));
2266
+ if(stx._mountCallbacks)Array.prototype.push.apply(stx._mountCallbacks,keep);
2267
+ }
2268
+ function hideScreen(e){
2269
+ if(!e||!e.el)return;
2270
+ unfreeze(e.el);
2271
+ e.el.setAttribute(HIDDEN,'');
2272
+ e.el.removeAttribute('data-stx-content');
2273
+ }
2274
+ // Disposal runs the same paths a navigation always has: the runtime's
2275
+ // container cleanup (effects, scopes, element hooks) and the page-level
2276
+ // onDestroy callbacks that were put aside when the screen was hidden.
2277
+ // Pending mount callbacks belong to whatever is arriving, so they survive.
2278
+ function disposeEntry(e){
2279
+ if(!e||!e.el)return;
2280
+ var el=e.el,stx=window.stx;
2281
+ e.el=null;
2282
+ if(e===active)saveEntry(e);
2283
+ if(stx&&stx._cleanupContainer){
2284
+ var keep=stx._mountCallbacks?stx._mountCallbacks.splice(0):[];
2285
+ try{stx._cleanupContainer(el)}catch(err){console.error('[router] screen cleanup failed:',err)}
2286
+ if(stx._mountCallbacks)Array.prototype.push.apply(stx._mountCallbacks,keep);
2287
+ }
2288
+ var fns=e.destroys;e.destroys=[];
2289
+ fns.forEach(function(fn){try{fn()}catch(err){console.warn('[stx] destroy callback error:',err)}});
2290
+ if(el.parentNode)el.parentNode.removeChild(el);
2291
+ }
2292
+ function eachEntry(fn){for(var id in tabs)tabs[id].forEach(function(e){if(e)fn(e,id)})}
2293
+ // Keep at most screenLimit screens alive, the least recently shown going
2294
+ // first. An evicted screen stays in its stack as an address: Back to it
2295
+ // loads it again.
2296
+ function enforceLimit(){
2297
+ var live=[];
2298
+ eachEntry(function(e){if(e.el&&e!==active&&!e.leaving)live.push(e)});
2299
+ live.sort(function(a,b){return a.shown-b.shown});
2300
+ while(live.length+1>Math.max(1,o.screenLimit|0))disposeEntry(live.shift());
2301
+ }
2302
+ // Everything back to the plain container: before a whole-document swap and
2303
+ // when the screens are given up.
2304
+ // The screen on screen is left in place: the swap cleans the container,
2305
+ // and it with it, as it always has.
2306
+ function collapseScreens(){
2307
+ if(!screensLive)return;
2308
+ settleAnimation();
2309
+ eachEntry(function(e){if(e!==active)disposeEntry(e)});
2310
+ tabs={};active=null;screensLive=false;
2311
+ }
2312
+ // Component scopes are keyed by ids the server derives from the page, so a
2313
+ // second visit to the same page, pushed over a retained first one, brings
2314
+ // the same ids. The retained copies are renamed out of the way, registry
2315
+ // entry and all, so each instance keeps its own and disposing one cannot
2316
+ // reach the other.
2317
+ var retagSeq=0;
2318
+ function retagCollisions(html){
2319
+ var ids={},any=false;
2320
+ html.replace(/data-stx-scope="([^"]+)"/g,function(m,id){ids[id]=1;any=true;return m});
2321
+ if(!any)return;
2322
+ var scopes=window.stx&&window.stx._scopes;
2323
+ eachEntry(function(e){
2324
+ if(!e.el)return;
2325
+ Array.prototype.forEach.call(e.el.querySelectorAll('[data-stx-scope]'),function(node){
2326
+ var id=node.getAttribute('data-stx-scope');
2327
+ if(!ids[id])return;
2328
+ var next=id+'-r'+(++retagSeq);
2329
+ node.setAttribute('data-stx-scope',next);
2330
+ if(scopes&&scopes[id]&&(!scopes[id].__el||scopes[id].__el===node)){scopes[next]=scopes[id];delete scopes[id]}
2331
+ });
2332
+ });
2333
+ }
2334
+
2335
+ // The swap's half: before the page is written, the outgoing screen is put
2336
+ // aside (lifted out of the page for a slide, or hidden), and a new screen
2337
+ // is made for the page to go into, first in the container so that a lookup
2338
+ // by id finds the page on screen before any retained copy of it.
2339
+ function screenBegin(host,ctx,html,writes){
2340
+ adoptInitial(host);
2341
+ var from=active;
2342
+ retagCollisions(html);
2343
+ if(from&&from.el){
2344
+ // A popstate saved the outgoing position already, under the token it
2345
+ // has since replaced; saving again here would file it under the wrong one.
2346
+ if(writes){rememberScroll();scrollSaved=true}
2347
+ saveEntry(from);
2348
+ }
2349
+ resetScope();
2350
+ var el=ce('div');
2351
+ el.setAttribute(SCREEN,'');
2352
+ var anim=!!(from&&from.el&&canAnimate(ctx.direction));
2353
+ if(anim)freeze(from.el);
2354
+ else if(from&&from.el)hideScreen(from);
2355
+ host.insertBefore(el,host.firstChild);
2356
+ markActive(el);
2357
+ return{el:el,from:from,ctx:ctx,anim:anim,cattrs:''};
2358
+ }
2359
+ // After the history write, so the entry knows its token and depth.
2360
+ function screenCommit(scr,href){
2361
+ var e=scr.ctx.entry||newEntry(href,scrollToken,histDepth);
2362
+ e.url=href;e.key=cacheKey(href);e.token=scrollToken;e.depth=histDepth;e.tab=curTab;
2363
+ e.el=scr.el;e.cattrs=scr.cattrs;e.shown=++shownSeq;e.destroys=[];
2364
+ var stack=stackOf(curTab);
2365
+ // A push drops whatever was forward of it; a pop or a replacement takes
2366
+ // the place of what was at its depth. The outgoing screen is left to the
2367
+ // transition, which disposes it once it is off screen.
2368
+ for(var i=e.depth;i<stack.length;i++){var x=stack[i];if(x&&x!==e&&x!==scr.from)disposeEntry(x)}
2369
+ stack.length=e.depth;
2370
+ stack[e.depth]=e;
2371
+ active=e;
2372
+ scrollSaved=false;
2373
+ }
2374
+ function screenEnd(scr){
2375
+ var from=scr.from,dir=scr.ctx.direction;
2376
+ if(!from||!from.el){finishScreen();return}
2377
+ // Retained: left by a push, or by a tab switch. Popped or replaced: gone.
2378
+ var keep=dir==='push'||dir==='tab';
2379
+ var stack=tabs[from.tab];
2380
+ if(keep&&!(stack&&stack[from.depth]===from))keep=false;
2381
+ from.leaving=true;
2382
+ var after=function(){
2383
+ from.leaving=false;
2384
+ if(keep&&from.el)hideScreen(from);
2385
+ else disposeEntry(from);
2386
+ finishScreen();
2387
+ };
2388
+ if(scr.anim)animateScreens(from.el,scr.el,dir,after);
2389
+ else after();
2390
+ }
2391
+ function finishScreen(){
2392
+ enforceLimit();
2393
+ observeLinks();
2394
+ }
2395
+
2396
+ // Show a screen that is already here: Back to one below in the stack, or a
2397
+ // tab's top screen. Nothing is fetched and no page script runs; the page is
2398
+ // told it is on screen again (stx:screen-shown) so it can refresh whatever
2399
+ // may have gone stale while it was hidden.
2400
+ function showRetained(to,dir,opts){
2401
+ opts=opts||{};
2402
+ settleAnimation();
2403
+ var from=active,host=getContainer();
2404
+ if(navAbort){try{navAbort.abort()}catch(e){}navAbort=null}
2405
+ navSeq++;
2406
+ isNavigating=false;
2407
+ document.body.classList.remove(o.loadingClass);
2408
+ if(from&&from.el){if(!opts.settled)saveScroll(from);saveEntry(from)}
2409
+ setDirection(dir);
2410
+ var anim=!opts.settled&&from&&from.el&&from!==to&&canAnimate(dir,true);
2411
+ if(anim)freeze(from.el);
2412
+ else if(from&&from.el&&from!==to&&!opts.settled)hideScreen(from);
2413
+ unfreeze(to.el);
2414
+ markActive(to.el);
2415
+ active=to;
2416
+ to.shown=++shownSeq;
2417
+ restoreEntry(to);
2418
+ pendingScroll=null;pendingInner=null;
2419
+ window.scrollTo({left:to.sx,top:to.sy,behavior:'instant'});
2420
+ applyInner(to.el,to.inner);
2421
+ if(opts.settled&&from&&from!==to&&from.el)hideScreen(from);
2422
+ refreshCurrentLinks();
2423
+ window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:to.url,direction:dir,retained:true,params:to.params||{}}}));
2424
+ focusAfterNavigation('');
2425
+ setTimeout(announceRoute,0);
2426
+ window.dispatchEvent(new CustomEvent('stx:screen-shown',{detail:{url:to.url,direction:dir}}));
2427
+ revalidate(to.key,to.url);
2428
+ var keep=dir==='push'||dir==='tab';
2429
+ var after=function(){
2430
+ if(from&&from!==to){
2431
+ if(dir==='pop'){
2432
+ // Everything above the screen shown is gone from the stack.
2433
+ var stack=tabs[to.tab]||[];
2434
+ for(var i=to.depth+1;i<stack.length;i++)if(stack[i]&&stack[i]!==from)disposeEntry(stack[i]);
2435
+ stack.length=to.depth+1;
2436
+ }
2437
+ if(keep&&from.el)hideScreen(from);
2438
+ else if(!keep)disposeEntry(from);
2439
+ }
2440
+ finishScreen();
2441
+ settleDirection();
2442
+ };
2443
+ if(anim){from.leaving=true;animateScreens(from.el,to.el,dir,function(){from.leaving=false;after()})}
2444
+ else after();
2445
+ return true;
2446
+ }
2447
+
2448
+ // \u2500\u2500 Direction and motion \u2500\u2500
2449
+ // data-nav-direction on <html> says what kind of move is under way --
2450
+ // push, pop, tab or replace -- for as long as it is, so CSS (and a page's
2451
+ // own transitions) can tell a step forward from a step back.
2452
+ var animEnd=null;
2453
+ function setDirection(d){if(d)document.documentElement.setAttribute('data-nav-direction',d)}
2454
+ function settleDirection(){
2455
+ if(isNavigating||animEnd)return;
2456
+ document.documentElement.removeAttribute('data-nav-direction');
2457
+ }
2458
+ function reducedMotion(){
2459
+ try{return !!(window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches)}catch(e){return false}
2460
+ }
2461
+ // instantNav belongs to a navigation that fetched; a screen shown from
2462
+ // the stack (Back) is not one, and must not inherit the last tab tap's.
2463
+ function canAnimate(dir,shown){
2464
+ return (dir==='push'||dir==='pop')&&(shown||!instantNav)&&o.navDuration>0&&typeof window.getComputedStyle==='function';
2465
+ }
2466
+ // The first background behind the container that is not transparent: the
2467
+ // screens are transparent, and two of them sliding over each other need
2468
+ // the page's colour to hide the one underneath.
2469
+ function screenBackground(host){
2470
+ for(var n=host;n&&n.nodeType===1;n=n.parentNode){
2471
+ var c='';
2472
+ try{c=getComputedStyle(n).backgroundColor}catch(e){}
2473
+ if(c&&c!=='transparent'&&c!=='rgba(0, 0, 0, 0)')return c;
2474
+ }
2475
+ return '';
2476
+ }
2477
+ // Lifted out of the flow and pinned where it is on screen, as a box the
2478
+ // height of the viewport scrolled to the same place: the page can scroll
2479
+ // to wherever the next screen wants, the outgoing one does not move, and a
2480
+ // sticky header inside it stays stuck.
2481
+ function freeze(el,top){
2482
+ var r=el.getBoundingClientRect();
2483
+ var vh=window.innerHeight||document.documentElement.clientHeight||0;
2484
+ var at=top===undefined?r.top:top;
2485
+ var y=Math.max(at,0);
2486
+ var s=el.style;
2487
+ s.position='fixed';s.top=y+'px';s.left=r.left+'px';s.width=r.width+'px';s.height=Math.max(vh-y,0)+'px';s.overflow='hidden';s.margin='0';
2488
+ el.scrollTop=y-at;
2489
+ }
2490
+ function unfreeze(el){
2491
+ if(!el)return;
2492
+ var s=el.style;
2493
+ ['position','top','left','width','height','overflow','margin','transform','transition','zIndex','backgroundColor','pointerEvents'].forEach(function(k){s[k]=''});
2494
+ el.removeAttribute('data-stx-screen-enter');
2495
+ el.removeAttribute('data-stx-screen-leave');
2496
+ el.scrollTop=0;
2497
+ }
2498
+ function parseDuration(v){
2499
+ v=String(v||'').split(',')[0].trim();
2500
+ var n=parseFloat(v);
2501
+ if(!(n>=0))return -1;
2502
+ return v.indexOf('ms')>0?n:n*1000;
2503
+ }
2504
+ function dimLayer(host){
2505
+ var r=host.getBoundingClientRect();
2506
+ var dim=ce('div');
2507
+ dim.setAttribute('data-stx-screen-dim','');
2508
+ dim.style.left=r.left+'px';
2509
+ dim.style.width=r.width+'px';
2510
+ return dim;
2511
+ }
2512
+ // Two real screens, animated by the shipped CSS: the one arriving and the
2513
+ // one leaving, and a dim between them over the one underneath.
2514
+ function animateScreens(fromEl,toEl,dir,done){
2515
+ var host=getContainer();
2516
+ var bg=screenBackground(host);
2517
+ var dim=dimLayer(host);
2518
+ host.setAttribute('data-stx-animating','');
2519
+ fromEl.style.backgroundColor=bg;toEl.style.backgroundColor=bg;
2520
+ fromEl.style.pointerEvents='none';
2521
+ fromEl.setAttribute('data-stx-screen-leave','');
2522
+ toEl.setAttribute('data-stx-screen-enter','');
2523
+ host.appendChild(dim);
2524
+ var ms=-1;
2525
+ try{ms=parseDuration(getComputedStyle(toEl).animationDuration)}catch(e){}
2526
+ if(!(ms>0))ms=o.navDuration;
2527
+ var timer=setTimeout(end,ms+120);
2528
+ function onEnd(e){if(e.target===toEl)end()}
2529
+ toEl.addEventListener('animationend',onEnd);
2530
+ function end(){
2531
+ if(animEnd!==end)return;
2532
+ animEnd=null;
2533
+ clearTimeout(timer);
2534
+ toEl.removeEventListener('animationend',onEnd);
2535
+ if(dim.parentNode)dim.parentNode.removeChild(dim);
2536
+ host.removeAttribute('data-stx-animating');
2537
+ toEl.removeAttribute('data-stx-screen-enter');
2538
+ toEl.style.backgroundColor='';
2539
+ done();
2540
+ settleDirection();
2541
+ }
2542
+ animEnd=end;
2543
+ }
2544
+ // A navigation that starts while a slide is still running finishes the
2545
+ // slide first, rather than lifting a screen that is halfway across.
2546
+ function settleAnimation(){if(animEnd)animEnd()}
2547
+
2548
+ // \u2500\u2500 Tabs \u2500\u2500
2549
+ // Back never crosses tabs: the history above the entry the app opened with
2550
+ // is always the current tab's stack. A switch rewinds to that entry, writes
2551
+ // the target tab's stack in its place, and shows its top screen exactly as
2552
+ // it was left.
2553
+ var rewinding=null;
2554
+ function rewind(){
2555
+ if(histDepth<=0)return Promise.resolve();
2556
+ return new Promise(function(resolve){
2557
+ var n=histDepth;
2558
+ var timer=setTimeout(finish,700);
2559
+ function finish(){
2560
+ clearTimeout(timer);
2561
+ if(rewinding===finish)rewinding=null;
2562
+ histDepth=(history.state&&history.state[DEPTH])|0;
2563
+ var token=history.state&&history.state[SCROLL_TOKEN];
2564
+ if(token)scrollToken=token;
2565
+ resolve();
2566
+ }
2567
+ rewinding=finish;
2568
+ history.go(-n);
2569
+ });
2570
+ }
2571
+ function entryState(e,pushed){
2572
+ var st={};
2573
+ st[SCROLL_TOKEN]=e.token;st[DEPTH]=e.depth;st[TAB]=e.tab;
2574
+ if(pushed)st[PUSHED_MARK]=true;
2575
+ return st;
2576
+ }
2577
+ function selectTab(href){
2578
+ if(!href)return Promise.resolve(false);
2579
+ var id=tabId(href);
2580
+ var on=screensOn();
2581
+ if(on&&!screensLive)adoptInitial(getContainer());
2582
+ if(!curTab)curTab=on&&active?active.tab:currentTabId();
2583
+ if(id===curTab)return reselectTab(href);
2584
+ if(active&&active.el){rememberScroll();saveScroll(active)}
2585
+ settleAnimation();
2586
+ if(navAbort){try{navAbort.abort()}catch(e){}navAbort=null}
2587
+ var seq=++navSeq;
2588
+ return rewind().then(function(){
2589
+ if(seq!==navSeq)return false;
2590
+ curTab=id;
2591
+ markTab(id);
2592
+ var stack=on?(tabs[id]||[]).filter(Boolean):[];
2593
+ if(!stack.length){
2594
+ if(on)tabs[id]=[];
2595
+ navCtxNext={direction:'tab'};
2596
+ instantNext=true;
2597
+ return navigate(href,'tab');
2598
+ }
2599
+ // Rewritten in order, so Back walks this tab's own stack.
2600
+ stack.forEach(function(e,i){
2601
+ e.depth=i;e.tab=id;
2602
+ if(i===0)history.replaceState(entryState(e,false),'',e.url);
2603
+ else history.pushState(entryState(e,true),'',e.url);
2604
+ });
2605
+ tabs[id]=stack;
2606
+ var top=stack[stack.length-1];
2607
+ histDepth=top.depth;
2608
+ scrollToken=top.token;
2609
+ if(top.el)return showRetained(top,'tab');
2610
+ // Its top screen was let go: load it into the same entry.
2611
+ pendingScroll=[top.sx,top.sy];
2612
+ navCtxNext={direction:'tab',entry:top};
2613
+ instantNext=true;
2614
+ return navigate(top.url,false);
2615
+ });
2616
+ }
2617
+ // The tab that is already current, selected again: back to the top of its
2618
+ // screen, and when it is there already, back to the tab's first screen.
2619
+ // stx:tabreselect says which, and a page that cancels it keeps its own.
2620
+ function reselectTab(href){
2621
+ var root=activeRoot();
2622
+ var y=window.pageYOffset||window.scrollY||0;
2623
+ var inner=innerScroll(root);
2624
+ var atRoot=active?active.depth===0&&active.key===tabId(href):cacheKey(location.href)===tabId(href);
2625
+ var action=(y>0||inner)?'top':atRoot?'none':'root';
2626
+ var ev=new CustomEvent('stx:tabreselect',{cancelable:true,detail:{href:href,action:action}});
2627
+ window.dispatchEvent(ev);
2628
+ if(ev.defaultPrevented||action==='none')return Promise.resolve(false);
2629
+ if(action==='top'){
2630
+ window.scrollTo({top:0,left:0,behavior:reducedMotion()?'instant':'smooth'});
2631
+ if(root&&root.querySelectorAll)root.querySelectorAll('[data-stx-scroll]').forEach(function(el){if(el.scrollTo)el.scrollTo({top:0,left:0,behavior:'smooth'});else el.scrollTop=0});
2632
+ return Promise.resolve(true);
2633
+ }
2634
+ var stack=tabs[curTab]||[];
2635
+ var first=stack.filter(Boolean)[0];
2636
+ if(first&&first.el&&first.key===tabId(href)&&histDepth>0){
2637
+ var target=first;
2638
+ return new Promise(function(resolve){
2639
+ history.go(target.depth-histDepth);
2640
+ setTimeout(function(){resolve(true)},0);
2641
+ });
2642
+ }
2643
+ // The tab's first screen is not here (opened on a deep link, or let
2644
+ // go): load the tab's own page in its place.
2645
+ return rewind().then(function(){
2646
+ navCtxNext={direction:'pop'};
2647
+ return navigate(href,'tab');
2648
+ });
2649
+ }
2650
+
2651
+ // \u2500\u2500 Swipe back \u2500\u2500
2652
+ // A pan from the left edge drags the screen on top away and shows the
2653
+ // retained one underneath, the way iOS does: the one underneath moves from
2654
+ // -30% and brightens as the top one goes. Released past half way, or
2655
+ // flicked, it completes as a Back (history.back(), shown without a second
2656
+ // animation); otherwise it springs back. Only where there is a screen to
2657
+ // go back to in this tab, never from inside data-stx-no-swipe, and never
2658
+ // over something that pans sideways on its own (touch-action none, pan-x,
2659
+ // pan-y, or a horizontal scroller that is not at its start).
2660
+ var swipe=null;
2661
+ var swipeSettled=null;
2662
+ function swipePrev(){
2663
+ if(o.swipeBack===false||!screensLive||!active||!active.el||isNavigating||animEnd)return null;
2664
+ var prev=(tabs[curTab]||[])[active.depth-1];
2665
+ return prev&&prev.el?prev:null;
2666
+ }
2667
+ function swipeBlocked(target){
2668
+ var host=getContainer();
2669
+ for(var n=target;n&&n.nodeType===1&&n!==host;n=n.parentNode){
2670
+ if(n.hasAttribute('data-stx-no-swipe'))return true;
2671
+ var cs=null;
2672
+ try{cs=getComputedStyle(n)}catch(e){}
2673
+ if(!cs)continue;
2674
+ var ta=String(cs.touchAction||'').split(' ');
2675
+ var has=function(v){return ta.indexOf(v)!==-1};
2676
+ if(has('none')||(has('pan-x')||has('pan-left')||has('pan-right'))!==(has('pan-y')||has('pan-up')||has('pan-down')))return true;
2677
+ var ox=String(cs.overflowX||'');
2678
+ if((ox==='auto'||ox==='scroll')&&n.scrollWidth>n.clientWidth&&n.scrollLeft>0)return true;
2679
+ }
2680
+ return false;
2681
+ }
2682
+ function now(){return window.performance&&performance.now?performance.now():Date.now()}
2683
+ function touchOf(e){return (e.touches&&e.touches[0])||(e.changedTouches&&e.changedTouches[0])||null}
2684
+ function swipeStart(e){
2685
+ if(swipe||!e.touches||e.touches.length!==1)return;
2686
+ var t=e.touches[0];
2687
+ if(t.clientX>o.swipeEdge)return;
2688
+ var prev=swipePrev();
2689
+ if(!prev||swipeBlocked(e.target))return;
2690
+ swipe={prev:prev,x0:t.clientX,y0:t.clientY,x:t.clientX,t:now(),v:0,on:false};
2691
+ }
2692
+ function swipeBegin(){
2693
+ var cur=active,prev=swipe.prev,host=getContainer();
2694
+ var w=host.getBoundingClientRect().width||window.innerWidth||1;
2695
+ saveScroll(cur);
2696
+ var docTop=cur.el.getBoundingClientRect().top+(window.pageYOffset||window.scrollY||0);
2697
+ swipe.w=w;
2698
+ swipe.cur=cur;
2699
+ swipe.bg=screenBackground(host);
2700
+ swipe.dim=dimLayer(host);
2701
+ swipe.dim.setAttribute('data-stx-drag','');
2702
+ // Shown before it is measured: a hidden screen has no box to pin.
2703
+ prev.el.removeAttribute(HIDDEN);
2704
+ freeze(prev.el,docTop-prev.sy);
2705
+ applyInner(prev.el,prev.inner);
2706
+ prev.el.style.zIndex='1';
2707
+ prev.el.style.backgroundColor=swipe.bg;
2708
+ cur.el.style.position='relative';
2709
+ cur.el.style.zIndex='3';
2710
+ cur.el.style.backgroundColor=swipe.bg;
2711
+ host.setAttribute('data-stx-animating','');
2712
+ host.appendChild(swipe.dim);
2713
+ setDirection('pop');
2714
+ swipe.on=true;
2715
+ }
2716
+ function swipeDraw(dx){
2717
+ var p=Math.min(Math.max(dx/swipe.w,0),1);
2718
+ swipe.p=p;
2719
+ swipe.cur.el.style.transform='translateX('+(p*swipe.w)+'px)';
2720
+ swipe.prev.el.style.transform='translateX('+(-0.3*swipe.w*(1-p))+'px)';
2721
+ swipe.dim.style.opacity=String(1-p);
2722
+ }
2723
+ function swipeMove(e){
2724
+ if(!swipe)return;
2725
+ var t=touchOf(e);
2726
+ if(!t)return;
2727
+ var dx=t.clientX-swipe.x0,dy=t.clientY-swipe.y0;
2728
+ if(!swipe.on){
2729
+ if(Math.abs(dx)<8&&Math.abs(dy)<8)return;
2730
+ // Vertical first, or leftwards: a scroll, not a Back.
2731
+ if(dx<=0||Math.abs(dy)>=Math.abs(dx)){swipe=null;return}
2732
+ swipeBegin();
2733
+ }
2734
+ if(e.cancelable)e.preventDefault();
2735
+ var n=now(),dt=n-swipe.t;
2736
+ if(dt>0)swipe.v=swipe.v*0.2+((t.clientX-swipe.x)/dt)*0.8;
2737
+ swipe.x=t.clientX;swipe.t=n;
2738
+ swipeDraw(dx);
2739
+ }
2740
+ function swipeEnd(e){
2741
+ if(!swipe)return;
2742
+ var s=swipe;
2743
+ swipe=null;
2744
+ if(!s.on)return;
2745
+ // A finger held still before lifting has no speed left.
2746
+ if(now()-s.t>80)s.v=0;
2747
+ var commit=e.type!=='touchcancel'&&(s.p>0.5||s.v>0.5);
2748
+ var remaining=commit?(1-s.p)*s.w:s.p*s.w;
2749
+ var ms=Math.round(Math.min(o.navDuration,Math.max(120,remaining/Math.max(Math.abs(s.v),0.6))));
2750
+ var ease='cubic-bezier(0.2, 0.9, 0.3, 1)';
2751
+ [s.cur.el,s.prev.el].forEach(function(el){el.style.transition='transform '+ms+'ms '+ease});
2752
+ s.dim.style.transition='opacity '+ms+'ms '+ease;
2753
+ s.cur.el.style.transform=commit?'translateX('+s.w+'px)':'translateX(0px)';
2754
+ s.prev.el.style.transform=commit?'translateX(0px)':'translateX('+(-0.3*s.w)+'px)';
2755
+ s.dim.style.opacity=commit?'0':'1';
2756
+ var finished=false;
2757
+ var finish=function(){
2758
+ if(finished)return;
2759
+ finished=true;
2760
+ animEnd=null;
2761
+ if(s.dim.parentNode)s.dim.parentNode.removeChild(s.dim);
2762
+ getContainer().removeAttribute('data-stx-animating');
2763
+ if(commit){
2764
+ // Already where Back would put it: the popstate that follows shows
2765
+ // the screen underneath as it is, with no second animation.
2766
+ s.cur.el.style.visibility='hidden';
2767
+ swipeSettled=s.prev;
2768
+ history.back();
2769
+ }
2770
+ else{
2771
+ hideScreen(s.prev);
2772
+ s.cur.el.style.transform='';s.cur.el.style.transition='';s.cur.el.style.position='';s.cur.el.style.zIndex='';s.cur.el.style.backgroundColor='';
2773
+ settleDirection();
2774
+ }
2775
+ };
2776
+ animEnd=finish;
2777
+ setTimeout(finish,ms+40);
2778
+ }
2779
+ document.addEventListener('touchstart',swipeStart,{capture:true,passive:true});
2780
+ document.addEventListener('touchmove',swipeMove,{capture:true,passive:false});
2781
+ document.addEventListener('touchend',swipeEnd,{capture:true,passive:true});
2782
+ document.addEventListener('touchcancel',swipeEnd,{capture:true,passive:true});
2783
+ // The system is short of memory: let go of every hidden screen except the
2784
+ // one a swipe back would show.
2785
+ window.addEventListener('craftMemoryWarning',function(){
2786
+ var keep=active?(tabs[curTab]||[])[active.depth-1]:null;
2787
+ eachEntry(function(e){if(e!==active&&e!==keep&&!e.leaving)disposeEntry(e)});
2788
+ });
2789
+
2790
+ // \u2500\u2500 Revalidation and updates \u2500\u2500
2791
+ // A page served from the router's cache, or a screen shown again, is asked
2792
+ // for once more behind the swap. If it changed, the cache takes the new copy
2793
+ // and stx:updated says so, so the page can refresh what it shows; the
2794
+ // screen is never swapped out from under the user.
2795
+ function revalidate(key,url){
2796
+ if(!o.revalidate||!o.cache||!key||prefetching[key])return;
2797
+ if(Date.now()-(cacheAt[key]||0)<o.revalidateAfter)return;
2798
+ var wantsFragment=shouldUseFragmentResponse();
2799
+ var before=cache[key];
2800
+ prefetching[key]=fetch(url,{headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}}).then(function(r){
2801
+ var incoming=r.headers.get('X-STX-Build')||'';
2802
+ if(isBuildSkew(incoming)){reloadForSkew(url,incoming,true);return null}
2803
+ return readPrefetchResponse(r,wantsFragment);
2804
+ }).then(function(result){
2805
+ if(!result)return;
2806
+ if(o.cache)setCache(key,result.html,result.layout,result.layoutGroup,result.title,result.containerAttrs);
2807
+ if(before!==undefined&&before!==result.html&&cacheKey(location.href)===key)emitUpdated(url,'page');
2808
+ }).catch(function(){}).finally(function(){delete prefetching[key]});
2809
+ }
2810
+ // The offline worker answered from its cache and found something newer
2811
+ // behind it. It posts { type: 'stx:updated', url, kind }, which its own
2812
+ // register script turns into a window event; the router does that only
2813
+ // where that script is not on the page, so the event is heard once. Either
2814
+ // way a page the router has cached is dropped, so the next visit gets the
2815
+ // new one.
2816
+ var ownUpdate=false;
2817
+ function emitUpdated(url,kind){
2818
+ ownUpdate=true;
2819
+ try{window.dispatchEvent(new CustomEvent('stx:updated',{detail:{url:url,kind:kind||'page'}}))}finally{ownUpdate=false}
2820
+ }
2821
+ window.addEventListener('stx:updated',function(e){
2822
+ var d=e&&e.detail;
2823
+ if(ownUpdate||!d||!d.url||d.kind==='api')return;
2824
+ try{evictCache(cacheKey(d.url))}catch(err){}
2825
+ });
2826
+ try{
2827
+ var sw=navigator.serviceWorker;
2828
+ if(sw&&sw.addEventListener)sw.addEventListener('message',function(e){
2829
+ var d=e&&e.data;
2830
+ if(!d||d.type!=='stx:updated'||window.stxOffline)return;
2831
+ window.dispatchEvent(new CustomEvent('stx:updated',{detail:{url:d.url,kind:d.kind||'page'}}));
2832
+ });
2833
+ }catch(e){}
2834
+
2835
+ // \u2500\u2500 Prefetch on sight \u2500\u2500
2836
+ // Links are fetched as they scroll into view, a few per screen, so most
2837
+ // taps find their page already here. Not on a connection that asked to
2838
+ // save data, and not for a link marked data-stx-prefetch="false" or
2839
+ // "hover" (hover and touch still prefetch everything).
2840
+ var sightObserver=null;
2841
+ var sightCount=0;
2842
+ function observeLinks(){
2843
+ if(!o.prefetch||!o.cache||o.prefetchVisible===false||typeof IntersectionObserver!=='function')return;
2844
+ var c=navigator.connection;
2845
+ if(c&&(c.saveData||/2g/.test(c.effectiveType||'')))return;
2846
+ if(!sightObserver)sightObserver=new IntersectionObserver(function(entries){
2847
+ entries.forEach(function(en){
2848
+ if(!en.isIntersecting)return;
2849
+ sightObserver.unobserve(en.target);
2850
+ if(sightCount>=o.prefetchVisibleMax)return;
2851
+ sightCount++;
2852
+ prefetchLink(en.target);
2853
+ });
2854
+ });
2855
+ sightCount=0;
2856
+ var root=activeRoot()||document.body;
2857
+ if(!root||!root.querySelectorAll)return;
2858
+ root.querySelectorAll('[data-stx-link][href]').forEach(function(a){
2859
+ if(a.__stxSeen)return;
2860
+ var p=a.getAttribute('data-stx-prefetch');
2861
+ if(p==='false'||p==='hover'||p==='none')return;
2862
+ a.__stxSeen=1;
2863
+ sightObserver.observe(a);
2864
+ });
2865
+ }
2866
+
1953
2867
  // \u2500\u2500 Link interception \u2500\u2500
1954
2868
  // Links the router must never touch, however it found them. This is the
1955
2869
  // opt-out set, and it is deliberately separate from shouldIntercept's
@@ -2052,6 +2966,8 @@ else {
2052
2966
  e.preventDefault();
2053
2967
  e.stopPropagation();
2054
2968
  log('[router] navigating to:',href);
2969
+ // A tab: its own stack, never a back entry (selectTab).
2970
+ if(link.getAttribute('data-stx-nav')==='tab'||link.hasAttribute('data-native-tab')){selectTab(href);return}
2055
2971
  instantNext=link.getAttribute('data-stx-transition')==='none';
2056
2972
  navigate(withCurrentLocale(href));
2057
2973
  });
@@ -2193,16 +3109,33 @@ else {
2193
3109
 
2194
3110
  // \u2500\u2500 Back/forward \u2500\u2500
2195
3111
  window.addEventListener('popstate',function(){
3112
+ // A tab switch rewinding to the first entry: not a navigation of its own.
3113
+ if(rewinding){rewinding();return}
3114
+ var settled=swipeSettled;
3115
+ swipeSettled=null;
2196
3116
  // Nothing has scrolled yet (restoration is manual), so the viewport still
2197
3117
  // shows the entry being left: save it under its own token before adopting
2198
3118
  // the popped entry's, and hand the swap the position to land on.
2199
- rememberScroll();
3119
+ if(!settled)rememberScroll();
3120
+ var st=history.state;
3121
+ // An entry the router did not write (a page's own pushState) keeps the
3122
+ // depth it was written at; a lower one is a step back.
3123
+ var depth=st&&typeof st[DEPTH]==='number'?st[DEPTH]:histDepth;
3124
+ var dir=depth<histDepth?'pop':depth>histDepth?'push':'replace';
3125
+ histDepth=depth;
3126
+ var popped=st&&st[SCROLL_TOKEN];
3127
+ scrollToken=popped||newScrollToken();
2200
3128
  if(restoreScroll){
2201
- var popped=history.state&&history.state[SCROLL_TOKEN];
2202
- scrollToken=popped||newScrollToken();
2203
3129
  if(!popped)stampScrollToken();
2204
3130
  pendingScroll=readScroll(scrollToken);
3131
+ pendingInner=readInner(scrollToken);
3132
+ }
3133
+ if(screensLive){
3134
+ if(st&&st[TAB]!==undefined&&st[TAB]!==curTab&&tabs[st[TAB]]){curTab=st[TAB];markTab(curTab)}
3135
+ var target=(tabs[curTab]||[])[depth];
3136
+ if(target&&target.el&&target.token===scrollToken&&target!==active){showRetained(target,dir==='replace'?'pop':dir,{settled:settled===target});return}
2205
3137
  }
3138
+ navCtxNext={direction:dir};
2206
3139
  navigate(location.pathname+location.search+location.hash,false);
2207
3140
  });
2208
3141
 
@@ -2246,7 +3179,13 @@ else {
2246
3179
  setCache(key,result.html,result.layout,result.layoutGroup,result.title,result.containerAttrs);
2247
3180
  if(eager)keepPage(key,result);
2248
3181
  }
2249
- if(result)loadModuleRegistries(result.html,href);
3182
+ if(result){
3183
+ loadModuleRegistries(result.html,href);
3184
+ // Its stylesheet too, so the tap never waits on cssLoadTimeout for a
3185
+ // sheet it could have had all along. Added unapplied (media=print),
3186
+ // as a navigation adds it; the swap makes it live.
3187
+ if(result.html.indexOf('<!--stx-fragment')===0)preloadGeneratedCss(fragmentCssHrefs(result.html),location.href);
3188
+ }
2250
3189
  }).catch(function(){}).finally(function(){delete prefetching[key]});
2251
3190
  }
2252
3191
 
@@ -2342,6 +3281,7 @@ else {
2342
3281
  document.addEventListener('mouseover',onIntent,true);
2343
3282
  document.addEventListener('touchstart',onIntent,{capture:true,passive:true});
2344
3283
  window.addEventListener('stx:load',prefetchEager);
3284
+ window.addEventListener('stx:load',function(){setTimeout(observeLinks,0)});
2345
3285
  }
2346
3286
 
2347
3287
  // \u2500\u2500 Active link management \u2500\u2500
@@ -2408,7 +3348,7 @@ else {
2408
3348
  // updateActiveLinks has already rewritten the classes by now.
2409
3349
  function updateNav(){
2410
3350
  var done=new Set();
2411
- qsa('nav, #mobileNav, [data-stx-nav]').forEach(function(nav){
3351
+ qsa('nav, #mobileNav, [data-stx-nav]:not(a)').forEach(function(nav){
2412
3352
  var states=[];
2413
3353
  Array.prototype.forEach.call(nav.querySelectorAll('a[href]'),function(a){
2414
3354
  if(done.has(a)||!a.hasAttribute('data-stx-link'))return;
@@ -2504,16 +3444,40 @@ else {
2504
3444
  // The container the router focuses after a navigation is not a
2505
3445
  // control, so it draws no focus ring: Safari drew one around the whole
2506
3446
  // page, a blue line along its bottom edge on every screen.
2507
- var css='[data-stx-route-focus]:focus{outline:none}.stx-navigating{cursor:wait}.stx-navigating a,.stx-navigating button{pointer-events:none}#stx-router-progress{position:fixed;top:0;left:0;right:0;height:'+ph+';background:'+pc+';box-shadow:0 0 8px '+pc+',0 0 4px '+pc+';transform:scaleX(0);transform-origin:left;transition:transform .18s ease-out,opacity .26s ease;opacity:0;pointer-events:none;z-index:999999}html.stx-instant::view-transition-group(*),html.stx-instant::view-transition-old(*),html.stx-instant::view-transition-new(*){animation:none!important}';
3447
+ // Links stay live while a page loads: a tap on another one is the newest
3448
+ // navigation and wins (navigate), where pointer-events:none used to
3449
+ // swallow it.
3450
+ var css='[data-stx-route-focus]:focus{outline:none}.stx-navigating{cursor:progress}#stx-router-progress{position:fixed;top:0;left:0;right:0;height:'+ph+';background:'+pc+';box-shadow:0 0 8px '+pc+',0 0 4px '+pc+';transform:scaleX(0);transform-origin:left;transition:transform .18s ease-out,opacity .26s ease;opacity:0;pointer-events:none;z-index:999999}html.stx-instant::view-transition-group(*),html.stx-instant::view-transition-old(*),html.stx-instant::view-transition-new(*){animation:none!important}';
3451
+ // One View Transitions block. There used to be two, and the second
3452
+ // (injected after this one) silently overrode the first: the page stays
3453
+ // put and only the routed content fades, which is what this now says
3454
+ // once. The retained screen inside it is not named, so the container
3455
+ // and the screen never carry the same name.
2508
3456
  if(o.viewTransitions&&'startViewTransition' in document){
2509
3457
  var dur=(o.viewTransitionDuration||220)+'ms';
2510
3458
  var ease=o.viewTransitionEasing||'cubic-bezier(0.16, 1, 0.3, 1)';
2511
- css+='::view-transition-old(root),::view-transition-new(root){animation-duration:'+dur+';animation-timing-function:'+ease+'}';
2512
- css+='::view-transition-old(root){animation-name:stx-r-fade-out}::view-transition-new(root){animation-name:stx-r-fade-in}';
3459
+ css+='::view-transition-old(root),::view-transition-new(root){animation:none}::view-transition{background:transparent}';
3460
+ css+='main,#app-content,[data-stx-content]:not([data-stx-screen]){view-transition-name:stx-content}::view-transition-group(stx-content){overflow:hidden}';
3461
+ css+='::view-transition-old(stx-content){animation:stx-r-fade-out '+dur+' '+ease+' both}::view-transition-new(stx-content){animation:stx-r-fade-in '+dur+' '+ease+' both}';
2513
3462
  css+='@keyframes stx-r-fade-out{from{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(-4px)}}';
2514
3463
  css+='@keyframes stx-r-fade-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}';
2515
- css+='@media (prefers-reduced-motion: reduce){::view-transition-old(root),::view-transition-new(root){animation-duration:0s;animation-name:none}}';
3464
+ css+='@media (prefers-reduced-motion: reduce){::view-transition-old(stx-content),::view-transition-new(stx-content){animation-duration:0s;animation-name:none}}';
2516
3465
  }
3466
+ // Screens: a hidden one takes no room, and a screen lays its page out
3467
+ // the way the container would have. Push and pop are iOS's: the new
3468
+ // screen slides in from the right edge while the one under it moves a
3469
+ // third of the way left and dims, over --stx-nav-duration on a spring
3470
+ // (linear() where the browser has it). Reduced motion cross-fades.
3471
+ css+='[data-stx-screen]{display:inherit;flex-direction:inherit;flex-wrap:inherit;gap:inherit;align-items:inherit;justify-content:inherit;grid-template-columns:inherit;grid-template-rows:inherit;grid-column:1/-1;flex:1 1 auto;align-self:stretch;min-width:0}['+HIDDEN+']{display:none!important}';
3472
+ css+=':root{--stx-nav-duration:'+(o.navDuration|0)+'ms;--stx-nav-ease:cubic-bezier(0.32,0.72,0,1)}@supports (animation-timing-function:linear(0,1)){:root{--stx-nav-ease:linear(0,.079,.236,.403,.551,.671,.764,.833,.883,.919,.944,.962,.974,.983,.988,.992,.995,.997,.998,.999,1)}}';
3473
+ css+='[data-stx-animating]{overflow-x:clip}[data-stx-screen-enter],[data-stx-screen-leave]{position:relative;will-change:transform;animation-duration:var(--stx-nav-duration);animation-timing-function:var(--stx-nav-ease);animation-fill-mode:both}';
3474
+ css+='html[data-nav-direction=push] [data-stx-screen-enter]{animation-name:stx-nav-in;z-index:3;box-shadow:0 0 24px rgb(0 0 0 / .12)}html[data-nav-direction=push] [data-stx-screen-leave]{animation-name:stx-nav-out;z-index:1}';
3475
+ css+='html[data-nav-direction=pop] [data-stx-screen-enter]{animation-name:stx-nav-back-in;z-index:1}html[data-nav-direction=pop] [data-stx-screen-leave]{animation-name:stx-nav-back-out;z-index:3;box-shadow:0 0 24px rgb(0 0 0 / .12)}';
3476
+ css+='[data-stx-screen-dim]{position:fixed;top:0;bottom:0;z-index:2;background:rgb(0 0 0 / .1);pointer-events:none;animation:stx-nav-dim var(--stx-nav-duration) var(--stx-nav-ease) both}html[data-nav-direction=pop] [data-stx-screen-dim]{animation-direction:reverse}[data-stx-screen-dim][data-stx-drag]{animation:none}';
3477
+ css+='@keyframes stx-nav-in{from{transform:translateX(100%)}to{transform:none}}@keyframes stx-nav-out{from{transform:none}to{transform:translateX(-30%)}}@keyframes stx-nav-back-in{from{transform:translateX(-30%)}to{transform:none}}@keyframes stx-nav-back-out{from{transform:none}to{transform:translateX(100%)}}@keyframes stx-nav-dim{from{opacity:0}to{opacity:1}}@keyframes stx-nav-fade-in{from{opacity:0}}@keyframes stx-nav-fade-out{to{opacity:0}}';
3478
+ css+='@media (prefers-reduced-motion: reduce){html[data-nav-direction] [data-stx-screen-enter]{animation-name:stx-nav-fade-in;animation-duration:200ms;box-shadow:none}html[data-nav-direction] [data-stx-screen-leave]{animation-name:stx-nav-fade-out;animation-duration:200ms;box-shadow:none}[data-stx-screen-dim]{display:none}}';
3479
+ // The screen a failed load leaves behind (showNavError).
3480
+ css+='.stx-retry{display:flex;flex-direction:column;align-items:center;gap:.5rem;padding:4rem 1.5rem;text-align:center}.stx-retry-title{margin:0;font-weight:600;font-size:1.0625rem}.stx-retry-text{margin:0;opacity:.7;max-width:20rem}.stx-retry-button{margin-top:.75rem;padding:.5rem 1.25rem;border:0;border-radius:999px;background:var(--native-accent,#2563eb);color:#fff;font:inherit;font-weight:600}';
2517
3481
  s.textContent=css;
2518
3482
  dhead().appendChild(s);
2519
3483
  }
@@ -2533,12 +3497,6 @@ else {
2533
3497
  }
2534
3498
  }
2535
3499
 
2536
- function injectViewTransitionCSS(){
2537
- if(document.getElementById('stx-view-transitions'))return;
2538
- var s=ce('style');s.id='stx-view-transitions';
2539
- s.textContent='::view-transition-group(root){animation:none}::view-transition-old(root){animation:none}::view-transition-new(root){animation:none}main,#app-content,[data-stx-content]{view-transition-name:stx-content}::view-transition-old(stx-content){animation:stx-fade-out .15s ease-out both}::view-transition-new(stx-content){animation:stx-fade-in .15s ease-in .1s both}@keyframes stx-fade-out{from{opacity:1}to{opacity:0}}@keyframes stx-fade-in{from{opacity:0}to{opacity:1}}::view-transition{background:transparent}::view-transition-group(stx-content){background:inherit;overflow:hidden}';
2540
- (document.head||document.documentElement).appendChild(s);
2541
- }
2542
3500
 
2543
3501
  // \u2500\u2500 Public API \u2500\u2500
2544
3502
  var router={
@@ -2573,6 +3531,12 @@ else {
2573
3531
  evictCache(cacheKey(url||(location.pathname+location.search)));
2574
3532
  },
2575
3533
  clearCache:function(){for(var k in cache)delete cache[k];for(var lk in layoutCache)delete layoutCache[lk];for(var gk in layoutGroupCache)delete layoutGroupCache[gk];cacheOrder.length=0},
3534
+ // Switch to the tab whose link points at href, as a tap on it would:
3535
+ // its retained screens and its own back stack (selectTab).
3536
+ selectTab:selectTab,
3537
+ back:function(){return history.back()},
3538
+ // The screens kept alive, for a page or a test that wants to look.
3539
+ screens:function(){var out=[];eachEntry(function(e,id){out.push({url:e.url,tab:id,depth:e.depth,live:!!e.el,active:e===active})});return out},
2576
3540
  cache:cache,
2577
3541
  swap:swap,
2578
3542
  updateNav:updateNav
@@ -2586,10 +3550,13 @@ else {
2586
3550
  // \u2500\u2500 Initialize \u2500\u2500
2587
3551
  function init(){
2588
3552
  injectStyles();
2589
- injectViewTransitionCSS();
2590
3553
  refreshCurrentLinks();
3554
+ // The first screen, from the start, so the page never changes shape on
3555
+ // its first navigation.
3556
+ if(screensOn()){var host=getContainer();if(host)adoptInitial(host)}
2591
3557
  restoreKeptPages();
2592
3558
  prefetchEager();
3559
+ observeLinks();
2593
3560
  }
2594
3561
 
2595
3562
  if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
@@ -2599,7 +3566,7 @@ else {
2599
3566
  export function getRouterScript() {
2600
3567
  if (cachedRouterScript)
2601
3568
  return cachedRouterScript;
2602
- cachedRouterScript = minifyRouterScript(stripRouterLogs(routerSource()));
3569
+ cachedRouterScript = minifyRouterScript(stripRouterLogs(routerSource()), !0);
2603
3570
  return cachedRouterScript;
2604
3571
  }
2605
3572
  export function getRouterScriptDev() {