bun-plugin-stx 0.2.405 → 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.
@@ -696,9 +696,9 @@ function stripRouterLogs(source) {
696
696
  }
697
697
  return out.join("");
698
698
  }
699
- function minifyRouterScript(source) {
699
+ function minifyRouterScript(source, mangle = false) {
700
700
  try {
701
- const transpiler = new Bun.Transpiler({ loader: "js", minifyWhitespace: true });
701
+ const transpiler = new Bun.Transpiler(mangle ? { loader: "js", minify: { whitespace: true, identifiers: true } } : { loader: "js", minifyWhitespace: true });
702
702
  return transpiler.transformSync(source).replace(/\}(var |let |const |function )/g, "};$1").trim();
703
703
  } catch {
704
704
  return source;
@@ -712,7 +712,7 @@ function routerSource() {
712
712
  if(window.__stxRouter&&window.__stxRouter.__rev===ROUTER_REV)return;
713
713
 
714
714
  // \u2500\u2500 Configuration \u2500\u2500
715
- 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};
715
+ 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};
716
716
  var o=Object.assign({},defaults,window.__stxRouterConfig||{},window.STX_ROUTER_OPTIONS||{});
717
717
  var containerSel=o.container;
718
718
  var debug=!!o.debug;
@@ -748,6 +748,12 @@ function routerSource() {
748
748
  // (which carries a scroll token too): going back from a pushed entry stays
749
749
  // in the app, from the first it leaves it (or, in a phone app, does nothing).
750
750
  var PUSHED_MARK='__stxPushed';
751
+ // How far above the entry the app opened with this one sits, and which tab
752
+ // it belongs to. A tab switch rewinds to depth 0 before it writes the next
753
+ // tab's entries, which is what keeps Back from ever crossing tabs.
754
+ var DEPTH='__stxDepth';
755
+ var TAB='__stxTab';
756
+ var histDepth=(history.state&&history.state[DEPTH])|0;
751
757
  var scrollSeq=0;
752
758
  var pendingScroll=null;
753
759
  var restoreScroll=!!o.scrollRestoration;
@@ -762,11 +768,44 @@ function routerSource() {
762
768
  return parts.length===2?[parseFloat(parts[0])||0,parseFloat(parts[1])||0]:null;
763
769
  }catch(e){return null}
764
770
  }
771
+ // Inner scrollers, marked data-stx-scroll: a screen whose list scrolls
772
+ // inside its own box rather than the window lost its place on Back exactly
773
+ // the way the window used to. Keyed by the attribute's value, or by order
774
+ // for an unnamed one.
775
+ function scrollerKey(el,i){return el.getAttribute('data-stx-scroll')||('#'+i)}
776
+ function innerScroll(root){
777
+ var m=null;
778
+ if(root&&root.querySelectorAll)root.querySelectorAll('[data-stx-scroll]').forEach(function(el,i){
779
+ if(el.scrollTop||el.scrollLeft){m=m||{};m[scrollerKey(el,i)]=[el.scrollLeft||0,el.scrollTop||0]}
780
+ });
781
+ return m;
782
+ }
783
+ function applyInner(root,m){
784
+ if(!m||!root||!root.querySelectorAll)return;
785
+ root.querySelectorAll('[data-stx-scroll]').forEach(function(el,i){
786
+ var at=m[scrollerKey(el,i)];
787
+ if(at){el.scrollLeft=at[0];el.scrollTop=at[1]}
788
+ });
789
+ }
765
790
  // Called while the outgoing entry is still the current one, so the position
766
791
  // read here belongs to the token being written.
767
792
  function rememberScroll(){
793
+ if(active&&active.el&&!active.el.hasAttribute(HIDDEN))saveScroll(active);
768
794
  if(!restoreScroll)return;
769
- try{window.sessionStorage.setItem(SCROLL_STORE+scrollToken,(window.pageXOffset||window.scrollX||0)+','+(window.pageYOffset||window.scrollY||0))}catch(e){}
795
+ try{
796
+ window.sessionStorage.setItem(SCROLL_STORE+scrollToken,(window.pageXOffset||window.scrollX||0)+','+(window.pageYOffset||window.scrollY||0));
797
+ // Taken as the swap began when there is one: by the time the history
798
+ // is written the outgoing page's scrollers have been replaced.
799
+ var inner=innerSnap!==undefined?innerSnap:innerScroll(activeRoot());
800
+ innerSnap=undefined;
801
+ if(inner)window.sessionStorage.setItem(SCROLL_STORE+scrollToken+':i',JSON.stringify(inner));
802
+ else window.sessionStorage.removeItem(SCROLL_STORE+scrollToken+':i');
803
+ }catch(e){}
804
+ }
805
+ var pendingInner=null;
806
+ var innerSnap;
807
+ function readInner(token){
808
+ try{return JSON.parse(window.sessionStorage.getItem(SCROLL_STORE+token+':i')||'null')}catch(e){return null}
770
809
  }
771
810
  // One place decides where a swap leaves the viewport, so the fragment path
772
811
  // and the whole-document path cannot drift apart.
@@ -775,10 +814,16 @@ function routerSource() {
775
814
  var at=pendingScroll;
776
815
  pendingScroll=null;
777
816
  window.scrollTo({left:at[0],top:at[1],behavior:'instant'});
817
+ // Again once the page has bound: a scroller's content is often drawn
818
+ // by a :for that has not run yet, and a box with nothing in it cannot
819
+ // be scrolled anywhere.
820
+ var inner=pendingInner;pendingInner=null;
821
+ if(inner){applyInner(activeRoot(),inner);setTimeout(function(){applyInner(activeRoot(),inner)},60)}
778
822
  return;
779
823
  }
824
+ pendingInner=null;
780
825
  if(o.scrollToTop&&!hash){window.scrollTo({top:0,behavior:'instant'});return}
781
- if(hash){var el=qs(hash);if(el)el.scrollIntoView({behavior:'smooth'})}
826
+ if(hash){var el=findIn(hash);if(el)el.scrollIntoView({behavior:'smooth'})}
782
827
  }
783
828
  // Keeps whatever another script put in the entry; only adds the token.
