commit ff8e7f5c4e7ade715d7d92a1d1c9ba73b7a4a3c4
Author: Michael Neuper <michael@michaelneuper.com>
Date: Sun, 11 Oct 2026 21:45:01 +0200
initial commit
Diffstat:
22 files changed, 4981 insertions(+), 0 deletions(-)
diff --git a/css/style.css b/css/style.css
@@ -0,0 +1,2256 @@
+/* ───────────────────────────── fonts ───────────────────────────── */
+
+@font-face {
+ font-family: "JetBrains Mono";
+ src: url("../fonts/jetbrains-mono-regular.woff2") format("woff2");
+ font-weight: 400;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "JetBrains Mono";
+ src: url("../fonts/jetbrains-mono-semibold.woff2") format("woff2");
+ font-weight: 600;
+ font-display: swap;
+}
+
+/* Computer Modern, via KaTeX's build of it: the same family as Latin Modern */
+@font-face {
+ font-family: "Computer Modern";
+ src: url("../fonts/cm-regular.woff2") format("woff2");
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Computer Modern";
+ src: url("../fonts/cm-italic.woff2") format("woff2");
+ font-weight: 400;
+ font-style: italic;
+ font-display: swap;
+}
+
+/* ───────────────────────────── themes ─────────────────────────────
+ Real Emacs themes. Each sets a background, a modeline background,
+ a foreground and seven accents; everything else is mixed from those.
+ Keep the names in sync with js/themes.js and the script in index.html. */
+
+:root {
+ color-scheme: dark;
+ --mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ --serif: "Computer Modern", "Latin Modern Roman", "CMU Serif", Georgia, serif;
+
+ --gutter: clamp(16px, 4.5vw, 56px);
+ --top-h: 44px;
+ --frame-h: 46px;
+ --col: 1088px;
+
+ --bg: #282828;
+ --bg-alt: #3c3836;
+ --fg: #ebdbb2;
+ --red: #fb4934;
+ --orange: #fe8019;
+ --yellow: #fabd2f;
+ --green: #b8bb26;
+ --cyan: #8ec07c;
+ --blue: #83a598;
+ --magenta: #d3869b;
+ --glow: 1;
+
+ --dim: color-mix(in srgb, var(--fg) 64%, var(--bg));
+ --faint: color-mix(in srgb, var(--fg) 34%, 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);
+ --led-off: color-mix(in srgb, var(--fg) 13%, var(--bg));
+
+ --c-hw: var(--yellow);
+ --c-sw: var(--blue);
+ --c-math: var(--magenta);
+ --c-sec: var(--red);
+}
+
+:root[data-theme="doom-gruvbox-light"] {
+ color-scheme: light;
+ --bg: #fbf1c7; --bg-alt: #ebdbb2; --fg: #3c3836;
+ --red: #9d0006; --orange: #af3a03; --yellow: #b57614; --green: #79740e;
+ --cyan: #427b58; --blue: #076678; --magenta: #8f3f71;
+ --glow: 0;
+}
+
+:root[data-theme="doom-one"] {
+ --bg: #282c34; --bg-alt: #21242b; --fg: #bbc2cf;
+ --red: #ff6c6b; --orange: #da8548; --yellow: #ecbe7b; --green: #98be65;
+ --cyan: #46d9ff; --blue: #51afef; --magenta: #c678dd;
+}
+
+: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;
+}
+
+: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;
+}
+
+:root[data-theme="modus-operandi"] {
+ color-scheme: light;
+ --bg: #ffffff; --bg-alt: #f2f2f2; --fg: #000000;
+ --red: #a60000; --orange: #884900; --yellow: #6f5500; --green: #006800;
+ --cyan: #005e8b; --blue: #0031a9; --magenta: #721045;
+ --glow: 0;
+}
+
+:root[data-theme="doom-dracula"] {
+ --bg: #282a36; --bg-alt: #1e2029; --fg: #f8f8f2;
+ --red: #ff5555; --orange: #ffb86c; --yellow: #f1fa8c; --green: #50fa7b;
+ --cyan: #8be9fd; --blue: #61bfff; --magenta: #ff79c6;
+}
+
+: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;
+}
+
+: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;
+}
+
+: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;
+}
+
+:root[data-theme="doom-solarized-light"] {
+ color-scheme: light;
+ --bg: #fdf6e3; --bg-alt: #eee8d5; --fg: #556b72;
+ --red: #dc322f; --orange: #cb4b16; --yellow: #b58900; --green: #859900;
+ --cyan: #2aa198; --blue: #268bd2; --magenta: #d33682;
+ --glow: 0;
+}
+
+[data-cat="hw"] { --c: var(--c-hw); }
+[data-cat="sw"] { --c: var(--c-sw); }
+[data-cat="math"] { --c: var(--c-math); }
+[data-cat="sec"] { --c: var(--c-sec); }
+
+/* ───────────────────────────── base ───────────────────────────── */
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ background: var(--bg);
+ color: var(--fg);
+ scroll-behavior: smooth;
+ scroll-snap-type: y proximity;
+ scroll-padding-top: calc(var(--top-h) + 24px);
+ -webkit-text-size-adjust: 100%;
+ text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+ font: 400 13px/1.6 var(--mono);
+ font-variant-ligatures: none;
+ -webkit-font-smoothing: antialiased;
+ transition: background-color 200ms ease, color 200ms ease;
+}
+
+::selection {
+ background: color-mix(in srgb, var(--blue) 35%, var(--bg));
+ color: var(--fg);
+}
+
+a {
+ color: inherit;
+ text-decoration-line: underline;
+ text-decoration-thickness: 1px;
+ text-decoration-color: var(--faint);
+ text-underline-offset: 0.2em;
+}
+
+a:hover {
+ text-decoration-color: currentColor;
+}
+
+button,
+input,
+output,
+select {
+ font: inherit;
+ color: inherit;
+}
+
+button {
+ cursor: pointer;
+}
+
+:focus-visible {
+ outline: 1px solid var(--fg);
+ outline-offset: 2px;
+}
+
+kbd {
+ font: inherit;
+ font-size: 0.95em;
+ padding: 0 0.45ch;
+ border: 1px solid var(--faint);
+ border-radius: 3px;
+}
+
+code {
+ font-family: var(--mono);
+ font-size: 0.95em;
+}
+
+.dim {
+ color: var(--dim);
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+.skip {
+ position: absolute;
+ left: 8px;
+ top: -100px;
+ z-index: 50;
+ padding: 6px 12px;
+ background: var(--fg);
+ color: var(--bg);
+}
+
+.skip:focus {
+ top: 8px;
+}
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ min-height: 30px;
+ padding: 0 14px;
+ border: 1px solid var(--faint);
+ border-radius: 0;
+ background: none;
+ font-size: 12px;
+ line-height: 1;
+ white-space: nowrap;
+}
+
+.btn:hover {
+ border-color: var(--fg);
+}
+
+.btn[aria-pressed="true"] {
+ border-color: var(--fg);
+ background: var(--fg);
+ color: var(--bg);
+}
+
+/* ───────────────────────────── top line ───────────────────────────── */
+
+.top {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 20;
+ display: flex;
+ align-items: center;
+ gap: 3ch;
+ height: var(--top-h);
+ padding: 0 var(--gutter);
+ font-size: 12px;
+ border-bottom: 1px solid transparent;
+ transition: background-color 200ms ease, border-color 200ms ease;
+}
+
+.is-scrolled .top {
+ background: color-mix(in srgb, var(--bg) 90%, transparent);
+ border-bottom-color: var(--rule);
+ -webkit-backdrop-filter: blur(10px);
+ backdrop-filter: blur(10px);
+}
+
+.brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 1.5ch;
+ margin-right: auto;
+ text-decoration: none;
+}
+
+.logo {
+ width: 22px;
+ height: 11px;
+ overflow: visible;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.6;
+}
+
+.nav {
+ display: flex;
+ gap: 2.5ch;
+}
+
+.nav a {
+ color: var(--dim);
+ text-decoration: none;
+}
+
+.nav a:hover,
+.nav a[aria-current="true"] {
+ color: var(--fg);
+}
+
+.nav a[aria-current="true"] {
+ text-decoration: underline;
+ text-decoration-color: var(--fg);
+ text-underline-offset: 6px;
+}
+
+.theme-key,
+.menu-key {
+ padding: 8px 0;
+ border: 0;
+ background: none;
+ color: var(--dim);
+ font-size: 12px;
+}
+
+.theme-key:hover,
+.menu-key:hover {
+ color: var(--fg);
+}
+
+.menu-key {
+ display: none;
+}
+
+/* ───────────────────────────── hero: the computer ───────────────────────────── */
+
+.hero {
+ display: grid;
+ grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
+ gap: 32px clamp(32px, 4vw, 64px);
+ align-items: center;
+ min-height: 100vh;
+ min-height: 100svh;
+ padding: calc(var(--top-h) + 28px) var(--gutter) calc(var(--frame-h) + 32px);
+ max-width: 1440px;
+ margin: 0 auto;
+}
+
+.hero-name {
+ margin: 0 0 20px -0.04em;
+ font: 400 clamp(46px, 6.4vw, 96px)/0.94 var(--serif);
+ letter-spacing: -0.015em;
+}
+
+.hero-lede {
+ max-width: 44ch;
+ margin: 0 0 20px;
+ font-size: 14px;
+ line-height: 1.6;
+}
+
+.legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 2.5ch;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ font-size: 12px;
+ color: var(--dim);
+}
+
+.legend li::before,
+.features li::before {
+ content: "";
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ margin-right: 1ch;
+ background: var(--c, var(--fg));
+ vertical-align: 0.06em;
+}
+
+.sim-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px 10px;
+ margin-top: 16px;
+ font-size: 12px;
+}
+
+.sim-field {
+ display: inline-flex;
+ align-items: center;
+ gap: 1.2ch;
+ margin-left: 1.5ch;
+ color: var(--dim);
+}
+
+.sim-field output {
+ min-width: 7ch;
+ color: var(--fg);
+ font-variant-numeric: tabular-nums;
+}
+
+select {
+ min-height: 30px;
+ padding: 0 28px 0 10px;
+ border: 1px solid var(--faint);
+ 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);
+ font-size: 12px;
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+input[type="range"] {
+ width: 120px;
+ height: 22px;
+ margin: 0;
+ background: transparent;
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+input[type="range"]::-webkit-slider-runnable-track {
+ height: 1px;
+ background: var(--faint);
+}
+
+input[type="range"]::-moz-range-track {
+ height: 1px;
+ background: var(--faint);
+}
+
+input[type="range"]::-webkit-slider-thumb {
+ width: 9px;
+ height: 18px;
+ margin-top: -9px;
+ border: 0;
+ border-radius: 0;
+ background: var(--fg);
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+input[type="range"]::-moz-range-thumb {
+ width: 9px;
+ height: 18px;
+ border: 0;
+ border-radius: 0;
+ background: var(--fg);
+}
+
+.sim-note {
+ max-width: 78ch;
+ margin: 12px 0 0;
+ font-size: 12px;
+ line-height: 1.6;
+ color: var(--dim);
+}
+
+/* board: Ben Eater's layout, two columns of modules either side of the bus */
+
+.board {
+ --led: 10px;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 64px minmax(0, 1fr);
+ grid-template-areas:
+ "left bus right"
+ "ctrl ctrl ctrl";
+ gap: 10px;
+ font-size: 11.5px;
+ font-variant-numeric: tabular-nums;
+}
+
+.col {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.col-l { grid-area: left; }
+.col-r { grid-area: right; }
+
+.mod {
+ padding: 7px 10px 9px;
+ border: 1px solid var(--rule);
+ background: color-mix(in srgb, var(--bg) 92%, var(--fg));
+}
+
+.mod.is-out {
+ border-color: var(--red);
+}
+
+.mod.is-in {
+ border-color: var(--green);
+}
+
+.mod-head {
+ display: flex;
+ align-items: baseline;
+ gap: 1ch;
+ margin-bottom: 6px;
+ color: var(--dim);
+ white-space: nowrap;
+}
+
+.mod-name {
+ margin-right: auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.sig {
+ padding: 0 0.4ch;
+ border-radius: 2px;
+ color: var(--faint);
+ font-size: 10.5px;
+}
+
+.sig.on {
+ background: var(--blue);
+ color: var(--bg);
+}
+
+.mod-body {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1ch;
+}
+
+.mod-val {
+ color: var(--fg);
+ white-space: nowrap;
+}
+
+.leds {
+ display: flex;
+ gap: calc(var(--led) * 0.45);
+}
+
+.led {
+ display: block;
+ flex: none;
+ width: var(--led);
+ height: var(--led);
+ padding: 0;
+ border: 0;
+ border-radius: 50%;
+ background: var(--led-off);
+ --lc: var(--red);
+}
+
+.led.on {
+ background: var(--lc);
+ box-shadow: 0 0 calc(var(--glow) * 7px) calc(var(--glow) * 1px) color-mix(in srgb, var(--lc) 70%, transparent);
+}
+
+.led.g { --lc: var(--green); }
+.led.y { --lc: var(--yellow); }
+.led.b { --lc: var(--blue); }
+
+.leds .gap {
+ width: calc(var(--led) * 0.4);
+}
+
+/* RAM: the whole 16-byte memory as a matrix you can edit */
+
+.ram-rows {
+ display: grid;
+ gap: 1px;
+}
+
+.ram-row {
+ --led: 8px;
+ display: grid;
+ grid-template-columns: 1.5ch 2ch auto minmax(0, 1fr);
+ align-items: center;
+ gap: 0.8ch;
+ padding: 1px 4px;
+ margin: 0 -4px;
+ line-height: 14px;
+}
+
+.ram-row.is-mar {
+ background: var(--hl);
+}
+
+.ram-row .pc {
+ color: var(--green);
+}
+
+.ram-row .addr {
+ color: var(--dim);
+}
+
+.ram-row .dis {
+ overflow: hidden;
+ color: var(--dim);
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.ram-row.is-mar .dis {
+ color: var(--fg);
+}
+
+.ram-row .led {
+ cursor: pointer;
+}
+
+.ram-row .led:hover {
+ outline: 1px solid var(--faint);
+ outline-offset: 1px;
+}
+
+/* the bus: eight lanes down the middle */
+
+.bus {
+ grid-area: bus;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 7px 0 9px;
+ border: 1px solid var(--rule);
+}
+
+.bus .mod-head {
+ margin-bottom: 4px;
+}
+
+.bus-val {
+ margin-bottom: 8px;
+}
+
+.lanes {
+ display: flex;
+ flex: 1;
+ gap: 4px;
+}
+
+.lane {
+ width: 2px;
+ background: var(--led-off);
+}
+
+.lane.on {
+ background: var(--red);
+ box-shadow: 0 0 calc(var(--glow) * 6px) color-mix(in srgb, var(--red) 70%, transparent);
+}
+
+/* output: a 7-segment display */
+
+.seg7 {
+ display: flex;
+ gap: 6px;
+}
+
+.seg7 svg {
+ width: 22px;
+ height: 38px;
+ overflow: visible;
+}
+
+.seg7 path {
+ fill: var(--led-off);
+}
+
+.seg7 path.on {
+ fill: var(--red);
+ filter: drop-shadow(0 0 calc(var(--glow) * 3px) color-mix(in srgb, var(--red) 80%, transparent));
+}
+
+.mod-out .mod-body {
+ cursor: pointer;
+}
+
+/* control word */
+
+.ctrl {
+ grid-area: ctrl;
+}
+
+.ctrl-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 8px 18px;
+}
+
+.cw {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.cw-bit {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 3px;
+ width: 3.4ch;
+ color: var(--faint);
+ font-size: 10px;
+}
+
+.cw-bit.on {
+ color: var(--fg);
+}
+
+.ustep {
+ margin-top: 8px;
+ color: var(--dim);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.ustep b {
+ color: var(--fg);
+ font-weight: 600;
+}
+
+/* ───────────────────────────── sections ───────────────────────────── */
+
+.sec {
+ max-width: var(--col);
+ margin: 0 auto;
+ padding: 136px var(--gutter) 0;
+}
+
+.hero,
+.sec > .winbar {
+ scroll-snap-align: start;
+}
+
+.winbar {
+ display: flex;
+ align-items: center;
+ gap: 1.5ch;
+ margin: 0 0 56px;
+ font-size: 12px;
+ line-height: 1;
+ color: var(--dim);
+}
+
+.winbar .file {
+ color: var(--fg);
+}
+
+.winbar .rule {
+ flex: 1;
+ height: 1px;
+ background: var(--rule);
+}
+
+.h2 {
+ max-width: 17em;
+ margin: 0 0 20px;
+ font: 400 clamp(36px, 5.4vw, 62px)/1.04 var(--serif);
+ letter-spacing: -0.01em;
+}
+
+.lede,
+.prose {
+ max-width: 34em;
+ margin: 0 0 48px;
+ font: 400 19px/1.55 var(--serif);
+}
+
+.prose {
+ margin-bottom: 0;
+}
+
+.lede kbd {
+ font: 400 13px/1 var(--mono);
+ vertical-align: 0.15em;
+}
+
+.sub-h {
+ margin: 72px 0 8px;
+ font: 400 28px/1.15 var(--serif);
+}
+
+.sub-p {
+ max-width: 34em;
+ margin: 0 0 28px;
+ font: 400 17px/1.55 var(--serif);
+}
+
+/* ───────────────────────────── datasheet ───────────────────────────── */
+
+.ds-head {
+ display: grid;
+ grid-template-columns: 14ch minmax(0, 1fr);
+ gap: 8px 2ch;
+ align-items: baseline;
+ padding-top: 16px;
+ border-top: 2px solid var(--fg);
+}
+
+.ds-part {
+ margin: 0;
+ font-weight: 600;
+ font-size: 15px;
+}
+
+.ds-title {
+ max-width: 19em;
+ margin: 0;
+ font: 400 clamp(28px, 3.8vw, 44px)/1.1 var(--serif);
+}
+
+.ds-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 400px);
+ gap: 72px;
+ align-items: start;
+}
+
+.ds-h {
+ margin: 48px 0 16px;
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 1.4;
+}
+
+.features {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.features li {
+ max-width: 62ch;
+ margin-bottom: 8px;
+ padding-left: 3ch;
+ text-indent: -3ch;
+}
+
+.features li::before {
+ margin-right: 2ch;
+ text-indent: 0;
+}
+
+.pinout {
+ margin: 0;
+}
+
+.chip {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 104px minmax(0, 1fr);
+ font-size: 12px;
+}
+
+.chip-side {
+ display: flex;
+ flex-direction: column;
+ padding: 10px 0;
+}
+
+.chip-body {
+ position: relative;
+ border: 1px solid var(--fg);
+ border-radius: 2px;
+}
+
+.chip-body::before {
+ content: "";
+ position: absolute;
+ top: -1px;
+ left: 50%;
+ width: 20px;
+ height: 10px;
+ border: 1px solid var(--fg);
+ border-top: 0;
+ border-radius: 0 0 10px 10px;
+ background: var(--bg);
+ transform: translateX(-50%);
+}
+
+.chip-body span {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--dim);
+ writing-mode: vertical-rl;
+ letter-spacing: 0.2em;
+}
+
+.pin {
+ display: grid;
+ align-items: center;
+ height: 28px;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--dim);
+ text-align: left;
+}
+
+.chip-side.left .pin {
+ grid-template-columns: 1fr 3ch 16px;
+ text-align: right;
+}
+
+.chip-side.right .pin {
+ grid-template-columns: 16px 3ch 1fr;
+}
+
+.pin .num {
+ text-align: center;
+ font-size: 11px;
+}
+
+.pin .leg {
+ height: 8px;
+ border: 1px solid var(--faint);
+}
+
+.chip-side.left .leg {
+ border-right: 0;
+}
+
+.chip-side.right .leg {
+ border-left: 0;
+}
+
+.pin .name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.pin:hover,
+.pin.is-active {
+ color: var(--fg);
+}
+
+.pin.is-active .name {
+ color: var(--c, var(--fg));
+ font-weight: 600;
+}
+
+.pin.is-active .leg {
+ border-color: var(--c, var(--fg));
+ background: var(--c, var(--fg));
+}
+
+.pin-readout {
+ min-height: 7.5em;
+ margin-top: 20px;
+ padding-top: 12px;
+ border-top: 1px solid var(--rule);
+ font-size: 12px;
+}
+
+.pin-readout p {
+ margin: 0;
+}
+
+.pin-readout .pr-head {
+ display: flex;
+ gap: 2ch;
+ margin-bottom: 6px;
+}
+
+.pin-readout .pr-name {
+ color: var(--c, var(--fg));
+ font-weight: 600;
+}
+
+.pinout figcaption {
+ margin-top: 8px;
+ font-size: 12px;
+ color: var(--dim);
+}
+
+.pin-list {
+ margin: 16px 0 0;
+ padding-left: 3ch;
+ font-size: 12px;
+}
+
+.pinout.is-built .pin-list {
+ display: none;
+}
+
+.pin-list li::before {
+ content: attr(data-name) " (" attr(data-type) ") ";
+ font-weight: 600;
+}
+
+.ds-note {
+ margin: -8px 0 14px;
+ font-size: 12px;
+ color: var(--dim);
+}
+
+.table-scroll {
+ overflow-x: auto;
+}
+
+.ds-table {
+ width: 100%;
+ min-width: 560px;
+ border-collapse: collapse;
+ font-size: 12.5px;
+ font-variant-numeric: tabular-nums;
+}
+
+.ds-table th,
+.ds-table td {
+ padding: 8px 1.5ch 8px 0;
+ border-bottom: 1px solid var(--rule);
+ text-align: left;
+ vertical-align: baseline;
+}
+
+.ds-table thead th {
+ border-top: 1px solid var(--fg);
+ border-bottom-color: var(--fg);
+ font-weight: 600;
+}
+
+.ds-table tbody th {
+ font-weight: 400;
+}
+
+.ds-table td:nth-child(2) {
+ color: var(--dim);
+}
+
+.ds-table .num {
+ text-align: right;
+ padding-right: 3ch;
+}
+
+.ds-rev {
+ min-width: 0;
+}
+
+.ds-rev th:first-child {
+ width: 16ch;
+ white-space: nowrap;
+}
+
+.ds-rev td:nth-child(2) {
+ color: var(--fg);
+}
+
+.ds-rev td p {
+ margin: 0;
+}
+
+.ds-rev td ul {
+ margin: 6px 0 0;
+ padding-left: 2.5ch;
+}
+
+.ds-rev td li {
+ margin-bottom: 3px;
+ color: var(--dim);
+}
+
+.ds-rev td li::marker {
+ color: var(--faint);
+}
+
+/* ───────────────────────────── memory map ───────────────────────────── */
+
+.mm {
+ --mm-cols: 8ch 8ch minmax(0, 1fr) 8ch;
+}
+
+.mm-bar {
+ display: flex;
+ height: 36px;
+ margin-bottom: 32px;
+ border: 1px solid var(--rule);
+}
+
+.mm-seg {
+ min-width: 3px;
+ padding: 0 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;
+ white-space: nowrap;
+ cursor: pointer;
+ transition: background-color 120ms ease;
+}
+
+.mm-seg:last-child {
+ border-right: 0;
+}
+
+.mm-seg:hover {
+ background: color-mix(in srgb, var(--c) 34%, transparent);
+}
+
+.mm-seg.is-active {
+ background: var(--c);
+ color: var(--bg);
+}
+
+.mm-cols {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
+ gap: 48px;
+ align-items: start;
+}
+
+.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);
+}
+
+.mm-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.proj-name {
+ margin: 0;
+ font: inherit;
+}
+
+.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;
+}
+
+.proj-row:hover {
+ background: var(--wash);
+}
+
+.proj-row .o,
+.proj-row .l,
+.proj-row .y {
+ font-size: 12px;
+ color: var(--dim);
+}
+
+.proj-row .n {
+ font-weight: 600;
+}
+
+.proj-row .n::before {
+ content: "";
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ margin-right: 1.2ch;
+ background: var(--c);
+ vertical-align: 0.06em;
+}
+
+.proj-row .s {
+ display: block;
+ margin-top: 2px;
+ font-weight: 400;
+ color: var(--dim);
+}
+
+.proj-row[aria-pressed="true"] {
+ background: var(--wash);
+ box-shadow: inset 2px 0 0 var(--c);
+}
+
+.proj-row[aria-pressed="true"] .o,
+.proj-row[aria-pressed="true"] .l {
+ color: var(--c);
+}
+
+.mm.is-built .proj-sum,
+.mm.is-built .proj > .proj-body {
+ display: none;
+}
+
+.mm-detail {
+ min-height: 320px;
+ padding-left: 32px;
+ border-left: 1px solid var(--rule);
+}
+
+.mm-detail:empty {
+ display: none;
+}
+
+.hex {
+ margin: 0 0 22px;
+ overflow-x: auto;
+ font-size: 11.5px;
+ line-height: 1.55;
+ color: var(--dim);
+ white-space: pre;
+}
+
+.hex .a {
+ color: var(--fg);
+}
+
+.hex .addr {
+ color: var(--c);
+}
+
+.mm-detail h3 {
+ margin: 0 0 14px;
+ font: 400 32px/1.1 var(--serif);
+}
+
+.mm-detail .proj-body p {
+ margin: 0 0 14px;
+ font: 400 17px/1.55 var(--serif);
+}
+
+.mm-detail .proj-body .proj-stack,
+.mm-detail .proj-body .proj-links {
+ font: 400 12px/1.6 var(--mono);
+}
+
+.proj-stack {
+ color: var(--dim);
+}
+
+.proj-links a {
+ margin-right: 2ch;
+}
+
+/* project demos */
+
+.demo {
+ margin-top: 22px;
+ padding-top: 18px;
+ border-top: 1px solid var(--rule);
+ font-size: 12px;
+}
+
+.demo-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+.demo-bar .dim {
+ margin-left: auto;
+}
+
+.demo canvas {
+ display: block;
+ width: 100%;
+}
+
+.demo pre {
+ margin: 0;
+ font-size: 12px;
+ line-height: 1.2;
+ user-select: none;
+ -webkit-user-select: none;
+}
+
+.demo .cap {
+ margin: 10px 0 0;
+ color: var(--dim);
+}
+
+.bc-input {
+ display: flex;
+ align-items: center;
+ gap: 1ch;
+ margin-bottom: 12px;
+}
+
+.bc-input input {
+ flex: 1;
+ min-width: 0;
+ min-height: 30px;
+ padding: 0 10px;
+ border: 1px solid var(--faint);
+ border-radius: 0;
+ background: none;
+ font-size: 13px;
+}
+
+.bc-input input:focus {
+ border-color: var(--fg);
+ outline: none;
+}
+
+.bc-cols {
+ display: grid;
+ grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
+ gap: 16px;
+}
+
+.bc-code {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ font-variant-numeric: tabular-nums;
+}
+
+.bc-code li {
+ display: grid;
+ grid-template-columns: 4ch 1fr;
+ gap: 1ch;
+ padding: 1px 6px;
+}
+
+.bc-code li.is-pc {
+ background: var(--hl);
+}
+
+.bc-code .off {
+ color: var(--dim);
+ text-align: right;
+}
+
+.bc-code .op {
+ color: var(--blue);
+}
+
+.bc-stack {
+ display: flex;
+ flex-direction: column-reverse;
+ justify-content: flex-start;
+ gap: 2px;
+ min-height: 120px;
+ padding: 6px;
+ border: 1px solid var(--rule);
+ border-top: 0;
+}
+
+.bc-stack span {
+ padding: 2px 8px;
+ border: 1px solid var(--faint);
+ text-align: right;
+}
+
+.bc-stack span:last-child {
+ border-color: var(--fg);
+}
+
+.bc-err {
+ color: var(--red);
+ white-space: pre;
+}
+
+.sched-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 12px;
+ margin-top: 8px;
+ color: var(--dim);
+}
+
+/* ───────────────────────────── posts: an Org buffer ───────────────────────────── */
+
+.org {
+ max-width: 820px;
+ font-size: 13px;
+}
+
+.org-kw {
+ margin: 0;
+ color: var(--dim);
+}
+
+.org-kw .v {
+ color: var(--fg);
+}
+
+.org-kw .title {
+ font: 400 1.6em/1.4 var(--serif);
+ color: var(--fg);
+}
+
+.org-tree {
+ margin: 20px 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.org-h {
+ display: flex;
+ align-items: baseline;
+ gap: 1ch;
+ padding: 3px 0;
+}
+
+.org-h:focus-visible {
+ outline: none;
+ background: var(--hl);
+}
+
+.org-bullet {
+ flex: none;
+ width: 2ch;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--c, var(--blue));
+ font: 400 13px/1 var(--mono);
+ text-align: left;
+}
+
+.org-h1 > .org-h {
+ margin-top: 10px;
+ font: 400 21px/1.3 var(--serif);
+}
+
+.org-h1 > .org-h .org-bullet {
+ color: var(--blue);
+}
+
+.org-h1 > .org-h .count {
+ font: 400 12px/1 var(--mono);
+ color: var(--dim);
+}
+
+.org-h2 {
+ padding-left: 3ch;
+}
+
+.org-h2 > .org-h {
+ font: 400 17px/1.4 var(--serif);
+}
+
+.org-h2 a {
+ flex: 1;
+ min-width: 0;
+ text-decoration: none;
+}
+
+.org-h2 a:hover {
+ text-decoration: underline;
+ text-decoration-thickness: 1px;
+}
+
+.org-tags {
+ flex: none;
+ margin-left: auto;
+ font: 400 12px/1 var(--mono);
+ color: var(--dim);
+}
+
+.org-tags .t {
+ color: var(--c, var(--dim));
+}
+
+.org-ellipsis {
+ color: var(--dim);
+ font: 400 12px/1 var(--mono);
+}
+
+.org-drawer {
+ margin: 2px 0 6px 3ch;
+ color: var(--dim);
+ font-size: 12px;
+ line-height: 1.6;
+ white-space: pre;
+ overflow-x: auto;
+}
+
+.org-drawer .k {
+ color: var(--magenta);
+}
+
+.org-drawer .ts {
+ color: var(--cyan);
+}
+
+.org-drawer a {
+ color: var(--fg);
+}
+
+/* ───────────────────────────── uart / contact ───────────────────────────── */
+
+.uart {
+ margin: 0 0 48px;
+}
+
+.uart-scroll {
+ overflow-x: auto;
+ border: 1px solid var(--rule);
+}
+
+.uart-svg {
+ display: block;
+ font-family: var(--mono);
+}
+
+.uart-svg .trace {
+ fill: none;
+ stroke: var(--fg);
+ stroke-width: 1.5;
+}
+
+.uart-svg .frame rect {
+ fill: transparent;
+}
+
+.uart-svg .frame.is-hot rect {
+ fill: var(--wash);
+}
+
+.uart-svg .bubble {
+ fill: none;
+ stroke: var(--cyan);
+}
+
+.uart-svg .frame.is-hot .bubble {
+ fill: color-mix(in srgb, var(--cyan) 22%, transparent);
+}
+
+.uart-svg .lbl {
+ fill: var(--fg);
+ font-size: 12px;
+}
+
+.uart-svg .sub {
+ fill: var(--dim);
+ font-size: 10px;
+}
+
+.uart-svg .tick {
+ stroke: var(--rule);
+}
+
+.uart-frame {
+ min-height: 1.6em;
+ margin: 10px 0 0;
+ font-size: 12px;
+}
+
+.contact {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 16px 32px;
+ margin-bottom: 8px;
+}
+
+.mail {
+ font: 400 clamp(26px, 4.4vw, 52px)/1.1 var(--serif);
+ overflow-wrap: anywhere;
+}
+
+.mail .at {
+ font: 400 0.42em/1 var(--mono);
+ color: var(--dim);
+ vertical-align: 0.35em;
+ white-space: pre;
+}
+
+.linkish {
+ padding: 0;
+ border: 0;
+ background: none;
+ color: inherit;
+ font: inherit;
+ text-decoration: underline;
+ text-decoration-thickness: 1px;
+ text-decoration-color: var(--faint);
+ text-underline-offset: 0.2em;
+}
+
+.linkish:hover {
+ text-decoration-color: currentColor;
+}
+
+.contact-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
+ gap: 0 56px;
+}
+
+.gpg-fp {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 2ch;
+ margin: 0 0 12px;
+}
+
+.gpg-fp code {
+ letter-spacing: 0.02em;
+}
+
+.gpg-cmd {
+ display: flex;
+ align-items: stretch;
+ gap: 8px;
+}
+
+.gpg-cmd pre {
+ flex: 1;
+ min-width: 0;
+ margin: 0;
+ padding: 7px 12px;
+ overflow-x: auto;
+ border: 1px solid var(--rule);
+ background: var(--wash);
+ font-size: 12px;
+}
+
+.elsewhere {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 3ch;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.ctf {
+ max-width: 70ch;
+ margin-top: 16px;
+}
+
+.ctf p {
+ margin: 0 0 12px;
+}
+
+.ctf-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 12px 2ch;
+}
+
+.ctf-progress {
+ color: var(--dim);
+}
+
+.ctf-progress.done {
+ color: var(--green);
+}
+
+.foot {
+ max-width: var(--col);
+ margin: 0 auto;
+ padding: 160px var(--gutter) calc(var(--frame-h) + 48px);
+ font-size: 12px;
+}
+
+.foot p {
+ margin: 0 0 4px;
+}
+
+/* ───────────────────────────── modeline, minibuffer, which-key ───────────────────────────── */
+
+.frame-bottom {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 30;
+ padding-bottom: env(safe-area-inset-bottom, 0px);
+ background: var(--bg);
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.modeline {
+ display: flex;
+ align-items: stretch;
+ height: 24px;
+ line-height: 24px;
+ background: var(--bg-alt);
+ color: var(--fg);
+}
+
+/* doom-modeline's bar */
+.modeline::before {
+ content: "";
+ flex: none;
+ width: 4px;
+ background: var(--blue);
+}
+
+.modeline > span {
+ padding: 0 1ch;
+}
+
+.ml-evil {
+ color: var(--green);
+ font-weight: 600;
+}
+
+.modeline[data-evil="I"] .ml-evil { color: var(--yellow); }
+.modeline[data-evil="V"] .ml-evil { color: var(--magenta); }
+.modeline[data-evil="E"] .ml-evil { color: var(--cyan); }
+
+.ml-buf {
+ font-weight: 600;
+}
+
+.ml-pos,
+.ml-pct,
+.ml-enc,
+.ml-time {
+ color: var(--dim);
+}
+
+.ml-spacer {
+ flex: 1;
+ padding: 0 !important;
+}
+
+.ml-flags {
+ color: var(--green);
+}
+
+.ml-mode {
+ color: var(--blue);
+ font-weight: 600;
+}
+
+.ml-vc {
+ color: var(--green);
+}
+
+.echo {
+ height: 22px;
+ padding: 0 1ch;
+ overflow: hidden;
+ line-height: 22px;
+ color: var(--dim);
+ text-overflow: ellipsis;
+}
+
+.echo.is-error {
+ color: var(--red);
+}
+
+.echo.is-ok {
+ color: var(--green);
+}
+
+.mini:not([hidden]) ~ .echo {
+ display: none;
+}
+
+.mini-prompt {
+ margin: 0;
+}
+
+.mini-prompt label {
+ display: flex;
+ height: 22px;
+ padding: 0 1ch;
+ line-height: 22px;
+}
+
+.mini-count {
+ min-width: 6ch;
+ color: var(--dim);
+}
+
+.mini-ps {
+ color: var(--blue);
+ white-space: pre;
+}
+
+.mini-prompt input {
+ flex: 1;
+ min-width: 0;
+ padding: 0;
+ border: 0;
+ outline: 0;
+ background: none;
+ color: var(--fg);
+ caret-color: var(--fg);
+ font-size: 12px;
+ line-height: 22px;
+}
+
+.mini-list {
+ max-height: calc(10 * 21px);
+ margin: 0;
+ padding: 0;
+ overflow-y: auto;
+ list-style: none;
+}
+
+.mini-list:empty {
+ display: none;
+}
+
+.mini-list li {
+ display: flex;
+ gap: 2ch;
+ height: 21px;
+ padding: 0 1ch;
+ line-height: 21px;
+ cursor: pointer;
+}
+
+.mini-list li[aria-selected="true"] {
+ background: var(--hl);
+}
+
+.mini-list .cand {
+ flex: none;
+ min-width: 24ch;
+ color: var(--fg);
+}
+
+.mini-list .cand mark {
+ background: none;
+ color: var(--magenta);
+ font-weight: 600;
+}
+
+.mini-list .ann {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ color: var(--dim);
+ text-overflow: ellipsis;
+}
+
+.mini-list .key {
+ flex: none;
+ color: var(--blue);
+}
+
+.which-key {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: var(--frame-h);
+ z-index: 29;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(26ch, 1fr));
+ gap: 2px 2ch;
+ padding: 10px 2ch 12px;
+ border-top: 1px solid var(--rule);
+ background: var(--bg-alt);
+ font-size: 12px;
+}
+
+.which-key[hidden] {
+ display: none;
+}
+
+.wk-title {
+ grid-column: 1 / -1;
+ margin-bottom: 4px;
+ color: var(--dim);
+}
+
+.wk-item {
+ display: flex;
+ gap: 1ch;
+ white-space: nowrap;
+}
+
+.wk-key {
+ min-width: 3ch;
+ color: var(--magenta);
+ font-weight: 600;
+}
+
+.wk-arrow {
+ color: var(--faint);
+}
+
+.wk-desc {
+ color: var(--fg);
+}
+
+.wk-desc.group {
+ color: var(--blue);
+}
+
+/* ───────────────────────────── M-x coffee-refill ───────────────────────────── */
+
+.coffee {
+ position: fixed;
+ right: 2ch;
+ bottom: calc(var(--frame-h) + 12px);
+ z-index: 28;
+ display: flex;
+ gap: 3ch;
+ width: min(620px, calc(100% - 4ch));
+ padding: 16px 20px 18px;
+ border: 1px solid var(--faint);
+ background: var(--bg);
+ box-shadow: 0 12px 40px rgb(0 0 0 / 0.35);
+ font-size: 12px;
+}
+
+.coffee[hidden] {
+ display: none;
+}
+
+.mug {
+ flex: none;
+ margin: 0;
+ font-size: 12px;
+ line-height: 1.25;
+ color: var(--fg);
+}
+
+.mug .steam {
+ color: var(--dim);
+}
+
+.mug .brew {
+ color: var(--orange);
+}
+
+.mug .crema {
+ color: var(--yellow);
+}
+
+.coffee-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.coffee-info .winbar {
+ margin: 0 28px 14px 0;
+}
+
+.coffee-info p {
+ margin: 0 0 10px;
+}
+
+.vcc {
+ display: flex;
+ gap: 1.5ch;
+ font-variant-numeric: tabular-nums;
+}
+
+.vbar {
+ color: var(--green);
+ letter-spacing: -0.05em;
+}
+
+.coffee.is-over .vbar,
+.coffee.is-over .volts {
+ color: var(--red);
+}
+
+.coffee-msg {
+ min-height: 3.2em;
+}
+
+.coffee-links {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 2ch;
+}
+
+.coffee-close {
+ position: absolute;
+ top: 6px;
+ right: 8px;
+ padding: 2px 6px;
+ border: 0;
+ background: none;
+ color: var(--dim);
+ font-size: 16px;
+ line-height: 1;
+}
+
+.coffee-close:hover {
+ color: var(--fg);
+}
+
+/* ───────────────────────────── help ───────────────────────────── */
+
+.help {
+ width: min(780px, calc(100% - 32px));
+ max-height: calc(100% - 64px);
+ padding: 24px 28px;
+ border: 1px solid var(--faint);
+ background: var(--bg);
+ color: var(--fg);
+ font-size: 12.5px;
+}
+
+.help::backdrop {
+ background: rgb(0 0 0 / 0.5);
+}
+
+.help .winbar {
+ margin-bottom: 28px;
+}
+
+.help-cols {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 40px;
+}
+
+.help h3 {
+ margin: 0 0 12px;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.help dl {
+ margin: 0;
+}
+
+.help dl div {
+ display: grid;
+ grid-template-columns: 13ch 1fr;
+ gap: 2ch;
+ padding: 3px 0;
+}
+
+.help dt {
+ color: var(--fg);
+}
+
+.help dd {
+ margin: 0;
+ color: var(--dim);
+}
+
+.help form {
+ margin-top: 28px;
+}
+
+/* ───────────────────────────── responsive ───────────────────────────── */
+
+@media (max-width: 1100px) {
+ .hero {
+ grid-template-columns: minmax(0, 1fr);
+ align-items: start;
+ }
+
+ .hero-id {
+ max-width: 640px;
+ }
+}
+
+@media (max-width: 920px) {
+ .nav {
+ display: none;
+ }
+
+ .nav.is-open {
+ position: absolute;
+ top: calc(var(--top-h) - 4px);
+ right: var(--gutter);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ min-width: 18ch;
+ padding: 14px 18px;
+ border: 1px solid var(--faint);
+ background: var(--bg);
+ }
+
+ .menu-key {
+ display: block;
+ }
+
+ .ds-grid,
+ .mm-cols,
+ .contact-grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .ds-grid {
+ gap: 0;
+ }
+
+ .pinout {
+ max-width: 420px;
+ }
+
+ .mm-detail {
+ min-height: 0;
+ margin: 4px 0 8px;
+ padding: 20px 0 20px 12px;
+ border-left: 0;
+ border-bottom: 1px solid var(--rule);
+ }
+
+ .ml-enc,
+ .ml-vc,
+ .ml-time,
+ .ml-pos {
+ display: none;
+ }
+}
+
+@media (max-width: 640px) {
+ .board {
+ --led: 8px;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ grid-template-areas:
+ "bus bus"
+ "left right"
+ "ctrl ctrl";
+ font-size: 10.5px;
+ }
+
+ .bus {
+ flex-direction: row;
+ gap: 1.5ch;
+ padding: 6px 10px;
+ }
+
+ .bus .mod-head,
+ .bus-val {
+ margin: 0;
+ }
+
+ .lanes {
+ flex-direction: column;
+ justify-content: center;
+ height: 26px;
+ gap: 1px;
+ }
+
+ .lane {
+ width: auto;
+ height: 2px;
+ }
+
+ .mod {
+ padding: 6px 8px 8px;
+ }
+
+ .ram-row {
+ --led: 6px;
+ grid-template-columns: 1.5ch 2ch auto;
+ }
+
+ .ram-row .dis {
+ display: none;
+ }
+
+ .seg7 svg {
+ width: 16px;
+ height: 28px;
+ }
+
+ .sim-field {
+ margin-left: 0;
+ }
+
+ .sec {
+ padding-top: 104px;
+ }
+
+ .winbar {
+ margin-bottom: 40px;
+ }
+
+ .lede,
+ .prose {
+ font-size: 17px;
+ }
+
+ .ds-head {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .mm {
+ --mm-cols: 7ch 6ch minmax(0, 1fr);
+ }
+
+ .mm-head span:last-child,
+ .proj-row .y {
+ display: none;
+ }
+
+ .bc-cols {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .org-tags {
+ display: none;
+ }
+
+ .help-cols {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 24px;
+ }
+
+ .ml-pct {
+ display: none;
+ }
+
+ .coffee {
+ right: 0;
+ left: 0;
+ width: auto;
+ margin: 0 8px;
+ flex-direction: column;
+ gap: 8px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+
+ *,
+ *::before,
+ *::after {
+ transition: none !important;
+ }
+}
diff --git a/favicon.svg b/favicon.svg
@@ -0,0 +1,12 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+ <style>
+ rect { fill: #000; }
+ path { stroke: #f3f1ea; }
+ @media (prefers-color-scheme: light) {
+ rect { fill: #fff; }
+ path { stroke: #111; }
+ }
+ </style>
+ <rect width="32" height="32" rx="6"/>
+ <path d="M2 22H4V10H10V22H12V10H18V22H21V10H27V22H30" fill="none" stroke-width="2.5" stroke-linejoin="miter" stroke-linecap="square"/>
+</svg>
diff --git a/fonts/cm-italic.woff2 b/fonts/cm-italic.woff2
Binary files differ.
diff --git a/fonts/cm-regular.woff2 b/fonts/cm-regular.woff2
Binary files differ.
diff --git a/fonts/jetbrains-mono-regular.woff2 b/fonts/jetbrains-mono-regular.woff2
Binary files differ.
diff --git a/fonts/jetbrains-mono-semibold.woff2 b/fonts/jetbrains-mono-semibold.woff2
Binary files differ.
diff --git a/index.html b/index.html
@@ -0,0 +1,351 @@
+<!doctype html>
+<html lang="en" data-theme="doom-gruvbox">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<title>Michael Neuper</title>
+<meta name="description" content="Computer science student at Stellenbosch University. Low-level software, electronics, a little security, and a lot of Emacs.">
+<link rel="icon" href="favicon.svg" type="image/svg+xml">
+<link rel="preload" href="fonts/jetbrains-mono-regular.woff2" as="font" type="font/woff2" crossorigin>
+<link rel="preload" href="fonts/cm-regular.woff2" as="font" type="font/woff2" crossorigin>
+<link rel="stylesheet" href="css/style.css">
+<script>
+ // Pick the theme before first paint so there is no flash. Keep this list in sync with js/themes.js.
+ try {
+ var t = localStorage.getItem("theme");
+ var known = /^(doom-gruvbox|doom-gruvbox-light|doom-one|atom-one-dark|modus-vivendi|modus-operandi|doom-dracula|doom-nord|catppuccin|doom-tokyo-night|doom-solarized-light)$/;
+ if (!known.test(t || "")) t = matchMedia("(prefers-color-scheme: light)").matches ? "doom-gruvbox-light" : "";
+ if (t) document.documentElement.dataset.theme = t;
+ } catch (e) {}
+</script>
+<script type="module" src="js/main.js"></script>
+</head>
+<!-- Reading the source? Good instinct. flag{view_source_is_recon} -->
+<body>
+<a class="skip" href="#about">Skip to content</a>
+
+<header class="top">
+ <a class="brand" href="#top" aria-label="Michael Neuper, back to top">
+ <svg class="logo" viewBox="0 0 32 16" aria-hidden="true"><path d="M1 14H3V2H9V14H11V2H17V14H20V2H26V14H31"/></svg>
+ <span>michael neuper</span>
+ </a>
+ <nav class="nav" id="nav" aria-label="Sections">
+ <a href="#about">about</a>
+ <a href="#projects">projects</a>
+ <a href="#posts">posts</a>
+ <a href="#contact">contact</a>
+ </nav>
+ <button class="theme-key" type="button" title="SPC h t">load-theme</button>
+ <button class="menu-key" type="button" aria-expanded="false" aria-controls="nav">menu</button>
+</header>
+
+<main>
+
+ <!-- ─────────────────────────── 8-bit computer ─────────────────────────── -->
+ <section class="hero" id="top" data-file="sap-1.asm" data-mode="Asm" aria-labelledby="hero-name">
+ <div class="hero-id">
+ <h1 class="hero-name" id="hero-name">Michael Neuper</h1>
+ <p class="hero-lede">Computer science student at Stellenbosch University. I like low-level software, electronics and the maths underneath both, and I do almost all of it in Emacs.</p>
+ <ul class="legend" aria-label="Colour key used across the site">
+ <li data-cat="hw">hardware</li>
+ <li data-cat="sw">software</li>
+ <li data-cat="math">maths</li>
+ <li data-cat="sec">security</li>
+ </ul>
+ </div>
+
+ <div class="hero-sim">
+ <div class="board" role="group" aria-label="Simulated 8-bit breadboard computer"></div>
+ <div class="sim-bar">
+ <button type="button" class="btn" data-sim="run" aria-pressed="false">run</button>
+ <button type="button" class="btn" data-sim="step">step</button>
+ <button type="button" class="btn" data-sim="reset">reset</button>
+ <label class="sim-field"><span>program</span>
+ <select data-sim="program">
+ <option value="fibonacci">fibonacci</option>
+ <option value="multiply">multiply 7 × 6</option>
+ <option value="count">count up and down</option>
+ <option value="add">add 28 + 14</option>
+ </select>
+ </label>
+ <label class="sim-field"><span>clock</span>
+ <input type="range" min="0" max="100" value="43" data-sim="hz" aria-describedby="sim-hz">
+ <output id="sim-hz">10 Hz</output>
+ </label>
+ </div>
+ <p class="sim-note">The 8-bit breadboard computer I built from Ben Eater’s series, simulated one clock cycle at a time. <kbd>c</kbd> runs or pauses, <kbd>n</kbd> steps one cycle, <kbd>r</kbd> resets. Click a bit in RAM to change the program.</p>
+ </div>
+ </section>
+
+ <!-- ───────────────────────────── datasheet ───────────────────────────── -->
+ <section class="sec" id="about" data-file="whoami.pdf" data-mode="PDFView">
+ <p class="winbar"><span class="file">whoami.pdf</span><span class="rule" aria-hidden="true"></span><span class="meta">revised June 2026</span></p>
+
+ <header class="ds-head">
+ <p class="ds-part">MN-2026</p>
+ <h2 class="ds-title">Low-level, wide-bandwidth computer science student</h2>
+ </header>
+
+ <div class="ds-grid">
+ <div class="ds-text">
+ <h3 class="ds-h">1 Description</h3>
+ <p class="prose">I study computer science at Stellenbosch University and have been a teaching assistant for introductory computer science and computer architecture. I like understanding things all the way down, from a line of C to the gates that run it, and I’m happiest when that ends in something I built: a scheduler, a compiler, or a computer on breadboards. Away from that I’m a linguistics nerd and a free-software advocate.</p>
+ </div>
+
+ <figure class="pinout">
+ <h3 class="ds-h">2 Pin configuration and functions</h3>
+ <div class="chip-wrap"><!-- built from the list below --></div>
+ <div class="pin-readout" aria-live="polite"><p class="dim">Point at a pin to read its function.</p></div>
+ <figcaption>Figure 1. MN-2026, DIP-16 package, top view.</figcaption>
+ <!-- Edit the pins here. data-cat: hw, sw, math or sec. "M-x some-command" in a description becomes a button. -->
+ <ol class="pin-list">
+ <li data-name="VCC" data-type="power">Supply input: coffee. Top it up with M-x coffee-refill.</li>
+ <li data-name="C" data-type="I/O" data-cat="sw">Most of what I write, from a compiler to terminal toys like a spinning cube and the Game of Life.</li>
+ <li data-name="ASM" data-type="I/O" data-cat="sw">Assembly, starting with the 8-bit computer’s eleven instructions.</li>
+ <li data-name="JVM" data-type="output" data-cat="sw">The target of my compiler, so I read javap output for fun.</li>
+ <li data-name="LINUX" data-type="I/O" data-cat="sw">Daily driver. Dotfiles on GitHub, and posts on packaging formats and turning a window manager into a desktop.</li>
+ <li data-name="TTL" data-type="I/O" data-cat="hw">74-series logic. The 8-bit computer is dozens of these chips on breadboards.</li>
+ <li data-name="SEC" data-type="input" data-cat="sec">Security: a consulting internship, a web application assessment, and the habit of asking how things break.</li>
+ <li data-name="GND" data-type="power">Return path. Sleep, when the config is finished (it is never finished).</li>
+ <li data-name="RST" data-type="input">Active low. Asserted by a walk without a laptop.</li>
+ <li data-name="TA" data-type="output" data-cat="hw">Teaching assistant for introductory computer science and computer architecture.</li>
+ <li data-name="TEX" data-type="output" data-cat="math">Notes, templates and maths typeset in LaTeX, usually from Emacs.</li>
+ <li data-name="ORG" data-type="I/O" data-cat="sw">Org mode and org-roam for notes, literate configs, and replacing Jupyter.</li>
+ <li data-name="EMACS" data-type="I/O" data-cat="sw">Doom Emacs, with a literate config of over a thousand lines. Location: ~/.config/emacs/.</li>
+ <li data-name="MATH" data-type="input" data-cat="math">Project Euler, discrete maths, and modelling things in Python.</li>
+ <li data-name="CAN" data-type="I/O">CAN bus, the network inside every car. Cars are my one interest that isn’t a computer, mostly.</li>
+ <li data-name="NC" data-type="no connect">Reserved for the next obsession.</li>
+ </ol>
+ </figure>
+ </div>
+
+ <h3 class="ds-h">3 Revision history</h3>
+ <div class="table-scroll">
+ <table class="ds-table ds-rev">
+ <thead>
+ <tr><th scope="col">Date</th><th scope="col">Changes</th></tr>
+ </thead>
+ <tbody>
+ <tr>
+ <th scope="row"><time datetime="2026-06">June 2026</time></th>
+ <td>Teaching assistant for computer architecture.</td>
+ </tr>
+ <tr>
+ <th scope="row"><time datetime="2026-02">February 2026</time></th>
+ <td>Teaching assistant for introductory computer science.</td>
+ </tr>
+ <tr>
+ <th scope="row"><time datetime="2025-01">January 2025</time></th>
+ <td>
+ <p>Bursar at BBD Software, in its Bursary & Beyond Programme. Each year’s Vac Weeks put bursars in small teams to design, build and present a full-stack web application:</p>
+ <ul>
+ <li>2025: an online laser tag game that uses computer vision to detect and tag players as they play.</li>
+ <li>2026: an online foosball game you steer with your phone’s gyroscope.</li>
+ </ul>
+ </td>
+ </tr>
+ <tr>
+ <th scope="row"><time datetime="2024-12">December 2024</time></th>
+ <td>
+ <p>Cybersecurity consultant intern.</p>
+ <ul>
+ <li>Completed cybersecurity training with labs on TryHackMe.</li>
+ <li>Assessed the security of a web application from end to end, documenting each vulnerability with its risk and how to fix it.</li>
+ <li>Wrote a professional report and presented it, client-style, to a panel of industry professionals.</li>
+ </ul>
+ </td>
+ </tr>
+ <tr>
+ <th scope="row"><time datetime="2024-01">January 2024</time></th>
+ <td>Initial release. Started an undergraduate degree in computer science at Stellenbosch University.</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+
+ <h3 class="ds-h">4 Ordering information</h3>
+ <p class="prose">Interested in systems programming, low-level software and security work. If that’s what you do, <a href="#contact">get in touch</a>.</p>
+ </section>
+
+ <!-- ──────────────────────────── memory map ──────────────────────────── -->
+ <section class="sec" id="projects" data-file="memory.map" data-mode="Fundamental">
+ <p class="winbar"><span class="file">memory.map</span><span class="rule" aria-hidden="true"></span><span class="meta" data-mm="meta"></span></p>
+ <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; two of them run.</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>
+ <!-- Edit projects here. data-cat: hw, sw, math or sec. data-src: shown in the source column.
+ data-demo: scheduler or bytecode (optional). -->
+ <ol class="mm-list">
+ <li class="proj" data-cat="hw" data-src="built">
+ <h3 class="proj-name">8-bit-computer</h3>
+ <p class="proj-sum">Ben Eater's 8-bit breadboard computer, built chip by chip.</p>
+ <div class="proj-body">
+ <p>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.</p>
+ <p>The simulation at the top of this page runs the same instruction set and microcode, so you can step through it one control word at a time.</p>
+ <p class="proj-stack">74HC logic, EEPROMs, breadboards, a lot of jumper wire</p>
+ <p class="proj-links"><a href="#top">Run it</a> <a href="https://eater.net/8bit">Ben Eater’s series</a></p>
+ </div>
+ </li>
+ <li class="proj" data-cat="sw" data-src="private" data-demo="scheduler">
+ <h3 class="proj-name">os-scheduler</h3>
+ <p class="proj-sum">A multicore operating system scheduler, for a concurrency course.</p>
+ <div class="proj-body">
+ <p>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 a scheduling policy.</p>
+ <p class="proj-links dim">Source private (concurrency coursework)</p>
+ </div>
+ </li>
+ <li class="proj" data-cat="sw" data-src="private" data-demo="bytecode">
+ <h3 class="proj-name">jvm-compiler</h3>
+ <p class="proj-sum">A compiler in C for a custom language that targets the JVM.</p>
+ <div class="proj-body">
+ <p>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.</p>
+ <p class="proj-stack">C, JVM bytecode</p>
+ <p class="proj-links dim">Source private (computer architecture coursework)</p>
+ </div>
+ </li>
+ </ol>
+ </div>
+ <article class="mm-detail" aria-live="polite"></article>
+ </div>
+ </div>
+ </section>
+
+ <!-- ────────────────────────────── posts ────────────────────────────── -->
+ <section class="sec" id="posts" data-file="posts.org" data-mode="Org">
+ <p class="winbar"><span class="file">posts.org</span><span class="rule" aria-hidden="true"></span><span class="meta" data-org="meta"></span></p>
+ <h2 class="h2">Posts</h2>
+ <p class="lede">From my blog at michaelneuper.com, shown the way I see them: as an Org buffer. Click a bullet or press <kbd>TAB</kbd> on a heading to fold it, <kbd>S-TAB</kbd> to cycle the whole buffer.</p>
+
+ <!-- Edit posts here. data-tags are space separated; data-cat colours the bullet. -->
+ <ol class="posts">
+ <li data-date="2024-11-22" data-tags="emacs org" data-cat="sw"><a href="https://michaelneuper.com/posts/how-i-use-org-roam-to-takes-notes-for-cs/">How I Use Org-Roam to Take Notes for CS</a></li>
+ <li data-date="2023-11-15" data-tags="emacs latex" data-cat="math"><a href="https://michaelneuper.com/posts/efficient-latex-editing-with-emacs/">Efficient LaTeX Editing With Emacs</a></li>
+ <li data-date="2023-11-12" data-tags="python maths" data-cat="math"><a href="https://michaelneuper.com/posts/modelling-population-growth-in-python/">Modelling Population Growth in Python</a></li>
+ <li data-date="2023-02-27" data-tags="emacs org" data-cat="sw"><a href="https://michaelneuper.com/posts/replace-jupyter-notebook-with-emacs-org-mode/">Replace Jupyter Notebook With Emacs Org Mode</a></li>
+ <li data-date="2023-01-09" data-tags="linux" data-cat="sw"><a href="https://michaelneuper.com/posts/what-linux-packaging-format-to-use/">Which Linux Packaging Format Should You Use?</a></li>
+ <li data-date="2022-12-16" data-tags="linux security" data-cat="sec"><a href="https://michaelneuper.com/posts/enable-monitor-mode-on-tp-link/">Enable Monitor Mode on TP-Link TL-WN722N</a></li>
+ <li data-date="2022-12-14" data-tags="security c" data-cat="sec"><a href="https://michaelneuper.com/posts/buffer-overflow/">Buffer Overflows: Keep Your Code Safe</a></li>
+ <li data-date="2022-12-11" data-tags="linux" data-cat="sw"><a href="https://michaelneuper.com/posts/turning-a-window-manager-into-a-desktop-environment/">A Guide to Building Your Own Desktop Environment</a></li>
+ <li data-date="2022-12-03" data-tags="web" data-cat="sw"><a href="https://michaelneuper.com/posts/how-to-create-your-own-website/">How to Create Your Own Static Website</a></li>
+ </ol>
+ </section>
+
+ <!-- ───────────────────────────── contact ───────────────────────────── -->
+ <section class="sec" id="contact" data-file="uart.log" data-mode="Fundamental">
+ <p class="winbar"><span class="file">uart.log</span><span class="rule" aria-hidden="true"></span><span class="meta">115200 8N1</span></p>
+ <h2 class="h2">Say hello</h2>
+ <p class="lede">Email works best. Here’s my address the way a logic analyser sees it on a serial line: 115200 baud, eight data bits, no parity, one stop bit.</p>
+
+ <div class="uart">
+ <div class="uart-scroll"><svg class="uart-svg" role="img" aria-label="UART waveform of my email address"></svg></div>
+ <p class="uart-frame dim" aria-live="polite">Point at a frame to decode it.</p>
+ </div>
+
+ <div class="contact">
+ <!-- The address is only ever put together in JavaScript, so scrapers reading this HTML don't see it.
+ The link, the copy button and the waveform all read data-user and data-host. -->
+ <a class="mail" href="#contact" data-user="michael" data-host="michaelneuper.com">michael<span class="at"> <at> </span>michaelneuper.com</a>
+ <button type="button" class="btn" data-email="copy">copy address</button>
+ </div>
+
+ <div class="contact-grid">
+ <div class="gpg">
+ <h3 class="ds-h">Encrypt it</h3>
+ <p class="gpg-fp"><span class="dim">fingerprint</span><code>4E7B F800 0D86 182D F484 5C37 508B CF84 373C 8930</code></p>
+ <div class="gpg-cmd">
+ <pre><code>curl -sL https://michaelneuper.com/michaelneuper.gpg | gpg --import</code></pre>
+ <button type="button" class="btn" data-copy=".gpg-cmd code">copy</button>
+ </div>
+ </div>
+ <div>
+ <h3 class="ds-h">Elsewhere</h3>
+ <ul class="elsewhere">
+ <li><a href="https://github.com/michaelneuper">GitHub</a></li>
+ <li><a href="https://www.linkedin.com/in/michaelneuper/">LinkedIn</a></li>
+ <li><a href="https://michaelneuper.com">michaelneuper.com</a></li>
+ <li><a href="https://buymeacoffee.com/michaelneuper" target="_blank" rel="noopener">Buy me a coffee</a></li>
+ </ul>
+ </div>
+ </div>
+
+ <div class="ctf">
+ <h3 class="ds-h">Capture the flag</h3>
+ <p>Three flags are hidden on this site, the kind you would find in a beginner CTF. They look like <code>flag{…}</code>. Found one? Run <kbd>M-x</kbd> <code>submit-flag</code>, or use the button.</p>
+ <p class="ctf-row"><button type="button" class="btn" data-ctf="submit">submit a flag</button><span class="ctf-progress" aria-live="polite">0 of 3 found</span></p>
+ </div>
+ </section>
+
+ <footer class="foot">
+ <p>Made with HTML, CSS, JavaScript and Emacs, without a framework, a build step or cookies.</p>
+ <p class="dim">Press <kbd>SPC</kbd> for commands or <kbd>M-x</kbd> to run one by name.</p>
+ </footer>
+</main>
+
+<!-- ───────────────────────── coffee, modeline and minibuffer ───────────────────────── -->
+<div class="coffee" hidden role="dialog" aria-label="Coffee refill">
+ <pre class="mug" aria-hidden="true"></pre>
+ <div class="coffee-info">
+ <p class="winbar"><span class="file">*coffee*</span><span class="rule" aria-hidden="true"></span><span class="meta">q to close</span></p>
+ <p class="vcc"><span class="dim">VCC</span><span class="vbar" aria-hidden="true"></span><span class="volts">2.6 V</span></p>
+ <p class="coffee-msg" aria-live="polite">brewing…</p>
+ <p class="coffee-links"><a href="https://buymeacoffee.com/michaelneuper" target="_blank" rel="noopener">That one was simulated. Buy me an actual coffee</a></p>
+ </div>
+ <button type="button" class="coffee-close" aria-label="Close">×</button>
+</div>
+<div class="which-key" hidden aria-live="polite"></div>
+<div class="frame-bottom">
+ <div class="modeline">
+ <span class="ml-evil" title="evil state"><N></span>
+ <span class="ml-buf">sap-1.asm</span>
+ <span class="ml-pos" aria-hidden="true">1:0</span>
+ <span class="ml-pct" aria-hidden="true">Top</span>
+ <span class="ml-spacer"></span>
+ <span class="ml-misc" aria-live="polite"></span>
+ <span class="ml-flags" aria-hidden="true"></span>
+ <span class="ml-mode">Asm</span>
+ <span class="ml-enc" aria-hidden="true">LF UTF-8</span>
+ <span class="ml-vc" aria-hidden="true">Git-master</span>
+ <span class="ml-time" aria-hidden="true"></span>
+ </div>
+ <div class="mini" hidden>
+ <form class="mini-prompt" autocomplete="off">
+ <label><span class="mini-count"></span><span class="mini-ps"></span><input type="text" spellcheck="false" autocapitalize="off" role="combobox" aria-expanded="true" aria-controls="mini-list" aria-autocomplete="list"></label>
+ </form>
+ <ul class="mini-list" id="mini-list" role="listbox"></ul>
+ </div>
+ <div class="echo" role="status"></div>
+</div>
+
+<dialog class="help" aria-labelledby="help-title">
+ <p class="winbar"><span class="file" id="help-title">*Help*</span><span class="rule" aria-hidden="true"></span><span class="meta">q or esc to close</span></p>
+ <div class="help-cols">
+ <div>
+ <h3>Keys</h3>
+ <dl>
+ <div><dt><kbd>SPC</kbd></dt><dd>leader key: shows every command</dd></div>
+ <div><dt><kbd>M-x</kbd></dt><dd>run a command by name (Alt+x or Option+x)</dd></div>
+ <div><dt><kbd>SPC h t</kbd></dt><dd>load a theme, with live preview</dd></div>
+ <div><dt><kbd>:</kbd></dt><dd>evil ex commands, like <code>:q</code></dd></div>
+ <div><dt><kbd>C-g</kbd></dt><dd>cancel (so does <kbd>esc</kbd>)</dd></div>
+ </dl>
+ </div>
+ <div>
+ <h3>On this page</h3>
+ <dl>
+ <div><dt><kbd>c</kbd> <kbd>n</kbd> <kbd>r</kbd></dt><dd>computer: run or pause, step, reset</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>
+ <div><dt><code>submit-flag</code></dt><dd>check a CTF flag</dd></div>
+ </dl>
+ </div>
+ </div>
+ <form method="dialog"><button class="btn" type="submit">close</button></form>
+</dialog>
+</body>
+</html>
diff --git a/js/coffee.js b/js/coffee.js
@@ -0,0 +1,147 @@
+// M-x coffee-refill: my GitHub bio, made to do something. A mug fills while
+// VCC (the datasheet's coffee supply pin) climbs back to 5 V, then the 8-bit
+// computer at the top of the page runs at four times its clock for 30 seconds.
+
+import { $, emit, reducedMotion, isTyping } from "./util.js";
+import { defcommand, message, goto } from "./shell.js";
+
+const ROWS = 5;
+const WIDTH = 10;
+const V_LOW = 2.6;
+const V_FULL = 5.0;
+const BOOST = { factor: 4, seconds: 30 };
+const STEAM = [
+ [" ( ) ( ", " ) ( ) "],
+ [" ) ( ) ", " ( ) ( "],
+];
+
+const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
+
+function mug(level, frame) {
+ const filled = Math.round(level * ROWS);
+ const row = (i) => {
+ const fromBottom = ROWS - 1 - i;
+ if (fromBottom >= filled) return " ".repeat(WIDTH);
+ const top = fromBottom === filled - 1;
+ return `<span class="${top ? "crema" : "brew"}">${(top ? "~" : "▒").repeat(WIDTH)}</span>`;
+ };
+ const steam = filled ? STEAM[frame % 2] : ["", ""];
+ const handle = ["|--.", "| |", "| |", "|--'", "| "];
+ return [
+ `<span class="steam">${esc(steam[0])}</span>`,
+ `<span class="steam">${esc(steam[1])}</span>`,
+ " .----------.",
+ ...Array.from({ length: ROWS }, (_, i) => ` |${row(i)}${handle[i]}`),
+ " '----------'",
+ ].join("\n");
+}
+
+function cupsToday(increment = false) {
+ const key = `coffee:${new Date().toISOString().slice(0, 10)}`;
+ let n = 0;
+ try {
+ n = Number(localStorage.getItem(key)) || 0;
+ if (increment) localStorage.setItem(key, String(++n));
+ } catch {
+ if (increment) n = 1;
+ }
+ return n;
+}
+
+export function initCoffee() {
+ const panel = $(".coffee");
+ if (!panel) return;
+ const pre = $(".mug", panel);
+ const bar = $(".vbar", panel);
+ const volts = $(".volts", panel);
+ const msg = $(".coffee-msg", panel);
+ const links = $(".coffee-links", panel);
+ const closeBtn = $(".coffee-close", panel);
+
+ const watch = document.createElement("button");
+ watch.type = "button";
+ watch.className = "linkish";
+ watch.textContent = "watch the computer";
+ watch.hidden = true;
+ watch.addEventListener("click", () => goto("top"));
+ links.append(" ", watch);
+
+ let raf = 0;
+ let steamTimer = 0;
+ let returnFocus = null;
+
+ const meter = (v, over = false) => {
+ const cells = 12;
+ const n = Math.max(0, Math.min(cells, Math.round(((v - V_LOW) / (V_FULL - V_LOW)) * cells)));
+ bar.textContent = `▕${"█".repeat(n)}${"░".repeat(cells - n)}▏`;
+ volts.textContent = `${v.toFixed(1)} V`;
+ panel.classList.toggle("is-over", over);
+ };
+
+ function close() {
+ if (panel.hidden) return;
+ cancelAnimationFrame(raf);
+ clearInterval(steamTimer);
+ panel.hidden = true;
+ if (returnFocus && document.contains(returnFocus)) returnFocus.focus({ preventScroll: true });
+ }
+
+ function refill() {
+ cancelAnimationFrame(raf);
+ clearInterval(steamTimer);
+ returnFocus = document.activeElement;
+ panel.hidden = false;
+ watch.hidden = true;
+ msg.textContent = "brewing…";
+ closeBtn.focus({ preventScroll: true });
+
+ const finish = () => {
+ const n = cupsToday(true);
+ let frame = 0;
+ steamTimer = setInterval(() => { pre.innerHTML = mug(1, ++frame); }, 450);
+ pre.innerHTML = mug(1, 0);
+ if (n > 5) {
+ meter(5.6, true);
+ msg.textContent = `Cup ${n} today. Absolute maximum rating exceeded: stresses beyond it may cause permanent damage, so no overclock this time. Have some water.`;
+ message("VCC above absolute maximum rating", "error");
+ return;
+ }
+ meter(V_FULL);
+ const note = n === 1 ? "VCC is back to 5.0 V."
+ : n <= 3 ? `Cup ${n} today, within the typical range.`
+ : n === 4 ? "Cup 4 today, above typical."
+ : "Cup 5 today: that’s the absolute maximum rating.";
+ msg.textContent = `${note} The computer at the top now runs at ${BOOST.factor}× clock for ${BOOST.seconds} seconds.`;
+ watch.hidden = false;
+ emit("caffeine", BOOST);
+ message(`Refilled. VCC 5.0 V, ${BOOST.factor}× clock for ${BOOST.seconds}s`, "ok");
+ };
+
+ if (reducedMotion.matches) return finish();
+ const t0 = performance.now();
+ const step = (now) => {
+ const t = Math.min(1, (now - t0) / 2400);
+ const e = 1 - Math.pow(1 - t, 2);
+ pre.innerHTML = mug(e, Math.floor(now / 450));
+ meter(V_LOW + (V_FULL - V_LOW) * e);
+ if (t < 1) raf = requestAnimationFrame(step);
+ else finish();
+ };
+ meter(V_LOW);
+ raf = requestAnimationFrame(step);
+ }
+
+ closeBtn.addEventListener("click", close);
+ addEventListener("keydown", (e) => {
+ if (panel.hidden || e.metaKey || e.altKey || isTyping(e)) return;
+ if (e.key === "q" || e.key === "Escape" || (e.ctrlKey && e.key === "g")) {
+ e.preventDefault();
+ e.stopPropagation();
+ close();
+ }
+ }, true);
+
+ defcommand("coffee-refill", refill, { doc: "Top up VCC and overclock the computer for a while", key: "SPC r" });
+ pre.innerHTML = mug(0, 0);
+ meter(V_LOW);
+}
diff --git a/js/computer.js b/js/computer.js
@@ -0,0 +1,548 @@
+// Ben Eater's 8-bit breadboard computer, simulated one clock cycle at a time.
+//
+// Same architecture as the build: a 4-bit address space with 16 bytes of RAM,
+// an 8-bit bus, A and B registers, an adder/subtractor, an output register on
+// a 7-segment display, carry and zero flags, and microcode that drives sixteen
+// control lines over five steps per instruction.
+
+import { $, $$, hex, reducedMotion, frameWhileVisible, isTyping, on } from "./util.js";
+import { defcommand, message, completingRead, setSegment } from "./shell.js";
+
+// Control lines, in the order the control-word LEDs show them.
+const LINES = ["HLT", "MI", "RI", "RO", "IO", "II", "AI", "AO", "EO", "SU", "BI", "OI", "CE", "CO", "J", "FI"];
+const L = Object.fromEntries(LINES.map((n, i) => [n, 1 << (15 - i)]));
+
+const OPS = { 0: "NOP", 1: "LDA", 2: "ADD", 3: "SUB", 4: "STA", 5: "LDI", 6: "JMP", 7: "JC", 8: "JZ", 14: "OUT", 15: "HLT" };
+const CODE = Object.fromEntries(Object.entries(OPS).map(([k, v]) => [v, +k]));
+const TAKES_ARG = new Set(["LDA", "ADD", "SUB", "STA", "LDI", "JMP", "JC", "JZ"]);
+
+// Microcode. Every instruction starts with the same two fetch steps.
+const FETCH = [L.CO | L.MI, L.RO | L.II | L.CE];
+const MICRO = {
+ LDA: [L.IO | L.MI, L.RO | L.AI, 0],
+ ADD: [L.IO | L.MI, L.RO | L.BI, L.EO | L.AI | L.FI],
+ SUB: [L.IO | L.MI, L.RO | L.BI, L.EO | L.AI | L.SU | L.FI],
+ STA: [L.IO | L.MI, L.AO | L.RI, 0],
+ LDI: [L.IO | L.AI, 0, 0],
+ JMP: [L.IO | L.J, 0, 0],
+ JC: [L.IO | L.J, 0, 0],
+ JZ: [L.IO | L.J, 0, 0],
+ OUT: [L.AO | L.OI, 0, 0],
+ HLT: [L.HLT, 0, 0],
+};
+
+function controlWord(s) {
+ if (s.step < 2) return FETCH[s.step];
+ const op = OPS[s.ir >> 4];
+ if (!MICRO[op]) return 0;
+ if ((op === "JC" && !s.cf) || (op === "JZ" && !s.zf)) return 0;
+ return MICRO[op][s.step - 2];
+}
+
+function alu(s, cw) {
+ const raw = cw & L.SU ? s.a + ((~s.b & 0xff) + 1) : s.a + s.b;
+ return { sum: raw & 0xff, carry: raw > 0xff ? 1 : 0, zero: (raw & 0xff) === 0 ? 1 : 0 };
+}
+
+function busValue(s, cw, sum) {
+ if (cw & L.CO) return s.pc;
+ if (cw & L.RO) return s.ram[s.mar];
+ if (cw & L.IO) return s.ir & 0x0f;
+ if (cw & L.AO) return s.a;
+ if (cw & L.EO) return sum;
+ return 0; // pull-down resistors
+}
+
+// One rising clock edge: everything with an "in" line latches the bus.
+function tick(s) {
+ if (s.halted) return;
+ const cw = controlWord(s);
+ const r = alu(s, cw);
+ const v = busValue(s, cw, r.sum);
+ if (cw & L.MI) s.mar = v & 0x0f;
+ if (cw & L.RI) s.ram[s.mar] = v;
+ if (cw & L.II) s.ir = v;
+ if (cw & L.AI) s.a = v;
+ if (cw & L.BI) s.b = v;
+ if (cw & L.OI) s.out = v;
+ if (cw & L.FI) { s.cf = r.carry; s.zf = r.zero; }
+ if (cw & L.J) s.pc = v & 0x0f;
+ if (cw & L.CE) s.pc = (s.pc + 1) & 0x0f;
+ s.step = (s.step + 1) % 5;
+ s.cycles++;
+ // HLT gates the clock as soon as its control word appears.
+ if (controlWord(s) & L.HLT) s.halted = true;
+}
+
+function resetState(s) {
+ Object.assign(s, { a: 0, b: 0, ir: 0, mar: 0, pc: 0, out: 0, step: 0, cf: 0, zf: 0, halted: false, cycles: 0 });
+}
+
+const disasm = (byte) => {
+ const op = OPS[byte >> 4] ?? "???";
+ return TAKES_ARG.has(op) ? `${op} ${byte & 15}` : op;
+};
+
+// ── programs ──────────────────────────────────────────────────────────
+
+const PROGRAMS = {
+ fibonacci: {
+ title: "fibonacci",
+ code: [["LDI", 1], ["STA", 15], ["LDI", 0], ["OUT"], ["ADD", 15], ["JC", 0], ["STA", 14], ["LDA", 15],
+ ["STA", 13], ["LDA", 14], ["STA", 15], ["LDA", 13], ["JMP", 3]],
+ data: { 13: ["z", 0], 14: ["t", 0], 15: ["y", 0] },
+ },
+ multiply: {
+ title: "multiply 7 × 6",
+ code: [["LDA", 14], ["SUB", 12], ["JC", 6], ["LDA", 13], ["OUT"], ["HLT"], ["STA", 14], ["LDA", 13],
+ ["ADD", 15], ["STA", 13], ["JMP", 0]],
+ data: { 12: ["one", 1], 13: ["prod", 0], 14: ["y", 6], 15: ["x", 7] },
+ },
+ count: {
+ title: "count up and down",
+ code: [["OUT"], ["ADD", 15], ["JC", 4], ["JMP", 0], ["SUB", 15], ["OUT"], ["JZ", 0], ["JMP", 4]],
+ data: { 15: ["step", 5] },
+ },
+ add: {
+ title: "add 28 + 14",
+ code: [["LDA", 14], ["ADD", 15], ["OUT"], ["HLT"]],
+ data: { 14: ["a", 28], 15: ["b", 14] },
+ },
+};
+
+function assemble(p) {
+ const ram = new Uint8Array(16);
+ p.code.forEach(([op, arg = 0], i) => { ram[i] = (CODE[op] << 4) | (arg & 15); });
+ for (const [addr, [, v]] of Object.entries(p.data)) ram[addr] = v;
+ return ram;
+}
+
+// ── 7-segment digits ──────────────────────────────────────────────────
+
+const H = (y, x1, x2) => `M${x1} ${y}l1.6 -1.6H${x2 - 1.6}l1.6 1.6l-1.6 1.6H${x1 + 1.6}z`;
+const V = (x, y1, y2) => `M${x} ${y1}l1.6 1.6V${y2 - 1.6}l-1.6 1.6l-1.6 -1.6V${y1 + 1.6}z`;
+const SEGS = { a: H(2, 3, 19), b: V(20, 3, 18.4), c: V(20, 19.6, 35), d: H(36, 3, 19), e: V(2, 19.6, 35), f: V(2, 3, 18.4), g: H(19, 3, 19) };
+const GLYPH = { 0: "abcdef", 1: "bc", 2: "abdeg", 3: "abcdg", 4: "bcfg", 5: "acdfg", 6: "acdefg", 7: "abc", 8: "abcdefg", 9: "abcdfg", "-": "g", " ": "" };
+
+// ── UI ────────────────────────────────────────────────────────────────
+
+export function initComputer() {
+ const hero = $(".hero");
+ const board = $(".board", hero);
+ if (!board) return;
+ const runBtn = $('[data-sim="run"]', hero);
+ const progSel = $('[data-sim="program"]', hero);
+ const hzIn = $('[data-sim="hz"]', hero);
+ const hzOut = $("#sim-hz");
+
+ const s = { ram: new Uint8Array(16) };
+ resetState(s);
+ let program = PROGRAMS.fibonacci;
+ let running = false;
+ let hz = 10;
+ let acc = 0;
+ let phase = 0;
+ let signed = false;
+ let dirty = true;
+ let boost = 1; // M-x coffee-refill overclocks the clock for a while
+ let boostTimer = 0;
+ const effHz = () => Math.min(500, hz * boost);
+
+ // ── build the board ────────────────────────────────────────────────
+ const el = (tag, cls, text) => {
+ const e = document.createElement(tag);
+ if (cls) e.className = cls;
+ if (text != null) e.textContent = text;
+ return e;
+ };
+ const mods = {};
+ 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));
+ for (const sig of sigs) {
+ const t = el("span", "sig", sig);
+ t.dataset.sig = sig;
+ head.append(t);
+ }
+ const body = el("div", "mod-body");
+ m.append(head, body);
+ mods[key] = { m, body, sigs: $$(".sig", head), val: null, leds: [] };
+ return mods[key];
+ };
+ const leds = (target, n, cls = "", gapAt = -1, tag = "span") => {
+ const w = el("div", "leds");
+ for (let i = 0; i < n; i++) {
+ if (i === gapAt) w.append(el("span", "gap"));
+ const l = el(tag, `led ${cls}`);
+ w.append(l);
+ target.leds.push(l);
+ }
+ return w;
+ };
+ const value = (target, text = "") => {
+ target.val = el("span", "mod-val", text);
+ return target.val;
+ };
+
+ const left = el("div", "col col-l");
+ const right = el("div", "col col-r");
+
+ const clk = mod("clk", "clock", ["HLT"]);
+ clk.body.append(leds(clk, 1, "b"), value(clk));
+ const mar = mod("mar", "memory address", ["MI"]);
+ mar.body.append(leds(mar, 4, "y"), value(mar));
+
+ const ram = mod("ram", "RAM", ["RI", "RO"]);
+ const rows = el("div", "ram-rows");
+ const ramRows = [];
+ for (let a = 0; a < 16; a++) {
+ const row = el("div", "ram-row");
+ const pcMark = el("span", "pc");
+ const addr = el("span", "addr", hex(a, 1));
+ const bits = el("span", "leds");
+ const cells = [];
+ for (let b = 7; b >= 0; b--) {
+ const btn = el("button", "led");
+ btn.type = "button";
+ btn.tabIndex = a === 0 && b === 7 ? 0 : -1;
+ btn.dataset.addr = a;
+ btn.dataset.bit = b;
+ btn.setAttribute("aria-label", `address ${a}, bit ${b}`);
+ bits.append(btn);
+ cells.push(btn);
+ }
+ const dis = el("span", "dis");
+ row.append(pcMark, addr, bits, dis);
+ rows.append(row);
+ ramRows.push({ row, pcMark, cells, dis });
+ }
+ ram.body.replaceWith(rows);
+
+ 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);
+
+ const pc = mod("pc", "program counter", ["CO", "J", "CE"]);
+ pc.body.append(leds(pc, 4, "g"), value(pc));
+ const a = mod("a", "A register", ["AI", "AO"]);
+ a.body.append(leds(a, 8), value(a));
+ const sum = mod("sum", "ALU Σ", ["EO", "SU"]);
+ sum.body.append(leds(sum, 8), value(sum));
+ const b = mod("b", "B register", ["BI"]);
+ b.body.append(leds(b, 8), value(b));
+ const out = mod("out", "output", ["OI"]);
+ const seg = el("div", "seg7");
+ const digits = [];
+ for (let d = 0; d < 4; d++) {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("viewBox", "0 0 22 38");
+ svg.setAttribute("aria-hidden", "true");
+ const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
+ g.setAttribute("transform", "skewX(-6) translate(2 0)");
+ const segs = {};
+ for (const [k, dpath] of Object.entries(SEGS)) {
+ const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ p.setAttribute("d", dpath);
+ g.append(p);
+ segs[k] = p;
+ }
+ svg.append(g);
+ seg.append(svg);
+ digits.push(segs);
+ }
+ const outLabel = el("span", "sr-only");
+ out.body.append(seg, value(out), outLabel);
+ out.body.title = "Click to switch between unsigned and signed";
+ const flags = mod("flags", "flags", ["FI"]);
+ const fl = el("div", "leds");
+ const cfLed = el("span", "led g");
+ const zfLed = el("span", "led g");
+ 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 bus = el("div", "bus");
+ const busHead = el("div", "mod-head");
+ busHead.append(el("span", "mod-name", "bus"));
+ const busVal = el("span", "bus-val mod-val", "0");
+ const lanesWrap = el("div", "lanes");
+ const lanes = [];
+ for (let i = 0; i < 8; i++) {
+ const l = el("span", "lane");
+ lanesWrap.append(l);
+ lanes.push(l);
+ }
+ bus.append(busHead, busVal, lanesWrap);
+
+ const ctrl = mod("ctrl", "control word", []);
+ ctrl.m.classList.add("ctrl");
+ const ctrlRow = el("div", "ctrl-row");
+ 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");
+ bit.append(l, el("span", "", `T${i}`));
+ stepWrap.append(bit);
+ stepLeds.push([bit, l]);
+ }
+ const cwWrap = el("div", "cw");
+ const cwBits = LINES.map((n) => {
+ const bit = el("span", "cw-bit");
+ const l = el("span", "led b");
+ bit.append(l, el("span", "", n));
+ cwWrap.append(bit);
+ return [bit, l];
+ });
+ ctrlRow.append(stepWrap, cwWrap);
+ const ustep = el("p", "ustep");
+ ctrl.body.replaceWith(ctrlRow);
+ ctrl.m.append(ustep);
+
+ board.replaceChildren(left, bus, right, ctrl.m);
+
+ // ── render ─────────────────────────────────────────────────────────
+ const setBits = (list, v, n = list.length) => {
+ for (let i = 0; i < n; i++) list[i].classList.toggle("on", ((v >> (n - 1 - i)) & 1) === 1);
+ };
+ const setText = (node, text) => {
+ if (node.textContent !== text) node.textContent = text;
+ };
+
+ let lastRam = "";
+ function render() {
+ const cw = controlWord(s);
+ const r = alu(s, cw);
+ const v = busValue(s, cw, r.sum);
+
+ // registers
+ setBits(mar.leds, s.mar); setText(mar.val, String(s.mar));
+ setBits(pc.leds, s.pc); setText(pc.val, String(s.pc));
+ setBits(a.leds, s.a); setText(a.val, String(s.a));
+ setBits(b.leds, s.b); setText(b.val, String(s.b));
+ setBits(sum.leds, r.sum); setText(sum.val, `${cw & L.SU ? "A−B" : "A+B"} ${r.sum}`);
+ setBits(ir.leds, s.ir); setText(ir.val, s.step < 2 && s.cycles === 0 ? "" : disasm(s.ir));
+ cfLed.classList.toggle("on", !!s.cf);
+ zfLed.classList.toggle("on", !!s.zf);
+ setText(flags.val, "");
+ setBits(lanes, v); setText(busVal, String(v));
+
+ // clock
+ clk.leds[0].classList.toggle("on", running && !s.halted && (effHz() > 24 || phase < 0.5));
+ setText(clk.val, s.halted ? "halted" : running ? (boost > 1 ? `${fmtHz(effHz())} (${boost}×)` : fmtHz(hz)) : "paused");
+
+ // output display
+ const o = signed && s.out > 127 ? -(256 - s.out) : s.out;
+ const txt = (o < 0 ? "-" : " ") + String(Math.abs(o)).padStart(3, " ");
+ txt.split("").forEach((ch, i) => {
+ const on = GLYPH[ch] ?? "";
+ for (const [k, p] of Object.entries(digits[i])) p.classList.toggle("on", on.includes(k));
+ });
+ setText(out.val, signed ? "signed" : "");
+ setText(outLabel, `output ${o}`);
+
+ // control word and step
+ cwBits.forEach(([bit, l], i) => {
+ const on = (cw >> (15 - i)) & 1;
+ bit.classList.toggle("on", !!on);
+ l.classList.toggle("on", !!on);
+ });
+ stepLeds.forEach(([bit, l], i) => {
+ bit.classList.toggle("on", i === s.step);
+ l.classList.toggle("on", i === s.step);
+ });
+ const d = describe(s, cw);
+ const html = `T${s.step} <b>${d.label}</b> ${d.text}`;
+ if (ustep.innerHTML !== html) ustep.innerHTML = html;
+
+ // which modules drive the bus, which latch it, and their control lines
+ const outs = { pc: L.CO, ram: L.RO, ir: L.IO, a: L.AO, sum: L.EO };
+ const ins = { mar: L.MI, ram: L.RI, ir: L.II, a: L.AI, b: L.BI, out: L.OI, pc: L.J | L.CE, flags: L.FI, clk: L.HLT };
+ for (const [key, m] of Object.entries(mods)) {
+ m.m.classList.toggle("is-out", !!(cw & (outs[key] ?? 0)));
+ m.m.classList.toggle("is-in", !!(cw & (ins[key] ?? 0)) && !(cw & (outs[key] ?? 0)));
+ for (const t of m.sigs) t.classList.toggle("on", !!(cw & L[t.dataset.sig]));
+ }
+
+ // RAM matrix
+ const key = `${s.ram.join(",")}|${s.mar}|${s.pc}`;
+ if (key !== lastRam) {
+ lastRam = key;
+ ramRows.forEach((rr, addr) => {
+ setBits(rr.cells, s.ram[addr]);
+ 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]));
+ });
+ }
+ }
+
+ 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] ?? "???";
+ if (!cw) {
+ if (label === "JC") return { label, text: "carry clear, so no jump" };
+ if (label === "JZ") return { label, text: "zero clear, so no jump" };
+ return { label, text: "nothing to do this step" };
+ }
+ const src = [[L.CO, "PC"], [L.RO, "RAM"], [L.IO, "operand"], [L.AO, "A"], [L.EO, cw & L.SU ? "A − B" : "A + B"]].find(([bit]) => cw & bit);
+ const dst = [[L.MI, "MAR"], [L.RI, "RAM"], [L.II, "IR"], [L.AI, "A"], [L.BI, "B"], [L.OI, "output"], [L.J, "PC"]].filter(([bit]) => cw & bit).map(([, n]) => n);
+ const parts = [];
+ if (src && dst.length) parts.push(`${src[1]} → ${dst.join(", ")}`);
+ if (cw & L.CE) parts.push("PC + 1");
+ if (cw & L.FI) parts.push("latch flags");
+ return { label, text: parts.join(", ") };
+ }
+
+ const fmtHz = (f) => (f < 10 ? `${f.toFixed(1)} Hz` : `${Math.round(f)} Hz`);
+
+ // ── simulation loop ────────────────────────────────────────────────
+ function frame(t, dt) {
+ if (running && !s.halted) {
+ acc += dt * effHz();
+ phase = (phase + dt * effHz()) % 1;
+ let n = Math.floor(acc);
+ acc -= n;
+ n = Math.min(n, 400);
+ while (n-- > 0 && !s.halted) tick(s);
+ if (s.halted) halted();
+ dirty = true;
+ }
+ if (dirty) {
+ render();
+ dirty = false;
+ }
+ }
+
+ function halted() {
+ running = false;
+ paintRun();
+ message(`HLT: ${program.title} finished with ${s.out} on the display. Press r to reset.`, "", 6000);
+ }
+
+ function paintRun() {
+ runBtn.setAttribute("aria-pressed", String(running));
+ runBtn.textContent = running ? "pause" : "run";
+ }
+
+ // ── controls ───────────────────────────────────────────────────────
+ function run(want = !running) {
+ if (want && s.halted) return message("Halted. Press r (or the reset button) to start again.");
+ running = want;
+ acc = 0;
+ paintRun();
+ dirty = true;
+ }
+ function step() {
+ if (running) run(false);
+ if (s.halted) return message("Halted. Press r to reset.");
+ tick(s);
+ if (s.halted) halted();
+ dirty = true;
+ }
+ function reset() {
+ resetState(s);
+ dirty = true;
+ }
+ function load(key) {
+ program = PROGRAMS[key] ?? PROGRAMS.fibonacci;
+ progSel.value = key;
+ s.ram = assemble(program);
+ resetState(s);
+ lastRam = "";
+ dirty = true;
+ }
+ function setHz(v) {
+ hz = 0.5 * Math.pow(1000, v / 100);
+ hzOut.value = fmtHz(hz);
+ dirty = true;
+ }
+
+ runBtn.addEventListener("click", () => run());
+ $('[data-sim="step"]', hero).addEventListener("click", step);
+ $('[data-sim="reset"]', hero).addEventListener("click", reset);
+ progSel.addEventListener("change", () => {
+ load(progSel.value);
+ if (!reducedMotion.matches) run(true);
+ });
+ hzIn.addEventListener("input", () => setHz(+hzIn.value));
+ out.body.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.
+ rows.addEventListener("click", (e) => {
+ const btn = e.target.closest(".led");
+ if (!btn) return;
+ s.ram[+btn.dataset.addr] ^= 1 << +btn.dataset.bit;
+ focusCell(+btn.dataset.addr, +btn.dataset.bit);
+ dirty = true;
+ });
+ rows.addEventListener("keydown", (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;
+ e.preventDefault();
+ focusCell((+btn.dataset.addr + d[0] + 16) % 16, Math.min(7, Math.max(0, +btn.dataset.bit + d[1])));
+ });
+ function focusCell(addr, bit) {
+ for (const rr of ramRows) for (const c of rr.cells) c.tabIndex = -1;
+ const c = ramRows[addr].cells[7 - bit];
+ c.tabIndex = 0;
+ c.focus({ preventScroll: true });
+ }
+
+ // c, n and r while the computer is on screen
+ let visible = false;
+ new IntersectionObserver(([e]) => { visible = e.isIntersecting; }, { threshold: 0.25 }).observe(board);
+ document.addEventListener("keydown", (e) => {
+ if (!visible || e.defaultPrevented || e.repeat || isTyping(e) || e.metaKey || e.ctrlKey || e.altKey) return;
+ if (document.querySelector("dialog[open]")) return;
+ const act = { c: () => run(), n: step, r: reset }[e.key];
+ if (!act) return;
+ e.preventDefault();
+ act();
+ });
+
+ defcommand("computer-run", () => run(), { doc: "Run or pause the 8-bit computer", key: "c" });
+ defcommand("computer-step", step, { doc: "Advance the clock by one cycle", key: "n" });
+ defcommand("computer-reset", reset, { doc: "Reset the computer (RAM is kept)", key: "r" });
+ defcommand("computer-load-program", async () => {
+ const key = await completingRead("Load program: ", Object.entries(PROGRAMS).map(([k, p]) => ({ value: k, ann: p.title })));
+ if (key) {
+ load(key);
+ run(true);
+ document.getElementById("top").scrollIntoView({ behavior: reducedMotion.matches ? "auto" : "smooth" });
+ }
+ }, { doc: "Load one of the example programs into RAM", key: "SPC c l" });
+
+ on("caffeine", ({ factor, seconds }) => {
+ boost = factor;
+ if (s.halted) reset();
+ if (!running) run(true);
+ let left = seconds;
+ clearInterval(boostTimer);
+ setSegment("misc", `${factor}× clock ${left}s`);
+ boostTimer = setInterval(() => {
+ left--;
+ if (left > 0) return setSegment("misc", `${factor}× clock ${left}s`);
+ clearInterval(boostTimer);
+ boost = 1;
+ dirty = true;
+ setSegment("misc", "");
+ message(`The caffeine wore off. Clock back to ${fmtHz(hz)}.`);
+ }, 1000);
+ dirty = true;
+ });
+
+ load("fibonacci");
+ setHz(+hzIn.value);
+ run(!reducedMotion.matches);
+ frameWhileVisible(board, frame, "0px");
+}
diff --git a/js/ctf.js b/js/ctf.js
@@ -0,0 +1,71 @@
+// Three beginner CTF flags hidden around the site. This file keeps only their
+// SHA-256 hashes, so reading it shows how flags are checked without giving them away.
+
+import { $ } from "./util.js";
+import { defcommand, message, completingRead, setSegment, call } from "./shell.js";
+
+const HASHES = [
+ "7a80f308e7d3c86589ac38394f24154bf36149b8ad833f728c9e0ddeed29693e",
+ "b8af26fa58465fa7be727749c616d906054f12928d817dd34d94284943d6f02e",
+ "d43266a3ddf2cab859b257fad7ea2f2eceee3726e72f1651305a6d3f14bf064e",
+];
+
+const load = () => {
+ try { return new Set(JSON.parse(localStorage.getItem("flags") || "[]")); } catch { return new Set(); }
+};
+const save = (set) => {
+ try { localStorage.setItem("flags", JSON.stringify([...set])); } catch { /* private mode */ }
+};
+
+async function sha256(text) {
+ const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
+ return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
+}
+
+export function initCtf() {
+ const progress = $(".ctf-progress");
+ let found = load();
+
+ const paint = () => {
+ const n = [...found].filter((h) => HASHES.includes(h)).length;
+ if (progress) {
+ progress.textContent = n === 3 ? "3 of 3 found. Nicely done." : `${n} of 3 found`;
+ progress.classList.toggle("done", n === 3);
+ }
+ setSegment("flags", n ? `flags ${n}/3` : "");
+ };
+ paint();
+
+ async function submit() {
+ const guess = await completingRead("Flag: ", [], { requireMatch: false });
+ if (!guess) return;
+ if (!crypto.subtle) return message("Checking flags needs a secure context (https or localhost).", "error");
+ const h = await sha256(guess.trim());
+ if (!HASHES.includes(h)) return message("Not a flag. Keep looking.", "error");
+ if (found.has(h)) return message("You already found that one.");
+ found.add(h);
+ save(found);
+ paint();
+ const n = [...found].filter((x) => HASHES.includes(x)).length;
+ message(n === 3 ? "Correct. That’s all three." : `Correct. ${n} of 3 found.`, "ok", 6000);
+ }
+
+ defcommand("submit-flag", submit, { doc: "Check a CTF flag you found on this site", key: "SPC s f" });
+ defcommand("copy-gpg-fingerprint", async () => {
+ const fp = $(".gpg-fp code")?.textContent ?? "";
+ try {
+ await navigator.clipboard.writeText(fp);
+ message(`Copied ${fp}`, "ok");
+ } catch {
+ message(fp);
+ }
+ }, { doc: "Copy my GPG key fingerprint", key: "SPC s g" });
+ $('[data-ctf="submit"]')?.addEventListener("click", () => call("submit-flag"));
+
+ // One flag lives in the console.
+ console.log(
+ "%cPoking around in the console? Good instinct.%c\nThere are three flags on this site. Here is one, base64-encoded:\nZmxhZ3tjb25zb2xlX2xvZ3NfbGVha30=\nSubmit flags with M-x submit-flag.",
+ "font: 600 13px monospace",
+ "font: 12px monospace",
+ );
+}
diff --git a/js/datasheet.js b/js/datasheet.js
@@ -0,0 +1,86 @@
+// Builds the DIP-16 pinout from the ordered list of pins in the HTML.
+// Pins 1–8 run down the left side, 9–16 run back up the right.
+
+import { $, $$ } from "./util.js";
+import { call } from "./shell.js";
+
+export function initDatasheet() {
+ const fig = $(".pinout");
+ if (!fig) return;
+ const pins = $$(".pin-list li", fig).map((li, i) => ({
+ num: i + 1,
+ name: li.dataset.name,
+ type: li.dataset.type,
+ ch: li.dataset.cat,
+ text: li.textContent.trim(),
+ }));
+ const half = pins.length / 2;
+ const readout = $(".pin-readout", fig);
+
+ const chip = document.createElement("div");
+ chip.className = "chip";
+ const left = side("left");
+ const right = side("right");
+ const body = document.createElement("div");
+ body.className = "chip-body";
+ body.innerHTML = "<span>MN-2026</span>";
+ chip.append(left, body, right);
+
+ pins.forEach((p, i) => {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "pin";
+ if (p.ch) btn.dataset.cat = p.ch;
+ btn.setAttribute("aria-label", `Pin ${p.num}, ${p.name}`);
+ const parts = { name: p.name, num: p.num, leg: "" };
+ const order = i < half ? ["name", "num", "leg"] : ["leg", "num", "name"];
+ for (const k of order) {
+ const s = document.createElement("span");
+ s.className = k;
+ s.textContent = parts[k];
+ btn.append(s);
+ }
+ btn.addEventListener("pointerenter", () => show(p, btn));
+ btn.addEventListener("focus", () => show(p, btn));
+ btn.addEventListener("click", () => show(p, btn));
+ if (i < half) left.append(btn);
+ else right.prepend(btn);
+ });
+
+ $(".chip-wrap", fig).append(chip);
+ fig.classList.add("is-built");
+
+ function show(p, btn) {
+ $$(".pin.is-active", chip).forEach((b) => b.classList.remove("is-active"));
+ btn.classList.add("is-active");
+ if (p.ch) readout.dataset.cat = p.ch;
+ else delete readout.dataset.cat;
+ readout.innerHTML = "";
+ const head = document.createElement("p");
+ head.className = "pr-head";
+ head.innerHTML = `<span class="dim">pin ${p.num}</span><span class="pr-name"></span><span class="dim"></span>`;
+ head.children[1].textContent = p.name;
+ head.children[2].textContent = p.type;
+ // "M-x some-command" in a pin description becomes a button that runs it.
+ const text = document.createElement("p");
+ for (const part of p.text.split(/(M-x [\w-]+)/)) {
+ if (!part.startsWith("M-x ")) {
+ text.append(part);
+ continue;
+ }
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "linkish";
+ b.textContent = part;
+ b.addEventListener("click", () => call(part.slice(4)));
+ text.append(b);
+ }
+ readout.append(head, text);
+ }
+}
+
+function side(cls) {
+ const d = document.createElement("div");
+ d.className = `chip-side ${cls}`;
+ return d;
+}
diff --git a/js/demos/bytecode.js b/js/demos/bytecode.js
@@ -0,0 +1,208 @@
+// Integer expressions compiled to JVM bytecode, then run on an operand stack
+// with Java's int semantics (wrap-around, truncating division, / by zero).
+
+import { reducedMotion } from "../util.js";
+
+const LOCALS = { x: 6, y: 7, z: -2 }; // slots 0, 1, 2 of a static method
+const SLOT = { x: 0, y: 1, z: 2 };
+const OPS = { "+": "iadd", "-": "isub", "*": "imul", "/": "idiv", "%": "irem" };
+
+function tokenize(src) {
+ const out = [];
+ const re = /\s*(?:(\d+)|([A-Za-z_]\w*)|(.))/gy;
+ let m;
+ while (re.lastIndex < src.length && (m = re.exec(src))) {
+ const pos = m.index + m[0].length - (m[1] || m[2] || m[3] || "").length;
+ if (m[1]) out.push({ t: "num", v: m[1], pos });
+ else if (m[2]) out.push({ t: "id", v: m[2], pos });
+ else if (m[3] && m[3].trim()) out.push({ t: "op", v: m[3], pos });
+ }
+ out.push({ t: "end", pos: src.length });
+ return out;
+}
+
+class CompileError extends Error {
+ constructor(msg, pos) { super(msg); this.pos = pos; }
+}
+
+function parse(src) {
+ const toks = tokenize(src);
+ let i = 0;
+ const peek = () => toks[i];
+ const next = () => toks[i++];
+ const expect = (v) => {
+ const t = next();
+ if (t.v !== v) throw new CompileError(`'${v}' expected`, t.pos);
+ };
+ function expr() {
+ let l = term();
+ while (peek().t === "op" && "+-".includes(peek().v)) l = { t: "bin", op: next().v, l, r: term() };
+ return l;
+ }
+ function term() {
+ let l = unary();
+ while (peek().t === "op" && "*/%".includes(peek().v)) l = { t: "bin", op: next().v, l, r: unary() };
+ return l;
+ }
+ function unary() {
+ if (peek().t === "op" && peek().v === "-") { next(); return { t: "neg", e: unary() }; }
+ if (peek().t === "op" && peek().v === "+") { next(); return unary(); }
+ return primary();
+ }
+ function primary() {
+ const t = next();
+ if (t.t === "num") {
+ const v = Number(t.v);
+ if (v > 2147483647) throw new CompileError("integer number too large", t.pos);
+ return { t: "num", v };
+ }
+ if (t.t === "id") {
+ if (!(t.v in SLOT)) throw new CompileError(`cannot find symbol: variable ${t.v}`, t.pos);
+ return { t: "var", name: t.v };
+ }
+ if (t.v === "(") { const e = expr(); expect(")"); return e; }
+ throw new CompileError(t.t === "end" ? "illegal start of expression" : `unexpected '${t.v}'`, t.pos);
+ }
+ const tree = expr();
+ if (peek().t !== "end") throw new CompileError(`unexpected '${peek().v}'`, peek().pos);
+ return tree;
+}
+
+function codegen(tree) {
+ const code = [];
+ const pool = [];
+ const emit = (op, arg, size = 1) => code.push({ op, arg, size });
+ (function gen(n) {
+ if (n.t === "num") {
+ const v = n.v;
+ if (v >= -1 && v <= 5) emit(v === -1 ? "iconst_m1" : `iconst_${v}`);
+ else if (v >= -128 && v <= 127) emit("bipush", v, 2);
+ else if (v >= -32768 && v <= 32767) emit("sipush", v, 3);
+ else {
+ let k = pool.indexOf(v);
+ if (k < 0) k = pool.push(v) - 1;
+ emit("ldc", v, 2);
+ code[code.length - 1].text = `ldc #${k + 2}`;
+ }
+ } else if (n.t === "var") emit(`iload_${SLOT[n.name]}`, n.name);
+ else if (n.t === "neg") { gen(n.e); emit("ineg"); }
+ else { gen(n.l); gen(n.r); emit(OPS[n.op]); }
+ })(tree);
+ emit("ireturn");
+ let off = 0;
+ for (const c of code) { c.off = off; off += c.size; }
+ return code;
+}
+
+function exec(ins, stack) {
+ const pop = () => stack.pop();
+ if (ins.op.startsWith("iconst_")) stack.push(ins.op === "iconst_m1" ? -1 : +ins.op.slice(7));
+ else if (ins.op === "bipush" || ins.op === "sipush" || ins.op === "ldc") stack.push(ins.arg);
+ else if (ins.op.startsWith("iload_")) stack.push(LOCALS[ins.arg]);
+ else if (ins.op === "ineg") stack.push(-pop() | 0);
+ else if (ins.op === "ireturn") return { done: true, value: stack[stack.length - 1] };
+ else {
+ const b = pop();
+ const a = pop();
+ if ((ins.op === "idiv" || ins.op === "irem") && b === 0) throw new Error("java.lang.ArithmeticException: / by zero");
+ stack.push(
+ ins.op === "iadd" ? (a + b) | 0
+ : ins.op === "isub" ? (a - b) | 0
+ : ins.op === "imul" ? Math.imul(a, b)
+ : ins.op === "idiv" ? Math.trunc(a / b) | 0
+ : (a % b) | 0,
+ );
+ }
+ return { done: false };
+}
+
+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">
+ <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>`;
+ const input = host.querySelector("input");
+ const list = host.querySelector(".bc-code");
+ const stackEl = host.querySelector(".bc-stack");
+ const result = host.querySelector('[data-k="result"]');
+ let code = [];
+ let pc = 0;
+ let stack = [];
+ let timer = 0;
+ let finished = false;
+
+ function compile() {
+ clearInterval(timer);
+ pc = 0;
+ stack = [];
+ finished = false;
+ try {
+ code = codegen(parse(input.value));
+ list.replaceChildren(...code.map((c) => {
+ const li = document.createElement("li");
+ li.innerHTML = `<span class="off">${c.off}:</span><span><span class="op">${c.text ? "ldc" : c.op}</span>${c.text ? ` ${c.text.slice(4)}<span class="dim"> // int ${c.arg}</span>` : c.arg !== undefined && !c.op.startsWith("iload") ? ` ${c.arg}` : ""}</span>`;
+ return li;
+ }));
+ result.textContent = "";
+ } catch (e) {
+ code = [];
+ const pre = document.createElement("li");
+ pre.className = "bc-err";
+ pre.textContent = `error: ${e.message}\n ${input.value}\n ${" ".repeat(e.pos ?? 0)}^`;
+ list.replaceChildren(pre);
+ result.textContent = "";
+ }
+ paint();
+ }
+
+ function paint() {
+ [...list.children].forEach((li, i) => li.classList.toggle("is-pc", i === pc && !finished && code.length > 0));
+ stackEl.replaceChildren(...stack.map((v) => {
+ const s = document.createElement("span");
+ s.textContent = v;
+ return s;
+ }));
+ }
+
+ function step() {
+ if (!code.length) return false;
+ if (finished) { pc = 0; stack = []; finished = false; result.textContent = ""; }
+ try {
+ const r = exec(code[pc], stack);
+ if (r.done) {
+ finished = true;
+ result.textContent = `returns ${r.value}`;
+ } else pc++;
+ } catch (e) {
+ finished = true;
+ result.textContent = e.message;
+ }
+ paint();
+ return !finished;
+ }
+
+ function run() {
+ clearInterval(timer);
+ if (finished) step();
+ if (reducedMotion.matches) {
+ while (step()) { /* run to the end */ }
+ return;
+ }
+ timer = setInterval(() => { if (!step()) clearInterval(timer); }, 320);
+ }
+
+ let debounce = 0;
+ input.addEventListener("input", () => { clearTimeout(debounce); debounce = setTimeout(compile, 180); });
+ input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); compile(); run(); } });
+ host.querySelector('[data-k="step"]').addEventListener("click", () => { clearInterval(timer); step(); });
+ host.querySelector('[data-k="run"]').addEventListener("click", run);
+ compile();
+ return () => clearInterval(timer);
+}
diff --git a/js/demos/scheduler.js b/js/demos/scheduler.js
@@ -0,0 +1,166 @@
+// 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";
+
+const CORES = 4;
+const SPAN = 48;
+const QUANTUM = 3;
+
+export function mount(host) {
+ host.innerHTML = `
+ <div class="demo-bar">
+ <select aria-label="Scheduling policy">
+ <option value="rr">round robin, quantum ${QUANTUM}</option>
+ <option value="sjf">shortest job first</option>
+ <option value="fcfs">first come, first served</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>
+ <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. Round robin preempts a process after ${QUANTUM} ticks; the other two policies let it run to completion.</p>`;
+ const cv = host.querySelector("canvas");
+ const sel = host.querySelector("select");
+ const stats = host.querySelector('[data-k="stats"]');
+ const pauseBtn = host.querySelector('[data-k="pause"]');
+
+ let t = 0;
+ let nextId = 1;
+ let queue = [];
+ let cores = Array.from({ length: CORES }, () => ({ p: null, slice: 0 }));
+ let hist = Array.from({ length: CORES }, () => []);
+ let done = 0;
+ let waitSum = 0;
+ let paused = false;
+
+ const spawn = () => {
+ const burst = 2 + Math.floor(Math.random() * 10);
+ queue.push({ id: nextId++, burst, left: burst, arrive: t, waited: 0 });
+ };
+ for (let i = 0; i < 7; i++) spawn();
+
+ function tick() {
+ const active = queue.length + cores.filter((c) => c.p).length;
+ if (active < 12 && Math.random() < 0.38) spawn();
+ // preempt
+ if (sel.value === "rr") {
+ for (const c of cores) {
+ if (c.p && c.slice >= QUANTUM && queue.length) {
+ queue.push(c.p);
+ c.p = null;
+ }
+ }
+ }
+ // dispatch
+ for (const c of cores) {
+ if (c.p || !queue.length) continue;
+ let i = 0;
+ if (sel.value === "sjf") queue.forEach((p, j) => { if (p.left < queue[i].left) i = j; });
+ c.p = queue.splice(i, 1)[0];
+ c.slice = 0;
+ }
+ // run one tick
+ cores.forEach((c, k) => {
+ hist[k].push(c.p ? c.p.id : 0);
+ if (hist[k].length > SPAN) hist[k].shift();
+ if (!c.p) return;
+ c.p.left--;
+ c.slice++;
+ if (c.p.left === 0) {
+ done++;
+ waitSum += c.p.waited;
+ c.p = null;
+ }
+ });
+ for (const p of queue) p.waited++;
+ t++;
+ stats.textContent = `t = ${t}, finished ${done}, mean wait ${done ? (waitSum / done).toFixed(1) : "0.0"} ticks`;
+ }
+
+ function draw() {
+ if (!cv.isConnected) return;
+ fitCanvas(cv);
+ 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];
+ g.clearRect(0, 0, W, H);
+ g.font = "11px 'JetBrains Mono', monospace";
+ g.textBaseline = "middle";
+ const lw = 46;
+ const cw = (W - lw) / SPAN;
+ const rh = 22;
+ for (let k = 0; k < CORES; k++) {
+ const y = 4 + k * (rh + 6);
+ g.fillStyle = c.dim;
+ g.fillText(`cpu${k}`, 0, y + rh / 2);
+ g.fillStyle = c.rule;
+ g.fillRect(lw, y, W - lw, rh);
+ const row = hist[k];
+ const off = SPAN - row.length;
+ // draw each uninterrupted run as one block, labelled if it is wide enough
+ for (let i = 0; i < row.length;) {
+ const id = row[i];
+ let j = i;
+ while (j < row.length && row[j] === id) j++;
+ if (id) {
+ const x = lw + (off + i) * cw;
+ const w = (j - i) * cw - 1;
+ g.fillStyle = color(id);
+ g.globalAlpha = 0.85;
+ g.fillRect(x, y, w, rh);
+ g.globalAlpha = 1;
+ const label = `P${id}`;
+ if (g.measureText(label).width + 6 < w) {
+ g.fillStyle = c.bg;
+ g.fillText(label, x + 3, y + rh / 2);
+ }
+ }
+ i = j;
+ }
+ }
+ // ready queue
+ const qy = 4 + CORES * (rh + 6) + 4;
+ g.fillStyle = c.dim;
+ g.fillText("ready", 0, qy + 9);
+ let x = lw;
+ for (const p of queue) {
+ const label = `P${p.id}·${p.left}`;
+ const w = g.measureText(label).width + 10;
+ if (x + w > W) break;
+ g.strokeStyle = color(p.id);
+ g.strokeRect(x + 0.5, qy + 0.5, w, 17);
+ g.fillStyle = c.fg;
+ g.fillText(label, x + 5, qy + 9);
+ x += w + 5;
+ }
+ }
+
+ 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; }
+ }
+ draw();
+ });
+
+ host.querySelector('[data-k="add"]').addEventListener("click", () => { spawn(); draw(); });
+ pauseBtn.addEventListener("click", () => {
+ paused = !paused;
+ pauseBtn.setAttribute("aria-pressed", String(paused));
+ pauseBtn.textContent = paused ? "resume" : "pause";
+ });
+ sel.addEventListener("change", () => {
+ hist = hist.map(() => []);
+ done = 0;
+ waitSum = 0;
+ });
+
+ return () => off();
+}
diff --git a/js/main.js b/js/main.js
@@ -0,0 +1,24 @@
+import { fontsReady } from "./util.js";
+import "./shell.js";
+import { initComputer } from "./computer.js";
+import { initDatasheet } from "./datasheet.js";
+import { initMemmap } from "./memmap.js";
+import { initOrg } from "./org.js";
+import { initUart } from "./uart.js";
+import { initCtf } from "./ctf.js";
+import { initCoffee } from "./coffee.js";
+
+function safely(fn) {
+ try { fn(); } catch (err) { console.error(err); }
+}
+
+safely(initComputer);
+safely(initDatasheet);
+safely(initOrg);
+safely(initUart);
+safely(initCtf);
+safely(initCoffee);
+
+// The hexdump measures character widths, so wait for the mono font.
+await fontsReady();
+safely(initMemmap);
diff --git a/js/memmap.js b/js/memmap.js
@@ -0,0 +1,186 @@
+// 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 } 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 || "",
+ 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;
+
+ 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;
+ 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 = "demo";
+ detail.append(host);
+ 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;
+ 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);
+ }
+ 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/js/org.js b/js/org.js
@@ -0,0 +1,151 @@
+// Renders the posts list as an Org buffer: years as top-level headings, posts
+// under them with tags and a property drawer. TAB folds the heading at point,
+// S-TAB cycles the whole buffer through OVERVIEW, CONTENTS and SHOW ALL.
+
+import { $, $$ } from "./util.js";
+import { message } from "./shell.js";
+
+const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+
+export function initOrg() {
+ const sec = $("#posts");
+ const src = sec && $(".posts", sec);
+ if (!src) return;
+
+ const posts = $$("li", src).map((li) => {
+ const a = $("a", li);
+ return { date: li.dataset.date, tags: (li.dataset.tags || "").split(/\s+/).filter(Boolean), cat: li.dataset.cat, title: a.textContent.trim(), href: a.href };
+ });
+ const years = [...new Set(posts.map((p) => p.date.slice(0, 4)))].sort().reverse();
+
+ const el = (tag, cls, text) => {
+ const e = document.createElement(tag);
+ if (cls) e.className = cls;
+ if (text != null) e.textContent = text;
+ return e;
+ };
+
+ const org = el("div", "org");
+ const kw = (k, v, cls = "v") => {
+ const p = el("p", "org-kw", `#+${k}: `);
+ p.append(el("span", cls, v));
+ return p;
+ };
+ org.append(kw("title", "Posts", "title"), kw("author", "Michael Neuper"), kw("startup", "content"));
+
+ const tree = el("ul", "org-tree");
+ tree.setAttribute("role", "tree");
+ tree.setAttribute("aria-label", "Posts by year");
+ const headings = [];
+
+ for (const y of years) {
+ const items = posts.filter((p) => p.date.startsWith(y));
+ const li = el("li", "org-h1");
+ li.setAttribute("role", "treeitem");
+ const h = heading(li, 1);
+ h.append(el("span", "", y), el("span", "count", `[${items.length}]`));
+ const ell = el("span", "org-ellipsis", "…");
+ ell.hidden = true;
+ h.append(ell);
+ const kids = el("ul", "org-tree");
+ kids.setAttribute("role", "group");
+ kids.style.margin = "0";
+ for (const p of items) {
+ const pl = el("li", "org-h2");
+ pl.setAttribute("role", "treeitem");
+ if (p.cat) pl.dataset.cat = p.cat;
+ const ph = heading(pl, 2);
+ const a = el("a", "", p.title);
+ a.href = p.href;
+ a.tabIndex = -1;
+ ph.append(a);
+ const tags = el("span", "org-tags");
+ tags.append(":");
+ for (const t of p.tags) tags.append(el("span", "t", t), ":");
+ ph.append(tags);
+ const pell = el("span", "org-ellipsis", "…");
+ ph.append(pell);
+ const d = new Date(`${p.date}T12:00:00Z`);
+ const drawer = el("div", "org-drawer");
+ drawer.innerHTML = `<span class="k">:PROPERTIES:</span>\n<span class="k">:DATE:</span> <span class="ts"><${p.date} ${DAYS[d.getUTCDay()]}></span>\n<span class="k">:URL:</span> <a href="${p.href}" tabindex="-1">${p.href.replace(/^https?:\/\//, "")}</a>\n<span class="k">:END:</span>`;
+ drawer.hidden = true;
+ pl.append(drawer);
+ pl._fold = (open) => {
+ drawer.hidden = !open;
+ pell.hidden = open;
+ pl.setAttribute("aria-expanded", String(open));
+ };
+ pl._open = () => !drawer.hidden;
+ pl._fold(false);
+ kids.append(pl);
+ }
+ li.append(kids);
+ li._fold = (open) => {
+ kids.hidden = !open;
+ ell.hidden = open;
+ li.setAttribute("aria-expanded", String(open));
+ };
+ li._open = () => !kids.hidden;
+ li._fold(true);
+ tree.append(li);
+ }
+ org.append(tree);
+ src.replaceWith(org);
+
+ const meta = $('[data-org="meta"]', sec);
+ if (meta) meta.textContent = `${posts.length} posts`;
+
+ function heading(li, level) {
+ const h = el("div", "org-h");
+ h.tabIndex = headings.length === 0 ? 0 : -1;
+ const bullet = el("button", "org-bullet", level === 1 ? "◉" : "○");
+ bullet.type = "button";
+ bullet.tabIndex = -1;
+ bullet.setAttribute("aria-label", "Fold or unfold");
+ bullet.addEventListener("click", () => { cycle(li); h.focus({ preventScroll: true }); });
+ h.append(bullet);
+ li.prepend(h);
+ headings.push(h);
+ return h;
+ }
+
+ function cycle(li) {
+ li._fold(!li._open());
+ }
+
+ // Global visibility cycling, like S-TAB in Org.
+ const STATES = ["OVERVIEW", "CONTENTS", "SHOW ALL"];
+ let global = 1;
+ function globalCycle() {
+ global = (global + 1) % 3;
+ for (const y of $$(".org-h1", tree)) {
+ y._fold(global > 0);
+ for (const p of $$(".org-h2", y)) p._fold(global === 2);
+ }
+ message(STATES[global]);
+ }
+
+ const visible = () => headings.filter((h) => h.offsetParent !== null);
+ tree.addEventListener("keydown", (e) => {
+ const h = e.target.closest(".org-h");
+ if (!h) return;
+ const li = h.parentElement;
+ const vis = visible();
+ const i = vis.indexOf(h);
+ const move = (j) => {
+ const t = vis[Math.max(0, Math.min(vis.length - 1, j))];
+ for (const x of headings) x.tabIndex = -1;
+ t.tabIndex = 0;
+ t.focus();
+ };
+ if (e.key === "Tab" && e.shiftKey) { e.preventDefault(); globalCycle(); }
+ else if (e.key === "Tab") { e.preventDefault(); cycle(li); }
+ else if (e.key === "ArrowDown" || e.key === "j") { e.preventDefault(); move(i + 1); }
+ else if (e.key === "ArrowUp" || e.key === "k") { e.preventDefault(); move(i - 1); }
+ else if (e.key === "Enter") {
+ const a = h.querySelector("a");
+ if (a) a.click();
+ else cycle(li);
+ }
+ });
+}
diff --git a/js/shell.js b/js/shell.js
@@ -0,0 +1,511 @@
+// The Emacs around the page: doom-modeline, the echo area, a minibuffer with
+// vertico-style completion (M-x, evil's :), which-key on the SPC leader, and
+// load-theme with live preview. Other modules add commands with defcommand().
+
+import { $, $$, emit, isTyping, finePointer, reducedMotion } from "./util.js";
+import { THEMES, DEFAULT_THEME } from "./themes.js";
+
+const root = document.documentElement;
+const modeline = $(".modeline");
+const ml = {
+ evil: $(".ml-evil"), buf: $(".ml-buf"), pos: $(".ml-pos"), pct: $(".ml-pct"),
+ flags: $(".ml-flags"), misc: $(".ml-misc"), mode: $(".ml-mode"), time: $(".ml-time"),
+};
+const mini = $(".mini");
+const form = $(".mini-prompt");
+const input = $("input", form);
+const psEl = $(".mini-ps");
+const countEl = $(".mini-count");
+const list = $(".mini-list");
+const echo = $(".echo");
+const wk = $(".which-key");
+const help = $(".help");
+const nav = $(".nav");
+const menuKey = $(".menu-key");
+
+// ── echo area ─────────────────────────────────────────────────────────
+
+let echoTimer = 0;
+const idleText = () => (finePointer.matches ? "Press SPC for commands, or M-x to run one by name." : "");
+export function message(text, kind = "", ms = 5000) {
+ echo.textContent = text;
+ echo.className = kind ? `echo is-${kind}` : "echo";
+ clearTimeout(echoTimer);
+ if (ms) echoTimer = setTimeout(() => message(idleText(), "", 0), ms);
+}
+message(idleText(), "", 0);
+
+// ── modeline ──────────────────────────────────────────────────────────
+
+export function setSegment(name, text) {
+ if (ml[name]) ml[name].textContent = text;
+}
+
+function evilState() {
+ const a = document.activeElement;
+ let s = "N";
+ if (!mini.hidden || (a && isTyping({ target: a }))) s = "I";
+ else if (String(getSelection()).length) s = "V";
+ modeline.dataset.evil = s;
+ ml.evil.textContent = `<${s}>`;
+}
+document.addEventListener("focusin", evilState);
+document.addEventListener("focusout", () => setTimeout(evilState));
+document.addEventListener("selectionchange", evilState);
+
+function onScroll() {
+ const max = root.scrollHeight - innerHeight;
+ const y = scrollY;
+ ml.pct.textContent = y <= 2 ? "Top" : y >= max - 2 ? "Bot" : `${Math.round((y / max) * 100)}%`;
+ ml.pos.textContent = `${Math.floor(y / 21) + 1}:0`;
+ root.classList.toggle("is-scrolled", y > 24);
+}
+addEventListener("scroll", onScroll, { passive: true });
+onScroll();
+
+function clock() {
+ const d = new Date();
+ ml.time.textContent = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
+}
+clock();
+setInterval(clock, 15000);
+
+// ── sections ──────────────────────────────────────────────────────────
+
+const sections = $$("main > section");
+const navLinks = $$(".nav a");
+const sectionIO = new IntersectionObserver((entries) => {
+ for (const e of entries) {
+ if (!e.isIntersecting) continue;
+ ml.buf.textContent = e.target.dataset.file;
+ ml.mode.textContent = e.target.dataset.mode;
+ for (const a of navLinks) a.setAttribute("aria-current", String(a.hash === `#${e.target.id}`));
+ emit("section", e.target.id);
+ }
+}, { rootMargin: "-40% 0px -55% 0px" });
+sections.forEach((s) => sectionIO.observe(s));
+
+export function goto(id, quiet = false) {
+ const s = document.getElementById(id);
+ if (!s) return;
+ // Sections snap to their header line, so scroll to that rather than the padding above it.
+ ($(".winbar", s) ?? s).scrollIntoView({ behavior: reducedMotion.matches ? "auto" : "smooth", block: "start" });
+ history.replaceState(null, "", id === "top" ? location.pathname : `#${id}`);
+ if (!quiet) message(`"${s.dataset.file}"`);
+}
+
+document.addEventListener("click", (e) => {
+ const a = e.target.closest('a[href^="#"]');
+ if (!a || e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey) return;
+ const id = a.hash.slice(1);
+ if (!sections.some((s) => s.id === id)) return;
+ e.preventDefault();
+ goto(id, true);
+});
+
+// ── commands ──────────────────────────────────────────────────────────
+
+const commands = new Map();
+export function defcommand(name, run, { doc = "", key = "", hidden = false } = {}) {
+ commands.set(name, { name, run, doc, key, hidden });
+}
+export function call(name, ...args) {
+ const c = commands.get(name);
+ if (!c) return message(`Symbol’s function definition is void: ${name}`, "error");
+ return c.run(...args);
+}
+
+// ── minibuffer: completing-read ───────────────────────────────────────
+
+let pending = null;
+
+// Resolves to the chosen string, or null if cancelled. `candidates` is an array of
+// { value, ann, key } (or strings), or a function of the input returning one.
+export function completingRead(prompt, candidates, opts = {}) {
+ const { initial = "", requireMatch = true, select = null, onPreview = null } = opts;
+ if (pending) pending.finish(null);
+ const returnFocus = document.activeElement;
+
+ return new Promise((resolve) => {
+ let items = [];
+ let sel = 0;
+ const norm = (c) => (typeof c === "string" ? { value: c } : c);
+
+ const render = () => {
+ const q = input.value.trim().toLowerCase();
+ const all = (typeof candidates === "function" ? candidates(input.value) : candidates).map(norm);
+ const terms = q.split(/\s+/).filter(Boolean);
+ items = typeof candidates === "function" ? all : all.filter((c) => terms.every((t) => c.value.toLowerCase().includes(t)));
+ // candidates that start with what you typed come first
+ if (terms.length && typeof candidates !== "function") {
+ const starts = (c) => c.value.toLowerCase().startsWith(terms[0]);
+ items = [...items.filter(starts), ...items.filter((c) => !starts(c))];
+ }
+ sel = Math.min(sel, Math.max(0, items.length - 1));
+ countEl.textContent = items.length ? `${sel + 1}/${items.length}` : "0/0";
+ list.replaceChildren(...items.map((c, i) => {
+ const li = document.createElement("li");
+ li.setAttribute("role", "option");
+ li.setAttribute("aria-selected", String(i === sel));
+ li.id = `mini-opt-${i}`;
+ const cand = document.createElement("span");
+ cand.className = "cand";
+ cand.append(...highlight(c.value, terms));
+ li.append(cand);
+ if (c.ann) {
+ const a = document.createElement("span");
+ a.className = "ann";
+ a.textContent = c.ann;
+ li.append(a);
+ }
+ if (c.key) {
+ const k = document.createElement("span");
+ k.className = "key";
+ k.textContent = c.key;
+ li.append(k);
+ }
+ li.addEventListener("mousedown", (e) => e.preventDefault());
+ li.addEventListener("click", () => { sel = i; accept(); });
+ return li;
+ }));
+ input.setAttribute("aria-activedescendant", items.length ? `mini-opt-${sel}` : "");
+ list.children[sel]?.scrollIntoView({ block: "nearest" });
+ };
+
+ const move = (d) => {
+ if (!items.length) return;
+ sel = (sel + d + items.length) % items.length;
+ render();
+ onPreview?.(items[sel].value);
+ };
+
+ const accept = () => {
+ const raw = input.value.trim();
+ const hit = items[sel]?.value;
+ if (hit !== undefined && (requireMatch || typeof candidates !== "function" || raw === "")) finish(hit);
+ else if (!requireMatch) finish(raw || hit || null);
+ else if (hit !== undefined) finish(hit);
+ else message("[No match]", "error", 1500);
+ };
+
+ const onKey = (e) => {
+ const ctrl = e.ctrlKey && !e.metaKey;
+ if (e.key === "ArrowDown" || (ctrl && e.key === "n")) { e.preventDefault(); move(1); }
+ else if (e.key === "ArrowUp" || (ctrl && e.key === "p")) { e.preventDefault(); move(-1); }
+ else if (e.key === "Enter") { e.preventDefault(); accept(); }
+ else if (e.key === "Escape" || (ctrl && e.key === "g")) { e.preventDefault(); finish(null); }
+ else if (e.key === "Tab") {
+ e.preventDefault();
+ if (items[sel]) {
+ input.value = items[sel].value;
+ render();
+ }
+ }
+ };
+ const onInput = () => {
+ sel = 0;
+ render();
+ if (items.length) onPreview?.(items[0].value);
+ };
+ const onBlur = () => setTimeout(() => { if (pending?.finish === finish && document.activeElement !== input) finish(null); }, 0);
+ const onSubmit = (e) => e.preventDefault();
+
+ function finish(value) {
+ if (pending?.finish !== finish) return;
+ pending = null;
+ input.removeEventListener("keydown", onKey);
+ input.removeEventListener("input", onInput);
+ input.removeEventListener("blur", onBlur);
+ form.removeEventListener("submit", onSubmit);
+ mini.hidden = true;
+ list.replaceChildren();
+ if (returnFocus && returnFocus !== input && document.contains(returnFocus)) returnFocus.focus({ preventScroll: true });
+ else input.blur();
+ evilState();
+ resolve(value);
+ }
+
+ pending = { finish };
+ psEl.textContent = prompt;
+ input.value = initial;
+ mini.hidden = false;
+ input.addEventListener("keydown", onKey);
+ input.addEventListener("input", onInput);
+ input.addEventListener("blur", onBlur);
+ form.addEventListener("submit", onSubmit);
+ if (select) {
+ const all = (typeof candidates === "function" ? candidates("") : candidates).map(norm);
+ sel = Math.max(0, all.findIndex((c) => c.value === select));
+ }
+ render();
+ input.focus({ preventScroll: true });
+ evilState();
+ });
+}
+
+function highlight(text, terms) {
+ if (!terms.length) return [text];
+ const lower = text.toLowerCase();
+ const marks = new Array(text.length).fill(false);
+ for (const t of terms) {
+ const i = lower.indexOf(t);
+ if (i >= 0) for (let k = i; k < i + t.length; k++) marks[k] = true;
+ }
+ const out = [];
+ let buf = "";
+ let on = false;
+ for (let i = 0; i <= text.length; i++) {
+ const m = i < text.length && marks[i];
+ if (i === text.length || m !== on) {
+ if (buf) {
+ if (on) {
+ const mk = document.createElement("mark");
+ mk.textContent = buf;
+ out.push(mk);
+ } else out.push(buf);
+ }
+ buf = "";
+ on = m;
+ }
+ if (i < text.length) buf += text[i];
+ }
+ return out;
+}
+
+// ── M-x and evil ex ───────────────────────────────────────────────────
+
+async function mx() {
+ const cands = [...commands.values()].filter((c) => !c.hidden).sort((a, b) => a.name.localeCompare(b.name))
+ .map((c) => ({ value: c.name, ann: c.doc, key: c.key }));
+ const name = await completingRead("M-x ", cands);
+ if (name) call(name);
+}
+defcommand("execute-extended-command", mx, { hidden: true });
+
+const EX = ["colorscheme", "edit", "help", "quit", "write", "wq"];
+async function ex() {
+ const line = await completingRead(":", (q) => {
+ const [cmd, arg = ""] = q.trim().split(/\s+/);
+ if (/^colo/.test(cmd || "") && q.includes(" ")) return THEMES.filter((t) => t.id.startsWith(arg)).map((t) => ({ value: `colorscheme ${t.id}`, ann: t.desc }));
+ if (/^e(dit)?$/.test(cmd || "") && q.includes(" ")) return sections.filter((s) => s.id.startsWith(arg)).map((s) => ({ value: `edit ${s.id}`, ann: s.dataset.file }));
+ return EX.filter((c) => c.startsWith(cmd || "")).map((c) => ({ value: c }));
+ }, { requireMatch: false });
+ if (line) runEx(line);
+}
+
+function runEx(line) {
+ const [cmd, arg] = line.trim().split(/\s+/);
+ if (/^q(uit|a)?!?$/.test(cmd)) call("save-buffers-kill-terminal");
+ else if (/^(w|write)$/.test(cmd)) message(`"${ml.buf.textContent}" written`);
+ else if (/^(wq|x)!?$/.test(cmd)) message(`"${ml.buf.textContent}" written. Still here, though.`);
+ else if (/^colo(r(scheme)?)?$/.test(cmd)) arg ? setTheme(arg) : call("load-theme");
+ else if (/^e(dit)?$/.test(cmd)) goto(findSection(arg)?.id ?? "top");
+ else if (/^h(elp)?$/.test(cmd)) call("help");
+ else message(`E492: Not an editor command: ${line}`, "error");
+}
+
+function findSection(word = "") {
+ const w = word.toLowerCase();
+ return sections.find((s) => s.id === w || s.dataset.file === w) || sections.find((s) => s.id.startsWith(w) || s.dataset.file.startsWith(w));
+}
+
+// ── themes ────────────────────────────────────────────────────────────
+
+const currentTheme = () => root.dataset.theme || DEFAULT_THEME;
+function applyTheme(id) {
+ if (root.dataset.theme === id) return;
+ root.dataset.theme = id;
+ emit("theme", id);
+}
+export function setTheme(id) {
+ if (!THEMES.some((t) => t.id === id)) return message(`Unable to find theme file for ‘${id}’`, "error");
+ applyTheme(id);
+ try { localStorage.setItem("theme", id); } catch { /* private mode */ }
+ message(`Loaded theme ${id}`);
+}
+
+async function loadTheme() {
+ const saved = currentTheme();
+ const id = await completingRead("Load custom theme: ", THEMES.map((t) => ({ value: t.id, ann: t.desc })), {
+ select: saved,
+ onPreview: applyTheme,
+ });
+ if (id) setTheme(id);
+ else applyTheme(saved);
+}
+defcommand("load-theme", loadTheme, { doc: "Load a theme, previewing each one", key: "SPC h t" });
+$(".theme-key").addEventListener("click", loadTheme);
+
+// ── a few more commands ───────────────────────────────────────────────
+
+defcommand("find-file", async () => {
+ const id = await completingRead("Find file: ", sections.map((s) => ({ value: s.dataset.file, ann: s.querySelector("h1, h2")?.textContent ?? "" })));
+ const s = id && sections.find((x) => x.dataset.file === id);
+ if (s) goto(s.id);
+}, { doc: "Jump to a section by its file name", key: "SPC ." });
+
+for (const s of sections) {
+ defcommand(`goto-${s.id}`, () => goto(s.id), { hidden: true });
+}
+
+defcommand("help", () => { if (!help.open) help.showModal(); }, { doc: "Show keys and commands", key: "SPC h h" });
+defcommand("save-buffers-kill-terminal", () => message("This is a website: close it with C-w. Glad you stayed, though."), { doc: "Quit Emacs", key: "SPC q q" });
+defcommand("restart-emacs", () => location.reload(), { doc: "Restart Emacs (reloads the page)", key: "SPC q r" });
+defcommand("buy-me-a-coffee", () => {
+ window.open("https://buymeacoffee.com/michaelneuper", "_blank", "noopener");
+ message("Opened buymeacoffee.com/michaelneuper");
+}, { doc: "Open my Buy Me a Coffee page", key: "SPC b" });
+defcommand("keyboard-quit", () => message("Quit"), { hidden: true });
+help.addEventListener("keydown", (e) => { if (e.key === "q") help.close(); });
+
+// ── which-key on the SPC leader ───────────────────────────────────────
+
+const KEYMAP = [
+ { key: "SPC", desc: "find file", cmd: "find-file" },
+ { key: ":", desc: "M-x", cmd: "execute-extended-command" },
+ { key: "a", desc: "about", cmd: "goto-about" },
+ { key: "p", desc: "projects", cmd: "goto-projects" },
+ { key: "n", desc: "posts", cmd: "goto-posts" },
+ { key: "m", desc: "mail me", cmd: "goto-contact" },
+ { key: "r", desc: "refill coffee", cmd: "coffee-refill" },
+ { key: "b", desc: "buy me a coffee", cmd: "buy-me-a-coffee" },
+ { key: "c", desc: "+computer", map: [
+ { key: "c", desc: "run or pause", cmd: "computer-run" },
+ { key: "n", desc: "step one cycle", cmd: "computer-step" },
+ { key: "r", desc: "reset", cmd: "computer-reset" },
+ { key: "l", desc: "load program", cmd: "computer-load-program" },
+ ] },
+ { key: "h", desc: "+help", map: [
+ { key: "t", desc: "load-theme", cmd: "load-theme" },
+ { key: "h", desc: "keys and commands", cmd: "help" },
+ ] },
+ { key: "s", desc: "+security", map: [
+ { key: "f", desc: "submit-flag", cmd: "submit-flag" },
+ { key: "g", desc: "copy GPG fingerprint", cmd: "copy-gpg-fingerprint" },
+ ] },
+ { key: "q", desc: "+quit", map: [
+ { key: "q", desc: "quit", cmd: "save-buffers-kill-terminal" },
+ { key: "r", desc: "restart", cmd: "restart-emacs" },
+ ] },
+];
+
+let leader = null; // { map, prefix }
+let wkTimer = 0;
+
+function startLeader() {
+ leader = { map: KEYMAP, prefix: "SPC" };
+ message("SPC-", "", 0);
+ clearTimeout(wkTimer);
+ wkTimer = setTimeout(showWhichKey, 300);
+}
+
+function showWhichKey() {
+ if (!leader) return;
+ const title = document.createElement("p");
+ title.className = "wk-title";
+ title.textContent = `${leader.prefix}-`;
+ wk.replaceChildren(title, ...leader.map.map((b) => {
+ const item = document.createElement("button");
+ item.type = "button";
+ item.className = "wk-item";
+ item.style.cssText = "border:0;background:none;padding:0;text-align:left;font:inherit;color:inherit";
+ item.innerHTML = `<span class="wk-key"></span><span class="wk-arrow">→</span><span class="wk-desc${b.map ? " group" : ""}"></span>`;
+ item.children[0].textContent = b.key;
+ item.children[2].textContent = b.desc;
+ item.addEventListener("mousedown", (e) => e.preventDefault());
+ item.addEventListener("click", () => press(b.key));
+ return item;
+ }));
+ wk.hidden = false;
+}
+
+function endLeader(text = null) {
+ leader = null;
+ clearTimeout(wkTimer);
+ wk.hidden = true;
+ if (text !== null) message(text, text.endsWith("undefined") ? "error" : "", 3000);
+ else message(idleText(), "", 0);
+}
+
+function press(key) {
+ const b = leader.map.find((x) => x.key === key);
+ if (!b) return endLeader(`${leader.prefix} ${key} is undefined`);
+ if (b.map) {
+ leader = { map: b.map, prefix: `${leader.prefix} ${key}` };
+ message(`${leader.prefix}-`, "", 0);
+ showWhichKey();
+ return;
+ }
+ endLeader();
+ call(b.cmd);
+}
+
+// ── keys ──────────────────────────────────────────────────────────────
+
+addEventListener("keydown", (e) => {
+ if (help.open) return;
+ if (leader) {
+ if (["Shift", "Control", "Alt", "Meta"].includes(e.key)) return;
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.key === "Escape" || (e.ctrlKey && e.key === "g")) endLeader("Quit");
+ else press(e.key === " " ? "SPC" : e.key);
+ return;
+ }
+ if (!mini.hidden || isTyping(e)) return;
+
+ if (e.altKey && e.code === "KeyX" && !e.ctrlKey && !e.metaKey) {
+ e.preventDefault();
+ mx();
+ } else if (e.ctrlKey && e.key === "g") {
+ e.preventDefault();
+ call("keyboard-quit");
+ } else if (e.metaKey || e.ctrlKey || e.altKey) {
+ // leave browser shortcuts alone
+ } else if (e.key === " " && !e.target.closest?.("button, a, select, summary, [tabindex]")) {
+ e.preventDefault();
+ startLeader();
+ } else if (e.key === ":") {
+ e.preventDefault();
+ ex();
+ } else if (e.key === "?") {
+ e.preventDefault();
+ call("help");
+ } else if (e.key === "Escape" && nav.classList.contains("is-open")) {
+ toggleMenu(false);
+ }
+}, true);
+
+// ── mobile menu ───────────────────────────────────────────────────────
+
+function toggleMenu(open = !nav.classList.contains("is-open")) {
+ nav.classList.toggle("is-open", open);
+ menuKey.setAttribute("aria-expanded", String(open));
+}
+menuKey.addEventListener("click", () => toggleMenu());
+nav.addEventListener("click", (e) => { if (e.target.closest("a")) toggleMenu(false); });
+document.addEventListener("click", (e) => {
+ if (nav.classList.contains("is-open") && !e.target.closest(".nav, .menu-key")) toggleMenu(false);
+});
+
+// ── copy buttons: <button data-copy="selector"> ───────────────────────
+
+document.addEventListener("click", async (e) => {
+ const b = e.target.closest("[data-copy]");
+ if (!b) return;
+ const src = document.querySelector(b.dataset.copy);
+ if (!src) return;
+ const label = b.textContent;
+ try {
+ await navigator.clipboard.writeText(src.textContent.trim());
+ b.textContent = "copied";
+ message(`Copied: ${src.textContent.trim()}`, "ok", 3000);
+ } catch {
+ const r = document.createRange();
+ r.selectNodeContents(src);
+ getSelection().removeAllRanges();
+ getSelection().addRange(r);
+ b.textContent = "selected";
+ }
+ setTimeout(() => { b.textContent = label; }, 2000);
+});
diff --git a/js/themes.js b/js/themes.js
@@ -0,0 +1,19 @@
+// Themes for M-x load-theme. The colours live in css/style.css under
+// :root[data-theme="…"]; keep this list, that CSS and the inline script in
+// index.html in sync.
+
+export const THEMES = [
+ { id: "doom-gruvbox", desc: "Gruvbox, medium contrast. The theme my own Emacs uses" },
+ { id: "doom-gruvbox-light", desc: "Gruvbox's light variant" },
+ { id: "doom-one", desc: "Doom Emacs's default, inspired by Atom One Dark" },
+ { id: "atom-one-dark", desc: "Atom's One Dark" },
+ { id: "modus-vivendi", desc: "Emacs's built-in high-contrast dark theme" },
+ { id: "modus-operandi", desc: "Emacs's built-in high-contrast light theme" },
+ { id: "doom-dracula", desc: "Dracula" },
+ { id: "doom-nord", desc: "Nord, arctic and north-bluish" },
+ { id: "catppuccin", desc: "Catppuccin, Mocha flavour" },
+ { id: "doom-tokyo-night", desc: "Tokyo Night" },
+ { id: "doom-solarized-light", desc: "Solarized light" },
+];
+
+export const DEFAULT_THEME = "doom-gruvbox";
diff --git a/js/uart.js b/js/uart.js
@@ -0,0 +1,146 @@
+// Draws the email address as a UART capture: 115200 baud, 8N1. Each byte is a
+// low start bit, eight data bits least-significant first, and a high stop bit,
+// with the decoded character above it the way a logic analyser shows it.
+
+import { $, $$, hex, reducedMotion } from "./util.js";
+import { message } from "./shell.js";
+
+const SVG = "http://www.w3.org/2000/svg";
+const BIT_US = 1e6 / 115200;
+const IDLE = 3;
+
+function el(name, attrs = {}, parent) {
+ const n = document.createElementNS(SVG, name);
+ for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
+ parent?.append(n);
+ return n;
+}
+
+export function initUart() {
+ const sec = $("#contact");
+ if (!sec) return;
+ const mail = $(".mail", sec);
+ const svg = $(".uart-svg", sec);
+ const scroller = $(".uart-scroll", sec);
+ const info = $(".uart-frame", sec);
+ // The page only shows "michael <at> michaelneuper.com"; the address is put
+ // together here so a scraper reading the HTML never sees it whole.
+ const address = `${mail.dataset.user}@${mail.dataset.host}`;
+ const arm = () => { mail.href = `mailto:${address}`; };
+ for (const ev of ["pointerenter", "focus", "touchstart"]) mail.addEventListener(ev, arm, { passive: true });
+ mail.addEventListener("click", arm);
+
+ const copy = $('[data-email="copy"]', sec);
+ copy?.addEventListener("click", async () => {
+ try {
+ await navigator.clipboard.writeText(address);
+ copy.textContent = "copied";
+ message(`Copied ${address}`, "ok", 3000);
+ } catch {
+ message(address, "", 8000);
+ copy.textContent = "shown below";
+ }
+ setTimeout(() => { copy.textContent = "copy address"; }, 2000);
+ });
+ const bytes = [...new TextEncoder().encode(address)];
+ const total = IDLE * 2 + bytes.length * 10;
+ let drawn = false;
+
+ function render() {
+ const avail = scroller.clientWidth;
+ const bw = Math.max(3.6, (avail - 24) / total);
+ const W = Math.round(bw * total + 24);
+ const H = 132;
+ const x0 = 12;
+ const hi = 62;
+ const lo = 96;
+ svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
+ svg.setAttribute("width", W);
+ svg.setAttribute("height", H);
+ svg.replaceChildren();
+
+ // frames: hover areas, decoder bubbles, labels
+ const framesG = el("g", {}, svg);
+ bytes.forEach((b, i) => {
+ const fx = x0 + (IDLE + i * 10) * bw;
+ const fw = 10 * bw;
+ const g = el("g", { class: "frame" }, framesG);
+ el("rect", { x: fx, y: 0, width: fw, height: H }, g);
+ el("rect", { class: "bubble", x: fx + 1.5, y: 10, width: fw - 3, height: fw > 34 ? 34 : 22, rx: 3 }, g);
+ const ch = String.fromCharCode(b);
+ const t = el("text", { class: "lbl", x: fx + fw / 2, y: 26, "text-anchor": "middle" }, g);
+ t.textContent = ch;
+ if (fw > 34) {
+ const s = el("text", { class: "sub", x: fx + fw / 2, y: 39, "text-anchor": "middle" }, g);
+ s.textContent = hex(b);
+ }
+ g.addEventListener("pointerenter", () => hot(g, i, b));
+ });
+
+ // bit-boundary ticks and a time axis every few frames
+ const ticks = el("g", {}, svg);
+ for (let k = 0; k <= total; k++) {
+ const x = Math.round(x0 + k * bw) + 0.5;
+ const frameEdge = k >= IDLE && (k - IDLE) % 10 === 0 && k <= total - IDLE;
+ el("line", { class: "tick", x1: x, x2: x, y1: lo + 6, y2: lo + (frameEdge ? 14 : 9) }, ticks);
+ }
+ const every = Math.max(1, Math.ceil(70 / (10 * bw)));
+ for (let i = 0; i <= bytes.length; i += every) {
+ const k = IDLE + i * 10;
+ const t = el("text", { class: "sub", x: x0 + k * bw, y: H - 6, "text-anchor": i ? "middle" : "start" }, svg);
+ t.textContent = `${Math.round(i * 10 * BIT_US)} µs`;
+ }
+
+ // the logic trace
+ const levels = [];
+ for (let k = 0; k < IDLE; k++) levels.push(1);
+ for (const b of bytes) {
+ levels.push(0);
+ for (let i = 0; i < 8; i++) levels.push((b >> i) & 1);
+ levels.push(1);
+ }
+ for (let k = 0; k < IDLE; k++) levels.push(1);
+ let d = `M${x0} ${hi}`;
+ let prev = 1;
+ levels.forEach((v, k) => {
+ if (v !== prev) d += `V${v ? hi : lo}`;
+ d += `H${x0 + (k + 1) * bw}`;
+ prev = v;
+ });
+ const path = el("path", { class: "trace", d }, svg);
+
+ if (!drawn && !reducedMotion.matches) {
+ const len = path.getTotalLength();
+ path.style.strokeDasharray = len;
+ path.style.strokeDashoffset = len;
+ framesG.style.opacity = 0;
+ const io = new IntersectionObserver(([e]) => {
+ if (!e.isIntersecting) return;
+ io.disconnect();
+ path.animate([{ strokeDashoffset: len }, { strokeDashoffset: 0 }], { duration: 1600, easing: "cubic-bezier(.3,0,.2,1)", fill: "forwards" })
+ .finished.then(() => { path.style.strokeDasharray = ""; path.style.strokeDashoffset = ""; }).catch(() => {});
+ framesG.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 500, delay: 1100, fill: "forwards" })
+ .finished.then(() => { framesG.style.opacity = ""; }).catch(() => {});
+ }, { threshold: 0.6 });
+ io.observe(scroller);
+ }
+ drawn = true;
+ }
+
+ function hot(g, i, b) {
+ $$(".frame.is-hot", svg).forEach((f) => f.classList.remove("is-hot"));
+ g.classList.add("is-hot");
+ const bits = Array.from({ length: 8 }, (_, k) => (b >> k) & 1).join("");
+ info.textContent = `frame ${String(i + 1).padStart(2, "0")} of ${bytes.length} 0x${hex(b)} '${String.fromCharCode(b)}' start 0 data ${bits.slice(0, 4)} ${bits.slice(4)} (LSB first) stop 1`;
+ }
+ svg.addEventListener("pointerleave", () => $$(".frame.is-hot", svg).forEach((f) => f.classList.remove("is-hot")));
+
+ render();
+ let w = scroller.clientWidth;
+ new ResizeObserver(() => {
+ if (scroller.clientWidth !== w) {
+ w = scroller.clientWidth;
+ render();
+ }
+ }).observe(scroller);
+}
diff --git a/js/util.js b/js/util.js
@@ -0,0 +1,90 @@
+// Small shared helpers: DOM, maths, theme colours, one animation loop, notes.
+
+export const $ = (sel, root = document) => root.querySelector(sel);
+export const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
+
+export const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
+export const lerp = (a, b, t) => a + (b - a) * t;
+export const ease = (t) => 1 - Math.pow(1 - clamp(t, 0, 1), 3);
+
+export const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
+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));
+
+// ── theme colours, for canvases ───────────────────────────────────────
+let colors = null;
+export function themeColors() {
+ if (colors) return colors;
+ const cs = getComputedStyle(document.documentElement);
+ const v = (n) => cs.getPropertyValue(n).trim();
+ colors = {
+ bg: v("--bg"), fg: v("--fg"), dim: v("--dim"), faint: v("--faint"), rule: v("--rule"), hl: v("--hl"),
+ red: v("--red"), orange: v("--orange"), yellow: v("--yellow"), green: v("--green"),
+ cyan: v("--cyan"), blue: v("--blue"), magenta: v("--magenta"),
+ light: v("--glow") === "0",
+ };
+ return colors;
+}
+on("theme", () => { colors = null; });
+
+// ── one requestAnimationFrame loop for everything ─────────────────────
+const subs = new Set();
+let raf = 0;
+let last = 0;
+function frame(now) {
+ const t = now / 1000;
+ const dt = last ? Math.min(0.1, t - last) : 1 / 60;
+ last = t;
+ for (const fn of subs) fn(t, dt);
+ raf = subs.size ? requestAnimationFrame(frame) : 0;
+ if (!raf) last = 0;
+}
+export function onFrame(fn) {
+ subs.add(fn);
+ if (!raf) raf = requestAnimationFrame(frame);
+ return () => subs.delete(fn);
+}
+
+// Run fn every frame only while el is on screen.
+export function frameWhileVisible(el, fn, margin = "80px") {
+ let off = null;
+ const io = new IntersectionObserver(([e]) => {
+ if (e.isIntersecting && !off) off = onFrame(fn);
+ else if (!e.isIntersecting && off) { off(); off = null; }
+ }, { rootMargin: margin });
+ io.observe(el);
+}
+
+// Size a canvas to its CSS box at device resolution. Returns true if it changed.
+export function fitCanvas(canvas, maxDpr = 2) {
+ const dpr = Math.min(maxDpr, window.devicePixelRatio || 1);
+ const w = canvas.clientWidth;
+ const h = canvas.clientHeight;
+ const pw = Math.round(w * dpr);
+ const ph = Math.round(h * dpr);
+ if (canvas.width === pw && canvas.height === ph) return false;
+ canvas.width = pw;
+ canvas.height = ph;
+ canvas.getContext("2d").setTransform(dpr, 0, 0, dpr, 0, 0);
+ return true;
+}
+
+export async function fontsReady() {
+ try {
+ await Promise.race([
+ Promise.all([document.fonts.load('12px "JetBrains Mono"'), document.fonts.load('40px "Computer Modern"')]),
+ new Promise((r) => setTimeout(r, 2500)),
+ ]);
+ } catch { /* fall back to whatever is there */ }
+}
+
+export function isTyping(e) {
+ const t = e.target;
+ return t instanceof HTMLElement && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) && t.type !== "range");
+}
+
+export const hex = (n, w = 2) => n.toString(16).toUpperCase().padStart(w, "0");
diff --git a/notes-to-self.txt b/notes-to-self.txt
@@ -0,0 +1,6 @@
+Note to self: robots.txt asks well-behaved crawlers to stay away from a path.
+It does nothing to stop anyone else from reading it.
+
+flag{robots_txt_is_not_access_control}
+
+Submit it with M-x submit-flag.
diff --git a/robots.txt b/robots.txt
@@ -0,0 +1,3 @@
+User-agent: *
+Allow: /
+Disallow: /notes-to-self.txt