Gunnar

nordisk/gunnar-ui

crates/gunnar-ui-web/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="color-scheme" content="light" />
    <title>Gunnar</title>
    <!-- ── The theme, before the first pixel ──────────────────────────────
         The console is LIGHT by default, dark or surprise by choice (the
         names are `crate::theme`'s, which a test holds this script to), and the choice
         lives in `localStorage` because it belongs to a viewer and not to a
         deployment. The console itself can only apply it once the wasm has
         mounted, which is a whole white flash after the browser has already
         painted — so the stored choice is read here, in four lines, before
         anything renders.

         `prefers-color-scheme` is deliberately NOT consulted: white is the
         default that was asked for, and a system setting that silently
         overturns it is not a default. Wrapped in try/catch because reading
         storage THROWS in a private window rather than answering null, and a
         console that fails to start over a colour would be a poor trade.

         Every inline script in this file is hashed into `script-src` at
         startup (`security::inline_script_hashes`, over the built
         `index.html`), so this one needs no 'unsafe-inline' and no exception. -->
    <script>
      (function () {
        try {
          var t = localStorage.getItem("gunnar-theme");
          if (t === "dark" || t === "surprise") {
            document.documentElement.setAttribute("data-theme", t);
          }
        } catch (e) { /* private window: the default stands */ }
      })();
    </script>
    <link data-trunk rel="rust" data-wasm-opt="z" />
    <link data-trunk rel="css" href="styles/console.css" />
    <link data-trunk rel="icon" href="styles/favicon.svg" />
    <link data-trunk rel="copy-file" href="assets/gunnar.webp" />
    <link data-trunk rel="copy-file" href="assets/gunnar_light.webp" />
    <link data-trunk rel="copy-file" href="assets/gunnar_dark.webp" />
    <link data-trunk rel="copy-file" href="assets/gunnar_surprise.webp" />
    <!-- The bite. It WAS the stage the "no wasm" sentence rolls over, until
         2026-09-13 put the login page's own picture there instead; it is kept
         in the bundle as one more face of the badger, beside the snarl below,
         for whatever page asks. Nothing references it today. -->
    <link data-trunk rel="copy-file" href="assets/gunnar_bite.webp" />
    <!-- The snarl: a third face of the badger, served for whatever page asks. -->
    <link data-trunk rel="copy-file" href="assets/gunnar_snarl.webp" />
    <link data-trunk rel="copy-file" href="assets/gunnar_perfect.webp" />
    <!-- The login page's left half: the badger in silver, full bleed beside
         the entry block. Public with no session, like every other flat asset
         at the root. -->
    <link data-trunk rel="copy-file" href="assets/gunnar_silver.webp" />
    <link data-trunk rel="copy-file" href="assets/ignalina.png" />
    <link data-trunk rel="copy-file" href="assets/vetra.svg" />
    <link data-trunk rel="copy-file" href="assets/gunnar-fast-setup.pdf" />
    <!-- The surprise theme's face: Manrope (SIL OFL 1.1), three static weights
         cut from the variable font and subset to the Latin the console writes,
         woff2. BUNDLED, never fetched from a font CDN: an estate may have no
         way out to the internet. Only `:root[data-theme="surprise"]` names the
         face, so a light or dark console never downloads it. The licence
         travels beside the files (and inside each, in its name table). -->
    <link data-trunk rel="copy-file" href="assets/manrope-400.woff2" />
    <link data-trunk rel="copy-file" href="assets/manrope-600.woff2" />
    <link data-trunk rel="copy-file" href="assets/manrope-800.woff2" />
    <link data-trunk rel="copy-file" href="assets/manrope-OFL.txt" />
    <!-- ★ **A blank page must say why — loudly.** The body is empty until the
         wasm console mounts, so every way the console
         can fail to start (the .wasm refused by a corporate proxy, WebAssembly
         disabled by policy, a module error) looks identical from a chair: an
         empty page. Measured 2026-09-08: a visitor on a corporate network clicked a
         public repository on the login page and saw exactly that, in every
         browser, while every asset answered 200 from outside.

         The rule for what to show instead: the badger, and over him a
         big glowing red sentence that bounces and rolls around the screen like
         a roller coaster — annoying on purpose, and readable the whole time.
         Readable is the constraint the animation is built under: the text
         never turns past 55° on any axis (so it is never mirrored), the glow
         is on the shadow and not the glyphs, the glyphs stay a flat near-black
         (achromatic, so the hue cycling cannot touch them — it was white when
         this page was dark, and the property that matters is that the glyphs
         take no hue), and the cycling touches only the glow. Under a
         reduced-motion preference it holds still and just glows. The real cause is printed small
         beneath it, because "sucks" is a verdict and the operator still needs
         the fact.

         `typeof WebAssembly` is asked FIRST, before any wait: a browser with
         wasm switched off by policy knows it at once, and twenty seconds of
         nothing before the sentence would be twenty seconds of the old bug. The
         server hashes this script into `script-src`, like trunk's bootstrap,
         so it runs under the CSP with no 'unsafe-inline'; the stylesheet is
         inline because the page's own may be the thing that failed. -->
    <style>
      .nowasm { position: fixed; inset: 0; overflow: hidden; margin: 0;
        background: #f4f4f4 url("/gunnar_silver.webp") center / contain no-repeat;
        perspective: 900px; font-family: system-ui, "Segoe UI", sans-serif; }
      /* `contain`, not `cover`, and the ground is the page's own #f4f4f4.
         This is the SAME picture the login page carries, and it is PORTRAIT
         (574x1024) where the one that used to be here was landscape
         (1392x768). Covering a wide screen with it would scale it to the width
         and crop some 70% of its height — a zoomed band across the badger's
         middle. Contained, the whole animal is there, and the letterboxing on
         either side is invisible because #f4f4f4 IS the mean of that picture's
         own edge: the value was measured off it, which is the only reason
         this works.

         A light scrim, not a dark one: the console is white now, and the
         near-black artwork is what this wash turns back into a texture the
         ink can be read over. */
      .nowasm::after { content: ""; position: absolute; inset: 0; background: rgba(244,244,244,.62); }
      .nowasm .coaster { position: absolute; left: 50%; top: 50%; width: min(90vw, 1100px);
        transform-style: preserve-3d; animation: coaster 9s ease-in-out infinite; z-index: 1; }
      .nowasm .shout { margin: 0; text-align: center; color: #14100e; font-weight: 900; line-height: 1.05;
        font-size: clamp(28px, 6vw, 84px); letter-spacing: .01em; transform-style: preserve-3d;
        text-shadow: 0 0 6px #fff, 0 0 18px #ff1a1a, 0 0 36px #ff3a3a, 0 0 70px #ff6a6a, 4px 4px 0 #ffc7c7, 8px 8px 0 #ffe2e2;
        animation: lsd 3s linear infinite; }
      .nowasm .why { position: absolute; left: 0; right: 0; bottom: 6vh; z-index: 2; text-align: center;
        color: #14100e; font: 14px/1.5 ui-monospace, monospace; padding: .5rem 1rem; margin: 0 auto; max-width: 70rem; background: rgba(244,244,244,.86); border-radius: 8px; text-shadow: 0 1px 3px #f4f4f4; }
      .nowasm .why code { color: #a11414; }
      @keyframes coaster {
        0%   { transform: translate(-50%,-50%) translate3d(-18vw, -14vh, -200px) rotateX(-30deg) rotateY(-45deg) rotateZ(-14deg); }
        20%  { transform: translate(-50%,-50%) translate3d( 18vw, -10vh,  120px) rotateX( 25deg) rotateY( 40deg) rotateZ( 10deg); }
        40%  { transform: translate(-50%,-50%) translate3d( 12vw,  12vh, -120px) rotateX(-35deg) rotateY(-30deg) rotateZ( 22deg); }
        60%  { transform: translate(-50%,-50%) translate3d(-20vw,  10vh,  160px) rotateX( 30deg) rotateY( 50deg) rotateZ(-20deg); }
        80%  { transform: translate(-50%,-50%) translate3d(  4vw, -14vh,  -60px) rotateX(-20deg) rotateY(-55deg) rotateZ(  6deg); }
        100% { transform: translate(-50%,-50%) translate3d(-18vw, -14vh, -200px) rotateX(-30deg) rotateY(-45deg) rotateZ(-14deg); }
      }
      @keyframes lsd { to { filter: hue-rotate(360deg); } }
      @media (prefers-reduced-motion: reduce) {
        .nowasm .coaster, .nowasm .shout { animation: none; }
        .nowasm .coaster { transform: translate(-50%,-50%); }
      }
    </style>
    <script>
      (function () {
        var started = false, shown = false;
        addEventListener("TrunkApplicationStarted", function () { started = true; });
        function el(tag, cls, text) {
          var e = document.createElement(tag);
          if (cls) e.className = cls;
          if (text) e.textContent = text;
          return e;
        }
        function explain(why) {
          if (started || shown) return;
          // ★ This script runs in <head>, so on the FIRST path into here —
          // `typeof WebAssembly === "undefined"`, asked immediately and on
          // purpose — there is no <body> yet and `document.body.replaceChildren`
          // throws, leaving the visitor the blank page this whole block exists
          // to replace. Measured 2026-09-13 in a headless browser with the
          // check forced: white, silent, nothing. Waiting for the document is
          // the fix; every later path (an error, the 20 s timeout) already has
          // a body and goes straight through.
          if (!document.body) {
            addEventListener("DOMContentLoaded", function () { explain(why); });
            return;
          }
          shown = true;
          var stage = el("main", "nowasm");
          var coaster = el("div", "coaster");
          var shout = el("h1", "shout", "Your browser sucks, WASM disabled! Turn it on, or go back to your old soggy git solution.");
          coaster.appendChild(shout);
          var note = el("p", "why");
          note.appendChild(el("span", null, "Gunnar's console is a WebAssembly application and it never started. The browser said: "));
          note.appendChild(el("code", null, String(why && (why.message || why))));
          note.appendChild(el("br"));
          note.appendChild(el("span", null, "On a corporate network that is usually a proxy blocking application/wasm or a browser policy switching WebAssembly off. Git over SSH on port 2222 is unaffected."));
          stage.appendChild(coaster);
          stage.appendChild(note);
          document.body.replaceChildren(stage);
        }
        if (typeof WebAssembly === "undefined") {
          explain("WebAssembly is not defined — it is switched off in this browser");
          return;
        }
        addEventListener("unhandledrejection", function (e) { explain(e.reason); });
        addEventListener("error", function (e) {
          // Capture phase, so a resource that failed to LOAD is seen too —
          // and named, because "undefined" is the one word worse than black.
          if (e.target && e.target !== window) {
            var src = e.target.src || e.target.href;
            if (src && /\.(wasm|js)(\?|$)/.test(src)) explain("the browser could not load " + src);
            return;
          }
          explain(e.error || e.message);
        }, true);
        setTimeout(function () { explain("nothing mounted within 20 s and no error was raised — most often the .wasm download was blocked or never finished"); }, 20000);
      })();
    </script>
    <noscript><p style="font: 15px system-ui; color: #14100e; background: #f2ece4; padding: 2rem;">Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.</p></noscript>
  </head>
  <body></body>
</html>