784
829
  function stampScrollToken(){
@@ -786,6 +831,7 @@ function routerSource() {
786
831
  var current=history.state;
787
832
  if(current&&typeof current==='object')for(var key in current)state[key]=current[key];
788
833
  state[SCROLL_TOKEN]=scrollToken;
834
+ state[DEPTH]=histDepth;
789
835
  try{history.replaceState(state,'',location.href)}catch(e){}
790
836
  }
791
837
  if(restoreScroll){
@@ -839,9 +885,24 @@ function routerSource() {
839
885
  function isBuildSkew(incoming){
840
886
  return !!(loadedBuild&&incoming&&incoming!==loadedBuild);
841
887
  }
842
- function reloadForSkew(url,incoming){
843
- log('[router] build skew: page is',loadedBuild,'server is',incoming,'\u2014 full navigation');
844
- location.href=url;
888
+ // A navigation the user asked for loads its destination as a document: no
889
+ // fragment of the newer build can be hydrated by this runtime, and a tap is
890
+ // the one moment a page load is expected anyway. Anything else that notices
891
+ // the newer build (a background revalidation, the offline worker) must not
892
+ // reload a screen the user is in the middle of using, so the reload waits
893
+ // until the page is hidden, and a cold start picks the build up regardless.
894
+ var skewDeferred=false;
895
+ function reloadForSkew(url,incoming,background){
896
+ log('[router] build skew: page is',loadedBuild,'server is',incoming,background?'\u2014 reload when hidden':'\u2014 full navigation');
897
+ if(!background){location.href=url;return}
898
+ if(skewDeferred)return;
899
+ skewDeferred=true;
900
+ var reloadHidden=function(){
901
+ if(document.visibilityState!=='hidden')return;
902
+ document.removeEventListener('visibilitychange',reloadHidden);
903
+ location.reload();
904
+ };
905
+ document.addEventListener('visibilitychange',reloadHidden);
845
906
  }
846
907
  // A link marked data-stx-transition="none" swaps without the cross-fade. A
847
908
  // tab bar is the case: iOS switches tabs instantly, and the fade read as a
@@ -988,6 +1049,9 @@ function routerSource() {
988
1049
  }
989
1050
 
990
1051
  var cache={};
1052
+ // When each entry was fetched, so a cache hit knows whether it is worth
1053
+ // checking behind the swap (revalidate).
1054
+ var cacheAt={};
991
1055
  var prefetching={};
992
1056
  var awaitingKey='';
993
1057
  var isNavigating=false;
@@ -1009,6 +1073,7 @@ function routerSource() {
1009
1073
  layoutGroupCache[key]=group;
1010
1074
  titleCache[key]=title||'';
1011
1075
  attrsCache[key]=cattrs||'';
1076
+ cacheAt[key]=Date.now();
1012
1077
  while(cacheOrder.length>o.prefetchCacheMax){
1013
1078
  var oldest=cacheOrder.shift();
1014
1079
  delete cache[oldest];
@@ -1016,6 +1081,7 @@ function routerSource() {
1016
1081
  delete layoutGroupCache[oldest];
1017
1082
  delete titleCache[oldest];
1018
1083
  delete attrsCache[oldest];
1084
+ delete cacheAt[oldest];
1019
1085
  }
1020
1086
  }
1021
1087
  // Read a prefetch response into the shape setCache stores, or null when this
@@ -1119,6 +1185,7 @@ function routerSource() {
1119
1185
  delete layoutGroupCache[key];
1120
1186
  delete titleCache[key];
1121
1187
  delete attrsCache[key];
1188
+ delete cacheAt[key];
1122
1189
  }
1123
1190
 
1124
1191
  // Extract top-level CSS blocks (rules AND @media blocks with nested braces).
@@ -1528,10 +1595,17 @@ function routerSource() {
1528
1595
  // pushes, and the string 'replace' replaces. Threading a fourth argument
1529
1596
  // through swap() to each of the three history sites would have been the
1530
1597
  // alternative (#1807).
1598
+ //
1599
+ // 'tab' is a tab's first screen: it takes the place of the entry the app
1600
+ // opened with (selectTab has rewound to it), under a token of its own so
1601
+ // the tab it replaced keeps its positions.
1602
+ var scrollSaved=false;
1531
1603
  function writeHistory(mode,href){
1532
1604
  // The position belongs to the entry being left, and scrollToken still
1533
- // names it here.
1534
- rememberScroll();
1605
+ // names it here -- unless the screen swap already saved it, before the
1606
+ // outgoing screen was lifted out of the page and the window clamped.
1607
+ if(!scrollSaved)rememberScroll();
1608
+ scrollSaved=false;
1535
1609
  if(mode==='replace'){
1536
1610
  // A replaced entry is still the one it replaces: pushed by the app or
1537
1611
  // the one the app opened with.
@@ -1541,22 +1615,43 @@ function routerSource() {
1541
1615
  return;
1542
1616
  }
1543
1617
  scrollToken=newScrollToken();
1618
+ if(mode==='tab'){histDepth=0;history.replaceState(tokenState(),'',href);return}
1619
+ histDepth++;
1544
1620
  var pushed=tokenState();
1545
1621
  pushed[PUSHED_MARK]=true;
1546
1622
  history.pushState(pushed,'',href);
1547
1623
  }
1548
- function tokenState(){var state={};state[SCROLL_TOKEN]=scrollToken;return state}
1624
+ function tokenState(){var state={};state[SCROLL_TOKEN]=scrollToken;state[DEPTH]=histDepth;if(curTab)state[TAB]=curTab;return state}
1549
1625
 
1550
1626
  // Second arg accepts the legacy pushState boolean OR an options object
1551
1627
  // { replace, instant }. Callers inside this file still pass the boolean.
1552
1628
  // instant is what data-stx-transition="none" is to a link: no cross-fade.
1629
+ //
1630
+ // The newest navigation wins. One already on its way when another starts
1631
+ // (a second tap, Back while a page is still loading) is aborted and its
1632
+ // answer ignored: it used to be the other way round, the later one was
1633
+ // dropped, and a Back during a load left the address bar on the popped
1634
+ // entry while the screen went on to show the page being left.
1635
+ var navSeq=0;
1636
+ var navAbort=null;
1637
+ // What the next swap needs to know about where it is going: set by the
1638
+ // caller right before navigate() (popstate, a tab switch) and carried to
1639
+ // swap() through pendingCtx, the same way pendingContainerAttrs is.
1640
+ var navCtxNext=null;
1641
+ var pendingCtx=null;
1642
+ function routerFetch(url,headers){
1643
+ var init={headers:headers};
1644
+ if(navAbort)init.signal=navAbort.signal;
1645
+ return fetch(url,init);
1646
+ }
1553
1647
  function navigate(url,pushState,force){
1648
+ var ctx=navCtxNext||{};
1649
+ navCtxNext=null;
1554
1650
  if(pushState&&typeof pushState==='object'){if(pushState.instant)instantNext=true;pushState=pushState.replace?'replace':true}
1555
1651
  // Lang-picker passes force=true with an already-localized path (/en/...).
1556
1652
  // Re-localizing would map it back to the *current* locale and no-op.
1557
1653
  if(!force) url=withCurrentLocale(url);
1558
1654
  log('[router] navigate() called:',url,'isNavigating:',isNavigating);
1559
- if(isNavigating)return Promise.resolve(false);
1560
1655
  // Resolve against the current document URL, matching native <a> semantics.
1561
1656
  // With location.origin as the base, any relative href ('?status=resolved',
1562
1657
  // '#anchor', './sibling') resolved to the SITE ROOT \u2014 so filter tabs,
@@ -1566,9 +1661,9 @@ function routerSource() {
1566
1661
 
1567
1662
  if(t.origin!==location.origin){location.href=url;return Promise.resolve(false)}
1568
1663
 
1569
- if(t.pathname===location.pathname&&t.hash){
1664
+ if(t.pathname===location.pathname&&t.hash&&pushState!=='tab'){
1570
1665
  if(pushState!==false)writeHistory(pushState,t.href);
1571
- var el=qs(t.hash);
1666
+ var el=findIn(t.hash);
1572
1667
  if(el)el.scrollIntoView({behavior:'smooth'});
1573
1668
  return Promise.resolve(true);
1574
1669
  }
@@ -1579,7 +1674,7 @@ function routerSource() {
1579
1674
  // entry \u2014 without this allowance, hitting back would early-return
1580
1675
  // and the visible page content would be left frozen on the
1581
1676
  // forward-navigation page.
1582
- if(pushState!==false&&t.href===location.href&&!t.hash&&!force)return Promise.resolve(false);
1677
+ if(pushState!==false&&pushState!=='tab'&&t.href===location.href&&!t.hash&&!force)return Promise.resolve(false);
1583
1678
 
1584
1679
  // Its prefetch is on the way: wait for that answer rather than asking the
1585
1680
  // server a second time. A later tap elsewhere wins.
@@ -1589,11 +1684,17 @@ function routerSource() {
1589
1684
  return prefetching[awaitedKey].then(function(){
1590
1685
  if(awaitingKey!==awaitedKey)return false;
1591
1686
  awaitingKey='';
1687
+ navCtxNext=ctx;
1592
1688
  return navigate(url,pushState,force);
1593
1689
  });
1594
1690
  }
1595
1691
  awaitingKey='';
1596
1692
 
1693
+ if(navAbort){try{navAbort.abort()}catch(e){}}
1694
+ navAbort=typeof AbortController==='function'?new AbortController():null;
1695
+ var id=++navSeq;
1696
+ ctx.id=id;
1697
+ if(!ctx.direction)ctx.direction=pushState==='replace'?'replace':pushState==='tab'?'tab':pushState===false?'pop':'push';
1597
1698
  isNavigating=true;
1598
1699
  instantNav=instantNext;
1599
1700
  instantNext=false;
@@ -1604,7 +1705,22 @@ function routerSource() {
1604
1705
  var targetPath=cacheKey(url);
1605
1706
  var targetHash=t.hash;
1606
1707
 
1607
- function done(){isNavigating=false;document.body.classList.remove(o.loadingClass);finishProgress()}
1708
+ function done(){
1709
+ if(id!==navSeq)return;
1710
+ isNavigating=false;navAbort=null;
1711
+ document.body.classList.remove(o.loadingClass);finishProgress();
1712
+ settleDirection();
1713
+ }
1714
+ function stale(){return id!==navSeq}
1715
+ // A failed fetch keeps the app: the destination shows a way to try again
1716
+ // in place of its content, where a document load used to be the answer
1717
+ // to everything -- offline that load is the offline page, or nothing.
1718
+ function failed(err){
1719
+ if(stale()||(err&&err.name==='AbortError'))return false;
1720
+ console.error('[router] fetch error:',err);
1721
+ return showNavError(url,err,pushState,targetHash,ctx);
1722
+ }
1723
+ function swapWith(html,key){pendingCtx=ctx;return swap(html,key,pushState,targetHash)}
1608
1724
 
1609
1725
  if(o.cache&&cache[targetPath]&&!force){
1610
1726
  // Cache hit \u2192 promote in LRU order so this entry survives eviction
@@ -1613,22 +1729,24 @@ function routerSource() {
1613
1729
  if(checkLayoutChange(layoutCache[targetPath],url,layoutGroupCache[targetPath])){
1614
1730
  // Layout changed \u2014 fetch full page and do full document swap
1615
1731
  log('[router] cache hit but layout changed \u2014 fetching full page');
1616
- return fetch(url,{headers:{'Accept':'text/html'}}).then(function(r){
1617
- if(!r.ok)throw new Error(r.status);
1732
+ return routerFetch(url,{'Accept':'text/html'}).then(function(r){
1733
+ if(!r.ok)throw httpError(r.status);
1618
1734
  return r.text();
1619
1735
  }).then(function(html){
1736
+ if(stale())return false;
1620
1737
  log('[router] full page fetched from cache path, len:',html.length);
1621
1738
  pendingLayoutDecl={layout:layoutCache[targetPath]||'',group:layoutGroupCache[targetPath]||''};
1622
- return swap(html,targetPath,pushState,targetHash);
1623
- }).catch(function(err){
1624
- console.error('[router] full page fetch error:',err);
1625
- location.href=url;
1626
- }).finally(done);
1739
+ return swapWith(html,targetPath);
1740
+ }).catch(failed).finally(done);
1627
1741
  }
1628
1742
  pendingContainerAttrs=attrsCache[targetPath]||'';
1629
1743
  pendingLayoutDecl=null;
1630
- return Promise.resolve(swap(cache[targetPath],targetPath,pushState,targetHash)).then(function(){
1744
+ return Promise.resolve(swapWith(cache[targetPath],targetPath)).then(function(swapped){
1745
+ if(swapped===false)return false;
1631
1746
  applyTitle(titleCache[targetPath]);
1747
+ // Shown from the cache at once; asked again behind it, so a page that
1748
+ // changed since says so (stx:updated) instead of staying stale.
1749
+ revalidate(targetPath,url);
1632
1750
  return true;
1633
1751
  }).finally(done);
1634
1752
  }
@@ -1638,8 +1756,9 @@ else {
1638
1756
  // the page content. Custom app-shell containers need full documents so
1639
1757
  // the router does not inject a <main> fragment into the wrong element.
1640
1758
  var wantsFragment=shouldUseFragmentResponse();
1641
- return fetch(url,{headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}}).then(function(r){
1642
- if(!r.ok)throw new Error(r.status);
1759
+ return routerFetch(url,wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}).then(function(r){
1760
+ if(stale())return null;
1761
+ if(!r.ok)throw httpError(r.status);
1643
1762
  // A route guard answered with a redirect and fetch followed it
1644
1763
  // transparently, so r.ok is the DESTINATION's 200 and this markup
1645
1764
  // belongs to r.url \u2014 not to the path that was requested. Without
@@ -1670,9 +1789,9 @@ else {
1670
1789
  // Layout change? Fetch the FULL page (no X-STX-Router header) and do full document swap
1671
1790
  if(isFragment&&checkLayoutChange(newLayout,url,newGroup)){
1672
1791
  log('[router] layout change \u2014 fetching full page for document swap');
1673
- return fetch(url,{headers:{'Accept':'text/html'}}).then(function(fullRes){
1792
+ return routerFetch(url,{'Accept':'text/html'}).then(function(fullRes){
1674
1793
  log('[router] full page fetched:',fullRes.status,'ok:',fullRes.ok);
1675
- if(!fullRes.ok)throw new Error(fullRes.status);
1794
+ if(!fullRes.ok)throw httpError(fullRes.status);
1676
1795
  return fullRes.text().then(function(html){
1677
1796
  log('[router] full page html length:',html.length);
1678
1797
  return{html:html,isFragment:false,layout:newLayout,layoutGroup:newGroup,title:newTitle};
@@ -1681,23 +1800,51 @@ else {
1681
1800
  }
1682
1801
  return r.text().then(function(html){return{html:html,isFragment:isFragment,layout:newLayout,layoutGroup:newGroup,title:newTitle,containerAttrs:newCAttrs,runtime:newRuntime}});
1683
1802
  }).then(function(result){
1684
- if(!result)return;
1803
+ if(!result||stale())return false;
1685
1804
  if(result.isFragment)result.html=fragmentMarker(result.runtime)+result.html;
1686
1805
  if(o.cache)setCache(targetPath,result.html,result.layout,result.layoutGroup,result.title,result.containerAttrs);
1687
1806
  pendingContainerAttrs=result.isFragment?(result.containerAttrs||''):'';
1688
1807
  pendingLayoutDecl=result.isFragment?null:{layout:result.layout||'',group:result.layoutGroup||''};
1689
- return Promise.resolve(swap(result.html,targetPath,pushState,targetHash)).then(function(){
1808
+ return Promise.resolve(swapWith(result.html,targetPath)).then(function(swapped){
1809
+ if(swapped===false)return false;
1690
1810
  // Fragment swaps carry no <head>; apply the title from the header.
1691
1811
  // Full-document swaps already set document.title from the <title> tag.
1692
1812
  if(result.isFragment)applyTitle(result.title);
1693
1813
  return true;
1694
1814
  });
1695
- }).catch(function(err){
1696
- console.error('[router] fetch error:',err);
1697
- location.href=url;
1698
- }).finally(done);
1815
+ }).catch(failed).finally(done);
1699
1816
  }
1700
1817
  }
1818
+ function httpError(status){var err=new Error(String(status));err.status=status;return err}
1819
+
1820
+ // \u2500\u2500 A screen that could not be loaded \u2500\u2500
1821
+ // Shown in the routed container (in a screen of its own when screens are
1822
+ // retained, so Back returns to the one before it), with the address bar at
1823
+ // the destination, so "Try again" and coming back online both retry it in
1824
+ // place. A page that wants to draw its own cancels stx:navigate-error.
1825
+ function escAttr(v){return String(v).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;')}
1826
+ function showNavError(url,err,pushState,hash,ctx){
1827
+ var status=err&&err.status||0;
1828
+ var offline=!status&&navigator.onLine===false;
1829
+ var ev=new CustomEvent('stx:navigate-error',{cancelable:true,detail:{url:url,status:status,offline:offline,error:err}});
1830
+ window.dispatchEvent(ev);
1831
+ if(ev.defaultPrevented)return false;
1832
+ var title=offline?'You are offline':'This page could not be loaded';
1833
+ 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.';
1834
+ 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>';
1835
+ pendingContainerAttrs='';
1836
+ pendingLayoutDecl=null;
1837
+ pendingCtx=ctx;
1838
+ return Promise.resolve(swap(html,cacheKey(url),pushState,hash)).then(function(){return false});
1839
+ }
1840
+ function retryShown(){var root=activeRoot();var el=root&&root.querySelector?root.querySelector('[data-stx-retry]'):null;return el?el.getAttribute('data-stx-retry'):''}
1841
+ document.addEventListener('click',function(e){
1842
+ var b=e.target&&e.target.closest?e.target.closest('[data-stx-retry-button]'):null;
1843
+ if(!b)return;
1844
+ var holder=b.closest('[data-stx-retry]');
1845
+ if(holder)navigate(holder.getAttribute('data-stx-retry'),'replace',true);
1846
+ });
1847
+ window.addEventListener('online',function(){var u=retryShown();if(u)navigate(u,'replace',true)});
1701
1848
 
1702
1849
  // A full document is only safe to partial-swap into the stx shell if it is
1703
1850
  // itself stx-rendered. Pages served by another engine mounted on the same
@@ -1743,7 +1890,7 @@ else {
1743
1890
 
1744
1891
  function announceRoute(){
1745
1892
  if(o.announceRoute===false)return;
1746
- var c=getContainer();
1893
+ var c=activeRoot();
1747
1894
  var h=c&&c.querySelector?c.querySelector('h1'):null;
1748
1895
  // The heading names the destination better than the title, which often
1749
1896
  // carries a site-name suffix. Falls back to the title, then gives up
@@ -1893,7 +2040,12 @@ else {
1893
2040
  var declaredRuntime=fragMark&&fragMark[1]?fragMark[1]:'';
1894
2041
  if(isFragment)html=html.slice(fragMark[0].length);
1895
2042
  if(!isFragment&&!isStxDocument(html)){log('[router] non-stx document \u2014 full navigation to:',url);location.href=url;return Promise.resolve(false)}
2043
+ var ctx=pendingCtx||{direction:pushState==='replace'?'replace':pushState==='tab'?'tab':pushState===false?'pop':'push'};
2044
+ pendingCtx=null;
1896
2045
  var currentContent=getContainer();
2046
+ // The routed container. currentContent is where the page is written: the
2047
+ // container itself, or with screens retained, the new screen inside it.
2048
+ var host=currentContent;
1897
2049
  log('[router] swap: isFragment='+isFragment+' container='+!!currentContent+' tag='+(currentContent&&currentContent.tagName)+' selector='+containerSel+' htmlLen='+html.length);
1898
2050
  if(!currentContent){log('[router] no container \u2014 falling back');location.href=url;return Promise.resolve(false)}
1899
2051
 
@@ -2010,14 +2162,16 @@ else {
2010
2162
  // Swap content \u2014 apply the destination container's own attributes first
2011
2163
  // so the incoming markup lands in a correctly-laid-out container instead
2012
2164
  // of flashing (or sticking) unstyled.
2013
- applyContainerAttrs(currentContent,pendingContainerAttrs);
2165
+ if(scr)scr.cattrs=pendingContainerAttrs;
2166
+ applyContainerAttrs(host,pendingContainerAttrs);
2014
2167
  currentContent.innerHTML=cleanFrag;
2015
2168
  // Remove old page scripts
2016
2169
  qsa('script[data-stx-page]').forEach(function(s){s.remove()});
2017
2170
  if(pushState!==false)writeHistory(pushState,url+(hash||''));
2171
+ if(scr)screenCommit(scr,url+(hash||''));
2018
2172
  refreshCurrentLinks();
2019
2173
  applyScroll(hash);
2020
- window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url}}));
2174
+ window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url,direction:ctx.direction}}));
2021
2175
  // Before page scripts run, so a page that focuses its own control on
2022
2176
  // mount still wins \u2014 its script executes after this.
2023
2177
  focusAfterNavigation(hash);
@@ -2122,17 +2276,28 @@ else {
2122
2276
  if(fragPending.length)Promise.all(fragPending).then(runFragScripts).catch(runFragScripts);
2123
2277
  else runFragScripts();
2124
2278
  }
2279
+ // With screens retained, the outgoing page is kept (or disposed once it
2280
+ // has animated away) by screenBegin rather than cleaned up here.
2281
+ var scr=null;
2282
+ var useScreens=screensOn();
2125
2283
  function startFragSwap(){
2126
- if(window.stx&&window.stx._cleanupContainer)window.stx._cleanupContainer(currentContent);
2284
+ if(ctx.id&&ctx.id!==navSeq)return Promise.resolve(false);
2285
+ if(pushState!==false)innerSnap=innerScroll(activeRoot());
2286
+ if(useScreens)settleAnimation();
2287
+ else if(window.stx&&window.stx._cleanupContainer)window.stx._cleanupContainer(currentContent);
2127
2288
  return new Promise(function(resolve,reject){
2128
2289
  function completeFragSwap(){
2129
2290
  try{
2291
+ setDirection(ctx.direction);
2292
+ if(useScreens){scr=screenBegin(host,ctx,html,pushState!==false);currentContent=scr.el}
2130
2293
  doFragSwap();
2294
+ if(scr)screenEnd(scr);
2131
2295
  resolve(true);
2132
2296
  }catch(err){reject(err)}
2133
2297
  }
2134
2298
  if(instantNav&&swapRunsInOneTask(html,url)){runInOneTask(completeFragSwap)}
2135
2299
  else if(instantNav&&runInstantSwap(completeFragSwap)){}
2300
+ else if(useScreens){completeFragSwap()}
2136
2301
  else if(runViewTransition(completeFragSwap)){}
2137
2302
  else if(instantNav){completeFragSwap()}
2138
2303
  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)}
@@ -2184,7 +2349,7 @@ else {
2184
2349
  function doSwap(){
2185
2350
  // \u2500\u2500 Swap <head> styles \u2500\u2500
2186
2351
  // Inject new styles FIRST, then remove old to prevent unstyled flash
2187
- var keepIds={'stx-view-transitions':1,'stx-r-css':1};
2352
+ var keepIds={'stx-r-css':1};
2188
2353
  var curStyles=qsa('head style');
2189
2354
  var newStyles=doc.querySelectorAll('head style');
2190
2355
 
@@ -2518,7 +2683,7 @@ else {
2518
2683
  else curRoot.removeAttribute('data-stx-html-attrs');
2519
2684
  }
2520
2685
 
2521
- window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url}}));
2686
+ window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url,direction:ctx.direction}}));
2522
2687
 
2523
2688
  // Execute page scripts FIRST \u2014 they define setup functions and set _latestSetup
2524
2689
  function execScripts(){
@@ -2572,6 +2737,13 @@ else {
2572
2737
  }
2573
2738
 
2574
2739
  function startSwap(){
2740
+ if(ctx.id&&ctx.id!==navSeq)return Promise.resolve(false);
2741
+ if(pushState!==false)innerSnap=innerScroll(activeRoot());
2742
+ setDirection(ctx.direction);
2743
+ // A whole document is written into the container as it is, so retained
2744
+ // screens go first: they would otherwise outlive the page they belong to.
2745
+ collapseScreens();
2746
+ currentContent=getContainer();
2575
2747
  // Clean up existing signals/effects
2576
2748
  if(window.stx&&window.stx._cleanupContainer){
2577
2749
  window.stx._cleanupContainer(currentContent);
@@ -2607,6 +2779,748 @@ else {
2607
2779
  return afterCss(docCssReady,url,startSwap);
2608
2780
  }
2609
2781
 
2782
+ // \u2500\u2500 Screens \u2500\u2500
2783
+ // A phone app keeps the screens it has shown. Going back finds the list
2784
+ // exactly where it was left, and switching tabs finds the other tab as it
2785
+ // was: the same DOM, its scroll, its signals still live. Re-rendering each
2786
+ // of them from the server on every visit is what made an stx app read as a
2787
+ // web page in a shell.
2788
+ //
2789
+ // So with screens on, each page is written into a screen of its own,
2790
+ // <div data-stx-screen>, inside the routed container. Leaving one by a push
2791
+ // or a tab switch hides it instead of disposing it; Back and the tab bar
2792
+ // show it again without running anything. Each tab owns a stack of them,
2793
+ // mirrored by the history entries above the one the app opened with.
2794
+ //
2795
+ // The runtime binds whichever element carries data-stx-content before it
2796
+ // falls back to the router's container, so the screen being shown wears
2797
+ // that marker and a navigation binds the new screen alone, leaving the
2798
+ // retained ones as they are. A container that is itself [data-stx-content]
2799
+ // (the app-shell mode) keeps the old behaviour, as does a page without the
2800
+ // signals runtime, which has nothing to retain.
2801
+ //
2802
+ // On when the page has tab links (data-stx-nav="tab"), or always with
2803
+ // screens:true; never with screens:false.
2804
+ var SCREEN='data-stx-screen';
2805
+ var HIDDEN='data-stx-screen-hidden';
2806
+ var TAB_SEL='[data-stx-nav="tab"],[data-native-tab]';
2807
+ var tabs={};
2808
+ var curTab='';
2809
+ var active=null;
2810
+ var shownSeq=0;
2811
+ var screensLive=false;
2812
+ function screensOn(){
2813
+ if(screensLive)return true;
2814
+ if(o.screens===false||o.screens==='false'||!shouldUseFragmentResponse())return false;
2815
+ var stx=window.stx;
2816
+ if(!stx||typeof stx._cleanupContainer!=='function')return false;
2817
+ var host=getContainer();
2818
+ if(!host||(host.closest&&host.closest('[data-stx-content]')))return false;
2819
+ return o.screens===true||!!qs(TAB_SEL);
2820
+ }
2821
+ // Where the current page lives: its screen, or the container.
2822
+ function activeRoot(){return active&&active.el&&active.el.isConnected?active.el:getContainer()}
2823
+ // A selector looked up in the page on screen first: a retained screen
2824
+ // carries the same ids as the page that rendered it.
2825
+ function findIn(sel){
2826
+ var root=activeRoot(),el=null;
2827
+ try{el=root&&root.querySelector?root.querySelector(sel):null;if(!el)el=qs(sel)}catch(e){}
2828
+ return el;
2829
+ }
2830
+ function stackOf(id){return tabs[id]||(tabs[id]=[])}
2831
+ 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}}
2832
+ function tabId(href){return cacheKey(withCurrentLocale(href))}
2833
+ function tabLinks(){return qsa(TAB_SEL)}
2834
+ // The tab the page on screen belongs to: the one the tab bar has current,
2835
+ // which the sticky nav keeps lit for a screen opened from it.
2836
+ function currentTabId(){
2837
+ var links=tabLinks(),best='',i;
2838
+ for(i=0;i<links.length;i++)if(links[i].hasAttribute('data-stx-nav-current'))return tabId(links[i].getAttribute('href')||'');
2839
+ for(i=0;i<links.length;i++){
2840
+ var st=linkState(links[i].getAttribute('href')||'',links[i].getAttribute('data-stx-active-match'));
2841
+ if(st&&(st.exact||st.matched))return tabId(links[i].getAttribute('href')||'');
2842
+ if(st&&st.active&&!best)best=tabId(links[i].getAttribute('href')||'');
2843
+ }
2844
+ return best;
2845
+ }
2846
+ // Which tab link the bar shows as current. Set by hand on a switch: the
2847
+ // sticky nav would otherwise keep the old tab lit over the other tab's
2848
+ // pushed screen, since neither tab's own URL is on screen.
2849
+ function markTab(id){
2850
+ tabLinks().forEach(function(a){
2851
+ if(tabId(a.getAttribute('href')||'')===id)a.setAttribute('data-stx-nav-current','');
2852
+ else a.removeAttribute('data-stx-nav-current');
2853
+ });
2854
+ }
2855
+ function hostAttrString(host){
2856
+ var out=[];
2857
+ if(host&&host.getAttributeNames)host.getAttributeNames().forEach(function(n){
2858
+ if(n==='data-stx-cattrs'||n==='data-stx-route-focus'||n==='data-stx-animating'||(n==='tabindex'&&host.hasAttribute('data-stx-route-focus')))return;
2859
+ out.push(n+'="'+escAttr(host.getAttribute(n))+'"');
2860
+ });
2861
+ return encodeURIComponent(out.join(' '));
2862
+ }
2863
+ // The page the app opened with becomes the first screen the first time a
2864
+ // screen is needed. Moved, not re-rendered: its bindings stay on its nodes.
2865
+ function adoptInitial(host){
2866
+ if(active&&active.el&&active.el.isConnected)return;
2867
+ screensLive=true;
2868
+ var el=ce('div');
2869
+ el.setAttribute(SCREEN,'');
2870
+ while(host.firstChild)el.appendChild(host.firstChild);
2871
+ host.appendChild(el);
2872
+ if(host.__stx_disposers){el.__stx_disposers=host.__stx_disposers;host.__stx_disposers=null}
2873
+ if(!curTab)curTab=currentTabId();
2874
+ active=newEntry(location.pathname+location.search+location.hash,scrollToken,histDepth);
2875
+ active.el=el;
2876
+ active.cattrs=hostAttrString(host);
2877
+ active.shown=++shownSeq;
2878
+ stackOf(curTab)[histDepth]=active;
2879
+ markActive(el);
2880
+ }
2881
+ function markActive(el){
2882
+ var host=getContainer();
2883
+ if(host)Array.prototype.forEach.call(host.querySelectorAll('['+SCREEN+'][data-stx-content]'),function(other){if(other!==el)other.removeAttribute('data-stx-content')});
2884
+ el.setAttribute('data-stx-content','');
2885
+ el.removeAttribute(HIDDEN);
2886
+ }
2887
+ function saveScroll(e){
2888
+ e.sx=window.pageXOffset||window.scrollX||0;
2889
+ e.sy=window.pageYOffset||window.scrollY||0;
2890
+ e.inner=innerScroll(e.el);
2891
+ }
2892
+ // Everything that is global to the page on screen, kept with it while it is
2893
+ // hidden: its page-level onDestroy callbacks (the runtime runs whatever is
2894
+ // queued on the next stx:load, which would tear a retained page down), its
2895
+ // setup, its server data and its route params.
2896
+ function saveEntry(e){
2897
+ var stx=window.stx||{};
2898
+ e.title=document.title;
2899
+ e.setup=stx._latestSetup;
2900
+ e.data=window.__STX_DATA__;
2901
+ e.config=window.__STX_RUNTIME_CONFIG__;
2902
+ e.params=stx._rp||window.__stx_rp||{};
2903
+ if(stx._destroyCallbacks)e.destroys=e.destroys.concat(stx._destroyCallbacks.splice(0));
2904
+ }
2905
+ function restoreEntry(e){
2906
+ var stx=window.stx||{};
2907
+ if(e.title)document.title=e.title;
2908
+ if(e.setup)stx._latestSetup=e.setup;
2909
+ window.__STX_DATA__=e.data||{};
2910
+ window.__STX_RUNTIME_CONFIG__=e.config||{};
2911
+ if(stx._destroyCallbacks&&e.destroys.length)Array.prototype.push.apply(stx._destroyCallbacks,e.destroys);
2912
+ e.destroys=[];
2913
+ applyContainerAttrs(getContainer(),e.cattrs);
2914
+ }
2915
+ // A new page must not share the outgoing page's scope object, or its setup
2916
+ // merges into the one the retained screen's bindings read. Cleaning an
2917
+ // empty element resets that scope and nothing else.
2918
+ function resetScope(){
2919
+ var stx=window.stx;
2920
+ if(!stx||!stx._cleanupContainer)return;
2921
+ var keep=stx._mountCallbacks?stx._mountCallbacks.splice(0):[];
2922
+ stx._cleanupContainer(ce('div'));
2923
+ if(stx._mountCallbacks)Array.prototype.push.apply(stx._mountCallbacks,keep);
2924
+ }
2925
+ function hideScreen(e){
2926
+ if(!e||!e.el)return;
2927
+ unfreeze(e.el);
2928
+ e.el.setAttribute(HIDDEN,'');
2929
+ e.el.removeAttribute('data-stx-content');
2930
+ }
2931
+ // Disposal runs the same paths a navigation always has: the runtime's
2932
+ // container cleanup (effects, scopes, element hooks) and the page-level
2933
+ // onDestroy callbacks that were put aside when the screen was hidden.
2934
+ // Pending mount callbacks belong to whatever is arriving, so they survive.
2935
+ function disposeEntry(e){
2936
+ if(!e||!e.el)return;
2937
+ var el=e.el,stx=window.stx;
2938
+ e.el=null;
2939
+ if(e===active)saveEntry(e);
2940
+ if(stx&&stx._cleanupContainer){
2941
+ var keep=stx._mountCallbacks?stx._mountCallbacks.splice(0):[];
2942
+ try{stx._cleanupContainer(el)}catch(err){console.error('[router] screen cleanup failed:',err)}
2943
+ if(stx._mountCallbacks)Array.prototype.push.apply(stx._mountCallbacks,keep);
2944
+ }
2945
+ var fns=e.destroys;e.destroys=[];
2946
+ fns.forEach(function(fn){try{fn()}catch(err){console.warn('[stx] destroy callback error:',err)}});
2947
+ if(el.parentNode)el.parentNode.removeChild(el);
2948
+ }
2949
+ function eachEntry(fn){for(var id in tabs)tabs[id].forEach(function(e){if(e)fn(e,id)})}
2950
+ // Keep at most screenLimit screens alive, the least recently shown going
2951
+ // first. An evicted screen stays in its stack as an address: Back to it
2952
+ // loads it again.
2953
+ function enforceLimit(){
2954
+ var live=[];
2955
+ eachEntry(function(e){if(e.el&&e!==active&&!e.leaving)live.push(e)});
2956
+ live.sort(function(a,b){return a.shown-b.shown});
2957
+ while(live.length+1>Math.max(1,o.screenLimit|0))disposeEntry(live.shift());
2958
+ }
2959
+ // Everything back to the plain container: before a whole-document swap and
2960
+ // when the screens are given up.
2961
+ // The screen on screen is left in place: the swap cleans the container,
2962
+ // and it with it, as it always has.
2963
+ function collapseScreens(){
2964
+ if(!screensLive)return;
2965
+ settleAnimation();
2966
+ eachEntry(function(e){if(e!==active)disposeEntry(e)});
2967
+ tabs={};active=null;screensLive=false;
2968
+ }
2969
+ // Component scopes are keyed by ids the server derives from the page, so a
2970
+ // second visit to the same page, pushed over a retained first one, brings
2971
+ // the same ids. The retained copies are renamed out of the way, registry
2972
+ // entry and all, so each instance keeps its own and disposing one cannot
2973
+ // reach the other.
2974
+ var retagSeq=0;
2975
+ function retagCollisions(html){
2976
+ var ids={},any=false;
2977
+ html.replace(/data-stx-scope="([^"]+)"/g,function(m,id){ids[id]=1;any=true;return m});
2978
+ if(!any)return;
2979
+ var scopes=window.stx&&window.stx._scopes;
2980
+ eachEntry(function(e){
2981
+ if(!e.el)return;
2982
+ Array.prototype.forEach.call(e.el.querySelectorAll('[data-stx-scope]'),function(node){
2983
+ var id=node.getAttribute('data-stx-scope');
2984
+ if(!ids[id])return;
2985
+ var next=id+'-r'+(++retagSeq);
2986
+ node.setAttribute('data-stx-scope',next);
2987
+ if(scopes&&scopes[id]&&(!scopes[id].__el||scopes[id].__el===node)){scopes[next]=scopes[id];delete scopes[id]}
2988
+ });
2989
+ });
2990
+ }
2991
+
2992
+ // The swap's half: before the page is written, the outgoing screen is put
2993
+ // aside (lifted out of the page for a slide, or hidden), and a new screen
2994
+ // is made for the page to go into, first in the container so that a lookup
2995
+ // by id finds the page on screen before any retained copy of it.
2996
+ function screenBegin(host,ctx,html,writes){
2997
+ adoptInitial(host);
2998
+ var from=active;
2999
+ retagCollisions(html);
3000
+ if(from&&from.el){
3001
+ // A popstate saved the outgoing position already, under the token it
3002
+ // has since replaced; saving again here would file it under the wrong one.
3003
+ if(writes){rememberScroll();scrollSaved=true}
3004
+ saveEntry(from);
3005
+ }
3006
+ resetScope();
3007
+ var el=ce('div');
3008
+ el.setAttribute(SCREEN,'');
3009
+ var anim=!!(from&&from.el&&canAnimate(ctx.direction));
3010
+ if(anim)freeze(from.el);
3011
+ else if(from&&from.el)hideScreen(from);
3012
+ host.insertBefore(el,host.firstChild);
3013
+ markActive(el);
3014
+ return{el:el,from:from,ctx:ctx,anim:anim,cattrs:''};
3015
+ }
3016
+ // After the history write, so the entry knows its token and depth.
3017
+ function screenCommit(scr,href){
3018
+ var e=scr.ctx.entry||newEntry(href,scrollToken,histDepth);
3019
+ e.url=href;e.key=cacheKey(href);e.token=scrollToken;e.depth=histDepth;e.tab=curTab;
3020
+ e.el=scr.el;e.cattrs=scr.cattrs;e.shown=++shownSeq;e.destroys=[];
3021
+ var stack=stackOf(curTab);
3022
+ // A push drops whatever was forward of it; a pop or a replacement takes
3023
+ // the place of what was at its depth. The outgoing screen is left to the
3024
+ // transition, which disposes it once it is off screen.
3025
+ for(var i=e.depth;i<stack.length;i++){var x=stack[i];if(x&&x!==e&&x!==scr.from)disposeEntry(x)}
3026
+ stack.length=e.depth;
3027
+ stack[e.depth]=e;
3028
+ active=e;
3029
+ scrollSaved=false;
3030
+ }
3031
+ function screenEnd(scr){
3032
+ var from=scr.from,dir=scr.ctx.direction;
3033
+ if(!from||!from.el){finishScreen();return}
3034
+ // Retained: left by a push, or by a tab switch. Popped or replaced: gone.
3035
+ var keep=dir==='push'||dir==='tab';
3036
+ var stack=tabs[from.tab];
3037
+ if(keep&&!(stack&&stack[from.depth]===from))keep=false;
3038
+ from.leaving=true;
3039
+ var after=function(){
3040
+ from.leaving=false;
3041
+ if(keep&&from.el)hideScreen(from);
3042
+ else disposeEntry(from);
3043
+ finishScreen();
3044
+ };
3045
+ if(scr.anim)animateScreens(from.el,scr.el,dir,after);
3046
+ else after();
3047
+ }
3048
+ function finishScreen(){
3049
+ enforceLimit();
3050
+ observeLinks();
3051
+ }
3052
+
3053
+ // Show a screen that is already here: Back to one below in the stack, or a
3054
+ // tab's top screen. Nothing is fetched and no page script runs; the page is
3055
+ // told it is on screen again (stx:screen-shown) so it can refresh whatever
3056
+ // may have gone stale while it was hidden.
3057
+ function showRetained(to,dir,opts){
3058
+ opts=opts||{};
3059
+ settleAnimation();
3060
+ var from=active,host=getContainer();
3061
+ if(navAbort){try{navAbort.abort()}catch(e){}navAbort=null}
3062
+ navSeq++;
3063
+ isNavigating=false;
3064
+ document.body.classList.remove(o.loadingClass);
3065
+ if(from&&from.el){if(!opts.settled)saveScroll(from);saveEntry(from)}
3066
+ setDirection(dir);
3067
+ var anim=!opts.settled&&from&&from.el&&from!==to&&canAnimate(dir,true);
3068
+ if(anim)freeze(from.el);
3069
+ else if(from&&from.el&&from!==to&&!opts.settled)hideScreen(from);
3070
+ unfreeze(to.el);
3071
+ markActive(to.el);
3072
+ active=to;
3073
+ to.shown=++shownSeq;
3074
+ restoreEntry(to);
3075
+ pendingScroll=null;pendingInner=null;
3076
+ window.scrollTo({left:to.sx,top:to.sy,behavior:'instant'});
3077
+ applyInner(to.el,to.inner);
3078
+ if(opts.settled&&from&&from!==to&&from.el)hideScreen(from);
3079
+ refreshCurrentLinks();
3080
+ window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:to.url,direction:dir,retained:true,params:to.params||{}}}));
3081
+ focusAfterNavigation('');
3082
+ setTimeout(announceRoute,0);
3083
+ window.dispatchEvent(new CustomEvent('stx:screen-shown',{detail:{url:to.url,direction:dir}}));
3084
+ revalidate(to.key,to.url);
3085
+ var keep=dir==='push'||dir==='tab';
3086
+ var after=function(){
3087
+ if(from&&from!==to){
3088
+ if(dir==='pop'){
3089
+ // Everything above the screen shown is gone from the stack.
3090
+ var stack=tabs[to.tab]||[];
3091
+ for(var i=to.depth+1;i<stack.length;i++)if(stack[i]&&stack[i]!==from)disposeEntry(stack[i]);
3092
+ stack.length=to.depth+1;
3093
+ }
3094
+ if(keep&&from.el)hideScreen(from);
3095
+ else if(!keep)disposeEntry(from);
3096
+ }
3097
+ finishScreen();
3098
+ settleDirection();
3099
+ };
3100
+ if(anim){from.leaving=true;animateScreens(from.el,to.el,dir,function(){from.leaving=false;after()})}
3101
+ else after();
3102
+ return true;
3103
+ }
3104
+
3105
+ // \u2500\u2500 Direction and motion \u2500\u2500
3106
+ // data-nav-direction on <html> says what kind of move is under way --
3107
+ // push, pop, tab or replace -- for as long as it is, so CSS (and a page's
3108
+ // own transitions) can tell a step forward from a step back.
3109
+ var animEnd=null;
3110
+ function setDirection(d){if(d)document.documentElement.setAttribute('data-nav-direction',d)}
3111
+ function settleDirection(){
3112
+ if(isNavigating||animEnd)return;
3113
+ document.documentElement.removeAttribute('data-nav-direction');
3114
+ }
3115
+ function reducedMotion(){
3116
+ try{return !!(window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches)}catch(e){return false}
3117
+ }
3118
+ // instantNav belongs to a navigation that fetched; a screen shown from
3119
+ // the stack (Back) is not one, and must not inherit the last tab tap's.
3120
+ function canAnimate(dir,shown){
3121
+ return (dir==='push'||dir==='pop')&&(shown||!instantNav)&&o.navDuration>0&&typeof window.getComputedStyle==='function';
3122
+ }
3123
+ // The first background behind the container that is not transparent: the
3124
+ // screens are transparent, and two of them sliding over each other need
3125
+ // the page's colour to hide the one underneath.
3126
+ function screenBackground(host){
3127
+ for(var n=host;n&&n.nodeType===1;n=n.parentNode){
3128
+ var c='';
3129
+ try{c=getComputedStyle(n).backgroundColor}catch(e){}
3130
+ if(c&&c!=='transparent'&&c!=='rgba(0, 0, 0, 0)')return c;
3131
+ }
3132
+ return '';
3133
+ }
3134
+ // Lifted out of the flow and pinned where it is on screen, as a box the
3135
+ // height of the viewport scrolled to the same place: the page can scroll
3136
+ // to wherever the next screen wants, the outgoing one does not move, and a
3137
+ // sticky header inside it stays stuck.
3138
+ function freeze(el,top){
3139
+ var r=el.getBoundingClientRect();
3140
+ var vh=window.innerHeight||document.documentElement.clientHeight||0;
3141
+ var at=top===undefined?r.top:top;
3142
+ var y=Math.max(at,0);
3143
+ var s=el.style;
3144
+ 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';
3145
+ el.scrollTop=y-at;
3146
+ }
3147
+ function unfreeze(el){
3148
+ if(!el)return;
3149
+ var s=el.style;
3150
+ ['position','top','left','width','height','overflow','margin','transform','transition','zIndex','backgroundColor','pointerEvents'].forEach(function(k){s[k]=''});
3151
+ el.removeAttribute('data-stx-screen-enter');
3152
+ el.removeAttribute('data-stx-screen-leave');
3153
+ el.scrollTop=0;
3154
+ }
3155
+ function parseDuration(v){
3156
+ v=String(v||'').split(',')[0].trim();
3157
+ var n=parseFloat(v);
3158
+ if(!(n>=0))return -1;
3159
+ return v.indexOf('ms')>0?n:n*1000;
3160
+ }
3161
+ function dimLayer(host){
3162
+ var r=host.getBoundingClientRect();
3163
+ var dim=ce('div');
3164
+ dim.setAttribute('data-stx-screen-dim','');
3165
+ dim.style.left=r.left+'px';
3166
+ dim.style.width=r.width+'px';
3167
+ return dim;
3168
+ }
3169
+ // Two real screens, animated by the shipped CSS: the one arriving and the
3170
+ // one leaving, and a dim between them over the one underneath.
3171
+ function animateScreens(fromEl,toEl,dir,done){
3172
+ var host=getContainer();
3173
+ var bg=screenBackground(host);
3174
+ var dim=dimLayer(host);
3175
+ host.setAttribute('data-stx-animating','');
3176
+ fromEl.style.backgroundColor=bg;toEl.style.backgroundColor=bg;
3177
+ fromEl.style.pointerEvents='none';
3178
+ fromEl.setAttribute('data-stx-screen-leave','');
3179
+ toEl.setAttribute('data-stx-screen-enter','');
3180
+ host.appendChild(dim);
3181
+ var ms=-1;
3182
+ try{ms=parseDuration(getComputedStyle(toEl).animationDuration)}catch(e){}
3183
+ if(!(ms>0))ms=o.navDuration;
3184
+ var timer=setTimeout(end,ms+120);
3185
+ function onEnd(e){if(e.target===toEl)end()}
3186
+ toEl.addEventListener('animationend',onEnd);
3187
+ function end(){
3188
+ if(animEnd!==end)return;
3189
+ animEnd=null;
3190
+ clearTimeout(timer);
3191
+ toEl.removeEventListener('animationend',onEnd);
3192
+ if(dim.parentNode)dim.parentNode.removeChild(dim);
3193
+ host.removeAttribute('data-stx-animating');
3194
+ toEl.removeAttribute('data-stx-screen-enter');
3195
+ toEl.style.backgroundColor='';
3196
+ done();
3197
+ settleDirection();
3198
+ }
3199
+ animEnd=end;
3200
+ }
3201
+ // A navigation that starts while a slide is still running finishes the
3202
+ // slide first, rather than lifting a screen that is halfway across.
3203
+ function settleAnimation(){if(animEnd)animEnd()}
3204
+
3205
+ // \u2500\u2500 Tabs \u2500\u2500
3206
+ // Back never crosses tabs: the history above the entry the app opened with
3207
+ // is always the current tab's stack. A switch rewinds to that entry, writes
3208
+ // the target tab's stack in its place, and shows its top screen exactly as
3209
+ // it was left.
3210
+ var rewinding=null;
3211
+ function rewind(){
3212
+ if(histDepth<=0)return Promise.resolve();
3213
+ return new Promise(function(resolve){
3214
+ var n=histDepth;
3215
+ var timer=setTimeout(finish,700);
3216
+ function finish(){
3217
+ clearTimeout(timer);
3218
+ if(rewinding===finish)rewinding=null;
3219
+ histDepth=(history.state&&history.state[DEPTH])|0;
3220
+ var token=history.state&&history.state[SCROLL_TOKEN];
3221
+ if(token)scrollToken=token;
3222
+ resolve();
3223
+ }
3224
+ rewinding=finish;
3225
+ history.go(-n);
3226
+ });
3227
+ }
3228
+ function entryState(e,pushed){
3229
+ var st={};
3230
+ st[SCROLL_TOKEN]=e.token;st[DEPTH]=e.depth;st[TAB]=e.tab;
3231
+ if(pushed)st[PUSHED_MARK]=true;
3232
+ return st;
3233
+ }
3234
+ function selectTab(href){
3235
+ if(!href)return Promise.resolve(false);
3236
+ var id=tabId(href);
3237
+ var on=screensOn();
3238
+ if(on&&!screensLive)adoptInitial(getContainer());
3239
+ if(!curTab)curTab=on&&active?active.tab:currentTabId();
3240
+ if(id===curTab)return reselectTab(href);
3241
+ if(active&&active.el){rememberScroll();saveScroll(active)}
3242
+ settleAnimation();
3243
+ if(navAbort){try{navAbort.abort()}catch(e){}navAbort=null}
3244
+ var seq=++navSeq;
3245
+ return rewind().then(function(){
3246
+ if(seq!==navSeq)return false;
3247
+ curTab=id;
3248
+ markTab(id);
3249
+ var stack=on?(tabs[id]||[]).filter(Boolean):[];
3250
+ if(!stack.length){
3251
+ if(on)tabs[id]=[];
3252
+ navCtxNext={direction:'tab'};
3253
+ instantNext=true;
3254
+ return navigate(href,'tab');
3255
+ }
3256
+ // Rewritten in order, so Back walks this tab's own stack.
3257
+ stack.forEach(function(e,i){
3258
+ e.depth=i;e.tab=id;
3259
+ if(i===0)history.replaceState(entryState(e,false),'',e.url);
3260
+ else history.pushState(entryState(e,true),'',e.url);
3261
+ });
3262
+ tabs[id]=stack;
3263
+ var top=stack[stack.length-1];
3264
+ histDepth=top.depth;
3265
+ scrollToken=top.token;
3266
+ if(top.el)return showRetained(top,'tab');
3267
+ // Its top screen was let go: load it into the same entry.
3268
+ pendingScroll=[top.sx,top.sy];
3269
+ navCtxNext={direction:'tab',entry:top};
3270
+ instantNext=true;
3271
+ return navigate(top.url,false);
3272
+ });
3273
+ }
3274
+ // The tab that is already current, selected again: back to the top of its
3275
+ // screen, and when it is there already, back to the tab's first screen.
3276
+ // stx:tabreselect says which, and a page that cancels it keeps its own.
3277
+ function reselectTab(href){
3278
+ var root=activeRoot();
3279
+ var y=window.pageYOffset||window.scrollY||0;
3280
+ var inner=innerScroll(root);
3281
+ var atRoot=active?active.depth===0&&active.key===tabId(href):cacheKey(location.href)===tabId(href);
3282
+ var action=(y>0||inner)?'top':atRoot?'none':'root';
3283
+ var ev=new CustomEvent('stx:tabreselect',{cancelable:true,detail:{href:href,action:action}});
3284
+ window.dispatchEvent(ev);
3285
+ if(ev.defaultPrevented||action==='none')return Promise.resolve(false);
3286
+ if(action==='top'){
3287
+ window.scrollTo({top:0,left:0,behavior:reducedMotion()?'instant':'smooth'});
3288
+ 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});
3289
+ return Promise.resolve(true);
3290
+ }
3291
+ var stack=tabs[curTab]||[];
3292
+ var first=stack.filter(Boolean)[0];
3293
+ if(first&&first.el&&first.key===tabId(href)&&histDepth>0){
3294
+ var target=first;
3295
+ return new Promise(function(resolve){
3296
+ history.go(target.depth-histDepth);
3297
+ setTimeout(function(){resolve(true)},0);
3298
+ });
3299
+ }
3300
+ // The tab's first screen is not here (opened on a deep link, or let
3301
+ // go): load the tab's own page in its place.
3302
+ return rewind().then(function(){
3303
+ navCtxNext={direction:'pop'};
3304
+ return navigate(href,'tab');
3305
+ });
3306
+ }
3307
+
3308
+ // \u2500\u2500 Swipe back \u2500\u2500
3309
+ // A pan from the left edge drags the screen on top away and shows the
3310
+ // retained one underneath, the way iOS does: the one underneath moves from
3311
+ // -30% and brightens as the top one goes. Released past half way, or
3312
+ // flicked, it completes as a Back (history.back(), shown without a second
3313
+ // animation); otherwise it springs back. Only where there is a screen to
3314
+ // go back to in this tab, never from inside data-stx-no-swipe, and never
3315
+ // over something that pans sideways on its own (touch-action none, pan-x,
3316
+ // pan-y, or a horizontal scroller that is not at its start).
3317
+ var swipe=null;
3318
+ var swipeSettled=null;
3319
+ function swipePrev(){
3320
+ if(o.swipeBack===false||!screensLive||!active||!active.el||isNavigating||animEnd)return null;
3321
+ var prev=(tabs[curTab]||[])[active.depth-1];
3322
+ return prev&&prev.el?prev:null;
3323
+ }
3324
+ function swipeBlocked(target){
3325
+ var host=getContainer();
3326
+ for(var n=target;n&&n.nodeType===1&&n!==host;n=n.parentNode){
3327
+ if(n.hasAttribute('data-stx-no-swipe'))return true;
3328
+ var cs=null;
3329
+ try{cs=getComputedStyle(n)}catch(e){}
3330
+ if(!cs)continue;
3331
+ var ta=String(cs.touchAction||'').split(' ');
3332
+ var has=function(v){return ta.indexOf(v)!==-1};
3333
+ if(has('none')||(has('pan-x')||has('pan-left')||has('pan-right'))!==(has('pan-y')||has('pan-up')||has('pan-down')))return true;
3334
+ var ox=String(cs.overflowX||'');
3335
+ if((ox==='auto'||ox==='scroll')&&n.scrollWidth>n.clientWidth&&n.scrollLeft>0)return true;
3336
+ }
3337
+ return false;
3338
+ }
3339
+ function now(){return window.performance&&performance.now?performance.now():Date.now()}
3340
+ function touchOf(e){return (e.touches&&e.touches[0])||(e.changedTouches&&e.changedTouches[0])||null}
3341
+ function swipeStart(e){
3342
+ if(swipe||!e.touches||e.touches.length!==1)return;
3343
+ var t=e.touches[0];
3344
+ if(t.clientX>o.swipeEdge)return;
3345
+ var prev=swipePrev();
3346
+ if(!prev||swipeBlocked(e.target))return;
3347
+ swipe={prev:prev,x0:t.clientX,y0:t.clientY,x:t.clientX,t:now(),v:0,on:false};
3348
+ }
3349
+ function swipeBegin(){
3350
+ var cur=active,prev=swipe.prev,host=getContainer();
3351
+ var w=host.getBoundingClientRect().width||window.innerWidth||1;
3352
+ saveScroll(cur);
3353
+ var docTop=cur.el.getBoundingClientRect().top+(window.pageYOffset||window.scrollY||0);
3354
+ swipe.w=w;
3355
+ swipe.cur=cur;
3356
+ swipe.bg=screenBackground(host);
3357
+ swipe.dim=dimLayer(host);
3358
+ swipe.dim.setAttribute('data-stx-drag','');
3359
+ // Shown before it is measured: a hidden screen has no box to pin.
3360
+ prev.el.removeAttribute(HIDDEN);
3361
+ freeze(prev.el,docTop-prev.sy);
3362
+ applyInner(prev.el,prev.inner);
3363
+ prev.el.style.zIndex='1';
3364
+ prev.el.style.backgroundColor=swipe.bg;
3365
+ cur.el.style.position='relative';
3366
+ cur.el.style.zIndex='3';
3367
+ cur.el.style.backgroundColor=swipe.bg;
3368
+ host.setAttribute('data-stx-animating','');
3369
+ host.appendChild(swipe.dim);
3370
+ setDirection('pop');
3371
+ swipe.on=true;
3372
+ }
3373
+ function swipeDraw(dx){
3374
+ var p=Math.min(Math.max(dx/swipe.w,0),1);
3375
+ swipe.p=p;
3376
+ swipe.cur.el.style.transform='translateX('+(p*swipe.w)+'px)';
3377
+ swipe.prev.el.style.transform='translateX('+(-0.3*swipe.w*(1-p))+'px)';
3378
+ swipe.dim.style.opacity=String(1-p);
3379
+ }
3380
+ function swipeMove(e){
3381
+ if(!swipe)return;
3382
+ var t=touchOf(e);
3383
+ if(!t)return;
3384
+ var dx=t.clientX-swipe.x0,dy=t.clientY-swipe.y0;
3385
+ if(!swipe.on){
3386
+ if(Math.abs(dx)<8&&Math.abs(dy)<8)return;
3387
+ // Vertical first, or leftwards: a scroll, not a Back.
3388
+ if(dx<=0||Math.abs(dy)>=Math.abs(dx)){swipe=null;return}
3389
+ swipeBegin();
3390
+ }
3391
+ if(e.cancelable)e.preventDefault();
3392
+ var n=now(),dt=n-swipe.t;
3393
+ if(dt>0)swipe.v=swipe.v*0.2+((t.clientX-swipe.x)/dt)*0.8;
3394
+ swipe.x=t.clientX;swipe.t=n;
3395
+ swipeDraw(dx);
3396
+ }
3397
+ function swipeEnd(e){
3398
+ if(!swipe)return;
3399
+ var s=swipe;
3400
+ swipe=null;
3401
+ if(!s.on)return;
3402
+ // A finger held still before lifting has no speed left.
3403
+ if(now()-s.t>80)s.v=0;
3404
+ var commit=e.type!=='touchcancel'&&(s.p>0.5||s.v>0.5);
3405
+ var remaining=commit?(1-s.p)*s.w:s.p*s.w;
3406
+ var ms=Math.round(Math.min(o.navDuration,Math.max(120,remaining/Math.max(Math.abs(s.v),0.6))));
3407
+ var ease='cubic-bezier(0.2, 0.9, 0.3, 1)';
3408
+ [s.cur.el,s.prev.el].forEach(function(el){el.style.transition='transform '+ms+'ms '+ease});
3409
+ s.dim.style.transition='opacity '+ms+'ms '+ease;
3410
+ s.cur.el.style.transform=commit?'translateX('+s.w+'px)':'translateX(0px)';
3411
+ s.prev.el.style.transform=commit?'translateX(0px)':'translateX('+(-0.3*s.w)+'px)';
3412
+ s.dim.style.opacity=commit?'0':'1';
3413
+ var finished=false;
3414
+ var finish=function(){
3415
+ if(finished)return;
3416
+ finished=true;
3417
+ animEnd=null;
3418
+ if(s.dim.parentNode)s.dim.parentNode.removeChild(s.dim);
3419
+ getContainer().removeAttribute('data-stx-animating');
3420
+ if(commit){
3421
+ // Already where Back would put it: the popstate that follows shows
3422
+ // the screen underneath as it is, with no second animation.
3423
+ s.cur.el.style.visibility='hidden';
3424
+ swipeSettled=s.prev;
3425
+ history.back();
3426
+ }
3427
+ else{
3428
+ hideScreen(s.prev);
3429
+ 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='';
3430
+ settleDirection();
3431
+ }
3432
+ };
3433
+ animEnd=finish;
3434
+ setTimeout(finish,ms+40);
3435
+ }
3436
+ document.addEventListener('touchstart',swipeStart,{capture:true,passive:true});
3437
+ document.addEventListener('touchmove',swipeMove,{capture:true,passive:false});
3438
+ document.addEventListener('touchend',swipeEnd,{capture:true,passive:true});
3439
+ document.addEventListener('touchcancel',swipeEnd,{capture:true,passive:true});
3440
+ // The system is short of memory: let go of every hidden screen except the
3441
+ // one a swipe back would show.
3442
+ window.addEventListener('craftMemoryWarning',function(){
3443
+ var keep=active?(tabs[curTab]||[])[active.depth-1]:null;
3444
+ eachEntry(function(e){if(e!==active&&e!==keep&&!e.leaving)disposeEntry(e)});
3445
+ });
3446
+
3447
+ // \u2500\u2500 Revalidation and updates \u2500\u2500
3448
+ // A page served from the router's cache, or a screen shown again, is asked
3449
+ // for once more behind the swap. If it changed, the cache takes the new copy
3450
+ // and stx:updated says so, so the page can refresh what it shows; the
3451
+ // screen is never swapped out from under the user.
3452
+ function revalidate(key,url){
3453
+ if(!o.revalidate||!o.cache||!key||prefetching[key])return;
3454
+ if(Date.now()-(cacheAt[key]||0)<o.revalidateAfter)return;
3455
+ var wantsFragment=shouldUseFragmentResponse();
3456
+ var before=cache[key];
3457
+ prefetching[key]=fetch(url,{headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}}).then(function(r){
3458
+ var incoming=r.headers.get('X-STX-Build')||'';
3459
+ if(isBuildSkew(incoming)){reloadForSkew(url,incoming,true);return null}
3460
+ return readPrefetchResponse(r,wantsFragment);
3461
+ }).then(function(result){
3462
+ if(!result)return;
3463
+ if(o.cache)setCache(key,result.html,result.layout,result.layoutGroup,result.title,result.containerAttrs);
3464
+ if(before!==undefined&&before!==result.html&&cacheKey(location.href)===key)emitUpdated(url,'page');
3465
+ }).catch(function(){}).finally(function(){delete prefetching[key]});
3466
+ }
3467
+ // The offline worker answered from its cache and found something newer
3468
+ // behind it. It posts { type: 'stx:updated', url, kind }, which its own
3469
+ // register script turns into a window event; the router does that only
3470
+ // where that script is not on the page, so the event is heard once. Either
3471
+ // way a page the router has cached is dropped, so the next visit gets the
3472
+ // new one.
3473
+ var ownUpdate=false;
3474
+ function emitUpdated(url,kind){
3475
+ ownUpdate=true;
3476
+ try{window.dispatchEvent(new CustomEvent('stx:updated',{detail:{url:url,kind:kind||'page'}}))}finally{ownUpdate=false}
3477
+ }
3478
+ window.addEventListener('stx:updated',function(e){
3479
+ var d=e&&e.detail;
3480
+ if(ownUpdate||!d||!d.url||d.kind==='api')return;
3481
+ try{evictCache(cacheKey(d.url))}catch(err){}
3482
+ });
3483
+ try{
3484
+ var sw=navigator.serviceWorker;
3485
+ if(sw&&sw.addEventListener)sw.addEventListener('message',function(e){
3486
+ var d=e&&e.data;
3487
+ if(!d||d.type!=='stx:updated'||window.stxOffline)return;
3488
+ window.dispatchEvent(new CustomEvent('stx:updated',{detail:{url:d.url,kind:d.kind||'page'}}));
3489
+ });
3490
+ }catch(e){}
3491
+
3492
+ // \u2500\u2500 Prefetch on sight \u2500\u2500
3493
+ // Links are fetched as they scroll into view, a few per screen, so most
3494
+ // taps find their page already here. Not on a connection that asked to
3495
+ // save data, and not for a link marked data-stx-prefetch="false" or
3496
+ // "hover" (hover and touch still prefetch everything).
3497
+ var sightObserver=null;
3498
+ var sightCount=0;
3499
+ function observeLinks(){
3500
+ if(!o.prefetch||!o.cache||o.prefetchVisible===false||typeof IntersectionObserver!=='function')return;
3501
+ var c=navigator.connection;
3502
+ if(c&&(c.saveData||/2g/.test(c.effectiveType||'')))return;
3503
+ if(!sightObserver)sightObserver=new IntersectionObserver(function(entries){
3504
+ entries.forEach(function(en){
3505
+ if(!en.isIntersecting)return;
3506
+ sightObserver.unobserve(en.target);
3507
+ if(sightCount>=o.prefetchVisibleMax)return;
3508
+ sightCount++;
3509
+ prefetchLink(en.target);
3510
+ });
3511
+ });
3512
+ sightCount=0;
3513
+ var root=activeRoot()||document.body;
3514
+ if(!root||!root.querySelectorAll)return;
3515
+ root.querySelectorAll('[data-stx-link][href]').forEach(function(a){
3516
+ if(a.__stxSeen)return;
3517
+ var p=a.getAttribute('data-stx-prefetch');
3518
+ if(p==='false'||p==='hover'||p==='none')return;
3519
+ a.__stxSeen=1;
3520
+ sightObserver.observe(a);
3521
+ });
3522
+ }
3523
+
2610
3524
  // \u2500\u2500 Link interception \u2500\u2500
2611
3525
  // Links the router must never touch, however it found them. This is the
2612
3526
  // opt-out set, and it is deliberately separate from shouldIntercept's
@@ -2709,6 +3623,8 @@ else {
2709
3623
  e.preventDefault();
2710
3624
  e.stopPropagation();
2711
3625
  log('[router] navigating to:',href);
3626
+ // A tab: its own stack, never a back entry (selectTab).
3627
+ if(link.getAttribute('data-stx-nav')==='tab'||link.hasAttribute('data-native-tab')){selectTab(href);return}
2712
3628
  instantNext=link.getAttribute('data-stx-transition')==='none';
2713
3629
  navigate(withCurrentLocale(href));
2714
3630
  });
@@ -2850,16 +3766,33 @@ else {
2850
3766
 
2851
3767
  // \u2500\u2500 Back/forward \u2500\u2500
2852
3768
  window.addEventListener('popstate',function(){
3769
+ // A tab switch rewinding to the first entry: not a navigation of its own.
3770
+ if(rewinding){rewinding();return}
3771
+ var settled=swipeSettled;
3772
+ swipeSettled=null;
2853
3773
  // Nothing has scrolled yet (restoration is manual), so the viewport still
2854
3774
  // shows the entry being left: save it under its own token before adopting
2855
3775
  // the popped entry's, and hand the swap the position to land on.
2856
- rememberScroll();
3776
+ if(!settled)rememberScroll();
3777
+ var st=history.state;
3778
+ // An entry the router did not write (a page's own pushState) keeps the
3779
+ // depth it was written at; a lower one is a step back.
3780
+ var depth=st&&typeof st[DEPTH]==='number'?st[DEPTH]:histDepth;
3781
+ var dir=depth<histDepth?'pop':depth>histDepth?'push':'replace';
3782
+ histDepth=depth;
3783
+ var popped=st&&st[SCROLL_TOKEN];
3784
+ scrollToken=popped||newScrollToken();
2857
3785
  if(restoreScroll){
2858
- var popped=history.state&&history.state[SCROLL_TOKEN];
2859
- scrollToken=popped||newScrollToken();
2860
3786
  if(!popped)stampScrollToken();
2861
3787
  pendingScroll=readScroll(scrollToken);
3788
+ pendingInner=readInner(scrollToken);
2862
3789
  }
3790
+ if(screensLive){
3791
+ if(st&&st[TAB]!==undefined&&st[TAB]!==curTab&&tabs[st[TAB]]){curTab=st[TAB];markTab(curTab)}
3792
+ var target=(tabs[curTab]||[])[depth];
3793
+ if(target&&target.el&&target.token===scrollToken&&target!==active){showRetained(target,dir==='replace'?'pop':dir,{settled:settled===target});return}
3794
+ }
3795
+ navCtxNext={direction:dir};
2863
3796
  navigate(location.pathname+location.search+location.hash,false);
2864
3797
  });
2865
3798
 
@@ -2903,7 +3836,13 @@ else {
2903
3836
  setCache(key,result.html,result.layout,result.layoutGroup,result.title,result.containerAttrs);
2904
3837
  if(eager)keepPage(key,result);
2905
3838
  }
2906
- if(result)loadModuleRegistries(result.html,href);
3839
+ if(result){
3840
+ loadModuleRegistries(result.html,href);
3841
+ // Its stylesheet too, so the tap never waits on cssLoadTimeout for a
3842
+ // sheet it could have had all along. Added unapplied (media=print),
3843
+ // as a navigation adds it; the swap makes it live.
3844
+ if(result.html.indexOf('<!--stx-fragment')===0)preloadGeneratedCss(fragmentCssHrefs(result.html),location.href);
3845
+ }
2907
3846
  }).catch(function(){}).finally(function(){delete prefetching[key]});
2908
3847
  }
2909
3848
 
@@ -2999,6 +3938,7 @@ else {
2999
3938
  document.addEventListener('mouseover',onIntent,true);
3000
3939
  document.addEventListener('touchstart',onIntent,{capture:true,passive:true});
3001
3940
  window.addEventListener('stx:load',prefetchEager);
3941
+ window.addEventListener('stx:load',function(){setTimeout(observeLinks,0)});
3002
3942
  }
3003
3943
 
3004
3944
  // \u2500\u2500 Active link management \u2500\u2500
@@ -3065,7 +4005,7 @@ else {
3065
4005
  // updateActiveLinks has already rewritten the classes by now.
3066
4006
  function updateNav(){
3067
4007
  var done=new Set();
3068
- qsa('nav, #mobileNav, [data-stx-nav]').forEach(function(nav){
4008
+ qsa('nav, #mobileNav, [data-stx-nav]:not(a)').forEach(function(nav){
3069
4009
  var states=[];
3070
4010
  Array.prototype.forEach.call(nav.querySelectorAll('a[href]'),function(a){
3071
4011
  if(done.has(a)||!a.hasAttribute('data-stx-link'))return;
@@ -3161,16 +4101,40 @@ else {
3161
4101
  // The container the router focuses after a navigation is not a
3162
4102
  // control, so it draws no focus ring: Safari drew one around the whole
3163
4103
  // page, a blue line along its bottom edge on every screen.
3164
- 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}';
4104
+ // Links stay live while a page loads: a tap on another one is the newest
4105
+ // navigation and wins (navigate), where pointer-events:none used to
4106
+ // swallow it.
4107
+ 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}';
4108
+ // One View Transitions block. There used to be two, and the second
4109
+ // (injected after this one) silently overrode the first: the page stays
4110
+ // put and only the routed content fades, which is what this now says
4111
+ // once. The retained screen inside it is not named, so the container
4112
+ // and the screen never carry the same name.
3165
4113
  if(o.viewTransitions&&'startViewTransition' in document){
3166
4114
  var dur=(o.viewTransitionDuration||220)+'ms';
3167
4115
  var ease=o.viewTransitionEasing||'cubic-bezier(0.16, 1, 0.3, 1)';
3168
- css+='::view-transition-old(root),::view-transition-new(root){animation-duration:'+dur+';animation-timing-function:'+ease+'}';
3169
- css+='::view-transition-old(root){animation-name:stx-r-fade-out}::view-transition-new(root){animation-name:stx-r-fade-in}';
4116
+ css+='::view-transition-old(root),::view-transition-new(root){animation:none}::view-transition{background:transparent}';
4117
+ css+='main,#app-content,[data-stx-content]:not([data-stx-screen]){view-transition-name:stx-content}::view-transition-group(stx-content){overflow:hidden}';
4118
+ 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}';
3170
4119
  css+='@keyframes stx-r-fade-out{from{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(-4px)}}';
3171
4120
  css+='@keyframes stx-r-fade-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}';
3172
- css+='@media (prefers-reduced-motion: reduce){::view-transition-old(root),::view-transition-new(root){animation-duration:0s;animation-name:none}}';
4121
+ css+='@media (prefers-reduced-motion: reduce){::view-transition-old(stx-content),::view-transition-new(stx-content){animation-duration:0s;animation-name:none}}';
3173
4122
  }
4123
+ // Screens: a hidden one takes no room, and a screen lays its page out
4124
+ // the way the container would have. Push and pop are iOS's: the new
4125
+ // screen slides in from the right edge while the one under it moves a
4126
+ // third of the way left and dims, over --stx-nav-duration on a spring
4127
+ // (linear() where the browser has it). Reduced motion cross-fades.
4128
+ 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}';
4129
+ 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)}}';
4130
+ 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}';
4131
+ 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}';
4132
+ 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)}';
4133
+ 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}';
4134
+ 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}}';
4135
+ 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}}';
4136
+ // The screen a failed load leaves behind (showNavError).
4137
+ 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}';
3174
4138
  s.textContent=css;
