# UJSX v2: TypeBox-Schema-Driven Universal JSX IR ## What Exists: The Current POC The `/workspace/aui` codebase has two packages: - **`@ade/ujsx`** — Core universal JSX IR: `h()` factory, `UniversalElement`/`RootElement` types, `HostConfig` reconciler, `TransformRegistry`, `StreamingTransformer` - **`@ade/aui`** — Markdown consumer: `jsxToMdast` transform rules, `renderMarkdown()` via mdast-util-to-markdown + GFM The architecture is already right at a high level: ``` JSX (h calls) → UniversalNode tree → Host/Transform → Output ``` ## What's Wrong 1. **No actual JSX syntax** — Uses `h()` calls instead of `` syntax. Needs a JSX transform config (like Hono's `tsconfig.json` jsxFactory) so you can write real JSX. 2. **HTML baggage** — `UniversalProps` has `onClick`, `onSubmit`, `onInput`, `onChange`, `className`, `aria-*`, `data-*` — all HTML-specific. For a universal IR targeting markdown (and eventually other formats), these are noise. 3. **No schema-driven types** — Types are hand-written interfaces. The whole point of using TypeBox is that the IR *is* a schema — nodes can be validated at runtime, composed generically, and converted to JSON Schema for tool definitions. 4. **Non-deterministic IDs** — `genId()` uses `Math.random()`. Needs to be injectable/deterministic. 5. **Separate props + children** — `UniversalElement` has both `props` and `children`. This is the React/HTML model where `children` is a special prop. For a universal IR, children is just a prop like any other. Simplify. 6. **No Context system** — React's Context/Provider pattern is essential for passing density/target/host config down the tree without prop drilling. 7. **Fragment not integrated** — Exists but reconciler and transforms don't handle it specially. 8. **Missing markdown nodes** — No blockquote, link, image, hr, table support. ## The Rewrite: TypeBox-Schema-Driven UJSX ### Core Principle **The UniversalElement IS a TypeBox schema at runtime.** Every node in the tree is both a valid TypeScript value AND a valid JSON Schema value. This means: - Runtime validation: `Value.Check(UElementSchema, node)` - Tool schema generation: Convert any component's props schema to JSON Schema for agent tool definitions - Generic composition: `Type.Intersect()`, `Type.Partial()`, `Type.Omit()` work on node schemas - Bi-directional transforms: Rules match on schema shape, not string tags ### TypeBox AST Schema ```typescript import { Type, Static, TSchema } from "@alkdev/typebox" // The recursive node schema — the heart of the system const UNodeSchema = Type.Recursive((This) => Type.Union([ Type.Null(), Type.String(), Type.Number(), Type.Boolean(), Type.Object({ type: Type.Union([Type.String(), Type.Function([Type.Object({}), This], This)]), props: Type.Record(Type.String(), Type.Any()), children: Type.Array(This), }), ]), { $id: "UNode" } ) type UNode = Static // Refined: element is the object branch const UElementSchema = Type.Object({ type: Type.Union([Type.String(), Type.Function([Type.Object({}), UNodeSchema], UNodeSchema)]), props: Type.Record(Type.String(), Type.Any()), children: Type.Array(UNodeSchema), }) type UElement = Static ``` Wait — `Type.Function` in TypeBox creates a JavaScript extended schema type, not a JSON Schema one. For the IR's purposes, we want function components to be part of the *runtime* TypeScript type but NOT part of the serializable schema. The schema (what gets validated and what tools see) should only have string types. Better approach: separate the runtime type from the serializable schema: ```typescript // Serializable schema — what validation and tool definitions see const USerializableElementSchema = Type.Recursive((This) => Type.Object({ type: Type.String(), // Always a string after component resolution props: Type.Record(Type.String(), Type.Any()), children: Type.Array(Type.Union([ Type.String(), Type.Number(), Type.Null(), This, ])), }), { $id: "UElement" } ) // Runtime TypeScript type — includes function components type UType = string | ComponentFn type UNode = UElement | string | number | null type UElement = { type: UType props: Record children: UNode[] } ``` Actually, let me think about this differently. The key insight from the user's message is **bi-directional transforms**. The current `TransformRegistry` goes one direction (UJSX → mdast). The universal IR concept means rules should go both ways: - UJSX → mdast (for markdown rendering) - mdast → UJSX (for parsing markdown back into the IR) - UJSX → hast (for HTML rendering) - hast → UJSX (for parsing HTML) - Eventually: UJSX → Graphology, UJSX → terminal ANSI, etc. This is where TypeBox schemas really shine: **each node type's schema IS the match criterion**. Instead of string-based matching (`n.type === 'h1'`), rules match on schema shape: ```typescript // Current: string-based matching jsxToMdast.register({ name: 'heading', match: (n) => isUniversalElement(n) && typeof n.type === 'string' && /^h[1-6]$/.test(n.type), transform: ... }) // TypeBox-driven: schema-based matching const HeadingSchema = Type.Object({ type: Type.String({ pattern: '^h[1-6]$' }), props: Type.Object({ level: Type.Number().optional() }), children: Type.Array(UNodeSchema), }) rule({ name: 'heading', schema: HeadingSchema, direction: 'ujsx->mdast', transform: (node, ctx, next) => ({ type: 'heading', depth: Number(node.type.slice(1)), children: transformChildren(node, ctx, next), }), }) ``` This is more verbose but gives us: 1. `Value.Check(HeadingSchema, node)` — validate before transforming 2. The schema can be registered as a tool parameter schema 3. Bi-directional: a `mdast->ujsx` rule for heading uses the same schema 4. `Type.Intersect()` to compose schemas for compound elements ### Simplified Element Model Remove HTML-specific stuff. The `props` becomes a plain `Record` with no HTML-centric defaults. Event handlers (`onClick`, etc.) and HTML attributes (`className`, `aria-*`) are gone from the core — they belong in an HTML host extension, not the universal IR. ```typescript // core/schema.ts import { Type, Static } from "@alkdev/typebox" // Primitives const UPrimitive = Type.Union([Type.String(), Type.Number(), Type.Null()]) // Element — the universal IR node const UElement = Type.Recursive((This) => Type.Object({ type: Type.String(), props: Type.Record(Type.String(), Type.Any()), children: Type.Array(Type.Union([UPrimitive, This])), }, { $id: "UElement" }), ) // Root — session/frame container const URoot = Type.Object({ type: Type.Literal("root"), props: Type.Object({ id: Type.Optional(Type.String()), target: Type.Optional(Type.String()), }), children: Type.Array(Type.Union([UPrimitive, UElement])), }, { $id: "URoot" }) // Node = everything a position in the tree can hold const UNode = Type.Union([UPrimitive, UElement, URoot], { $id: "UNode" }) type UElement = Static type URoot = Static type UNode = Static ``` ### The `h()` Factory (Cleaned Up) ```typescript // core/h.ts import type { UNode, UElement, URoot } from './schema.ts' let _idCounter = 0 export function h( type: string, props?: Record | null, ...children: UNode[] ): UElement { return { type, props: props ?? {}, children: children.flat(Infinity).filter(c => c != null && c !== false) as UNode[], } } export function createRoot(id?: string, ...children: UNode[]): URoot { return { type: 'root', props: { id }, children: children.flat(Infinity).filter(c => c != null && c !== false) as UNode[], } } // Alias for JSX transform compatibility export const jsx = h export const jsxs = h export const jsxDEV = h // Fragment: children pass-through export function Fragment(props: { children?: UNode[] }): UNode[] { return (props.children ?? []).flat(Infinity).filter(c => c != null && c !== false) as UNode[] } ``` Key changes from current: - **No metadata injection** — `timestamp` and `id` on every element was overhead without clear use. If needed, it belongs in the host, not the IR. - **No `className`/event handlers** — Universal props, not HTML props. - **Deterministic by default** — No `Math.random()`. ### JSX Transform Config To support actual JSX syntax (``), you need a `tsconfig.json` or `deno.json` with: ```json { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "@ade/ujsx" } } ``` And an `jsx-runtime` module that exports `jsx`, `jsxs`, `jsxDEV`, `Fragment`. This is what Hono does (`hono/jsx/jsx-runtime`). ### Host System (Preserved, Simplified) The `HostConfig` interface is good. Keep it but remove the HTML-specific assumptions: ```typescript // host/config.ts export interface HostConfig { name: string createRootContext(container: unknown, options?: Record): RootCtx finalizeRoot?(ctx: RootCtx): void createInstance(tag: TTag, props: Record, ctx: RootCtx, parent?: Instance): Instance createTextInstance(text: string, ctx: RootCtx, parent?: Instance): Instance appendChild(parent: Instance, child: Instance, ctx: RootCtx): void insertBefore?(parent: Instance, child: Instance, before: Instance, ctx: RootCtx): void removeChild?(parent: Instance, child: Instance, ctx: RootCtx): void prepareUpdate?(instance: Instance, tag: TTag, prevProps: Record, nextProps: Record, ctx: RootCtx): unknown | null commitUpdate?(instance: Instance, payload: unknown, tag: TTag, prevProps: Record, nextProps: Record, ctx: RootCtx): void } ``` No changes needed. The host is host-agnostic already. The graphology host stays. A markdown host can be added: ```typescript // host/markdown.ts export function createMarkdownHost(): HostConfig { // Maps universal tags to mdast node creation // Uses the same rules as the TransformRegistry but via HostConfig } ``` ### TypeBox-Driven Transform Rules The key evolution of the `TransformRegistry`. Rules are now schema-aware and bi-directional: ```typescript // transform/rule.ts import { Type, TSchema, Value } from "@alkdev/typebox" export type Direction = 'ujsx->mdast' | 'mdast->ujsx' | 'ujsx->hast' | 'hast->ujsx' export interface TransformRule { name: string direction: Direction schema: TSchema // The TypeBox schema this rule matches against match: (node: TInput) => boolean // Runtime match (can use schema or custom logic) transform: (node: TInput, ctx: TransformContext, next: TransformFn) => TOutput priority?: number } export class TransformRegistry { private rules: TransformRule[] = [] register(rule: TransformRule) { this.rules.push(rule) this.rules.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) } transform(node: TInput, ctx: TransformContext, direction?: Direction): TOutput { const rule = direction ? this.rules.find(r => r.direction === direction && r.match(node)) : this.rules.find(r => r.match(node)) if (!rule) throw new Error(`No rule for node`) return rule.transform(node, ctx, (n, c) => this.transform(n, c, direction)) } } ``` With schema-based matching, we can also do: ```typescript // Validate a node against a rule's schema before transforming function matchesSchema(schema: TSchema, node: unknown): boolean { return Value.Check(schema, node) } ``` ### Bi-Directional Markdown Rules ```typescript // markdown/rules.ts import { Type } from "@alkdev/typebox" const HeadingUSchema = Type.Object({ type: Type.String({ pattern: '^h[1-6]$' }), props: Type.Record(Type.String(), Type.Any()), children: Type.Array(Type.Any()), }) const HeadingMdastSchema = Type.Object({ type: Type.Literal('heading'), depth: Type.Number(), children: Type.Array(Type.Any()), }) // UJSX → mdast registry.register({ name: 'heading-ujsx-to-mdast', direction: 'ujsx->mdast', schema: HeadingUSchema, match: (n) => isUElement(n) && /^h[1-6]$/.test(String(n.type)), transform: (n, ctx, next) => ({ type: 'heading', depth: Number(String(n.type).slice(1)), children: transformChildren(n, ctx, next), }), }) // mdast → UJSX registry.register({ name: 'heading-mdast-to-ujsx', direction: 'mdast->ujsx', schema: HeadingMdastSchema, match: (n) => n.type === 'heading', transform: (n, ctx, next) => ({ type: `h${n.depth}`, props: {}, children: (n.children || []).map((c, i) => next(c, childCtx(n, ctx, i))), }), }) ``` This is the bi-directional concept. Same registry, same `transform()` call, different `direction` parameter. The schemas provide the contract for each direction. ### Component Schema = Tool Schema The real payoff of TypeBox-driven schemas: any component's props schema is automatically a tool parameter schema. ```typescript // A HUD component defined with TypeBox const TaskSectionProps = Type.Object({ task: Type.Object({ description: Type.String(), updatedAt: Type.Number(), }), density: Type.Union([Type.Literal('full'), Type.Literal('compact'), Type.Literal('minimal')]), }) // The component function TaskSection(props: Static): UElement { if (props.density === 'minimal') { return h('p', null, props.task.description) } return h('div', null, h('h2', null, 'Task'), h('p', null, props.task.description), ) } // TaskSectionProps IS the schema for an agent tool that sets the task: // hud({ tool: "task.set", args: { description: "...", updatedAt: Date.now() } }) // The tool's inputSchema = TaskSectionProps ``` No duplication. The component's type IS the tool's schema. `Value.Check(TaskSectionProps, input)` validates both. ### Context System Needed for passing density/target config down the tree without prop drilling: ```typescript // core/context.ts export interface ContextValue { density: 'full' | 'compact' | 'minimal' target: string // 'markdown' | 'graph' | 'html' | etc. metadata: Record } // Simple context implementation (no React dependency) type ContextListener = (value: ContextValue) => void export class Context { private value: ContextValue private listeners: Set = new Set() constructor(initial: ContextValue) { this.value = initial } get(): ContextValue { return this.value } set(partial: Partial) { this.value = { ...this.value, ...partial } for (const fn of this.listeners) fn(this.value) } subscribe(fn: ContextListener): () => void { this.listeners.add(fn) return () => this.listeners.delete(fn) } } ``` This is lightweight and doesn't need React. Components receive context via the `host` or `transform` path. ### File Structure (Proposed Rewrite) ``` ujsx/ mod.ts # Re-exports deno.json # JSX transform config core/ schema.ts # TypeBox schemas for UNode, UElement, URoot h.ts # h() factory, createRoot(), Fragment context.ts # Context system for density/target jsx-runtime.ts # JSX transform runtime exports host/ config.ts # HostConfig interface + createRoot reconciler (preserved) graphology.ts # Graphology host (preserved, fix commented-out append) markdown.ts # NEW: Markdown host using HostConfig pattern transform/ registry.ts # Enhanced TransformRegistry with direction + schema rule.ts # TransformRule type with TSchema streaming/ transformer.ts # Chunk-based async transformer (preserved) aui/ mod.ts deno.json markdown/ render.ts # renderMarkdown() (preserved, uses new registry) rules.ts # Bi-directional rules with TypeBox schemas types.ts # mdast type re-exports components/ # NEW: Pre-built markdown-targeted components container.ts # context-bar.ts # with density awareness heading.ts # list.ts # , , code.ts # , blockquote.ts # NEW:
link.ts # NEW: table.ts # NEW: hr.ts # NEW: hud/ # NEW: HUD-specific components HUD.tsx # Root HUD component TaskSection.tsx # Task display DecisionsList.tsx # Key decisions NotesSection.tsx # Scratchpad NextSteps.tsx # Plan/next steps ActiveFiles.tsx # Currently active files WarningBanner.tsx # Context pressure warning ``` ### tsconfig for JSX Syntax ```json { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "@ade/ujsx" } } ``` With this, you write: ```tsx import { h } from "@ade/ujsx" function TaskSection({ task, density }: TaskSectionProps) { if (density === 'minimal') { return

