@unoverse-platform/studio 0.1.4 → 0.1.6
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/core/types.ts +5 -0
- package/vendor/sdk/lib/primitives.tsx +33 -4
- package/vendor/sdk/lib/render.tsx +23 -4
- package/vendor/sdk/lib/streamed.tsx +12 -47
- package/vendor/sdk/lib/template.tsx +3 -3
|
@@ -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;
|
|
@@ -71,6 +71,11 @@ export interface UnoverseNode {
|
|
|
71
71
|
userData?: Record<string, unknown>;
|
|
72
72
|
/** Icon: the served glyph NAME (literal) — or bind a data field via `bind.name`. */
|
|
73
73
|
icon?: string;
|
|
74
|
+
/** Icon: the same literal under the key a `Ref`'s `with` writes. An atom binds `name`, and
|
|
75
|
+
* `applyLiterals` replaces that bind with a literal of the SAME key — so a Ref-supplied
|
|
76
|
+
* glyph arrives as `name`, never as `icon`. Declared because the renderer reads it; it was
|
|
77
|
+
* read without being declared, which typechecked nowhere until Studio's config was fixed. */
|
|
78
|
+
name?: string;
|
|
74
79
|
/** Disable the element (Input/Button) when this data field is truthy; the node's
|
|
75
80
|
* `style.disabled` is merged while disabled. */
|
|
76
81
|
disabledWhen?: string;
|
|
@@ -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,21 @@ 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
|
+
// AN ARRAY ITEM IS THE VALUE. `typeof [] === "object"`, so an array took the spread
|
|
151
|
+
// branch and its INDICES became scope keys ({0: "London", 1: "+44…"}) while `value` was
|
|
152
|
+
// never set. A nested Each bound to `value` — a table's row of cells — then found
|
|
153
|
+
// nothing, and every table rendered its headers over empty rows.
|
|
154
|
+
const scopeOf = (item: unknown, i: number): Record<string, unknown> => {
|
|
155
|
+
const base = { index: i, position: i + 1 };
|
|
156
|
+
return item != null && typeof item === "object" && !Array.isArray(item)
|
|
157
|
+
? { ...base, ...(item as Record<string, unknown>) }
|
|
158
|
+
: { ...base, value: item };
|
|
159
|
+
};
|
|
160
|
+
const rendered = tpl ? items.map((item, i) => renderNode(tpl, scopeOf(item, i), onAction, theme, i, slot)) : null;
|
|
142
161
|
return (
|
|
143
162
|
<BoxView key={key} node={node} data={data} theme={theme}>
|
|
144
163
|
{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 —
|