@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,26 @@
1
+ /**
2
+ * studio: the Studio screens, owned once and imported by BOTH Studios,
3
+ * the Canvas-embedded catalog and the standalone local authoring app.
4
+ * See docs/architecture/LOCAL_STUDIO.md.
5
+ *
6
+ * These screens contain NO transport, auth or persistence of their own. Everything
7
+ * host-specific is imported from the bare specifier `studio-host`, which each app
8
+ * aliases to its own module (see host.ts in the consuming app). That is the whole
9
+ * seam: Canvas points it at a running universe, local Studio points it at a folder
10
+ * on disk.
11
+ *
12
+ * Consumed as SOURCE via a bundler alias, the same convention `@unoverse/sdk`
13
+ * already uses. No build step, no publish cycle, no stale dist.
14
+ */
15
+ export { ComponentsView } from "./ComponentsView";
16
+ export { AtomsView } from "./AtomsView";
17
+ export { TemplatesView } from "./TemplatesView";
18
+ export { DesignSystemView } from "./DesignSystemView";
19
+ export { SkillsView } from "./SkillsView";
20
+ export { PromptBlocksView } from "./PromptBlocksView";
21
+ export { IdentityHeader } from "./IdentityHeader";
22
+ export { stateActivation } from "./states";
23
+ // ConfigForm renders a node/package config from its schema. Each host drives it from
24
+ // its own screen (Canvas from MarketplaceView, local Studio from the node testing area),
25
+ // so it is shared even though neither shares the screen around it.
26
+ export { ConfigForm, defaultsFromSchema } from "./ConfigForm";
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Component-state activation — shared by the Templates and Components views.
3
+ *
4
+ * A component's `states/` files are its Switch-case layers: the state NAME is the
5
+ * case key, and the Switch's `on` field is the discriminant that selects it
6
+ * (UNOVERSE_LAYERS.md §7). To show a state, write `{ [discriminant]: name }` into
7
+ * the component's own slice — exactly what the component's own buttons do.
8
+ */
9
+
10
+ /** Find the discriminant field whose Switch has a case named `stateName`. */
11
+ export function discriminantFor(root: unknown, stateName: string): string | null {
12
+ let found: string | null = null;
13
+ const walk = (n: unknown): void => {
14
+ if (found || !n || typeof n !== "object") return;
15
+ if (Array.isArray(n)) return n.forEach(walk);
16
+ const o = n as Record<string, unknown>;
17
+ if (
18
+ o.type === "Switch" &&
19
+ typeof o.on === "string" &&
20
+ o.cases &&
21
+ typeof o.cases === "object" &&
22
+ Object.prototype.hasOwnProperty.call(o.cases, stateName)
23
+ ) {
24
+ found = o.on;
25
+ return;
26
+ }
27
+ Object.values(o).forEach(walk);
28
+ };
29
+ walk(root);
30
+ return found;
31
+ }
32
+
33
+ /** The COMPONENT_DATA payload that activates a component state (null if unresolvable). */
34
+ export function stateActivation(root: unknown, state: { name: string; when?: unknown }): Record<string, unknown> | null {
35
+ const disc = discriminantFor(root, state.name);
36
+ if (disc) return { [disc]: state.name };
37
+ // Fallback: a state file that carries its own `visibleWhen` selector (template-style).
38
+ const w = state.when;
39
+ if (w && typeof w === "object" && typeof (w as { field?: unknown }).field === "string") {
40
+ const c = w as { field: string; eq?: unknown };
41
+ return { [c.field]: c.eq ?? true };
42
+ }
43
+ return null;
44
+ }
package/src/App.tsx ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Local Studio — the shell.
3
+ *
4
+ * Everything visible here is a SHARED screen (../screens), the same ones the Canvas
5
+ * Studio renders. This file only does what a host must: pick the project, pick the
6
+ * theme variant, resolve the theme, and choose which screen is showing.
7
+ *
8
+ * See docs/architecture/LOCAL_STUDIO.md.
9
+ */
10
+ import { useEffect, useState } from "react";
11
+ import type { ResolvedTheme } from "@unoverse/sdk";
12
+ import {
13
+ ComponentsView,
14
+ AtomsView,
15
+ TemplatesView,
16
+ DesignSystemView,
17
+ SkillsView,
18
+ PromptBlocksView,
19
+ } from "@unoverse/studio";
20
+ import { client, usePersistentState } from "./host";
21
+ import { UniverseSession } from "./UniverseSession";
22
+ import { ConnectUniverse } from "./ConnectUniverse";
23
+ import { PublishDialog } from "./PublishDialog";
24
+ import { NewProject } from "./NewProject";
25
+ import { loadUniverses, type Universe } from "./universes";
26
+ // Local-only screens. Nodes are AUTHORED and TESTED here and nowhere else: Canvas keeps
27
+ // the node library (using nodes), the marketplace acquires them (a universe's job).
28
+ import { NodesView } from "./NodesView";
29
+
30
+ type View = "apps" | "components" | "atoms" | "tokens" | "skills" | "prompts" | "nodes";
31
+
32
+ const NAV: { key: View; label: string; group: string }[] = [
33
+ { key: "apps", label: "Apps", group: "Build" },
34
+ { key: "components", label: "Components", group: "Build" },
35
+ { key: "atoms", label: "Atoms", group: "Build" },
36
+ { key: "tokens", label: "Design System", group: "Build" },
37
+ { key: "skills", label: "Skills", group: "AI" },
38
+ { key: "prompts", label: "Prompt Blocks", group: "AI" },
39
+ { key: "nodes", label: "Nodes", group: "Code" },
40
+ ];
41
+
42
+ /**
43
+ * The shell, wrapped in the session for the selected universe.
44
+ *
45
+ * The provider must sit ABOVE every screen, because a screen deep in the tree
46
+ * (TemplatesView) already calls `useAuth()`. With no universe selected nothing is
47
+ * mounted and Studio behaves exactly as it did offline.
48
+ */
49
+ export function App() {
50
+ // Which universe is selected, remembered across refreshes. The list itself lives in
51
+ // localStorage (universes.ts); this is only the pointer into it.
52
+ const [activeUrl, setActiveUrl] = usePersistentState<string | null>("studio:universe", null);
53
+ const active = loadUniverses().find((u) => u.url === activeUrl) ?? null;
54
+
55
+ return (
56
+ <UniverseSession universe={active}>
57
+ <Shell
58
+ active={active}
59
+ onSelectUniverse={(u: Universe | null) => setActiveUrl(u?.url ?? null)}
60
+ />
61
+ </UniverseSession>
62
+ );
63
+ }
64
+
65
+ function Shell({
66
+ active,
67
+ onSelectUniverse,
68
+ }: {
69
+ active: Universe | null;
70
+ onSelectUniverse: (u: Universe | null) => void;
71
+ }) {
72
+ const [connectOpen, setConnectOpen] = useState(false);
73
+ const [publishOpen, setPublishOpen] = useState(false);
74
+ const [newProjectOpen, setNewProjectOpen] = useState(false);
75
+ const [view, setView] = usePersistentState<View>("studio:view", "components");
76
+ const [org, setOrg] = usePersistentState<string>("studio:org", "all");
77
+ const [variant, setVariant] = usePersistentState<string>("studio:variant", "light");
78
+
79
+ const [projects, setProjects] = useState<string[]>([]);
80
+ const [theme, setTheme] = useState<ResolvedTheme | null>(null);
81
+ const [foundation, setFoundation] = useState<ResolvedTheme | null>(null);
82
+ const [error, setError] = useState<string | null>(null);
83
+
84
+ // The projects in this developer's rx/ folder. Flat, per RX_ORG_MODEL. Re-read after
85
+ // creating one, so a new project appears without a reload.
86
+ const loadProjects = () =>
87
+ fetch("/dev/orgs")
88
+ .then((r) => r.json())
89
+ .then((d) => setProjects(d.orgs ?? []))
90
+ .catch((e) => setError(String(e)));
91
+ useEffect(() => {
92
+ loadProjects();
93
+ }, []);
94
+
95
+ // The theme being previewed, plus the design-system foundation for the same variant
96
+ // so the tokens screen can show what this project overrides and what it inherits.
97
+ useEffect(() => {
98
+ const ref = org === "all" ? variant : `${org}/${variant}`;
99
+ client
100
+ .readTheme(ref)
101
+ .then(setTheme)
102
+ .catch(() => client.readTheme(variant).then(setTheme).catch((e) => setError(String(e))));
103
+ client.readTheme(variant).then(setFoundation).catch(() => setFoundation(null));
104
+ }, [org, variant]);
105
+
106
+ if (error) {
107
+ return (
108
+ <div className="flex h-screen items-center justify-center bg-[#0f141a] p-8 text-center text-sm text-red-300">
109
+ <div>
110
+ <p className="mb-2 font-semibold">Studio could not read your project.</p>
111
+ <p className="text-gray-400">{error}</p>
112
+ </div>
113
+ </div>
114
+ );
115
+ }
116
+
117
+ return (
118
+ <div className="grid h-screen grid-rows-[auto_1fr] bg-[#0f141a] text-gray-200">
119
+ <header className="flex items-center gap-4 border-b border-gray-800 px-4 py-2">
120
+ {/* Local asset, not a CDN URL: Studio is offline by design and the mark must
121
+ render on a plane. Wordmark stays lowercase per the brand. */}
122
+ <span className="flex items-center gap-2">
123
+ <img src="/logo.png" alt="" className="size-6 object-contain" />
124
+ <span className="text-sm font-semibold tracking-tight text-gray-100">unoverse studio</span>
125
+ </span>
126
+
127
+ <nav className="flex items-center gap-1">
128
+ {NAV.map((n) => (
129
+ <button
130
+ key={n.key}
131
+ onClick={() => setView(n.key)}
132
+ className={`rounded px-2.5 py-1 text-xs font-medium transition ${
133
+ view === n.key ? "bg-white/10 text-gray-100" : "text-gray-400 hover:text-gray-200"
134
+ }`}
135
+ >
136
+ {n.label}
137
+ </button>
138
+ ))}
139
+ </nav>
140
+
141
+ <div className="ml-auto flex items-center gap-2 text-xs">
142
+ {/*
143
+ The dropdown both SELECTS and CREATES. A separate button would be a second
144
+ place to look for the same idea, and the moment a developer wants a new project
145
+ is the moment they open this list and do not find it.
146
+ */}
147
+ <select
148
+ value={org}
149
+ onChange={(e) => {
150
+ if (e.target.value === "__new") return setNewProjectOpen(true);
151
+ setOrg(e.target.value);
152
+ }}
153
+ className="rounded border border-gray-700 bg-[#1b2129] px-2 py-1 text-gray-200"
154
+ >
155
+ <option value="all">All projects</option>
156
+ {projects.map((o) => (
157
+ <option key={o} value={o}>
158
+ {o}
159
+ </option>
160
+ ))}
161
+ <option disabled>──────────</option>
162
+ <option value="__new">New project…</option>
163
+ </select>
164
+ <button
165
+ onClick={() => setVariant(variant === "light" ? "dark" : "light")}
166
+ className="rounded border border-gray-700 px-2 py-1 text-gray-300 hover:text-gray-100"
167
+ title="Theme variant"
168
+ >
169
+ {variant}
170
+ </button>
171
+ {/* The ONLY place Studio asks for anything. Unconnected reads as an invitation,
172
+ not an error: working offline is a supported state, not a missing step. */}
173
+ <button
174
+ onClick={() => setConnectOpen(true)}
175
+ className="rounded border border-gray-700 px-2 py-1 text-gray-300 hover:text-gray-100"
176
+ title="Where your work publishes to"
177
+ >
178
+ {active ? active.label : "Not connected"}
179
+ </button>
180
+ {/* PUBLISHES THE SELECTED PROJECT. Disabled rather than hidden when there is
181
+ nowhere to send or nothing selected: a missing button reads as a missing
182
+ feature, a disabled one with a reason reads as a next step. */}
183
+ <button
184
+ onClick={() => setPublishOpen(true)}
185
+ disabled={!active || org === "all"}
186
+ title={
187
+ !active
188
+ ? "Connect a universe first"
189
+ : org === "all"
190
+ ? "Pick one project to publish"
191
+ : `Publish ${org} to ${active.label}`
192
+ }
193
+ className={
194
+ active && org !== "all"
195
+ ? "rounded bg-emerald-500 px-3 py-1 font-medium text-emerald-950 hover:bg-emerald-400"
196
+ : "rounded border border-gray-700 px-2 py-1 text-gray-500 disabled:cursor-not-allowed"
197
+ }
198
+ >
199
+ Publish
200
+ </button>
201
+ </div>
202
+ </header>
203
+
204
+ {publishOpen && active && org !== "all" && (
205
+ <PublishDialog project={org} universe={active} onClose={() => setPublishOpen(false)} />
206
+ )}
207
+
208
+ {/* Created, then SELECTED. Making a project and leaving the developer looking at the
209
+ old one would be a step they have to discover. */}
210
+ {newProjectOpen && (
211
+ <NewProject
212
+ onCreated={async (name) => {
213
+ await loadProjects();
214
+ setOrg(name);
215
+ setNewProjectOpen(false);
216
+ }}
217
+ onClose={() => setNewProjectOpen(false)}
218
+ />
219
+ )}
220
+
221
+ {connectOpen && (
222
+ <ConnectUniverse
223
+ active={active}
224
+ onSelect={onSelectUniverse}
225
+ onClose={() => setConnectOpen(false)}
226
+ />
227
+ )}
228
+
229
+ {/* The shared screens size themselves with `flex-1`, so the host must be a flex
230
+ column. As a grid cell they would collapse to their content height. */}
231
+ <main className="flex min-h-0 flex-col overflow-hidden">
232
+ {!theme ? (
233
+ <div className="p-6 text-xs text-gray-500">Reading your theme…</div>
234
+ ) : view === "apps" ? (
235
+ <TemplatesView theme={theme} themeName={variant} org={org} />
236
+ ) : view === "components" ? (
237
+ <ComponentsView theme={theme} org={org} />
238
+ ) : view === "atoms" ? (
239
+ <AtomsView theme={theme} />
240
+ ) : view === "tokens" ? (
241
+ <DesignSystemView theme={theme} foundation={foundation} org={org} />
242
+ ) : view === "skills" ? (
243
+ <SkillsView />
244
+ ) : view === "nodes" ? (
245
+ <NodesView />
246
+ ) : (
247
+ <PromptBlocksView />
248
+ )}
249
+ </main>
250
+ </div>
251
+ );
252
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Connect to a universe: the "when you are ready" step.
3
+ *
4
+ * Everything before this point is offline. A developer builds components, templates and
5
+ * nodes against their own folders with no account and no network. This screen is where
6
+ * they choose somewhere to publish, and it is deliberately the only place Studio asks
7
+ * for anything.
8
+ *
9
+ * The flow is: type an address, Studio asks that universe who authenticates it, then
10
+ * signs in against whatever the answer was. Studio names no provider.
11
+ *
12
+ * See docs/architecture/LOCAL_STUDIO.md.
13
+ */
14
+ import { useState } from "react";
15
+ import { useAuth } from "react-oidc-context";
16
+ import {
17
+ normaliseUrl,
18
+ defaultLabel,
19
+ discover,
20
+ loginReady,
21
+ canPublish,
22
+ loadUniverses,
23
+ saveUniverse,
24
+ removeUniverse,
25
+ type Universe,
26
+ } from "./universes";
27
+
28
+ /** Decode a JWT payload without verifying it. Display only: the universe verifies. */
29
+ function claimsOf(accessToken: string | undefined): Record<string, unknown> | null {
30
+ if (!accessToken) return null;
31
+ try {
32
+ return JSON.parse(atob(accessToken.split(".")[1] ?? ""));
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Sign out of the connected universe.
40
+ *
41
+ * `removeUser` clears the local session only: it does not sign you out of the identity
42
+ * provider, so signing in again may return the same account without prompting. That is the
43
+ * provider's decision, not ours, and a full federated logout needs a logout URL configured
44
+ * per universe. Local is the honest scope for a tool.
45
+ */
46
+ function SignOut({ auth }: { auth: ReturnType<typeof useAuth> }) {
47
+ return (
48
+ <button
49
+ onClick={() => auth?.removeUser()}
50
+ className="text-xs text-gray-500 underline-offset-2 hover:text-gray-300 hover:underline"
51
+ >
52
+ Sign out
53
+ </button>
54
+ );
55
+ }
56
+
57
+ export function ConnectUniverse({
58
+ active,
59
+ onSelect,
60
+ onClose,
61
+ }: {
62
+ active: Universe | null;
63
+ onSelect: (universe: Universe | null) => void;
64
+ onClose: () => void;
65
+ }) {
66
+ const [universes, setUniverses] = useState<Universe[]>(() => loadUniverses());
67
+ const [address, setAddress] = useState("");
68
+ const [busy, setBusy] = useState(false);
69
+ const [error, setError] = useState<string | null>(null);
70
+
71
+ // Undefined when no provider is mounted, which is the normal offline case.
72
+ const auth = useAuth();
73
+ const claims = claimsOf(auth?.user?.access_token);
74
+ const signedIn = Boolean(auth?.isAuthenticated);
75
+
76
+ async function connect() {
77
+ setError(null);
78
+ setBusy(true);
79
+ try {
80
+ const url = normaliseUrl(address);
81
+ const config = await discover(url);
82
+ const universe: Universe = {
83
+ url,
84
+ label: defaultLabel(url),
85
+ config,
86
+ connectedAt: new Date().toISOString(),
87
+ };
88
+ setUniverses(saveUniverse(universe));
89
+ setAddress("");
90
+ onSelect(universe);
91
+ } catch (e: any) {
92
+ // These messages are written to be actionable (universes.ts), so show them as-is.
93
+ setError(e?.message ?? String(e));
94
+ } finally {
95
+ setBusy(false);
96
+ }
97
+ }
98
+
99
+ function forget(url: string) {
100
+ setUniverses(removeUniverse(url));
101
+ if (active?.url === url) onSelect(null);
102
+ }
103
+
104
+ const readiness = active ? loginReady(active.config) : null;
105
+ const allowed = active && claims ? canPublish(claims, active.config) : false;
106
+ /** The signed-in address. Namespace-aware: Auth0 emits `email` ONLY namespaced on an
107
+ * access token, so a plain `claims.email` is undefined and the id leaks into the UI. */
108
+ const email = (() => {
109
+ const direct = (claims as any)?.email;
110
+ if (typeof direct === "string") return direct;
111
+ const key = Object.keys(claims ?? {}).find((k) => k.endsWith("/email") || k.endsWith("/claims/email"));
112
+ const value = key ? (claims as any)[key] : undefined;
113
+ return typeof value === "string" ? value : null;
114
+ })();
115
+
116
+ /** What the token says, for the message below. Trimmed, as the checks are. */
117
+ const permissionList: string[] = (() => {
118
+ const direct = (claims as any)?.permissions;
119
+ const nsKey = Object.keys(claims ?? {}).find((k) => k.endsWith("/permissions"));
120
+ const list = Array.isArray(direct) ? direct : ((claims as any)?.[nsKey ?? ""] ?? []);
121
+ return Array.isArray(list) ? list.map((p) => String(p).trim()) : [];
122
+ })();
123
+
124
+ return (
125
+ <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/60 p-8" onClick={onClose}>
126
+ <div
127
+ className="w-full max-w-lg rounded-xl border border-gray-800 bg-[#151b22] p-6 text-gray-200 shadow-2xl"
128
+ onClick={(e) => e.stopPropagation()}
129
+ >
130
+ <h2 className="text-base font-semibold text-gray-100">Publish to a universe</h2>
131
+ <p className="mt-1 text-xs text-gray-400">
132
+ Studio works offline. Connect a universe when you are ready to publish.
133
+ </p>
134
+
135
+ {/* Add one */}
136
+ <div className="mt-5 flex gap-2">
137
+ <input
138
+ value={address}
139
+ onChange={(e) => setAddress(e.target.value)}
140
+ onKeyDown={(e) => e.key === "Enter" && !busy && connect()}
141
+ placeholder="your-universe.com"
142
+ className="flex-1 rounded border border-gray-700 bg-[#1b2129] px-3 py-2 text-sm text-gray-200 placeholder:text-gray-600 focus:border-gray-500 focus:outline-none"
143
+ />
144
+ <button
145
+ onClick={connect}
146
+ disabled={busy || !address.trim()}
147
+ className="rounded bg-gray-100 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-white disabled:opacity-30"
148
+ >
149
+ {busy ? "Checking..." : "Connect"}
150
+ </button>
151
+ </div>
152
+ {error && <p className="mt-2 text-xs text-red-400">{error}</p>}
153
+
154
+ {/* The ones this developer has connected to. There is no central directory of
155
+ universes and there cannot be: each is independent with its own provider. */}
156
+ {universes.length > 0 && (
157
+ <ul className="mt-5 space-y-2">
158
+ {universes.map((u) => (
159
+ <li
160
+ key={u.url}
161
+ className={`flex items-center justify-between rounded-md border px-3 py-2 text-sm ${
162
+ active?.url === u.url
163
+ ? "border-gray-500 bg-white/5"
164
+ : "border-gray-800 hover:border-gray-700"
165
+ }`}
166
+ >
167
+ <button className="text-left" onClick={() => onSelect(u)}>
168
+ <span className="font-medium">{u.label}</span>
169
+ <span className="ml-2 text-gray-500">{u.url}</span>
170
+ </button>
171
+ <button className="text-xs text-gray-500 hover:text-red-400" onClick={() => forget(u.url)}>
172
+ Forget
173
+ </button>
174
+ </li>
175
+ ))}
176
+ </ul>
177
+ )}
178
+
179
+ {/* Sign-in state for the selected universe. Every branch says what to do next. */}
180
+ {active && (
181
+ <div className="mt-5 border-t border-gray-800 pt-4 text-sm">
182
+ {readiness && !readiness.ok ? (
183
+ // A universe with auth off, or one misconfigured. Publishing is not
184
+ // possible, and saying so beats a redirect to a provider that is not there.
185
+ <p className="text-xs text-amber-400">{readiness.reason}</p>
186
+ ) : !signedIn ? (
187
+ <button
188
+ onClick={() => auth?.signinRedirect()}
189
+ className="rounded bg-gray-100 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-white"
190
+ >
191
+ Sign in to {active.label}
192
+ </button>
193
+ ) : allowed ? (
194
+ <div className="flex items-center justify-between gap-3">
195
+ {/* The fact, not the identifier. A raw `auth0|692be24b…` is noise: it is not
196
+ how anyone thinks of themselves, and it appeared only because `email` is a
197
+ NAMESPACED claim that a plain read misses (AUTH_TOKEN_FLOW.md names this
198
+ exact footgun). Read properly it is an address; absent, the sentence is
199
+ better without it. */}
200
+ <p className="text-sm font-medium text-emerald-400">
201
+ Ready to publish{email ? <span className="font-normal text-gray-400"> as {email}</span> : null}
202
+ </p>
203
+ <SignOut auth={auth} />
204
+ </div>
205
+ ) : (
206
+ // Signed in but without the permission. Said HERE, not at the first push,
207
+ // so nobody builds for an hour and then finds out.
208
+ <div>
209
+ <p className="text-xs text-amber-400">
210
+ Signed in, but your account does not have{" "}
211
+ <code>{active.config.publishPermission}</code> on this universe. Ask whoever
212
+ administers it to grant it.
213
+ </p>
214
+ {/* WHAT THE TOKEN ACTUALLY CARRIES. Kept, in a reduced form, because "you do not
215
+ have this permission" is unhelpful when the identity provider visibly
216
+ says you do. Printing the claim turns a guess into a fact: it is how a
217
+ leading space in an Auth0 permission (" marketplace:publish") was found,
218
+ after the screens, the token and the label had all agreed it was correct. */}
219
+ <p className="mt-2 text-xs text-gray-500">
220
+ Your token carries: <code>{permissionList.join(", ") || "no permissions claim"}</code>
221
+ </p>
222
+ <div className="mt-2">
223
+ <SignOut auth={auth} />
224
+ </div>
225
+ </div>
226
+ )}
227
+ </div>
228
+ )}
229
+ </div>
230
+ </div>
231
+ );
232
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Creating a project from inside Studio.
3
+ *
4
+ * A project is `rx/<name>/`: the folder a developer authors in, and the unit that
5
+ * publishes. Until this existed, Studio could create your FIRST one (bin/studio.mjs, at
6
+ * launch, only when the folder was empty) and never your second. The dropdown listed what
7
+ * was on disk and offered no way to add to it, so the answer to "start another project"
8
+ * was to quit Studio and mkdir.
9
+ *
10
+ * The name is validated on the server (local/scaffold.mjs) rather than here, because it is
11
+ * the same rule the CLI applies and a rule stated twice is a rule that will disagree with
12
+ * itself. This screen shows what the server said.
13
+ */
14
+ import { useState } from "react";
15
+
16
+ export function NewProject({
17
+ onCreated,
18
+ onClose,
19
+ }: {
20
+ onCreated: (name: string) => void;
21
+ onClose: () => void;
22
+ }) {
23
+ const [name, setName] = useState("");
24
+ const [error, setError] = useState<string | null>(null);
25
+ const [busy, setBusy] = useState(false);
26
+
27
+ async function create() {
28
+ setBusy(true);
29
+ setError(null);
30
+ try {
31
+ const res = await fetch("/dev/projects", {
32
+ method: "POST",
33
+ headers: { "content-type": "application/json" },
34
+ body: JSON.stringify({ name: name.trim() }),
35
+ });
36
+ const b = await res.json();
37
+ if (!res.ok || b.error) throw new Error(b.error ?? `HTTP ${res.status}`);
38
+ onCreated(b.name);
39
+ } catch (e: any) {
40
+ setError(e?.message ?? String(e));
41
+ } finally {
42
+ setBusy(false);
43
+ }
44
+ }
45
+
46
+ return (
47
+ <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/60 p-8" onClick={onClose}>
48
+ <div
49
+ className="w-full max-w-md rounded-xl border border-gray-800 bg-[#151b22] p-6 text-gray-200 shadow-2xl"
50
+ onClick={(e) => e.stopPropagation()}
51
+ >
52
+ <h2 className="text-base font-semibold text-gray-100">New project</h2>
53
+ <p className="mt-1 text-xs text-gray-500">
54
+ Creates rx/&lt;name&gt;/ with components, styles and templates, and one component to
55
+ open on.
56
+ </p>
57
+
58
+ <input
59
+ autoFocus
60
+ value={name}
61
+ onChange={(e) => setName(e.target.value)}
62
+ onKeyDown={(e) => e.key === "Enter" && name.trim() && !busy && create()}
63
+ placeholder="my-project"
64
+ spellCheck={false}
65
+ className="mt-4 w-full rounded border border-gray-700 bg-[#0f141a] px-3 py-2 text-sm text-white placeholder:text-gray-600 focus:border-emerald-500 focus:outline-none"
66
+ />
67
+ <p className="mt-2 text-xs text-gray-600">
68
+ Lowercase letters, numbers and dashes. This becomes the folder name and the owner
69
+ of everything you publish from it.
70
+ </p>
71
+
72
+ {error && <p className="mt-3 text-xs text-red-400">{error}</p>}
73
+
74
+ <div className="mt-6 flex items-center gap-2">
75
+ <button
76
+ onClick={create}
77
+ disabled={busy || !name.trim()}
78
+ className="rounded bg-emerald-500 px-4 py-2 text-sm font-semibold text-emerald-950 hover:bg-emerald-400 disabled:opacity-30"
79
+ >
80
+ {busy ? "Creating…" : "Create project"}
81
+ </button>
82
+ <button onClick={onClose} className="rounded px-3 py-2 text-sm text-gray-400 hover:text-gray-200">
83
+ Cancel
84
+ </button>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ );
89
+ }