Engineering Journal
Ginexys
Ginexys

Building a Browser Window Manager in Vanilla JS: GINEXYS OS Shell

2026-05-22

TLDR: os-shell.js is a self-contained window manager. Each app is an iframe in a draggable/resizable chrome shell. Z-order is a monotonically increasing integer. The wallpaper is a canvas particle network with mouse attraction and click ripple. No framework. ~880 lines.

Repo: ginexys.com

Why Build a Browser OS

The GINEXYS toolchain has three tools that users want to run side by side: a PDF processor, a table formatter, and a schematic editor. Standard tabbed navigation forces you to context-switch between tools. A window manager lets you pin the schema editor to the left half of the screen while the table formatter sits on the right.

The OS metaphor also fits the product's positioning as a developer workbench. It signals intent: this is a workspace, not a landing page with a feature list.


Window Structure

Each app window is a div with a title bar, a viewport, and resize handles:

<div class="cws-window" data-app-id="pdf-processor">
  <div class="win-titlebar">
    <span class="win-title">PDF Processor</span>
    <div class="win-controls">
      <button class="win-btn win-minimize">_</button>
      <button class="win-btn win-close">✕</button>
    </div>
  </div>
  <div class="win-viewport">
    <iframe src="/tools/pdf-processor/" title="PDF Processor"></iframe>
  </div>
  <div class="win-resize-handle"></div>
</div>

The iframe gives each app a fully isolated JS and DOM context. Styles, globals, and event listeners in the PDF processor cannot leak into the table formatter or into the OS shell itself.


Drag Implementation

Title bar drag uses pointermove with setPointerCapture to track the cursor even when it leaves the title bar area:

titlebar.addEventListener('pointerdown', (e) => {
    e.preventDefault();
    titlebar.setPointerCapture(e.pointerId);
    const startX = e.clientX - win.el.offsetLeft;
    const startY = e.clientY - win.el.offsetTop;

function onMove(e) { win.el.style.left = (e.clientX - startX) + 'px'; win.el.style.top = Math.max(0, e.clientY - startY) + 'px'; } function onUp() { titlebar.removeEventListener('pointermove', onMove); titlebar.removeEventListener('pointerup', onUp); sessionStorage.setItem('winPos_' + appId, JSON.stringify({ x: win.el.offsetLeft, y: win.el.offsetTop })); }

titlebar.addEventListener('pointermove', onMove); titlebar.addEventListener('pointerup', onUp); });

setPointerCapture is the key. Without it, fast drags that move the cursor past the title bar boundary lose tracking. With it, the element owns the pointer for the duration of the drag regardless of where the cursor goes.

Window positions are saved to sessionStorage on every drag end. On next open, the position is restored:

const saved = sessionStorage.getItem('winPos_' + appId);
if (saved) {
    const { x, y } = JSON.parse(saved);
    win.el.style.left = x + 'px';
    win.el.style.top  = y + 'px';
}

Z-Order: A Single Counter

Focus management (click = come to front) uses a monotonically increasing integer:

let highestZIndex = 10;

function bringToFront(appId) { const win = windowMap.get(appId); if (!win) return; highestZIndex++; win.el.style.zIndex = highestZIndex; }

Every window click, open, and restore calls bringToFront. No sorting. No array management. The highest integer is always the frontmost window. Integer overflow is not a practical concern at one increment per user interaction.


App Suspend and Resume

Windows can be minimized without destroying the iframe. On minimize, the iframe is blanked and removed from the DOM to stop its JS execution and network activity:

function suspendWindow(appId) {
    const iframe = viewport.querySelector('iframe');
    if (iframe) {
        iframe.src = 'about:blank';
        iframe.remove();
    }
}

On restore, a new iframe is created with the original src:

function resumeWindow(appId) {
    const iframe = document.createElement('iframe');
    iframe.src = app.src;
    viewport.appendChild(iframe);
}

This is a deliberate tradeoff. Suspended apps lose all in-memory state. A table you were editing is gone when you un-minimize. The alternative (keeping the iframe in the DOM with display: none) keeps state but means all minimized apps continue running JS and making network requests in the background. For a developer tools platform with potentially heavy extraction workers, background execution is a worse tradeoff than state loss on minimize.


The Wallpaper: Particle Network

The desktop wallpaper is a <canvas> element covering the full desktop area. It renders 68 nodes connected by edges when within 145px of each other.

const N = 68, LINK = 145, PULL = 180;

Mouse attraction: Each frame, every node within PULL pixels of the cursor receives a small velocity push toward the cursor. The force scales linearly from the pull radius to zero at the cursor position:

const f = ((PULL - d) / PULL) * 0.02;
p.vx += (dx / d) * f;
p.vy += (dy / d) * f;

Edge rendering: Opacity fades with distance. Nodes near the LINK threshold have near-invisible edges. Nodes very close have near-opaque edges:

const opacity = (1 - dist / LINK) * 0.4;
ctx.strokeStyle = rgba(0,180,216,${opacity.toFixed(3)});

rgba(0,180,216,...) is the blueprint cyan used throughout the GINEXYS design system. On the dark desktop background, this produces a subtle technical-looking grid that references schematic diagrams without being distracting.

Click ripple: Each click spawns a ring at the click position. The ring expands outward at 4.5 canvas units per frame, fading from opacity 0.55 to 0. Simultaneously, all nodes within 130 canvas units receive an outward velocity kick:

ripples.push({ x: cx, y: cy, r: 0, a: 0.55 });
for (const p of pts) {
    const dx = p.x - cx, dy = p.y - cy;
    const d = Math.hypot(dx, dy);
    if (d < 130 && d > 0) {
        p.vx += (dx / d) * 2.8;
        p.vy += (dy / d) * 2.8;
    }
}

The combination of the expanding ring and the scattered nodes makes the desktop feel physically responsive to interaction.


Theme Sync Across Iframes

The OS shell manages a dark/light theme toggle. When the theme changes, all open app windows need to update. Since each app is in an isolated iframe, the shell uses postMessage:

for (const [, win] of windowMap) {
    const iframe = win.el.querySelector('iframe');
    if (iframe && iframe.contentWindow) {
        iframe.contentWindow.postMessage({ type: 'cws:theme-change', theme: t }, '*');
    }
}

Each app's JS listens for cws:theme-change and applies the appropriate CSS class to its own <body>. The apps do not need to know anything about the shell's theme state. The message is the only coupling.


Tradeoffs

Suspend-on-minimize loses state. This is the right call for heavy tools but wrong for lightweight panels. A future improvement would be a per-app suspendable flag that keeps lightweight tools alive while blanking heavy workers.

sessionStorage position persistence resets on tab close. Users who close and reopen the browser lose their window layout. localStorage would persist across sessions at the cost of stale positions on different screen sizes. The current tradeoff favors freshness over persistence.

Single-page all-in-one-file architecture. os-shell.js is an IIFE that self-registers as a custom element. This makes it embeddable on any page with a single <script> tag but limits tree-shaking and lazy loading. For a production application, splitting the wallpaper animation into a separate module would allow lazy initialization.

Read this post in the full Engineering Journal →