Sfoglia il codice sorgente

add yaml wire format and universal edit modes (#219)

* add yaml wire format and universal edit modes

**Summary**

- Add `@json-render/yaml` package with streaming parser, YAML prompt generation, and AI SDK transform
- Add universal edit modes (patch, merge, diff) to `@json-render/core`, usable across both JSONL and YAML formats
- Integrate YAML support and edit mode selection into the web playground with format toggle, token usage display, and prompt caching

* improvements

* fixes

* fixes ci

* docs
Chris Tate 3 mesi fa
parent
commit
dc489601be
39 ha cambiato i file con 4011 aggiunte e 153 eliminazioni
  1. 2 1
      .changeset/config.json
  2. 1 0
      README.md
  3. 93 3
      apps/web/app/(main)/docs/api/core/page.mdx
  4. 232 0
      apps/web/app/(main)/docs/api/yaml/page.mdx
  5. 2 2
      apps/web/app/api/docs-chat/route.ts
  6. 78 24
      apps/web/app/api/generate/route.ts
  7. 2 2
      apps/web/components/code-block.tsx
  8. 174 56
      apps/web/components/playground.tsx
  9. 1 0
      apps/web/lib/docs-navigation.ts
  10. 1 0
      apps/web/lib/page-titles.ts
  11. 155 0
      apps/web/lib/spec-patch.ts
  12. 482 0
      apps/web/lib/use-playground-stream.ts
  13. 3 1
      apps/web/package.json
  14. 36 1
      packages/core/README.md
  15. 78 0
      packages/core/src/diff.ts
  16. 316 0
      packages/core/src/edit-modes.ts
  17. 16 0
      packages/core/src/index.ts
  18. 41 0
      packages/core/src/merge.ts
  19. 16 41
      packages/core/src/prompt.ts
  20. 10 0
      packages/core/src/schema.ts
  21. 113 0
      packages/yaml/README.md
  22. 59 0
      packages/yaml/package.json
  23. 109 0
      packages/yaml/src/diff.test.ts
  24. 1 0
      packages/yaml/src/diff.ts
  25. 24 0
      packages/yaml/src/index.ts
  26. 109 0
      packages/yaml/src/merge.test.ts
  27. 1 0
      packages/yaml/src/merge.ts
  28. 149 0
      packages/yaml/src/parser.test.ts
  29. 110 0
      packages/yaml/src/parser.ts
  30. 110 0
      packages/yaml/src/prompt.test.ts
  31. 582 0
      packages/yaml/src/prompt.ts
  32. 161 0
      packages/yaml/src/transform.test.ts
  33. 448 0
      packages/yaml/src/transform.ts
  34. 9 0
      packages/yaml/tsconfig.json
  35. 10 0
      packages/yaml/tsup.config.ts
  36. 7 0
      packages/yaml/vitest.config.ts
  37. 119 20
      pnpm-lock.yaml
  38. 10 2
      skills/core/SKILL.md
  39. 141 0
      skills/yaml/SKILL.md

+ 2 - 1
.changeset/config.json

@@ -21,7 +21,8 @@
       "@json-render/mcp",
       "@json-render/svelte",
       "@json-render/solid",
-      "@json-render/react-three-fiber"
+      "@json-render/react-three-fiber",
+      "@json-render/yaml"
     ]
   ],
   "linked": [],

+ 1 - 0
README.md

