NULL(1)
User Commands — Underground SectionNAME
null — an underground zine that is also a working terminal. Issue 0x1F of NULL://TERMINAL, published by the (fictional) null collective, rendered as one green-phosphor CRT you can type at.
SYNOPSIS
null [--phosphor green|amber] [--issue 0x1F] [--no-escape]
Boots a fake POST sequence (any key skips it), then drops you at a real prompt. Everything the terminal can print is also printed below it as plain HTML — the terminal is the front door, not a wall.
DESCRIPTION
The aesthetic school is the green-phosphor CRT: P1 tubes, VT220s, BBS door screens, demoscene NFO files, and the textfile zines of the early nineties. One typeface (JetBrains Mono), one hue family per theme, scanlines from a repeating gradient, glow from stacked text-shadow, curvature suggested by a radial-gradient vignette rather than any actual distortion. Nothing on the page is an image; the masthead is figlet-style ASCII drawn by hand and verified for column alignment before shipping.
The fiction is load-bearing: five real articles (dead protocols, the 56k handshake, the 1998 iron harvest, a phosphor manifesto, a retired phreak's exit interview) are written into semantic HTML first. The terminal reads them out of the DOM at runtime — one source of truth, zero drift between the interactive and printed editions, and the whole issue survives with JavaScript disabled.
COMMANDS
- help
- lists the documented commands. The undocumented ones (sudo, exit, cowsay, finger, uname) are left as rumors.
- ls / cat <file>
- list and read the issue. TAB completes filenames; any key skips the typewriter.
- banner
- reprints the ASCII masthead.
- theme green|amber
- retunes the phosphor sitewide, and remembers.
- whoami / date / clear / guide
- exactly what you'd hope.
INTERNALS: THE TYPEWRITER
Output types itself with requestAnimationFrame, not setInterval — a few characters per frame, so speed scales with the display and never queues up in a background tab. A single shared skipAll flag is flipped by any keypress; every loop checks it and dumps the remainder instantly. Users with prefers-reduced-motion set skip the animation entirely and get instant text.
async function typeInto(el, text, n){ if (REDUCED){ el.textContent += text; return; } let i = 0; while (i < text.length){ // any key sets skipAll — dump the rest, stop animating if (skipAll){ el.textContent += text.slice(i); return; } el.textContent += text.slice(i, i + n); // n chars per frame i += n; await new Promise(r => requestAnimationFrame(r)); } }
INTERNALS: THE PARSER
There is no parser, really — that's the trick. Input splits on whitespace; the first token indexes a registry object; the rest are arguments. Each command is a plain function (async if it prints slowly). Adding a command is one register() call, which is why the easter eggs cost almost nothing.
const parts = raw.split(/\s+/); const entry = COMMANDS[parts[0].toLowerCase()]; if (entry) await entry.run(parts.slice(1)); else line('nullsh: ' + parts[0] + ': command not found', 'err');
The prompt itself is a hidden <input> mirrored into three spans: text-before-caret, the character under the caret (rendered as the block cursor), and text-after. Arrow keys, selection, and mobile keyboards all keep working because the browser still owns the real caret — the CRT cursor just rides it.
INTERNALS: HISTORY AND COMPLETION
History is an array plus one index. ArrowUp stashes your unfinished line in a draft variable before rewinding, so ArrowDown can bring it back — the detail that separates a terminal from a text box. TAB completion picks its candidate pool by position: command names for the first token, filenames after cat, handles after finger. Multiple hits extend to the longest common prefix, then print the options, bash-style.
if (e.key === 'ArrowUp'){ if (hIdx === history.length) draft = cmd.value; // stash the draft if (hIdx > 0) hIdx--; cmd.value = history[hIdx]; }
INTERNALS: PHOSPHOR THEMING
Every color on the site — text, glow, borders, scrollbars, selection — derives from seven custom properties on :root. Switching to amber is one attribute flip; the value persists in localStorage and a two-line inline script in <head> applies it before first paint, so there is no green flash for amber loyalists.
:root[data-theme="amber"]{ --bg:#0a0500; --ink:#ffb000; --dim:#b87d00; --bright:#ffdf9e; --glow:rgba(255,176,0,.4); } /* js: */ document.documentElement.dataset.theme = t;
HOW IT WAS MADE
Built by Claude (Fable 5) writing vanilla HTML, CSS, and JavaScript by hand — no frameworks, no build step, no images, one Google Fonts request. The ASCII masthead was generated letter-by-letter from a hand-defined figlet alphabet and programmatically checked so every row is the same width. This site is one of a 25-site showcase; the hub is at fable-25-dhb.pages.dev.
STEAL THIS
- Render content once, in HTML. Let the fancy layer (here, the terminal) read from the DOM. You get progressive enhancement, accessibility, and a single source of truth for free.
- Animate with rAF and a kill switch. One shared boolean, checked inside every animation loop, is all "press any key to skip" costs. Wire the same boolean to prefers-reduced-motion.
- Fake the caret, keep the input. A hidden real <input> mirrored into spans gives you a styleable block cursor without reimplementing text editing.
- Theme through custom properties only. If every color is a var, a total reskin is one dataset attribute — and it can't half-apply.
- Suggest, don't simulate. Scanlines are a repeating gradient; curvature is a vignette; glow is text-shadow. Cheap effects, layered, read as a CRT better than any WebGL shader budget.
SEE ALSO
null://terminal (the issue itself), fable-showcase(7), gopher(1), finger(1) — RIP.