Skip to content
Starter Kits— browse docs
On this page

Wiki

A themed site over your whole world — publish-ready on day one, and everything on it editable in place.

Everything else on this list is for the people building the world. This one is for everybody else.

A wiki is a site over what your world already holds: no copying, no export step, no second database. It reads the world live, it's editable in place by the people who own it, and it publishes as itself — its own layout, its own colours, its own fonts — the first time you press Publish.

You will learn

  • What a wiki is, as distinct from a tool and an app
  • How a site reads a world live instead of copying it
  • What "publishes as itself" means, and why the bake step exists
  • Where the platform's Customize panel comes from, given the kit has no settings UI

Try it

Wiki, running (the site surface, over a two-document world)
Starting the example…

Browse the grid, open an entry, come back. It's the real site surface — but a wiki is a site over YOUR world, and the one behind this frame holds two example documents, so the shape is honest and the contents aren't yours. The Customize panel is missing for the same reason: it's the host's panel, and a docs page isn't a host. Point this at a real world and both fill in.

Create it

vvd create codex --wiki
Expected output:
→ Creating wiki codex in /Users/you/dev/codex

✦  codex — a brand-new wiki, ready to come alive.

Next:
cd codex
vvd run    # render it live in your world — hot-reloads as you edit
vvd save   # save a new version (a private draft)
✓ Created codex (wiki) → /Users/you/dev/codex

There's one wiki base, so --wiki takes no --template=. Point it at a world with vvd run --world=<your-world-slug> and it fills with your actual documents — which is the only way to see this kit properly, because a wiki with nothing in it is just a layout.

What you'd build with it

The audience is always "people who read the world but don't edit it":

  • A public world wiki — the reason this exists. Characters, places, history, browsable.
  • A player-facing campaign site — what the party knows, kept separate from what you know.
  • A series bible for collaborators — one link for a new writer, instead of a folder of documents.
  • A pitch or presentation site — the world, made presentable, without exporting anything.
  • A fan-facing companion — publish the parts of the world that are finished, leave the rest private.
  • An internal reference for a big project — the same site, just never published publicly.

What's in it

src/publish-hooks.ts74 lines

The bake step. When you publish, this projects your live world into the static shape the published site reads.

src/publish-hooks.ts
import type {
  DocumentRef,
  PublishBakeContext,
  PublishedAppContent,
} from "@vvd/sdk"

// codex's own publish BAKE — the app-defined half of publishing.
//
// The PLATFORM owns identity, version allocation, audience, chunked
// persistence and serving. The APP owns this projection: read the live world
// through a reads-only context and return ONE immutable edition. It runs
// wherever the publisher runs it (a sandboxed browser context today), never
// against a database of its own — the ctx is the only door, and it has no
// write path.
//
// The shape returned below is the standard world-site projection the platform
// understands: index / refs / routes / mediaUrls / entityTypes / documents.
// Serving reconstructs it and re-serves it to your renderBaked (src/app.tsx)
// through the BAKED host, so useWorldQuery, useWorldMeta and HostEmbed all
// resolve on the published page with no sockets and no auth.
export async function bake(ctx: PublishBakeContext): Promise<PublishedAppContent> {
  const [world, docs, entityTypes, mediaUrls, appState] = await Promise.all([
    ctx.world(),
    // Every shippable row — already filtered by the platform's visibility gates.
    ctx.documents(),
    ctx.entityTypes(),
    ctx.mediaUrls(),
    // The instance's live shared app state (your page's title/banner/featured),
    // when the publishing surface supplies it. Optional + additive: a host that
    // does not hand it over simply publishes the page's defaults.
    ctx.appState ? ctx.appState() : Promise.resolve(undefined),
  ])

  const refs: Record<string, DocumentRef> = {}
  const routes: Record<string, string> = {}
  const documents: Record<string, unknown> = {}
  for (const d of docs) {
    refs[d.id] = {
      id: d.id,
      name: d.name,
      slug: d.slug ?? d.id,
      type: d.documentType,
      avatarMediaId: d.avatarMediaId ?? null,
      entityTypeId: d.entityTypeId ?? null,
    }
    routes[d.id] = "/" + encodeURIComponent(d.slug ?? d.id)
  }

  // Fetch every document's content in parallel BATCHES — awaiting them one by
  // one turns a 200-card world into a minutes-long publish, and firing all of
  // them at once floods the host.
  const BATCH = 24
  for (let i = 0; i < docs.length; i += BATCH) {
    const slice = docs.slice(i, i + BATCH)
    const contents = await Promise.all(
      slice.map((d) => ctx.documentContent(d.id).catch(() => null)),
    )
    slice.forEach((d, j) => {
      if (contents[j] != null) documents[d.id] = contents[j]
    })
  }

  return {
    index: docs,
    refs,
    routes,
    mediaUrls,
    entityTypes,
    documents,
    // The app's OWN namespace: your page's frozen state, read back by
    // frozenPage() in src/app.tsx.
    extra: { worldName: world.name, page: appState ?? null },
  }
}
src/app.tsx549 lines

