Skip to content

Commit 93d0a76

Browse files
mmejpr5
authored andcommitted
feat(a2ui-renderer): A2UI v0.9 — BYOC catalogs, dark mode, component-neutral guidelines
Replace @a2ui/lit with @a2ui/web_core 0.9, introduce createCatalog() API for custom component catalogs, add basic (18 components) and minimal (5 components) built-in catalogs, CSS variable theming for dark mode, eliminate XSS vector (dangerouslySetInnerHTML removed from Text component).
1 parent c08f353 commit 93d0a76

76 files changed

Lines changed: 2758 additions & 3661 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/a2ui-renderer/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,10 @@
4848
"attw": "attw --pack . --profile node16"
4949
},
5050
"dependencies": {
51-
"@a2ui/lit": "^0.8.1",
51+
"@a2ui/web_core": "0.9.0",
5252
"clsx": "^2.1.1",
53-
"markdown-it": "^14.1.0",
54-
"zod": "^3.25.75"
53+
"zod": "^3.25.75",
54+
"zod-to-json-schema": "^3.24.1"
5555
},
5656
"devDependencies": {
5757
"@testing-library/react": "^16.0.0",
Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
1-
import { Data, type Types } from "@a2ui/lit/0.8";
1+
/**
2+
* v0.9 A2UI type definitions for CopilotKit integration.
3+
*/
24

3-
export type Theme = Types.Theme;
4-
export type A2UIClientEventMessage = Types.A2UIClientEventMessage;
5-
export const DEFAULT_SURFACE_ID = Data.A2uiMessageProcessor.DEFAULT_SURFACE_ID;
5+
/** Theme type - v0.9 themes are passed via createSurface message */
6+
export type Theme = Record<string, unknown>;
7+
8+
/**
9+
* Client event message dispatched when a user interacts with an A2UI surface.
10+
* This is the format expected by A2UIMessageRenderer's handleAction.
11+
*/
12+
export interface A2UIClientEventMessage {
13+
userAction?: {
14+
name: string;
15+
surfaceId: string;
16+
sourceComponentId?: string;
17+
context?: Record<string, unknown>;
18+
timestamp?: string;
19+
dataContextPath?: string;
20+
};
21+
}
22+
23+
/** Default surface ID when none is specified */
24+
export const DEFAULT_SURFACE_ID = "default";

packages/a2ui-renderer/src/index.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,8 @@
1515
*/
1616

1717
export * from "./react-renderer/index.js";
18-
export { A2UIViewer } from "./A2UIViewer.js";
1918
export { DEFAULT_SURFACE_ID } from "./a2ui-types.js";
2019
export type { Theme, A2UIClientEventMessage } from "./a2ui-types.js";
21-
export type { A2UIViewerProps } from "./A2UIViewer.js";
22-
export { theme as viewerTheme } from "./theme/viewer-theme.js";
2320

24-
// Re-export v0_8 types namespace for consumers
25-
import type { v0_8 } from "@a2ui/lit";
26-
export type ComponentInstance = v0_8.Types.ComponentInstance;
27-
export type UserAction = v0_8.Types.UserAction;
28-
export type Action = v0_8.Types.Action;
29-
export type ServerToClientMessage = v0_8.Types.ServerToClientMessage;
30-
export type Surface = v0_8.Types.Surface;
31-
export type AnyComponentNode = v0_8.Types.AnyComponentNode;
21+
// Backward compat: viewerTheme (v0.9 themes handled internally)
22+
export const viewerTheme: Record<string, unknown> = {};
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import React, { useSyncExternalStore, memo, useMemo, useCallback } from "react";
18+
import {
19+
type SurfaceModel,
20+
ComponentContext,
21+
type ComponentModel,
22+
} from "@a2ui/web_core/v0_9";
23+
import type { ReactComponentImplementation } from "./adapter";
24+
25+
const ResolvedChild = memo(
26+
({
27+
surface,
28+
id,
29+
basePath,
30+
compImpl,
31+
componentModel,
32+
}: {
33+
surface: SurfaceModel<ReactComponentImplementation>;
34+
id: string;
35+
basePath: string;
36+
componentModel: ComponentModel;
37+
compImpl: ReactComponentImplementation;
38+
}) => {
39+
const ComponentToRender = compImpl.render;
40+
41+
// Create context. Recreate if the componentModel instance changes (e.g. type change recreation).
42+
const context = useMemo(
43+
() => new ComponentContext(surface, id, basePath),
44+
// componentModel is used as a trigger for recreation even if not in the body
45+
// eslint-disable-next-line react-hooks/exhaustive-deps
46+
[surface, id, basePath, componentModel],
47+
);
48+
49+
const buildChild = useCallback(
50+
(childId: string, specificPath?: string) => {
51+
const path = specificPath || context.dataContext.path;
52+
return (
53+
<DeferredChild
54+
key={`${childId}-${path}`}
55+
surface={surface}
56+
id={childId}
57+
basePath={path}
58+
/>
59+
);
60+
},
61+
[surface, context.dataContext.path],
62+
);
63+
64+
return <ComponentToRender context={context} buildChild={buildChild} />;
65+
},
66+
);
67+
ResolvedChild.displayName = "ResolvedChild";
68+
69+
export const DeferredChild: React.FC<{
70+
surface: SurfaceModel<ReactComponentImplementation>;
71+
id: string;
72+
basePath: string;
73+
}> = memo(({ surface, id, basePath }) => {
74+
// 1. Subscribe specifically to this component's existence
75+
const store = useMemo(() => {
76+
let version = 0;
77+
return {
78+
subscribe: (cb: () => void) => {
79+
const unsub1 = surface.componentsModel.onCreated.subscribe((comp) => {
80+
if (comp.id === id) {
81+
version++;
82+
cb();
83+
}
84+
});
85+
const unsub2 = surface.componentsModel.onDeleted.subscribe((delId) => {
86+
if (delId === id) {
87+
version++;
88+
cb();
89+
}
90+
});
91+
return () => {
92+
unsub1.unsubscribe();
93+
unsub2.unsubscribe();
94+
};
95+
},
96+
getSnapshot: () => {
97+
const comp = surface.componentsModel.get(id);
98+
// We use instance identity + version as the snapshot to ensure
99+
// type replacements (e.g. Button -> Text) trigger a re-render.
100+
return comp ? `${comp.type}-${version}` : `missing-${version}`;
101+
},
102+
};
103+
}, [surface, id]);
104+
105+
useSyncExternalStore(store.subscribe, store.getSnapshot);
106+
107+
const componentModel = surface.componentsModel.get(id);
108+
109+
if (!componentModel) {
110+
return (
111+
<div
112+
style={{
113+
padding: "12px 16px",
114+
borderRadius: "8px",
115+
background:
116+
"linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%)",
117+
backgroundSize: "200% 100%",
118+
animation: "a2ui-shimmer 1.5s ease-in-out infinite",
119+
minHeight: "2rem",
120+
}}
121+
>
122+
<style>{`@keyframes a2ui-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }`}</style>
123+
</div>
124+
);
125+
}
126+
127+
const compImpl = surface.catalog.components.get(componentModel.type);
128+
129+
if (!compImpl) {
130+
return (
131+
<div style={{ color: "red" }}>
132+
Unknown component: {componentModel.type}
133+
</div>
134+
);
135+
}
136+
137+
return (
138+
<ResolvedChild
139+
surface={surface}
140+
id={id}
141+
basePath={basePath}
142+
componentModel={componentModel}
143+
compImpl={compImpl}
144+
/>
145+
);
146+
});
147+
DeferredChild.displayName = "DeferredChild";
148+
149+
export const A2uiSurface: React.FC<{
150+
surface: SurfaceModel<ReactComponentImplementation>;
151+
}> = ({ surface }) => {
152+
// The root component always has ID 'root' and base path '/'
153+
return <DeferredChild surface={surface} id="root" basePath="/" />;
154+
};
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import React, {
18+
useRef,
19+
useSyncExternalStore,
20+
useCallback,
21+
memo,
22+
useEffect,
23+
} from "react";
24+
import { type ComponentContext, GenericBinder } from "@a2ui/web_core/v0_9";
25+
import type {
26+
ComponentApi,
27+
InferredComponentApiSchemaType,
28+
ResolveA2uiProps,
29+
} from "@a2ui/web_core/v0_9";
30+
31+
export interface ReactComponentImplementation extends ComponentApi {
32+
/** The framework-specific rendering wrapper. */
33+
render: React.FC<{
34+
context: ComponentContext;
35+
buildChild: (id: string, basePath?: string) => React.ReactNode;
36+
}>;
37+
}
38+
39+
export type ReactA2uiComponentProps<T> = {
40+
props: T;
41+
buildChild: (id: string, basePath?: string) => React.ReactNode;
42+
context: ComponentContext;
43+
};
44+
45+
// --- Component Factories ---
46+
47+
/**
48+
* Creates a React component implementation using the deep generic binder.
49+
*/
50+
export function createReactComponent<Api extends ComponentApi>(
51+
api: Api,
52+
RenderComponent: React.FC<
53+
ReactA2uiComponentProps<
54+
ResolveA2uiProps<InferredComponentApiSchemaType<Api>>
55+
>
56+
>,
57+
): ReactComponentImplementation {
58+
type Props = ResolveA2uiProps<InferredComponentApiSchemaType<Api>>;
59+
60+
const MemoizedRender = memo(RenderComponent, (prev, next) => {
61+
if (prev.props !== next.props) return false;
62+
if (prev.context.componentModel.id !== next.context.componentModel.id)
63+
return false;
64+
if (prev.context.dataContext.path !== next.context.dataContext.path)
65+
return false;
66+
return true;
67+
});
68+
69+
const ReactWrapper: React.FC<{
70+
context: ComponentContext;
71+
buildChild: (id: string, basePath?: string) => React.ReactNode;
72+
}> = ({ context, buildChild }) => {
73+
const bindingRef = useRef<GenericBinder<Props> | null>(null);
74+
75+
// Create or recreate the binder if the context object changes.
76+
// DeferredChild memoizes `context`, so reference changes strictly correspond
77+
// to ComponentModel updates (like type changes) or Base Path adjustments.
78+
if (!bindingRef.current) {
79+
bindingRef.current = new GenericBinder<Props>(context, api.schema);
80+
} else if (
81+
(bindingRef.current as unknown as { context: ComponentContext })
82+
.context !== context
83+
) {
84+
bindingRef.current.dispose();
85+
bindingRef.current = new GenericBinder<Props>(context, api.schema);
86+
}
87+
const binding = bindingRef.current;
88+
89+
const subscribe = useCallback(
90+
(callback: () => void) => {
91+
const sub = binding.subscribe(callback);
92+
return () => sub.unsubscribe();
93+
},
94+
[binding],
95+
);
96+
97+
const getSnapshot = useCallback(() => binding.snapshot, [binding]);
98+
const props = useSyncExternalStore(subscribe, getSnapshot);
99+
100+
// Prevent DataModel subscription leaks on unmount
101+
useEffect(() => {
102+
return () => binding.dispose();
103+
}, [binding]);
104+
105+
return (
106+
<MemoizedRender
107+
props={props || ({} as Props)}
108+
buildChild={buildChild}
109+
context={context}
110+
/>
111+
);
112+
};
113+
114+
return {
115+
name: api.name,
116+
schema: api.schema,
117+
render: ReactWrapper,
118+
};
119+
}
120+
121+
/**
122+
* Creates a React component implementation that manages its own context bindings (no generic binder).
123+
*/
124+
export function createBinderlessComponent(
125+
api: ComponentApi,
126+
RenderComponent: React.FC<{
127+
context: ComponentContext;
128+
buildChild: (id: string, basePath?: string) => React.ReactNode;
129+
}>,
130+
): ReactComponentImplementation {
131+
return {
132+
name: api.name,
133+
schema: api.schema,
134+
render: RenderComponent,
135+
};
136+
}

0 commit comments

Comments
 (0)