<!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>