@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.
- package/bin/studio.mjs +124 -0
- package/index.html +12 -0
- package/local/catalog.ts +105 -0
- package/local/keys.ts +118 -0
- package/local/nodes.ts +77 -0
- package/local/plugin.ts +401 -0
- package/local/scaffold.d.mts +13 -0
- package/local/scaffold.mjs +85 -0
- package/package.json +43 -0
- package/public/logo.png +0 -0
- package/screens/AtomsView.tsx +206 -0
- package/screens/ComponentsView.tsx +551 -0
- package/screens/ConfigForm.tsx +217 -0
- package/screens/DesignSystemView.tsx +324 -0
- package/screens/IdentityHeader.tsx +88 -0
- package/screens/PromptBlocksView.tsx +137 -0
- package/screens/SkillsView.tsx +167 -0
- package/screens/TemplatesView.tsx +1005 -0
- package/screens/index.ts +26 -0
- package/screens/states.ts +44 -0
- package/src/App.tsx +252 -0
- package/src/ConnectUniverse.tsx +232 -0
- package/src/NewProject.tsx +89 -0
- package/src/NodeKeys.tsx +98 -0
- package/src/NodesView.tsx +443 -0
- package/src/PublishDialog.tsx +309 -0
- package/src/UniverseSession.tsx +121 -0
- package/src/host.ts +165 -0
- package/src/index.css +7 -0
- package/src/main.tsx +11 -0
- package/src/universes.ts +210 -0
- package/vendor/sdk/appEngine.tsx +219 -0
- package/vendor/sdk/lib/UnoverseComponent.tsx +120 -0
- package/vendor/sdk/lib/connection.tsx +302 -0
- package/vendor/sdk/lib/core/actions.ts +84 -0
- package/vendor/sdk/lib/core/client.ts +134 -0
- package/vendor/sdk/lib/core/conditions.ts +43 -0
- package/vendor/sdk/lib/core/connection.ts +142 -0
- package/vendor/sdk/lib/core/index.ts +32 -0
- package/vendor/sdk/lib/core/store.ts +455 -0
- package/vendor/sdk/lib/core/types.ts +194 -0
- package/vendor/sdk/lib/index.ts +59 -0
- package/vendor/sdk/lib/isolate.tsx +70 -0
- package/vendor/sdk/lib/primitives.tsx +453 -0
- package/vendor/sdk/lib/realtime/audioUtils.ts +40 -0
- package/vendor/sdk/lib/realtime/types.ts +45 -0
- package/vendor/sdk/lib/realtime/useAudioCapture.ts +237 -0
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +145 -0
- package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +164 -0
- package/vendor/sdk/lib/render.tsx +178 -0
- package/vendor/sdk/lib/streamed.tsx +65 -0
- package/vendor/sdk/lib/style.ts +227 -0
- package/vendor/sdk/lib/template.tsx +521 -0
- package/vendor/sdk/lib/theme.ts +55 -0
- package/vendor/sdk/lib/voice.tsx +311 -0
- package/vendor/sdk/main.tsx +96 -0
- 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
|
+
}
|