19 KiB
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/RootElementtypes,HostConfigreconciler,TransformRegistry,StreamingTransformer@ade/aui— Markdown consumer:jsxToMdasttransform 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
-
No actual JSX syntax — Uses
h()calls instead of<Component />syntax. Needs a JSX transform config (like Hono'stsconfig.jsonjsxFactory) so you can write real JSX. -
HTML baggage —
UniversalPropshasonClick,onSubmit,onInput,onChange,className,aria-*,data-*— all HTML-specific. For a universal IR targeting markdown (and eventually other formats), these are noise. -
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.
-
Non-deterministic IDs —
genId()usesMath.random(). Needs to be injectable/deterministic. -
Separate props + children —
UniversalElementhas bothpropsandchildren. This is the React/HTML model wherechildrenis a special prop. For a universal IR, children is just a prop like any other. Simplify. -
No Context system — React's Context/Provider pattern is essential for passing density/target/host config down the tree without prop drilling.
-
Fragment not integrated — Exists but reconciler and transforms don't handle it specially.
-
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
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<typeof UNodeSchema>
// 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<typeof UElementSchema>
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:
// 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<string, unknown>
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:
// 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:
Value.Check(HeadingSchema, node)— validate before transforming- The schema can be registered as a tool parameter schema
- Bi-directional: a
mdast->ujsxrule for heading uses the same schema Type.Intersect()to compose schemas for compound elements
Simplified Element Model
Remove HTML-specific stuff. The props becomes a plain Record<string, unknown> 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.
// 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<typeof UElement>
type URoot = Static<typeof URoot>
type UNode = Static<typeof UNode>
The h() Factory (Cleaned Up)
// core/h.ts
import type { UNode, UElement, URoot } from './schema.ts'
let _idCounter = 0
export function h(
type: string,
props?: Record<string, unknown> | 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 —
timestampandidon 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 (<TaskSection task={state.task} />), you need a tsconfig.json or deno.json with:
{
"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:
// host/config.ts
export interface HostConfig<TTag extends string, Instance, RootCtx> {
name: string
createRootContext(container: unknown, options?: Record<string, unknown>): RootCtx
finalizeRoot?(ctx: RootCtx): void
createInstance(tag: TTag, props: Record<string, unknown>, 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<string, unknown>, nextProps: Record<string, unknown>, ctx: RootCtx): unknown | null
commitUpdate?(instance: Instance, payload: unknown, tag: TTag, prevProps: Record<string, unknown>, nextProps: Record<string, unknown>, ctx: RootCtx): void
}
No changes needed. The host is host-agnostic already. The graphology host stays. A markdown host can be added:
// host/markdown.ts
export function createMarkdownHost(): HostConfig<string, MdastNode, MdastRoot> {
// 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:
// transform/rule.ts
import { Type, TSchema, Value } from "@alkdev/typebox"
export type Direction = 'ujsx->mdast' | 'mdast->ujsx' | 'ujsx->hast' | 'hast->ujsx'
export interface TransformRule<TInput, TOutput, TAncestor> {
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<TAncestor>, next: TransformFn<TInput, TOutput, TAncestor>) => TOutput
priority?: number
}
export class TransformRegistry<TInput, TOutput, TAncestor> {
private rules: TransformRule<TInput, TOutput, TAncestor>[] = []
register(rule: TransformRule<TInput, TOutput, TAncestor>) {
this.rules.push(rule)
this.rules.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
}
transform(node: TInput, ctx: TransformContext<TAncestor>, 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:
// 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
// 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.
// 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<typeof TaskSectionProps>): 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:
// core/context.ts
export interface ContextValue {
density: 'full' | 'compact' | 'minimal'
target: string // 'markdown' | 'graph' | 'html' | etc.
metadata: Record<string, unknown>
}
// Simple context implementation (no React dependency)
type ContextListener = (value: ContextValue) => void
export class Context {
private value: ContextValue
private listeners: Set<ContextListener> = new Set()
constructor(initial: ContextValue) {
this.value = initial
}
get(): ContextValue { return this.value }
set(partial: Partial<ContextValue>) {
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 # <Container>
context-bar.ts # <ContextBar> with density awareness
heading.ts # <Heading level={1-6}>
list.ts # <List>, <ListItem>, <TaskList>
code.ts # <Code>, <CodeBlock>
blockquote.ts # NEW: <Blockquote>
link.ts # NEW: <Link>
table.ts # NEW: <Table>
hr.ts # NEW: <Divider>
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
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@ade/ujsx"
}
}
With this, you write:
import { h } from "@ade/ujsx"
function TaskSection({ task, density }: TaskSectionProps) {
if (density === 'minimal') {
return <p>{task.description}</p>
}
return (
<div>
<h2>Task</h2>
<p>{task.description}</p>
</div>
)
}
Instead of:
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
- Replace types.ts with schema.ts — TypeBox schemas for UNode/UElement/URoot. Export both schemas and
Static<>types. - Clean up h.ts — Remove metadata injection, HTML-specific props,
Math.random(). Addjsx-runtime.tsexport. - Add deno.json jsxImportSource — Enable JSX syntax.
- Strip UniversalProps — Replace with plain
Record<string, unknown>. NoonClick,className, etc. - Upgrade TransformRegistry — Add
directionandschemafields to rules. - Add missing markdown rules — blockquote, link, image, hr, table.
- Add Context — Simple context system for density/target.
- Fix graphology host — Uncomment append logic, replace
JSON.stringifydiffing. - Add Markdown host —
createMarkdownHost()using HostConfig. - Add bi-directional rules — mdast → ujsx direction for each element.
- 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