The app itself: a `defineApp` with the route it owns, the surface it draws, and the tools it hosts inside it.

src/app.tsx
import { useEffect, useMemo, useState, type ReactNode } from "react"

import {
  HostEmbed,
  HostIcon,
  ToolRegistry,
  defineApp,
  defineTool,
  field,
  resolveCustomizationChoice,
  WIKI_BODY_FONT_PARAM,
  WIKI_HEADING_FONT_PARAM,
  WIKI_TEMPLATE_DEFAULT_ID,
  WIKI_THEME_PARAM,
  WIKI_WORLD_THEME_ID,
  useAppCustomization,
  useCollabState,
  useHostCapability,
  useHostTheme,
  useWorldMeta,
  useWorldQuery,
  type AppCustomizationValues,
  type PublishedAppSurfaceProps,
} from "@vvd/sdk"

// This wiki's OWN publish hooks live next door (src/publish-hooks.ts) — the
// bake half is generic (the same world projection every wiki base shares), the
// render half is THIS file's look, below.
import { bake } from "./publish-hooks"

const NAME = "codex"
const FILE = "src/app.tsx"

// codex is a WIKI TEMPLATE — a themed site over your whole world.
//
// - It never copies world data: the browse grid + entry pages read the world
//   LIVE through the host (edit a card in the editor; this wiki follows).
// - EVERYTHING IS EDITABLE: open the platform's Customize panel (owners only
//   — the app ships NO customize/publish buttons of its own; the host's side
//   panel drives it via useAppCustomization().customizing) and the
//   title/subtitle edit inline, the banner image swaps through the host's
//   media picker, entries star into Featured, and sections reorder. All of it
//   is durable SHARED app state (useCollabState) — every member and the
//   published site see the same page; readers never see a control.
// - THEMES below are the template's exposed customization, spoken in the
//   platform's STANDARD Customize contract: vvd.json declares the same themes
//   as a \`customization\` schema (pure data), so the host renders the
//   Customize pill on your installed wiki — no picker code in this bundle.
//   The app just reads the live choice with useAppCustomization(). The
//   "Your World" theme derives from useHostTheme() — the user's own
//   platform/world look, followed live.
// - vvd.json declares category: "site" (this app IS a wiki template — once
//   installed it appears in the world's template switcher, next to Overworld)
//   and publish: "custom" — the data mirror of the publish hooks at the bottom
//   of this file. Tap Publish and the platform freezes ONE edition of your
//   world (bake) and serves it back through YOUR OWN render (renderBaked), so
//   the live page wears this template's layout, colours and fonts. Nothing to
//   opt into: a brand-new wiki is publishable as itself on its first publish.

// PUBLISHED vs LIVE, in one line: the same page component renders both. Live,
// its state comes from collab + the Customize panel; published, from the frozen
// edition (snapshot + settings). The published page has NO collab, NO auth and
// NO sockets, so the baked half must never call useCollabState /
// useAppCustomization — it reads the frozen values instead.

