diff --git a/apps/desk98/AboutView.vue b/apps/desk98/AboutView.vue
deleted file mode 100644
index 35c83bcc..00000000
--- a/apps/desk98/AboutView.vue
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/desk98/Button98.vue b/apps/desk98/Button98.vue
deleted file mode 100644
index d677555f..00000000
--- a/apps/desk98/Button98.vue
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/apps/desk98/CaptionButtons.vue b/apps/desk98/CaptionButtons.vue
deleted file mode 100644
index 20118a41..00000000
--- a/apps/desk98/CaptionButtons.vue
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/apps/desk98/Counter.vue b/apps/desk98/Counter.vue
deleted file mode 100644
index ca37cec8..00000000
--- a/apps/desk98/Counter.vue
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/apps/desk98/DesktopIcons.vue b/apps/desk98/DesktopIcons.vue
deleted file mode 100644
index 2d0eb6db..00000000
--- a/apps/desk98/DesktopIcons.vue
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/desk98/Digit.vue b/apps/desk98/Digit.vue
deleted file mode 100644
index 5de1f583..00000000
--- a/apps/desk98/Digit.vue
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/desk98/FolderView.vue b/apps/desk98/FolderView.vue
deleted file mode 100644
index ed95df73..00000000
--- a/apps/desk98/FolderView.vue
+++ /dev/null
@@ -1,57 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/desk98/MinesCell.vue b/apps/desk98/MinesCell.vue
deleted file mode 100644
index 32f832ac..00000000
--- a/apps/desk98/MinesCell.vue
+++ /dev/null
@@ -1,55 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/desk98/MinesView.vue b/apps/desk98/MinesView.vue
deleted file mode 100644
index 3672ac2f..00000000
--- a/apps/desk98/MinesView.vue
+++ /dev/null
@@ -1,48 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/desk98/NotepadView.vue b/apps/desk98/NotepadView.vue
deleted file mode 100644
index e74386cc..00000000
--- a/apps/desk98/NotepadView.vue
+++ /dev/null
@@ -1,78 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/desk98/PopupPanel.vue b/apps/desk98/PopupPanel.vue
deleted file mode 100644
index 707a2d9e..00000000
--- a/apps/desk98/PopupPanel.vue
+++ /dev/null
@@ -1,64 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/desk98/ShutdownView.vue b/apps/desk98/ShutdownView.vue
deleted file mode 100644
index 85242e8b..00000000
--- a/apps/desk98/ShutdownView.vue
+++ /dev/null
@@ -1,43 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/desk98/StartMenu.vue b/apps/desk98/StartMenu.vue
deleted file mode 100644
index 4eb83282..00000000
--- a/apps/desk98/StartMenu.vue
+++ /dev/null
@@ -1,63 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/desk98/T98.vue b/apps/desk98/T98.vue
deleted file mode 100644
index 1a828722..00000000
--- a/apps/desk98/T98.vue
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
- {{ props.t }}
-
diff --git a/apps/desk98/Taskbar.vue b/apps/desk98/Taskbar.vue
deleted file mode 100644
index 23a263ea..00000000
--- a/apps/desk98/Taskbar.vue
+++ /dev/null
@@ -1,54 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/desk98/Window98.vue b/apps/desk98/Window98.vue
deleted file mode 100644
index 587be263..00000000
--- a/apps/desk98/Window98.vue
+++ /dev/null
@@ -1,55 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/desk98/app.tsx b/apps/desk98/app.tsx
new file mode 100644
index 00000000..1daaaa4a
--- /dev/null
+++ b/apps/desk98/app.tsx
@@ -0,0 +1,1488 @@
+// apps/desk98/app.tsx — PocketJS 98: a Windows 98 desktop compositor as a
+// PocketJS app on the gpui macOS host — Vue Vapor, authored in JSX
+// (vue-jsx-vapor, the same path as apps/hero-vue-vapor).
+//
+// The compositor owns ALL input: the host forwards raw mouse/keyboard over
+// the desk svc dialect (svc.ts), and this file routes every event itself —
+// window drags, resizes, caption buttons, menus, text selection, program
+// content — against the same geometry the chrome renders (wm.ts +
+// programs.tsx helpers). The framework's focus/onPress pipeline is never
+// engaged; a window manager IS its own hit tester. Window moves ride
+// paint-only translate props, raises ride zIndex, so a drag never relayouts
+// and an idle desktop hashes stable for the demand-render governor.
+//
+// Shortcuts are macOS-style: the host forwards ⌘ chords as cmd-flagged key
+// lines (⌘Q quits and ⌘V pastes host-side) — ⌘W closes, ⌘M minimizes,
+// ⌘` cycles, ⌘N opens Notepad, ⌘Esc toggles Start, ⌘A/C/X edit the
+// focused Notepad.
+//
+// Without the desk companion (sim, goldens, consoles) the app boots a
+// static arrangement and just renders it — the unmodified-app base case.
+
+import { ref, shallowRef, triggerRef } from "vue";
+import { Image, View } from "@pocketjs/framework/components";
+import { onFrame } from "@pocketjs/framework/lifecycle";
+import { virtualNow } from "@pocketjs/framework/clock";
+import { connectSvc, type CursorKind, type HostEvent } from "./svc.ts";
+import {
+ cascadePos,
+ clampMove,
+ contentTop,
+ cursorForDir,
+ hitRegion,
+ maximizedGeo,
+ resizeGeo,
+ type CaptionButton,
+ type Dir,
+ type Geo,
+ type Region,
+} from "./wm.ts";
+import {
+ createWin,
+ type AboutData,
+ type DeskIcon,
+ type FolderData,
+ type FolderRow,
+ type MinesData,
+ type PadData,
+ type Popup,
+ type PopupItem,
+ type ShutdownData,
+ type TaskEntry,
+ type WinCtl,
+} from "./state.ts";
+import {
+ ABOUT_GEO,
+ AboutView,
+ aboutHit,
+ FolderView,
+ folderRowAt,
+ measure,
+ MINES_GEO,
+ MinesView,
+ minesHit,
+ NotepadView,
+ PAD_LINE_H,
+ SHUTDOWN_GEO,
+ ShutdownView,
+ shutdownHit,
+} from "./programs.tsx";
+import {
+ applyMove,
+ backspace,
+ colFromX,
+ del,
+ deleteSel,
+ hasSel,
+ insertText,
+ selectAll,
+ selectedText,
+ wordRangeAt,
+ type CaretMove,
+ type Doc,
+} from "./notepad.ts";
+import { newMines, reveal, toggleFlag } from "./mines.ts";
+import { CAPTION_ACTIVE, CAPTION_INACTIVE, FRAME, TASK_H } from "./theme.ts";
+import { CaptionButtons, DesktopIcons, PopupPanel, StartMenu, T98, Taskbar } from "./chrome.tsx";
+
+const WELCOME = [
+ "Welcome to PocketJS 98.",
+ "",
+ "This desktop is one PocketJS guest: the windows,",
+ "the taskbar, the Start menu and this Notepad are",
+ "Vue Vapor JSX over the same DrawList contract",
+ "the consoles boot, painted by the gpui backend.",
+ "",
+ "Things to try:",
+ " - drag windows by the title bar",
+ " - drag any edge or corner to resize",
+ " - double-click a title bar to maximize",
+ " - drag-select this text; Cmd+C/X/V, right-click",
+ " - right-click the desktop or Minesweeper",
+ " - Cmd+` cycles windows, Cmd+W closes them",
+ " - Cmd+Esc opens the Start menu",
+ "",
+ "The font is W95FA, baked to the same atlas",
+ "format every other PocketJS target reads.",
+];
+
+type Drag =
+ | { type: "move"; id: number; sx: number; sy: number; orig: Geo }
+ | { type: "resize"; id: number; dir: Dir; sx: number; sy: number; orig: Geo }
+ | { type: "capbtn"; id: number; btn: CaptionButton }
+ | { type: "textsel"; id: number }
+ | { type: "minehold"; id: number }
+ | { type: "smiley"; id: number }
+ | { type: "dialogbtn"; id: number; tag: string }
+ | null;
+
+// Program data bags (typed views of w.data for render + routing).
+const padOf = (w: WinCtl) => w.data as PadData;
+const minesOf = (w: WinCtl) => w.data as MinesData;
+const folderOf = (w: WinCtl) => w.data as FolderData;
+const aboutOf = (w: WinCtl) => w.data as AboutData;
+const shutdownOf = (w: WinCtl) => w.data as ShutdownData;
+
+/** One window: raised frame, caption gradient, controls, menu bar, and the
+ * program content dispatched on the (static) window kind. Position/size
+ * ride the style prop — translate moves are paint-only, and zIndex raises
+ * without reordering siblings (a reorder would rebuild the layout tree). */
+function Window98(props: { win: WinCtl; active: boolean }) {
+ const w = props.win;
+ return (
+
+
+
+
+
+
+
+
+ {w.menus !== null ? (
+
+ {(w.menus ?? []).map((menu, i) => (
+
+
+
+ ))}
+
+ ) : null}
+
+ {w.kind === "notepad" ? (
+
+ ) : w.kind === "mines" ? (
+
+ ) : w.kind === "folder" ? (
+
+ ) : w.kind === "about" ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+export default function App() {
+ const svc = connectSvc();
+
+ const vp = shallowRef<{ w: number; h: number }>({ w: 800, h: 600 });
+ const wins = shallowRef([]);
+ const focusId = ref(-1);
+ const iconSel = ref(-1);
+ const startOpen = ref(false);
+ const startHover = ref(-1);
+ const startFly = shallowRef<{ index: number; popup: Popup } | null>(null);
+ const flyHover = ref(-1);
+ const popup = shallowRef<{ popup: Popup; winId?: number } | null>(null);
+ const popupHover = ref(-1);
+ const clock = ref("--:--");
+
+ // Non-reactive input state (nothing renders from these directly).
+ let stack: number[] = []; // window ids, bottom → top
+ let drag: Drag = null;
+ let mx = 0;
+ let my = 0;
+ let prevDown = false;
+ let epoch = 0; // wall ms at hello, anchored to virtualNow() then
+ let epochAt = 0;
+ let lastCursor: CursorKind = "default";
+ let lastClick = { key: "", t: -1, x: 0, y: 0 };
+ let lastCaret = { x: -1, y: -1, h: 0 };
+ let minesStart = 0;
+
+ const byId = (id: number) => wins.value.find((w) => w.id === id);
+ const focused = () => byId(focusId.value);
+
+ // ---- window management ----------------------------------------------------
+
+ function applyZ() {
+ stack.forEach((id, i) => {
+ const w = byId(id);
+ if (w) w.z.value = i + 1;
+ });
+ }
+
+ function raise(id: number) {
+ stack = stack.filter((x) => x !== id).concat(id);
+ applyZ();
+ focusId.value = id;
+ const w = byId(id);
+ if (w?.minimized.value) w.minimized.value = false;
+ }
+
+ function addWin(w: WinCtl) {
+ wins.value = wins.value.concat(w);
+ stack = stack.concat(w.id);
+ applyZ();
+ focusId.value = w.id;
+ }
+
+ function closeWin(id: number) {
+ wins.value = wins.value.filter((w) => w.id !== id);
+ stack = stack.filter((x) => x !== id);
+ applyZ();
+ focusId.value = stack.length > 0 ? stack[stack.length - 1] : -1;
+ }
+
+ function minimize(id: number) {
+ const w = byId(id);
+ if (w) w.minimized.value = true;
+ const next = stack.filter((x) => x !== id && !byId(x)?.minimized.value);
+ focusId.value = next.length > 0 ? next[next.length - 1] : -1;
+ }
+
+ function toggleMax(w: WinCtl) {
+ if (!w.resizable) return;
+ if (w.maximized.value) {
+ w.maximized.value = false;
+ if (w.restoreGeo) w.geo.value = w.restoreGeo;
+ } else {
+ w.restoreGeo = w.geo.value;
+ w.maximized.value = true;
+ w.geo.value = maximizedGeo(vp.value.w, vp.value.h);
+ }
+ }
+
+ function cycleWindows() {
+ const visible = stack.filter((id) => !byId(id)?.minimized.value);
+ if (visible.length < 2) return;
+ raise(visible[0]); // bottom-most visible comes up — repeated ⌘` cycles
+ }
+
+ // ---- clipboard (notepad selection ↔ host) ---------------------------------
+
+ function focusedPad(): { w: WinCtl; d: PadData } | null {
+ const w = focused();
+ return w?.kind === "notepad" ? { w, d: padOf(w) } : null;
+ }
+
+ function copySel(): void {
+ const p = focusedPad();
+ if (!p) return;
+ const text = selectedText(p.d.doc.value);
+ if (text !== "" && svc) svc.send({ t: "copy", text });
+ }
+
+ function cutSel(): void {
+ const p = focusedPad();
+ if (!p || !hasSel(p.d.doc.value)) return;
+ copySel();
+ p.d.doc.value = deleteSel(p.d.doc.value);
+ scrollCaretIntoView(p.w);
+ }
+
+ function pasteReq(): void {
+ // The clipboard lives host-side; the paste line answers next frame.
+ svc?.send({ t: "paste-req" });
+ }
+
+ function selectAllIn(w: WinCtl): void {
+ const d = padOf(w);
+ d.doc.value = selectAll(d.doc.value);
+ }
+
+ // ---- programs ---------------------------------------------------------------
+
+ function openNotepad(title: string, content: string[]) {
+ const existing = wins.value.find((w) => w.kind === "notepad" && w.title === title);
+ if (existing) return raise(existing.id);
+ const data: PadData = {
+ kind: "notepad",
+ doc: shallowRef({
+ lines: content.length > 0 ? content : [""],
+ caret: { row: 0, col: 0 },
+ }),
+ scroll: ref(0),
+ preedit: ref<{ s: string; c: number } | null>(null),
+ };
+ const w = createWin({
+ kind: "notepad",
+ title,
+ icon: "icons/notepad-16.svg",
+ geo: cascadePos(wins.value.length, vp.value.w, vp.value.h, 400, 300),
+ minW: 220,
+ minH: 140,
+ menus: [
+ {
+ label: "File",
+ width: measure("File") + 12,
+ items: () => [
+ {
+ label: "New",
+ act: () => {
+ data.doc.value = { lines: [""], caret: { row: 0, col: 0 } };
+ },
+ },
+ { sep: true, label: "" },
+ {
+ label: "Exit",
+ shortcut: "Cmd+W",
+ act: () => {
+ closeWin(w.id);
+ },
+ },
+ ],
+ },
+ {
+ label: "Edit",
+ width: measure("Edit") + 12,
+ items: () => [
+ { label: "Cut", shortcut: "Cmd+X", disabled: !hasSel(data.doc.value), act: cutSel },
+ { label: "Copy", shortcut: "Cmd+C", disabled: !hasSel(data.doc.value), act: copySel },
+ { label: "Paste", shortcut: "Cmd+V", act: pasteReq },
+ {
+ label: "Select All",
+ shortcut: "Cmd+A",
+ act: () => {
+ selectAllIn(w);
+ },
+ },
+ { sep: true, label: "" },
+ {
+ label: "Time/Date",
+ shortcut: "F5",
+ act: () => {
+ insertTimeDate(w);
+ },
+ },
+ { sep: true, label: "" },
+ { label: "Word Wrap", disabled: true },
+ ],
+ },
+ {
+ label: "Help",
+ width: measure("Help") + 12,
+ items: () => [{ label: "About PocketJS 98", act: openAbout }],
+ },
+ ],
+ data,
+ });
+ addWin(w);
+ }
+
+ function openMines() {
+ const existing = wins.value.find((w) => w.kind === "mines");
+ if (existing) return raise(existing.id);
+ const data: MinesData = {
+ kind: "mines",
+ board: shallowRef(newMines((virtualNow() * 1000) | 0)),
+ held: ref(-1),
+ smileyHeld: ref(false),
+ elapsed: ref(0),
+ };
+ const w = createWin({
+ kind: "mines",
+ title: "Minesweeper",
+ icon: "icons/mines-16.svg",
+ geo: { ...cascadePos(wins.value.length, vp.value.w, vp.value.h, MINES_GEO.w, MINES_GEO.h) },
+ buttons: ["min", "close"],
+ resizable: false,
+ menus: [
+ {
+ label: "Game",
+ width: measure("Game") + 12,
+ items: () => [
+ {
+ label: "New",
+ shortcut: "F2",
+ act: () => {
+ minesNew(w);
+ },
+ },
+ { sep: true, label: "" },
+ {
+ label: "Exit",
+ shortcut: "Cmd+W",
+ act: () => {
+ closeWin(w.id);
+ },
+ },
+ ],
+ },
+ {
+ label: "Help",
+ width: measure("Help") + 12,
+ items: () => [{ label: "About PocketJS 98", act: openAbout }],
+ },
+ ],
+ data,
+ });
+ addWin(w);
+ }
+
+ function minesNew(w: WinCtl) {
+ const d = minesOf(w);
+ d.board.value = newMines((virtualNow() * 1000) | 0);
+ d.elapsed.value = 0;
+ minesStart = 0;
+ }
+
+ function openFolder(title: string, icon: string, rows: FolderRow[], geoW = 420, geoH = 280) {
+ const existing = wins.value.find((w) => w.kind === "folder" && w.title === title);
+ if (existing) return raise(existing.id);
+ const data: FolderData = { kind: "folder", rows, selected: ref(-1) };
+ const w = createWin({
+ kind: "folder",
+ title,
+ icon,
+ geo: cascadePos(wins.value.length, vp.value.w, vp.value.h, geoW, geoH),
+ minW: 260,
+ minH: 160,
+ data,
+ });
+ addWin(w);
+ }
+
+ function openMyComputer() {
+ openFolder("My Computer", "icons/computer-16.svg", [
+ {
+ icon: "icons/drive-16.svg",
+ name: "(C:)",
+ size: "",
+ type: "Local Disk",
+ open: () => {
+ openDriveC();
+ },
+ },
+ { icon: "icons/cdrom-16.svg", name: "(D:)", size: "", type: "CD-ROM Disc" },
+ { icon: "icons/folder-16.svg", name: "Control Panel", size: "", type: "System Folder" },
+ { icon: "icons/folder-16.svg", name: "Printers", size: "", type: "System Folder" },
+ ]);
+ }
+
+ function openDriveC() {
+ openFolder("(C:)", "icons/drive-16.svg", [
+ { icon: "icons/folder-16.svg", name: "Program Files", size: "", type: "File Folder" },
+ { icon: "icons/folder-16.svg", name: "Windows", size: "", type: "File Folder" },
+ { icon: "icons/file-16.svg", name: "AUTOEXEC.BAT", size: "1 KB", type: "MS-DOS Batch File" },
+ { icon: "icons/file-16.svg", name: "CONFIG.SYS", size: "1 KB", type: "System file" },
+ {
+ icon: "icons/notepad-16.svg",
+ name: "README.TXT",
+ size: "2 KB",
+ type: "Text Document",
+ open: () => {
+ openNotepad("README.TXT - Notepad", WELCOME);
+ },
+ },
+ ]);
+ }
+
+ function openDocuments() {
+ openFolder("My Documents", "icons/folder-16.svg", [
+ {
+ icon: "icons/notepad-16.svg",
+ name: "welcome.txt",
+ size: "1 KB",
+ type: "Text Document",
+ open: () => {
+ openNotepad("welcome.txt - Notepad", WELCOME);
+ },
+ },
+ ]);
+ }
+
+ function openRecycle() {
+ openFolder("Recycle Bin", "icons/recycle-16.svg", []);
+ }
+
+ function openAbout() {
+ const existing = wins.value.find((w) => w.kind === "about");
+ if (existing) return raise(existing.id);
+ const data: AboutData = { kind: "about", armed: ref(null) };
+ const w = createWin({
+ kind: "about",
+ title: "About PocketJS 98",
+ icon: "icons/computer-16.svg",
+ geo: centered(ABOUT_GEO.w, ABOUT_GEO.h),
+ buttons: ["close"],
+ resizable: false,
+ data,
+ });
+ addWin(w);
+ }
+
+ function openShutdown() {
+ const existing = wins.value.find((w) => w.kind === "shutdown");
+ if (existing) return raise(existing.id);
+ const data: ShutdownData = {
+ kind: "shutdown",
+ choice: ref(0),
+ armed: ref(null),
+ };
+ const w = createWin({
+ kind: "shutdown",
+ title: "Shut Down Windows",
+ icon: "icons/shutdown-16.svg",
+ geo: centered(SHUTDOWN_GEO.w, SHUTDOWN_GEO.h),
+ buttons: ["close"],
+ resizable: false,
+ data,
+ });
+ addWin(w);
+ }
+
+ function centered(w: number, h: number): Geo {
+ return {
+ x: Math.max(0, Math.round((vp.value.w - w) / 2)),
+ y: Math.max(0, Math.round((vp.value.h - TASK_H - h) / 2)),
+ w,
+ h,
+ };
+ }
+
+ function restartSession() {
+ for (const w of wins.value.slice()) closeWin(w.id);
+ boot();
+ }
+
+ function insertTimeDate(w: WinCtl) {
+ const d = padOf(w);
+ const t = new Date(epoch + (virtualNow() - epochAt) * 1000);
+ const stamp = `${pad2(t.getHours())}:${pad2(t.getMinutes())} ${pad2(t.getMonth() + 1)}/${pad2(t.getDate())}/${t.getFullYear()}`;
+ d.doc.value = insertText(d.doc.value, stamp);
+ }
+
+ // ---- desktop icons + start menu ----------------------------------------------
+
+ const icons: DeskIcon[] = [
+ { icon: "icons/computer.svg", label: "My Computer", open: openMyComputer },
+ { icon: "icons/documents.svg", label: "My Documents", open: openDocuments },
+ { icon: "icons/recycle.svg", label: "Recycle Bin", open: openRecycle },
+ {
+ icon: "icons/notepad.svg",
+ label: "Notepad",
+ open: () => {
+ openNotepad("Untitled - Notepad", [""]);
+ },
+ },
+ { icon: "icons/mines.svg", label: "Minesweeper", open: openMines },
+ ];
+ const ICON_X = 8;
+ const ICON_W = 74;
+ const ICON_STRIDE = 58;
+ const ICON_CELL_H = 48;
+
+ function iconAt(x: number, y: number): number {
+ if (x < ICON_X || x >= ICON_X + ICON_W) return -1;
+ const rel = y - 8;
+ if (rel < 0 || rel % ICON_STRIDE >= ICON_CELL_H) return -1;
+ const i = Math.floor(rel / ICON_STRIDE);
+ return i >= 0 && i < icons.length ? i : -1;
+ }
+
+ const startItems = (): PopupItem[] => [
+ {
+ label: "Programs",
+ icon: "icons/folder-16.svg",
+ sub: [
+ {
+ label: "Notepad",
+ icon: "icons/notepad-16.svg",
+ act: () => {
+ openNotepad("Untitled - Notepad", [""]);
+ },
+ },
+ { label: "Minesweeper", icon: "icons/mines-16.svg", act: openMines },
+ ],
+ },
+ {
+ label: "Documents",
+ icon: "icons/folder-16.svg",
+ sub: [
+ {
+ label: "welcome.txt",
+ icon: "icons/notepad-16.svg",
+ act: () => {
+ openNotepad("welcome.txt - Notepad", WELCOME);
+ },
+ },
+ ],
+ },
+ { label: "Settings", icon: "icons/settings-16.svg", disabled: true },
+ { label: "Find", icon: "icons/find-16.svg", disabled: true },
+ { label: "Help", icon: "icons/help-16.svg", act: openAbout },
+ { label: "Run...", icon: "icons/run-16.svg", disabled: true },
+ { sep: true, label: "" },
+ { label: "Shut Down...", icon: "icons/shutdown-16.svg", act: openShutdown },
+ ];
+
+ const START_ROW = 26;
+ const START_SEP = 8;
+ const startH = () =>
+ 2 + startItems().reduce((a, it) => a + (it.sep ? START_SEP : START_ROW), 0);
+ const startY = () => vp.value.h - TASK_H - startH();
+
+ function startItemAt(x: number, y: number): number {
+ const items = startItems();
+ if (x < 2 + 25 || x >= 2 + 182 - 1) return -1;
+ let oy = startY() + 1;
+ for (let i = 0; i < items.length; i++) {
+ const h = items[i].sep ? START_SEP : START_ROW;
+ if (y >= oy && y < oy + h) return items[i].sep ? -1 : i;
+ oy += h;
+ }
+ return -1;
+ }
+
+ function buildPopup(x: number, y: number, items: PopupItem[]): Popup {
+ let w = 0;
+ for (const it of items) {
+ if (it.sep) continue;
+ w = Math.max(
+ w,
+ 26 + measure(it.label) + (it.shortcut ? 20 + measure(it.shortcut) : 0) + (it.sub ? 14 : 0) + 14,
+ );
+ }
+ const h = 2 + items.reduce((a, it) => a + (it.sep ? START_SEP : 18), 0);
+ return {
+ x: Math.min(x, vp.value.w - w - 2),
+ y: Math.min(y, vp.value.h - TASK_H - h),
+ w: Math.max(w, 120),
+ items,
+ };
+ }
+
+ function popupItemAt(p: Popup, x: number, y: number): number {
+ if (x < p.x + 1 || x >= p.x + p.w - 1) return -1;
+ let oy = p.y + 1;
+ for (let i = 0; i < p.items.length; i++) {
+ const h = p.items[i].sep ? START_SEP : 18;
+ if (y >= oy && y < oy + h) return p.items[i].sep ? -1 : i;
+ oy += h;
+ }
+ return -1;
+ }
+
+ function closeMenus() {
+ startOpen.value = false;
+ startFly.value = null;
+ popup.value = null;
+ startHover.value = -1;
+ flyHover.value = -1;
+ popupHover.value = -1;
+ for (const w of wins.value) w.openMenu.value = -1;
+ }
+
+ function toggleStart() {
+ popup.value = null;
+ startOpen.value = !startOpen.value;
+ if (!startOpen.value) {
+ startFly.value = null;
+ startHover.value = -1;
+ flyHover.value = -1;
+ }
+ }
+
+ // ---- helpers ------------------------------------------------------------------
+
+ function chromeOpts(w: WinCtl) {
+ return {
+ buttons: w.buttons,
+ resizable: w.resizable,
+ maximized: w.maximized.value,
+ menuWidths: (w.menus ?? []).map((m) => m.width),
+ };
+ }
+
+ /** Topmost visible window under the point, with its chrome region. */
+ function hitWindows(x: number, y: number): { win: WinCtl; region: Region } | null {
+ for (let i = stack.length - 1; i >= 0; i--) {
+ const w = byId(stack[i]);
+ if (!w || w.minimized.value) continue;
+ const region = hitRegion(w.geo.value, chromeOpts(w), x, y);
+ if (region) return { win: w, region };
+ }
+ return null;
+ }
+
+ function isDblClick(key: string): boolean {
+ const now = virtualNow();
+ const hit =
+ lastClick.key === key &&
+ now - lastClick.t < 0.4 &&
+ Math.abs(mx - lastClick.x) < 4 &&
+ Math.abs(my - lastClick.y) < 4;
+ lastClick = hit ? { key: "", t: -1, x: 0, y: 0 } : { key, t: now, x: mx, y: my };
+ return hit;
+ }
+
+ function pad2(n: number): string {
+ return String(n).padStart(2, "0");
+ }
+
+ function sendCursor(k: CursorKind) {
+ if (svc && k !== lastCursor) {
+ lastCursor = k;
+ svc.send({ t: "cursor", k });
+ }
+ }
+
+ /** Notepad caret position for a content-local point (row clamped). */
+ function padCaretAt(d: PadData, cx: number, cy: number): { row: number; col: number } {
+ const doc = d.doc.value;
+ const row = Math.max(
+ 0,
+ Math.min(doc.lines.length - 1, Math.floor((cy - 3 + d.scroll.value) / PAD_LINE_H)),
+ );
+ const col = colFromX(doc.lines[row], cx - 3, (s) => measure(s));
+ return { row, col };
+ }
+
+ // ---- input routing --------------------------------------------------------------
+
+ function onPrimaryDown(shift: boolean) {
+ // Open menus swallow the click (classic: outside-click only dismisses).
+ if (startOpen.value) {
+ const fly = startFly.value;
+ if (fly) {
+ const i = popupItemAt(fly.popup, mx, my);
+ if (i >= 0) {
+ const item = fly.popup.items[i];
+ if (!item.disabled && item.act) {
+ item.act();
+ closeMenus();
+ }
+ return;
+ }
+ }
+ const i = startItemAt(mx, my);
+ if (i >= 0) {
+ const item = startItems()[i];
+ if (item.sub) return; // hover already opened the flyout; stay open
+ if (!item.disabled && item.act) {
+ item.act();
+ closeMenus();
+ }
+ return;
+ }
+ // Outside click (the Start button included) dismisses and is consumed.
+ closeMenus();
+ return;
+ }
+ const pop = popup.value;
+ if (pop) {
+ const i = popupItemAt(pop.popup, mx, my);
+ closeMenus();
+ if (i >= 0) {
+ const item = pop.popup.items[i];
+ if (!item.disabled && item.act) item.act();
+ }
+ return;
+ }
+
+ // Taskbar.
+ if (my >= vp.value.h - TASK_H) {
+ if (mx >= 2 && mx < 58) {
+ startOpen.value = !startOpen.value;
+ return;
+ }
+ const entry = taskEntryAt(mx, my);
+ if (entry !== -1) {
+ const id = wins.value[entry].id;
+ const w = byId(id);
+ if (!w) return;
+ if (focusId.value === id && !w.minimized.value) minimize(id);
+ else raise(id);
+ }
+ return;
+ }
+
+ // Windows, top-down.
+ const hit = hitWindows(mx, my);
+ if (hit) {
+ const { win: w, region } = hit;
+ raise(w.id);
+ if (region.kind === "button") {
+ drag = { type: "capbtn", id: w.id, btn: region.button };
+ w.pressedBtn.value = region.button;
+ return;
+ }
+ if (region.kind === "caption") {
+ if (isDblClick(`cap:${w.id}`)) {
+ toggleMax(w);
+ return;
+ }
+ if (!w.maximized.value) {
+ drag = { type: "move", id: w.id, sx: mx, sy: my, orig: w.geo.value };
+ }
+ return;
+ }
+ if (region.kind === "resize") {
+ drag = { type: "resize", id: w.id, dir: region.dir, sx: mx, sy: my, orig: w.geo.value };
+ return;
+ }
+ if (region.kind === "menu") {
+ const open = w.openMenu.value === region.index ? -1 : region.index;
+ w.openMenu.value = open;
+ if (open >= 0 && w.menus) {
+ const mxs = w.menus.slice(0, open).reduce((a, m) => a + m.width, 0);
+ const g = w.geo.value;
+ popup.value = {
+ popup: buildPopup(g.x + FRAME + mxs, g.y + FRAME + 18 + 1 + 18, w.menus[open].items()),
+ winId: w.id,
+ };
+ }
+ return;
+ }
+ routeContentDown(w, region.cx, region.cy, shift);
+ return;
+ }
+
+ // Desktop: select an icon (double-click opens), deactivate windows.
+ const icon = iconAt(mx, my);
+ iconSel.value = icon;
+ focusId.value = -1;
+ if (icon >= 0 && isDblClick(`icon:${icon}`)) icons[icon].open();
+ }
+
+ function routeContentDown(w: WinCtl, cx: number, cy: number, shift: boolean) {
+ if (w.kind === "notepad") {
+ const d = padOf(w);
+ const doc = d.doc.value;
+ const { row, col } = padCaretAt(d, cx, cy);
+ if (isDblClick(`pad:${w.id}`)) {
+ // Double-click: select the word under the pointer.
+ const r = wordRangeAt(doc.lines[row], col);
+ d.doc.value = { lines: doc.lines, caret: { row, col: r.to }, anchor: { row, col: r.from } };
+ return;
+ }
+ // Click places the caret; shift-click extends; dragging selects.
+ const anchor = shift ? (doc.anchor ?? doc.caret) : { row, col };
+ d.doc.value = { lines: doc.lines, caret: { row, col }, anchor };
+ drag = { type: "textsel", id: w.id };
+ return;
+ }
+ if (w.kind === "mines") {
+ const d = minesOf(w);
+ const hit = minesHit(cx, cy);
+ if (hit?.type === "cell") {
+ drag = { type: "minehold", id: w.id };
+ d.held.value = hit.i;
+ } else if (hit?.type === "smiley") {
+ drag = { type: "smiley", id: w.id };
+ d.smileyHeld.value = true;
+ }
+ return;
+ }
+ if (w.kind === "folder") {
+ const d = folderOf(w);
+ const row = folderRowAt(cy, d.rows.length);
+ d.selected.value = row;
+ if (row >= 0 && isDblClick(`row:${w.id}:${row}`)) d.rows[row].open?.();
+ return;
+ }
+ if (w.kind === "about") {
+ const g = w.geo.value;
+ const hit = aboutHit(g.w - FRAME * 2, g.h - FRAME - contentTop({ menuWidths: [] }), cx, cy);
+ if (hit === "ok") {
+ drag = { type: "dialogbtn", id: w.id, tag: "ok" };
+ aboutOf(w).armed.value = "ok";
+ }
+ return;
+ }
+ if (w.kind === "shutdown") {
+ const g = w.geo.value;
+ const d = shutdownOf(w);
+ const hit = shutdownHit(g.w - FRAME * 2, g.h - FRAME - contentTop({ menuWidths: [] }), cx, cy);
+ if (hit === "radio0") d.choice.value = 0;
+ else if (hit === "radio1") d.choice.value = 1;
+ else if (hit === "ok" || hit === "cancel") {
+ drag = { type: "dialogbtn", id: w.id, tag: hit };
+ d.armed.value = hit;
+ }
+ }
+ }
+
+ function onRightDown() {
+ if (startOpen.value || popup.value) {
+ closeMenus();
+ return;
+ }
+ const hit = hitWindows(mx, my);
+ if (hit) {
+ const { win: w, region } = hit;
+ if (region.kind === "content" && w.kind === "mines") {
+ raise(w.id);
+ const d = minesOf(w);
+ const cell = minesHit(region.cx, region.cy);
+ if (cell?.type === "cell") {
+ d.board.value = toggleFlag(d.board.value, cell.i);
+ triggerRef(d.board);
+ }
+ return;
+ }
+ if (region.kind === "content" && w.kind === "notepad") {
+ raise(w.id);
+ const d = padOf(w);
+ const has = hasSel(d.doc.value);
+ popup.value = {
+ popup: buildPopup(mx, my, [
+ { label: "Cut", shortcut: "Cmd+X", disabled: !has, act: cutSel },
+ { label: "Copy", shortcut: "Cmd+C", disabled: !has, act: copySel },
+ { label: "Paste", shortcut: "Cmd+V", act: pasteReq },
+ { sep: true, label: "" },
+ {
+ label: "Select All",
+ shortcut: "Cmd+A",
+ act: () => {
+ selectAllIn(w);
+ },
+ },
+ ]),
+ };
+ return;
+ }
+ if (region.kind === "caption") {
+ raise(w.id);
+ popup.value = {
+ popup: buildPopup(mx, my, [
+ {
+ label: "Minimize",
+ shortcut: "Cmd+M",
+ act: () => {
+ minimize(w.id);
+ },
+ },
+ {
+ label: w.maximized.value ? "Restore" : "Maximize",
+ disabled: !w.resizable,
+ act: () => {
+ toggleMax(w);
+ },
+ },
+ { sep: true, label: "" },
+ {
+ label: "Close",
+ shortcut: "Cmd+W",
+ act: () => {
+ closeWin(w.id);
+ },
+ },
+ ]),
+ winId: w.id,
+ };
+ }
+ return;
+ }
+ if (my < vp.value.h - TASK_H) {
+ const icon = iconAt(mx, my);
+ iconSel.value = icon;
+ popup.value = {
+ popup: buildPopup(mx, my, [
+ { label: "Arrange Icons", act: () => {} },
+ { label: "Refresh", act: () => {} },
+ { sep: true, label: "" },
+ {
+ label: "New Text Document",
+ act: () => {
+ openNotepad("Untitled - Notepad", [""]);
+ },
+ },
+ { sep: true, label: "" },
+ { label: "Properties", disabled: true },
+ ]),
+ };
+ }
+ }
+
+ function onMove() {
+ // Menu hover states.
+ if (startOpen.value) {
+ const i = startItemAt(mx, my);
+ startHover.value = i;
+ if (i >= 0) {
+ const item = startItems()[i];
+ if (item.sub) {
+ if (startFly.value?.index !== i) {
+ let oy = startY() + 1;
+ for (let k = 0; k < i; k++) oy += startItems()[k].sep ? START_SEP : START_ROW;
+ startFly.value = { index: i, popup: buildPopup(2 + 182 - 3, oy, item.sub) };
+ flyHover.value = -1;
+ }
+ } else if (startFly.value) {
+ startFly.value = null;
+ }
+ }
+ const fly = startFly.value;
+ if (fly) flyHover.value = popupItemAt(fly.popup, mx, my);
+ }
+ const pop = popup.value;
+ if (pop) {
+ popupHover.value = popupItemAt(pop.popup, mx, my);
+ // A menu-bar dropdown follows the hovered menu title (classic).
+ if (pop.winId !== undefined) {
+ const w = byId(pop.winId);
+ if (w?.menus) {
+ const r = hitRegion(w.geo.value, chromeOpts(w), mx, my);
+ if (r?.kind === "menu" && r.index !== w.openMenu.value) {
+ w.openMenu.value = r.index;
+ const mxs = w.menus.slice(0, r.index).reduce((a, m) => a + m.width, 0);
+ const g = w.geo.value;
+ popup.value = {
+ popup: buildPopup(
+ g.x + FRAME + mxs,
+ g.y + FRAME + 18 + 1 + 18,
+ w.menus[r.index].items(),
+ ),
+ winId: w.id,
+ };
+ }
+ }
+ }
+ }
+
+ // Drags.
+ if (drag?.type === "move") {
+ const w = byId(drag.id);
+ if (w) {
+ w.geo.value = clampMove(
+ { ...drag.orig, x: drag.orig.x + (mx - drag.sx), y: drag.orig.y + (my - drag.sy) },
+ vp.value.w,
+ vp.value.h,
+ );
+ }
+ return;
+ }
+ if (drag?.type === "resize") {
+ const w = byId(drag.id);
+ if (w) {
+ w.geo.value = resizeGeo(drag.orig, drag.dir, mx - drag.sx, my - drag.sy, w.minW, w.minH);
+ }
+ sendCursor(cursorForDir(drag.dir));
+ return;
+ }
+ if (drag?.type === "textsel") {
+ const w = byId(drag.id);
+ if (w) {
+ const d = padOf(w);
+ const g = w.geo.value;
+ const cx = mx - g.x - FRAME;
+ const cy = my - g.y - contentTop(chromeOpts(w));
+ const doc = d.doc.value;
+ const { row, col } = padCaretAt(d, Math.max(0, cx), cy);
+ if (row !== doc.caret.row || col !== doc.caret.col) {
+ d.doc.value = {
+ lines: doc.lines,
+ caret: { row, col },
+ anchor: doc.anchor ?? doc.caret,
+ };
+ }
+ }
+ sendCursor("text");
+ return;
+ }
+ if (drag?.type === "capbtn") {
+ const w = byId(drag.id);
+ if (w) {
+ const r = hitRegion(w.geo.value, chromeOpts(w), mx, my);
+ w.pressedBtn.value = r?.kind === "button" && r.button === drag.btn ? drag.btn : null;
+ }
+ return;
+ }
+ if (drag?.type === "minehold") {
+ const w = byId(drag.id);
+ if (w) {
+ const d = minesOf(w);
+ const r = hitRegion(w.geo.value, chromeOpts(w), mx, my);
+ const cell = r?.kind === "content" ? minesHit(r.cx, r.cy) : null;
+ d.held.value = cell?.type === "cell" ? cell.i : -1;
+ }
+ return;
+ }
+ if (drag?.type === "dialogbtn") {
+ const w = byId(drag.id);
+ if (w) {
+ const r = hitRegion(w.geo.value, chromeOpts(w), mx, my);
+ const g = w.geo.value;
+ const cw = g.w - FRAME * 2;
+ const chh = g.h - FRAME - contentTop({ menuWidths: [] });
+ let over: string | null = null;
+ if (r?.kind === "content") {
+ over =
+ w.kind === "about" ? aboutHit(cw, chh, r.cx, r.cy) : shutdownHit(cw, chh, r.cx, r.cy);
+ }
+ const armed = w.kind === "about" ? aboutOf(w).armed : shutdownOf(w).armed;
+ armed.value = over === drag.tag ? drag.tag : null;
+ }
+ return;
+ }
+
+ // Hover cursor shape.
+ let k: CursorKind = "default";
+ const hover = hitWindows(mx, my);
+ if (hover) {
+ if (hover.region.kind === "resize") k = cursorForDir(hover.region.dir);
+ else if (hover.region.kind === "content" && hover.win.kind === "notepad") k = "text";
+ }
+ sendCursor(k);
+ }
+
+ function onPrimaryUp() {
+ const d = drag;
+ drag = null;
+ if (!d) return;
+ if (d.type === "capbtn") {
+ const w = byId(d.id);
+ if (w && w.pressedBtn.value === d.btn) {
+ w.pressedBtn.value = null;
+ if (d.btn === "close") closeWin(d.id);
+ else if (d.btn === "min") minimize(d.id);
+ else if (d.btn === "max") toggleMax(w);
+ } else if (w) w.pressedBtn.value = null;
+ return;
+ }
+ if (d.type === "minehold") {
+ const w = byId(d.id);
+ if (w) {
+ const md = minesOf(w);
+ const i = md.held.value;
+ md.held.value = -1;
+ if (i >= 0) {
+ const was = md.board.value.phase;
+ md.board.value = reveal(md.board.value, i);
+ triggerRef(md.board);
+ if (was === "ready" && md.board.value.phase === "playing") minesStart = virtualNow();
+ }
+ }
+ return;
+ }
+ if (d.type === "smiley") {
+ const w = byId(d.id);
+ if (w) {
+ minesOf(w).smileyHeld.value = false;
+ const r = hitRegion(w.geo.value, chromeOpts(w), mx, my);
+ if (r?.kind === "content" && minesHit(r.cx, r.cy)?.type === "smiley") minesNew(w);
+ }
+ return;
+ }
+ if (d.type === "dialogbtn") {
+ const w = byId(d.id);
+ if (w) {
+ const armedRef = w.kind === "about" ? aboutOf(w).armed : shutdownOf(w).armed;
+ const armed = armedRef.value;
+ armedRef.value = null;
+ if (armed === d.tag) {
+ if (w.kind === "about") closeWin(w.id);
+ else if (w.kind === "shutdown") {
+ const choice = shutdownOf(w).choice.value;
+ if (d.tag === "cancel") closeWin(w.id);
+ else if (choice === 0) svc?.send({ t: "quit" });
+ else restartSession();
+ }
+ }
+ }
+ return;
+ }
+ if (d.type === "resize") sendCursor("default");
+ }
+
+ /** macOS-style ⌘ chords (host forwards them cmd-flagged, raw lowercase k). */
+ function onCmd(k: string) {
+ switch (k) {
+ case "escape":
+ toggleStart();
+ return;
+ case "`":
+ cycleWindows();
+ return;
+ case "n":
+ openNotepad("Untitled - Notepad", [""]);
+ return;
+ case "w": {
+ const w = focused();
+ if (w) closeWin(w.id);
+ return;
+ }
+ case "m": {
+ const w = focused();
+ if (w) minimize(w.id);
+ return;
+ }
+ case "a": {
+ const p = focusedPad();
+ if (p) selectAllIn(p.w);
+ return;
+ }
+ case "c":
+ copySel();
+ return;
+ case "x":
+ cutSel();
+ return;
+ }
+ }
+
+ function onKey(ev: HostEvent) {
+ const k = ev.k ?? "";
+ if (ev.cmd) {
+ onCmd(k);
+ return;
+ }
+ if (k === "Escape") {
+ if (startOpen.value || popup.value) closeMenus();
+ return;
+ }
+ const w = focused();
+ if (!w) return;
+ if (w.kind === "mines" && k === "F2") {
+ minesNew(w);
+ return;
+ }
+ if (w.kind === "notepad") {
+ if (k === "F5") {
+ insertTimeDate(w);
+ return;
+ }
+ const d = padOf(w);
+ const doc = d.doc.value;
+ switch (k) {
+ case "Enter":
+ d.doc.value = insertText(doc, "\n");
+ break;
+ case "Backspace":
+ d.doc.value = backspace(doc);
+ break;
+ case "Delete":
+ d.doc.value = del(doc);
+ break;
+ case "Tab":
+ d.doc.value = insertText(doc, " ");
+ break;
+ case "Left":
+ case "Right":
+ case "Up":
+ case "Down":
+ case "Home":
+ case "End":
+ d.doc.value = applyMove(doc, k as CaretMove, ev.sh ?? false);
+ break;
+ default:
+ return;
+ }
+ scrollCaretIntoView(w);
+ return;
+ }
+ if (w.kind === "shutdown" && k === "Enter") {
+ const choice = shutdownOf(w).choice.value;
+ if (choice === 0) svc?.send({ t: "quit" });
+ else restartSession();
+ return;
+ }
+ if (w.kind === "about" && k === "Enter") closeWin(w.id);
+ }
+
+ function padViewH(w: WinCtl): number {
+ return w.geo.value.h - FRAME - contentTop(chromeOpts(w)) - 2;
+ }
+
+ function scrollCaretIntoView(w: WinCtl) {
+ const d = padOf(w);
+ const y = d.doc.value.caret.row * PAD_LINE_H;
+ const viewH = padViewH(w);
+ if (y - d.scroll.value < 0) d.scroll.value = Math.max(0, y);
+ else if (y - d.scroll.value > viewH - PAD_LINE_H) d.scroll.value = y - viewH + PAD_LINE_H;
+ }
+
+ function typeInto(w: WinCtl, s: string) {
+ const d = padOf(w);
+ d.doc.value = insertText(d.doc.value, s);
+ scrollCaretIntoView(w);
+ }
+
+ // ---- taskbar --------------------------------------------------------------------
+
+ const taskEntries = (): TaskEntry[] =>
+ wins.value
+ .filter((w) => w.kind !== "shutdown")
+ .map((w) => ({ id: w.id, title: w.title, icon: w.icon }));
+ const taskButtonW = () => {
+ const n = Math.max(1, taskEntries().length);
+ return Math.min(160, Math.floor((vp.value.w - 70 - 60 - n * 3) / n));
+ };
+
+ function taskEntryAt(x: number, y: number): number {
+ if (y < vp.value.h - TASK_H + 3) return -1;
+ const entries = taskEntries();
+ const w = taskButtonW();
+ const x0 = 2 + 54 + 3 + 1 + 3;
+ for (let i = 0; i < entries.length; i++) {
+ const bx = x0 + i * (w + 3);
+ if (x >= bx && x < bx + w) {
+ return wins.value.findIndex((win) => win.id === entries[i].id);
+ }
+ }
+ return -1;
+ }
+
+ // ---- frame pump -------------------------------------------------------------------
+
+ function handleEvent(ev: HostEvent) {
+ switch (ev.t) {
+ case "hello": {
+ vp.value = { w: ev.w ?? 800, h: ev.h ?? 600 };
+ epoch = ev.epoch ?? 0;
+ epochAt = virtualNow();
+ break;
+ }
+ case "resize": {
+ const w = ev.w ?? vp.value.w;
+ const h = ev.h ?? vp.value.h;
+ vp.value = { w, h };
+ for (const win of wins.value) {
+ if (win.maximized.value) win.geo.value = maximizedGeo(w, h);
+ else win.geo.value = clampMove(win.geo.value, w, h);
+ }
+ break;
+ }
+ case "mouse": {
+ if (ev.b === 2) {
+ if (ev.d) {
+ mx = ev.x ?? mx;
+ my = ev.y ?? my;
+ onRightDown();
+ }
+ break;
+ }
+ mx = ev.x ?? mx;
+ my = ev.y ?? my;
+ const down = ev.d ?? false;
+ if (down && !prevDown) onPrimaryDown(ev.sh ?? false);
+ else if (!down && prevDown) {
+ onMove();
+ onPrimaryUp();
+ } else onMove();
+ prevDown = down;
+ break;
+ }
+ case "key":
+ onKey(ev);
+ break;
+ case "ch": {
+ const w = focused();
+ if (w?.kind === "notepad" && ev.s) typeInto(w, ev.s);
+ break;
+ }
+ case "paste": {
+ const w = focused();
+ if (w?.kind === "notepad" && ev.text) typeInto(w, ev.text);
+ break;
+ }
+ case "ime": {
+ const w = focused();
+ if (w?.kind === "notepad") {
+ const d = padOf(w);
+ // Composition replaces the selection the moment it starts.
+ if (ev.s && hasSel(d.doc.value)) d.doc.value = deleteSel(d.doc.value);
+ d.preedit.value = ev.s ? { s: ev.s, c: ev.c ?? ev.s.length } : null;
+ }
+ break;
+ }
+ case "scroll": {
+ const hover = hitWindows(mx, my);
+ if (hover?.win.kind === "notepad") {
+ const d = padOf(hover.win);
+ const contentH = d.doc.value.lines.length * PAD_LINE_H + 6;
+ const maxY = Math.max(0, contentH - padViewH(hover.win));
+ d.scroll.value = Math.max(0, Math.min(maxY, d.scroll.value + (ev.dy ?? 0)));
+ }
+ break;
+ }
+ }
+ }
+
+ function boot() {
+ openNotepad("welcome.txt - Notepad", WELCOME);
+ if (!svc) {
+ // Standalone (sim, goldens): a lively static arrangement.
+ openMines();
+ openMyComputer();
+ }
+ }
+
+ boot();
+
+ onFrame(() => {
+ if (svc) for (const ev of svc.poll()) handleEvent(ev);
+
+ // Taskbar clock (minute precision, anchored at the hello epoch).
+ if (epoch > 0) {
+ const t = new Date(epoch + (virtualNow() - epochAt) * 1000);
+ const s = `${pad2(t.getHours())}:${pad2(t.getMinutes())}`;
+ if (s !== clock.value) clock.value = s;
+ }
+
+ // Minesweeper timer.
+ const mw = wins.value.find((w) => w.kind === "mines");
+ if (mw) {
+ const d = minesOf(mw);
+ if (d.board.value.phase === "playing" && minesStart > 0) {
+ const e = Math.min(999, Math.floor(virtualNow() - minesStart));
+ if (e !== d.elapsed.value) d.elapsed.value = e;
+ }
+ }
+
+ // IME candidate window docking: report the focused notepad caret.
+ const fw = focused();
+ if (svc && fw?.kind === "notepad") {
+ const d = padOf(fw);
+ const g = fw.geo.value;
+ const doc = d.doc.value;
+ const line = doc.lines[doc.caret.row] ?? "";
+ const x = g.x + FRAME + 4 + measure(line.slice(0, doc.caret.col));
+ const y = g.y + contentTop(chromeOpts(fw)) + 3 + doc.caret.row * PAD_LINE_H - d.scroll.value;
+ if (x !== lastCaret.x || y !== lastCaret.y) {
+ lastCaret = { x, y, h: PAD_LINE_H };
+ svc.send({ t: "caret", x, y, h: PAD_LINE_H });
+ }
+ }
+ });
+
+ // ---- render -------------------------------------------------------------
+
+ return (
+
+
+ {wins.value.map((w) => (
+
+ ))}
+ {startOpen.value ? (
+
+ ) : null}
+ {startOpen.value && startFly.value ? (
+
+ ) : null}
+ {popup.value ? : null}
+
+
+ );
+}
diff --git a/apps/desk98/app.vue b/apps/desk98/app.vue
deleted file mode 100644
index 267fe5dd..00000000
--- a/apps/desk98/app.vue
+++ /dev/null
@@ -1,1341 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/desk98/chrome.tsx b/apps/desk98/chrome.tsx
new file mode 100644
index 00000000..54e6e034
--- /dev/null
+++ b/apps/desk98/chrome.tsx
@@ -0,0 +1,252 @@
+// apps/desk98/chrome.tsx — presentational Windows 98 chrome, Vue Vapor JSX
+// (vue-jsx-vapor; lists are plain .map() like every vapor JSX app in the
+// repo). Every component here only paints; the compositor (app.tsx) owns hit
+// testing and routes all pointer/keyboard input itself off the svc mouse
+// stream, so nothing in this file registers a handler. Geometry mirrors
+// wm.ts via theme.ts constants.
+//
+// Class strings are FULL literals throughout — the style table compiles at
+// build time and template-interpolated fragments are a compile error, so the
+// bevel recipes repeat verbatim instead of riding shared constants.
+
+import { Image, Text, View } from "@pocketjs/framework/components";
+import { FONT, FONT_B, FONT_XL } from "./theme.ts";
+import type { CaptionButton } from "./wm.ts";
+import type { DeskIcon, Popup, TaskEntry, WinCtl } from "./state.ts";
+
+/** W95FA text. Slots 19/20/21 ride the style prop — the class table never
+ * sees them (baked per-app via pak.json, docs in gen-assets.ts). Text rides
+ * the `t` prop; `cls` replaces the class attr so nothing falls through to
+ * a user component's attrs. */
+export function T98(props: { t: string; cls?: string; bold?: boolean; xl?: boolean }) {
+ return (
+
+ {props.t}
+
+ );
+}
+
+const BTN_ICON: Record = {
+ min: "icons/cap-min.svg",
+ max: "icons/cap-max.svg",
+ close: "icons/cap-close.svg",
+};
+
+/** Caption controls, flush right and flush against each other (wm.ts
+ * captionButtonXs mirrors this row). Press feedback inverts the bevel and
+ * nudges the glyph one px — app.tsx drives win.pressedBtn off the raw
+ * pointer stream. */
+export function CaptionButtons(props: { win: WinCtl }) {
+ const w = props.win;
+ return (
+
+ {w.buttons.map((btn) => (
+
+
+
+ ))}
+
+ );
+}
+
+/** The taskbar: the Start button (PocketJS favicon mark, gen-icons
+ * start-logo), one button per window, the sunken clock tray. */
+export function Taskbar(props: {
+ entries: TaskEntry[];
+ activeId: number;
+ startOpen: boolean;
+ clock: string;
+ buttonW: number;
+}) {
+ return (
+
+
+
+
+
+
+
+ {props.entries.map((entry) => (
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+/** Generic popup menu panel (context menus, dropdowns, start flyouts). */
+export function PopupPanel(props: { popup: Popup; hover: number }) {
+ return (
+
+ {props.popup.items.map((item, i) =>
+ item.sep ? (
+
+
+
+
+ ) : (
+
+ {item.icon ? (
+
+ ) : (
+
+ )}
+
+
+
+ {item.shortcut ? (
+
+ ) : null}
+ {item.sub ? : null}
+
+ ),
+ )}
+
+ );
+}
+
+/** The Start menu: a plain navy gradient banner strip + 26px rows (flyouts
+ * render as PopupPanels). */
+export function StartMenu(props: {
+ x: number;
+ y: number;
+ h: number;
+ items: Popup["items"];
+ hover: number;
+}) {
+ return (
+
+
+
+ {props.items.map((item, i) =>
+ item.sep ? (
+
+
+
+
+ ) : (
+
+
+
+
+
+ {item.sub ? : null}
+
+ ),
+ )}
+
+
+ );
+}
+
+/** Desktop icons: a left column of 32px art + label, teal behind. */
+export function DesktopIcons(props: { icons: DeskIcon[]; selected: number }) {
+ return (
+
+ {props.icons.map((icon, i) => (
+
+
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/desk98/main.ts b/apps/desk98/main.ts
deleted file mode 100644
index a127ab9a..00000000
--- a/apps/desk98/main.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-// 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/main.tsx b/apps/desk98/main.tsx
new file mode 100644
index 00000000..94613228
--- /dev/null
+++ b/apps/desk98/main.tsx
@@ -0,0 +1,5 @@
+// apps/desk98/main.tsx — PocketJS 98 entry point (Vue Vapor, JSX).
+import { mount } from "@pocketjs/framework";
+import App from "./app.tsx";
+
+mount(() => );
diff --git a/apps/desk98/pocket.json b/apps/desk98/pocket.json
index aba01973..9eb3b192 100644
--- a/apps/desk98/pocket.json
+++ b/apps/desk98/pocket.json
@@ -21,7 +21,7 @@
}
},
"app": {
- "entry": "apps/desk98/main.ts",
+ "entry": "apps/desk98/main.tsx",
"output": "desk98-main",
"framework": "vue-vapor",
"companions": [
diff --git a/apps/desk98/programs.ts b/apps/desk98/programs.ts
deleted file mode 100644
index 3a8fca25..00000000
--- a/apps/desk98/programs.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-// 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/programs.tsx b/apps/desk98/programs.tsx
new file mode 100644
index 00000000..fe347fb1
--- /dev/null
+++ b/apps/desk98/programs.tsx
@@ -0,0 +1,446 @@
+// apps/desk98/programs.tsx — the window contents: Notepad (with selection),
+// Minesweeper, the Explorer-style folder view, About and Shut Down dialogs.
+// Vue Vapor JSX, presentational like chrome.tsx; each program also exports
+// the content-local hit helpers app.tsx routes clicks through, so render
+// geometry and hit geometry sit in one file. 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 { computed } from "vue";
+import { Image, View } from "@pocketjs/framework/components";
+import { getOps } from "@pocketjs/framework/host";
+import { T98 } from "./chrome.tsx";
+import { FONT } from "./theme.ts";
+import { rowSelSpan } from "./notepad.ts";
+import { MINES_W, type Cell } from "./mines.ts";
+import type { AboutData, FolderData, MinesData, PadData, ShutdownData } from "./state.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
+
+/** Row text split at the selection edges; one segment when unselected. */
+function segs(d: PadData, line: string, row: number): { t: string; sel: boolean }[] {
+ const span = rowSelSpan(d.doc.value, row);
+ if (!span) return [{ t: line, sel: false }];
+ return [
+ { t: line.slice(0, span.from), sel: false },
+ { t: line.slice(span.from, span.to), sel: true },
+ { t: line.slice(span.to), sel: false },
+ ];
+}
+
+export function NotepadView(props: { data: PadData; active: boolean }) {
+ const d = props.data;
+ const caretRow = () => d.doc.value.caret.row;
+ const caretX = () => {
+ const doc = d.doc.value;
+ const line = doc.lines[doc.caret.row] ?? "";
+ const pre = d.preedit.value;
+ const head = line.slice(0, doc.caret.col);
+ return measure(pre ? head + pre.s.slice(0, pre.c) : head);
+ };
+ return (
+
+
+
+ {d.doc.value.lines.map((line, row) => (
+
+ {row === caretRow() && d.preedit.value ? (
+ [
+ ,
+
+
+
+ ,
+ ,
+ ]
+ ) : (
+ segs(d, line, row).map((seg) =>
+ seg.sel ? (
+
+
+
+ ) : (
+
+ ),
+ )
+ )}
+
+ ))}
+
+ {props.active ? (
+
+ ) : null}
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// 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;
+}
+
+const NUM_COLORS = [
+ "", // 0 unused
+ "text-[#0000ff]",
+ "text-[#008000]",
+ "text-[#ff0000]",
+ "text-[#000080]",
+ "text-[#800000]",
+ "text-[#008080]",
+ "text-[#000000]",
+ "text-[#808080]",
+];
+
+const SEGS: Record = {
+ "0": 0b0111111,
+ "1": 0b0000110,
+ "2": 0b1011011,
+ "3": 0b1001111,
+ "4": 0b1100110,
+ "5": 0b1101101,
+ "6": 0b1111101,
+ "7": 0b0000111,
+ "8": 0b1111111,
+ "9": 0b1101111,
+ "-": 0b1000000,
+ " ": 0,
+};
+
+/** One 7-seg digit, 13×23, red on black.
+ * a
+ * f b segments: bit 0..6 = a b c d e f g
+ * g
+ * e c
+ * d
+ */
+function Digit(props: { ch: string }) {
+ const on = (bit: number) => (((SEGS[props.ch] ?? 0) >> bit) & 1) === 1;
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** Three-digit 7-seg counter (mine count / timer), clamped to -99..999. */
+function Counter(props: { value: number }) {
+ const text = computed(() => {
+ const v = Math.max(-99, Math.min(999, Math.round(props.value)));
+ return v < 0 ? "-" + String(-v).padStart(2, "0") : String(v).padStart(3, "0");
+ });
+ return (
+
+
+
+
+
+ );
+}
+
+/** One field cell: raised while hidden, flat when revealed (red on the bust
+ * mine), flag/mine art, colored adjacency digit. */
+function MinesCell(props: { data: MinesData; i: number }) {
+ const c = (): Cell => props.data.board.value.cells[props.i];
+ const heldDown = () => props.data.held.value === props.i && c().state === "hidden";
+ // The hidden/revealed swap must sit in a JSX child position — a bare
+ // ternary returned from the component body evaluates once at setup.
+ return (
+
+ {c().state !== "revealed" ? (
+
+ {c().state === "flag" ? : null}
+
+ ) : (
+
+ {c().mine ? (
+
+ ) : c().adj > 0 ? (
+
+ ) : null}
+
+ )}
+
+ );
+}
+
+const ROWS9 = Array.from({ length: MINES_W }, (_, i) => i);
+
+/** Minesweeper content: sunken header (mine counter, smiley, timer) over the
+ * 9×9 field. The grid rides static index arrays — the board ref retriggers
+ * cell reads, rows never move. */
+export function MinesView(props: { data: MinesData }) {
+ const d = props.data;
+ const smiley = () => {
+ if (d.smileyHeld.value) return "icons/smile.svg";
+ const m = d.board.value;
+ if (m.phase === "lost") return "icons/smile-dead.svg";
+ if (m.phase === "won") return "icons/smile-cool.svg";
+ if (d.held.value >= 0) return "icons/smile-ooh.svg";
+ return "icons/smile.svg";
+ };
+ return (
+
+
+
+
+
+
+
+
+
+
+ {ROWS9.map((ry) => (
+
+ {ROWS9.map((rx) => (
+
+ ))}
+
+ ))}
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// 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;
+}
+
+export function FolderView(props: { data: FolderData; resizable: boolean }) {
+ const d = props.data;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {d.rows.map((row, i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+ {d.rows.length === 0 ? (
+
+
+
+ ) : null}
+
+
+
+
+
+ {props.resizable ? : null}
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// About + Shut Down dialogs
+// ---------------------------------------------------------------------------
+
+export const ABOUT_GEO = { w: 340, h: 216 } as const;
+export const SHUTDOWN_GEO = { w: 300, h: 176 } as const;
+
+/** Dialog push button; armed = pressed bevel + 1px content nudge. */
+function Button98(props: { label: string; armed: boolean }) {
+ return (
+
+
+
+
+
+ );
+}
+
+/** 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 function AboutView(props: { data: AboutData }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+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;
+}
+
+export function ShutdownView(props: { data: ShutdownData }) {
+ const radio = (i: number, label: string) => (
+
+
+
+ {props.data.choice.value === i ? (
+
+ ) : null}
+
+
+
+
+ );
+ return (
+
+
+
+
+
+
+
+
+
+ {radio(0, "Shut down")}
+ {radio(1, "Restart")}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/desk98/screenshot.png b/apps/desk98/screenshot.png
index 93522dd2..725f5a3c 100644
Binary files a/apps/desk98/screenshot.png and b/apps/desk98/screenshot.png differ
diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md
index 37bd234a..2ca00a64 100644
--- a/docs/BACKENDS.md
+++ b/docs/BACKENDS.md
@@ -123,7 +123,7 @@ 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
+`apps/desk98` — a Windows 98 desktop compositor written in Vue Vapor JSX
— 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