website

Personal website
git clone https://github.com/michaelneuper/website.git
README | Files | Log | Refs

commit b83023eb74830c9b640c92eac38068340fdd8f58
parent ac11716a810e6c093c4d293afdaaca0184322056
Author: Michael Neuper <michael@michaelneuper.com>
Date:   Mon, 12 Oct 2026 23:24:34 +0200

add tips

Diffstat:
Msrc/components/frame/Help.astro | 7+++++++
Msrc/components/frame/Modeline.astro | 1+
Msrc/scripts/ctf.js | 23+++++++++++------------
Msrc/scripts/demos/scheduler.js | 13++++++++++++-
Msrc/scripts/emacs/echo.js | 7++++++-
Msrc/scripts/emacs/index.js | 7+++++--
Msrc/scripts/emacs/keys.js | 21++++++++++++++++++++-
Msrc/scripts/emacs/modeline.js | 11++++++++++-
Asrc/scripts/emacs/motions.js | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/scripts/emacs/play.js | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/scripts/emacs/search.js | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/scripts/emacs/tips.js | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/scripts/zone.js | 202-------------------------------------------------------------------------------
Msrc/styles/frame/help.css | 4++++
Msrc/styles/frame/modeline.css | 21+++++++++++++++++++++
Asrc/styles/frame/play.css | 29+++++++++++++++++++++++++++++
Asrc/styles/frame/search.css | 12++++++++++++
Msrc/styles/home/contact.css | 2+-
Msrc/styles/post/outline.css | 3++-
Dsrc/styles/zone.css | 12------------
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; -}