@unoverse-platform/studio 0.1.3 → 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/scaffold.mjs +14 -2
- 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/vendor/skills/unoverse-create/SKILL.md +88 -0
- package/vendor/skills/unoverse-create/references/agent-skill.md +65 -0
- package/vendor/skills/unoverse-create/references/block.md +43 -0
- package/vendor/skills/unoverse-create/references/component.md +152 -0
- package/vendor/skills/unoverse-create/references/node.md +175 -0
- package/vendor/skills/unoverse-create/references/template.md +115 -0
- package/vendor/skills/unoverse-create/references/workflow.md +52 -0
|
@@ -1,19 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* <UnoverseComponent> — reads a definition by URI from the Unoverse MCP server
|
|
3
|
-
* (via ./core) and renders it natively
|
|
3
|
+
* (via ./core) and renders it natively.
|
|
4
|
+
*
|
|
5
|
+
* ONE COMPONENT, TWO DATA SOURCES. A definition cannot tell which is rendering it, so the
|
|
6
|
+
* SDK stopped pretending they were different things:
|
|
7
|
+
*
|
|
8
|
+
* data source give it state lives in
|
|
9
|
+
* a plain object `uri` + `data` a local slice, here
|
|
10
|
+
* the live stream `store` + `chatId` + `nodeId` the store's slice
|
|
11
|
+
*
|
|
12
|
+
* The split used to be two exported components, and a host had to know which one it was
|
|
13
|
+
* holding. It never mattered to the component: the same `renderNode` walks the same
|
|
14
|
+
* definition, and `setValue` writes the same instance either way.
|
|
4
15
|
*/
|
|
5
|
-
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
|
6
|
-
import type
|
|
16
|
+
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type CSSProperties } from "react";
|
|
17
|
+
import { dispatchAction, type ComponentStore, type ResolvedTheme, type UnoverseClient, type UnoverseDefinition } from "./core";
|
|
7
18
|
import { renderNode, keyframesCss, type ActionHandler } from "./render";
|
|
8
19
|
import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
|
|
9
20
|
import { IsolatedRoot } from "./isolate";
|
|
10
21
|
|
|
22
|
+
/** Subscribe to an instance in the store, re-rendering when a merge lands. Snapshots THIS
|
|
23
|
+
* instance's slice (merge() gives it a fresh identity), so chunks for other components bail
|
|
24
|
+
* out in React instead of re-rendering everyone. Tolerates no store: the static path calls
|
|
25
|
+
* the same hook, because hooks cannot be conditional. */
|
|
26
|
+
export function useUnoverseInstance(store?: ComponentStore, chatId = "", nodeId = "") {
|
|
27
|
+
const subscribe = useCallback((cb: () => void) => (store ? store.subscribe(cb) : () => {}), [store]);
|
|
28
|
+
const data = useSyncExternalStore(
|
|
29
|
+
subscribe,
|
|
30
|
+
useCallback(() => (store ? store.get(chatId, nodeId) : undefined), [store, chatId, nodeId]),
|
|
31
|
+
);
|
|
32
|
+
const type = useSyncExternalStore(
|
|
33
|
+
subscribe,
|
|
34
|
+
useCallback(() => (store ? store.getType(chatId, nodeId) : undefined), [store, chatId, nodeId]),
|
|
35
|
+
);
|
|
36
|
+
return { type, data };
|
|
37
|
+
}
|
|
38
|
+
|
|
11
39
|
export interface UnoverseComponentProps {
|
|
12
40
|
client: UnoverseClient;
|
|
13
|
-
/** e.g. "unoverse://components/Card" */
|
|
14
|
-
uri
|
|
15
|
-
/** instance data bound into the definition
|
|
41
|
+
/** STATIC source: the definition to render, e.g. "unoverse://components/Card". */
|
|
42
|
+
uri?: string;
|
|
43
|
+
/** STATIC source: instance data bound into the definition. */
|
|
16
44
|
data?: Record<string, unknown>;
|
|
45
|
+
/** LIVE source: the merge-state store. With it, `uri` and `data` come from the instance. */
|
|
46
|
+
store?: ComponentStore;
|
|
47
|
+
chatId?: string;
|
|
48
|
+
nodeId?: string;
|
|
49
|
+
/** Neutral turn state merged into the data scope (e.g. `streaming`), so a component can
|
|
50
|
+
* reflect it. Live source only. */
|
|
51
|
+
extraData?: Record<string, unknown>;
|
|
17
52
|
onAction?: ActionHandler;
|
|
18
53
|
/**
|
|
19
54
|
* Resolved theme. Optional — if omitted, the SDK FETCHES it from the server
|
|
@@ -36,9 +71,20 @@ export interface UnoverseComponentProps {
|
|
|
36
71
|
fill?: boolean;
|
|
37
72
|
}
|
|
38
73
|
|
|
39
|
-
export function UnoverseComponent({ client, uri, data, onAction, theme, isolate = true, fill = false }: UnoverseComponentProps) {
|
|
74
|
+
export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, extraData, onAction, theme, isolate = true, fill = false }: UnoverseComponentProps) {
|
|
40
75
|
const [def, setDef] = useState<UnoverseDefinition | null>(null);
|
|
41
76
|
const [error, setError] = useState<string | null>(null);
|
|
77
|
+
// A component's OWN view state (a tab, a step, an expanded face) moves by writing its own
|
|
78
|
+
// slice. That write is not a host concern: a tab click should work wherever a component
|
|
79
|
+
// renders, without every surface wiring a handler for it. So the renderer keeps the slice
|
|
80
|
+
// and applies `setValue` to it, and STILL forwards every action to `onAction` for hosts
|
|
81
|
+
// that care (a channel persisting state, a workflow answering a submit).
|
|
82
|
+
const [localSlice, setLocalSlice] = useState<Record<string, unknown>>({});
|
|
83
|
+
// LIVE SOURCE. The hook runs either way (hooks cannot be conditional) and no-ops without a
|
|
84
|
+
// store, so one component serves both paths without a wrapper deciding for it.
|
|
85
|
+
const live = useUnoverseInstance(store, chatId, nodeId);
|
|
86
|
+
const resolvedUri = store ? (live.type ? `unoverse://components/${live.type}` : null) : (uri ?? null);
|
|
87
|
+
const sourceData = store ? (extraData ? { ...(live.data ?? {}), ...extraData } : live.data) : data;
|
|
42
88
|
// No theme passed → fetch it from the server (the SDK owns zero token values). The
|
|
43
89
|
// theme REF comes from the definition itself: a template's `org` names its client's
|
|
44
90
|
// theme (`<org>/light`); a universal component previews under the default set
|
|
@@ -53,14 +99,16 @@ export function UnoverseComponent({ client, uri, data, onAction, theme, isolate
|
|
|
53
99
|
let alive = true;
|
|
54
100
|
setDef(null);
|
|
55
101
|
setError(null);
|
|
102
|
+
setLocalSlice({});
|
|
103
|
+
if (!resolvedUri) return;
|
|
56
104
|
client
|
|
57
|
-
.readDefinition(
|
|
105
|
+
.readDefinition(resolvedUri)
|
|
58
106
|
.then((d) => alive && setDef(d))
|
|
59
107
|
.catch((e) => alive && setError(String(e?.message ?? e)));
|
|
60
108
|
return () => {
|
|
61
109
|
alive = false;
|
|
62
110
|
};
|
|
63
|
-
}, [client,
|
|
111
|
+
}, [client, resolvedUri]);
|
|
64
112
|
|
|
65
113
|
// Fill props the instance didn't provide from the definition's defaults; instance `data`
|
|
66
114
|
// overrides. This is what lets a partially-emitted component still render its declared
|
|
@@ -77,20 +125,61 @@ export function UnoverseComponent({ client, uri, data, onAction, theme, isolate
|
|
|
77
125
|
[def],
|
|
78
126
|
);
|
|
79
127
|
|
|
128
|
+
// ONE ACTION RULE, TWO OWNERS OF THE SLICE. `setValue` writes THIS instance's keys; where
|
|
129
|
+
// they land is the only difference between the two sources, and the component never knows.
|
|
130
|
+
const handleAction = useMemo<ActionHandler>(
|
|
131
|
+
() =>
|
|
132
|
+
store
|
|
133
|
+
? (action, scope) =>
|
|
134
|
+
dispatchAction(action, scope, {
|
|
135
|
+
store,
|
|
136
|
+
chatId: chatId ?? "",
|
|
137
|
+
nodeId: nodeId ?? "",
|
|
138
|
+
// Server actions carry the INSTANCE context (which component + its current
|
|
139
|
+
// slice) so the channel can forward the submitted state with the action.
|
|
140
|
+
sendToServer: onAction
|
|
141
|
+
? (t, payload) =>
|
|
142
|
+
onAction(t, payload, {
|
|
143
|
+
chatId,
|
|
144
|
+
nodeId,
|
|
145
|
+
type: live.type,
|
|
146
|
+
state: store.get(chatId ?? "", nodeId ?? ""),
|
|
147
|
+
})
|
|
148
|
+
: undefined,
|
|
149
|
+
})
|
|
150
|
+
: (action, payload, meta) => {
|
|
151
|
+
// No store, so the slice is local. This is what makes a tab work in a preview,
|
|
152
|
+
// a content panel or an email-shaped host with no state code in between.
|
|
153
|
+
if (action && typeof action === "object" && (action as { type?: string }).type === "setValue") {
|
|
154
|
+
const values = ((action as { values?: { key: string; value: unknown }[] }).values ?? []).filter(
|
|
155
|
+
(v) => v && typeof v.key === "string",
|
|
156
|
+
);
|
|
157
|
+
if (values.length)
|
|
158
|
+
setLocalSlice((prev) => ({ ...prev, ...Object.fromEntries(values.map((v) => [v.key, v.value])) }));
|
|
159
|
+
}
|
|
160
|
+
onAction?.(action, payload, meta);
|
|
161
|
+
},
|
|
162
|
+
[store, chatId, nodeId, onAction, live.type],
|
|
163
|
+
);
|
|
164
|
+
|
|
80
165
|
// Loading/error hints carry NO styling — the SDK owns zero styles (tokens or hints).
|
|
81
166
|
if (error) return <div>Unoverse error: {error}</div>;
|
|
82
|
-
if (
|
|
167
|
+
if (store && !live.type) return <div>waiting for COMPONENT_INIT…</div>;
|
|
168
|
+
if (!def || !activeTheme) return <div>Loading {resolvedUri ?? "component"}…</div>;
|
|
83
169
|
// Internal state initial values (def.state) sit beneath the live slice — the
|
|
84
170
|
// microapp's own keys (step/phase/…) start at their authored values and move only
|
|
85
171
|
// when its actions write them. Props stay the EXTERNAL contract.
|
|
86
|
-
|
|
172
|
+
// The local slice sits ON TOP: a value the component wrote itself is the current one.
|
|
173
|
+
const merged = store
|
|
174
|
+
? { ...propDefaults, ...(def?.state ?? {}), ...(sourceData ?? {}) }
|
|
175
|
+
: { ...propDefaults, ...(def?.state ?? {}), ...(sourceData ?? {}), ...localSlice };
|
|
87
176
|
// Inject the served keyframes + apply the SERVED base render-root (theme.root — DS typography
|
|
88
177
|
// + font-smoothing). When ISOLATED, theme.root is applied to the shadow-root container inside
|
|
89
178
|
// IsolatedRoot (a REAL box — reliable font cascade in the shadow tree). When NOT isolated the
|
|
90
179
|
// host owns isolation, so apply it here on a `display:contents` wrapper (out of layout while
|
|
91
180
|
// its inherited props cascade) — the design-system baseline either way.
|
|
92
181
|
const keyframes = <style>{keyframesCss(activeTheme)}</style>;
|
|
93
|
-
const tree = renderNode(def.root, merged,
|
|
182
|
+
const tree = renderNode(def.root, merged, handleAction, activeTheme);
|
|
94
183
|
// The isolation container MIRRORS the component root's declared sizing intent (§4a:
|
|
95
184
|
// the component owns its size; the host responds). A root that declares
|
|
96
185
|
// `height: "full"` needs a definite chain — the container passes the host's height
|
|
@@ -53,6 +53,12 @@ export function dispatchAction(
|
|
|
53
53
|
// Structure-bumping write (not the deferred COMPONENT_DATA path): templates re-resolve
|
|
54
54
|
// their `select.where` slots the moment a component changes its own state (§5b).
|
|
55
55
|
ctx.store.mergeComponentState(ctx.chatId, ctx.nodeId, patch);
|
|
56
|
+
// A CHANGED LAYOUT IS NEWS. `defaultState` is the view a component is showing, so a
|
|
57
|
+
// write to it means this instance just entered a different face — the moment an
|
|
58
|
+
// `onEnterView` hook exists for. Reported on the channel path every other server
|
|
59
|
+
// action already uses; the server decides whether any hook cares. Nothing to author:
|
|
60
|
+
// the setValue already carries the fact, so no component repeats it in a `then`.
|
|
61
|
+
if (ctx.nodeId && "defaultState" in patch) ctx.sendToServer?.("viewEntered", { layout: patch.defaultState });
|
|
56
62
|
} else if (spec.type === "setTemplateValue") {
|
|
57
63
|
// Write the dev's chosen keys into TEMPLATE state — the template-state twin of setValue.
|
|
58
64
|
// No component scope. The dev builds disclosure / focus / etc. from a key THEY name
|
|
@@ -79,18 +79,46 @@ export class UnoverseClient {
|
|
|
79
79
|
return this.connecting;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A SERVER RESTART kills every session it was holding, and the client cannot know: it
|
|
85
|
+
* keeps a live-looking transport whose session id the server has never heard of, so every
|
|
86
|
+
* later call returns "No valid session" until the page is reloaded. One dropped session
|
|
87
|
+
* should not require a reload of every open tab.
|
|
88
|
+
*
|
|
89
|
+
* So a call that fails that way reconnects ONCE and runs again. Anything else rethrows,
|
|
90
|
+
* because a retry loop around a real error hides it.
|
|
91
|
+
*/
|
|
92
|
+
private async withSession<T>(run: () => Promise<T>): Promise<T> {
|
|
93
|
+
await this.connect();
|
|
94
|
+
try {
|
|
95
|
+
return await run();
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (!/No valid session|session not found/i.test(String((err as Error)?.message ?? err))) throw err;
|
|
98
|
+
this.connected = false;
|
|
99
|
+
this.connecting = null;
|
|
100
|
+
// The SAME construction as the constructor, validator included: that validator is
|
|
101
|
+
// deliberate (a CSP that forbids `new Function` breaks the default one), and a
|
|
102
|
+
// reconnect that quietly dropped it would fail only on the strictest host.
|
|
103
|
+
this.client = new Client(
|
|
104
|
+
{ name: "unoverse-core", version: "0.0.1" },
|
|
105
|
+
{ jsonSchemaValidator: new CfWorkerJsonSchemaValidator() },
|
|
106
|
+
);
|
|
107
|
+
await this.connect();
|
|
108
|
+
return run();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
82
112
|
/** Generic tools/call passthrough (e.g. `user_action` component submissions). */
|
|
83
113
|
async callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
84
|
-
|
|
85
|
-
return this.client.callTool({ name, arguments: args });
|
|
114
|
+
return this.withSession(() => this.client.callTool({ name, arguments: args }));
|
|
86
115
|
}
|
|
87
116
|
|
|
88
117
|
/** resources/read a definition by URI (e.g. unoverse://components/Card). Cached. */
|
|
89
118
|
async readDefinition(uri: string): Promise<UnoverseDefinition> {
|
|
90
119
|
const cached = this.cache.get(uri);
|
|
91
120
|
if (cached) return cached;
|
|
92
|
-
await this.
|
|
93
|
-
const res = await this.client.readResource({ uri });
|
|
121
|
+
const res = await this.withSession(() => this.client.readResource({ uri }));
|
|
94
122
|
const first = res.contents[0] as { text?: string };
|
|
95
123
|
if (!first?.text) throw new Error(`No definition content for ${uri}`);
|
|
96
124
|
const def = JSON.parse(first.text) as UnoverseDefinition;
|
|
@@ -105,8 +133,7 @@ export class UnoverseClient {
|
|
|
105
133
|
async readTheme(name = "light"): Promise<ResolvedTheme> {
|
|
106
134
|
const cached = this.themeCache.get(name);
|
|
107
135
|
if (cached) return cached;
|
|
108
|
-
await this.
|
|
109
|
-
const res = await this.client.readResource({ uri: `unoverse://theme/${name}` });
|
|
136
|
+
const res = await this.withSession(() => this.client.readResource({ uri: `unoverse://theme/${name}` }));
|
|
110
137
|
const first = res.contents[0] as { text?: string };
|
|
111
138
|
if (!first?.text) throw new Error(`No theme content for ${name}`);
|
|
112
139
|
const theme = JSON.parse(first.text) as ResolvedTheme;
|
|
@@ -112,12 +112,30 @@ function nodeChrome(node: UnoverseNode, data: Record<string, unknown>, theme: Re
|
|
|
112
112
|
|
|
113
113
|
/** `Box`/`Stack`/`Row`/`Column` — the generic container (`<div>`). Children are
|
|
114
114
|
* pre-rendered by the dispatcher; layout/look come entirely from node.style. */
|
|
115
|
+
/**
|
|
116
|
+
* INSPECTABILITY: a node carrying `$id` stamps it on the element it renders.
|
|
117
|
+
*
|
|
118
|
+
* Nothing in the SDK reads it back and nothing looks different without it. It exists so a
|
|
119
|
+
* TOOL can ask "which element did this node produce" — an authoring surface boxing the node
|
|
120
|
+
* under the cursor, for instance. The renderer is the only place that can answer, because
|
|
121
|
+
* `Switch`, `Each` and `visibleWhen` mean the rendered tree is not the definition tree, so
|
|
122
|
+
* the link is made while rendering or not at all.
|
|
123
|
+
*
|
|
124
|
+
* `$id` is assigned by whoever supplies the definition, never by an author. This is a
|
|
125
|
+
* generic behaviour, not a style and not a UX shape: no primitive is added, no layout
|
|
126
|
+
* decision is taken, and a definition without `$id` renders byte-identically.
|
|
127
|
+
*/
|
|
128
|
+
const inspectId = (node: UnoverseNode): string | undefined => {
|
|
129
|
+
const id = (node as { $id?: unknown }).$id;
|
|
130
|
+
return typeof id === "string" ? id : undefined;
|
|
131
|
+
};
|
|
132
|
+
|
|
115
133
|
export function BoxView({ node, data, theme, children }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; children?: ReactNode }): ReactNode {
|
|
116
134
|
const { className, styleEls, style } = nodeChrome(node, data, theme);
|
|
117
135
|
const scrollRef = useAutoScrollToEnd(node.autoScroll === true);
|
|
118
136
|
const revealRef = useRevealWhenStuck(node.revealOnStick === true);
|
|
119
137
|
return (
|
|
120
|
-
<div ref={scrollRef ?? revealRef} className={className} style={style}>
|
|
138
|
+
<div ref={scrollRef ?? revealRef} className={className} style={style} data-unoverse-id={inspectId(node)}>
|
|
121
139
|
{styleEls}
|
|
122
140
|
{children}
|
|
123
141
|
</div>
|
|
@@ -203,12 +221,12 @@ function useRevealWhenStuck(enabled: boolean) {
|
|
|
203
221
|
export function TextView({ node, data, theme }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme }): ReactNode {
|
|
204
222
|
// Literal `value` fallback: components are microapps — static copy is HARDCODED in the
|
|
205
223
|
// def, props exist only for state (UNOVERSE_AUTHORING.md). bind wins when present.
|
|
206
|
-
return <span style={styleToCss(node.style, theme, data)}>{bound(node.bind?.value, data) ?? (node.value as ReactNode)}</span>;
|
|
224
|
+
return <span style={styleToCss(node.style, theme, data)} data-unoverse-id={inspectId(node)}>{bound(node.bind?.value, data) ?? (node.value as ReactNode)}</span>;
|
|
207
225
|
}
|
|
208
226
|
|
|
209
227
|
/** `Image` — a bound OR literal src (`<img>`). */
|
|
210
228
|
export function ImageView({ node, data, theme }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme }): ReactNode {
|
|
211
|
-
return <img style={styleToCss(node.style, theme, data)} src={bound(node.bind?.src, data) ?? node.src} alt={bound(node.bind?.alt, data) ?? node.alt ?? ""} />;
|
|
229
|
+
return <img data-unoverse-id={inspectId(node)} style={styleToCss(node.style, theme, data)} src={bound(node.bind?.src, data) ?? node.src} alt={bound(node.bind?.alt, data) ?? node.alt ?? ""} />;
|
|
212
230
|
}
|
|
213
231
|
|
|
214
232
|
/** `Button` — fires `onAction(node.action, data)`. Content is a bound label OR composed
|
|
@@ -216,7 +234,7 @@ export function ImageView({ node, data, theme }: { node: UnoverseNode; data: Rec
|
|
|
216
234
|
export function ButtonView({ node, data, theme, onAction, children }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; onAction?: ActionHandler; children?: ReactNode }): ReactNode {
|
|
217
235
|
const { className, styleEls, style, disabled } = nodeChrome(node, data, theme);
|
|
218
236
|
return (
|
|
219
|
-
<button className={className} style={style} disabled={disabled} onClick={() => onAction?.(node.action ?? "click", data)}>
|
|
237
|
+
<button className={className} style={style} disabled={disabled} data-unoverse-id={inspectId(node)} onClick={() => onAction?.(node.action ?? "click", data)}>
|
|
220
238
|
{styleEls}
|
|
221
239
|
{node.children?.length ? children : bound(node.bind?.label, data)}
|
|
222
240
|
</button>
|
|
@@ -418,6 +436,17 @@ function buildMarkdownOverrides(theme: ResolvedTheme, inheritLinkColor = false)
|
|
|
418
436
|
a: { component: ExternalLink },
|
|
419
437
|
table: { component: ScrollableTable },
|
|
420
438
|
img: { component: MarkdownImage },
|
|
439
|
+
// INLINE MARKS and BLOCKS. Authored copy uses all of these (a bolded access code, a
|
|
440
|
+
// rule between sections, a quoted term), and an element absent from this map renders
|
|
441
|
+
// at the browser's default — the one place a definition's look escapes the token
|
|
442
|
+
// layer. Each maps to a served recipe key; the VALUES live in rx/styles/semantic/prose.
|
|
443
|
+
strong: { props: { style: s("strong") } },
|
|
444
|
+
em: { props: { style: s("emphasis") } },
|
|
445
|
+
del: { props: { style: s("strikethrough") } },
|
|
446
|
+
code: { props: { style: s("code") } },
|
|
447
|
+
pre: { props: { style: s("codeBlock") } },
|
|
448
|
+
blockquote: { props: { style: s("blockquote") } },
|
|
449
|
+
hr: { props: { style: s("rule") } },
|
|
421
450
|
th: { props: { style: { ...s("cell"), ...s("headerCell") } } },
|
|
422
451
|
td: { props: { style: s("cell") } },
|
|
423
452
|
ul: { props: { style: { ...s("list"), listStyleType: p.bulletStyle as string } } },
|
|
@@ -50,6 +50,8 @@ export { styleToCss, keyframesCss } from "./style";
|
|
|
50
50
|
export interface ActionMeta {
|
|
51
51
|
chatId?: string;
|
|
52
52
|
nodeId?: string;
|
|
53
|
+
/** Which component this instance is — so the server needs no registry to find out. */
|
|
54
|
+
type?: string;
|
|
53
55
|
state?: Record<string, unknown>;
|
|
54
56
|
}
|
|
55
57
|
|
|
@@ -111,7 +113,12 @@ export function renderNode(
|
|
|
111
113
|
|
|
112
114
|
// Store-backed / leaf primitives → dispatch to ./primitives.
|
|
113
115
|
if (node.type === "Skeleton") return <SkeletonView key={key} variant={node.variant} theme={theme} />;
|
|
114
|
-
|
|
116
|
+
// Icon: DATA path reads `bind.name`; LITERAL path reads the `icon` attribute. `name` is
|
|
117
|
+
// read as a literal too, because an atom that binds `name` (the only spelling the data
|
|
118
|
+
// path reads) has that bind turned INTO a `name` attribute when a Ref supplies the glyph
|
|
119
|
+
// through `with` — so without this an atom can be data-driven or literal, never both.
|
|
120
|
+
if (node.type === "Icon")
|
|
121
|
+
return <IconView key={key} name={node.bind?.name ? (data[node.bind.name] as string) : (node.icon ?? node.name)} style={node.style} theme={theme} />;
|
|
115
122
|
if (node.type === "Input") return <InputView key={key} node={node} data={data} theme={theme} onAction={onAction} />;
|
|
116
123
|
if (node.type === "Markdown") return <MarkdownView key={key} node={node} data={data} theme={theme} />;
|
|
117
124
|
if (node.type === "ComponentSlot" || node.type === "Timeline") return slot ? slot(node, key) : null;
|
|
@@ -136,9 +143,17 @@ export function renderNode(
|
|
|
136
143
|
const src = node.bind?.items ? data[node.bind.items] : node.items;
|
|
137
144
|
const items = Array.isArray(src) ? src : [];
|
|
138
145
|
const tpl = node.template;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
146
|
+
// WHERE AN ITEM SITS is something only the renderer knows, and a numbered list cannot
|
|
147
|
+
// be authored without it: the data carries no ordinal, and a template cannot count.
|
|
148
|
+
// `index` is 0-based for logic, `position` 1-based because that is what a reader sees.
|
|
149
|
+
// The item's own fields always win, so data that carries either name is untouched.
|
|
150
|
+
const scopeOf = (item: unknown, i: number): Record<string, unknown> => {
|
|
151
|
+
const base = { index: i, position: i + 1 };
|
|
152
|
+
return item != null && typeof item === "object"
|
|
153
|
+
? { ...base, ...(item as Record<string, unknown>) }
|
|
154
|
+
: { ...base, value: item };
|
|
155
|
+
};
|
|
156
|
+
const rendered = tpl ? items.map((item, i) => renderNode(tpl, scopeOf(item, i), onAction, theme, i, slot)) : null;
|
|
142
157
|
return (
|
|
143
158
|
<BoxView key={key} node={node} data={data} theme={theme}>
|
|
144
159
|
{rendered}
|
|
@@ -1,27 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* DEPRECATED ALIAS. `UnoverseComponent` now renders from either a plain `data` object or a
|
|
3
|
+
* live store, so the streamed variant is the same component with the store props filled in.
|
|
4
|
+
*
|
|
5
|
+
* Kept so existing channels keep working while their call sites move over. Nothing here
|
|
6
|
+
* decides anything: it forwards.
|
|
4
7
|
*/
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { UnoverseComponent } from "./UnoverseComponent";
|
|
8
|
+
import { UnoverseComponent, useUnoverseInstance } from "./UnoverseComponent";
|
|
9
|
+
import type { ComponentStore, ResolvedTheme, UnoverseClient } from "./core";
|
|
8
10
|
import type { ActionHandler } from "./render";
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
* Snapshots THIS instance's slice (merge() gives it a fresh identity per merge),
|
|
12
|
-
* so chunks for other components bail out in React instead of re-rendering everyone. */
|
|
13
|
-
export function useUnoverseInstance(store: ComponentStore, chatId: string, nodeId: string) {
|
|
14
|
-
const subscribe = useCallback((cb: () => void) => store.subscribe(cb), [store]);
|
|
15
|
-
const data = useSyncExternalStore(
|
|
16
|
-
subscribe,
|
|
17
|
-
useCallback(() => store.get(chatId, nodeId), [store, chatId, nodeId]),
|
|
18
|
-
);
|
|
19
|
-
const type = useSyncExternalStore(
|
|
20
|
-
subscribe,
|
|
21
|
-
useCallback(() => store.getType(chatId, nodeId), [store, chatId, nodeId]),
|
|
22
|
-
);
|
|
23
|
-
return { type, data };
|
|
24
|
-
}
|
|
12
|
+
export { useUnoverseInstance };
|
|
25
13
|
|
|
26
14
|
export interface StreamedUnoverseComponentProps {
|
|
27
15
|
client: UnoverseClient;
|
|
@@ -30,36 +18,13 @@ export interface StreamedUnoverseComponentProps {
|
|
|
30
18
|
nodeId: string;
|
|
31
19
|
onAction?: ActionHandler;
|
|
32
20
|
theme?: ResolvedTheme;
|
|
33
|
-
/** Neutral turn state merged into the component's data scope (e.g. `streaming`)
|
|
34
|
-
* component can reflect it — like legacy passing `streamingState` to StreamingText. */
|
|
21
|
+
/** Neutral turn state merged into the component's data scope (e.g. `streaming`). */
|
|
35
22
|
extraData?: Record<string, unknown>;
|
|
36
23
|
/** A surface's single occupant fills the surface — see UnoverseComponent.fill. */
|
|
37
24
|
fill?: boolean;
|
|
38
25
|
}
|
|
39
26
|
|
|
40
|
-
/**
|
|
41
|
-
export function StreamedUnoverseComponent(
|
|
42
|
-
|
|
43
|
-
// Interpret the action envelope (§2e-3): `setValue` writes THIS instance's slice
|
|
44
|
-
// locally (the ported `updateData`); any other type routes to the channel handler
|
|
45
|
-
// (the workflow). `then` chains. The component never sees the store directly.
|
|
46
|
-
const handleAction = useMemo<ActionHandler>(
|
|
47
|
-
() => (action, scope) =>
|
|
48
|
-
dispatchAction(action, scope, {
|
|
49
|
-
store,
|
|
50
|
-
chatId,
|
|
51
|
-
nodeId,
|
|
52
|
-
// Server actions carry the INSTANCE context (which component + its current slice)
|
|
53
|
-
// so the channel can forward the submitted state — e.g. a wizard's collected
|
|
54
|
-
// answers — with the action. Transport metadata only; nothing is interpreted here.
|
|
55
|
-
sendToServer: onAction ? (t, payload) => onAction(t, payload, { chatId, nodeId, state: store.get(chatId, nodeId) }) : undefined,
|
|
56
|
-
}),
|
|
57
|
-
[store, chatId, nodeId, onAction],
|
|
58
|
-
);
|
|
59
|
-
if (!type) return <div>waiting for COMPONENT_INIT…</div>;
|
|
60
|
-
// No focus/displayState injection: a component's inline/focused look is its OWN state,
|
|
61
|
-
// written via the `setValue` action into this slice and read via `visibleWhen` — the SDK
|
|
62
|
-
// hardcodes no focus concept (UNOVERSE_STATE_MODEL §2). It's just another data field.
|
|
63
|
-
const merged = extraData ? { ...data, ...extraData } : data;
|
|
64
|
-
return <UnoverseComponent client={client} uri={`unoverse://components/${type}`} data={merged} onAction={handleAction} theme={theme} fill={fill} />;
|
|
27
|
+
/** @deprecated Render `UnoverseComponent` with `store`, `chatId` and `nodeId`. */
|
|
28
|
+
export function StreamedUnoverseComponent(props: StreamedUnoverseComponentProps) {
|
|
29
|
+
return <UnoverseComponent {...props} />;
|
|
65
30
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* - reads the template recipe by URI (resources/read, via the client),
|
|
8
8
|
* - subscribes to the store so it re-renders as components stream in,
|
|
9
9
|
* - resolves each ComponentSlot against the timeline and renders the matched
|
|
10
|
-
* leaves with the SAME
|
|
10
|
+
* leaves with the SAME UnoverseComponent used everywhere else (store-backed).
|
|
11
11
|
*
|
|
12
12
|
* This slice handles ComponentSlot + Skeleton (Tier-1, e.g. KeyService). The
|
|
13
13
|
* chat primitives (Conversation/Input/…) come in later slices.
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";
|
|
16
16
|
import { dispatchAction, type ComponentStore, type ResolvedTheme, type UnoverseClient, type UnoverseDefinition, type UnoverseNode } from "./core";
|
|
17
17
|
import { renderNode, styleToCss, keyframesCss, cssWidth, type ActionHandler, type SlotResolver } from "./render";
|
|
18
|
-
import {
|
|
18
|
+
import { UnoverseComponent } from "./UnoverseComponent";
|
|
19
19
|
import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
|
|
20
20
|
import { IsolatedRoot } from "./isolate";
|
|
21
21
|
import { useVoiceService, type UseVoiceServiceConfig } from "./voice";
|
|
@@ -351,7 +351,7 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
|
|
|
351
351
|
|
|
352
352
|
const leaf = (pointer: string, extraData?: Record<string, unknown>, fill?: boolean) => {
|
|
353
353
|
const { chatId, nodeId } = store.split(pointer);
|
|
354
|
-
return <
|
|
354
|
+
return <UnoverseComponent key={pointer} client={client} store={store} chatId={chatId} nodeId={nodeId} onAction={dispatch} theme={theme} extraData={extraData} fill={fill} />;
|
|
355
355
|
};
|
|
356
356
|
|
|
357
357
|
// THE ACTIVE STATE (docs/design/04): a template is in exactly ONE state at a time —
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: unoverse-create
|
|
3
|
+
description: Create or edit Unoverse platform artifacts — UI components (including briefed/AI-filled components), templates, atoms and styles (rx/), agent skills and prompt blocks (prompts/), custom workflow nodes (nodes/), and workflows built live on the Canvas via the builder MCP. Use when the user wants to create, add, build, or modify a component, template, theme, agent skill, prompt block, custom node, or workflow in an Unoverse starter repo.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Creating Unoverse Artifacts
|
|
7
|
+
|
|
8
|
+
You are helping a developer build in the **Unoverse developer carve-out** — the three
|
|
9
|
+
editable folders mounted into the running platform:
|
|
10
|
+
|
|
11
|
+
| Folder | What lives there |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `apps/unoverse/rx/` | **Design** (data, not code): components, atoms, org-scoped templates + styles |
|
|
14
|
+
| `apps/unoverse/prompts/` | **Behavior**: agent skills (`skills/`) + prompt blocks (`blocks/`) |
|
|
15
|
+
| `apps/unoverse/nodes/` | **Logic**: custom workflow nodes, authored as YAML |
|
|
16
|
+
|
|
17
|
+
## Step 1 — Identify the artifact, read its playbook
|
|
18
|
+
|
|
19
|
+
Decide what the user is creating, then **read the matching reference file before writing
|
|
20
|
+
anything**:
|
|
21
|
+
|
|
22
|
+
| The user wants… | Read |
|
|
23
|
+
|---|---|
|
|
24
|
+
| A UI component or shared atom — incl. a page/card the AI fills with content (briefed) | `references/component.md` (§Briefs for AI-filled) |
|
|
25
|
+
| A template / app layout (chat surface, wizard shell) | `references/template.md` |
|
|
26
|
+
| A style / theme / token change | `references/component.md` §Tokens |
|
|
27
|
+
| An agent skill (behavior guide the AI follows) | `references/agent-skill.md` |
|
|
28
|
+
| A prompt block (reusable prompt fragment) | `references/block.md` |
|
|
29
|
+
| A custom workflow node (integration, tool, logic) | `references/node.md` |
|
|
30
|
+
| A workflow (wire nodes into an agent/pipeline on the Canvas) | `references/workflow.md` |
|
|
31
|
+
|
|
32
|
+
Workflows are the one artifact that is NOT files: they are built live on the running
|
|
33
|
+
platform through the `canvas` MCP (registered by this repo's `.mcp.json`) —
|
|
34
|
+
the playbook covers the bind/build/test contract.
|
|
35
|
+
|
|
36
|
+
If it's ambiguous (e.g. "add a card that shows weather"), it's usually a **component**
|
|
37
|
+
(the UI) plus possibly a **node** (the data source) — confirm the scope with the user.
|
|
38
|
+
|
|
39
|
+
**New to design artifacts?** The guided learning journey at `docs/design/` (README →
|
|
40
|
+
quick-start → … → troubleshooting) teaches components, templates, the state model
|
|
41
|
+
(incl. locked voice/native state), tokens, Studio, and validation in order;
|
|
42
|
+
`docs/design/CLAUDE.md` is the condensed agent rulebook. The playbooks here assume that
|
|
43
|
+
material.
|
|
44
|
+
|
|
45
|
+
## Step 2 — Hard rules (apply to every artifact)
|
|
46
|
+
|
|
47
|
+
1. **Only edit the three carve-out folders.** Never touch, vendor, or work around the
|
|
48
|
+
SDK, engine, or server — they are not yours to change. If a task seems to require an
|
|
49
|
+
SDK change, the answer is almost always "express it as data" — re-read the playbook.
|
|
50
|
+
2. **Docs are the source of truth.** The playbooks name the design journey and the deep
|
|
51
|
+
references ([UNOVERSE_AUTHORING](https://github.com/unoverse-platform/docs/blob/main/unoverse/UNOVERSE_AUTHORING.md), [UNOVERSE_STATE_MODEL](https://github.com/unoverse-platform/docs/blob/main/unoverse/UNOVERSE_STATE_MODEL.md), [UNOVERSE_LAYERS](https://github.com/unoverse-platform/docs/blob/main/unoverse/UNOVERSE_LAYERS.md),
|
|
52
|
+
[UNOVERSE_CONFORMANCE](https://github.com/unoverse-platform/docs/blob/main/unoverse/UNOVERSE_CONFORMANCE.md)). They live in the docs repo
|
|
53
|
+
(https://github.com/unoverse-platform/docs), not in your repo. When the user's request goes beyond the playbook, read the named section rather
|
|
54
|
+
than improvising conventions.
|
|
55
|
+
3. **UI is data.** No pixels, hex colors, or CSS anywhere — token names only. No logic
|
|
56
|
+
in definitions — anything computed (totals, formatting, chosen colors) is computed in
|
|
57
|
+
the node and sent as a plain field.
|
|
58
|
+
4. **Audit before you're done.** Every component/template must pass the conformance
|
|
59
|
+
checklist ([UNOVERSE_AUTHORING](https://github.com/unoverse-platform/docs/blob/main/unoverse/UNOVERSE_AUTHORING.md) §9). Walk it item by item.
|
|
60
|
+
5. **Discovery meta is ranked, not read.** Any spatially-discoverable artifact — a
|
|
61
|
+
component or template with a manifest, an agent skill, a node — is selected by
|
|
62
|
+
embedding `title`/`name` + `whenToUse`/`description` + `category` against the user's
|
|
63
|
+
own words (or the planner's task). The strict contract is
|
|
64
|
+
`docs/nodes/node-discoverability.md`: outcome-first, utterance-shaped, opening
|
|
65
|
+
words dominate, disqualify by property (never name a sibling). Each playbook carries
|
|
66
|
+
the summary; read doc 14 before writing any meta.
|
|
67
|
+
|
|
68
|
+
## Step 3 — Deploy loop (after the artifact is written)
|
|
69
|
+
|
|
70
|
+
Components and templates have NO scaffold command — author them from the references
|
|
71
|
+
(mirror the closest existing artifact). Validation lives in Studio: its publish step
|
|
72
|
+
lints every definition before anything leaves the machine (0 errors required, doc-cited
|
|
73
|
+
messages; justify any warning), and the conformance checklist is
|
|
74
|
+
[UNOVERSE_CONFORMANCE](https://github.com/unoverse-platform/docs/blob/main/unoverse/UNOVERSE_CONFORMANCE.md).
|
|
75
|
+
|
|
76
|
+
| Artifact | To see it live |
|
|
77
|
+
|---|---|
|
|
78
|
+
| Component / atom / template / style | Preview in Studio (`unoverse studio`), then publish from Studio — the publish lint gates it |
|
|
79
|
+
| Existing component restyle/edit only | takes effect live (the SDK reads `rx/` directly) |
|
|
80
|
+
| Agent skill / prompt block | publish from Studio, same gate |
|
|
81
|
+
| Node | Studio's Nodes screen: Load sample → Run. A node is YAML; running it is the only proof (no build) |
|
|
82
|
+
|
|
83
|
+
Verify the platform with `unoverse check` (services, schema, environment). Studio opens
|
|
84
|
+
on :4108 (`unoverse studio` from the project folder).
|
|
85
|
+
|
|
86
|
+
> In the platform monorepo (not the starter), the node docs live at
|
|
87
|
+
> `packages/docs/nodes/`, the design journey at `packages/docs/design/`, and the dev loop
|
|
88
|
+
> is just `npm run dev` — component nodes synthesize from rx/ definitions at boot.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Playbook — Agent Skills (prompts/skills)
|
|
2
|
+
|
|
3
|
+
An agent skill is a markdown behavior guide the platform's AI agents discover and follow
|
|
4
|
+
at runtime (e.g. how to handle complaints, how to walk a user through a process). It is
|
|
5
|
+
**prose for an agent**, not code.
|
|
6
|
+
|
|
7
|
+
## Where files go
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
apps/unoverse/prompts/skills/<skill-name>/
|
|
11
|
+
SKILL.md # required — frontmatter + instructions
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The runtime scans this folder and serves skills to agents; agents match on the
|
|
15
|
+
`description` and `triggers`.
|
|
16
|
+
|
|
17
|
+
## SKILL.md format
|
|
18
|
+
|
|
19
|
+
```markdown
|
|
20
|
+
---
|
|
21
|
+
name: skill-name # required: lowercase, numbers, hyphens; max 64 chars
|
|
22
|
+
title: Skill Name # human display name — shows as the item's title in listings
|
|
23
|
+
description: What this skill IS — ONE short line (≤ 120 chars), the listing subtitle
|
|
24
|
+
whenToUse: The SELECTION text — outcome-first, in the user's vocabulary; ranked against intent for spatial discovery. See docs/nodes/node-discoverability.md — the node rules apply verbatim.
|
|
25
|
+
version: 1.0.0 # optional, semver
|
|
26
|
+
category: ai # optional: ai | integration | workflow | utility
|
|
27
|
+
triggers: # optional, max 20 keywords/phrases (2–50 chars each)
|
|
28
|
+
- keyword or phrase
|
|
29
|
+
credentials: [] # optional: credential types the skill's flow needs
|
|
30
|
+
nodeTypes: [] # optional: workflow node types the skill relies on
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
# Skill Name
|
|
34
|
+
|
|
35
|
+
## When to Use This Skill
|
|
36
|
+
[The situations that should activate this behavior]
|
|
37
|
+
|
|
38
|
+
## Approach / Instructions
|
|
39
|
+
[Clear, step-by-step guidance. Concrete do/don't phrasing beats abstractions —
|
|
40
|
+
include example lines the agent can use verbatim where tone matters.]
|
|
41
|
+
|
|
42
|
+
## Examples
|
|
43
|
+
[Concrete before/after or dialogue examples]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Stick to the keys above. The markdown body must exist — a skill with frontmatter only
|
|
47
|
+
is invalid.
|
|
48
|
+
|
|
49
|
+
## Writing rules
|
|
50
|
+
|
|
51
|
+
1. **`whenToUse` does the routing; `description` says what it is.** Never blend them
|
|
52
|
+
into one field. `whenToUse` is embedded and ranked against user intent (spatial
|
|
53
|
+
discovery / findIntent) — write it **outcome-first in the user's vocabulary**
|
|
54
|
+
("Handle an unhappy customer — a complaint, frustration, or a request to
|
|
55
|
+
escalate…"), never mechanism-first. The full rules are
|
|
56
|
+
`docs/nodes/node-discoverability.md` — they apply to skills verbatim.
|
|
57
|
+
2. **Study an existing skill** in `prompts/skills/` and match its voice and structure.
|
|
58
|
+
3. Keep instructions outcome-focused and scannable: short sections, bulleted do/don't
|
|
59
|
+
lists, explicit escalation/stop conditions.
|
|
60
|
+
4. One skill = one behavior. If you're writing "and also…", split it.
|
|
61
|
+
|
|
62
|
+
## Ship
|
|
63
|
+
|
|
64
|
+
`docker compose restart unoverse` — the runtime rescans skills at boot. Verify the
|
|
65
|
+
skill appears via the platform's skill listing (`unoverse check` for overall health).
|