Desktop
One real library actor end to end — writable state, derived presentation, slot hosting, and generator commands.
Desktop (from @motionactor/desktop) animates an operating-system desktop: a dock,
draggable windows that open with a genie effect, and a cursor. It is a real library actor,
and it exercises every core pattern in one place.
State: signals and groups
Everything the actor owns is a signal. Related fields are
namespaced with group. Collections that move as one logical
change are a single signal of an array, not a reactiveSignal.
export class Desktop extends LayoutActor<typeof desktopSlots> {
public static type = "desktop";
public static slots = desktopSlots;
public skin = signal<DesktopSkin>("aqua");
public windows = signal<DesktopWindowRecord[]>([]);
public focusedWindowId = signal("");
public dock = group({
items: signal<DesktopDockItem[]>([]),
position: signal<DesktopDockPosition>("bottom"),
highlights: signal<Record<string, number>>({}),
});
public mouse = group({
x: signal(0),
y: signal(0),
visible: signal(true),
clickProgress: signal(0),
});
}Presentation: computed in the actor, not the view
The view never does geometry. Dock metrics, genie targets and per-window transforms are
derived values on the actor; the renderer reads finished
numbers.
public dockMetrics = derived((): DesktopDockMetrics => {
const { width, height } = this.localLayout();
return resolveDockMetrics(this.skin(), this.dock.items().length, width, height);
});
/** appId → screen-space center used as the genie target for open/minimize. */
public dockIconCenters = derived(() => {
const { iconSize, gap, paddingX, left, centerY } = this.dockMetrics();
// …one center per dock item
});
public windowVisuals = derived((): DesktopWindowVisual[] => {
const centers = this.dockIconCenters();
return this.windows().map((window, index) => ({
id: window.id,
transform: buildWindowTransform(window, centers[window.appId] ?? null),
opacity: clamp01(window.openProgress) * (1 - clamp01(window.minimizeProgress)),
zIndex: 100 + index,
isFocused: this.focusedWindowId() === window.id,
visible: window.isOpen || window.openProgress > 0,
}));
});Derived chains compose
windowVisuals reads dockIconCenters, which reads dockMetrics, which reads
localLayout() and signals. The view is a thin reader of windowVisuals() — which is
also why the same actor renders correctly on a different backend, and why a test can
assert genie geometry without a DOM.
Hosting: slots and window content
Window chrome belongs to the desktop; window content is any other actor. Each window is a
DesktopWindowHost occupying the desktop's windows slot, and the hosted app mounts into
that host's content slot.
const desktopSlots = defineSlots({
windows: slot({ accepts: DesktopWindowHost, multiple: true }),
});
const desktopWindowHostSlots = defineSlots({
content: slot({ accepts: LayoutActor }),
});
// One call wires both: spawn a host into "windows", mount the app actor
// into its "content" slot, and append the window record to the windows signal.
const browser = desktop.spawnActorWindow(BrowserPanel, {
windowId: "browser-window",
appId: "browser",
title: "Browser",
x: 120, y: 86, width: 980, height: 620,
});accepts: LayoutActor on the content slot is the design statement: any renderable actor
can be a window's content. The desktop hosts apps it has never heard of.
Commands: generators that consume authored time
Motion is authored through generator methods. Durations are seconds. Methods with more than
one primitive argument take object parameters. Concurrent tweens compose with
runtime.all; whole-collection changes use transition so the array moves as one logical
change.
public *moveCursor(params: { x: number; y: number }, transition: Duration = 0.6667) {
yield* this.runtime.all(
this.mouse.x.tween(params.x, { duration: transition, easing: Easing.inOutCubic }),
this.mouse.y.tween(params.y, { duration: transition, easing: Easing.inOutCubic }),
);
}
public *openWindow(windowId: string, duration: Duration = 0.6667) {
const baseline = moveWindowToFront(/* mark the window open */);
this.windows.set(baseline);
this.focusedWindowId.set(windowId);
yield* this.windows.transition(
(progress) =>
baseline.map((window) =>
window.id === windowId
? animateWindowField(window, progress, { openProgress: 1 })
: window,
),
{ duration, easing: Easing.out(Easing.inCubic) },
);
}Note the ordering in openWindow: the structural facts (set, focusedWindowId) are
written immediately, then the visual progress animates. The invariant — an opening window
is focused and frontmost — holds from frame one of the animation, not from its end.
Authoring a scene
const compiled = createScene(function* (ctx) {
const desktop = ctx.spawn(Desktop, {
layout: { x: 220, y: 110, width: 1480, height: 860 },
skin: "aqua",
});
desktop.setDockItems([
{ id: "browser", label: "Browser", icon: "🌐" },
{ id: "terminal", label: "Terminal", icon: "⌘" },
]);
const terminal = desktop.spawnActorWindow(Terminal, {
windowId: "terminal-window",
appId: "terminal",
title: "Terminal",
x: 460, y: 210, width: 760, height: 420,
});
yield* ctx.wait(0.4);
yield* desktop.moveCursor({ x: 760, y: 810 }, 0.4667);
yield* desktop.click(0.2);
yield* desktop.highlightDockItem("terminal", 0.2667);
yield* desktop.openWindow("terminal-window", 0.5333);
yield* terminal.command("pnpm test desktop-runtime", 0.5333);
yield* desktop.minimizeWindow("terminal-window", 0.4667);
return { desktop, terminal };
});ctx.wait(0.4)desktop.moveCursor(…)desktop.click(0.2)desktop.highlightDockItem(…)desktop.openWindow(…)terminal.command(…)The hosted Terminal is a full actor with its own commands, so
terminal.command(...) sequences inside the same timeline as the desktop's genie
animations. Nothing coordinates them — they share one authoring cursor.
Rendering and reading state
// Render: SceneRenderer resolves and draws each frame, memoized by default.
<SceneRenderer compiled={compiled} frame={frame} width={1920} height={1080} />// Read: scene exports are typed.
const state = compiled.at(90);
state.scene.desktop.windowVisuals; // finished per-window presentation
state.scene.desktop.focusedWindowId;