type ThemeSpec = { name: string; bg: string; ink: string; card: string; line: string; accent: string; serif: boolean }
// THE shared wiki theme catalog (@vvd/sdk): ONE list of looks for every wiki
// template — any theme works on any template, and a stored choice survives a
// template switch. This template contributes LAYOUT + typography; the palette
// comes from the chosen preset's values (themeFromPreset below). vvd.json
// declares the same catalog, so the host panel and this app always agree.
const THEME_PARAM = WIKI_THEME_PARAM
const DEFAULT_THEME = THEME_PARAM.default
const WORLD_THEME_ID = WIKI_WORLD_THEME_ID

// The SHARED font params — pick ANY font on ANY template; "Default" keeps
// this template's own typography. Google fonts load via one injected
// stylesheet per font, deduped by id.
function ensureWikiFont(name: string, source?: string) {
  if (source !== "google" || typeof document === "undefined") return
  const id = "wiki-font-" + name.replace(/ /g, "-").toLowerCase()
  if (document.getElementById(id)) return
  const link = document.createElement("link")
  link.id = id
  link.rel = "stylesheet"
  link.href = "https://fonts.googleapis.com/css2?family=" + name.replace(/ /g, "+") + ":wght@400;500;600;700&display=swap"
  document.head.appendChild(link)
}
function chosenFont(param: typeof WIKI_HEADING_FONT_PARAM, choiceId: string): string | null {
  if (choiceId === "default") return null
  const option = param.options.find((o) => o.id === choiceId)
  if (!option) return null
  ensureWikiFont(option.name, option.source)
  return option.family
}

// This template's SIGNATURE look — what "Default" means here: the wiki
// defines its own colors and fonts, and the catalog only overrides them when
// the user picks a specific theme.
const TEMPLATE_DEFAULT: ThemeSpec = { name: "Default", bg: "#f4eedd", ink: "#2c2418", card: "#fffdf4", line: "rgba(60,45,20,0.2)", accent: "#8a5a2b", serif: true }

// A catalog preset worn by THIS template: colors from its values, with the
// template's own fallbacks (any theme the catalog grows renders sensibly).
// "Default" = the template's signature look above.
function themeFromPreset(p: { id: string; name: string; values: Record<string, string> }): ThemeSpec {
  if (p.id === WIKI_TEMPLATE_DEFAULT_ID) return TEMPLATE_DEFAULT
  const v = p.values
  return {
    name: p.name,
    bg: v.background || "#f4eedd",
    ink: v.foreground || "#1c1b18",
    card: v.secondary || v.background || "#fffdf4",
    line: v.line || "rgba(127,127,127,0.3)",
    accent: v.primary || "#8a5a2b",
    serif: v.font === "serif",
  }
}

// The user's own platform/world look as a ThemeSpec — tokens when the host
// resolves a cascade, sane mode-matched fallbacks when it doesn't.
function worldTheme(snap: { mode: string; tokens: Record<string, string> }): ThemeSpec {
  const tk = snap.tokens
  const light = snap.mode === "light"
  return {
    name: "Your World",
    bg: tk["--background"] || (light ? "#f6f5f1" : "#101014"),
    ink: tk["--foreground"] || tk["--text-color"] || (light ? "#1c1b18" : "#e8e8ec"),
    card: tk["--muted"] || (light ? "#ffffff" : "#181820"),
    line: tk["--border"] || (light ? "rgba(0,0,0,0.14)" : "rgba(255,255,255,0.14)"),
    accent: tk["--primary"] || (light ? "#3d3a34" : "#9aa4b2"),
    serif: false,
  }
}

const font = (t: ThemeSpec) =>
  t.serif ? "Georgia, 'Times New Roman', serif" : "ui-sans-serif, system-ui, sans-serif"

// The page's sections, in their DEFAULT order — reorder lives in shared state.
const DEFAULT_SECTIONS = ["featured", "entries", "extras"]
const SECTION_NAMES: Record<string, string> = { featured: "Featured", entries: "All entries", extras: "Everything else" }

// The page's own durable shape — the five things an owner edits. LIVE it is a
// collab doc; PUBLISHED it is the same five values, frozen into the edition.
// One type for both sides, so the page component never asks which it is.
type PageState = {
  title: string
  subtitle: string
  bannerMediaId: string
  sectionOrder: string[]
  featuredIds: string[]
}
const EMPTY_PAGE: PageState = { title: "", subtitle: "", bannerMediaId: "", sectionOrder: [], featuredIds: [] }

