@unoverse-platform/studio 0.1.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 (57) hide show
  1. package/bin/studio.mjs +124 -0
  2. package/index.html +12 -0
  3. package/local/catalog.ts +105 -0
  4. package/local/keys.ts +118 -0
  5. package/local/nodes.ts +77 -0
  6. package/local/plugin.ts +401 -0
  7. package/local/scaffold.d.mts +13 -0
  8. package/local/scaffold.mjs +85 -0
  9. package/package.json +43 -0
  10. package/public/logo.png +0 -0
  11. package/screens/AtomsView.tsx +206 -0
  12. package/screens/ComponentsView.tsx +551 -0
  13. package/screens/ConfigForm.tsx +217 -0
  14. package/screens/DesignSystemView.tsx +324 -0
  15. package/screens/IdentityHeader.tsx +88 -0
  16. package/screens/PromptBlocksView.tsx +137 -0
  17. package/screens/SkillsView.tsx +167 -0
  18. package/screens/TemplatesView.tsx +1005 -0
  19. package/screens/index.ts +26 -0
  20. package/screens/states.ts +44 -0
  21. package/src/App.tsx +252 -0
  22. package/src/ConnectUniverse.tsx +232 -0
  23. package/src/NewProject.tsx +89 -0
  24. package/src/NodeKeys.tsx +98 -0
  25. package/src/NodesView.tsx +443 -0
  26. package/src/PublishDialog.tsx +309 -0
  27. package/src/UniverseSession.tsx +121 -0
  28. package/src/host.ts +165 -0
  29. package/src/index.css +7 -0
  30. package/src/main.tsx +11 -0
  31. package/src/universes.ts +210 -0
  32. package/vendor/sdk/appEngine.tsx +219 -0
  33. package/vendor/sdk/lib/UnoverseComponent.tsx +120 -0
  34. package/vendor/sdk/lib/connection.tsx +302 -0
  35. package/vendor/sdk/lib/core/actions.ts +84 -0
  36. package/vendor/sdk/lib/core/client.ts +134 -0
  37. package/vendor/sdk/lib/core/conditions.ts +43 -0
  38. package/vendor/sdk/lib/core/connection.ts +142 -0
  39. package/vendor/sdk/lib/core/index.ts +32 -0
  40. package/vendor/sdk/lib/core/store.ts +455 -0
  41. package/vendor/sdk/lib/core/types.ts +194 -0
  42. package/vendor/sdk/lib/index.ts +59 -0
  43. package/vendor/sdk/lib/isolate.tsx +70 -0
  44. package/vendor/sdk/lib/primitives.tsx +453 -0
  45. package/vendor/sdk/lib/realtime/audioUtils.ts +40 -0
  46. package/vendor/sdk/lib/realtime/types.ts +45 -0
  47. package/vendor/sdk/lib/realtime/useAudioCapture.ts +237 -0
  48. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +145 -0
  49. package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +164 -0
  50. package/vendor/sdk/lib/render.tsx +178 -0
  51. package/vendor/sdk/lib/streamed.tsx +65 -0
  52. package/vendor/sdk/lib/style.ts +227 -0
  53. package/vendor/sdk/lib/template.tsx +521 -0
  54. package/vendor/sdk/lib/theme.ts +55 -0
  55. package/vendor/sdk/lib/voice.tsx +311 -0
  56. package/vendor/sdk/main.tsx +96 -0
  57. package/vite.config.ts +70 -0
