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"]);