@unoverse-platform/studio 0.1.4 → 0.1.5
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/index.html +1 -0
- package/local/authoring.ts +101 -0
- package/local/catalog.ts +6 -0
- package/local/mcp.ts +260 -0
- package/local/plugin.ts +249 -7
- package/local/source.ts +230 -0
- package/package.json +15 -2
- package/public/favicon.svg +4 -0
- package/screens/AtomsView.tsx +42 -11
- package/screens/ComponentsView.tsx +175 -18
- package/screens/SourceView.tsx +784 -0
- package/screens/TemplatesView.tsx +16 -4
- package/screens/index.ts +3 -0
- package/screens/source/FileTree.tsx +131 -0
- package/screens/source/completion.ts +152 -0
- package/screens/source/highlight.ts +72 -0
- package/screens/source/inspect.ts +103 -0
- package/screens/source/picker.ts +89 -0
- package/src/host.ts +26 -1
- package/vendor/sdk/appEngine.tsx +1 -0
- package/vendor/sdk/lib/UnoverseComponent.tsx +101 -12
- package/vendor/sdk/lib/core/actions.ts +6 -0
- package/vendor/sdk/lib/core/client.ts +33 -6
- package/vendor/sdk/lib/primitives.tsx +33 -4
- package/vendor/sdk/lib/render.tsx +19 -4
- package/vendor/sdk/lib/streamed.tsx +12 -47
- package/vendor/sdk/lib/template.tsx +3 -3
package/screens/AtomsView.tsx
CHANGED
|
@@ -20,6 +20,29 @@ interface AtomDef {
|
|
|
20
20
|
description?: string;
|
|
21
21
|
whenToUse?: string;
|
|
22
22
|
props?: Record<string, PropDef>;
|
|
23
|
+
/** `markdown` marks a DOCUMENT component — one of the shapes a model composes Unoverse
|
|
24
|
+
* Markdown from. Everything else is interface furniture. The distinction is load-bearing
|
|
25
|
+
* rather than cosmetic: the category IS the menu the composer is derived from, so an
|
|
26
|
+
* atom's group here is exactly what a model can and cannot reach for. */
|
|
27
|
+
category?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The rail's groups, derived from the atoms themselves — a new category appears on its own
|
|
31
|
+
* rather than falling into "Interface" unnoticed. Document components lead: they are the
|
|
32
|
+
* set with a contract attached to it. */
|
|
33
|
+
const GROUP_LABEL: Record<string, string> = { markdown: "Unoverse Markdown" };
|
|
34
|
+
const UNGROUPED = "Interface";
|
|
35
|
+
|
|
36
|
+
function groupAtoms(atoms: AtomDef[]): { label: string; atoms: AtomDef[] }[] {
|
|
37
|
+
const by = new Map<string, AtomDef[]>();
|
|
38
|
+
for (const a of atoms) {
|
|
39
|
+
const key = a.category ?? UNGROUPED;
|
|
40
|
+
(by.get(key) ?? by.set(key, []).get(key)!).push(a);
|
|
41
|
+
}
|
|
42
|
+
const keys = [...by.keys()].sort((a, b) =>
|
|
43
|
+
a === "markdown" ? -1 : b === "markdown" ? 1 : a === UNGROUPED ? 1 : b === UNGROUPED ? -1 : a.localeCompare(b),
|
|
44
|
+
);
|
|
45
|
+
return keys.map((k) => ({ label: GROUP_LABEL[k] ?? k, atoms: by.get(k)! }));
|
|
23
46
|
}
|
|
24
47
|
|
|
25
48
|
const VIEWPORTS: { id: string; label: string; w: number | null }[] = [
|
|
@@ -80,6 +103,7 @@ export function AtomsView({ theme }: { theme: ResolvedTheme }) {
|
|
|
80
103
|
}, []);
|
|
81
104
|
|
|
82
105
|
const current = atoms.find((a) => a.name === selected);
|
|
106
|
+
const groups = useMemo(() => groupAtoms(atoms), [atoms]);
|
|
83
107
|
|
|
84
108
|
const setField = (key: string, raw: string, type?: string) => {
|
|
85
109
|
setText((t) => ({ ...t, [key]: raw }));
|
|
@@ -102,20 +126,27 @@ export function AtomsView({ theme }: { theme: ResolvedTheme }) {
|
|
|
102
126
|
<div className="grid min-h-0 flex-1 grid-cols-[190px_1fr] overflow-hidden">
|
|
103
127
|
{/* NAVIGATOR */}
|
|
104
128
|
<nav className="flex min-h-0 min-w-0 flex-col overflow-auto border-r border-gray-700 bg-[#1b2129] p-2">
|
|
105
|
-
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400">Atoms</div>
|
|
106
129
|
{atoms.length === 0 && (
|
|
107
130
|
<div className="p-2 text-xs text-gray-400">No atoms — restart the Unoverse server so /dev/atoms is available.</div>
|
|
108
131
|
)}
|
|
109
|
-
{
|
|
110
|
-
<
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
132
|
+
{groups.map((g) => (
|
|
133
|
+
<div key={g.label} className="mb-2">
|
|
134
|
+
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400">
|
|
135
|
+
{g.label}
|
|
136
|
+
<span className="ml-1.5 font-normal text-gray-500">{g.atoms.length}</span>
|
|
137
|
+
</div>
|
|
138
|
+
{g.atoms.map((a) => (
|
|
139
|
+
<button
|
|
140
|
+
key={a.name}
|
|
141
|
+
onClick={() => choose(a)}
|
|
142
|
+
className={`block w-full rounded px-2 py-1 text-left text-sm transition ${
|
|
143
|
+
selected === a.name ? "bg-emerald-500/15 text-emerald-300" : "text-gray-300 hover:bg-white/5"
|
|
144
|
+
}`}
|
|
145
|
+
>
|
|
146
|
+
{a.name}
|
|
147
|
+
</button>
|
|
148
|
+
))}
|
|
149
|
+
</div>
|
|
119
150
|
))}
|
|
120
151
|
</nav>
|
|
121
152
|
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
* by category. Top-right: the rendered component (canvas). Bottom: live controls
|
|
4
4
|
* docked under the canvas. Each control edit is a COMPONENT_DATA merge.
|
|
5
5
|
*/
|
|
6
|
-
import { useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
7
7
|
import { ComponentStore } from "@unoverse/sdk";
|
|
8
|
-
import {
|
|
9
|
-
import { client, authedFetch } from "studio-host";
|
|
8
|
+
import { UnoverseComponent, type ResolvedTheme } from "@unoverse/sdk";
|
|
9
|
+
import { client, authedFetch, hasSourceAccess } from "studio-host";
|
|
10
|
+
import { SourceView } from "./SourceView";
|
|
10
11
|
import { stateActivation } from "./states";
|
|
11
12
|
import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
|
|
12
13
|
|
|
@@ -40,6 +41,10 @@ interface ComponentDef {
|
|
|
40
41
|
states?: { name: string; when?: unknown }[];
|
|
41
42
|
/** The definition tree (used to resolve a state's Switch discriminant). */
|
|
42
43
|
root?: unknown;
|
|
44
|
+
/** The component's OWN state keys and their initial values. What the sub-state rail is
|
|
45
|
+
* allowed to list: a Switch in the tree turning on anything else belongs to an inlined
|
|
46
|
+
* atom or shared component, or to plain data. */
|
|
47
|
+
state?: Record<string, unknown>;
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
const VIEWPORTS: { id: string; label: string; w: number | null }[] = [
|
|
@@ -61,11 +66,36 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
|
|
|
61
66
|
const [panelOpen, setPanelOpen] = useState(false); // controls dock starts closed — most microapps have no props
|
|
62
67
|
const [panelHeight, setPanelHeight] = useState(240);
|
|
63
68
|
const [metaOpen, setMetaOpen] = usePersistentState<boolean>("wb:components:meta", false); // manifest meta panel
|
|
69
|
+
const [sourceOpen, setSourceOpen] = usePersistentState<boolean>("wb:components:source", false); // the source pane
|
|
70
|
+
// The editor takes the larger half by default: with it open, this is a code surface.
|
|
71
|
+
const [editorPct, setEditorPct] = usePersistentState<number>("wb:components:editorPct", 58);
|
|
72
|
+
// Bumped after a save so the preview re-reads the definition. A remount is the lever the
|
|
73
|
+
// SDK gives a host; the renderer is a dumb renderer and is not edited for this.
|
|
74
|
+
const [previewNonce, setPreviewNonce] = useState(0);
|
|
64
75
|
// Responsive preview — the stage width. Components are fluid (width: full), so
|
|
65
76
|
// the channel/slot sizes them; here we simulate that slot to test responsiveness.
|
|
66
77
|
const [stageWidth, setStageWidth] = useState<number | null>(768); // default Tablet — a single component reads best at a contained width
|
|
67
78
|
|
|
68
79
|
// Drag the divider to resize the controls dock (up = taller).
|
|
80
|
+
/** Drag the code/preview split. Percentages, so it survives a window resize. */
|
|
81
|
+
const onSplitDrag = (e: React.MouseEvent) => {
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
const host = (e.currentTarget as HTMLElement).parentElement;
|
|
84
|
+
if (!host) return;
|
|
85
|
+
const { left, width } = host.getBoundingClientRect();
|
|
86
|
+
const onMove = (ev: MouseEvent) =>
|
|
87
|
+
setEditorPct(Math.max(25, Math.min(80, ((ev.clientX - left) / width) * 100)));
|
|
88
|
+
const onUp = () => {
|
|
89
|
+
window.removeEventListener("mousemove", onMove);
|
|
90
|
+
window.removeEventListener("mouseup", onUp);
|
|
91
|
+
};
|
|
92
|
+
window.addEventListener("mousemove", onMove);
|
|
93
|
+
window.addEventListener("mouseup", onUp);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** After a save the host accepted: re-read the definition without reloading the page. */
|
|
97
|
+
const refreshPreview = useCallback(() => setPreviewNonce((n) => n + 1), []);
|
|
98
|
+
|
|
69
99
|
const onDragStart = (e: React.MouseEvent) => {
|
|
70
100
|
e.preventDefault();
|
|
71
101
|
const startY = e.clientY;
|
|
@@ -154,30 +184,96 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
|
|
|
154
184
|
// CONTEXTUAL SUB-STATES — a face's own internal steps (a Switch inside the face on a
|
|
155
185
|
// PRIVATE key, e.g. `step`: detail ⇄ book). They exist only within that face, so they
|
|
156
186
|
// show only while it's active — never as component-wide states. Fully derived: any
|
|
157
|
-
// named case of any
|
|
158
|
-
|
|
159
|
-
|
|
187
|
+
// named case of any Switch in the ACTIVE face's subtree that turns on ONE OF THE
|
|
188
|
+
// COMPONENT'S OWN STATE KEYS.
|
|
189
|
+
//
|
|
190
|
+
// That last clause is the whole rule. A face's subtree contains inlined atoms and
|
|
191
|
+
// shared components, and those carry Switches of their own — the Document switches on
|
|
192
|
+
// `tabbed`, on each section's `kind`, and on its own `tab`. Collecting every Switch
|
|
193
|
+
// listed True, Prose, Steps, KeyFacts, List, Faq, FinePrint, Table, Callout, 0, 1, 2, 3
|
|
194
|
+
// beside Detail and Apply, as if a product card had fourteen states. None of them are
|
|
195
|
+
// states of this component: they turn on DATA (`kind`) or on another component's slice
|
|
196
|
+
// (`tab`), and writing them into this slice does nothing. A component's states are the
|
|
197
|
+
// keys it declares in `state` — that is what the toolbar is allowed to write.
|
|
198
|
+
const subStatesOf = (faceName: string): { on: string; name: string }[] => {
|
|
199
|
+
if (!current?.root || !hasFaces || faceName === "inline") return [];
|
|
160
200
|
let face: unknown;
|
|
161
201
|
const findFace = (n: any): void => {
|
|
162
202
|
if (face || !n || typeof n !== "object") return;
|
|
163
203
|
if (Array.isArray(n)) { n.forEach(findFace); return; }
|
|
164
|
-
if (n.type === "Switch" && n.on === "defaultState" && n.cases?.[
|
|
204
|
+
if (n.type === "Switch" && n.on === "defaultState" && n.cases?.[faceName]) { face = n.cases[faceName]; return; }
|
|
165
205
|
Object.values(n).forEach(findFace);
|
|
166
206
|
};
|
|
167
207
|
findFace(current.root);
|
|
208
|
+
// The component's OWN state keys — everything else in the subtree belongs to an
|
|
209
|
+
// inlined atom or shared component, or is plain data.
|
|
210
|
+
const ownKeys = new Set(Object.keys((current.state ?? {}) as Record<string, unknown>));
|
|
168
211
|
const out: { on: string; name: string }[] = [];
|
|
169
212
|
const walk = (n: any): void => {
|
|
170
213
|
if (!n || typeof n !== "object") return;
|
|
171
214
|
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") {
|
|
215
|
+
if (n.type === "Switch" && typeof n.on === "string" && n.on !== "defaultState" && ownKeys.has(n.on) && n.cases && typeof n.cases === "object") {
|
|
173
216
|
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
217
|
}
|
|
175
218
|
Object.values(n).forEach(walk);
|
|
176
219
|
};
|
|
177
220
|
walk(face);
|
|
178
221
|
return out;
|
|
222
|
+
};
|
|
223
|
+
const subStates = subStatesOf(masterState);
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* OPEN A LAYOUT: put the preview into the view that layout draws.
|
|
227
|
+
*
|
|
228
|
+
* NOT EVERY LAYOUT IS A FACE. `layouts/course.yaml` is the `course` face, but
|
|
229
|
+
* `layouts/course-detail.yaml` is a STEP inside it: the face is a Switch on `step` whose
|
|
230
|
+
* `detail` case includes that file. Setting `defaultState` to "course-detail" matches no
|
|
231
|
+
* case at all, so the preview fell back to its default and the rail appeared to lie.
|
|
232
|
+
*
|
|
233
|
+
* The name carries the relationship (`<face>-<step>`), which is the same name-sync rule
|
|
234
|
+
* that makes a layout filename a state name in the first place. Longest face prefix wins,
|
|
235
|
+
* so a face called `course` never swallows one called `course-list`.
|
|
236
|
+
*/
|
|
237
|
+
/**
|
|
238
|
+
* THE VIEW ON THE PREVIEW, as the layout that draws it.
|
|
239
|
+
*
|
|
240
|
+
* The face alone is not the answer: inside `course` the `step` key decides whether
|
|
241
|
+
* `course-detail` or `course-apply` is drawn, and both are their own layout files. So
|
|
242
|
+
* this reads the face AND its active step, which is what lets the rail follow along when
|
|
243
|
+
* the component's own buttons change the view.
|
|
244
|
+
*/
|
|
245
|
+
const activeLayout = ((): string => {
|
|
246
|
+
if (!hasFaces || masterState === "inline") return masterState;
|
|
247
|
+
const subs = subStates;
|
|
248
|
+
if (!subs.length) return masterState;
|
|
249
|
+
const slice = selected ? (store.get(CHAT_ID, selected) as Record<string, unknown> | undefined) : undefined;
|
|
250
|
+
// No value yet means the face's FIRST named case, the same default the renderer takes.
|
|
251
|
+
const step = (slice?.[subs[0].on] as string) || subs[0].name;
|
|
252
|
+
return `${masterState}-${step}`;
|
|
179
253
|
})();
|
|
180
254
|
|
|
255
|
+
const openLayout = (name: string) => {
|
|
256
|
+
if (!selected) return;
|
|
257
|
+
if (faces.includes(name)) {
|
|
258
|
+
setMasterState(name);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const face = [...faces]
|
|
262
|
+
.filter((f) => f !== "inline")
|
|
263
|
+
.sort((a, b) => b.length - a.length)
|
|
264
|
+
.find((f) => name.startsWith(`${f}-`));
|
|
265
|
+
if (!face) {
|
|
266
|
+
setMasterState(name); // not a face and not a step of one: best effort
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const step = subStatesOf(face).find((s) => s.name === name.slice(face.length + 1));
|
|
270
|
+
setMasterState(face);
|
|
271
|
+
if (step) {
|
|
272
|
+
store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: selected, data: { [step.on]: step.name } });
|
|
273
|
+
setStoreTick((v) => v + 1);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
181
277
|
// COPY FOR CANVAS — writes the canvas's own clipboard payload (gravity-workflow,
|
|
182
278
|
// the same shape its Cmd+C emits), so Cmd+V on any canvas pastes this component's
|
|
183
279
|
// node. This is how design components reach a workflow now — the canvas palette
|
|
@@ -264,7 +360,9 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
|
|
|
264
360
|
STATE_MODEL §5b) are NOT in this list; they live in the toolbar because
|
|
265
361
|
every component has them. A stateless component is inline by default —
|
|
266
362
|
no states/ folder, no rail entry at all (inline needs no dedicated state). */}
|
|
267
|
-
{
|
|
363
|
+
{/* Hidden in dev mode: the file rail lists these as `states/` and
|
|
364
|
+
`layouts/`, and opening one is what puts the preview into it. */}
|
|
365
|
+
{selected === c.name && !sourceOpen && (c.states?.length ?? 0) > 0 && (
|
|
268
366
|
<div className="my-0.5 ml-2 flex flex-col gap-px border-l border-gray-700 pl-2">
|
|
269
367
|
{(c.states ?? []).map((s) => {
|
|
270
368
|
const data = stateActivation(c.root, s);
|
|
@@ -294,7 +392,7 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
|
|
|
294
392
|
Switch, e.g. focus's detail ⇄ book). Layouts on top, states down the
|
|
295
393
|
left: these list here ONLY while their face is active — they aren't
|
|
296
394
|
states of the other faces. First named case = the face's default. */}
|
|
297
|
-
{selected === c.name && subStates.length > 0 && (
|
|
395
|
+
{selected === c.name && !sourceOpen && subStates.length > 0 && (
|
|
298
396
|
<div className="my-0.5 ml-2 flex flex-col gap-px border-l border-gray-700 pl-2">
|
|
299
397
|
{subStates.map((s, i) => {
|
|
300
398
|
const cur = (store.get(CHAT_ID, c.name) as Record<string, unknown>)[s.on];
|
|
@@ -327,6 +425,34 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
|
|
|
327
425
|
{/* CANVAS */}
|
|
328
426
|
<section className="flex min-h-0 flex-1 flex-col" style={{ background: theme.color["surface.sunken"] ?? "#f3f4f6", color: theme.color["text.primary"] }}>
|
|
329
427
|
<div className="flex items-center gap-3 border-b px-4 py-2" style={{ borderColor: theme.color["border.subtle"] }}>
|
|
428
|
+
{/* CODE — FIRST, because it is the biggest change on this screen: it turns a
|
|
429
|
+
preview surface into a development one. It sat mid-row looking like a peer
|
|
430
|
+
of Meta, which is only a disclosure. Renders only where the host grants
|
|
431
|
+
source access, so Canvas never shows it (LOCAL_STUDIO.md §The capability
|
|
432
|
+
gate). The icon is a panel-split glyph rather than a `</>` typed out of
|
|
433
|
+
punctuation: it says WHERE the code goes, and it flips to show the state. */}
|
|
434
|
+
{hasSourceAccess && selected && (
|
|
435
|
+
<button
|
|
436
|
+
onClick={() => setSourceOpen((o) => !o)}
|
|
437
|
+
aria-pressed={sourceOpen}
|
|
438
|
+
className="flex items-center gap-1.5 rounded border px-2 py-1 text-[11px] font-medium transition"
|
|
439
|
+
style={{
|
|
440
|
+
borderColor: sourceOpen ? "transparent" : theme.color["border.subtle"],
|
|
441
|
+
color: sourceOpen ? theme.color["text.inverse"] : theme.color["text.secondary"],
|
|
442
|
+
background: sourceOpen ? theme.color["action.primary"] : "transparent",
|
|
443
|
+
}}
|
|
444
|
+
title={sourceOpen ? "Close the editor" : "Edit the source behind this component"}
|
|
445
|
+
>
|
|
446
|
+
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
447
|
+
<rect x="1" y="2.5" width="14" height="11" rx="2" stroke="currentColor" strokeWidth="1.3" />
|
|
448
|
+
{/* The filled half is the pane the code takes, so the glyph mirrors the
|
|
449
|
+
layout: editor left, preview right. */}
|
|
450
|
+
<path d="M1.6 3.2h5.4v9.6H1.6z" fill="currentColor" opacity={sourceOpen ? 0.9 : 0.35} />
|
|
451
|
+
</svg>
|
|
452
|
+
Code
|
|
453
|
+
</button>
|
|
454
|
+
)}
|
|
455
|
+
|
|
330
456
|
<span className="text-[11px] font-medium uppercase tracking-wider opacity-60">{current?.name ?? "rendered"}</span>
|
|
331
457
|
|
|
332
458
|
{/* The component's OWN states are picked in the LEFT RAIL; the two MASTER
|
|
@@ -334,7 +460,11 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
|
|
|
334
460
|
apart from the step states. NOT universal: only a component specifically
|
|
335
461
|
coded with the two faces (root Switch on defaultState) shows the control —
|
|
336
462
|
a flat component has one look and gets nothing. */}
|
|
337
|
-
{
|
|
463
|
+
{/* HIDDEN IN DEV MODE. With the editor open the file rail lists the same faces
|
|
464
|
+
as `layouts/`, in the same order, and opening one switches the preview. Two
|
|
465
|
+
controls for one choice is one too many, and the rail is the one that also
|
|
466
|
+
tells you which file you are reading. */}
|
|
467
|
+
{hasFaces && !sourceOpen && (
|
|
338
468
|
<div className="flex items-center gap-1 rounded p-0.5 text-[11px]" style={{ background: theme.color["border.subtle"] }}>
|
|
339
469
|
{faces.map((ms) => (
|
|
340
470
|
<button
|
|
@@ -456,13 +586,40 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
|
|
|
456
586
|
{/* Focus faces that fill + scroll internally never overflow this box; a
|
|
457
587
|
CONTENT-TALL focus face (a document component like PerfectDay) scrolls
|
|
458
588
|
the stage instead of being clipped. */}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
589
|
+
{/* CODE LEFT, RENDER RIGHT. With the editor open this is a development surface,
|
|
590
|
+
so the editor takes the dominant half and the component becomes the preview
|
|
591
|
+
beside it. Closed, the component has the stage to itself. */}
|
|
592
|
+
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden">
|
|
593
|
+
{sourceOpen && selected && (
|
|
594
|
+
<>
|
|
595
|
+
{/* min-w-0 and a basis rather than a width: a flex child sized by content
|
|
596
|
+
ignores its width and grows, which is what pushed the preview off. */}
|
|
597
|
+
<div className="flex min-h-0 min-w-0 shrink-0 flex-col" style={{ flexBasis: `${editorPct}%` }}>
|
|
598
|
+
<SourceView
|
|
599
|
+
uri={`unoverse://components/${selected.toLowerCase()}`}
|
|
600
|
+
accent={theme.color["action.primary"]}
|
|
601
|
+
layoutOrder={faces}
|
|
602
|
+
activeState={activeLayout}
|
|
603
|
+
onPickState={openLayout}
|
|
604
|
+
onSaved={refreshPreview}
|
|
605
|
+
/>
|
|
606
|
+
</div>
|
|
607
|
+
{/* Drag to rebalance. The same affordance the controls dock already uses. */}
|
|
608
|
+
<div
|
|
609
|
+
onMouseDown={onSplitDrag}
|
|
610
|
+
className="w-1.5 shrink-0 cursor-col-resize bg-gray-700 transition-colors hover:bg-emerald-600/50"
|
|
611
|
+
title="Drag to resize"
|
|
612
|
+
/>
|
|
613
|
+
</>
|
|
614
|
+
)}
|
|
615
|
+
<div className={isFocused ? "min-h-0 flex-1 overflow-auto" : "min-w-0 flex-1 overflow-auto p-10"}>
|
|
616
|
+
<div
|
|
617
|
+
key={`${masterState}:${previewNonce}`}
|
|
618
|
+
className={`mx-auto max-w-full ${isFocused ? "h-full" : "transition-[width] duration-200"}`}
|
|
619
|
+
style={{ width: sourceOpen ? "100%" : (stageWidth ?? "100%"), ...(isFocused ? { animation: "wb-focusFadeIn 0.2s ease-out" } : {}) }}
|
|
620
|
+
>
|
|
621
|
+
{selected && <UnoverseComponent client={client} store={store} chatId={CHAT_ID} nodeId={selected} theme={theme} onAction={(a) => setLastAction(typeof a === "string" ? a : a?.type ?? null)} />}
|
|
622
|
+
</div>
|
|
466
623
|
</div>
|
|
467
624
|
</div>
|
|
468
625
|
</section>
|