@easyops-cn/a2ui-react 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/.claude/commands/speckit.analyze.md +184 -0
  2. package/.claude/commands/speckit.checklist.md +294 -0
  3. package/.claude/commands/speckit.clarify.md +181 -0
  4. package/.claude/commands/speckit.constitution.md +82 -0
  5. package/.claude/commands/speckit.implement.md +135 -0
  6. package/.claude/commands/speckit.plan.md +89 -0
  7. package/.claude/commands/speckit.specify.md +256 -0
  8. package/.claude/commands/speckit.tasks.md +137 -0
  9. package/.claude/commands/speckit.taskstoissues.md +30 -0
  10. package/.github/workflows/deploy.yml +69 -0
  11. package/.husky/pre-commit +1 -0
  12. package/.prettierignore +4 -0
  13. package/.prettierrc +7 -0
  14. package/.specify/memory/constitution.md +73 -0
  15. package/.specify/scripts/bash/check-prerequisites.sh +166 -0
  16. package/.specify/scripts/bash/common.sh +156 -0
  17. package/.specify/scripts/bash/create-new-feature.sh +297 -0
  18. package/.specify/scripts/bash/setup-plan.sh +61 -0
  19. package/.specify/scripts/bash/update-agent-context.sh +799 -0
  20. package/.specify/templates/agent-file-template.md +28 -0
  21. package/.specify/templates/checklist-template.md +40 -0
  22. package/.specify/templates/plan-template.md +105 -0
  23. package/.specify/templates/spec-template.md +115 -0
  24. package/.specify/templates/tasks-template.md +250 -0
  25. package/CLAUDE.md +105 -0
  26. package/CONTRIBUTING.md +97 -0
  27. package/README.md +126 -0
  28. package/components.json +21 -0
  29. package/eslint.config.js +25 -0
  30. package/netlify.toml +50 -0
  31. package/package.json +94 -0
  32. package/playground/README.md +75 -0
  33. package/playground/index.html +22 -0
  34. package/playground/package.json +32 -0
  35. package/playground/public/favicon.svg +8 -0
  36. package/playground/src/App.css +256 -0
  37. package/playground/src/App.tsx +115 -0
  38. package/playground/src/assets/react.svg +1 -0
  39. package/playground/src/components/ErrorDisplay.tsx +13 -0
  40. package/playground/src/components/ExampleSelector.tsx +64 -0
  41. package/playground/src/components/Header.tsx +47 -0
  42. package/playground/src/components/JsonEditor.tsx +32 -0
  43. package/playground/src/components/Preview.tsx +78 -0
  44. package/playground/src/components/ThemeToggle.tsx +19 -0
  45. package/playground/src/data/examples.ts +1571 -0
  46. package/playground/src/hooks/useTheme.ts +55 -0
  47. package/playground/src/index.css +220 -0
  48. package/playground/src/main.tsx +10 -0
  49. package/playground/tsconfig.app.json +34 -0
  50. package/playground/tsconfig.json +13 -0
  51. package/playground/tsconfig.node.json +26 -0
  52. package/playground/vite.config.ts +31 -0
  53. package/specs/001-a2ui-renderer/checklists/requirements.md +41 -0
  54. package/specs/001-a2ui-renderer/data-model.md +140 -0
  55. package/specs/001-a2ui-renderer/plan.md +123 -0
  56. package/specs/001-a2ui-renderer/quickstart.md +141 -0
  57. package/specs/001-a2ui-renderer/research.md +140 -0
  58. package/specs/001-a2ui-renderer/spec.md +165 -0
  59. package/specs/001-a2ui-renderer/tasks.md +310 -0
  60. package/specs/002-playground/checklists/requirements.md +37 -0
  61. package/specs/002-playground/contracts/components.md +120 -0
  62. package/specs/002-playground/data-model.md +149 -0
  63. package/specs/002-playground/plan.md +73 -0
  64. package/specs/002-playground/quickstart.md +158 -0
  65. package/specs/002-playground/research.md +117 -0
  66. package/specs/002-playground/spec.md +109 -0
  67. package/specs/002-playground/tasks.md +224 -0
  68. package/src/0.8/A2UIRender.test.tsx +793 -0
  69. package/src/0.8/A2UIRender.tsx +142 -0
  70. package/src/0.8/components/ComponentRenderer.test.tsx +373 -0
  71. package/src/0.8/components/ComponentRenderer.tsx +163 -0
  72. package/src/0.8/components/UnknownComponent.tsx +49 -0
  73. package/src/0.8/components/display/AudioPlayerComponent.tsx +37 -0
  74. package/src/0.8/components/display/DividerComponent.tsx +23 -0
  75. package/src/0.8/components/display/IconComponent.tsx +137 -0
  76. package/src/0.8/components/display/ImageComponent.tsx +57 -0
  77. package/src/0.8/components/display/TextComponent.tsx +56 -0
  78. package/src/0.8/components/display/VideoComponent.tsx +31 -0
  79. package/src/0.8/components/display/display.test.tsx +660 -0
  80. package/src/0.8/components/display/index.ts +10 -0
  81. package/src/0.8/components/index.ts +14 -0
  82. package/src/0.8/components/interactive/ButtonComponent.tsx +44 -0
  83. package/src/0.8/components/interactive/CheckBoxComponent.tsx +45 -0
  84. package/src/0.8/components/interactive/DateTimeInputComponent.tsx +176 -0
  85. package/src/0.8/components/interactive/MultipleChoiceComponent.tsx +157 -0
  86. package/src/0.8/components/interactive/SliderComponent.tsx +53 -0
  87. package/src/0.8/components/interactive/TextFieldComponent.tsx +65 -0
  88. package/src/0.8/components/interactive/index.ts +10 -0
  89. package/src/0.8/components/interactive/interactive.test.tsx +618 -0
  90. package/src/0.8/components/layout/CardComponent.tsx +30 -0
  91. package/src/0.8/components/layout/ColumnComponent.tsx +93 -0
  92. package/src/0.8/components/layout/ListComponent.tsx +81 -0
  93. package/src/0.8/components/layout/ModalComponent.tsx +41 -0
  94. package/src/0.8/components/layout/RowComponent.tsx +94 -0
  95. package/src/0.8/components/layout/TabsComponent.tsx +59 -0
  96. package/src/0.8/components/layout/index.ts +10 -0
  97. package/src/0.8/components/layout/layout.test.tsx +558 -0
  98. package/src/0.8/contexts/A2UIProvider.test.tsx +226 -0
  99. package/src/0.8/contexts/A2UIProvider.tsx +54 -0
  100. package/src/0.8/contexts/ActionContext.test.tsx +242 -0
  101. package/src/0.8/contexts/ActionContext.tsx +105 -0
  102. package/src/0.8/contexts/ComponentsMapContext.tsx +125 -0
  103. package/src/0.8/contexts/DataModelContext.test.tsx +335 -0
  104. package/src/0.8/contexts/DataModelContext.tsx +184 -0
  105. package/src/0.8/contexts/SurfaceContext.test.tsx +339 -0
  106. package/src/0.8/contexts/SurfaceContext.tsx +197 -0
  107. package/src/0.8/hooks/useA2UIMessageHandler.test.tsx +399 -0
  108. package/src/0.8/hooks/useA2UIMessageHandler.ts +123 -0
  109. package/src/0.8/hooks/useComponent.test.tsx +148 -0
  110. package/src/0.8/hooks/useComponent.ts +39 -0
  111. package/src/0.8/hooks/useDataBinding.test.tsx +334 -0
  112. package/src/0.8/hooks/useDataBinding.ts +99 -0
  113. package/src/0.8/hooks/useDispatchAction.test.tsx +83 -0
  114. package/src/0.8/hooks/useDispatchAction.ts +35 -0
  115. package/src/0.8/hooks/useSurface.test.tsx +114 -0
  116. package/src/0.8/hooks/useSurface.ts +34 -0
  117. package/src/0.8/index.ts +38 -0
  118. package/src/0.8/schemas/client_to_server.json +50 -0
  119. package/src/0.8/schemas/server_to_client.json +148 -0
  120. package/src/0.8/schemas/standard_catalog_definition.json +661 -0
  121. package/src/0.8/types/index.ts +448 -0
  122. package/src/0.8/utils/dataBinding.test.ts +443 -0
  123. package/src/0.8/utils/dataBinding.ts +212 -0
  124. package/src/0.8/utils/pathUtils.test.ts +353 -0
  125. package/src/0.8/utils/pathUtils.ts +200 -0
  126. package/src/components/ui/button.tsx +62 -0
  127. package/src/components/ui/calendar.tsx +220 -0
  128. package/src/components/ui/card.tsx +92 -0
  129. package/src/components/ui/checkbox.tsx +30 -0
  130. package/src/components/ui/dialog.tsx +141 -0
  131. package/src/components/ui/input.tsx +21 -0
  132. package/src/components/ui/label.tsx +22 -0
  133. package/src/components/ui/native-select.tsx +53 -0
  134. package/src/components/ui/popover.tsx +46 -0
  135. package/src/components/ui/select.tsx +188 -0
  136. package/src/components/ui/separator.tsx +26 -0
  137. package/src/components/ui/slider.tsx +61 -0
  138. package/src/components/ui/tabs.tsx +64 -0
  139. package/src/components/ui/textarea.tsx +18 -0
  140. package/src/index.ts +1 -0
  141. package/src/lib/utils.ts +6 -0
  142. package/tsconfig.json +28 -0
  143. package/vite.config.ts +29 -0
  144. package/vitest.config.ts +22 -0
  145. package/vitest.setup.ts +8 -0
  146. package/website/README.md +4 -0
  147. package/website/assets/favicon.svg +8 -0
  148. package/website/content/.gitkeep +0 -0
  149. package/website/content/index.md +122 -0
  150. package/website/global.d.ts +9 -0
  151. package/website/package.json +17 -0
  152. package/website/plain.config.js +28 -0
  153. package/website/serve.json +6 -0
  154. package/website/src/client/color-mode-switch.css +47 -0
  155. package/website/src/client/index.js +61 -0
  156. package/website/src/client/moon.svg +1 -0
  157. package/website/src/client/sun.svg +1 -0
  158. package/website/src/components/Footer.jsx +9 -0
  159. package/website/src/components/Header.jsx +44 -0
  160. package/website/src/components/Page.jsx +28 -0
  161. package/website/src/global.css +423 -0
