Engineering notes · Interfaces

Rethinking the scene contract

How we stopped asking a component tree to perform two jobs, and found a calmer way to evolve a drawing tool.

September 3, 202614 minute readBy Mira Chen

Our editor once treated every visual surface as a child of one large scene component. It worked—until new canvases needed different controls, loading states, and empty views.

The issue was not the number of props alone. The parent knew too much about when a child appeared and the child knew too much about where it lived. Each improvement tightened the knot.

roughcut-labs / studio×

Let side panels render their own empty states

A small request exposed a larger question: which layer really owns the scene around a tool?

architecturecommunityaccepted
Discussion #241 · 18 thoughtful replies

We sketched the relationships before touching code. The first drawing looked like a family tree in which every arrow eventually returned to the same ancestor.

That shape made an ordinary choice—show a welcome panel or a blank canvas—travel through unrelated layers. A view could be reusable only after it received a growing bundle of flags.

Getting rid of render props

Render props had helped us move quickly. Later they became a disguised routing system: the scene asked a callback which screen it should create, then supplied that screen with state it did not need.

// before: ownership is split across both sides
<SceneRoot
  renderPanel={({ state, actions }) => (
    <ToolPanel
      state={state}
      onClose={actions.closePanel}
      emptyView={showWelcome ? <Welcome /> : null}
    />
  )}
/>

The replacement is less clever. The shell composes siblings, while a narrow context carries only the shared drawing state. Screens decide their own presentation.

// after: composition describes the layout directly
<SceneProvider store={sceneStore}>
  <Canvas />
  <ToolPanel />
  <SceneStatus />
</SceneProvider>
The goal was not fewer components. It was fewer components with two owners.

The footer seemed harmless, but it mixed connection state, zoom actions, and product links. We moved structural controls beside the canvas and kept product navigation at the workspace level.

The visible result barely changed. The important shift was that a canvas test no longer had to construct account navigation, and a product-shell test no longer needed a fake drawing engine.

function WorkspaceFooter() {
  return (
    <footer>
      <SceneStatus />
      <CanvasControls />
      <WorkspaceLinks />
    </footer>
  );
}

Mail and menus

Menus revealed the same pattern at a smaller scale. A menu should own its open state and commands; the workspace should decide only where that menu is placed.

Once command data became a small array, keyboard navigation and visible labels came from the same source. That eliminated a class of mismatches between shortcuts and menu copy.

const commands = [
  { id: "duplicate", label: "Duplicate scene", keys: "⌘D" },
  { id: "archive", label: "Archive scene", keys: "⇧A" },
];

A welcome screen with one job

The last migration was the most visible. The welcome screen had gradually become an alternate application shell. We reduced it to a choice: open recent work or begin with a template.

The screen now accepts a recent-project list and two callbacks. It does not know how the scene is stored or how navigation works. That boundary made it easy to add a compact variant without adding another mode to the canvas.

type WelcomeProps = {
  recent: ReadonlyArray<ProjectSummary>;
  onOpen(id: ProjectId): void;
  onCreate(template: TemplateId): void;
};

Wrapping up

None of these edits invented a new pattern. The useful move was applying a familiar one consistently: the shell arranges regions, each region owns its interaction, and shared scene state crosses boundaries through a deliberately small interface.

Our best signal came from deletion. New surfaces needed fewer forwarding props, tests described less irrelevant setup, and contributors could locate a visual decision by following the screen rather than a callback chain.

roughcut-labs / studio
Scene contract migration is complete

The final patch removes the legacy renderer and documents the three ownership rules.

MC
Mira ChenDesigns editor systems at Roughcut Studio and writes about the seams between interaction and architecture.
← Earlier: Drawing state without a singletonNext: Testing visual commands →