commit 0c148e7a310c2ac09bc1476d97ee0d567e520458
parent aad4bf10dede4fe6afa66fa8ea86d9082e6c1caa
Author: Michael Neuper <michael@michaelneuper.com>
Date: Mon, 12 Oct 2026 21:36:28 +0200
polish ux
Diffstat:
20 files changed, 698 insertions(+), 162 deletions(-)
diff --git a/src/components/home/Projects.astro b/src/components/home/Projects.astro
@@ -28,13 +28,15 @@ const projects = await Promise.all(
<button type="button" class="pj-card" id={`pj-${p.name}`} role="tab" data-cat={p.cat} aria-selected={i === 0 ? "true" : "false"} aria-controls="project-detail" tabindex={i === 0 ? 0 : -1}>
<span class="pj-thumb"><ProjectThumb demo={p.demo} /></span>
<span class="pj-label">{p.name}</span>
+ {/* the category, which the card shows only as the colour of its swatch */}
+ <span class="sr-only">, {CATS[p.cat] ?? p.cat}</span>
</button>
))}
</div>
- <p class="pj-hint" aria-hidden="true">scroll, drag or <kbd>↑</kbd> <kbd>↓</kbd></p>
+ <p class="pj-hint" aria-hidden="true">drag or <kbd>↑</kbd> <kbd>↓</kbd></p>
</div>
- <article class="pj-detail" id="project-detail" role="tabpanel" aria-live="polite"></article>
+ <article class="pj-detail" id="project-detail" role="tabpanel"></article>
<ol class="pj-list">
{projects.map((p) => (
diff --git a/src/content/projects/8-bit-computer.md b/src/content/projects/8-bit-computer.md
@@ -4,7 +4,7 @@ title: "8-bit breadboard computer"
order: 1
cat: hw
demo: computer
-demoNote: "A JavaScript simulation of the board, with the build’s instructions and microcode. Click a bit in RAM to change the program, or the display for signed numbers."
+demoNote: "A JavaScript simulation of the board, with the build’s instructions and microcode. Flip a bit in RAM to change the program, or select the display for signed numbers."
summary: "Ben Eater's 8-bit breadboard computer, built chip by chip."
stack: "74HC logic, EEPROMs, breadboards, a lot of jumper wire"
links:
diff --git a/src/pages/giscus/[theme].css.js b/src/pages/giscus/[theme].css.js
@@ -17,7 +17,7 @@ function block(selector) {
return Object.fromEntries([...(m?.[1] ?? "").matchAll(/(--[\w-]+|color-scheme):\s*([^;]+);/g)].map((d) => [d[1], d[2].trim()]));
}
-const TOKENS = ["--bg", "--bg-alt", "--fg", "--red", "--orange", "--yellow", "--green", "--cyan", "--blue", "--magenta"];
+const TOKENS = ["--bg", "--bg-alt", "--fg", "--red", "--orange", "--yellow", "--green", "--cyan", "--blue", "--magenta", "--dim-mix"];
export function GET({ params }) {
const base = block(":root");
@@ -50,8 +50,11 @@ const css = (tokens, light) => `/* giscus theme for michaelneuper.com, made from
main {
color-scheme: ${light ? "light" : "dark"};
${tokens}
- --dim: ${mix("--fg", 64, "--bg")};
+ /* as themes.css has them: --dim mixed per theme to stay over 4.5:1, and
+ --control the resting border of buttons, over 3:1 */
+ --dim: color-mix(in srgb, var(--fg) var(--dim-mix), var(--bg));
--faint: ${mix("--fg", 34, "--bg")};
+ --control: color-mix(in srgb, var(--dim) 75%, var(--bg));
--rule: ${mix("--fg", 15, "--bg")};
--hl: ${mix("--fg", 10, "--bg")};
@@ -88,7 +91,7 @@ ${tokens}
--color-btn-text: var(--fg);
--color-btn-bg: transparent;
- --color-btn-border: var(--faint);
+ --color-btn-border: var(--control);
--color-btn-shadow: none;
--color-btn-inset-shadow: none;
--color-btn-hover-bg: var(--hl);
@@ -115,7 +118,7 @@ ${tokens}
--color-fg-default: var(--fg);
--color-fg-muted: var(--dim);
- --color-fg-subtle: var(--faint);
+ --color-fg-subtle: var(--dim);
--color-canvas-default: transparent;
--color-canvas-overlay: var(--bg-alt);
--color-canvas-inset: var(--hl);
diff --git a/src/pages/index.astro b/src/pages/index.astro
@@ -20,7 +20,7 @@ import Coffee from "../components/home/Coffee.astro";
</script>
</Fragment>
<Fragment slot="help">
- <div><dt><kbd>c</kbd> <kbd>n</kbd> <kbd>r</kbd></dt><dd>computer: run or pause, step, reset</dd></div>
+ <div><dt><kbd>c</kbd> <kbd>n</kbd> <kbd>r</kbd></dt><dd>computer: run or pause, step, reset (while it's on screen and nothing else has focus)</dd></div>
<div><dt><kbd>TAB</kbd></dt><dd>fold the Org heading under focus</dd></div>
<div><dt><kbd>S-TAB</kbd></dt><dd>cycle the whole Org buffer</dd></div>
<div><dt><code>coffee-refill</code></dt><dd>top up VCC and overclock the computer</dd></div>
diff --git a/src/scripts/demos/bytecode.js b/src/scripts/demos/bytecode.js
@@ -120,20 +120,23 @@ function exec(ins, stack) {
export function mount(host) {
host.innerHTML = `
<div class="bc-input">
- <input type="text" value="(x + 3) * y - 10 / z" spellcheck="false" autocapitalize="off" aria-label="Expression to compile">
+ <input type="text" name="expression" value="(x + 3) * y - 10 / z" autocomplete="off" spellcheck="false" autocapitalize="off" aria-label="Expression to compile">
<button type="button" class="btn" data-k="step">step</button>
<button type="button" class="btn" data-k="run">run</button>
</div>
<p class="dim" style="margin:0 0 12px">int x = 6, y = 7, z = -2 (local variable slots 0 to 2)</p>
<div class="bc-cols">
- <div><div class="dim">javap -c</div><ol class="bc-code"></ol></div>
- <div><div class="dim">operand stack</div><div class="bc-stack"></div><p class="dim" data-k="result" style="margin:8px 0 0"></p></div>
+ <div><div class="dim">javap -c</div><ol class="bc-code" translate="no"></ol></div>
+ <div><div class="dim">operand stack</div><div class="bc-stack"></div><p class="dim" data-k="result" aria-live="polite" style="margin:8px 0 0"></p></div>
</div>`;
const input = host.querySelector("input");
const list = host.querySelector(".bc-code");
const stackEl = host.querySelector(".bc-stack");
+ // Only the outcome is announced: what it returns, the exception, or why it
+ // won't compile when you try to step or run it (not on every keystroke).
const result = host.querySelector('[data-k="result"]');
let code = [];
+ let error = "";
let pc = 0;
let stack = [];
let timer = 0;
@@ -144,6 +147,7 @@ export function mount(host) {
pc = 0;
stack = [];
finished = false;
+ error = "";
try {
code = codegen(parse(input.value));
list.replaceChildren(...code.map((c) => {
@@ -154,9 +158,10 @@ export function mount(host) {
result.textContent = "";
} catch (e) {
code = [];
+ error = `error: ${e.message}`;
const pre = document.createElement("li");
pre.className = "bc-err";
- pre.textContent = `error: ${e.message}\n ${input.value}\n ${" ".repeat(e.pos ?? 0)}^`;
+ pre.textContent = `${error}\n ${input.value}\n ${" ".repeat(e.pos ?? 0)}^`;
list.replaceChildren(pre);
result.textContent = "";
}
@@ -164,7 +169,10 @@ export function mount(host) {
}
function paint() {
- [...list.children].forEach((li, i) => li.classList.toggle("is-pc", i === pc && !finished && code.length > 0));
+ [...list.children].forEach((li, i) => {
+ if (i === pc && !finished && code.length > 0) li.setAttribute("aria-current", "step");
+ else li.removeAttribute("aria-current");
+ });
stackEl.replaceChildren(...stack.map((v) => {
const s = document.createElement("span");
s.textContent = v;
@@ -173,7 +181,10 @@ export function mount(host) {
}
function step() {
- if (!code.length) return false;
+ if (!code.length) {
+ result.textContent = error;
+ return false;
+ }
if (finished) { pc = 0; stack = []; finished = false; result.textContent = ""; }
try {
const r = exec(code[pc], stack);
@@ -191,6 +202,8 @@ export function mount(host) {
function run() {
clearInterval(timer);
+ // nothing compiled: step says why
+ if (!code.length) { step(); return; }
if (finished) step();
if (reducedMotion.matches) {
while (step()) { /* run to the end */ }
@@ -201,9 +214,10 @@ export function mount(host) {
let debounce = 0;
input.addEventListener("input", () => { clearTimeout(debounce); debounce = setTimeout(compile, 180); });
- input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); compile(); run(); } });
+ // Enter compiles now, so a compile still pending from typing mustn't stop the run
+ input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); clearTimeout(debounce); compile(); run(); } });
host.querySelector('[data-k="step"]').addEventListener("click", () => { clearInterval(timer); step(); });
host.querySelector('[data-k="run"]').addEventListener("click", run);
compile();
- return () => clearInterval(timer);
+ return () => { clearInterval(timer); clearTimeout(debounce); };
}
diff --git a/src/scripts/demos/computer/board.js b/src/scripts/demos/computer/board.js
@@ -16,7 +16,8 @@ const GLYPH = { 0: "abcdef", 1: "bc", 2: "abdeg", 3: "abcdg", 4: "bcfg", 5: "acd
// ── UI ────────────────────────────────────────────────────────────────
-const fmtHz = (f) => (f < 10 ? `${f.toFixed(1)} Hz` : `${Math.round(f)} Hz`);
+// with a non-breaking space, so the unit never wraps away from its number
+const fmtHz = (f) => (f < 10 ? `${f.toFixed(1)} Hz` : `${Math.round(f)} Hz`);
// State that outlives any one mounted board: the coffee overclock, and which
// board is on the page (the 8-bit project's demo mounts and unmounts it).
@@ -41,7 +42,7 @@ const TEMPLATE = `
</label>
<label class="sim-field"><span>clock</span>
<input type="range" min="0" max="100" value="43" data-sim="hz" aria-label="Clock speed">
- <output>10 Hz</output>
+ <output>10 Hz</output>
</label>
</div>
<div class="board" role="group" aria-label="Simulated 8-bit breadboard computer"></div>`;
@@ -62,6 +63,8 @@ export function mount(host) {
let acc = 0;
let phase = 0;
let signed = false;
+ let touch = false; // a coarse pointer: RAM rows are the targets, not bits
+ let editing = -1; // the address open in the row editor
let dirty = true;
let lastBoost = boost;
const effHz = () => Math.min(500, hz * boost);
@@ -117,6 +120,7 @@ export function mount(host) {
const ramRows = [];
for (let a = 0; a < 16; a++) {
const row = el("div", "ram-row");
+ row.dataset.addr = a;
const pcMark = el("span", "pc");
const addr = el("span", "addr", hex(a, 1));
const bits = el("span", "leds");
@@ -134,10 +138,36 @@ export function mount(host) {
const dis = el("span", "dis");
row.append(pcMark, addr, bits, dis);
rows.append(row);
- ramRows.push({ row, pcMark, cells, dis });
+ ramRows.push({ row, pcMark, bits, cells, dis });
}
ram.body.replaceWith(rows);
+ // A bit is too small to tap, so on a coarse pointer each row is the target and
+ // opens this editor for its byte. It goes over the board under the row (above
+ // it near the bottom), so nothing on the board moves as it opens and closes.
+ const editor = el("div", "mod ram-edit");
+ editor.setAttribute("role", "dialog");
+ editor.hidden = true;
+ const edHead = el("div", "mod-head");
+ const edAddr = el("span", "mod-name");
+ const edDis = el("span", "mod-val");
+ const edClose = el("button", "btn", "close");
+ edClose.type = "button";
+ edHead.append(edAddr, edDis, edClose);
+ const edBits = el("div", "ram-edit-bits");
+ const edCells = [];
+ for (let b = 7; b >= 0; b--) {
+ const t = el("button", "ram-edit-bit");
+ t.type = "button";
+ t.dataset.bit = b;
+ t.setAttribute("aria-label", `Bit ${b}`);
+ t.append(el("span", "led"), el("span", "", String(b)));
+ edBits.append(t);
+ edCells.push(t);
+ }
+ editor.append(edHead, edBits);
+ ram.m.append(editor);
+
const ir = mod("ir", "instruction", ["II", "IO"]);
ir.body.append(leds(ir, 8, "", 4), value(ir));
// the opcode nibble in green, the operand in yellow
@@ -171,9 +201,15 @@ export function mount(host) {
seg.append(svg);
digits.push(segs);
}
+ // The display is a toggle for signed numbers. The number it shows is read out
+ // from outLabel beside it, so the button's name stays put as the number changes.
const outLabel = el("span", "sr-only");
- out.body.append(seg, value(out), outLabel);
- out.body.title = "Click to switch between unsigned and signed";
+ const outBtn = el("button", "mod-body");
+ outBtn.type = "button";
+ outBtn.title = "Click to switch between unsigned and signed";
+ outBtn.setAttribute("aria-label", "Signed numbers");
+ outBtn.append(seg, value(out));
+ out.body.replaceWith(outLabel, outBtn);
const flags = mod("flags", "flags", ["FI"]);
const fl = el("div", "leds");
const cfLed = el("span", "led g");
@@ -235,6 +271,15 @@ export function mount(host) {
const setText = (node, text) => {
if (node.textContent !== text) node.textContent = text;
};
+ const setAttr = (node, name, v) => {
+ if (node.getAttribute(name) !== v) node.setAttribute(name, v);
+ };
+ // bit i of a byte counting from bit 7, the order the LEDs are laid out in
+ const bitOn = (byte, i) => ((byte >> (7 - i)) & 1) === 1;
+ const disText = (addr) => {
+ const data = program.data[addr];
+ return data ? `${data[0]} = ${s.ram[addr]}` : disasm(s.ram[addr]);
+ };
let lastRam = "";
function render() {
@@ -267,6 +312,7 @@ export function mount(host) {
});
setText(out.val, signed ? "signed" : "");
setText(outLabel, `output ${o}`);
+ setAttr(outBtn, "aria-pressed", String(signed));
// control word and step
cwBits.forEach(([bit, l], i) => {
@@ -296,15 +342,32 @@ export function mount(host) {
if (key !== lastRam) {
lastRam = key;
ramRows.forEach((rr, addr) => {
- setBits(rr.cells, s.ram[addr]);
+ rr.cells.forEach((c, i) => {
+ const on = bitOn(s.ram[addr], i);
+ c.classList.toggle("on", on);
+ setAttr(c, "aria-pressed", String(on));
+ });
rr.row.classList.toggle("is-mar", addr === s.mar);
setText(rr.pcMark, addr === s.pc ? "▸" : "");
- const data = program.data[addr];
- setText(rr.dis, data ? `${data[0]} = ${s.ram[addr]}` : disasm(s.ram[addr]));
+ const text = disText(addr);
+ setText(rr.dis, text);
+ if (touch) setAttr(rr.row, "aria-label", `Address ${addr}, ${text}`);
});
+ paintEditor();
}
}
+ function paintEditor() {
+ if (editing < 0) return;
+ setText(edAddr, `address ${hex(editing, 1)}`);
+ setText(edDis, disText(editing));
+ edCells.forEach((t, i) => {
+ const on = bitOn(s.ram[editing], i);
+ t.firstChild.classList.toggle("on", on);
+ setAttr(t, "aria-pressed", String(on));
+ });
+ }
+
function describe(st, cw) {
if (st.halted) return { label: "HLT", text: "clock stopped; reset to run again" };
const label = st.step < 2 ? "fetch" : OPS[st.ir >> 4] ?? "???";
@@ -351,9 +414,9 @@ export function mount(host) {
message(`HLT: ${program.title} finished with ${s.out} on the display. Press r to reset.`, "", 6000);
}
+ // a toggle keeps its label, as the scheduler's pause does: pressed means running
function paintRun() {
runBtn.setAttribute("aria-pressed", String(running));
- runBtn.textContent = running ? "pause" : "run";
}
// ── controls ───────────────────────────────────────────────────────
@@ -385,7 +448,10 @@ export function mount(host) {
}
function setHz(v) {
hz = 0.5 * Math.pow(1000, v / 100);
- hzOut.value = fmtHz(hz);
+ const text = fmtHz(hz);
+ hzOut.value = text;
+ // the slider's own 0 to 100 means nothing read out, so it gives the speed
+ hzIn.setAttribute("aria-valuetext", text);
dirty = true;
}
@@ -397,14 +463,20 @@ export function mount(host) {
if (!reducedMotion.matches) run(true);
});
hzIn.addEventListener("input", () => setHz(+hzIn.value));
- out.body.addEventListener("click", () => {
+ outBtn.addEventListener("click", () => {
signed = !signed;
dirty = true;
message(signed ? "Output shows signed two’s complement" : "Output shows unsigned");
});
- // Edit RAM by clicking bits; arrow keys move around the matrix.
+ // Edit RAM by clicking bits; arrow keys move around the matrix. On touch a tap
+ // on a row opens the row editor instead, and the arrow keys move between rows.
rows.addEventListener("click", (e) => {
+ if (touch) {
+ const row = e.target.closest(".ram-row");
+ if (row) toggleEditor(+row.dataset.addr);
+ return;
+ }
const btn = e.target.closest(".led");
if (!btn) return;
s.ram[+btn.dataset.addr] ^= 1 << +btn.dataset.bit;
@@ -412,6 +484,7 @@ export function mount(host) {
dirty = true;
});
rows.addEventListener("keydown", (e) => {
+ if (touch) return rowKey(e);
const btn = e.target.closest(".led");
const d = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, 1], ArrowRight: [0, -1] }[e.key];
if (!btn || !d) return;
@@ -425,7 +498,107 @@ export function mount(host) {
c.focus({ preventScroll: true });
}
- // c, n and r while the computer is on screen
+ // ── touch: rows and the row editor ─────────────────────────────────
+ // On a coarse pointer each row is a button, and the bits in it are inert, so a
+ // tap anywhere on the row lands on the row.
+ const coarse = matchMedia("(pointer: coarse)");
+ function setTouch(on) {
+ closeEditor(false);
+ touch = on;
+ board.classList.toggle("is-touch", on);
+ ramRows.forEach((rr, addr) => {
+ rr.bits.inert = on;
+ if (on) {
+ rr.row.setAttribute("role", "button");
+ rr.row.setAttribute("aria-haspopup", "dialog");
+ rr.row.setAttribute("aria-expanded", "false");
+ rr.row.tabIndex = addr === 0 ? 0 : -1;
+ } else {
+ for (const name of ["role", "aria-haspopup", "aria-expanded", "aria-label", "tabindex"]) rr.row.removeAttribute(name);
+ }
+ });
+ // render() labels the rows
+ lastRam = "";
+ dirty = true;
+ }
+ const onPointer = () => setTouch(coarse.matches);
+ coarse.addEventListener("change", onPointer);
+
+ function rowKey(e) {
+ const row = e.target.closest(".ram-row");
+ if (!row) return;
+ const addr = +row.dataset.addr;
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ toggleEditor(addr);
+ } else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
+ e.preventDefault();
+ focusRow((addr + (e.key === "ArrowUp" ? 15 : 1)) % 16);
+ }
+ }
+ // the rows are one tab stop, on the row last used
+ const roveTo = (addr) => ramRows.forEach((rr, i) => { rr.row.tabIndex = i === addr ? 0 : -1; });
+ function focusRow(addr) {
+ roveTo(addr);
+ ramRows[addr].row.focus({ preventScroll: true });
+ }
+
+ function toggleEditor(addr) {
+ if (editing === addr) return closeEditor(true);
+ closeEditor(false);
+ editing = addr;
+ roveTo(addr);
+ ramRows[addr].row.setAttribute("aria-expanded", "true");
+ editor.setAttribute("aria-label", `Edit address ${addr}`);
+ editor.hidden = false;
+ paintEditor();
+ place();
+ // Focus moves in without scrolling, which would slide the bits out from
+ // under a finger; the page only scrolls if the editor runs off screen.
+ edCells[0].focus({ preventScroll: true });
+ editor.scrollIntoView({ block: "nearest" });
+ document.addEventListener("pointerdown", onOutside);
+ }
+ function closeEditor(refocus) {
+ if (editing < 0) return;
+ const row = ramRows[editing].row;
+ editing = -1;
+ editor.hidden = true;
+ row.setAttribute("aria-expanded", "false");
+ document.removeEventListener("pointerdown", onOutside);
+ if (refocus) row.focus({ preventScroll: true });
+ }
+ // a tap anywhere else closes it, as a popup would
+ const onOutside = (e) => {
+ if (!editor.contains(e.target) && !rows.contains(e.target)) closeEditor(false);
+ };
+ // under its row, or above it if it would run past the bottom of the board
+ function place() {
+ if (editing < 0) return;
+ const row = ramRows[editing].row;
+ const below = row.offsetTop + row.offsetHeight;
+ editor.style.top = `${below + editor.offsetHeight > board.clientHeight ? row.offsetTop - editor.offsetHeight : below}px`;
+ }
+ const ro = new ResizeObserver(place);
+ ro.observe(board);
+
+ editor.addEventListener("click", (e) => {
+ const t = e.target.closest("button");
+ if (t === edClose) closeEditor(true);
+ else if (t) {
+ s.ram[editing] ^= 1 << +t.dataset.bit;
+ render();
+ }
+ });
+ editor.addEventListener("keydown", (e) => {
+ if (e.key !== "Escape") return;
+ e.preventDefault();
+ closeEditor(true);
+ });
+
+ // The loop runs while the board is on screen. c, n and r work while focus is
+ // in the demo, or on nothing at all with at least a quarter of the board in
+ // view, so a key meant for something else on the page never resets it.
let visible = false;
let off = null;
const io = new IntersectionObserver(([e]) => {
@@ -435,7 +608,9 @@ export function mount(host) {
}, { threshold: [0, 0.25] });
io.observe(board);
const onKey = (e) => {
- if (!visible || e.defaultPrevented || e.repeat || isTyping(e) || e.metaKey || e.ctrlKey || e.altKey) return;
+ if (e.defaultPrevented || e.repeat || isTyping(e) || e.metaKey || e.ctrlKey || e.altKey) return;
+ const focus = document.activeElement;
+ if (!host.contains(focus) && !(visible && (!focus || focus === document.body))) return;
if (document.querySelector("dialog[open]")) return;
const act = { c: () => run(), n: step, r: reset }[e.key];
if (!act) return;
@@ -446,6 +621,7 @@ export function mount(host) {
load("fibonacci");
setHz(+hzIn.value);
+ setTouch(coarse.matches);
// It waits for run (or c) so it doesn't flicker beside the write-up, unless a coffee overclock is on.
run(boost > 1);
// Drawn once now, so the board is its full height before it scrolls into view
@@ -455,8 +631,11 @@ export function mount(host) {
current = api;
return () => {
io.disconnect();
+ ro.disconnect();
off?.();
document.removeEventListener("keydown", onKey);
+ document.removeEventListener("pointerdown", onOutside);
+ coarse.removeEventListener("change", onPointer);
if (current === api) current = null;
};
}
diff --git a/src/scripts/demos/computer/commands.js b/src/scripts/demos/computer/commands.js
@@ -1,5 +1,6 @@
-// M-x commands for the 8-bit computer (c, n and r while it's on screen, or
-// SPC c). They open the 8-bit project first if its demo isn't showing.
+// M-x commands for the 8-bit computer (c, n and r with focus in its demo, or on
+// nothing while it's on screen, or SPC c from anywhere). They open the 8-bit
+// project first if its demo isn't showing.
import { reducedMotion, emit } from "../../util.js";
import { defcommand, completingRead } from "../../emacs/index.js";
diff --git a/src/scripts/demos/othello.js b/src/scripts/demos/othello.js
@@ -3,6 +3,7 @@
// at the end of the think time. While it thinks, each candidate move is shaded
// by its share of the search.
+import { clamp } from "../util.js";
import "../../styles/demos/othello.css";
const C = 1;
@@ -11,6 +12,7 @@ const BLACK = 1; // you
const WHITE = -1; // the search
const PASS = -1;
const name = (i) => "abcdefgh"[i % 8] + (Math.floor(i / 8) + 1);
+const num = new Intl.NumberFormat(); // in the reader's locale
function flips(b, i, p) {
if (b[i]) return [];
@@ -110,29 +112,48 @@ export function mount(host) {
<button type="button" class="btn" data-k="new">new game</button>
<label class="sim-field" style="margin:0"><span>think time</span>
<select data-k="time">
- <option value="250">0.25 s</option>
- <option value="600" selected>0.6 s</option>
- <option value="1000">1 s</option>
+ <option value="250">0.25 s</option>
+ <option value="600" selected>0.6 s</option>
+ <option value="1000">1 s</option>
</select>
</label>
<span class="dim" data-k="score"></span>
</div>
<div class="oth" role="grid" aria-label="Othello board, you play black"></div>
- <p class="cap" data-k="status" aria-live="polite"></p>`;
+ <p class="cap"><span data-k="status" aria-live="polite"></span> <span class="oth-count" data-k="count"></span></p>`;
const grid = host.querySelector(".oth");
+ // Only moves, passes and the end of the game are announced; the running count
+ // of the search sits beside the live region, not in it.
const status = host.querySelector('[data-k="status"]');
+ const countEl = host.querySelector('[data-k="count"]');
const scoreEl = host.querySelector('[data-k="score"]');
const timeSel = host.querySelector('[data-k="time"]');
- const cells = Array.from({ length: 64 }, (_, i) => {
- const b = document.createElement("button");
- b.type = "button";
- b.className = "oth-cell";
- b.dataset.i = i;
- b.innerHTML = '<span class="heat"></span><span class="disc"></span>';
- grid.append(b);
- return b;
- });
+ // A grid of rows of cells with a single tab stop, which the arrow keys move.
+ const cells = [];
+ for (let r = 0; r < 8; r++) {
+ const row = document.createElement("div");
+ row.className = "oth-row";
+ row.setAttribute("role", "row");
+ for (let c = 0; c < 8; c++) {
+ const cell = document.createElement("div");
+ cell.className = "oth-cell";
+ cell.setAttribute("role", "gridcell");
+ cell.tabIndex = -1;
+ cell.dataset.i = r * 8 + c;
+ cell.innerHTML = '<span class="heat" aria-hidden="true"></span><span class="disc" aria-hidden="true"></span>';
+ row.append(cell);
+ cells.push(cell);
+ }
+ grid.append(row);
+ }
+ let focused = 0;
+ function rove(i, focus = false) {
+ cells[focused].tabIndex = -1;
+ focused = i;
+ cells[i].tabIndex = 0;
+ if (focus) cells[i].focus();
+ }
let board;
let turn;
@@ -140,14 +161,18 @@ export function mount(host) {
let thinking = false;
let alive = true;
let heat = null;
+ let game = 0; // so a search can tell a new game has started under it
function reset() {
+ game++;
+ thinking = false;
board = new Int8Array(64);
board[27] = board[36] = WHITE;
board[28] = board[35] = BLACK;
turn = BLACK;
last = -1;
heat = null;
+ countEl.textContent = "";
status.textContent = "Your move: you play black. Legal moves are dotted.";
paint();
}
@@ -167,7 +192,9 @@ export function mount(host) {
const hs = cell.querySelector(".heat");
hs.textContent = h ? `${Math.round(h * 100)}` : "";
hs.style.setProperty("--hp", h ? `${Math.round(35 + h * 60)}%` : "0%");
- cell.setAttribute("aria-label", `${name(i)}, ${v === BLACK ? "black" : v === WHITE ? "white" : legal.has(i) ? "legal move" : "empty"}`);
+ const label = `${name(i)}, ${v === BLACK ? "black" : v === WHITE ? "white" : legal.has(i) ? "empty, legal move" : "empty"}${i === last ? ", last move" : ""}`;
+ // paint runs every few milliseconds during a search; leave unchanged labels be
+ if (cell.getAttribute("aria-label") !== label) cell.setAttribute("aria-label", label);
});
scoreEl.textContent = `black ${b}, white ${w}`;
}
@@ -182,21 +209,22 @@ export function mount(host) {
}
async function think() {
+ const g = game;
thinking = true;
paint();
const root = node(null, null, board.slice(), WHITE);
const budget = +timeSel.value;
const t0 = performance.now();
- while (alive && performance.now() - t0 < budget) {
+ while (performance.now() - t0 < budget) {
const slice = performance.now() + 14;
while (performance.now() < slice) iterate(root);
const total = root.visits || 1;
heat = new Map(root.children.filter((c) => c.move !== PASS).map((c) => [c.move, c.visits / total]));
- status.textContent = `Searching: ${root.visits.toLocaleString("en")} random games so far.`;
+ countEl.textContent = `Searching: ${num.format(root.visits)} random games so far.`;
paint();
await new Promise((r) => setTimeout(r, 0));
+ if (!alive || g !== game) return;
}
- if (!alive) return;
let best = root.children[0];
for (const c of root.children) if (c.visits > best.visits) best = c;
play(board, best.move, WHITE);
@@ -204,24 +232,23 @@ export function mount(host) {
heat?.delete(best.move);
thinking = false;
turn = BLACK;
+ countEl.textContent = "";
const pct = Math.round((best.wins / best.visits) * 100);
- status.textContent = `White played ${name(best.move)} after ${root.visits.toLocaleString("en")} random games, expecting to win ${pct}% of them.`;
+ let msg = `White played ${name(best.move)} after ${num.format(root.visits)} random games, expecting to win ${pct}% of them.`;
paint();
if (over()) return;
if (!moves(board, BLACK).length) {
- status.textContent += " You have no legal move, so you pass.";
+ msg += " You have no legal move, so you pass.";
turn = WHITE;
- setTimeout(() => alive && think(), 700);
+ setTimeout(() => alive && g === game && think(), 700);
}
+ status.textContent = msg;
heat = null;
setTimeout(() => alive && !thinking && paint(), 1200);
}
- grid.addEventListener("click", (e) => {
- const cell = e.target.closest(".oth-cell");
- if (!cell || turn !== BLACK || thinking) return;
- const i = +cell.dataset.i;
- if (!flips(board, i, BLACK).length) return;
+ function playAt(i) {
+ if (turn !== BLACK || thinking || !flips(board, i, BLACK).length) return;
play(board, i, BLACK);
last = i;
heat = null;
@@ -229,16 +256,43 @@ export function mount(host) {
if (over()) return;
if (moves(board, WHITE).length) {
turn = WHITE;
+ status.textContent = `You played ${name(i)}.`;
think();
} else {
- status.textContent = "White has no legal move and passes. Your move again.";
+ status.textContent = `You played ${name(i)}. White has no legal move and passes. Your move again.`;
paint();
}
+ }
+
+ grid.addEventListener("click", (e) => {
+ const cell = e.target.closest(".oth-cell");
+ if (cell) playAt(+cell.dataset.i);
});
- host.querySelector('[data-k="new"]').addEventListener("click", () => {
- if (!thinking) reset();
+ grid.addEventListener("focusin", (e) => {
+ const cell = e.target.closest(".oth-cell");
+ if (cell) rove(+cell.dataset.i);
+ });
+ grid.addEventListener("keydown", (e) => {
+ if (e.altKey || e.metaKey) return;
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ playAt(focused);
+ return;
+ }
+ const r = Math.floor(focused / 8);
+ const c = focused % 8;
+ const to = {
+ ArrowLeft: [r, c - 1], ArrowRight: [r, c + 1], ArrowUp: [r - 1, c], ArrowDown: [r + 1, c],
+ Home: e.ctrlKey ? [0, 0] : [r, 0], End: e.ctrlKey ? [7, 7] : [r, 7],
+ }[e.key];
+ if (!to) return;
+ e.preventDefault();
+ rove(clamp(to[0], 0, 7) * 8 + clamp(to[1], 0, 7), true);
});
+ // a new game also abandons a search in progress
+ host.querySelector('[data-k="new"]').addEventListener("click", reset);
reset();
+ rove(moves(board, BLACK)[0]);
return () => { alive = false; };
}
diff --git a/src/scripts/demos/rbudp.js b/src/scripts/demos/rbudp.js
@@ -2,7 +2,7 @@
// over UDP, send BLAST_COMPLETE over TCP, get back MISSING_PACKETS (or
// TRANSFER_DONE), and blast just the missing ones until nothing is missing.
-import { fitCanvas, themeColors, onFrame, reducedMotion } from "../util.js";
+import { fitCanvasOnResize, frameWhileVisible, themeColors, reducedMotion, on } from "../util.js";
import "../../styles/demos/rbudp.css";
const PACKETS = 64;
@@ -37,10 +37,14 @@ export function mount(host) {
let clock = 0;
const loss = () => +lossIn.value / 100;
+ // Protocol and message names (UDP, BLAST_COMPLETE) are code, so page
+ // translators should leave them alone.
+ const code = (s) => Object.assign(document.createElement("span"), { textContent: s, translate: false });
const line = (r, proto, text) => {
const li = document.createElement("li");
- li.innerHTML = `<span class="dim">round ${r}</span><span class="rb-proto">${proto}</span><span></span>`;
- li.lastChild.textContent = text;
+ li.innerHTML = `<span class="dim">round ${r}</span><span class="rb-proto"${proto === "done" ? "" : ' translate="no"'}>${proto}</span><span></span>`;
+ // the odd parts are the message names
+ li.lastChild.append(...text.split(/\b([A-Z]+(?:_[A-Z]+)+)\b/).map((s, i) => (i % 2 ? code(s) : s)));
log.append(li);
log.scrollTop = log.scrollHeight;
};
@@ -52,6 +56,9 @@ export function mount(host) {
datagrams = 0;
log.replaceChildren();
blast();
+ draw();
+ // with reduced motion the whole transfer has already happened
+ if (moving()) loop.wake();
}
function blast() {
@@ -112,12 +119,12 @@ export function mount(host) {
}
}
+ // the canvas's CSS size, kept up to date by fitCanvasOnResize below
+ let W = 0;
+ let H = 0;
function draw() {
- if (!cv.isConnected) return;
- fitCanvas(cv);
+ if (!W) return;
const g = cv.getContext("2d");
- const W = cv.clientWidth;
- const H = cv.clientHeight;
const c = themeColors();
g.clearRect(0, 0, W, H);
g.font = "11px 'JetBrains Mono', monospace";
@@ -196,11 +203,16 @@ export function mount(host) {
host.querySelector('[data-k="send"]').addEventListener("click", start);
lossIn.addEventListener("input", () => { lossOut.value = `${lossIn.value}%`; });
- const off = onFrame((t, dt) => {
- if (!host.isConnected) { off(); return; }
+ // Frames only while packets or a TCP message are in flight and the canvas is
+ // on screen; otherwise it is drawn once per change.
+ const moving = () => phase === "blast" || phase === "tcp";
+ const loop = frameWhileVisible(cv, (t, dt) => {
step(dt);
draw();
+ return moving();
});
+ const unfit = fitCanvasOnResize(cv, (w, h) => { W = w; H = h; draw(); });
+ const offTheme = on("theme", () => draw());
stats.textContent = "64 packets ready to send";
- return () => off();
+ return () => { loop.stop(); unfit(); offTheme(); };
}
diff --git a/src/scripts/demos/scheduler.js b/src/scripts/demos/scheduler.js
@@ -1,13 +1,15 @@
// A toy multicore scheduler: processes arrive, wait in one shared ready queue,
// and get dispatched to four cores under the chosen policy. Drawn as a Gantt chart.
-import { fitCanvas, themeColors, onFrame } from "../util.js";
+import { fitCanvasOnResize, frameWhileVisible, themeColors, reducedMotion, on } from "../util.js";
const CORES = 4;
const SPAN = 48;
const QUANTUM = 3;
export function mount(host) {
+ // with reduced motion the chart starts still, until you unpause it
+ let paused = reducedMotion.matches;
host.innerHTML = `
<div class="demo-bar">
<select aria-label="Scheduling policy">
@@ -16,7 +18,7 @@ export function mount(host) {
<option value="prio">preemptive priority</option>
</select>
<button type="button" class="btn" data-k="add">add process</button>
- <button type="button" class="btn" data-k="pause" aria-pressed="false">pause</button>
+ <button type="button" class="btn" data-k="pause" aria-pressed="${paused}">pause</button>
<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>
@@ -34,7 +36,6 @@ export function mount(host) {
let done = 0;
let waitSum = 0;
let preempted = 0;
- let paused = false;
const prioOf = new Map();
const spawn = () => {
@@ -100,16 +101,20 @@ export function mount(host) {
});
for (const p of queue) p.waited++;
t++;
+ showStats();
+ }
+
+ function showStats() {
const pre = sel.value === "fcfs" ? "" : `, ${preempted} preempted`;
stats.textContent = `t = ${t}, finished ${done}, mean wait ${done ? (waitSum / done).toFixed(1) : "0.0"} ticks${pre}`;
}
+ // the canvas's CSS size, kept up to date by fitCanvasOnResize below
+ let W = 0;
+ let H = 0;
function draw() {
- if (!cv.isConnected) return;
- fitCanvas(cv);
+ if (!W) return;
const g = cv.getContext("2d");
- const W = cv.clientWidth;
- const H = cv.clientHeight;
const c = themeColors();
const palette = [c.blue, c.green, c.yellow, c.magenta, c.cyan, c.orange, c.red];
const color = (id) => palette[id % palette.length];
@@ -169,28 +174,39 @@ export function mount(host) {
}
}
+ // A still chart opens on a full window of history rather than an empty one.
+ if (paused) for (let i = 0; i < SPAN; i++) tick();
+ showStats();
+
+ // Four ticks a second, drawn as they happen. The loop parks while paused and
+ // stops while the chart is off screen.
let acc = 0;
- const off = onFrame((now, dt) => {
- if (!host.isConnected) { off(); return; }
- if (!paused) {
- acc += dt;
- while (acc > 0.25) { tick(); acc -= 0.25; }
- }
+ const loop = frameWhileVisible(cv, (now, dt) => {
+ if (paused) return false;
+ acc += dt;
+ if (acc <= 0.25) return true;
+ while (acc > 0.25) { tick(); acc -= 0.25; }
draw();
+ return true;
});
+ const unfit = fitCanvasOnResize(cv, (w, h) => { W = w; H = h; draw(); });
+ const offTheme = on("theme", () => draw());
host.querySelector('[data-k="add"]').addEventListener("click", () => { spawn(); draw(); });
+ // a toggle button keeps its label; aria-pressed says whether it is paused
pauseBtn.addEventListener("click", () => {
paused = !paused;
pauseBtn.setAttribute("aria-pressed", String(paused));
- pauseBtn.textContent = paused ? "resume" : "pause";
+ if (!paused) loop.wake();
});
sel.addEventListener("change", () => {
hist = hist.map(() => []);
done = 0;
waitSum = 0;
preempted = 0;
+ showStats();
+ draw();
});
- return () => off();
+ return () => { loop.stop(); unfit(); offTheme(); };
}
diff --git a/src/scripts/home/projects.js b/src/scripts/home/projects.js
@@ -1,8 +1,10 @@
// The projects wheel. The cards sit on a ring and turn along it: the picked one
// at the ring's outermost point, upright and framed, the others tilted along
-// the arc and fading towards the far side. Scroll over the wheel, drag it, or
-// use the arrow keys on a card. The card that stops at the pointer opens in
-// the detail, with its demo.
+// the arc and fading towards the far side. Drag the wheel, scroll it sideways,
+// or use the arrow keys on a card; scrolling up and down over it scrolls the
+// page, so it never traps the reader. The card that stops at the pointer opens
+// in the detail, with its demo, and goes in the address (#projects/<slug>), so
+// a link can open a project.
//
// On wide screens the wheel stands in the left margin, bulging towards the
// content; on narrower ones it lies across the top of the section, so the
@@ -59,7 +61,7 @@ export function initProjects() {
const ox = list.offsetLeft;
const flat = across.matches;
list.setAttribute("aria-orientation", flat ? "horizontal" : "vertical");
- hint.innerHTML = flat ? "drag or <kbd>←</kbd> <kbd>→</kbd>" : "scroll, drag or <kbd>↑</kbd> <kbd>↓</kbd>";
+ hint.innerHTML = flat ? "drag or <kbd>←</kbd> <kbd>→</kbd>" : "drag or <kbd>↑</kbd> <kbd>↓</kbd>";
if (flat) {
// centre below the wheel; the picked card at the top of the arc
const R = Math.max(W * 1.15, 560);
@@ -107,7 +109,8 @@ export function initProjects() {
// ── turning ──────────────────────────────────────────────────────────
- function go(p, { now = false, focus = false } = {}) {
+ // quiet: leave the address alone, for the project the page opens on
+ function go(p, { now = false, focus = false, quiet = false } = {}) {
target = Math.round(p);
const i = mod(target);
cards.forEach((c, j) => {
@@ -116,10 +119,15 @@ export function initProjects() {
});
if (focus) cards[i].focus({ preventScroll: true });
turn();
- // the detail follows once the wheel stops, so spinning past cards doesn't start their demos
+ // the detail and the address follow once the wheel stops, so spinning past
+ // cards doesn't start their demos
clearTimeout(settle);
- if (now || reducedMotion.matches) show(i);
- else settle = setTimeout(() => show(i), 260);
+ const open = () => {
+ show(i);
+ if (!quiet) link(i);
+ };
+ if (now || reducedMotion.matches) open();
+ else settle = setTimeout(open, 260);
}
// turn to card i, the short way round
@@ -144,28 +152,16 @@ export function initProjects() {
});
}
- // Scrolling turns the wheel one card per notch, or per 60px of trackpad. A
- // scroll that started on the page keeps scrolling the page when the wheel
- // comes under the pointer; only one that starts over the wheel turns it.
+ // Sideways scrolling turns the wheel one card per notch, or per 60px of
+ // trackpad. Scrolling up and down is left to the page, even over the wheel.
let acc = 0;
let lock = 0;
- let pageScroll = -1e9;
- addEventListener("wheel", (e) => {
- if (!wheel.contains(e.target)) pageScroll = performance.now();
- }, { passive: true, capture: true });
wheel.addEventListener("wheel", (e) => {
- if (!geo) return;
- const now = performance.now();
- if (now - pageScroll < 300) {
- pageScroll = now;
- return;
- }
- // lying flat, the wheel only takes sideways scrolling
- if (geo.flat && Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
- const delta = geo.flat ? e.deltaX : e.deltaY;
- if (!delta) return;
+ if (!geo || Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
e.preventDefault();
+ const now = performance.now();
if (now < lock) return;
+ const delta = e.deltaX;
acc += e.deltaMode === 1 ? delta * 32 : e.deltaMode === 2 ? delta * 400 : delta;
if (Math.abs(acc) >= 60) {
go(target + Math.sign(acc));
@@ -205,7 +201,9 @@ export function initProjects() {
wheel.classList.remove("is-dragging");
dragged = true;
setTimeout(() => { dragged = false; });
- go(pos + clamp(d.v * 140, -2, 2));
+ // a cancelled drag (the browser took a finger's swipe to scroll the page) turns back
+ if (e.type === "pointercancel") turn();
+ else go(pos + clamp(d.v * 140, -2, 2));
};
wheel.addEventListener("pointerup", release);
wheel.addEventListener("pointercancel", release);
@@ -234,15 +232,17 @@ export function initProjects() {
const li = items[i];
detail.dataset.cat = li.dataset.cat;
detail.setAttribute("aria-labelledby", cards[i].id);
- // the write-up, and the demo beside it (or under it when there isn't room);
- // the card already shows the category and the slug, and the write-up says
- // what the summary says, so those stay in the list for when there's no wheel
- const text = document.createElement("div");
- text.className = "pj-text";
- text.append(...[".proj-name", ".proj-facts", ".proj-body"].map((s) => $(s, li).cloneNode(true)));
+ // The title and facts, the demo, then the write-up. With room, the demo
+ // stands beside the other two; without, it comes before the write-up, so
+ // a phone gets to it without scrolling through the text. The card already
+ // shows the category and the slug, and the write-up says what the summary
+ // says, so those stay in the list for when there's no wheel.
+ const head = document.createElement("div");
+ head.className = "pj-head";
+ head.append($(".proj-name", li).cloneNode(true), $(".proj-facts", li).cloneNode(true));
const row = document.createElement("div");
row.className = "pj-row";
- row.append(text);
+ row.append(head, $(".proj-body", li).cloneNode(true));
detail.replaceChildren(row);
if (meta) meta.textContent = `${i + 1}/${n}`;
if (!reducedMotion.matches) {
@@ -264,7 +264,7 @@ export function initProjects() {
const host = document.createElement("div");
host.className = "demo";
box.append(host);
- row.append(box);
+ head.after(box);
try {
const { mount } = await import(`../demos/${demo}.js`);
if (shown === i && host.isConnected) unmount = mount(host) ?? null;
@@ -280,8 +280,61 @@ export function initProjects() {
if (i >= 0) goTo(i, { now: true });
});
+ // ── the address ──────────────────────────────────────────────────────
+
+ // The picked project goes in the address, so it can be linked to. It
+ // replaces the entry rather than adding one, so switching projects doesn't
+ // fill the history.
+ function link(i) {
+ const hash = `#projects/${items[i].dataset.name}`;
+ if (location.hash === hash) return;
+ try {
+ history.replaceState(null, "", hash);
+ } catch {
+ // browsers refuse it when it's called too often; the next pick tries again
+ }
+ }
+
+ // the project an address like #projects/<slug> links to, or -1
+ const linked = () => items.findIndex((li) => location.hash === `#projects/${li.dataset.name}`);
+
+ // A link to a project brings the section into view on its header line, as
+ // the menu does. On load the browser has no element with that id to jump
+ // to, and the sections above are still building (the fonts, the git log)
+ // and push it down, so it's held there until the page settles or the reader
+ // starts scrolling, as src/scripts/emacs/buffers.js does for #projects.
+ const section = root.closest("section");
+ const bar = $(".winbar", section) ?? section;
+ function reveal(landing) {
+ if (!landing) {
+ bar.scrollIntoView({ behavior: reducedMotion.matches ? "auto" : "smooth", block: "start" });
+ return;
+ }
+ const ro = new ResizeObserver(() => bar.scrollIntoView({ behavior: "instant", block: "start" }));
+ const inputs = ["wheel", "touchstart", "keydown", "pointerdown"];
+ const stop = () => {
+ ro.disconnect();
+ for (const t of inputs) removeEventListener(t, stop, true);
+ };
+ for (const t of inputs) addEventListener(t, stop, { capture: true, passive: true });
+ ro.observe(document.body);
+ setTimeout(stop, 3000);
+ }
+
+ addEventListener("hashchange", () => {
+ const i = linked();
+ if (i < 0) return;
+ goTo(i, { now: true });
+ reveal(false);
+ });
+
new ResizeObserver(layout).observe(wheel);
across.addEventListener("change", layout);
+ // The page opens on the linked project, already turned to it, or on the
+ // first; either way the address stays as it is.
+ const start = linked();
+ pos = Math.max(start, 0);
layout();
- go(0, { now: true });
+ go(pos, { now: true, quiet: true });
+ if (start >= 0) reveal(true);
}
diff --git a/src/scripts/util.js b/src/scripts/util.js
@@ -13,7 +13,12 @@ export const finePointer = matchMedia("(hover: hover) and (pointer: fine)");
// ── events ────────────────────────────────────────────────────────────
const bus = new EventTarget();
export const emit = (type, detail) => bus.dispatchEvent(new CustomEvent(type, { detail }));
-export const on = (type, fn) => bus.addEventListener(type, (e) => fn(e.detail));
+// Returns a function that stops listening.
+export function on(type, fn) {
+ const h = (e) => fn(e.detail);
+ bus.addEventListener(type, h);
+ return () => bus.removeEventListener(type, h);
+}
// ── theme colours, for canvases ───────────────────────────────────────
let colors = null;
@@ -49,14 +54,30 @@ export function onFrame(fn) {
return () => subs.delete(fn);
}
-// Run fn every frame only while el is on screen.
+// Run fn(t, dt) every frame, but only while el is on screen. fn returns false
+// when nothing is left to animate, which parks the loop until wake(). Returns
+// { wake, stop }; stop() is for good, when el goes away.
export function frameWhileVisible(el, fn, margin = "80px") {
+ let visible = false;
+ let parked = false;
let off = null;
- const io = new IntersectionObserver(([e]) => {
- if (e.isIntersecting && !off) off = onFrame(fn);
- else if (!e.isIntersecting && off) { off(); off = null; }
+ const sync = () => {
+ const want = visible && !parked;
+ if (want && !off) off = onFrame(tick);
+ else if (!want && off) { off(); off = null; }
+ };
+ const tick = (t, dt) => {
+ if (fn(t, dt) === false) { parked = true; sync(); }
+ };
+ const io = new IntersectionObserver((entries) => {
+ visible = entries[entries.length - 1].isIntersecting;
+ sync();
}, { rootMargin: margin });
io.observe(el);
+ return {
+ wake() { parked = false; sync(); },
+ stop() { io.disconnect(); visible = false; sync(); },
+ };
}
// Size a canvas to its CSS box at device resolution. Returns true if it changed.
@@ -73,6 +94,18 @@ export function fitCanvas(canvas, maxDpr = 2) {
return true;
}
+// Fit a canvas before its first paint and again whenever its CSS box changes,
+// then call draw(w, h) with its CSS size, since resizing a canvas clears it.
+// Returns a disposer.
+export function fitCanvasOnResize(canvas, draw, maxDpr = 2) {
+ const ro = new ResizeObserver(() => {
+ fitCanvas(canvas, maxDpr);
+ draw(canvas.clientWidth, canvas.clientHeight);
+ });
+ ro.observe(canvas);
+ return () => ro.disconnect();
+}
+
export async function fontsReady() {
try {
await Promise.race([
diff --git a/src/styles/base.css b/src/styles/base.css
@@ -52,6 +52,21 @@ button {
cursor: pointer;
}
+/* On touch, act on the first tap rather than waiting to see if it is a
+ double-tap zoom. The tap flash takes the theme's colours instead of the
+ browser's grey: plenty of controls have no pressed state of their own, so it
+ is what shows a tap landed. */
+a,
+button,
+input,
+select,
+summary,
+label,
+[role="button"] {
+ touch-action: manipulation;
+ -webkit-tap-highlight-color: color-mix(in srgb, var(--fg) 15%, transparent);
+}
+
:focus-visible {
outline: 1px solid var(--fg);
outline-offset: 2px;
@@ -102,7 +117,7 @@ code {
align-items: center;
min-height: 30px;
padding: 0 14px;
- border: 1px solid var(--faint);
+ border: 1px solid var(--control);
border-radius: 0;
background: none;
font-size: 12px;
@@ -114,6 +129,13 @@ code {
border-color: var(--fg);
}
+/* held down: phones have no hover, so this is what shows a tap landed.
+ Before the pressed rule, so a toggle that is on keeps its fill */
+.btn:active {
+ border-color: var(--fg);
+ background: var(--hl);
+}
+
.btn[aria-pressed="true"] {
border-color: var(--fg);
background: var(--fg);
@@ -125,7 +147,7 @@ code {
select {
min-height: 30px;
padding: 0 28px 0 10px;
- border: 1px solid var(--faint);
+ border: 1px solid var(--control);
border-radius: 0;
background: var(--bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23888'/%3E%3C/svg%3E") no-repeat right 10px center;
color: var(--fg);
@@ -134,6 +156,10 @@ select {
appearance: none;
}
+select:hover {
+ border-color: var(--fg);
+}
+
input[type="range"] {
width: 120px;
height: 22px;
@@ -143,14 +169,15 @@ input[type="range"] {
appearance: none;
}
+/* the track is the slider's outline, so it gets the control border colour */
input[type="range"]::-webkit-slider-runnable-track {
height: 1px;
- background: var(--faint);
+ background: var(--control);
}
input[type="range"]::-moz-range-track {
height: 1px;
- background: var(--faint);
+ background: var(--control);
}
input[type="range"]::-webkit-slider-thumb {
diff --git a/src/styles/demos/bytecode.css b/src/styles/demos/bytecode.css
@@ -7,20 +7,21 @@
margin-bottom: 12px;
}
+/* bordered like the buttons beside it, and like them it takes the site's focus
+ ring from base.css */
.bc-input input {
flex: 1;
min-width: 0;
min-height: 30px;
padding: 0 10px;
- border: 1px solid var(--faint);
+ border: 1px solid var(--control);
border-radius: 0;
background: none;
font-size: 13px;
}
-.bc-input input:focus {
+.bc-input input:hover {
border-color: var(--fg);
- outline: none;
}
.bc-cols {
@@ -43,7 +44,7 @@
padding: 1px 6px;
}
-.bc-code li.is-pc {
+.bc-code li[aria-current="step"] {
background: var(--hl);
}
@@ -65,6 +66,7 @@
padding: 6px;
border: 1px solid var(--rule);
border-top: 0;
+ font-variant-numeric: tabular-nums;
}
.bc-stack span {
diff --git a/src/styles/demos/computer.css b/src/styles/demos/computer.css
@@ -13,6 +13,8 @@
.board {
--led: 10px;
+ /* the RAM row editor is placed over it */
+ position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 64px minmax(0, 1fr);
grid-template-areas: "left bus right";
@@ -81,7 +83,7 @@
padding: 0 0.4ch;
border-radius: 2px;
color: var(--dim);
- font-size: 10.5px;
+ font-size: 11px;
}
.sig.on {
@@ -181,6 +183,85 @@
outline-offset: 1px;
}
+/* An off bit gets a ring, so the zeros you can set still show: the off colour
+ alone is about 1.1:1 on the module. A lit bit stays a filled dot. */
+.ram-row .led:not(.on),
+.ram-edit .led:not(.on) {
+ box-shadow: inset 0 0 0 1px var(--dim);
+}
+
+/* touch: a bit is too small to tap, so the row is the target, tall enough for
+ a finger, and opens the row editor */
+.board.is-touch .ram-row {
+ min-height: 24px;
+ cursor: pointer;
+}
+
+/* and the clock slider tall enough to tap too */
+@media (pointer: coarse) {
+ .sim-bar input[type="range"] {
+ height: 32px;
+ }
+}
+
+/* the row editor: the byte's eight bits as buttons big enough to tap, over the
+ board under the row it edits (board.js places it) */
+.ram-edit {
+ position: absolute;
+ left: 0;
+ right: 0;
+ z-index: 1;
+ border-color: var(--dim);
+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.3);
+}
+
+.ram-edit .mod-head {
+ align-items: center;
+}
+
+.ram-edit .mod-name {
+ margin-right: 0;
+}
+
+.ram-edit .btn {
+ margin-left: auto;
+}
+
+.ram-edit-bits {
+ display: flex;
+ gap: 4px;
+}
+
+.ram-edit-bit {
+ --led: 10px;
+ display: flex;
+ flex: 1 1 0;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ min-width: 0;
+ min-height: 40px;
+ padding: 0;
+ border: 1px solid var(--rule);
+ background: none;
+ color: var(--dim);
+}
+
+/* the nibbles apart, as on the instruction register */
+.ram-edit-bit:nth-child(5) {
+ margin-left: 6px;
+}
+
+.ram-edit-bit:active {
+ background: var(--hl);
+}
+
+.ram-edit-bit[aria-pressed="true"] {
+ color: var(--fg);
+ font-weight: 600;
+}
+
/* the bus: eight lanes down the middle */
.bus {
@@ -238,8 +319,13 @@
filter: drop-shadow(0 0 calc(var(--glow) * 3px) color-mix(in srgb, var(--red) 80%, transparent));
}
+/* the display is a button that switches between unsigned and signed */
.mod-out .mod-body {
- cursor: pointer;
+ width: 100%;
+ padding: 0;
+ border: 0;
+ background: none;
+ text-align: inherit;
}
/* the microstep counter and the control word */
@@ -255,9 +341,10 @@
flex-direction: column;
align-items: center;
gap: 3px;
- width: 3.4ch;
+ /* just wider than a three-letter line, so eight fit across the narrow board */
+ width: 3.2ch;
color: var(--dim);
- font-size: 10px;
+ font-size: 11px;
}
.cw-bit.on {
@@ -298,7 +385,10 @@
grid-template-areas:
"bus bus"
"left right";
- font-size: 10.5px;
+ /* no text under 11px, with lines a little tighter so the board keeps the
+ height it had at 10.5px */
+ font-size: 11px;
+ line-height: 1.5;
}
.bus {
diff --git a/src/styles/demos/demo.css b/src/styles/demos/demo.css
@@ -28,8 +28,10 @@
margin-bottom: 12px;
}
+/* the bar's running stats, in figures that keep their width as they change */
.demo-bar .dim {
margin-left: auto;
+ font-variant-numeric: tabular-nums;
}
.demo canvas {
diff --git a/src/styles/demos/othello.css b/src/styles/demos/othello.css
@@ -3,15 +3,16 @@
/* The board in the theme's colours: squares like the computer's modules, ruled
between, and discs like its LEDs, white lit in the theme's text colour and black
unlit (swapped round in light themes, so black stays the dark one). Legal moves
- are faint dots, the last disc played has a red dot, and the search's candidate
- moves are ghost white discs, more solid the more it tried them. */
+ are small dots in --dim, over 3:1 on the felt in every theme; the last disc
+ played has a red dot, and the search's candidate moves are ghost white discs,
+ more solid the more it tried them. */
.oth {
--felt: color-mix(in srgb, var(--bg) 92%, var(--fg));
--felt-line: var(--rule);
--disc-dark: light-dark(var(--fg), color-mix(in oklch, var(--bg) 55%, #000));
--disc-light: light-dark(color-mix(in oklch, var(--bg) 35%, #fff), var(--fg));
display: grid;
- grid-template-columns: repeat(8, minmax(0, 1fr));
+ grid-template-rows: repeat(8, minmax(0, 1fr));
gap: 1px;
width: min(100%, 336px);
aspect-ratio: 1;
@@ -20,15 +21,24 @@
background: var(--felt-line);
}
+.oth-row {
+ display: grid;
+ grid-template-columns: repeat(8, minmax(0, 1fr));
+ gap: 1px;
+}
+
.oth-cell {
position: relative;
- padding: 0;
- border: 0;
- border-radius: 0;
background: var(--felt);
cursor: default;
}
+/* lift the focused square over its neighbours, which would otherwise cover
+ the parts of its ring that reach into them */
+.oth-cell:focus-visible {
+ z-index: 2;
+}
+
.oth-cell.legal {
cursor: pointer;
}
@@ -38,7 +48,7 @@
position: absolute;
inset: 42%;
border-radius: 50%;
- background: var(--faint);
+ background: var(--dim);
}
.oth-cell.legal:hover::after {
@@ -88,3 +98,7 @@
.oth-cell .heat:empty {
background: none;
}
+
+.oth-count {
+ font-variant-numeric: tabular-nums;
+}
diff --git a/src/styles/home/projects.css b/src/styles/home/projects.css
@@ -3,8 +3,10 @@
(src/scripts/home/projects.js) */
.pj {
- /* the wheel's height, and the least a project's split takes */
- --pj-h: min(620px, calc(100svh - var(--top-h) - var(--frame-h) - 72px));
+ /* The wheel's height, and the least a project's split takes: as tall as the
+ tallest project (the 8-bit computer, about 721px), so picking another one
+ doesn't move the page, unless the window is too short to show that much. */
+ --pj-h: min(724px, calc(100svh - var(--top-h) - var(--frame-h) - 72px));
}
/* with room beside the content column, the wheel stands out in the margin */
@@ -26,7 +28,8 @@
position: sticky;
top: calc(var(--top-h) + 24px);
height: var(--pj-h);
- touch-action: none;
+ /* a finger moving up or down scrolls the page, even over the wheel */
+ touch-action: pan-y;
-webkit-user-select: none;
user-select: none;
}
@@ -170,7 +173,7 @@
}
.pj-hint kbd {
- font-size: 10px;
+ font-size: 11px;
}
/* ── the drawings on the cards ────────────────────────────────────── */
@@ -354,9 +357,11 @@
}
}
-/* the demo, under a header line of its own: *demo*, a rule, then a note on what it is */
+/* the demo, under a header line of its own: *demo*, a rule, then a note on what
+ it is. Without room beside the write-up, it comes between the facts and the
+ write-up, with space either side. */
.pj-demo {
- margin-top: 36px;
+ margin: 36px 0;
font-size: 12px;
}
@@ -380,15 +385,24 @@
color: var(--dim);
}
-/* with room, the two windows sit side by side */
+/* with room, the two windows sit side by side: the title, facts and write-up
+ on the left, and the demo the whole height of the right */
@container pj-detail (min-width: 820px) {
.pj-row {
- display: flex;
+ display: grid;
+ grid-template-columns: 50% minmax(0, 1fr);
+ grid-template-rows: auto 1fr;
+ grid-template-areas: "head demo" "body demo";
min-height: var(--pj-h);
}
- .pj-row > .pj-text {
- flex: 0 0 50%;
+ .pj-row > .pj-head {
+ grid-area: head;
+ padding-right: 32px;
+ }
+
+ .pj-row > .proj-body {
+ grid-area: body;
padding-right: 32px;
}
@@ -399,9 +413,9 @@
}
.pj-row > .pj-demo {
- flex: 1;
+ grid-area: demo;
min-width: 0;
- margin-top: 0;
+ margin: 0;
padding-left: 32px;
border-left: 1px solid var(--rule);
}
@@ -439,7 +453,6 @@
height: 200px;
margin: 0 calc(-1 * var(--gutter)) 36px;
overflow: hidden;
- touch-action: pan-y;
}
.pj-card {
diff --git a/src/styles/missing/status-bits.css b/src/styles/missing/status-bits.css
@@ -19,8 +19,9 @@
align-items: center;
gap: 0.14em;
padding: 0 0.04em;
- /* unlit 0s, dimmer than the labels but still 3:1 against the background, as large text needs */
- color: color-mix(in srgb, var(--fg) 48%, var(--bg));
+ /* unlit 0s, dimmer than the labels but still 3:1 against the background, as
+ large text needs: the controls' border colour, which themes.css keeps there */
+ color: var(--control);
font-variant-numeric: tabular-nums;
transition: color 120ms ease;
}
diff --git a/src/styles/themes.css b/src/styles/themes.css
@@ -24,8 +24,15 @@
--magenta: #d3869b;
--glow: 1;
- --dim: color-mix(in srgb, var(--fg) 64%, var(--bg));
+ /* How much of --fg secondary text keeps, set per theme: enough for 4.6:1 on
+ everything it sits on (--bg, the modeline's --bg-alt, --hl under code
+ blocks and selected rows, and the demos' modules at fg 8%). */
+ --dim-mix: 70%;
+ --dim: color-mix(in srgb, var(--fg) var(--dim-mix), var(--bg));
--faint: color-mix(in srgb, var(--fg) 34%, var(--bg));
+ /* the resting border of buttons, selects and inputs: a step below --dim,
+ which keeps it over 3:1 on --bg in every theme while --fg marks hover */
+ --control: color-mix(in srgb, var(--dim) 75%, var(--bg));
--rule: color-mix(in srgb, var(--fg) 15%, var(--bg));
--hl: color-mix(in srgb, var(--fg) 10%, var(--bg));
--wash: color-mix(in srgb, var(--fg) 5%, transparent);
@@ -43,6 +50,7 @@
--bg: #fbf1c7; --bg-alt: #ebdbb2; --fg: #3c3836;
--red: #9d0006; --orange: #af3a03; --yellow: #b57614; --green: #79740e;
--cyan: #427b58; --blue: #076678; --magenta: #8f3f71;
+ --dim-mix: 79%;
--glow: 0;
}
@@ -50,18 +58,21 @@
--bg: #282c34; --bg-alt: #21242b; --fg: #bbc2cf;
--red: #ff6c6b; --orange: #da8548; --yellow: #ecbe7b; --green: #98be65;
--cyan: #46d9ff; --blue: #51afef; --magenta: #c678dd;
+ --dim-mix: 82%;
}
:root[data-theme="atom-one-dark"] {
--bg: #282c34; --bg-alt: #21252b; --fg: #abb2bf;
--red: #e06c75; --orange: #d19a66; --yellow: #e5c07b; --green: #98c379;
--cyan: #56b6c2; --blue: #61afef; --magenta: #c678dd;
+ --dim-mix: 90%;
}
:root[data-theme="modus-vivendi"] {
--bg: #000000; --bg-alt: #1e1e1e; --fg: #ffffff;
--red: #ff5f59; --orange: #fec43f; --yellow: #d0bc00; --green: #44bc44;
--cyan: #00d3d0; --blue: #2fafff; --magenta: #feacd0;
+ --dim-mix: 64%;
}
:root[data-theme="modus-operandi"] {
@@ -69,6 +80,7 @@
--bg: #ffffff; --bg-alt: #f2f2f2; --fg: #000000;
--red: #a60000; --orange: #884900; --yellow: #6f5500; --green: #006800;
--cyan: #005e8b; --blue: #0031a9; --magenta: #721045;
+ --dim-mix: 64%;
--glow: 0;
}
@@ -76,31 +88,39 @@
--bg: #282a36; --bg-alt: #1e2029; --fg: #f8f8f2;
--red: #ff5555; --orange: #ffb86c; --yellow: #f1fa8c; --green: #50fa7b;
--cyan: #8be9fd; --blue: #61bfff; --magenta: #ff79c6;
+ --dim-mix: 64%;
}
:root[data-theme="doom-nord"] {
--bg: #2e3440; --bg-alt: #3b4252; --fg: #eceff4;
--red: #bf616a; --orange: #d08770; --yellow: #ebcb8b; --green: #a3be8c;
--cyan: #88c0d0; --blue: #81a1c1; --magenta: #b48ead;
+ --dim-mix: 70%;
}
:root[data-theme="catppuccin"] {
--bg: #1e1e2e; --bg-alt: #181825; --fg: #cdd6f4;
--red: #f38ba8; --orange: #fab387; --yellow: #f9e2af; --green: #a6e3a1;
--cyan: #94e2d5; --blue: #89b4fa; --magenta: #cba6f7;
+ --dim-mix: 68%;
}
:root[data-theme="doom-tokyo-night"] {
--bg: #1a1b26; --bg-alt: #16161e; --fg: #c0caf5;
--red: #f7768e; --orange: #ff9e64; --yellow: #e0af68; --green: #9ece6a;
--cyan: #7dcfff; --blue: #7aa2f7; --magenta: #bb9af7;
+ --dim-mix: 70%;
}
+/* doom's foreground, #556b72, is only 5.2:1 on this cream, which leaves no room
+ for a lighter --dim that still reads. So the text is darkened a fifth towards
+ base03 (#002b36), and --dim lands about where the old text colour was. */
:root[data-theme="doom-solarized-light"] {
color-scheme: light;
- --bg: #fdf6e3; --bg-alt: #eee8d5; --fg: #556b72;
+ --bg: #fdf6e3; --bg-alt: #eee8d5; --fg: #445e66;
--red: #dc322f; --orange: #cb4b16; --yellow: #b58900; --green: #859900;
--cyan: #2aa198; --blue: #268bd2; --magenta: #d33682;
+ --dim-mix: 92%;
--glow: 0;
}