import { z } from "zod/v4"; import { colineUiNodeSchema, type ColineUiNode } from "./tree"; /** * The chat-card tree subset (plan §10.4, open question #4 resolved). * * Tool result cards render inside chat bubbles on every platform, so the * card vocabulary is a strict subset of the tree: presentational nodes, * references, and buttons (which dispatch ordinary app actions). Form * inputs, tabs, menus, and other stateful chrome are excluded — a card is * a result, not an app surface. * * Bounds keep cards cheap to render and impossible to abuse as a layout * engine: max 120 nodes, depth 6, 20 table rows. */ export const CHAT_CARD_ALLOWED_NODE_TYPES = new Set([ "Text", "Heading", "Badge", "Divider", "FileCard", "UserChip", "Image", "Link", "CodeBlock", "Table", "Avatar", "Alert", "Progress", "Stack", "Card", "Button", "Reference", ]); export const CHAT_CARD_MAX_NODES = 120; export const CHAT_CARD_MAX_DEPTH = 6; export const CHAT_CARD_MAX_TABLE_ROWS = 20; export type ChatCardValidation = | { ok: true; nodeCount: number } | { ok: false; reason: string }; interface WalkState { count: number; } function childrenOf(node: ColineUiNode): ColineUiNode[] { switch (node.type) { case "Stack": return node.props.children; case "Card": return [...(node.props.children ?? []), ...(node.props.footer ?? [])]; default: return []; } } function walk(node: ColineUiNode, depth: number, state: WalkState): string | null { if (depth > CHAT_CARD_MAX_DEPTH) { return `Card exceeds the maximum depth of ${CHAT_CARD_MAX_DEPTH}.`; } state.count += 1; if (state.count > CHAT_CARD_MAX_NODES) { return `Card exceeds the maximum of ${CHAT_CARD_MAX_NODES} nodes.`; } if (!CHAT_CARD_ALLOWED_NODE_TYPES.has(node.type)) { return `"${node.type}" nodes are not allowed in tool result cards.`; } if (node.type === "Table" && node.props.rows.length > CHAT_CARD_MAX_TABLE_ROWS) { return `Card tables are limited to ${CHAT_CARD_MAX_TABLE_ROWS} rows.`; } for (const child of childrenOf(node)) { const issue = walk(child, depth + 1, state); if (issue) return issue; } return null; } export function validateChatCardTree(node: unknown): ChatCardValidation { const parsed = colineUiNodeSchema.safeParse(node); if (!parsed.success) { return { ok: false, reason: "Card is not a valid Coline UI tree." }; } const state: WalkState = { count: 0 }; const issue = walk(parsed.data, 1, state); if (issue) { return { ok: false, reason: issue }; } return { ok: true, nodeCount: state.count }; } /** Schema wrapper: parses a tree AND enforces the chat-card subset. */ export const chatCardTreeSchema = colineUiNodeSchema.superRefine((node, ctx) => { const state: WalkState = { count: 0 }; const issue = walk(node, 1, state); if (issue) { ctx.addIssue({ code: "custom", message: issue }); } }); export type ChatCardTree = z.infer;