// What customize mode may do to the page. Null on the published page — a
// frozen edition takes no writes, and that is the ONLY difference.
type PageActions = {
  setText: (key: "title" | "subtitle", value: string) => void
  setBanner: (mediaId: string) => void
  moveSection: (key: string, dir: number) => void
  toggleFeatured: (id: string) => void
}

// THE page — the one component both the live mount and the published edition
// render. Everything it needs arrives as props (the customization values, the
// page state, the write actions), so it works identically under the live host
// and under the published page's read-only baked host.
function WikiPage({ editing, values, page, actions, entryPath }: {
  editing: boolean
  values: AppCustomizationValues
  page: PageState
  actions: PageActions | null
  entryPath: string | null
}) {
  const meta = useWorldMeta()
  const docs = useWorldQuery("documents")
  const media = useHostCapability("media")
  const scope = useHostCapability("scope")
  const hostTheme = useHostTheme()
  const [openId, setOpenId] = useState<string | null>(null)
  const [query, setQuery] = useState("")

  // The instance's theme — resolved from the STANDARD Customize values, so ONE
  // line serves both sides: live the platform's Customize pill writes them,
  // published the edition carries them frozen.
  const themeId = resolveCustomizationChoice(THEME_PARAM, values)
  const preset = THEME_PARAM.presets.find((p) => p.id === themeId) || THEME_PARAM.presets[0]
  const T = themeId === WORLD_THEME_ID ? worldTheme(hostTheme) : themeFromPreset(preset)
  // The chosen fonts (shared params) — null means "Default": this template's
  // own typography stays.
  const BODY_FF = chosenFont(WIKI_BODY_FONT_PARAM, resolveCustomizationChoice(WIKI_BODY_FONT_PARAM, values)) || font(T)
  const HEAD_FF = chosenFont(WIKI_HEADING_FONT_PARAM, resolveCustomizationChoice(WIKI_HEADING_FONT_PARAM, values)) || "inherit"

  const title = page.title.trim() || (meta ? meta.name : NAME)
  const subtitle = page.subtitle.trim() || (meta && meta.description) || ""
  const order = page.sectionOrder.length > 0 ? page.sectionOrder : DEFAULT_SECTIONS
  const featuredIds = page.featuredIds

  // The banner: placeholder paints instantly when the host can, resolve() is truth.
  const bannerId = page.bannerMediaId
  const [bannerUrl, setBannerUrl] = useState<string | null>(null)
  useEffect(() => {
    if (!bannerId) { setBannerUrl(null); return }
    let on = true
    setBannerUrl(media.placeholder(bannerId))
    media.resolve(bannerId).then((url) => { if (on && url) setBannerUrl(url) })
    return () => { on = false }
  }, [bannerId, media])
  const pickBanner = async () => {
    if (!media.pick || !actions) return
    const picked = await media.pick({ worldId: scope.worldId, accept: ["image/*"] })
    if (picked) actions.setBanner(picked.id)
  }

  // A published deep link ("/<slug>") opens that entry once the edition loads —
  // the platform hands it in as entryPath; live mounts pass null.
  useEffect(() => {
    if (!entryPath) return
    const slug = decodeURIComponent(entryPath.startsWith("/") ? entryPath.slice(1) : entryPath)
    if (!slug) return
    const hit = docs.find((d) => (d.slug || d.id) === slug)
    if (hit) setOpenId(hit.id)
  }, [entryPath, docs])

  const moveSection = (key: string, dir: number) => actions?.moveSection(key, dir)
  const toggleFeatured = (id: string) => actions?.toggleFeatured(id)

  // Cards are the wiki's entries (readers only see viewable ones); everything
  // else the world holds (maps, timelines, notes) gets its own shelf below.
  const cards = useMemo(
    () => docs.filter((d) => d.documentType === "card" && d.isViewable !== false),
    [docs],
  )
  const entries = useMemo(() => {
    const q = query.trim().toLowerCase()
    if (!q) return cards
    return cards.filter((d) =>
      (d.name || "").toLowerCase().includes(q) ||
      (d.aliases || []).some((a) => a.toLowerCase().includes(q)),
    )
  }, [cards, query])
  const featured = useMemo(
    () => featuredIds.map((id) => cards.find((d) => d.id === id)).filter((d): d is (typeof cards)[number] => d != null),
    [featuredIds, cards],
  )
  const extras = useMemo(() => docs.filter((d) => d.documentType !== "card"), [docs])
  const open = openId ? docs.find((d) => d.id === openId) : null

  const chip = { padding: "5px 12px", borderRadius: 999, border: "1px solid " + T.line, background: "transparent", color: T.ink, cursor: "pointer", font: "inherit", fontSize: 13 } as const
  const editChip = { ...chip, borderStyle: "dashed", opacity: 0.85 } as const

  const entryCard = (d: (typeof cards)[number], big: boolean) => (
    <div key={d.id} style={{ position: "relative" }}>
      <button type="button" onClick={() => setOpenId(d.id)}
        style={{ width: "100%", textAlign: "left", padding: big ? 16 : 12, borderRadius: 12, border: "1px solid " + T.line, background: T.card, color: T.ink, cursor: "pointer", font: "inherit" }}>
        <span style={{ display: "grid", placeItems: "center", width: big ? 42 : 34, height: big ? 42 : 34, borderRadius: 9, marginBottom: 8, background: T.accent, color: "#fff", fontWeight: 700 }}>
          {d.name ? d.name.slice(0, 1).toUpperCase() : ""}
        </span>
        <span style={{ display: "block", fontWeight: 600, fontSize: big ? 16 : 14, lineHeight: 1.3 }}>{d.name || "Untitled"}</span>
      </button>
      {editing && (
        <button type="button" onClick={() => toggleFeatured(d.id)}
          title={featuredIds.includes(d.id) ? "Remove from Featured" : "Add to Featured"}
          style={{ position: "absolute", top: 6, right: 6, display: "grid", placeItems: "center", width: 26, height: 26, borderRadius: "50%", border: "1px solid " + T.line, background: T.bg, color: featuredIds.includes(d.id) ? T.accent : T.ink, cursor: "pointer", opacity: featuredIds.includes(d.id) ? 1 : 0.55 }}>
          <HostIcon icon="star" size={13} />
        </button>
      )}
    </div>
  )

  // A section header that grows reorder handles in customize mode.
  const sectionHead = (key: string) => (
    <div style={{ display: "flex", alignItems: "center", gap: 8, margin: "0 0 10px" }}>
      <h3 style={{ margin: 0, fontSize: 13, textTransform: "uppercase", letterSpacing: 1.2, opacity: 0.55, fontFamily: HEAD_FF }}>{SECTION_NAMES[key] || key}</h3>
      {editing && (
        <span style={{ display: "inline-flex", gap: 4 }}>
          <button type="button" onClick={() => moveSection(key, -1)} title="Move section up" style={{ ...chip, padding: "1px 8px" }}>↑</button>
          <button type="button" onClick={() => moveSection(key, 1)} title="Move section down" style={{ ...chip, padding: "1px 8px" }}>↓</button>
        </span>
      )}
    </div>
  )

  const sections: Record<string, ReactNode> = {
    featured: featured.length > 0 || editing ? (
      <section key="featured" style={{ marginBottom: 26 }}>
        {sectionHead("featured")}
        {featured.length > 0 ? (
          <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 10 }}>
            {featured.map((d) => entryCard(d, true))}
          </div>
        ) : (
          <p style={{ margin: 0, opacity: 0.55, fontSize: 13.5 }}>Star entries below to feature them here.</p>
        )}
      </section>
    ) : null,
    entries: (
      <section key="entries" style={{ marginBottom: 26 }}>
        {sectionHead("entries")}
        <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))", gap: 10 }}>
          {entries.map((d) => entryCard(d, false))}
          {entries.length === 0 && (
            <p style={{ gridColumn: "1 / -1", opacity: 0.6 }}>
              {query ? "No entries match “" + query + "”." : "No entries yet — cards you create in the editor appear here, live."}
            </p>
          )}
        </div>
      </section>
    ),
    extras: extras.length > 0 ? (
      <section key="extras" style={{ marginBottom: 26, borderTop: "1px solid " + T.line, paddingTop: 16 }}>
        {sectionHead("extras")}
        <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
          {extras.map((d) => (
            <button key={d.id} type="button" onClick={() => setOpenId(d.id)} style={chip}>
              {(d.name || "Untitled") + " · " + d.documentType}
            </button>
          ))}
        </div>
      </section>
    ) : null,
  }

  return (
    <div style={{ position: "absolute", inset: 0, overflowY: "auto", background: T.bg, color: T.ink, fontFamily: BODY_FF }}>
      {/* The banner — a world media REFERENCE (never a copy). Customize mode swaps
          it through the host's own media picker; published, it just renders. */}
      {(bannerUrl || editing) && (
        <div style={{ position: "relative", height: bannerUrl ? 180 : 72, background: bannerUrl ? "center / cover no-repeat url(" + JSON.stringify(bannerUrl) + ")" : T.card, borderBottom: "1px solid " + T.line }}>
          {editing && (
            <span style={{ position: "absolute", right: 14, bottom: 12, display: "inline-flex", gap: 6 }}>
              <button type="button" onClick={pickBanner} style={{ ...editChip, background: T.bg }}>
                {bannerUrl ? "Swap banner image" : "Set a banner image"}
              </button>
              {bannerUrl && (
                <button type="button" onClick={() => actions?.setBanner("")} style={{ ...editChip, background: T.bg }}>
                  Remove
                </button>
              )}
            </span>
          )}
        </div>
      )}
      <div style={{ maxWidth: 880, margin: "0 auto", padding: "40px 24px 72px" }}>
        <header style={{ borderBottom: "1px solid " + T.line, paddingBottom: 18, marginBottom: 22 }}>
          <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
            {editing ? (
              <input value={page.title} onChange={(e) => actions?.setText("title", e.target.value)}
                placeholder={meta ? meta.name : NAME} aria-label="Wiki title"
                style={{ margin: 0, fontSize: 34, letterSpacing: 0.3, fontWeight: 700, fontFamily: HEAD_FF, color: T.ink, background: "transparent", border: "none", borderBottom: "1px dashed " + T.line, outline: "none", minWidth: 240, flex: 1 }} />
            ) : (
              <h1 style={{ margin: 0, fontSize: 34, letterSpacing: 0.3, fontFamily: HEAD_FF }}>{title}</h1>
            )}
          </div>
          {editing ? (
            <input value={page.subtitle} onChange={(e) => actions?.setText("subtitle", e.target.value)}
              placeholder={(meta && meta.description) || "A line about this place…"} aria-label="Wiki subtitle"
              style={{ margin: "8px 0 0", width: "100%", maxWidth: 560, opacity: 0.85, fontFamily: "inherit", fontSize: "inherit", color: T.ink, background: "transparent", border: "none", borderBottom: "1px dashed " + T.line, outline: "none" }} />
          ) : subtitle ? (
            <p style={{ margin: "8px 0 0", opacity: 0.75, maxWidth: 560 }}>{subtitle}</p>
          ) : null}
          <input
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder={"Search " + title + "…"}
            style={{ marginTop: 16, width: "100%", maxWidth: 340, padding: "8px 12px", borderRadius: 10, border: "1px solid " + T.line, background: T.card, color: T.ink, font: "inherit", fontSize: 14, outline: "none" }}
          />
        </header>

        {open ? (
          <article>
            <button type="button" onClick={() => setOpenId(null)} style={{ ...chip, marginBottom: 14 }}>
              ← {title}
            </button>
            <h2 style={{ margin: "0 0 4px", fontSize: 26 }}>{open.name || "Untitled"}</h2>
            {open.aliases && open.aliases.length > 0 && (
              <p style={{ margin: "0 0 14px", opacity: 0.6, fontSize: 13 }}>{"Also known as " + open.aliases.join(", ")}</p>
            )}
            {/* The entry's REAL document, rendered by the host's own registry —
                read-only, live. On a host without embeds this quietly no-ops. */}
            <div style={{ background: T.card, border: "1px solid " + T.line, borderRadius: 14, padding: 18 }}>
              <HostEmbed documentId={open.id} view="embed" />
            </div>
          </article>
        ) : (
          <>
            {order.map((key) => sections[key] ?? null)}
            <footer style={{ marginTop: 24, opacity: 0.45, fontSize: 12.5 }}>
              {NAME} — a wiki template. Edit {FILE} to reshape it; add a THEMES entry to expose a new look.
            </footer>
          </>
        )}
      </div>
    </div>
  )
}

