From 649ac3c0d6cfbdb0c8e980b1b5db0aa4a64e00bc Mon Sep 17 00:00:00 2001 From: James Jiang Date: Tue, 28 Jul 2026 00:30:03 +1000 Subject: [PATCH 01/11] feat: floating console with figlet heading Dependency-free terminal widget on the homepage. Launcher button opens a draggable floating window (~80x24), dockable to the bottom quarter. Commands: theme, history, clear, close, help. On open it shows the site title as a Bulbhead FIGfont banner (parser + font embedded, no npm dep). Progressive enhancement: launcher and window stay hidden without JS. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EBF9sk8L2MdbSC1Pbz9tkH --- assets/css/main.css | 106 +++++++++ assets/figlet/heading.flf | 434 ++++++++++++++++++++++++++++++++++ assets/js/console.js | 216 +++++++++++++++++ layouts/index.html | 1 + layouts/partials/console.html | 25 ++ 5 files changed, 782 insertions(+) create mode 100644 assets/figlet/heading.flf create mode 100644 assets/js/console.js create mode 100644 layouts/partials/console.html diff --git a/assets/css/main.css b/assets/css/main.css index 162e5e6..66850ec 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -137,6 +137,112 @@ main, .page-head h1 { margin: 0 0 0.5rem; font-size: 1.6rem; letter-spacing: -0.01em; } .page-intro { color: var(--muted); max-width: 46ch; } +/* ---- console (floating terminal) ---- */ +.console-launch { + position: fixed; + right: 1rem; + bottom: 1rem; + z-index: 50; + font-family: var(--mono); + font-size: 0.8rem; + padding: 0.5em 0.9em; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 999px; + cursor: pointer; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12); +} +.console-launch:hover, +.console-launch:focus-visible { color: var(--accent); border-color: var(--accent); } + +.console { + position: fixed; + right: 1rem; + bottom: 1rem; + z-index: 51; + /* roughly an 80x24 terminal */ + width: min(84ch, calc(100vw - 2rem)); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 10px 34px rgba(0, 0, 0, 0.2); + overflow: hidden; + font-family: var(--mono); + font-size: 0.82rem; + display: flex; + flex-direction: column; + height: min(90vh, 36rem); +} +/* the display:flex above beats the UA [hidden] rule, so hide explicitly */ +.console[hidden] { display: none; } +/* docked: pinned to the bottom quarter of the viewport */ +.console--docked { + inset: auto 0 0 0; + width: 100%; + height: 25vh; + max-height: 25vh; + border-radius: 0; + border-inline: 0; + border-bottom: 0; +} +.console__bar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); + cursor: move; + user-select: none; + touch-action: none; +} +.console__actions { margin-left: auto; display: flex; gap: 0.15rem; } +.console__btn { + background: none; + border: 0; + padding: 0 0.25em; + color: var(--muted); + font: inherit; + font-size: 1rem; + line-height: 1; + cursor: pointer; +} +.console__btn:hover, +.console__btn:focus-visible { color: var(--accent); } +.console__title { color: var(--muted); font-size: 0.75rem; } +.console__screen { + padding: 0.75rem; + cursor: text; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} +.console__log { margin: 0; flex: 1; min-height: 0; overflow: auto; } +.console__line { display: block; white-space: pre-wrap; color: var(--fg); } +.console__line--cmd { color: var(--muted); } +.console__banner-art { + margin: 0 0 0.5rem; + white-space: pre; + line-height: 1.02; + color: var(--accent); + overflow-x: auto; +} +a.console__line:hover { color: var(--accent); } +.console__form { display: flex; align-items: baseline; gap: 0.5em; margin-top: 0.25rem; } +.console__prompt { color: var(--accent); } +.console__input { + flex: 1; + min-width: 0; + background: none; + border: 0; + padding: 0; + color: inherit; + font: inherit; + caret-color: var(--accent); +} +.console__input:focus { outline: none; } + /* ---- topics (taxonomy list) ---- */ .topics { display: flex; diff --git a/assets/figlet/heading.flf b/assets/figlet/heading.flf new file mode 100644 index 0000000..7a446f5 --- /dev/null +++ b/assets/figlet/heading.flf @@ -0,0 +1,434 @@ +flf2a$ 4 4 99 0 20 +Bulbhead by Jef Poskanzer, 23jun94 +Figlet release 2.0 -- August 5, 1993 + ____ __ __ __ ____ _ _ ____ __ ____ +( _ \( )( )( ) ( _ \( )_( )( ___) /__\ ( _ \ + ) _ < )(__)( )(__ ) _ < ) _ ( )___) /(__)\ )(_) ) +(____/(______)(____)(____/(_) (_)(____)(__)(__)(____/ + +Update February 12, 2002 by Markus Gebhard markus@jave.de + Added german umlauts + +Explanation of first line: +flf2 - "magic number" for file identification +a - should always be `a', for now +$ - the "hardblank" -- prints as a blank, but can't be smushed +4 - height of a character +4 - height of a character, not including descenders +99 - max line length (excluding comment lines) + a fudge factor +0 - default smushmode for this font (like "-m 0" on command line) +13 - number of comment lines + +$$@ +$$@ +$$@ +$$@@ +/\@ +)(@ +\/@ +()@@ +||@ + @ + @ + @@ + | | @ +-|-|-@ +-|-|-@ + | | @@ + _|_ @ +/ |_)@ +\_| \@ +(_|_/@@ + _ _ @ +(_)/ ) @ + / /_ @ + (_/(_)@@ + _ @ + ( ) @ + /_\/@ +(__/\@@ +/@ + @ + @ + @@ + _ @ + / )@ +( ( @ + \_)@@ + _ @ +( \ @ + ) )@ +(_/ @@ + @ +\|/@ +/|\@ + @@ + _ @ + _| |_ @ +(_ _)@ + |_| @@ + @ + @ +()@ +/ @@ + @ + ___ @ +(___)@ + @@ + @ + @ + @ +()@@ + _ @ + / )@ + / / @ +(_/ @@ + ___ @ + / _ \ @ +( (_) )@ + \___/ @@ + __ @ + / )@ + )( @ + (__)@@ + ___ @ +(__ \ @ + / _/ @ +(____)@@ + ___ @ +(__ )@ + (_ \@ +(___/@@ + __ @ + /. | @ +(_ _)@ + (_) @@ + ___ @ +| __)@ +|__ \@ +(___/@@ + _ @ + / ) @ +/ _ \@ +\___/@@ + ___ @ +(__ )@ + / / @ +(_/ @@ + ___ @ +( _ )@ +/ _ \@ +\___/@@ + ___ @ +/ _ \@ +\_ /@ + (_/ @@ + @ +()@ + @ +()@@ +()@ + @ +()@ +/ @@ + __@ + / /@ +< < @ + \_\@@ + ___ @ +(___)@ + ___ @ +(___)@@ +__ @ +\ \ @ + > >@ +/_/ @@ + ___ @ +(__ )@ + (_/ @ + (_) @@ + __ @ + / \@ +| ()/@ + \__ @@ + __ @ + /__\ @ + /(__)\ @ +(__)(__)@@ + ____ @ +( _ \@ + ) _ <@ +(____/@@ + ___ @ + / __)@ +( (__ @ + \___)@@ + ____ @ +( _ \ @ + )(_) )@ +(____/ @@ + ____ @ +( ___)@ + )__) @ +(____)@@ + ____ @ +( ___)@ + )__) @ +(__) @@ + ___ @ + / __)@ +( (_-.@ + \___/@@ + _ _ @ +( )_( )@ + ) _ ( @ +(_) (_)@@ + ____ @ +(_ _)@ + _)(_ @ +(____)@@ + ____ @ + (_ _)@ +.-_)( @ +\____) @@ + _ _ @ +( )/ )@ + ) ( @ +(_)\_)@@ + __ @ +( ) @ + )(__ @ +(____)@@ + __ __ @ +( \/ )@ + ) ( @ +(_/\/\_)@@ + _ _ @ +( \( )@ + ) ( @ +(_)\_)@@ + _____ @ +( _ )@ + )(_)( @ +(_____)@@ + ____ @ +( _ \@ + )___/@ +(__) @@ + _____ @ +( _ )@ + )(_)( @ +(___/\\@@ + ____ @ +( _ \@ + ) /@ +(_)\_)@@ + ___ @ +/ __)@ +\__ \@ +(___/@@ + ____ @ +(_ _)@ + )( @ + (__) @@ + __ __ @ +( )( )@ + )(__)( @ +(______)@@ + _ _ @ +( \/ )@ + \ / @ + \/ @@ + _ _ @ +( \/\/ )@ + ) ( @ +(__/\__)@@ + _ _ @ +( \/ )@ + ) ( @ +(_/\_)@@ + _ _ @ +( \/ )@ + \ / @ + (__) @@ + ____ @ +(_ )@ + / /_ @ +(____)@@ + __@ +| @ +| @ +|__@@ + _ @ +( \ @ + \ \ @ + \_)@@ +__ @ + |@ + |@ +__|@@ + / \ @ +(_^_)@ + @ + @@ + @ + @ + ___ @ +(___)@@ +\@ + @ + @ + @@ + __ @ + /__\ @ + /(__)\ @ +(__)(__)@@ + ____ @ +( _ \@ + ) _ <@ +(____/@@ + ___ @ + / __)@ +( (__ @ + \___)@@ + ____ @ +( _ \ @ + )(_) )@ +(____/ @@ + ____ @ +( ___)@ + )__) @ +(____)@@ + ____ @ +( ___)@ + )__) @ +(__) @@ + ___ @ + / __)@ +( (_-.@ + \___/@@ + _ _ @ +( )_( )@ + ) _ ( @ +(_) (_)@@ + ____ @ +(_ _)@ + _)(_ @ +(____)@@ + ____ @ + (_ _)@ +.-_)( @ +\____) @@ + _ _ @ +( )/ )@ + ) ( @ +(_)\_)@@ + __ @ +( ) @ + )(__ @ +(____)@@ + __ __ @ +( \/ )@ + ) ( @ +(_/\/\_)@@ + _ _ @ +( \( )@ + ) ( @ +(_)\_)@@ + _____ @ +( _ )@ + )(_)( @ +(_____)@@ + ____ @ +( _ \@ + )___/@ +(__) @@ + _____ @ +( _ )@ + )(_)( @ +(___/\\@@ + ____ @ +( _ \@ + ) /@ +(_)\_)@@ + ___ @ +/ __)@ +\__ \@ +(___/@@ + ____ @ +(_ _)@ + )( @ + (__) @@ + __ __ @ +( )( )@ + )(__)( @ +(______)@@ + _ _ @ +( \/ )@ + \ / @ + \/ @@ + _ _ @ +( \/\/ )@ + ) ( @ +(__/\__)@@ + _ _ @ +( \/ )@ + ) ( @ +(_/\_)@@ + _ _ @ +( \/ )@ + \ / @ + (__) @@ + ____ @ +(_ )@ + / /_ @ +(____)@@ + ,-@ +_| @ + | @ + `-@@ +/\@ +||@ +||@ +\/@@ +-. @ + |_@ + | @ +-' @@ + @ +/\/@ + @ + @@ + (_)(_) @ + /__\ @ + /(__)\ @ +(__)(__)@@ +(_)_(_)@ +( _ )@ + )(_)( @ +(_____)@@ + (_)(_) @ +( )( )@ + )(__)( @ +(______)@@ + (_)(_) @ + /__\ @ + /(__)\ @ +(__)(__)@@ +(_)_(_)@ +( _ )@ + )(_)( @ +(_____)@@ + (_)(_) @ +( )( )@ + )(__)( @ +(______)@@ + ___ @ +/ _ )@ +| _ \@ +| __/@@ +160 NO-BREAK SPACE + $@ + $@ + $@ + $@@ diff --git a/assets/js/console.js b/assets/js/console.js new file mode 100644 index 0000000..6a4e22c --- /dev/null +++ b/assets/js/console.js @@ -0,0 +1,216 @@ +/* basic — a small floating terminal. No deps. Hidden until the launcher opens it; + the page works fine without it. */ +(function () { + "use strict"; + + // ---- pure core (side-effect-free so `node console.js` can self-check) ---- + + function parse(input) { + var parts = String(input).trim().split(/\s+/); + return { name: (parts[0] || "").toLowerCase(), args: parts.slice(1) }; + } + + // Minimal FIGfont parser + renderer (full-width, no smushing). No deps. + function parseFig(txt) { + var lines = String(txt).split("\n"); + var head = lines[0].split(" "); + var hard = head[0].slice(-1); // hardblank char (last of signature) + var height = parseInt(head[1], 10); + var comment = parseInt(head[5], 10); + if (!(height > 0)) return null; + var map = {}, idx = 1 + comment; + for (var c = 32; c <= 126; c++) { + var glyph = []; + for (var r = 0; r < height; r++) { + var line = (lines[idx++] || "").replace(/\r$/, ""); + var end = line.slice(-1); + if (end) line = line.replace(new RegExp(end.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "+$"), ""); + glyph.push(line.split(hard).join(" ")); + } + map[c] = glyph; + } + return { height: height, map: map }; + } + + function banner(text, fig) { + if (!fig) return [String(text)]; + var rows = []; + for (var r = 0; r < fig.height; r++) rows.push(""); + var s = String(text); + for (var i = 0; i < s.length; i++) { + var g = fig.map[s.charCodeAt(i)] || fig.map[32]; + for (var r2 = 0; r2 < fig.height; r2++) rows[r2] += g[r2] || ""; + } + // drop fully-blank leading/trailing rows some fonts pad with + while (rows.length && !rows[0].trim()) rows.shift(); + while (rows.length && !rows[rows.length - 1].trim()) rows.pop(); + return rows; + } + + // run(input, ctx) -> { lines:[{text}], clear?, close?, theme? }. ctx.history: string[] + function run(input, ctx) { + var cmd = parse(input); + var history = (ctx && ctx.history) || []; + + switch (cmd.name) { + case "": + return { lines: [] }; + + case "help": + return { lines: [ + { text: "commands" }, + { text: " theme light | dark | auto" }, + { text: " history show command history" }, + { text: " clear clear the screen" }, + { text: " close close the console" }, + { text: " help this message" } + ] }; + + case "theme": { + var mode = (cmd.args[0] || "").toLowerCase(); + if (["light", "dark", "auto"].indexOf(mode) === -1) { + return { lines: [{ text: "theme: use light | dark | auto." }] }; + } + return { lines: [{ text: "theme set to " + mode + "." }], theme: mode }; + } + + case "history": + if (!history.length) return { lines: [{ text: "no history yet." }] }; + return { lines: history.map(function (h, i) { + return { text: (i + 1 < 10 ? " " : "") + (i + 1) + " " + h }; + }) }; + + case "clear": + return { lines: [], clear: true }; + + case "close": + return { lines: [{ text: "bye." }], close: true }; + + default: + return { lines: [{ text: cmd.name + ": command not found. Type `help`." }] }; + } + } + + // ---- browser wiring ---- + + if (typeof document !== "undefined") { + var launch = document.querySelector(".console-launch"); + var root = document.querySelector(".console"); + if (launch && root) { + var bar = root.querySelector(".console__bar"); + var log = root.querySelector(".console__log"); + var form = root.querySelector(".console__form"); + var input = root.querySelector(".console__input"); + var expandBtn = root.querySelector(".console__expand"); + var closeBtn = root.querySelector(".console__close"); + var history = []; + + var write = function (line, kind) { + var el = document.createElement("div"); + el.className = "console__line" + (kind ? " console__line--" + kind : ""); + el.textContent = line.text; + log.appendChild(el); + }; + + var applyTheme = function (mode) { + if (mode === "auto") { localStorage.removeItem("theme"); delete document.documentElement.dataset.theme; } + else { localStorage.setItem("theme", mode); document.documentElement.dataset.theme = mode; } + }; + + var open = function () { + root.hidden = false; + launch.setAttribute("aria-expanded", "true"); + input.focus(); + }; + var close = function () { + root.hidden = true; + root.classList.remove("console--docked"); + root.style.left = root.style.top = root.style.right = root.style.bottom = ""; + expandBtn.setAttribute("aria-pressed", "false"); + launch.setAttribute("aria-expanded", "false"); + launch.focus(); + }; + var toggleDock = function () { + var docked = root.classList.toggle("console--docked"); + if (docked) { root.style.left = root.style.top = root.style.right = root.style.bottom = ""; } + expandBtn.setAttribute("aria-pressed", docked ? "true" : "false"); + input.focus(); + }; + + launch.addEventListener("click", open); + closeBtn.addEventListener("click", close); + expandBtn.addEventListener("click", toggleDock); + + document.addEventListener("keydown", function (e) { + if (e.key === "Escape" && !root.hidden) close(); + }); + + form.addEventListener("submit", function (e) { + e.preventDefault(); + var value = input.value; + write({ text: "$ " + value }, "cmd"); + if (value.trim()) history.push(value.trim()); + var res = run(value, { history: history }); + if (res.clear) log.innerHTML = ""; + res.lines.forEach(function (l) { write(l); }); + if (res.theme) applyTheme(res.theme); + input.value = ""; + log.scrollTop = log.scrollHeight; + if (res.close) close(); + }); + + root.querySelector(".console__screen").addEventListener("click", function () { input.focus(); }); + + // Drag by the title bar (skipped while docked). + bar.addEventListener("pointerdown", function (e) { + if (e.target.closest(".console__btn") || root.classList.contains("console--docked")) return; + var rect = root.getBoundingClientRect(); + var dx = e.clientX - rect.left, dy = e.clientY - rect.top; + var move = function (ev) { + var x = Math.max(0, Math.min(window.innerWidth - rect.width, ev.clientX - dx)); + var y = Math.max(0, Math.min(window.innerHeight - rect.height, ev.clientY - dy)); + root.style.left = x + "px"; root.style.top = y + "px"; + root.style.right = root.style.bottom = "auto"; + }; + var up = function () { + document.removeEventListener("pointermove", move); + document.removeEventListener("pointerup", up); + }; + document.addEventListener("pointermove", move); + document.addEventListener("pointerup", up); + }); + + var fig = null; + try { fig = parseFig(atob(root.dataset.font || "")); } catch (e) { fig = null; } + var art = document.createElement("pre"); + art.className = "console__banner-art"; + art.textContent = banner(root.dataset.title || "", fig).join("\n"); + log.appendChild(art); + write({ text: "\n" }); + write({ text: "type `help` to see available commands" }); + launch.hidden = false; + } + } + + // ---- self-check: `node assets/js/console.js` ---- + + if (typeof module !== "undefined" && require.main === module) { + var assert = require("assert"); + assert.strictEqual(run("", {}).lines.length, 0); + assert.ok(/theme /.test(run("help", {}).lines[1].text)); + assert.strictEqual(run("theme dark", {}).theme, "dark"); + assert.ok(/use light/.test(run("theme purple", {}).lines[0].text)); + assert.strictEqual(run("clear", {}).clear, true); + assert.strictEqual(run("close", {}).close, true); + assert.ok(/no history/.test(run("history", { history: [] }).lines[0].text)); + assert.ok(/theme dark/.test(run("history", { history: ["theme dark"] }).lines[0].text)); + assert.ok(/not found/.test(run("wat", {}).lines[0].text)); + assert.deepStrictEqual(banner("x", null), ["x"], "no font -> plain title"); + var flf = require("fs").readFileSync(__dirname + "/../figlet/heading.flf", "utf8"); + var fig = parseFig(flf); + assert.ok(fig && fig.height > 0, "figfont parses"); + var b = banner("basic", fig); + assert.ok(b.length > 0 && b.some(function (r) { return /\S/.test(r); }), "banner renders glyphs"); + console.log("console.js self-check passed\n" + b.join("\n")); + } +})(); diff --git a/layouts/index.html b/layouts/index.html index df23419..65160bb 100644 --- a/layouts/index.html +++ b/layouts/index.html @@ -2,6 +2,7 @@ {{/* No h1 — the site title in the header already carries it. Keep the intro only. */}} {{ $intro := or .Content site.Params.description }} {{ with $intro }}
{{ . }}
{{ end }} + {{ partial "console.html" . }} {{ partial "topics.html" . }} {{ partial "post-list.html" (where site.RegularPages "Type" "posts") }} {{ end }} diff --git a/layouts/partials/console.html b/layouts/partials/console.html new file mode 100644 index 0000000..1268bac --- /dev/null +++ b/layouts/partials/console.html @@ -0,0 +1,25 @@ +{{/* Floating terminal. Launcher + window both stay hidden until JS enhances them, + so the page is unaffected without JS. */}} + + +{{ $font := resources.Get "figlet/heading.flf" }} + + +{{ $js := resources.Get "js/console.js" | resources.Minify | resources.Fingerprint }} + From fc606cb418baa99273bc2b9ca29a70d52b0f6ef1 Mon Sep 17 00:00:00 2001 From: James Jiang Date: Tue, 28 Jul 2026 08:07:09 +1000 Subject: [PATCH 02/11] feat: music command with 8-bit soundtrack, subcommand-based console theme + music now use the subcommand pattern (bare cmd prints usage block). music synthesises a square-wave chiptune via WebAudio, with play/stop/volume. Add CLAUDE.md documenting the theme and console command style. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 54 ++++++++++++++++++++ assets/js/console.js | 116 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 158 insertions(+), 12 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..33ba735 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# basic + +Minimal black-and-white Hugo theme for text-first blogs. Light/dark/auto, no JS +dependencies. The theme itself lives at repo root; `exampleSite/` is the demo +site used for local dev. + +## Commands + +- `pnpm dev` — hugo server on `exampleSite/` with drafts, live reload +- `pnpm build` — minified production build of `exampleSite/` +- `node assets/js/console.js` — run the console self-check (asserts at bottom of file) + +Hugo min version 0.116.0. Assets go through Hugo Pipes (Minify + Fingerprint), +see `layouts/partials/console.html`. + +## Layout + +- `layouts/` — templates (`_default/`, `partials/`). `baseof.html` is the shell. +- `assets/css/main.css` — the whole stylesheet. One `--accent` var tints hovers. +- `assets/js/console.js` — the floating terminal (below). +- `assets/figlet/heading.flf` — figfont for the console banner. + +## Console + +Floating terminal, progressively enhanced — page works with JS off (launcher + +window start `hidden`, JS unhides). All logic is in one file, `assets/js/console.js`: + +- `run(input, ctx)` is a **pure** function returning `{ lines, clear?, close?, theme?, music?, volume? }`. + Keep it side-effect-free so the `node console.js` self-check can exercise it. +- Browser wiring at the bottom reads those signals (applies theme, plays music, etc). +- 8-bit soundtrack is synthesised live with WebAudio — no audio files. + +### Command style — all commands MUST follow this + +Multi-action commands are **subcommand-based**. Bare command prints a usage +block; a bad subcommand/arg prints a one-line `usage:` hint. Pattern (see +`theme` and `music`): + +``` +Usage: ... + + + +Subcommands: + + : + : +``` + +- Align the `:` in subcommand and `help` lines. +- Bad arg → single line: `usage: `. +- Add every new command to the `help` list (aligned `name : description`). +- Add an `assert` to the self-check block for each new branch, then run + `node assets/js/console.js`. diff --git a/assets/js/console.js b/assets/js/console.js index 6a4e22c..15fcd80 100644 --- a/assets/js/console.js +++ b/assets/js/console.js @@ -59,19 +59,35 @@ case "help": return { lines: [ { text: "commands" }, - { text: " theme light | dark | auto" }, - { text: " history show command history" }, - { text: " clear clear the screen" }, - { text: " close close the console" }, - { text: " help this message" } + { text: " theme : changes the theme of the site" }, + { text: " music : plays an 8-bit soundtrack" }, + { text: " history : shows command history" }, + { text: " clear : clears the screen" }, + { text: " close : closes the console" }, + { text: " help : this message" } ] }; case "theme": { - var mode = (cmd.args[0] || "").toLowerCase(); - if (["light", "dark", "auto"].indexOf(mode) === -1) { - return { lines: [{ text: "theme: use light | dark | auto." }] }; + var sub = (cmd.args[0] || "").toLowerCase(); + var modes = ["light", "dark", "auto"]; + if (sub === "list") { + return { lines: modes.map(function (m) { return { text: " " + m }; }) }; } - return { lines: [{ text: "theme set to " + mode + "." }], theme: mode }; + if (sub === "set") { + var mode = (cmd.args[1] || "").toLowerCase(); + if (modes.indexOf(mode) === -1) return { lines: [{ text: "usage: theme set " }] }; + return { lines: [{ text: "theme set to " + mode + "." }], theme: mode }; + } + return { lines: [ + { text: "Usage: theme ..." }, + { text: "" }, + { text: " Changes the theme of the site" }, + { text: "" }, + { text: "Subcommands:" }, + { text: "" }, + { text: " list : List available themes" }, + { text: " set : Set the theme" } + ] }; } case "history": @@ -80,6 +96,28 @@ return { text: (i + 1 < 10 ? " " : "") + (i + 1) + " " + h }; }) }; + case "music": { + var sub = (cmd.args[0] || "").toLowerCase(); + if (sub === "play") return { lines: [], music: "on" }; + if (sub === "stop") return { lines: [], music: "off" }; + if (sub === "volume") { + var v = parseInt(cmd.args[1], 10); + if (!(v >= 0 && v <= 10)) return { lines: [{ text: "usage: music volume <0-10>" }] }; + return { lines: [{ text: "volume set to " + v + "." }], volume: v }; + } + return { lines: [ + { text: "Usage: music ..." }, + { text: "" }, + { text: " Play some tunes" }, + { text: "" }, + { text: "Subcommands:" }, + { text: "" }, + { text: " play : Play music" }, + { text: " stop : Stop music" }, + { text: " volume : Set music volume [0-10]" } + ] }; + } + case "clear": return { lines: [], clear: true }; @@ -112,6 +150,49 @@ log.appendChild(el); }; + // ---- 8-bit soundtrack: square-wave lead + bass, WebAudio, no assets ---- + // A-minor chiptune loop, 32 eighth-notes. midi note, 0 = rest. + var chiptune = (function () { + var lead = [69, 0, 72, 76, 74, 0, 72, 69, 71, 0, 74, 76, 77, 76, 74, 72, + 69, 0, 72, 76, 81, 0, 79, 77, 76, 0, 72, 74, 76, 72, 69, 0]; + var bass = [45, 45, 52, 52, 41, 41, 48, 48, 43, 43, 50, 50, 40, 40, 47, 47, + 45, 45, 52, 52, 41, 41, 48, 48, 45, 45, 43, 43, 40, 40, 47, 47]; + var spn = 60 / 132 / 2; // seconds per eighth-note at 132bpm + var ctx, master, timer, nextTime, step, playing = false, vol = 0.5; + var freq = function (m) { return 440 * Math.pow(2, (m - 69) / 12); }; + var voice = function (m, t, dur, type, vol) { + if (!m) return; + var o = ctx.createOscillator(), g = ctx.createGain(); + o.type = type; o.frequency.value = freq(m); + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(vol, t + 0.008); + g.gain.exponentialRampToValueAtTime(0.0001, t + dur * 0.9); + o.connect(g); g.connect(master); o.start(t); o.stop(t + dur); + }; + var tick = function () { + while (nextTime < ctx.currentTime + 0.12) { + voice(lead[step], nextTime, spn, "square", 0.18); + voice(bass[step], nextTime, spn, "square", 0.10); + step = (step + 1) % lead.length; + nextTime += spn; + } + }; + return { + on: function () { + if (playing) return; + if (!ctx) { + ctx = new (window.AudioContext || window.webkitAudioContext)(); + master = ctx.createGain(); master.gain.value = vol; master.connect(ctx.destination); + } + ctx.resume(); + playing = true; step = 0; nextTime = ctx.currentTime + 0.05; + tick(); timer = setInterval(tick, 25); + }, + off: function () { playing = false; clearInterval(timer); }, + setVolume: function (n) { vol = n / 10; if (master) master.gain.value = vol; } + }; + })(); + var applyTheme = function (mode) { if (mode === "auto") { localStorage.removeItem("theme"); delete document.documentElement.dataset.theme; } else { localStorage.setItem("theme", mode); document.documentElement.dataset.theme = mode; } @@ -123,6 +204,7 @@ input.focus(); }; var close = function () { + chiptune.off(); root.hidden = true; root.classList.remove("console--docked"); root.style.left = root.style.top = root.style.right = root.style.bottom = ""; @@ -154,6 +236,9 @@ if (res.clear) log.innerHTML = ""; res.lines.forEach(function (l) { write(l); }); if (res.theme) applyTheme(res.theme); + if (res.music === "on") { chiptune.on(); write({ text: "♪ music on" }); } + if (res.music === "off") { chiptune.off(); write({ text: "music off" }); } + if (res.volume != null) chiptune.setVolume(res.volume); input.value = ""; log.scrollTop = log.scrollHeight; if (res.close) close(); @@ -197,9 +282,16 @@ if (typeof module !== "undefined" && require.main === module) { var assert = require("assert"); assert.strictEqual(run("", {}).lines.length, 0); - assert.ok(/theme /.test(run("help", {}).lines[1].text)); - assert.strictEqual(run("theme dark", {}).theme, "dark"); - assert.ok(/use light/.test(run("theme purple", {}).lines[0].text)); + assert.ok(/theme/.test(run("help", {}).lines[1].text)); + assert.strictEqual(run("theme set dark", {}).theme, "dark"); + assert.ok(/usage: theme set/.test(run("theme set purple", {}).lines[0].text)); + assert.ok(/dark/.test(run("theme list", {}).lines[1].text)); + assert.ok(/Subcommands/.test(run("theme", {}).lines[4].text)); + assert.strictEqual(run("music play", {}).music, "on"); + assert.strictEqual(run("music stop", {}).music, "off"); + assert.strictEqual(run("music volume 7", {}).volume, 7); + assert.ok(/0-10/.test(run("music volume 99", {}).lines[0].text)); + assert.ok(/Subcommands/.test(run("music", {}).lines[4].text)); assert.strictEqual(run("clear", {}).clear, true); assert.strictEqual(run("close", {}).close, true); assert.ok(/no history/.test(run("history", { history: [] }).lines[0].text)); From 55efdd6efc179aa66093b84ea71fedc33a8485bf Mon Sep 17 00:00:00 2001 From: James Jiang Date: Tue, 28 Jul 2026 08:09:20 +1000 Subject: [PATCH 03/11] feat: calmer C-major pentatonic soundtrack Co-Authored-By: Claude Opus 4.8 --- assets/js/console.js | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/assets/js/console.js b/assets/js/console.js index 15fcd80..c3db21b 100644 --- a/assets/js/console.js +++ b/assets/js/console.js @@ -150,14 +150,14 @@ log.appendChild(el); }; - // ---- 8-bit soundtrack: square-wave lead + bass, WebAudio, no assets ---- - // A-minor chiptune loop, 32 eighth-notes. midi note, 0 = rest. + // ---- 8-bit soundtrack: calm C-major pentatonic, WebAudio, no assets ---- + // 32 eighth-notes. midi note, 0 = rest. Sparse melody that rings. var chiptune = (function () { - var lead = [69, 0, 72, 76, 74, 0, 72, 69, 71, 0, 74, 76, 77, 76, 74, 72, - 69, 0, 72, 76, 81, 0, 79, 77, 76, 0, 72, 74, 76, 72, 69, 0]; - var bass = [45, 45, 52, 52, 41, 41, 48, 48, 43, 43, 50, 50, 40, 40, 47, 47, - 45, 45, 52, 52, 41, 41, 48, 48, 45, 45, 43, 43, 40, 40, 47, 47]; - var spn = 60 / 132 / 2; // seconds per eighth-note at 132bpm + var lead = [72, 0, 76, 0, 79, 0, 76, 0, 74, 0, 77, 0, 81, 0, 79, 0, + 72, 0, 76, 0, 84, 0, 79, 0, 81, 0, 79, 0, 76, 0, 74, 0]; + var bass = [48, 0, 0, 0, 0, 0, 0, 0, 45, 0, 0, 0, 0, 0, 0, 0, + 41, 0, 0, 0, 0, 0, 0, 0, 43, 0, 0, 0, 0, 0, 0, 0]; + var spn = 60 / 84 / 2; // seconds per eighth-note at a gentle 84bpm var ctx, master, timer, nextTime, step, playing = false, vol = 0.5; var freq = function (m) { return 440 * Math.pow(2, (m - 69) / 12); }; var voice = function (m, t, dur, type, vol) { @@ -165,14 +165,14 @@ var o = ctx.createOscillator(), g = ctx.createGain(); o.type = type; o.frequency.value = freq(m); g.gain.setValueAtTime(0.0001, t); - g.gain.exponentialRampToValueAtTime(vol, t + 0.008); - g.gain.exponentialRampToValueAtTime(0.0001, t + dur * 0.9); + g.gain.exponentialRampToValueAtTime(vol, t + 0.04); // soft attack + g.gain.exponentialRampToValueAtTime(0.0001, t + dur); o.connect(g); g.connect(master); o.start(t); o.stop(t + dur); }; var tick = function () { while (nextTime < ctx.currentTime + 0.12) { - voice(lead[step], nextTime, spn, "square", 0.18); - voice(bass[step], nextTime, spn, "square", 0.10); + voice(lead[step], nextTime, spn * 2.2, "triangle", 0.14); // let it ring + voice(bass[step], nextTime, spn * 7, "sine", 0.12); // slow pad step = (step + 1) % lead.length; nextTime += spn; } From 96c6f6290cd224a4b9d012ba329577ec4991246c Mon Sep 17 00:00:00 2001 From: James Jiang Date: Tue, 28 Jul 2026 08:12:47 +1000 Subject: [PATCH 04/11] chore: set exampleSite baseURL to github pages subpath Co-Authored-By: Claude Opus 4.8 --- exampleSite/hugo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exampleSite/hugo.toml b/exampleSite/hugo.toml index 81da19d..cb5bee2 100644 --- a/exampleSite/hugo.toml +++ b/exampleSite/hugo.toml @@ -1,4 +1,4 @@ -baseURL = "https://example.org/" +baseURL = "https://jjsnack.github.io/basic/" languageCode = "en" title = "basic" theme = "basic" From 558afbdfb574f59131b722d75fe7b807af052e20 Mon Sep 17 00:00:00 2001 From: James Jiang Date: Tue, 28 Jul 2026 08:14:26 +1000 Subject: [PATCH 05/11] Revert "chore: set exampleSite baseURL to github pages subpath" This reverts commit 96c6f6290cd224a4b9d012ba329577ec4991246c. --- exampleSite/hugo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exampleSite/hugo.toml b/exampleSite/hugo.toml index cb5bee2..81da19d 100644 --- a/exampleSite/hugo.toml +++ b/exampleSite/hugo.toml @@ -1,4 +1,4 @@ -baseURL = "https://jjsnack.github.io/basic/" +baseURL = "https://example.org/" languageCode = "en" title = "basic" theme = "basic" From 0f06f88c750a1be946b12f836db955db0f11de6d Mon Sep 17 00:00:00 2001 From: James Jiang Date: Tue, 28 Jul 2026 08:14:33 +1000 Subject: [PATCH 06/11] ci: hardcode github pages baseURL in deploy build Co-Authored-By: Claude Opus 4.8 --- .github/workflows/pages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index d173bc3..1ce7226 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/configure-pages@v5 id: pages - name: Build - run: hugo --source=exampleSite --minify --baseURL "${{ steps.pages.outputs.base_url }}/" + run: hugo --source=exampleSite --minify --baseURL "https://jjsnack.github.io/basic/" - uses: actions/upload-pages-artifact@v3 with: path: exampleSite/public From 6ffa4fbf977da24df5c5e3f26b160de05d851c27 Mon Sep 17 00:00:00 2001 From: James Jiang Date: Tue, 28 Jul 2026 08:17:49 +1000 Subject: [PATCH 07/11] Revert "ci: hardcode github pages baseURL in deploy build" This reverts commit 0f06f88c750a1be946b12f836db955db0f11de6d. --- .github/workflows/pages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 1ce7226..d173bc3 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/configure-pages@v5 id: pages - name: Build - run: hugo --source=exampleSite --minify --baseURL "https://jjsnack.github.io/basic/" + run: hugo --source=exampleSite --minify --baseURL "${{ steps.pages.outputs.base_url }}/" - uses: actions/upload-pages-artifact@v3 with: path: exampleSite/public From 37ed68a8b7d44930b94bc195209ceb4df5e13658 Mon Sep 17 00:00:00 2001 From: James Jiang Date: Tue, 28 Jul 2026 08:19:50 +1000 Subject: [PATCH 08/11] fix(console): hide figlet banner from screen readers The banner
 lives inside .console__log, which is role="log"
aria-live="polite". Without aria-hidden a screen reader announces the
decorative ASCII art character-by-character. Mark it hidden.

Co-Authored-By: Claude Opus 4.8 
Claude-Session: https://claude.ai/code/session_01Bgxxw4baXATznPYP4ARHzW
---
 assets/js/console.js | 1 +
 1 file changed, 1 insertion(+)

diff --git a/assets/js/console.js b/assets/js/console.js
index c3db21b..ac7ce0b 100644
--- a/assets/js/console.js
+++ b/assets/js/console.js
@@ -269,6 +269,7 @@
       try { fig = parseFig(atob(root.dataset.font || "")); } catch (e) { fig = null; }
       var art = document.createElement("pre");
       art.className = "console__banner-art";
+      art.setAttribute("aria-hidden", "true"); // decorative ASCII — keep it out of the aria-live log
       art.textContent = banner(root.dataset.title || "", fig).join("\n");
       log.appendChild(art);
       write({ text: "\n" });

From 5af4a8beb624e42f85be53195334977ac4983b0f Mon Sep 17 00:00:00 2001
From: James Jiang 
Date: Tue, 28 Jul 2026 08:20:49 +1000
Subject: [PATCH 09/11] fix(console): report blocked audio; clarify shadowed
 names
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- music play now reports "blocked by the browser" instead of claiming
  "♪ music on" when AudioContext.resume() rejects. on() returns a promise
  so the caller can tell success from failure.
- rename voice()'s per-note gain param vol -> peak (was shadowing the
  module-level master vol used by setVolume).
- rename local history array -> cmdHistory (was shadowing window.history).

Co-Authored-By: Claude Opus 4.8 
Claude-Session: https://claude.ai/code/session_01Bgxxw4baXATznPYP4ARHzW
---
 assets/js/console.js | 28 ++++++++++++++++++----------
 1 file changed, 18 insertions(+), 10 deletions(-)

diff --git a/assets/js/console.js b/assets/js/console.js
index ac7ce0b..eff10b8 100644
--- a/assets/js/console.js
+++ b/assets/js/console.js
@@ -141,7 +141,7 @@
       var input = root.querySelector(".console__input");
       var expandBtn = root.querySelector(".console__expand");
       var closeBtn = root.querySelector(".console__close");
-      var history = [];
+      var cmdHistory = []; // local — do not shadow window.history
 
       var write = function (line, kind) {
         var el = document.createElement("div");
@@ -160,12 +160,12 @@
         var spn = 60 / 84 / 2; // seconds per eighth-note at a gentle 84bpm
         var ctx, master, timer, nextTime, step, playing = false, vol = 0.5;
         var freq = function (m) { return 440 * Math.pow(2, (m - 69) / 12); };
-        var voice = function (m, t, dur, type, vol) {
+        var voice = function (m, t, dur, type, peak) { // peak: per-note gain (distinct from master vol)
           if (!m) return;
           var o = ctx.createOscillator(), g = ctx.createGain();
           o.type = type; o.frequency.value = freq(m);
           g.gain.setValueAtTime(0.0001, t);
-          g.gain.exponentialRampToValueAtTime(vol, t + 0.04); // soft attack
+          g.gain.exponentialRampToValueAtTime(peak, t + 0.04); // soft attack
           g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
           o.connect(g); g.connect(master); o.start(t); o.stop(t + dur);
         };
@@ -178,15 +178,18 @@
           }
         };
         return {
+          // Resolves once audio is actually running; rejects if the browser blocks it.
           on: function () {
-            if (playing) return;
+            if (playing) return Promise.resolve();
             if (!ctx) {
               ctx = new (window.AudioContext || window.webkitAudioContext)();
               master = ctx.createGain(); master.gain.value = vol; master.connect(ctx.destination);
             }
-            ctx.resume();
-            playing = true; step = 0; nextTime = ctx.currentTime + 0.05;
-            tick(); timer = setInterval(tick, 25);
+            playing = true; step = 0;
+            return Promise.resolve(ctx.resume()).then(function () {
+              nextTime = ctx.currentTime + 0.05;
+              tick(); timer = setInterval(tick, 25);
+            }, function (err) { playing = false; throw err; });
           },
           off: function () { playing = false; clearInterval(timer); },
           setVolume: function (n) { vol = n / 10; if (master) master.gain.value = vol; }
@@ -231,12 +234,17 @@
         e.preventDefault();
         var value = input.value;
         write({ text: "$ " + value }, "cmd");
-        if (value.trim()) history.push(value.trim());
-        var res = run(value, { history: history });
+        if (value.trim()) cmdHistory.push(value.trim());
+        var res = run(value, { history: cmdHistory });
         if (res.clear) log.innerHTML = "";
         res.lines.forEach(function (l) { write(l); });
         if (res.theme) applyTheme(res.theme);
-        if (res.music === "on") { chiptune.on(); write({ text: "♪ music on" }); }
+        if (res.music === "on") {
+          chiptune.on().then(
+            function () { write({ text: "♪ music on" }); log.scrollTop = log.scrollHeight; },
+            function () { write({ text: "music blocked by the browser — interact with the page and retry." }); log.scrollTop = log.scrollHeight; }
+          );
+        }
         if (res.music === "off") { chiptune.off(); write({ text: "music off" }); }
         if (res.volume != null) chiptune.setVolume(res.volume);
         input.value = "";

From c52735379a47cc172325cd88cdfc96feb5b5b89c Mon Sep 17 00:00:00 2001
From: James Jiang 
Date: Tue, 28 Jul 2026 08:21:02 +1000
Subject: [PATCH 10/11] style(console): drop dead a.console__line hover rule
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

write() only ever creates 
lines — no code path renders an anchor, so this selector matched nothing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Bgxxw4baXATznPYP4ARHzW --- assets/css/main.css | 1 - 1 file changed, 1 deletion(-) diff --git a/assets/css/main.css b/assets/css/main.css index 66850ec..0d6fb8e 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -228,7 +228,6 @@ main, color: var(--accent); overflow-x: auto; } -a.console__line:hover { color: var(--accent); } .console__form { display: flex; align-items: baseline; gap: 0.5em; margin-top: 0.25rem; } .console__prompt { color: var(--accent); } .console__input { From 18c58fc2aac1a07e05e07d612dfa66930a12bf24 Mon Sep 17 00:00:00 2001 From: James Jiang Date: Tue, 28 Jul 2026 08:21:55 +1000 Subject: [PATCH 11/11] perf(console): fetch figfont as a cached asset, not inlined per page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The figfont was base64-encoded into a data-font attribute on every page (~6KB of HTML each). Publish it as a fingerprinted resource and fetch it once at runtime instead — cached across the whole site, and the banner render moves off the initial HTML payload. Falls back to no banner if the fetch fails; the console still works. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Bgxxw4baXATznPYP4ARHzW --- assets/js/console.js | 21 ++++++++++++++------- layouts/partials/console.html | 4 ++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/assets/js/console.js b/assets/js/console.js index eff10b8..bd89e6e 100644 --- a/assets/js/console.js +++ b/assets/js/console.js @@ -273,16 +273,23 @@ document.addEventListener("pointerup", up); }); - var fig = null; - try { fig = parseFig(atob(root.dataset.font || "")); } catch (e) { fig = null; } - var art = document.createElement("pre"); - art.className = "console__banner-art"; - art.setAttribute("aria-hidden", "true"); // decorative ASCII — keep it out of the aria-live log - art.textContent = banner(root.dataset.title || "", fig).join("\n"); - log.appendChild(art); write({ text: "\n" }); write({ text: "type `help` to see available commands" }); launch.hidden = false; + + // Banner: fetch the figfont (a cached, fingerprinted asset — not inlined + // base64 per page) and render it above the welcome text once it arrives. + if (root.dataset.fontSrc) { + fetch(root.dataset.fontSrc).then(function (r) { return r.text(); }).then(function (txt) { + var fig = null; + try { fig = parseFig(txt); } catch (e) { fig = null; } + var art = document.createElement("pre"); + art.className = "console__banner-art"; + art.setAttribute("aria-hidden", "true"); // decorative ASCII — keep it out of the aria-live log + art.textContent = banner(root.dataset.title || "", fig).join("\n"); + log.insertBefore(art, log.firstChild); + }).catch(function () {}); // no banner if the font can't load — page still works + } } } diff --git a/layouts/partials/console.html b/layouts/partials/console.html index 1268bac..7c8ae76 100644 --- a/layouts/partials/console.html +++ b/layouts/partials/console.html @@ -2,8 +2,8 @@ so the page is unaffected without JS. */}} -{{ $font := resources.Get "figlet/heading.flf" }} -