3175
4139
  dhead().appendChild(s);
3176
4140
  }
@@ -3190,12 +4154,6 @@ else {
3190
4154
  }
3191
4155
  }
3192
4156
 
3193
- function injectViewTransitionCSS(){
3194
- if(document.getElementById('stx-view-transitions'))return;
3195
- var s=ce('style');s.id='stx-view-transitions';
3196
- 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}';
3197
- (document.head||document.documentElement).appendChild(s);
3198
- }
3199
4157
 
3200
4158
  // \u2500\u2500 Public API \u2500\u2500
3201
4159
  var router={
@@ -3230,6 +4188,12 @@ else {
3230
4188
  evictCache(cacheKey(url||(location.pathname+location.search)));
3231
4189
  },
3232
4190
  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},
4191
+ // Switch to the tab whose link points at href, as a tap on it would:
4192
+ // its retained screens and its own back stack (selectTab).
4193
+ selectTab:selectTab,
4194
+ back:function(){return history.back()},
4195
+ // The screens kept alive, for a page or a test that wants to look.
4196
+ 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},
3233
4197
  cache:cache,
3234
4198
  swap:swap,
3235
4199
  updateNav:updateNav
@@ -3243,10 +4207,13 @@ else {
3243
4207
  // \u2500\u2500 Initialize \u2500\u2500
3244
4208
  function init(){
3245
4209
  injectStyles();
3246
- injectViewTransitionCSS();
3247
4210
  refreshCurrentLinks();
4211
+ // The first screen, from the start, so the page never changes shape on
4212
+ // its first navigation.
4213
+ if(screensOn()){var host=getContainer();if(host)adoptInitial(host)}
3248
4214
  restoreKeptPages();
3249
4215
  prefetchEager();
4216
+ observeLinks();
3250
4217
  }
3251
4218
 
3252
4219
  if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
@@ -3256,7 +4223,7 @@ else {
3256
4223
  function getRouterScript() {
3257
4224
  if (cachedRouterScript)
3258
4225
  return cachedRouterScript;
3259
- cachedRouterScript = minifyRouterScript(stripRouterLogs(routerSource()));
4226
+ cachedRouterScript = minifyRouterScript(stripRouterLogs(routerSource()), true);
3260
4227
  return cachedRouterScript;
3261
4228
  }
3262
4229
  function getRouterScriptDev() {