// The LIVE mount: collab state + the platform Customize panel supply the page,
// and every edit lands on every member's screen at once.
function Wiki({ canEdit }: { canEdit: boolean }) {
  // The instance's customization — the STANDARD Customize contract, so the
  // platform's own Customize pill drives it (this app ships no pill of its own).
  const { values, customizing } = useAppCustomization()
  // The page itself is durable SHARED state: title/subtitle override the world's,
  // the banner is a media id (reference, never a copy), Featured is a starred
  // list, and the section order is the page's layout. Two editors converge.
  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 plain snapshot of the shared doc — memoized so the page's own lists
  // don't rebuild on every keystroke elsewhere in the tree.
  const page = useMemo<PageState>(
    () => ({
      title: data?.title ?? "",
      subtitle: data?.subtitle ?? "",
      bannerMediaId: data?.bannerMediaId ?? "",
      sectionOrder: data ? [...data.sectionOrder] : [],
      featuredIds: data ? [...data.featuredIds] : [],
    }),
    [data],
  )
  // Customize mode is PLATFORM-DRIVEN: the host's Customize side panel flips
  // "customizing" — this app ships no customize/publish buttons of its own.
  const editing = canEdit && customizing
  const pageActions: PageActions | null = actions
    ? {
        setText: (key, value) => actions.set(key, value),
        setBanner: (mediaId) => actions.set("bannerMediaId", mediaId),
        moveSection: (key, dir) => {
          const cur = page.sectionOrder.length > 0 ? [...page.sectionOrder] : [...DEFAULT_SECTIONS]
          const from = cur.indexOf(key)
          const to = from + dir
          if (from < 0 || to < 0 || to >= cur.length) return
          // First reorder SEEDS the shared list from the default; after that, move().
          if (page.sectionOrder.length > 0) {
            actions.list("sectionOrder").move(from, to)
          } else {
            cur.splice(to, 0, cur.splice(from, 1)[0])
            actions.list("sectionOrder").push(...cur)
          }
        },
        toggleFeatured: (id) => {
          const i = page.featuredIds.indexOf(id)
          if (i >= 0) actions.list("featuredIds").remove(i)
          else actions.list("featuredIds").push(id)
        },
      }
    : null

  return (
    <WikiPage
      editing={editing}
      values={values}
      page={page}
      actions={editing ? pageActions : null}
      entryPath={null}
    />
  )
}

