@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.
@@ -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
- {atoms.map((a) => (
110
- <button
111
- key={a.name}
112
- onClick={() => choose(a)}
113
- className={`block w-full rounded px-2 py-1 text-left text-sm transition ${
114
- selected === a.name ? "bg-emerald-500/15 text-emerald-300" : "text-gray-300 hover:bg-white/5"
115
- }`}
116
- >
117
- {a.name}
118
- </button>
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 { StreamedUnoverseComponent, type ResolvedTheme } from "@unoverse/sdk";
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 non-defaultState Switch in the ACTIVE face's subtree.
158
- const subStates = ((): { on: string; name: string }[] => {
159
- if (!current?.root || !hasFaces || masterState === "inline") return [];
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?.[masterState]) { face = n.cases[masterState]; return; }
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
- {selected === c.name && (c.states?.length ?? 0) > 0 && (
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
- {hasFaces && (
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
- <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)} />}
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>