// This is the whole thing: what "vvd create bestiary --tool" writes into
// src/codec.ts and src/tool.tsx, glued into one file because this page has no
// bundler. Nothing is rewritten below — every line is a line the CLI writes.
import { type CSSProperties, useEffect, useMemo, useState } from "react"
import { defineStateCodec, field, DocumentGate, HostIcon, type ToolRenderProps, createTranslator, defineTool, resolveHostLocale, useContextMenu, useDocDropTarget, useDocument, useDocumentPresence, useHostCapability, useHostLocale } from "@vvd/sdk"
// src/locales/en.json — inlined: an import of a JSON file needs a bundler, and this page has none.
const en = {
"common": {
"untitled": "Untitled"
},
"board": {
"add": "Add a note",
"count": {
"0": "no notes yet",
"one": "{count} note",
"other": "{count} notes"
},
"empty": "No notes yet.",
"emptyEditable": "No notes yet — add one, then open this world in a second window and watch it appear there too."
},
"note": {
"placeholder": "Write something…",
"link": "Link a document",
"linked": "linked document",
"open": "Open in your world",
"unlink": "Unlink document",
"delete": "Delete note"
},
"picker": {
"search": "Search your world…",
"create": "Create “{query}”",
"empty": "Nothing found"
}
}
// src/codec.ts
/** One note on the board. linkedDocumentId points at a WORLD document — always an id, never a copy. */
export type Note = {
text: string
linkedDocumentId: string | null
/** Creation time — a stable sort key every peer agrees on. */
at: number
}
export const codec = defineStateCodec({
// A per-KEY map: two people adding or editing different notes at once BOTH land.
notes: field.map<Note>(),
})
// src/tool.tsx
const NAME = "bestiary"
// bestiary is a tiny shared notes board — small on purpose, but nothing here is
// faked: every note is real collaborative state (edits converge live for everyone),
// presence is real, and a note's link chip points at a REAL world document by id —
// rename the card and the chip follows. It already behaves like a vvd tool: it
// lives in the sidebar, embeds as a block on any page, shares like any document,
// accepts sidebar drags through the platform dnd engine, and puts its actions on
// the platform right-click menu.
const newId = () => crypto.randomUUID()
// ── Words ────────────────────────────────────────────────────────────────────
// The HOST decides WHICH language the reader is in (and re-publishes live when they
// switch — the subtree re-renders in place, nothing remounts); these JSON files decide
// what bestiary says in it. Adding a language is two steps: `vvd locales add fr`
// writes src/locales/fr.json with every key blank, then import it into CATALOGS below.
// `vvd locales check` tells you what's still untranslated — it warns, it never blocks,
// and a half-filled catalog falls back key-by-key to English, so you can ship at any point.
//
// Only UI text lives here. A note's own words are the AUTHOR's — never localize the
// content a person typed into your creation.
const CATALOGS = { en }
function useT() {
const { locale } = useHostLocale()
return useMemo(
() => createTranslator(resolveHostLocale({ locale, catalogs: CATALOGS, defaultLocale: "en" })),
[locale],
)
}
type Hit = { id: string; name: string; entityTypeName: string | null; entityTypeIcon: string | null }
const CHIP: CSSProperties = { display: "inline-flex", alignItems: "center", gap: 6, maxWidth: "100%", padding: "3px 10px", borderRadius: 999, border: "1px solid rgba(127,127,127,0.4)", background: "transparent", color: "inherit", font: "inherit", fontSize: 12, overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis" }
/** The baseline presence display — who else is in this document right now. */
function PresenceStrip({ handle }: { handle: Parameters<typeof useDocumentPresence>[0] }) {
const others = useDocumentPresence(handle).filter((p) => !p.isSelf)
if (others.length === 0) return null
return (
<span style={{ display: "flex", alignItems: "center" }} title={others.map((p) => p.name).join(", ")}>
{others.slice(0, 5).map((p) => (
<span key={p.userId} style={{ width: 24, height: 24, borderRadius: "50%", marginLeft: -8, display: "inline-flex", alignItems: "center", justifyContent: "center", background: p.color, color: "#fff", fontSize: 11, fontWeight: 700, border: "2px solid rgba(255,255,255,0.35)" }}>
{p.name ? p.name.slice(0, 1).toUpperCase() : ""}
</span>
))}
{others.length > 5 && <span style={{ marginLeft: 6, fontSize: 12, opacity: 0.7 }}>+{others.length - 5}</span>}
</span>
)
}
/** The native reference-picker pattern (the Board/Canvas/relation-tree pickers):
* search the world IMMEDIATELY (an empty query shows suggestions), arrow keys +
* Enter to pick, a create-as-card fallback — and always hand back the document
* ID, never the text. Entity icons render through the platform icon engine. */
function ReferencePicker({ onPick, onClose }: { onPick: (hit: Hit) => void; onClose: () => void }) {
const search = useHostCapability("search")
const t = useT()
const [q, setQ] = useState("")
const [hits, setHits] = useState<Hit[]>([])
const [loaded, setLoaded] = useState(false)
const [active, setActive] = useState(0)
useEffect(() => {
let live = true
const timer = setTimeout(() => {
search.query(q, { types: ["card"], limit: 12 }).then((r) => {
if (!live) return
setHits(r)
setLoaded(true)
setActive(0)
})
}, q ? 150 : 0)
return () => { live = false; clearTimeout(timer) }
}, [q, search])
const canCreate = !!search.create && q.trim() !== "" && !hits.some((h) => h.name.toLowerCase() === q.trim().toLowerCase())
const total = hits.length + (canCreate ? 1 : 0)
const choose = (i: number) => {
if (i < hits.length) onPick(hits[i])
else if (canCreate) void search.create?.(q.trim(), null).then((made) => { if (made) onPick(made) })
}
return (
<span style={{ display: "grid", gap: 2, minWidth: 220, padding: 4, borderRadius: 10, background: "rgba(20,22,29,0.97)", color: "#fff", border: "1px solid rgba(255,255,255,0.14)", boxShadow: "0 14px 32px rgba(0,0,0,0.45)" }}>
<input autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder={t("picker.search")}
onKeyDown={(e) => {
if (e.key === "Escape") { e.preventDefault(); onClose() }
else if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, Math.max(0, total - 1))) }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)) }
else if (e.key === "Enter") { e.preventDefault(); if (total > 0) choose(active) }
}}
onBlur={() => setTimeout(onClose, 120)}
style={{ padding: "7px 10px", borderRadius: 7, border: "none", outline: "none", background: "rgba(255,255,255,0.08)", color: "#fff", font: "inherit", fontSize: 13 }} />
<span style={{ display: "grid", maxHeight: 240, overflowY: "auto" }}>
{hits.map((h, i) => (
<button key={h.id} type="button" onMouseDown={(e) => { e.preventDefault(); choose(i) }} onMouseEnter={() => setActive(i)}
style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 9px", borderRadius: 7, border: "none", textAlign: "left", cursor: "pointer", font: "inherit", fontSize: 13, color: "#fff", background: i === active ? "rgba(255,255,255,0.12)" : "transparent" }}>
<HostIcon icon={h.entityTypeIcon || "file-text"} size={13} />
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis" }}>{h.name || t("common.untitled")}</span>
{h.entityTypeName ? <span style={{ fontSize: 10.5, opacity: 0.5 }}>{h.entityTypeName}</span> : null}
</button>
))}
{canCreate && (
<button type="button" onMouseDown={(e) => { e.preventDefault(); choose(hits.length) }} onMouseEnter={() => setActive(hits.length)}
style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 9px", borderRadius: 7, border: "none", textAlign: "left", cursor: "pointer", font: "inherit", fontSize: 13, color: "#fff", background: active === hits.length ? "rgba(255,255,255,0.12)" : "transparent" }}>
<HostIcon icon="plus" size={13} />
{/* One phrase, one key — never "Create " + q + "": word order is not universal. */}
<span style={{ flex: 1 }}>{t("picker.create", { query: q.trim() })}</span>
</button>
)}
{loaded && hits.length === 0 && !canCreate && <span style={{ padding: "10px 12px", fontSize: 12, opacity: 0.5 }}>{t("picker.empty")}</span>}
</span>
</span>
)
}
function NoteCard({ note, canEdit, linkedName, onChange, onDelete, onOpenLink }: {
note: Note
canEdit: boolean
linkedName: string | null
onChange: (next: Note) => void
onDelete: () => void
onOpenLink: () => void
}) {
const [linking, setLinking] = useState(false)
const t = useT()
const { openContextMenu } = useContextMenu()
return (
<div
onContextMenu={(e) => {
// Actions live on the platform right-click menu — no button chrome on the card.
if (!canEdit) return
openContextMenu(e, [
...(note.linkedDocumentId
? [{ kind: "item" as const, id: "unlink", label: t("note.unlink"), icon: "tabler:circle-x", onSelect: () => onChange({ ...note, linkedDocumentId: null }) }]
: []),
{ kind: "item" as const, id: "delete", label: t("note.delete"), icon: "tabler:trash", variant: "destructive" as const, onSelect: onDelete },
])
}}
style={{ display: "flex", flexDirection: "column", gap: 8, padding: 14, borderRadius: 12, border: "1px solid rgba(127,127,127,0.3)", background: "rgba(127,127,127,0.07)" }}>
<textarea value={note.text} readOnly={!canEdit} placeholder={t("note.placeholder")}
onChange={(e) => onChange({ ...note, text: e.target.value })}
style={{ resize: "none", minHeight: 64, border: "none", outline: "none", background: "transparent", font: "inherit", fontSize: 14, lineHeight: 1.45, color: "inherit" }} />
{/* Dates are never hand-formatted: t.date runs Intl.DateTimeFormat in the reader's
language, so the same timestamp reads right in every one of them. */}
<span style={{ fontSize: 11, opacity: 0.4 }}>{t.date(note.at)}</span>
{note.linkedDocumentId ? (
<button type="button" onClick={onOpenLink} title={t("note.open")} style={{ ...CHIP, cursor: "pointer", alignSelf: "flex-start" }}>
<HostIcon icon="link" size={11} /> {linkedName || t("note.linked")}
</button>
) : canEdit ? (
<span style={{ position: "relative", alignSelf: "flex-start" }}>
<button type="button" onClick={() => setLinking(true)} style={{ ...CHIP, cursor: "pointer", opacity: 0.55, borderStyle: "dashed" }}>{t("note.link")}</button>
{linking && (
<span style={{ position: "absolute", zIndex: 30, top: "100%", left: 0, marginTop: 4, width: 250 }}>
<ReferencePicker onClose={() => setLinking(false)} onPick={(h) => { onChange({ ...note, linkedDocumentId: h.id }); setLinking(false) }} />
</span>
)}
</span>
) : null}
</div>
)
}
function Board({ data, actions, handle, canEdit, worldId }: {
data: { notes: Readonly<Record<string, Note>> }
actions: { map(name: "notes"): { set(key: string, value: Note): void; delete(key: string): void } }
handle: Parameters<typeof useDocumentPresence>[0]
canEdit: boolean
worldId: string
}) {
const refs = useHostCapability("refs")
const nav = useHostCapability("nav")
const t = useT()
const notes = useMemo(() => Object.entries(data.notes).sort((a, b) => a[1].at - b[1].at), [data.notes])
// Resolve linked ids → display names through the host (batched, host-cached; never stored).
const idsKey = useMemo(
() => [...new Set(notes.map(([, n]) => n.linkedDocumentId).filter((x): x is string => !!x))].join(","),
[notes],
)
const [names, setNames] = useState<Record<string, string>>({})
useEffect(() => {
const ids = idsKey ? idsKey.split(",") : []
if (!ids.length) return
let live = true
refs.meta(ids).then((m) => {
if (!live) return
const out: Record<string, string> = {}
m.forEach((r, docId) => { out[docId] = r.name })
setNames(out)
})
return () => { live = false }
}, [idsKey, refs])
const openDoc = (docId: string) => nav.openDocument(refs.coordsFor(docId) ?? { worldId, documentId: docId })
const addNote = () => actions.map("notes").set(newId(), { text: "", linkedDocumentId: null, at: Date.now() })
// The platform dnd contract: drag any document from the sidebar onto the board
// and it lands as a linked note (reference by id — never a copy).
const drop = useDocDropTarget({
zone: "bestiary-board",
accepts: (p) => canEdit && (!p.worldId || p.worldId === worldId),
onDrop: (p) => actions.map("notes").set(newId(), { text: "", linkedDocumentId: p.documentId, at: Date.now() }),
})
return (
<div ref={drop.ref} {...drop.props}
style={{ height: "100%", overflowY: "auto", outline: drop.canDrop ? "2px dashed rgba(127,127,127,0.55)" : "none", outlineOffset: -8 }}>
<div style={{ maxWidth: 960, margin: "0 auto", padding: "28px 20px 64px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>{NAME}</h1>
{/* Counts go through t.plural — Intl.PluralRules picks the form, and a catalog
can add an exact-count form ("0": "no notes yet") that no rule can express. */}
<span style={{ flex: 1, fontSize: 13, opacity: 0.5 }}>{t.plural("board.count", notes.length)}</span>
<PresenceStrip handle={handle} />
{canEdit && (
<button type="button" onClick={addNote}
style={{ padding: "7px 14px", borderRadius: 10, border: "1px solid rgba(127,127,127,0.4)", background: "transparent", color: "inherit", cursor: "pointer", font: "inherit", fontSize: 13, fontWeight: 600 }}>
{t("board.add")}
</button>
)}
</div>
<div style={{ marginTop: 18, display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 12 }}>
{notes.map(([id, note]) => (
<NoteCard key={id} note={note} canEdit={canEdit}
linkedName={note.linkedDocumentId ? names[note.linkedDocumentId] || null : null}
onChange={(next) => actions.map("notes").set(id, next)}
onDelete={() => actions.map("notes").delete(id)}
onOpenLink={() => note.linkedDocumentId && openDoc(note.linkedDocumentId)} />
))}
{notes.length === 0 && (
<p style={{ gridColumn: "1 / -1", opacity: 0.6, fontSize: 14 }}>
{canEdit ? t("board.emptyEditable") : t("board.empty")}
</p>
)}
</div>
</div>
</div>
)
}
export function BestiaryView({ document, context }: ToolRenderProps) {
const coords = useMemo(
() => ({ worldId: document.worldId, documentId: document.id }),
[document.worldId, document.id],
)
const { data, status, actions, retry, handle } = useDocument(coords, codec)
return (
<DocumentGate status={status} onRetry={retry}>
{data && actions && <Board data={data} actions={actions} handle={handle} canEdit={context.canEdit} worldId={document.worldId} />}
</DocumentGate>
)
}
export default defineTool({
id: "bestiary",
name: "bestiary",
documentTypes: ["bestiary"],
needs: [],
render: BestiaryView,
})