@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,210 @@
1
+ /**
2
+ * The universes this developer can publish to.
3
+ *
4
+ * A UNIVERSE NAMES ITS OWN IDENTITY PROVIDER. Studio hardcodes no issuer and no vendor:
5
+ * it asks a universe at `/.well-known/unoverse-universe` and follows the answer, so
6
+ * Auth0, Okta, Entra and Keycloak all work with no change here. That is the whole reason
7
+ * login lives in Studio (a browser, where OIDC is a redirect) rather than in the CLI.
8
+ *
9
+ * THERE IS NO CENTRAL DIRECTORY, and there cannot be. Every universe is independent with
10
+ * its own database and its own IdP (docs/AUTH_TOKEN_FLOW.md: "OIDC-provider-agnostic").
11
+ * Nothing sits above them to enumerate. So "the universes I can deploy to" means the ones
12
+ * this developer has connected to, held here on their machine. That is the honest answer,
13
+ * not a placeholder for a registry.
14
+ *
15
+ * NO TOKEN IS EVER WRITTEN HERE. Only the URL, a label, and the public config. The access
16
+ * token stays wherever the OIDC library keeps it for the session. Local Studio already
17
+ * holds this line for credentials (`local/keys.ts`: "Studio therefore never WRITES a
18
+ * secret") and connecting to a universe must not be the thing that breaks it.
19
+ *
20
+ * See docs/architecture/LOCAL_STUDIO.md.
21
+ */
22
+
23
+ /** What a universe publishes about itself. Mirrors `getPublicAuthConfig()` on the server. */
24
+ export interface UniverseConfig {
25
+ /** False means the universe is not gated at all: do not send anyone to log in. */
26
+ authEnabled: boolean;
27
+ issuer: string | null;
28
+ clientId: string | null;
29
+ audience: string | null;
30
+ /** The permission a token needs before this universe accepts a publish. */
31
+ publishPermission: string;
32
+ }
33
+
34
+ export interface Universe {
35
+ /** Origin, normalised. The identity of the entry: one row per universe. */
36
+ url: string;
37
+ /** What the developer calls it. Defaults to the host. */
38
+ label: string;
39
+ config: UniverseConfig;
40
+ /** When it was last reachable, ISO. Purely informational. */
41
+ connectedAt?: string;
42
+ }
43
+
44
+ const STORAGE_KEY = "studio:universes";
45
+
46
+ /** The path a universe serves its public config on. */
47
+ export const DISCOVERY_PATH = "/.well-known/unoverse-universe";
48
+
49
+ /**
50
+ * Turn whatever a developer typed into an origin.
51
+ *
52
+ * People type `localhost:4105`, `example.com/`, and `https://example.com/canvas`. All
53
+ * three mean one universe, and storing them as three entries would let the same universe
54
+ * hold three different logins. So this is the identity function for the list.
55
+ *
56
+ * Bare hosts default to https, EXCEPT localhost and 127.0.0.1, which are overwhelmingly
57
+ * a developer's own machine over http and would otherwise fail confusingly.
58
+ */
59
+ export function normaliseUrl(input: string): string {
60
+ const trimmed = (input ?? "").trim();
61
+ if (!trimmed) throw new Error("Enter a universe address");
62
+
63
+ const isLocal = /^(https?:\/\/)?(localhost|127\.0\.0\.1)(:|\/|$)/i.test(trimmed);
64
+ const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `${isLocal ? "http" : "https"}://${trimmed}`;
65
+
66
+ let parsed: URL;
67
+ try {
68
+ parsed = new URL(withScheme);
69
+ } catch {
70
+ throw new Error(`"${input}" is not a valid address`);
71
+ }
72
+ return parsed.origin;
73
+ }
74
+
75
+ /** A readable default name: the host, without the port for a real domain. */
76
+ export function defaultLabel(url: string): string {
77
+ try {
78
+ return new URL(url).host;
79
+ } catch {
80
+ return url;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Ask a universe how to log in.
86
+ *
87
+ * Failure here is the common case, not the exception: wrong address, universe down, a
88
+ * plain website that is not a universe at all. Each gets a message a developer can act
89
+ * on, because "Failed to fetch" tells them nothing about which of the three it was.
90
+ */
91
+ export async function discover(
92
+ url: string,
93
+ fetchImpl: typeof fetch = fetch,
94
+ ): Promise<UniverseConfig> {
95
+ let response: Response;
96
+ try {
97
+ response = await fetchImpl(`${url}${DISCOVERY_PATH}`, { headers: { accept: "application/json" } });
98
+ } catch {
99
+ throw new Error(`Could not reach ${url}. Check the address, and that the universe is running.`);
100
+ }
101
+
102
+ if (response.status === 404) {
103
+ throw new Error(`Something answered at ${url}, but it is not a universe.`);
104
+ }
105
+ if (!response.ok) {
106
+ throw new Error(`${url} answered with an error (${response.status}).`);
107
+ }
108
+
109
+ let body: unknown;
110
+ try {
111
+ body = await response.json();
112
+ } catch {
113
+ throw new Error(`${url} answered, but not like a universe. If it is one, it may need restarting.`);
114
+ }
115
+
116
+ const cfg = body as Partial<UniverseConfig>;
117
+ // authEnabled is the field that decides whether we send someone to log in, so its
118
+ // absence is a broken response rather than a default worth guessing at.
119
+ if (typeof cfg?.authEnabled !== "boolean") {
120
+ throw new Error(`${url} answered, but not like a universe. If it is one, it may need restarting.`);
121
+ }
122
+
123
+ return {
124
+ authEnabled: cfg.authEnabled,
125
+ issuer: cfg.issuer ?? null,
126
+ clientId: cfg.clientId ?? null,
127
+ audience: cfg.audience ?? null,
128
+ publishPermission: cfg.publishPermission ?? "marketplace:publish",
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Can this universe actually be logged into?
134
+ *
135
+ * A universe can say `authEnabled: true` and still be missing an issuer or a client id,
136
+ * which is a misconfigured universe rather than a Studio problem. Saying so beats
137
+ * redirecting the browser to `undefined`.
138
+ */
139
+ export function loginReady(config: UniverseConfig): { ok: true } | { ok: false; reason: string } {
140
+ if (!config.authEnabled) return { ok: false, reason: "This universe has authentication switched off." };
141
+ if (!config.issuer) return { ok: false, reason: "This universe has no identity provider configured." };
142
+ if (!config.clientId) return { ok: false, reason: "This universe has no client id configured for sign-in." };
143
+ return { ok: true };
144
+ }
145
+
146
+ /**
147
+ * Does this token allow publishing here?
148
+ *
149
+ * Checked so Studio can say "signed in, but not allowed to publish" up front, instead of
150
+ * letting someone build for an hour and fail on the push. Namespace-aware for the same
151
+ * reason the server is: an IdP may emit `permissions` under a namespaced claim, and a
152
+ * plain read silently returns nothing (docs/AUTH_TOKEN_FLOW.md).
153
+ */
154
+ export function canPublish(claims: Record<string, unknown> | null | undefined, config: UniverseConfig): boolean {
155
+ if (!claims) return false;
156
+ const direct = claims.permissions;
157
+ const namespacedKey = Object.keys(claims).find(
158
+ (k) => k.endsWith("/permissions") || k.endsWith("/claims/permissions"),
159
+ );
160
+ const list = Array.isArray(direct) ? direct : namespacedKey && Array.isArray(claims[namespacedKey]) ? (claims[namespacedKey] as unknown[]) : [];
161
+
162
+ // TRIMMED, because a permission is typed into an identity provider by a person and a
163
+ // stray space is invisible everywhere it is displayed. One arrived as " marketplace:publish"
164
+ // and cost a long debugging session: the Auth0 screen showed it assigned, the token
165
+ // carried it, the label printed it, and the comparison correctly said no. Nothing about
166
+ // surrounding whitespace is ever meaningful in a permission name.
167
+ const wanted = String(config.publishPermission).trim();
168
+ return list.some((p) => String(p).trim() === wanted);
169
+ }
170
+
171
+ // ── The stored list ───────────────────────────────────────────────────────
172
+
173
+ type Store = Pick<Storage, "getItem" | "setItem">;
174
+
175
+ function store(): Store | null {
176
+ try {
177
+ return typeof localStorage === "undefined" ? null : localStorage;
178
+ } catch {
179
+ return null; // storage blocked (private mode, embedded contexts)
180
+ }
181
+ }
182
+
183
+ /** Every universe this developer has connected to. Never throws: a corrupt entry is
184
+ * dropped rather than taking the whole list, and the app down with it. */
185
+ export function loadUniverses(storage: Store | null = store()): Universe[] {
186
+ const raw = storage?.getItem(STORAGE_KEY);
187
+ if (!raw) return [];
188
+ try {
189
+ const parsed = JSON.parse(raw);
190
+ if (!Array.isArray(parsed)) return [];
191
+ return parsed.filter((u): u is Universe => typeof u?.url === "string" && typeof u?.config?.authEnabled === "boolean");
192
+ } catch {
193
+ return [];
194
+ }
195
+ }
196
+
197
+ /** Add or update one, keyed on url so reconnecting refreshes rather than duplicates. */
198
+ export function saveUniverse(universe: Universe, storage: Store | null = store()): Universe[] {
199
+ const next = [...loadUniverses(storage).filter((u) => u.url !== universe.url), universe].sort((a, b) =>
200
+ a.label.localeCompare(b.label),
201
+ );
202
+ storage?.setItem(STORAGE_KEY, JSON.stringify(next));
203
+ return next;
204
+ }
205
+
206
+ export function removeUniverse(url: string, storage: Store | null = store()): Universe[] {
207
+ const next = loadUniverses(storage).filter((u) => u.url !== url);
208
+ storage?.setItem(STORAGE_KEY, JSON.stringify(next));
209
+ return next;
210
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * The native app ENGINE (§6d) — surface-agnostic. It:
3
+ * - reads its OWN workflow binding from its manifest (§4b: "App supplies workflow, trigger"),
4
+ * - opens the SDK's native connection (`/stream` register_session → store),
5
+ * - fires the workflow DIRECTLY via `conn.sendMessage` → REST `{apiUrl}/api/workflows/:id/execute`
6
+ * with the USER's token (§5a: "direct channel → Gravity backend — never brokered").
7
+ *
8
+ * The server tool only LOADS this app; it never fires or brokers a token. Uses the LOCAL
9
+ * vendored SDK (./lib), not the npm package.
10
+ */
11
+ import { useEffect, useMemo, useRef, useState } from "react";
12
+ import {
13
+ UnoverseClient,
14
+ ComponentStore,
15
+ StreamedUnoverseTemplate,
16
+ useUnoverseConnection,
17
+ useUnoverseTheme,
18
+ computeAppWidth,
19
+ resolveActiveLayout,
20
+ type UseVoiceServiceConfig,
21
+ } from "./lib";
22
+
23
+ export interface AppConfig {
24
+ serverUrl: string;
25
+ apiUrl?: string;
26
+ templateId: string;
27
+ getAccessToken?: () => Promise<string | null> | string | null;
28
+ userId?: string;
29
+ conversationId: string;
30
+ }
31
+
32
+ export function AppEngine(cfg: AppConfig) {
33
+ const { serverUrl, apiUrl, templateId, getAccessToken, userId, conversationId } = cfg;
34
+
35
+ const store = useMemo(() => new ComponentStore(), []);
36
+ const client = useMemo(
37
+ () => new UnoverseClient(`${serverUrl}/mcp`, { getAccessToken: getAccessToken as any }),
38
+ [serverUrl],
39
+ );
40
+ const [binding, setBinding] = useState<{ workflow?: string; trigger?: string; service?: string }>({});
41
+ // STATE-OWNED WIDTH (rx data): the layout root's `appWidth` = the core surface's
42
+ // constant width; an occupied panel slot's `appWidth` adds to it (computeAppWidth).
43
+ // No declared width → the host keeps its own default. There is no other path.
44
+ const [def, setDef] = useState<{ root?: unknown; org?: string; layouts?: Record<string, unknown>; defaultLayout?: string } | null>(null);
45
+ const lastWidthRef = useRef<string | undefined>(undefined);
46
+ // Named app sizes resolve via the served theme (theme.appSize), like every token.
47
+ const theme = useUnoverseTheme(client, def ? (def.org ? `${def.org}/light` : "light") : null);
48
+
49
+ // The app owns which workflow it runs — read it from its manifest (§4b), not from config.
50
+ useEffect(() => {
51
+ let alive = true;
52
+ client
53
+ .connect()
54
+ .then(() => (client as any).client.readResource({ uri: `unoverse://apps/${templateId.toLowerCase()}` }))
55
+ .then((res: any) => {
56
+ const parsed = res?.contents?.[0]?.text ? JSON.parse(res.contents[0].text) : null;
57
+ const m = parsed?.manifest ?? parsed ?? {};
58
+ const b = m.binding ?? {};
59
+ // `service` (e.g. "voice") is the app's own declaration — it selects whether the
60
+ // renderer runs a native service. Read it here, next to the binding, and forward it.
61
+ if (alive) setBinding({ workflow: b.workflow, trigger: b.trigger, service: m.service });
62
+ })
63
+ .catch((e) => console.error("[app] manifest read failed:", e));
64
+ // The width lives in the template DATA (root/slot `appWidth`), so read the
65
+ // definition too — same resource the renderer loads.
66
+ client
67
+ .connect()
68
+ .then(() => client.readDefinition(`unoverse://templates/${templateId}`))
69
+ .then((d) => {
70
+ const t = d as { root?: unknown; org?: string; layouts?: Record<string, unknown>; defaultLayout?: string } | null;
71
+ if (alive) setDef(t ? { root: t.root, org: t.org, layouts: t.layouts, defaultLayout: t.defaultLayout } : null);
72
+ })
73
+ .catch((e) => console.error("[app] template read failed:", e));
74
+ return () => {
75
+ alive = false;
76
+ };
77
+ }, [client, templateId]);
78
+
79
+ // Report the app's width UP to the embedding host (iframe boundary → the twin of the
80
+ // inbound `unoverse:config` channel), re-reporting whenever a panel surface opens or
81
+ // closes: the app grows by the panel's declared width, then shrinks back. The host
82
+ // animates the resize. Only posts on an ACTUAL width change (guard).
83
+ useEffect(() => {
84
+ const report = () => {
85
+ // NAME-SYNC: width is computed off the ACTIVE layout (multi-layout templates
86
+ // swap whole arrangements); single-layout defs resolve to their root.
87
+ const w = computeAppWidth(resolveActiveLayout(def as never, store), store, theme?.appSize);
88
+ if (w && w !== lastWidthRef.current) {
89
+ lastWidthRef.current = w;
90
+ window.parent?.postMessage({ type: "unoverse:size", width: w }, "*");
91
+ }
92
+ };
93
+ report();
94
+ return store.subscribe(report);
95
+ }, [store, def, theme]);
96
+
97
+ // SDK's native connection: opens /stream, register_session, feeds the store. Outbound
98
+ // sendMessage fires the workflow DIRECTLY (REST, user token) — no server broker.
99
+ const conn = useUnoverseConnection(
100
+ { apiUrl: apiUrl ?? serverUrl, streamUrl: serverUrl, getAccessToken: getAccessToken as any },
101
+ {
102
+ workflowId: binding.workflow ?? "",
103
+ targetTriggerNode: binding.trigger ?? "",
104
+ userId: userId ?? "",
105
+ conversationId,
106
+ chatId: conversationId,
107
+ },
108
+ store,
109
+ // Open only once BOTH are ready: the user identity AND the manifest binding (read async).
110
+ // Avoids the transient "connection missing workflowId/userId/conversationId" warning.
111
+ { enabled: !!userId && !!binding.workflow },
112
+ );
113
+
114
+ // VOICE (§2e-1): a `service: "voice"` app runs the native voice service — which now lives
115
+ // in the shared renderer (StreamedUnoverseTemplate), identical to Studio. The host only
116
+ // supplies the connection identity it already holds; startCall/endCall/toggleMute are
117
+ // answered inside the renderer, so this host wires NO voice buttons. Absent → inert.
118
+ const voiceConfig = useMemo<UseVoiceServiceConfig | undefined>(
119
+ () =>
120
+ binding.service === "voice" && binding.workflow
121
+ ? {
122
+ // START_CALL REST triggers the workflow on the gateway (user token). The audio
123
+ // WS lane is derived by the SDK from the client's server origin (see the renderer)
124
+ // — the host passes NO wsUrl (its srcdoc `location.origin` is "null").
125
+ apiUrl: apiUrl ?? serverUrl,
126
+ getAccessToken: getAccessToken as UseVoiceServiceConfig["getAccessToken"],
127
+ session: {
128
+ userId: userId ?? "",
129
+ conversationId,
130
+ chatId: conversationId,
131
+ workflowId: binding.workflow,
132
+ targetTriggerNode: binding.trigger ?? "",
133
+ },
134
+ // Producer: writes callState/isMuted into TEMPLATE STATE so the voice states switch.
135
+ store,
136
+ }
137
+ : undefined,
138
+ [binding.service, binding.workflow, binding.trigger, apiUrl, serverUrl, getAccessToken, userId, conversationId, store],
139
+ );
140
+
141
+ return (
142
+ <StreamedUnoverseTemplate
143
+ client={client}
144
+ store={store}
145
+ uri={`unoverse://templates/${templateId}`}
146
+ voice={voiceConfig}
147
+ onAction={(action, data, meta) => {
148
+ const t = typeof action === "string" ? action : (action as any)?.type;
149
+ if (t === "close") {
150
+ // The app owns its close chrome (its own top-right X). Tell the embedding host to
151
+ // dismiss the surface (twin of `unoverse:config`/`unoverse:size`). The host decides
152
+ // what "close" means for its frame (slide the drawer shut); we don't assume.
153
+ window.parent?.postMessage({ type: "unoverse:close" }, "*");
154
+ return;
155
+ }
156
+ if (t === "sendMessage") {
157
+ // A suggestion that NAMES AN APP launches it instead of sending its text: read
158
+ // that app's manifest (the binding lives there — §4b, never in template data)
159
+ // and fire its trigger. The `text` field stays as the fallback for channels
160
+ // without launch support. Same-workflow apps only for now: the connection
161
+ // posts to the BOUND workflow id, so a foreign-workflow app logs and falls
162
+ // through to its trigger on the bound workflow.
163
+ const app = typeof (data as any)?.app === "string" ? (data as any).app : "";
164
+ if (app) {
165
+ (client as any).client
166
+ .readResource({ uri: `unoverse://apps/${app.toLowerCase()}` })
167
+ .then((res: any) => {
168
+ const parsed = res?.contents?.[0]?.text ? JSON.parse(res.contents[0].text) : null;
169
+ const b = parsed?.manifest?.binding ?? parsed?.binding ?? {};
170
+ if (!b.trigger) return console.error("[app] launch failed: no binding on", app);
171
+ if (b.workflow && binding.workflow && b.workflow !== binding.workflow)
172
+ console.warn("[app] launch:", app, "binds a different workflow — firing its trigger on the bound workflow");
173
+ // LOADING STATE: write the app's declared defaultState (open NAME; `mode` =
174
+ // pre-rename alias) into template state NOW — e.g. a "focus" app pre-opens
175
+ // the focus surface, whose slot shows its fallback (skeleton) until the
176
+ // workflow streams the component in. Same template-state key the node's
177
+ // TEMPLATE_DATA emit writes; no new vocabulary. "template" apps ARE the
178
+ // surface — nothing to write.
179
+ const declared = parsed?.manifest?.defaultState ?? parsed?.manifest?.mode ?? parsed?.defaultState ?? parsed?.mode;
180
+ if (declared && declared !== "template") store.mergeTemplateState({ defaultState: declared });
181
+ conn.trigger({ app, metadata: { targetTriggerNode: b.trigger } });
182
+ })
183
+ .catch((e: any) => console.error("[app] launch failed:", e));
184
+ return;
185
+ }
186
+ // Composer submit is generic: Enter carries `text`; a send BUTTON carries none,
187
+ // so fall back to the shared draft the store already holds (same as Studio).
188
+ const text = (typeof (data as any)?.text === "string" && (data as any).text) || store.getDraft();
189
+ if (text.trim()) {
190
+ conn.sendMessage(text);
191
+ store.setDraft(""); // clear the composer after sending (the Input binds `draft`)
192
+ }
193
+ return;
194
+ }
195
+ // Controlled composer: the Input emits "input" on every keystroke → the shared
196
+ // draft (local, never a server round-trip). A send button / Enter emits "sendMessage".
197
+ if (t === "input") {
198
+ store.setDraft(typeof (data as any)?.text === "string" ? (data as any).text : "");
199
+ return;
200
+ }
201
+ // Any other named server action (submit, selectCourse…): if the server is
202
+ // ELICITING on this session (the native §3.3 path — an app tool call is
203
+ // waiting), the submitted state ANSWERS the elicitation; one accept then
204
+ // completes the waiting node AND the held tool call server-side. Otherwise
205
+ // fall back to the data-plane `user_action` submit (waiter transport).
206
+ if (!t) return;
207
+ const state = (meta?.state ?? data) as Record<string, unknown>;
208
+ if (conn.resolveElicitation(state)) return;
209
+ conn.sendAction(t, {
210
+ componentNodeId: meta?.nodeId,
211
+ // The component's OWN chatId is the TURN id — the waiting node execution is
212
+ // keyed `<turn>:<nodeId>`, not the conversation-level chatId.
213
+ componentChatId: meta?.chatId,
214
+ componentState: state,
215
+ });
216
+ }}
217
+ />
218
+ );
219
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * <UnoverseComponent> — reads a definition by URI from the Unoverse MCP server
3
+ * (via ./core) and renders it natively with the given data.
4
+ */
5
+ import { useEffect, useMemo, useState, type CSSProperties } from "react";
6
+ import type { ResolvedTheme, UnoverseClient, UnoverseDefinition } from "./core";
7
+ import { renderNode, keyframesCss, type ActionHandler } from "./render";
8
+ import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
9
+ import { IsolatedRoot } from "./isolate";
10
+
11
+ export interface UnoverseComponentProps {
12
+ client: UnoverseClient;
13
+ /** e.g. "unoverse://components/Card" */
14
+ uri: string;
15
+ /** instance data bound into the definition (the streamed props) */
16
+ data?: Record<string, unknown>;
17
+ onAction?: ActionHandler;
18
+ /**
19
+ * Resolved theme. Optional — if omitted, the SDK FETCHES it from the server
20
+ * (`unoverse://theme/light`). The SDK bundles no tokens. Pass one to override
21
+ * (e.g. the workbench's light/dark toggle); swap it to restyle, zero def changes.
22
+ */
23
+ theme?: ResolvedTheme;
24
+ /**
25
+ * Render inside a Shadow DOM for full CSS isolation (default true). The SDK owns the
26
+ * isolation boundary so every consumer is protected identically; the canvas/channel no
27
+ * longer wrap it themselves. Set false only for a host that manages its own isolation.
28
+ */
29
+ isolate?: boolean;
30
+ /**
31
+ * A SURFACE'S SINGLE OCCUPANT FILLS THE SURFACE (docs/design/05): set by a limit-1
32
+ * reaction surface so the instance wrapper takes the frame's full height. The
33
+ * component still owns its face — this only gives its `height: "full"` a definite
34
+ * container. Flow/rail instances stay content-sized (never set there).
35
+ */
36
+ fill?: boolean;
37
+ }
38
+
39
+ export function UnoverseComponent({ client, uri, data, onAction, theme, isolate = true, fill = false }: UnoverseComponentProps) {
40
+ const [def, setDef] = useState<UnoverseDefinition | null>(null);
41
+ const [error, setError] = useState<string | null>(null);
42
+ // No theme passed → fetch it from the server (the SDK owns zero token values). The
43
+ // theme REF comes from the definition itself: a template's `org` names its client's
44
+ // theme (`<org>/light`); a universal component previews under the default set
45
+ // ("light" — channels pass their org's theme explicitly). Wait for the def before
46
+ // fetching (null = no fetch) so the wrong theme never flashes.
47
+ const fetched = useUnoverseTheme(client, theme ? null : def ? (def.org ? `${def.org}/light` : "light") : null);
48
+ const activeTheme = theme ?? fetched;
49
+ // Register the theme's webfonts (served DATA) at document level — see ensureFontStylesheets.
50
+ useEffect(() => ensureFontStylesheets(activeTheme), [activeTheme]);
51
+
52
+ useEffect(() => {
53
+ let alive = true;
54
+ setDef(null);
55
+ setError(null);
56
+ client
57
+ .readDefinition(uri)
58
+ .then((d) => alive && setDef(d))
59
+ .catch((e) => alive && setError(String(e?.message ?? e)));
60
+ return () => {
61
+ alive = false;
62
+ };
63
+ }, [client, uri]);
64
+
65
+ // Fill props the instance didn't provide from the definition's defaults; instance `data`
66
+ // overrides. This is what lets a partially-emitted component still render its declared
67
+ // baseline — e.g. StreamingText's `progressText` default ("Thinking…") shows while the node
68
+ // streams and hasn't emitted a progress line. IMPORTANT: a def `default` must therefore be
69
+ // a sensible RUNTIME fallback, NOT a demo placeholder — a demo-y default (e.g. a markdown
70
+ // showcase on `text`) would mask an empty stream. Keep demo content out of prop defaults.
71
+ // Memoized on the def — a streaming component re-renders per chunk; the defaults don't move.
72
+ const propDefaults = useMemo(
73
+ () =>
74
+ Object.fromEntries(
75
+ Object.entries((def?.props ?? {}) as Record<string, { default?: unknown }>).map(([k, v]) => [k, v?.default]),
76
+ ),
77
+ [def],
78
+ );
79
+
80
+ // Loading/error hints carry NO styling — the SDK owns zero styles (tokens or hints).
81
+ if (error) return <div>Unoverse error: {error}</div>;
82
+ if (!def || !activeTheme) return <div>Loading {uri}…</div>;
83
+ // Internal state initial values (def.state) sit beneath the live slice — the
84
+ // microapp's own keys (step/phase/…) start at their authored values and move only
85
+ // when its actions write them. Props stay the EXTERNAL contract.
86
+ const merged = { ...propDefaults, ...(def?.state ?? {}), ...(data ?? {}) };
87
+ // Inject the served keyframes + apply the SERVED base render-root (theme.root — DS typography
88
+ // + font-smoothing). When ISOLATED, theme.root is applied to the shadow-root container inside
89
+ // IsolatedRoot (a REAL box — reliable font cascade in the shadow tree). When NOT isolated the
90
+ // host owns isolation, so apply it here on a `display:contents` wrapper (out of layout while
91
+ // its inherited props cascade) — the design-system baseline either way.
92
+ const keyframes = <style>{keyframesCss(activeTheme)}</style>;
93
+ const tree = renderNode(def.root, merged, onAction, activeTheme);
94
+ // The isolation container MIRRORS the component root's declared sizing intent (§4a:
95
+ // the component owns its size; the host responds). A root that declares
96
+ // `height: "full"` needs a definite chain — the container passes the host's height
97
+ // through (100%). Any other root keeps an auto container → fit-to-content, the §5a
98
+ // default (a card in a timeline must NOT fill). Without this, a full-height
99
+ // component's 100% resolves against an auto box and silently collapses.
100
+ const fillsHost = fill || ((def.root as { style?: Record<string, unknown> })?.style?.height ?? null) === "full";
101
+ const isolatedRootStyle = {
102
+ ...(activeTheme.root as CSSProperties),
103
+ ...(fillsHost ? { height: "100%" } : {}),
104
+ ...(fill ? { flex: "1 1 auto", minHeight: 0 } : {}),
105
+ };
106
+ if (isolate) {
107
+ return (
108
+ <IsolatedRoot rootStyle={isolatedRootStyle}>
109
+ {keyframes}
110
+ {tree}
111
+ </IsolatedRoot>
112
+ );
113
+ }
114
+ return (
115
+ <>
116
+ {keyframes}
117
+ <div style={{ display: "contents", ...(activeTheme.root as CSSProperties) }}>{tree}</div>
118
+ </>
119
+ );
120
+ }