diff --git a/shells/desktop/README.md b/shells/desktop/README.md
index fc34664..0ce16d2 100644
--- a/shells/desktop/README.md
+++ b/shells/desktop/README.md
@@ -9,20 +9,20 @@ resolution, AppInstance isolation, scheduling and native composition.
## Themes
-Pocket Shell Desktop ships three System UI themes: Classic 98, Windows XP and Aqua.
+Pocket Shell Desktop ships three System UI themes: Classic 98, XP and Aqua.
Each is a period desktop rebuilt from PocketJS-native drawing — no bitmaps of
the originals, no theme-specific code paths outside the theme's own
definition. **All three run on the same System manifest, AppInstances and
native compositor.**
-| Classic 98 | Windows XP | Aqua |
+| Classic 98 | XP | Aqua |
|---|---|---|
|  |  |  |
- **Classic 98** — hard two-ring bevels, 18px captions, a 28px taskbar with
the Start rail menu, the W95FA bitmap face, native 32px pixel-art desktop
icons and Explorer's coolbar and "Folders" pane in the file manager.
-- **Windows XP** — Luna chrome: three-stop gel gradients under 1px
+- **XP** — Luna chrome: three-stop gel gradients under 1px
highlight and seat strips, top-rounded window frames, the two-column Start
panel (user header, pinned programs, places, Turn Off Computer), a baked
green Start pill, softly shaded Luna-style vector icons, and the Explorer
@@ -47,6 +47,15 @@ themes keeps every client rectangle, caret and compositor surface exact. The
Pocket app icon is the PocketJS favicon mark, cut from Aqua silver-and-blue
or Luna silver per theme.
+The same contract covers what a program draws inside its window: list boxes
+and their rows, a scroll mark, group frames, value wells, radio marks,
+progress bars and push buttons. The Shut Down dialog draws its radio marks
+from it. A Pocket app that imports `src/system-ui/theme.ts` draws its own
+window from the same parts, and bakes the five theme font slots and the icon
+set into its own pack the way the System UI does (`src/system-ui/pak.json`,
+`src/system-ui/icons/`). The host tells the app which theme is active; the
+`theme` lines below carry it.
+
Choose a theme from **Start → Settings** (the logo menu on Aqua), or press
**Cmd+Shift+T** to cycle while testing.
@@ -76,11 +85,35 @@ Aqua screen bar with no window focused, the About dialog and its menu entries,
and the welcome note show it, so a product that installs this System UI under
its own `pocket.system.json` gets its own name in each of them.
+The About dialog's text comes from `pocket.about.json` beside the System
+manifest, because the manifest schema has no field for product copy:
+
+```json
+{
+ "body": ["One line of text.", "Another line."],
+ "link": "example.org/product"
+}
+```
+
+`body` holds one text line per entry; the dialog does not wrap them and its
+fixed size holds four above the link. `link` is shown as text under the body.
+Both fields are optional. This repository's file describes the shell; a
+product that bundles the System UI replaces the file with its own.
+
The host and the System UI exchange JSON lines over the `system-ui` companion
service; `src/system-ui/svc.ts` lists every line. Besides input, the host can
-send `{t:"open", package}` to open an installed app by package id. When that
-app's window is already open, the line restores it if it is minimized, raises
-it and focuses it. A package id outside the installed catalog is ignored.
+send two lines:
+
+- `{t:"open", package}` opens an installed app by package id. When that app's
+ window is already open, the line restores it if it is minimized, raises it
+ and focuses it. A package id outside the installed catalog is ignored.
+- `{t:"theme", id}` selects the theme with that id: `classic`, `xp` or `aqua`.
+ An id that names no theme is ignored.
+
+The System UI sends `{t:"theme", id}` to the host once after boot and after
+every frame that ends in another theme, whether the Settings menu, the cycle
+chord or the host's line changed it. A host that keeps the user's choice or
+passes the theme on to its apps reads this line.
The experimental framework implementation is pinned directly in
`vendor/pocketjs` from [PocketJS PR #399](https://github.com/pocket-nexus/pocketjs/pull/399),
diff --git a/shells/desktop/pocket.about.json b/shells/desktop/pocket.about.json
new file mode 100644
index 0000000..5ebb455
--- /dev/null
+++ b/shells/desktop/pocket.about.json
@@ -0,0 +1,9 @@
+{
+ "body": [
+ "A desktop compositor demo on the portable Rust backend.",
+ "SolidJS JSX over the same DrawList the",
+ "consoles boot; windows, menus and shortcuts",
+ "live in the guest."
+ ],
+ "link": "github.com/pocket-nexus/pocket-shell"
+}
diff --git a/shells/desktop/src/system-ui/app.tsx b/shells/desktop/src/system-ui/app.tsx
index 955331e..ae1150e 100644
--- a/shells/desktop/src/system-ui/app.tsx
+++ b/shells/desktop/src/system-ui/app.tsx
@@ -17,7 +17,9 @@
// ⌘` cycles, ⌘N opens Notepad, ⌘Esc toggles Start, ⌘A/C/X edit the
// focused Notepad. Pocket app input and scheduling are owned by the native
// compositor using the focused surface fact emitted by this shell. The host
-// can also open an installed app by package id (the svc "open" line).
+// can also open an installed app by package id (the svc "open" line) and
+// select the theme (the svc "theme" line); the shell reports the active
+// theme to the host the same way.
//
// Without the System UI companion (sim, goldens, consoles) the app boots a
// static arrangement and just renders it — the unmodified-app base case.
@@ -125,6 +127,7 @@ import {
} from "./pocket-apps.ts";
import {
CLASSIC_THEME,
+ isThemeId,
nextThemeId,
THEMES,
themeById,
@@ -325,6 +328,8 @@ export default function App() {
let lastClick = { key: "", t: -1, x: 0, y: 0 };
let lastCaret = { x: -1, y: -1, h: 0 };
let minesStart = 0;
+ /** Theme the host was last told about; null until the first report. */
+ let reportedTheme: ThemeId | null = null;
const byId = (id: number) => wins().find((w) => w.id === id);
const focused = () => byId(focusId());
@@ -717,7 +722,7 @@ export default function App() {
case "drivec":
return [
{ icon: "folder", name: "Program Files", size: "", type: "File Folder" },
- { icon: "folder", name: "Windows", size: "", type: "File Folder" },
+ { icon: "folder", name: "System", size: "", type: "File Folder" },
{
icon: "folder",
name: "My Documents",
@@ -727,7 +732,7 @@ export default function App() {
navigate(w, "documents");
},
},
- { icon: "file", name: "AUTOEXEC.BAT", size: "1 KB", type: "MS-DOS Batch File" },
+ { icon: "file", name: "AUTOEXEC.BAT", size: "1 KB", type: "Batch File" },
{ icon: "file", name: "CONFIG.SYS", size: "1 KB", type: "System file" },
{
icon: "notepad",
@@ -855,7 +860,10 @@ export default function App() {
};
const w = createWin({
kind: "shutdown",
- title: "Shut Down Windows",
+ // No name after the verb: the dialog is 300px wide, and Aqua's
+ // centered caption has room for about twenty characters between its
+ // control cluster and the balancing spacer.
+ title: "Shut Down",
icon: "shutdown",
geo: centered(SHUTDOWN_GEO.w, SHUTDOWN_GEO.h),
buttons: ["close"],
@@ -2162,6 +2170,12 @@ export default function App() {
}
break;
}
+ case "theme": {
+ // The host selects a theme, as the Settings menu does. An id that
+ // names no theme is ignored.
+ if (isThemeId(ev.id)) setTheme(ev.id);
+ break;
+ }
}
}
@@ -2222,6 +2236,13 @@ export default function App() {
}
}
+ // Theme report: once after boot, then after every frame that ends in
+ // another theme than the one last reported, whatever changed it (the
+ // Settings menu, the cycle chord or the host's own theme line).
+ if (svc && reportedTheme !== themeId()) {
+ reportedTheme = themeId();
+ svc.send({ t: "theme", id: reportedTheme });
+ }
});
// ---- render -------------------------------------------------------------
diff --git a/shells/desktop/src/system-ui/pocket-apps.ts b/shells/desktop/src/system-ui/pocket-apps.ts
index 18545fd..e4b9be5 100644
--- a/shells/desktop/src/system-ui/pocket-apps.ts
+++ b/shells/desktop/src/system-ui/pocket-apps.ts
@@ -1,9 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-only
// The desktop's name and its visible application catalog come from the System
-// manifest. The native host receives separately resolved complete package
-// plans; this module carries only System-owned presentation data.
+// manifest, and the About dialog's text from pocket.about.json beside it. The
+// native host receives separately resolved complete package plans; this
+// module carries only System-owned presentation data.
import type { PocketSystemV1 } from "@pocketjs/framework/manifest";
+import aboutJson from "../../pocket.about.json";
import systemJson from "../../pocket.system.json";
const system = systemJson as unknown as PocketSystemV1;
@@ -15,6 +17,38 @@ const installedPackages = new Set(system.installation.installedPackages);
* under its own manifest gets its own name in all of them. */
export const DESKTOP_NAME: string = system.title;
+/** What the About dialog shows under the desktop's name. */
+export interface DesktopAbout {
+ /** One text line each. The dialog does not wrap them, and its fixed size
+ * holds four above the link. */
+ body: readonly string[];
+ /** Shown as text under the body; "" leaves the row out. */
+ link: string;
+}
+
+/** Read a pocket.about.json value. The System manifest schema has no field
+ * for product copy, so the text lives in this file beside the manifest. Both
+ * fields are optional: a missing `body` gives no lines and a missing `link`
+ * no link row. Entries of `body` that are not strings are dropped. */
+export function readAbout(value: unknown): DesktopAbout {
+ const record =
+ value !== null && typeof value === "object"
+ ? (value as { body?: unknown; link?: unknown })
+ : {};
+ return {
+ body: Array.isArray(record.body)
+ ? record.body.filter((line): line is string => typeof line === "string")
+ : [],
+ link: typeof record.link === "string" ? record.link : "",
+ };
+}
+
+/** The About text of the product that installs this System UI. This
+ * repository's file describes the shell; a product that bundles the System
+ * UI under its own pocket.system.json puts its own pocket.about.json beside
+ * that manifest. */
+export const DESKTOP_ABOUT: DesktopAbout = readAbout(aboutJson);
+
export interface PocketAppSpec {
/** Stable package id used by the native compositor surface registry. */
package: string;
diff --git a/shells/desktop/src/system-ui/programs.tsx b/shells/desktop/src/system-ui/programs.tsx
index 3b3694e..61799b6 100644
--- a/shells/desktop/src/system-ui/programs.tsx
+++ b/shells/desktop/src/system-ui/programs.tsx
@@ -22,7 +22,7 @@ import {
type VSeg,
} from "./notepad.ts";
import { MINES_W, type Cell } from "./mines.ts";
-import { DESKTOP_NAME } from "./pocket-apps.ts";
+import { DESKTOP_ABOUT, DESKTOP_NAME } from "./pocket-apps.ts";
import {
PLACES,
type AboutData,
@@ -680,15 +680,16 @@ export function AboutView(props: {
-
-
-
-
-
+ {DESKTOP_ABOUT.body.map((line) => (
+
+ ))}
+ {DESKTOP_ABOUT.link !== "" ? (
+
+ ) : null}
(
-
-
+
+
{props.data.choice() === i ? (
-
+
) : null}
diff --git a/shells/desktop/src/system-ui/svc.ts b/shells/desktop/src/system-ui/svc.ts
index 1195439..c9bc78b 100644
--- a/shells/desktop/src/system-ui/svc.ts
+++ b/shells/desktop/src/system-ui/svc.ts
@@ -20,8 +20,15 @@
// when its window is already open, restore it
// from the task strip, raise it and focus it.
// An id outside the installed catalog is ignored
+// {t:"theme", id} select the theme with that id ("classic",
+// "xp" or "aqua"). An id that names no theme
+// is ignored
//
// guest → host intents:
+// {t:"theme", id} the active theme: sent once after boot and
+// after every frame that ends in another theme,
+// whether the Settings menu, the cycle chord or
+// the host's theme line changed it
// {t:"quit"} Shut Down
// {t:"copy", text} put text on the system clipboard
// {t:"paste-req"} ask for the clipboard (host answers {t:"paste"})
@@ -30,6 +37,7 @@
// grabbing|ew|ns|nwse|nesw
import { getOps } from "@pocketjs/framework";
+import type { ThemeId } from "./theme.ts";
export interface HostEvent {
t:
@@ -41,7 +49,8 @@ export interface HostEvent {
| "scroll"
| "paste"
| "ime"
- | "open";
+ | "open"
+ | "theme";
w?: number;
h?: number;
epoch?: number;
@@ -64,6 +73,8 @@ export interface HostEvent {
c?: number | null;
/** Package id of the installed app an "open" line names. */
package?: string;
+ /** Theme id a "theme" line selects. */
+ id?: string;
}
export type CursorKind =
@@ -86,7 +97,8 @@ export interface Svc {
| { t: "copy"; text: string }
| { t: "paste-req" }
| { t: "caret"; x: number; y: number; h: number }
- | { t: "cursor"; k: CursorKind },
+ | { t: "cursor"; k: CursorKind }
+ | { t: "theme"; id: ThemeId },
): void;
}
diff --git a/shells/desktop/src/system-ui/theme.ts b/shells/desktop/src/system-ui/theme.ts
index a8fd0cd..62982fa 100644
--- a/shells/desktop/src/system-ui/theme.ts
+++ b/shells/desktop/src/system-ui/theme.ts
@@ -13,6 +13,14 @@
// controls on the left, hoists the menu bar to the top of the screen and
// turns the task strip into a centered Dock. None of that is special-cased
// by theme id anywhere outside this file.
+//
+// The contract also covers what a program draws inside its window: list
+// boxes, group frames, value wells, radio marks, progress bars and push
+// buttons. The Shut Down dialog paints its radio marks from these content
+// parts, and a Pocket app that imports this file draws its whole window from
+// them in the active theme. The host tells such an app which theme is active
+// (svc.ts, the "theme" lines), so the app does not special-case a theme id
+// either.
/** Baked font slots (src/system-ui/gen-assets.ts -> pak.json). 19-21 are the
* W95FA bitmap face, 22-23 the antialiased Inter face shared by every
@@ -175,6 +183,14 @@ export interface ChromeMetrics {
iconsSide: "left" | "right";
resizeBand: number;
resizeCorner: number;
+ /** Content parts. Inset of a list box's rows from its outer edge (mirrors
+ * listWell's ring and p-[…]), width of `scrollTrack` (what a list box that
+ * overflows takes from its rows on the right), and horizontal padding of
+ * a group frame (mirrors groupBox's px-[…]). A program that truncates or
+ * wraps text to fit reads these. */
+ listPad: number;
+ scrollW: number;
+ groupPadX: number;
}
export interface DesktopTheme {
@@ -325,6 +341,47 @@ export interface DesktopTheme {
statusWell: string;
dialogButton: (pressed: boolean, primary: boolean) => string;
dialogButtonText: (primary: boolean) => string;
+ // Content parts: what a program draws inside its window.
+ /** Box around a list of rows. */
+ listWell: string;
+ /** One list row: its fill. The program lays out the row's content and
+ * sets its height through the style prop; `zebra` marks every second row
+ * for a theme that stripes its lists. */
+ listRow: (selected: boolean, zebra: boolean) => string;
+ /** A row's first line, and the detail line under it. */
+ listText: (selected: boolean) => string;
+ listDetailText: (selected: boolean) => string;
+ /** Position mark of a list box that overflows: a track `metrics.scrollW`
+ * wide on the box's right edge and the thumb inside it. The thumb's
+ * height and offset ride the style prop. */
+ scrollTrack: string;
+ scrollThumb: string;
+ /** Titled frame around related controls. Its top margin reserves the room
+ * for the label, an absolute plate the theme sets on the frame's top edge
+ * or above it. */
+ groupBox: string;
+ groupLabel: string;
+ groupLabelText: string;
+ /** Box around a value shown for reading or copying. */
+ fieldWell: string;
+ /** Radio mark: the ring, the face inside it, and the dot of the chosen
+ * option inside the face. */
+ radioRing: (checked: boolean) => string;
+ radioFace: (checked: boolean) => string;
+ radioDot: string;
+ /** Progress bar: the track and its filled part, whose width rides the
+ * style prop. */
+ progressTrack: string;
+ progressFill: string;
+ /** The two controls a Pocket app presses: a row holding a radio mark and
+ * its label, and a push button. An app's guest runs the framework focus
+ * manager, and the core applies the `focus:` and `active:` variants of a
+ * class to the focused and the pressed node. These literals carry both
+ * variants. `dialogButton` takes the pressed state as an argument instead
+ * because the System UI hit-tests its own dialogs. The button's label
+ * takes `dialogButtonText`. */
+ optionRow: string;
+ contentButton: (primary: boolean) => string;
}
// ---------------------------------------------------------------------------
@@ -555,6 +612,9 @@ export const CLASSIC_THEME: DesktopTheme = {
iconsSide: "left",
resizeBand: 4,
resizeCorner: 14,
+ listPad: 2,
+ scrollW: 16,
+ groupPadX: 8,
},
desktop: "absolute inset-0 bg-[#008080] overflow-hidden",
desktopLayers: NO_LAYERS,
@@ -740,6 +800,40 @@ export const CLASSIC_THEME: DesktopTheme = {
? "w-[75] h-[23] flex-col justify-center items-center bg-[#c0c0c0] bevel-[#000000,#ffffff,#808080,#dfdfdf]"
: "w-[75] h-[23] flex-col justify-center items-center bg-[#c0c0c0] bevel-[#ffffff,#000000,#dfdfdf,#808080]",
dialogButtonText: () => "text-[#000000]",
+ // A list box is a sunken white well; the two bevel rings take its 2px pad.
+ listWell:
+ "flex-col bg-[#ffffff] bevel-[#808080,#ffffff,#000000,#dfdfdf] p-[2] overflow-hidden",
+ listRow: (selected) =>
+ selected ? "flex-row bg-[#000080]" : "flex-row",
+ listText: (selected) => (selected ? "text-[#ffffff]" : "text-[#000000]"),
+ listDetailText: (selected) =>
+ selected ? "text-[#ffffff]" : "text-[#808080]",
+ // The track's white and gray dither reads as this flat light gray; the
+ // thumb is a raised button face.
+ scrollTrack: "absolute right-0 top-0 bottom-0 w-[16] bg-[#dfdfdf]",
+ scrollThumb:
+ "absolute left-0 right-0 top-0 bg-[#c0c0c0] bevel-[#dfdfdf,#000000,#ffffff,#808080]",
+ // The etched frame is the raised bevel's rings swapped pairwise: a dark
+ // line with a light one inside it. The label plate cuts the top edge.
+ groupBox:
+ "flex-col mt-[8] pt-[12] pb-[8] px-[8] bevel-[#808080,#ffffff,#ffffff,#808080]",
+ groupLabel: "absolute left-[8] top-[-7] px-[3] bg-[#c0c0c0]",
+ groupLabelText: "text-[#000000]",
+ fieldWell:
+ "flex-col bg-[#ffffff] bevel-[#808080,#ffffff,#000000,#dfdfdf] px-[5] py-[4]",
+ radioRing: () =>
+ "w-[12] h-[12] rounded-full bg-[#808080] flex-col justify-center items-center",
+ radioFace: () =>
+ "w-[10] h-[10] rounded-full bg-[#ffffff] flex-col justify-center items-center",
+ radioDot: "w-[4] h-[4] rounded-full bg-[#000000]",
+ progressTrack: "h-[16] flex-row p-[2] bevel-[#808080,#ffffff]",
+ progressFill: "h-[12] bg-[#000080]",
+ // Focus draws the black rectangle of the focused control; a pressed button
+ // inverts its bevel like `dialogButton`.
+ optionRow:
+ "h-[20] flex-row items-center gap-[6] px-[3] border-[#c0c0c000] focus:border-[#000000]",
+ contentButton: () =>
+ "min-w-[75] h-[23] px-[10] flex-row justify-center items-center bg-[#c0c0c0] bevel-[#ffffff,#000000,#dfdfdf,#808080] focus:bevel-[#000000,#000000,#ffffff,#808080] active:bevel-[#000000,#ffffff,#808080,#dfdfdf]",
};
// ---------------------------------------------------------------------------
@@ -760,7 +854,7 @@ export const CLASSIC_THEME: DesktopTheme = {
// the button meets the screen edge square and curves on the right.
export const XP_THEME: DesktopTheme = {
id: "xp",
- label: "Windows XP",
+ label: "XP",
fontSlot: (role) => (role === "ui" ? FONT_SMOOTH : FONT_SMOOTH_B),
icon: xpIcon,
startStyle: "panel",
@@ -819,6 +913,9 @@ export const XP_THEME: DesktopTheme = {
iconsSide: "left",
resizeBand: 4,
resizeCorner: 16,
+ listPad: 1,
+ scrollW: 17,
+ groupPadX: 8,
},
// Sky gradient plus one grass band that fades in over it: three stops in a
// single node can only cross blue to green through mud.
@@ -1091,6 +1188,40 @@ export const XP_THEME: DesktopTheme = {
? "w-[75] h-[23] flex-col justify-center items-center rounded-[3] border-[#003c74] bg-gradient-to-b from-[#c8c4b8] via-[#dedad0] to-[#f0eee8]"
: "w-[75] h-[23] flex-col justify-center items-center rounded-[3] border-[#003c74] bg-gradient-to-b from-[#ffffff] via-[#f5f3ed] to-[#dcd6c8]",
dialogButtonText: () => "text-[#000000]",
+ // Flat wells under the 1px blue-gray line Luna draws around every field.
+ listWell:
+ "flex-col bg-[#ffffff] border-[#7f9db9] p-[1] overflow-hidden",
+ listRow: (selected) =>
+ selected ? "flex-row bg-[#316ac5]" : "flex-row",
+ listText: (selected) => (selected ? "text-[#ffffff]" : "text-[#000000]"),
+ listDetailText: (selected) =>
+ selected ? "text-[#ffffff]" : "text-[#6f6e64]",
+ scrollTrack:
+ "absolute right-0 top-0 bottom-0 w-[17] bg-gradient-to-r from-[#f3f1ec] to-[#fefefb]",
+ scrollThumb:
+ "absolute left-[1] right-[1] top-0 rounded-[2] border-[#98b1e4] bg-[#c1d2f9]",
+ // A rounded border paints its whole box in the border color first, so the
+ // frame names the window body's beige as its fill.
+ groupBox:
+ "flex-col mt-[8] pt-[12] pb-[8] px-[8] rounded-[4] border-[#d0d0bf] bg-[#ece9d8]",
+ groupLabel: "absolute left-[8] top-[-8] px-[3] bg-[#ece9d8]",
+ groupLabelText: "text-[#0046d5]",
+ fieldWell: "flex-col bg-[#ffffff] border-[#7f9db9] px-[5] py-[4]",
+ radioRing: () =>
+ "w-[13] h-[13] rounded-full bg-[#1c5180] flex-col justify-center items-center",
+ radioFace: () =>
+ "w-[11] h-[11] rounded-full bg-[#f6f6f2] flex-col justify-center items-center",
+ radioDot: "w-[5] h-[5] rounded-full bg-[#21a121]",
+ progressTrack:
+ "h-[16] flex-row p-[2] rounded-[3] border-[#686868] bg-[#ffffff]",
+ progressFill:
+ "h-[10] bg-gradient-to-b from-[#c4f5bd] via-[#2ed12e] to-[#89e684]",
+ // Focus tints the button's top and bottom bands blue, the glow Luna draws
+ // inside its default button; a pressed button sinks like `dialogButton`.
+ optionRow:
+ "h-[20] flex-row items-center gap-[6] px-[3] border-[#ece9d800] focus:border-[#7f9db9]",
+ contentButton: () =>
+ "min-w-[75] h-[23] px-[12] flex-row justify-center items-center rounded-[3] border-[#003c74] bg-gradient-to-b from-[#ffffff] via-[#f5f3ed] to-[#dcd6c8] focus:bg-gradient-to-b focus:from-[#cfe0fd] focus:via-[#f5f3ed] focus:to-[#9db9f0] active:bg-gradient-to-b active:from-[#c8c4b8] active:via-[#dedad0] active:to-[#f0eee8]",
};
// ---------------------------------------------------------------------------
@@ -1176,6 +1307,9 @@ export const AQUA_THEME: DesktopTheme = {
iconsSide: "right",
resizeBand: 4,
resizeCorner: 16,
+ listPad: 1,
+ scrollW: 15,
+ groupPadX: 10,
},
// "Aqua Blue": a mid-blue field with pale swirl bands. Two translucent
// pills and one soft band stand in for the wallpaper's streaks.
@@ -1455,6 +1589,50 @@ export const AQUA_THEME: DesktopTheme = {
},
dialogButtonText: (primary) =>
primary ? "text-[#ffffff]" : "text-[#000000]",
+ // A white list under a hairline, striped like the Finder's, with the blue
+ // gel on the chosen row.
+ listWell: "flex-col bg-[#ffffff] border-[#a3a3a3] p-[1] overflow-hidden",
+ listRow: (selected, zebra) => {
+ if (selected)
+ return "flex-row bg-gradient-to-b from-[#6c9ef0] to-[#3875d7]";
+ if (zebra) return "flex-row bg-[#edf3fe]";
+ return "flex-row";
+ },
+ listText: (selected) => (selected ? "text-[#ffffff]" : "text-[#000000]"),
+ listDetailText: (selected) =>
+ selected ? "text-[#ffffff]" : "text-[#7a7a7a]",
+ // The thumb is a flat blue pill: a gradient this narrow would band.
+ scrollTrack:
+ "absolute right-0 top-0 bottom-0 w-[15] bg-gradient-to-r from-[#d6d6d6] via-[#f2f2f2] to-[#ffffff]",
+ scrollThumb:
+ "absolute left-[2] right-[2] top-0 rounded-[5] border-[#3a70c8] bg-[#5a9aee]",
+ // A recessed plate a shade under the window gray; the label sits above it.
+ groupBox:
+ "flex-col mt-[20] pt-[10] pb-[10] px-[10] rounded-[6] border-[#c2c2c2] bg-[#dedede]",
+ groupLabel: "absolute left-[2] top-[-19]",
+ groupLabelText: "text-[#1e1e1e]",
+ fieldWell: "flex-col bg-[#ffffff] border-[#a3a3a3] px-[5] py-[4]",
+ // The chosen option fills with the blue gel around a dark dot.
+ radioRing: (checked) =>
+ checked
+ ? "w-[14] h-[14] rounded-full bg-[#2a5fbe] flex-col justify-center items-center"
+ : "w-[14] h-[14] rounded-full bg-[#7a7a7a] flex-col justify-center items-center",
+ radioFace: (checked) =>
+ checked
+ ? "w-[12] h-[12] rounded-full bg-[#6fa8f5] flex-col justify-center items-center"
+ : "w-[12] h-[12] rounded-full bg-[#f7f7f7] flex-col justify-center items-center",
+ radioDot: "w-[4] h-[4] rounded-full bg-[#10254f]",
+ progressTrack:
+ "h-[12] flex-row p-[1] rounded-[6] border-[#8a8a8a] bg-[#f4f4f4]",
+ progressFill: "h-[10] rounded-[5] bg-[#5a9aee]",
+ // Focus is the blue ring of keyboard access; pressed faces match
+ // `dialogButton`.
+ optionRow:
+ "h-[20] flex-row items-center gap-[6] px-[3] border-[#e8e8e800] focus:border-[#6c9ef0]",
+ contentButton: (primary) =>
+ primary
+ ? "min-w-[75] h-[23] px-[14] flex-row justify-center items-center rounded-[11] border-[#2a5fbe] bg-gradient-to-b from-[#b4d4f8] via-[#5a9bee] to-[#78b8f6] focus:border-[#10254f] active:bg-gradient-to-b active:from-[#5b8fdc] active:via-[#3670cf] active:to-[#4b8be0]"
+ : "min-w-[75] h-[23] px-[14] flex-row justify-center items-center rounded-[11] border-[#7a7a7a] bg-gradient-to-b from-[#ffffff] via-[#f1f1f1] to-[#dadada] focus:border-[#3875d7] active:bg-gradient-to-b active:from-[#d4d4d4] active:via-[#c6c6c6] active:to-[#cccccc]",
};
export const THEMES: readonly DesktopTheme[] = [
@@ -1469,6 +1647,11 @@ export function themeById(id: ThemeId): DesktopTheme {
return CLASSIC_THEME;
}
+/** Whether a value read off the wire names a theme. */
+export function isThemeId(value: unknown): value is ThemeId {
+ return THEMES.some((theme) => theme.id === value);
+}
+
/** The theme after `id` in picker order (⌘⇧T cycles through them). */
export function nextThemeId(id: ThemeId): ThemeId {
const i = THEMES.findIndex((t) => t.id === id);
diff --git a/shells/desktop/test/system-ui-sim.test.ts b/shells/desktop/test/system-ui-sim.test.ts
index 2731c1b..c698683 100644
--- a/shells/desktop/test/system-ui-sim.test.ts
+++ b/shells/desktop/test/system-ui-sim.test.ts
@@ -7,7 +7,8 @@
// (bootWorld mutateOps), so the whole input dialect journey runs
// headless — typing, drag selection, ⌘ chords, the notepad context
// menu, paste-req — with guest intents (copy payloads!) asserted on
-// the wire, and the host's open line launching apps by package id.
+// the wire, the host's open line launching apps by package id, and the
+// theme lines in both directions.
//
// The solid bundle must be prebuilt (the sim's fallback build cannot
// resolve the framework-suffixed name):
@@ -23,12 +24,19 @@ import {
treeHasText,
type SimWorld,
} from "../../../vendor/pocketjs/hosts/sim/sim.ts";
-import { DESKTOP_NAME } from "../src/system-ui/pocket-apps.ts";
+import { DESKTOP_ABOUT, DESKTOP_NAME } from "../src/system-ui/pocket-apps.ts";
import {
AQUA_THEME,
CLASSIC_THEME,
+ THEMES,
XP_THEME,
} from "../src/system-ui/theme.ts";
+import {
+ cascadePos,
+ contentTop,
+ desktopIconPosition,
+ desktopIconRows,
+} from "../src/system-ui/wm.ts";
const APP = "pocket-desktop-system-ui";
@@ -123,6 +131,15 @@ function treeTextCount(tree: unknown, text: string): number {
: own;
}
+/** The text of every text node, in tree order. */
+function treeTexts(tree: unknown, out: string[] = []): string[] {
+ if (tree == null) return out;
+ const node = tree as { x?: unknown; k?: unknown[] };
+ if (typeof node.x === "string") out.push(node.x);
+ if (Array.isArray(node.k)) for (const child of node.k) treeTexts(child, out);
+ return out;
+}
+
function treeHasClass(tree: unknown, className: string): boolean {
if (tree == null) return false;
const node = tree as { c?: unknown; k?: unknown[] };
@@ -149,9 +166,12 @@ describe("system-ui System UI companion journey", () => {
await step(world, 2);
mouse(svc, 100, 445, false);
await step(world, 2);
- expect(treeHasText(world.getTree(), "Classic 98")).toBe(true);
- expect(treeHasText(world.getTree(), "Windows XP")).toBe(true);
- expect(treeHasText(world.getTree(), "Aqua")).toBe(true);
+ // The flyout's rows are the three theme labels, in picker order.
+ expect(
+ treeTexts(world.getTree()).filter((text) =>
+ THEMES.some((theme) => theme.label === text),
+ ),
+ ).toEqual(["Classic 98", "XP", "Aqua"]);
mouse(svc, 220, 461, true);
mouse(svc, 220, 461, false);
await step(world, 2);
@@ -470,6 +490,174 @@ describe("system-ui System UI companion journey", () => {
tree = world.getTree();
expect(treeHasText(tree, "Shut Down...")).toBe(false);
expect(treeHasText(tree, `About ${DESKTOP_NAME}`)).toBe(true);
- expect(treeHasText(tree, "github.com/pocket-nexus/pocket-shell")).toBe(true);
+ // Under the heading the dialog shows pocket.about.json: one text node
+ // per body line, then the link.
+ expect(DESKTOP_ABOUT.body.length).toBeGreaterThan(0);
+ for (const line of DESKTOP_ABOUT.body)
+ expect(treeTextCount(tree, line)).toBe(1);
+ expect(treeTextCount(tree, DESKTOP_ABOUT.link)).toBe(1);
+ const texts = treeTexts(tree);
+ expect(texts.indexOf(DESKTOP_ABOUT.link)).toBe(
+ texts.indexOf(DESKTOP_ABOUT.body[0]) + DESKTOP_ABOUT.body.length,
+ );
+ }, 30000);
+
+ test("the shell reports its theme and takes the host's theme line", async () => {
+ const svc = mockSvc();
+ const provider = await testTextProvider();
+ const world = await bootWorld(APP, 60, {offload: provider.ops}, svc.mutateOps);
+ const guestFrame = world.frame;
+ world.frame = (...args) => { provider.betweenFrames(); guestFrame(...args); };
+ svc.push({ t: "hello", w: 800, h: 600, epoch: 1755650000000 });
+ await step(world, 3);
+
+ /** Ids of the theme lines the shell has sent, oldest first. */
+ const reported = () =>
+ svc.sent().filter((line) => line.t === "theme").map((line) => line.id);
+
+ // One report after boot, and none while the theme stands.
+ expect(reported()).toEqual(["classic"]);
+ await step(world, 6);
+ expect(reported()).toEqual(["classic"]);
+
+ // The cycle chord changes the theme; the shell reports the new one.
+ svc.push({ t: "key", k: "t", cmd: true, sh: true });
+ await step(world, 2);
+ expect(treeHasClass(world.getTree(), XP_THEME.desktop)).toBe(true);
+ expect(reported()).toEqual(["classic", "xp"]);
+
+ // The host's line selects a theme, and the shell reports that one too.
+ svc.push({ t: "theme", id: "aqua" });
+ await step(world, 2);
+ let tree = world.getTree();
+ expect(treeHasClass(tree, AQUA_THEME.desktop)).toBe(true);
+ expect(treeHasClass(tree, AQUA_THEME.screenBar)).toBe(true);
+ expect(treeHasClass(tree, XP_THEME.desktop)).toBe(false);
+ expect(reported()).toEqual(["classic", "xp", "aqua"]);
+
+ // The active theme again, an id that names no theme and a line without
+ // a usable id change nothing and report nothing.
+ const before = JSON.stringify(world.getTree());
+ svc.push({ t: "theme", id: "aqua" });
+ svc.push({ t: "theme", id: "luna" });
+ svc.push({ t: "theme", id: 7 });
+ svc.push({ t: "theme" });
+ await step(world, 2);
+ expect(JSON.stringify(world.getTree())).toBe(before);
+ expect(reported()).toEqual(["classic", "xp", "aqua"]);
+
+ // A pick in the Settings menu is reported like the chord. On Aqua the
+ // logo menu's Settings row spans y 119..138 and its flyout opens at
+ // x=207 with "Classic 98" on its first row.
+ svc.push({ t: "key", k: "escape", cmd: true });
+ await step(world, 2);
+ mouse(svc, 100, 129, false);
+ await step(world, 2);
+ mouse(svc, 250, 133, true);
+ mouse(svc, 250, 133, false);
+ await step(world, 2);
+ tree = world.getTree();
+ expect(treeHasClass(tree, CLASSIC_THEME.desktop)).toBe(true);
+ expect(reported()).toEqual(["classic", "xp", "aqua", "classic"]);
+
+ // The host's line closes an open launcher, as a pick in it would.
+ svc.push({ t: "key", k: "escape", cmd: true });
+ await step(world, 2);
+ expect(treeHasText(world.getTree(), "Shut Down...")).toBe(true);
+ svc.push({ t: "theme", id: "xp" });
+ await step(world, 2);
+ expect(treeHasText(world.getTree(), "Shut Down...")).toBe(false);
+ expect(reported()).toEqual(["classic", "xp", "aqua", "classic", "xp"]);
+
+ // Lines that arrive within one frame are applied in order, and the
+ // shell reports the theme the frame ends in once.
+ svc.push({ t: "theme", id: "classic" });
+ svc.push({ t: "theme", id: "aqua" });
+ await step(world, 2);
+ expect(treeHasClass(world.getTree(), AQUA_THEME.desktop)).toBe(true);
+ expect(reported()).toEqual(["classic", "xp", "aqua", "classic", "xp", "aqua"]);
+ }, 30000);
+
+ test("no text of the desktop names another product", async () => {
+ const svc = mockSvc();
+ const provider = await testTextProvider();
+ const world = await bootWorld(APP, 60, {offload: provider.ops}, svc.mutateOps);
+ const guestFrame = world.frame;
+ world.frame = (...args) => { provider.betweenFrames(); guestFrame(...args); };
+ svc.push({ t: "hello", w: 800, h: 600, epoch: 1755650000000 });
+ await step(world, 3);
+
+ /** Text nodes on screen that carry a product name the desktop must not show. */
+ const named = () =>
+ treeTexts(world.getTree()).filter((text) => /Windows|MS-DOS/.test(text));
+ const click = async (x: number, y: number) => {
+ mouse(svc, x, y, true);
+ mouse(svc, x, y, false);
+ await step(world, 2);
+ };
+
+ // The welcome note mentions windows in lower case only.
+ expect(named()).toEqual([]);
+ svc.push({ t: "key", k: "w", cmd: true });
+ await step(world, 2);
+
+ // The theme picker under Start > Settings (its row begins at y=433).
+ svc.push({ t: "key", k: "escape", cmd: true });
+ await step(world, 2);
+ mouse(svc, 100, 445, false);
+ await step(world, 2);
+ expect(treeHasText(world.getTree(), "Classic 98")).toBe(true);
+ expect(named()).toEqual([]);
+
+ // Start > Shut Down... (the last row, y 545..571) opens the dialog. Its
+ // caption is the verb alone: one text node, since the dialog has no
+ // task button.
+ const captions = () =>
+ treeTexts(world.getTree()).filter((text) => text === "Shut Down");
+ await click(100, 558);
+ let tree = world.getTree();
+ expect(captions()).toHaveLength(1);
+ expect(treeHasText(tree, "What do you want the computer to do?")).toBe(true);
+ expect(named()).toEqual([]);
+
+ // The dialog's two radio marks come from the theme: one chosen with its
+ // dot, one not. Aqua paints the two rings differently.
+ for (const theme of THEMES) {
+ svc.push({ t: "theme", id: theme.id });
+ await step(world, 2);
+ tree = world.getTree();
+ expect(treeHasClass(tree, theme.radioRing(true))).toBe(true);
+ expect(treeHasClass(tree, theme.radioRing(false))).toBe(true);
+ expect(treeHasClass(tree, theme.radioFace(true))).toBe(true);
+ expect(treeHasClass(tree, theme.radioFace(false))).toBe(true);
+ expect(treeHasClass(tree, theme.radioDot)).toBe(true);
+ expect(captions()).toHaveLength(1);
+ }
+ svc.push({ t: "theme", id: "classic" });
+ svc.push({ t: "key", k: "w", cmd: true });
+ await step(world, 2);
+ expect(captions()).toHaveLength(0);
+
+ // My Computer is the first desktop icon; with no other window open its
+ // window takes the first cascade slot. The second place of its sidebar
+ // is (C:), whose listing held the folder and file type that named
+ // other products.
+ const metrics = CLASSIC_THEME.metrics;
+ const icon = desktopIconPosition(0, desktopIconRows(600, metrics), metrics, 800);
+ await click(icon.x + 37, icon.y + 16);
+ await click(icon.x + 37, icon.y + 16);
+ expect(treeHasText(world.getTree(), "Control Panel")).toBe(true);
+ const geo = cascadePos(0, 800, 600, 560, 320, metrics);
+ await click(
+ geo.x + metrics.frame + 40,
+ geo.y + contentTop({ menuWidths: [] }, metrics) +
+ metrics.folderToolH + metrics.folderSideTop +
+ metrics.folderSideRowH + metrics.folderSideRowH / 2,
+ );
+ const listing = treeTexts(world.getTree());
+ expect(listing).toContain("Program Files");
+ expect(listing.filter((text) => text === "System")).toHaveLength(1);
+ expect(listing.filter((text) => text === "Batch File")).toHaveLength(1);
+ expect(named()).toEqual([]);
}, 30000);
});
diff --git a/shells/desktop/test/system-ui.test.ts b/shells/desktop/test/system-ui.test.ts
index 67987e9..d48c179 100644
--- a/shells/desktop/test/system-ui.test.ts
+++ b/shells/desktop/test/system-ui.test.ts
@@ -33,8 +33,14 @@ import {
import {
AQUA_THEME,
CLASSIC_THEME,
+ isThemeId,
+ nextThemeId,
+ themeById,
+ THEMES,
XP_THEME,
+ type DesktopTheme,
} from "../src/system-ui/theme.ts";
+import { parseClassLiteral } from "../../../vendor/pocketjs/framework/compiler/tailwind.ts";
import {
MINES_N,
MINES_W,
@@ -71,10 +77,13 @@ import {
type Doc,
} from "../src/system-ui/notepad.ts";
import {
+ DESKTOP_ABOUT,
DESKTOP_NAME,
POCKET_APPS,
pocketAppByPackage,
+ readAbout,
} from "../src/system-ui/pocket-apps.ts";
+import about from "../pocket.about.json";
import system from "../pocket.system.json";
import {
validateAndResolveBuildPlan,
@@ -147,6 +156,44 @@ describe("Pocket app desktop catalog", () => {
expect(DESKTOP_NAME).toBe("Pocket Shell Desktop");
});
+ test("the About dialog takes its text from pocket.about.json", () => {
+ expect(DESKTOP_ABOUT).toEqual(about);
+ // This repository's file describes the shell.
+ expect(DESKTOP_ABOUT).toEqual({
+ body: [
+ "A desktop compositor demo on the portable Rust backend.",
+ "SolidJS JSX over the same DrawList the",
+ "consoles boot; windows, menus and shortcuts",
+ "live in the guest.",
+ ],
+ link: "github.com/pocket-nexus/pocket-shell",
+ });
+ });
+
+ test("an About file may leave out its body, its link or both", () => {
+ expect(readAbout({ body: ["One line."], link: "example.org" })).toEqual({
+ body: ["One line."],
+ link: "example.org",
+ });
+ expect(readAbout({ body: ["One line."] })).toEqual({
+ body: ["One line."],
+ link: "",
+ });
+ expect(readAbout({ link: "example.org" })).toEqual({
+ body: [],
+ link: "example.org",
+ });
+ // Entries that are not strings are dropped; a body or link of another
+ // type reads as absent.
+ expect(readAbout({ body: ["a", 1, null, "b"], link: 2 })).toEqual({
+ body: ["a", "b"],
+ link: "",
+ });
+ expect(readAbout({ body: "One line." })).toEqual({ body: [], link: "" });
+ for (const value of [{}, [], null, undefined, "text", 3])
+ expect(readAbout(value)).toEqual({ body: [], link: "" });
+ });
+
test("a host open line resolves installed apps only", () => {
expect(pocketAppByPackage("dev.pocket-nexus.hero")).toEqual({
package: "dev.pocket-nexus.hero",
@@ -344,6 +391,115 @@ const OPTS: ChromeOpts = {
menuWidths: [34, 34],
};
+describe("themes", () => {
+ test("the picker labels name no other product", () => {
+ expect(THEMES.map((theme) => theme.id)).toEqual(["classic", "xp", "aqua"]);
+ expect(THEMES.map((theme) => theme.label)).toEqual([
+ "Classic 98",
+ "XP",
+ "Aqua",
+ ]);
+ });
+
+ test("a theme line's id is checked against the themes", () => {
+ for (const theme of THEMES) {
+ expect(isThemeId(theme.id)).toBe(true);
+ expect(themeById(theme.id)).toBe(theme);
+ }
+ for (const value of ["luna", "XP", "", 0, null, undefined, {}])
+ expect(isThemeId(value)).toBe(false);
+ expect(nextThemeId("classic")).toBe("xp");
+ expect(nextThemeId("aqua")).toBe("classic");
+ });
+});
+
+describe("content parts", () => {
+ /** Every class literal a theme can return for the content parts. */
+ function contentLiterals(theme: DesktopTheme): Record {
+ const both = [false, true];
+ const out: Record = {
+ listWell: theme.listWell,
+ scrollTrack: theme.scrollTrack,
+ scrollThumb: theme.scrollThumb,
+ groupBox: theme.groupBox,
+ groupLabel: theme.groupLabel,
+ groupLabelText: theme.groupLabelText,
+ fieldWell: theme.fieldWell,
+ radioDot: theme.radioDot,
+ progressTrack: theme.progressTrack,
+ progressFill: theme.progressFill,
+ optionRow: theme.optionRow,
+ };
+ for (const on of both) {
+ for (const zebra of both)
+ out[`listRow(${on},${zebra})`] = theme.listRow(on, zebra);
+ out[`listText(${on})`] = theme.listText(on);
+ out[`listDetailText(${on})`] = theme.listDetailText(on);
+ out[`radioRing(${on})`] = theme.radioRing(on);
+ out[`radioFace(${on})`] = theme.radioFace(on);
+ out[`contentButton(${on})`] = theme.contentButton(on);
+ }
+ return out;
+ }
+
+ test("every theme fills every content part with a class the compiler accepts", () => {
+ for (const theme of THEMES) {
+ for (const [part, literal] of Object.entries(contentLiterals(theme))) {
+ // A literal the compiler rejects compiles to no style at all, so a
+ // typo would paint nothing without failing the build.
+ expect(`${theme.id} ${part}: ${parseClassLiteral(literal) !== null}`)
+ .toBe(`${theme.id} ${part}: true`);
+ }
+ }
+ });
+
+ test("the metrics mirror the literals a program lays text out against", () => {
+ for (const theme of THEMES) {
+ const m = theme.metrics;
+ expect(theme.scrollTrack).toContain(`w-[${m.scrollW}]`);
+ expect(theme.groupBox).toContain(`px-[${m.groupPadX}]`);
+ expect(theme.listWell).toContain(`p-[${m.listPad}]`);
+ }
+ });
+
+ test("the controls a Pocket app presses carry the focus and pressed variants", () => {
+ for (const theme of THEMES) {
+ expect(theme.optionRow).toContain(" focus:");
+ for (const primary of [false, true]) {
+ expect(theme.contentButton(primary)).toContain(" focus:");
+ expect(theme.contentButton(primary)).toContain(" active:");
+ // The button sizes to its label above the dialog button's width.
+ expect(theme.contentButton(primary)).toContain("min-w-[75] h-[23]");
+ expect(theme.dialogButton(false, primary)).toContain("w-[75] h-[23]");
+ }
+ }
+ });
+
+ test("a selected list row and its text differ from an unselected one in every theme", () => {
+ for (const theme of THEMES) {
+ expect(theme.listRow(true, false)).not.toBe(theme.listRow(false, false));
+ expect(theme.listText(true)).not.toBe(theme.listText(false));
+ expect(theme.listDetailText(true)).not.toBe(theme.listDetailText(false));
+ }
+ // Only Aqua stripes its lists.
+ expect(AQUA_THEME.listRow(false, true)).not.toBe(AQUA_THEME.listRow(false, false));
+ expect(CLASSIC_THEME.listRow(false, true)).toBe(CLASSIC_THEME.listRow(false, false));
+ expect(XP_THEME.listRow(false, true)).toBe(XP_THEME.listRow(false, false));
+ });
+
+ test("Classic keeps the radio mark the Shut Down dialog drew before the part existed", () => {
+ for (const checked of [false, true]) {
+ expect(CLASSIC_THEME.radioRing(checked)).toBe(
+ "w-[12] h-[12] rounded-full bg-[#808080] flex-col justify-center items-center",
+ );
+ expect(CLASSIC_THEME.radioFace(checked)).toBe(
+ "w-[10] h-[10] rounded-full bg-[#ffffff] flex-col justify-center items-center",
+ );
+ }
+ expect(CLASSIC_THEME.radioDot).toBe("w-[4] h-[4] rounded-full bg-[#000000]");
+ });
+});
+
describe("caption buttons", () => {
test("all three buttons sit flush against each other, flush right", () => {
const xs = captionButtonXs(400, ["min", "max", "close"]);