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)
12f
desktop.moveCursor(…)
14f
desktop.click(0.2)
6f
desktop.highlightDockItem(…)
8f
desktop.openWindow(…)
16f
terminal.command(…)
16f
30 fps
0s·0f
1s·30f
2s·60f
3s·90f

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;

On this page