diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml
index 8f8a1cdc..5f7064cb 100644
--- a/.github/workflows/macos.yml
+++ b/.github/workflows/macos.yml
@@ -19,10 +19,13 @@ on:
- "hosts/macos/**"
- "tools/macos.ts"
- "apps/note/**"
+ - "apps/desk98/**"
- "framework/compiler/**"
- "assets/fonts/**"
- "tests/platform-contracts.test.ts"
- "tests/note.test.ts"
+ - "tests/desk98.test.ts"
+ - "tests/desk98-sim.test.ts"
- "tests/font-bake.test.ts"
- "tests/npm-package.test.ts"
- "package.json"
@@ -37,10 +40,13 @@ on:
- "hosts/macos/**"
- "tools/macos.ts"
- "apps/note/**"
+ - "apps/desk98/**"
- "framework/compiler/**"
- "assets/fonts/**"
- "tests/platform-contracts.test.ts"
- "tests/note.test.ts"
+ - "tests/desk98.test.ts"
+ - "tests/desk98-sim.test.ts"
- "tests/font-bake.test.ts"
- "tests/npm-package.test.ts"
- "package.json"
@@ -75,8 +81,8 @@ jobs:
- name: Install JS dependencies
run: bun install --frozen-lockfile
- - name: Contract + note + platform + npm package tests
- run: bun test tests/platform-contracts.test.ts tests/note.test.ts tests/font-bake.test.ts tests/npm-package.test.ts && bun tests/contract.ts
+ - name: Contract + note + desk98 + platform + npm package tests
+ run: bun test tests/platform-contracts.test.ts tests/note.test.ts tests/desk98.test.ts tests/font-bake.test.ts tests/npm-package.test.ts && bun tests/contract.ts
- name: Compile the note against macos-app (plan + bundle + pak)
run: |
@@ -90,6 +96,23 @@ jobs:
await Bun.write(".pocket/macos-app/note-main.plan.json", JSON.stringify(r.plan, null, 2) + "\n");'
bun tools/build.ts --plan=.pocket/macos-app/note-main.plan.json
+ - name: Compile desk98 against macos-app (vue-vapor, W95FA pak.json splice + SVG icons)
+ run: |
+ bun -e '
+ import { validateAndResolveBuildPlan } from "./framework/src/manifest/resolve.ts";
+ import { mkdirSync } from "node:fs";
+ const m = await Bun.file("apps/desk98/pocket.json").json();
+ const r = validateAndResolveBuildPlan(m, { target: "macos-app" });
+ if (!r.ok) throw new Error(JSON.stringify(r.diagnostics));
+ mkdirSync(".pocket/macos-app", { recursive: true });
+ await Bun.write(".pocket/macos-app/desk98-main.plan.json", JSON.stringify(r.plan, null, 2) + "\n");'
+ bun tools/build.ts --plan=.pocket/macos-app/desk98-main.plan.json
+
+ - name: desk98 sim smoke (vue-vapor bundle)
+ run: |
+ bun tools/build.ts desk98-main --framework=vue-vapor
+ bun test --conditions=browser tests/desk98-sim.test.ts
+
- name: Test core (TEXT_RUN + native measure gates)
run: cargo test --locked --manifest-path engine/core/Cargo.toml
diff --git a/apps/desk98/AboutView.vue b/apps/desk98/AboutView.vue
new file mode 100644
index 00000000..35c83bcc
--- /dev/null
+++ b/apps/desk98/AboutView.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desk98/Button98.vue b/apps/desk98/Button98.vue
new file mode 100644
index 00000000..d677555f
--- /dev/null
+++ b/apps/desk98/Button98.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/desk98/CaptionButtons.vue b/apps/desk98/CaptionButtons.vue
new file mode 100644
index 00000000..20118a41
--- /dev/null
+++ b/apps/desk98/CaptionButtons.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/desk98/Counter.vue b/apps/desk98/Counter.vue
new file mode 100644
index 00000000..ca37cec8
--- /dev/null
+++ b/apps/desk98/Counter.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/desk98/DesktopIcons.vue b/apps/desk98/DesktopIcons.vue
new file mode 100644
index 00000000..2d0eb6db
--- /dev/null
+++ b/apps/desk98/DesktopIcons.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desk98/Digit.vue b/apps/desk98/Digit.vue
new file mode 100644
index 00000000..5de1f583
--- /dev/null
+++ b/apps/desk98/Digit.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desk98/FolderView.vue b/apps/desk98/FolderView.vue
new file mode 100644
index 00000000..ed95df73
--- /dev/null
+++ b/apps/desk98/FolderView.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desk98/MinesCell.vue b/apps/desk98/MinesCell.vue
new file mode 100644
index 00000000..32f832ac
--- /dev/null
+++ b/apps/desk98/MinesCell.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desk98/MinesView.vue b/apps/desk98/MinesView.vue
new file mode 100644
index 00000000..3672ac2f
--- /dev/null
+++ b/apps/desk98/MinesView.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desk98/NotepadView.vue b/apps/desk98/NotepadView.vue
new file mode 100644
index 00000000..e74386cc
--- /dev/null
+++ b/apps/desk98/NotepadView.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desk98/PopupPanel.vue b/apps/desk98/PopupPanel.vue
new file mode 100644
index 00000000..707a2d9e
--- /dev/null
+++ b/apps/desk98/PopupPanel.vue
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desk98/ShutdownView.vue b/apps/desk98/ShutdownView.vue
new file mode 100644
index 00000000..85242e8b
--- /dev/null
+++ b/apps/desk98/ShutdownView.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desk98/StartMenu.vue b/apps/desk98/StartMenu.vue
new file mode 100644
index 00000000..4eb83282
--- /dev/null
+++ b/apps/desk98/StartMenu.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desk98/T98.vue b/apps/desk98/T98.vue
new file mode 100644
index 00000000..1a828722
--- /dev/null
+++ b/apps/desk98/T98.vue
@@ -0,0 +1,17 @@
+
+
+
+ {{ props.t }}
+
diff --git a/apps/desk98/Taskbar.vue b/apps/desk98/Taskbar.vue
new file mode 100644
index 00000000..23a263ea
--- /dev/null
+++ b/apps/desk98/Taskbar.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desk98/Window98.vue b/apps/desk98/Window98.vue
new file mode 100644
index 00000000..587be263
--- /dev/null
+++ b/apps/desk98/Window98.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desk98/app.vue b/apps/desk98/app.vue
new file mode 100644
index 00000000..267fe5dd
--- /dev/null
+++ b/apps/desk98/app.vue
@@ -0,0 +1,1341 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desk98/fonts/w95fa-19.bin b/apps/desk98/fonts/w95fa-19.bin
new file mode 100644
index 00000000..65f505eb
Binary files /dev/null and b/apps/desk98/fonts/w95fa-19.bin differ
diff --git a/apps/desk98/fonts/w95fa-19@2x.bin b/apps/desk98/fonts/w95fa-19@2x.bin
new file mode 100644
index 00000000..66ad9c42
Binary files /dev/null and b/apps/desk98/fonts/w95fa-19@2x.bin differ
diff --git a/apps/desk98/fonts/w95fa-20.bin b/apps/desk98/fonts/w95fa-20.bin
new file mode 100644
index 00000000..f57ee7b1
Binary files /dev/null and b/apps/desk98/fonts/w95fa-20.bin differ
diff --git a/apps/desk98/fonts/w95fa-20@2x.bin b/apps/desk98/fonts/w95fa-20@2x.bin
new file mode 100644
index 00000000..2ed23cf6
Binary files /dev/null and b/apps/desk98/fonts/w95fa-20@2x.bin differ
diff --git a/apps/desk98/fonts/w95fa-21.bin b/apps/desk98/fonts/w95fa-21.bin
new file mode 100644
index 00000000..fda2c497
Binary files /dev/null and b/apps/desk98/fonts/w95fa-21.bin differ
diff --git a/apps/desk98/fonts/w95fa-21@2x.bin b/apps/desk98/fonts/w95fa-21@2x.bin
new file mode 100644
index 00000000..e819a62d
Binary files /dev/null and b/apps/desk98/fonts/w95fa-21@2x.bin differ
diff --git a/apps/desk98/gen-assets.ts b/apps/desk98/gen-assets.ts
new file mode 100644
index 00000000..32806bb7
--- /dev/null
+++ b/apps/desk98/gen-assets.ts
@@ -0,0 +1,215 @@
+// apps/desk98/gen-assets.ts — bakes W95FA (assets/fonts/W95FA.otf, OFL; the
+// FontsArena Win95 MS Sans Serif recreation) into per-app FONT ATLAS blobs,
+// committed under apps/desk98/fonts/ and spliced by pak.json:
+//
+// bun apps/desk98/gen-assets.ts
+//
+// Slots (repo slots 0..18 are Inter/JetBrains Mono; these live app-side):
+// 19 W95FA 12.5px regular — the whole desktop
+// 20 W95FA 12.5px synthetic bold (GDI smear: 1px max-blend + advance+1)
+// 21 W95FA 25px regular — the Start-menu banner
+//
+// W95FA is a bitmap-font conversion on an 80-units/px grid at its native
+// 12.5px, with sloppy CFF floats (…129.92 for 130) and a 10-unit x phase.
+// Coordinates snap to the 40-unit half-pixel grid minus that phase before
+// baking, so at rasterDensity 2 every stroke edge lands on a device pixel;
+// the bake then hard-thresholds coverage to 0/255 and asserts the AA
+// fraction was already negligible (the snap really aligned) and that
+// edge-sharing strokes survived the fill ('h' keeps its ascender — the
+// nonzero-winding guarantee this font's outlines depend on).
+
+import { parse as parseFont, type Font } from "opentype.js";
+import { mkdirSync } from "node:fs";
+import { join } from "node:path";
+import { bakeSlot, type BakedAtlas } from "../../framework/compiler/bake-font.ts";
+import {
+ FONT_CMAP_ENTRY_SIZE,
+ FONT_FLAG_BOLD,
+ FONT_HEADER_SIZE,
+} from "../../contracts/spec/spec.ts";
+
+const ROOT = new URL("../..", import.meta.url).pathname;
+const OUT = join(ROOT, "apps/desk98/fonts");
+mkdirSync(OUT, { recursive: true });
+
+const PHASE = 10; // measured x offset of the outline grid, font units
+const STEP = 40; // half-pixel grid at 12.5px (80 units/px)
+
+function snapCoord(v: number, phase: number): number {
+ return Math.round((v - phase) / STEP) * STEP;
+}
+
+async function loadSnapped(): Promise {
+ const font = parseFont(await Bun.file(join(ROOT, "assets/fonts/W95FA.otf")).arrayBuffer());
+ // Snap only the glyphs the bake reads — .notdef and unmapped decorations
+ // are off-grid and never rasterized (gid 0 is the drawn tofu box).
+ const gids = new Set();
+ for (const cp of CHARS) {
+ const gi = font.charToGlyphIndex(String.fromCodePoint(cp));
+ if (gi > 0) gids.add(gi);
+ }
+ for (const gi of gids) {
+ const glyph = font.glyphs.get(gi);
+ const path = glyph.path as { commands?: Array> };
+ for (const cmd of path?.commands ?? []) {
+ for (const key of ["x", "x1", "x2"]) {
+ if (typeof cmd[key] === "number") {
+ const s = snapCoord(cmd[key] as number, PHASE);
+ const err = Math.abs((cmd[key] as number) - PHASE - s);
+ // Two conversion strays ('"' and 'y') sit up to 16 units off the
+ // grid — snap them too (≤0.2px drift); anything worse is a new
+ // font revision and needs a fresh look.
+ if (err > 20) {
+ throw new Error(`gen-assets: gid ${gi} x=${cmd[key]} is off the ${STEP}-unit grid`);
+ }
+ cmd[key] = s;
+ }
+ }
+ for (const key of ["y", "y1", "y2"]) {
+ if (typeof cmd[key] === "number") cmd[key] = snapCoord(cmd[key] as number, 0);
+ }
+ }
+ }
+
+ // W95FA ships no backtick (cp 96 → .notdef, the drawn tofu box), but the
+ // desktop's ⌘` shortcut copy needs one. Synthesize it: mirror the
+ // apostrophe's ink around its own x bounds (the mark leans the other way;
+ // bounds are snapped 40-multiples, so the mirror stays on the pixel grid)
+ // into a donor glyph outside the ASCII set, and remap cp 96 onto it.
+ const apoGi = font.charToGlyphIndex("'");
+ const apo = font.glyphs.get(apoGi);
+ const apoCmds = (apo.path as { commands: Array> }).commands;
+ const xs: number[] = [];
+ for (const cmd of apoCmds) {
+ for (const key of ["x", "x1", "x2"]) {
+ if (typeof cmd[key] === "number") xs.push(cmd[key] as number);
+ }
+ }
+ const pivot = Math.min(...xs) + Math.max(...xs);
+ let donorGid = -1;
+ for (let gi = font.glyphs.length - 1; gi > 0; gi--) {
+ if (!gids.has(gi)) {
+ donorGid = gi;
+ break;
+ }
+ }
+ if (donorGid < 0) throw new Error("gen-assets: no donor gid for the synthetic backtick");
+ const donor = font.glyphs.get(donorGid);
+ const donorPath = donor.path as { commands: Array> };
+ donorPath.commands.length = 0;
+ for (const cmd of apoCmds) {
+ const m: Record = { ...cmd };
+ for (const key of ["x", "x1", "x2"]) {
+ if (typeof m[key] === "number") m[key] = pivot - (m[key] as number);
+ }
+ donorPath.commands.push(m);
+ }
+ donor.advanceWidth = apo.advanceWidth;
+ const origIndex = font.charToGlyphIndex.bind(font);
+ font.charToGlyphIndex = (s: string) => (s === "`" ? donorGid : origIndex(s));
+
+ return font;
+}
+
+const CHARS: number[] = [];
+for (let c = 32; c <= 126; c++) CHARS.push(c);
+
+/** Hard-threshold the coverage plane to 0/255 and report the AA fraction. */
+function threshold(atlas: BakedAtlas): number {
+ const cmapEnd = FONT_HEADER_SIZE + atlas.glyphCount * FONT_CMAP_ENTRY_SIZE;
+ let aa = 0;
+ let ink = 0;
+ for (let i = cmapEnd; i < atlas.bytes.length; i++) {
+ const b = atlas.bytes[i];
+ if (b > 0) ink++;
+ if (b > 0 && b < 255) {
+ aa++;
+ atlas.bytes[i] = b >= 128 ? 255 : 0;
+ }
+ }
+ return ink > 0 ? aa / ink : 0;
+}
+
+/** Coverage cell for a codepoint (gid-indexed cells after header + cmap). */
+function cellFor(atlas: BakedAtlas, cp: number): Uint8Array {
+ const view = new DataView(atlas.bytes.buffer, atlas.bytes.byteOffset, atlas.bytes.byteLength);
+ const cellBytes = atlas.coverageW * atlas.coverageH;
+ const cellsOff = FONT_HEADER_SIZE + atlas.glyphCount * FONT_CMAP_ENTRY_SIZE;
+ for (let i = 0; i < atlas.glyphCount; i++) {
+ const e = FONT_HEADER_SIZE + i * FONT_CMAP_ENTRY_SIZE;
+ if (view.getUint32(e, true) === cp) {
+ const gid = view.getUint16(e + 4, true);
+ return atlas.bytes.subarray(cellsOff + gid * cellBytes, cellsOff + (gid + 1) * cellBytes);
+ }
+ }
+ throw new Error(`gen-assets: codepoint ${cp} missing from the bake`);
+}
+
+/** Synthetic bold: widen cells by one logical px, max-blend a copy shifted
+ * one logical px right (the classic GDI smear), advance +1. Rebuilds the
+ * blob because cellW changes. */
+function embolden(src: BakedAtlas, slot: number): Uint8Array {
+ const d = src.rasterDensity;
+ const srcW = src.coverageW;
+ const dstCellW = src.cellW + 1;
+ const dstW = dstCellW * d;
+ const h = src.coverageH;
+ const cellBytesSrc = srcW * h;
+ const cellBytesDst = dstW * h;
+ const cmapBytes = src.glyphCount * FONT_CMAP_ENTRY_SIZE;
+ const out = new Uint8Array(FONT_HEADER_SIZE + cmapBytes + src.glyphCount * cellBytesDst);
+ out.set(src.bytes.subarray(0, FONT_HEADER_SIZE + cmapBytes));
+ out[8] = dstCellW;
+ out[12] = slot;
+ out[13] = FONT_FLAG_BOLD;
+ // Advances grow one logical px (clamped to the u8 the cmap carries).
+ for (let i = 0; i < src.glyphCount; i++) {
+ const a = FONT_HEADER_SIZE + i * FONT_CMAP_ENTRY_SIZE + 6;
+ out[a] = Math.min(255, out[a] + 1);
+ }
+ const srcCells = FONT_HEADER_SIZE + cmapBytes;
+ for (let g = 0; g < src.glyphCount; g++) {
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < dstW; x++) {
+ const at = (dx: number) => {
+ const sx = x - dx;
+ return sx >= 0 && sx < srcW
+ ? src.bytes[srcCells + g * cellBytesSrc + y * srcW + sx]
+ : 0;
+ };
+ out[srcCells + g * cellBytesDst + y * dstW + x] = Math.max(at(0), at(d));
+ }
+ }
+ }
+ return out;
+}
+
+const font = await loadSnapped();
+
+for (const density of [1, 2]) {
+ const suffix = density === 2 ? "@2x" : "";
+ const a19 = bakeSlot(font, 19, 12.5, false, CHARS, density);
+ const aa19 = threshold(a19);
+ const a21 = bakeSlot(font, 21, 25, false, CHARS, density);
+ const aa21 = threshold(a21);
+ // The snap guarantee: at density 2 every edge is a device pixel, so the
+ // rasterizer produced (near-)bi-level coverage BEFORE thresholding.
+ if (density === 2 && (aa19 > 0.02 || aa21 > 0.02)) {
+ throw new Error(`gen-assets: AA fraction too high (${aa19}, ${aa21}) — grid snap regressed?`);
+ }
+ // Fill-rule guarantee: W95FA strokes share edges; even-odd cancels them.
+ const hCell = cellFor(a19, "h".codePointAt(0)!);
+ const nCell = cellFor(a19, "n".codePointAt(0)!);
+ if (Buffer.from(hCell).equals(Buffer.from(nCell))) {
+ throw new Error("gen-assets: 'h' baked identical to 'n' — fill rule regressed to even-odd?");
+ }
+ if (!hCell.some((b) => b > 0)) throw new Error("gen-assets: 'h' baked empty");
+ const a20 = embolden(a19, 20);
+ await Bun.write(join(OUT, `w95fa-19${suffix}.bin`), a19.bytes);
+ await Bun.write(join(OUT, `w95fa-20${suffix}.bin`), a20);
+ await Bun.write(join(OUT, `w95fa-21${suffix}.bin`), a21.bytes);
+ console.log(
+ `gen-assets: density ${density} — slot19 ${a19.bytes.length}B (AA ${(aa19 * 100).toFixed(2)}%), ` +
+ `slot20 ${a20.length}B, slot21 ${a21.bytes.length}B (AA ${(aa21 * 100).toFixed(2)}%)`,
+ );
+}
diff --git a/apps/desk98/gen-icons.ts b/apps/desk98/gen-icons.ts
new file mode 100644
index 00000000..ce930638
--- /dev/null
+++ b/apps/desk98/gen-icons.ts
@@ -0,0 +1,562 @@
+// apps/desk98/gen-icons.ts — pixel-art icon source. ASCII grids compile to
+// crispEdges SVGs in apps/desk98/icons/ (committed; re-run on art changes):
+//
+// bun apps/desk98/gen-icons.ts
+//
+// One 16×16 grid per subject; desktop icons emit a second 32px file scaled
+// 2× so both stay one art. Caption glyphs and tiny hud art carry their own
+// grids at native size. The build then rasterizes each SVG at the plan's
+// density like any other asset — no hand-baked PNGs.
+
+import { mkdirSync } from "node:fs";
+import { join } from "node:path";
+
+const PAL: Record = {
+ k: "#000000",
+ w: "#ffffff",
+ g: "#c0c0c0",
+ d: "#808080",
+ e: "#dfdfdf",
+ y: "#fcd116",
+ Y: "#fcf080",
+ b: "#000080",
+ B: "#1084d0",
+ r: "#ff0000",
+ R: "#800000",
+ t: "#008080",
+ G: "#008000",
+ o: "#ff8000",
+ s: "#ffd800", // smiley yellow
+};
+
+interface Icon {
+ file: string;
+ rows: string[];
+ /** Also emit `${file}` at 2× under this name (desktop icons). */
+ big?: string;
+}
+
+function svgFor(rows: string[], scale: number): string {
+ const h = rows.length;
+ const w = Math.max(...rows.map((r) => r.length));
+ for (const r of rows) {
+ if (r.length !== w) throw new Error(`gen-icons: ragged grid (row "${r}" vs width ${w})`);
+ }
+ const byColor = new Map();
+ for (let y = 0; y < h; y++) {
+ const row = rows[y];
+ let x = 0;
+ while (x < row.length) {
+ const ch = row[x];
+ if (ch === "." || ch === " ") {
+ x++;
+ continue;
+ }
+ let run = 1;
+ while (x + run < row.length && row[x + run] === ch) run++;
+ const color = PAL[ch];
+ if (!color) throw new Error(`gen-icons: unknown palette char '${ch}'`);
+ const d = byColor.get(color) ?? [];
+ d.push(`M${x * scale} ${y * scale}h${run * scale}v${scale}h${-run * scale}z`);
+ byColor.set(color, d);
+ x += run;
+ }
+ }
+ const paths = [...byColor.entries()]
+ .map(([color, ds]) => ``)
+ .join("");
+ return `\n`;
+}
+
+const ICONS: Icon[] = [
+ {
+ // My Computer: a CRT over a slim base, navy screen with a sky glint.
+ file: "computer-16.svg",
+ big: "computer.svg",
+ rows: [
+ "................",
+ ".kkkkkkkkkkkkk..",
+ ".kggggggggggkk..",
+ ".kgkkkkkkkkgkk..",
+ ".kgkbbbbbbkgkk..",
+ ".kgkbBBbbbkgkk..",
+ ".kgkbBbbbbkgkk..",
+ ".kgkbbbbbbkgkk..",
+ ".kgkkkkkkkkgkk..",
+ ".kggggggggggkk..",
+ ".kkkkkkkkkkkkk..",
+ "......kggk......",
+ "....kkggggkk....",
+ "..kggggggggggk..",
+ "..kggkkkkkkggk..",
+ "..kkkkkkkkkkkk..",
+ ],
+ },
+ {
+ // My Documents: the two-tone yellow folder with a paper peeking out.
+ file: "folder-16.svg",
+ big: "documents.svg",
+ rows: [
+ "................",
+ "................",
+ ".kkkkk..........",
+ "kYYYYYkkkkkkkk..",
+ "kYYYYYYYYYYYYk..",
+ "kYwwwwwwwwwwYk..",
+ "kYwkkkwwkkwwYk..",
+ "kkkkkkkkkkkkkkk.",
+ "kyyyyyyyyyyyyyk.",
+ "kyYYYYYYYYYYYyk.",
+ "kyyyyyyyyyyyyyk.",
+ "kyyyyyyyyyyyyyk.",
+ "kyyyyyyyyyyyyyk.",
+ "kkkkkkkkkkkkkkk.",
+ "................",
+ "................",
+ ],
+ },
+ {
+ // Recycle Bin: gray basket, dark lid, recycle chevrons.
+ file: "recycle-16.svg",
+ big: "recycle.svg",
+ rows: [
+ "................",
+ "......kkkk......",
+ "....kkggggkk....",
+ "..kkggggggggkk..",
+ ".kddddddddddddk.",
+ ".kddddddddddddk.",
+ "..kggggggggggk..",
+ "..kgGGggggGGgk..",
+ "..kgGgggggGggk..",
+ "..kggGGgGGgggk..",
+ "..kgggGGGggggk..",
+ "..kggggGgggggk..",
+ "..kggggggggggk..",
+ "...kggggggggk...",
+ "...kkkkkkkkkk...",
+ "................",
+ ],
+ },
+ {
+ // Notepad: spiral pad, ruled lines.
+ file: "notepad-16.svg",
+ big: "notepad.svg",
+ rows: [
+ "................",
+ "..kdkdkdkdkdk...",
+ ".kwwwwwwwwwwwk..",
+ ".kwwwwwwwwwwwk..",
+ ".kwkkkkkkkkwwk..",
+ ".kwwwwwwwwwwwk..",
+ ".kwkkkkkkwwwwk..",
+ ".kwwwwwwwwwwwk..",
+ ".kwkkkkkkkwwwk..",
+ ".kwwwwwwwwwwwk..",
+ ".kwkkkkwwwwwwk..",
+ ".kwwwwwwwwwwwk..",
+ ".kwwwwwwwwwwwk..",
+ ".kkkkkkkkkkkkk..",
+ "................",
+ "................",
+ ],
+ },
+ {
+ // Minesweeper: a raised cell with a mine.
+ file: "mines-16.svg",
+ big: "mines.svg",
+ rows: [
+ "wwwwwwwwwwwwwwwd",
+ "wggggggggggggggd",
+ "wgggggggkggggggd",
+ "wgggkggkkkggkggd",
+ "wggggkkkkkkkgggd",
+ "wgggkkkwwkkkkggd",
+ "wgggkkwwkkkkkggd",
+ "wgkkkkwwkkkkkkgd",
+ "wgggkkkkkkkkkggd",
+ "wgggkkkkkkkkkggd",
+ "wggggkkkkkkkgggd",
+ "wgggkggkkkggkggd",
+ "wgggggggkggggggd",
+ "wggggggggggggggd",
+ "wggggggggggggggd",
+ "dddddddddddddddd",
+ ],
+ },
+ {
+ // Local disk.
+ file: "drive-16.svg",
+ rows: [
+ "................",
+ "................",
+ "................",
+ "................",
+ ".kkkkkkkkkkkkkk.",
+ ".kggggggggggggk.",
+ ".keeeeeeeeeeegk.",
+ ".kggggggggggggk.",
+ ".kgggggggggkgGk.",
+ ".kkkkkkkkkkkkkk.",
+ "................",
+ "................",
+ "................",
+ "................",
+ "................",
+ "................",
+ ],
+ },
+ {
+ // CD-ROM drive: a disc.
+ file: "cdrom-16.svg",
+ rows: [
+ "................",
+ "................",
+ ".....kkkkkk.....",
+ "...kkeeeeeekk...",
+ "..keeeeeeeeeek..",
+ "..keeewwweeeek..",
+ ".keeewgggweeeek.",
+ ".keewggkggweeek.",
+ ".keewgkwkgweeek.",
+ ".keewggkggweeek.",
+ ".keeewgggweeeek.",
+ "..keeewwweeeek..",
+ "..keeeeeeeeeek..",
+ "...kkeeeeeekk...",
+ ".....kkkkkk.....",
+ "................",
+ ],
+ },
+ {
+ // Plain document.
+ file: "file-16.svg",
+ rows: [
+ "................",
+ "..kkkkkkkkk.....",
+ "..kwwwwwwwkk....",
+ "..kwwwwwwwkgk...",
+ "..kwwwwwwwkkkk..",
+ "..kwkkkkkwwwwk..",
+ "..kwwwwwwwwwwk..",
+ "..kwkkkkkkkwwk..",
+ "..kwwwwwwwwwwk..",
+ "..kwkkkkkkwwwk..",
+ "..kwwwwwwwwwwk..",
+ "..kwkkkkkkkkwk..",
+ "..kwwwwwwwwwwk..",
+ "..kwwwwwwwwwwk..",
+ "..kkkkkkkkkkkk..",
+ "................",
+ ],
+ },
+ {
+ // Shut Down: a power key on a gray keycap.
+ file: "shutdown-16.svg",
+ big: "shutdown.svg",
+ rows: [
+ "................",
+ ".kkkkkkkkkkkkkk.",
+ ".kwwwwwwwwwwwgk.",
+ ".kwggggggggggdk.",
+ ".kwgggkkkgggggk.",
+ ".kwggkgkgkggggk.",
+ ".kwgkggkggkgggk.",
+ ".kwgkggkggkgggk.",
+ ".kwgkggggggkggk.",
+ ".kwgkggggggkggk.",
+ ".kwggkggggkgggk.",
+ ".kwgggkkkkggggk.",
+ ".kwggggggggggdk.",
+ ".kgddddddddddgk.",
+ ".kkkkkkkkkkkkkk.",
+ "................",
+ ],
+ },
+ {
+ // Settings: a gear.
+ file: "settings-16.svg",
+ rows: [
+ "................",
+ "......kk........",
+ "..kk.kggk.kk....",
+ "..kgkkggkkgk....",
+ "...kggggggk.....",
+ "..kkggkkggkk....",
+ ".kgggkwwkgggk...",
+ ".kgggkwwkgggk...",
+ "..kkggkkggkk....",
+ "...kggggggk.....",
+ "..kgkkggkkgk....",
+ "..kk.kggk.kk....",
+ "......kk........",
+ "................",
+ "................",
+ "................",
+ ],
+ },
+ {
+ // Find: a magnifier.
+ file: "find-16.svg",
+ rows: [
+ "................",
+ "...kkkk.........",
+ "..kwwwwk........",
+ ".kwggggwk.......",
+ ".kwgggggk.......",
+ ".kwgggggk.......",
+ ".kwggggwk.......",
+ "..kwwwwk........",
+ "...kkkkkk.......",
+ "......kkdk......",
+ ".......kddk.....",
+ "........kddk....",
+ ".........kddk...",
+ "..........kk....",
+ "................",
+ "................",
+ ],
+ },
+ {
+ // Help: a question mark on a page.
+ file: "help-16.svg",
+ rows: [
+ "................",
+ "....kkkkkk......",
+ "...kbbbbbbk.....",
+ "..kbbkkkbbbk....",
+ "..kbbk.kbbbk....",
+ "...kk..kbbbk....",
+ "......kbbbk.....",
+ ".....kbbbk......",
+ ".....kbbk.......",
+ ".....kbbk.......",
+ "......kk........",
+ ".....kbbk.......",
+ ".....kbbk.......",
+ "......kk........",
+ "................",
+ "................",
+ ],
+ },
+ {
+ // Run…: a command window.
+ file: "run-16.svg",
+ rows: [
+ "................",
+ ".kkkkkkkkkkkkk..",
+ ".kbbbbbbbbbbbk..",
+ ".kkkkkkkkkkkkk..",
+ ".kwwwwwwwwwwwk..",
+ ".kwkwwwwwwwwwk..",
+ ".kwkkwwwwwwwwk..",
+ ".kwkkkwwwwwwwk..",
+ ".kwkkwwkkkkwwk..",
+ ".kwkwwwwwwwwwk..",
+ ".kwwwwwwwwwwwk..",
+ ".kkkkkkkkkkkkk..",
+ "................",
+ "................",
+ "................",
+ "................",
+ ],
+ },
+ {
+ // Recycle 16 shares the desktop art; folder-16 doubles as Documents.
+ // Start logo: the PocketJS favicon motif (site/assets/favicon.svg) as a
+ // single-color pixel mark — rounded pocket frame, lens, two bars.
+ file: "start-logo.svg",
+ rows: [
+ "................",
+ "................",
+ "................",
+ "..kkkkkkkkkkkk..",
+ ".k............k.",
+ ".k............k.",
+ ".k..kk..kkkkk.k.",
+ ".k.kkkk.......k.",
+ ".k.kkkk.kkk...k.",
+ ".k..kk........k.",
+ ".k............k.",
+ ".k............k.",
+ "..kkkkkkkkkkkk..",
+ "................",
+ "................",
+ "................",
+ ],
+ },
+];
+
+// Native-size art (caption glyphs, hud bits) — no 2× variant. Pak textures
+// must be pow2, so every canvas is 8×8 or 16×16 with the art inside.
+const NATIVE: Icon[] = [
+ {
+ file: "cap-min.svg",
+ rows: ["........", "........", "........", "........", "........", ".kkkkkk.", ".kkkkkk.", "........"],
+ },
+ {
+ file: "cap-max.svg",
+ rows: ["kkkkkkkk", "kkkkkkkk", "k......k", "k......k", "k......k", "k......k", "k......k", "kkkkkkkk"],
+ },
+ {
+ file: "cap-restore.svg",
+ rows: [
+ "...kkkkk",
+ "...kkkkk",
+ "...k...k",
+ "kkkkk..k",
+ "kkkkkkkk",
+ "k...k...",
+ "k...k...",
+ "kkkkk...",
+ ],
+ },
+ {
+ file: "cap-close.svg",
+ rows: ["........", "kk....kk", ".kk..kk.", "..kkkk..", "...kk...", "..kkkk..", ".kk..kk.", "kk....kk"],
+ },
+ {
+ file: "menu-arrow.svg",
+ rows: ["........", "..k.....", "..kk....", "..kkk...", "..kkkk..", "..kkk...", "..kk....", "..k....."],
+ },
+ { file: "grip.svg", rows: grip16() },
+ {
+ file: "mine.svg",
+ rows: [
+ "...k....",
+ "..kkk.k.",
+ ".kkkkkk.",
+ "kkwkkkkk",
+ ".kkkkkk.",
+ "..kkk.k.",
+ "...k....",
+ "........",
+ ],
+ },
+ {
+ file: "flag.svg",
+ rows: [
+ "..rr....",
+ "rrrr....",
+ "..rr....",
+ "...k....",
+ "...k....",
+ "..kk....",
+ ".kkkkk..",
+ "kkkkkkk.",
+ ],
+ },
+ { file: "smile.svg", rows: face("smile") },
+ { file: "smile-ooh.svg", rows: face("ooh") },
+ { file: "smile-dead.svg", rows: face("dead") },
+ { file: "smile-cool.svg", rows: face("cool") },
+];
+
+/** Status-bar size grip: diagonal white/gray ridge pairs in the lower-right
+ * triangle of a 16×16 canvas. */
+function grip16(): string[] {
+ const N = 16;
+ const grid: string[][] = Array.from({ length: N }, () => Array(N).fill("."));
+ for (const k of [17, 21, 25, 29]) {
+ for (let x = 0; x < N; x++) {
+ const yw = k - x;
+ if (yw >= 4 && yw < N && x >= 4) grid[yw][x] = "w";
+ const yd = k + 1 - x;
+ if (yd >= 4 && yd < N && x >= 4) grid[yd][x] = "d";
+ }
+ }
+ return grid.map((r) => r.join(""));
+}
+
+/** Smiley faces built procedurally: a yellow disc with a black ring, then
+ * per-state eyes and mouth pixels — hand grids kept coming out ragged. */
+function face(kind: "smile" | "ooh" | "dead" | "cool"): string[] {
+ const N = 16;
+ const grid: string[][] = Array.from({ length: N }, () => Array(N).fill("."));
+ const c = (N - 1) / 2;
+ const inside = (x: number, y: number) => (x - c) ** 2 + (y - c) ** 2 <= 7.2 ** 2;
+ for (let y = 0; y < N; y++) {
+ for (let x = 0; x < N; x++) if (inside(x, y)) grid[y][x] = "s";
+ }
+ for (let y = 0; y < N; y++) {
+ for (let x = 0; x < N; x++) {
+ if (grid[y][x] !== "s") continue;
+ const edge =
+ !inside(x - 1, y) || !inside(x + 1, y) || !inside(x, y - 1) || !inside(x, y + 1);
+ if (edge) grid[y][x] = "k";
+ }
+ }
+ const px = (x: number, y: number) => {
+ grid[y][x] = "k";
+ };
+ if (kind === "dead") {
+ for (const ex of [4, 9]) {
+ px(ex, 4);
+ px(ex + 2, 4);
+ px(ex + 1, 5);
+ px(ex, 6);
+ px(ex + 2, 6);
+ }
+ // Frown.
+ for (let x = 6; x <= 9; x++) px(x, 10);
+ px(5, 11);
+ px(10, 11);
+ } else if (kind === "cool") {
+ // Sunglasses: one bar with two lenses.
+ for (let x = 2; x <= 13; x++) px(x, 5);
+ for (const lx of [3, 9]) {
+ for (let x = lx; x <= lx + 3; x++) {
+ px(x, 6);
+ px(x, 7);
+ }
+ }
+ for (let x = 6; x <= 9; x++) px(x, 12);
+ px(5, 11);
+ px(10, 11);
+ } else {
+ // Eyes.
+ for (const ex of [5, 10]) {
+ px(ex, 5);
+ px(ex, 6);
+ }
+ if (kind === "smile") {
+ for (let x = 6; x <= 9; x++) px(x, 12);
+ px(5, 11);
+ px(10, 11);
+ px(4, 10);
+ px(11, 10);
+ } else {
+ // "ooh": a small round mouth.
+ for (const [x, y] of [
+ [7, 9],
+ [8, 9],
+ [6, 10],
+ [9, 10],
+ [6, 11],
+ [9, 11],
+ [7, 12],
+ [8, 12],
+ ]) {
+ px(x, y);
+ }
+ }
+ }
+ return grid.map((r) => r.join(""));
+}
+
+const outDir = join(import.meta.dir, "icons");
+mkdirSync(outDir, { recursive: true });
+let count = 0;
+for (const icon of ICONS) {
+ await Bun.write(join(outDir, icon.file), svgFor(icon.rows, 1));
+ count++;
+ if (icon.big) {
+ await Bun.write(join(outDir, icon.big), svgFor(icon.rows, 2));
+ count++;
+ }
+}
+for (const icon of NATIVE) {
+ await Bun.write(join(outDir, icon.file), svgFor(icon.rows, 1));
+ count++;
+}
+console.log(`gen-icons: wrote ${count} SVGs to apps/desk98/icons/`);
diff --git a/apps/desk98/icons/cap-close.svg b/apps/desk98/icons/cap-close.svg
new file mode 100644
index 00000000..fc2e0652
--- /dev/null
+++ b/apps/desk98/icons/cap-close.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/cap-max.svg b/apps/desk98/icons/cap-max.svg
new file mode 100644
index 00000000..dcb63473
--- /dev/null
+++ b/apps/desk98/icons/cap-max.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/cap-min.svg b/apps/desk98/icons/cap-min.svg
new file mode 100644
index 00000000..1b164133
--- /dev/null
+++ b/apps/desk98/icons/cap-min.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/cap-restore.svg b/apps/desk98/icons/cap-restore.svg
new file mode 100644
index 00000000..ea5e2deb
--- /dev/null
+++ b/apps/desk98/icons/cap-restore.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/cdrom-16.svg b/apps/desk98/icons/cdrom-16.svg
new file mode 100644
index 00000000..cefb23ff
--- /dev/null
+++ b/apps/desk98/icons/cdrom-16.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/computer-16.svg b/apps/desk98/icons/computer-16.svg
new file mode 100644
index 00000000..cae26311
--- /dev/null
+++ b/apps/desk98/icons/computer-16.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/computer.svg b/apps/desk98/icons/computer.svg
new file mode 100644
index 00000000..e7ee4ed5
--- /dev/null
+++ b/apps/desk98/icons/computer.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/documents.svg b/apps/desk98/icons/documents.svg
new file mode 100644
index 00000000..e109acec
--- /dev/null
+++ b/apps/desk98/icons/documents.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/drive-16.svg b/apps/desk98/icons/drive-16.svg
new file mode 100644
index 00000000..50b35abd
--- /dev/null
+++ b/apps/desk98/icons/drive-16.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/file-16.svg b/apps/desk98/icons/file-16.svg
new file mode 100644
index 00000000..c1ab39eb
--- /dev/null
+++ b/apps/desk98/icons/file-16.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/find-16.svg b/apps/desk98/icons/find-16.svg
new file mode 100644
index 00000000..04aeda89
--- /dev/null
+++ b/apps/desk98/icons/find-16.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/flag.svg b/apps/desk98/icons/flag.svg
new file mode 100644
index 00000000..608636e0
--- /dev/null
+++ b/apps/desk98/icons/flag.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/folder-16.svg b/apps/desk98/icons/folder-16.svg
new file mode 100644
index 00000000..4b84d6c9
--- /dev/null
+++ b/apps/desk98/icons/folder-16.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/grip.svg b/apps/desk98/icons/grip.svg
new file mode 100644
index 00000000..3025ddce
--- /dev/null
+++ b/apps/desk98/icons/grip.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/help-16.svg b/apps/desk98/icons/help-16.svg
new file mode 100644
index 00000000..6af52a61
--- /dev/null
+++ b/apps/desk98/icons/help-16.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/menu-arrow.svg b/apps/desk98/icons/menu-arrow.svg
new file mode 100644
index 00000000..aae25464
--- /dev/null
+++ b/apps/desk98/icons/menu-arrow.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/mine.svg b/apps/desk98/icons/mine.svg
new file mode 100644
index 00000000..3ec34a9f
--- /dev/null
+++ b/apps/desk98/icons/mine.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/mines-16.svg b/apps/desk98/icons/mines-16.svg
new file mode 100644
index 00000000..764c774c
--- /dev/null
+++ b/apps/desk98/icons/mines-16.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/mines.svg b/apps/desk98/icons/mines.svg
new file mode 100644
index 00000000..1864289f
--- /dev/null
+++ b/apps/desk98/icons/mines.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/notepad-16.svg b/apps/desk98/icons/notepad-16.svg
new file mode 100644
index 00000000..ef12913a
--- /dev/null
+++ b/apps/desk98/icons/notepad-16.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/notepad.svg b/apps/desk98/icons/notepad.svg
new file mode 100644
index 00000000..84896053
--- /dev/null
+++ b/apps/desk98/icons/notepad.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/recycle-16.svg b/apps/desk98/icons/recycle-16.svg
new file mode 100644
index 00000000..54241f3b
--- /dev/null
+++ b/apps/desk98/icons/recycle-16.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/recycle.svg b/apps/desk98/icons/recycle.svg
new file mode 100644
index 00000000..99470652
--- /dev/null
+++ b/apps/desk98/icons/recycle.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/run-16.svg b/apps/desk98/icons/run-16.svg
new file mode 100644
index 00000000..4667bd4f
--- /dev/null
+++ b/apps/desk98/icons/run-16.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/settings-16.svg b/apps/desk98/icons/settings-16.svg
new file mode 100644
index 00000000..cd74e753
--- /dev/null
+++ b/apps/desk98/icons/settings-16.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/shutdown-16.svg b/apps/desk98/icons/shutdown-16.svg
new file mode 100644
index 00000000..8895d607
--- /dev/null
+++ b/apps/desk98/icons/shutdown-16.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/shutdown.svg b/apps/desk98/icons/shutdown.svg
new file mode 100644
index 00000000..eed64d4e
--- /dev/null
+++ b/apps/desk98/icons/shutdown.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/smile-cool.svg b/apps/desk98/icons/smile-cool.svg
new file mode 100644
index 00000000..8ca8c41d
--- /dev/null
+++ b/apps/desk98/icons/smile-cool.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/smile-dead.svg b/apps/desk98/icons/smile-dead.svg
new file mode 100644
index 00000000..101a9526
--- /dev/null
+++ b/apps/desk98/icons/smile-dead.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/smile-ooh.svg b/apps/desk98/icons/smile-ooh.svg
new file mode 100644
index 00000000..b0708216
--- /dev/null
+++ b/apps/desk98/icons/smile-ooh.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/smile.svg b/apps/desk98/icons/smile.svg
new file mode 100644
index 00000000..9959b262
--- /dev/null
+++ b/apps/desk98/icons/smile.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/icons/start-logo.svg b/apps/desk98/icons/start-logo.svg
new file mode 100644
index 00000000..f02fec14
--- /dev/null
+++ b/apps/desk98/icons/start-logo.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desk98/main.ts b/apps/desk98/main.ts
new file mode 100644
index 00000000..a127ab9a
--- /dev/null
+++ b/apps/desk98/main.ts
@@ -0,0 +1,8 @@
+// apps/desk98/main.ts — PocketJS 98 entry point. Only the mount lives here:
+// the framework-runtime import stays confined to this file (app-check
+// typechecks the entry's whole graph; the prelude's globalThis reshaping
+// belongs to the runtime, not to app modules).
+import { mount } from "@pocketjs/framework/vue-vapor";
+import App from "./app.vue";
+
+mount(App);
diff --git a/apps/desk98/mines.ts b/apps/desk98/mines.ts
new file mode 100644
index 00000000..eef57865
--- /dev/null
+++ b/apps/desk98/mines.ts
@@ -0,0 +1,132 @@
+// apps/desk98/mines.ts — pure Minesweeper rules (beginner 9×9, 10 mines).
+// No Solid, no framework imports — unit-tested directly. First reveal is
+// always safe: mines place after it, excluding the clicked cell.
+
+export const MINES_W = 9;
+export const MINES_H = 9;
+export const MINES_N = 10;
+
+export type CellState = "hidden" | "flag" | "revealed";
+
+export interface Cell {
+ mine: boolean;
+ adj: number;
+ state: CellState;
+}
+
+export type Phase = "ready" | "playing" | "won" | "lost";
+
+export interface Mines {
+ cells: Cell[]; // row-major MINES_W × MINES_H
+ phase: Phase;
+ flags: number;
+ revealed: number;
+ /** Index of the mine that ended the game (drawn red), -1 otherwise. */
+ bust: number;
+ seed: number;
+}
+
+/** mulberry32 — deterministic placement from a seed. */
+function rng(seed: number): () => number {
+ let a = seed >>> 0;
+ return () => {
+ a = (a + 0x6d2b79f5) | 0;
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+export function newMines(seed: number): Mines {
+ return {
+ cells: Array.from({ length: MINES_W * MINES_H }, () => ({
+ mine: false,
+ adj: 0,
+ state: "hidden" as CellState,
+ })),
+ phase: "ready",
+ flags: 0,
+ revealed: 0,
+ bust: -1,
+ seed,
+ };
+}
+
+function neighbors(i: number): number[] {
+ const x = i % MINES_W;
+ const y = (i / MINES_W) | 0;
+ const out: number[] = [];
+ for (let dy = -1; dy <= 1; dy++) {
+ for (let dx = -1; dx <= 1; dx++) {
+ if (dx === 0 && dy === 0) continue;
+ const nx = x + dx;
+ const ny = y + dy;
+ if (nx >= 0 && nx < MINES_W && ny >= 0 && ny < MINES_H) out.push(ny * MINES_W + nx);
+ }
+ }
+ return out;
+}
+
+function place(m: Mines, safe: number): void {
+ const rand = rng(m.seed);
+ let planted = 0;
+ while (planted < MINES_N) {
+ const i = Math.floor(rand() * MINES_W * MINES_H);
+ if (i === safe || m.cells[i].mine) continue;
+ m.cells[i].mine = true;
+ planted++;
+ }
+ for (let i = 0; i < m.cells.length; i++) {
+ m.cells[i].adj = neighbors(i).filter((n) => m.cells[n].mine).length;
+ }
+}
+
+/** Reveal a cell (mutates and returns m). Floods zero-adjacency regions. */
+export function reveal(m: Mines, i: number): Mines {
+ if (m.phase === "won" || m.phase === "lost") return m;
+ const c = m.cells[i];
+ if (c.state !== "hidden") return m;
+ if (m.phase === "ready") {
+ place(m, i);
+ m.phase = "playing";
+ }
+ if (c.mine) {
+ m.phase = "lost";
+ m.bust = i;
+ for (const cell of m.cells) {
+ if (cell.mine && cell.state !== "flag") cell.state = "revealed";
+ }
+ return m;
+ }
+ const stack = [i];
+ while (stack.length > 0) {
+ const j = stack.pop()!;
+ const cj = m.cells[j];
+ if (cj.state === "revealed" || cj.state === "flag" || cj.mine) continue;
+ cj.state = "revealed";
+ m.revealed++;
+ if (cj.adj === 0) for (const n of neighbors(j)) stack.push(n);
+ }
+ if (m.revealed === MINES_W * MINES_H - MINES_N) {
+ m.phase = "won";
+ // Convention: flag every remaining mine on a win.
+ for (const cell of m.cells) if (cell.mine) cell.state = "flag";
+ m.flags = MINES_N;
+ }
+ return m;
+}
+
+/** Toggle a flag (right-click). */
+export function toggleFlag(m: Mines, i: number): Mines {
+ if (m.phase === "won" || m.phase === "lost") return m;
+ const c = m.cells[i];
+ if (c.state === "revealed") return m;
+ if (c.state === "flag") {
+ c.state = "hidden";
+ m.flags--;
+ } else {
+ c.state = "flag";
+ m.flags++;
+ }
+ return m;
+}
diff --git a/apps/desk98/notepad.ts b/apps/desk98/notepad.ts
new file mode 100644
index 00000000..fc1212dc
--- /dev/null
+++ b/apps/desk98/notepad.ts
@@ -0,0 +1,212 @@
+// apps/desk98/notepad.ts — pure Notepad line-editing rules (wrap off, like
+// the 98 default): insertion, deletion, caret movement and the selection
+// model (anchor + caret). No framework imports — unit-tested directly.
+
+export interface Caret {
+ row: number;
+ col: number;
+}
+
+export interface Doc {
+ lines: string[];
+ caret: Caret;
+ /** Selection anchor (the other end of the range); null/absent = none.
+ * Every edit collapses it — edited Docs never carry a stale anchor. */
+ anchor?: Caret | null;
+}
+
+// ---------------------------------------------------------------------------
+// Selection
+// ---------------------------------------------------------------------------
+
+function cmpCaret(a: Caret, b: Caret): number {
+ return a.row !== b.row ? a.row - b.row : a.col - b.col;
+}
+
+/** The ordered selection range, null when collapsed or absent. */
+export function selRange(doc: Doc): { from: Caret; to: Caret } | null {
+ if (!doc.anchor) return null;
+ const c = cmpCaret(doc.anchor, doc.caret);
+ if (c === 0) return null;
+ return c < 0 ? { from: doc.anchor, to: doc.caret } : { from: doc.caret, to: doc.anchor };
+}
+
+export function hasSel(doc: Doc): boolean {
+ return selRange(doc) !== null;
+}
+
+export function selectedText(doc: Doc): string {
+ const r = selRange(doc);
+ if (!r) return "";
+ if (r.from.row === r.to.row) return doc.lines[r.from.row].slice(r.from.col, r.to.col);
+ const parts = [doc.lines[r.from.row].slice(r.from.col)];
+ for (let row = r.from.row + 1; row < r.to.row; row++) parts.push(doc.lines[row]);
+ parts.push(doc.lines[r.to.row].slice(0, r.to.col));
+ return parts.join("\n");
+}
+
+/** Delete the selected range; caret lands at its start. No-op when none. */
+export function deleteSel(doc: Doc): Doc {
+ const r = selRange(doc);
+ if (!r) return { lines: doc.lines, caret: doc.caret };
+ const lines = doc.lines.slice();
+ const merged = lines[r.from.row].slice(0, r.from.col) + lines[r.to.row].slice(r.to.col);
+ lines.splice(r.from.row, r.to.row - r.from.row + 1, merged);
+ return { lines, caret: { row: r.from.row, col: r.from.col } };
+}
+
+export function selectAll(doc: Doc): Doc {
+ const last = doc.lines.length - 1;
+ return {
+ lines: doc.lines,
+ caret: { row: last, col: doc.lines[last].length },
+ anchor: { row: 0, col: 0 },
+ };
+}
+
+/** Column range of the word (or whitespace run) around col — double-click. */
+export function wordRangeAt(line: string, col: number): { from: number; to: number } {
+ if (line.length === 0) return { from: 0, to: 0 };
+ const i = Math.max(0, Math.min(col, line.length - 1));
+ const wordish = (ch: string) => /[\w]/.test(ch);
+ const cls = wordish(line[i]) ? 1 : line[i] === " " ? 0 : 2;
+ const same = (ch: string) => (cls === 1 ? wordish(ch) : cls === 0 ? ch === " " : !wordish(ch) && ch !== " ");
+ let from = i;
+ let to = i + 1;
+ while (from > 0 && same(line[from - 1])) from--;
+ while (to < line.length && same(line[to])) to++;
+ return { from, to };
+}
+
+/** The selected column span on one row, null when the row has none. Rows
+ * strictly inside a multi-row selection select their full length. */
+export function rowSelSpan(doc: Doc, row: number): { from: number; to: number } | null {
+ const r = selRange(doc);
+ if (!r || row < r.from.row || row > r.to.row) return null;
+ const from = row === r.from.row ? r.from.col : 0;
+ const to = row === r.to.row ? r.to.col : doc.lines[row].length;
+ return from === to ? null : { from, to };
+}
+
+// ---------------------------------------------------------------------------
+// Editing
+// ---------------------------------------------------------------------------
+
+function clampCaret(lines: string[], caret: Caret): Caret {
+ const row = Math.max(0, Math.min(caret.row, lines.length - 1));
+ const col = Math.max(0, Math.min(caret.col, lines[row].length));
+ return { row, col };
+}
+
+/** Insert typed text at the caret (replacing any selection); \n splits. */
+export function insertText(doc: Doc, s: string): Doc {
+ if (hasSel(doc)) doc = deleteSel(doc);
+ let { lines, caret } = doc;
+ lines = lines.slice();
+ caret = clampCaret(lines, caret);
+ const parts = s.replace(/\r\n?/g, "\n").split("\n");
+ const line = lines[caret.row];
+ const before = line.slice(0, caret.col);
+ const after = line.slice(caret.col);
+ if (parts.length === 1) {
+ lines[caret.row] = before + parts[0] + after;
+ return { lines, caret: { row: caret.row, col: caret.col + parts[0].length } };
+ }
+ const inserted = [before + parts[0], ...parts.slice(1, -1), parts[parts.length - 1] + after];
+ lines.splice(caret.row, 1, ...inserted);
+ return {
+ lines,
+ caret: { row: caret.row + parts.length - 1, col: parts[parts.length - 1].length },
+ };
+}
+
+export function backspace(doc: Doc): Doc {
+ if (hasSel(doc)) return deleteSel(doc);
+ let { lines, caret } = doc;
+ lines = lines.slice();
+ caret = clampCaret(lines, caret);
+ if (caret.col > 0) {
+ const line = lines[caret.row];
+ lines[caret.row] = line.slice(0, caret.col - 1) + line.slice(caret.col);
+ return { lines, caret: { row: caret.row, col: caret.col - 1 } };
+ }
+ if (caret.row === 0) return { lines, caret };
+ const col = lines[caret.row - 1].length;
+ lines[caret.row - 1] += lines[caret.row];
+ lines.splice(caret.row, 1);
+ return { lines, caret: { row: caret.row - 1, col } };
+}
+
+export function del(doc: Doc): Doc {
+ if (hasSel(doc)) return deleteSel(doc);
+ let { lines, caret } = doc;
+ lines = lines.slice();
+ caret = clampCaret(lines, caret);
+ const line = lines[caret.row];
+ if (caret.col < line.length) {
+ lines[caret.row] = line.slice(0, caret.col) + line.slice(caret.col + 1);
+ return { lines, caret };
+ }
+ if (caret.row === lines.length - 1) return { lines, caret };
+ lines[caret.row] += lines[caret.row + 1];
+ lines.splice(caret.row + 1, 1);
+ return { lines, caret };
+}
+
+export type CaretMove = "Left" | "Right" | "Up" | "Down" | "Home" | "End";
+
+export function moveCaret(doc: Doc, key: CaretMove): Caret {
+ const { lines } = doc;
+ const caret = clampCaret(lines, doc.caret);
+ switch (key) {
+ case "Left":
+ if (caret.col > 0) return { row: caret.row, col: caret.col - 1 };
+ if (caret.row > 0) return { row: caret.row - 1, col: lines[caret.row - 1].length };
+ return caret;
+ case "Right":
+ if (caret.col < lines[caret.row].length) return { row: caret.row, col: caret.col + 1 };
+ if (caret.row < lines.length - 1) return { row: caret.row + 1, col: 0 };
+ return caret;
+ case "Up":
+ return caret.row > 0 ? clampCaret(lines, { row: caret.row - 1, col: caret.col }) : caret;
+ case "Down":
+ return caret.row < lines.length - 1
+ ? clampCaret(lines, { row: caret.row + 1, col: caret.col })
+ : caret;
+ case "Home":
+ return { row: caret.row, col: 0 };
+ case "End":
+ return { row: caret.row, col: lines[caret.row].length };
+ }
+}
+
+/** One caret move, selection-aware. `extend` (shift held) keeps or plants
+ * the anchor; a plain horizontal move over a selection collapses to the
+ * matching edge (the standard behavior), vertical moves step from it. */
+export function applyMove(doc: Doc, key: CaretMove, extend: boolean): Doc {
+ if (extend) {
+ const anchor = doc.anchor ?? doc.caret;
+ return { lines: doc.lines, caret: moveCaret(doc, key), anchor };
+ }
+ const r = selRange(doc);
+ if (r) {
+ const edge = key === "Left" || key === "Up" || key === "Home" ? r.from : r.to;
+ if (key === "Left" || key === "Right") return { lines: doc.lines, caret: edge };
+ return { lines: doc.lines, caret: moveCaret({ lines: doc.lines, caret: edge }, key) };
+ }
+ return { lines: doc.lines, caret: moveCaret(doc, key) };
+}
+
+/** Caret column from a click x, given per-prefix pixel widths. `measure`
+ * returns the width of a string prefix; binary-search-free linear scan is
+ * fine at Notepad line lengths. */
+export function colFromX(line: string, x: number, measure: (s: string) => number): number {
+ if (x <= 0) return 0;
+ let prev = 0;
+ for (let i = 1; i <= line.length; i++) {
+ const w = measure(line.slice(0, i));
+ if (x < (prev + w) / 2) return i - 1;
+ prev = w;
+ }
+ return x >= prev ? line.length : line.length;
+}
diff --git a/apps/desk98/pak.json b/apps/desk98/pak.json
new file mode 100644
index 00000000..8b5da2a4
--- /dev/null
+++ b/apps/desk98/pak.json
@@ -0,0 +1,5 @@
+[
+ { "key": "ui:font.19", "file": "fonts/w95fa-19.bin" },
+ { "key": "ui:font.20", "file": "fonts/w95fa-20.bin" },
+ { "key": "ui:font.21", "file": "fonts/w95fa-21.bin" }
+]
diff --git a/apps/desk98/pocket.config.ts b/apps/desk98/pocket.config.ts
new file mode 100644
index 00000000..c3de12b3
--- /dev/null
+++ b/apps/desk98/pocket.config.ts
@@ -0,0 +1,20 @@
+// apps/desk98/pocket.config.ts — the Notepad caret reuses the note app's
+// square-wave blink: 500 ms on, 500 ms off, hard edges. Long constant
+// segments keep the DrawList byte-stable between edges so the demand-
+// rendering host paints ~2 frames a second while the caret rests
+// (docs/BACKENDS.md governor discipline).
+import { definePocketConfig } from "@pocketjs/framework/config";
+
+export default definePocketConfig({
+ theme: {
+ keyframes: {
+ caret: {
+ "0%,49.99%": { opacity: 1 },
+ "50%,100%": { opacity: 0 },
+ },
+ },
+ animation: {
+ caret: "caret 1s linear infinite",
+ },
+ },
+});
diff --git a/apps/desk98/pocket.json b/apps/desk98/pocket.json
new file mode 100644
index 00000000..aba01973
--- /dev/null
+++ b/apps/desk98/pocket.json
@@ -0,0 +1,43 @@
+{
+ "$schema": "https://pocketjs.dev/schema/pocket-2.json",
+ "pocket": 2,
+ "id": "dev.pocket-stack.desk98",
+ "name": "pocket-desk98",
+ "title": "PocketJS 98",
+ "version": "0.1.0",
+ "engine": {
+ "capabilities": {
+ "requires": [
+ "text.glyphs.baked",
+ "input.buttons"
+ ],
+ "enhances": [
+ "input.text",
+ "input.pointer",
+ "input.ime",
+ "host.clipboard",
+ "display.viewport.live"
+ ]
+ }
+ },
+ "app": {
+ "entry": "apps/desk98/main.ts",
+ "output": "desk98-main",
+ "framework": "vue-vapor",
+ "companions": [
+ "desk"
+ ],
+ "viewport": {
+ "dynamic": {
+ "default": [
+ 800,
+ 600
+ ],
+ "min": [
+ 640,
+ 480
+ ]
+ }
+ }
+ }
+}
diff --git a/apps/desk98/programs.ts b/apps/desk98/programs.ts
new file mode 100644
index 00000000..3a8fca25
--- /dev/null
+++ b/apps/desk98/programs.ts
@@ -0,0 +1,97 @@
+// apps/desk98/programs.ts — pure program geometry: content-local hit helpers
+// and metrics for Notepad, Minesweeper, the folder view and the dialogs. The
+// matching views live in the *.vue components; render geometry and hit
+// geometry stay in one place by sharing these constants. Content coordinates
+// are (cx, cy) from wm.ts hitRegion — origin at the frame's inner top-left,
+// below caption (and menu bar if present).
+
+import { getOps } from "@pocketjs/framework/host";
+import { FONT } from "./theme.ts";
+import { MINES_W } from "./mines.ts";
+
+export function measure(s: string): number {
+ const ops = getOps();
+ return ops.measureText ? ops.measureText(s, FONT) : s.length * 7;
+}
+
+// ---------------------------------------------------------------------------
+// Notepad
+// ---------------------------------------------------------------------------
+
+export const PAD_LINE_H = 16;
+export const PAD_PAD = 3; // inset of the text from the white well
+
+// ---------------------------------------------------------------------------
+// Minesweeper — fixed-size window; all metrics in content-local px.
+// ---------------------------------------------------------------------------
+
+export const MINES_GEO = { w: 166, h: 227 } as const;
+const M_PAD = 5; // content padding
+const M_HEADER_H = 36;
+const M_FIELD_TOP = M_PAD + M_HEADER_H + 6; // header + gap
+const M_CELL = 16;
+const M_CELLS_X = M_PAD + 3; // field bevel-w-[3] ring
+const M_CELLS_Y = M_FIELD_TOP + 3;
+
+export type MinesHit = { type: "cell"; i: number } | { type: "smiley" } | null;
+
+export function minesHit(cx: number, cy: number): MinesHit {
+ const sx = 160 / 2 - 13;
+ if (cx >= sx && cx < sx + 26 && cy >= M_PAD + 5 && cy < M_PAD + 5 + 26) {
+ return { type: "smiley" };
+ }
+ const x = Math.floor((cx - M_CELLS_X) / M_CELL);
+ const y = Math.floor((cy - M_CELLS_Y) / M_CELL);
+ if (x >= 0 && x < 9 && y >= 0 && y < 9) return { type: "cell", i: y * MINES_W + x };
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+// Folder (Explorer details view)
+// ---------------------------------------------------------------------------
+
+export const FOLDER_HEADER_H = 17;
+export const FOLDER_ROW_H = 17;
+export const FOLDER_STATUS_H = 20;
+
+/** Row index for a content-local click inside the list, -1 none. */
+export function folderRowAt(cy: number, rowCount: number): number {
+ const i = Math.floor((cy - 1 - FOLDER_HEADER_H) / FOLDER_ROW_H);
+ return i >= 0 && i < rowCount ? i : -1;
+}
+
+// ---------------------------------------------------------------------------
+// About + Shut Down dialogs
+// ---------------------------------------------------------------------------
+
+export const ABOUT_GEO = { w: 340, h: 216 } as const;
+export const SHUTDOWN_GEO = { w: 300, h: 176 } as const;
+
+/** Content-local button hits for the About dialog. */
+export function aboutHit(contentW: number, contentH: number, cx: number, cy: number): "ok" | null {
+ const x = contentW - 10 - 75;
+ const y = contentH - 10 - 23;
+ return cx >= x && cx < x + 75 && cy >= y && cy < y + 23 ? "ok" : null;
+}
+
+export type ShutdownHit = "ok" | "cancel" | "radio0" | "radio1" | null;
+
+export function shutdownHit(
+ contentW: number,
+ contentH: number,
+ cx: number,
+ cy: number,
+): ShutdownHit {
+ const by = contentH - 10 - 23;
+ const cancelX = contentW - 10 - 75;
+ const okX = cancelX - 6 - 75;
+ if (cy >= by && cy < by + 23) {
+ if (cx >= okX && cx < okX + 75) return "ok";
+ if (cx >= cancelX && cx < cancelX + 75) return "cancel";
+ }
+ for (const i of [0, 1]) {
+ const ry = 46 + i * 20;
+ if (cx >= 56 && cx < 220 && cy >= ry && cy < ry + 18) return i === 0 ? "radio0" : "radio1";
+ }
+ return null;
+}
diff --git a/apps/desk98/screenshot.png b/apps/desk98/screenshot.png
new file mode 100644
index 00000000..93522dd2
Binary files /dev/null and b/apps/desk98/screenshot.png differ
diff --git a/apps/desk98/state.ts b/apps/desk98/state.ts
new file mode 100644
index 00000000..37a97f44
--- /dev/null
+++ b/apps/desk98/state.ts
@@ -0,0 +1,152 @@
+// apps/desk98/state.ts — compositor state: window controls, popups, desktop
+// icons. Hot geometry lives in per-window refs so a drag re-evaluates one
+// window's style binding, not the world; the window LIST only changes on
+// open/close (a reorder would rebuild the layout tree — z rides zIndex).
+
+import { ref, shallowRef, type Ref, type ShallowRef } from "vue";
+import type { CaptionButton, Geo } from "./wm.ts";
+import type { Doc } from "./notepad.ts";
+import type { Mines } from "./mines.ts";
+
+export type WinKind = "notepad" | "mines" | "folder" | "about" | "shutdown";
+
+export interface MenuDef {
+ label: string;
+ /** Hit width in px (measured at open — mirrors the render's px-[6] pads). */
+ width: number;
+ /** Built at open — disabled states follow live state (selection, …). */
+ items: () => PopupItem[];
+}
+
+export interface PopupItem {
+ label: string;
+ icon?: string;
+ shortcut?: string;
+ disabled?: boolean;
+ sep?: boolean;
+ sub?: PopupItem[];
+ act?: () => void;
+}
+
+export interface Popup {
+ x: number;
+ y: number;
+ w: number;
+ items: PopupItem[];
+}
+
+export interface WinCtl {
+ id: number;
+ kind: WinKind;
+ title: string;
+ icon: string;
+ buttons: readonly CaptionButton[];
+ resizable: boolean;
+ minW: number;
+ minH: number;
+ menus: MenuDef[] | null;
+ geo: ShallowRef;
+ z: Ref;
+ minimized: Ref;
+ maximized: Ref;
+ /** Geometry to restore on un-maximize. */
+ restoreGeo: Geo | null;
+ pressedBtn: Ref;
+ /** Open menu-bar index, -1 closed. */
+ openMenu: Ref;
+ /** Program-specific state bag (PadData, MinesData, …). */
+ data: unknown;
+}
+
+// Program state bags. Vue refs instead of accessor/setter pairs — templates
+// read `.value` explicitly (refs nested in objects never auto-unwrap).
+
+export interface PadData {
+ kind: "notepad";
+ doc: ShallowRef;
+ scroll: Ref;
+ preedit: Ref<{ s: string; c: number } | null>;
+}
+
+export interface MinesData {
+ kind: "mines";
+ /** Mutated in place by mines.ts rules — re-assign + triggerRef to paint. */
+ board: ShallowRef;
+ /** Cell index held by the primary button, -1 none. */
+ held: Ref;
+ smileyHeld: Ref;
+ /** Seconds shown by the timer (app.vue advances it while playing). */
+ elapsed: Ref;
+}
+
+export interface FolderRow {
+ icon: string;
+ name: string;
+ size: string;
+ type: string;
+ open?: () => void;
+}
+
+export interface FolderData {
+ kind: "folder";
+ rows: FolderRow[];
+ selected: Ref;
+}
+
+export interface AboutData {
+ kind: "about";
+ armed: Ref;
+}
+
+export interface ShutdownData {
+ kind: "shutdown";
+ choice: Ref;
+ armed: Ref;
+}
+
+export interface TaskEntry {
+ id: number;
+ title: string;
+ icon: string;
+}
+
+export interface DeskIcon {
+ icon: string;
+ label: string;
+ open: () => void;
+}
+
+let nextId = 1;
+
+export function createWin(spec: {
+ kind: WinKind;
+ title: string;
+ icon: string;
+ geo: Geo;
+ buttons?: readonly CaptionButton[];
+ resizable?: boolean;
+ minW?: number;
+ minH?: number;
+ menus?: MenuDef[] | null;
+ data?: unknown;
+}): WinCtl {
+ return {
+ id: nextId++,
+ kind: spec.kind,
+ title: spec.title,
+ icon: spec.icon,
+ buttons: spec.buttons ?? ["min", "max", "close"],
+ resizable: spec.resizable ?? true,
+ minW: spec.minW ?? 200,
+ minH: spec.minH ?? 120,
+ menus: spec.menus ?? null,
+ geo: shallowRef(spec.geo),
+ z: ref(0),
+ minimized: ref(false),
+ maximized: ref(false),
+ restoreGeo: null,
+ pressedBtn: ref(null),
+ openMenu: ref(-1),
+ data: spec.data,
+ };
+}
diff --git a/apps/desk98/svc.ts b/apps/desk98/svc.ts
new file mode 100644
index 00000000..89817a22
--- /dev/null
+++ b/apps/desk98/svc.ts
@@ -0,0 +1,102 @@
+// apps/desk98/svc.ts — the desk companion protocol over the spec svc channel
+// (HostOps svcOpen/svcPoll/svcSend), the note dialect's input lines extended
+// for a desktop compositor. hosts/macos speaks it when the plan's companion
+// list names "desk".
+//
+// host → guest lines (superset of apps/note/svc.ts):
+// {t:"hello", w, h, epoch} viewport at boot + wall-clock ms (taskbar clock)
+// {t:"resize", w, h} live window resize
+// {t:"ch", s} typed characters
+// {t:"key", k, sh, alt, ctl} named key + modifiers (adds F1..F12)
+// {t:"key", k, cmd:true} ⌘ chord — k is the raw lowercase key ("w",
+// "m", "`", "c", …); ⌘Q/⌘V stay host-side
+// {t:"paste", text} system clipboard (⌘V or a paste-req reply)
+// {t:"ime", s, c} IME preedit + caret char index (null clears)
+// {t:"mouse", x, y, d, sh} primary-button pointer stream
+// {t:"mouse", x, y, d, b:2} right-button press/release (desk-only lines)
+// {t:"scroll", dy} wheel delta in logical px
+//
+// guest → host intents:
+// {t:"quit"} Shut Down
+// {t:"copy", text} put text on the system clipboard
+// {t:"paste-req"} ask for the clipboard (host answers {t:"paste"})
+// {t:"caret", x, y, h} caret rect — docks the IME candidate window
+// {t:"cursor", k} pointer shape: default|text|pointer|move|
+// grabbing|ew|ns|nwse|nesw
+
+import { getOps } from "@pocketjs/framework";
+
+export interface HostEvent {
+ t: "hello" | "resize" | "ch" | "key" | "mouse" | "scroll" | "paste" | "ime";
+ w?: number;
+ h?: number;
+ epoch?: number;
+ s?: string;
+ k?: string;
+ x?: number;
+ y?: number;
+ /** Button state for "mouse" lines (the b-button's state, primary if no b). */
+ d?: boolean;
+ /** Mouse button: undefined/1 = primary, 2 = right. */
+ b?: number;
+ sh?: boolean;
+ alt?: boolean;
+ ctl?: boolean;
+ /** ⌘ held — k is then the raw lowercase key name ("w", "m", "`", …). */
+ cmd?: boolean;
+ dy?: number;
+ text?: string;
+ /** IME preedit caret (char index into s), null when composition ends. */
+ c?: number | null;
+}
+
+export type CursorKind =
+ | "default"
+ | "text"
+ | "pointer"
+ | "move"
+ | "grabbing"
+ | "ew"
+ | "ns"
+ | "nwse"
+ | "nesw";
+
+export interface Svc {
+ /** Drain and parse this frame's host lines (call once per frame). */
+ poll(): HostEvent[];
+ send(
+ line:
+ | { t: "quit" }
+ | { t: "copy"; text: string }
+ | { t: "paste-req" }
+ | { t: "caret"; x: number; y: number; h: number }
+ | { t: "cursor"; k: CursorKind },
+ ): void;
+}
+
+/** Probe the channel; null = standalone (sim, goldens — static desktop). */
+export function connectSvc(): Svc | null {
+ const ops = getOps();
+ if (!ops.svcOpen || !ops.svcPoll || !ops.svcSend || !ops.svcOpen("desk")) return null;
+ const poll = ops.svcPoll.bind(ops);
+ const send = ops.svcSend.bind(ops);
+ return {
+ poll() {
+ const batch = poll();
+ if (!batch) return [];
+ const events: HostEvent[] = [];
+ for (const line of batch.split("\n")) {
+ if (line === "") continue;
+ try {
+ events.push(JSON.parse(line) as HostEvent);
+ } catch {
+ // A malformed line is a host bug; skip it rather than wedge.
+ }
+ }
+ return events;
+ },
+ send(line) {
+ send(JSON.stringify(line));
+ },
+ };
+}
diff --git a/apps/desk98/theme.ts b/apps/desk98/theme.ts
new file mode 100644
index 00000000..48c7a3db
--- /dev/null
+++ b/apps/desk98/theme.ts
@@ -0,0 +1,37 @@
+// apps/desk98/theme.ts — the Windows 98 classic look as PocketJS class
+// literals, translated from sheru's win98 theme (packages/themes/src/win98)
+// the way pocket-shell's cooked skin does it: one bevel system over
+// button-face gray. Raised chrome is a two-ring outset bevel, pressing
+// inverts it, content wells are sunken; nothing is rounded, nothing
+// animates. bevel-[outerTL,outerBR,innerTL,innerBR] per the compiler
+// grammar (engine bevel rings, spec PROP.bevelOuter*).
+
+/** Baked W95FA slots (apps/desk98/gen-assets.ts → pak.json). */
+export const FONT = 19; // 12.5px regular
+export const FONT_B = 20; // 12.5px synthetic bold
+export const FONT_XL = 21; // 25px regular (start-menu banner)
+
+// The bevel recipes (they repeat as FULL literals in the components — the
+// class table compiles at build time and template-interpolated fragments
+// are a compile error):
+// raised control bevel-[#ffffff,#000000,#dfdfdf,#808080]
+// pressed bevel-[#000000,#ffffff,#808080,#dfdfdf]
+// window frame bevel-[#dfdfdf,#000000,#ffffff,#808080]
+// sunken well bevel-[#808080,#ffffff,#000000,#dfdfdf]
+// thin raised bevel-[#ffffff,#808080] thin sunken bevel-[#808080,#ffffff]
+
+export const CAPTION_ACTIVE =
+ "flex-row items-center h-[18] pl-[3] pr-[2] bg-gradient-to-r from-[#000080] to-[#1084d0] mb-[1]";
+export const CAPTION_INACTIVE =
+ "flex-row items-center h-[18] pl-[3] pr-[2] bg-gradient-to-r from-[#808080] to-[#b5b5b5] mb-[1]";
+
+// Chrome metrics (mirrored by wm.ts hit testing — change both together).
+export const FRAME = 3;
+export const TITLE_H = 18;
+export const TITLE_GAP = 1; // face-gray hairline under the caption
+export const BTN_W = 16;
+export const BTN_H = 14; // all caption buttons sit flush, no close gap
+export const MENU_H = 18; // menu bar row
+export const TASK_H = 28; // taskbar
+export const RESIZE_BAND = 4;
+export const RESIZE_CORNER = 14;
diff --git a/apps/desk98/wm.ts b/apps/desk98/wm.ts
new file mode 100644
index 00000000..8e68d0d8
--- /dev/null
+++ b/apps/desk98/wm.ts
@@ -0,0 +1,191 @@
+// apps/desk98/wm.ts — pure window-manager math: chrome hit regions, resize
+// arithmetic, movement clamps. No Solid, no framework imports — unit-tested
+// directly (tests/desk98.test.ts). The compositor (app.tsx) owns the state;
+// this module owns the geometry rules.
+//
+// Chrome anatomy (theme.ts metrics): a window is a face-gray box with a 3px
+// raised frame (padding), an 18px caption (+1px hairline), an optional 18px
+// menu bar, then content. Caption controls sit flush right and flush against
+// each other — [min][zoom][close], each 16×14, 2px under the caption top.
+
+import {
+ BTN_H,
+ BTN_W,
+ FRAME,
+ MENU_H,
+ RESIZE_BAND,
+ RESIZE_CORNER,
+ TASK_H,
+ TITLE_GAP,
+ TITLE_H,
+} from "./theme.ts";
+
+export interface Geo {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+}
+
+export type CaptionButton = "min" | "max" | "close";
+export type Dir = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
+
+export type Region =
+ | { kind: "caption" }
+ | { kind: "button"; button: CaptionButton }
+ | { kind: "menu"; index: number }
+ | { kind: "content"; cx: number; cy: number }
+ | { kind: "resize"; dir: Dir };
+
+export interface ChromeOpts {
+ /** Caption controls present, left to right (close is always last). */
+ buttons: readonly CaptionButton[];
+ resizable: boolean;
+ maximized: boolean;
+ /** Menu-bar item widths in px (empty = no menu bar). */
+ menuWidths: readonly number[];
+}
+
+/** Left x of each caption button, right-aligned inside the frame, flush. */
+export function captionButtonXs(w: number, buttons: readonly CaptionButton[]): number[] {
+ const xs: number[] = [];
+ let right = w - FRAME - 2;
+ for (let i = buttons.length - 1; i >= 0; i--) {
+ xs.unshift(right - BTN_W);
+ right -= BTN_W;
+ }
+ return xs;
+}
+
+/** Content-area top inside the window (frame + caption + menu bar). */
+export function contentTop(opts: Pick): number {
+ return FRAME + TITLE_H + TITLE_GAP + (opts.menuWidths.length > 0 ? MENU_H : 0);
+}
+
+/** Hit-test a point in window-local coordinates against the chrome. */
+export function hitRegion(geo: Geo, opts: ChromeOpts, px: number, py: number): Region | null {
+ const x = px - geo.x;
+ const y = py - geo.y;
+ if (x < 0 || y < 0 || x >= geo.w || y >= geo.h) return null;
+
+ // Resize bands claim the outer edge before anything else.
+ if (opts.resizable && !opts.maximized) {
+ const corner = RESIZE_CORNER;
+ const n = y < RESIZE_BAND;
+ const s = y >= geo.h - RESIZE_BAND;
+ const w = x < RESIZE_BAND;
+ const e = x >= geo.w - RESIZE_BAND;
+ if (n || s || w || e) {
+ const nearL = x < corner;
+ const nearR = x >= geo.w - corner;
+ const nearT = y < corner;
+ const nearB = y >= geo.h - corner;
+ let dir: Dir;
+ if ((n && nearL) || (w && nearT)) dir = "nw";
+ else if ((n && nearR) || (e && nearT)) dir = "ne";
+ else if ((s && nearL) || (w && nearB)) dir = "sw";
+ else if ((s && nearR) || (e && nearB)) dir = "se";
+ else if (n) dir = "n";
+ else if (s) dir = "s";
+ else if (w) dir = "w";
+ else dir = "e";
+ return { kind: "resize", dir };
+ }
+ }
+
+ // Caption strip.
+ if (y >= FRAME && y < FRAME + TITLE_H) {
+ const xs = captionButtonXs(geo.w, opts.buttons);
+ const btnTop = FRAME + 2;
+ if (y >= btnTop && y < btnTop + BTN_H) {
+ for (let i = 0; i < xs.length; i++) {
+ if (x >= xs[i] && x < xs[i] + BTN_W) {
+ return { kind: "button", button: opts.buttons[i] };
+ }
+ }
+ }
+ if (x >= FRAME && x < geo.w - FRAME) return { kind: "caption" };
+ }
+
+ // Menu bar.
+ const menuTop = FRAME + TITLE_H + TITLE_GAP;
+ if (opts.menuWidths.length > 0 && y >= menuTop && y < menuTop + MENU_H) {
+ let mx = FRAME;
+ for (let i = 0; i < opts.menuWidths.length; i++) {
+ if (x >= mx && x < mx + opts.menuWidths[i]) return { kind: "menu", index: i };
+ mx += opts.menuWidths[i];
+ }
+ }
+
+ const top = contentTop(opts);
+ if (x >= FRAME && x < geo.w - FRAME && y >= top && y < geo.h - FRAME) {
+ return { kind: "content", cx: x - FRAME, cy: y - top };
+ }
+ return { kind: "caption" }; // frame padding drags like the caption did in 98
+}
+
+/** Apply a resize drag: dir edge follows the pointer, mins hold, the
+ * anchored edge never moves. */
+export function resizeGeo(
+ orig: Geo,
+ dir: Dir,
+ dx: number,
+ dy: number,
+ minW: number,
+ minH: number,
+): Geo {
+ let { x, y, w, h } = orig;
+ if (dir.includes("e")) w = Math.max(minW, orig.w + dx);
+ if (dir.includes("s")) h = Math.max(minH, orig.h + dy);
+ if (dir.includes("w")) {
+ w = Math.max(minW, orig.w - dx);
+ x = orig.x + orig.w - w;
+ }
+ if (dir.includes("n")) {
+ h = Math.max(minH, orig.h - dy);
+ y = orig.y + orig.h - h;
+ }
+ return { x, y, w, h };
+}
+
+/** Clamp a moved window so its caption stays reachable: some strip of the
+ * title bar remains on screen and above the taskbar. */
+export function clampMove(geo: Geo, vpW: number, vpH: number): Geo {
+ const grip = 48; // px of caption that must stay visible
+ const x = Math.min(Math.max(geo.x, grip - geo.w), vpW - grip);
+ const y = Math.min(Math.max(geo.y, 0), vpH - TASK_H - TITLE_H);
+ return { ...geo, x, y };
+}
+
+/** Maximized geometry: the desktop minus the taskbar. */
+export function maximizedGeo(vpW: number, vpH: number): Geo {
+ return { x: 0, y: 0, w: vpW, h: vpH - TASK_H };
+}
+
+/** Cascade position for the i-th opened window. */
+export function cascadePos(i: number, vpW: number, vpH: number, w: number, h: number): Geo {
+ const step = 24;
+ const cols = Math.max(1, Math.floor((vpH - TASK_H - h - 8) / step) + 1);
+ const k = i % Math.max(1, cols);
+ const x = Math.min(64 + i * step, Math.max(8, vpW - w - 8));
+ const y = 28 + k * step;
+ return { x, y, w, h };
+}
+
+/** The resize cursor for a band direction ({t:"cursor"} intent keys). */
+export function cursorForDir(dir: Dir): "ew" | "ns" | "nwse" | "nesw" {
+ switch (dir) {
+ case "e":
+ case "w":
+ return "ew";
+ case "n":
+ case "s":
+ return "ns";
+ case "nw":
+ case "se":
+ return "nwse";
+ case "ne":
+ case "sw":
+ return "nesw";
+ }
+}
diff --git a/assets/fonts/LICENSE-W95FA.txt b/assets/fonts/LICENSE-W95FA.txt
new file mode 100644
index 00000000..f1a20ac1
--- /dev/null
+++ b/assets/fonts/LICENSE-W95FA.txt
@@ -0,0 +1,97 @@
+Copyright (c) , (),
+with Reserved Font Name .
+Copyright (c) , (),
+with Reserved Font Name .
+Copyright (c) , ().
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/assets/fonts/W95FA.otf b/assets/fonts/W95FA.otf
new file mode 100644
index 00000000..153077a6
Binary files /dev/null and b/assets/fonts/W95FA.otf differ
diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md
index a2187505..37bd234a 100644
--- a/docs/BACKENDS.md
+++ b/docs/BACKENDS.md
@@ -103,11 +103,42 @@ bun run macos note --proof
`tools/macos.ts` resolves the manifest against `macos-app`, writes the
plan, builds the bundle + pak, and derives the capability-shaped host flags
-(`--fixed`, `--native-text`) from the resolved plan. `--editor` is NOT a
-capability: it enables the note's companion svc adapter (an app protocol —
-the profile deliberately registers no pointer/text/IME/clipboard ids, see
-contracts/spec/platforms.ts). On exit the host prints its governor receipt
-(`pocket-macos: N ticks, M frames rendered`); a settled app shows M ≪ N.
+(`--fixed`, `--native-text`, `--companions`) from the resolved plan.
+`--editor` is NOT a capability: it enables the note's companion svc adapter
+(an app protocol — the profile deliberately registers no
+pointer/text/IME/clipboard ids, see contracts/spec/platforms.ts). On exit
+the host prints its governor receipt (`pocket-macos: N ticks, M frames
+rendered`); a settled app shows M ≪ N.
+
+## The desk companion
+
+The host speaks a second svc dialect when the plan's companion list names
+`desk` (apps/desk98/svc.ts): the note dialect's input lines extended with
+**right-button mouse lines (`b:2`), alt/ctl key modifiers, F1–F12,
+cmd-flagged ⌘ chords, a boot epoch in the hello**, a `{t:"cursor"}` guest
+intent that sets the window's pointer shape, and a `{t:"paste-req"}` guest
+intent the host answers with a paste line (menu-driven Paste). ⌘Q quits
+and ⌘V pastes host-side; every other ⌘ chord reaches the guest, so the
+compositor owns its shortcuts (⌘W close, ⌘M minimize, ⌘` cycle windows,
+⌘Esc Start menu, ⌘A/C/X editing). Plain typing arrives only through the
+IME input handler (`insertText:` → one `ch` line per keypress).
+
+`apps/desk98` — a Windows 98 desktop compositor written in Vue Vapor SFCs
+— is the reference consumer: the guest owns every window (drag, resize,
+z-order, menus, text selection, Minesweeper) by hit-testing the raw
+pointer stream itself, window moves ride paint-only translate props, and
+raises ride zIndex, so a drag never relayouts and an idle desktop keeps
+the demand-render governor at a few frames per second. Its W95FA pixel
+font is baked per-app into slots 19–21 through `apps/desk98/pak.json`
+(`gen-assets.ts`) — the repo slot table (0–18) never moves.
+
+```
+bun run macos desk98 # the full desktop; drag-select, Cmd+`, Cmd+W, Cmd+Esc
+```
+
+Scripted acceptance drives the same dialect from flags: `--mouse
+X,Y[,d|u|r]@TICK` (drags, right clicks), `--key
+[cmd+][alt+][ctl+]NAME@TICK`, `--type TEXT@TICK`.
## Choosing a backend
diff --git a/framework/compiler/bake-font.ts b/framework/compiler/bake-font.ts
index 1e51e426..bdd56544 100644
--- a/framework/compiler/bake-font.ts
+++ b/framework/compiler/bake-font.ts
@@ -8,8 +8,9 @@
// out — the core resolves cmap misses to gid 0 (tofu) at runtime.
//
// Rasterization: opentype.js outlines, flattened to polylines, scanline
-// even-odd fill with horizontally-biased supersampling into 8-bit coverage
-// cells. Atlas v3 keeps cell/line/cmap metrics in LOGICAL px while baking the
+// nonzero-winding fill (the TrueType/CFF rule — even-odd cancels the shared
+// edges and overlaps some outlines are built from) with horizontally-biased
+// supersampling into 8-bit coverage cells. Atlas v3 keeps cell/line/cmap metrics in LOGICAL px while baking the
// bitmap at `rasterDensity` samples per logical px. A Vita density-2 build can
// therefore draw sharp 2x coverage without changing PSP-compatible layout.
// Cells are tight in logical space: cellW = max inked logical width over the
@@ -165,32 +166,47 @@ function rasterize(contours: Contour[], cellW: number, cellH: number): Uint8Arra
const sw = cellW * SS_X;
const samplesPerPixel = SS_X * SS_Y;
const counts = new Uint16Array(cellW); // covered subsamples per pixel column, one row at a time
- const xs: number[] = [];
+ // Crossings for one scanline: x position + winding direction (+1 downward,
+ // -1 upward in y-down space). Nonzero winding — pairing sorted crossings
+ // (even-odd) erases the interior wherever two contours share an edge or
+ // overlap, which pixel-font outlines (stroke rectangles sharing stem edges)
+ // and composite glyphs (accent over base) legitimately do.
+ const edges: { x: number; w: number }[] = [];
for (let row = 0; row < cellH; row++) {
counts.fill(0);
for (let sub = 0; sub < SS_Y; sub++) {
const y = row + (sub + 0.5) / SS_Y;
- xs.length = 0;
+ edges.length = 0;
for (const c of contours) {
for (let i = 0; i < c.length - 1; i++) {
const p0 = c[i];
const p1 = c[i + 1];
- if ((p0.y <= y && p1.y > y) || (p1.y <= y && p0.y > y)) {
- xs.push(p0.x + ((y - p0.y) * (p1.x - p0.x)) / (p1.y - p0.y));
+ if (p0.y <= y && p1.y > y) {
+ edges.push({ x: p0.x + ((y - p0.y) * (p1.x - p0.x)) / (p1.y - p0.y), w: 1 });
+ } else if (p1.y <= y && p0.y > y) {
+ edges.push({ x: p0.x + ((y - p0.y) * (p1.x - p0.x)) / (p1.y - p0.y), w: -1 });
}
}
}
- if (xs.length < 2) continue;
- xs.sort((a, b) => a - b);
- for (let k = 0; k + 1 < xs.length; k += 2) {
- // subcolumn centers inside [xs[k], xs[k+1])
- let s0 = Math.ceil(xs[k] * SS_X - 0.5);
- let s1 = Math.floor(xs[k + 1] * SS_X - 0.5);
- if (s0 < 0) s0 = 0;
- if (s1 >= sw) s1 = sw - 1;
- for (let s = s0; s <= s1; s++) {
- const center = (s + 0.5) / SS_X;
- if (center >= xs[k] && center < xs[k + 1]) counts[(s / SS_X) | 0]++;
+ if (edges.length < 2) continue;
+ edges.sort((a, b) => a.x - b.x);
+ let wind = 0;
+ let spanX = 0;
+ for (const e of edges) {
+ const prev = wind;
+ wind += e.w;
+ if (prev === 0 && wind !== 0) {
+ spanX = e.x;
+ } else if (prev !== 0 && wind === 0) {
+ // subcolumn centers inside [spanX, e.x)
+ let s0 = Math.ceil(spanX * SS_X - 0.5);
+ let s1 = Math.floor(e.x * SS_X - 0.5);
+ if (s0 < 0) s0 = 0;
+ if (s1 >= sw) s1 = sw - 1;
+ for (let s = s0; s <= s1; s++) {
+ const center = (s + 0.5) / SS_X;
+ if (center >= spanX && center < e.x) counts[(s / SS_X) | 0]++;
+ }
}
}
}
diff --git a/hosts/macos/src/main.rs b/hosts/macos/src/main.rs
index 4f261f82..48fd98f5 100644
--- a/hosts/macos/src/main.rs
+++ b/hosts/macos/src/main.rs
@@ -23,6 +23,15 @@
//! forwarded as lines, guest intents (save/quit/copy/caret) handled here.
//! An app protocol, not a capability: the live-viewport hook and button
//! map work for every app regardless of this flag.
+//! - desk — the DESK COMPANION adapter (apps/desk98/svc.ts): the same
+//! input-line dialect as the editor, extended with right-button mouse
+//! lines (b:2), alt/ctl key modifiers, F1–F12, cmd-flagged ⌘ chords
+//! (except ⌘Q = host quit and ⌘V = host-side paste), a boot epoch in the
+//! hello (for the guest's wall clock), a {t:"cursor"} guest intent that
+//! sets the window's pointer shape, and a {t:"paste-req"} guest intent
+//! answered with a paste line (menu-driven Paste). Wired when the plan's
+//! companion list names "desk"; the note dialect stays byte-compatible
+//! (extra fields ignored).
use std::cell::{Cell, RefCell};
use std::ops::Range;
@@ -32,11 +41,11 @@ use std::time::{Duration, Instant};
use anyhow::{Context as _, Result, anyhow};
use gpui::{
- App, AppContext as _, Application, Bounds, ClipboardItem, Context, Entity, EntityInputHandler,
- FocusHandle, Focusable, InteractiveElement, IntoElement, KeyDownEvent, KeyUpEvent, MouseButton,
- MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Point, Render,
- ScrollWheelEvent, SharedString, Styled, TitlebarOptions, UTF16Selection, Window, WindowBounds,
- WindowOptions, canvas, div, point, px, size,
+ App, AppContext as _, Application, Bounds, ClipboardItem, Context, CursorStyle, Entity,
+ EntityInputHandler, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyDownEvent,
+ KeyUpEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels,
+ Point, Render, ScrollWheelEvent, SharedString, Styled, TitlebarOptions, UTF16Selection, Window,
+ WindowBounds, WindowOptions, canvas, div, point, px, size,
};
use pocket_mod::Guest;
use pocket_ui_gpui::{GpuiRenderer, TextConfig, native_measure};
@@ -73,6 +82,11 @@ enum ScriptEvent {
Click(u64, f32, f32),
/// Hold a console button for ~6 ticks starting at a tick.
Press(u64, u32),
+ /// Raw pointer line at a tick: kind 'd' presses, 'u' releases, 'm' moves
+ /// with the current scripted button state (drag scripting).
+ Mouse(u64, f32, f32, char),
+ /// Named key with optional cmd+/alt+/ctl+/sh+ prefixes (svc `key` line).
+ Key(u64, String, bool, bool, bool, bool),
}
struct Args {
@@ -169,6 +183,60 @@ fn parse_args() -> Result {
args.script
.push(ScriptEvent::Click(t.parse()?, x.parse()?, y.parse()?));
}
+ "--mouse" => {
+ // --mouse X,Y[,d|u|r]@TICK — scripted pointer line (m = move,
+ // r = right press+release).
+ let v = val("--mouse")?;
+ let (spec, t) = v
+ .rsplit_once('@')
+ .ok_or_else(|| anyhow!("--mouse X,Y[,d|u]@TICK"))?;
+ let parts: Vec<&str> = spec.split(',').collect();
+ if parts.len() < 2 || parts.len() > 3 {
+ return Err(anyhow!("--mouse X,Y[,d|u]@TICK"));
+ }
+ let kind = parts
+ .get(2)
+ .map_or('m', |s| s.chars().next().unwrap_or('m'));
+ args.script.push(ScriptEvent::Mouse(
+ t.parse()?,
+ parts[0].parse()?,
+ parts[1].parse()?,
+ kind,
+ ));
+ }
+ "--key" => {
+ // --key [cmd+][alt+][ctl+][sh+]NAME@TICK — scripted svc key line.
+ let v = val("--key")?;
+ let (mut name, t) = v
+ .rsplit_once('@')
+ .ok_or_else(|| anyhow!("--key NAME@TICK"))?;
+ let (mut cmd, mut alt, mut ctl, mut sh) = (false, false, false, false);
+ loop {
+ if let Some(rest) = name.strip_prefix("cmd+") {
+ cmd = true;
+ name = rest;
+ } else if let Some(rest) = name.strip_prefix("alt+") {
+ alt = true;
+ name = rest;
+ } else if let Some(rest) = name.strip_prefix("ctl+") {
+ ctl = true;
+ name = rest;
+ } else if let Some(rest) = name.strip_prefix("sh+") {
+ sh = true;
+ name = rest;
+ } else {
+ break;
+ }
+ }
+ args.script.push(ScriptEvent::Key(
+ t.parse()?,
+ name.to_string(),
+ cmd,
+ alt,
+ ctl,
+ sh,
+ ));
+ }
"--quit-after" => args.quit_after_ticks = Some(val("--quit-after")?.parse()?),
"--announce-ready" => args.announce_ready = true,
"--press" => {
@@ -283,7 +351,13 @@ struct PocketRoot {
buttons: u32,
/// Buttons currently held by the --press script.
script_buttons: u32,
- // editor input
+ /// Primary-button state of the --mouse script.
+ script_mouse: bool,
+ // editor/desk input
+ /// The desk companion is in the plan — the extended input dialect.
+ desk: bool,
+ /// Pointer shape requested by the guest ({t:"cursor"} intent).
+ cursor_style: CursorStyle,
mouse_down: bool,
click_edge: bool,
last_mouse: Option<(f32, f32, bool)>,
@@ -340,6 +414,7 @@ impl PocketRoot {
let focus = cx.focus_handle();
let viewport = args.viewport;
let script = args.script.clone();
+ let desk = args.companions.iter().any(|c| c == "desk");
let root = PocketRoot {
surface,
guest,
@@ -356,6 +431,9 @@ impl PocketRoot {
canvas_origin: Rc::new(Cell::new(point(px(0.0), px(0.0)))),
buttons: 0,
script_buttons: 0,
+ script_mouse: false,
+ desk,
+ cursor_style: CursorStyle::Arrow,
mouse_down: false,
click_edge: false,
last_mouse: None,
@@ -411,10 +489,25 @@ impl PocketRoot {
self.surface.svc_push(value.to_string());
}
+ /// Input lines flow when either JSON-line dialect is wired.
+ fn forward_input(&self) -> bool {
+ self.args.editor || self.desk
+ }
+
/// The svc hello: viewport first, then the document (order matters — the
- /// app lays text out against the viewport it was just told about).
+ /// app lays text out against the viewport it was just told about). The
+ /// epoch anchors the guest's wall clock (desk taskbar); the note dialect
+ /// ignores it.
fn send_hello(&mut self) {
- self.svc(serde_json::json!({"t": "hello", "w": self.viewport.0, "h": self.viewport.1}));
+ log::debug!(
+ "pocket-macos: svc hello (desk={}, editor={})",
+ self.desk,
+ self.args.editor
+ );
+ self.svc(serde_json::json!({
+ "t": "hello", "w": self.viewport.0, "h": self.viewport.1,
+ "epoch": epoch_ms() as u64,
+ }));
if let Some(file) = &self.args.file {
let text = std::fs::read_to_string(file).unwrap_or_default();
if !text.is_empty() {
@@ -452,6 +545,37 @@ impl PocketRoot {
ScriptEvent::Press(t, bit) if tick == t + 6 => {
self.script_buttons &= !bit;
}
+ ScriptEvent::Mouse(t, x, y, kind) if t == tick => {
+ if kind == 'r' {
+ // Right click: press + release in one tick (b:2 lines).
+ self.svc(serde_json::json!(
+ {"t": "mouse", "x": x, "y": y, "d": true, "b": 2, "sh": false}
+ ));
+ self.svc(serde_json::json!(
+ {"t": "mouse", "x": x, "y": y, "d": false, "b": 2, "sh": false}
+ ));
+ } else {
+ let down = match kind {
+ 'd' => {
+ self.script_mouse = true;
+ true
+ }
+ 'u' => {
+ self.script_mouse = false;
+ false
+ }
+ _ => self.script_mouse,
+ };
+ self.svc(serde_json::json!(
+ {"t": "mouse", "x": x, "y": y, "d": down, "sh": false}
+ ));
+ }
+ }
+ ScriptEvent::Key(t, ref k, cmd, alt, ctl, sh) if t == tick => {
+ self.svc(serde_json::json!(
+ {"t": "key", "k": k, "cmd": cmd, "sh": sh, "alt": alt, "ctl": ctl}
+ ));
+ }
_ => {}
}
}
@@ -465,7 +589,7 @@ impl PocketRoot {
}
if !self.booted {
self.booted = true;
- if self.args.editor {
+ if self.forward_input() {
self.send_hello();
}
}
@@ -493,7 +617,7 @@ impl PocketRoot {
) {
log::warn!("pocket-macos: resize hook failed: {e}");
}
- if self.args.editor {
+ if self.forward_input() {
self.svc(serde_json::json!({"t": "resize", "w": vp.0, "h": vp.1}));
}
}
@@ -524,7 +648,7 @@ impl PocketRoot {
0
}
} else {
- self.buttons
+ self.buttons | self.script_buttons
};
self.click_edge = false;
if let Err(e) = self.guest.frame(buttons) {
@@ -546,6 +670,15 @@ impl PocketRoot {
v["text"].as_str().unwrap_or_default().to_string(),
));
}
+ Some("paste-req") => {
+ // The guest can't read the system clipboard; answer a
+ // menu-driven Paste with the dialect's paste line.
+ if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text())
+ && !text.is_empty()
+ {
+ self.svc(serde_json::json!({"t": "paste", "text": text}));
+ }
+ }
Some("caret") => {
self.caret_rect = Some((
v["x"].as_f64().unwrap_or(0.0) as f32,
@@ -554,6 +687,13 @@ impl PocketRoot {
v["h"].as_f64().unwrap_or(20.0) as f32,
));
}
+ Some("cursor") => {
+ let style = cursor_for(v["k"].as_str().unwrap_or("default"));
+ if style != self.cursor_style {
+ self.cursor_style = style;
+ cx.notify();
+ }
+ }
other => log::warn!("pocket-macos: unknown intent {other:?}"),
},
Err(e) => log::warn!("pocket-macos: bad svc line from guest: {e}"),
@@ -588,9 +728,35 @@ impl PocketRoot {
fn on_key_down(&mut self, e: &KeyDownEvent, _w: &mut Window, cx: &mut Context) {
let ks = &e.keystroke;
- if self.args.editor {
+ if self.forward_input() {
let shift = ks.modifiers.shift;
if ks.modifiers.platform {
+ if self.desk {
+ // Desk dialect: ⌘Q quits the host (macOS convention) and
+ // ⌘V pastes here (the guest can't read the clipboard);
+ // every other chord goes to the guest as a cmd-flagged
+ // key line — the compositor owns its own shortcuts
+ // (⌘W close, ⌘M minimize, ⌘` cycle, ⌘C/X/A editing).
+ match ks.key.as_str() {
+ "q" => self.exit = true,
+ "v" => {
+ if let Some(text) =
+ cx.read_from_clipboard().and_then(|item| item.text())
+ && !text.is_empty()
+ {
+ self.svc(serde_json::json!({"t": "paste", "text": text}));
+ }
+ }
+ k => self.svc(serde_json::json!({
+ "t": "key", "k": k, "cmd": true, "sh": shift,
+ "alt": ks.modifiers.alt, "ctl": ks.modifiers.control,
+ })),
+ }
+ // Consumed: don't let AppKit hunt the (empty) menu bar
+ // for a key equivalent and beep.
+ cx.stop_propagation();
+ return;
+ }
match ks.key.as_str() {
"q" | "w" => self.exit = true,
"z" => self.svc(
@@ -623,19 +789,32 @@ impl PocketRoot {
"pageup" => Some("PageUp"),
"pagedown" => Some("PageDown"),
"escape" => Some("Escape"),
+ // Desk-dialect chord keys (Alt+F4, Ctrl+Esc, Alt+Tab).
+ "f1" => Some("F1"),
+ "f2" => Some("F2"),
+ "f3" => Some("F3"),
+ "f4" => Some("F4"),
+ "f5" => Some("F5"),
+ "f6" => Some("F6"),
+ "f7" => Some("F7"),
+ "f8" => Some("F8"),
+ "f9" => Some("F9"),
+ "f10" => Some("F10"),
+ "f11" => Some("F11"),
+ "f12" => Some("F12"),
_ => None,
};
if let Some(k) = named {
- self.svc(serde_json::json!({"t": "key", "k": k, "sh": shift}));
- } else if self.marked.is_none() {
- // Plain typing (IME composition delivers through the input
- // handler instead — no double-input path).
- if let Some(s) = &ks.key_char
- && !s.is_empty()
- {
- self.svc(serde_json::json!({"t": "ch", "s": s}));
- }
+ self.svc(serde_json::json!({
+ "t": "key", "k": k, "sh": shift,
+ "alt": ks.modifiers.alt, "ctl": ks.modifiers.control,
+ }));
}
+ // Plain typing is NOT emitted here. gpui hands the unhandled key
+ // event to the input context after this listener, and insertText:
+ // always reaches the registered input handler — so the character
+ // arrives once through replace_text_in_range. Emitting key_char
+ // here as well delivered every keypress twice.
} else {
if ks.modifiers.platform && (ks.key == "q" || ks.key == "w") {
self.exit = true;
@@ -648,7 +827,7 @@ impl PocketRoot {
}
fn on_key_up(&mut self, e: &KeyUpEvent, _w: &mut Window, _cx: &mut Context) {
- if !self.args.editor
+ if !self.forward_input()
&& let Some(bit) = button_for(&e.keystroke.key)
{
self.buttons &= !bit;
@@ -673,37 +852,55 @@ impl PocketRoot {
fn on_mouse_down(&mut self, e: &MouseDownEvent, w: &mut Window, _cx: &mut Context) {
self.focus.focus(w);
- if e.button != MouseButton::Left {
- return;
- }
- self.mouse_down = true;
- self.click_edge = true;
- if self.args.editor {
- let (x, y) = self.logical_pos(e.position);
- self.push_mouse(x, y, true, e.modifiers.shift);
+ match e.button {
+ MouseButton::Left => {
+ self.mouse_down = true;
+ self.click_edge = true;
+ if self.forward_input() {
+ let (x, y) = self.logical_pos(e.position);
+ self.push_mouse(x, y, true, e.modifiers.shift);
+ }
+ }
+ // Right button is desk-dialect only (b:2 lines would read as
+ // primary presses to the note protocol).
+ MouseButton::Right if self.desk => {
+ let (x, y) = self.logical_pos(e.position);
+ self.svc(serde_json::json!(
+ {"t": "mouse", "x": x, "y": y, "d": true, "b": 2, "sh": e.modifiers.shift}
+ ));
+ }
+ _ => {}
}
}
fn on_mouse_up(&mut self, e: &MouseUpEvent, _w: &mut Window, _cx: &mut Context) {
- if e.button != MouseButton::Left {
- return;
- }
- self.mouse_down = false;
- if self.args.editor {
- let (x, y) = self.logical_pos(e.position);
- self.push_mouse(x, y, false, e.modifiers.shift);
+ match e.button {
+ MouseButton::Left => {
+ self.mouse_down = false;
+ if self.forward_input() {
+ let (x, y) = self.logical_pos(e.position);
+ self.push_mouse(x, y, false, e.modifiers.shift);
+ }
+ }
+ MouseButton::Right if self.desk => {
+ let (x, y) = self.logical_pos(e.position);
+ self.svc(serde_json::json!(
+ {"t": "mouse", "x": x, "y": y, "d": false, "b": 2, "sh": e.modifiers.shift}
+ ));
+ }
+ _ => {}
}
}
fn on_mouse_move(&mut self, e: &MouseMoveEvent, _w: &mut Window, _cx: &mut Context) {
- if self.args.editor {
+ if self.forward_input() {
let (x, y) = self.logical_pos(e.position);
self.push_mouse(x, y, self.mouse_down, e.modifiers.shift);
}
}
fn on_scroll(&mut self, e: &ScrollWheelEvent, w: &mut Window, _cx: &mut Context) {
- if self.args.editor {
+ if self.forward_input() {
let dy = f32::from(e.delta.pixel_delta(w.line_height()).y);
if dy != 0.0 {
self.svc(serde_json::json!({"t": "scroll", "dy": dy}));
@@ -712,6 +909,22 @@ impl PocketRoot {
}
}
+/// {t:"cursor"} intent → pointer shape. Keys mirror CSS cursor names the
+/// desk dialect uses; unknown keys fall back to the arrow.
+fn cursor_for(k: &str) -> CursorStyle {
+ match k {
+ "text" => CursorStyle::IBeam,
+ "pointer" => CursorStyle::PointingHand,
+ "move" => CursorStyle::OpenHand,
+ "grabbing" => CursorStyle::ClosedHand,
+ "ew" => CursorStyle::ResizeLeftRight,
+ "ns" => CursorStyle::ResizeUpDown,
+ "nwse" => CursorStyle::ResizeUpLeftDownRight,
+ "nesw" => CursorStyle::ResizeUpRightDownLeft,
+ _ => CursorStyle::Arrow,
+ }
+}
+
fn button_for(key: &str) -> Option {
Some(match key {
"up" => BTN_UP,
@@ -892,16 +1105,19 @@ impl Render for PocketRoot {
.then_some((self.args.viewport.0 as f32, self.args.viewport.1 as f32));
let entity: Entity = cx.entity();
let focus = self.focus.clone();
- let ime = self.args.editor;
+ let ime = self.forward_input();
div()
.size_full()
.bg(gpui::black())
+ .cursor(self.cursor_style)
.track_focus(&self.focus)
.on_key_down(cx.listener(Self::on_key_down))
.on_key_up(cx.listener(Self::on_key_up))
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up))
+ .on_mouse_down(MouseButton::Right, cx.listener(Self::on_mouse_down))
+ .on_mouse_up(MouseButton::Right, cx.listener(Self::on_mouse_up))
.on_mouse_move(cx.listener(Self::on_mouse_move))
.on_scroll_wheel(cx.listener(Self::on_scroll))
.child(
diff --git a/tests/desk98-sim.test.ts b/tests/desk98-sim.test.ts
new file mode 100644
index 00000000..9e575370
--- /dev/null
+++ b/tests/desk98-sim.test.ts
@@ -0,0 +1,170 @@
+// tests/desk98-sim.test.ts — desk98 in the sim. Two worlds:
+//
+// 1. standalone (no desk companion): the app boots a static arrangement —
+// the unmodified-app base case.
+// 2. a mock desk companion: svcOpen/svcPoll/svcSend installed before eval
+// (bootWorld mutateOps), so the whole desk dialect journey runs
+// headless — typing, drag selection, ⌘ chords, the notepad context
+// menu, paste-req — with guest intents (copy payloads!) asserted on
+// the wire.
+//
+// The vue-vapor bundle must be prebuilt (the sim's fallback build cannot
+// resolve the framework-suffixed name):
+//
+// bun tools/build.ts desk98-main --framework=vue-vapor
+// bun test --conditions=browser tests/desk98-sim.test.ts
+
+import { describe, expect, test } from "bun:test";
+import { bootWorld, runScenario, treeHasText, type SimWorld } from "../hosts/sim/sim.ts";
+
+const APP = "desk98-main.vue-vapor";
+
+describe("desk98-main boots standalone", () => {
+ test("desktop, taskbar and the boot windows render", async () => {
+ const trace = await runScenario({ app: APP, seconds: 2 });
+ expect(treeHasText(trace.tree, "Start")).toBe(true);
+ expect(treeHasText(trace.tree, "Minesweeper")).toBe(true);
+ expect(treeHasText(trace.tree, "My Computer")).toBe(true);
+ expect(treeHasText(trace.tree, "Welcome to PocketJS 98.")).toBe(true);
+ }, 30000);
+});
+
+// ---------------------------------------------------------------------------
+// The desk companion journey
+// ---------------------------------------------------------------------------
+
+interface MockSvc {
+ push: (line: Record) => void;
+ sent: () => Record[];
+ mutateOps: (ops: Record) => void;
+}
+
+function mockSvc(): MockSvc {
+ const toGuest: string[] = [];
+ const fromGuest: Record[] = [];
+ return {
+ push: (line) => toGuest.push(JSON.stringify(line)),
+ sent: () => fromGuest,
+ mutateOps: (ops) => {
+ ops.svcOpen = (name: string) => name === "desk";
+ ops.svcPoll = () => {
+ if (toGuest.length === 0) return null;
+ const batch = toGuest.join("\n");
+ toGuest.length = 0;
+ return batch;
+ };
+ ops.svcSend = (line: string) => {
+ fromGuest.push(JSON.parse(line) as Record);
+ };
+ },
+ };
+}
+
+/** One frame + core catch-up + a microtask turn (Vue Vapor flushes
+ * dependent render effects in a microtask). */
+async function step(world: SimWorld, frames = 1): Promise {
+ for (let f = 0; f < frames; f++) {
+ world.frame(0);
+ for (let t = 0; t < world.ticksPerFrame; t++) world.tick();
+ await Promise.resolve();
+ }
+}
+
+function mouse(svc: MockSvc, x: number, y: number, d: boolean, b?: number) {
+ svc.push(b === 2 ? { t: "mouse", x, y, d, b: 2, sh: false } : { t: "mouse", x, y, d, sh: false });
+}
+
+describe("desk98 desk companion journey", () => {
+ test("typing, selection, ⌘ chords, context menu and paste-req", async () => {
+ const svc = mockSvc();
+ const world = await bootWorld(APP, 60, undefined, svc.mutateOps);
+ const EPOCH = 1755650000000;
+ svc.push({ t: "hello", w: 800, h: 600, epoch: EPOCH });
+ await step(world, 3);
+
+ // With the companion connected only the welcome notepad boots (the
+ // standalone extras — the My Computer folder with its status bar — stay
+ // closed); the taskbar clock ticks from the hello epoch.
+ let tree = world.getTree();
+ expect(treeHasText(tree, "welcome.txt - Notepad")).toBe(true);
+ expect(treeHasText(tree, "object(s)")).toBe(false);
+ const d = new Date(EPOCH);
+ const hhmm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
+ expect(treeHasText(tree, hhmm)).toBe(true);
+
+ // Typing: ch lines land at the caret (doc origin), one char per line.
+ for (const ch of ["H", "i"]) svc.push({ t: "ch", s: ch });
+ await step(world, 2);
+ tree = world.getTree();
+ expect(treeHasText(tree, "HiWelcome to PocketJS 98.")).toBe(true);
+
+ // Double-click selects the word under the pointer; ⌘C ships it as a
+ // copy intent (the welcome window sits at 64,28; content text origin
+ // 70,71; row 0 centers at y≈79).
+ mouse(svc, 75, 79, true);
+ mouse(svc, 75, 79, false);
+ await step(world);
+ mouse(svc, 75, 79, true);
+ mouse(svc, 75, 79, false);
+ await step(world, 2);
+ svc.push({ t: "key", k: "c", cmd: true });
+ await step(world, 2);
+ const copies = svc.sent().filter((l) => l.t === "copy");
+ expect(copies.length).toBe(1);
+ expect(copies[0].text).toBe("HiWelcome");
+
+ // Drag selection: down at the line start, drag right, release; ⌘C
+ // copies a prefix of the row and the selected run renders as its own
+ // navy segment (its text splits out of the full-line node).
+ mouse(svc, 70, 79, true);
+ await step(world);
+ mouse(svc, 140, 79, false);
+ await step(world);
+ mouse(svc, 140, 79, false);
+ await step(world);
+ svc.push({ t: "key", k: "c", cmd: true });
+ await step(world, 2);
+ const copy2 = svc.sent().filter((l) => l.t === "copy")[1];
+ expect(typeof copy2.text).toBe("string");
+ const dragged = copy2.text as string;
+ expect(dragged.length).toBeGreaterThan(0);
+ expect("HiWelcome to PocketJS 98.".startsWith(dragged)).toBe(true);
+ tree = world.getTree();
+ expect(treeHasText(tree, dragged)).toBe(true);
+
+ // Right-click in the content opens the edit context menu; the Paste row
+ // sends paste-req; the host's paste line lands at the caret.
+ mouse(svc, 75, 100, true, 2);
+ mouse(svc, 75, 100, false, 2);
+ await step(world, 2);
+ tree = world.getTree();
+ expect(treeHasText(tree, "Select All")).toBe(true);
+ expect(treeHasText(tree, "Paste")).toBe(true);
+ // Popup at (75,100): 1px border, 18px rows — Cut, Copy, Paste.
+ mouse(svc, 100, 101 + 18 + 18 + 9, true);
+ mouse(svc, 100, 101 + 18 + 18 + 9, false);
+ await step(world, 2);
+ expect(svc.sent().some((l) => l.t === "paste-req")).toBe(true);
+ svc.push({ t: "paste", text: "[PASTED]" });
+ await step(world, 2);
+ expect(treeHasText(world.getTree(), "[PASTED]")).toBe(true);
+
+ // ⌘Esc toggles the Start menu.
+ svc.push({ t: "key", k: "escape", cmd: true });
+ await step(world, 2);
+ tree = world.getTree();
+ expect(treeHasText(tree, "Programs")).toBe(true);
+ expect(treeHasText(tree, "Shut Down...")).toBe(true);
+ svc.push({ t: "key", k: "Escape" });
+ await step(world, 2);
+ expect(treeHasText(world.getTree(), "Shut Down...")).toBe(false);
+
+ // ⌘N opens a fresh Notepad, ⌘W closes it again.
+ svc.push({ t: "key", k: "n", cmd: true });
+ await step(world, 2);
+ expect(treeHasText(world.getTree(), "Untitled - Notepad")).toBe(true);
+ svc.push({ t: "key", k: "w", cmd: true });
+ await step(world, 2);
+ expect(treeHasText(world.getTree(), "Untitled - Notepad")).toBe(false);
+ }, 30000);
+});
diff --git a/tests/desk98.test.ts b/tests/desk98.test.ts
new file mode 100644
index 00000000..0debcf5d
--- /dev/null
+++ b/tests/desk98.test.ts
@@ -0,0 +1,352 @@
+// tests/desk98.test.ts — PocketJS 98: window-manager chrome math, the
+// Minesweeper rules, Notepad line editing and the selection model (all
+// pure). The sim boot smoke lives in tests/desk98-sim.test.ts (needs the
+// vue-vapor bundle prebuilt).
+
+import { describe, expect, test } from "bun:test";
+import {
+ captionButtonXs,
+ clampMove,
+ contentTop,
+ cursorForDir,
+ hitRegion,
+ maximizedGeo,
+ resizeGeo,
+ type ChromeOpts,
+ type Geo,
+} from "../apps/desk98/wm.ts";
+import {
+ MINES_N,
+ MINES_W,
+ newMines,
+ reveal,
+ toggleFlag,
+} from "../apps/desk98/mines.ts";
+import {
+ applyMove,
+ backspace,
+ colFromX,
+ del,
+ deleteSel,
+ hasSel,
+ insertText,
+ moveCaret,
+ rowSelSpan,
+ selectAll,
+ selectedText,
+ selRange,
+ wordRangeAt,
+ type Doc,
+} from "../apps/desk98/notepad.ts";
+
+// ---------------------------------------------------------------------------
+// wm.ts — chrome hit regions
+// ---------------------------------------------------------------------------
+
+const GEO: Geo = { x: 100, y: 50, w: 400, h: 300 };
+const OPTS: ChromeOpts = {
+ buttons: ["min", "max", "close"],
+ resizable: true,
+ maximized: false,
+ menuWidths: [34, 34],
+};
+
+describe("caption buttons", () => {
+ test("all three buttons sit flush against each other, flush right", () => {
+ const xs = captionButtonXs(400, ["min", "max", "close"]);
+ // close right edge at w - FRAME(3) - 2.
+ expect(xs[2] + 16).toBe(400 - 3 - 2);
+ expect(xs[1]).toBe(xs[2] - 16); // no close gap
+ expect(xs[0]).toBe(xs[1] - 16);
+ });
+
+ test("close-only dialogs place the single button flush right", () => {
+ const xs = captionButtonXs(300, ["close"]);
+ expect(xs).toEqual([300 - 3 - 2 - 16]);
+ });
+});
+
+describe("hitRegion", () => {
+ test("caption bar drags, buttons claim their cells", () => {
+ // Caption strip, left of the buttons.
+ expect(hitRegion(GEO, OPTS, 100 + 200, 50 + 10)).toEqual({ kind: "caption" });
+ const xs = captionButtonXs(GEO.w, OPTS.buttons);
+ for (const [i, name] of (["min", "max", "close"] as const).entries()) {
+ const r = hitRegion(GEO, OPTS, 100 + xs[i] + 8, 50 + 3 + 2 + 7);
+ expect(r).toEqual({ kind: "button", button: name });
+ }
+ });
+
+ test("resize bands claim edges and corners with the right directions", () => {
+ expect(hitRegion(GEO, OPTS, 100 + 200, 50 + 1)).toEqual({ kind: "resize", dir: "n" });
+ expect(hitRegion(GEO, OPTS, 100 + 1, 50 + 150)).toEqual({ kind: "resize", dir: "w" });
+ expect(hitRegion(GEO, OPTS, 100 + 399, 50 + 299)).toEqual({ kind: "resize", dir: "se" });
+ expect(hitRegion(GEO, OPTS, 100 + 1, 50 + 299)).toEqual({ kind: "resize", dir: "sw" });
+ expect(hitRegion(GEO, OPTS, 100 + 399, 50 + 1)).toEqual({ kind: "resize", dir: "ne" });
+ });
+
+ test("maximized and fixed windows expose no resize bands", () => {
+ const max = { ...OPTS, maximized: true };
+ expect(hitRegion(GEO, max, 100 + 200, 50 + 1)).toEqual({ kind: "caption" });
+ const fixed = { ...OPTS, resizable: false };
+ expect(hitRegion(GEO, fixed, 100 + 399, 50 + 299)).not.toEqual({
+ kind: "resize",
+ dir: "se",
+ });
+ });
+
+ test("menu bar items hit by accumulated widths, content below them", () => {
+ const menuY = 50 + 3 + 18 + 1 + 9;
+ expect(hitRegion(GEO, OPTS, 100 + 3 + 10, menuY)).toEqual({ kind: "menu", index: 0 });
+ expect(hitRegion(GEO, OPTS, 100 + 3 + 34 + 10, menuY)).toEqual({ kind: "menu", index: 1 });
+ const r = hitRegion(GEO, OPTS, 100 + 50, 50 + contentTop(OPTS) + 20);
+ expect(r).toEqual({ kind: "content", cx: 47, cy: 20 });
+ });
+
+ test("outside the window misses", () => {
+ expect(hitRegion(GEO, OPTS, 99, 60)).toBeNull();
+ expect(hitRegion(GEO, OPTS, 100 + 400, 60)).toBeNull();
+ });
+});
+
+describe("resizeGeo", () => {
+ const orig: Geo = { x: 100, y: 50, w: 400, h: 300 };
+ test("east/south follow the pointer, west/north anchor the far edge", () => {
+ expect(resizeGeo(orig, "se", 40, 30, 200, 120)).toEqual({ x: 100, y: 50, w: 440, h: 330 });
+ const west = resizeGeo(orig, "w", 60, 0, 200, 120);
+ expect(west.w).toBe(340);
+ expect(west.x + west.w).toBe(orig.x + orig.w); // right edge pinned
+ const north = resizeGeo(orig, "n", 0, -20, 200, 120);
+ expect(north.h).toBe(320);
+ expect(north.y + north.h).toBe(orig.y + orig.h);
+ });
+
+ test("minimums hold on every edge", () => {
+ const tiny = resizeGeo(orig, "se", -1000, -1000, 200, 120);
+ expect(tiny.w).toBe(200);
+ expect(tiny.h).toBe(120);
+ const wTiny = resizeGeo(orig, "nw", 1000, 1000, 200, 120);
+ expect(wTiny.w).toBe(200);
+ expect(wTiny.h).toBe(120);
+ expect(wTiny.x + wTiny.w).toBe(orig.x + orig.w);
+ expect(wTiny.y + wTiny.h).toBe(orig.y + orig.h);
+ });
+});
+
+describe("clampMove / maximizedGeo", () => {
+ test("the caption always stays reachable", () => {
+ const g = clampMove({ x: -1000, y: -50, w: 400, h: 300 }, 800, 600);
+ expect(g.x).toBe(48 - 400);
+ expect(g.y).toBe(0);
+ const low = clampMove({ x: 790, y: 590, w: 400, h: 300 }, 800, 600);
+ expect(low.x).toBe(800 - 48);
+ expect(low.y).toBe(600 - 28 - 18);
+ });
+
+ test("maximized fills the desktop above the taskbar", () => {
+ expect(maximizedGeo(800, 600)).toEqual({ x: 0, y: 0, w: 800, h: 572 });
+ });
+
+ test("resize cursor kinds", () => {
+ expect(cursorForDir("e")).toBe("ew");
+ expect(cursorForDir("n")).toBe("ns");
+ expect(cursorForDir("se")).toBe("nwse");
+ expect(cursorForDir("sw")).toBe("nesw");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// mines.ts
+// ---------------------------------------------------------------------------
+
+describe("minesweeper", () => {
+ test("first reveal is always safe and plants exactly ten mines", () => {
+ for (const seed of [1, 42, 1234, 987654]) {
+ const m = reveal(newMines(seed), 40);
+ expect(m.phase === "lost").toBe(false);
+ expect(m.cells.filter((c) => c.mine).length).toBe(MINES_N);
+ expect(m.cells[40].mine).toBe(false);
+ expect(m.cells[40].state).toBe("revealed");
+ }
+ });
+
+ test("zero-adjacency regions flood open", () => {
+ // Find a seed/cell whose reveal floods more than one cell.
+ const m = newMines(7);
+ reveal(m, 0);
+ if (m.cells[0].adj === 0) {
+ expect(m.revealed).toBeGreaterThan(1);
+ }
+ // Whatever the layout, revealed count matches cells marked revealed.
+ expect(m.cells.filter((c) => c.state === "revealed").length).toBe(m.revealed);
+ });
+
+ test("flags toggle and never reveal", () => {
+ const m = reveal(newMines(3), 0);
+ const hidden = m.cells.findIndex((c) => c.state === "hidden");
+ toggleFlag(m, hidden);
+ expect(m.cells[hidden].state).toBe("flag");
+ expect(m.flags).toBe(1);
+ reveal(m, hidden); // flagged cells refuse reveal
+ expect(m.cells[hidden].state).toBe("flag");
+ toggleFlag(m, hidden);
+ expect(m.flags).toBe(0);
+ });
+
+ test("revealing a mine loses and exposes the field; clearing all safe cells wins", () => {
+ const m = reveal(newMines(11), 22);
+ const mine = m.cells.findIndex((c) => c.mine);
+ reveal(m, mine);
+ expect(m.phase).toBe("lost");
+ expect(m.bust).toBe(mine);
+ expect(m.cells.filter((c) => c.mine && c.state === "revealed").length).toBe(MINES_N);
+
+ const w = newMines(5);
+ reveal(w, 0);
+ for (let i = 0; i < w.cells.length; i++) {
+ if (!w.cells[i].mine) reveal(w, i);
+ }
+ expect(w.phase).toBe("won");
+ expect(w.flags).toBe(MINES_N); // win convention: mines auto-flag
+ });
+
+ test("placement is deterministic per seed", () => {
+ const a = reveal(newMines(99), 0);
+ const b = reveal(newMines(99), 0);
+ expect(a.cells.map((c) => c.mine)).toEqual(b.cells.map((c) => c.mine));
+ expect(MINES_W).toBe(9);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// notepad.ts
+// ---------------------------------------------------------------------------
+
+describe("notepad editing", () => {
+ const doc = { lines: ["hello", "world"], caret: { row: 0, col: 5 } };
+
+ test("insert with newlines splits lines and lands the caret", () => {
+ const d = insertText(doc, "!\nnew");
+ expect(d.lines).toEqual(["hello!", "new", "world"]);
+ expect(d.caret).toEqual({ row: 1, col: 3 });
+ });
+
+ test("backspace joins lines at col 0", () => {
+ const d = backspace({ lines: ["ab", "cd"], caret: { row: 1, col: 0 } });
+ expect(d.lines).toEqual(["abcd"]);
+ expect(d.caret).toEqual({ row: 0, col: 2 });
+ });
+
+ test("delete joins the next line at the end", () => {
+ const d = del({ lines: ["ab", "cd"], caret: { row: 0, col: 2 } });
+ expect(d.lines).toEqual(["abcd"]);
+ expect(d.caret).toEqual({ row: 0, col: 2 });
+ });
+
+ test("caret movement clamps and wraps", () => {
+ expect(moveCaret({ lines: ["ab", "c"], caret: { row: 0, col: 2 } }, "Right")).toEqual({
+ row: 1,
+ col: 0,
+ });
+ expect(moveCaret({ lines: ["ab", "c"], caret: { row: 1, col: 0 } }, "Left")).toEqual({
+ row: 0,
+ col: 2,
+ });
+ expect(moveCaret({ lines: ["ab", "c"], caret: { row: 0, col: 2 } }, "Down")).toEqual({
+ row: 1,
+ col: 1,
+ });
+ expect(moveCaret({ lines: ["ab", "c"], caret: { row: 1, col: 1 } }, "End")).toEqual({
+ row: 1,
+ col: 1,
+ });
+ });
+
+ test("colFromX picks the nearest gap by prefix midpoints", () => {
+ const measure = (s: string) => s.length * 6;
+ expect(colFromX("abcd", 0, measure)).toBe(0);
+ expect(colFromX("abcd", 2, measure)).toBe(0); // < half of the first char
+ expect(colFromX("abcd", 4, measure)).toBe(1);
+ expect(colFromX("abcd", 100, measure)).toBe(4);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// notepad.ts — selection model
+// ---------------------------------------------------------------------------
+
+describe("notepad selection", () => {
+ const sel: Doc = {
+ lines: ["hello world", "second line", "third"],
+ caret: { row: 1, col: 4 },
+ anchor: { row: 0, col: 6 },
+ };
+
+ test("selRange orders anchor/caret either way; collapsed = none", () => {
+ expect(selRange(sel)).toEqual({ from: { row: 0, col: 6 }, to: { row: 1, col: 4 } });
+ const flipped: Doc = { ...sel, caret: sel.anchor as { row: number; col: number }, anchor: sel.caret };
+ expect(selRange(flipped)).toEqual(selRange(sel));
+ expect(hasSel({ lines: ["a"], caret: { row: 0, col: 1 }, anchor: { row: 0, col: 1 } })).toBe(false);
+ expect(hasSel({ lines: ["a"], caret: { row: 0, col: 1 } })).toBe(false);
+ });
+
+ test("selectedText joins the range with newlines", () => {
+ expect(selectedText(sel)).toBe("world\nseco");
+ const one: Doc = { lines: ["hello"], caret: { row: 0, col: 4 }, anchor: { row: 0, col: 1 } };
+ expect(selectedText(one)).toBe("ell");
+ });
+
+ test("deleteSel merges the edge lines and lands the caret at the start", () => {
+ const d = deleteSel(sel);
+ expect(d.lines).toEqual(["hello nd line", "third"]);
+ expect(d.caret).toEqual({ row: 0, col: 6 });
+ expect(hasSel(d)).toBe(false);
+ });
+
+ test("typing replaces the selection; backspace/delete just remove it", () => {
+ const typed = insertText(sel, "X");
+ expect(typed.lines).toEqual(["hello Xnd line", "third"]);
+ expect(typed.caret).toEqual({ row: 0, col: 7 });
+ expect(backspace(sel).lines).toEqual(["hello nd line", "third"]);
+ expect(del(sel).lines).toEqual(["hello nd line", "third"]);
+ });
+
+ test("shift extends from the caret; a plain move collapses to the edge", () => {
+ const start: Doc = { lines: ["abc def"], caret: { row: 0, col: 4 } };
+ const ext = applyMove(start, "Right", true);
+ expect(ext.anchor).toEqual({ row: 0, col: 4 });
+ expect(ext.caret).toEqual({ row: 0, col: 5 });
+ const left = applyMove(sel, "Left", false);
+ expect(left.caret).toEqual({ row: 0, col: 6 }); // collapse to from
+ expect(hasSel(left)).toBe(false);
+ const right = applyMove(sel, "Right", false);
+ expect(right.caret).toEqual({ row: 1, col: 4 }); // collapse to to
+ });
+
+ test("selectAll spans the whole document", () => {
+ const all = selectAll({ lines: ["ab", "cde"], caret: { row: 0, col: 0 } });
+ expect(all.anchor).toEqual({ row: 0, col: 0 });
+ expect(all.caret).toEqual({ row: 1, col: 3 });
+ expect(selectedText(all)).toBe("ab\ncde");
+ });
+
+ test("wordRangeAt picks word, whitespace and punctuation runs", () => {
+ expect(wordRangeAt("foo bar_baz!", 5)).toEqual({ from: 4, to: 11 });
+ expect(wordRangeAt("foo bar", 3)).toEqual({ from: 3, to: 4 }); // the space run
+ expect(wordRangeAt("a==b", 1)).toEqual({ from: 1, to: 3 }); // punct run
+ expect(wordRangeAt("", 0)).toEqual({ from: 0, to: 0 });
+ });
+
+ test("rowSelSpan covers edge rows partially and middle rows fully", () => {
+ const tall: Doc = {
+ lines: ["aaaa", "bbbb", "cccc"],
+ caret: { row: 2, col: 2 },
+ anchor: { row: 0, col: 1 },
+ };
+ expect(rowSelSpan(tall, 0)).toEqual({ from: 1, to: 4 });
+ expect(rowSelSpan(tall, 1)).toEqual({ from: 0, to: 4 });
+ expect(rowSelSpan(tall, 2)).toEqual({ from: 0, to: 2 });
+ expect(rowSelSpan(tall, 3)).toBeNull();
+ expect(rowSelSpan({ lines: ["x"], caret: { row: 0, col: 0 } }, 0)).toBeNull();
+ });
+});
diff --git a/tests/font-bake.test.ts b/tests/font-bake.test.ts
index f2520405..34c9b999 100644
--- a/tests/font-bake.test.ts
+++ b/tests/font-bake.test.ts
@@ -136,3 +136,44 @@ describe("monospace slots", () => {
expect(glyphAdvances(bakeSlot(font, 1, 14, false, chars, 1)).size).toBeGreaterThan(1);
});
});
+
+describe("fill rule", () => {
+ // W95FA (a bitmap-font conversion) builds glyphs from stroke rectangles
+ // that SHARE EDGES — under the old even-odd pairing the shared spans
+ // cancelled and 'h' lost its ascender (rendered identical to 'n').
+ // Nonzero winding is the TrueType/CFF rule; this pins it.
+ test("edge-sharing strokes survive (nonzero winding, not even-odd)", async () => {
+ const w95 = parseFont(
+ await Bun.file(new URL("../assets/fonts/W95FA.otf", import.meta.url)).arrayBuffer(),
+ );
+ const chars = ["h", "n"].map((c) => c.codePointAt(0)!);
+ const atlas = bakeSlot(w95, 0, 12.5, false, chars, 2);
+ const view = new DataView(atlas.bytes.buffer, atlas.bytes.byteOffset, atlas.bytes.byteLength);
+ const cellBytes = atlas.coverageW * atlas.coverageH;
+ const cellsOff = FONT_HEADER_SIZE + atlas.glyphCount * FONT_CMAP_ENTRY_SIZE;
+ const cellOf = (cp: number): Uint8Array => {
+ for (let i = 0; i < atlas.glyphCount; i++) {
+ const e = FONT_HEADER_SIZE + i * FONT_CMAP_ENTRY_SIZE;
+ if (view.getUint32(e, true) === cp) {
+ const gid = view.getUint16(e + 4, true);
+ return atlas.bytes.subarray(cellsOff + gid * cellBytes, cellsOff + (gid + 1) * cellBytes);
+ }
+ }
+ throw new Error(`codepoint ${cp} missing`);
+ };
+ const h = cellOf(chars[0]);
+ const n = cellOf(chars[1]);
+ expect(h.some((b) => b > 0)).toBe(true);
+ expect(Buffer.from(h).equals(Buffer.from(n))).toBe(false);
+ // The ascender: 'h' must have ink strictly above 'n''s topmost row.
+ const topInk = (cell: Uint8Array): number => {
+ for (let y = 0; y < atlas.coverageH; y++) {
+ for (let x = 0; x < atlas.coverageW; x++) {
+ if (cell[y * atlas.coverageW + x] > 0) return y;
+ }
+ }
+ return atlas.coverageH;
+ };
+ expect(topInk(h)).toBeLessThan(topInk(n));
+ });
+});
diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts
index 583775e7..1e21acdf 100644
--- a/tests/platform-contracts.test.ts
+++ b/tests/platform-contracts.test.ts
@@ -403,6 +403,7 @@ describe("semantic resolution", () => {
cards: [true, true, false, true],
chrome: [true, true, false, true],
cursor: [true, true, false, true],
+ desk98: [false, false, true, true], // dynamic-only desktop compositor; the desk companion is macos-app's
gallery: [true, true, false, true],
hero: [true, true, true, true],
"hero-vue-sfc": [true, true, false, true],
diff --git a/tools/macos.ts b/tools/macos.ts
index a4f3218d..f71d7a82 100644
--- a/tools/macos.ts
+++ b/tools/macos.ts
@@ -85,6 +85,14 @@ if (plan.features["text.layout.native"]) flags.push("--native-text");
if (plan.companions.length > 0) flags.push("--companions", plan.companions.join(","));
if (editor) flags.push("--editor");
+if (proof && !editor) {
+ console.error(
+ "bun run macos --proof drives the NOTE companion protocol (scripted typing + autosave); " +
+ `${appDir} declares [${plan.companions.join(", ")}]. Script other apps with the host's ` +
+ "--mouse/--key/--type/--click flags directly.",
+ );
+ process.exit(1);
+}
if (proof) {
const file = `${root}dist/macos-proof.md`;
await $`rm -f ${file}`;
diff --git a/tools/test.ts b/tools/test.ts
index bbdb6ee5..d25eaaab 100644
--- a/tools/test.ts
+++ b/tools/test.ts
@@ -44,6 +44,7 @@ const SUITE: readonly Stage[] = [
"tests/widget-args.test.ts",
"tests/ipod-nano.test.ts",
"tests/note.test.ts",
+ "tests/desk98.test.ts",
"tests/site-stage.test.ts",
"tests/host-build-inputs.test.ts",
"tests/iphone2g-profile.test.ts",
@@ -115,6 +116,12 @@ const SUITE: readonly Stage[] = [
browser: true,
tests: ["tests/octane-smoke.test.ts"],
},
+ {
+ name: "desk98 sim",
+ prep: [["bun", "tools/build.ts", "desk98-main", "--framework=vue-vapor"]],
+ browser: true,
+ tests: ["tests/desk98-sim.test.ts"],
+ },
{
name: "cafe sim (determinism)",
prep: [["bun", "tools/build.ts", "cafe-main"]],