@@ -0,0 +1,521 @@
1
+ /**
2
+ * <StreamedUnoverseTemplate> — renders a TEMPLATE definition (the layout) and
3
+ * fills its ComponentSlots from the store's timeline.
4
+ *
5
+ * A template owns nothing (UNOVERSE_SPEC.md §2e-0): it reads the shared state
6
+ * and arranges what it finds. This component is that arrangement —
7
+ * - reads the template recipe by URI (resources/read, via the client),
8
+ * - subscribes to the store so it re-renders as components stream in,
9
+ * - resolves each ComponentSlot against the timeline and renders the matched
10
+ * leaves with the SAME StreamedUnoverseComponent used everywhere else.
11
+ *
12
+ * This slice handles ComponentSlot + Skeleton (Tier-1, e.g. KeyService). The
13
+ * chat primitives (Conversation/Input/…) come in later slices.
14
+ */
15
+ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";
16
+ import { dispatchAction, type ComponentStore, type ResolvedTheme, type UnoverseClient, type UnoverseDefinition, type UnoverseNode } from "./core";
17
+ import { renderNode, styleToCss, keyframesCss, cssWidth, type ActionHandler, type SlotResolver } from "./render";
18
+ import { StreamedUnoverseComponent } from "./streamed";
19
+ import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
20
+ import { IsolatedRoot } from "./isolate";
21
+ import { useVoiceService, type UseVoiceServiceConfig } from "./voice";
22
+
23
+ export interface StreamedUnoverseTemplateProps {
24
+ client: UnoverseClient;
25
+ store: ComponentStore;
26
+ /** e.g. "unoverse://templates/KeyService" */
27
+ uri: string;
28
+ onAction?: ActionHandler;
29
+ theme?: ResolvedTheme;
30
+ /** Channel overrides for the template's declared props (e.g. per-tenant logoUrl/brandName).
31
+ * Merged OVER the definition's own `props` defaults into the root data scope. */
32
+ props?: Record<string, unknown>;
33
+ /** Render inside a Shadow DOM for full CSS isolation (default true). See UnoverseComponent. */
34
+ isolate?: boolean;
35
+ /** The `voice` NATIVE SERVICE config (UNOVERSE_SPEC §2e-1). Present ONLY for a
36
+ * `service: "voice"` app — the host supplies the connection identity (apiUrl/wsUrl/
37
+ * token/session) it already holds; the renderer OWNS the service so every host is
38
+ * identical. Absent → the service is inert (no mic/WS ever opens). This is the ONE
39
+ * shared home for the voice buttons: `startCall`/`endCall`/`toggleMute` are answered
40
+ * HERE, not hand-wired per host (that drift is what broke the demo's Start Call). */
41
+ voice?: UseVoiceServiceConfig;
42
+ }
43
+
44
+ /** Inert voice config for non-voice templates — the hook is called unconditionally
45
+ * (rules of hooks) but nothing opens until a `startCall` this template never fires.
46
+ * No `store` → not a producer, so it writes no callState into ordinary templates. */
47
+ const INERT_VOICE: UseVoiceServiceConfig = {
48
+ apiUrl: "",
49
+ session: { userId: "", conversationId: "", workflowId: "", targetTriggerNode: "" },
50
+ };
51
+
52
+ /** The author's declared prop defaults (def.props[k].default) → a flat data object. Content, not policy. */
53
+ function propDefaults(props?: Record<string, unknown>): Record<string, unknown> {
54
+ const out: Record<string, unknown> = {};
55
+ for (const [k, v] of Object.entries(props ?? {})) {
56
+ if (v && typeof v === "object" && "default" in (v as Record<string, unknown>)) {
57
+ out[k] = (v as { default: unknown }).default;
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** Format a turn timestamp as relative copy ("just now" / "5 min ago"). Neutral
64
+ * projection of a model value (like `text`), not UX policy. Mirrors legacy
65
+ * ChatHistoryItem.formatRelativeTime. Empty string when unstamped. */
66
+ function formatRelative(ts?: number): string {
67
+ if (!ts) return "";
68
+ const diff = Math.floor((Date.now() - ts) / 1000);
69
+ if (diff < 60) return "just now";
70
+ if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
71
+ if (diff < 86400) return `${Math.floor(diff / 3600)} hr ago`;
72
+ return new Date(ts).toLocaleDateString();
73
+ }
74
+
75
+ /**
76
+ * Walk a template tree and collect every VIEW a reaction surface claims — the `eq`
77
+ * value of each `ComponentSlot.select.where { field: "defaultState" }`. State-selected
78
+ * UI (STATE_MODEL §5b): a component instance renders in exactly ONE placeholder. If a
79
+ * surface shows a given view, the conversation flow (the `inline` placeholder) must NOT
80
+ * also render instances in that view — else the same instance paints twice. A view no
81
+ * surface claims is absent from this set, so it stays in the flow (the fallback).
82
+ * Exported for testing.
83
+ */
84
+ export function collectSurfacedViews(node: unknown, out: Set<string> = new Set()): Set<string> {
85
+ if (Array.isArray(node)) {
86
+ for (const n of node) collectSurfacedViews(n, out);
87
+ return out;
88
+ }
89
+ if (!node || typeof node !== "object") return out;
90
+ const n = node as Record<string, unknown>;
91
+ const sel = n.select as { where?: { field?: string; eq?: unknown } } | undefined;
92
+ if (n.type === "ComponentSlot" && sel?.where?.field === "defaultState" && typeof sel.where.eq === "string") {
93
+ out.add(sel.where.eq);
94
+ }
95
+ for (const v of Object.values(n)) if (v && typeof v === "object") collectSurfacedViews(v, out);
96
+ return out;
97
+ }
98
+
99
+ /**
100
+ * STATE-OWNED APP WIDTH (docs/design/05, layouts era): the app's outer width is
101
+ * declared by its LAYOUT's data, never the manifest. Two declaration points, both
102
+ * the neutral `appWidth` key (a named app size resolved via theme.appSize):
103
+ * - a plain node's `appWidth` = an always-open panel (the core column) — the
104
+ * layout's constant term.
105
+ * - a ComponentSlot surface's `appWidth` = its panel width, counted only while
106
+ * the surface is OCCUPIED (its view is the active state).
107
+ * The caller passes the ACTIVE layout (resolveActiveLayout) — layout swaps are
108
+ * whole-arrangement swaps, so the width is always the active layout's total.
109
+ * Activation is DERIVED, never stored. No declared width → undefined: the host
110
+ * keeps its own default, the sizing twin of "unmatched view falls back to inline".
111
+ * There is no other path.
112
+ */
113
+ export function computeAppWidth(root: unknown, store: ComponentStore, appSize?: Record<string, string>): string | undefined {
114
+ // Resolve a declared width: a NAMED SIZE via theme.appSize (like any token), else raw
115
+ // CSS — guarded so an unresolvable name can never emit invalid CSS.
116
+ const rw = (w: unknown): string | undefined => cssWidth(typeof w === "string" ? (appSize?.[w] ?? w) : undefined);
117
+ const r = root as Record<string, unknown> | null | undefined;
118
+ const widths: string[] = [];
119
+ // Gather the tree's width-bearing nodes: always-open core panels and the reaction
120
+ // SURFACES (view → panel width).
121
+ const surfaces = new Map<string, string | undefined>();
122
+ const walk = (node: unknown): void => {
123
+ if (Array.isArray(node)) {
124
+ for (const c of node) walk(c);
125
+ return;
126
+ }
127
+ if (!node || typeof node !== "object") return;
128
+ const n = node as Record<string, unknown> & { appWidth?: string; type?: string; select?: { where?: { field?: string; eq?: unknown } } };
129
+ if (n !== r) {
130
+ if (n.type === "ComponentSlot") {
131
+ const claim = n.select?.where;
132
+ if (claim?.field === "defaultState" && typeof claim.eq === "string") surfaces.set(claim.eq, rw(n.appWidth));
133
+ } else if (n.appWidth !== undefined) {
134
+ const w = rw(n.appWidth);
135
+ if (w) widths.push(w); // a plain panel (the core column) is ALWAYS open
136
+ }
137
+ }
138
+ for (const v of Object.values(n)) if (v && typeof v === "object") walk(v);
139
+ };
140
+ walk(r);
141
+ // ONE STATE AT A TIME (docs/design/04): the app = core + the ACTIVE state's panel —
142
+ // the most recently surfaced view. Panel combinations cannot exist, so the width is
143
+ // always one of a small known set, bounded by construction.
144
+ let active = "";
145
+ let activeAt = -1;
146
+ for (const turn of store.getResponses())
147
+ for (const p of turn.components) {
148
+ const { chatId, nodeId } = store.split(p);
149
+ const view = (store.get(chatId, nodeId) as Record<string, unknown>)?.defaultState;
150
+ if (typeof view === "string" && surfaces.has(view) && store.touchOf(p) > activeAt) {
151
+ activeAt = store.touchOf(p);
152
+ active = view;
153
+ }
154
+ }
155
+ const activePanel = active ? surfaces.get(active) : undefined;
156
+ if (activePanel) widths.push(activePanel);
157
+ if (!widths.length) return undefined;
158
+ return widths.length === 1 ? widths[0] : `calc(${widths.join(" + ")})`;
159
+ }
160
+
161
+ /**
162
+ * TEMPLATE LAYOUTS — name-sync (docs/design/05). A template with `layouts` is a set
163
+ * of full arrangements, the template-tier twin of a component's faces: the ACTIVE
164
+ * layout is the one whose NAME matches the latest surfaced component view, falling
165
+ * back to `defaultLayout` when nothing (or nothing matching) is surfaced. Same
166
+ * derivation as the active state — read off the store, never stored. Single-layout
167
+ * templates (`layouts` absent) resolve to `root`: the classic behavior, untouched.
168
+ */
169
+ export function resolveActiveLayout(def: UnoverseDefinition | null | undefined, store: ComponentStore): UnoverseNode | undefined {
170
+ if (!def) return undefined;
171
+ const layouts = def.layouts;
172
+ if (!layouts) return def.root;
173
+ const { view } = resolveActiveView(def, store, new Set(Object.keys(layouts)));
174
+ return layouts[view] ?? layouts[def.defaultLayout ?? "main"] ?? def.root;
175
+ }
176
+
177
+ /**
178
+ * THE ACTIVE VIEW — the ONE derivation every consumer shares (docs/design/04 + the
179
+ * guest-control law). The latest surfaced component view among `validViews`, with
180
+ * PINNED enforcement: while a pinned layout's view is surfaced, a background arrival
181
+ * cannot displace it — only a guest write anywhere can. Layout choice and surface
182
+ * gating MUST both read this function: two loops that each pick "the latest view"
183
+ * disagree the moment pin overrides one of them — the layout renders view A while
184
+ * every surface inside it gates against view B, and the whole panel goes blank.
185
+ */
186
+ export function resolveActiveView(
187
+ def: UnoverseDefinition | null | undefined,
188
+ store: ComponentStore,
189
+ validViews: Set<string>,
190
+ ): { view: string; byGuest: boolean } {
191
+ let view = "";
192
+ let at = -1;
193
+ let byGuest = false;
194
+ let pinnedView = "";
195
+ let pinnedAt = -1;
196
+ for (const turn of store.getResponses())
197
+ for (const p of turn.components) {
198
+ const { chatId, nodeId } = store.split(p);
199
+ const v = (store.get(chatId, nodeId) as Record<string, unknown>)?.defaultState;
200
+ if (typeof v !== "string" || !validViews.has(v)) continue;
201
+ const t = store.touchOf(p);
202
+ if (t > at) {
203
+ at = t;
204
+ view = v;
205
+ byGuest = store.interactionTouchOf(p) >= t;
206
+ }
207
+ if ((def?.layouts?.[v] as { pinned?: boolean } | undefined)?.pinned && t > pinnedAt) {
208
+ pinnedAt = t;
209
+ pinnedView = v;
210
+ }
211
+ }
212
+ if (pinnedView && view !== pinnedView && !byGuest) return { view: pinnedView, byGuest: false };
213
+ return { view, byGuest };
214
+ }
215
+
216
+ /** Resolve a ComponentSlot's `select` against the store → ordered pointers. Exported for testing. */
217
+ export function selectPointers(store: ComponentStore, node: UnoverseNode): string[] {
218
+ const sel = node.select ?? {};
219
+ let pointers =
220
+ sel.from === "all"
221
+ ? store.getResponses().flatMap((r) => r.components)
222
+ : (store.latestResponse()?.components ?? []);
223
+
224
+ // `where` — select by COMPONENT STATE (the reaction contract, STATE_MODEL §5b): any
225
+ // component whose own slice matches, ordered newest state-write first so `limit: 1`
226
+ // = "most recent wins". The generic replacement for type-pinned surfaces — a
227
+ // template reacts to whatever component enters the state, never to a named type.
228
+ if (sel.where) {
229
+ const { field, eq, ne } = sel.where;
230
+ pointers = pointers.filter((p) => {
231
+ const { chatId, nodeId } = store.split(p);
232
+ const v = (store.get(chatId, nodeId) as Record<string, unknown>)[field];
233
+ return ne !== undefined ? v !== ne : v === eq;
234
+ });
235
+ pointers = [...pointers].sort((a, b) => store.touchOf(b) - store.touchOf(a));
236
+ }
237
+ if (sel.limit != null) pointers = pointers.slice(0, sel.limit);
238
+ return pointers;
239
+ }
240
+
241
+ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme: themeProp, props, isolate = true, voice: voiceConfig }: StreamedUnoverseTemplateProps) {
242
+ const [def, setDef] = useState<UnoverseDefinition | null>(null);
243
+ const [error, setError] = useState<string | null>(null);
244
+ // The voice native service lives HERE (shared), not in each host. Called every render
245
+ // (rules of hooks); inert until a `startCall` only a voice template has a button for.
246
+ // The audio WS lane travels WITH the SDK: derive it from the client's own server origin
247
+ // (same server that hosts /mcp + /stream + /ws/gravity), NEVER from the host — a host's
248
+ // `location.origin` is "null" in a sandboxed srcdoc iframe. Overrides any host wsUrl.
249
+ const voice = useVoiceService(
250
+ voiceConfig ? { ...voiceConfig, wsUrl: client.serverOrigin.replace(/^http/, "ws") } : INERT_VOICE,
251
+ );
252
+ // No theme passed → fetch from the server (the SDK owns zero token values). The
253
+ // theme REF comes from the definition itself: a template belongs to a client org,
254
+ // and that org names its theme — `<org>/light`. Wait for the def before fetching
255
+ // (null = no fetch) so the wrong theme never flashes.
256
+ const fetched = useUnoverseTheme(client, themeProp ? null : def ? (def.org ? `${def.org}/light` : "light") : null);
257
+ const theme = themeProp ?? fetched;
258
+ // Register the theme's webfonts (served DATA) at document level — see ensureFontStylesheets.
259
+ useEffect(() => ensureFontStylesheets(theme), [theme]);
260
+
261
+ // Re-render on STRUCTURE changes only (turns placed/completed, template state,
262
+ // lifecycle). COMPONENT_DATA merges don't move the timeline — the leaves subscribe
263
+ // to their own slices — so streaming chunks never re-walk the whole template tree.
264
+ // Stable subscribe fn: an inline arrow would tear down + re-create the subscription
265
+ // every render.
266
+ useSyncExternalStore(
267
+ useCallback((cb) => store.subscribe(cb), [store]),
268
+ useCallback(() => store.getStructureVersion(), [store]),
269
+ );
270
+
271
+ useEffect(() => {
272
+ let alive = true;
273
+ setDef(null);
274
+ setError(null);
275
+ client
276
+ .readDefinition(uri)
277
+ .then((d) => alive && setDef(d))
278
+ .catch((e) => alive && setError(String(e?.message ?? e)));
279
+ return () => {
280
+ alive = false;
281
+ };
282
+ }, [client, uri]);
283
+
284
+ // NAME-SYNC: the tree this template presents — the layout matching the latest
285
+ // surfaced component view (multi-layout templates), else the classic root. The
286
+ // resolved node is a stable per-def object, so downstream memos key off it.
287
+ const activeRoot = def ? resolveActiveLayout(def, store) : undefined;
288
+
289
+ // The set of VIEWS this template's reaction surfaces claim — computed per active
290
+ // layout: layout NAMES claim their view (name-sync) alongside the surfaces inside.
291
+ // The timeline uses it to keep a claimed instance out of the flow (one instance → one
292
+ // placeholder).
293
+ const surfacedViews = useMemo(() => {
294
+ const views = activeRoot ? collectSurfacedViews(activeRoot) : new Set<string>();
295
+ for (const name of Object.keys(def?.layouts ?? {})) views.add(name);
296
+ return views;
297
+ }, [activeRoot, def]);
298
+
299
+ // Loading/error hints carry NO styling — the SDK owns zero styles.
300
+ if (error) return <div>Unoverse error: {error}</div>;
301
+ if (!def || !theme) return <div>Loading {uri}…</div>;
302
+
303
+ // ONE action interpreter for the whole tree: template chrome runs the same
304
+ // `dispatchAction` (core/actions.ts) every leaf runs — `setTemplateValue` writes
305
+ // template state, `then` chains, anything else routes to the channel. Template
306
+ // chrome has NO component slice, so `setValue` here is an authoring error (it
307
+ // belongs on the component); the empty ids make that write inert.
308
+ const dispatch: ActionHandler = (action, data, meta) => {
309
+ // VOICE NATIVE-SERVICE actions are answered by the shared service (§2e-1) — never
310
+ // forwarded to the host/workflow. This is the single wiring point that makes every
311
+ // host behave identically; audio rides the /ws/gravity lane, not the component stream.
312
+ if (typeof action === "string") {
313
+ if (action === "startCall") return void voice.startCall();
314
+ if (action === "endCall") return void voice.endCall();
315
+ if (action === "toggleMute") return void voice.toggleMute();
316
+ // "back to chat" leaves the voice surface — tear the call down first, then let the
317
+ // host handle any navigation it owns (falls through).
318
+ if (action === "backToChat") voice.endCall();
319
+ }
320
+ // GUEST CLOSE from TEMPLATE CHROME: a ✕ on the layout itself (a rail/panel header —
321
+ // not inside a single card) fires the CloseButton atom's `setValue defaultState:"inline"`.
322
+ // Template chrome has no card slice to write, so that write would be inert; instead it
323
+ // means "un-surface the current view" → the store retracts every surface to base (the
324
+ // same reset a new turn runs). A card that closes ITSELF keeps using the plain component
325
+ // write (real ids), untouched. `then` still chains via dispatchAction below.
326
+ if (
327
+ action && typeof action === "object" &&
328
+ (action as { type?: string }).type === "setValue" &&
329
+ (action as { values?: { key?: string; value?: unknown }[] }).values?.some(
330
+ (v) => v.key === "defaultState" && v.value === "inline",
331
+ )
332
+ ) {
333
+ store.closeSurfaces();
334
+ return;
335
+ }
336
+ dispatchAction(action as Parameters<typeof dispatchAction>[0], (data ?? {}) as Record<string, unknown>, {
337
+ store,
338
+ chatId: "",
339
+ nodeId: "",
340
+ sendToServer: onAction ? (t, payload) => onAction(t, payload, meta) : undefined,
341
+ });
342
+ };
343
+
344
+ const leaf = (pointer: string, extraData?: Record<string, unknown>, fill?: boolean) => {
345
+ const { chatId, nodeId } = store.split(pointer);
346
+ return <StreamedUnoverseComponent key={pointer} client={client} store={store} chatId={chatId} nodeId={nodeId} onAction={dispatch} theme={theme} extraData={extraData} fill={fill} />;
347
+ };
348
+
349
+ // THE ACTIVE STATE (docs/design/04): a template is in exactly ONE state at a time —
350
+ // the most recently surfaced view ("" = the conversation/welcome base). DERIVED off
351
+ // the store, never stored. Exactly one reaction surface renders: the active state's.
352
+ //
353
+ // PINNED LAYOUTS — the guest-control law: a layout authored `"pinned": true` changes
354
+ // ONLY by the guest's hand. While a pinned view is surfaced, background arrivals
355
+ // (cards from later searches, streamed renders) cannot displace it (observed live:
356
+ // the panel flapped between a card rail and the composing page). It yields to a
357
+ // GUEST write anywhere (tap-to-focus, its own ✕), to its component leaving the view,
358
+ // or to a template swap. Authored in the layout file — the SDK only enforces it.
359
+ // SAME derivation as resolveActiveLayout (resolveActiveView) — the layout on screen
360
+ // and the state its surfaces gate against must NEVER come from two different loops.
361
+ const surfacedView = resolveActiveView(def, store, surfacedViews).view;
362
+
363
+ // ComponentSlot — filter the timeline to matching component pointers (KeyService).
364
+ const resolveSlot = (node: UnoverseNode, key?: React.Key): ReactNode => {
365
+ // ONE STATE AT A TIME: a reaction surface renders ONLY while its view IS the
366
+ // template's active state. Everything else is not part of this interface — its
367
+ // data is untouched and re-presents the moment its state is active again.
368
+ // Surfaces never stack; panel combinations cannot exist.
369
+ const claim = node.select?.where;
370
+ if (claim?.field === "defaultState" && typeof claim.eq === "string" && claim.eq !== surfacedView) {
371
+ return node.fallback ? renderNode(node.fallback, {}, dispatch, theme, key ?? "fallback") : null;
372
+ }
373
+ const pointers = selectPointers(store, node);
374
+ if (pointers.length === 0) {
375
+ return node.fallback ? renderNode(node.fallback, {}, dispatch, theme, key ?? "fallback") : null;
376
+ }
377
+ // `frame` — chrome that exists ONLY because the slot matched (fallback's twin).
378
+ // The frame subtree renders with a slot resolver that serves the outer slot's
379
+ // matches to any bare nested ComponentSlot — so a focus overlay is BORN when a
380
+ // component enters the state and DIES when it leaves (§5b: derived, not stored).
381
+ if (node.frame) {
382
+ // A SURFACE'S SINGLE OCCUPANT FILLS THE SURFACE (docs/design/05): a limit-1
383
+ // surface (detail panel, focus overlay) gives its occupant the frame's full
384
+ // height — the component's `height: "full"` finally has a definite container,
385
+ // with zero per-face styling. Multi-occupant surfaces (a rail) stay content-sized.
386
+ const fill = node.select?.limit === 1;
387
+ const innerSlot: SlotResolver = () => pointers.map((p) => leaf(p, undefined, fill));
388
+ // A panel declares its width ONCE (docs/design/05): `appWidth` grows the app
389
+ // (computeAppWidth) AND sizes the frame box here — the two can never disagree.
390
+ // Named sizes resolve via theme.appSize like any token; unresolvable → no width.
391
+ // `flex: 0 0 auto` — a panel is its declared width ALWAYS. A container that
392
+ // can't fit the sum clips at the edge; panels never compress into garbage.
393
+ const declared = (node as { appWidth?: string }).appWidth;
394
+ const appWidth = cssWidth(typeof declared === "string" ? (theme.appSize?.[declared] ?? declared) : undefined);
395
+ const frame = appWidth
396
+ ? ({ ...node.frame, style: { ...(node.frame.style ?? {}), width: appWidth, flex: "0 0 auto" } } as UnoverseNode)
397
+ : node.frame;
398
+ return renderNode(frame, store.getTemplateState() as Record<string, unknown>, dispatch, theme, key ?? "frame", innerSlot);
399
+ }
400
+ return pointers.map((p) => leaf(p));
401
+ };
402
+
403
+ // Timeline — GENERIC iterator. Walks the timeline and renders the `user` / `assistant`
404
+ // DATA sub-tree per turn. Zero chat UX in the SDK: bubbles, alignment, structure all live
405
+ // in those sub-trees (rx/templates/chatlayout/*.json). The assistant sub-tree's ComponentSlot
406
+ // is scoped to THAT turn's components.
407
+ const resolveTimeline = (node: UnoverseNode, key?: React.Key): ReactNode => {
408
+ return (
409
+ <div key={key} style={styleToCss(node.style, theme)}>
410
+ {store.getTimeline().map((turn) => {
411
+ const sub = turn.role === "user" ? node.user : node.assistant;
412
+ if (!sub) return null;
413
+ // One instance → one placeholder (STATE_MODEL §5b). An instance whose active
414
+ // VIEW a reaction surface is showing is CLAIMED — the flow (the `inline`
415
+ // placeholder) must not render it too, or the same card paints twice. Instances
416
+ // in a view no surface claims stay here (the fallback). This is what makes "a
417
+ // component lifts into its surface" true instead of double-rendering it inline.
418
+ const shown =
419
+ turn.role === "assistant"
420
+ ? turn.components.filter((p) => {
421
+ const { chatId, nodeId } = store.split(p);
422
+ const view = (store.get(chatId, nodeId) as Record<string, unknown> | undefined)?.defaultState;
423
+ return typeof view !== "string" || !surfacedViews.has(view);
424
+ })
425
+ : [];
426
+ // Slot scoped to this turn's UNCLAIMED components (assistant turns only).
427
+ const turnSlot: SlotResolver = (slotNode, k) => {
428
+ let ptrs = [...shown];
429
+ if (slotNode.select?.limit != null) ptrs = ptrs.slice(0, slotNode.select.limit);
430
+ if (ptrs.length === 0) return slotNode.fallback ? renderNode(slotNode.fallback, {}, dispatch, theme, k ?? "fb") : null;
431
+ // Pass the turn's streaming state into each component's scope so a streamed
432
+ // component (e.g. StreamingText) can stop its own loading dots when complete.
433
+ const streaming = turn.role === "assistant" && turn.streamingState === "streaming";
434
+ return ptrs.map((p) => leaf(p, { streaming }));
435
+ };
436
+ // Data scope per turn = author's static data (avatarUrl, thinkingText…)
437
+ // + NEUTRAL projections of the timeline model. The SDK exposes facts, never
438
+ // UX policy: `text` (user message), `streaming` (the turn's streamingState),
439
+ // `empty` (no components yet). It does NOT decide "show a thinking indicator" —
440
+ // the definition composes that from `streaming` + `empty` (nested visibleWhen =
441
+ // AND). Static author data is forwarded verbatim; the SDK interprets none of it.
442
+ const time = formatRelative(turn.createdAt);
443
+ const data =
444
+ turn.role === "user"
445
+ ? { ...(node.userData ?? {}), text: turn.text, time }
446
+ : {
447
+ ...(node.assistantData ?? {}),
448
+ time,
449
+ streaming: turn.streamingState === "streaming",
450
+ // `empty`/`active` count only UNCLAIMED components: a turn whose sole card
451
+ // lifted into a surface goes inactive (the definition hides it) rather than
452
+ // leaving an empty avatar behind; a still-streaming turn stays active.
453
+ empty: shown.length === 0,
454
+ // `active` = in progress OR has visible content. A completed-empty turn is
455
+ // inactive → the definition hides it (legacy ChatHistoryItem null-returns).
456
+ active: turn.streamingState === "streaming" || shown.length > 0,
457
+ };
458
+ return renderNode(sub, data, dispatch, theme, turn.id, turnSlot);
459
+ })}
460
+ </div>
461
+ );
462
+ };
463
+
464
+ // One resolver for all store-backed template primitives.
465
+ const resolve: SlotResolver = (node, key) =>
466
+ node.type === "Timeline" ? resolveTimeline(node, key) : resolveSlot(node, key);
467
+
468
+ // Root data scope = the author's declared prop defaults (logoUrl, brandName, suggestions…)
469
+ // + channel overrides + NEUTRAL conversation-level facts derived from the store (isEmpty/
470
+ // hasMessages). This is what lets the page AROUND the message list (header, welcome screen,
471
+ // suggestion cards) bind + show/hide — the whole-template counterpart of the per-turn scope
472
+ // Timeline injects. The SDK adds no UX policy: just author content + model projections.
473
+ const isEmpty = store.getTimeline().length === 0;
474
+ const rootData: Record<string, unknown> = {
475
+ ...propDefaults(def.props),
476
+ ...((def as { state?: Record<string, unknown> }).state ?? {}),
477
+ ...(props ?? {}),
478
+ // The active template's OWN state — one generic bag of the DEV's chosen keys (draft,
479
+ // openPanel, suggestions data, focusMode, voice call state, …). No per-feature
480
+ // projections; the engine knows no key names (UNOVERSE_STATE_MODEL §2).
481
+ ...store.getTemplateState(),
482
+ // Conversation-derived facts — READ OFF the timeline / lifecycle, never stored.
483
+ isEmpty,
484
+ hasMessages: !isEmpty,
485
+ surfacedView,
486
+ lifecycle: store.getLifecycle(),
487
+ isThinking: store.getLifecycle() === "thinking",
488
+ isStreaming: store.getLifecycle() === "thinking" || store.getLifecycle() === "streaming",
489
+ };
490
+
491
+ // Inject the served keyframes ONCE for this render root so any `style.animation` in a
492
+ // definition resolves (the SDK animates generically — no per-animation primitive).
493
+ // The wrapper applies the SERVED base render settings (theme.root — font-smoothing, etc.):
494
+ // `display: contents` keeps it out of layout while its inherited props cascade to the tree.
495
+ // The SDK authors only `display: contents` (structure); every value comes from theme.root.
496
+ // Isolated → theme.root on the shadow-root container (reliable cascade); else → display:contents.
497
+ const keyframes = <style>{keyframesCss(theme)}</style>;
498
+ // A layout root never carries `appWidth` (guard-enforced) — panels inside it do.
499
+ const rootNode = (activeRoot ?? def.root) as UnoverseNode;
500
+ const tree = renderNode(rootNode, rootData, dispatch, theme, undefined, resolve);
501
+ // A template is a SURFACE: its render-root fills the box the host gives it, so a root
502
+ // that declares `height: "full"` (DATA) can resolve. This only fills when the host's box
503
+ // is definite-height; when the host sizes to content the 100% resolves to content — so a
504
+ // content-sized app (fluidHeight:false) still wraps its component. The SDK adds no layout
505
+ // policy beyond "a template fills its container"; the template DATA owns everything else.
506
+ const surfaceRoot = { ...(theme.root as CSSProperties), height: "100%" };
507
+ if (isolate) {
508
+ return (
509
+ <IsolatedRoot rootStyle={surfaceRoot}>
510
+ {keyframes}
511
+ {tree}
512
+ </IsolatedRoot>
513
+ );
514
+ }
515
+ return (
516
+ <>
517
+ {keyframes}
518
+ <div style={{ display: "contents", ...surfaceRoot }}>{tree}</div>
519
+ </>
520
+ );
521
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * useUnoverseTheme — fetch the RESOLVED theme from the MCP server by name.
3
+ *
4
+ * The SDK owns ZERO token values. The server owns the visual (apps/unoverse/
5
+ * rx/styles, resolved live) and serves it at `unoverse://theme/{name}`; this hook
6
+ * fetches it (cached in the client). A brand change in rx/styles is refresh-only
7
+ * — there is no baked theme in this bundle to rebuild (UNOVERSE_SPEC §2d-1).
8
+ */
9
+ import { useEffect, useState } from "react";
10
+ import type { ResolvedTheme, UnoverseClient } from "./core";
11
+
12
+ /**
13
+ * Ensure the theme's webfont stylesheets are loaded ONCE in the document head.
14
+ * `theme.fonts` is SERVED DATA (`fonts.stylesheets` in an org's styles) — the SDK
15
+ * authors no font choices, it just registers what the server serves. Fonts must live
16
+ * at document level: a shadow root cannot register @font-face, but faces declared in
17
+ * the document apply inside shadow trees via the font cascade.
18
+ */
19
+ export function ensureFontStylesheets(theme: ResolvedTheme | null): void {
20
+ if (!theme?.fonts?.length || typeof document === "undefined") return;
21
+ for (const href of theme.fonts) {
22
+ if (document.head.querySelector(`link[data-unoverse-font="${CSS.escape(href)}"]`)) continue;
23
+ const link = document.createElement("link");
24
+ link.rel = "stylesheet";
25
+ link.href = href;
26
+ link.setAttribute("data-unoverse-font", href);
27
+ document.head.appendChild(link);
28
+ }
29
+ }
30
+
31
+ /** Returns the resolved theme (null until the first fetch resolves).
32
+ * `name: null` = don't fetch yet (the caller is still deriving the name — e.g. waiting
33
+ * for the definition whose `org` names its pack's theme). An unknown name falls back to
34
+ * the neutral `light` so a pack without a theme still renders. */
35
+ export function useUnoverseTheme(client: UnoverseClient, name: string | null = "light"): ResolvedTheme | null {
36
+ const [theme, setTheme] = useState<ResolvedTheme | null>(null);
37
+ useEffect(() => {
38
+ if (!name) return;
39
+ let alive = true;
40
+ client
41
+ .readTheme(name)
42
+ .then((t) => alive && setTheme(t))
43
+ .catch(() => {
44
+ if (!alive || name === "light") return;
45
+ client
46
+ .readTheme("light")
47
+ .then((t) => alive && setTheme(t))
48
+ .catch(() => {});
49
+ });
50
+ return () => {
51
+ alive = false;
52
+ };
53
+ }, [client, name]);
54
+ return theme;
55
+ }