From cecd9449497a81a289150a960899536916b23df0 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:19:09 +0800 Subject: [PATCH] feat(desktop): product About text, theme lines and content theme parts The About dialog's body lines and its link come from pocket.about.json beside the System manifest, read at bundle time through pocket-apps.ts. The manifest schema has no field for product copy, so the text has its own file; both of its fields are optional. This repository's file holds the text the dialog showed before, so its About is unchanged; a product that bundles the System UI under its own pocket.system.json replaces the file. The System UI protocol gains a theme line in each direction. The host sends {t:"theme", id} to select a theme; an id that names no theme is ignored. The shell sends {t:"theme", id} 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. DesktopTheme gains content parts for what a program draws inside its window: a list box with its rows and a scroll mark, a group frame, a value well, a radio mark, a progress bar, and the option row and push button a Pocket app presses, which carry the focus: and active: variants the framework's focus manager applies. The Shut Down dialog draws its radio marks from them, and a Pocket app that imports theme.ts draws its window in the active theme. Three metrics (listPad, scrollW, groupPadX) carry the geometry a program lays text out against. No text of the desktop names another product: the XP theme's label is "XP", the Shut Down dialog's caption is "Shut Down", and the (C:) listing shows a "System" folder and a "Batch File" type. The caption carries no name because the dialog is 300px wide and Aqua's centered caption clips a longer title. Co-Authored-By: Claude Opus 5.5 --- shells/desktop/README.md | 45 ++++- shells/desktop/pocket.about.json | 9 + shells/desktop/src/system-ui/app.tsx | 29 ++- shells/desktop/src/system-ui/pocket-apps.ts | 38 +++- shells/desktop/src/system-ui/programs.tsx | 27 +-- shells/desktop/src/system-ui/svc.ts | 16 +- shells/desktop/src/system-ui/theme.ts | 185 +++++++++++++++++- shells/desktop/test/system-ui-sim.test.ts | 200 +++++++++++++++++++- shells/desktop/test/system-ui.test.ts | 156 +++++++++++++++ 9 files changed, 671 insertions(+), 34 deletions(-) create mode 100644 shells/desktop/pocket.about.json 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 | |---|---|---| | ![Pocket Shell Desktop classic theme](docs/classic-theme.png) | ![Pocket Shell Desktop XP theme](docs/xp-theme.png) | ![Pocket Shell Desktop Aqua theme](docs/aqua-theme.png) | - **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"]);