@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,309 @@
1
+ /**
2
+ * Publish a project to the connected universe.
3
+ *
4
+ * PLAN, THEN SEND. Publishing writes rows that every workflow in a universe can use, so
5
+ * "what am I about to do" is answered before anything moves. Blind publishing is how a
6
+ * project half-lands and nobody can tell by looking.
7
+ *
8
+ * The plan runs the moment this opens, unasked: it reads disk and dry-runs against the
9
+ * universe, writing nothing. Only the second press sends. An empty dialog with a button
10
+ * marked "check" made the developer ask for the answer they opened the dialog to get.
11
+ *
12
+ * The work happens in Studio's local host (`local/plugin.ts`), not here: it reads the
13
+ * developer's disk, which a browser cannot. This screen collects a decision and shows a
14
+ * result. It calls the same functions as `unoverse publish assets`, so the two cannot
15
+ * disagree about what a publish is.
16
+ *
17
+ * See docs/architecture/DECLARATIVE_NODES.md §9.
18
+ */
19
+ import { useEffect, useRef, useState } from "react";
20
+ import { useAuth } from "react-oidc-context";
21
+ import { canPublish, type Universe } from "./universes";
22
+
23
+ interface Item {
24
+ kind: string;
25
+ name: string;
26
+ why?: string;
27
+ mode?: string;
28
+ }
29
+ interface Plan {
30
+ create: Item[];
31
+ update: Item[];
32
+ unchanged: Item[];
33
+ refused: Item[];
34
+ }
35
+ interface Problem {
36
+ level: string;
37
+ file: string;
38
+ line?: number;
39
+ msg: string;
40
+ }
41
+
42
+ /** Decode a JWT payload without verifying it. Display only: the universe verifies. */
43
+ function claimsOf(token: string | undefined): Record<string, unknown> | null {
44
+ if (!token) return null;
45
+ try {
46
+ return JSON.parse(atob(token.split(".")[1] ?? ""));
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ export function PublishDialog({
53
+ project,
54
+ universe,
55
+ onClose,
56
+ }: {
57
+ project: string;
58
+ universe: Universe;
59
+ onClose: () => void;
60
+ }) {
61
+ const auth = useAuth();
62
+ const token = auth?.user?.access_token;
63
+
64
+ const [plan, setPlan] = useState<Plan | null>(null);
65
+ const [problems, setProblems] = useState<Problem[]>([]);
66
+ const [blocked, setBlocked] = useState<Problem[] | null>(null);
67
+ const [sent, setSent] = useState<{ sent: Item[]; failed: Item[] } | null>(null);
68
+ const [busy, setBusy] = useState(false);
69
+ const [error, setError] = useState<string | null>(null);
70
+
71
+ const allowed = canPublish(claimsOf(token), universe.config);
72
+
73
+ async function call(step: "plan" | "send") {
74
+ setError(null);
75
+ setBusy(true);
76
+ try {
77
+ const res = await fetch(`/publish/${step}`, {
78
+ method: "POST",
79
+ headers: { "content-type": "application/json" },
80
+ body: JSON.stringify({ project, universe: universe.url, token }),
81
+ });
82
+ const body = await res.json();
83
+ if (body.error) throw new Error(body.error);
84
+ // Errors STOP the publish. Reported as findings rather than a failure, because the
85
+ // developer's next action is to fix a file, not to retry.
86
+ if (body.blocked) {
87
+ setBlocked(body.errors);
88
+ return;
89
+ }
90
+ setProblems(body.problems ?? []);
91
+ setPlan(body.plan);
92
+ if (step === "send") setSent({ sent: body.sent ?? [], failed: body.failed ?? [] });
93
+ } catch (e: any) {
94
+ setError(e?.message ?? String(e));
95
+ } finally {
96
+ setBusy(false);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * PLAN ON OPEN. The dialog's whole job is to answer "what am I about to publish", so it
102
+ * answers it without being asked. Planning reads disk and dry-runs against the universe;
103
+ * it writes nothing, which is what makes running it unprompted safe.
104
+ *
105
+ * The guard is a ref rather than the usual empty dep array: `call` closes over state that
106
+ * changes, and a second plan on a re-render would be a wasted round trip and a flicker.
107
+ */
108
+ const planned = useRef(false);
109
+ useEffect(() => {
110
+ if (planned.current || !allowed || !token) return;
111
+ planned.current = true;
112
+ call("plan");
113
+ }, [allowed, token]);
114
+
115
+ const toSend = (plan?.create.length ?? 0) + (plan?.update.length ?? 0);
116
+ const warnings = problems.filter((p) => p.level === "warn").length;
117
+
118
+ /**
119
+ * ONE ROW PER ITEM. A comma-joined list per status cannot be scanned: the question a
120
+ * developer opens this with is "which of MY things is about to move", and that is a
121
+ * property of the item, not of a group heading. So every item gets a line carrying its
122
+ * own status, and the statuses sort so the ones that will move sit at the top.
123
+ *
124
+ * The kind is singular here, because a row is one thing. `bpp` is a style set and
125
+ * `bppchatlayout` is an app; without that word they read as five apps, which is how a
126
+ * developer concludes their components were never collected when they were.
127
+ */
128
+ const KIND_LABEL: Record<string, string> = {
129
+ component: "component",
130
+ atom: "atom",
131
+ style: "styles",
132
+ template: "app",
133
+ node: "node",
134
+ skill: "skill",
135
+ "prompt-block": "prompt block",
136
+ recipe: "recipe",
137
+ };
138
+
139
+ /**
140
+ * Contrast is the whole job here. Dark text on a dark panel made a five row list
141
+ * unreadable, so the name carries full weight, the status carries colour, and only the
142
+ * kind is allowed to recede.
143
+ */
144
+ const STATUS = {
145
+ create: { label: "new", pill: "bg-emerald-400/15 text-emerald-300 ring-emerald-400/30", order: 0 },
146
+ update: { label: "changed", pill: "bg-amber-400/15 text-amber-300 ring-amber-400/30", order: 1 },
147
+ refused: { label: "refused", pill: "bg-red-400/15 text-red-300 ring-red-400/30", order: 2 },
148
+ unchanged: { label: "unchanged", pill: "bg-gray-500/10 text-gray-400 ring-gray-500/30", order: 3 },
149
+ } as const;
150
+ type Status = keyof typeof STATUS;
151
+
152
+ /** Every item, flattened, carrying its status. Sorted by what will move, then by name. */
153
+ const rows: Array<Item & { status: Status }> = plan
154
+ ? (Object.keys(STATUS) as Status[])
155
+ .flatMap((status) => plan[status].map((i) => ({ ...i, status })))
156
+ .sort(
157
+ (a, b) =>
158
+ STATUS[a.status].order - STATUS[b.status].order ||
159
+ a.kind.localeCompare(b.kind) ||
160
+ a.name.localeCompare(b.name),
161
+ )
162
+ : [];
163
+
164
+ return (
165
+ <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/60 p-8" onClick={onClose}>
166
+ <div
167
+ className="w-full max-w-xl rounded-xl border border-gray-800 bg-[#151b22] p-6 text-gray-200 shadow-2xl"
168
+ onClick={(e) => e.stopPropagation()}
169
+ >
170
+ <h2 className="text-base font-semibold text-gray-100">
171
+ Publish <span className="text-gray-400">{project}</span> to {universe.label}
172
+ </h2>
173
+
174
+ {/* Said HERE, before any work, not at the first push. */}
175
+ {!allowed && (
176
+ <p className="mt-3 text-xs text-amber-400">
177
+ Your account does not have <code>{universe.config.publishPermission}</code> on this
178
+ universe. Ask whoever administers it to grant it.
179
+ </p>
180
+ )}
181
+
182
+ {error && <p className="mt-3 text-xs text-red-400">{error}</p>}
183
+
184
+ {busy && !plan && !blocked && (
185
+ <p className="mt-4 text-sm text-gray-500">Reading {project}…</p>
186
+ )}
187
+
188
+ {/* Lint errors: nothing was sent, and the file and line are what fixes it. */}
189
+ {blocked && (
190
+ <div className="mt-4">
191
+ <p className="text-xs text-red-400">
192
+ {blocked.length} error{blocked.length === 1 ? "" : "s"} in {project}. Nothing was sent.
193
+ </p>
194
+ <ul className="mt-2 max-h-48 space-y-1 overflow-y-auto text-xs">
195
+ {blocked.slice(0, 12).map((p, i) => (
196
+ <li key={i} className="text-gray-400">
197
+ <span className="text-gray-500">
198
+ {p.file}
199
+ {p.line ? `:${p.line}` : ""}
200
+ </span>{" "}
201
+ {p.msg}
202
+ </li>
203
+ ))}
204
+ </ul>
205
+ </div>
206
+ )}
207
+
208
+ {plan && !blocked && (
209
+ <div className="mt-4">
210
+ {/* The count sentence answers "how much", the list answers "which". Both, always. */}
211
+ <p className="text-xs font-medium uppercase tracking-wide text-gray-400">
212
+ {[
213
+ plan.create.length && `${plan.create.length} new`,
214
+ plan.update.length && `${plan.update.length} changed`,
215
+ plan.unchanged.length && `${plan.unchanged.length} unchanged`,
216
+ plan.refused.length && `${plan.refused.length} refused`,
217
+ ]
218
+ .filter(Boolean)
219
+ .join(", ") || "nothing to publish"}
220
+ </p>
221
+
222
+ <div className="mt-3 max-h-72 divide-y divide-white/5 overflow-y-auto rounded-lg border border-white/10 bg-black/20">
223
+ {rows.map((r) => (
224
+ <div key={`${r.kind}/${r.name}`} className="flex items-baseline gap-3 px-3 py-2 hover:bg-white/5">
225
+ <span
226
+ className={`w-[74px] shrink-0 rounded px-1.5 py-0.5 text-center text-[11px] font-semibold uppercase tracking-wide ring-1 ${STATUS[r.status].pill}`}
227
+ >
228
+ {STATUS[r.status].label}
229
+ </span>
230
+ <span
231
+ className={`flex-1 truncate text-sm font-medium ${
232
+ r.status === "unchanged" ? "text-gray-400" : "text-white"
233
+ }`}
234
+ >
235
+ {r.name}
236
+ </span>
237
+ {/* The kind sits last and quiet: it labels the name rather than competing with it. */}
238
+ <span className="shrink-0 text-xs text-gray-500">{KIND_LABEL[r.kind] ?? r.kind}</span>
239
+ {/* The reason belongs ON the refused row: it is the only row it explains. */}
240
+ {r.why && <span className="shrink-0 text-xs text-red-300">{r.why}</span>}
241
+ </div>
242
+ ))}
243
+ </div>
244
+ {/*
245
+ Said once, in the place where its absence gets noticed. The Components tab
246
+ lists the design system alongside this project's own, so a plan holding two
247
+ components where the tab showed eighteen reads as a collection bug.
248
+ */}
249
+ <p className="pt-3 text-xs leading-relaxed text-gray-500">
250
+ Only {project} publishes. Design system components, atoms and styles belong to
251
+ the universe already.
252
+ </p>
253
+ {/*
254
+ A COUNT ALONE IS USELESS. "2 warnings" tells a developer something is wrong
255
+ and gives them no way to find it, which is worse than silence: it is a nag
256
+ with no address. The file, line and message are what fixes it, so they are
257
+ here, one click away rather than one grep away.
258
+ */}
259
+ {warnings > 0 && (
260
+ <details className="pt-1">
261
+ <summary className="cursor-pointer text-xs text-amber-300/80 hover:text-amber-300">
262
+ {warnings} warning{warnings === 1 ? "" : "s"}, not blocking
263
+ </summary>
264
+ <ul className="mt-2 max-h-40 space-y-2 overflow-y-auto text-xs">
265
+ {problems
266
+ .filter((p) => p.level === "warn")
267
+ .map((p, i) => (
268
+ <li key={i} className="leading-relaxed">
269
+ <span className="text-gray-500">
270
+ {p.file?.replace(/^.*\/rx\//, "")}
271
+ {p.line ? `:${p.line}` : ""}
272
+ </span>
273
+ <br />
274
+ <span className="text-gray-400">{p.msg}</span>
275
+ </li>
276
+ ))}
277
+ </ul>
278
+ </details>
279
+ )}
280
+ </div>
281
+ )}
282
+
283
+ {sent && (
284
+ <p className={`mt-4 text-sm ${sent.failed.length ? "text-red-400" : "text-emerald-400"}`}>
285
+ {sent.sent.length} published{sent.failed.length ? `, ${sent.failed.length} failed` : ""}
286
+ </p>
287
+ )}
288
+
289
+ <div className="mt-6 flex items-center gap-2">
290
+ {plan && !sent && toSend > 0 && (
291
+ <button
292
+ onClick={() => call("send")}
293
+ disabled={busy}
294
+ className="rounded bg-emerald-500 px-4 py-2 text-sm font-semibold text-emerald-950 hover:bg-emerald-400 disabled:opacity-30"
295
+ >
296
+ {busy ? "Publishing…" : `Publish ${toSend} item${toSend === 1 ? "" : "s"}`}
297
+ </button>
298
+ )}
299
+ {plan && !sent && toSend === 0 && (
300
+ <p className="text-sm text-emerald-400">{project} is already up to date</p>
301
+ )}
302
+ <button onClick={onClose} className="rounded px-3 py-2 text-sm text-gray-400 hover:text-gray-200">
303
+ {sent ? "Done" : "Cancel"}
304
+ </button>
305
+ </div>
306
+ </div>
307
+ </div>
308
+ );
309
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The signed-in session for ONE universe.
3
+ *
4
+ * Local Studio is offline and unauthenticated by default, and that must not change:
5
+ * building a component needs no account, no network and no provider. Authentication
6
+ * appears only when a developer connects to a universe in order to publish.
7
+ *
8
+ * So the provider is mounted CONDITIONALLY. With no universe selected, or one that has
9
+ * auth switched off, children render exactly as they did before this file existed.
10
+ *
11
+ * THE CONFIG COMES FROM THE UNIVERSE, NOT FROM HERE. Canvas reads its issuer and client
12
+ * id from `window.__GRAVITY_CONFIG__` injected into its own page; Studio runs on the
13
+ * developer's machine and cannot, so it asks (`universes.ts` → discover). That is what
14
+ * keeps the tooling provider-agnostic: nothing in Studio names Auth0.
15
+ *
16
+ * See docs/architecture/LOCAL_STUDIO.md.
17
+ */
18
+ import type { ReactNode } from "react";
19
+ import { AuthProvider, AuthContext, type AuthContextProps } from "react-oidc-context";
20
+ import { WebStorageStateStore } from "oidc-client-ts";
21
+ import { loginReady, type Universe } from "./universes";
22
+
23
+ /**
24
+ * The OIDC settings for one universe. Mirrors Canvas's `oidcConfig`
25
+ * (`apps/canvas/src/App.jsx`) so a token minted for Studio looks the same to the
26
+ * platform as one minted for Canvas, with one difference: every value is read from the
27
+ * universe rather than from this app's build.
28
+ */
29
+ function oidcConfigFor(universe: Universe) {
30
+ const { issuer, clientId, audience } = universe.config;
31
+ return {
32
+ authority: issuer as string,
33
+ client_id: clientId as string,
34
+ // Studio's own origin. The universe's identity provider must list this as an allowed
35
+ // callback, which is the one setup step a customer does on their side.
36
+ redirect_uri: window.location.origin,
37
+ scope: "openid profile email",
38
+ // Auth0 and friends only mint an API access token when an audience is asked for.
39
+ // Without it the login succeeds and the token is useless against the universe.
40
+ ...(audience ? { extraQueryParams: { audience } } : {}),
41
+ // sessionStorage, not localStorage: the token dies with the tab. Studio's standing
42
+ // rule is that it never persists a secret (local/keys.ts), and a token is a secret.
43
+ userStore: new WebStorageStateStore({ store: window.sessionStorage }),
44
+ // Same reason Canvas disables it: a stored user carries the redirect_uri it was
45
+ // minted with, and a silent renew against a different universe reuses the wrong one.
46
+ automaticSilentRenew: false,
47
+ onSigninCallback: () => {
48
+ window.history.replaceState({}, document.title, window.location.pathname);
49
+ },
50
+ };
51
+ }
52
+
53
+ /**
54
+ * A signed-out session, for when Studio is not connected to a universe.
55
+ *
56
+ * Cast because `AuthContextProps` also carries the oidc-client-ts settings and event
57
+ * surface, none of which exists without a provider and none of which the screens touch.
58
+ * The honest alternative is a fake settings object, which would be a bigger lie than this.
59
+ */
60
+ const OFFLINE_SESSION = {
61
+ isAuthenticated: false,
62
+ isLoading: false,
63
+ user: undefined,
64
+ error: undefined,
65
+ activeNavigator: undefined,
66
+ signinRedirect: offline("signinRedirect"),
67
+ signinPopup: offline("signinPopup"),
68
+ signinSilent: offline("signinSilent"),
69
+ signoutRedirect: offline("signoutRedirect"),
70
+ signoutPopup: offline("signoutPopup"),
71
+ signoutSilent: offline("signoutSilent"),
72
+ removeUser: offline("removeUser"),
73
+ clearStaleState: offline("clearStaleState"),
74
+ querySessionStatus: offline("querySessionStatus"),
75
+ revokeTokens: offline("revokeTokens"),
76
+ startSilentRenew: () => {},
77
+ stopSilentRenew: () => {},
78
+ } as unknown as AuthContextProps;
79
+
80
+ /** Reject loudly: calling this means a caller forgot Studio has no universe connected. */
81
+ function offline(name: string) {
82
+ return async () => {
83
+ throw new Error(`${name} needs a universe. Connect one first (Studio is offline).`);
84
+ };
85
+ }
86
+
87
+ export function UniverseSession({
88
+ universe,
89
+ children,
90
+ }: {
91
+ universe: Universe | null;
92
+ children: ReactNode;
93
+ }) {
94
+ // No universe, or one that cannot be logged into: the offline case, and the normal one.
95
+ //
96
+ // The shared screens call `useAuth()` unconditionally and cope with having no session.
97
+ // But react-oidc-context warns whenever the context VALUE is falsy:
98
+ //
99
+ // const context = React.useContext(AuthContext);
100
+ // if (!context) console.warn("AuthProvider context is undefined, ...");
101
+ //
102
+ // so mounting the provider with `undefined` warns exactly as loudly as mounting nothing
103
+ // (both were tried). With TemplatesView re-rendering it is a stream of warnings about a
104
+ // state that is fully supported.
105
+ //
106
+ // So hand it a real value meaning SIGNED OUT. Everything the screens read is present and
107
+ // false; the sign-in actions throw rather than no-op, because reaching one from here
108
+ // would mean a code path forgot there is no universe, and silence would hide that.
109
+ if (!universe || !loginReady(universe.config).ok) {
110
+ return <AuthContext.Provider value={OFFLINE_SESSION}>{children}</AuthContext.Provider>;
111
+ }
112
+
113
+ // `key` forces a fresh provider when the universe changes. Without it the library keeps
114
+ // the previous authority and client id, and a developer switching universes would stay
115
+ // signed in to the old one while appearing to be on the new.
116
+ return (
117
+ <AuthProvider key={universe.url} {...oidcConfigFor(universe)}>
118
+ {children}
119
+ </AuthProvider>
120
+ );
121
+ }
package/src/host.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The local Studio's HOST module.
3
+ *
4
+ * The shared screens (../screens) import transport, auth and persistence from the bare
5
+ * specifier `studio-host`. Canvas aliases that to a module talking to a running
6
+ * universe. This one talks to the developer's own disk, via the local routes in
7
+ * ../local/plugin.ts. Same screens, different world underneath.
8
+ *
9
+ * There is no auth here on purpose. Local Studio is offline and single-user: the files
10
+ * are already yours. Authentication belongs to a universe, not to a folder.
11
+ *
12
+ * See docs/architecture/LOCAL_STUDIO.md.
13
+ */
14
+ import { useCallback, useEffect, useState } from "react";
15
+ import type { ResolvedTheme, UnoverseDefinition, UnoverseClient } from "@unoverse/sdk";
16
+
17
+ /** Everything is same-origin: the Vite dev server serves both the app and the routes. */
18
+ export const UNOVERSE_BASE = "";
19
+
20
+ /** No login offline. Kept so the shared screens can ask without branching on host. */
21
+ export const hasAuth = false;
22
+
23
+ /** Plain fetch. The bearer the Canvas host attaches has no meaning against a folder. */
24
+ export async function authedFetch(input: string, init?: RequestInit): Promise<Response> {
25
+ return fetch(input, init);
26
+ }
27
+
28
+ /** The Canvas host's helper under its other name, so shared screens can use either. */
29
+ export const fetchUnoverse = authedFetch;
30
+
31
+ /**
32
+ * The disk-backed client.
33
+ *
34
+ * The SDK renderer takes an `UnoverseClient` and calls it to resolve definitions and
35
+ * themes. Against a universe those are MCP reads. Here they are reads of the
36
+ * developer's folder, served by the local plugin. The SDK is untouched: it asks the
37
+ * same questions and neither knows nor cares what answers them.
38
+ */
39
+ class LocalUnoverseClient {
40
+ /**
41
+ * THE ORIGIN OF THE UNIVERSE THIS CLIENT TALKS TO. There isn't one: this reads a folder.
42
+ *
43
+ * It is here because the SDK's client exposes it (`core/client.ts`) and the renderer uses
44
+ * it to derive other lanes on the same server, notably the voice audio socket
45
+ * (`template.tsx` → `client.serverOrigin.replace(/^http/, "ws")`). This class is a
46
+ * duck-typed stand-in for that client, so a member it does not implement is not a missing
47
+ * feature, it is a CRASH at render: reading `.replace` of undefined took the whole
48
+ * Studio page down the moment a template carrying a voice config was previewed.
49
+ *
50
+ * Studio's own origin is the honest answer. Nothing is listening on `/ws/gravity` here,
51
+ * so voice fails when it tries to CONNECT, which is a message about needing a universe
52
+ * rather than a blank screen. Previewing the layout keeps working either way.
53
+ */
54
+ get serverOrigin(): string {
55
+ // CONNECTED UNIVERSE FIRST (2026-07-28). Once the developer connects this Studio to a
56
+ // universe (App.tsx persists the pointer under `studio:universe`), every lane the
57
+ // renderer derives from the client's origin — above all the VOICE audio socket
58
+ // (`template.tsx` → serverOrigin.replace(/^http/, "ws") → /ws/gravity) — must dial
59
+ // THE UNIVERSE, which actually serves it. Studio's own origin serves no /ws/gravity,
60
+ // so before this read the voice socket died against :4108 even while chat streamed
61
+ // fine from the universe. The folder answers only when no universe is selected.
62
+ try {
63
+ const raw = localStorage.getItem("studio:universe");
64
+ if (raw) {
65
+ const url = JSON.parse(raw) as unknown;
66
+ if (typeof url === "string" && url) return new URL(url).origin;
67
+ }
68
+ } catch {
69
+ /* malformed pointer → fall through to the folder's honest answer */
70
+ }
71
+ return typeof location === "undefined" ? "http://localhost" : location.origin;
72
+ }
73
+
74
+ /** Nothing to connect to. Present because the SDK's lifecycle calls it. */
75
+ async connect(): Promise<void> {}
76
+
77
+ /** Tools are a universe's verbs (they run workflows). A folder has none. */
78
+ async callTool(name: string): Promise<unknown> {
79
+ throw new Error(
80
+ `Local Studio cannot call "${name}". Tools run workflows, which need a universe. ` +
81
+ `Deploy your work and use it on Canvas.`,
82
+ );
83
+ }
84
+
85
+ async readDefinition(uri: string): Promise<UnoverseDefinition> {
86
+ const r = await fetch(`/local/definition?uri=${encodeURIComponent(uri)}`);
87
+ if (!r.ok) throw new Error(`${uri}: ${(await r.json().catch(() => ({}))).error ?? r.status}`);
88
+ return (await r.json()) as UnoverseDefinition;
89
+ }
90
+
91
+ async readTheme(name = "light"): Promise<ResolvedTheme> {
92
+ const r = await fetch(`/local/theme?ref=${encodeURIComponent(name)}`);
93
+ if (!r.ok) throw new Error(`theme ${name}: ${r.status}`);
94
+ return (await r.json()) as ResolvedTheme;
95
+ }
96
+
97
+ async listThemes(): Promise<string[]> {
98
+ const r = await fetch("/dev/themes");
99
+ return r.ok ? ((await r.json()).themes ?? []) : [];
100
+ }
101
+
102
+ async listComponents(): Promise<{ uri: string; name?: string }[]> {
103
+ const r = await fetch("/dev/components");
104
+ if (!r.ok) return [];
105
+ const { components = [] } = await r.json();
106
+ return components.map((c: { name: string }) => ({ uri: `unoverse://components/${c.name}`, name: c.name }));
107
+ }
108
+ }
109
+
110
+ // The cast is the duck-typing made explicit: the SDK types its prop as the concrete
111
+ // UnoverseClient CLASS (private fields block structural matching), and this class is
112
+ // its documented stand-in — it implements every member the renderer actually calls.
113
+ export const client = new LocalUnoverseClient() as unknown as UnoverseClient;
114
+
115
+ /** Like useState, but survives a reload, so an edit-and-refresh keeps your place. */
116
+ export function usePersistentState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
117
+ const [value, setValue] = useState<T>(() => {
118
+ try {
119
+ const raw = localStorage.getItem(key);
120
+ return raw === null ? initial : (JSON.parse(raw) as T);
121
+ } catch {
122
+ return initial;
123
+ }
124
+ });
125
+ useEffect(() => {
126
+ try {
127
+ localStorage.setItem(key, JSON.stringify(value));
128
+ } catch {
129
+ /* ignore */
130
+ }
131
+ }, [key, value]);
132
+ return [value, setValue];
133
+ }
134
+
135
+ export type RegistryKind = "node" | "skill" | "template" | "prompt" | "component";
136
+
137
+ /**
138
+ * Registry toggles decide what a RUNNING universe exposes; they persist to
139
+ * installed_plugins. Offline there is no universe and nothing to persist, so
140
+ * everything reads as enabled and toggling does nothing. The screens are shared, so
141
+ * they must not have to know which host they are in.
142
+ */
143
+ export function useRegistryToggles(_kind: RegistryKind) {
144
+ // Signature parity with the web host's hook (registry.tsx): the shared screens
145
+ // call isEnabled(name)/toggle(name), so the offline stubs must accept the arg.
146
+ const isEnabled = useCallback((_name: string) => true, []);
147
+ const toggle = useCallback(async (_name: string) => {}, []);
148
+ return { isEnabled, toggle };
149
+ }
150
+
151
+ /** Rendered by the shared screens next to catalog items. Inert offline (see above). */
152
+ export function ToggleSwitch(_: { on: boolean; onClick: () => void; label: string }) {
153
+ return null;
154
+ }
155
+
156
+ /**
157
+ * Chrome for the shared screens. Content panels are LIGHT in both hosts (Design System,
158
+ * Prompt Blocks and Nodes all read on white); only the surrounding navigator chrome is
159
+ * dark here. The seam stays so either host can diverge without touching a shared screen.
160
+ */
161
+ export const chrome = {
162
+ input: "border-gray-300 bg-white text-gray-800",
163
+ label: "text-gray-700",
164
+ muted: "text-gray-500",
165
+ };
package/src/index.css ADDED
@@ -0,0 +1,7 @@
1
+ @import "tailwindcss";
2
+
3
+ html,
4
+ body,
5
+ #root {
6
+ height: 100%;
7
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,11 @@
1
+ /** Local Studio entry point. No auth provider: offline, single-user, your own files. */
2
+ import { StrictMode } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { App } from "./App";
5
+ import "./index.css";
6
+
7
+ createRoot(document.getElementById("root")!).render(
8
+ <StrictMode>
9
+ <App />
10
+ </StrictMode>,
11
+ );