@@ -135,6 +135,7 @@ function Dashboard({ spec }) {
 | `@json-render/jotai`        | Jotai adapter for `StateStore`                                         |
 | `@json-render/xstate`       | XState Store (atom) adapter for `StateStore`                           |
 | `@json-render/mcp`          | MCP Apps integration for Claude, ChatGPT, Cursor, VS Code              |
+| `@json-render/yaml`         | YAML wire format with streaming parser, edit modes, AI SDK transform   |
 
 ## Renderers
 

+ 93 - 3
apps/web/app/(main)/docs/api/core/page.mdx

@@ -76,6 +76,7 @@ interface PromptOptions {
   system?: string;           // Custom system message intro
   customRules?: string[];    // Additional rules to append
   mode?: "standalone" | "inline" | "generate" | "chat"; // Output mode (default: "standalone")
+  editModes?: EditMode[];    // Edit modes to document in prompt (default: ["patch"])
 }
 
 interface SpecValidationResult<T> {
@@ -480,9 +481,10 @@ function buildUserPrompt(options: UserPromptOptions): string
 
 interface UserPromptOptions {
   prompt: string;                        // The user's text prompt
-  currentSpec?: Spec | null;             // Existing spec to refine (triggers patch-only mode)
+  currentSpec?: Spec | null;             // Existing spec to refine (triggers edit mode)
   state?: Record<string, unknown> | null; // Runtime state context to include
   maxPromptLength?: number;              // Max length for user text (truncates before wrapping)
+  editModes?: EditMode[];                // Edit modes for refinement (default: ["patch"])
 }
 ```
 
@@ -492,14 +494,15 @@ interface UserPromptOptions {
 const userPrompt = buildUserPrompt({ prompt: "create a todo app" });
 ```
 
-### Refinement (patch-only mode)
+### Refinement (edit modes)
 
-When `currentSpec` is provided, the prompt instructs the AI to output only the patches needed for the change, not recreate the entire spec:
+When `currentSpec` is provided, the prompt instructs the AI to use the specified edit modes instead of recreating the entire spec. Available modes: `"patch"` (RFC 6902), `"merge"` (RFC 7396), and `"diff"` (unified diff).
 
 ```typescript
 const userPrompt = buildUserPrompt({
   prompt: "add a dark mode toggle",
   currentSpec: existingSpec,
+  editModes: ["patch", "merge"],
 });
 ```
 
@@ -514,6 +517,93 @@ const userPrompt = buildUserPrompt({
 });
 ```
 
+## Edit Modes
+
+Universal edit mode utilities for modifying existing specs. Used by `buildUserPrompt` internally and available for direct use.
+
+```typescript
+import {
+  buildEditInstructions,
+  buildEditUserPrompt,
+  isNonEmptySpec,
+  type EditMode,
+  type EditConfig,
+} from '@json-render/core';
+
+type EditMode = "patch" | "merge" | "diff";
+```
+
+### buildEditInstructions
+
+Generate the prompt section describing available edit modes. Supports both JSON and YAML formats.
+
+```typescript
+function buildEditInstructions(config: EditConfig, format: "json" | "yaml"): string
+
+const instructions = buildEditInstructions({ modes: ["patch", "merge"] }, "json");
+```
+
+### buildEditUserPrompt
+
+Build a user prompt for editing an existing spec. Includes the current spec (with line numbers when diff mode is enabled) and mode-specific instructions.
+
+```typescript
+function buildEditUserPrompt(options: BuildEditUserPromptOptions): string
+
+interface BuildEditUserPromptOptions {
+  prompt: string;
+  currentSpec?: Spec | null;
+  config?: EditConfig;
+  format: "json" | "yaml";
+  maxPromptLength?: number;
+  serializer?: (spec: Spec) => string;
+}
+```
+
+### isNonEmptySpec
+
+Check whether a value is a non-empty spec (has a root string and at least one element).
+
+```typescript
+function isNonEmptySpec(spec: unknown): spec is Spec
+```
+
+## Deep Merge and Diff
+
+Format-agnostic utilities for merging and diffing spec objects.
+
+### deepMergeSpec
+
+Deep-merge with RFC 7396 semantics: `null` deletes, arrays replace, objects recurse. Neither input is mutated.
+
+```typescript
+import { deepMergeSpec } from '@json-render/core';
+
+function deepMergeSpec(
+  base: Record<string, unknown>,
+  patch: Record<string, unknown>
+): Record<string, unknown>
+
+const merged = deepMergeSpec(currentSpec, { elements: { main: { props: { title: "New" } } } });
+```
+
+### diffToPatches
+
+Generate RFC 6902 JSON Patch operations that transform one object into another. Arrays are compared shallowly and replaced atomically; plain objects recurse.
+
+```typescript
+import { diffToPatches } from '@json-render/core';
+
+function diffToPatches(
+  oldObj: Record<string, unknown>,
+  newObj: Record<string, unknown>,
+  basePath?: string
+): JsonPatch[]
+
+const patches = diffToPatches(oldSpec, newSpec);
+// [{ op: "replace", path: "/elements/main/props/title", value: "New Title" }]
+```
+
 ## evaluateVisibility
 
 Evaluates a visibility condition against the state model.

+ 232 - 0
apps/web/app/(main)/docs/api/yaml/page.mdx

@@ -0,0 +1,232 @@
+import { pageMetadata } from "@/lib/page-metadata"
+export const metadata = pageMetadata("docs/api/yaml")
+
+# @json-render/yaml
+
+YAML wire format for json-render. Progressive rendering and surgical edits via streaming YAML.
+
+## Prompt Generation
+
+### yamlPrompt
+
+Generate a YAML-format system prompt from any json-render catalog. Works with catalogs from any renderer.
+
+```typescript
+function yamlPrompt(
+  catalog: Catalog,
+  options?: YamlPromptOptions
+): string
+```
+
+```typescript
+import { yamlPrompt } from "@json-render/yaml";
+
+const systemPrompt = yamlPrompt(catalog, {
+  mode: "standalone",
+  customRules: ["Always use dark theme"],
+  editModes: ["merge"],
+});
+```
+
+### YamlPromptOptions
+
+<table>
+  <thead>
+    <tr>
+      <th>Option</th>
+      <th>Type</th>
+      <th>Default</th>
+      <th>Description</th>
+    </tr>
+  </thead>
+  <tbody>
+    <tr>
+      <td><code>system</code></td>
+      <td><code>string</code></td>
+      <td><code>{'\"You are a UI generator that outputs YAML.\"'}</code></td>
+      <td>Custom system message intro</td>
+    </tr>
+    <tr>
+      <td><code>mode</code></td>
+      <td><code>{'\"standalone\" | \"inline\"'}</code></td>
+      <td><code>{'\"standalone\"'}</code></td>
+      <td>Standalone outputs only YAML; inline allows conversational responses with embedded YAML fences</td>
+    </tr>
+    <tr>
+      <td><code>customRules</code></td>
+      <td><code>{'string[]'}</code></td>
+      <td><code>{'[]'}</code></td>
+      <td>Additional rules appended to the prompt</td>
+    </tr>
+    <tr>
+      <td><code>editModes</code></td>
+      <td><code>{'EditMode[]'}</code></td>
+      <td><code>{'[\"merge\"]'}</code></td>
+      <td>Edit modes to document in the prompt (patch, merge, diff)</td>
+    </tr>
+  </tbody>
+</table>
+
+## AI SDK Transform
+
+### createYamlTransform
+
+Creates a `TransformStream` that intercepts AI SDK stream chunks and converts YAML spec/edit blocks into json-render patch data parts.
+
+```typescript
+function createYamlTransform(
+  options?: YamlTransformOptions
+): TransformStream<StreamChunk, StreamChunk>
+```
+
+Recognized fence types:
+
+- <code>{'```yaml-spec'}</code> -- Full YAML spec, parsed progressively
+- <code>{'```yaml-edit'}</code> -- Partial YAML, deep-merged with current spec
+- <code>{'```yaml-patch'}</code> -- RFC 6902 JSON Patch lines
+- <code>{'```diff'}</code> -- Unified diff against serialized spec
+
+### pipeYamlRender
+
+Convenience wrapper that pipes an AI SDK stream through the YAML transform. Drop-in replacement for `pipeJsonRender` from `@json-render/core`.
+
+```typescript
+function pipeYamlRender<T>(
+  stream: ReadableStream<T>,
+  options?: YamlTransformOptions
+): ReadableStream<T>
+```
+
+```typescript
+import { pipeYamlRender } from "@json-render/yaml";
+import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
+
+const stream = createUIMessageStream({
+  execute: async ({ writer }) => {
+    writer.merge(pipeYamlRender(result.toUIMessageStream()));
+  },
+});
+return createUIMessageStreamResponse({ stream });
+```
+
+## Streaming Parser
+
+### createYamlStreamCompiler
+
+Create a streaming YAML compiler that incrementally parses YAML text and emits JSON Patch operations by diffing each successful parse against the previous snapshot.
+
+```typescript
+function createYamlStreamCompiler<T>(
+  initial?: Partial<T>
+): YamlStreamCompiler<T>
+```
+
+```typescript
+import { createYamlStreamCompiler } from "@json-render/yaml";
+
+const compiler = createYamlStreamCompiler<Spec>();
+
+compiler.push("root: main\n");
+compiler.push("elements:\n  main:\n    type: Card\n");
+
+const { result, newPatches } = compiler.flush();
+```
+
+### YamlStreamCompiler
+
+<table>
+  <thead>
+    <tr>
+      <th>Method</th>
+      <th>Returns</th>
+      <th>Description</th>
+    </tr>
+  </thead>
+  <tbody>
+    <tr>
+      <td><code>push(chunk)</code></td>
+      <td><code>{'{ result: T; newPatches: JsonPatch[] }'}</code></td>
+      <td>Push a chunk of text, returns current result and new patches</td>
+    </tr>
+    <tr>
+      <td><code>flush()</code></td>
+      <td><code>{'{ result: T; newPatches: JsonPatch[] }'}</code></td>
+      <td>Flush remaining buffer, return final result</td>
+    </tr>
+    <tr>
+      <td><code>getResult()</code></td>
+      <td><code>T</code></td>
+      <td>Get the current compiled result</td>
+    </tr>
+    <tr>
+      <td><code>getPatches()</code></td>
+      <td><code>{'JsonPatch[]'}</code></td>
+      <td>Get all patches applied so far</td>
+    </tr>
+    <tr>
+      <td><code>reset(initial?)</code></td>
+      <td><code>void</code></td>
+      <td>Reset to initial state</td>
+    </tr>
+  </tbody>
+</table>
+
+## Fence Constants
+
+Exported string constants for fence detection in custom parsers:
+
+<table>
+  <thead>
+    <tr>
+      <th>Constant</th>
+      <th>Value</th>
+    </tr>
+  </thead>
+  <tbody>
+    <tr>
+      <td><code>YAML_SPEC_FENCE</code></td>
+      <td><code>{'\"```yaml-spec\"'}</code></td>
+    </tr>
+    <tr>
+      <td><code>YAML_EDIT_FENCE</code></td>
+      <td><code>{'\"```yaml-edit\"'}</code></td>
+    </tr>
+    <tr>
+      <td><code>YAML_PATCH_FENCE</code></td>
+      <td><code>{'\"```yaml-patch\"'}</code></td>
+    </tr>
+    <tr>
+      <td><code>DIFF_FENCE</code></td>
+      <td><code>{'\"```diff\"'}</code></td>
+    </tr>
+    <tr>
+      <td><code>FENCE_CLOSE</code></td>
+      <td><code>{'\"```\"'}</code></td>
+    </tr>
+  </tbody>
+</table>
+
+## Re-exports from @json-render/core
+
+### diffToPatches
+
+Generate RFC 6902 JSON Patch operations that transform one object into another.
+
+```typescript
+function diffToPatches(
+  oldObj: Record<string, unknown>,
+  newObj: Record<string, unknown>,
+  basePath?: string
+): JsonPatch[]
+```
+
+### deepMergeSpec
+
+Deep-merge with RFC 7396 semantics: `null` deletes, arrays replace, objects recurse.
+
+```typescript
+function deepMergeSpec(
+  base: Record<string, unknown>,
+  patch: Record<string, unknown>
+): Record<string, unknown>
+```

+ 2 - 2
apps/web/app/api/docs-chat/route.ts

@@ -16,8 +16,8 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
 
 GitHub repository: https://github.com/vercel-labs/json-render
 Documentation: https://json-render.dev/docs
-npm packages: @json-render/core, @json-render/react, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate
-Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, react-pdf, react-email, react-native, shadcn, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate. See /docs/skills for details.
+npm packages: @json-render/core, @json-render/react, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
+Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, react-pdf, react-email, react-native, shadcn, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
 
 You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
 

+ 78 - 24
apps/web/app/api/generate/route.ts

@@ -1,32 +1,73 @@
 import { streamText } from "ai";
 import { headers } from "next/headers";
-import { buildUserPrompt } from "@json-render/core";
+import type { Spec, EditMode } from "@json-render/core";
+import {
+  buildUserPrompt,
+  buildEditUserPrompt,
+  isNonEmptySpec,
+} from "@json-render/core";
+import { yamlPrompt } from "@json-render/yaml";
+import { stringify as yamlStringify } from "yaml";
 import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
 import { playgroundCatalog } from "@/lib/render/catalog";
 
 export const maxDuration = 30;
 
-const SYSTEM_PROMPT = playgroundCatalog.prompt({
-  customRules: [
-    "NEVER use viewport height classes (min-h-screen, h-screen) - the UI renders inside a fixed-size container.",
-    "NEVER use page background colors (bg-gray-50) - the container has its own background.",
-    "For forms or small UIs: use Card as root with maxWidth:'sm' or 'md' and centered:true.",
-    "For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts.",
-    "Wrap each repeated item in a Card for visual separation and structure.",
-    "Use realistic, professional sample data. Include 3-5 items with varied content. Never leave state arrays empty.",
-    'For form inputs (Input, Textarea, Select), always include checks for validation (e.g. required, email, minLength). Always pair checks with a $bindState expression on the value prop (e.g. { "$bindState": "/path" }).',
-  ],
-});
+const PLAYGROUND_RULES = [
+  "NEVER use viewport height classes (min-h-screen, h-screen) - the UI renders inside a fixed-size container.",
+  "NEVER use page background colors (bg-gray-50) - the container has its own background.",
+  "For forms or small UIs: use Card as root with maxWidth:'sm' or 'md' and centered:true.",
+  "For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts.",
+  "Wrap each repeated item in a Card for visual separation and structure.",
+  "Use realistic, professional sample data. Include 3-5 items with varied content. Never leave state arrays empty.",
+  'For form inputs (Input, Textarea, Select), always include checks for validation (e.g. required, email, minLength). Always pair checks with a $bindState expression on the value prop (e.g. { "$bindState": "/path" }).',
+];
 
 const MAX_PROMPT_LENGTH = 500;
 const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
 
+function getSystemPrompt(isYaml: boolean, editModes?: EditMode[]): string {
+  if (isYaml) {
+    return yamlPrompt(playgroundCatalog, {
+      mode: "standalone",
+      customRules: PLAYGROUND_RULES,
+      editModes: editModes ?? ["merge"],
+    });
+  }
+  return playgroundCatalog.prompt({
+    customRules: PLAYGROUND_RULES,
+    editModes,
+  });
+}
+
+function buildYamlUserPrompt(
+  prompt: string,
+  previousSpec?: Spec | null,
+  editModes?: EditMode[],
+): string {
+  if (isNonEmptySpec(previousSpec)) {
+    return buildEditUserPrompt({
+      prompt,
+      currentSpec: previousSpec,
+      config: { modes: editModes ?? ["merge"] },
+      format: "yaml",
+      maxPromptLength: MAX_PROMPT_LENGTH,
+      serializer: (s) => yamlStringify(s, { indent: 2 }).trimEnd(),
+    });
+  }
+
+  const userText = prompt.slice(0, MAX_PROMPT_LENGTH);
+  return [
+    userText,
+    "",
+    "Output the full spec in a ```yaml-spec fence. Stream progressively — output elements one at a time.",
+  ].join("\n");
+}
+
 export async function POST(req: Request) {
-  // Get client IP for rate limiting
   const headersList = await headers();
   const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
 
-  // Check rate limits (minute and daily)
   const [minuteResult, dailyResult] = await Promise.all([
     minuteRateLimit.limit(ip),
     dailyRateLimit.limit(ip),
@@ -48,22 +89,34 @@ export async function POST(req: Request) {
     );
   }
 
-  const { prompt, context } = await req.json();
+  const { prompt, context, format, editModes } = await req.json();
+  const isYaml = format === "yaml";
 
-  const userPrompt = buildUserPrompt({
-    prompt,
-    currentSpec: context?.previousSpec,
-    maxPromptLength: MAX_PROMPT_LENGTH,
-  });
+  const systemPrompt = getSystemPrompt(isYaml, editModes);
+  const userPrompt = isYaml
+    ? buildYamlUserPrompt(prompt, context?.previousSpec, editModes)
+    : buildUserPrompt({
+        prompt,
+        currentSpec: context?.previousSpec,
+        maxPromptLength: MAX_PROMPT_LENGTH,
+        editModes,
+      });
 
   const result = streamText({
     model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
-    system: SYSTEM_PROMPT,
+    system: [
+      {
+        role: "system",
+        content: systemPrompt,
+        providerOptions: {
+          anthropic: { cacheControl: { type: "ephemeral" } },
+        },
+      },
+    ],
     prompt: userPrompt,
     temperature: 0.7,
   });
 
-  // Stream the text, then append token usage metadata at the end
   const encoder = new TextEncoder();
   const textStream = result.textStream;
 
@@ -72,7 +125,6 @@ export async function POST(req: Request) {
       for await (const chunk of textStream) {
         controller.enqueue(encoder.encode(chunk));
       }
-      // Append usage metadata after stream completes
       try {
         const usage = await result.usage;
         const meta = JSON.stringify({
@@ -80,10 +132,12 @@ export async function POST(req: Request) {
           promptTokens: usage.inputTokens,
           completionTokens: usage.outputTokens,
           totalTokens: usage.totalTokens,
+          cachedTokens: usage.inputTokenDetails?.cacheReadTokens ?? 0,
+          cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens ?? 0,
         });
         controller.enqueue(encoder.encode(`\n${meta}\n`));
       } catch {
-        // Usage not available — skip silently
+        // Usage not available
       }
       controller.close();
     },

+ 2 - 2
apps/web/components/code-block.tsx

@@ -145,7 +145,7 @@ function getHighlighter() {
   if (!highlighterPromise) {
     highlighterPromise = createHighlighter({
       themes: [vercelLightTheme, vercelDarkTheme],
-      langs: ["json", "tsx", "typescript"],
+      langs: ["json", "tsx", "typescript", "yaml"],
     });
   }
   return highlighterPromise;
@@ -158,7 +158,7 @@ if (typeof window !== "undefined") {
 
 interface CodeBlockProps {
   code: string;
-  lang: "json" | "tsx" | "typescript";
+  lang: "json" | "tsx" | "typescript" | "yaml";
   fillHeight?: boolean;
   hideCopyButton?: boolean;
 }

+ 174 - 56
apps/web/components/playground.tsx

@@ -2,10 +2,16 @@
 
 import { useEffect, useState, useCallback, useRef, useMemo } from "react";
 import { flushSync } from "react-dom";
-import { useUIStream, type TokenUsage } from "@json-render/react";
 import type { Spec } from "@json-render/core";
 import { collectUsedComponents, serializeProps } from "@json-render/codegen";
 import { toast } from "sonner";
+import { stringify as yamlStringify } from "yaml";
+import type { EditMode } from "@json-render/core";
+import {
+  usePlaygroundStream,
+  type StreamFormat,
+  type TokenUsage,
+} from "@/lib/use-playground-stream";
 import {
   ResizablePanelGroup,
   ResizablePanel,
@@ -22,10 +28,10 @@ import { PlaygroundRenderer } from "@/lib/render/renderer";
 import { playgroundCatalog } from "@/lib/render/catalog";
 import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
 
-type Tab = "json" | "nested" | "stream" | "catalog" | "visual";
+type Tab = "spec" | "nested" | "stream" | "catalog" | "visual";
 type RenderView = "preview" | "code";
 type MobileView =
-  | "json"
+  | "spec"
   | "nested"
   | "stream"
   | "catalog"
@@ -40,6 +46,79 @@ interface Version {
   status: "generating" | "complete" | "error";
   usage: TokenUsage | null;
   rawLines: string[];
+  format: StreamFormat;
+}
+
+function formatTokens(n: number): string {
+  if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
+  return String(n);
+}
+
+function PlaygroundControls({
+  format,
+  setFormat,
+  editModes,
+  setEditModes,
+  showClear,
+  onClear,
+}: {
+  format: StreamFormat;
+  setFormat: (f: StreamFormat) => void;
+  editModes: EditMode[];
+  setEditModes: React.Dispatch<React.SetStateAction<EditMode[]>>;
+  showClear: boolean;
+  onClear: () => void;
+}) {
+  return (
+    <div className="flex items-center gap-2">
+      <div className="flex items-center rounded border border-border text-[10px] font-mono overflow-hidden">
+        {(["jsonl", "yaml"] as const).map((f) => (
+          <button
+            key={f}
+            onClick={() => setFormat(f)}
+            className={`px-1.5 py-0.5 transition-colors ${
+              format === f
+                ? "bg-muted text-foreground"
+                : "text-muted-foreground hover:text-foreground"
+            }`}
+          >
+            {f}
+          </button>
+        ))}
+      </div>
+      <div className="flex items-center rounded border border-border text-[10px] font-mono overflow-hidden">
+        {(["patch", "merge", "diff"] as const).map((m) => (
+          <button
+            key={m}
+            onClick={() => {
+              setEditModes((prev) =>
+                prev.includes(m)
+                  ? prev.length > 1
+                    ? prev.filter((x) => x !== m)
+                    : prev
+                  : [...prev, m],
+              );
+            }}
+            className={`px-1.5 py-0.5 transition-colors ${
+              editModes.includes(m)
+                ? "bg-muted text-foreground"
+                : "text-muted-foreground hover:text-foreground"
+            }`}
+          >
+            {m}
+          </button>
+        ))}
+      </div>
+      {showClear && (
+        <button
+          onClick={onClear}
+          className="text-xs text-muted-foreground hover:text-foreground transition-colors"
+        >
+          Clear
+        </button>
+      )}
+    </div>
+  );
 }
 
 /**
@@ -100,13 +179,15 @@ export function Playground() {
     null,
   );
   const [inputValue, setInputValue] = useState("");
-  const [activeTab, setActiveTab] = useState<Tab>("json");
+  const [activeTab, setActiveTab] = useState<Tab>("spec");
   const [catalogSection, setCatalogSection] = useState<
     "components" | "actions"
   >("components");
   const [renderView, setRenderView] = useState<RenderView>("preview");
   const [mobileView, setMobileView] = useState<MobileView>("preview");
   const [versionsSheetOpen, setVersionsSheetOpen] = useState(false);
+  const [format, setFormat] = useState<StreamFormat>("jsonl");
+  const [editModes, setEditModes] = useState<EditMode[]>(["patch"]);
   const inputRef = useRef<HTMLTextAreaElement>(null);
   const mobileInputRef = useRef<HTMLTextAreaElement>(null);
   const versionsEndRef = useRef<HTMLDivElement>(null);
@@ -124,12 +205,13 @@ export function Playground() {
     rawLines: streamRawLines,
     send,
     clear,
-  } = useUIStream({
+  } = usePlaygroundStream({
     api: "/api/generate",
+    format,
+    editModes,
     onError: (err: Error) => {
       console.error("Generation error:", err);
       toast.error(err.message || "Generation failed. Please try again.");
-      // Mark the version as errored
       if (generatingVersionIdRef.current) {
         const erroredVersionId = generatingVersionIdRef.current;
         setVersions((prev) =>
@@ -140,7 +222,7 @@ export function Playground() {
         generatingVersionIdRef.current = null;
       }
     },
-  } as Parameters<typeof useUIStream>[0]);
+  });
 
   // Get the selected version
   const selectedVersion = versions.find((v) => v.id === selectedVersionId);
@@ -215,6 +297,7 @@ export function Playground() {
       status: "generating",
       usage: null,
       rawLines: [],
+      format,
     };
 
     generatingVersionIdRef.current = newVersionId;
@@ -224,7 +307,7 @@ export function Playground() {
 
     // Pass the current tree as context so the API can iterate on it
     await send(inputValue.trim(), { previousSpec: currentTreeRef.current });
-  }, [inputValue, isStreaming, send]);
+  }, [inputValue, isStreaming, send, format]);
 
   const handleKeyDown = useCallback(
     (e: React.KeyboardEvent) => {
@@ -250,9 +333,16 @@ export function Playground() {
     [selectedVersionId, isStreaming],
   );
 
-  const jsonCode = currentTree
-    ? JSON.stringify(currentTree, null, 2)
-    : "// waiting...";
+  const specCode = useMemo(() => {
+    if (!currentTree)
+      return format === "yaml" ? "# waiting..." : "// waiting...";
+    if (format === "yaml") {
+      return yamlStringify(currentTree, { indent: 2 }).trimEnd();
+    }
+    return JSON.stringify(currentTree, null, 2);
+  }, [currentTree, format]);
+
+  const specLang = format === "yaml" ? "yaml" : "json";
 
   const nestedCode = useMemo(() => {
     if (!currentTree || !currentTree.root) return "// waiting...";
@@ -275,7 +365,7 @@ export function Playground() {
       const componentName = element.type;
 
       const propsObj: Record<string, unknown> = {};
-      for (const [k, v] of Object.entries(element.props)) {
+      for (const [k, v] of Object.entries(element.props ?? {})) {
         if (v !== null && v !== undefined) {
           propsObj[k] = v;
         }
@@ -321,6 +411,15 @@ ${jsx}
 }`;
   }, [currentTree]);
 
+  // Determine syntax lang for raw stream based on selected version's format
+  const streamLang = isSelectedVersionGenerating
+    ? format === "yaml"
+      ? "yaml"
+      : "json"
+    : selectedVersion?.format === "yaml"
+      ? "yaml"
+      : "json";
+
   // Chat pane content
   const chatPane = (
     <div className="h-full flex flex-col border-t border-border">
@@ -392,9 +491,15 @@ ${jsx}
                 )}
               </div>
               {version.usage && (
-                <div className="flex items-center gap-2 mt-1 ml-6">
+                <div className="mt-1 ml-6">
                   <span className="text-[10px] font-mono text-muted-foreground/60">
-                    {version.usage.totalTokens.toLocaleString()} tokens
+                    {formatTokens(
+                      version.usage.promptTokens - version.usage.cachedTokens,
+                    )}{" "}
+                    in · {formatTokens(version.usage.completionTokens)} out
+                    {version.usage.cachedTokens > 0
+                      ? ` · ${formatTokens(version.usage.cachedTokens)} cached`
+                      : ""}
                   </span>
                 </div>
               )}
@@ -425,20 +530,18 @@ ${jsx}
           autoFocus
         />
         <div className="flex justify-between items-center mt-2">
-          {versions.length > 0 ? (
-            <button
-              onClick={() => {
-                setVersions([]);
-                setSelectedVersionId(null);
-                clear();
-              }}
-              className="text-xs text-muted-foreground hover:text-foreground transition-colors"
-            >
-              Clear
-            </button>
-          ) : (
-            <div />
-          )}
+          <PlaygroundControls
+            format={format}
+            setFormat={setFormat}
+            editModes={editModes}
+            setEditModes={setEditModes}
+            showClear={versions.length > 0}
+            onClear={() => {
+              setVersions([]);
+              setSelectedVersionId(null);
+              clear();
+            }}
+          />
           {isStreaming ? (
             <button
               onClick={() => clear()}
@@ -492,18 +595,18 @@ ${jsx}
   const copyText =
     activeTab === "stream"
       ? currentRawLines.join("\n")
-      : activeTab === "json"
-        ? jsonCode
+      : activeTab === "spec"
+        ? specCode
         : activeTab === "nested"
           ? nestedCode
           : activeTab === "visual"
-            ? jsonCode
+            ? specCode
             : "";
 
   const codePane = (
     <div className="h-full flex flex-col border-t border-border">
       <div className="border-b border-border px-3 h-9 flex items-center gap-3">
-        {(["json", "visual", "nested", "stream", "catalog"] as const).map(
+        {(["spec", "visual", "nested", "stream", "catalog"] as const).map(
           (tab) => (
             <button
               key={tab}
@@ -514,7 +617,7 @@ ${jsx}
                   : "text-muted-foreground hover:text-foreground"
               }`}
             >
-              {tab}
+              {tab === "spec" ? (format === "yaml" ? "yaml" : "json") : tab}
             </button>
           ),
         )}
@@ -679,7 +782,7 @@ ${jsx}
           currentRawLines.length > 0 ? (
             <CodeBlock
               code={currentRawLines.join("\n")}
-              lang="json"
+              lang={streamLang}
               fillHeight
               hideCopyButton
             />
@@ -691,7 +794,12 @@ ${jsx}
         ) : activeTab === "nested" ? (
           <CodeBlock code={nestedCode} lang="json" fillHeight hideCopyButton />
         ) : (
-          <CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
+          <CodeBlock
+            code={specCode}
+            lang={specLang}
+            fillHeight
+            hideCopyButton
+          />
         )}
       </div>
     </div>
@@ -790,7 +898,7 @@ ${jsx}
               : 0}
           </button>
           {/* Code tabs */}
-          {(["json", "visual", "nested", "stream", "catalog"] as const).map(
+          {(["spec", "visual", "nested", "stream", "catalog"] as const).map(
             (tab) => (
               <button
                 key={tab}
@@ -801,7 +909,7 @@ ${jsx}
                     : "text-muted-foreground hover:text-foreground"
                 }`}
               >
-                {tab}
+                {tab === "spec" ? (format === "yaml" ? "yaml" : "json") : tab}
               </button>
             ),
           )}
@@ -982,7 +1090,7 @@ ${jsx}
             currentRawLines.length > 0 ? (
               <CodeBlock
                 code={currentRawLines.join("\n")}
-                lang="json"
+                lang={streamLang}
                 fillHeight
                 hideCopyButton
               />
@@ -998,8 +1106,13 @@ ${jsx}
               fillHeight
               hideCopyButton
             />
-          ) : mobileView === "json" ? (
-            <CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
+          ) : mobileView === "spec" ? (
+            <CodeBlock
+              code={specCode}
+              lang={specLang}
+              fillHeight
+              hideCopyButton
+            />
           ) : mobileView === "preview" ? (
             currentTree && currentTree.root ? (
               <div className="w-full min-h-full flex items-center justify-center p-6">
@@ -1075,20 +1188,18 @@ ${jsx}
             rows={2}
           />
           <div className="flex justify-between items-center mt-2">
-            {versions.length > 0 ? (
-              <button
-                onClick={() => {
-                  setVersions([]);
-                  setSelectedVersionId(null);
-                  clear();
-                }}
-                className="text-xs text-muted-foreground hover:text-foreground transition-colors"
-              >
-                Clear
-              </button>
-            ) : (
-              <div />
-            )}
+            <PlaygroundControls
+              format={format}
+              setFormat={setFormat}
+              editModes={editModes}
+              setEditModes={setEditModes}
+              showClear={versions.length > 0}
+              onClear={() => {
+                setVersions([]);
+                setSelectedVersionId(null);
+                clear();
+              }}
+            />
             {isStreaming ? (
               <button
                 onClick={() => clear()}
@@ -1165,9 +1276,16 @@ ${jsx}
                     )}
                   </div>
                   {version.usage && (
-                    <div className="flex items-center gap-2 mt-1 ml-6">
+                    <div className="mt-1 ml-6">
                       <span className="text-[10px] font-mono text-muted-foreground/60">
-                        {version.usage.totalTokens.toLocaleString()} tokens
+                        {formatTokens(
+                          version.usage.promptTokens -
+                            version.usage.cachedTokens,
+                        )}{" "}
+                        in · {formatTokens(version.usage.completionTokens)} out
+                        {version.usage.cachedTokens > 0
+                          ? ` · ${formatTokens(version.usage.cachedTokens)} cached`
+                          : ""}
                       </span>
                     </div>
                   )}

+ 1 - 0
apps/web/lib/docs-navigation.ts

@@ -144,6 +144,7 @@ export const docsNavigation: NavSection[] = [
       { title: "@json-render/zustand", href: "/docs/api/zustand" },
       { title: "@json-render/jotai", href: "/docs/api/jotai" },
       { title: "@json-render/xstate", href: "/docs/api/xstate" },
+      { title: "@json-render/yaml", href: "/docs/api/yaml" },
     ],
   },
 ];

+ 1 - 0
apps/web/lib/page-titles.ts

@@ -58,6 +58,7 @@ export const PAGE_TITLES: Record<string, string> = {
   "docs/api/jotai": "@json-render/jotai API",
   "docs/api/react-three-fiber": "@json-render/react-three-fiber API",
   "docs/api/xstate": "@json-render/xstate API",
+  "docs/api/yaml": "@json-render/yaml API",
 };
 
 /**

+ 155 - 0
apps/web/lib/spec-patch.ts

@@ -0,0 +1,155 @@
+import type { Spec, JsonPatch } from "@json-render/core";
+import { setByPath, getByPath, removeByPath } from "@json-render/core";
+
+export function setSpecValue(
+  newSpec: Spec,
+  path: string,
+  value: unknown,
+): void {
+  if (path === "/root") {
+    newSpec.root = value as string;
+    return;
+  }
+  if (path === "/state") {
+    newSpec.state = value as Record<string, unknown>;
+    return;
+  }
+  if (path.startsWith("/state/")) {
+    if (!newSpec.state) newSpec.state = {};
+    setByPath(
+      newSpec.state as Record<string, unknown>,
+      path.slice("/state".length),
+      value,
+    );
+    return;
+  }
+  if (path.startsWith("/elements/")) {
+    const pathParts = path.slice("/elements/".length).split("/");
+    const elementKey = pathParts[0];
+    if (!elementKey) return;
+    if (pathParts.length === 1) {
+      if (value == null || typeof value !== "object") return;
+      const el = value as Record<string, unknown>;
+      newSpec.elements[elementKey] = {
+        ...el,
+        type: typeof el.type === "string" ? el.type : "",
+        props: el.props != null && typeof el.props === "object" ? el.props : {},
+        children: Array.isArray(el.children) ? el.children : [],
+      } as Spec["elements"][string];
+    } else {
+      const element = newSpec.elements[elementKey];
+      if (element) {
+        const newElement = { ...element };
+        setByPath(
+          newElement as unknown as Record<string, unknown>,
+          "/" + pathParts.slice(1).join("/"),
+          value,
+        );
+        newSpec.elements[elementKey] = newElement;
+      }
+    }
+  }
+}
+
+export function removeSpecValue(newSpec: Spec, path: string): void {
+  if (path === "/state") {
+    delete newSpec.state;
+    return;
+  }
+  if (path.startsWith("/state/") && newSpec.state) {
+    removeByPath(
+      newSpec.state as Record<string, unknown>,
+      path.slice("/state".length),
+    );
+    return;
+  }
+  if (path.startsWith("/elements/")) {
+    const pathParts = path.slice("/elements/".length).split("/");
+    const elementKey = pathParts[0];
+    if (!elementKey) return;
+    if (pathParts.length === 1) {
+      delete newSpec.elements[elementKey];
+    } else {
+      const element = newSpec.elements[elementKey];
+      if (element) {
+        const newElement = { ...element };
+        removeByPath(
+          newElement as unknown as Record<string, unknown>,
+          "/" + pathParts.slice(1).join("/"),
+        );
+        newSpec.elements[elementKey] = newElement;
+      }
+    }
+  }
+}
+
+export function getSpecValue(spec: Spec, path: string): unknown {
+  if (path === "/root") return spec.root;
+  if (path === "/state") return spec.state;
+  if (path.startsWith("/state/") && spec.state) {
+    return getByPath(
+      spec.state as Record<string, unknown>,
+      path.slice("/state".length),
+    );
+  }
+  return getByPath(spec as unknown as Record<string, unknown>, path);
+}
+
+export function normalizeSpec(spec: Spec): void {
+  if (
+    spec.state === null ||
+    (spec.state !== undefined && typeof spec.state !== "object")
+  ) {
+    spec.state = undefined;
+  }
+
+  for (const key of Object.keys(spec.elements)) {
+    const el = spec.elements[key];
+    if (!el || typeof el !== "object") {
+      delete spec.elements[key];
+      continue;
+    }
+    if (el.props == null || typeof el.props !== "object") {
+      spec.elements[key] = { ...el, props: {} } as Spec["elements"][string];
+    }
+    if (!Array.isArray(spec.elements[key]!.children)) {
+      spec.elements[key] = {
+        ...spec.elements[key]!,
+        children: [],
+      } as Spec["elements"][string];
+    }
+  }
+}
+
+export function applySpecPatch(spec: Spec, patch: JsonPatch): Spec {
+  const newSpec = {
+    ...spec,
+    elements: { ...spec.elements },
+    ...(spec.state ? { state: { ...spec.state } } : {}),
+  };
+  switch (patch.op) {
+    case "add":
+    case "replace":
+      setSpecValue(newSpec, patch.path, patch.value);
+      break;
+    case "remove":
+      removeSpecValue(newSpec, patch.path);
+      break;
+    case "move":
+      if (patch.from) {
+        const moveValue = getSpecValue(newSpec, patch.from);
+        removeSpecValue(newSpec, patch.from);
+        setSpecValue(newSpec, patch.path, moveValue);
+      }
+      break;
+    case "copy":
+      if (patch.from) {
+        setSpecValue(newSpec, patch.path, getSpecValue(newSpec, patch.from));
+      }
+      break;
+    case "test":
+      break;
+  }
+  normalizeSpec(newSpec);
+  return newSpec;
+}

+ 482 - 0
apps/web/lib/use-playground-stream.ts

@@ -0,0 +1,482 @@
+"use client";
+
+import { useState, useCallback, useRef, useEffect } from "react";
+import type { Spec, JsonPatch, EditMode } from "@json-render/core";
+import { deepMergeSpec, diffToPatches } from "@json-render/core";
+import { parse as yamlParse, stringify as yamlStringify } from "yaml";
+import { applyPatch as applyUnifiedDiff } from "diff";
+import {
+  createYamlStreamCompiler,
+  YAML_SPEC_FENCE,
+  YAML_EDIT_FENCE,
+  YAML_PATCH_FENCE,
+  DIFF_FENCE,
+  FENCE_CLOSE,
+} from "@json-render/yaml";
+import { applySpecPatch } from "./spec-patch";
+
+export type StreamFormat = "jsonl" | "yaml";
+
+export interface TokenUsage {
+  promptTokens: number;
+  completionTokens: number;
+  totalTokens: number;
+  cachedTokens: number;
+  cacheWriteTokens: number;
+}
+
+export interface UsePlaygroundStreamOptions {
+  api: string;
+  format: StreamFormat;
+  editModes?: EditMode[];
+  onError?: (error: Error) => void;
+  onComplete?: (spec: Spec) => void;
+}
+
+export interface UsePlaygroundStreamReturn {
+  spec: Spec | null;
+  isStreaming: boolean;
+  error: Error | null;
+  usage: TokenUsage | null;
+  rawLines: string[];
+  send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
+  clear: () => void;
+}
+
+// ── JSONL helpers ──
+
+type ParsedLine =
+  | { type: "patch"; patch: JsonPatch }
+  | { type: "usage"; usage: TokenUsage }
+  | { type: "json-edit"; mergeObj: Record<string, unknown> }
+  | null;
+
+function parseLine(line: string): ParsedLine {
+  try {
+    const trimmed = line.trim();
+    if (!trimmed || trimmed.startsWith("//")) return null;
+    const parsed = JSON.parse(trimmed);
+    if (parsed.__meta === "usage") {
+      return {
+        type: "usage",
+        usage: {
+          promptTokens: parsed.promptTokens ?? 0,
+          completionTokens: parsed.completionTokens ?? 0,
+          totalTokens: parsed.totalTokens ?? 0,
+          cachedTokens: parsed.cachedTokens ?? 0,
+          cacheWriteTokens: parsed.cacheWriteTokens ?? 0,
+        },
+      };
+    }
+    if (parsed.__json_edit === true) {
+      const mergeObj = { ...parsed };
+      delete mergeObj.__json_edit;
+      return { type: "json-edit", mergeObj };
+    }
+    return { type: "patch", patch: parsed as JsonPatch };
+  } catch {
+    return null;
+  }
+}
+
+type FenceState = "outside" | "yaml-spec" | "yaml-edit" | "yaml-patch" | "diff";
+
+// ── Hook ──
+
+export function usePlaygroundStream({
+  api,
+  format,
+  editModes,
+  onError,
+  onComplete,
+}: UsePlaygroundStreamOptions): UsePlaygroundStreamReturn {
+  const [spec, setSpec] = useState<Spec | null>(null);
+  const [isStreaming, setIsStreaming] = useState(false);
+  const [error, setError] = useState<Error | null>(null);
+  const [usage, setUsage] = useState<TokenUsage | null>(null);
+  const [rawLines, setRawLines] = useState<string[]>([]);
+  const rawLinesRef = useRef<string[]>([]);
+  const abortControllerRef = useRef<AbortController | null>(null);
+
+  const onCompleteRef = useRef(onComplete);
+  onCompleteRef.current = onComplete;
+  const onErrorRef = useRef(onError);
+  onErrorRef.current = onError;
+  const formatRef = useRef(format);
+  formatRef.current = format;
+  const editModesRef = useRef(editModes);
+  editModesRef.current = editModes;
+
+  const clear = useCallback(() => {
+    setSpec(null);
+    setError(null);
+  }, []);
+
+  const send = useCallback(
+    async (prompt: string, context?: Record<string, unknown>) => {
+      abortControllerRef.current = new AbortController();
+
+      setIsStreaming(true);
+      setError(null);
+      setUsage(null);
+      rawLinesRef.current = [];
+      setRawLines([]);
+
+      const previousSpec = context?.previousSpec as Spec | undefined;
+      let currentSpec: Spec =
+        previousSpec && previousSpec.root
+          ? { ...previousSpec, elements: { ...previousSpec.elements } }
+          : { root: "", elements: {} };
+      setSpec(currentSpec);
+
+      try {
+        const response = await fetch(api, {
+          method: "POST",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({
+            prompt,
+            context,
+            format: formatRef.current,
+            editModes: editModesRef.current,
+          }),
+          signal: abortControllerRef.current.signal,
+        });
+
+        if (!response.ok) {
+          let errorMessage = `HTTP error: ${response.status}`;
+          try {
+            const errorData = await response.json();
+            if (errorData.message) errorMessage = errorData.message;
+            else if (errorData.error) errorMessage = errorData.error;
+          } catch {
+            // use default
+          }
+          throw new Error(errorMessage);
+        }
+
+        const reader = response.body?.getReader();
+        if (!reader) throw new Error("No response body");
+
+        const decoder = new TextDecoder();
+        let buffer = "";
+
+        if (formatRef.current === "yaml") {
+          // ── YAML streaming ──
+          let fenceState: FenceState = "outside";
+          const compiler = createYamlStreamCompiler<Record<string, unknown>>();
+          let yamlEditAccumulated = "";
+          let yamlPatchAccumulated = "";
+          let diffAccumulated = "";
+
+          while (true) {
+            const { done, value } = await reader.read();
+            if (done) break;
+
+            buffer += decoder.decode(value, { stream: true });
+            const lines = buffer.split("\n");
+            buffer = lines.pop() ?? "";
+
+            for (const line of lines) {
+              const trimmed = line.trim();
+
+              // Check for usage metadata (appended after stream)
+              if (trimmed.startsWith("{") && trimmed.includes('"__meta"')) {
+                try {
+                  const parsed = JSON.parse(trimmed);
+                  if (parsed.__meta === "usage") {
+                    setUsage({
+                      promptTokens: parsed.promptTokens ?? 0,
+                      completionTokens: parsed.completionTokens ?? 0,
+                      totalTokens: parsed.totalTokens ?? 0,
+                      cachedTokens: parsed.cachedTokens ?? 0,
+                      cacheWriteTokens: parsed.cacheWriteTokens ?? 0,
+                    });
+                    continue;
+                  }
+                } catch {
+                  // not JSON
+                }
+              }
+
+              rawLinesRef.current.push(line);
+
+              if (fenceState === "outside") {
+                if (
+                  trimmed === YAML_SPEC_FENCE ||
+                  trimmed.startsWith(YAML_SPEC_FENCE + " ")
+                ) {
+                  fenceState = "yaml-spec";
+                  compiler.reset();
+                } else if (
+                  trimmed === YAML_EDIT_FENCE ||
+                  trimmed.startsWith(YAML_EDIT_FENCE + " ")
+                ) {
+                  fenceState = "yaml-edit";
+                  yamlEditAccumulated = "";
+                } else if (
+                  trimmed === YAML_PATCH_FENCE ||
+                  trimmed.startsWith(YAML_PATCH_FENCE + " ")
+                ) {
+                  fenceState = "yaml-patch";
+                  yamlPatchAccumulated = "";
+                } else if (
+                  trimmed === DIFF_FENCE ||
+                  trimmed.startsWith(DIFF_FENCE + " ")
+                ) {
+                  fenceState = "diff";
+                  diffAccumulated = "";
+                }
+              } else if (trimmed === FENCE_CLOSE || trimmed === "````") {
+                if (fenceState === "yaml-spec") {
+                  const { result, newPatches } = compiler.flush();
+                  if (result && typeof result === "object" && result.root) {
+                    for (const patch of newPatches) {
+                      currentSpec = applySpecPatch(currentSpec, patch);
+                    }
+                    setSpec({ ...currentSpec });
+                  }
+                } else if (fenceState === "yaml-edit") {
+                  try {
+                    const editObj = yamlParse(yamlEditAccumulated);
+                    if (editObj && typeof editObj === "object") {
+                      const merged = deepMergeSpec(
+                        currentSpec as unknown as Record<string, unknown>,
+                        editObj as Record<string, unknown>,
+                      );
+                      const patches = diffToPatches(
+                        currentSpec as unknown as Record<string, unknown>,
+                        merged,
+                      );
+                      for (const patch of patches) {
+                        currentSpec = applySpecPatch(currentSpec, patch);
+                      }
+                      setSpec({ ...currentSpec });
+                    }
+                  } catch {
+                    // Invalid YAML edit
+                  }
+                } else if (fenceState === "yaml-patch") {
+                  for (const patchLine of yamlPatchAccumulated.split("\n")) {
+                    const t = patchLine.trim();
+                    if (!t) continue;
+                    try {
+                      const patch = JSON.parse(t) as JsonPatch;
+                      if (patch.op) {
+                        currentSpec = applySpecPatch(currentSpec, patch);
+                      }
+                    } catch {
+                      // Skip invalid JSON lines
+                    }
+                  }
+                  setSpec({ ...currentSpec });
+                } else if (fenceState === "diff") {
+                  try {
+                    const specYaml = yamlStringify(currentSpec, { indent: 2 });
+                    const patched = applyUnifiedDiff(specYaml, diffAccumulated);
+                    if (typeof patched === "string") {
+                      const parsed = yamlParse(patched);
+                      if (parsed && typeof parsed === "object") {
+                        const patches = diffToPatches(
+                          currentSpec as unknown as Record<string, unknown>,
+                          parsed as Record<string, unknown>,
+                        );
+                        for (const patch of patches) {
+                          currentSpec = applySpecPatch(currentSpec, patch);
+                        }
+                        setSpec({ ...currentSpec });
+                      }
+                    }
+                  } catch {
+                    // Diff apply or reparse failed
+                  }
+                }
+                fenceState = "outside";
+              } else if (fenceState === "yaml-spec") {
+                const { newPatches } = compiler.push(line + "\n");
+                if (newPatches.length > 0) {
+                  for (const patch of newPatches) {
+                    currentSpec = applySpecPatch(currentSpec, patch);
+                  }
+                  setSpec({ ...currentSpec });
+                }
+              } else if (fenceState === "yaml-edit") {
+                yamlEditAccumulated += line + "\n";
+              } else if (fenceState === "yaml-patch") {
+                yamlPatchAccumulated += line + "\n";
+              } else if (fenceState === "diff") {
+                diffAccumulated += line + "\n";
+              }
+            }
+            setRawLines([...rawLinesRef.current]);
+          }
+
+          // Process remaining buffer
+          if (buffer.trim()) {
+            const trimmed = buffer.trim();
+            if (trimmed.startsWith("{") && trimmed.includes('"__meta"')) {
+              try {
+                const parsed = JSON.parse(trimmed);
+                if (parsed.__meta === "usage") {
+                  setUsage({
+                    promptTokens: parsed.promptTokens ?? 0,
+                    completionTokens: parsed.completionTokens ?? 0,
+                    totalTokens: parsed.totalTokens ?? 0,
+                    cachedTokens: parsed.cachedTokens ?? 0,
+                    cacheWriteTokens: parsed.cacheWriteTokens ?? 0,
+                  });
+                }
+              } catch {
+                // not JSON
+              }
+            } else if (fenceState === "yaml-spec") {
+              compiler.push(buffer);
+              const { result, newPatches } = compiler.flush();
+              if (result && typeof result === "object" && result.root) {
+                for (const patch of newPatches) {
+                  currentSpec = applySpecPatch(currentSpec, patch);
+                }
+                setSpec({ ...currentSpec });
+              }
+            }
+          }
+        } else {
+          // ── JSONL streaming ──
+          let jsonlDiffState: "outside" | "diff" = "outside";
+          let jsonlDiffAccumulated = "";
+
+          while (true) {
+            const { done, value } = await reader.read();
+            if (done) break;
+
+            buffer += decoder.decode(value, { stream: true });
+            const lines = buffer.split("\n");
+            buffer = lines.pop() ?? "";
+
+            for (const line of lines) {
+              const trimmed = line.trim();
+              if (!trimmed) continue;
+
+              // Diff fence detection within JSONL mode
+              if (jsonlDiffState === "outside") {
+                if (
+                  trimmed === DIFF_FENCE ||
+                  trimmed.startsWith(DIFF_FENCE + " ")
+                ) {
+                  jsonlDiffState = "diff";
+                  jsonlDiffAccumulated = "";
+                  continue;
+                }
+              } else if (
+                jsonlDiffState === "diff" &&
+                (trimmed === FENCE_CLOSE || trimmed === "````")
+              ) {
+                try {
+                  const specJson = JSON.stringify(currentSpec, null, 2);
+                  const patched = applyUnifiedDiff(
+                    specJson,
+                    jsonlDiffAccumulated,
+                  );
+                  if (typeof patched === "string") {
+                    const parsed = JSON.parse(patched);
+                    if (parsed && typeof parsed === "object") {
+                      const patches = diffToPatches(
+                        currentSpec as unknown as Record<string, unknown>,
+                        parsed as Record<string, unknown>,
+                      );
+                      for (const patch of patches) {
+                        currentSpec = applySpecPatch(currentSpec, patch);
+                      }
+                      setSpec({ ...currentSpec });
+                    }
+                  }
+                } catch {
+                  // Diff apply failed
+                }
+                jsonlDiffState = "outside";
+                continue;
+              }
+
+              if (jsonlDiffState === "diff") {
+                jsonlDiffAccumulated += line + "\n";
+                rawLinesRef.current.push(line);
+                continue;
+              }
+
+              // Standard JSONL line parsing
+              const result = parseLine(trimmed);
+              if (!result) continue;
+              if (result.type === "usage") {
+                setUsage(result.usage);
+              } else if (result.type === "json-edit") {
+                const merged = deepMergeSpec(
+                  currentSpec as unknown as Record<string, unknown>,
+                  result.mergeObj,
+                );
+                const patches = diffToPatches(
+                  currentSpec as unknown as Record<string, unknown>,
+                  merged,
+                );
+                for (const patch of patches) {
+                  currentSpec = applySpecPatch(currentSpec, patch);
+                }
+                rawLinesRef.current.push(trimmed);
+                setSpec({ ...currentSpec });
+              } else {
+                rawLinesRef.current.push(trimmed);
+                currentSpec = applySpecPatch(currentSpec, result.patch);
+                setSpec({ ...currentSpec });
+              }
+            }
+            setRawLines([...rawLinesRef.current]);
+          }
+
+          if (buffer.trim()) {
+            const trimmed = buffer.trim();
+            const result = parseLine(trimmed);
+            if (result) {
+              if (result.type === "usage") {
+                setUsage(result.usage);
+              } else if (result.type === "json-edit") {
+                const merged = deepMergeSpec(
+                  currentSpec as unknown as Record<string, unknown>,
+                  result.mergeObj,
+                );
+                const patches = diffToPatches(
+                  currentSpec as unknown as Record<string, unknown>,
+                  merged,
+                );
+                for (const patch of patches) {
+                  currentSpec = applySpecPatch(currentSpec, patch);
+                }
+                rawLinesRef.current.push(trimmed);
+                setSpec({ ...currentSpec });
+              } else {
+                rawLinesRef.current.push(trimmed);
+                currentSpec = applySpecPatch(currentSpec, result.patch);
+                setSpec({ ...currentSpec });
+              }
+            }
+          }
+        }
+
+        onCompleteRef.current?.(currentSpec);
+      } catch (err) {
+        if ((err as Error).name === "AbortError") return;
+        const error = err instanceof Error ? err : new Error(String(err));
+        setError(error);
+        onErrorRef.current?.(error);
+      } finally {
+        setIsStreaming(false);
+      }
+    },
+    [api],
+  );
+
+  useEffect(() => {
+    return () => {
+      abortControllerRef.current?.abort();
+    };
+  }, []);
+
+  return { spec, isStreaming, error, usage, rawLines, send, clear };
+}

+ 3 - 1
apps/web/package.json

@@ -18,6 +18,7 @@
     "@json-render/codegen": "workspace:*",
     "@json-render/core": "workspace:*",
     "@json-render/react": "workspace:*",
+    "@json-render/yaml": "workspace:*",
     "@mdx-js/loader": "^3.1.1",
     "@mdx-js/mdx": "^3.1.1",
     "@mdx-js/react": "^3.1.1",
@@ -34,9 +35,9 @@
     "bash-tool": "1.3.14",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
+    "diff": "^8.0.3",
     "embla-carousel-react": "^8.6.0",
     "geist": "1.7.0",
-    "just-bash": "2.9.6",
     "lucide-react": "^0.562.0",
     "next": "16.1.1",
     "next-themes": "^0.4.6",
@@ -51,6 +52,7 @@
     "tailwind-merge": "^3.4.0",
     "unist-util-visit": "5.1.0",
     "vaul": "^1.1.2",
+    "yaml": "^2.8.2",
     "zod": "^4.3.6"
   },
   "devDependencies": {

+ 36 - 1
packages/core/README.md

@@ -210,7 +210,20 @@ Schema options:
 | Export | Purpose |
 |--------|---------|
 | `buildUserPrompt(options)` | Build a user prompt with optional spec refinement and state context |
+| `buildEditUserPrompt(options)` | Build a user prompt for editing an existing spec (used internally by `buildUserPrompt`) |
+| `buildEditInstructions(config, format)` | Generate the prompt section describing available edit modes |
+| `isNonEmptySpec(spec)` | Check whether a spec has a root and at least one element |
 | `UserPromptOptions` | Options type for `buildUserPrompt` |
+| `EditMode` | `"patch" \| "merge" \| "diff"` |
+| `EditConfig` | Configuration for edit modes (`{ modes: EditMode[] }`) |
+| `BuildEditUserPromptOptions` | Options type for `buildEditUserPrompt` |
+
+### Merge and Diff
+
+| Export | Purpose |
+|--------|---------|
+| `deepMergeSpec(base, patch)` | RFC 7396 deep merge (null deletes, arrays replace, objects recurse) |
+| `diffToPatches(oldObj, newObj)` | Generate RFC 6902 JSON Patch operations from object diff |
 
 ### Spec Validation
 
@@ -491,10 +504,11 @@ import { buildUserPrompt } from "@json-render/core";
 // Fresh generation
 const prompt = buildUserPrompt({ prompt: "create a todo app" });
 
-// Refinement with existing spec (triggers patch-only mode)
+// Refinement with edit modes (default: patch-only)
 const refinementPrompt = buildUserPrompt({
   prompt: "add a dark mode toggle",
   currentSpec: existingSpec,
+  editModes: ["patch", "merge"],
 });
 
 // With runtime state context
@@ -504,6 +518,27 @@ const contextPrompt = buildUserPrompt({
 });
 ```
 
+When `currentSpec` is provided, the prompt instructs the AI to use the specified edit modes. Available modes:
+
+- **`"patch"`** — RFC 6902 JSON Patch. One operation per line. Best for precise, targeted single-field updates.
+- **`"merge"`** — RFC 7396 JSON Merge Patch. Partial object deep-merged; `null` deletes. Best for structural changes.
+- **`"diff"`** — Unified diff against the serialized spec. Best for small text-level changes.
+
+## Deep Merge and Diff
+
+Format-agnostic utilities for working with specs:
+
+```typescript
+import { deepMergeSpec, diffToPatches } from "@json-render/core";
+
+// RFC 7396 deep merge: null deletes, arrays replace, objects recurse
+const merged = deepMergeSpec(baseSpec, { elements: { main: { props: { title: "New" } } } });
+
+// RFC 6902 diff: generate JSON Patch operations from two objects
+const patches = diffToPatches(oldSpec, newSpec);
+// [{ op: "replace", path: "/elements/main/props/title", value: "New" }]
+```
+
 ## Spec Validation
 
 Validate spec structure and auto-fix common issues:

+ 78 - 0
packages/core/src/diff.ts

@@ -0,0 +1,78 @@
+import type { JsonPatch } from "./types";
+
+/**
+ * Escape a single JSON Pointer token per RFC 6901.
+ * `~` → `~0`, `/` → `~1`.
+ */
+function escapeToken(token: string): string {
+  return token.replace(/~/g, "~0").replace(/\//g, "~1");
+}
+
+function buildPath(basePath: string, key: string): string {
+  return `${basePath}/${escapeToken(key)}`;
+}
+
+function isPlainObject(value: unknown): value is Record<string, unknown> {
+  return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+/**
+ * Shallow equality for arrays — used to avoid emitting patches when the
+ * children list hasn't actually changed.
+ */
+function arraysEqual(a: unknown[], b: unknown[]): boolean {
+  if (a.length !== b.length) return false;
+  for (let i = 0; i < a.length; i++) {
+    if (a[i] !== b[i]) return false;
+  }
+  return true;
+}
+
+/**
+ * Produce RFC 6902 JSON Patch operations that transform `oldObj` into `newObj`.
+ *
+ * - New keys → `add`
+ * - Changed scalar/array values → `replace`
+ * - Removed keys → `remove`
+ * - Arrays are compared shallowly and replaced atomically (not element-diffed)
+ * - Plain objects recurse
+ */
+export function diffToPatches(
+  oldObj: Record<string, unknown>,
+  newObj: Record<string, unknown>,
+  basePath = "",
+): JsonPatch[] {
+  const patches: JsonPatch[] = [];
+
+  // Keys present in newObj
+  for (const key of Object.keys(newObj)) {
+    const path = buildPath(basePath, key);
+    const oldVal = oldObj[key];
+    const newVal = newObj[key];
+
+    if (!(key in oldObj)) {
+      patches.push({ op: "add", path, value: newVal });
+      continue;
+    }
+
+    // Both exist — compare
+    if (isPlainObject(oldVal) && isPlainObject(newVal)) {
+      patches.push(...diffToPatches(oldVal, newVal, path));
+    } else if (Array.isArray(oldVal) && Array.isArray(newVal)) {
+      if (!arraysEqual(oldVal, newVal)) {
+        patches.push({ op: "replace", path, value: newVal });
+      }
+    } else if (oldVal !== newVal) {
+      patches.push({ op: "replace", path, value: newVal });
+    }
+  }
+
+  // Keys removed from oldObj
+  for (const key of Object.keys(oldObj)) {
+    if (!(key in newObj)) {
+      patches.push({ op: "remove", path: buildPath(basePath, key) });
+    }
+  }
+
+  return patches;
+}

+ 316 - 0
packages/core/src/edit-modes.ts

@@ -0,0 +1,316 @@
+import type { Spec } from "./types";
+
+/**
+ * Edit mode for modifying an existing spec.
+ *
+ * - `"patch"` — RFC 6902 JSON Patch. One operation per line.
+ * - `"merge"` — RFC 7396 JSON Merge Patch. Partial object deep-merged; `null` deletes.
+ * - `"diff"`  — Unified diff (POSIX). Line-level text edits against the serialized spec.
+ */
+export type EditMode = "patch" | "merge" | "diff";
+
+export interface EditConfig {
+  /** Which edit modes are enabled. When >1, the AI chooses per edit. */
+  modes: EditMode[];
+}
+
+const DEFAULT_MODES: EditMode[] = ["patch"];
+
+function normalizeModes(config?: EditConfig): EditMode[] {
+  if (!config?.modes?.length) return DEFAULT_MODES;
+  return config.modes;
+}
+
+// ── JSON-format instructions ──
+
+function jsonPatchInstructions(): string {
+  return [
+    "PATCH MODE (RFC 6902 JSON Patch):",
+    "Output one JSON object per line. Each line is a patch operation.",
+    '- Add: {"op":"add","path":"/elements/new-key","value":{...}}',
+    '- Replace: {"op":"replace","path":"/elements/existing-key","value":{...}}',
+    '- Remove: {"op":"remove","path":"/elements/old-key"}',
+    "Only output patches for what needs to change.",
+  ].join("\n");
+}
+
+function jsonMergeInstructions(): string {
+  return [
+    "MERGE MODE (RFC 7396 JSON Merge Patch):",
+    "Output a single JSON object on one line with __json_edit set to true.",
+    "Include only the keys that changed. Unmentioned keys are preserved.",
+    "Set a key to null to delete it.",
+    "",
+    "Example (update a title and add an element):",
+    '{"__json_edit":true,"elements":{"main":{"props":{"title":"New Title"}},"new-el":{"type":"Card","props":{},"children":[]}}}',
+    "",
+    "Example (delete an element):",
+    '{"__json_edit":true,"elements":{"old-widget":null}}',
+  ].join("\n");
+}
+
+function jsonDiffInstructions(): string {
+  return [
+    "DIFF MODE (unified diff):",
+    "Output a unified diff inside a ```diff code fence.",
+    "The diff applies against the JSON-serialized current spec.",
+    "",
+    "Example:",
+    "```diff",
+    "--- a/spec.json",
+    "+++ b/spec.json",
+    "@@ -3,1 +3,1 @@",
+    '-      "title": "Login"',
+    '+      "title": "Welcome Back"',
+    "```",
+  ].join("\n");
+}
+
+// ── YAML-format instructions ──
+
+function yamlPatchInstructions(): string {
+  return [
+    "PATCH MODE (RFC 6902 JSON Patch):",
+    "Output RFC 6902 JSON Patch lines inside a ```yaml-patch code fence.",
+    "Each line is one JSON patch operation.",
+    "",
+    "Example:",
+    "```yaml-patch",
+    '{"op":"replace","path":"/elements/main/props/title","value":"New Title"}',
+    '{"op":"add","path":"/elements/new-el","value":{"type":"Card","props":{},"children":[]}}',
+    "```",
+  ].join("\n");
+}
+
+function yamlMergeInstructions(): string {
+  return [
+    "MERGE MODE (RFC 7396 JSON Merge Patch):",
+    "Output only the changed parts in a ```yaml-edit code fence.",
+    "Uses deep merge semantics: only keys you include are updated. Unmentioned elements and props are preserved.",
+    "Set a key to null to delete it.",
+    "",
+    "Example edit (update title, add a new element):",
+    "```yaml-edit",
+    "elements:",
+    "  main:",
+    "    props:",
+    "      title: Updated Title",
+    "  new-chart:",
+    "    type: Card",
+    "    props: {}",
+    "    children: []",
+    "```",
+    "",
+    "Example deletion:",
+    "```yaml-edit",
+    "elements:",
+    "  old-widget: null",
+    "```",
+  ].join("\n");
+}
+
+function yamlDiffInstructions(): string {
+  return [
+    "DIFF MODE (unified diff):",
+    "Output a unified diff inside a ```diff code fence.",
+    "The diff applies against the YAML-serialized current spec.",
+    "",
+    "Example:",
+    "```diff",
+    "--- a/spec.yaml",
+    "+++ b/spec.yaml",
+    "@@ -6,1 +6,1 @@",
+    "-      title: Login",
+    "+      title: Welcome Back",
+    "```",
+  ].join("\n");
+}
+
+// ── Mode selection guidance ──
+
+function modeSelectionGuidance(modes: EditMode[]): string {
+  if (modes.length === 1) return "";
+  const parts = ["Choose the best edit strategy for the requested change:"];
+  if (modes.includes("patch")) {
+    parts.push("- PATCH: best for precise, targeted single-field updates");
+  }
+  if (modes.includes("merge")) {
+    parts.push(
+      "- MERGE: best for structural changes (add/remove elements, reparent children, update multiple props at once)",
+    );
+  }
+  if (modes.includes("diff")) {
+    parts.push(
+      "- DIFF: best for small text-level changes when you can see the exact lines to change",
+    );
+  }
+  return parts.join("\n");
+}
+
+/**
+ * Generate the prompt section describing available edit modes.
+ * Only documents the modes that are enabled.
+ */
+export function buildEditInstructions(
+  config: EditConfig | undefined,
+  format: "json" | "yaml",
+): string {
+  const modes = normalizeModes(config);
+  const sections: string[] = [];
+
+  sections.push("EDITING EXISTING SPECS:");
+  sections.push("");
+
+  const guidance = modeSelectionGuidance(modes);
+  if (guidance) {
+    sections.push(guidance);
+    sections.push("");
+  }
+
+  for (const mode of modes) {
+    if (format === "json") {
+      switch (mode) {
+        case "patch":
+          sections.push(jsonPatchInstructions());
+          break;
+        case "merge":
+          sections.push(jsonMergeInstructions());
+          break;
+        case "diff":
+          sections.push(jsonDiffInstructions());
+          break;
+      }
+    } else {
+      switch (mode) {
+        case "patch":
+          sections.push(yamlPatchInstructions());
+          break;
+        case "merge":
+          sections.push(yamlMergeInstructions());
+          break;
+        case "diff":
+          sections.push(yamlDiffInstructions());
+          break;
+      }
+    }
+    sections.push("");
+  }
+
+  return sections.join("\n");
+}
+
+function addLineNumbers(text: string): string {
+  const lines = text.split("\n");
+  const width = String(lines.length).length;
+  return lines
+    .map((line, i) => `${String(i + 1).padStart(width)}| ${line}`)
+    .join("\n");
+}
+
+export function isNonEmptySpec(spec: unknown): spec is Spec {
+  if (!spec || typeof spec !== "object") return false;
+  const s = spec as Record<string, unknown>;
+  return (
+    typeof s.root === "string" &&
+    typeof s.elements === "object" &&
+    s.elements !== null &&
+    Object.keys(s.elements as object).length > 0
+  );
+}
+
+export interface BuildEditUserPromptOptions {
+  prompt: string;
+  currentSpec?: Spec | null;
+  config?: EditConfig;
+  format: "json" | "yaml";
+  maxPromptLength?: number;
+  /** Serialise the spec. Defaults to JSON.stringify for json, must be provided for yaml. */
+  serializer?: (spec: Spec) => string;
+}
+
+/**
+ * Generate the user prompt for edits, including the current spec
+ * (with line numbers when diff mode is enabled) and mode instructions.
+ */
+export function buildEditUserPrompt(
+  options: BuildEditUserPromptOptions,
+): string {
+  const { prompt, currentSpec, config, format, maxPromptLength, serializer } =
+    options;
+
+  let userText = String(prompt || "");
+  if (maxPromptLength !== undefined && maxPromptLength > 0) {
+    userText = userText.slice(0, maxPromptLength);
+  }
+
+  if (!isNonEmptySpec(currentSpec)) {
+    return userText;
+  }
+
+  const modes = normalizeModes(config);
+  const showLineNumbers = modes.includes("diff");
+
+  const serialize = serializer ?? ((s: Spec) => JSON.stringify(s, null, 2));
+  const specText = serialize(currentSpec);
+
+  const parts: string[] = [];
+
+  if (showLineNumbers) {
+    parts.push("CURRENT UI STATE (line numbers for reference):");
+    parts.push("```");
+    parts.push(addLineNumbers(specText));
+    parts.push("```");
+  } else {
+    parts.push(
+      "CURRENT UI STATE (already loaded, DO NOT recreate existing elements):",
+    );
+    parts.push("```");
+    parts.push(specText);
+    parts.push("```");
+  }
+
+  parts.push("");
+  parts.push(`USER REQUEST: ${userText}`);
+  parts.push("");
+
+  if (modes.length === 1) {
+    const mode = modes[0]!;
+    switch (mode) {
+      case "patch":
+        parts.push(
+          format === "yaml"
+            ? "Output ONLY the patches in a ```yaml-patch fence."
+            : "Output ONLY the JSON Patch lines needed for the change.",
+        );
+        break;
+      case "merge":
+        parts.push(
+          format === "yaml"
+            ? "Output ONLY the changes in a ```yaml-edit fence. Include only keys that need to change."
+            : "Output ONLY a single JSON merge line with __json_edit set to true. Include only keys that need to change.",
+        );
+        break;
+      case "diff":
+        parts.push("Output ONLY the unified diff in a ```diff fence.");
+        break;
+    }
+  } else {
+    const modeNames = modes.map((m) => {
+      switch (m) {
+        case "patch":
+          return format === "yaml" ? "```yaml-patch fence" : "JSON Patch lines";
+        case "merge":
+          return format === "yaml"
+            ? "```yaml-edit fence"
+            : "JSON merge line (__json_edit)";
+        case "diff":
+          return "```diff fence";
+      }
+    });
+    parts.push(
+      `Choose the best edit strategy and output using one of: ${modeNames.join(", ")}`,
+    );
+  }
+
+  return parts.join("\n");
+}

+ 16 - 0
packages/core/src/index.ts

@@ -178,3 +178,19 @@ export { defineCatalog } from "./schema";
 export type { UserPromptOptions } from "./prompt";
 
 export { buildUserPrompt } from "./prompt";
+
+// Object diff & merge (format-agnostic)
+export { deepMergeSpec } from "./merge";
+export { diffToPatches } from "./diff";
+
+// Edit modes
+export type {
+  EditMode,
+  EditConfig,
+  BuildEditUserPromptOptions,
+} from "./edit-modes";
+export {
+  buildEditInstructions,
+  buildEditUserPrompt,
+  isNonEmptySpec,
+} from "./edit-modes";

+ 41 - 0
packages/core/src/merge.ts

@@ -0,0 +1,41 @@
+function isPlainObject(value: unknown): value is Record<string, unknown> {
+  return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+/**
+ * Deep-merge `patch` into `base`, returning a new object.
+ *
+ * Semantics (RFC 7396 JSON Merge Patch):
+ * - `null` values in `patch` delete the corresponding key from `base`
+ * - Arrays in `patch` replace (not concat) the corresponding array in `base`
+ * - Plain objects recurse
+ * - All other values replace
+ *
+ * Neither `base` nor `patch` is mutated.
+ */
+export function deepMergeSpec(
+  base: Record<string, unknown>,
+  patch: Record<string, unknown>,
+): Record<string, unknown> {
+  const result: Record<string, unknown> = { ...base };
+
+  for (const key of Object.keys(patch)) {
+    const patchVal = patch[key];
+
+    // null → delete
+    if (patchVal === null) {
+      delete result[key];
+      continue;
+    }
+
+    const baseVal = result[key];
+
+    if (isPlainObject(patchVal) && isPlainObject(baseVal)) {
+      result[key] = deepMergeSpec(baseVal, patchVal);
+    } else {
+      result[key] = patchVal;
+    }
+  }
+
+  return result;
+}

+ 16 - 41
packages/core/src/prompt.ts

@@ -1,4 +1,6 @@
 import type { Spec } from "./types";
+import type { EditMode } from "./edit-modes";
+import { buildEditUserPrompt, isNonEmptySpec } from "./edit-modes";
 
 /**
  * Options for building a user prompt.
@@ -12,37 +14,16 @@ export interface UserPromptOptions {
   state?: Record<string, unknown> | null;
   /** Maximum length for the user's text prompt (applied before wrapping) */
   maxPromptLength?: number;
+  /** Edit modes to offer when refining an existing spec. Default: `["patch"]`. */
+  editModes?: EditMode[];
 }
 
-/**
- * Check whether a spec is non-empty (has a root and at least one element).
- */
-function isNonEmptySpec(spec: unknown): spec is Spec {
-  if (!spec || typeof spec !== "object") return false;
-  const s = spec as Record<string, unknown>;
-  return (
-    typeof s.root === "string" &&
-    typeof s.elements === "object" &&
-    s.elements !== null &&
-    Object.keys(s.elements as object).length > 0
-  );
-}
-
-const PATCH_INSTRUCTIONS = `IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change:
-- To add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
-- To modify an existing element: {"op":"replace","path":"/elements/existing-key","value":{...}}
-- To remove an element: {"op":"remove","path":"/elements/old-key"}
-- To update the root: {"op":"replace","path":"/root","value":"new-root-key"}
-- To add children: update the parent element with new children array
-
-DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`;
-
 /**
  * Build a user prompt for AI generation.
  *
  * Handles common patterns that every consuming app needs:
  * - Truncating the user's prompt to a max length
- * - Including the current spec for refinement (patch-only mode)
+ * - Including the current spec for refinement (edit mode)
  * - Including runtime state context
  *
  * @example
@@ -53,12 +34,12 @@ DO NOT output patches for elements that don't need to change. Only output what's
  * // Refinement with existing spec
  * buildUserPrompt({ prompt: "add a dark mode toggle", currentSpec: spec })
  *
- * // With state context
- * buildUserPrompt({ prompt: "show my data", state: { todos: [] } })
+ * // With multiple edit modes
+ * buildUserPrompt({ prompt: "change title", currentSpec: spec, editModes: ["patch", "merge"] })
  * ```
  */
 export function buildUserPrompt(options: UserPromptOptions): string {
-  const { prompt, currentSpec, state, maxPromptLength } = options;
+  const { prompt, currentSpec, state, maxPromptLength, editModes } = options;
 
   // Sanitize and optionally truncate the user's text
   let userText = String(prompt || "");
@@ -68,25 +49,19 @@ export function buildUserPrompt(options: UserPromptOptions): string {
 
   // --- Refinement mode: currentSpec is provided ---
   if (isNonEmptySpec(currentSpec)) {
-    const parts: string[] = [];
-
-    parts.push(
-      `CURRENT UI STATE (already loaded, DO NOT recreate existing elements):`,
-    );
-    parts.push(JSON.stringify(currentSpec, null, 2));
-    parts.push("");
-    parts.push(`USER REQUEST: ${userText}`);
+    const editPrompt = buildEditUserPrompt({
+      prompt: userText,
+      currentSpec,
+      config: { modes: editModes ?? ["patch"] },
+      format: "json",
+    });
 
     // Append state context if provided
     if (state && Object.keys(state).length > 0) {
-      parts.push("");
-      parts.push(`AVAILABLE STATE:\n${JSON.stringify(state, null, 2)}`);
+      return `${editPrompt}\n\nAVAILABLE STATE:\n${JSON.stringify(state, null, 2)}`;
     }
 
-    parts.push("");
-    parts.push(PATCH_INSTRUCTIONS);
-
-    return parts.join("\n");
+    return editPrompt;
   }
 
   // --- Fresh generation mode ---

+ 10 - 0
packages/core/src/schema.ts

@@ -1,4 +1,6 @@
 import { z } from "zod";
+import type { EditMode } from "./edit-modes";
+import { buildEditInstructions } from "./edit-modes";
 
 /**
  * Schema builder primitives
@@ -142,6 +144,8 @@ export interface PromptOptions {
    * @deprecated `"chat"` — use `"inline"` instead.
    */
   mode?: "standalone" | "inline" | "generate" | "chat";
+  /** Edit modes to document in the system prompt. Default: `["patch"]`. */
+  editModes?: EditMode[];
 }
 
 /**
@@ -1049,6 +1053,12 @@ Note: state patches appear right after the elements that use them, so the UI fil
     lines.push("");
   }
 
+  // Edit modes
+  const editModes = options.editModes;
+  if (editModes && editModes.length > 0) {
+    lines.push(buildEditInstructions({ modes: editModes }, "json"));
+  }
+
   // Rules
   lines.push("RULES:");
   const baseRules =

+ 113 - 0
packages/yaml/README.md

@@ -0,0 +1,113 @@
+# @json-render/yaml
+
+YAML wire format for `@json-render/core`. Progressive rendering and surgical edits via streaming YAML.
+
+## Installation
+
+```bash
+npm install @json-render/yaml @json-render/core yaml
+```
+
+## Key Concepts
+
+- **YAML wire format**: Uses `yaml-spec`, `yaml-edit`, `yaml-patch`, and `diff` code fences instead of JSONL
+- **Streaming parser**: Incrementally parses YAML as it arrives, emitting JSON Patch operations
+- **Edit modes**: Supports patch (RFC 6902), merge (RFC 7396), and unified diff for surgical edits
+- **AI SDK transform**: Drop-in `TransformStream` that converts YAML fences into json-render patch data parts
+
+## Quick Start
+
+### Generate a YAML System Prompt
+
+```typescript
+import { yamlPrompt } from "@json-render/yaml";
+import { catalog } from "./catalog";
+
+const systemPrompt = yamlPrompt(catalog, {
+  mode: "standalone",
+  editModes: ["merge"],
+});
+```
+
+### Stream YAML Specs (AI SDK)
+
+```typescript
+import { pipeYamlRender } from "@json-render/yaml";
+import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
+
+const stream = createUIMessageStream({
+  execute: async ({ writer }) => {
+    writer.merge(pipeYamlRender(result.toUIMessageStream()));
+  },
+});
+return createUIMessageStreamResponse({ stream });
+```
+
+### Streaming Parser (Low-Level)
+
+```typescript
+import { createYamlStreamCompiler } from "@json-render/yaml";
+
+const compiler = createYamlStreamCompiler<Spec>();
+
+// Feed chunks as they arrive
+const { result, newPatches } = compiler.push("root: main\n");
+compiler.push("elements:\n  main:\n    type: Card\n");
+
+// Flush remaining data
+const { result: final } = compiler.flush();
+```
+
+## API Reference
+
+### `yamlPrompt(catalog, options?)`
+
+Generate a YAML-format system prompt from any json-render catalog.
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `system` | `string` | `"You are a UI generator that outputs YAML."` | Custom system message intro |
+| `mode` | `"standalone" \| "inline"` | `"standalone"` | Output mode |
+| `customRules` | `string[]` | `[]` | Additional rules |
+| `editModes` | `EditMode[]` | `["merge"]` | Edit modes to document |
+
+### `createYamlTransform(options?)`
+
+Creates a `TransformStream` that converts YAML spec/edit fences in AI SDK stream chunks into json-render patch data parts.
+
+| Option | Type | Description |
+|--------|------|-------------|
+| `previousSpec` | `Spec` | Seed with a previous spec for multi-turn edit support |
+
+### `pipeYamlRender(stream, options?)`
+
+Convenience wrapper that pipes an AI SDK stream through the YAML transform. Drop-in replacement for `pipeJsonRender` from `@json-render/core`.
+
+### `createYamlStreamCompiler(initial?)`
+
+Create a streaming YAML compiler that incrementally parses YAML text and emits JSON Patch operations.
+
+**Returns** `YamlStreamCompiler<T>` with methods:
+
+| Method | Description |
+|--------|-------------|
+| `push(chunk)` | Push a chunk of text. Returns `{ result, newPatches }` |
+| `flush()` | Flush remaining buffer and return final result |
+| `getResult()` | Get the current compiled result |
+| `getPatches()` | Get all patches applied so far |
+| `reset(initial?)` | Reset to initial state |
+
+### Fence Constants
+
+Exported constants for fence detection:
+
+- `YAML_SPEC_FENCE` — `` ```yaml-spec ``
+- `YAML_EDIT_FENCE` — `` ```yaml-edit ``
+- `YAML_PATCH_FENCE` — `` ```yaml-patch ``
+- `DIFF_FENCE` — `` ```diff ``
+- `FENCE_CLOSE` — `` ``` ``
+
+### Re-exports from `@json-render/core`
+
+- `diffToPatches(oldObj, newObj)` — Generate RFC 6902 JSON Patch from object diff
+- `deepMergeSpec(base, patch)` — RFC 7396 JSON Merge Patch

+ 59 - 0
packages/yaml/package.json

@@ -0,0 +1,59 @@
+{
+  "name": "@json-render/yaml",
+  "version": "0.13.0",
+  "license": "Apache-2.0",
+  "description": "YAML wire format for @json-render/core. Progressive rendering and surgical edits via streaming YAML.",
+  "keywords": [
+    "json",
+    "yaml",
+    "ui",
+    "ai",
+    "generative-ui",
+    "llm",
+    "streaming",
+    "progressive-rendering"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/vercel-labs/json-render.git",
+    "directory": "packages/yaml"
+  },
+  "homepage": "https://json-render.dev",
+  "bugs": {
+    "url": "https://github.com/vercel-labs/json-render/issues"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "main": "./dist/index.js",
+  "module": "./dist/index.mjs",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.mjs",
+      "require": "./dist/index.js"
+    }
+  },
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsup",
+    "dev": "tsup --watch",
+    "typecheck": "tsc --noEmit",
+    "test": "vitest run"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*",
+    "diff": "^8.0.3",
+    "yaml": "^2.8.2"
+  },
+  "devDependencies": {
+    "@internal/typescript-config": "workspace:*",
+    "tsup": "^8.0.2",
+    "typescript": "^5.4.5",
+    "vitest": "^4.0.17",
+    "zod": "^4.3.6"
+  }
+}

+ 109 - 0
packages/yaml/src/diff.test.ts

@@ -0,0 +1,109 @@
+import { describe, it, expect } from "vitest";
+import { diffToPatches } from "./diff";
+
+describe("diffToPatches", () => {
+  it("returns empty array for identical objects", () => {
+    const obj = { a: 1, b: "hello" };
+    expect(diffToPatches(obj, obj)).toEqual([]);
+  });
+
+  it("detects added keys", () => {
+    const patches = diffToPatches({}, { name: "Alice" });
+    expect(patches).toEqual([{ op: "add", path: "/name", value: "Alice" }]);
+  });
+
+  it("detects removed keys", () => {
+    const patches = diffToPatches({ name: "Alice" }, {});
+    expect(patches).toEqual([{ op: "remove", path: "/name" }]);
+  });
+
+  it("detects changed scalar values", () => {
+    const patches = diffToPatches({ name: "Alice" }, { name: "Bob" });
+    expect(patches).toEqual([{ op: "replace", path: "/name", value: "Bob" }]);
+  });
+
+  it("recurses into nested objects", () => {
+    const oldObj = { user: { name: "Alice", age: 30 } };
+    const newObj = { user: { name: "Alice", age: 31 } };
+    const patches = diffToPatches(oldObj, newObj);
+    expect(patches).toEqual([{ op: "replace", path: "/user/age", value: 31 }]);
+  });
+
+  it("replaces arrays atomically", () => {
+    const oldObj = { items: ["a", "b"] };
+    const newObj = { items: ["a", "b", "c"] };
+    const patches = diffToPatches(oldObj, newObj);
+    expect(patches).toEqual([
+      { op: "replace", path: "/items", value: ["a", "b", "c"] },
+    ]);
+  });
+
+  it("does not emit patch for identical arrays", () => {
+    const oldObj = { items: [1, 2, 3] };
+    const newObj = { items: [1, 2, 3] };
+    expect(diffToPatches(oldObj, newObj)).toEqual([]);
+  });
+
+  it("handles type changes (object → scalar)", () => {
+    const oldObj = { data: { nested: true } };
+    const newObj = { data: "flat" };
+    const patches = diffToPatches(
+      oldObj as Record<string, unknown>,
+      newObj as Record<string, unknown>,
+    );
+    expect(patches).toEqual([{ op: "replace", path: "/data", value: "flat" }]);
+  });
+
+  it("handles a complex spec diff", () => {
+    const oldSpec = {
+      root: "main",
+      elements: {
+        main: { type: "Card", props: { title: "Hello" }, children: [] },
+      },
+    };
+    const newSpec = {
+      root: "main",
+      elements: {
+        main: {
+          type: "Card",
+          props: { title: "Hello" },
+          children: ["child-1"],
+        },
+        "child-1": {
+          type: "Text",
+          props: { content: "World" },
+          children: [],
+        },
+      },
+    };
+    const patches = diffToPatches(oldSpec, newSpec);
+    expect(patches).toContainEqual({
+      op: "replace",
+      path: "/elements/main/children",
+      value: ["child-1"],
+    });
+    expect(patches).toContainEqual({
+      op: "add",
+      path: "/elements/child-1",
+      value: {
+        type: "Text",
+        props: { content: "World" },
+        children: [],
+      },
+    });
+  });
+
+  it("escapes JSON Pointer tokens (~ and /)", () => {
+    const patches = diffToPatches({}, { "a/b": 1, "c~d": 2 });
+    expect(patches).toContainEqual({
+      op: "add",
+      path: "/a~1b",
+      value: 1,
+    });
+    expect(patches).toContainEqual({
+      op: "add",
+      path: "/c~0d",
+      value: 2,
+    });
+  });
+});

+ 1 - 0
packages/yaml/src/diff.ts

@@ -0,0 +1 @@
+export { diffToPatches } from "@json-render/core";

+ 24 - 0
packages/yaml/src/index.ts

@@ -0,0 +1,24 @@
+// Diff
+export { diffToPatches } from "./diff";
+
+// Merge
+export { deepMergeSpec } from "./merge";
+
+// Streaming YAML compiler
+export type { YamlStreamCompiler } from "./parser";
+export { createYamlStreamCompiler } from "./parser";
+
+// AI SDK transform
+export {
+  createYamlTransform,
+  pipeYamlRender,
+  YAML_SPEC_FENCE,
+  YAML_EDIT_FENCE,
+  YAML_PATCH_FENCE,
+  DIFF_FENCE,
+  FENCE_CLOSE,
+} from "./transform";
+
+// Prompt generation
+export type { YamlPromptOptions } from "./prompt";
+export { yamlPrompt } from "./prompt";

+ 109 - 0
packages/yaml/src/merge.test.ts

@@ -0,0 +1,109 @@
+import { describe, it, expect } from "vitest";
+import { deepMergeSpec } from "./merge";
+
+describe("deepMergeSpec", () => {
+  it("adds new keys", () => {
+    const result = deepMergeSpec({ a: 1 }, { b: 2 });
+    expect(result).toEqual({ a: 1, b: 2 });
+  });
+
+  it("replaces scalar values", () => {
+    const result = deepMergeSpec({ a: 1 }, { a: 2 });
+    expect(result).toEqual({ a: 2 });
+  });
+
+  it("deletes keys set to null", () => {
+    const result = deepMergeSpec({ a: 1, b: 2 }, { b: null });
+    expect(result).toEqual({ a: 1 });
+  });
+
+  it("deep-merges nested objects", () => {
+    const base = { user: { name: "Alice", age: 30 } };
+    const patch = { user: { age: 31 } };
+    const result = deepMergeSpec(base, patch);
+    expect(result).toEqual({ user: { name: "Alice", age: 31 } });
+  });
+
+  it("replaces arrays (does not concat)", () => {
+    const base = { items: [1, 2, 3] };
+    const patch = { items: [4, 5] };
+    const result = deepMergeSpec(base, patch);
+    expect(result).toEqual({ items: [4, 5] });
+  });
+
+  it("does not mutate base or patch", () => {
+    const base = { a: { b: 1 } };
+    const patch = { a: { c: 2 } };
+    const baseCopy = JSON.parse(JSON.stringify(base));
+    const patchCopy = JSON.parse(JSON.stringify(patch));
+    deepMergeSpec(base, patch);
+    expect(base).toEqual(baseCopy);
+    expect(patch).toEqual(patchCopy);
+  });
+
+  it("handles a spec-like edit merge", () => {
+    const base = {
+      root: "main",
+      elements: {
+        main: {
+          type: "Card",
+          props: { title: "Dashboard" },
+          children: ["metric-1"],
+        },
+        "metric-1": {
+          type: "Metric",
+          props: { label: "Revenue", value: "$1M" },
+          children: [],
+        },
+      },
+    };
+    const patch = {
+      elements: {
+        main: {
+          props: { title: "Updated Dashboard" },
+          children: ["metric-1", "chart-1"],
+        },
+        "chart-1": {
+          type: "Chart",
+          props: { data: "revenue" },
+          children: [],
+        },
+      },
+    };
+    const result = deepMergeSpec(base, patch);
+    expect(result.root).toBe("main");
+    expect(
+      (result.elements as Record<string, Record<string, unknown>>)["main"],
+    ).toEqual({
+      type: "Card",
+      props: { title: "Updated Dashboard" },
+      children: ["metric-1", "chart-1"],
+    });
+    expect(
+      (result.elements as Record<string, Record<string, unknown>>)["metric-1"],
+    ).toEqual({
+      type: "Metric",
+      props: { label: "Revenue", value: "$1M" },
+      children: [],
+    });
+    expect(
+      (result.elements as Record<string, Record<string, unknown>>)["chart-1"],
+    ).toEqual({
+      type: "Chart",
+      props: { data: "revenue" },
+      children: [],
+    });
+  });
+
+  it("deletes an element via null", () => {
+    const base = {
+      elements: {
+        main: { type: "Card" },
+        old: { type: "Widget" },
+      },
+    };
+    const patch = { elements: { old: null } };
+    const result = deepMergeSpec(base, patch);
+    expect(result.elements).toEqual({ main: { type: "Card" } });
+  });
+});

+ 1 - 0
packages/yaml/src/merge.ts

@@ -0,0 +1 @@
+export { deepMergeSpec } from "@json-render/core";

+ 149 - 0
packages/yaml/src/parser.test.ts

@@ -0,0 +1,149 @@
+import { describe, it, expect } from "vitest";
+import { createYamlStreamCompiler } from "./parser";
+
+describe("createYamlStreamCompiler", () => {
+  it("parses a simple YAML document incrementally", () => {
+    const compiler = createYamlStreamCompiler();
+
+    const r1 = compiler.push("root: main\n");
+    expect(r1.newPatches.length).toBeGreaterThan(0);
+    expect(r1.result).toHaveProperty("root", "main");
+  });
+
+  it("accumulates elements as lines arrive", () => {
+    const compiler = createYamlStreamCompiler();
+
+    compiler.push("root: main\n");
+    compiler.push("elements:\n");
+    compiler.push("  main:\n");
+    compiler.push("    type: Card\n");
+
+    const { result } = compiler.flush();
+    expect(result).toHaveProperty("root", "main");
+
+    const elements = result.elements as Record<string, Record<string, unknown>>;
+    expect(elements.main).toBeDefined();
+    expect(elements.main!.type).toBe("Card");
+  });
+
+  it("emits patches only for changes", () => {
+    const compiler = createYamlStreamCompiler();
+
+    const r1 = compiler.push("root: main\n");
+    expect(r1.newPatches).toEqual([
+      { op: "add", path: "/root", value: "main" },
+    ]);
+
+    // Pushing the same content again (no new complete line) should not emit patches
+    const r2 = compiler.push("");
+    expect(r2.newPatches).toEqual([]);
+  });
+
+  it("tracks all patches via getPatches()", () => {
+    const compiler = createYamlStreamCompiler();
+
+    compiler.push("a: 1\n");
+    compiler.push("b: 2\n");
+
+    const allPatches = compiler.getPatches();
+    expect(allPatches.length).toBe(2);
+    expect(allPatches[0]).toEqual({ op: "add", path: "/a", value: 1 });
+    expect(allPatches[1]).toEqual({ op: "add", path: "/b", value: 2 });
+  });
+
+  it("resets to initial state", () => {
+    const compiler = createYamlStreamCompiler();
+
+    compiler.push("root: main\n");
+    expect(compiler.getResult()).toHaveProperty("root", "main");
+
+    compiler.reset();
+    expect(compiler.getResult()).toEqual({});
+    expect(compiler.getPatches()).toEqual([]);
+  });
+
+  it("resets with initial value and diffs from it", () => {
+    const compiler = createYamlStreamCompiler();
+
+    compiler.reset({ root: "existing", elements: {} });
+
+    // The YAML includes root, so the initial value is preserved in the diff base
+    const { newPatches } = compiler.push(
+      "root: existing\nelements:\n  main:\n    type: Card\n",
+    );
+    const { result } = compiler.flush();
+
+    expect(result).toHaveProperty("root", "existing");
+    expect(result).toHaveProperty("elements");
+    // Only the new element should be patched, not root (unchanged)
+    expect(newPatches.find((p) => p.path === "/root")).toBeUndefined();
+    expect(newPatches.find((p) => p.path === "/elements/main")).toBeDefined();
+  });
+
+  it("handles a full spec YAML", () => {
+    const compiler = createYamlStreamCompiler();
+
+    const yaml = [
+      "root: main\n",
+      "elements:\n",
+      "  main:\n",
+      "    type: Card\n",
+      "    props:\n",
+      "      title: Dashboard\n",
+      "    children:\n",
+      "      - metric-1\n",
+      "  metric-1:\n",
+      "    type: Metric\n",
+      "    props:\n",
+      "      label: Revenue\n",
+      '      value: "$1.2M"\n',
+      "    children: []\n",
+      "state:\n",
+      "  revenue: 1200000\n",
+    ];
+
+    for (const line of yaml) {
+      compiler.push(line);
+    }
+    const { result } = compiler.flush();
+
+    expect(result.root).toBe("main");
+    expect(result.state).toEqual({ revenue: 1200000 });
+
+    const elements = result.elements as Record<string, Record<string, unknown>>;
+    expect(elements.main).toBeDefined();
+    expect(elements["metric-1"]).toBeDefined();
+    expect((elements["metric-1"]!.props as Record<string, unknown>).label).toBe(
+      "Revenue",
+    );
+  });
+
+  it("does not crash on invalid YAML mid-stream", () => {
+    const compiler = createYamlStreamCompiler();
+
+    // Partial YAML that won't parse
+    compiler.push("elements:\n");
+    compiler.push("  main:\n");
+    compiler.push("    type: "); // incomplete value — no newline yet
+
+    // Should not throw, result should still be from last successful parse
+    const r = compiler.push("\n");
+    expect(r.result).toBeDefined();
+  });
+
+  it("YAML 1.2 does not coerce yes/no/on/off to booleans", () => {
+    const compiler = createYamlStreamCompiler();
+
+    compiler.push("active: yes\n");
+    compiler.push("disabled: no\n");
+    compiler.push("on_value: on\n");
+    compiler.push("off_value: off\n");
+
+    const { result } = compiler.flush();
+    // YAML 1.2 (yaml v2 default) treats these as strings, not booleans
+    expect(result.active).toBe("yes");
+    expect(result.disabled).toBe("no");
+    expect(result.on_value).toBe("on");
+    expect(result.off_value).toBe("off");
+  });
+});

+ 110 - 0
packages/yaml/src/parser.ts

@@ -0,0 +1,110 @@
+import { parse } from "yaml";
+import type { JsonPatch } from "@json-render/core";
+import { diffToPatches } from "./diff";
+
+/**
+ * Streaming YAML compiler that incrementally parses YAML text and emits
+ * JSON Patch operations for each change detected.
+ *
+ * Same interface shape as `SpecStreamCompiler` from `@json-render/core`.
+ */
+export interface YamlStreamCompiler<T> {
+  /** Push a chunk of text. Returns the current result and any new patches. */
+  push(chunk: string): { result: T; newPatches: JsonPatch[] };
+  /** Flush remaining buffer and return the final result. */
+  flush(): { result: T; newPatches: JsonPatch[] };
+  /** Get the current compiled result. */
+  getResult(): T;
+  /** Get all patches that have been applied. */
+  getPatches(): JsonPatch[];
+  /** Reset the compiler to initial state. */
+  reset(initial?: Partial<T>): void;
+}
+
+/**
+ * Create a streaming YAML compiler.
+ *
+ * Incrementally parses YAML text as it arrives and emits JSON Patch
+ * operations by diffing each successful parse against the previous snapshot.
+ *
+ * Uses `yaml.parse()` with YAML 1.2 defaults (the `yaml` v2 default).
+ * YAML 1.2 does not coerce `yes`/`no`/`on`/`off` to booleans.
+ *
+ * @example
+ * ```ts
+ * const compiler = createYamlStreamCompiler<Spec>();
+ * compiler.push("root: main\n");
+ * compiler.push("elements:\n  main:\n    type: Card\n");
+ * const { result } = compiler.flush();
+ * ```
+ */
+export function createYamlStreamCompiler<
+  T extends Record<string, unknown> = Record<string, unknown>,
+>(initial?: Partial<T>): YamlStreamCompiler<T> {
+  let accumulated = "";
+  let snapshot: Record<string, unknown> = initial
+    ? { ...initial }
+    : ({} as Record<string, unknown>);
+  let result: T = { ...snapshot } as T;
+  const allPatches: JsonPatch[] = [];
+
+  function tryParse(): { result: T; newPatches: JsonPatch[] } {
+    const newPatches: JsonPatch[] = [];
+
+    try {
+      const parsed = parse(accumulated);
+
+      if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
+        const patches = diffToPatches(
+          snapshot,
+          parsed as Record<string, unknown>,
+        );
+
+        if (patches.length > 0) {
+          snapshot = structuredClone(parsed as Record<string, unknown>);
+          result = { ...snapshot } as T;
+          allPatches.push(...patches);
+          newPatches.push(...patches);
+        }
+      }
+    } catch {
+      // Incomplete YAML — wait for more data
+    }
+
+    return { result, newPatches };
+  }
+
+  return {
+    push(chunk: string): { result: T; newPatches: JsonPatch[] } {
+      accumulated += chunk;
+
+      // Only attempt parse when we have a complete line
+      if (chunk.includes("\n")) {
+        return tryParse();
+      }
+
+      return { result, newPatches: [] };
+    },
+
+    flush(): { result: T; newPatches: JsonPatch[] } {
+      return tryParse();
+    },
+
+    getResult(): T {
+      return result;
+    },
+
+    getPatches(): JsonPatch[] {
+      return [...allPatches];
+    },
+
+    reset(newInitial?: Partial<T>): void {
+      accumulated = "";
+      snapshot = newInitial
+        ? { ...newInitial }
+        : ({} as Record<string, unknown>);
+      result = { ...snapshot } as T;
+      allPatches.length = 0;
+    },
+  };
+}

+ 110 - 0
packages/yaml/src/prompt.test.ts

@@ -0,0 +1,110 @@
+import { describe, it, expect } from "vitest";
+import { defineSchema, defineCatalog } from "@json-render/core";
+import { z } from "zod";
+import { yamlPrompt } from "./prompt";
+
+const testSchema = defineSchema(
+  (s) => ({
+    spec: s.object({
+      root: s.string(),
+      elements: s.record(
+        s.object({
+          type: s.ref("catalog.components"),
+          props: s.propsOf("catalog.components"),
+          children: s.array(s.string()),
+        }),
+      ),
+    }),
+    catalog: s.object({
+      components: s.map({
+        props: s.zod(),
+        description: s.string(),
+      }),
+      actions: s.map({
+        description: s.string(),
+      }),
+    }),
+  }),
+  {
+    builtInActions: [{ name: "setState", description: "Set a state value" }],
+  },
+);
+
+const testCatalog = defineCatalog(testSchema, {
+  components: {
+    Card: {
+      props: z.object({ title: z.string() }),
+      description: "A card container",
+    },
+    Text: {
+      props: z.object({ content: z.string() }),
+      description: "Display text",
+    },
+  },
+  actions: {
+    refresh: { description: "Refresh data" },
+  },
+});
+
+describe("yamlPrompt", () => {
+  it("generates a prompt string", () => {
+    const prompt = yamlPrompt(testCatalog);
+    expect(typeof prompt).toBe("string");
+    expect(prompt.length).toBeGreaterThan(0);
+  });
+
+  it("includes YAML format instructions", () => {
+    const prompt = yamlPrompt(testCatalog);
+    expect(prompt).toContain("YAML");
+    expect(prompt).toContain("yaml-spec");
+  });
+
+  it("includes component names from the catalog", () => {
+    const prompt = yamlPrompt(testCatalog);
+    expect(prompt).toContain("Card");
+    expect(prompt).toContain("Text");
+  });
+
+  it("includes action names", () => {
+    const prompt = yamlPrompt(testCatalog);
+    expect(prompt).toContain("refresh");
+    expect(prompt).toContain("setState");
+  });
+
+  it("includes yaml-edit instructions", () => {
+    const prompt = yamlPrompt(testCatalog);
+    expect(prompt).toContain("yaml-edit");
+    expect(prompt).toContain("deep merge");
+  });
+
+  it("includes a YAML example", () => {
+    const prompt = yamlPrompt(testCatalog);
+    expect(prompt).toContain("root: main");
+    expect(prompt).toContain("elements:");
+    expect(prompt).toContain("type: Card");
+  });
+
+  it("respects mode: inline", () => {
+    const prompt = yamlPrompt(testCatalog, { mode: "inline" });
+    expect(prompt).toContain("respond conversationally");
+  });
+
+  it("respects mode: standalone", () => {
+    const prompt = yamlPrompt(testCatalog, { mode: "standalone" });
+    expect(prompt).toContain("Output ONLY");
+  });
+
+  it("appends custom rules", () => {
+    const prompt = yamlPrompt(testCatalog, {
+      customRules: ["Always use dark theme colors"],
+    });
+    expect(prompt).toContain("Always use dark theme colors");
+  });
+
+  it("uses custom system message", () => {
+    const prompt = yamlPrompt(testCatalog, {
+      system: "You are a dashboard builder.",
+    });
+    expect(prompt).toContain("You are a dashboard builder.");
+  });
+});

+ 582 - 0
packages/yaml/src/prompt.ts

@@ -0,0 +1,582 @@
+import { stringify } from "yaml";
+import type { Catalog, EditMode, SchemaDefinition } from "@json-render/core";
+import { buildEditInstructions } from "@json-render/core";
+
+interface ZodLike {
+  _def?: Record<string, unknown>;
+}
+
+export interface YamlPromptOptions {
+  /** Custom system message intro. */
+  system?: string;
+  /**
+   * - `"standalone"` (default): LLM outputs only the YAML spec (no prose).
+   * - `"inline"`: LLM responds conversationally, then wraps YAML in a fence.
+   */
+  mode?: "standalone" | "inline";
+  /** Additional rules appended to the RULES section. */
+  customRules?: string[];
+  /** Edit modes to document. Default: `["merge"]` (yaml-edit). */
+  editModes?: EditMode[];
+}
+
+interface CatalogComponentDef {
+  props?: ZodLike;
+  description?: string;
+  slots?: string[];
+  events?: string[];
+  example?: Record<string, unknown>;
+}
+
+// ── Zod introspection (local, minimal) ──────────────────────────────────────
+
+function getZodTypeName(schema: ZodLike): string {
+  if (!schema?._def) return "";
+  const def = schema._def;
+  return (
+    (def.typeName as string | undefined) ??
+    (typeof def.type === "string" ? (def.type as string) : "") ??
+    ""
+  );
+}
+
+function formatZodType(schema: ZodLike): string {
+  if (!schema?._def) return "unknown";
+  const def = schema._def;
+  const typeName = getZodTypeName(schema);
+
+  switch (typeName) {
+    case "ZodString":
+    case "string":
+      return "string";
+    case "ZodNumber":
+    case "number":
+      return "number";
+    case "ZodBoolean":
+    case "boolean":
+      return "boolean";
+    case "ZodLiteral":
+    case "literal":
+      return JSON.stringify(def.value);
+    case "ZodEnum":
+    case "enum": {
+      let values: string[];
+      if (Array.isArray(def.values)) {
+        values = def.values as string[];
+      } else if (def.entries && typeof def.entries === "object") {
+        values = Object.values(def.entries as Record<string, string>);
+      } else {
+        return "enum";
+      }
+      return values.map((v) => `"${v}"`).join(" | ");
+    }
+    case "ZodArray":
+    case "array": {
+      const inner = (
+        typeof def.element === "object"
+          ? def.element
+          : typeof def.type === "object"
+            ? def.type
+            : undefined
+      ) as ZodLike | undefined;
+      return inner ? `Array<${formatZodType(inner)}>` : "Array<unknown>";
+    }
+    case "ZodObject":
+    case "object": {
+      const shape =
+        typeof def.shape === "function"
+          ? (def.shape as () => Record<string, ZodLike>)()
+          : (def.shape as Record<string, ZodLike>);
+      if (!shape) return "object";
+      const props = Object.entries(shape)
+        .map(([key, value]) => {
+          const innerTypeName = getZodTypeName(value);
+          const isOptional =
+            innerTypeName === "ZodOptional" ||
+            innerTypeName === "ZodNullable" ||
+            innerTypeName === "optional" ||
+            innerTypeName === "nullable";
+          return `${key}${isOptional ? "?" : ""}: ${formatZodType(value)}`;
+        })
+        .join(", ");
+      return `{ ${props} }`;
+    }
+    case "ZodOptional":
+    case "optional":
+    case "ZodNullable":
+    case "nullable": {
+      const inner = (def.innerType as ZodLike) ?? (def.wrapped as ZodLike);
+      return inner ? formatZodType(inner) : "unknown";
+    }
+    case "ZodUnion":
+    case "union": {
+      const options = def.options as ZodLike[] | undefined;
+      return options
+        ? options.map((opt) => formatZodType(opt)).join(" | ")
+        : "unknown";
+    }
+    default:
+      return "unknown";
+  }
+}
+
+function getExampleProps(def: CatalogComponentDef): Record<string, unknown> {
+  if (def.example && Object.keys(def.example).length > 0) return def.example;
+  if (!def.props?._def) return {};
+  const zodDef = def.props._def;
+  const typeName = getZodTypeName(def.props);
+  if (typeName !== "ZodObject" && typeName !== "object") return {};
+  const shape =
+    typeof zodDef.shape === "function"
+      ? (zodDef.shape as () => Record<string, ZodLike>)()
+      : (zodDef.shape as Record<string, ZodLike>);
+  if (!shape) return {};
+  const result: Record<string, unknown> = {};
+  for (const [key, value] of Object.entries(shape)) {
+    const inner = getZodTypeName(value);
+    if (
+      inner === "ZodOptional" ||
+      inner === "optional" ||
+      inner === "ZodNullable" ||
+      inner === "nullable"
+    )
+      continue;
+    result[key] = exampleValue(value);
+  }
+  return result;
+}
+
+function exampleValue(schema: ZodLike): unknown {
+  if (!schema?._def) return "...";
+  const def = schema._def;
+  const t = getZodTypeName(schema);
+  switch (t) {
+    case "ZodString":
+    case "string":
+      return "example";
+    case "ZodNumber":
+    case "number":
+      return 0;
+    case "ZodBoolean":
+    case "boolean":
+      return true;
+    case "ZodLiteral":
+    case "literal":
+      return def.value;
+    case "ZodEnum":
+    case "enum": {
+      if (Array.isArray(def.values) && (def.values as unknown[]).length > 0)
+        return (def.values as unknown[])[0];
+      if (def.entries && typeof def.entries === "object") {
+        const vals = Object.values(def.entries as Record<string, string>);
+        return vals.length > 0 ? vals[0] : "example";
+      }
+      return "example";
+    }
+    case "ZodOptional":
+    case "optional":
+    case "ZodNullable":
+    case "nullable":
+    case "ZodDefault":
+    case "default": {
+      const inner = (def.innerType as ZodLike) ?? (def.wrapped as ZodLike);
+      return inner ? exampleValue(inner) : null;
+    }
+    case "ZodArray":
+    case "array":
+      return [];
+    case "ZodObject":
+    case "object":
+      return getExampleProps({ props: schema } as CatalogComponentDef);
+    case "ZodUnion":
+    case "union": {
+      const options = def.options as ZodLike[] | undefined;
+      return options && options.length > 0 ? exampleValue(options[0]!) : "...";
+    }
+    default:
+      return "...";
+  }
+}
+
+// ── YAML helper ──
+
+/** Render a value as an indented YAML string (2-space indent). */
+function toYaml(value: unknown): string {
+  return stringify(value, { indent: 2 }).trimEnd();
+}
+
+// ── Prompt generation ──
+
+/**
+ * Generate a YAML-format system prompt from any json-render catalog.
+ *
+ * Works with catalogs from any renderer (`@json-render/react`,
+ * `@json-render/vue`, etc.) — it only reads the catalog metadata.
+ *
+ * @example
+ * ```ts
+ * import { yamlPrompt } from "@json-render/yaml";
+ * const systemPrompt = yamlPrompt(catalog, { mode: "inline" });
+ * ```
+ */
+export function yamlPrompt(
+  catalog: Catalog<SchemaDefinition, unknown>,
+  options?: YamlPromptOptions,
+): string {
+  const {
+    system = "You are a UI generator that outputs YAML.",
+    mode = "standalone",
+    customRules = [],
+    editModes = ["merge"],
+  } = options ?? {};
+
+  const lines: string[] = [];
+  lines.push(system);
+  lines.push("");
+
+  // ── Output format ──
+
+  if (mode === "inline") {
+    lines.push("OUTPUT FORMAT (text + YAML):");
+    lines.push(
+      "You respond conversationally. When generating UI, first write a brief explanation (1-3 sentences), then output the YAML spec wrapped in a ```yaml-spec code fence.",
+    );
+    lines.push(
+      "If the user's message does not require a UI (e.g. a greeting or clarifying question), respond with text only — no YAML.",
+    );
+  } else {
+    lines.push("OUTPUT FORMAT (YAML):");
+    lines.push(
+      "Output a YAML document that describes a UI tree. Wrap it in a ```yaml-spec code fence.",
+    );
+  }
+  lines.push("");
+  lines.push(
+    "The YAML document has three top-level keys: root, elements, and state (optional).",
+  );
+  lines.push(
+    "Stream progressively — output elements one at a time so the UI fills in as you write.",
+  );
+  lines.push("");
+
+  // ── Example ──
+
+  const allComponents = (catalog.data as Record<string, unknown>).components as
+    | Record<string, CatalogComponentDef>
+    | undefined;
+  const cn = catalog.componentNames;
+  const comp1 = cn[0] || "Component";
+  const comp2 = cn.length > 1 ? cn[1]! : comp1;
+  const comp1Def = allComponents?.[comp1];
+  const comp2Def = allComponents?.[comp2];
+  const comp1Props = comp1Def ? getExampleProps(comp1Def) : {};
+  const comp2Props = comp2Def ? getExampleProps(comp2Def) : {};
+
+  const exampleSpec = {
+    root: "main",
+    elements: {
+      main: {
+        type: comp1,
+        props: comp1Props,
+        children: ["child-1", "list"],
+      },
+      "child-1": {
+        type: comp2,
+        props: comp2Props,
+        children: [],
+      },
+      list: {
+        type: comp1,
+        props: comp1Props,
+        repeat: { statePath: "/items", key: "id" },
+        children: ["item"],
+      },
+      item: {
+        type: comp2,
+        props: { ...comp2Props },
+        children: [],
+      },
+    },
+    state: {
+      items: [
+        { id: "1", title: "First Item" },
+        { id: "2", title: "Second Item" },
+      ],
+    },
+  };
+
+  lines.push("Example:");
+  lines.push("");
+  lines.push("```yaml-spec");
+  lines.push(toYaml(exampleSpec));
+  lines.push("```");
+  lines.push("");
+
+  // ── Edit modes (dynamic based on config) ──
+
+  lines.push(buildEditInstructions({ modes: editModes }, "yaml"));
+
+  // ── Initial state ──
+
+  lines.push("INITIAL STATE:");
+  lines.push(
+    "The spec includes a top-level `state` key to seed the state model. Components using $state, $bindState, $bindItem, $item, or $index read from this state.",
+  );
+  lines.push(
+    "CRITICAL: You MUST include state whenever your UI displays data via these expressions or uses repeat to iterate over arrays.",
+  );
+  lines.push(
+    "Include realistic sample data. For lists: 3-5 items with relevant fields. Never leave arrays empty.",
+  );
+  lines.push(
+    'When content comes from the state model, use { "$state": "/some/path" } dynamic props instead of hardcoding values.',
+  );
+  lines.push(
+    'State paths use RFC 6901 JSON Pointer syntax (e.g. "/todos/0/title"). Do NOT use dot notation.',
+  );
+  lines.push("");
+
+  // ── Dynamic lists ──
+
+  lines.push("DYNAMIC LISTS (repeat field):");
+  lines.push(
+    "Any element can have a top-level `repeat` field to render its children once per item in a state array.",
+  );
+  lines.push("Example:");
+  lines.push("");
+  lines.push(
+    toYaml({
+      list: {
+        type: comp1,
+        props: comp1Props,
+        repeat: { statePath: "/todos", key: "id" },
+        children: ["todo-item"],
+      },
+    }),
+  );
+  lines.push("");
+  lines.push(
+    'Inside repeated children, use { "$item": "field" } for item data and { "$index": true } for the array index.',
+  );
+  lines.push(
+    "ALWAYS use repeat for lists backed by state arrays. NEVER hardcode individual elements per item.",
+  );
+  lines.push(
+    "IMPORTANT: `repeat` is a top-level field on the element (sibling of type/props/children), NOT inside props.",
+  );
+  lines.push("");
+
+  // ── Array state actions ──
+
+  lines.push("ARRAY STATE ACTIONS:");
+  lines.push(
+    'Use "pushState" to append items to arrays. Use "removeState" to remove items by index.',
+  );
+  lines.push('Use "$id" inside pushState values to auto-generate a unique ID.');
+  lines.push("");
+
+  // ── Components ──
+
+  if (allComponents) {
+    lines.push(`AVAILABLE COMPONENTS (${catalog.componentNames.length}):`);
+    lines.push("");
+
+    for (const [name, def] of Object.entries(allComponents)) {
+      const propsStr = def.props ? formatZodType(def.props) : "{}";
+      const hasChildren = def.slots && def.slots.length > 0;
+      const childrenStr = hasChildren ? " [accepts children]" : "";
+      const eventsStr =
+        def.events && def.events.length > 0
+          ? ` [events: ${def.events.join(", ")}]`
+          : "";
+      const descStr = def.description ? ` - ${def.description}` : "";
+      lines.push(`- ${name}: ${propsStr}${descStr}${childrenStr}${eventsStr}`);
+    }
+    lines.push("");
+  }
+
+  // ── Actions ──
+
+  const actions = (catalog.data as Record<string, unknown>).actions as
+    | Record<string, { params?: ZodLike; description?: string }>
+    | undefined;
+  const builtInActions = catalog.schema.builtInActions ?? [];
+  const hasCustomActions = actions && catalog.actionNames.length > 0;
+  const hasBuiltInActions = builtInActions.length > 0;
+
+  if (hasCustomActions || hasBuiltInActions) {
+    lines.push("AVAILABLE ACTIONS:");
+    lines.push("");
+    for (const action of builtInActions) {
+      lines.push(`- ${action.name}: ${action.description} [built-in]`);
+    }
+    if (hasCustomActions) {
+      for (const [name, def] of Object.entries(actions)) {
+        lines.push(`- ${name}${def.description ? `: ${def.description}` : ""}`);
+      }
+    }
+    lines.push("");
+  }
+
+  // ── Events ──
+
+  lines.push("EVENTS (the `on` field):");
+  lines.push(
+    "Elements can have an optional `on` field to bind events to actions. It is a top-level field (sibling of type/props/children), NOT inside props.",
+  );
+  lines.push("Example:");
+  lines.push("");
+  lines.push(
+    toYaml({
+      "save-btn": {
+        type: comp1,
+        props: comp1Props,
+        on: {
+          press: {
+            action: "setState",
+            params: { statePath: "/saved", value: true },
+          },
+        },
+        children: [],
+      },
+    }),
+  );
+  lines.push("");
+  lines.push(
+    'Action params can use dynamic references: { "$state": "/statePath" }.',
+  );
+  lines.push(
+    "IMPORTANT: Do NOT put action/actionParams inside props. Always use the `on` field.",
+  );
+  lines.push("");
+
+  // ── Visibility ──
+
+  lines.push("VISIBILITY CONDITIONS:");
+  lines.push(
+    "Elements can have an optional `visible` field to conditionally show/hide based on state. It is a top-level field (sibling of type/props/children).",
+  );
+  lines.push("Conditions:");
+  lines.push('- { "$state": "/path" } — visible when truthy');
+  lines.push('- { "$state": "/path", "not": true } — visible when falsy');
+  lines.push('- { "$state": "/path", "eq": "value" } — visible when equal');
+  lines.push(
+    '- { "$state": "/path", "neq": "value" } — visible when not equal',
+  );
+  lines.push(
+    '- { "$state": "/path", "gt": N } / gte / lt / lte — numeric comparisons',
+  );
+  lines.push("- Use ONE operator per condition. Do not combine multiple.");
+  lines.push('- Add "not": true to any condition to invert it.');
+  lines.push("- [cond, cond] — implicit AND (all must be true)");
+  lines.push('- { "$and": [...] } — explicit AND');
+  lines.push('- { "$or": [...] } — at least one must be true');
+  lines.push("- true / false — always visible/hidden");
+  lines.push("");
+
+  // ── Dynamic props ──
+
+  lines.push("DYNAMIC PROPS:");
+  lines.push("Any prop value can be a dynamic expression:");
+  lines.push(
+    '1. Read-only: { "$state": "/path" } — resolves to the value at that state path.',
+  );
+  lines.push(
+    '2. Two-way binding: { "$bindState": "/path" } — read + write. Use on form inputs.',
+  );
+  lines.push(
+    '   Inside repeat scopes: { "$bindItem": "field" } for item-level binding.',
+  );
+  lines.push(
+    '3. Conditional: { "$cond": <condition>, "$then": <val>, "$else": <val> }',
+  );
+  lines.push(
+    '4. Template: { "$template": "Hello, ${/name}!" } — interpolates state references.',
+  );
+  lines.push("");
+
+  // ── $computed (only if catalog has functions) ──
+
+  const catalogFunctions = (catalog.data as Record<string, unknown>).functions;
+  if (catalogFunctions && Object.keys(catalogFunctions).length > 0) {
+    lines.push(
+      '5. Computed: { "$computed": "<fn>", "args": { "key": <expr> } }',
+    );
+    lines.push("   Available functions:");
+    for (const name of Object.keys(
+      catalogFunctions as Record<string, unknown>,
+    )) {
+      lines.push(`   - ${name}`);
+    }
+    lines.push("");
+  }
+
+  // ── Validation (only if components have checks) ──
+
+  const hasChecks = allComponents
+    ? Object.values(allComponents).some((def) => {
+        if (!def.props) return false;
+        return formatZodType(def.props).includes("checks");
+      })
+    : false;
+
+  if (hasChecks) {
+    lines.push("VALIDATION:");
+    lines.push(
+      "Form components with a `checks` prop support client-side validation.",
+    );
+    lines.push(
+      "Built-in types: required, email, minLength, maxLength, pattern, min, max, numeric, url, matches, equalTo, lessThan, greaterThan, requiredIf.",
+    );
+    lines.push(
+      "IMPORTANT: Components with checks must also use $bindState or $bindItem for two-way binding.",
+    );
+    lines.push("");
+  }
+
+  // ── State watchers ──
+
+  if (hasCustomActions || hasBuiltInActions) {
+    lines.push("STATE WATCHERS:");
+    lines.push(
+      "Elements can have an optional `watch` field to trigger actions when state changes. Top-level field, NOT inside props.",
+    );
+    lines.push(
+      "Maps state paths to action bindings. Fires when the watched value changes (not on initial render).",
+    );
+    lines.push("");
+  }
+
+  // ── Rules ──
+
+  lines.push("RULES:");
+  const baseRules =
+    mode === "inline"
+      ? [
+          "When generating UI, wrap the YAML in a ```yaml-spec code fence",
+          "Write a brief conversational response before the YAML",
+          "When editing existing UI, use a ```yaml-edit fence with only the changed parts",
+          "The document must have: root (string), elements (map of elements), and optionally state",
+          "Each element must have: type, props, children (list of child keys)",
+          "ONLY use components listed above",
+          "Use unique, descriptive keys for elements (e.g. 'header', 'metric-1', 'chart-revenue')",
+          "Include state whenever using $state, $bindState, $bindItem, $item, $index, or repeat",
+        ]
+      : [
+          "Output ONLY the YAML spec inside a ```yaml-spec fence — no prose, no extra markdown",
+          "When editing existing UI, use a ```yaml-edit fence with only the changed parts",
+          "The document must have: root (string), elements (map of elements), and optionally state",
+          "Each element must have: type, props, children (list of child keys)",
+          "ONLY use components listed above",
+          "Use unique, descriptive keys for elements (e.g. 'header', 'metric-1', 'chart-revenue')",
+          "Include state whenever using $state, $bindState, $bindItem, $item, $index, or repeat",
+        ];
+
+  const schemaRules = catalog.schema.defaultRules ?? [];
+  const allRules = [...baseRules, ...schemaRules, ...customRules];
+  allRules.forEach((rule, i) => {
+    lines.push(`${i + 1}. ${rule}`);
+  });
+
+  return lines.join("\n");
+}

+ 161 - 0
packages/yaml/src/transform.test.ts

@@ -0,0 +1,161 @@
+import { describe, it, expect } from "vitest";
+import { SPEC_DATA_PART_TYPE, type StreamChunk } from "@json-render/core";
+import { createYamlTransform } from "./transform";
+
+/** Helper: feed text chunks through the transform and collect output. */
+async function runTransform(
+  lines: string[],
+  options?: Parameters<typeof createYamlTransform>[0],
+): Promise<StreamChunk[]> {
+  const transform = createYamlTransform(options);
+  const output: StreamChunk[] = [];
+
+  // Build input chunks
+  const inputChunks: StreamChunk[] = [
+    { type: "text-start", id: "1" },
+    ...lines.map((l) => ({ type: "text-delta" as const, id: "1", delta: l })),
+    { type: "text-end", id: "1" },
+  ];
+
+  // Create a readable from the input chunks and pipe through
+  const input = new ReadableStream<StreamChunk>({
+    start(controller) {
+      for (const chunk of inputChunks) {
+        controller.enqueue(chunk);
+      }
+      controller.close();
+    },
+  });
+
+  const outputStream = input.pipeThrough(transform);
+  const reader = outputStream.getReader();
+
+  while (true) {
+    const { done, value } = await reader.read();
+    if (done) break;
+    output.push(value);
+  }
+
+  return output;
+}
+
+function extractPatches(output: StreamChunk[]) {
+  return output
+    .filter((c) => c.type === SPEC_DATA_PART_TYPE)
+    .map((c) => (c as { data: { type: string; patch: unknown } }).data.patch);
+}
+
+function extractText(output: StreamChunk[]) {
+  return output
+    .filter((c) => c.type === "text-delta")
+    .map((c) => (c as { delta: string }).delta)
+    .join("");
+}
+
+describe("createYamlTransform", () => {
+  it("passes through plain text outside fences", async () => {
+    const output = await runTransform(["Hello world\n", "How are you?\n"]);
+    const text = extractText(output);
+    expect(text).toContain("Hello world");
+    expect(text).toContain("How are you?");
+  });
+
+  it("parses yaml-spec fence into patches", async () => {
+    const output = await runTransform([
+      "Here is your UI:\n",
+      "```yaml-spec\n",
+      "root: main\n",
+      "elements:\n",
+      "  main:\n",
+      "    type: Card\n",
+      "    props:\n",
+      "      title: Dashboard\n",
+      "    children: []\n",
+      "```\n",
+    ]);
+
+    const patches = extractPatches(output);
+    expect(patches.length).toBeGreaterThan(0);
+
+    // Should have patched the root
+    const rootPatch = patches.find(
+      (p: any) => p.path === "/root" && p.value === "main",
+    );
+    expect(rootPatch).toBeDefined();
+
+    // Text before the fence should pass through
+    const text = extractText(output);
+    expect(text).toContain("Here is your UI:");
+  });
+
+  it("parses yaml-edit fence with merge semantics", async () => {
+    const previousSpec = {
+      root: "main",
+      elements: {
+        main: {
+          type: "Card",
+          props: { title: "Old Title" },
+          children: [],
+        },
+      },
+    };
+
+    // Only send a yaml-edit block — previousSpec is the base
+    const output = await runTransform(
+      [
+        "```yaml-edit\n",
+        "elements:\n",
+        "  main:\n",
+        "    props:\n",
+        "      title: New Title\n",
+        "```\n",
+      ],
+      { previousSpec: previousSpec as any },
+    );
+
+    const patches = extractPatches(output);
+
+    // Should have at least one patch
+    expect(patches.length).toBeGreaterThan(0);
+
+    // Should include a patch that updates the title — could be at
+    // the leaf level or replacing the whole props object
+    const hasTitleUpdate = patches.some(
+      (p: any) =>
+        (p.path === "/elements/main/props/title" && p.value === "New Title") ||
+        (p.path === "/elements/main/props" &&
+          (p.value as any)?.title === "New Title"),
+    );
+    expect(hasTitleUpdate).toBe(true);
+  });
+
+  it("swallows fence delimiters (not emitted as text)", async () => {
+    const output = await runTransform([
+      "```yaml-spec\n",
+      "root: main\n",
+      "```\n",
+    ]);
+
+    const text = extractText(output);
+    expect(text).not.toContain("```yaml-spec");
+    expect(text).not.toContain("```");
+  });
+
+  it("handles non-text chunks by passing them through", async () => {
+    const transform = createYamlTransform();
+    const input = new ReadableStream<StreamChunk>({
+      start(controller) {
+        controller.enqueue({ type: "tool-call", id: "t1", name: "getWeather" });
+        controller.close();
+      },
+    });
+
+    const reader = input.pipeThrough(transform).getReader();
+    const { value } = await reader.read();
+    expect(value).toEqual({
+      type: "tool-call",
+      id: "t1",
+      name: "getWeather",
+    });
+  });
+});

+ 448 - 0
packages/yaml/src/transform.ts

@@ -0,0 +1,448 @@
+import { parse, stringify } from "yaml";
+import { applyPatch as applyUnifiedDiff } from "diff";
+import {
+  SPEC_DATA_PART_TYPE,
+  deepMergeSpec,
+  diffToPatches,
+  type JsonPatch,
+  type Spec,
+  type StreamChunk,
+} from "@json-render/core";
+import { createYamlStreamCompiler } from "./parser";
+
+export const YAML_SPEC_FENCE = "```yaml-spec";
+export const YAML_EDIT_FENCE = "```yaml-edit";
+export const YAML_PATCH_FENCE = "```yaml-patch";
+export const DIFF_FENCE = "```diff";
+export const FENCE_CLOSE = "```";
+
+export interface YamlTransformOptions {
+  /** Seed with a previous spec for multi-turn edit support. */
+  previousSpec?: Spec;
+}
+
+/**
+ * Creates a `TransformStream` that intercepts AI SDK UI message stream chunks
+ * and converts YAML spec/edit blocks into json-render patch data parts.
+ *
+ * Two fence types are recognised:
+ *
+ * 1. **`\`\`\`yaml-spec`** — Full YAML spec. Parsed progressively, emitting
+ *    patches as each new property is detected.
+ * 2. **`\`\`\`yaml-edit`** — Partial YAML. Deep-merged with the current spec,
+ *    then diffed to produce patches. Only changed keys need to be included.
+ *
+ * Non-fenced text passes through unchanged as `text-delta` chunks, matching
+ * the behaviour of `createJsonRenderTransform` from `@json-render/core`.
+ */
+export function createYamlTransform(
+  options?: YamlTransformOptions,
+): TransformStream<StreamChunk, StreamChunk> {
+  let currentTextId = "";
+  let inTextBlock = false;
+  let textIdCounter = 0;
+
+  let lineBuffer = "";
+  let buffering = false;
+
+  // Fence state
+  let fenceMode: "yaml-spec" | "yaml-edit" | "yaml-patch" | "diff" | null =
+    null;
+  let yamlAccumulated = "";
+  let diffAccumulated = "";
+
+  // Streaming compiler for yaml-spec progressive rendering
+  let compiler = createYamlStreamCompiler<Record<string, unknown>>();
+
+  // The "current spec" — built up during yaml-spec, used as base for yaml-edit
+  let currentSpec: Record<string, unknown> = options?.previousSpec
+    ? structuredClone(
+        options.previousSpec as unknown as Record<string, unknown>,
+      )
+    : {};
+
+  // ── Text block helpers (same pattern as createJsonRenderTransform) ──
+
+  function closeTextBlock(
+    controller: TransformStreamDefaultController<StreamChunk>,
+  ) {
+    if (inTextBlock) {
+      controller.enqueue({ type: "text-end", id: currentTextId });
+      inTextBlock = false;
+    }
+  }
+
+  function ensureTextBlock(
+    controller: TransformStreamDefaultController<StreamChunk>,
+  ) {
+    if (!inTextBlock) {
+      textIdCounter++;
+      currentTextId = String(textIdCounter);
+      controller.enqueue({ type: "text-start", id: currentTextId });
+      inTextBlock = true;
+    }
+  }
+
+  function emitTextDelta(
+    delta: string,
+    controller: TransformStreamDefaultController<StreamChunk>,
+  ) {
+    ensureTextBlock(controller);
+    controller.enqueue({ type: "text-delta", id: currentTextId, delta });
+  }
+
+  function emitPatch(
+    patch: JsonPatch,
+    controller: TransformStreamDefaultController<StreamChunk>,
+  ) {
+    closeTextBlock(controller);
+    controller.enqueue({
+      type: SPEC_DATA_PART_TYPE,
+      data: { type: "patch", patch },
+    });
+  }
+
+  function emitPatches(
+    patches: JsonPatch[],
+    controller: TransformStreamDefaultController<StreamChunk>,
+  ) {
+    for (const patch of patches) {
+      emitPatch(patch, controller);
+    }
+  }
+
+  // ── YAML fence processing ──
+
+  /**
+   * Feed a line of YAML to the streaming compiler (yaml-spec mode).
+   * Emits patches for any newly detected properties.
+   */
+  function feedYamlSpec(
+    line: string,
+    controller: TransformStreamDefaultController<StreamChunk>,
+  ) {
+    yamlAccumulated += line + "\n";
+    const { newPatches } = compiler.push(line + "\n");
+    if (newPatches.length > 0) {
+      emitPatches(newPatches, controller);
+    }
+  }
+
+  /**
+   * Feed a line of YAML for edit mode. We accumulate all lines and process
+   * on fence close since partial edits may not parse until complete.
+   */
+  function feedYamlEdit(line: string) {
+    yamlAccumulated += line + "\n";
+  }
+
+  /**
+   * Finalise a yaml-edit block: parse the accumulated YAML, deep-merge
+   * with the current spec, diff, and emit patches.
+   */
+  function finaliseYamlEdit(
+    controller: TransformStreamDefaultController<StreamChunk>,
+  ) {
+    try {
+      const editObj = parse(yamlAccumulated);
+      if (editObj && typeof editObj === "object" && !Array.isArray(editObj)) {
+        const merged = deepMergeSpec(
+          currentSpec,
+          editObj as Record<string, unknown>,
+        );
+        const patches = diffToPatches(currentSpec, merged);
+        if (patches.length > 0) {
+          currentSpec = merged;
+          emitPatches(patches, controller);
+        }
+      }
+    } catch {
+      // Invalid YAML edit block — silently drop
+    }
+  }
+
+  /**
+   * Finalise a yaml-spec block: flush the compiler for any remaining
+   * partial data and update currentSpec.
+   */
+  function finaliseYamlSpec(
+    controller: TransformStreamDefaultController<StreamChunk>,
+  ) {
+    const { newPatches } = compiler.flush();
+    if (newPatches.length > 0) {
+      emitPatches(newPatches, controller);
+    }
+    currentSpec = structuredClone(
+      compiler.getResult() as Record<string, unknown>,
+    );
+  }
+
+  /**
+   * Finalise a yaml-patch block: parse each accumulated line as an
+   * RFC 6902 JSON Patch operation and emit directly.
+   */
+  function finaliseYamlPatch(
+    controller: TransformStreamDefaultController<StreamChunk>,
+  ) {
+    for (const line of yamlAccumulated.split("\n")) {
+      const trimmed = line.trim();
+      if (!trimmed) continue;
+      try {
+        const patch = JSON.parse(trimmed) as JsonPatch;
+        if (patch.op) {
+          emitPatch(patch, controller);
+          // Update currentSpec for subsequent edits
+          if (patch.op === "add" || patch.op === "replace") {
+            const parts = patch.path.split("/").filter(Boolean);
+            let target: Record<string, unknown> = currentSpec;
+            for (let i = 0; i < parts.length - 1; i++) {
+              const key = parts[i]!;
+              if (typeof target[key] !== "object" || target[key] === null) {
+                target[key] = {};
+              }
+              target = target[key] as Record<string, unknown>;
+            }
+            const lastKey = parts[parts.length - 1];
+            if (lastKey) target[lastKey] = patch.value;
+          } else if (patch.op === "remove") {
+            const parts = patch.path.split("/").filter(Boolean);
+            let target: Record<string, unknown> = currentSpec;
+            for (let i = 0; i < parts.length - 1; i++) {
+              const key = parts[i]!;
+              if (typeof target[key] !== "object" || target[key] === null)
+                break;
+              target = target[key] as Record<string, unknown>;
+            }
+            const lastKey = parts[parts.length - 1];
+            if (lastKey) delete target[lastKey];
+          }
+        }
+      } catch {
+        // Skip invalid JSON lines
+      }
+    }
+  }
+
+  /**
+   * Finalise a diff block: apply the unified diff to the YAML-serialized
+   * current spec, reparse, diff against current, and emit patches.
+   */
+  function finaliseDiff(
+    controller: TransformStreamDefaultController<StreamChunk>,
+  ) {
+    try {
+      const currentYaml = stringify(currentSpec, { indent: 2 });
+      const patched = applyUnifiedDiff(currentYaml, diffAccumulated);
+      if (patched === false) return;
+      const newSpec = parse(patched);
+      if (newSpec && typeof newSpec === "object" && !Array.isArray(newSpec)) {
+        const patches = diffToPatches(
+          currentSpec,
+          newSpec as Record<string, unknown>,
+        );
+        if (patches.length > 0) {
+          currentSpec = newSpec as Record<string, unknown>;
+          emitPatches(patches, controller);
+        }
+      }
+    } catch {
+      // Diff apply or reparse failed
+    }
+  }
+
+  // ── Line processing ──
+
+  function processCompleteLine(
+    line: string,
+    controller: TransformStreamDefaultController<StreamChunk>,
+  ) {
+    const trimmed = line.trim();
+
+    // Fence open detection
+    if (fenceMode === null) {
+      if (trimmed.startsWith(YAML_SPEC_FENCE)) {
+        fenceMode = "yaml-spec";
+        yamlAccumulated = "";
+        compiler.reset(currentSpec);
+        return;
+      }
+      if (trimmed.startsWith(YAML_EDIT_FENCE)) {
+        fenceMode = "yaml-edit";
+        yamlAccumulated = "";
+        return;
+      }
+      if (trimmed.startsWith(YAML_PATCH_FENCE)) {
+        fenceMode = "yaml-patch";
+        yamlAccumulated = "";
+        return;
+      }
+      if (trimmed.startsWith(DIFF_FENCE)) {
+        fenceMode = "diff";
+        diffAccumulated = "";
+        return;
+      }
+    }
+
+    // Fence close detection
+    if (fenceMode !== null && trimmed === FENCE_CLOSE) {
+      if (fenceMode === "yaml-spec") {
+        finaliseYamlSpec(controller);
+      } else if (fenceMode === "yaml-edit") {
+        finaliseYamlEdit(controller);
+      } else if (fenceMode === "yaml-patch") {
+        finaliseYamlPatch(controller);
+      } else if (fenceMode === "diff") {
+        finaliseDiff(controller);
+      }
+      fenceMode = null;
+      return;
+    }
+
+    // Inside a fence
+    if (fenceMode === "yaml-spec") {
+      feedYamlSpec(line, controller);
+      return;
+    }
+    if (fenceMode === "yaml-edit") {
+      feedYamlEdit(line);
+      return;
+    }
+    if (fenceMode === "yaml-patch") {
+      yamlAccumulated += line + "\n";
+      return;
+    }
+    if (fenceMode === "diff") {
+      diffAccumulated += line + "\n";
+      return;
+    }
+
+    // Outside fence — pass through as text
+    if (!trimmed) {
+      emitTextDelta("\n", controller);
+      return;
+    }
+    emitTextDelta(line + "\n", controller);
+  }
+
+  function flushBuffer(
+    controller: TransformStreamDefaultController<StreamChunk>,
+  ) {
+    if (!lineBuffer) return;
+
+    if (fenceMode !== null) {
+      processCompleteLine(lineBuffer, controller);
+    } else {
+      emitTextDelta(lineBuffer, controller);
+    }
+    lineBuffer = "";
+    buffering = false;
+  }
+
+  // ── TransformStream ──
+
+  return new TransformStream<StreamChunk, StreamChunk>({
+    transform(chunk, controller) {
+      switch (chunk.type) {
+        case "text-start": {
+          const id = (chunk as { id: string }).id;
+          const idNum = parseInt(id, 10);
+          if (!isNaN(idNum) && idNum >= textIdCounter) {
+            textIdCounter = idNum;
+          }
+          currentTextId = id;
+          inTextBlock = true;
+          controller.enqueue(chunk);
+          break;
+        }
+
+        case "text-delta": {
+          const delta = chunk as { id: string; delta: string };
+          const text = delta.delta;
+
+          for (let i = 0; i < text.length; i++) {
+            const ch = text.charAt(i);
+
+            if (ch === "\n") {
+              if (buffering) {
+                processCompleteLine(lineBuffer, controller);
+                lineBuffer = "";
+                buffering = false;
+              } else if (fenceMode === null) {
+                emitTextDelta("\n", controller);
+              }
+            } else if (lineBuffer.length === 0 && !buffering) {
+              // Inside a fence, buffer everything. Outside, only buffer
+              // potential fence-open lines (start with backtick).
+              if (fenceMode !== null || ch === "`") {
+                buffering = true;
+                lineBuffer += ch;
+              } else {
+                emitTextDelta(ch, controller);
+              }
+            } else if (buffering) {
+              lineBuffer += ch;
+            } else {
+              emitTextDelta(ch, controller);
+            }
+          }
+          break;
+        }
+
+        case "text-end": {
+          flushBuffer(controller);
+          if (inTextBlock) {
+            controller.enqueue({ type: "text-end", id: currentTextId });
+            inTextBlock = false;
+          }
+          break;
+        }
+
+        default: {
+          controller.enqueue(chunk);
+          break;
+        }
+      }
+    },
+
+    flush(controller) {
+      flushBuffer(controller);
+      if (fenceMode === "yaml-spec") {
+        finaliseYamlSpec(controller);
+      } else if (fenceMode === "yaml-edit") {
+        finaliseYamlEdit(controller);
+      } else if (fenceMode === "yaml-patch") {
+        finaliseYamlPatch(controller);
+      } else if (fenceMode === "diff") {
+        finaliseDiff(controller);
+      }
+      closeTextBlock(controller);
+    },
+  });
+}
+
+/**
+ * Convenience wrapper that pipes an AI SDK UI message stream through the
+ * YAML transform, converting YAML spec/edit blocks into json-render patches.
+ *
+ * Drop-in replacement for `pipeJsonRender` from `@json-render/core`.
+ *
+ * @example
+ * ```ts
+ * import { pipeYamlRender } from "@json-render/yaml";
+ *
+ * const stream = createUIMessageStream({
+ *   execute: async ({ writer }) => {
+ *     writer.merge(pipeYamlRender(result.toUIMessageStream()));
+ *   },
+ * });
+ * return createUIMessageStreamResponse({ stream });
+ * ```
+ */
+export function pipeYamlRender<T = StreamChunk>(
+  stream: ReadableStream<T>,
+  options?: YamlTransformOptions,
+): ReadableStream<T> {
+  return stream.pipeThrough(
+    createYamlTransform(options) as unknown as TransformStream<T, T>,
+  );
+}

+ 9 - 0
packages/yaml/tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "extends": "@internal/typescript-config/base.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "dist"]
+}

+ 10 - 0
packages/yaml/tsup.config.ts

@@ -0,0 +1,10 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+  entry: ["src/index.ts"],
+  format: ["cjs", "esm"],
+  dts: true,
+  sourcemap: true,
+  clean: true,
+  external: ["@json-render/core", "yaml", "diff"],
+});

+ 7 - 0
packages/yaml/vitest.config.ts

@@ -0,0 +1,7 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+  test: {
+    include: ["src/**/*.test.ts"],
+  },
+});

+ 119 - 20
pnpm-lock.yaml

@@ -83,6 +83,9 @@ importers:
       '@json-render/react':
         specifier: workspace:*
         version: link:../../packages/react
+      '@json-render/yaml':
+        specifier: workspace:*
+        version: link:../../packages/yaml
       '@mdx-js/loader':
         specifier: ^3.1.1
         version: 3.1.1(webpack@5.96.1)
@@ -131,15 +134,15 @@ importers:
       clsx:
         specifier: ^2.1.1
         version: 2.1.1
+      diff:
+        specifier: ^8.0.3
+        version: 8.0.3
       embla-carousel-react:
         specifier: ^8.6.0
         version: 8.6.0(react@19.2.3)
       geist:
         specifier: 1.7.0
         version: 1.7.0(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
-      just-bash:
-        specifier: 2.9.6
-        version: 2.9.6
       lucide-react:
         specifier: ^0.562.0
         version: 0.562.0(react@19.2.3)
@@ -182,6 +185,9 @@ importers:
       vaul:
         specifier: ^1.1.2
         version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+      yaml:
+        specifier: ^2.8.2
+        version: 2.8.2
       zod:
         specifier: ^4.3.6
         version: 4.3.6
@@ -1870,6 +1876,34 @@ importers:
         specifier: ^5.4.5
         version: 5.9.3
 
+  packages/yaml:
+    dependencies:
+      '@json-render/core':
+        specifier: workspace:*
+        version: link:../core
+      diff:
+        specifier: ^8.0.3
+        version: 8.0.3
+      yaml:
+        specifier: ^2.8.2
+        version: 2.8.2
+    devDependencies:
+      '@internal/typescript-config':
+        specifier: workspace:*
+        version: link:../typescript-config
+      tsup:
+        specifier: ^8.0.2
+        version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
+      typescript:
+        specifier: ^5.4.5
+        version: 5.9.3
+      vitest:
+        specifier: ^4.0.17
+        version: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@22.19.6)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+      zod:
+        specifier: ^4.3.6
+        version: 4.3.6
+
   packages/zustand:
     dependencies:
       '@json-render/core':
@@ -13767,7 +13801,8 @@ snapshots:
 
   '@bcoe/v8-coverage@0.2.3': {}
 
-  '@borewit/text-codec@0.2.1': {}
+  '@borewit/text-codec@0.2.1':
+    optional: true
 
   '@changesets/apply-release-plan@7.0.14':
     dependencies:
@@ -15251,7 +15286,7 @@ snapshots:
       '@mdx-js/mdx': 3.1.1
       source-map: 0.7.6
     optionalDependencies:
-      webpack: 5.96.1(esbuild@0.25.0)
+      webpack: 5.96.1
     transitivePeerDependencies:
       - supports-color
 
@@ -15293,7 +15328,8 @@ snapshots:
 
   '@mediapipe/tasks-vision@0.10.17': {}
 
-  '@mixmark-io/domino@2.2.0': {}
+  '@mixmark-io/domino@2.2.0':
+    optional: true
 
   '@modelcontextprotocol/ext-apps@1.2.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6)':
     dependencies:
@@ -18287,7 +18323,7 @@ snapshots:
   '@stripe/ui-extension-tools@0.0.1(@babel/core@7.29.0)(babel-jest@27.5.1(@babel/core@7.29.0))':
     dependencies:
       '@types/jest': 28.1.8
-      '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
+      '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
       '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
       eslint: 8.57.1
       eslint-plugin-react: 7.37.5(eslint@8.57.1)
@@ -18584,8 +18620,10 @@ snapshots:
       token-types: 6.1.2
     transitivePeerDependencies:
       - supports-color
+    optional: true
 
-  '@tokenizer/token@0.3.0': {}
+  '@tokenizer/token@0.3.0':
+    optional: true
 
   '@tootallnate/once@1.1.2': {}
 
@@ -18823,7 +18861,7 @@ snapshots:
       '@types/node': 22.19.6
     optional: true
 
-  '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
+  '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
     dependencies:
       '@eslint-community/regexpp': 4.12.2
       '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
@@ -19429,7 +19467,8 @@ snapshots:
 
   alien-signals@3.1.2: {}
 
-  amdefine@1.0.1: {}
+  amdefine@1.0.1:
+    optional: true
 
   anser@1.4.10: {}
 
@@ -20087,6 +20126,7 @@ snapshots:
   commander@2.8.1:
     dependencies:
       graceful-readlink: 1.0.1
+    optional: true
 
   commander@4.1.1: {}
 
@@ -20112,6 +20152,7 @@ snapshots:
     dependencies:
       amdefine: 1.0.1
       commander: 2.8.1
+    optional: true
 
   concat-map@0.0.1: {}
 
@@ -21505,6 +21546,7 @@ snapshots:
   fast-xml-parser@5.3.5:
     dependencies:
       strnum: 2.1.2
+    optional: true
 
   fastq@1.20.1:
     dependencies:
@@ -21555,6 +21597,7 @@ snapshots:
       uint8array-extras: 1.5.0
     transitivePeerDependencies:
       - supports-color
+    optional: true
 
   fill-range@7.1.1:
     dependencies:
@@ -21836,7 +21879,8 @@ snapshots:
 
   graceful-fs@4.2.11: {}
 
-  graceful-readlink@1.0.1: {}
+  graceful-readlink@1.0.1:
+    optional: true
 
   graphemer@1.4.0: {}
 
@@ -22146,7 +22190,8 @@ snapshots:
 
   ini@1.3.8: {}
 
-  ini@6.0.0: {}
+  ini@6.0.0:
+    optional: true
 
   inline-style-parser@0.2.7: {}
 
@@ -23065,6 +23110,7 @@ snapshots:
       - bufferutil
       - supports-color
       - utf-8-validate
+    optional: true
 
   keyv@4.5.4:
     dependencies:
@@ -24061,7 +24107,8 @@ snapshots:
       pkg-types: 1.3.1
       ufo: 1.6.2
 
-  modern-tar@0.7.3: {}
+  modern-tar@0.7.3:
+    optional: true
 
   mri@1.2.0: {}
 
@@ -24455,7 +24502,8 @@ snapshots:
 
   pako@1.0.11: {}
 
-  papaparse@5.5.3: {}
+  papaparse@5.5.3:
+    optional: true
 
   parent-module@1.0.1:
     dependencies:
@@ -24749,6 +24797,7 @@ snapshots:
     transitivePeerDependencies:
       - bufferutil
       - utf-8-validate
+    optional: true
 
   qrcode-terminal@0.11.0: {}
 
@@ -24919,7 +24968,8 @@ snapshots:
       minimist: 1.2.8
       strip-json-comments: 2.0.1
 
-  re2js@1.2.1: {}
+  re2js@1.2.1:
+    optional: true
 
   react-confetti-explosion@3.0.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
     dependencies:
@@ -26025,7 +26075,8 @@ snapshots:
 
   slugify@1.6.6: {}
 
-  smol-toml@1.6.0: {}
+  smol-toml@1.6.0:
+    optional: true
 
   solid-js@1.9.11:
     dependencies:
@@ -26082,9 +26133,11 @@ snapshots:
 
   sprintf-js@1.0.3: {}
 
-  sprintf-js@1.1.3: {}
+  sprintf-js@1.1.3:
+    optional: true
 
-  sql.js@1.13.0: {}
+  sql.js@1.13.0:
+    optional: true
 
   stack-utils@2.0.6:
     dependencies:
@@ -26292,11 +26345,13 @@ snapshots:
       '@types/node': 22.19.6
       qs: 6.14.1
 
-  strnum@2.1.2: {}
+  strnum@2.1.2:
+    optional: true
 
   strtok3@10.3.4:
     dependencies:
       '@tokenizer/token': 0.3.0
+    optional: true
 
   structured-headers@0.4.1: {}
 
@@ -26485,6 +26540,16 @@ snapshots:
     optionalDependencies:
       esbuild: 0.25.0
 
+  terser-webpack-plugin@5.3.16(webpack@5.96.1):
+    dependencies:
+      '@jridgewell/trace-mapping': 0.3.31
+      jest-worker: 27.5.1
+      schema-utils: 4.3.3
+      serialize-javascript: 6.0.2
+      terser: 5.46.0
+      webpack: 5.96.1
+    optional: true
+
   terser@5.46.0:
     dependencies:
       '@jridgewell/source-map': 0.3.11
@@ -26582,6 +26647,7 @@ snapshots:
       '@borewit/text-codec': 0.2.1
       '@tokenizer/token': 0.3.0
       ieee754: 1.2.1
+    optional: true
 
   totalist@3.0.1: {}
 
@@ -26761,6 +26827,7 @@ snapshots:
   turndown@7.2.2:
     dependencies:
       '@mixmark-io/domino': 2.2.0
+    optional: true
 
   tw-animate-css@1.4.0: {}
 
@@ -26844,7 +26911,8 @@ snapshots:
 
   ufo@1.6.2: {}
 
-  uint8array-extras@1.5.0: {}
+  uint8array-extras@1.5.0:
+    optional: true
 
   unbox-primitive@1.1.0:
     dependencies:
@@ -27312,6 +27380,37 @@ snapshots:
 
   webpack-sources@3.3.3: {}
 
+  webpack@5.96.1:
+    dependencies:
+      '@types/eslint-scope': 3.7.7
+      '@types/estree': 1.0.8
+      '@webassemblyjs/ast': 1.14.1
+      '@webassemblyjs/wasm-edit': 1.14.1
+      '@webassemblyjs/wasm-parser': 1.14.1
+      acorn: 8.15.0
+      browserslist: 4.28.1
+      chrome-trace-event: 1.0.4
+      enhanced-resolve: 5.19.0
+      es-module-lexer: 1.7.0
+      eslint-scope: 5.1.1
+      events: 3.3.0
+      glob-to-regexp: 0.4.1
+      graceful-fs: 4.2.11
+      json-parse-even-better-errors: 2.3.1
+      loader-runner: 4.3.1
+      mime-types: 2.1.35
+      neo-async: 2.6.2
+      schema-utils: 3.3.0
+      tapable: 2.3.0
+      terser-webpack-plugin: 5.3.16(webpack@5.96.1)
+      watchpack: 2.5.1
+      webpack-sources: 3.3.3
+    transitivePeerDependencies:
+      - '@swc/core'
+      - esbuild
+      - uglify-js
+    optional: true
+
   webpack@5.96.1(esbuild@0.25.0):
     dependencies:
       '@types/eslint-scope': 3.7.7

+ 10 - 2
skills/core/SKILL.md

@@ -159,13 +159,15 @@ import { buildUserPrompt } from "@json-render/core";
 // Fresh generation
 buildUserPrompt({ prompt: "create a todo app" });
 
-// Refinement (patch-only mode)
-buildUserPrompt({ prompt: "add a toggle", currentSpec: spec });
+// Refinement with edit modes (default: patch-only)
+buildUserPrompt({ prompt: "add a toggle", currentSpec: spec, editModes: ["patch", "merge"] });
 
 // With runtime state
 buildUserPrompt({ prompt: "show data", state: { todos: [] } });
 ```
 
+Available edit modes: `"patch"` (RFC 6902 JSON Patch), `"merge"` (RFC 7396 Merge Patch), `"diff"` (unified diff).
+
 ## Spec Validation
 
 Validate spec structure and auto-fix common issues:
@@ -244,6 +246,12 @@ The `StateStore` interface: `get(path)`, `set(path, value)`, `update(updates)`,
 | `resolvePropValue` | Resolve a single prop expression against data |
 | `resolveElementProps` | Resolve all prop expressions in an element |
 | `buildUserPrompt` | Build user prompts with refinement and state context |
+| `buildEditUserPrompt` | Build user prompt for editing existing specs |
+| `buildEditInstructions` | Generate prompt section for available edit modes |
+| `isNonEmptySpec` | Check if spec has root and at least one element |
+| `deepMergeSpec` | RFC 7396 deep merge (null deletes, arrays replace, objects recurse) |
+| `diffToPatches` | Generate RFC 6902 JSON Patch operations from object diff |
+| `EditMode` | Type: `"patch" \| "merge" \| "diff"` |
 | `validateSpec` | Validate spec structure |
 | `autoFixSpec` | Auto-fix common spec issues |
 | `createSpecStreamCompiler` | Stream JSONL patches into spec |

+ 141 - 0
skills/yaml/SKILL.md

@@ -0,0 +1,141 @@
+---
+name: yaml
+description: YAML wire format for json-render with streaming parser, prompt generation, and AI SDK transform. Use when working with @json-render/yaml, YAML-based spec streaming, yaml-spec/yaml-edit fences, or YAML prompt generation.
+---
+
+# @json-render/yaml
+
+YAML wire format for `@json-render/core`. Progressive rendering and surgical edits via streaming YAML.
+
+## Key Concepts
+
+- **YAML wire format**: Alternative to JSONL that uses code fences (`yaml-spec`, `yaml-edit`, `yaml-patch`, `diff`)
+- **Streaming parser**: Incrementally parses YAML, emits JSON Patch operations via diffing
+- **Edit modes**: Patch (RFC 6902), merge (RFC 7396), and unified diff
+- **AI SDK transform**: `TransformStream` that converts YAML fences into json-render patches
+
+## Generating YAML Prompts
+
+```typescript
+import { yamlPrompt } from "@json-render/yaml";
+import { catalog } from "./catalog";
+
+// Standalone mode (LLM outputs only YAML)
+const systemPrompt = yamlPrompt(catalog, {
+  mode: "standalone",
+  editModes: ["merge"],
+  customRules: ["Always use dark theme"],
+});
+
+// Inline mode (LLM responds conversationally, wraps YAML in fences)
+const chatPrompt = yamlPrompt(catalog, { mode: "inline" });
+```
+
+Options:
+
+- `system` (string) — Custom system message intro
+- `mode` ("standalone" | "inline") — Output mode, default "standalone"
+- `customRules` (string[]) — Additional rules appended to prompt
+- `editModes` (EditMode[]) — Edit modes to document, default ["merge"]
+
+## AI SDK Transform
+
+Use `pipeYamlRender` as a drop-in replacement for `pipeJsonRender`:
+
+```typescript
+import { pipeYamlRender } from "@json-render/yaml";
+import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
+
+const stream = createUIMessageStream({
+  execute: async ({ writer }) => {
+    writer.merge(pipeYamlRender(result.toUIMessageStream()));
+  },
+});
+return createUIMessageStreamResponse({ stream });
+```
+
+For multi-turn edits, pass the previous spec:
+
+```typescript
+pipeYamlRender(result.toUIMessageStream(), {
+  previousSpec: currentSpec,
+});
+```
+
+The transform recognizes four fence types:
+
+- `yaml-spec` — Full spec, parsed progressively line-by-line
+- `yaml-edit` — Partial YAML deep-merged with current spec (RFC 7396)
+- `yaml-patch` — RFC 6902 JSON Patch lines
+- `diff` — Unified diff applied to serialized spec
+
+## Streaming Parser (Low-Level)
+
+```typescript
+import { createYamlStreamCompiler } from "@json-render/yaml";
+
+const compiler = createYamlStreamCompiler<Spec>();
+
+// Feed chunks as they arrive from any source
+const { result, newPatches } = compiler.push("root: main\n");
+compiler.push("elements:\n  main:\n    type: Card\n");
+
+// Flush remaining data at end of stream
+const { result: final } = compiler.flush();
+
+// Reset for next stream (optionally with initial state)
+compiler.reset({ root: "main", elements: {} });
+```
+
+Methods: `push(chunk)`, `flush()`, `getResult()`, `getPatches()`, `reset(initial?)`
+
+## Edit Modes (from @json-render/core)
+
+The YAML package uses the universal edit mode system from core:
+
+```typescript
+import { buildEditInstructions, buildEditUserPrompt } from "@json-render/core";
+import type { EditMode } from "@json-render/core";
+
+// Generate edit instructions for YAML format
+const instructions = buildEditInstructions({ modes: ["merge", "patch"] }, "yaml");
+
+// Build user prompt with current spec context
+const userPrompt = buildEditUserPrompt({
+  prompt: "Change the title to Dashboard",
+  currentSpec: spec,
+  config: { modes: ["merge"] },
+  format: "yaml",
+  serializer: (s) => yamlStringify(s, { indent: 2 }).trimEnd(),
+});
+```
+
+## Fence Constants
+
+For custom parsing, use the exported constants:
+
+```typescript
+import {
+  YAML_SPEC_FENCE,   // "```yaml-spec"
+  YAML_EDIT_FENCE,   // "```yaml-edit"
+  YAML_PATCH_FENCE,  // "```yaml-patch"
+  DIFF_FENCE,        // "```diff"
+  FENCE_CLOSE,       // "```"
+} from "@json-render/yaml";
+```
+
+## Key Exports
+
+| Export | Description |
+|--------|-------------|
+| `yamlPrompt` | Generate YAML system prompt from catalog |
+| `createYamlTransform` | AI SDK TransformStream for YAML fences |
+| `pipeYamlRender` | Convenience pipe wrapper (replaces `pipeJsonRender`) |
+| `createYamlStreamCompiler` | Streaming YAML parser with patch emission |
+| `YAML_SPEC_FENCE` | Fence constant for yaml-spec |
+| `YAML_EDIT_FENCE` | Fence constant for yaml-edit |
+| `YAML_PATCH_FENCE` | Fence constant for yaml-patch |
+| `DIFF_FENCE` | Fence constant for diff |
+| `FENCE_CLOSE` | Fence close constant |
+| `diffToPatches` | Re-export: object diff to JSON Patch |
+| `deepMergeSpec` | Re-export: RFC 7396 deep merge |