/* Shell chrome behaviour (web-server-guide § The site) — same-origin, CSP-clean, no deps. Loaded from <head> without defer so the MathJax config below is in place before the deferred typesetter reads it; everything else is wired on DOMContentLoaded or delegated clicks. Loaded by both tiers: the signed-out pages render the same shell, where the hooks below simply find nothing to bind (no editors, no terminal, no vault). */(function(){'use strict';// ── theme ────────────────────────────────────────────────────────────────────────// First, before anything paints: this file loads from <head> without defer, so the// `data-theme` it sets is on <html> before the body renders, and the page never// flashes the wrong ground. The reader's choice (the header's toggle) is kept in// localStorage under the key the code viewer (/code/) shares, so one choice holds// across both; with none made, the system's preference decides, and follows it live.// Storage may be absent or throw (a private window, blocked site data): the toggle// still works for the page, it just is not remembered.//// The terminal's iframe loads this file too, so it themes itself the same way, and the// `storage` event — which fires in every OTHER same-origin document when the key// changes — carries a toggle into it (and into other tabs) without a message of its// own. Every apply announces itself as `euler:theme` on window, which terminal.js// listens for to repaint xterm, whose colours are not CSS.varTHEME_KEY='euler-theme';varthemeRoot=document.documentElement;varprefersDark=window.matchMedia?window.matchMedia('(prefers-color-scheme: dark)'):null;functionstoredTheme(){varvalue=null;try{value=window.localStorage.getItem(THEME_KEY);}catch(e){/* not kept */}returnvalue==='light'||value==='dark'?value:null;}functionsystemTheme(){returnprefersDark&&!prefersDark.matches?'light':'dark';}functionapplyTheme(theme){themeRoot.setAttribute('data-theme',theme);paintThemeToggles();window.dispatchEvent(newCustomEvent('euler:theme',{detail:theme}));}//: The toggle names the act it offers, not the state: the state is on screen already.functionpaintThemeToggles(){vardark=themeRoot.getAttribute('data-theme')!=='light';varact=dark?'Switch to the light theme':'Switch to the dark theme';document.querySelectorAll('[data-theme-toggle]').forEach(function(button){button.title=act;button.setAttribute('aria-label',act);});}applyTheme(storedTheme()||systemTheme());if(prefersDark&&prefersDark.addEventListener){prefersDark.addEventListener('change',function(){if(!storedTheme()){applyTheme(systemTheme());}});}window.addEventListener('storage',function(ev){if(ev.key===THEME_KEY||ev.key===null){applyTheme(storedTheme()||systemTheme());}});document.addEventListener('DOMContentLoaded',paintThemeToggles);document.addEventListener('click',function(ev){varbutton=ev.target.closest&&ev.target.closest('[data-theme-toggle]');if(!button){return;}varnext=themeRoot.getAttribute('data-theme')==='light'?'dark':'light';try{window.localStorage.setItem(THEME_KEY,next);}catch(e){/* not kept */}applyTheme(next);});// MathJax v3 config — this file loads before the deferred /vendor/mathjax// bundle, so the loader reads it on init. Statements and notes carry TeX as// $…$ / $$…$$ text (convention_documentation.md); \$ escapes a literal dollar.// MathJax's defaults already skip pre/code/textarea, so source views and the// editors are never typeset.// The TeX extensions a cached statement needs, LOADED UP FRONT rather than left to// MathJax's `autoload`. They are vendored either way (vendor/README.md); the difference// is *when* they arrive. Autoload fetches on first use — in the middle of a typeset —// and that is a race the swap path loses: reaching a problem by htmx swap rendered// `\color` as its own literal text, while a plain refresh had the extension cached from// the previous visit and looked perfect. One macro away from any of these six and the// page silently shows TeX source instead of maths.//// Preloading costs one fetch per document, of files already vendored, and buys a// typesetter whose vocabulary does not depend on what the reader happened to open// first. `loader.load` fetches them at startup; `packages: {'[+]': …}` adds them to the// TeX input's package list (the combined bundle ships only the defaults).//// The set is what the cached statements actually use — \color · \pu · \unicode ·// \enclose · \boldsymbol · \style — the same scan vendor/README.md records. A statement// reaching for a seventh needs its package vendored and added in both lists here.varMJ_TEX=['color','mhchem','html','unicode','enclose','boldsymbol'];// Resolved by MathJax's own `pageReady`, which runs only after the loader has every// package above and the initial typeset is done. It is what the swap re-typeset waits// on, and it must be created HERE, before the bundle: a swap can fire while// `window.MathJax` is still this plain config object, with no `startup`, no// `typesetPromise` and nothing to hang a promise off (see `typesetPane` below).varmathStarted;varmathReady=newPromise(function(resolve){mathStarted=resolve;});window.MathJax={loader:{load:MJ_TEX.map(function(p){return'[tex]/'+p;})},tex:{packages:{'[+]':MJ_TEX},inlineMath:[['$','$']],displayMath:[['$$','$$']],processEscapes:true},// matchFontHeight measures the surrounding font's x-height and inflates the// math (~20% over system-ui), and the measurement varies with load timing —// the "large math until a refresh" symptom. Off = deterministic: math// renders at exactly the surrounding font size, every client, every load.chtml:{scale:1,matchFontHeight:false},startup:{pageReady:function(){returnMathJax.startup.defaultPageReady().then(mathStarted);}}};// Every off-site link opens in a new tab: following one in-place would tear// down the shell — and with it the terminal in the right pane, which is the// one thing the design promises never to lose. Applied document-wide (not per// link), so it holds for markup we do not author: the cached projecteuler.net// statements, generated notes, and the markdown docs alike.functionexternalize(root){(root||document).querySelectorAll('a[href]:not([target])').forEach(function(a){varurl;try{url=newURL(a.getAttribute('href'),window.location.href);}catch(e){return;}if(url.protocol!=='http:'&&url.protocol!=='https:'){return;}// mailto:, #fragif(url.host===window.location.host){return;}a.target='_blank';a.rel=(a.rel?a.rel+' ':'')+'noopener noreferrer';});}document.addEventListener('DOMContentLoaded',function(){externalize(document);});// Re-typeset the left pane after a swap — ON A CHAIN rooted at `mathReady`, never as a// bare call guarded by "is MathJax there yet".//// The old guard was `if (window.MathJax && MathJax.typesetPromise)`, and it had two// losing outcomes either side of a race with the deferred bundle. Too early, MathJax is// still the plain config object: the guard is false, the swap is never typeset at all,// and the pane shows raw `$…$`. A moment later `typesetPromise` exists but startup has// not finished adding the packages, so the typeset runs against a half-configured TeX// input and every macro outside the defaults renders as its own literal source — which// is exactly the `\color[RGB]124, 192, 255` a swapped-in statement showed, while a// refresh of the same URL looked perfect because startup was long done by then. The// swap is the *normal* way into a problem here (the terminal's `show`), so the losing// case was the common one.//// Waiting on `mathReady` closes both: it resolves inside MathJax's own `pageReady`,// after the loader has the packages and the first typeset is done. Later swaps then// queue behind their predecessor, which is MathJax's documented requirement —// concurrent typesets on one document are not supported. A rejection is swallowed so a// single statement with bad TeX cannot wedge typesetting for the rest of the session.//// On a page with no MathJax at all (the signed-out shell drops the bundle) `mathReady`// simply never resolves, and the queued work is a no-op on a pane that has no maths.vartypesetting=mathReady;functiontypesetPane(){typesetting=typesetting.then(function(){if(!window.MathJax||!MathJax.typesetPromise){returnundefined;}if(MathJax.typesetClear){MathJax.typesetClear();}returnMathJax.typesetPromise([document.getElementById('content')]);}).catch(function(){/* keep the chain alive for the next swap */});}document.addEventListener('htmx:afterSwap',function(){typesetPane();enhanceEditors();externalize(document);// A swapped-in pane may carry terminal controls of its own (the start page's// Shell tile): they arrive with the server's static markup and know nothing// of the live socket / hidden state, so paint them from the state we hold.paintWsHidden();// A swapped-in index arrives with an empty filter box and the whole region// showing — restore that section's query onto it (§ the pane filter).paintPaneFilter();});// ── the pane's scroll memory (web-server-guide § The site) ──────────────────────────────// Every page the left pane shows reopens where it was last left, for the rest of the// browser session — whichever way you come back: the back arrow, refresh, a nav or crumb// link, the terminal's `show`, the browser's own back/forward, or a document reload.//// The browser cannot do this for us. It restores the *window's* scroll, and the window// never scrolls here: #content is its own scroll box beside the terminal (whose// scrollback is xterm's and is never touched). And a swap keeps whatever scrollTop the// old page had, clamped to the new one's height — so without this a page opened mid-way// down for no reason at all.//// Filed per path (pathname + search) in sessionStorage, like the pane filter: a position// is where you are in a sitting, so it dies with the tab. Recorded just before #content// is replaced and when the document goes away; applied after the swap lands, and again// once MathJax has typeset it, since the maths changes the page's height. A link with a// #fragment goes to its anchor instead.varSCROLL_KEY='euler:scroll';varshownPath=null;// the page #content holds: the key its offset is filed undervarpendingTop=null;// a restore still waiting on the typeset — the offset it will setvarrestoreSeq=0;functionscrollMap(){try{returnJSON.parse(sessionStorage.getItem(SCROLL_KEY)||'{}')||{};}catch(e){return{};}}functionsaveScroll(){varpane=document.getElementById('content');if(!pane||shownPath===null){return;}// Mid-restore the pane sits at an un-typeset height; file where it is going, not where it is.vartop=pendingTop!==null?pendingTop:pane.scrollTop;varmap=scrollMap();if(top>0){map[shownPath]=top;}else{deletemap[shownPath];}try{sessionStorage.setItem(SCROLL_KEY,JSON.stringify(map));}catch(e){/* private mode */}}functionrestoreScroll(path,anchor){shownPath=path;varseq=++restoreSeq;varpane=document.getElementById('content');if(!pane||anchor){pendingTop=null;return;}// an anchor is htmx's (or the browser's) to scroll tovartop=scrollMap()[path]||0;pane.scrollTop=top;pendingTop=top;// The signed-out shell has no MathJax, so `typesetting` never settles there; the timer// ends the wait regardless.varsettled=newPromise(function(resolve){window.setTimeout(resolve,2000);});Promise.race([typesetting,settled]).then(function(){if(seq!==restoreSeq){return;}// a newer page owns the pane nowvarnow=document.getElementById('content');if(now){now.scrollTop=top;}pendingTop=null;});}functionisPaneSwap(ev){returnev.detail&&ev.detail.target&&ev.detail.target.id==='content';}document.addEventListener('htmx:beforeSwap',function(ev){if(isPaneSwap(ev)){saveScroll();}});// A GET into #content is a page landing, filed under the path it actually answered from// (after any redirect). Writes that re-render the pane (a save, a delete) leave it alone.document.addEventListener('htmx:afterSwap',function(ev){if(!isPaneSwap(ev)){return;}varcfg=ev.detail.requestConfig;if(!cfg||String(cfg.verb).toLowerCase()!=='get'){return;}varinfo=ev.detail.pathInfo||{};restoreScroll(info.responsePath||info.finalRequestPath||panePath(),info.anchor);});// The browser's back/forward: htmx re-fetches the page itself (historyCacheSize 0),// outside the swap events above — the URL has already moved, but shownPath has not.document.addEventListener('htmx:historyCacheMiss',saveScroll);document.addEventListener('htmx:historyRestore',function(ev){restoreScroll((ev.detail&&ev.detail.path)||panePath(),window.location.hash);});window.addEventListener('pagehide',saveScroll);document.addEventListener('visibilitychange',function(){if(document.visibilityState==='hidden'){saveScroll();}});document.addEventListener('DOMContentLoaded',function(){if('scrollRestoration'inwindow.history){window.history.scrollRestoration='manual';}restoreScroll(panePath(),window.location.hash);});// ── the pane's back arrow (web-server-guide § The site) ─────────────────────────────────// The address bar's back button navigates the *document*, which tears down the// right pane's terminal — the session the shell promises never to lose. So the// header carries a back of its own over the pages #content has shown: a swap,// not a navigation, so the terminal never notices.//// The stack is the pane's, not the browser's. htmx pushes a URL for every pane// navigation (hx-push-url), and that push is the signal a new page landed. The// arrow's own swap pushes its URL directly (below) rather than through htmx, so// it never lands here — going back pops, it does not stack.varpaneStack=[];functionpanePath(){returnwindow.location.pathname+window.location.search;}//: The page behind the current one — or, on the first page of a visit, itself.functionbackTarget(){returnpaneStack.length>1?paneStack[paneStack.length-2]:(paneStack[0]||'/');}functionrefreshBack(){varback=document.getElementById('nav-back');if(!back){return;}// the terminal frame has no headervartarget=backTarget();back.setAttribute('href',target);// a real href: no-JS, middle-clickback.classList.toggle('is-first',paneStack.length<2);back.title=paneStack.length>1?'back to '+target:'back';varrefresh=document.getElementById('nav-refresh');if(refresh){refresh.setAttribute('href',panePath());}// no-JS / middle-click fallback}// Refresh the content pane in place: re-fetch the current URL into #content. The terminal is a// separate pane (#ws), so it is untouched — this is the pane's own reload, not the document's.// No history push and no stack change: it re-renders the same page, it does not navigate.functionpaneRefresh(){window.htmx.ajax('GET',panePath(),{target:'#content',swap:'innerHTML'});}functionpaneBack(){vartarget=backTarget();if(paneStack.length>1){paneStack.pop();}// leave the page we are on// htmx binds hx-get at process time, so the arrow cannot carry a target that// changes on every navigation — it asks htmx for the swap directly instead, and// pushes the URL itself (htmx.ajax does not, and the address bar must keep up).window.htmx.ajax('GET',target,{target:'#content',swap:'innerHTML'}).then(function(){window.history.pushState({},'',target);refreshBack();});}document.addEventListener('htmx:pushedIntoHistory',function(ev){varpath=(ev.detail&&ev.detail.path)||panePath();if(paneStack[paneStack.length-1]!==path){paneStack.push(path);}refreshBack();});// A browser back/forward still works (htmx restores the pane) — but its history// is not ours, so re-seed rather than let the two drift apart.window.addEventListener('popstate',function(){paneStack=[panePath()];refreshBack();});document.addEventListener('DOMContentLoaded',function(){paneStack=[panePath()];// a fresh document: nothing behind usrefreshBack();});// An account icon is scaled here, before it is sent: a phone's photo becomes a small square,// and loses its metadata on the way. A file the browser cannot read goes as it is, for the// server to say why it will not keep it.varICON_SIZE=160;document.addEventListener('change',function(ev){varinput=ev.target;if(!input.matches||!input.matches('input[data-icon-file]')||!input.files.length){return;}createImageBitmap(input.files[0]).then(function(image){varside=Math.min(image.width,image.height);varcanvas=document.createElement('canvas');canvas.width=canvas.height=ICON_SIZE;canvas.getContext('2d').drawImage(image,(image.width-side)/2,(image.height-side)/2,side,side,0,0,ICON_SIZE,ICON_SIZE);canvas.toBlob(function(blob){varfiles=newDataTransfer();files.items.add(newFile([blob],'icon',{type:blob.type}));input.files=files.files;input.form.requestSubmit();},'image/webp',0.9);}).catch(function(){input.form.requestSubmit();});});// Lazy-load the CodeMirror editor only when an edit page or a solution file's read-only// view appears in the pane — its vendored graph (~630 KB) never loads on other pages. The// module is cached after first import; each visit just (re)mounts on the fresh element.varcm=null;functionenhanceEditors(){varpane=document.getElementById('content')||document;if(!pane.querySelector('[data-cm]')){return;}if(cm){cm.mount(pane);return;}import('/assets/editor.js').then(function(m){cm=m;m.mount(pane);}).catch(function(){/* no-JS/blocked: the plain textarea or <pre> still works */});}document.addEventListener('DOMContentLoaded',enhanceEditors);document.addEventListener('click',function(ev){// Native <details> dropdowns (Actions, user menu): close on selection or// on any click outside the open menu.document.querySelectorAll('details.menu[open]').forEach(function(menu){if(!menu.contains(ev.target)||ev.target.closest('a, button')){menu.removeAttribute('open');}});// The Actions menu's Save submits the pane's editor form.if(ev.target.closest('[data-action="submit-editor"]')){varform=document.querySelector('#content form.editor-form');if(form){form.requestSubmit();}}// The account page's "Change password" Action opens the folded form in place.if(ev.target.closest('[data-action="change-password"]')){varfold=document.getElementById('password-fold');if(fold){fold.open=true;fold.scrollIntoView({block:'nearest'});}}// The header's back arrow: a swap, never a document navigation (its href is// the no-JS fallback, and what a middle-click opens in a new tab).if(ev.target.closest('#nav-back')&&window.htmx&&!ev.metaKey&&!ev.ctrlKey){ev.preventDefault();paneBack();}// The header's refresh: a pane swap, never a document reload (which would drop the terminal).if(ev.target.closest('#nav-refresh')&&window.htmx&&!ev.metaKey&&!ev.ctrlKey){ev.preventDefault();paneRefresh();}});// A deliberate exit (logout) should not trip the terminal's beforeunload// guard: tell the /terminal iframe to disarm before the shell navigates.// Also forget the vault password key — the session it unlocked is ending.document.addEventListener('submit',function(ev){if(!ev.target.matches('form[action="/auth/logout"]')){return;}if(window.Vault){window.Vault.clear();}varterminal=document.getElementById('terminal');if(terminal&&terminal.contentWindow){terminal.contentWindow.postMessage({euler:'disarm'},window.location.origin);}});// The shell's `logout` reaches here through the terminal: submit the header's own form, so// the sign-out above runs unchanged. requestSubmit, because submit() fires no submit event.functionsignOut(){varform=document.querySelector('form[data-logout]');if(form){form.requestSubmit();}}window.addEventListener('message',function(ev){if(ev.origin!==window.location.origin||!ev.data||ev.data.euler!=='logout'){return;}signOut();});// ── the logout notice ──────────────────────────────────────────────────────// The terminal keeps the clock and says what to show; the page shows it, so a hidden// terminal warns like any other. The reason is announced once, not on every tick.document.addEventListener('DOMContentLoaded',function(){varsessionNotice=document.getElementById('session-notice');if(!sessionNotice){return;}varnoticeReason=sessionNotice.querySelector('[data-notice-reason]');varnoticeLeave=sessionNotice.querySelector('[data-notice-leave]');noticeLeave.addEventListener('click',signOut);window.addEventListener('message',function(ev){if(ev.origin!==window.location.origin||!ev.data||ev.data.euler!=='logout-notice'){return;}sessionNotice.hidden=!ev.data.detail;if(!ev.data.detail){return;}if(noticeReason.textContent!==ev.data.reason){noticeReason.textContent=ev.data.reason;}sessionNotice.querySelector('[data-notice-detail]').textContent=ev.data.detail;noticeLeave.textContent=ev.data.running?'Stop work and sign out':'Sign out now';});});// ── the terminal controls ──────────────────────────────────────────────────// Any number of controls, one state. A control is [data-term-toggle]: it carries// the state on its [data-term-dot] and names the act it offers in its title (which// is also its accessible name — the titlebar's is a dot with no text). The one that// acts is on the terminal window's own titlebar (base.html); the header's// [data-term-status] chip only reports, so the state stays readable while the window// is hidden and its titlebar is gone. All of them are painted from the one// `termConnected` below, so they can never disagree.//// Connecting and disconnecting are always the USER'S acts (the terminal never// reconnects on its own), so a click posts the act into the /terminal iframe and// waits to be TOLD what happened rather than assuming it worked.//// The state starts false and is painted immediately: the shell may not have a// terminal at all (signed out), the socket may fail to open, and a control that// claims a live session before the iframe has reported one is a lie we would// have no way to correct. The first report (the page-load connect) flips it.vartermConnected=false;//: The shell has lent the terminal to an interactive child (`! sh`, `! py`, `claude`)://: connected, but a command typed now would land in that program, not the solver shell.//: Reported by the iframe with the connection (terminal.js § `busy` / `ready`), and//: painted as `body.term-busy`, so every [data-term-cmd] control — the account page's//: rows and the Actions menu as well as the chips' panels — is disabled, including//: one an htmx swap brings in while the child is still running.vartermBusy=false;functionpaintTerminalControls(){// The act on offer, in the title and the accessible name — the same words the git// panel's spelled-out button wears, so a control with a label and one without say// the same thing.varact=(termConnected?'Disconnect':'Connect')+' the terminal';// Not the disabled ones: the public site's window has no session to toggle, and its// controls keep the title the markup gave them ("Sign in to connect").document.querySelectorAll('[data-term-toggle]:not([disabled])').forEach(function(button){button.title=act;button.setAttribute('aria-label',act);});// A toggle that also SHOWS its act in words (the header chip's panel row). The bare// controls above are a dot with a tooltip; a menu row has to read as the thing it// does, and both are written from the one `act` so they cannot say different things.document.querySelectorAll('[data-term-toggle-label]').forEach(function(label){label.textContent=act;});document.querySelectorAll('[data-term-dot]').forEach(function(dot){dot.className='dot '+(termConnected?'on':'off');});// The section strip's Shell tile says the state in words as well as in the dot:// it is the tile's data line, where its three neighbours carry a count, and a lone// dot in that slot would be the one tile in the row that says nothing.document.querySelectorAll('[data-term-state]').forEach(function(word){word.textContent=termConnected?'connected':'disconnected';});// The header's chip: a readout, not a control — and the only place BOTH of the// terminal's states are shown at once. They are independent (hiding the window// never touches the socket) and either one explains a shell that is not answering,// so the chip carries the session in its dot, layout as the struck-through glyph// (.is-hidden), and both in words in the tooltip.varhidden=wsHidden();document.querySelectorAll('[data-term-status]').forEach(function(chip){chip.title='terminal — '+(termConnected?'connected':'disconnected')+', '+(hidden?'hidden':'visible')+(termBusy?', running another program':'');chip.classList.toggle('is-on',termConnected);chip.classList.toggle('is-hidden',hidden);});// The layout half, in words and a dot, for the chip's panel — the session half is the// [data-term-state] / [data-term-dot] pair above. Two readouts because the two states// are independent: a terminal can be connected and hidden, and a panel that showed only// one of them would leave the other unexplained. Which of the hide/show rows is on offer// is CSS's (body.ws-hidden), so the pair below stays two single-purpose controls.document.querySelectorAll('[data-term-layout]').forEach(function(word){word.textContent=hidden?'hidden':'visible';});document.querySelectorAll('[data-term-layout-dot]').forEach(function(dot){dot.className='dot '+(hidden?'off':'on');});// A menu whose verbs type into the shell needs one to be there. This is the same// single state, not a second reading of the socket: the panels join the set above// rather than tracking the terminal on their own. Their server-side state (the git// chip's branch, the message chip's count) is untouched — that owes the socket nothing.//// Selected by `.term-menu`, the marker EVERY such menu carries, rather than by each// menu's own class: the git chip was first, and naming the mechanism after it meant// the message chip could not have joined without editing this line.document.querySelectorAll('.term-menu').forEach(function(menu){menu.classList.toggle('term-offline',!termConnected);});document.body.classList.toggle('term-busy',termBusy);}// Not registered on DOMContentLoaded itself: paintWsHidden() below calls this after// toggling the layout class, so the two readouts are always painted from one pass.//: Put the caret in the right pane's window: the terminal (by message — the parent cannot//: reach xterm's textarea across the frame) or, on the public site, the sign-in's email.functionfocusWs(){varemail=document.querySelector('[data-ws-signin] #email');if(email){email.focus();return;}postToTerminal({euler:'focus'});}//: Post an act into the terminal iframe. Unframed (or signed out) there is none.functionpostToTerminal(message){varterminal=document.getElementById('terminal');if(terminal&&terminal.contentWindow){terminal.contentWindow.postMessage(message,window.location.origin);}}document.addEventListener('click',function(ev){varbutton=ev.target.closest&&ev.target.closest('[data-term-toggle]');if(!button){return;}postToTerminal({euler:termConnected?'disconnect':'connect'});varmenu=button.closest('details');if(menu){menu.open=false;}});// ── hide the terminal to the footer ─────────────────────────────────────────// Layout only: a persisted flag collapses the right pane (body.ws-hidden in the CSS)// so #content spans the full width, with the hidden window in the footer. The socket// is never touched — Connect/Disconnect stays its own control, and the two states the// terminal has (connected/disconnected, visible/hidden) are independent.//// Hide and show are SEPARATE acts, so each control does one predictable thing:// [data-term-hide] — the pane's own titlebar handle — always hides;// [data-term-show] — the header's Shell item, the Shell tile, the footer's// hidden window — always shows AND focuses.//// Show means "take me to the terminal", so it also puts the caret there: a control// that reveals the shell and then leaves the next keystroke going nowhere has done// half its job. Focus is the iframe's to give (the parent cannot reach xterm's hidden// textarea across the boundary), so it is asked for by message. Showing an already-// visible terminal is layout-wise a no-op but still focuses — that is what the// header's Shell item does beyond swapping /shell into the pane.//// A narrow screen (site.css § The narrow layout: one pane at a time, the terminal// over the content) keeps its own flag, and starts every document HIDDEN: on a phone the// content is what you came for and the terminal is a tap away in the footer. That flag// lives for the document only and is never written to storage, so showing the terminal// on a phone neither persists nor changes what a wide screen remembers — and the wide// flag never pops a terminal open under a phone's reader. Crossing the breakpoint// (a window narrowed past it, a tablet rotated) repaints from whichever flag now rules.varWS_HIDDEN_KEY='euler:ws-hidden';varnarrow=window.matchMedia?window.matchMedia('(max-width: 820px)'):null;varnarrowHidden=true;// The public site's right pane is the same window holding the sign-in instead of a// session (base.html, [data-ws-signin]), and it hides and shows the same way — but it// STARTS hidden, and keeps its own flag. A visitor came for the content; the window waits// in the footer, and the header's Login brings it back (§ the login link, below). Its flag// lives for the tab (sessionStorage), so a new visit starts minimized again, and it never// touches what a signed-in reader chose for their terminal.varWS_SIGNIN_HIDDEN_KEY='euler:ws-signin-hidden';functionisNarrow(){return!!(narrow&&narrow.matches);}//: Which window the right pane holds: 'terminal', 'signin', or null — the auth tier's//: pages, whose right pane is the dialogue itself and never hides (so a stale flag never//: collapses it, and the chip never reports "hidden" about a window that is not there).functionwsKind(){if(document.getElementById('terminal')){return'terminal';}returndocument.querySelector('[data-ws-signin]')?'signin':null;}functionwsHidden(){varkind=wsKind();if(!kind){returnfalse;}if(isNarrow()){returnnarrowHidden;}try{if(kind==='signin'){returnsessionStorage.getItem(WS_SIGNIN_HIDDEN_KEY)!=='0';}returnlocalStorage.getItem(WS_HIDDEN_KEY)==='1';}catch(e){returnkind==='signin';}}functionsetWsHidden(on){if(isNarrow()){narrowHidden=on;return;}try{if(wsKind()==='signin'){sessionStorage.setItem(WS_SIGNIN_HIDDEN_KEY,on?'1':'0');return;}localStorage.setItem(WS_HIDDEN_KEY,on?'1':'0');}catch(e){/* private mode */}}functionpaintWsHidden(){document.body.classList.toggle('ws-hidden',wsHidden());// The header chip reports this state too, so the one paint covers both readouts.paintTerminalControls();}document.addEventListener('DOMContentLoaded',paintWsHidden);if(narrow&&narrow.addEventListener){narrow.addEventListener('change',paintWsHidden);}// On a narrow screen the terminal covers the content, so a pane that moves to another// page hides it: the header's nav, the back arrow, the shell's `show` — each asked to// see a page, and the page is under the terminal. Not a re-render of the same page (the// refresh arrow, a nudge from the shell after a command): that would pull the terminal// away mid-typing. And not a control that asks for the terminal too ([data-term-show],// the nav's Shell item, which swaps /shell into the pane and shows the shell).varlandedPath=window.location.pathname;document.addEventListener('htmx:afterSwap',function(ev){if(!ev.detail||!ev.detail.target||ev.detail.target.id!=='content'){return;}varcfg=ev.detail.requestConfig;if(!cfg||String(cfg.verb).toLowerCase()!=='get'){return;}varinfo=ev.detail.pathInfo||{};varpath=String(info.responsePath||info.finalRequestPath||'').split(/[?#]/)[0];varmoved=path&&path!==landedPath;if(path){landedPath=path;}varasked=cfg.elt&&cfg.elt.closest&&cfg.elt.closest('[data-term-show]');if(!moved||asked||!isNarrow()||wsHidden()){return;}setWsHidden(true);paintWsHidden();});document.addEventListener('click',function(ev){if(!ev.target.closest){return;}varhideBtn=ev.target.closest('[data-term-hide]');varshowBtn=ev.target.closest('[data-term-show]');if((!hideBtn&&!showBtn)||!wsKind()){return;}setWsHidden(!!hideBtn);// hide → true, show → false (no-op if already visible)paintWsHidden();// After the paint, so the pane is on screen before the frame takes focus (a hidden// iframe cannot hold it, and the message is delivered asynchronously either way).// The public window has no frame: the caret goes to its sign-in form instead.if(showBtn){focusWs();}varmenu=(hideBtn||showBtn).closest('details');if(menu){menu.open=false;}// No preventDefault: the header's Shell item and the start page's tile are real// links (hx-get /shell) that carry [data-term-show], and both halves of that click// must run — the pane swaps, the terminal shows and takes the caret.});// The account page's tool rows: a click types the command that fixes the row into// the shell (`git-identity`, `claude /login; claude auth status`). The web shell is the front door// for these — the logins are interactive and belong in a terminal, not in a form// that would have to handle a credential — so the button carries the user there// rather than pretending to do it for them.document.addEventListener('click',function(ev){varbutton=ev.target.closest&&ev.target.closest('[data-term-cmd]');if(!button){return;}// A disconnected terminal drops the frame (terminal.js's send()), so the git// menu paints itself offline and says so rather than letting a click do nothing// silently. Honour that here: the panel's own note is the answer, not a command// shouted into a closed socket.if(button.closest('.term-offline')||termBusy){return;}postToTerminal({euler:'run',command:button.getAttribute('data-term-cmd')});// The verb's answer belongs in the terminal, so close the menu that is now// covering it. The chip itself refreshes when the shell says so (§ git-changed).varmenu=button.closest('details');if(menu){menu.open=false;}});// The iframe's own report of where it stands: the controls follow the terminal,// never the other way round. A session that drops on its own (the shell exits,// the socket dies) lands here too, so nothing claims a live terminal that is not// there.window.addEventListener('message',function(ev){if(ev.origin!==window.location.origin||!ev.data||ev.data.euler!=='term-state'){return;}vardropped=termConnected&&!ev.data.connected;termConnected=!!ev.data.connected;termBusy=termConnected&&!!ev.data.busy;paintTerminalControls();if(dropped){checkSession();}});// ── a session ended somewhere else ─────────────────────────────────────────// A live terminal is how the server reaches this page: `logout-end` signs it out. With// none, nothing says the session has gone (`logout-all`, a revocation, its expiry), and the// page would go on looking signed in. So while the terminal is down, and this tab is on// screen, ask; loading the page again then lands wherever a signed-out reader belongs.// An open editor is left alone: its text is worth more than a tidy header.varSESSION_CHECK_MS=15000;functioncheckSession(){if(termConnected||document.visibilityState==='hidden'){return;}if(!document.getElementById('terminal')||document.querySelector('#content [data-cm]')){return;}fetch('/auth/check',{credentials:'same-origin',cache:'no-store'}).then(function(response){if(response.status===401){location.assign(location.pathname+location.search);}}).catch(function(){});// unreachable says nothing about the session}setInterval(checkSession,SESSION_CHECK_MS);document.addEventListener('visibilitychange',checkSession);// A click in the terminal lands in the iframe's document, out of reach of the click// listener that closes open menus, so the iframe reports it and the menus close here.window.addEventListener('message',function(ev){if(ev.origin!==window.location.origin||!ev.data||ev.data.euler!=='pane-click'){return;}document.querySelectorAll('details.menu[open]').forEach(function(menu){menu.removeAttribute('open');});});// ── the git chip's refresh (OSC 5379 `git`) ────────────────────────────────// The header reads its git state once per navigation and never polls — but the// git commands run in the TERMINAL, and that is not a navigation. So the shell// says when it moved (euler_webshell/shell/osc.py → terminal.js → here) and the chip// re-reads itself: one `git status`, at the one moment the state changed.//// The event is dispatched on <body> because the chip listens for it `from:body`// and swaps its own CONTENTS — fresh state inside the same #git, without touching// the pane or the terminal, and without resetting the menu's open state. Firing an// event rather than calling htmx.ajax keeps the fetch declarative, in the markup,// next to the element it refreshes.//// It fires for a hand-typed `git-sync` exactly as for the menu's item: the menu// types the same command, so there is one path here, not two.window.addEventListener('message',function(ev){if(ev.origin!==window.location.origin||!ev.data||ev.data.euler!=='git-changed'){return;}document.body.dispatchEvent(newCustomEvent('euler:git-changed'));});// ── the account panel's refresh (OSC 5379 `account`) ───────────────────────// A command that changes what /account shows about you — `user` (a new identity),// `git-identity` (a GitHub sign-in) — runs in the terminal, not via a navigation,// so it nudges the page the same way a git command does. The listener that acts on// it (`euler:account-changed from:body`) lives INSIDE the account fragment, on// #vault-panel — so this reaches nothing, and costs nothing, unless /account is the// visible pane. (git-sync / git-filter change decrypt access too, but they already// fire git-changed, which #vault-panel also listens for — so no command emits both.)window.addEventListener('message',function(ev){if(ev.origin!==window.location.origin||!ev.data||ev.data.euler!=='account-changed'){return;}document.body.dispatchEvent(newCustomEvent('euler:account-changed'));});// ── the solutions grid's refresh (OSC 5379 `progress`) ──────────────────────// `summary` / `mark` re-wrote problems.json in the terminal. Same shape as the// account nudge: the listener lives inside solutions.html, so this reaches nothing// unless the grid is the visible pane.window.addEventListener('message',function(ev){if(ev.origin!==window.location.origin||!ev.data||ev.data.euler!=='progress-changed'){return;}document.body.dispatchEvent(newCustomEvent('euler:progress-changed'));});// ── a server response asking the terminal to run a command ─────────────────// `HX-Trigger: {"euler-term-run": "<command>"}` — the progress upload stores the// page and hands the import to `summary` (and the update verbs chained after it) this// way, the same `run` message the git// menu's [data-term-cmd] buttons post. The pane is revealed first: the command's// output (warnings, the commit) is the answer, and a hidden terminal would swallow// it. A disconnected terminal would drop the frame, and a busy one would hand the// command to whatever program holds it, so nothing is sent — the response's status// line tells the user to run it themselves.document.addEventListener('euler-term-run',function(ev){varcommand=ev.detail&&ev.detail.value;if(!command||!termConnected||termBusy){return;}setWsHidden(false);paintWsHidden();postToTerminal({euler:'run',command:String(command)});});// ── the message chip's refresh (both nudges) ────────────────────────────────// Two sources, one event, because the page's answer is the same either way: re-read// the chip.// · euler-msg pushed a delivery to this user's instance, which sent a text frame// down the terminal's socket — news from another person, arriving between// navigations, which a server-rendered header cannot know on its own;// · the shell emitted OSC 5379 `msg` because the user themselves read, sent, replied// or dismissed — the same move `git-sync` makes for the git chip.// #msg-chip listens `from:body` and swaps its own contents, so the count updates// without touching the pane or the terminal.window.addEventListener('message',function(ev){if(ev.origin!==window.location.origin||!ev.data||ev.data.euler!=='message'){return;}document.body.dispatchEvent(newCustomEvent('euler:message'));});// ── copy buttons ───────────────────────────────────────────────────────────// [data-copy] holds the text (the public key today). The label's flip to// "Copied" is the only feedback that the clipboard actually took it.document.addEventListener('click',function(ev){varbutton=ev.target.closest&&ev.target.closest('[data-copy]');if(!button||!navigator.clipboard){return;}navigator.clipboard.writeText(button.getAttribute('data-copy')).then(function(){varwas=button.textContent;button.textContent='Copied';setTimeout(function(){button.textContent=was;},1200);},function(){button.textContent='Copy failed';});});// ── the pane filter ────────────────────────────────────────────────────────// The .pane-filter box on the page divider (_divider.html) narrows the region below it// in place. Client-side only — everything it can show is already in the pane — and// delegated, so it survives the htmx swap that brings the page in.//// The haystack is each thing's data-filter string, which carries more than its visible// name: a blog's full path and facet, a guide's filename, a problem's number in both// the typed and the zero-padded form. It falls back to the element's text if the// attribute is ever absent.//// Two behaviours, because the two things below a divider are not the same kind of// thing://// CARDS AND TABLE ROWS are hidden — a grid of tiles and a table of commands have no// meaning in their gaps, so a miss simply goes, and a sub-heading (.subrule) whose// whole region went goes with it. [hidden] is beaten by the display rules on// .card/.cards/tr, so site.css carries a matching [hidden] override.//// CELLS are dimmed. A century cell's POSITION is what says which problem it is —// hiding the misses would renumber the grid under the reader — so a miss fades and// stays put. A century with no match at all is hidden: it is a whole grid saying// nothing, and the ones that do match should not be scrolled apart by it.//// The section strip is exempt. It is the page's own chrome, not its content: typing// `syn` on the terminal page narrows the guides, and must not blank out Solutions and// Blogs on the way.//// The count on the divider is the answer to what was typed: the total while the box is// empty, `matched / total` while a query is live.//// The query is remembered for the browser session, per section: leaving /blogs/ and// coming back — by the nav, by the pane's back arrow, or by the drafts toggle, which is// a second route (/blogs/all) onto the same grid — restores the narrowed view rather// than silently reopening the whole tree, while the guides are never met pre-narrowed// by something typed at the blogs. sessionStorage, not localStorage: a filter is where// you are in a sitting, not a standing preference like the hidden terminal, so it dies// with the tab. Emptying the box forgets it.functionfilterKey(input){return'euler:filter:'+(input.getAttribute('data-filter-scope')||'');}functionfilterQuery(input){try{returnsessionStorage.getItem(filterKey(input))||'';}catch(e){return'';}}functionsetFilterQuery(input,q){try{if(q){sessionStorage.setItem(filterKey(input),q);}else{sessionStorage.removeItem(filterKey(input));}}catch(e){/* private mode */}}// ── folds ──────────────────────────────────────────────────────────────────// The blogs index's folders ([data-fold], blogs.html) render open, and the ones you// closed are remembered for the sitting, like the scroll and the filter beside them// (sessionStorage: where you are, not a standing preference), and re-folded as the page// arrives. A toggle made while a query is live is the filter's doing (it opens what it// matched), not the reader's, so it is not recorded.varFOLDS_KEY='euler:folds';functionclosedFolds(){try{returnJSON.parse(sessionStorage.getItem(FOLDS_KEY)||'[]')||[];}catch(e){return[];}}functionpaintFolds(root){varclosed=closedFolds();(root||document).querySelectorAll('details[data-fold]').forEach(function(fold){fold.open=closed.indexOf(fold.getAttribute('data-fold'))===-1;});}// Expand all / collapse all ([data-folds], _divider.html): every fold in the pane at once,// recorded as the reader's choice — the same state the one-by-one clicks would leave.document.addEventListener('click',function(ev){varbutton=ev.target.closest&&ev.target.closest('[data-folds]');if(!button){return;}varopen=button.getAttribute('data-folds')==='open';varnames=[];document.querySelectorAll('#content details[data-fold]').forEach(function(fold){fold.open=open;names.push(fold.getAttribute('data-fold'));});try{sessionStorage.setItem(FOLDS_KEY,JSON.stringify(open?[]:names));}catch(e){/* private mode */}});// `toggle` does not bubble, so it is caught on the way down.document.addEventListener('toggle',function(ev){varfold=ev.target;if(!fold.matches||!fold.matches('details[data-fold]')){return;}varinput=document.querySelector('.pane-filter');if(input&&input.value.trim()){return;}varname=fold.getAttribute('data-fold');varclosed=closedFolds().filter(function(n){returnn!==name;});if(!fold.open){closed.push(name);}try{sessionStorage.setItem(FOLDS_KEY,JSON.stringify(closed));}catch(e){/* private mode */}},true);functionhay(el){return(el.getAttribute('data-filter')||el.textContent||'').toLowerCase();}functionapplyPaneFilter(input){varq=input.value.trim().toLowerCase();varpane=document.getElementById('content')||document;varmatched=0;// One pass over the hideable regions: a card grid (minus the section strip) or the// command catalogue's table body. Both hide their items, both fold away when nothing// is left, and both may be titled by a .subrule whose count is the count of what is// under it — not the number the server rendered before anyone typed.varregions=pane.querySelectorAll('nav.cards:not(.sections), .catalogue tbody');regions.forEach(function(region){varitems=region.querySelectorAll('.card, tr');varvisible=0;items.forEach(function(item){varmatch=!q||hay(item).indexOf(q)!==-1;item.hidden=!match;if(match){visible+=1;}});matched+=visible;// The catalogue's own region is its <table>, not the <tbody> we walked: hiding the// tbody alone would leave a headed table with no rows under it.varbox=region.closest('table')||region;box.hidden=!visible;varheading=box.previousElementSibling;// The catalogue's heading is the .subrule before its SECTION, not before the table// inside it — the rendered markdown puts the table one level down.if(!heading||!heading.classList.contains('subrule')){varsection=box.closest('.catalogue');heading=section&§ion.previousElementSibling;}if(heading&&heading.classList.contains('subrule')){heading.hidden=!visible;vargroupCount=heading.querySelector('[data-group-count]');if(groupCount){groupCount.textContent=visible;}}// A blogs folder (blogs.html) is a <details> around its heading and grid: the whole// fold goes with its cards, and a live query opens it — a match folded out of sight is// not a match. Opening it here is not the reader folding it (§ folds, below).varfold=box.closest('details[data-fold]');if(fold){fold.hidden=!visible;if(q&&visible){fold.open=true;}}});// With the box empty the folds are the reader's again: what they folded this sitting.if(!q){paintFolds(pane);}// The century grids.pane.querySelectorAll('.century').forEach(function(century){varanyVisible=false;century.querySelectorAll('.cell[data-filter]').forEach(function(cell){varmatch=!q||hay(cell).indexOf(q)!==-1;cell.classList.toggle('is-dim',!match);if(match){anyVisible=true;matched+=1;}});century.hidden=!anyVisible;});varcount=pane.querySelector('[data-filter-count]');if(count){vartotal=count.getAttribute('data-filter-total');count.textContent=q?matched+' / '+total:total;}}// Paint the remembered query onto a filterable page as it arrives — the first load and// every swap that brings one in. The server always renders the box empty and the region// whole, so this runs after the markup lands, never before. A page without a box has// nothing to do, and its section's query keeps until the next visit.functionpaintPaneFilter(){varinput=document.querySelector('.pane-filter');if(!input){return;}input.value=filterQuery(input);applyPaneFilter(input);}document.addEventListener('DOMContentLoaded',paintPaneFilter);document.addEventListener('input',function(ev){varinput=ev.target.closest&&ev.target.closest('.pane-filter');if(!input){return;}setFilterQuery(input,input.value.trim());applyPaneFilter(input);});// ── the vault: auto-unlock + account-panel recovery ────────────────// Sign-in stashed PK (vault.js); unlock the per-user service's session with it// once per page load. Fire-and-forget: 'stale'/'error' just leave the vault// locked, and the account panel shows the recovery form below.document.addEventListener('DOMContentLoaded',function(){if(!window.Vault){return;}window.Vault.unlock().then(function(result){// The chip renders server-side on this load, BEFORE this unlock runs — so its// worktree scan (git status over solutions/private, which needs the master key)// may have failed against a still-locked vault. A successful unlock means that// scan can now succeed: nudge the chip to re-read, over the same git-changed// path the shell's git commands use. Only on a real unlock, so a page whose// vault was already open pays nothing.if(result==='unlocked'){document.body.dispatchEvent(newCustomEvent('euler:git-changed'));}});});// The recovery form (#vault-recover, in the /account/vault fragment): the user// types a password; its PK is derived the VAULT's own way — its salt and kdf// (/vault/status). If that password is simply the current one (the instance// restarted since sign-in) the unlock succeeds directly; if it is the OLD// password after a change made elsewhere, re-key the vault to the current// sign-in's PK when we hold one. Delegated: the fragment is htmx-swapped.document.addEventListener('submit',function(ev){varform=ev.target.closest&&ev.target.closest('#vault-recover');if(!form||!window.Vault){return;}ev.preventDefault();varerrorBox=form.querySelector('#vault-recover-error');varfail=function(message){errorBox.textContent=message;errorBox.hidden=false;};varpassword=form.querySelector('#vault-recover-password').value;(asyncfunction(){try{varstatus=awaitfetch('/vault/status',{credentials:'same-origin'});if(!status.ok){returnfail('vault status unavailable');}varinfo=awaitstatus.json();if(!info.vault){returnfail('no vault to unlock');}varpk=awaitVault.derivePkFor(password,info);varcreds=Vault.stored();varresp;if(creds&&creds.pk!==pk){// an old password: carry the vault forward (re-keyed) to the current sign-in's PKresp=awaitVault.postJson('/vault/rekey',{old_pk:pk,new_pk:creds.pk,new_salt:creds.salt});}else{resp=awaitVault.postJson('/vault/unlock',{pk:pk,salt:info.salt});}if(resp.status===409){returnfail('that password does not unlock this vault');}if(!resp.ok){returnfail('unlock failed — try again');}if(window.htmx){window.htmx.ajax('GET','/account/vault',{target:'#vault-panel'});}}catch(err){fail('unlock failed: '+(err&&err.message?err.message:'unexpected error'));}})();});// ── the terminal drives the left pane ──────────────────────────────────────// `show` / `edit` in a web shell emit an OSC 5379 sequence on the PTY; the// terminal document turns it into {euler: 'navigate', path} and posts it here// (it never touches this DOM itself). We swap the pane exactly as a link would// — same htmx target, same pushed URL — so the back arrow's stack stays honest// and the terminal, which is not part of #content, is untouched.window.addEventListener('message',function(ev){if(ev.origin!==window.location.origin||!ev.data||ev.data.euler!=='navigate'){return;}varpath=String(ev.data.path||'');if(path.charAt(0)!=='/'||path.charAt(1)==='/'){return;}// same-origin paths onlyif(!window.htmx){window.location.assign(path);return;}// no-JS-htmx fallbackwindow.htmx.ajax('GET',path,{target:'#content',swap:'innerHTML'}).then(function(){window.history.pushState({},'',path);if(paneStack[paneStack.length-1]!==path){paneStack.push(path);}refreshBack();});});// Signing in and out return to the page the pane is on (auth/returnto.py validates it).// The header is not re-rendered as the pane moves, so the server-rendered `next` is the// page the document was loaded on; the path is stamped here, at the moment of the click,// in the capture phase so it lands before the navigation or the form post.functioncurrentPage(){returnlocation.pathname+location.search;}document.addEventListener('click',function(ev){varlink=ev.target.closest&&ev.target.closest('a[data-login]');if(!link){return;}// The public site carries the sign-in form in its right pane: the header's Login goes// there, not to a page that holds the same form. That window starts minimized to the// footer (§ hide the terminal), so Login SHOWS it first — exactly as the footer's hidden// window would — and then focuses the form; on a narrow screen the shown window covers// the content, which is the point. Anywhere without that window (the auth tier's pages)// the link goes to /login.if(wsKind()==='signin'){ev.preventDefault();setWsHidden(false);paintWsHidden();focusWs();varmenu=link.closest('details');if(menu){menu.open=false;}return;}link.href='/login?next='+encodeURIComponent(currentPage());},true);document.addEventListener('submit',function(ev){varform=ev.target;if(!form.matches||!form.matches('form[data-logout]')){return;}varfield=form.querySelector('input[name="next"]');if(field){field.value=currentPage();}},true);})();