@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,551 @@
1
+ /**
2
+ * Components view — Storybook-style. Left: a navigator of all components grouped
3
+ * by category. Top-right: the rendered component (canvas). Bottom: live controls
4
+ * docked under the canvas. Each control edit is a COMPONENT_DATA merge.
5
+ */
6
+ import { useEffect, useMemo, useState } from "react";
7
+ import { ComponentStore } from "@unoverse/sdk";
8
+ import { StreamedUnoverseComponent, type ResolvedTheme } from "@unoverse/sdk";
9
+ import { client, authedFetch } from "studio-host";
10
+ import { stateActivation } from "./states";
11
+ import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
12
+
13
+ const CHAT_ID = "demo";
14
+
15
+ interface PropDef {
16
+ type?: string;
17
+ default?: unknown;
18
+ /** Studio-only sample value — marking/design data that must NEVER reach a live
19
+ * render (live absence is what drives `skeleton` ghosts). Preferred over `default`
20
+ * when seeding the preview stage. */
21
+ preview?: unknown;
22
+ input?: boolean;
23
+ }
24
+ interface ComponentDef {
25
+ name: string;
26
+ /** Display name from the manifest ("Card Finder"); falls back to `name`. */
27
+ title?: string;
28
+ description?: string;
29
+ /** The spatial selection text (manifest.whenToUse) — what findIntent ranks on. */
30
+ whenToUse?: string;
31
+ category?: string;
32
+ /** Spatial-CAPABLE: the component folder carries a manifest.json (server-projected).
33
+ * Only capable components show the Spatial toggle — a basic component shows nothing. */
34
+ spatial?: boolean;
35
+ props?: Record<string, PropDef>;
36
+ /** The node's input prop keys, derived server-side (= the node's configSchema).
37
+ * The CONTROLS panel renders exactly these; the rest stays hardcoded in the def. */
38
+ inputs?: string[];
39
+ /** The component's layer views — its states/ folder (LAYERS §7); a wizard's steps. */
40
+ states?: { name: string; when?: unknown }[];
41
+ /** The definition tree (used to resolve a state's Switch discriminant). */
42
+ root?: unknown;
43
+ }
44
+
45
+ const VIEWPORTS: { id: string; label: string; w: number | null }[] = [
46
+ { id: "mobile", label: "Mobile", w: 390 },
47
+ { id: "tablet", label: "Tablet", w: 768 },
48
+ { id: "desktop", label: "Desktop", w: 1280 },
49
+ { id: "full", label: "Full", w: null },
50
+ ];
51
+
52
+ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; org?: string }) {
53
+ const store = useMemo(() => new ComponentStore(), []);
54
+ const spatial = useRegistryToggles("component");
55
+ const [components, setComponents] = useState<ComponentDef[]>([]);
56
+ const [selected, setSelected] = usePersistentState<string | null>("wb:components:selected", null);
57
+ const [values, setValues] = useState<Record<string, unknown>>({}); // typed values → store/render
58
+ const [text, setText] = useState<Record<string, string>>({}); // raw editor strings (decoupled from the typed value)
59
+ const [lastAction, setLastAction] = useState<string | null>(null);
60
+ const [, setStoreTick] = useState(0); // re-render on store changes (focus / displayState)
61
+ const [panelOpen, setPanelOpen] = useState(false); // controls dock starts closed — most microapps have no props
62
+ const [panelHeight, setPanelHeight] = useState(240);
63
+ const [metaOpen, setMetaOpen] = usePersistentState<boolean>("wb:components:meta", false); // manifest meta panel
64
+ // Responsive preview — the stage width. Components are fluid (width: full), so
65
+ // the channel/slot sizes them; here we simulate that slot to test responsiveness.
66
+ const [stageWidth, setStageWidth] = useState<number | null>(768); // default Tablet — a single component reads best at a contained width
67
+
68
+ // Drag the divider to resize the controls dock (up = taller).
69
+ const onDragStart = (e: React.MouseEvent) => {
70
+ e.preventDefault();
71
+ const startY = e.clientY;
72
+ const startH = panelHeight;
73
+ const onMove = (ev: MouseEvent) => setPanelHeight(Math.max(120, Math.min(window.innerHeight - 200, startH + (startY - ev.clientY))));
74
+ const onUp = () => {
75
+ window.removeEventListener("mousemove", onMove);
76
+ window.removeEventListener("mouseup", onUp);
77
+ };
78
+ window.addEventListener("mousemove", onMove);
79
+ window.addEventListener("mouseup", onUp);
80
+ };
81
+
82
+ // Seed the component's data with the props' real DEFAULT VALUES, kept as their
83
+ // real TYPES (an array stays an array, an object an object). The previous code did
84
+ // String(default), which turned an array prop into "[object Object],…" — so the
85
+ // SDK's `Each` saw a string instead of a list and rendered nothing. Editor display
86
+ // strings are derived separately into `text`.
87
+ const toText = (v: unknown) => (typeof v === "string" ? v : v == null ? "" : JSON.stringify(v, null, 2));
88
+ const defaultsOf = (c: ComponentDef): Record<string, unknown> =>
89
+ Object.fromEntries(
90
+ Object.entries(c.props ?? {}).map(([k, v]) => [k, v.preview ?? v.default ?? (v.type === "array" ? [] : v.type === "object" ? {} : "")]),
91
+ );
92
+
93
+ function choose(c: ComponentDef) {
94
+ setSelected(c.name);
95
+ const defaults = defaultsOf(c);
96
+ setValues(defaults);
97
+ setText(Object.fromEntries(Object.entries(defaults).map(([k, v]) => [k, toText(v)])));
98
+ store.apply({ type: "COMPONENT_INIT", chatId: CHAT_ID, nodeId: c.name, component: { type: c.name, props: defaults } });
99
+ }
100
+
101
+ useEffect(() => {
102
+ // The header org scope: "all" = design system + every org's components (tagged);
103
+ // an org = design system + that org's own only (org-private).
104
+ authedFetch(`/dev/components${org !== "all" ? `?org=${encodeURIComponent(org)}` : ""}`)
105
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
106
+ .then((d) => {
107
+ const list: ComponentDef[] = d.components ?? [];
108
+ setComponents(list);
109
+ // Restore the persisted selection across reloads; fall back to the first.
110
+ const restore = list.find((c) => c.name === selected) ?? list[0];
111
+ if (restore) choose(restore);
112
+ })
113
+ .catch(() => setComponents([]));
114
+ // eslint-disable-next-line react-hooks/exhaustive-deps
115
+ }, [org]);
116
+
117
+ // Re-render when the store changes (so a def's setValue displayState button flips the
118
+ // toggle highlight + grow container, not just the rendered component).
119
+ useEffect(() => store.subscribe(() => setStoreTick((t) => t + 1)), [store]);
120
+
121
+ const current = components.find((c) => c.name === selected);
122
+ // The TWO MASTER STATES (inline / focused) are universal — every component's root
123
+ // switches its face on `defaultState` (STATE_MODEL §5b). The toolbar control writes
124
+ // that key into the slice — the same generic setValue/COMPONENT_DATA path a real
125
+ // component's own buttons use. `focused` also drives the grow-to-fill container.
126
+ const slice = selected ? store.get(CHAT_ID, selected) : undefined;
127
+ const masterState = ((slice?.defaultState ?? slice?.displayState) as string | undefined) ?? "inline";
128
+ // A component's FACES come from its OWN root `Switch on defaultState` cases — an
129
+ // OPEN name set (inline/focused/course/…), never a hardcoded pair. `inline` is the
130
+ // universal default face and always leads. A flat component (no Switch) has ONE
131
+ // look; the face control would be a lie, so it's hidden.
132
+ const facesOf = (root: unknown): string[] => {
133
+ const found: string[] = [];
134
+ const walk = (n: any): void => {
135
+ if (!n || typeof n !== "object") return;
136
+ if (Array.isArray(n)) { n.forEach(walk); return; }
137
+ if (n.type === "Switch" && n.on === "defaultState" && n.cases && typeof n.cases === "object") {
138
+ for (const k of Object.keys(n.cases)) if (k !== "default" && !found.includes(k)) found.push(k);
139
+ }
140
+ Object.values(n).forEach(walk);
141
+ };
142
+ walk(root);
143
+ return ["inline", ...found.filter((f) => f !== "inline")];
144
+ };
145
+ const faces = current?.root ? facesOf(current.root) : ["inline"];
146
+ const hasFaces = faces.length > 1;
147
+ // Any non-inline face gets the grow-to-fill container (focused/course/… are the fuller looks).
148
+ const isFocused = hasFaces && masterState !== "inline";
149
+ const setMasterState = (ms: string) => {
150
+ if (!selected) return;
151
+ store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: selected, data: { defaultState: ms } });
152
+ };
153
+
154
+ // CONTEXTUAL SUB-STATES — a face's own internal steps (a Switch inside the face on a
155
+ // PRIVATE key, e.g. `step`: detail ⇄ book). They exist only within that face, so they
156
+ // show only while it's active — never as component-wide states. Fully derived: any
157
+ // named case of any non-defaultState Switch in the ACTIVE face's subtree.
158
+ const subStates = ((): { on: string; name: string }[] => {
159
+ if (!current?.root || !hasFaces || masterState === "inline") return [];
160
+ let face: unknown;
161
+ const findFace = (n: any): void => {
162
+ if (face || !n || typeof n !== "object") return;
163
+ if (Array.isArray(n)) { n.forEach(findFace); return; }
164
+ if (n.type === "Switch" && n.on === "defaultState" && n.cases?.[masterState]) { face = n.cases[masterState]; return; }
165
+ Object.values(n).forEach(findFace);
166
+ };
167
+ findFace(current.root);
168
+ const out: { on: string; name: string }[] = [];
169
+ const walk = (n: any): void => {
170
+ if (!n || typeof n !== "object") return;
171
+ if (Array.isArray(n)) { n.forEach(walk); return; }
172
+ if (n.type === "Switch" && typeof n.on === "string" && n.on !== "defaultState" && n.cases && typeof n.cases === "object") {
173
+ for (const k of Object.keys(n.cases)) if (k !== "default" && !out.some((s) => s.name === k)) out.push({ on: n.on, name: k });
174
+ }
175
+ Object.values(n).forEach(walk);
176
+ };
177
+ walk(face);
178
+ return out;
179
+ })();
180
+
181
+ // COPY FOR CANVAS — writes the canvas's own clipboard payload (gravity-workflow,
182
+ // the same shape its Cmd+C emits), so Cmd+V on any canvas pastes this component's
183
+ // node. This is how design components reach a workflow now — the canvas palette
184
+ // no longer lists them.
185
+ //
186
+ // The design system is now ONE generic `Component` node (not one node type per
187
+ // component). So the pasted node's TYPE is always "Component" and the chosen
188
+ // component is carried in config (`component: <name>`), with the def's default
189
+ // props seeded so it pastes as a working, rendered component. Emitting the old
190
+ // per-component type (`type: <name>`) would land as an "unknown node / Package not
191
+ // installed" placeholder, because that node type no longer exists in the catalog.
192
+ const [copiedForCanvas, setCopiedForCanvas] = useState(false);
193
+ const copyForCanvas = async () => {
194
+ if (!current) return;
195
+ // Width from the def; height stays AUTO — the canvas node hugs the rendered
196
+ // component's height (components flex width, own height).
197
+ const size = (current as any).nodeSize ?? { width: 400 };
198
+ const node = {
199
+ id: `${current.name.toLowerCase()}1`,
200
+ type: "Component",
201
+ position: { x: 0, y: 0 },
202
+ data: {
203
+ label: current.name,
204
+ status: "ready",
205
+ config: { component: current.name, ...defaultsOf(current) },
206
+ },
207
+ style: { width: size.width },
208
+ draggable: true,
209
+ };
210
+ await navigator.clipboard.writeText(
211
+ JSON.stringify({ type: "gravity-workflow", version: 2, nodes: [node], edges: [] }, null, 2),
212
+ );
213
+ setCopiedForCanvas(true);
214
+ setTimeout(() => setCopiedForCanvas(false), 1600);
215
+ };
216
+
217
+ // Edit a control. `text` always updates so the field stays typeable; the TYPED
218
+ // value pushed to the store is JSON-parsed for array/object props (and left as a
219
+ // raw string for string props). Half-typed invalid JSON just isn't pushed yet.
220
+ const setField = (key: string, raw: string, type?: string) => {
221
+ if (!selected) return;
222
+ setText((t) => ({ ...t, [key]: raw }));
223
+ let val: unknown = raw;
224
+ if (type === "array" || type === "object") {
225
+ try {
226
+ val = JSON.parse(raw);
227
+ } catch {
228
+ return; // keep the keystroke in `text`; don't push half-typed JSON to the store
229
+ }
230
+ }
231
+ setValues((v) => ({ ...v, [key]: val }));
232
+ store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: selected, data: { [key]: val } });
233
+ };
234
+
235
+ const byCategory = Object.entries(
236
+ components.reduce<Record<string, ComponentDef[]>>((acc, c) => {
237
+ (acc[c.category ?? "Uncategorized"] ??= []).push(c);
238
+ return acc;
239
+ }, {}),
240
+ ).sort(([a], [b]) => a.localeCompare(b));
241
+
242
+ return (
243
+ <div className="grid min-h-0 flex-1 grid-cols-[190px_1fr] overflow-hidden">
244
+ {/* NAVIGATOR */}
245
+ <nav className="flex min-h-0 min-w-0 flex-col overflow-auto border-r border-gray-700 bg-[#2c2c2e] p-2">
246
+ {components.length === 0 && (
247
+ <div className="p-2 text-xs text-gray-400">No components — restart the Unoverse server so /dev/components is available.</div>
248
+ )}
249
+ {byCategory.map(([cat, items]) => (
250
+ <div key={cat} className="mb-3">
251
+ <div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400">{cat}</div>
252
+ {items.map((c) => (
253
+ <div key={c.name}>
254
+ <button
255
+ onClick={() => choose(c)}
256
+ className={`block w-full rounded px-2 py-1 text-left text-sm transition ${
257
+ selected === c.name ? "bg-emerald-500/15 text-emerald-300" : "text-gray-300 hover:bg-white/5"
258
+ }`}
259
+ >
260
+ {c.name}
261
+ </button>
262
+ {/* The component's OWN states (its states/ folder) live HERE in the rail.
263
+ The two MASTER states (inline/focused — the `defaultState` face switch,
264
+ STATE_MODEL §5b) are NOT in this list; they live in the toolbar because
265
+ every component has them. A stateless component is inline by default —
266
+ no states/ folder, no rail entry at all (inline needs no dedicated state). */}
267
+ {selected === c.name && (c.states?.length ?? 0) > 0 && (
268
+ <div className="my-0.5 ml-2 flex flex-col gap-px border-l border-gray-700 pl-2">
269
+ {(c.states ?? []).map((s) => {
270
+ const data = stateActivation(c.root, s);
271
+ const activeKey = data ? Object.keys(data)[0] : null;
272
+ const isActive =
273
+ Boolean(activeKey) &&
274
+ (store.get(CHAT_ID, c.name) as Record<string, unknown>)[activeKey!] === (data as Record<string, unknown>)[activeKey!];
275
+ return (
276
+ <button
277
+ key={s.name}
278
+ onClick={() => {
279
+ if (!data) return;
280
+ store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: c.name, data });
281
+ setStoreTick((v) => v + 1);
282
+ }}
283
+ className={`block w-full truncate rounded px-2 py-0.5 text-left text-xs capitalize transition ${
284
+ isActive ? "bg-emerald-500/10 text-emerald-300" : "text-gray-400 hover:bg-white/5 hover:text-gray-200"
285
+ }`}
286
+ >
287
+ {s.name}
288
+ </button>
289
+ );
290
+ })}
291
+ </div>
292
+ )}
293
+ {/* CONTEXTUAL SUB-STATES — the ACTIVE face's internal steps (its private
294
+ Switch, e.g. focus's detail ⇄ book). Layouts on top, states down the
295
+ left: these list here ONLY while their face is active — they aren't
296
+ states of the other faces. First named case = the face's default. */}
297
+ {selected === c.name && subStates.length > 0 && (
298
+ <div className="my-0.5 ml-2 flex flex-col gap-px border-l border-gray-700 pl-2">
299
+ {subStates.map((s, i) => {
300
+ const cur = (store.get(CHAT_ID, c.name) as Record<string, unknown>)[s.on];
301
+ const on = cur === s.name || (i === 0 && (cur === undefined || cur === ""));
302
+ return (
303
+ <button
304
+ key={s.name}
305
+ onClick={() => {
306
+ store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: c.name, data: { [s.on]: s.name } });
307
+ setStoreTick((v) => v + 1);
308
+ }}
309
+ className={`block w-full truncate rounded px-2 py-0.5 text-left text-xs capitalize transition ${
310
+ on ? "bg-emerald-500/10 text-emerald-300" : "text-gray-400 hover:bg-white/5 hover:text-gray-200"
311
+ }`}
312
+ >
313
+ {s.name}
314
+ </button>
315
+ );
316
+ })}
317
+ </div>
318
+ )}
319
+ </div>
320
+ ))}
321
+ </div>
322
+ ))}
323
+ </nav>
324
+
325
+ {/* MAIN — canvas on top, controls docked at the bottom */}
326
+ <div className="flex min-h-0 min-w-0 flex-col">
327
+ {/* CANVAS */}
328
+ <section className="flex min-h-0 flex-1 flex-col" style={{ background: theme.color["surface.sunken"] ?? "#f3f4f6", color: theme.color["text.primary"] }}>
329
+ <div className="flex items-center gap-3 border-b px-4 py-2" style={{ borderColor: theme.color["border.subtle"] }}>
330
+ <span className="text-[11px] font-medium uppercase tracking-wider opacity-60">{current?.name ?? "rendered"}</span>
331
+
332
+ {/* The component's OWN states are picked in the LEFT RAIL; the two MASTER
333
+ states (inline / focused — the face switch on `defaultState`) live HERE,
334
+ apart from the step states. NOT universal: only a component specifically
335
+ coded with the two faces (root Switch on defaultState) shows the control —
336
+ a flat component has one look and gets nothing. */}
337
+ {hasFaces && (
338
+ <div className="flex items-center gap-1 rounded p-0.5 text-[11px]" style={{ background: theme.color["border.subtle"] }}>
339
+ {faces.map((ms) => (
340
+ <button
341
+ key={ms}
342
+ onClick={() => setMasterState(ms)}
343
+ className="rounded px-2 py-0.5 font-medium capitalize transition"
344
+ style={
345
+ masterState === ms
346
+ ? { background: theme.color["surface.base"], color: theme.color["text.primary"] }
347
+ : { color: theme.color["text.secondary"] }
348
+ }
349
+ >
350
+ {ms}
351
+ </button>
352
+ ))}
353
+ </div>
354
+ )}
355
+
356
+ <button
357
+ onClick={copyForCanvas}
358
+ className="rounded border px-2 py-0.5 text-[11px] font-medium transition"
359
+ style={{
360
+ borderColor: theme.color["border.subtle"],
361
+ color: copiedForCanvas ? theme.color["text.primary"] : theme.color["text.secondary"],
362
+ background: copiedForCanvas ? theme.color["surface.sunken"] : "transparent",
363
+ }}
364
+ title="Copy this component as a canvas node — paste (Cmd+V) on any workflow canvas"
365
+ >
366
+ {copiedForCanvas ? "✓ Copied — paste on canvas" : "⧉ Copy for Canvas"}
367
+ </button>
368
+
369
+
370
+ {/* Spatial toggle — ONLY for spatial-capable components (manifest.json present).
371
+ Same two-level model as templates/skills: this is Level 1 (workbench
372
+ eligibility); the Content Engine's per-workflow toggle is Level 2. */}
373
+ {current?.spatial && <ToggleSwitch on={spatial.isEnabled(current.name)} onClick={() => spatial.toggle(current.name)} label="Spatial" />}
374
+ {current?.spatial && (
375
+ <button
376
+ onClick={() => setMetaOpen((o) => !o)}
377
+ className="flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] font-medium"
378
+ style={{
379
+ borderColor: theme.color["border.subtle"],
380
+ color: metaOpen ? theme.color["text.primary"] : theme.color["text.secondary"],
381
+ background: metaOpen ? theme.color["surface.sunken"] : "transparent",
382
+ }}
383
+ title="Show the component's manifest (spatial discovery meta)"
384
+ >
385
+ ⓘ Meta <span className="opacity-60">{metaOpen ? "▴" : "▾"}</span>
386
+ </button>
387
+ )}
388
+
389
+ {/* Responsive viewport presets — components are fluid; this resizes the slot */}
390
+ <div className="ml-auto flex items-center gap-1 rounded p-0.5 text-[11px]" style={{ background: theme.color["border.subtle"] }}>
391
+ {VIEWPORTS.map((v) => (
392
+ <button
393
+ key={v.id}
394
+ onClick={() => setStageWidth(v.w)}
395
+ className="rounded px-2 py-0.5 font-medium transition"
396
+ style={
397
+ stageWidth === v.w
398
+ ? { background: theme.color["surface.base"], color: theme.color["text.primary"] }
399
+ : { color: theme.color["text.secondary"] }
400
+ }
401
+ >
402
+ {v.label}
403
+ </button>
404
+ ))}
405
+ </div>
406
+ <span className="tabular-nums text-[11px]" style={{ color: theme.color["text.tertiary"] }}>
407
+ {stageWidth ? `${stageWidth}px` : "100%"}
408
+ </span>
409
+ {lastAction && (
410
+ <span className="text-xs font-medium" style={{ color: theme.color["action.primary"] }}>
411
+ action: {lastAction}
412
+ </span>
413
+ )}
414
+ </div>
415
+
416
+ {/* MANIFEST META — the component's spatial discovery meta (manifest.json),
417
+ the lightweight sibling of the Templates view's MCP-app panel. */}
418
+ {metaOpen && current?.spatial && (() => {
419
+ const accent = theme.color["action.primary"] ?? "#6366f1";
420
+ return (
421
+ <div className="border-b px-5 py-4" style={{ borderColor: theme.color["border.subtle"], background: theme.color["surface.base"] }}>
422
+ {/* Same identity anatomy as the Apps meta panel — title / accent category
423
+ chip / muted tag, description, then the accented when-to-use callout. */}
424
+ <div className="mx-auto flex max-w-5xl flex-col gap-1">
425
+ <div className="flex flex-wrap items-center gap-2">
426
+ <h2 className="text-[17px] font-semibold leading-tight tracking-tight" style={{ color: theme.color["text.primary"] }}>
427
+ {current.title || current.name}
428
+ </h2>
429
+ {current.category && (
430
+ <span className="rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide" style={{ background: `${accent}14`, color: accent }}>
431
+ {current.category}
432
+ </span>
433
+ )}
434
+ <span className="font-mono text-[10px]" style={{ color: theme.color["text.tertiary"] }}>
435
+ manifest.json
436
+ </span>
437
+ </div>
438
+ {current.description && (
439
+ <p className="mt-1 max-w-3xl text-[13px] leading-relaxed" style={{ color: theme.color["text.secondary"] }}>{current.description}</p>
440
+ )}
441
+ {current.whenToUse && (
442
+ <div className="mt-2 max-w-3xl rounded-r pl-3 py-1.5" style={{ borderLeft: `3px solid ${accent}`, background: theme.color["surface.sunken"] }}>
443
+ <div className="mb-0.5 text-[10px] font-semibold uppercase tracking-wider" style={{ color: accent }}>
444
+ When to use
445
+ </div>
446
+ <p className="text-[15px] font-medium leading-relaxed" style={{ color: theme.color["text.primary"] }}>{current.whenToUse}</p>
447
+ </div>
448
+ )}
449
+ </div>
450
+ </div>
451
+ );
452
+ })()}
453
+ {/* The grow-to-focus container, ported from legacy .focusContainer:
454
+ fill the area (padding 0, height 100%) + focusFadeIn 0.2s ease-out. */}
455
+ <style>{"@keyframes wb-focusFadeIn{from{opacity:0;transform:scale(0.98)}to{opacity:1;transform:scale(1)}}"}</style>
456
+ {/* Focus faces that fill + scroll internally never overflow this box; a
457
+ CONTENT-TALL focus face (a document component like PerfectDay) scrolls
458
+ the stage instead of being clipped. */}
459
+ <div className={isFocused ? "min-h-0 flex-1 overflow-auto" : "flex-1 overflow-auto p-10"}>
460
+ <div
461
+ key={masterState}
462
+ className={`mx-auto max-w-full ${isFocused ? "h-full" : "transition-[width] duration-200"}`}
463
+ style={{ width: stageWidth ?? "100%", ...(isFocused ? { animation: "wb-focusFadeIn 0.2s ease-out" } : {}) }}
464
+ >
465
+ {selected && <StreamedUnoverseComponent client={client} store={store} chatId={CHAT_ID} nodeId={selected} theme={theme} onAction={(a) => setLastAction(typeof a === "string" ? a : a?.type ?? null)} />}
466
+ </div>
467
+ </div>
468
+ </section>
469
+
470
+ {/* CONTROLS — docked bottom panel: collapsible + drag-to-resize (Storybook) */}
471
+ {panelOpen && (
472
+ <div
473
+ onMouseDown={onDragStart}
474
+ className="h-1.5 shrink-0 cursor-row-resize bg-gray-700 transition-colors hover:bg-emerald-600/50"
475
+ title="Drag to resize"
476
+ />
477
+ )}
478
+ <div
479
+ className="flex min-h-0 shrink-0 flex-col bg-[#232a34] text-gray-200"
480
+ style={panelOpen ? { height: panelHeight } : undefined}
481
+ >
482
+ <div
483
+ onClick={() => setPanelOpen((o) => !o)}
484
+ title={panelOpen ? "Collapse controls" : "Expand controls"}
485
+ className="flex cursor-pointer select-none items-center gap-2 border-y border-gray-700 px-3 py-2 text-[11px] uppercase tracking-wider text-gray-400 hover:bg-white/5"
486
+ >
487
+ <span className="leading-none text-gray-300">{panelOpen ? "▾" : "▸"}</span>
488
+ <span>controls</span>
489
+ {current && <span className="text-gray-400">· {current.name}</span>}
490
+ <button
491
+ onClick={(e) => {
492
+ e.stopPropagation();
493
+ if (current) choose(current);
494
+ }}
495
+ className="ml-auto rounded border border-gray-700 px-2 py-0.5 text-[10px] normal-case text-gray-300 hover:bg-white/5"
496
+ >
497
+ reset
498
+ </button>
499
+ </div>
500
+ {panelOpen && (
501
+ <div className="min-h-0 flex-1 overflow-auto">
502
+ <table className="w-full border-collapse text-sm">
503
+ <thead>
504
+ <tr className="text-left text-[10px] uppercase tracking-wider text-gray-400">
505
+ <th className="w-44 border-b border-gray-700 px-4 py-2 font-semibold">Name</th>
506
+ <th className="border-b border-gray-700 px-4 py-2 font-semibold">Control</th>
507
+ </tr>
508
+ </thead>
509
+ <tbody>
510
+ {current &&
511
+ Object.entries(current.props ?? {})
512
+ // Render controls for exactly the node's inputs, as derived by
513
+ // the server (= the node's configSchema). The workbench makes no
514
+ // decision of its own — it renders what it's handed. (Defaults
515
+ // for ALL props are still seeded above, so the preview renders
516
+ // the hardcoded copy.) No `inputs` (untagged def) → show all.
517
+ .filter(([key]) => !current.inputs || current.inputs.includes(key))
518
+ .map(([key, def]) => (
519
+ <tr key={key} className="border-b border-gray-700/50 align-top hover:bg-white/[0.02]">
520
+ <td className="px-4 py-3">
521
+ <div className="font-medium text-gray-200">{key}</div>
522
+ <div className="text-[10px] uppercase tracking-wide text-gray-400">{def.type ?? "string"}</div>
523
+ </td>
524
+ <td className="px-4 py-3">
525
+ {def.type === "array" || def.type === "object" || key === "description" ? (
526
+ <textarea
527
+ value={text[key] ?? ""}
528
+ onChange={(e) => setField(key, e.target.value, def.type)}
529
+ rows={def.type === "array" || def.type === "object" ? 8 : 2}
530
+ spellCheck={false}
531
+ className="w-full max-w-2xl resize-y rounded-md border border-gray-700 bg-[#2c2c2e] px-3 py-2 font-mono text-xs text-gray-200 focus:border-emerald-500 focus:outline-none"
532
+ />
533
+ ) : (
534
+ <input
535
+ value={text[key] ?? ""}
536
+ onChange={(e) => setField(key, e.target.value, def.type)}
537
+ className="w-full max-w-2xl rounded-md border border-gray-700 bg-[#2c2c2e] px-3 py-2 text-gray-200 focus:border-emerald-500 focus:outline-none"
538
+ />
539
+ )}
540
+ </td>
541
+ </tr>
542
+ ))}
543
+ </tbody>
544
+ </table>
545
+ </div>
546
+ )}
547
+ </div>
548
+ </div>
549
+ </div>
550
+ );
551
+ }