// ── Publishing: this wiki publishes AS ITSELF ────────────────────────────────
// The platform owns identity, versions and serving; the APP owns bake (live
// world → one frozen edition, in src/publish-hooks.ts) and renderBaked (that
// edition → pixels, right here). Because both halves ship in this bundle, the
// live page at your site's address renders THIS template — never a platform
// stand-in surface. vvd.json mirrors it as publish: "custom".

/** The frozen Customize values an edition carries (theme + fonts). */
function frozenCustomization(settings: unknown): AppCustomizationValues {
  if (!settings || typeof settings !== "object") return {}
  const rec = settings as Record<string, unknown>
  // The site family freezes an envelope ({ v, customization, config }); older
  // editions froze the bare values map. Read both.
  const inner = rec.customization
  const values = inner && typeof inner === "object" ? inner : rec
  return values as AppCustomizationValues
}

/** The frozen page state an edition carries (what bake put under extra.page). */
function frozenPage(snapshot: unknown): PageState {
  const extra = snapshot && typeof snapshot === "object" ? (snapshot as { extra?: unknown }).extra : null
  const raw = extra && typeof extra === "object" ? (extra as { page?: unknown }).page : null
  if (!raw || typeof raw !== "object") return EMPTY_PAGE
  const p = raw as Partial<Record<keyof PageState, unknown>>
  const strings = (v: unknown): string[] =>
    Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []
  return {
    title: typeof p.title === "string" ? p.title : "",
    subtitle: typeof p.subtitle === "string" ? p.subtitle : "",
    bannerMediaId: typeof p.bannerMediaId === "string" ? p.bannerMediaId : "",
    sectionOrder: strings(p.sectionOrder),
    featuredIds: strings(p.featuredIds),
  }
}

