commit ac11716a810e6c093c4d293afdaaca0184322056
parent ecaf256df25d68630d9af7ece663d4fb6e2b5a2e
Author: Michael Neuper <michael@michaelneuper.com>
Date: Mon, 12 Oct 2026 22:23:58 +0200
add animation when finding all flags
Diffstat:
5 files changed, 251 insertions(+), 7 deletions(-)
diff --git a/astro.config.mjs b/astro.config.mjs
@@ -27,6 +27,9 @@ export default defineConfig({
// Code takes its colours from the current Emacs theme (the --astro-code-* variables in src/styles/post/org.css).
shikiConfig: { theme: "css-variables" },
},
+ // Minifying strips comments, but legal ones (//! or /*!) stay in the scripts,
+ // for anyone reading them in the browser's devtools (src/scripts/ctf.js has one).
+ vite: { build: { rolldownOptions: { output: { comments: { legal: true } } } } },
// Pages from the Hugo site that now live on the home page.
redirects: {
"/posts": "/#posts",
diff --git a/src/scripts/ctf.js b/src/scripts/ctf.js
@@ -1,9 +1,13 @@
// Three beginner CTF flags hidden around the site. This file keeps only their
// SHA-256 hashes, so reading it shows how flags are checked without giving them away.
+// Finding all three runs M-x zone (src/scripts/zone.js), which the submit
+// button then turns into, and the modeline shows root.
import { $ } from "./util.js";
import { defcommand, message, completingRead, setSegment, call } from "./emacs/index.js";
+import { zone } from "./zone.js";
+//! Not that easy :) These are SHA-256 hashes, so the flags aren't in here.
const HASHES = [
"7a80f308e7d3c86589ac38394f24154bf36149b8ad833f728c9e0ddeed29693e",
"b8af26fa58465fa7be727749c616d906054f12928d817dd34d94284943d6f02e",
@@ -24,15 +28,27 @@ async function sha256(text) {
export function initCtf() {
const progress = $(".ctf-progress");
+ const button = $('[data-ctf="submit"]');
let found = load();
+ const count = () => [...found].filter((h) => HASHES.includes(h)).length;
+ const DONE = "All three flags found. M-x zone is yours now.";
const paint = () => {
- const n = [...found].filter((h) => HASHES.includes(h)).length;
+ const n = count();
+ const all = n === 3;
if (progress) {
- progress.textContent = n === 3 ? "3 of 3 found. Nicely done." : `${n} of 3 found`;
- progress.classList.toggle("done", n === 3);
+ progress.textContent = all ? "3 of 3 found" : `${n} of 3 found`;
+ progress.classList.toggle("done", all);
}
- setSegment("flags", n ? `flags ${n}/3` : "");
+ if (button) {
+ button.textContent = all ? "M-x zone" : "submit a flag";
+ button.classList.toggle("is-done", all);
+ }
+ setSegment("flags", all ? "# root" : n ? `flags ${n}/3` : "");
+ // M-x only lists zone once it has been earned
+ defcommand("zone", () => (count() === 3 ? zone() : message("Find all three flags first.")), {
+ doc: "Let the text on screen drip apart, then put it back", hidden: !all,
+ });
};
paint();
@@ -46,8 +62,12 @@ export function initCtf() {
found.add(h);
save(found);
paint();
- const n = [...found].filter((x) => HASHES.includes(x)).length;
- message(n === 3 ? "Correct. That’s all three." : `Correct. ${n} of 3 found.`, "ok", 6000);
+ const n = count();
+ if (n === 3) {
+ message("Correct. That’s all three.", "ok", 6000);
+ // once the minibuffer has closed, so its text isn't caught mid-way
+ setTimeout(() => zone({ done: DONE }), 500);
+ } else message(`Correct. ${n} of 3 found.`, "ok", 6000);
}
defcommand("submit-flag", submit, { doc: "Check a CTF flag you found on this site", key: "SPC s f" });
@@ -60,7 +80,8 @@ export function initCtf() {
message(fp);
}
}, { doc: "Copy my GPG key fingerprint", key: "SPC s g" });
- $('[data-ctf="submit"]')?.addEventListener("click", () => call("submit-flag"));
+ //! Not that easy :) Your guess is hashed and compared, so there are no flags to find here.
+ button?.addEventListener("click", () => call(count() === 3 ? "zone" : "submit-flag"));
// One flag lives in the console.
console.log(
diff --git a/src/scripts/zone.js b/src/scripts/zone.js
@@ -0,0 +1,202 @@
+// M-x zone, after Emacs's zone.el: the reward for finding all three flags.
+// Every character on screen comes loose and drips off the bottom while its
+// bits flip, then they all fly back and correct themselves as they land, the
+// way the name decodes on load. Any key, tap or scroll brings them back early.
+//
+// The page is copied onto a canvas one character at a time (text only: the
+// chip and the demos fade out under it), so nothing in the page itself moves.
+
+import { $, reducedMotion, onFrame } from "./util.js";
+import { message, setSegment } from "./emacs/index.js";
+import "../styles/zone.css";
+
+const rand = (n) => Math.floor(Math.random() * n);
+const printable = (v) => v >= 0x21 && v < 0x7f;
+const ease = (t) => 1 - Math.pow(1 - Math.min(1, Math.max(0, t)), 3);
+const MAX_CHARS = 4000;
+
+// every visible character in <main>, with where it sits and how it's drawn
+function collect() {
+ const W = innerWidth, H = innerHeight;
+ const chars = [];
+ const styles = new Map();
+ const range = document.createRange();
+ const walker = document.createTreeWalker($("main"), NodeFilter.SHOW_TEXT);
+ for (let node; (node = walker.nextNode()) && chars.length < MAX_CHARS;) {
+ const text = node.data;
+ if (!text.trim()) continue;
+ const el = node.parentElement;
+ if (!el || el.closest("script, style, noscript, [hidden], .sr-only, [aria-hidden='true'] svg")) continue;
+ range.selectNodeContents(node);
+ const box = range.getBoundingClientRect();
+ if (box.bottom < 0 || box.top > H || box.right < 0 || box.left > W || !box.width) continue;
+ let st = styles.get(el);
+ if (!st) {
+ const cs = getComputedStyle(el);
+ if (cs.visibility !== "visible" || +cs.opacity === 0) continue;
+ st = { font: `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`, color: cs.color };
+ styles.set(el, st);
+ }
+ for (let i = 0; i < text.length && chars.length < MAX_CHARS; i++) {
+ const ch = text[i];
+ if (/\s/.test(ch)) continue;
+ range.setStart(node, i);
+ range.setEnd(node, i + 1);
+ const r = range.getClientRects()[0];
+ if (!r || r.bottom < 0 || r.top > H || r.right < 0 || r.left > W) continue;
+ chars.push({ ch, code: ch.charCodeAt(0), x: r.left, top: r.top, ...st });
+ }
+ }
+ return chars;
+}
+
+// a byte with k bits flipped, and the bytes on the way back, all printable
+function corrupt(code, k) {
+ if (!printable(code)) return [code];
+ for (let tries = 0; tries < 30; tries++) {
+ let v = code;
+ const order = [0, 1, 2, 3, 4].sort(() => Math.random() - 0.5).slice(0, k);
+ for (const b of order) v ^= 1 << b;
+ const path = [v];
+ for (const b of order) path.push((v ^= 1 << b));
+ if (path.every(printable)) return path;
+ }
+ return [code];
+}
+
+let running = false;
+
+export function zone({ done = "" } = {}) {
+ if (running) return;
+ if (reducedMotion.matches) {
+ if (done) message(done, "ok", 6000);
+ return;
+ }
+ running = true;
+ const chars = collect();
+ const W = innerWidth, H = innerHeight;
+ const dpr = Math.min(2, devicePixelRatio || 1);
+ const cv = document.createElement("canvas");
+ cv.className = "zone";
+ cv.setAttribute("aria-hidden", "true");
+ cv.width = W * dpr;
+ cv.height = H * dpr;
+ document.body.append(cv);
+ const ctx = cv.getContext("2d");
+ ctx.scale(dpr, dpr);
+ const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim() || "#000";
+
+ // the baseline of each font, from the top of a character's box
+ const ascent = new Map();
+ for (const c of chars) {
+ if (!ascent.has(c.font)) {
+ ctx.font = c.font;
+ const m = ctx.measureText("Hg");
+ ascent.set(c.font, m.fontBoundingBoxAscent ?? m.actualBoundingBoxAscent);
+ }
+ c.y0 = c.top + ascent.get(c.font);
+ c.y = c.y0;
+ c.cx = c.x;
+ c.vy = 0;
+ c.g = 1500 + Math.random() * 1500;
+ c.delay = 0.25 + Math.random() * 1.2;
+ c.flipAt = c.delay + Math.random() * 0.2;
+ c.shown = c.code;
+ }
+ // drawn a font at a time, so the canvas switches fonts as little as possible
+ chars.sort((a, b) => (a.font < b.font ? -1 : a.font > b.font ? 1 : 0));
+
+ const mode = $(".ml-mode")?.textContent ?? "";
+ setSegment("mode", "Zone");
+
+ // input during the drip brings everything back; the page doesn't scroll meanwhile
+ const block = (e) => e.preventDefault();
+ const early = () => { if (phase === "drip" || phase === "hold") back(); };
+ const opts = { capture: true, passive: false };
+ addEventListener("wheel", block, opts);
+ addEventListener("touchmove", block, opts);
+ for (const t of ["keydown", "pointerdown", "wheel", "touchstart"]) addEventListener(t, early, { capture: true });
+ const keys = (e) => { if ([" ", "PageDown", "PageUp", "ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) e.preventDefault(); };
+ addEventListener("keydown", keys, true);
+
+ let phase = "drip";
+ let t = 0;
+ let tBack = 0;
+ let fade = 0; // how much of the page is covered: 0 to 1
+
+ function back() {
+ phase = "back";
+ tBack = t;
+ for (const c of chars) {
+ c.fromX = c.cx;
+ // the ones that fell off the bottom come back up from below it
+ c.fromY = c.y > H + 30 ? H + 40 + Math.random() * 240 : c.y;
+ c.backDelay = Math.random() * 0.45;
+ c.path = corrupt(c.code, 2 + rand(2));
+ }
+ if (done) message(done, "ok", 6000);
+ }
+
+ const stop = onFrame((_, dt) => {
+ t += dt;
+ if (phase === "drip") {
+ fade = Math.min(1, t / 0.2);
+ let left = 0;
+ for (const c of chars) {
+ if (t < c.delay) { left++; continue; }
+ c.vy += c.g * dt;
+ c.y += c.vy * dt;
+ if (t >= c.flipAt && printable(c.shown)) {
+ const v = c.shown ^ (1 << rand(5));
+ if (printable(v)) c.shown = v;
+ c.flipAt = t + 0.06 + Math.random() * 0.16;
+ }
+ if (c.y < H + 30) left++;
+ }
+ if (!left || t > 3.4) { phase = "hold"; tBack = t; }
+ } else if (phase === "hold") {
+ if (t - tBack > 0.35) back();
+ } else if (phase === "back") {
+ const s = t - tBack;
+ let moving = 0;
+ for (const c of chars) {
+ const p = ease((s - c.backDelay) / 0.9);
+ c.cx = c.fromX + (c.x - c.fromX) * p;
+ c.y = c.fromY + (c.y0 - c.fromY) * p;
+ c.shown = c.path[Math.min(c.path.length - 1, Math.floor(p * c.path.length))];
+ if (p < 1) moving++;
+ }
+ if (!moving) { phase = "out"; tBack = t; }
+ } else if (phase === "out") {
+ fade = 1 - Math.min(1, (t - tBack) / 0.25);
+ if (fade <= 0) {
+ finish();
+ return;
+ }
+ }
+
+ ctx.clearRect(0, 0, W, H);
+ ctx.globalAlpha = fade;
+ ctx.fillStyle = bg;
+ ctx.fillRect(0, 0, W, H);
+ ctx.globalAlpha = 1;
+ let font = "";
+ for (const c of chars) {
+ if (c.y > H + 60 || c.y < -60) continue;
+ if (c.font !== font) ctx.font = font = c.font;
+ ctx.fillStyle = c.color;
+ ctx.fillText(c.shown === c.code ? c.ch : String.fromCharCode(c.shown), c.cx, c.y);
+ }
+ });
+
+ function finish() {
+ stop();
+ cv.remove();
+ removeEventListener("wheel", block, opts);
+ removeEventListener("touchmove", block, opts);
+ for (const tt of ["keydown", "pointerdown", "wheel", "touchstart"]) removeEventListener(tt, early, { capture: true });
+ removeEventListener("keydown", keys, true);
+ setSegment("mode", mode);
+ running = false;
+ }
+}
diff --git a/src/styles/home/contact.css b/src/styles/home/contact.css
@@ -108,6 +108,12 @@
color: var(--green);
}
+/* once all three are found, the button runs M-x zone */
+.ctf-row .btn.is-done {
+ border-color: var(--green);
+ color: var(--green);
+}
+
@media (max-width: 920px) {
.contact-grid {
grid-template-columns: minmax(0, 1fr);
diff --git a/src/styles/zone.css b/src/styles/zone.css
@@ -0,0 +1,12 @@
+/* M-x zone (src/scripts/zone.js): the canvas the page's text drips down. It sits
+ over the page but under the top line and the modeline, and takes the clicks
+ meant for the page while it runs. */
+
+.zone {
+ position: fixed;
+ inset: 0;
+ z-index: 15;
+ width: 100%;
+ height: 100%;
+ cursor: default;
+}