Euler source The code that runs the solver shell and this site, version 4.34.2 Repository on GitHub

Shell chrome behaviour — same-origin, CSP-clean, no deps.

1,093 lines of JavaScript Raw file Open on GitHub

/* 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.
  var THEME_KEY = 'euler-theme';
  var themeRoot = document.documentElement;
  var prefersDark = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
  function storedTheme() {
    var value = null;
    try { value = window.localStorage.getItem(THEME_KEY); } catch (e) { /* not kept */ }
    return value === 'light' || value === 'dark' ? value : null;
  }
  function systemTheme() { return prefersDark && !prefersDark.matches ? 'light' : 'dark'; }
  function applyTheme(theme) {
    themeRoot.setAttribute('data-theme', theme);
    paintThemeToggles();
    window.dispatchEvent(new CustomEvent('euler:theme', { detail: theme }));
  }
  //: The toggle names the act it offers, not the state: the state is on screen already.
  function paintThemeToggles() {
    var dark = themeRoot.getAttribute('data-theme') !== 'light';
    var act = 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) {
    var button = ev.target.closest && ev.target.closest('[data-theme-toggle]');
    if (!button) { return; }
    var next = 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.
  var MJ_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).
  var mathStarted;
  var mathReady = new Promise(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 () {
        return MathJax.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.
  function externalize(root) {
    (root || document).querySelectorAll('a[href]:not([target])').forEach(function (a) {
      var url;
      try { url = new URL(a.getAttribute('href'), window.location.href); } catch (e) { return; }
      if (url.protocol !== 'http:' && url.protocol !== 'https:') { return; }  // mailto:, #frag
      if (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.
  var typesetting = mathReady;

  function typesetPane() {
    typesetting = typesetting.then(function () {
      if (!window.MathJax || !MathJax.typesetPromise) { return undefined; }
      if (MathJax.typesetClear) { MathJax.typesetClear(); }
      return MathJax.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.
  var SCROLL_KEY = 'euler:scroll';
  var shownPath = null;           // the page #content holds: the key its offset is filed under
  var pendingTop = null;          // a restore still waiting on the typeset — the offset it will set
  var restoreSeq = 0;

  function scrollMap() {
    try { return JSON.parse(sessionStorage.getItem(SCROLL_KEY) || '{}') || {}; } catch (e) { return {}; }
  }

  function saveScroll() {
    var pane = 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.
    var top = pendingTop !== null ? pendingTop : pane.scrollTop;
    var map = scrollMap();
    if (top > 0) { map[shownPath] = top; } else { delete map[shownPath]; }
    try { sessionStorage.setItem(SCROLL_KEY, JSON.stringify(map)); } catch (e) { /* private mode */ }
  }

  function restoreScroll(path, anchor) {
    shownPath = path;
    var seq = ++restoreSeq;
    var pane = document.getElementById('content');
    if (!pane || anchor) { pendingTop = null; return; }  // an anchor is htmx's (or the browser's) to scroll to
    var top = 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.
    var settled = new Promise(function (resolve) { window.setTimeout(resolve, 2000); });
    Promise.race([typesetting, settled]).then(function () {
      if (seq !== restoreSeq) { return; }                // a newer page owns the pane now
      var now = document.getElementById('content');
      if (now) { now.scrollTop = top; }
      pendingTop = null;
    });
  }

  function isPaneSwap(ev) {
    return ev.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; }
    var cfg = ev.detail.requestConfig;
    if (!cfg || String(cfg.verb).toLowerCase() !== 'get') { return; }
    var info = 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' in window.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.
  var paneStack = [];

  function panePath() { return window.location.pathname + window.location.search; }

  //: The page behind the current one — or, on the first page of a visit, itself.
  function backTarget() {
    return paneStack.length > 1 ? paneStack[paneStack.length - 2] : (paneStack[0] || '/');
  }

  function refreshBack() {
    var back = document.getElementById('nav-back');
    if (!back) { return; }                              // the terminal frame has no header
    var target = backTarget();
    back.setAttribute('href', target);                  // a real href: no-JS, middle-click
    back.classList.toggle('is-first', paneStack.length < 2);
    back.title = paneStack.length > 1 ? 'back to ' + target : 'back';
    var refresh = 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.
  function paneRefresh() {
    window.htmx.ajax('GET', panePath(), { target: '#content', swap: 'innerHTML' });
  }

  function paneBack() {
    var target = 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) {
    var path = (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 us
    refreshBack();
  });

  // 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.
  var ICON_SIZE = 160;
  document.addEventListener('change', function (ev) {
    var input = ev.target;
    if (!input.matches || !input.matches('input[data-icon-file]') || !input.files.length) { return; }
    createImageBitmap(input.files[0]).then(function (image) {
      var side = Math.min(image.width, image.height);
      var canvas = 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) {
        var files = new DataTransfer();
        files.items.add(new File([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.
  var cm = null;
  function enhanceEditors() {
    var pane = 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"]')) {
      var form = 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"]')) {
      var fold = 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(); }
    var terminal = 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.
  function signOut() {
    var form = 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 () {
    var sessionNotice = document.getElementById('session-notice');
    if (!sessionNotice) { return; }
    var noticeReason = sessionNotice.querySelector('[data-notice-reason]');
    var noticeLeave = 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.
  var termConnected = 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.
  var termBusy = false;

  function paintTerminalControls() {
    // 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.
    var act = (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.
    var hidden = 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.
  function focusWs() {
    var email = 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.
  function postToTerminal(message) {
    var terminal = document.getElementById('terminal');
    if (terminal && terminal.contentWindow) {
      terminal.contentWindow.postMessage(message, window.location.origin);
    }
  }

  document.addEventListener('click', function (ev) {
    var button = ev.target.closest && ev.target.closest('[data-term-toggle]');
    if (!button) { return; }
    postToTerminal({ euler: termConnected ? 'disconnect' : 'connect' });
    var menu = 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.
  var WS_HIDDEN_KEY = 'euler:ws-hidden';
  var narrow = window.matchMedia ? window.matchMedia('(max-width: 820px)') : null;
  var narrowHidden = 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.
  var WS_SIGNIN_HIDDEN_KEY = 'euler:ws-signin-hidden';

  function isNarrow() { 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).
  function wsKind() {
    if (document.getElementById('terminal')) { return 'terminal'; }
    return document.querySelector('[data-ws-signin]') ? 'signin' : null;
  }
  function wsHidden() {
    var kind = wsKind();
    if (!kind) { return false; }
    if (isNarrow()) { return narrowHidden; }
    try {
      if (kind === 'signin') { return sessionStorage.getItem(WS_SIGNIN_HIDDEN_KEY) !== '0'; }
      return localStorage.getItem(WS_HIDDEN_KEY) === '1';
    } catch (e) { return kind === 'signin'; }
  }
  function setWsHidden(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 */ }
  }
  function paintWsHidden() {
    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).
  var landedPath = window.location.pathname;
  document.addEventListener('htmx:afterSwap', function (ev) {
    if (!ev.detail || !ev.detail.target || ev.detail.target.id !== 'content') { return; }
    var cfg = ev.detail.requestConfig;
    if (!cfg || String(cfg.verb).toLowerCase() !== 'get') { return; }
    var info = ev.detail.pathInfo || {};
    var path = String(info.responsePath || info.finalRequestPath || '').split(/[?#]/)[0];
    var moved = path && path !== landedPath;
    if (path) { landedPath = path; }
    var asked = 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; }
    var hideBtn = ev.target.closest('[data-term-hide]');
    var showBtn = 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(); }
    var menu = (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) {
    var button = 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).
    var menu = 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; }
    var dropped = 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.
  var SESSION_CHECK_MS = 15000;
  function checkSession() {
    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(new CustomEvent('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(new CustomEvent('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(new CustomEvent('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) {
    var command = 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(new CustomEvent('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) {
    var button = ev.target.closest && ev.target.closest('[data-copy]');
    if (!button || !navigator.clipboard) { return; }
    navigator.clipboard.writeText(button.getAttribute('data-copy')).then(function () {
      var was = 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.
  function filterKey(input) {
    return 'euler:filter:' + (input.getAttribute('data-filter-scope') || '');
  }
  function filterQuery(input) {
    try { return sessionStorage.getItem(filterKey(input)) || ''; } catch (e) { return ''; }
  }
  function setFilterQuery(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.
  var FOLDS_KEY = 'euler:folds';
  function closedFolds() {
    try { return JSON.parse(sessionStorage.getItem(FOLDS_KEY) || '[]') || []; } catch (e) { return []; }
  }
  function paintFolds(root) {
    var closed = 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) {
    var button = ev.target.closest && ev.target.closest('[data-folds]');
    if (!button) { return; }
    var open = button.getAttribute('data-folds') === 'open';
    var names = [];
    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) {
    var fold = ev.target;
    if (!fold.matches || !fold.matches('details[data-fold]')) { return; }
    var input = document.querySelector('.pane-filter');
    if (input && input.value.trim()) { return; }
    var name = fold.getAttribute('data-fold');
    var closed = closedFolds().filter(function (n) { return n !== name; });
    if (!fold.open) { closed.push(name); }
    try { sessionStorage.setItem(FOLDS_KEY, JSON.stringify(closed)); } catch (e) { /* private mode */ }
  }, true);

  function hay(el) {
    return (el.getAttribute('data-filter') || el.textContent || '').toLowerCase();
  }

  function applyPaneFilter(input) {
    var q = input.value.trim().toLowerCase();
    var pane = document.getElementById('content') || document;
    var matched = 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.
    var regions = pane.querySelectorAll('nav.cards:not(.sections), .catalogue tbody');
    regions.forEach(function (region) {
      var items = region.querySelectorAll('.card, tr');
      var visible = 0;
      items.forEach(function (item) {
        var match = !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.
      var box = region.closest('table') || region;
      box.hidden = !visible;
      var heading = 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')) {
        var section = box.closest('.catalogue');
        heading = section && section.previousElementSibling;
      }
      if (heading && heading.classList.contains('subrule')) {
        heading.hidden = !visible;
        var groupCount = 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).
      var fold = 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) {
      var anyVisible = false;
      century.querySelectorAll('.cell[data-filter]').forEach(function (cell) {
        var match = !q || hay(cell).indexOf(q) !== -1;
        cell.classList.toggle('is-dim', !match);
        if (match) { anyVisible = true; matched += 1; }
      });
      century.hidden = !anyVisible;
    });
    var count = pane.querySelector('[data-filter-count]');
    if (count) {
      var total = 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.
  function paintPaneFilter() {
    var input = document.querySelector('.pane-filter');
    if (!input) { return; }
    input.value = filterQuery(input);
    applyPaneFilter(input);
  }
  document.addEventListener('DOMContentLoaded', paintPaneFilter);

  document.addEventListener('input', function (ev) {
    var input = 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(new CustomEvent('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) {
    var form = ev.target.closest && ev.target.closest('#vault-recover');
    if (!form || !window.Vault) { return; }
    ev.preventDefault();
    var errorBox = form.querySelector('#vault-recover-error');
    var fail = function (message) { errorBox.textContent = message; errorBox.hidden = false; };
    var password = form.querySelector('#vault-recover-password').value;
    (async function () {
      try {
        var status = await fetch('/vault/status', { credentials: 'same-origin' });
        if (!status.ok) { return fail('vault status unavailable'); }
        var info = await status.json();
        if (!info.vault) { return fail('no vault to unlock'); }
        var pk = await Vault.derivePkFor(password, info);
        var creds = Vault.stored();
        var resp;
        if (creds && creds.pk !== pk) {
          // an old password: carry the vault forward (re-keyed) to the current sign-in's PK
          resp = await Vault.postJson('/vault/rekey',
            { old_pk: pk, new_pk: creds.pk, new_salt: creds.salt });
        } else {
          resp = await Vault.postJson('/vault/unlock', { pk: pk, salt: info.salt });
        }
        if (resp.status === 409) { return fail('that password does not unlock this vault'); }
        if (!resp.ok) { return fail('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; }
    var path = String(ev.data.path || '');
    if (path.charAt(0) !== '/' || path.charAt(1) === '/') { return; }   // same-origin paths only
    if (!window.htmx) { window.location.assign(path); return; }         // no-JS-htmx fallback
    window.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.
  function currentPage() { return location.pathname + location.search; }
  document.addEventListener('click', function (ev) {
    var link = 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();
      var menu = link.closest('details');
      if (menu) { menu.open = false; }
      return;
    }
    link.href = '/login?next=' + encodeURIComponent(currentPage());
  }, true);
  document.addEventListener('submit', function (ev) {
    var form = ev.target;
    if (!form.matches || !form.matches('form[data-logout]')) { return; }
    var field = form.querySelector('input[name="next"]');
    if (field) { field.value = currentPage(); }
  }, true);
})();