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

303 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
```css
.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`:
```css
.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:
```js
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:
```js
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:
```js
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:
```js
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`:
```js
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')`:
```js
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`:
```js
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 NOTE** — `selId` 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:
```js
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:
```js
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:
```js
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:
```js
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:
```js
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"`:
```js
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:
```js
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:
```js
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 `0`→`12` (midnight) and `12`→`12` (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:
```js
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.
```js
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`.
```js
{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.