A Windows 98 desktop that happens to be a personal site. Plain HTML, CSS and JavaScript, served straight off GitHub Pages. There is no build step — what is in the repo is what the browser gets, and that is the whole point.
index.html the desktop
guestbook.html the guestbook
links.html the links page
404.html
sw.js the service worker — has to stay at the root, its
scope is its own directory
js/ everything the pages load
index.js the desktop: windows, taskbar, start menu, repo grid
extras.js the lazy loaders and find: files
apps.js fun.js the accessories and the toys
pages.js charts.js /now, /uses, /colophon, and the graphs on them
fx.js the motion layer, and the reduced-motion promise
touch.js the site on a phone: device detection, the
on-screen pad, long press for the right click
defrag.js disk defragmenter, on a real disk
documents.js my documents: every game's save, exportable
guestbook.js composing and reading a board entry, and the
giscus config for both boards
themes.js theme-maker.js theme-scan.js
arcade.js eight small games — tetris, breakout, 2048, simon,
hangman, whack, and two with an opponent that does
not cheat (minimax, and a pattern reader)
toys.js the old web's furniture, most of which the field
guides file under extinct: a pet, oneko, blinkies,
stamps, a hand-sorted directory, awards — plus the
joke programs and the small correct tools
web.js the reading layer: deep links, the address bar,
favorites, anchors, contents, reading time, plain
text, quoting, sidenotes, printing. Loads last —
it patches the globals the others declare rather
than editing them in place
vendor/ third-party scripts, vendored
css/
style.css all of it, with 95 marked sections, ending in the
reading layer and the print stylesheet
giscus-win98.css the theme the giscus iframe loads, since style.css
cannot reach into another origin
games/ one folder per game, plus the shared netplay layer
echoes/ jokerz/ wizardz/ become-user/ troll-problem/
netplay.js
data/ site content, board config, the projects list, the
github snapshot
src/ assets only: images, fonts, music
server/ the optional self-hosted netplay relay
feed.xml the rss, written by hand
feed.json the json feed, generated from feed.xml
.github/scripts/ the test suite — no dependencies, same as the site.
build-feed-json.mjs is the one generator
npm run serve # python3 -m http.server 8099
npm test # the whole headless suiteThe browser tests need Playwright's chromium and a server on :8099:
python3 -m http.server 8099 &
node .github/scripts/browser-check.mjs
node .github/scripts/browser-echoes.mjs
node .github/scripts/browser-mobile.mjsThere is no framework. Each script loads the real files, asserts, and exits non-zero if anything drifted.
| script | what it holds to |
|---|---|
check-wizardz.mjs |
combat, pause/resume, touch ownership, manual casting and refresh-rate consistency |
check-offline.mjs |
offline fallbacks, stalled requests, cache scope and media streaming |
check-games.mjs |
every script parses, the fifty spells and their sigils, desktop wiring, my documents' backup envelope, the GitHub snapshot |
check-guestbook.mjs |
composing and reading an entry, and the giscus config |
check-motion.mjs |
reduced motion means no motion, the charts, the stylesheet's own links |
check-defrag.mjs |
the defragmenter's plan never loses a cluster |
check-echoes.mjs |
a few thousand fights against the RPG's balance targets, and its maps |
check-web.mjs |
the reading layer against a fake window, the route names in js/web.js, 404.html and sitemap.xml agreeing, and feed.json matching feed.xml |
browser-check.mjs |
the site opens, two tabs duel, the guestbook works both ways |
browser-arcade.mjs |
the thirty new windows played rather than opened: eight games of tic-tac-toe it expects to lose, tiles merged in 2048, the pet's clock wound back nine hours |
browser-web.mjs |
deep links open the right document, the address bar in every form a person writes one, back and forward, favorites surviving a reload, quoting, find-in-document, the layout and tap targets on a 412px phone, the fullscreen that works without the API, and /now and /uses resolving — it brings its own server for that last part |
browser-echoes.mjs |
a character is rolled, a voyage is walked, a fight is won |
browser-mobile.mjs |
every app on a Pixel 7: nothing widens the page, a finger drags and closes a window, the troll problem aims before it builds, and the music is never handed to a sleeping audio graph |
-
A phone zooms the whole page out if anything sticks out sideways. One element past the right edge and the browser widens the layout viewport to cover it, so every window renders at half size in the middle of the screen. The news ticker does exactly that for half of every loop and is only clipped by a tailwind class off a cdn, so
#marquee-railandhtml { overflow-x: clip }are the floor under it.browser-mobile.mjssamples the page width right through the marquee loop. -
A finger drag emits pointer events and nothing else. No browser synthesises
mousemovefor one — only a click at the end of a tap — so a titlebar wired tomousedown/mousemoveis immovable on a phone. Window dragging is on pointer events with capture. -
createMediaElementSourceis a one-way door. It takes the<audio>element off the speakers and hands its output to the web audio graph. Call it while the AudioContext is suspended — which is how a phone creates one — and the track plays with the clock ticking, the progress bar filling and no sound at all. The graph is only ever built once the context is genuinely running. -
sw.jsstays at the root. A service worker's scope is its own directory; moving it intojs/would silently stop it controlling the site. -
Every
url()incss/style.cssis absolute. They resolve against the stylesheet, not the page, so a relative one broke the icon font the moment the file moved intocss/.check-motion.mjsfails the build on a relative one now. -
The service worker precaches by URL. Move a file and its entry in
PRECACHEhas to move too —check-games.mjsfails if a precached URL does not exist. -
data/github.jsonis generated, once a day by.github/workflows/github-data.yml. Do not hand-edit it. -
window.innerWidthis not the viewport on a phone. If anything overflows horizontally the browser widens the layout viewport to fit it andinnerWidthwidens too — measured at 1648 on a 412px screen. UseTOUCH.viewportWidth(), which readsdocumentElement.clientWidthand agrees with the media queries. -
Route names live in three files that cannot import from each other.
js/web.jsresolves them,404.htmlredirects to them (it is a separate page, so it carries its own copy), andsitemap.xmllists them. The keys inappActions()are the source of truth, and a few have a nicer public name —usespageis?app=uses.check-web.mjsholds all three lists to the real routes; it is what caught/usespointing at nothing. -
feed.jsonis generated fromfeed.xmlbynpm run feed. Add an<item>and re-run it, orcheck-web.mjsfails. -
js/web.jsloads last, on purpose. It wrapscreateAppWindow,closeAppWindow,startMenuActionandshowSection, which have to exist before it runs. Move its<script>tag up and the deep links stop working silently. -
.doc-belongs to the slash pages; my documents is.docs-. Both features grew a "document window" and both reached for the same prefix.js/documents.jsloads second, so it won, and every/now,/usesand colophon window rendered in MS Sans Serif rather than the Courier its own rule asks for — silently, for weeks.check-web.mjsfails the build if the two namespaces overlap again. -
The document font size is
var(--doc-font-size). The A-/A+ buttons set it on the root element. A second rule hard-codingfont-sizeon.doc-bodywould win and kill the buttons without an error, socheck-web.mjslooks for one. -
.doc-body's font size, and everything elseinstant()touches. With motion off,FX.animateapplies the keyframe's end state directly. It used to apply three properties and drop the rest, so a keyframe that moved something byleftnever happened for anyone with reduced motion on — and nothing said so.check-motion.mjsholds it to the whole keyframe now. -
Fullscreen is two implementations. The Fullscreen API where it exists; a
.fs-fauxclass pinning the window over the viewport where it does not, which is Safari on iPhone and any iframe withoutallow="fullscreen". Everything downstream —.fs-active, the canvas fitting, Escape — is driven off the same class either way, so a change to one needs checking against the other. -
Touch rules key off
pointer: coarse, never a width. A narrow desktop window is not a phone and a tablet with a stylus is not a fingertip. The bigger tap targets and the 16px inputs must not appear under a mouse — that is what keeps this looking like 1998. -
A new window has four places to be registered, and
check-web.mjsfails if any of them is missed:appActions()injs/index.js, the start menu inindex.html, the search index injs/extras.js, andPRECACHEinsw.js. It also checks that everyunlockAchievement()names an achievement that exists, and that every class the new files invent has a style. -
node_modules/is disposable. The site has no dependencies and neither does the test suite; the only thing that lands there is Playwright, installed with--no-savefor the browser runs.