{task.description}

} return (

Task

{task.description}

) } ``` Instead of: ```typescript function TaskSection({ task, density }: TaskSectionProps) { if (density === 'minimal') { return h('p', null, task.description) } return h('div', null, h('h2', null, 'Task'), h('p', null, task.description), ) } ``` ### Migration Steps 1. **Replace types.ts with schema.ts** — TypeBox schemas for UNode/UElement/URoot. Export both schemas and `Static<>` types. 2. **Clean up h.ts** — Remove metadata injection, HTML-specific props, `Math.random()`. Add `jsx-runtime.ts` export. 3. **Add deno.json jsxImportSource** — Enable JSX syntax. 4. **Strip UniversalProps** — Replace with plain `Record`. No `onClick`, `className`, etc. 5. **Upgrade TransformRegistry** — Add `direction` and `schema` fields to rules. 6. **Add missing markdown rules** — blockquote, link, image, hr, table. 7. **Add Context** — Simple context system for density/target. 8. **Fix graphology host** — Uncomment append logic, replace `JSON.stringify` diffing. 9. **Add Markdown host** — `createMarkdownHost()` using HostConfig. 10. **Add bi-directional rules** — mdast → ujsx direction for each element. 11. **Build HUD components** — The `aui/hud/` directory with density-aware components. ### What This Enables for the Agent HUD With the cleaned-up UJSX: - Components ARE schemas — any HUD component's props schema is automatically a tool parameter schema - Bi-directional transforms mean we can parse markdown INTO the IR (parse existing agent notes, markdown files, AGENTS.md) - The host system means the same HUD component tree could render to markdown for LLM consumption OR to a graph for visualization OR to HTML for a web dashboard - TypeBox validation means we can validate HUD events against component schemas before accepting them - The Context system carries density/target through the component tree — clean and no prop drilling