@@ -0,0 +1,339 @@
1
+ /**
2
+ * SurfaceContext Tests
3
+ *
4
+ * Tests for the Surface context provider and hook.
5
+ */
6
+
7
+ import { describe, it, expect, vi } from 'vitest'
8
+ import { render, screen, act } from '@testing-library/react'
9
+ import { renderHook } from '@testing-library/react'
10
+ import { SurfaceProvider, useSurfaceContext } from './SurfaceContext'
11
+ import type { ReactNode } from 'react'
12
+ import type { ComponentDefinition } from '../types'
13
+
14
+ describe('SurfaceContext', () => {
15
+ // Helper to render hook with provider
16
+ const wrapper = ({ children }: { children: ReactNode }) => (
17
+ <SurfaceProvider>{children}</SurfaceProvider>
18
+ )
19
+
20
+ describe('SurfaceProvider', () => {
21
+ it('should render children', () => {
22
+ render(
23
+ <SurfaceProvider>
24
+ <div data-testid="child">Child content</div>
25
+ </SurfaceProvider>
26
+ )
27
+ expect(screen.getByTestId('child')).toBeInTheDocument()
28
+ })
29
+
30
+ it('should provide context value', () => {
31
+ const { result } = renderHook(() => useSurfaceContext(), { wrapper })
32
+ expect(result.current).toBeDefined()
33
+ expect(result.current.surfaces).toBeInstanceOf(Map)
34
+ })
35
+ })
36
+
37
+ describe('useSurfaceContext', () => {
38
+ it('should throw error when used outside provider', () => {
39
+ const consoleError = vi
40
+ .spyOn(console, 'error')
41
+ .mockImplementation(() => {})
42
+
43
+ expect(() => {
44
+ renderHook(() => useSurfaceContext())
45
+ }).toThrow('useSurfaceContext must be used within a SurfaceProvider')
46
+
47
+ consoleError.mockRestore()
48
+ })
49
+ })
50
+
51
+ describe('initSurface', () => {
52
+ it('should initialize surface with root and empty components', () => {
53
+ const { result } = renderHook(() => useSurfaceContext(), { wrapper })
54
+
55
+ act(() => {
56
+ result.current.initSurface('surface-1', 'root-component')
57
+ })
58
+
59
+ const surface = result.current.getSurface('surface-1')
60
+ expect(surface).toBeDefined()
61
+ expect(surface?.surfaceId).toBe('surface-1')
62
+ expect(surface?.root).toBe('root-component')
63
+ expect(surface?.components).toBeInstanceOf(Map)
64
+ expect(surface?.components.size).toBe(0)
65
+ })
66
+
67
+ it('should initialize surface with styles', () => {
68
+ const { result } = renderHook(() => useSurfaceContext(), { wrapper })
69
+
70
+ act(() => {
71
+ result.current.initSurface('surface-1', 'root', {
72
+ font: 'Arial',
73
+ primaryColor: '#ff0000',
74
+ })
75
+ })
76
+
77
+ const surface = result.current.getSurface('surface-1')
78
+ expect(surface?.styles).toEqual({
79
+ font: 'Arial',
80
+ primaryColor: '#ff0000',
81
+ })
82
+ })
83
+
84
+ it('should preserve existing components when reinitializing', () => {
85
+ const { result } = renderHook(() => useSurfaceContext(), { wrapper })
86
+
87
+ const component: ComponentDefinition = {
88
+ id: 'comp-1',
89
+ component: { Text: { text: { literalString: 'Hello' } } },
90
+ }
91
+
92
+ act(() => {
93
+ result.current.updateSurface('surface-1', [component])
94
+ })
95
+
96
+ act(() => {
97
+ result.current.initSurface('surface-1', 'new-root')
98
+ })
99
+
100
+ const surface = result.current.getSurface('surface-1')
101
+ expect(surface?.root).toBe('new-root')
102
+ expect(surface?.components.get('comp-1')).toEqual(component)
103
+ })
104
+
105
+ it('should update root and styles on reinitialize', () => {
106
+ const { result } = renderHook(() => useSurfaceContext(), { wrapper })
107
+
108
+ act(() => {
109
+ result.current.initSurface('surface-1', 'old-root', { font: 'Arial' })
110
+ })
111
+
112
+ act(() => {
113
+ result.current.initSurface('surface-1', 'new-root', {
114
+ font: 'Helvetica',
115
+ })
116
+ })
117
+
118
+ const surface = result.current.getSurface('surface-1')
119
+ expect(surface?.root).toBe('new-root')
120
+ expect(surface?.styles?.font).toBe('Helvetica')
121
+ })
122
+ })
123
+
124
+ describe('updateSurface', () => {
125
+ it('should add components to existing surface', () => {
126
+ const { result } = renderHook(() => useSurfaceContext(), { wrapper })
127
+
128
+ const component: ComponentDefinition = {
129
+ id: 'comp-1',
130
+ component: { Text: { text: { literalString: 'Hello' } } },
131
+ }
132
+
133
+ act(() => {
134
+ result.current.initSurface('surface-1', 'root')
135
+ result.current.updateSurface('surface-1', [component])
136
+ })
137
+
138
+ const surface = result.current.getSurface('surface-1')
139
+ expect(surface?.components.get('comp-1')).toEqual(component)
140
+ })
141
+
142
+ it('should update existing component', () => {
143
+ const { result } = renderHook(() => useSurfaceContext(), { wrapper })
144
+
145
+ const component1: ComponentDefinition = {
146
+ id: 'comp-1',
147
+ component: { Text: { text: { literalString: 'Hello' } } },
148
+ }
149
+
150
+ const component2: ComponentDefinition = {
151
+ id: 'comp-1',
152
+ component: { Text: { text: { literalString: 'Updated' } } },
153
+ }
154
+
155
+ act(() => {
156
+ result.current.initSurface('surface-1', 'root')
157
+ result.current.updateSurface('surface-1', [component1])
158
+ })
159
+
160
+ act(() => {
161
+ result.current.updateSurface('surface-1', [component2])
162
+ })
163
+
164
+ const surface = result.current.getSurface('surface-1')
165
+ expect(surface?.components.get('comp-1')).toEqual(component2)
166
+ })
167
+
168
+ it('should add multiple components at once', () => {
169
+ const { result } = renderHook(() => useSurfaceContext(), { wrapper })
170
+
171
+ const components: ComponentDefinition[] = [
172
+ { id: 'comp-1', component: { Text: { text: { literalString: 'A' } } } },
173
+ { id: 'comp-2', component: { Text: { text: { literalString: 'B' } } } },
174
+ { id: 'comp-3', component: { Text: { text: { literalString: 'C' } } } },
175
+ ]
176
+
177
+ act(() => {
178
+ result.current.initSurface('surface-1', 'root')
179
+ result.current.updateSurface('surface-1', components)
180
+ })
181
+
182
+ const surface = result.current.getSurface('surface-1')
183
+ expect(surface?.components.size).toBe(3)
184
+ expect(surface?.components.get('comp-1')).toEqual(components[0])
185
+ expect(surface?.components.get('comp-2')).toEqual(components[1])
186
+ expect(surface?.components.get('comp-3')).toEqual(components[2])
187
+ })
188
+
189
+ it('should create surface if not exists (surfaceUpdate before beginRendering)', () => {
190
+ const { result } = renderHook(() => useSurfaceContext(), { wrapper })
191
+
192
+ const component: ComponentDefinition = {
193
+ id: 'comp-1',
194
+ component: { Text: { text: { literalString: 'Hello' } } },
195
+ }
196
+
197
+ act(() => {
198
+ result.current.updateSurface('surface-1', [component])
199
+ })
200
+
201
+ const surface = result.current.getSurface('surface-1')
202
+ expect(surface).toBeDefined()
203
+ expect(surface?.surfaceId).toBe('surface-1')
204
+ expect(surface?.root).toBe('')
205
+ expect(surface?.components.get('comp-1')).toEqual(component)
206
+ })
207
+ })
208
+
209
+ describe('getSurface', () => {
210
+ it('should return undefined for non-existent surface', () => {
211
+ const { result } = renderHook(() => useSurfaceContext(), { wrapper })
212
+ expect(result.current.getSurface('non-existent')).toBeUndefined()
213
+ })
214
+
215
+ it('should return surface for existing surface', () => {
216
+ const { result } = renderHook(() => useSurfaceContext(), { wrapper })
217
+
218
+ act(() => {
219
+ result.current.initSurface('surface-1', 'root')
220
+ })
221
+
222
+ const surface = result.current.getSurface('surface-1')
223
+ expect(surface).toBeDefined()
224
+ expect(surface?.surfaceId).toBe('surface-1')
225
+ })
226
+ })
227
+
228
+ describe('getComponent', () => {
229
+ it('should return undefined for non-existent surface', () => {
230
+ const { result } = renderHook(() => useSurfaceContext(), { wrapper })
231
+ expect(
232
+ result.current.getComponent('non-existent', 'comp-1')
233
+ ).toBeUndefined()
234
+ })
235
+
236
+ it('should return undefined for non-existent component', () => {
237
+ const { result } = renderHook(() => useSurfaceContext(), { wrapper })
238
+
239
+ act(() => {
240
+ result.current.initSurface('surface-1', 'root')
241
+ })
242
+
243
+ expect(
244
+ result.current.getComponent('surface-1', 'non-existent')
245
+ ).toBeUndefined()
246
+ })
247
+
248
+ it('should return component for existing component', () => {
249
+ const { result } = renderHook(() => useSurfaceContext(), { wrapper })
250
+
251
+ const component: ComponentDefinition = {
252
+ id: 'comp-1',
253
+ component: { Text: { text: { literalString: 'Hello' } } },
254
+ }
255
+
256
+ act(() => {
257
+ result.current.initSurface('surface-1', 'root')
258
+ result.current.updateSurface('surface-1', [component])
259
+ })
260
+
261
+ expect(result.current.getComponent('surface-1', 'comp-1')).toEqual(
262
+ component
263
+ )
264
+ })
265
+ })
266
+
267
+ describe('deleteSurface', () => {
268
+ it('should delete surface', () => {
269
+ const { result } = renderHook(() => useSurfaceContext(), { wrapper })
270
+
271
+ act(() => {
272
+ result.current.initSurface('surface-1', 'root')
273
+ })
274
+
275
+ act(() => {
276
+ result.current.deleteSurface('surface-1')
277
+ })
278
+
279
+ expect(result.current.getSurface('surface-1')).toBeUndefined()
280
+ })
281
+
282
+ it('should not affect other surfaces', () => {
283
+ const { result } = renderHook(() => useSurfaceContext(), { wrapper })
284
+
285
+ act(() => {
286
+ result.current.initSurface('surface-1', 'root1')
287
+ result.current.initSurface('surface-2', 'root2')
288
+ })
289
+
290
+ act(() => {
291
+ result.current.deleteSurface('surface-1')
292
+ })
293
+
294
+ expect(result.current.getSurface('surface-1')).toBeUndefined()
295
+ expect(result.current.getSurface('surface-2')).toBeDefined()
296
+ })
297
+
298
+ it('should handle deleting non-existent surface gracefully', () => {
299
+ const { result } = renderHook(() => useSurfaceContext(), { wrapper })
300
+
301
+ expect(() => {
302
+ act(() => {
303
+ result.current.deleteSurface('non-existent')
304
+ })
305
+ }).not.toThrow()
306
+ })
307
+ })
308
+
309
+ describe('clearSurfaces', () => {
310
+ it('should clear all surfaces', () => {
311
+ const { result } = renderHook(() => useSurfaceContext(), { wrapper })
312
+
313
+ act(() => {
314
+ result.current.initSurface('surface-1', 'root1')
315
+ result.current.initSurface('surface-2', 'root2')
316
+ })
317
+
318
+ act(() => {
319
+ result.current.clearSurfaces()
320
+ })
321
+
322
+ expect(result.current.surfaces.size).toBe(0)
323
+ expect(result.current.getSurface('surface-1')).toBeUndefined()
324
+ expect(result.current.getSurface('surface-2')).toBeUndefined()
325
+ })
326
+ })
327
+
328
+ describe('surfaces state', () => {
329
+ it('should provide access to all surfaces via surfaces property', () => {
330
+ const { result } = renderHook(() => useSurfaceContext(), { wrapper })
331
+
332
+ act(() => {
333
+ result.current.initSurface('surface-1', 'root')
334
+ })
335
+
336
+ expect(result.current.surfaces.has('surface-1')).toBe(true)
337
+ })
338
+ })
339
+ })
@@ -0,0 +1,197 @@
1
+ /**
2
+ * SurfaceContext - Manages the Surface state for A2UI rendering.
3
+ *
4
+ * A Surface is the top-level container that holds:
5
+ * - surfaceId: Unique identifier
6
+ * - root: The root component ID
7
+ * - components: Map of all components
8
+ * - styles: Optional style configuration
9
+ */
10
+
11
+ import {
12
+ createContext,
13
+ useContext,
14
+ useState,
15
+ useMemo,
16
+ useCallback,
17
+ type ReactNode,
18
+ } from 'react'
19
+ import type { Surface, ComponentDefinition, SurfaceStyles } from '../types'
20
+
21
+ /**
22
+ * Surface context value interface.
23
+ */
24
+ export interface SurfaceContextValue {
25
+ /** Map of all surfaces by surfaceId */
26
+ surfaces: Map<string, Surface>
27
+
28
+ /**
29
+ * Initializes a surface with root and styles.
30
+ * If the surface already exists, preserves existing components.
31
+ * Called when beginRendering message is received.
32
+ */
33
+ initSurface: (surfaceId: string, root: string, styles?: SurfaceStyles) => void
34
+
35
+ /** Updates components in a surface */
36
+ updateSurface: (surfaceId: string, components: ComponentDefinition[]) => void
37
+
38
+ /** Deletes a surface */
39
+ deleteSurface: (surfaceId: string) => void
40
+
41
+ /** Gets a surface by ID */
42
+ getSurface: (surfaceId: string) => Surface | undefined
43
+
44
+ /** Gets a component from a surface */
45
+ getComponent: (
46
+ surfaceId: string,
47
+ componentId: string
48
+ ) => ComponentDefinition | undefined
49
+
50
+ /** Clears all surfaces */
51
+ clearSurfaces: () => void
52
+ }
53
+
54
+ /**
55
+ * Surface context for A2UI rendering.
56
+ */
57
+ export const SurfaceContext = createContext<SurfaceContextValue | null>(null)
58
+
59
+ /**
60
+ * Props for SurfaceProvider.
61
+ */
62
+ export interface SurfaceProviderProps {
63
+ children: ReactNode
64
+ }
65
+
66
+ /**
67
+ * Provider component for Surface state management.
68
+ */
69
+ export function SurfaceProvider({ children }: SurfaceProviderProps) {
70
+ const [surfaces, setSurfaces] = useState<Map<string, Surface>>(new Map())
71
+
72
+ const initSurface = useCallback(
73
+ (surfaceId: string, root: string, styles?: SurfaceStyles) => {
74
+ setSurfaces((prev) => {
75
+ const existing = prev.get(surfaceId)
76
+ const next = new Map(prev)
77
+
78
+ // Preserve existing components if surface already exists
79
+ // This handles the case where surfaceUpdate comes before beginRendering
80
+ next.set(surfaceId, {
81
+ surfaceId,
82
+ root,
83
+ components: existing?.components ?? new Map(),
84
+ styles,
85
+ })
86
+ return next
87
+ })
88
+ },
89
+ []
90
+ )
91
+
92
+ const updateSurface = useCallback(
93
+ (surfaceId: string, components: ComponentDefinition[]) => {
94
+ setSurfaces((prev) => {
95
+ const surface = prev.get(surfaceId)
96
+ if (!surface) {
97
+ // Surface doesn't exist yet, create it with empty root
98
+ // This can happen if surfaceUpdate comes before beginRendering
99
+ const newSurface: Surface = {
100
+ surfaceId,
101
+ root: '',
102
+ components: new Map(),
103
+ }
104
+
105
+ for (const comp of components) {
106
+ newSurface.components.set(comp.id, comp)
107
+ }
108
+
109
+ const next = new Map(prev)
110
+ next.set(surfaceId, newSurface)
111
+ return next
112
+ }
113
+
114
+ // Update existing surface's components
115
+ const next = new Map(prev)
116
+ const componentMap = new Map(surface.components)
117
+
118
+ for (const comp of components) {
119
+ componentMap.set(comp.id, comp)
120
+ }
121
+
122
+ next.set(surfaceId, {
123
+ ...surface,
124
+ components: componentMap,
125
+ })
126
+
127
+ return next
128
+ })
129
+ },
130
+ []
131
+ )
132
+
133
+ const deleteSurface = useCallback((surfaceId: string) => {
134
+ setSurfaces((prev) => {
135
+ const next = new Map(prev)
136
+ next.delete(surfaceId)
137
+ return next
138
+ })
139
+ }, [])
140
+
141
+ const getSurface = useCallback(
142
+ (surfaceId: string) => {
143
+ return surfaces.get(surfaceId)
144
+ },
145
+ [surfaces]
146
+ )
147
+
148
+ const getComponent = useCallback(
149
+ (surfaceId: string, componentId: string) => {
150
+ const surface = surfaces.get(surfaceId)
151
+ return surface?.components.get(componentId)
152
+ },
153
+ [surfaces]
154
+ )
155
+
156
+ const clearSurfaces = useCallback(() => {
157
+ setSurfaces(new Map())
158
+ }, [])
159
+
160
+ const value = useMemo<SurfaceContextValue>(
161
+ () => ({
162
+ surfaces,
163
+ initSurface,
164
+ updateSurface,
165
+ deleteSurface,
166
+ getSurface,
167
+ getComponent,
168
+ clearSurfaces,
169
+ }),
170
+ [
171
+ surfaces,
172
+ initSurface,
173
+ updateSurface,
174
+ deleteSurface,
175
+ getSurface,
176
+ getComponent,
177
+ clearSurfaces,
178
+ ]
179
+ )
180
+
181
+ return (
182
+ <SurfaceContext.Provider value={value}>{children}</SurfaceContext.Provider>
183
+ )
184
+ }
185
+
186
+ /**
187
+ * Hook to access the Surface context.
188
+ *
189
+ * @throws Error if used outside of SurfaceProvider
190
+ */
191
+ export function useSurfaceContext(): SurfaceContextValue {
192
+ const context = useContext(SurfaceContext)
193
+ if (!context) {
194
+ throw new Error('useSurfaceContext must be used within a SurfaceProvider')
195
+ }
196
+ return context
197
+ }