Building voidcore: an OS-style homepage in vanilla JS
If you've poked around this site, you've noticed it pretends to be an operating system: a boot sequence, a menubar, draggable windows, a dock, a command palette. This post is a tour of how that's built — and why there's no framework, no bundler, and no node_modules folder anywhere in the repo.
Why vanilla
The stack is deliberately boring: PHP 8.3-FPM in a hardened container (read-only rootfs, dropped capabilities, disable_functions) behind Caddy, JSON files instead of a database, and on the frontend plain ES6 in IIFEs plus pure CSS. No build step at all — the file I edit is the file the browser gets, with a single $assetVersion query parameter for cache busting.
The reasoning: this site is a long-lived personal project, not a product with a team. Every dependency is something that can rot. A framework would buy me component abstractions I don't need — the "components" here are windows, and a window manager is genuinely not much code. HTTP/2 makes the "but you need a bundler" argument mostly moot for a site this size: scripts load with defer in document order, and that order is the dependency graph.
The window manager and the app registry
The core is os.js: it knows how to create, open, close, focus, minimize and maximize windows. Focus is a simple incrementing z-index counter — click a window, it gets zCounter++. One deliberate quirk: clicking the red traffic light doesn't destroy the window, it minimizes it. DOM state (a half-typed form, a paused game) survives "closing".
Apps never touch the window manager's internals. Each app is one file that pushes a descriptor into a global array:
window.ByteSideAppRegistry = window.ByteSideAppRegistry || [];
window.ByteSideAppRegistry.push({
id: 'finder',
title: 'Projects',
icon: 'bx bx-folder-open',
position: { top: 130, left: 240, width: 760, height: 540 },
body: renderBody(),
});
A registry module drains that array and renders windows plus dock items. The push pattern means script order barely matters for registration, apps are self-contained, and adding an app is "add one JS file, list it in the script loader".
Stacking is governed by a single documented z-index layer map: wallpaper at 1, windows from 10 upward, menubar and dock at 1000, the command palette at 2000, notifications at 3000, the screensaver at 5000, the lock screen at 6000, and boot/power overlays at 9000+. Every new feature has to slot into that table instead of inventing z-index: 99999.
Mobile is a second OS, not a breakpoint
Floating windows on a phone are miserable, so on small screens the site boots into a separate mobile shell: a body.mobile-os class gates an entirely different chrome — statusbar, home grid, app switcher — while the desktop chrome is hidden. Crucially, the windows themselves are reused: opening an app just makes its existing .window element fullscreen, so app state survives. The layout persistence layer early-returns on mobile, because letting a phone viewport write window positions into the same localStorage key the desktop reads corrupts the desktop layout.
What was surprisingly hard
Script timing. Everything loads with defer, which means scripts execute at readyState === 'interactive', before DOMContentLoaded — but the registry renders the windows between those two moments. Every app that queries its own DOM has to wait for DCL explicitly. This bit me more than once; it's now a documented pattern with a comment block apps copy.
Audio autoplay policies. Browsers keep the AudioContext suspended until a user gesture. Sounds attached to notifications that fire during the boot animation — before any click — have to be explicitly marked silent, and the first pointerdown or keydown resumes the context. Getting "UI sounds that never throw and never play at the wrong time" right took real care.
localStorage doesn't notify itself. The storage event only fires in other tabs. With multiple modules mirroring the same toggle (the sound state alone has four UI touchpoints), every write has to update the in-memory copy and dispatch a custom event like byte:sound-changed by hand. Forget one and you get UI that silently drifts out of sync.
None of these are framework problems — a framework wouldn't have fixed the autoplay policy or the storage event semantics. They're platform problems, and building on the platform directly means you actually learn where the edges are.
The whole thing has been live on byteside.io since late May 2026. The repo's hard rules fit in a few markdown files, and the site still loads as plain PHP, CSS and JS you can read in the dev tools — which was the point all along.