commit 4c4913d39097c109c298cbed73692d837ef38d84
parent 0dba4351c12b63f00661528fa6438cbbfc636565
Author: Michael Neuper <michael@michaelneuper.com>
Date: Mon, 12 Oct 2026 20:23:24 +0200
rework projects section
Diffstat:
19 files changed, 946 insertions(+), 450 deletions(-)
diff --git a/src/components/home/ProjectThumb.astro b/src/components/home/ProjectThumb.astro
@@ -0,0 +1,115 @@
+---
+// A small drawing of a project's demo for its card on the projects wheel, in
+// the theme's colours: the computer's LEDs, the scheduler's cores, an Othello
+// board mid-search, a compiled expression, and packets crossing over UDP.
+
+interface Props {
+ demo: string;
+}
+
+const { demo } = Astro.props;
+const range = (n: number) => Array.from({ length: n }, (_, i) => i);
+const bits = (byte: number) => range(8).map((k) => (byte >> (7 - k)) & 1);
+
+// the first few bytes of the Fibonacci program the computer starts with
+const RAM = [0x51, 0x4f, 0x50, 0xe0, 0x2f, 0x70, 0x4e];
+// cores in the scheduler: [start, length, colour] blocks
+const CORES = [
+ [[0, 5, "yellow"], [5, 4, "red"], [9, 3, "yellow"]],
+ [[0, 3, "orange"], [3, 6, "cyan"], [9, 3, "orange"]],
+ [[0, 7, "magenta"], [7, 3, "green"], [10, 2, "magenta"]],
+ [[0, 2, "green"], [2, 5, "red"], [7, 5, "blue"]],
+];
+const QUEUE = ["orange", "red", "cyan", "green", "yellow"];
+// an Othello position: 1 is the player's disc, 2 the opponent's; shade is how
+// often the search tried each legal move
+const BOARD = [
+ "........",
+ "...2....",
+ "..1222..",
+ "..2112..",
+ "..1221..",
+ "...112..",
+ "....2...",
+ "........",
+];
+const SHADE: Record<string, number> = { "1,2": 0.25, "2,6": 0.7, "6,3": 0.4, "5,6": 0.15 };
+const CODE = ["iload_0", "iconst_2", "imul", "iconst_1", "iadd", "ireturn"];
+// packets: 1 arrived, 0 lost and waiting to be resent
+const GOT = [1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1];
+---
+<svg class="th" viewBox="0 0 160 96" aria-hidden="true">
+ {demo === "computer" && (
+ <g>
+ <rect class="th-box" x="4.5" y="4.5" width="64" height="14" />
+ {range(4).map((k) => <circle class="th-on" cx={12 + k * 7} cy="11.5" r="2.2" />)}
+ <rect class="th-box" x="4.5" y="22.5" width="64" height="54" />
+ {RAM.map((b, r) => bits(b).map((on, k) => <circle class={on ? "th-red" : "th-off"} cx={12 + k * 7} cy={29.5 + r * 7} r="2.2" />))}
+ <rect class="th-box" x="4.5" y="80.5" width="64" height="11" />
+ {[0, 1, 0, 1, 1, 1, 1, 0].map((on, k) => <circle class={on ? "th-on" : "th-off"} cx={12 + k * 7} cy="86" r="2" />)}
+ <rect class="th-box" x="74.5" y="4.5" width="11" height="87" />
+ {range(4).map((k) => <line class="th-line" x1={77 + k * 2} y1="8" x2={77 + k * 2} y2="88" />)}
+ <rect class="th-box" x="91.5" y="4.5" width="64" height="14" />
+ {[0, 1, 1, 0].map((on, k) => <circle class={on ? "th-on" : "th-off"} cx={99 + k * 7} cy="11.5" r="2.2" />)}
+ <rect class="th-box" x="91.5" y="22.5" width="64" height="14" />
+ {bits(0x2a).map((on, k) => <circle class={on ? "th-red" : "th-off"} cx={99 + k * 7} cy="29.5" r="2.2" />)}
+ <rect class="th-box" x="91.5" y="40.5" width="64" height="14" />
+ {bits(0x15).map((on, k) => <circle class={on ? "th-red" : "th-off"} cx={99 + k * 7} cy="47.5" r="2.2" />)}
+ <rect class="th-box" x="91.5" y="58.5" width="64" height="33" />
+ {/* the output, a seven-segment 42 */}
+ <path class="th-seg-off" d="M112 64h8M112 75h8M112 86h8M111 65v9M121 65v9M111 76v9M121 76v9M126 64h8M126 75h8M126 86h8M125 65v9M135 65v9M125 76v9M135 76v9" />
+ <path class="th-seg" d="M111 65v9M121 65v9M112 75h8M121 76v9M126 64h8M135 65v9M126 75h8M125 76v9M126 86h8" />
+ </g>
+ )}
+
+ {demo === "scheduler" && (
+ <g>
+ {CORES.map((blocks, r) => (
+ <g>
+ <rect class="th-off" x="4" y={6 + r * 18} width="152" height="13" />
+ {blocks.map(([s, len, c]) => <rect x={64 + (s as number) * 7.67} y={6 + r * 18} width={(len as number) * 7.67 - 1} height="13" style={`fill: var(--${c})`} />)}
+ </g>
+ ))}
+ {QUEUE.map((c, k) => <rect class="th-q" x={4.5 + k * 22} y="80.5" width="18" height="10" style={`stroke: var(--${c})`} />)}
+ </g>
+ )}
+
+ {demo === "othello" && (
+ <g>
+ <rect class="th-box" x="36.5" y="4.5" width="87" height="87" />
+ {range(7).map((k) => <line class="th-line" x1={47.375 + k * 10.875} y1="5" x2={47.375 + k * 10.875} y2="91" />)}
+ {range(7).map((k) => <line class="th-line" x1="37" y1={15.375 + k * 10.875} x2="123" y2={15.375 + k * 10.875} />)}
+ {Object.entries(SHADE).map(([at, a]) => {
+ const [r, c] = at.split(",").map(Number);
+ return <rect class="th-on" x={37 + c * 10.875} y={5 + r * 10.875} width="10.4" height="10.4" style={`opacity: ${a}`} />;
+ })}
+ {BOARD.map((row, r) => [...row].map((ch, c) => ch !== "." && (
+ <circle class={ch === "1" ? "th-disc" : "th-disc-o"} cx={42.4 + c * 10.875} cy={10.4 + r * 10.875} r="3.6" />
+ )))}
+ </g>
+ )}
+
+ {demo === "bytecode" && (
+ <g>
+ <text class="th-src" x="6" y="15">x * 2 + 1</text>
+ {CODE.map((op, k) => <text class:list={["th-op", { "is-at": k === 2 }]} x="6" y={32 + k * 11}>{op}</text>)}
+ {[["2", 70.5], ["x", 54.5]].map(([v, y]) => (
+ <g>
+ <rect class="th-box" x="112.5" y={y} width="40" height="13" />
+ <text class="th-op" x="128" y={(y as number) + 9.5}>{v}</text>
+ </g>
+ ))}
+ <line class="th-line" x1="108" y1="87.5" x2="156" y2="87.5" />
+ </g>
+ )}
+
+ {demo === "rbudp" && (
+ <g>
+ {range(25).map((k) => <rect class="th-dim" x={6 + (k % 5) * 8} y={14 + Math.floor(k / 5) * 8} width="6" height="6" />)}
+ {range(25).map((k) => <rect class={GOT[k] ? "th-on" : "th-off"} x={114 + (k % 5) * 8} y={14 + Math.floor(k / 5) * 8} width="6" height="6" />)}
+ {[[58, 30], [72, 22], [86, 42], [97, 34]].map(([x, y]) => <rect class="th-on" x={x} y={y} width="6" height="6" style="opacity: 0.6" />)}
+ <line class="th-line th-dash" x1="6" y1="70.5" x2="154" y2="70.5" />
+ <path class="th-line" d="M100 77l-6 3.5 6 3.5" />
+ </g>
+ )}
+</svg>
diff --git a/src/components/home/Projects.astro b/src/components/home/Projects.astro
@@ -1,54 +1,63 @@
---
-// Projects, laid out as a linker memory map (src/scripts/home/memmap.js builds
-// the map, the rows and the hexdump from this list). Each project is a file in
-// src/content/projects; its demo is src/scripts/demos/<demo>.js.
+// Projects: a wheel of cards, out in the margin on wide screens, and the
+// project picked on it, written up with its demo. Each project is a file in
+// src/content/projects; src/scripts/home/projects.js turns the wheel, and a
+// project's demo is src/scripts/demos/<demo>.js. Without JavaScript the
+// projects are a plain list.
import { getCollection, render } from "astro:content";
import Buffer from "../frame/Buffer.astro";
+import ProjectThumb from "./ProjectThumb.astro";
import "../../styles/home/projects.css";
import "../../styles/demos/demo.css";
+const CATS: Record<string, string> = { hw: "hardware", sw: "software", math: "maths", sec: "security" };
const projects = await Promise.all(
(await getCollection("projects"))
.sort((a, b) => a.data.order - b.data.order)
.map(async (p) => ({ ...p.data, Body: (await render(p)).Content })),
);
---
-<Buffer id="projects" file="projects.map" mode="Fundamental" metaAttrs={{ "data-mm": "meta" }}>
+<Buffer id="projects" file="projects.map" mode="Fundamental" metaAttrs={{ "data-pj": "meta" }}>
<h2 class="h2">Projects</h2>
- <p class="lede">Laid out like a linker map: each project is a region in memory holding its one-line summary, and the hexdump shows exactly those bytes. Select one to read about it and try it out.</p>
- <div class="mm">
- <div class="mm-bar" aria-hidden="true"></div>
- <div class="mm-cols">
- <div class="mm-table">
- <p class="mm-head" aria-hidden="true"><span>origin</span><span>length</span><span>name</span><span>source</span></p>
- <ol class="mm-list">
- {projects.map((p) => (
- <li class="proj" data-cat={p.cat} data-src={p.src} data-demo={p.demo} data-wide={p.wide || undefined}>
- <h3 class="proj-name">{p.name}</h3>
- <p class="proj-sum">{p.summary}</p>
- <div class="proj-body">
- <p.Body />
- {p.stack && <p class="proj-stack">{p.stack}</p>}
- {p.links.length > 0 && <p class="proj-links">{p.links.map((l) => <a href={l.href}>{l.text}</a>)}</p>}
- {p.note && <p class="proj-links dim">{p.note}</p>}
- </div>
- </li>
- ))}
- </ol>
+ <div class="pj">
+ <div class="pj-wheel">
+ <svg class="pj-spine" aria-hidden="true"><path /></svg>
+ <div class="pj-cards" role="tablist" aria-label="Projects" aria-orientation="vertical">
+ {projects.map((p, i) => (
+ <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>
+ </button>
+ ))}
</div>
- <article class="mm-detail" aria-live="polite"></article>
+ <p class="pj-hint" aria-hidden="true">scroll, drag or <kbd>↑</kbd> <kbd>↓</kbd></p>
</div>
+
+ <article class="pj-detail" id="project-detail" role="tabpanel" aria-live="polite"></article>
+
+ <ol class="pj-list">
+ {projects.map((p) => (
+ <li class="proj" data-cat={p.cat} data-demo={p.demo} data-demo-note={p.demoNote}>
+ <p class="proj-cat">{CATS[p.cat] ?? p.cat}</p>
+ <h3 class="proj-name">{p.name}</h3>
+ <p class="proj-sum">{p.summary}</p>
+ <div class="proj-body">
+ <p.Body />
+ {p.stack && <p class="proj-stack">{p.stack}</p>}
+ {p.links.length > 0 && <p class="proj-links">{p.links.map((l) => <a href={l.href}>{l.text}</a>)}</p>}
+ {p.note && <p class="proj-links dim">{p.note}</p>}
+ </div>
+ </li>
+ ))}
+ </ol>
</div>
</Buffer>
<script>
- import { fontsReady } from "../../scripts/util.js";
- import { initMemmap } from "../../scripts/home/memmap.js";
+ import { initProjects } from "../../scripts/home/projects.js";
// the 8-bit computer's M-x commands; its project mounts the board as a demo
import "../../scripts/demos/computer/commands.js";
- // The hexdump measures character widths, so wait for the mono font.
- await fontsReady();
- initMemmap();
+ initProjects();
</script>
diff --git a/src/content.config.ts b/src/content.config.ts
@@ -18,21 +18,20 @@ const posts = defineCollection({
}),
});
-// Projects in the memory map on the home page, one file each, listed by order.
-// Each one's region holds its summary as UTF-8, so the summary sets its size.
+// Projects on the home page's wheel, one file each, listed by order.
const projects = defineCollection({
loader: glob({ pattern: "*.md", base: "./src/content/projects" }),
schema: z.object({
name: z.string(),
order: z.number(),
cat: z.enum(["hw", "sw", "math", "sec"]),
- // Shown in the source column: built, private, a repo...
- src: z.string(),
summary: z.string(),
- // computer, scheduler, bytecode, othello or rbudp (src/scripts/demos/<demo>.js)
+ // computer, scheduler, bytecode, othello or rbudp (src/scripts/demos/<demo>.js);
+ // it also picks the drawing on the project's card (src/components/home/ProjectThumb.astro)
demo: z.string().optional(),
- // The demo gets the full width under the list.
- wide: z.boolean().default(false),
+ // A line or two over the demo on what it is: the demos are written for the
+ // page, so this says how one differs from the project itself.
+ demoNote: z.string().optional(),
stack: z.string().optional(),
links: z.array(z.object({ text: z.string(), href: z.string() })).default([]),
// A line in place of links, like "Source private (coursework)".
diff --git a/src/content/projects/8-bit-computer.md b/src/content/projects/8-bit-computer.md
@@ -2,9 +2,8 @@
name: 8-bit-computer
order: 1
cat: hw
-src: built
demo: computer
-wide: true
+demoNote: "A JavaScript simulation of the board, made for this page. It runs the same instructions and microcode as the breadboards, so you can step through a program one control word at a time."
summary: "Ben Eater's 8-bit breadboard computer, built chip by chip."
stack: "74HC logic, EEPROMs, breadboards, a lot of jumper wire"
links:
@@ -12,6 +11,6 @@ links:
href: "https://eater.net/8bit"
---
-My most recent finished project: a programmable computer on breadboards, built from 74-series logic chips following Ben Eater’s video series. It has 16 bytes of RAM, an 8-bit bus, and microcode in EEPROMs that drives sixteen control lines.
+My most recent finished project: a programmable computer on breadboards, built from 74-series logic chips by following Ben Eater’s video series. It is a set of modules that share one 8-bit bus: a program counter, 16 bytes of RAM, an instruction register, A and B registers feeding an adder that also subtracts, and an output register that drives a 7-segment display.
-The simulation below runs the same instruction set and microcode, so you can step through a program one control word at a time.
+Every instruction takes five clock steps, and the first two fetch it from RAM. At each step, microcode stored in EEPROMs sets sixteen control lines that decide which module puts a value on the bus and which ones read it. Carry and zero flags give it conditional jumps, enough for loops like counting up and down or computing Fibonacci numbers.
diff --git a/src/content/projects/chat-voip-p2p.md b/src/content/projects/chat-voip-p2p.md
@@ -2,18 +2,16 @@
name: chat-voip-p2p
order: 5
cat: sw
-src: private
demo: rbudp
+demoNote: "The RBUDP file transfer in miniature, animated for this page: blast every packet over UDP, ask over TCP which ones arrived, and resend the gaps."
summary: "Four networking projects in Java: chat, fast UDP file transfer, voice calls and P2P sharing."
stack: "Java, JavaFX, TCP and UDP sockets, Java Sound, RSA, AES-GCM"
note: "Source private (coursework, group project)"
---
-A series of networking projects built with my group for a university course:
+Four projects my group built for a university course:
- A chat server that handles many clients at once, with a JavaFX client, load-tested with dozens of simultaneous users.
- File transfer over Reliable Blast UDP, measured against TCP across packet sizes, loss rates and file sizes from 1 to 100 MB.
- The chat extended with voice calls and group calls streamed over UDP, plus voice notes.
- Peer-to-peer file sharing: a server brokers searches, then peers send files to each other directly over TCP, encrypted with AES-GCM keys exchanged using RSA, with pause and resume. We tested it across four machines over Tailscale.
-
-Below is the RBUDP transfer in miniature: blast every packet over UDP, ask over TCP which ones arrived, and resend the gaps.
diff --git a/src/content/projects/jvm-compiler.md b/src/content/projects/jvm-compiler.md
@@ -2,11 +2,13 @@
name: jvm-compiler
order: 4
cat: sw
-src: private
demo: bytecode
+demoNote: "Only the last step, for arithmetic expressions, rewritten in JavaScript for this page. The JVM is a stack machine, so an expression compiles to instructions that push and pop an operand stack. Type one to see its bytecode and run it."
summary: "A compiler in C for a custom language that targets the JVM."
-stack: "C, JVM bytecode"
+stack: "C, JVM bytecode, Jasmin"
note: "Source private (computer architecture coursework)"
---
-I wrote it in C for a computer architecture course. It reads a small custom programming language and produces bytecode the Java virtual machine can run. The source stays private, but the last stage is easy to show: the JVM is a stack machine, so an expression compiles to instructions that push and pop an operand stack. Type one below to see the bytecode and run it.
+I wrote it for a computer architecture course. It reads a program in a small procedural language, with subroutines, arrays, `if` and `while` statements, input and output, and produces a class file the Java virtual machine can run.
+
+It works in stages. A scanner splits the source into tokens, and a recursive-descent parser checks them against the grammar. A symbol table, built on my own hash table, records every name and its type so the type checker can catch mistakes. The last stage emits JVM instructions, which Jasmin assembles into the class file.
diff --git a/src/content/projects/os-scheduler.md b/src/content/projects/os-scheduler.md
@@ -2,10 +2,13 @@
name: os-scheduler
order: 2
cat: sw
-src: private
demo: scheduler
+demoNote: "A simplified simulation written for this page. Its processes only use the CPU, so none of them wait for resources or deadlock."
summary: "A multicore operating system scheduler, for a concurrency course."
+stack: "C, OpenMP"
note: "Source private (concurrency coursework)"
---
-The part of an operating system that decides which process runs on which core, and for how long. I wrote it for a concurrency course, so the source stays private, but the idea fits in a small simulation: processes arrive, wait in a shared ready queue, and get dispatched to cores under first come, first served, round robin or preemptive priority scheduling.
+The part of an operating system that decides which process runs on which core, and for how long. Processes come from a file that lists each one’s instructions, which request and release named resources. OpenMP threads act as the cores: each takes the next process from a shared ready queue, behind a lock so two cores never take the same one, and runs it under first come, first served, round robin or preemptive priority scheduling.
+
+A process that asks for a resource another process has goes into a waiting queue until the holder releases it, then back to the ready queue. Once nothing is left to run, the scheduler checks the waiting processes for deadlock by following who holds the resource each one needs: a chain that leads back to where it started is a deadlock.
diff --git a/src/content/projects/othello-mcts.md b/src/content/projects/othello-mcts.md
@@ -2,8 +2,8 @@
name: othello-mcts
order: 3
cat: sw
-src: private
demo: othello
+demoNote: "A serial version of the player, rewritten in JavaScript for this page. While it thinks, each legal move is shaded by how often the search tried it."
summary: "An Othello player in C that runs Monte Carlo tree search across MPI processes."
stack: "C, MPI, Docker"
note: "Source private (concurrency coursework)"
@@ -11,6 +11,4 @@ note: "Source private (concurrency coursework)"
The player picks a move by running thousands of random games from the current position and keeping the move that was explored most, using UCT to balance trying new moves against improving promising ones. Each move gets one second.
-The project was for a concurrency course, so the next step was to parallelise it with MPI, which I did in three ways: independent search trees combined at the end with `MPI_Allreduce`, trees that share their statistics every so often, and the same sharing done asynchronously with non-blocking sends. In ten-game matches on four processes, each version beat the one before it, except the asynchronous version, which won half its games against the synchronous one.
-
-You can play a serial version, rewritten in JavaScript for this page, below. While it thinks, each legal move is shaded by how often the search tried it.
+The project was for a concurrency course, so the search also runs in parallel over MPI, in three versions: independent search trees combined at the end with `MPI_Allreduce`, trees that share their statistics every so often, and the same sharing done asynchronously with non-blocking sends. Each version is packaged as a Docker image, and a match runner plays them against each other. In ten-game matches on four processes, each version beat the one before it, except the asynchronous version, which won half its games against the synchronous one.
diff --git a/src/scripts/demos/computer/board.js b/src/scripts/demos/computer/board.js
@@ -76,11 +76,12 @@ export function mount(host) {
const mod = (key, name, sigs = []) => {
const m = el("section", `mod mod-${key}`);
const head = el("div", "mod-head");
- head.append(el("span", "mod-name", name));
+ const lines = el("span", "sigs");
+ head.append(el("span", "mod-name", name), lines);
for (const sig of sigs) {
const t = el("span", "sig", LABEL[sig] ?? sig);
t.dataset.sig = sig;
- head.append(t);
+ lines.append(t);
}
const body = el("div", "mod-body");
m.append(head, body);
@@ -138,8 +139,8 @@ export function mount(host) {
const ir = mod("ir", "instruction", ["II", "IO"]);
ir.body.append(leds(ir, 8, "", 4), value(ir));
- ir.leds.forEach((l, i) => l.classList.add(i < 4 ? "b" : "y"));
- left.append(clk.m, mar.m, ram.m, ir.m);
+ // the opcode nibble in green, the operand in yellow
+ ir.leds.forEach((l, i) => l.classList.add(i < 4 ? "g" : "y"));
const pc = mod("pc", "program counter", ["CO", "J", "CE"]);
pc.body.append(leds(pc, 4, "g"), value(pc));
@@ -179,7 +180,8 @@ export function mount(host) {
fl.append(cfLed, el("span", "dim", "C"), el("span", "gap"), zfLed, el("span", "dim", "Z"));
fl.style.alignItems = "center";
flags.body.append(fl, value(flags));
- right.append(pc.m, a.m, sum.m, b.m, out.m, flags.m);
+ const pcRow = el("div", "mod-pair");
+ pcRow.append(pc.m, flags.m);
const bus = el("div", "bus");
const busHead = el("div", "mod-head");
@@ -194,32 +196,36 @@ export function mount(host) {
}
bus.append(busHead, busVal, lanesWrap);
- const ctrl = mod("ctrl", "control word", []);
- ctrl.m.classList.add("ctrl");
- const ctrlRow = el("div", "ctrl-row");
+ // the microstep counter beside the clock it counts, and the control word that step sets
+ const stepMod = mod("step", "microstep counter", []);
const stepWrap = el("div", "cw");
const stepLeds = [];
for (let i = 0; i < 5; i++) {
const bit = el("span", "cw-bit");
- const l = el("span", "led");
+ const l = el("span", "led g");
bit.append(l, el("span", "", `T${i}`));
stepWrap.append(bit);
stepLeds.push([bit, l]);
}
+ stepMod.body.replaceWith(stepWrap);
+ const ctrl = mod("ctrl", "control word", []);
const cwWrap = el("div", "cw");
const cwBits = LINES.map((n) => {
const bit = el("span", "cw-bit");
- const l = el("span", "led b");
+ const l = el("span", "led y");
bit.append(l, el("span", "", LABEL[n] ?? n));
cwWrap.append(bit);
return [bit, l];
});
- ctrlRow.append(stepWrap, cwWrap);
const ustep = el("p", "ustep");
- ctrl.body.replaceWith(ctrlRow);
+ ctrl.body.replaceWith(cwWrap);
ctrl.m.append(ustep);
+ const clkRow = el("div", "mod-pair");
+ clkRow.append(clk.m, stepMod.m);
+ left.append(clkRow, mar.m, ram.m, ir.m);
+ right.append(pcRow, a.m, sum.m, b.m, out.m, ctrl.m);
- board.replaceChildren(left, bus, right, ctrl.m);
+ board.replaceChildren(left, bus, right);
// ── render ─────────────────────────────────────────────────────────
const setBits = (list, v, n = list.length) => {
diff --git a/src/scripts/demos/othello.js b/src/scripts/demos/othello.js
@@ -166,7 +166,7 @@ export function mount(host) {
const h = heat?.get(i);
const hs = cell.querySelector(".heat");
hs.textContent = h ? `${Math.round(h * 100)}` : "";
- hs.style.setProperty("--hp", h ? `${Math.round(15 + h * 75)}%` : "0%");
+ 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"}`);
});
scoreEl.textContent = `black ${b}, white ${w}`;
diff --git a/src/scripts/demos/scheduler.js b/src/scripts/demos/scheduler.js
@@ -20,7 +20,7 @@ export function mount(host) {
<span class="dim" data-k="stats"></span>
</div>
<canvas height="150" aria-label="Gantt chart of processes running on four cores" role="img"></canvas>
- <p class="cap">A small simulation written for this page: 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">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>`;
const cv = host.querySelector("canvas");
const sel = host.querySelector("select");
const stats = host.querySelector('[data-k="stats"]');
diff --git a/src/scripts/home/memmap.js b/src/scripts/home/memmap.js
@@ -1,206 +0,0 @@
-// Projects as a linker memory map. Each project's region holds its one-line
-// summary as UTF-8, so a region is exactly as long as that text, origins are
-// cumulative, and the hexdump shows the region's actual bytes. Projects with a
-// data-demo attribute mount a small interactive demo under their write-up.
-
-import { $, $$, hex, reducedMotion, on } from "../util.js";
-
-const GLYPHS = "░▒▓/\\<>+=*#%&@$0123456789ABCDEF";
-
-export function initMemmap() {
- const mm = $(".mm");
- if (!mm) return;
- const bar = $(".mm-bar", mm);
- const detail = $(".mm-detail", mm);
- const narrow = matchMedia("(max-width: 920px)");
- const enc = new TextEncoder();
-
- let origin = 0;
- const projects = $$(".proj", mm).map((li) => {
- const sum = $(".proj-sum", li).textContent.trim();
- const p = {
- li,
- cat: li.dataset.cat,
- src: li.dataset.src || "",
- demo: li.dataset.demo || "",
- wide: "wide" in li.dataset,
- name: $(".proj-name", li).textContent.trim(),
- sum,
- bytes: enc.encode(sum),
- body: $(".proj-body", li),
- origin,
- };
- origin += p.bytes.length;
- return p;
- });
- const meta = $('[data-mm="meta"]');
- if (meta) meta.textContent = `${projects.length} regions, 0x${hex(origin, 4)} bytes`;
-
- projects.forEach((p, i) => {
- const h = $(".proj-name", p.li);
- const btn = document.createElement("button");
- btn.type = "button";
- btn.className = "proj-row";
- btn.dataset.cat = p.cat;
- btn.setAttribute("aria-pressed", "false");
- btn.innerHTML = `<span class="o">0x${hex(p.origin, 4)}</span><span class="l">0x${hex(p.bytes.length, 2)}</span><span class="n"></span><span class="y"></span>`;
- const n = $(".n", btn);
- n.textContent = p.name;
- const s = document.createElement("span");
- s.className = "s";
- s.textContent = p.sum;
- n.append(s);
- $(".y", btn).textContent = p.src;
- h.replaceChildren(btn);
- btn.addEventListener("click", () => select(i, true));
- p.btn = btn;
-
- const seg = document.createElement("div");
- seg.className = "mm-seg";
- seg.dataset.cat = p.cat;
- seg.style.flexGrow = p.bytes.length;
- seg.textContent = p.name;
- seg.title = `${p.name}: 0x${hex(p.origin, 4)}, ${p.bytes.length} bytes`;
- seg.addEventListener("click", () => select(i, true));
- bar.append(seg);
- p.seg = seg;
- });
- mm.classList.add("is-built");
-
- let current = -1;
- let anim = 0;
- let unmount = null;
- let wideHost = null;
-
- async function select(i, user = false) {
- const p = projects[i];
- if (user && i === current && narrow.matches) {
- close();
- return;
- }
- current = i;
- projects.forEach((q, j) => {
- q.btn.setAttribute("aria-pressed", String(i === j));
- q.seg.classList.toggle("is-active", i === j);
- });
- unmount?.();
- unmount = null;
- wideHost?.remove();
- wideHost = null;
- detail.dataset.cat = p.cat;
-
- const dump = document.createElement("pre");
- dump.className = "hex";
- dump.setAttribute("aria-hidden", "true");
- const title = document.createElement("h3");
- title.textContent = p.name;
- const body = p.body.cloneNode(true);
- detail.replaceChildren(dump, title, body);
- place();
- hexdump(dump, p);
-
- if (p.demo) {
- const host = document.createElement("div");
- host.className = p.wide ? "demo demo-wide" : "demo";
- if (p.wide) wideHost = host;
- placeDemo(host, p);
- try {
- const mod = await import(`../demos/${p.demo}.js`);
- if (current === i && host.isConnected) unmount = mod.mount(host) ?? null;
- } catch (err) {
- console.error(err);
- host.remove();
- }
- }
- }
-
- function close() {
- unmount?.();
- unmount = null;
- wideHost?.remove();
- wideHost = null;
- projects[current]?.btn.setAttribute("aria-pressed", "false");
- projects[current]?.seg.classList.remove("is-active");
- detail.replaceChildren();
- current = -1;
- }
-
- // On narrow screens the write-up opens under its row instead of beside the list.
- function place() {
- const p = projects[current];
- if (!p) return;
- if (narrow.matches) p.li.append(detail);
- else $(".mm-cols", mm).append(detail);
- if (wideHost) placeDemo(wideHost, p);
- }
-
- // Most demos sit under the write-up; a wide one gets the full width under the list.
- function placeDemo(host, p) {
- if (p.wide && !narrow.matches) mm.append(host);
- else detail.append(host);
- }
-
- // Other parts of the page can open a project by name (the computer commands do).
- on("project", (name) => {
- const i = projects.findIndex((q) => q.name === name);
- if (i >= 0 && i !== current) select(i);
- });
- narrow.addEventListener("change", place);
-
- function hexdump(pre, p) {
- // 16 bytes a line like hexdump -C when its 78 columns fit, otherwise 8
- const probe = document.createElement("span");
- probe.textContent = "0".repeat(10);
- pre.append(probe);
- const cols = Math.floor(pre.clientWidth / (probe.getBoundingClientRect().width / 10 || 7));
- probe.remove();
- const per = cols >= 78 ? 16 : 8;
- const lines = [];
- for (let off = 0; off < p.bytes.length; off += per) {
- const chunk = [...p.bytes.slice(off, off + per)];
- const hx = chunk.map((b) => hex(b)).concat(Array(per - chunk.length).fill(" "));
- const ascii = chunk.map((b) => (b >= 32 && b < 127 ? String.fromCharCode(b) : ".")).join("");
- lines.push({
- addr: hex(p.origin + off, 8),
- hex: per === 16 ? `${hx.slice(0, 8).join(" ")} ${hx.slice(8).join(" ")}` : hx.join(" "),
- ascii: ascii.padEnd(per, " "),
- });
- }
- const render = (reveal) => {
- pre.replaceChildren();
- lines.forEach((ln, li) => {
- const k = reveal - li * 0.08;
- const a = document.createElement("span");
- a.className = "addr";
- a.textContent = ln.addr;
- const asc = document.createElement("span");
- asc.className = "a";
- asc.textContent = `|${scramble(ln.ascii, k)}|`;
- pre.append(a, ` ${scramble(ln.hex, k)} `, asc, li < lines.length - 1 ? "\n" : "");
- });
- };
- cancelAnimationFrame(anim);
- if (reducedMotion.matches) return render(9);
- const t0 = performance.now();
- const frame = (now) => {
- const k = (now - t0) / 480;
- render(k);
- if (k < 1 + lines.length * 0.08) anim = requestAnimationFrame(frame);
- };
- anim = requestAnimationFrame(frame);
- }
-
- select(0);
-}
-
-// Characters left of the reveal point are final; to the right they flicker.
-function scramble(str, k) {
- if (k >= 1) return str;
- const cut = Math.max(0, Math.floor(str.length * k));
- let out = str.slice(0, cut);
- for (let i = cut; i < str.length; i++) {
- const c = str[i];
- out += k < 0 ? " " : c === " " ? c : GLYPHS[(Math.random() * GLYPHS.length) | 0];
- }
- return out;
-}
diff --git a/src/scripts/home/projects.js b/src/scripts/home/projects.js
@@ -0,0 +1,284 @@
+// 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.
+//
+// On wide screens the wheel stands in the left margin, bulging towards the
+// content; on narrow ones it lies across the top of the section.
+
+import { $, $$, on, onFrame, reducedMotion, clamp } from "../util.js";
+
+const GAP = 18; // px between neighbouring cards along the arc
+const STUB = 14; // px from a card to the spine
+const HINT = 28; // px kept clear under the wheel for the hint
+
+export function initProjects() {
+ const root = $(".pj");
+ if (!root) return;
+ const wheel = $(".pj-wheel", root);
+ const list = $(".pj-cards", root);
+ const cards = $$(".pj-card", root);
+ const items = $$(".proj", root);
+ const detail = $(".pj-detail", root);
+ const spine = $(".pj-spine", root);
+ const meta = $('[data-pj="meta"]');
+ const hint = $(".pj-hint", root);
+ const across = matchMedia("(max-width: 999px)");
+ const n = cards.length;
+ if (!n) return;
+
+ const pointer = document.createElement("span");
+ pointer.className = "pj-pointer";
+ pointer.setAttribute("aria-hidden", "true");
+ wheel.append(pointer);
+ root.classList.add("is-built");
+
+ // Positions count cards round the ring and keep counting past a full turn,
+ // so the wheel always turns the short way; a card's index is the position mod n.
+ let pos = 0; // where the wheel is while it turns
+ let target = 0; // where it is turning to
+ let shown = -1; // the card in the detail
+ let geo = null;
+ let stop = null;
+ let settle = 0;
+ let unmount = null;
+
+ const mod = (k) => ((k % n) + n) % n;
+ // how far card i is from position p, the short way round: -n/2 to n/2
+ const offset = (i, p) => mod(i - p + n / 2) - n / 2;
+
+ // ── the ring ─────────────────────────────────────────────────────────
+
+ function layout() {
+ const W = wheel.clientWidth, H = wheel.clientHeight;
+ const cw = cards[0].offsetWidth, ch = cards[0].offsetHeight;
+ if (!W || !H || !cw) return;
+ // the cards' layer starts out in the margin; positions here are from the wheel's own left edge
+ 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>";
+ if (flat) {
+ // centre below the wheel; the picked card at the top of the arc
+ const R = Math.max(W * 1.15, 560);
+ geo = { flat, R, a: (cw + GAP) / R, x: W / 2, y: ch / 2 + 16, cw, ch, ox };
+ pointer.style.transform = `translate(${W / 2 - 5}px, 3px)`;
+ } else {
+ // centre off to the left; the picked card at the arc's right-hand point
+ const h = H - HINT;
+ const R = Math.max(h * 0.8, 380);
+ geo = { flat, R, a: (ch + GAP) / R, x: W - cw / 2 - 16, y: h / 2, cw, ch, ox };
+ pointer.style.transform = `translate(${W - 11}px, ${h / 2 - 5}px)`;
+ }
+ drawSpine(W, H);
+ render();
+ }
+
+ // a circle inside the cards' one, which every card's stub reaches
+ function drawSpine(W, H) {
+ const { flat, R, x, y, cw, ch } = geo;
+ const r = R - (flat ? ch : cw) / 2 - STUB;
+ const ox = flat ? x : x - R, oy = flat ? y + R : y;
+ const A = 1.25;
+ const at = (t) => (flat ? [ox + r * Math.sin(t), oy - r * Math.cos(t)] : [ox + r * Math.cos(t), oy + r * Math.sin(t)]);
+ const [x0, y0] = at(-A), [x1, y1] = at(A);
+ spine.setAttribute("viewBox", `${geo.ox} 0 ${W - geo.ox} ${H}`);
+ spine.firstElementChild.setAttribute("d", `M${x0.toFixed(1)} ${y0.toFixed(1)}A${r} ${r} 0 0 1 ${x1.toFixed(1)} ${y1.toFixed(1)}`);
+ }
+
+ function render() {
+ if (!geo) return;
+ const { flat, R, a, x, y, cw, ch, ox } = geo;
+ const far = n / 2;
+ cards.forEach((card, i) => {
+ const d = offset(i, pos);
+ const t = d * a;
+ const cx = flat ? x + R * Math.sin(t) : x - R * (1 - Math.cos(t));
+ const cy = flat ? y + R * (1 - Math.cos(t)) : y + R * Math.sin(t);
+ const k = Math.abs(d);
+ card.style.transform = `translate(${(cx - ox - cw / 2).toFixed(1)}px, ${(cy - ch / 2).toFixed(1)}px) rotate(${t.toFixed(4)}rad) scale(${(1 - k * 0.07).toFixed(3)})`;
+ // gone by the far side of the ring, where a card crosses from one end to the other
+ card.style.opacity = Math.max(0, 1 - (k / far) ** 3).toFixed(3);
+ card.style.zIndex = String(10 - Math.round(k));
+ });
+ }
+
+ // ── turning ──────────────────────────────────────────────────────────
+
+ function go(p, { now = false, focus = false } = {}) {
+ target = Math.round(p);
+ const i = mod(target);
+ cards.forEach((c, j) => {
+ c.setAttribute("aria-selected", String(j === i));
+ c.tabIndex = j === i ? 0 : -1;
+ });
+ if (focus) cards[i].focus({ preventScroll: true });
+ turn();
+ // the detail follows 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);
+ }
+
+ // turn to card i, the short way round
+ const goTo = (i, opts) => go(target + offset(i, target), opts);
+
+ function turn() {
+ if (reducedMotion.matches || !geo) {
+ pos = target;
+ render();
+ return;
+ }
+ if (stop) return;
+ stop = onFrame((t, dt) => {
+ if (drag?.moved) return;
+ pos += (target - pos) * (1 - Math.exp(-dt * 13));
+ if (Math.abs(target - pos) < 0.002) {
+ pos = target;
+ stop();
+ stop = null;
+ }
+ render();
+ });
+ }
+
+ // 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.
+ 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;
+ e.preventDefault();
+ if (now < lock) return;
+ acc += e.deltaMode === 1 ? delta * 32 : e.deltaMode === 2 ? delta * 400 : delta;
+ if (Math.abs(acc) >= 60) {
+ go(target + Math.sign(acc));
+ acc = 0;
+ lock = now + 150;
+ }
+ }, { passive: false });
+
+ // dragging
+ let drag = null;
+ let dragged = false; // the click that ends a drag doesn't pick the card under it
+ wheel.addEventListener("pointerdown", (e) => {
+ if (!geo || e.button !== 0) return;
+ drag = { id: e.pointerId, x: e.clientX, y: e.clientY, from: pos, moved: false, v: 0, at: performance.now() };
+ });
+ wheel.addEventListener("pointermove", (e) => {
+ if (!drag || e.pointerId !== drag.id) return;
+ const along = geo.flat ? e.clientX - drag.x : e.clientY - drag.y;
+ if (!drag.moved) {
+ if (Math.abs(along) < 6) return;
+ drag.moved = true;
+ wheel.setPointerCapture(e.pointerId);
+ wheel.classList.add("is-dragging");
+ }
+ const p = drag.from - along / (geo.a * geo.R);
+ const now = performance.now();
+ drag.v = (p - pos) / Math.max(8, now - drag.at);
+ drag.at = now;
+ pos = p;
+ render();
+ });
+ const release = (e) => {
+ if (!drag || e.pointerId !== drag.id) return;
+ const d = drag;
+ drag = null;
+ if (!d.moved) return; // a click, which the card handles
+ wheel.classList.remove("is-dragging");
+ dragged = true;
+ setTimeout(() => { dragged = false; });
+ go(pos + clamp(d.v * 140, -2, 2));
+ };
+ wheel.addEventListener("pointerup", release);
+ wheel.addEventListener("pointercancel", release);
+
+ cards.forEach((card, i) => {
+ card.addEventListener("click", () => {
+ if (!dragged) goTo(i);
+ });
+ card.addEventListener("keydown", (e) => {
+ const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[e.key];
+ if (step) go(target + step, { focus: true });
+ else if (e.key === "Home") goTo(0, { focus: true });
+ else if (e.key === "End") goTo(n - 1, { focus: true });
+ else return;
+ e.preventDefault();
+ });
+ });
+
+ // ── the detail ───────────────────────────────────────────────────────
+
+ async function show(i) {
+ if (i === shown) return;
+ shown = i;
+ unmount?.();
+ unmount = null;
+ 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)
+ const text = document.createElement("div");
+ text.className = "pj-text";
+ text.append(...[".proj-cat", ".proj-name", ".proj-sum", ".proj-body"].map((s) => $(s, li).cloneNode(true)));
+ const row = document.createElement("div");
+ row.className = "pj-row";
+ row.append(text);
+ detail.replaceChildren(row);
+ if (meta) meta.textContent = `${i + 1}/${n}`;
+ if (!reducedMotion.matches) {
+ detail.animate([{ opacity: 0, transform: "translateY(8px)" }, { opacity: 1, transform: "none" }], { duration: 220, easing: "ease-out" });
+ }
+ const demo = li.dataset.demo;
+ if (!demo) return;
+ // The demo is a stand-in written for the page, so it opens in a buffer of
+ // its own, *demo*, with a note on how it differs from the project.
+ const box = document.createElement("div");
+ box.className = "pj-demo";
+ box.innerHTML = '<p class="pj-demo-head"><span>*demo*</span><span class="rule" aria-hidden="true"></span></p>';
+ if (li.dataset.demoNote) {
+ const note = document.createElement("p");
+ note.className = "pj-demo-note";
+ note.textContent = li.dataset.demoNote;
+ box.append(note);
+ }
+ const host = document.createElement("div");
+ host.className = "demo";
+ box.append(host);
+ row.append(box);
+ try {
+ const { mount } = await import(`../demos/${demo}.js`);
+ if (shown === i && host.isConnected) unmount = mount(host) ?? null;
+ } catch (err) {
+ console.error(err);
+ box.remove();
+ }
+ }
+
+ // Other parts of the page can open a project by name (the computer commands do).
+ on("project", (name) => {
+ const i = items.findIndex((li) => $(".proj-name", li).textContent.trim() === name);
+ if (i >= 0) goTo(i, { now: true });
+ });
+
+ new ResizeObserver(layout).observe(wheel);
+ across.addEventListener("change", layout);
+ layout();
+ go(0, { now: true });
+}
diff --git a/src/styles/demos/bytecode.css b/src/styles/demos/bytecode.css
@@ -82,7 +82,7 @@
white-space: pre;
}
-@media (max-width: 640px) {
+@container demo (max-width: 600px) {
.bc-cols {
grid-template-columns: minmax(0, 1fr);
}
diff --git a/src/styles/demos/computer.css b/src/styles/demos/computer.css
@@ -23,9 +23,7 @@
--led: 10px;
display: grid;
grid-template-columns: minmax(0, 1fr) 64px minmax(0, 1fr);
- grid-template-areas:
- "left bus right"
- "ctrl ctrl ctrl";
+ grid-template-areas: "left bus right";
gap: 10px;
font-size: 11.5px;
font-variant-numeric: tabular-nums;
@@ -40,6 +38,19 @@
.col-l { grid-area: left; }
.col-r { grid-area: right; }
+/* the clock with the microstep counter beside it, and the program counter with
+ the flags; short of room, their control lines go under their names */
+.mod-pair {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 10px;
+}
+
+.mod-pair .mod-head {
+ flex-wrap: wrap;
+ row-gap: 2px;
+}
+
.mod {
padding: 7px 10px 9px;
border: 1px solid var(--rule);
@@ -69,6 +80,11 @@
text-overflow: ellipsis;
}
+.sigs {
+ display: flex;
+ gap: 1ch;
+}
+
.sig {
padding: 0 0.4ch;
border-radius: 2px;
@@ -234,18 +250,7 @@
cursor: pointer;
}
-/* control word */
-
-.ctrl {
- grid-area: ctrl;
-}
-
-.ctrl-row {
- display: flex;
- flex-wrap: wrap;
- align-items: flex-end;
- gap: 8px 18px;
-}
+/* the microstep counter and the control word */
.cw {
display: flex;
@@ -267,12 +272,17 @@
color: var(--fg);
}
+/* what the step does, kept to two lines so the board doesn't jump as it changes */
.ustep {
- margin-top: 8px;
- color: var(--dim);
- white-space: nowrap;
+ display: -webkit-box;
+ min-height: 3em;
+ margin: 8px 0 0;
overflow: hidden;
- text-overflow: ellipsis;
+ color: var(--dim);
+ line-height: 1.5;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
}
.ustep b {
@@ -280,14 +290,21 @@
font-weight: 600;
}
-@media (max-width: 640px) {
+/* on a phone the pairs stack */
+@container demo (max-width: 440px) {
+ .mod-pair {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+/* narrow: beside a project's text, or on a phone */
+@container demo (max-width: 600px) {
.board {
--led: 8px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
"bus bus"
- "left right"
- "ctrl ctrl";
+ "left right";
font-size: 10.5px;
}
diff --git a/src/styles/demos/demo.css b/src/styles/demos/demo.css
@@ -1,11 +1,5 @@
/* Project demos: the frame each one mounts into, and its bar of controls */
-.demo-wide {
- margin-top: 40px;
- padding-top: 28px;
- border-top: 1px solid var(--rule);
-}
-
.sim-field {
display: inline-flex;
align-items: center;
@@ -20,10 +14,9 @@
font-variant-numeric: tabular-nums;
}
+/* a demo lays itself out by its own width (the @container rules in each demo's styles) */
.demo {
- margin-top: 22px;
- padding-top: 18px;
- border-top: 1px solid var(--rule);
+ container: demo / inline-size;
font-size: 12px;
}
@@ -57,7 +50,7 @@
color: var(--dim);
}
-@media (max-width: 640px) {
+@container demo (max-width: 600px) {
.sim-field {
margin-left: 0;
}
diff --git a/src/styles/demos/othello.css b/src/styles/demos/othello.css
@@ -1,13 +1,23 @@
/* othello-mcts: the board, with each move shaded by how much the search tried it */
+/* 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. */
.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));
- gap: 2px;
+ gap: 1px;
width: min(100%, 336px);
aspect-ratio: 1;
- padding: 2px;
- background: color-mix(in srgb, var(--fg) 25%, var(--bg));
+ padding: 0;
+ border: 1px solid var(--faint);
+ background: var(--felt-line);
}
.oth-cell {
@@ -15,7 +25,7 @@
padding: 0;
border: 0;
border-radius: 0;
- background: color-mix(in srgb, var(--green) 28%, var(--bg));
+ background: var(--felt);
cursor: default;
}
@@ -26,42 +36,53 @@
.oth-cell.legal::after {
content: "";
position: absolute;
- inset: 40%;
+ inset: 42%;
border-radius: 50%;
- background: var(--fg);
- opacity: 0.4;
+ background: var(--faint);
}
-.oth-cell.last {
- box-shadow: inset 0 0 0 2px var(--yellow);
+.oth-cell.legal:hover::after {
+ inset: 13%;
+ background: color-mix(in srgb, var(--disc-dark) 55%, transparent);
+ box-shadow: inset 0 0 0 1px var(--faint);
}
.oth-cell .disc {
position: absolute;
- inset: 12%;
+ inset: 13%;
border-radius: 50%;
}
.oth-cell .disc.b {
- background: #1b1b1b;
- box-shadow: 0 0 0 1px rgb(0 0 0 / 0.35);
+ background: var(--disc-dark);
+ box-shadow: inset 0 0 0 1px light-dark(transparent, var(--faint));
}
.oth-cell .disc.w {
- background: #f1efe8;
- box-shadow: 0 0 0 1px rgb(0 0 0 / 0.15);
+ background: var(--disc-light);
+ box-shadow: inset 0 0 0 1px light-dark(var(--faint), transparent);
+}
+
+.oth-cell.last .disc::after {
+ content: "";
+ position: absolute;
+ inset: 38%;
+ border-radius: 50%;
+ background: var(--red);
}
.oth-cell .heat {
position: absolute;
- inset: 0;
+ inset: 13%;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
- background: color-mix(in srgb, var(--blue) var(--hp, 0%), transparent);
- color: var(--fg);
+ border-radius: 50%;
+ background: color-mix(in oklch, var(--disc-light) var(--hp, 0%), transparent);
+ color: var(--disc-dark);
font-size: 10px;
+ font-weight: 600;
}
.oth-cell .heat:empty {
diff --git a/src/styles/frame/buffer.css b/src/styles/frame/buffer.css
@@ -11,6 +11,15 @@
scroll-snap-align: start;
}
+/* On the home page each section is at least a screen tall from its winbar down,
+ so with its winbar at the top (where scrolling snaps it) the next one is out of
+ sight. Every section has the same padding above its winbar, so that padding
+ cancels out. lvh: tall enough when a phone's toolbars are hidden too. */
+.hero ~ .sec {
+ min-height: calc(100vh - var(--top-h) - 24px);
+ min-height: calc(100lvh - var(--top-h) - 24px);
+}
+
.winbar {
display: flex;
align-items: center;
diff --git a/src/styles/home/projects.css b/src/styles/home/projects.css
@@ -1,173 +1,313 @@
-/* Projects, as a linker memory map: a bar of regions, the list, and a hexdump of the one you pick */
+/* Projects: a wheel of cards (out in the left margin on wide screens, across the top
+ on narrow ones) and the write-up and demo of the one picked on it
+ (src/scripts/home/projects.js) */
-.mm {
- --mm-cols: 8ch 8ch minmax(0, 1fr) 8ch;
+.pj {
+ --wheel: 200px;
+ --pj-gap: 44px;
+ display: grid;
+ grid-template-columns: var(--wheel) minmax(0, 1fr);
+ column-gap: var(--pj-gap);
+ align-items: start;
}
-.mm-bar {
- display: flex;
- height: 36px;
- margin-bottom: 32px;
+/* with room beside the content column, the wheel stands out in the margin */
+@media (min-width: 1400px) {
+ .pj {
+ --wheel: min(236px, calc((100vw - var(--col)) / 2 + var(--gutter) - var(--pj-gap) - 20px));
+ margin-left: calc(-1 * (var(--wheel) + var(--pj-gap)));
+ }
+}
+
+/* ── the wheel ─────────────────────────────────────────────────────── */
+
+.pj-wheel {
+ position: sticky;
+ top: calc(var(--top-h) + 24px);
+ height: min(620px, calc(100svh - var(--top-h) - var(--frame-h) - 72px));
+ touch-action: none;
+ -webkit-user-select: none;
+ user-select: none;
+}
+
+/* The cards and the spine fade out towards the top and bottom of the wheel. Their
+ layer reaches out into the margin, where the arc goes, so the fade doesn't cut it off. */
+.pj-spine,
+.pj-cards {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: -320px;
+ width: calc(100% + 320px);
+ -webkit-mask-image: linear-gradient(to bottom, transparent, #000 26%, #000 70%, transparent calc(100% - 28px));
+ mask-image: linear-gradient(to bottom, transparent, #000 26%, #000 70%, transparent calc(100% - 28px));
+}
+
+.pj-spine {
+ height: 100%;
+ pointer-events: none;
+}
+
+.pj-spine path {
+ fill: none;
+ stroke: var(--rule);
+ stroke-width: 1.25;
+}
+
+.pj-card {
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: block;
+ width: min(176px, calc(var(--wheel) - 34px));
+ padding: 0;
border: 1px solid var(--rule);
+ background: var(--bg);
+ color: var(--dim);
+ text-align: left;
+ transform-origin: 50% 50%;
+ will-change: transform;
+ transition: border-color 150ms ease, color 150ms ease;
+}
+
+.pj-card:hover {
+ border-color: var(--faint);
+}
+
+.pj-card[aria-selected="true"] {
+ border-color: var(--fg);
+ color: var(--fg);
+}
+
+.is-dragging .pj-card {
+ cursor: grabbing;
+}
+
+/* each card hangs off the spine by a stub with a via at its end, like a trace */
+.pj-card::before,
+.pj-card::after {
+ content: "";
+ position: absolute;
+ pointer-events: none;
+}
+
+.pj-card::before {
+ top: 50%;
+ right: 100%;
+ width: 14px;
+ height: 1px;
+ background: var(--rule);
+}
+
+.pj-card::after {
+ top: calc(50% - 3px);
+ right: calc(100% + 11px);
+ width: 7px;
+ height: 7px;
+ border: 1px solid var(--faint);
+ border-radius: 50%;
+ background: var(--bg);
+}
+
+.pj-thumb {
+ display: block;
+ aspect-ratio: 160 / 96;
+ padding: 7px;
+ border-bottom: 1px solid var(--rule);
+}
+
+.pj-card[aria-selected="true"] .pj-thumb {
+ border-bottom-color: var(--faint);
+}
+
+.pj-thumb svg {
+ display: block;
+ width: 100%;
+ height: 100%;
}
-.mm-seg {
- min-width: 3px;
- padding: 0 6px;
+.pj-label {
+ display: block;
+ padding: 5px 8px 6px;
overflow: hidden;
- border-right: 1px solid var(--bg);
- background: color-mix(in srgb, var(--c) 20%, transparent);
- box-shadow: inset 0 2px 0 var(--c);
font-size: 11px;
- line-height: 34px;
+ line-height: 1.35;
+ text-overflow: ellipsis;
white-space: nowrap;
- cursor: pointer;
- transition: background-color 120ms ease;
}
-.mm-seg:last-child {
- border-right: 0;
+.pj-label::before {
+ content: "";
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ margin-right: 1ch;
+ background: var(--c, var(--fg));
+ vertical-align: 0.08em;
}
-.mm-seg:hover {
- background: color-mix(in srgb, var(--c) 34%, transparent);
+/* the picked card always stops at this pointer */
+.pj-pointer {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 0;
+ height: 0;
+ border-top: 5px solid transparent;
+ border-bottom: 5px solid transparent;
+ border-left: 7px solid var(--fg);
+ pointer-events: none;
}
-.mm-seg.is-active {
- background: var(--c);
- color: var(--bg);
+.pj-hint {
+ position: absolute;
+ right: 16px;
+ bottom: 4px;
+ margin: 0;
+ font-size: 11px;
+ color: var(--faint);
}
-.mm-cols {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
- gap: 48px;
- align-items: start;
+.pj-hint kbd {
+ font-size: 10px;
}
-.mm-head {
- display: grid;
- grid-template-columns: var(--mm-cols);
- gap: 1.5ch;
- margin: 0;
- padding: 0 0 8px 12px;
- border-bottom: 1px solid var(--rule);
- font-size: 12px;
- color: var(--dim);
+/* ── the drawings on the cards ────────────────────────────────────── */
+
+.th {
+ font: 400 9.5px var(--mono);
}
-.mm-list {
- margin: 0;
- padding: 0;
- list-style: none;
+.th-box,
+.th-line,
+.th-q {
+ fill: none;
+ stroke: var(--faint);
+ stroke-width: 1;
}
-.proj-name {
- margin: 0;
- font: inherit;
+.th-line {
+ stroke: var(--rule);
}
-.proj-row {
- display: grid;
- grid-template-columns: var(--mm-cols);
- gap: 1.5ch;
- align-items: baseline;
- width: 100%;
- padding: 11px 0 11px 12px;
- border: 0;
- border-bottom: 1px solid var(--rule);
- background: none;
- text-align: left;
- font-size: 13px;
- font-variant-numeric: tabular-nums;
+.th-dash {
+ stroke-dasharray: 3 3;
}
-.proj-row:hover {
- background: var(--wash);
+.th-on {
+ fill: var(--c, var(--fg));
}
-.proj-row .o,
-.proj-row .l,
-.proj-row .y {
- font-size: 12px;
- color: var(--dim);
+.th-red {
+ fill: var(--red);
}
-.proj-row .n {
- font-weight: 600;
+.th-off {
+ fill: var(--led-off);
}
-.proj-row .n::before {
- content: "";
- display: inline-block;
- width: 7px;
- height: 7px;
- margin-right: 1.2ch;
- background: var(--c);
- vertical-align: 0.06em;
+.th-dim {
+ fill: var(--faint);
}
-.proj-row .s {
- display: block;
- margin-top: 2px;
- font-weight: 400;
- color: var(--dim);
+.th-disc {
+ fill: var(--fg);
}
-.proj-row[aria-pressed="true"] {
- background: var(--wash);
- box-shadow: inset 2px 0 0 var(--c);
+.th-disc-o {
+ fill: var(--bg);
+ stroke: var(--fg);
+ stroke-width: 1;
}
-.proj-row[aria-pressed="true"] .o,
-.proj-row[aria-pressed="true"] .l {
- color: var(--c);
+.th-seg,
+.th-seg-off {
+ fill: none;
+ stroke-width: 2.2;
+ stroke-linecap: round;
}
-.mm.is-built .proj-sum,
-.mm.is-built .proj > .proj-body {
- display: none;
+.th-seg {
+ stroke: var(--red);
}
-.mm-detail {
- min-height: 320px;
- padding-left: 32px;
- border-left: 1px solid var(--rule);
+.th-seg-off {
+ stroke: var(--led-off);
}
-.mm-detail:empty {
- display: none;
+.th-src {
+ fill: var(--fg);
}
-.hex {
- margin: 0 0 22px;
- overflow-x: auto;
- font-size: 11.5px;
- line-height: 1.55;
+.th-op {
+ fill: var(--dim);
+}
+
+.th-op.is-at {
+ fill: var(--c, var(--fg));
+}
+
+.th g g text.th-op {
+ text-anchor: middle;
+}
+
+/* ── a project ─────────────────────────────────────────────────────── */
+
+.pj-detail {
+ container: pj-detail / inline-size;
+ min-height: 420px;
+}
+
+.proj-cat {
+ margin: 0 0 12px;
+ font-size: 12px;
color: var(--dim);
- white-space: pre;
}
-.hex .a {
- color: var(--fg);
+.proj-cat::before {
+ content: "";
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ margin-right: 1ch;
+ background: var(--c, var(--fg));
+ vertical-align: 0.06em;
+}
+
+.proj-name {
+ margin: 0 0 10px;
+ font: 400 clamp(30px, 3.6vw, 44px)/1.06 var(--serif);
}
-.hex .addr {
- color: var(--c);
+.proj-sum {
+ max-width: 64ch;
+ margin: 0 0 28px;
+ font-size: 13px;
+ color: var(--dim);
}
-.mm-detail h3 {
+.proj-body > p,
+.proj-body li {
+ max-width: 36em;
+ font: 400 17px/1.6 var(--serif);
+}
+
+.proj-body > p {
margin: 0 0 14px;
- font: 400 32px/1.1 var(--serif);
}
-.mm-detail .proj-body p {
+.proj-body ul {
+ max-width: 36em;
margin: 0 0 14px;
- font: 400 17px/1.55 var(--serif);
+ padding-left: 1.2em;
}
-.mm-detail .proj-body .proj-stack,
-.mm-detail .proj-body .proj-links {
+.proj-body li {
+ margin-bottom: 6px;
+}
+
+.proj-body > .proj-stack,
+.proj-body > .proj-links {
font: 400 12px/1.6 var(--mono);
}
@@ -179,27 +319,136 @@
margin-right: 2ch;
}
-@media (max-width: 920px) {
- .mm-cols {
- grid-template-columns: minmax(0, 1fr);
+/* the demo, under a header line like a winbar's: *demo*, a rule, then a note on what it is */
+.pj-demo {
+ margin-top: 36px;
+ font-size: 12px;
+}
+
+.pj-demo-head {
+ display: flex;
+ align-items: center;
+ gap: 1.5ch;
+ margin: 0 0 6px;
+}
+
+.pj-demo-head .rule {
+ flex: 1;
+ height: 1px;
+ background: var(--rule);
+}
+
+.pj-demo-note {
+ max-width: 64ch;
+ margin: 0 0 18px;
+ line-height: 1.5;
+ color: var(--dim);
+}
+
+/* with room, the demo sits beside the write-up */
+@container pj-detail (min-width: 820px) {
+ .pj-row {
+ display: flex;
+ gap: 48px;
+ align-items: flex-start;
}
- .mm-detail {
- min-height: 0;
- margin: 4px 0 8px;
- padding: 20px 0 20px 12px;
- border-left: 0;
- border-bottom: 1px solid var(--rule);
+ .pj-row > .pj-text {
+ flex: 0 0 calc(46% - 24px);
}
+
+ .pj-row .proj-body > p:not(.proj-stack, .proj-links),
+ .pj-row .proj-body li {
+ font-size: 16px;
+ }
+
+ .pj-row > .pj-demo {
+ flex: 1;
+ min-width: 0;
+ margin-top: 0;
+ }
+}
+
+/* without JavaScript there is no wheel, only the list */
+.pj-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.pj-list .proj {
+ padding: 32px 0;
+ border-top: 1px solid var(--rule);
+}
+
+.pj:not(.is-built) {
+ display: block;
+ margin-left: 0;
}
-@media (max-width: 640px) {
- .mm {
- --mm-cols: 7ch 6ch minmax(0, 1fr);
+.pj:not(.is-built) .pj-wheel,
+.pj:not(.is-built) .pj-detail,
+.pj.is-built .pj-list {
+ display: none;
+}
+
+/* ── narrow screens: the wheel lies across the top ────────────────── */
+
+@media (max-width: 999px) {
+ .pj {
+ display: block;
+ }
+
+ .pj-wheel {
+ position: relative;
+ top: auto;
+ height: 200px;
+ margin: 0 calc(-1 * var(--gutter)) 36px;
+ overflow: hidden;
+ touch-action: pan-y;
+ }
+
+ .pj-card {
+ width: 150px;
+ }
+
+ .pj-card::before {
+ top: 100%;
+ right: auto;
+ left: 50%;
+ width: 1px;
+ height: 14px;
}
- .mm-head span:last-child,
- .proj-row .y {
- display: none;
+ .pj-card::after {
+ top: calc(100% + 11px);
+ right: auto;
+ left: calc(50% - 3px);
+ }
+
+ .pj-pointer {
+ border-top: 7px solid var(--fg);
+ border-right: 5px solid transparent;
+ border-bottom: 0;
+ border-left: 5px solid transparent;
+ }
+
+ /* lying flat, it fades at the sides */
+ .pj-spine,
+ .pj-cards {
+ left: 0;
+ width: 100%;
+ -webkit-mask-image: linear-gradient(to right, transparent, #000 22%, #000 78%, transparent);
+ mask-image: linear-gradient(to right, transparent, #000 22%, #000 78%, transparent);
+ }
+
+ .pj-hint {
+ right: auto;
+ bottom: 0;
+ left: var(--gutter);
+ }
+
+ .pj-detail {
+ min-height: 0;
}
}