website

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

commit ff8e7f5c4e7ade715d7d92a1d1c9ba73b7a4a3c4
Author: Michael Neuper <michael@michaelneuper.com>
Date:   Sun, 11 Oct 2026 21:45:01 +0200

initial commit

Diffstat:
Acss/style.css | 2256+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afavicon.svg | 12++++++++++++
Afonts/cm-italic.woff2 | 0
Afonts/cm-regular.woff2 | 0
Afonts/jetbrains-mono-regular.woff2 | 0
Afonts/jetbrains-mono-semibold.woff2 | 0
Aindex.html | 351+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ajs/coffee.js | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ajs/computer.js | 548+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ajs/ctf.js | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ajs/datasheet.js | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ajs/demos/bytecode.js | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ajs/demos/scheduler.js | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ajs/main.js | 24++++++++++++++++++++++++
Ajs/memmap.js | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ajs/org.js | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ajs/shell.js | 511+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ajs/themes.js | 19+++++++++++++++++++
Ajs/uart.js | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ajs/util.js | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anotes-to-self.txt | 6++++++
Arobots.txt | 3+++
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 &amp; 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"> &lt;at&gt; </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">&lt;N&gt;</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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); + +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}&ensp;<b>${d.label}</b>&ensp;${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">&lt;${p.date} ${DAYS[d.getUTCDay()]}&gt;</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