Skip to content
Apps— browse docs
On this page

Step 2: Wrap it in an app

A tool consumes a host. An app provides one — three things, and a route.

The board from Step 1 is a tool: it has an id, documentTypes, and a render function. Nothing about it knows it's going to get a whole tab to itself. That part is the app around it — three things, in one src/app.tsx:

src/app.tsx
import { ToolRegistry, defineApp, defineTool } from "@vvd/sdk"

const view = defineTool({
  id: "toy",
  name: "Toy",
  documentTypes: ["toy"],
  surface: "plain",
  render: Board, // ← the component from Step 1
})

export default defineApp({
  id: "toy",
  name: "Toy",
  route: "toy",
  Host: function ToyHost({ children }) {
    return <>{children}</>
  },
  Surface: function ToySurface({ children }) {
    return <div style={{ height: "100%" }}>{children}</div>
  },
  tools: new ToolRegistry().register(view),
})

Four things landed at once:

  • route: "toy" — your app mounts at /worlds/<world>/toy and gets a tab in the world shell.
  • Host answers useHost() for everything inside it. A bare passthrough, here — an app earns a real one the moment it needs to inject something every tool it composes should see.
  • Surface is the chrome a document renders inside — a bare div today, a frosted panel tomorrow. Same tool, different frame, because the app owns it.
  • tools is the registry view lives in. A document of type "toy" opens it.
Note:

This is the same seam as every tool you've built

A tool consumes useHost(); an app provides one. That's the whole difference, and it's the one sentence Apps opens with — worth a read if Host/Surface feel unfamiliar. defineApp covers every field on the manifest.

The screen, on its own

view is an ordinary tool, so it's what you can actually run on this page — no letters have moved in yet, and nothing here is shared. One thing to keep straight as you follow along: the playground panes in this build are labelled src/app.tsx but run only the Board/view portion of it, default-exported as a tool so the page can mount it — in your real src/app.tsx, the defineApp wrapper above stays around that portion, unchanged:

The board, not yet shared
src/app.tsx, running
Starting the example…

Fixed on purpose — nothing to drag, nothing shared. Both arrive in Step 4; Step 3 gives it somewhere to live first.

The whole file, assembled

For the record, here is what your actual src/app.tsx looks like with both halves in it — the Board from the pane above, wrapped in the defineApp from the top of this page. Every later step edits the Board portion; the wrapper never changes again:

src/app.tsx
import { ToolRegistry, defineApp, defineTool } from "@vvd/sdk"

type Block = { x: number; y: number }

const WORD = "TOY"
const SIZE = 64
const COLORS = ["#c0392b", "#2e86c1", "#d68910"]

function defaultBlock(i: number, n: number): Block {
  const gap = Math.min(0.24, 0.6 / Math.max(1, n - 1))
  return { x: 0.5 + (i - (n - 1) / 2) * gap, y: 0.5 }
}

function Board() {
  const letters = WORD.split("")
  return (
    <div style={{ position: "relative", height: "100%" }}>
      {letters.map((ch, i) => {
        const b = defaultBlock(i, letters.length)
        return (
          <div
            key={i}
            style={{
              position: "absolute",
              left: b.x * 100 + "%",
              top: b.y * 100 + "%",
              width: SIZE,
              height: SIZE,
              marginLeft: -SIZE / 2,
              marginTop: -SIZE / 2,
              display: "grid",
              placeItems: "center",
              borderRadius: 12,
              background: COLORS[i % COLORS.length],
              color: "#fff",
              fontFamily: "Georgia, serif",
              fontSize: 26,
              fontWeight: 700,
            }}
          >
            {ch}
          </div>
        )
      })}
    </div>
  )
}

const view = defineTool({
  id: "toy",
  name: "Toy",
  documentTypes: ["toy"],
  surface: "plain",
  render: Board,
})

export default defineApp({
  id: "toy",
  name: "Toy",
  route: "toy",
  Host: function ToyHost({ children }) {
    return <>{children}</>
  },
  Surface: function ToySurface({ children }) {
    return <div style={{ height: "100%" }}>{children}</div>
  },
  tools: new ToolRegistry().register(view),
})

What you don't get yet

Run this right now and you get exactly one board per world — same as before, just tabbed. Two campaigns in the same world would be dragging each other's blocks around by accident. That's what the rest of this page fixes, starting with where the board's state actually lives.