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:
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>/toyand gets a tab in the world shell.HostanswersuseHost()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.Surfaceis the chrome a document renders inside — a baredivtoday, a frosted panel tomorrow. Same tool, different frame, because the app owns it.toolsis the registryviewlives in. A document of type"toy"opens it.
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:
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:
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.