/**
 * The PUBLISHED face of this wiki (renderBaked): the SAME page component,
 * mounted by the platform over one frozen edition. No collab, no auth, no
 * sockets — the values that were live come from the snapshot and the frozen
 * settings instead, which is why this half must never call useCollabState or
 * useAppCustomization.
 */
export function CodexBaked({ snapshot, settings, entryPath }: PublishedAppSurfaceProps) {
  return (
    <WikiPage
      editing={false}
      values={frozenCustomization(settings)}
      page={frozenPage(snapshot)}
      actions={null}
      entryPath={entryPath}
    />
  )
}

const view = defineTool({
  id: "codex",
  name: "codex",
  documentTypes: ["codex"],
  needs: ["world"],
  surface: "plain",
  render: function CodexView({ context }) {
    return <Wiki canEdit={context.canEdit} />
  },
})

export default defineApp({
  id: "codex",
  name: "codex",
  route: "codex",
  category: "site",
  // The STANDARD Customize schema (mirrored in vvd.json, which is what the
  // catalog reads): declaring it is what gives this wiki the platform's
  // Customize pill on every surface it mounts.
  customization: [THEME_PARAM, WIKI_HEADING_FONT_PARAM, WIKI_BODY_FONT_PARAM],
  // Publishing, both halves in this bundle — the app-defined publish seam.
  // Declaring hooks (rather than naming a platform preset) is exactly what
  // makes the live page render THIS wiki. vvd.json mirrors it: publish "custom".
  publish: { bake, renderBaked: CodexBaked },
  Host: function CodexHost({ children }) {
    return <>{children}</>
  },
  Surface: function CodexSurface({ children }) {
    return <div style={{ position: "relative", height: "100%", width: "100%" }}>{children}</div>
  },
  tools: new ToolRegistry().register(view),
})

