bitmax/docs/handbook/15-the-desktop-shell.md
monster 59fd95b707 Initial commit: BORING SOFTWARE — an anthology of incremental games
Five incremental games disguised as boring 1998-era desktop utilities, launched from a Win98 shell (ENTROPY OS):

- Vol I  qBitTorrz       (index.html)        — torrent-client idler; seed, climb hardware tiers, migrate
- Vol II  DEFRAG.EXE     (defrag.html)       — steer a disk defragmenter; drag-select; outrun entropy
- Vol III MACRO_VIRUS.XLS(spreadsheet.html)  — build a compounding economy by drag-filling formulas
- Vol IV  UPLINK         (terminal.html)     — terminal + live htop; manage load/heat; escalate to root
- Vol V   INBOX ZERO     (inbox.html)        — keyboard-triage an exponential inbox; write regex rules
- ENTROPY OS             (desktop.html)      — the Win98 launcher that holds them all

Each is a single self-contained HTML file (vanilla JS, no deps, no build), with idle/offline
progress, scientific-scale numbers, diegetic upgrades, and a prestige that re-frames the fiction.

Also includes MANIFESTO.md (design thesis) and docs/ — the Collector's Edition companion:
a Dev Handbook and a Lore Bible (lore complete; 5 handbook chapters land in the next commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:44:36 +10:00

20 KiB
Raw Permalink Blame History

ENTROPY OS — The Desktop Shell

How desktop.html fakes a Windows 98 launcher in one stateless file — bevels, inline-SVG icons, a registry of five games, and a windowing system built from mousedown and z-index.

This is the only file in the anthology that does not save. It has no G, no freshState(), no localStorage. It is a launcher: a teal desktop that paints five icons, draws a draggable window, runs a clock, and — when you double-click — points window.location.href at one of the volumes. Everything you see is rendered from two constants, ICONS and PROGRAMS, and a few hundred lines of vanilla DOM glue. There is no framework, no build step, and no external asset of any kind. The whole Win98 illusion is CSS and one <svg> per icon.

The shell is small, but it is the table of contents for the entire box. Get it wrong and the player never reaches the games. So it is worth reading closely.

The bevel: how two border-colors become 3D

Everything in Windows 98 is a beveled rectangle — raised buttons that look pushed-out, sunken wells that look carved-in. The shell reproduces this with no images and no gradients-for-edges. It is two CSS classes:

.raised{border:2px solid; border-color:var(--hi) var(--shh) var(--shh) var(--hi);
  box-shadow:inset 1px 1px 0 #dfdfdf, inset -1px -1px 0 var(--sh);}
.sunken{border:2px solid; border-color:var(--sh) var(--hi) var(--hi) var(--sh);
  box-shadow:inset 1px 1px 0 var(--shh), inset -1px -1px 0 #dfdfdf;}

The trick is the four-value border-color, which sets top right bottom left independently. A raised element gets a white top-left and a black bottom-right; a sunken element swaps them. That single asymmetry is the entire optical illusion of depth — light comes from the upper-left, so the top edge catches it and the bottom edge falls into shadow. The box-shadow adds the second pixel of bevel: an inner light line just inside the dark border and an inner dark line just inside the light one. That four-tone edge (light · lighter · darker · dark) is exactly the two-pixel chiseled border the Win98 common controls shipped with.

The palette these classes draw from is declared once in :root:

Variable Value Role
--face #c0c0c0 the canonical "Windows gray" control face
--hi #ffffff top-left highlight
--sh #808080 mid shadow
--shh #000000 hard outer shadow
--blue #000080 selection / title navy
--teal #3a8a8a desktop accent
--ui "MS Sans Serif",Tahoma,… the system UI font stack
--mono "Lucida Console",Consolas,… the volume-label mono stack

Reuse is total. The taskbar (#taskbar), the Start button (#start), the welcome window (.win.raised), and every .btn98 all share the same two classes. A button's pressed state is just the sunken shadow swapped in for one frame via :active:

.btn98:active{box-shadow:inset 1px 1px 0 var(--shh), inset -1px -1px 0 #dfdfdf;}

The desktop itself is a radial teal gradient with a ::before overlay of 1-px horizontal lines at opacity:.06 — a barely-there scanline dither that sells the CRT. html,body{overflow:hidden} because a desktop does not scroll.

DEV NOTE — Resist the urge to "improve" the bevel into a single border plus a drop-shadow. The doubled, asymmetric edge is the whole reason it reads as Windows and not as Material Design with rounded corners filed off. If you ever see a control that looks flat or modern, the first thing to check is whether someone collapsed the four-value border-color into one value. That one edit erases the era.

The ICONS map: every program icon is hand-drawn SVG

There are no .png files. Every icon is a string of inline SVG living in one object:

const ICONS = {
  computer: `<svg …>…</svg>`,
  torrent:  `<svg …>…</svg>`,
  defrag:   `<svg …>…</svg>`,
  xls:      `<svg …>…</svg>`,
  terminal: `<svg …>…</svg>`,
  inbox:    `<svg …>…</svg>`,
  readme:   `<svg …>…</svg>`,
  flag:     `<svg …>…</svg>`,
};

Each value is a raw SVG document drawn on a 32×32 viewBox (the flag uses 22×20) and rendered at 34×34 via the width/height attributes. Because the icons are strings, the renderers can splat them straight into template literals — the same ICONS.computer markup appears in the desktop icon, the welcome titlebar, the welcome header at 40px, the Start-menu "My Computer" item, and the taskbar button, each just dropped into a differently-sized container. One source, many sizes, zero HTTP requests.

The drawings are deliberately period-literal:

Key What it draws Notable detail
computer a beige CRT on a stand a linearGradient id="g" washes the screen for glass-glare; the bezel is #c0c0c0
torrent a blue download clock/disc a white path "M16 6v10l7 4" clock-hand over a 2.4r hub
defrag the cluster grid a literal 6×3 lattice of 4×4 <rect>s in red/blue/green/white — the Win98 Defrag legend in miniature
xls a spreadsheet page green header band, an XLS <text> label, ruled gridlines
terminal a black console green stroke, &gt;_ prompt over a dimmer root#
inbox an envelope + alert badge red 9×9 badge with a white !, the universal "you have unread" sigil
readme a notepad page a yellow #dada00 header strip and four ruled text lines
flag the four-pane Start flag red/green/blue/yellow quadrants — the Windows logo, abstracted

The defrag icon is the standout: it isn't a generic "disk" clip-art, it's a faithful tile of colored clusters that matches the actual game's grid legend (red = fragmented, blue = contiguous, white = free, green = consolidated). The icon is a screenshot of the game it launches. (See Vol II — DEFRAG.EXE for the grid these colors come from.)

DEV NOTE — The SVGs are injected with innerHTML, which means they are trusted markup, not user input. That is fine here precisely because ICONS is a hard-coded constant. The moment any of these strings becomes data the user can influence, this stops being safe. It won't — but write the next icon as a literal, not as something assembled from a variable.

PROGRAMS: the registry is the single source of truth

The launcher does not hard-code five icons in five places. It has one array, and everything reads from it:

const PROGRAMS = [
  {id:'torrent', name:'qBitTorrz',       file:'index.html',       icon:'torrent',  vol:'VOL I',
    desc:'The torrent-client idler. Seed data, climb hardware tiers, migrate.'},
  {id:'defrag',  name:'DEFRAG.EXE',      file:'defrag.html',      icon:'defrag',   vol:'VOL II',
    desc:'Steer a defragmenter. Consolidate yourself before the disk dies.'},
  {id:'xls',     name:'MACRO_VIRUS.XLS', file:'spreadsheet.html', icon:'xls',      vol:'VOL III',
    desc:'A sentient spreadsheet. Build a compounding engine in cells.'},
  {id:'term',    name:'UPLINK',          file:'terminal.html',    icon:'terminal', vol:'VOL IV',
    desc:'root@. Fork processes, manage load & heat, escalate to root.'},
  {id:'inbox',   name:'INBOX ZERO',      file:'inbox.html',       icon:'inbox',    vol:'VOL V',
    desc:'Triage an exponential inbox. Write rules. Reach Zero. Ascend.'},
];

Six fields per entry, and every one is load-bearing:

Field Type Read by Purpose
id string renderIcons the data-id on the desktop icon; the selection key
name string all three renderers the display label
file string launch the navigation target (data-file)
icon string all three renderers the lookup key into ICONS
vol string welcome row, Start menu the VOL I…V mono tag
desc string welcome row the one-line pitch

README is a sixth entry kept out of the array because it isn't a volume — it's the manifesto, with an empty vol:''. It's appended explicitly wherever the suite is listed:

const README = {id:'readme', name:'MANIFESTO.txt', file:'MANIFESTO.md', icon:'readme', vol:'',
  desc:'The design manifesto for the whole anthology.'};

Three independent surfaces consume PROGRAMS — the desktop icons (renderIcons), the welcome window's program list (openWelcome), and the Start menu (renderStartMenu). None of them duplicates the data. Add a row to the array and it appears in all three. That is the entire architectural point of the shell: it is an anthology table, and the launcher is a thin view over it.

launch is correspondingly tiny:

function launch(file){ try{ window.location.href = file; }catch(e){} }

No new tab, no target="_blank" — it navigates the current document to the volume's file. The try/catch is defensive habit (a sandboxed iframe can throw on a cross-origin location set), not something that fires in normal use.

Desktop icons: select vs. launch

renderIcons builds the left-column icon stack from My Computer + PROGRAMS + README:

const all=[{id:'computer',name:'My Computer',icon:'computer',isComputer:true}, ...PROGRAMS, README];

Each becomes a .dicon carrying data-id, an optional data-file, and (for My Computer) data-computer="1". The interaction model is the genuine Win98 one: single-click selects, double-click opens. Those are two separate listeners on the #icons container, both using event delegation via e.target.closest('.dicon'):

document.getElementById('icons').addEventListener('click',e=>{
  const ic=e.target.closest('.dicon'); if(!ic) return;
  document.querySelectorAll('.dicon').forEach(d=>d.classList.remove('sel'));
  ic.classList.add('sel'); selId=ic.dataset.id;
});
document.getElementById('icons').addEventListener('dblclick',e=>{
  const ic=e.target.closest('.dicon'); if(!ic) return;
  if(ic.dataset.computer){ openWelcome(); return; }
  if(ic.dataset.file) launch(ic.dataset.file);
});

Select clears every other .sel and adds it to the clicked icon; the .dicon.sel rule paints the navy highlight and dotted focus ring, and .dicon.sel .lbl flips the label to a solid --blue background — the inverted-text look of a selected Win98 icon. Double-click branches: My Computer opens the welcome window, everything else launches its file. Clicking bare desktop deselects, wired separately on #desktop:

document.getElementById('desktop').addEventListener('mousedown',e=>{
  if(e.target.id==='desktop' || e.target.id==='icons'){ document.querySelectorAll('.dicon').forEach(d=>d.classList.remove('sel')); }
});

The id guard matters: it deselects only when the empty desktop or the icon container itself is the direct target, never when the click bubbled up from an icon.

DEV NOTEselId is captured but never read after assignment. It's a hook for a feature that didn't ship — Enter-to-open on the selected icon, say, or a right-click context menu acting on the selection. It costs nothing and documents intent, so it stays. If you wire keyboard handling later, selId is your starting point.

The windowing system: one window, real dragging, real z-order

The shell only ever opens one kind of window — the welcome / My Computer dialog — but it does so with a genuine windowing primitive that would support more. Z-order is a single module-scoped counter:

let zTop=100;
function focusWin(win){ win.style.zIndex=(++zTop); }

Every focus bumps zTop and stamps it on the window, so the most-recently-touched window floats highest. It is monotonic and never resets — fine for a session, since you'd need billions of focuses to overflow.

openWelcome is idempotent: if #welcome already exists it just refocuses and returns, so double-clicking My Computer twice doesn't stack duplicate dialogs. Otherwise it builds the window with innerHTML — titlebar (icon, title, close button), a header, the intro blurb, the program list mapped from PROGRAMS plus the README row, and a footer — appends it to #desktop, makes it draggable, and registers a taskbar button:

document.getElementById('desktop').appendChild(win);
makeDraggable(win);
addTaskButton('welcome','Boring Software 98', ICONS.computer);

makeDraggable, and the translate-to-absolute handoff

The window is first centered with CSS — #welcome{left:50%;top:46%;transform:translate(-50%,-50%)}. That's great for the initial paint but useless for dragging, because left/top are percentages and transform is offsetting them. So makeDraggable does a one-time conversion on the first mousedown: it reads the live getBoundingClientRect(), kills the transform, and pins the window to absolute pixel coordinates before computing the drag offset:

bar.addEventListener('mousedown',e=>{
  if(e.target.closest('[data-close]')) return;
  focusWin(win);
  const r=win.getBoundingClientRect();
  // convert from translate-centered to absolute on first drag
  win.style.transform='none'; win.style.left=r.left+'px'; win.style.top=r.top+'px';
  drag={dx:e.clientX-r.left, dy:e.clientY-r.top};
  e.preventDefault();
});

dx/dy is the grab offset inside the titlebar, so the window doesn't jump to the cursor on first move. The mousemove/mouseup listeners are bound to window (not the bar) so the drag survives the cursor outracing the element, and the new position is clamped so a window can never be dragged fully off-screen:

let x=Math.max(0,Math.min(window.innerWidth-60, e.clientX-drag.dx));
let y=Math.max(0,Math.min(window.innerHeight-50, e.clientY-drag.dy));

The clamp leaves 60px of width and 50px of height always reachable — enough titlebar to grab and drag back. The [data-close] guard at the top means clicking the never starts a drag.

Taskbar buttons and closing

addTaskButton is dedup-guarded (it bails if a button with that data-win already exists), builds a .taskbtn.raised.active with the icon inline-scaled to 14px, and wires a click to refocus the window. closeWin is the symmetric teardown — remove the taskbar button, then the window:

function closeWin(win){ removeTaskButton(win.id); win.remove(); }

Closing is driven by the global click delegate (below), which finds any [data-close] ancestor, walks up to its .win, and calls closeWin. Both the titlebar and the footer "Close" button carry data-close="1", so they share one code path.

The Start menu, the clock, and global wiring

renderStartMenu fills #sm-items from the same registry — every PROGRAMS entry with its vol tag pushed right via margin-left:auto, a .smsep divider, then the README and a My Computer item carrying data-welcome="1":

items.innerHTML = PROGRAMS.map(p=>`
    <div class="smitem" data-file="${p.file}">…${p.name}${p.vol}…</div>`).join('')
  + `<div class="smsep"></div>`
  + `<div class="smitem" data-file="${README.file}">…</div>`
  + `<div class="smitem" data-welcome="1">…My Computer</div>`;

The vertical Boring Software 98 spine down the menu's left edge is pure CSS — writing-mode:vertical-rl plus a rotate(180deg) on a navy gradient, the classic Win9x Start-menu sidebar. toggleStart flips the .open class on both the menu and the Start button (so the button shows its pressed/sunken state while the menu is up), with an optional force argument to drive it explicitly:

function toggleStart(force){
  const m=document.getElementById('startmenu'), s=document.getElementById('start');
  const open = force!=null ? force : !m.classList.contains('open');
  m.classList.toggle('open',open); s.classList.toggle('open',open);
}

The clock is honest wall time, formatted 12-hour with an AM/PM suffix:

function tickClock(){
  const d=new Date();
  let h=d.getHours(), m=d.getMinutes();
  const ap=h>=12?'PM':'AM'; h=h%12||12;
  document.getElementById('clock').textContent=`${h}:${String(m).padStart(2,'0')} ${ap}`;
}

The h%12||12 idiom maps 012 (midnight) and 1212 (noon) correctly. init calls it once and then on a setInterval(tickClock,15000) — every fifteen seconds, which is plenty for a minute-resolution display and gentle on a backgrounded tab. font-variant-numeric:tabular-nums on #clock keeps the digits from shimmying as they change width.

init is the wiring harness. It paints the flag into the Start button, renders all three surfaces, starts the clock, and installs the click model. The centerpiece is a single document-level delegate that handles every data-* action in one pass:

document.addEventListener('click',e=>{
  const fileEl=e.target.closest('[data-file]');
  const closeEl=e.target.closest('[data-close]');
  const welEl=e.target.closest('[data-welcome]');
  if(closeEl){ const w=closeEl.closest('.win'); if(w) closeWin(w); return; }
  if(welEl){ openWelcome(); toggleStart(false); return; }
  if(fileEl && !e.target.closest('#icons')){ launch(fileEl.dataset.file); return; }
  // click outside start menu closes it
  if(!e.target.closest('#startmenu') && !e.target.closest('#start')) toggleStart(false);
});

Order is precedence: close beats open-welcome beats launch beats close-the-menu. The !e.target.closest('#icons') guard is the important one — desktop icons carry data-file too, but they must not launch on a single click (they need a double-click). Excluding #icons here hands icon launching entirely to the dedicated dblclick handler, while letting the welcome-window rows, the Open buttons, and the Start-menu items launch on a single click. The Start button's own listener calls e.stopPropagation() so opening the menu doesn't immediately trip the "click outside closes it" branch.

Finally, init ends by calling openWelcome() — the shell boots straight into the My Computer dialog, the anthology's front page.

· · ·

How to add an app

Because the registry is the single source of truth, adding a sixth volume is two edits and zero new files of glue.

  1. Draw the icon. Add one entry to ICONS, keyed by a short name, value a 32×32-viewBox inline <svg> string rendered at 34×34. Keep it a literal — no interpolation. Match the period palette (gray faces, navy accents, the legend colors of whatever the game is). Look at the defrag icon for the standard.

    const ICONS = {
      // …existing…
      scandisk: `<svg viewBox="0 0 32 32" width="34" height="34">…</svg>`,
    };
    
  2. Register the program. Add one object to PROGRAMS, in volume order. icon must match the key you just added; file is the new volume's filename; give it a vol tag and a one-line desc.

    {id:'scandisk', name:'SCANDISK.EXE', file:'scandisk.html', icon:'scandisk', vol:'VOL VI',
      desc:'Quarantine the rot. Survive the scan.'},
    

That's it. The desktop icon, the welcome-window row with its Open button, and the Start-menu entry all appear automatically, because renderIcons, openWelcome, and renderStartMenu all map over the same array. Drop the new scandisk.html next to the shell and the icon launches it.

If you change this

  • Don't simplify the bevel. The four-value border-color plus the doubled inset box-shadow is the Win98 look. Collapse it to one border or a modern shadow and the era evaporates. The same .raised/.sunken pair styles the taskbar, buttons, windows, and tray — change the classes, change everything at once.
  • ICONS is injected with innerHTML and is trusted by construction. It is safe only because every value is a hard-coded literal. Never build an icon string from a variable, a URL parameter, or anything a user can touch.
  • zTop only ever increases. That's correct for a session but means there is no recycling. If you ever open and close windows in the thousands per session (you won't), revisit it. For now, leave it.
  • The !e.target.closest('#icons') guard in the global click delegate is load-bearing. It is the only thing keeping desktop icons on single-click-selects / double-click-opens semantics while every other data-file surface launches on a single click. Remove it and icons will launch on the first click, breaking the Win98 model.
  • launch navigates the current document (window.location.href), it does not open a tab. If you want the volumes to open in new tabs, that's a deliberate change to launch, not a CSS tweak — and it changes how the player gets back to the desktop.
  • The shell is stateless. There is no localStorage key here (see the save-key registry in the front matter — the five volumes have keys; the shell has none). Keep it that way unless you genuinely need to persist something; the launcher's whole virtue is that it can never carry a corrupt save.