commit b83023eb74830c9b640c92eac38068340fdd8f58
parent ac11716a810e6c093c4d293afdaaca0184322056
Author: Michael Neuper <michael@michaelneuper.com>
Date: Mon, 12 Oct 2026 23:24:34 +0200
add tips
Diffstat:
20 files changed, 612 insertions(+), 234 deletions(-)
diff --git a/src/components/frame/Help.astro b/src/components/frame/Help.astro
@@ -13,6 +13,10 @@ import "../../styles/frame/help.css";
<div><dt><kbd>M-x</kbd></dt><dd>run a command by name (Alt+x or Option+x)</dd></div>
<div><dt><kbd>SPC h t</kbd></dt><dd>load a theme, with live preview</dd></div>
<div><dt><kbd>:</kbd></dt><dd>evil ex commands, like <code>:q</code></dd></div>
+ <div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>scroll down, up</dd></div>
+ <div><dt><kbd>d</kbd> <kbd>u</kbd></dt><dd>half a screen down, up (<kbd>C-d</kbd> <kbd>C-u</kbd> too on a Mac)</dd></div>
+ <div><dt><kbd>gg</kbd> <kbd>G</kbd></dt><dd>top, bottom</dd></div>
+ <div><dt><kbd>/</kbd></dt><dd>search the page; then <kbd>n</kbd> <kbd>N</kbd> for the next, previous match, and <kbd>esc</kbd> to clear</dd></div>
<div><dt><kbd>C-g</kbd></dt><dd>cancel (so does <kbd>esc</kbd>)</dd></div>
</dl>
</div>
@@ -21,6 +25,9 @@ import "../../styles/frame/help.css";
<dl>
<slot />
<div><dt><code>submit-flag</code></dt><dd>check a CTF flag</dd></div>
+ {/* shown once the three CTF flags are found (src/scripts/ctf.js) */}
+ <div data-unlock="butterfly" hidden><dt><code>butterfly</code></dt><dd>flip one bit, as in xkcd 378</dd></div>
+ <div><dt><code>morse-region</code></dt><dd>turn the selected text into Morse code (<code>unmorse-region</code> turns it back)</dd></div>
</dl>
</div>
</div>
diff --git a/src/components/frame/Modeline.astro b/src/components/frame/Modeline.astro
@@ -11,6 +11,7 @@ interface Props {
const { buf, mode } = Astro.props;
---
+<button class="btn to-top" type="button" title="Back to the top (gg)" hidden>back to top</button>
<div class="which-key" hidden aria-live="polite"></div>
<div class="frame-bottom">
<div class="modeline">
diff --git a/src/scripts/ctf.js b/src/scripts/ctf.js
@@ -1,11 +1,11 @@
// 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.
+// Finding all three unlocks M-x butterfly (src/scripts/emacs/play.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";
+import { butterfly } from "./emacs/play.js";
//! Not that easy :) These are SHA-256 hashes, so the flags aren't in here.
const HASHES = [
@@ -31,7 +31,6 @@ export function initCtf() {
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 = count();
@@ -41,14 +40,16 @@ export function initCtf() {
progress.classList.toggle("done", all);
}
if (button) {
- button.textContent = all ? "M-x zone" : "submit a flag";
+ button.textContent = all ? "M-x butterfly" : "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,
+ // M-x only lists butterfly once it has been earned, and *Help* only then mentions it
+ defcommand("butterfly", () => (count() === 3 ? butterfly() : message("Find all three flags first.")), {
+ doc: "Use butterflies to flip the desired bit on the drive platter", hidden: !all,
});
+ const help = $('.help [data-unlock="butterfly"]');
+ if (help) help.hidden = !all;
};
paint();
@@ -64,9 +65,7 @@ export function initCtf() {
paint();
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);
+ message("Correct. That’s all three. M-x butterfly is yours now.", "ok", 8000);
} else message(`Correct. ${n} of 3 found.`, "ok", 6000);
}
@@ -81,7 +80,7 @@ export function initCtf() {
}
}, { doc: "Copy my GPG key fingerprint", key: "SPC s g" });
//! 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"));
+ button?.addEventListener("click", () => call(count() === 3 ? "butterfly" : "submit-flag"));
// One flag lives in the console.
console.log(
diff --git a/src/scripts/demos/scheduler.js b/src/scripts/demos/scheduler.js
@@ -7,6 +7,13 @@ const CORES = 4;
const SPAN = 48;
const QUANTUM = 3;
+// what each policy does, shown under the chart for the one that's picked
+const POLICY = {
+ fcfs: "First come, first served runs each process to completion.",
+ rr: `Round robin preempts a process after ${QUANTUM} ticks.`,
+ prio: "Preemptive priority always runs the highest-priority processes (p1 is highest), so a new arrival can take a core from a lower-priority one.",
+};
+
export function mount(host) {
// with reduced motion the chart starts still, until you unpause it
let paused = reducedMotion.matches;
@@ -22,11 +29,14 @@ export function mount(host) {
<span class="dim" data-k="stats"></span>
</div>
<canvas height="150" style="height:150px" aria-label="Gantt chart of processes running on four cores" role="img"></canvas>
- <p class="cap">Four cores share one ready queue. First come, first served runs each process to completion. Round robin preempts a process after ${QUANTUM} ticks. Preemptive priority always runs the highest-priority processes (p1 is highest), so a new arrival can take a core from a lower-priority one.</p>`;
+ <p class="cap" data-k="policy" aria-live="polite"></p>`;
const cv = host.querySelector("canvas");
const sel = host.querySelector("select");
const stats = host.querySelector('[data-k="stats"]');
const pauseBtn = host.querySelector('[data-k="pause"]');
+ const policy = host.querySelector('[data-k="policy"]');
+ const describe = () => { policy.textContent = `Four cores share one ready queue. ${POLICY[sel.value]}`; };
+ describe();
let t = 0;
let nextId = 1;
@@ -204,6 +214,7 @@ export function mount(host) {
done = 0;
waitSum = 0;
preempted = 0;
+ describe();
showStats();
draw();
});
diff --git a/src/scripts/emacs/echo.js b/src/scripts/emacs/echo.js
@@ -7,7 +7,9 @@ const echo = $(".echo");
let echoTimer = 0;
const idleText = () => (finePointer.matches ? "Press SPC for commands, or M-x to run one by name." : "");
-export function message(text, kind = "", ms = 5000) {
+// quiet: shown but not read out, for the tips, which come round every minute or so
+export function message(text, kind = "", ms = 5000, { quiet = false } = {}) {
+ echo.setAttribute("aria-live", quiet ? "off" : "polite");
echo.textContent = text;
echo.className = kind ? `echo is-${kind}` : "echo";
clearTimeout(echoTimer);
@@ -17,3 +19,6 @@ export function message(text, kind = "", ms = 5000) {
/** Back to the idle hint. */
export const idle = () => message(idleText(), "", 0);
idle();
+
+/** Whether the echo area is showing nothing but the idle hint. */
+export const isIdle = () => echo.className === "echo" && echo.textContent === idleText();
diff --git a/src/scripts/emacs/index.js b/src/scripts/emacs/index.js
@@ -1,9 +1,12 @@
// The Emacs around every page: doom-modeline, the echo area, a minibuffer with
-// vertico-style completion (M-x, evil's :), which-key on the SPC leader, and
-// load-theme with live preview. Import from here to add commands or talk to it.
+// vertico-style completion (M-x, evil's :), which-key on the SPC leader,
+// load-theme with live preview, a couple of Emacs's jokes (play.js) and the odd
+// tip (tips.js). Import from here to add commands or talk to it.
import "./builtins.js";
import "./keys.js";
+import "./play.js";
+import "./tips.js";
export { message } from "./echo.js";
export { defcommand, call } from "./commands.js";
diff --git a/src/scripts/emacs/keys.js b/src/scripts/emacs/keys.js
@@ -1,11 +1,15 @@
// The keys: SPC opens which-key on the leader map, M-x (Alt+x) and : open the
-// minibuffer, ? opens *Help* and C-g quits. Keys typed into a field are left alone.
+// minibuffer, ? opens *Help* and C-g quits. evil's motions scroll (motions.js),
+// and / searches the page, then n and N step through the matches (search.js).
+// Keys typed into a field are left alone.
import { $, isTyping } from "../util.js";
import { message, idle } from "./echo.js";
import { call, hasCommand } from "./commands.js";
import { mx, ex } from "./mx.js";
import { toggleMenu, menuOpen } from "./menu.js";
+import { motion } from "./motions.js";
+import { search, searchNext, searchActive, clearSearch } from "./search.js";
const wk = $(".which-key");
const help = $(".help");
@@ -108,13 +112,18 @@ addEventListener("keydown", (e) => {
return;
}
if (!mini.hidden || isTyping(e)) return;
+ // The posts list has its own j and k, and a dialog or butterfly has the keys to itself.
+ const free = !e.target.closest?.("[role='tree']") && !document.querySelector("dialog[open], .butterfly");
if (e.altKey && e.code === "KeyX" && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
mx();
} else if (e.ctrlKey && e.key === "g") {
e.preventDefault();
+ clearSearch();
call("keyboard-quit");
+ } else if (free && motion(e)) {
+ // scrolled
} else if (e.metaKey || e.ctrlKey || e.altKey) {
// leave browser shortcuts alone
} else if (e.key === " " && !e.target.closest?.("button, a, select, summary, [tabindex]")) {
@@ -126,7 +135,17 @@ addEventListener("keydown", (e) => {
} else if (e.key === "?") {
e.preventDefault();
call("help");
+ } else if (free && e.key === "/") {
+ e.preventDefault();
+ search();
+ } else if (free && (e.key === "n" || e.key === "N") && searchActive()) {
+ // only while there are matches; otherwise n is the computer's step
+ e.preventDefault();
+ searchNext(e.key === "n" ? 1 : -1);
} else if (e.key === "Escape" && menuOpen()) {
toggleMenu(false);
+ } else if (e.key === "Escape" && searchActive()) {
+ clearSearch();
+ idle();
}
}, true);
diff --git a/src/scripts/emacs/modeline.js b/src/scripts/emacs/modeline.js
@@ -1,7 +1,7 @@
// doom-modeline: evil state, buffer name, position, major mode and a clock.
// Other modules fill the misc and flags segments with setSegment().
-import { $, isTyping } from "../util.js";
+import { $, isTyping, reducedMotion } from "../util.js";
const root = document.documentElement;
const modeline = $(".modeline");
@@ -27,12 +27,21 @@ document.addEventListener("focusin", evilState);
document.addEventListener("focusout", () => setTimeout(evilState));
document.addEventListener("selectionchange", evilState);
+// back to top, once the reader is a screen down; gg does the same
+const toTop = $(".to-top");
+toTop.addEventListener("click", () => {
+ scrollTo({ top: 0, behavior: reducedMotion.matches ? "instant" : "smooth" });
+ // the button hides at the top, so focus goes to the first link on the page
+ $(".brand")?.focus({ preventScroll: true });
+});
+
function onScroll() {
const max = root.scrollHeight - innerHeight;
const y = scrollY;
ml.pct.textContent = y <= 2 ? "Top" : y >= max - 2 ? "Bot" : `${Math.round((y / max) * 100)}%`;
ml.pos.textContent = `${Math.floor(y / 21) + 1}:0`;
root.classList.toggle("is-scrolled", y > 24);
+ toTop.hidden = y < innerHeight;
}
addEventListener("scroll", onScroll, { passive: true });
onScroll();
diff --git a/src/scripts/emacs/motions.js b/src/scripts/emacs/motions.js
@@ -0,0 +1,50 @@
+// evil's motions, for reading: j and k scroll a few lines, d and u half a
+// screen, gg goes to the top and G to the bottom. C-d and C-u work too on a
+// Mac; elsewhere the browser keeps them (bookmark, view source).
+
+import { reducedMotion } from "../util.js";
+import { message, idle } from "./echo.js";
+
+const LINE = 64; // px for j and k, about three lines of body text
+const mac = /mac|iphone|ipad/i.test(navigator.userAgentData?.platform ?? navigator.platform);
+
+let gAt = 0; // when g was pressed, for gg
+
+// Handles e if it's a motion and returns true, so keys.js can stop there.
+export function motion(e) {
+ // held down, a key repeats faster than a smooth scroll finishes
+ const behavior = reducedMotion.matches || e.repeat ? "instant" : "smooth";
+ const by = (dy) => scrollBy({ top: dy, behavior });
+ const half = innerHeight / 2;
+
+ if (e.ctrlKey && !e.altKey && !e.metaKey) {
+ if (!mac || (e.key !== "d" && e.key !== "u")) return false;
+ e.preventDefault();
+ by(e.key === "d" ? half : -half);
+ return true;
+ }
+ if (e.ctrlKey || e.altKey || e.metaKey) return false;
+
+ const was = gAt;
+ gAt = 0;
+ switch (e.key) {
+ case "j": by(LINE); break;
+ case "k": by(-LINE); break;
+ case "d": by(half); break;
+ case "u": by(-half); break;
+ case "G": scrollTo({ top: document.documentElement.scrollHeight, behavior }); break;
+ case "g":
+ if (performance.now() - was < 800) {
+ scrollTo({ top: 0, behavior });
+ idle();
+ } else {
+ gAt = performance.now();
+ message("g-", "", 800);
+ }
+ break;
+ default:
+ return false;
+ }
+ e.preventDefault();
+ return true;
+}
diff --git a/src/scripts/emacs/play.js b/src/scripts/emacs/play.js
@@ -0,0 +1,203 @@
+// Two of Emacs's jokes, as it has them.
+//
+// M-x butterfly is xkcd 378: real programmers flap a butterfly's wings once, and
+// the air currents focus a cosmic ray onto the drive platter to flip the bit
+// they want. Emacs asks if you really want to unleash the powers of the
+// butterfly, plays "Amazing physics going on..." in a *butterfly* buffer, and
+// reports one flipped bit. Here the bit lands in a letter on the page. It's the
+// prize for finding the three CTF flags, so ctf.js defines the command.
+//
+// M-x morse-region and unmorse-region (play/morse.el) turn the region, here the
+// text you've selected, into Morse code and back: codes separated by slashes
+// within a word, words by their spaces.
+
+import { $, reducedMotion } from "../util.js";
+import { message, idle } from "./echo.js";
+import { defcommand } from "./commands.js";
+import { completingRead } from "./minibuffer.js";
+import { setSegment } from "./modeline.js";
+import { readable } from "./search.js";
+import "../../styles/frame/play.css";
+
+const rand = (n) => Math.floor(Math.random() * n);
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+// ── the region ────────────────────────────────────────────────────────
+
+// The last text selected on the page. It's kept while the minibuffer takes
+// focus to run a command on it, and dropped when the selection goes away
+// anywhere else, as a click deactivates the mark in Emacs.
+let region = null;
+const mini = $(".mini");
+document.addEventListener("selectionchange", () => {
+ const sel = getSelection();
+ if (sel.rangeCount && !sel.isCollapsed) {
+ const r = sel.getRangeAt(0);
+ if ($("main")?.contains(r.commonAncestorContainer)) region = r.cloneRange();
+ } else if (mini.hidden) {
+ region = null;
+ }
+});
+
+// Rewrite the text in a range, a text node at a time, with fn(text, state).
+function rewrite(range, fn) {
+ const root = range.commonAncestorContainer;
+ const nodes = [];
+ if (root.nodeType === Node.TEXT_NODE) nodes.push(root);
+ else {
+ const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
+ acceptNode: (n) => (range.intersectsNode(n) && readable(n.parentElement) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT),
+ });
+ for (let n = walk.nextNode(); n; n = walk.nextNode()) nodes.push(n);
+ }
+ const state = {};
+ for (const n of nodes) {
+ const from = n === range.startContainer ? range.startOffset : 0;
+ const to = n === range.endContainer ? range.endOffset : n.data.length;
+ n.data = n.data.slice(0, from) + fn(n.data.slice(from, to), state) + n.data.slice(to);
+ }
+}
+
+function onRegion(fn) {
+ if (!region || !region.startContainer.isConnected) {
+ return message("The mark is not set now, so there is no region", "error");
+ }
+ rewrite(region, fn);
+ region = null;
+ getSelection().removeAllRanges();
+}
+
+// ── morse ─────────────────────────────────────────────────────────────
+
+// ITU Morse, without the slash, which separates the letters
+const MORSE = {
+ a: ".-", b: "-...", c: "-.-.", d: "-..", e: ".", f: "..-.", g: "--.", h: "....", i: "..", j: ".---",
+ k: "-.-", l: ".-..", m: "--", n: "-.", o: "---", p: ".--.", q: "--.-", r: ".-.", s: "...", t: "-",
+ u: "..-", v: "...-", w: ".--", x: "-..-", y: "-.--", z: "--..",
+ 0: "-----", 1: ".----", 2: "..---", 3: "...--", 4: "....-", 5: ".....", 6: "-....", 7: "--...", 8: "---..", 9: "----.",
+ ".": ".-.-.-", ",": "--..--", "?": "..--..", "'": ".----.", "!": "-.-.--", "(": "-.--.", ")": "-.--.-", "&": ".-...",
+ ":": "---...", ";": "-.-.-.", "=": "-...-", "+": ".-.-.", "-": "-....-", "_": "..--.-", "\"": ".-..-.", "$": "...-..-", "@": ".--.-.",
+};
+const UNMORSE = Object.fromEntries(Object.entries(MORSE).map(([k, v]) => [v, k]));
+
+// As morse.el: each code after a slash, except the first in a word.
+function toMorse(text, state) {
+ let out = "";
+ for (const ch of text) {
+ const code = MORSE[ch.toLowerCase()];
+ if (/\s/.test(ch)) { out += ch; state.sep = ""; }
+ else if (code) { out += (state.sep ?? "") + code; state.sep = "/"; }
+ else { out += ch; state.sep = ""; }
+ }
+ return out;
+}
+
+// Each run of dots and dashes it knows becomes its letter, and the slash after it goes.
+const fromMorse = (text) => text.replace(/([.-]+)\/?/g, (m, code) => UNMORSE[code] ?? m);
+
+defcommand("morse-region", () => onRegion(toMorse), { doc: "Convert all text in a given region to morse code" });
+defcommand("unmorse-region", () => onRegion(fromMorse), { doc: "Convert morse coded text in region to ordinary ASCII text" });
+
+// ── butterfly ─────────────────────────────────────────────────────────
+
+const PHYSICS = "Amazing physics going on...";
+let flapping = false;
+
+// A letter or digit in view, and the bit to flip: one that leaves it printable
+// (and not a backtick, which the serif font lacks).
+function target() {
+ const top = 60, bottom = innerHeight - 90;
+ const nodes = [];
+ const walk = document.createTreeWalker($("main") ?? document.body, NodeFilter.SHOW_TEXT, {
+ acceptNode: (n) => {
+ if (!/[a-z0-9]/i.test(n.data) || !readable(n.parentElement)) return NodeFilter.FILTER_REJECT;
+ const r = n.parentElement.getBoundingClientRect();
+ return r.bottom > top && r.top < bottom ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
+ },
+ });
+ for (let n = walk.nextNode(); n; n = walk.nextNode()) nodes.push(n);
+ for (let tries = 0; tries < 60 && nodes.length; tries++) {
+ const n = nodes[rand(nodes.length)];
+ const i = rand(n.data.length);
+ if (!/[a-z0-9]/i.test(n.data[i])) continue;
+ const r = new Range();
+ r.setStart(n, i);
+ r.setEnd(n, i + 1);
+ const box = r.getBoundingClientRect();
+ if (box.top < top || box.bottom > bottom) continue;
+ const code = n.data.charCodeAt(i);
+ const bits = [0, 1, 2, 3, 4, 5].filter((b) => {
+ const v = code ^ (1 << b);
+ return v > 0x20 && v < 0x7f && v !== 0x60;
+ });
+ return { n, i, bit: bits[rand(bits.length)] };
+ }
+ return null;
+}
+
+export async function butterfly() {
+ if (flapping) return;
+ const answer = await completingRead("Do you really want to unleash the powers of the butterfly? (yes or no) ", ["yes", "no"]);
+ if (answer === null) return message("Quit");
+ if (answer === "no") {
+ message("Well, then go to xkcd.com!");
+ window.open("https://xkcd.com/378/", "_blank", "noopener");
+ return;
+ }
+ flapping = true;
+ idle();
+
+ // the *butterfly* buffer, with the physics flying into place, as animate-string does
+ const buf = $(".ml-buf")?.textContent ?? "";
+ const mode = $(".ml-mode")?.textContent ?? "";
+ setSegment("buf", "*butterfly*");
+ setSegment("mode", "Fundamental");
+ const screen = document.createElement("div");
+ screen.className = "butterfly";
+ screen.setAttribute("role", "status");
+ const line = document.createElement("p");
+ line.setAttribute("aria-label", PHYSICS);
+ for (const ch of PHYSICS) {
+ const s = document.createElement("span");
+ s.setAttribute("aria-hidden", "true");
+ s.textContent = ch;
+ if (!reducedMotion.matches) s.style.transform = `translate(${rand(innerWidth) - innerWidth / 2}px, ${rand(innerHeight) - innerHeight / 2}px)`;
+ line.append(s);
+ }
+ screen.append(line);
+ document.body.append(screen);
+ // the next frame, so the spans start where they were put
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ for (const s of line.children) s.style.transform = "";
+ }));
+
+ // Emacs sits for up to five seconds, at random; esc or C-g ends it early
+ let quit = false;
+ const stop = (e) => {
+ if (e.key === "Escape" || (e.ctrlKey && e.key === "g")) quit = true;
+ };
+ addEventListener("keydown", stop, true);
+ const wait = reducedMotion.matches ? 1200 : 1600 + rand(3000);
+ for (let t = 0; t < wait && !quit; t += 100) await sleep(100);
+ removeEventListener("keydown", stop, true);
+ screen.remove();
+ setSegment("buf", buf);
+ setSegment("mode", mode);
+ flapping = false;
+ if (quit) return message("Quit");
+
+ const hit = target();
+ if (!hit) return message("Successfully flipped one bit!");
+ const { n, i, bit } = hit;
+ n.data = n.data.slice(0, i) + String.fromCharCode(n.data.charCodeAt(i) ^ (1 << bit)) + n.data.slice(i + 1);
+ // show where it landed for a few seconds
+ if (typeof CSS !== "undefined" && "highlights" in CSS) {
+ const r = new Range();
+ r.setStart(n, i);
+ r.setEnd(n, i + 1);
+ CSS.highlights.set("butterfly", new Highlight(r));
+ setTimeout(() => CSS.highlights.delete("butterfly"), 5000);
+ }
+ message("Successfully flipped one bit!", "ok", 6000);
+}
+
diff --git a/src/scripts/emacs/search.js b/src/scripts/emacs/search.js
@@ -0,0 +1,129 @@
+// evil's / the way Doom does it with consult-line: the minibuffer lists every
+// place the page matches what you type, the page jumps to each as you move
+// through the list, and Enter stays there (esc goes back to where you were).
+// Afterwards n and N step through the matches and esc clears them. Matches are
+// painted with the CSS Custom Highlight API, so the page's markup is left alone.
+
+import { $, reducedMotion } from "../util.js";
+import { message } from "./echo.js";
+import { defcommand } from "./commands.js";
+import { completingRead } from "./minibuffer.js";
+import "../../styles/frame/search.css";
+
+const painted = typeof CSS !== "undefined" && "highlights" in CSS;
+const MAX = 300; // matches listed; more than this and the query wants narrowing
+
+let matches = []; // Ranges, in page order
+let query = "";
+let at = -1;
+
+// Text the reader can see: not hidden, folded away or for screen readers only.
+// play.js uses it too, for the region and for butterfly.
+export function readable(el) {
+ if (el.closest("script, style, [aria-hidden='true'], .sr-only, canvas")) return false;
+ return el.checkVisibility ? el.checkVisibility({ visibilityProperty: true, checkVisibilityCSS: true }) : el.offsetParent !== null;
+}
+
+function collect(q) {
+ const found = [];
+ if (!q) return found;
+ // smartcase, as evil does: case matters only once the query has a capital
+ const fold = !/[A-Z]/.test(q);
+ const needle = fold ? q.toLowerCase() : q;
+ const walk = document.createTreeWalker($("main") ?? document.body, NodeFilter.SHOW_TEXT, {
+ acceptNode: (n) => (n.data.trim() && readable(n.parentElement) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT),
+ });
+ for (let n = walk.nextNode(); n && found.length < MAX; n = walk.nextNode()) {
+ const hay = fold ? n.data.toLowerCase() : n.data;
+ for (let i = hay.indexOf(needle); i >= 0 && found.length < MAX; i = hay.indexOf(needle, i + needle.length)) {
+ const r = new Range();
+ r.setStart(n, i);
+ r.setEnd(n, i + needle.length);
+ found.push(r);
+ }
+ }
+ return found;
+}
+
+function paint() {
+ if (!painted) return;
+ if (matches.length) CSS.highlights.set("search", new Highlight(...matches));
+ else CSS.highlights.delete("search");
+ if (at >= 0 && matches[at]) CSS.highlights.set("search-current", new Highlight(matches[at]));
+ else CSS.highlights.delete("search-current");
+}
+
+// Bring match i to the middle of the screen, unless it's already in view.
+function show(i, behavior) {
+ at = i;
+ paint();
+ const r = matches[i].getBoundingClientRect();
+ const top = 60, bottom = innerHeight - 90; // clear of the top line and the modeline
+ if (r.top < top || r.bottom > bottom) scrollTo({ top: scrollY + r.top - innerHeight / 2, behavior });
+}
+
+// The line around a match, as the minibuffer lists it.
+function context(r) {
+ const text = r.startContainer.data;
+ const from = Math.max(0, r.startOffset - 32);
+ const to = Math.min(text.length, r.endOffset + 48);
+ return `${from > 0 ? "…" : ""}${text.slice(from, to).replace(/\s+/g, " ").trim()}${to < text.length ? "…" : ""}`;
+}
+
+export async function search() {
+ const start = scrollY;
+ let listed = { q: null, items: [] };
+ const candidates = (q) => {
+ if (q !== listed.q) {
+ query = q;
+ matches = collect(q);
+ at = -1;
+ paint();
+ // numbered, so each is its own candidate even when two lines read the same
+ listed = { q, items: matches.map((r, i) => ({ value: `${i + 1}: ${context(r)}`, ann: r.startContainer.parentElement.closest("[data-file]")?.dataset.file ?? "" })) };
+ }
+ return listed.items;
+ };
+ const pick = await completingRead("/", candidates, {
+ onPreview: (v) => show(parseInt(v, 10) - 1, "instant"),
+ });
+ if (!pick) {
+ clearSearch();
+ scrollTo({ top: start, behavior: "instant" });
+ return;
+ }
+ show(parseInt(pick, 10) - 1, reducedMotion.matches ? "instant" : "smooth");
+ report();
+}
+
+const report = (note = "") => message(`/${query} [${at + 1}/${matches.length}]${note ? ` ${note}` : ""}`, "", 4000);
+
+export const searchActive = () => matches.length > 0;
+
+// n and N: the next or previous match, wrapping round the page as vim does.
+export function searchNext(dir) {
+ // the page may have changed under the matches (another project, a fold)
+ if (!matches[0]?.startContainer.isConnected || matches.some((r) => !r.startContainer.isConnected)) {
+ matches = collect(query);
+ at = Math.min(at, matches.length - 1);
+ if (!matches.length) {
+ paint();
+ return message(`E486: Pattern not found: ${query}`, "error");
+ }
+ }
+ let i = at + dir;
+ let note = "";
+ if (i >= matches.length) { i = 0; note = "search hit BOTTOM, continuing at TOP"; }
+ else if (i < 0) { i = matches.length - 1; note = "search hit TOP, continuing at BOTTOM"; }
+ show(i, reducedMotion.matches ? "instant" : "smooth");
+ report(note);
+}
+
+// esc, like :nohlsearch
+export function clearSearch() {
+ matches = [];
+ at = -1;
+ paint();
+}
+
+defcommand("consult-line", search, { doc: "Search this page", key: "/" });
diff --git a/src/scripts/emacs/tips.js b/src/scripts/emacs/tips.js
@@ -0,0 +1,89 @@
+// A tip in the echo area every now and then, each giving away one of the
+// site's easter eggs. One comes only while the echo area is idle and the reader
+// isn't in the middle of something, and only on screens with a keyboard and a
+// mouse: most of the eggs need keys, and that's where the echo area has room for
+// a line. Tips aren't repeated until all of them have been shown (remembered in
+// localStorage), only ones for things on the page come up, and screen readers
+// aren't sent one a minute.
+
+import { $, finePointer } from "../util.js";
+import { message, isIdle } from "./echo.js";
+import { hasCommand, listCommands } from "./commands.js";
+
+const FIRST = 10_000; // ms before the first tip
+const EVERY = 30_000; // ms between tips
+const SHOW = 9000; // ms a tip stays up
+const QUIET = 5000; // ms since the last key or click before one may interrupt
+
+const cmd = (name) => () => hasCommand(name);
+const unlocked = () => listCommands().some((c) => c.name === "butterfly" && !c.hidden);
+
+const TIPS = [
+ { id: "search", text: "/ searches this page, and n and N step through the matches." },
+ { id: "motions", text: "j and k scroll, and gg and G go to the top and bottom, as in evil." },
+ { id: "themes", text: "SPC h t tries the 11 themes with a live preview; :colorscheme picks one by name." },
+ { id: "help", text: "? lists every key and command on this page." },
+ { id: "quit", text: "Try :q, or SPC q q." },
+ { id: "morse", text: "Select some text, then M-x morse-region turns it into Morse code.", when: cmd("morse-region") },
+ { id: "find-file", text: "SPC . jumps to a section by its file name.", when: () => document.querySelectorAll("main > section[data-file]").length > 1 },
+ { id: "404", text: "Mistype an address: the 404 page is a text adventure.", when: () => !hasCommand("dunnet") },
+ { id: "dunnet", text: "Look around this room. Something might be buried here.", when: cmd("dunnet") },
+ { id: "ctf", text: "Three CTF flags are hidden on this site; M-x submit-flag checks one.", when: () => hasCommand("submit-flag") && !unlocked() },
+ { id: "prize", text: "Find all three CTF flags and M-x learns a new command.", when: () => hasCommand("submit-flag") && !unlocked() },
+ { id: "butterfly", text: "M-x butterfly: real programmers use butterflies.", when: unlocked },
+ // the home page
+ { id: "coffee", text: "SPC r refills the coffee, which overclocks the 8-bit computer for 30 seconds.", when: cmd("coffee-refill") },
+ // a post
+ { id: "post-fold", text: "TAB on a heading folds it, and S-TAB cycles the whole post, as in Org mode.", when: cmd("org-shifttab") },
+ { id: "imenu", text: "SPC i lists this post's headings to jump to.", when: cmd("imenu") },
+];
+
+const load = () => {
+ try { return new Set(JSON.parse(localStorage.getItem("tips-seen") || "[]")); } catch { return new Set(); }
+};
+const save = (seen) => {
+ try { localStorage.setItem("tips-seen", JSON.stringify([...seen])); } catch { /* private mode */ }
+};
+
+let lastInput = 0;
+for (const t of ["keydown", "pointerdown"]) addEventListener(t, () => { lastInput = performance.now(); }, { capture: true, passive: true });
+
+// Whether a tip may come up now: the echo area free, nothing open, nobody typing.
+const calm = () =>
+ !document.hidden &&
+ finePointer.matches &&
+ isIdle() &&
+ $(".mini")?.hidden &&
+ $(".which-key")?.hidden &&
+ !$(".help")?.open &&
+ !$(".butterfly") &&
+ performance.now() - lastInput > QUIET;
+
+// About how many characters the echo area holds: 12px JetBrains Mono is 7.2px a character.
+const room = () => Math.floor(($(".echo")?.clientWidth ?? 0) / 7.3) - 2;
+
+function pick() {
+ const fits = room();
+ const here = TIPS.filter((t) => (!t.when || t.when()) && `Tip: ${t.text}`.length <= fits);
+ if (!here.length) return null;
+ const seen = load();
+ let fresh = here.filter((t) => !seen.has(t.id));
+ // all of them shown: start round again, with what's here
+ if (!fresh.length) {
+ for (const t of here) seen.delete(t.id);
+ fresh = here;
+ }
+ const tip = fresh[Math.floor(Math.random() * fresh.length)];
+ seen.add(tip.id);
+ save(seen);
+ return tip;
+}
+
+function tick() {
+ // not now: try again shortly, so tips still come about when they're due
+ if (!calm()) return setTimeout(tick, 5000);
+ const tip = pick();
+ if (tip) message(`Tip: ${tip.text}`, "tip", SHOW, { quiet: true });
+ setTimeout(tick, EVERY);
+}
+setTimeout(tick, FIRST);
diff --git a/src/scripts/zone.js b/src/scripts/zone.js
@@ -1,202 +0,0 @@
-// 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/frame/help.css b/src/styles/frame/help.css
@@ -41,6 +41,10 @@
padding: 3px 0;
}
+.help dl div[hidden] {
+ display: none;
+}
+
.help dt {
color: var(--fg);
}
diff --git a/src/styles/frame/modeline.css b/src/styles/frame/modeline.css
@@ -94,6 +94,11 @@
color: var(--green);
}
+/* the odd tip (src/scripts/emacs/tips.js), quieter than a message */
+.echo.is-tip {
+ color: var(--dim);
+}
+
.mini:not([hidden]) ~ .echo {
display: none;
}
@@ -245,3 +250,19 @@
display: none;
}
}
+
+/* back to top: over the bottom right of the page, just above the modeline,
+ once the reader is a screen down (modeline.js). .btn.to-top, because the
+ bundle can put base.css's .btn (background: none) after this, and the text
+ under the button would show through. */
+.btn.to-top {
+ position: fixed;
+ right: var(--gutter);
+ bottom: calc(var(--frame-h) + env(safe-area-inset-bottom, 0px) + 12px);
+ z-index: 28;
+ background: var(--bg);
+}
+
+.to-top[hidden] {
+ display: none;
+}
diff --git a/src/styles/frame/play.css b/src/styles/frame/play.css
@@ -0,0 +1,29 @@
+/* M-x butterfly (src/scripts/emacs/play.js): the *butterfly* buffer over the
+ page, under the top line and the modeline, with the physics flying
+ into place in ten steps, the way Emacs's animate-string moves text. */
+
+.butterfly {
+ position: fixed;
+ inset: 0;
+ z-index: 15;
+ display: grid;
+ place-items: center;
+ background: var(--bg);
+}
+
+.butterfly p {
+ margin: 0;
+ font-size: 13px;
+ white-space: pre;
+}
+
+.butterfly span {
+ display: inline-block;
+ transition: transform 1.4s steps(10, end);
+}
+
+/* where the bit landed, for a few seconds afterwards */
+::highlight(butterfly) {
+ background-color: var(--fg);
+ color: var(--bg);
+}
diff --git a/src/styles/frame/search.css b/src/styles/frame/search.css
@@ -0,0 +1,12 @@
+/* Matches of / (src/scripts/emacs/search.js), painted over the text without
+ changing it: every match tinted the theme's yellow, as vim's Search, and the
+ one you're on in reverse video, as its IncSearch. */
+
+::highlight(search) {
+ background-color: color-mix(in srgb, var(--yellow) 32%, transparent);
+}
+
+::highlight(search-current) {
+ background-color: var(--fg);
+ color: var(--bg);
+}
diff --git a/src/styles/home/contact.css b/src/styles/home/contact.css
@@ -108,7 +108,7 @@
color: var(--green);
}
-/* once all three are found, the button runs M-x zone */
+/* once all three are found, the button runs M-x butterfly */
.ctf-row .btn.is-done {
border-color: var(--green);
color: var(--green);
diff --git a/src/styles/post/outline.css b/src/styles/post/outline.css
@@ -61,7 +61,8 @@
top: calc(var(--top-h) + 32px);
grid-row: 1;
grid-column: 2;
- max-height: calc(100vh - var(--top-h) - var(--frame-h) - 64px);
+ /* stops short of the back-to-top button over the modeline */
+ max-height: calc(100vh - var(--top-h) - var(--frame-h) - 92px);
margin: 0;
overflow-y: auto;
}
diff --git a/src/styles/zone.css b/src/styles/zone.css
@@ -1,12 +0,0 @@
-/* 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;
-}