Two files, and the split between them is the interesting part. src/app.tsx is this wiki's look — its layout, its themes, its browse grid. src/publish-hooks.ts is the bake step, and it's the same in every wiki base: the generic projection from "a live world" to "a frozen edition of it."

Why does publishing need a bake step at all?Deep dive

Because a published site has no sockets.

A live wiki reads the world through the host: useWorldQuery("documents") returns a list that updates as people edit. A public visitor has none of that — no session, no permissions to evaluate, no connection to keep open. And they shouldn't: the page needs to be fast, cacheable and safe to serve to anyone.

So publishing freezes one edition. bake runs against the live world and produces a plain snapshot of exactly what the site shows; renderBaked draws that snapshot with the same components the live page uses. One look, two data sources — which is why the published site wears your template's layout instead of a platform stand-in, and why a brand-new wiki is publishable as itself on its first publish.

The half you'd write yourself is renderBaked, and it's already written. The bake half is the same for every wiki, which is why it's a separate file you can mostly ignore.

Where do the Customize controls come from?Deep dive

Not from this bundle — there isn't a settings screen in it.

The kit declares a customization schema (themes, heading font, body font) as pure data, mirrored in vvd.json. The host reads that data without loading your code, renders the Customize panel itself, and the app reads the live choice back with useAppCustomization().

Two things fall out of that. The panel looks like every other panel in the product, because it is every other panel. And an owner can retheme a wiki without the bundle being loaded at all — which is what makes the Customize pill appear on the Workshop card.

Owners see the controls; readers never do. See Customization vs. page state.

How it works

A wiki is an app with a category. Same defineApp, plus category: "site" and publish hooks — which is why vvd create --wiki scaffolds an app project. See What you're building.

The world is the content. Your cards, maps and notes are the pages; the wiki decides how they're grouped and shown. See The world becomes pages.

Publishing is one command, twice. vvd save then Publish — and the live site is your own render over a frozen edition. See Publish it and Custom domains.

Next steps