Step 5: Let the owner edit the page
Title, subtitle, and a featured list two owners can edit at once — no sync code.
Someone installs your wiki. They want their own title, and a few entries starred on the front page. None of that should mean editing your code, and none of it should mean you building a settings screen.
vvd splits "what an owner picks" into two kinds, and they behave differently at publish
time. Step 4 covered the first — customization (theme, fonts): options you declare as
data, rendered by the platform's own panel. This step is the second — page state: title,
banner, what's starred. That's ordinary shared state, and field.value / field.list give
you a typed, convergent document with no wiring at all.
Two owners doing exactly that — here's the shape of it, and running below, two live panes.
(The demo names its document via useDocument because this page mounts a tool; your
scaffold's src/app.tsx uses useCollabState — the second fence below — so don't paste
this one into it:)
export const codec = defineStateCodec({
title: field.value(""),
featuredIds: field.list<string>([]),
})
function WikiPageState({ document, context }: ToolRenderProps) {
const coords = { worldId: document.worldId, documentId: document.id }
const { data, actions } = useDocument(coords, codec)
const entries = useWorldQuery("documents", { type: "card" })
const toggleStar = (id: string) => {
const at = data.featuredIds.indexOf(id)
if (at >= 0) actions.list("featuredIds").remove(at)
else actions.list("featuredIds").push(id)
}
return (
<>
<input
value={data.title}
readOnly={!context.canEdit}
onChange={(e) => actions.set("title", e.target.value)}
/>
{entries.map((entry) => (
<button key={entry.id} onClick={() => toggleStar(entry.id)}>
{entry.name} {data.featuredIds.includes(entry.id) ? "★" : ""}
</button>
))}
</>
)
}Type a title in either pane; star an entry in one and watch it land in the other:
One honest difference between the demo and your real wiki: these two panes name their
document through useDocument, because this page mounts a tool rather than an app instance.
useCollabState (below) takes the same field shape and resolves the document for you — one
per world, per app, per project — so your wiki passes it no coordinates. The merge behaviour
you just watched is identical either way; it's a property of the field types, not of which
hook opened the document.
const { data, actions } = useCollabState({
title: field.value<string>(""),
subtitle: field.value<string>(""),
bannerMediaId: field.value<string>(""),
sectionOrder: field.list<string>(),
featuredIds: field.list<string>(),
})One room for every template
All site-category apps in a world share one shared-state room —
app-state:<world>:wiki, not one named after your app. Switch from the built-in Default
template to yours and the page keeps its title and its featured entries; your field names
live in that shared namespace on purpose.
canEdit decides whether; customizing decides what it looks like
context.canEdit is the authority on whether this person may change anything — false for a
reader, false on a published page. customizing is a different question: is the host's
Customize panel open right now? Use it to show chrome (a star button, a dashed underline) so
the page looks like a page when nobody's editing it — never to gate whether an edit actually
lands.
const { values, customizing } = useAppCustomization()
const editing = canEdit && customizingDon't make canEdit depend on customizing
The first-party site surfaces once did, and closing the sidebar silently stripped and
remounted every inline affordance mid-edit. canEdit decides whether; customizing
decides what it looks like.
Picking a banner
const media = useHostCapability("media")
const scope = useHostCapability("scope")
const pickBanner = async () => {
if (!media.pick || !actions) return
const picked = await media.pick({ worldId: scope.worldId, accept: ["image/*"] })
if (picked) actions.set("bannerMediaId", picked.id)
}media.pick is optional — it exists on editing hosts and not on a published one. Guard
it, as above, and your button disappears where it can't work instead of throwing. Store the
id; resolve it to a URL the way Step 3 did.
None of this exists on the published side
useCollabState and useAppCustomization are both live-only — there's no collab document
and no Customize panel on a published page. The published renderBaked gets the frozen
values as props instead, and reads them back with the same pure helpers you'd use anywhere
else. Step 7 covers exactly what those props look like.
Recap
- Page state (title, banner, featured) is
field.value/field.listshared state — no sync code required. useCollabStateresolves the document for you; the merge behaviour is identical to a plainuseDocument.canEditgates whether a write lands;customizingonly changes what the chrome looks like.media.pickis optional and live-only — guard it, and store the media id, never a URL.