@unoverse-platform/studio 0.1.103 → 0.1.104
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/package.json +4 -2
- package/screens/AtomsView.tsx +2 -1
- package/screens/ComponentsView.tsx +2 -1
- package/screens/NativeAppHost.tsx +1 -2
- package/screens/TemplatesLibraryView.tsx +14 -1
- package/screens/TemplatesView.tsx +2 -1
- package/screens/index.ts +4 -0
- package/screens/react/unoverse.tsx +281 -0
- package/vendor/base/src/agent-mcp/index.ts +1 -1
- package/vendor/base/src/agent-mcp/invoke.ts +6 -2
- package/vendor/base/src/agent-mcp/tools.ts +14 -5
- package/vendor/base/src/agent-mcp/types.ts +6 -0
- package/vendor/base/src/auth/auth.ts +90 -23
- package/vendor/base/src/definitions/load/compile/sections.ts +79 -0
- package/vendor/base/src/definitions/load/compile/template.ts +23 -2
- package/vendor/base/src/definitions/model.ts +9 -0
- package/vendor/base/src/definitions/registry/catalog.ts +6 -7
- package/vendor/base/src/executor.ts +25 -1
- package/vendor/base/src/manifests/executor/toolBridge.ts +25 -19
- package/vendor/base/src/manifests/runtime/channels/final.ts +4 -1
- package/vendor/base/src/manifests/runtime/duplex/session.ts +3 -9
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +28 -25
- package/vendor/base/src/platform/serviceCalls.ts +13 -0
- package/vendor/sdk/appEngine.ts +363 -0
- package/vendor/sdk/lib/actions.ts +36 -0
- package/vendor/sdk/lib/citations.ts +48 -0
- package/vendor/sdk/lib/core/apps.ts +18 -0
- package/vendor/sdk/lib/core/client.ts +3 -3
- package/vendor/sdk/lib/core/store.ts +6 -0
- package/vendor/sdk/lib/dom/app.ts +546 -0
- package/vendor/sdk/lib/dom/chrome.ts +125 -0
- package/vendor/sdk/lib/dom/component.ts +333 -0
- package/vendor/sdk/lib/dom/connection.ts +309 -0
- package/vendor/sdk/lib/dom/element.ts +69 -0
- package/vendor/sdk/lib/dom/isolate.ts +37 -0
- package/vendor/sdk/lib/dom/markdown.ts +388 -0
- package/vendor/sdk/lib/dom/markdownParse.ts +440 -0
- package/vendor/sdk/lib/dom/mounted.ts +29 -0
- package/vendor/sdk/lib/dom/primitives.ts +689 -0
- package/vendor/sdk/lib/dom/region.ts +195 -0
- package/vendor/sdk/lib/dom/render.ts +177 -0
- package/vendor/sdk/lib/dom/theme.ts +38 -0
- package/vendor/sdk/lib/dom/types.ts +36 -0
- package/vendor/sdk/lib/index.ts +26 -17
- package/vendor/sdk/lib/orb/canvas.ts +139 -0
- package/vendor/sdk/lib/orb/index.ts +8 -18
- package/vendor/sdk/lib/orb/presets.ts +1 -1
- package/vendor/sdk/lib/orb/{types.ts → states.ts} +0 -24
- package/vendor/sdk/lib/realtime/capture.ts +152 -0
- package/vendor/sdk/lib/realtime/playback.ts +129 -0
- package/vendor/sdk/lib/realtime/socket.ts +117 -0
- package/vendor/sdk/lib/style.ts +45 -37
- package/vendor/sdk/lib/voiceService.ts +344 -0
- package/vendor/sdk/main.ts +107 -0
- package/vendor/sdk/mcpAppBridge.ts +125 -102
- package/vendor/sdk/pageInterfaces.ts +452 -0
- package/vendor/sdk/responseView.ts +193 -0
- package/vite.config.ts +3 -3
- package/vendor/sdk/appEngine.tsx +0 -395
- package/vendor/sdk/lib/UnoverseComponent.tsx +0 -325
- package/vendor/sdk/lib/app.tsx +0 -730
- package/vendor/sdk/lib/connection.tsx +0 -433
- package/vendor/sdk/lib/isolate.tsx +0 -70
- package/vendor/sdk/lib/orb/ThinkingOrb.tsx +0 -131
- package/vendor/sdk/lib/orb/theme.ts +0 -83
- package/vendor/sdk/lib/primitives.tsx +0 -943
- package/vendor/sdk/lib/realtime/useAudioCapture.ts +0 -237
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +0 -157
- package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +0 -164
- package/vendor/sdk/lib/render.tsx +0 -244
- package/vendor/sdk/lib/streamed.tsx +0 -30
- package/vendor/sdk/lib/theme.ts +0 -55
- package/vendor/sdk/lib/transition/MountArrangement.tsx +0 -71
- package/vendor/sdk/lib/transition/index.ts +0 -9
- package/vendor/sdk/lib/transition/viewTransition.ts +0 -29
- package/vendor/sdk/lib/voice.tsx +0 -422
- package/vendor/sdk/main.tsx +0 -125
- package/vendor/sdk/pageInterfaces.tsx +0 -388
- package/vendor/sdk/responseView.tsx +0 -82
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EVERY PRIMITIVE, IN PLAIN DOM. The closed set of leaf elements: Box, Text, Image, Button,
|
|
3
|
+
* Select, Input, Icon, Skeleton, Orb and the Unknown fallback (Markdown is in ./markdown).
|
|
4
|
+
* The dispatcher (./render) walks the tree and hands each node to one of these.
|
|
5
|
+
*
|
|
6
|
+
* A primitive is built once and updated in place: `update(node, data, kids?)` writes only what
|
|
7
|
+
* changed, `destroy()` stops whatever it started. It renders ONE generic element and nothing
|
|
8
|
+
* about a specific UX, and authors no style values: every value is served (theme.*).
|
|
9
|
+
*/
|
|
10
|
+
import type { ResolvedTheme, UnoverseNode } from "../core/types";
|
|
11
|
+
import { styleToCss, type CssStyle } from "../style";
|
|
12
|
+
import { nodeAction, type ActionHandler } from "../actions";
|
|
13
|
+
import { startOrb, type OrbState, type OrbTheme } from "../orb/canvas";
|
|
14
|
+
import { nodeChrome, styleEls } from "./chrome";
|
|
15
|
+
import { setAttr, setChildren, setStyle, textOf } from "./element";
|
|
16
|
+
import type { Data, RenderCtx } from "./types";
|
|
17
|
+
|
|
18
|
+
export interface Prim {
|
|
19
|
+
readonly nodes: Node[];
|
|
20
|
+
update(node: UnoverseNode, data: Data, kids?: Node[]): void;
|
|
21
|
+
destroy(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const bound = (field: string | undefined, data: Data): unknown => (field ? data[field] : undefined);
|
|
25
|
+
|
|
26
|
+
/** INSPECTABILITY: a node carrying `$id` stamps it on its element, for authoring tools. */
|
|
27
|
+
const inspectId = (node: UnoverseNode): string | undefined => {
|
|
28
|
+
const id = (node as { $id?: unknown }).$id;
|
|
29
|
+
return typeof id === "string" ? id : undefined;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** A change the element makes itself (a picked selection, a scale), applied once on the page. */
|
|
33
|
+
type Behaviour = (el: HTMLElement) => () => void;
|
|
34
|
+
|
|
35
|
+
/** Runs at most one behaviour on an element, restarting it only when it changes. */
|
|
36
|
+
function behaviourSlot(ctx: RenderCtx) {
|
|
37
|
+
let name: string | null = null;
|
|
38
|
+
let stop: (() => void) | null = null;
|
|
39
|
+
let alive = true;
|
|
40
|
+
return {
|
|
41
|
+
set(next: string | null, el: HTMLElement | null, run?: Behaviour) {
|
|
42
|
+
if (next === name) return;
|
|
43
|
+
stop?.();
|
|
44
|
+
stop = null;
|
|
45
|
+
name = next;
|
|
46
|
+
if (!next || !el || !run) return;
|
|
47
|
+
const mine = next;
|
|
48
|
+
ctx.mounted(() => {
|
|
49
|
+
if (alive && name === mine) stop = run(el);
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
destroy() {
|
|
53
|
+
alive = false;
|
|
54
|
+
stop?.();
|
|
55
|
+
stop = null;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The selection chip's look: a SERVED recipe (`theme.selection.chip`) when the org styles one;
|
|
61
|
+
// else this neutral fallback of token NAMES resolved against the same theme.
|
|
62
|
+
const SELECTION_CHIP_BUILTIN: Record<string, unknown> = {
|
|
63
|
+
background: "action.primary",
|
|
64
|
+
color: "text.inverse",
|
|
65
|
+
font: "body.sm",
|
|
66
|
+
radius: "full",
|
|
67
|
+
padding: ["1", "3"],
|
|
68
|
+
shadow: "lg",
|
|
69
|
+
border: "none",
|
|
70
|
+
cursor: "pointer",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `Box`/`Stack`/`Row`/`Column` (and the container of `Each`): the generic `<div>`. Its
|
|
75
|
+
* children are rendered by the dispatcher and handed in. Behaviours, each opt-in by data:
|
|
76
|
+
* a STAGE (`style.stage: 1280x720`, drawn at that size and scaled as one piece), `autoScroll`
|
|
77
|
+
* (stick to the newest content), `revealOnStick` (a sticky bar shown once the element before it
|
|
78
|
+
* scrolls away) and `selection` (a chip that fires an action with the selected text).
|
|
79
|
+
*/
|
|
80
|
+
export function boxView(ctx: RenderCtx): Prim {
|
|
81
|
+
const doc = ctx.doc;
|
|
82
|
+
let node: UnoverseNode;
|
|
83
|
+
let data: Data;
|
|
84
|
+
let kids: Node[] = [];
|
|
85
|
+
let host: HTMLDivElement | null = null;
|
|
86
|
+
let stageEl: HTMLDivElement | null = null;
|
|
87
|
+
let stageSize: [number, number] | null = null;
|
|
88
|
+
let scale = 1;
|
|
89
|
+
let hostStyle: CssStyle = {};
|
|
90
|
+
let pick: { text: string; top: number; left: number } | null = null;
|
|
91
|
+
let chip: HTMLButtonElement | null = null;
|
|
92
|
+
let rules: string[] = [];
|
|
93
|
+
const styles = styleEls(doc);
|
|
94
|
+
const behaviour = behaviourSlot(ctx);
|
|
95
|
+
const stageWatch = behaviourSlot(ctx);
|
|
96
|
+
|
|
97
|
+
const fill = () => {
|
|
98
|
+
if (!host) return;
|
|
99
|
+
const sel = (node as { selection?: { label?: string; action?: unknown } }).selection;
|
|
100
|
+
if (!stageSize && sel && pick) {
|
|
101
|
+
if (!chip) {
|
|
102
|
+
chip = doc.createElement("button");
|
|
103
|
+
chip.addEventListener("mousedown", (e) => e.preventDefault()); // keep the selection alive through the click
|
|
104
|
+
chip.addEventListener("click", () => {
|
|
105
|
+
const s = (node as { selection?: { action?: unknown } }).selection;
|
|
106
|
+
if (!pick) return;
|
|
107
|
+
ctx.onAction?.((s?.action as Parameters<ActionHandler>[0]) ?? "select", { ...data, selection: pick.text });
|
|
108
|
+
pick = null;
|
|
109
|
+
fill();
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const recipe = ((ctx.theme as { selection?: { chip?: Record<string, unknown> } }).selection?.chip ?? SELECTION_CHIP_BUILTIN) as Record<string, unknown>;
|
|
113
|
+
setStyle(chip, { position: "absolute", top: pick.top, left: pick.left, transform: "translate(-50%, -100%)", zIndex: 40, whiteSpace: "nowrap", ...styleToCss(recipe, ctx.theme) });
|
|
114
|
+
chip.textContent = sel.label ?? "Comment";
|
|
115
|
+
}
|
|
116
|
+
setChildren(host, [...styles(rules), ...kids, ...(!stageSize && sel && pick && chip ? [chip] : [])]);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const applyStage = () => {
|
|
120
|
+
if (!host || !stageSize) return;
|
|
121
|
+
setStyle(host, { ...hostStyle, position: "absolute", top: 0, left: 0, width: stageSize[0], height: stageSize[1], transform: `scale(${scale})`, transformOrigin: "0 0" });
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const selection: Behaviour = (el) => {
|
|
125
|
+
const read = () => {
|
|
126
|
+
const root = el.getRootNode() as Document | ShadowRoot;
|
|
127
|
+
const s = (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection();
|
|
128
|
+
const clear = () => {
|
|
129
|
+
if (pick) {
|
|
130
|
+
pick = null;
|
|
131
|
+
fill();
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
if (!s || s.isCollapsed || s.rangeCount === 0) return clear();
|
|
135
|
+
const range = s.getRangeAt(0);
|
|
136
|
+
if (!el.contains(range.commonAncestorContainer)) return clear();
|
|
137
|
+
const text = s.toString().trim();
|
|
138
|
+
if (!text) return clear();
|
|
139
|
+
const r = range.getBoundingClientRect();
|
|
140
|
+
const box = el.getBoundingClientRect();
|
|
141
|
+
pick = { text, top: r.top - box.top, left: Math.min(Math.max(0, r.left - box.left + r.width / 2), box.width) };
|
|
142
|
+
fill();
|
|
143
|
+
};
|
|
144
|
+
const up = () => window.setTimeout(read, 0); // after the browser settles the selection
|
|
145
|
+
// On the DOCUMENT: a drag-select often ends outside the host, and mouse events cross the
|
|
146
|
+
// shadow boundary. `read` still only raises the chip for a selection inside this host.
|
|
147
|
+
const d = el.ownerDocument;
|
|
148
|
+
d.addEventListener("mouseup", up);
|
|
149
|
+
return () => d.removeEventListener("mouseup", up);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const autoScroll: Behaviour = (el) => {
|
|
153
|
+
const nearBottom = () => el.scrollHeight - el.scrollTop - el.clientHeight < 140;
|
|
154
|
+
const stick = () => {
|
|
155
|
+
if (nearBottom()) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
|
156
|
+
};
|
|
157
|
+
el.scrollTop = el.scrollHeight; // land at the end on mount
|
|
158
|
+
const ro = new ResizeObserver(stick); // streaming text growing a turn's height
|
|
159
|
+
const observeKids = () => {
|
|
160
|
+
for (const kid of Array.from(el.children)) ro.observe(kid);
|
|
161
|
+
};
|
|
162
|
+
observeKids();
|
|
163
|
+
const mo = new MutationObserver(() => {
|
|
164
|
+
observeKids();
|
|
165
|
+
stick();
|
|
166
|
+
});
|
|
167
|
+
mo.observe(el, { childList: true, subtree: true });
|
|
168
|
+
return () => {
|
|
169
|
+
ro.disconnect();
|
|
170
|
+
mo.disconnect();
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const revealOnStick: Behaviour = (el) => {
|
|
175
|
+
const sentinel = el.previousElementSibling;
|
|
176
|
+
// Overlay, not push: the bar's own height is cancelled so content sits under it.
|
|
177
|
+
el.style.marginBottom = `-${el.offsetHeight}px`;
|
|
178
|
+
el.style.transition = "opacity 200ms ease, transform 200ms ease";
|
|
179
|
+
const hide = () => {
|
|
180
|
+
el.style.opacity = "0";
|
|
181
|
+
el.style.transform = "translateY(-8px)";
|
|
182
|
+
el.style.pointerEvents = "none";
|
|
183
|
+
};
|
|
184
|
+
const show = () => {
|
|
185
|
+
el.style.opacity = "1";
|
|
186
|
+
el.style.transform = "translateY(0)";
|
|
187
|
+
el.style.pointerEvents = "auto";
|
|
188
|
+
};
|
|
189
|
+
hide();
|
|
190
|
+
if (!sentinel) {
|
|
191
|
+
show();
|
|
192
|
+
return () => {};
|
|
193
|
+
}
|
|
194
|
+
const io = new IntersectionObserver(([e]) => (e.isIntersecting ? hide() : show()), { threshold: 0 });
|
|
195
|
+
io.observe(sentinel);
|
|
196
|
+
return () => io.disconnect();
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
get nodes() {
|
|
201
|
+
return stageEl ? [stageEl] : host ? [host] : [];
|
|
202
|
+
},
|
|
203
|
+
update(n, d, k = []) {
|
|
204
|
+
node = n;
|
|
205
|
+
data = d;
|
|
206
|
+
kids = k;
|
|
207
|
+
const chrome = nodeChrome(n, d, ctx.theme);
|
|
208
|
+
rules = chrome.rules;
|
|
209
|
+
const sel = (n as { selection?: unknown }).selection;
|
|
210
|
+
hostStyle = sel && !chrome.style.position ? { ...chrome.style, position: "relative" } : chrome.style;
|
|
211
|
+
const m = /^(\d+)x(\d+)$/.exec(String((n.style as { stage?: unknown } | undefined)?.stage ?? ""));
|
|
212
|
+
const size: [number, number] | null = m ? [Number(m[1]), Number(m[2])] : null;
|
|
213
|
+
if (!host || !!size !== !!stageSize) {
|
|
214
|
+
behaviour.set(null, null);
|
|
215
|
+
stageWatch.set(null, null);
|
|
216
|
+
host = doc.createElement("div");
|
|
217
|
+
stageEl = size ? doc.createElement("div") : null;
|
|
218
|
+
if (stageEl) stageEl.appendChild(host);
|
|
219
|
+
scale = 1;
|
|
220
|
+
}
|
|
221
|
+
stageSize = size;
|
|
222
|
+
setAttr(host, "class", chrome.className);
|
|
223
|
+
setAttr(host, "data-unoverse-id", inspectId(n));
|
|
224
|
+
if (stageEl && size) {
|
|
225
|
+
setStyle(stageEl, { position: "relative", width: "100%", aspectRatio: `${size[0]} / ${size[1]}` });
|
|
226
|
+
applyStage();
|
|
227
|
+
const outer = stageEl;
|
|
228
|
+
stageWatch.set(`stage:${size[0]}`, outer, (el) => {
|
|
229
|
+
const ro = new ResizeObserver(() => {
|
|
230
|
+
scale = el.clientWidth / (stageSize?.[0] ?? 1);
|
|
231
|
+
applyStage();
|
|
232
|
+
});
|
|
233
|
+
ro.observe(el);
|
|
234
|
+
return () => ro.disconnect();
|
|
235
|
+
});
|
|
236
|
+
} else {
|
|
237
|
+
setStyle(host, hostStyle);
|
|
238
|
+
// One behaviour per element, in this order (the element can carry only one).
|
|
239
|
+
const which = n.autoScroll === true ? "autoScroll" : n.revealOnStick === true ? "revealOnStick" : sel ? "selection" : null;
|
|
240
|
+
behaviour.set(which, host, which === "autoScroll" ? autoScroll : which === "revealOnStick" ? revealOnStick : selection);
|
|
241
|
+
if (!sel) pick = null;
|
|
242
|
+
}
|
|
243
|
+
fill();
|
|
244
|
+
},
|
|
245
|
+
destroy() {
|
|
246
|
+
behaviour.destroy();
|
|
247
|
+
stageWatch.destroy();
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** `Text`: a bound or literal string (`<span>`). */
|
|
253
|
+
export function textView(ctx: RenderCtx): Prim {
|
|
254
|
+
const el = ctx.doc.createElement("span");
|
|
255
|
+
return {
|
|
256
|
+
nodes: [el],
|
|
257
|
+
update(node, data) {
|
|
258
|
+
setStyle(el, styleToCss(node.style, ctx.theme, data));
|
|
259
|
+
setAttr(el, "data-unoverse-id", inspectId(node));
|
|
260
|
+
const t = textOf(bound(node.bind?.value, data) ?? node.value);
|
|
261
|
+
if (el.textContent !== t) el.textContent = t;
|
|
262
|
+
},
|
|
263
|
+
destroy() {},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** `Image`: a bound or literal src (`<img>`). */
|
|
268
|
+
export function imageView(ctx: RenderCtx): Prim {
|
|
269
|
+
const el = ctx.doc.createElement("img");
|
|
270
|
+
return {
|
|
271
|
+
nodes: [el],
|
|
272
|
+
update(node, data) {
|
|
273
|
+
setAttr(el, "data-unoverse-id", inspectId(node));
|
|
274
|
+
setStyle(el, styleToCss(node.style, ctx.theme, data));
|
|
275
|
+
setAttr(el, "src", (bound(node.bind?.src, data) as string | undefined) ?? node.src);
|
|
276
|
+
setAttr(el, "alt", (bound(node.bind?.alt, data) as string | undefined) ?? node.alt ?? "");
|
|
277
|
+
},
|
|
278
|
+
destroy() {},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** `Button`: fires the node's action. Content is a bound label OR its composed children. */
|
|
283
|
+
export function buttonView(ctx: RenderCtx): Prim {
|
|
284
|
+
const el = ctx.doc.createElement("button");
|
|
285
|
+
const styles = styleEls(ctx.doc);
|
|
286
|
+
const label = ctx.doc.createTextNode("");
|
|
287
|
+
let node: UnoverseNode;
|
|
288
|
+
let data: Data;
|
|
289
|
+
el.addEventListener("click", () => ctx.onAction?.(nodeAction(node, "click"), data));
|
|
290
|
+
return {
|
|
291
|
+
nodes: [el],
|
|
292
|
+
update(n, d, kids = []) {
|
|
293
|
+
node = n;
|
|
294
|
+
data = d;
|
|
295
|
+
const { className, rules, style, disabled } = nodeChrome(n, d, ctx.theme);
|
|
296
|
+
setAttr(el, "class", className);
|
|
297
|
+
setStyle(el, style);
|
|
298
|
+
setAttr(el, "disabled", disabled);
|
|
299
|
+
setAttr(el, "data-unoverse-id", inspectId(n));
|
|
300
|
+
let content: Node[] = kids;
|
|
301
|
+
if (!n.children?.length) {
|
|
302
|
+
label.data = textOf(bound(n.bind?.label, d));
|
|
303
|
+
content = label.data ? [label] : [];
|
|
304
|
+
}
|
|
305
|
+
setChildren(el, [...styles(rules), ...content]);
|
|
306
|
+
},
|
|
307
|
+
destroy() {},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Unknown node type: its children, so the tree does not break. */
|
|
312
|
+
export function unknownView(ctx: RenderCtx): Prim {
|
|
313
|
+
const el = ctx.doc.createElement("div");
|
|
314
|
+
return {
|
|
315
|
+
nodes: [el],
|
|
316
|
+
update(node, _data, kids = []) {
|
|
317
|
+
setStyle(el, styleToCss(node.style, ctx.theme));
|
|
318
|
+
setAttr(el, "data-unoverse-unknown", node.type);
|
|
319
|
+
setChildren(el, kids);
|
|
320
|
+
},
|
|
321
|
+
destroy() {},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// React's camelCase SVG presentation names, as the attributes the browser reads.
|
|
326
|
+
const SVG_CAMEL = /^(stroke|fill|clip|stop|font|text|marker|flood|lighting|color|dominant|vector|shape|paint|pointer|alignment|baseline|glyph|letter|word|writing|unicode|overline|underline|strikethrough|image|enable|horiz|vert)[A-Z]/;
|
|
327
|
+
const svgAttr = (k: string) => (k === "className" ? "class" : k === "xlinkHref" ? "xlink:href" : SVG_CAMEL.test(k) ? k.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase()) : k);
|
|
328
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
329
|
+
|
|
330
|
+
type Glyph = { viewBox?: string; attrs?: Record<string, unknown>; children?: [string, Record<string, unknown>][] };
|
|
331
|
+
|
|
332
|
+
/** A served glyph as an `<svg>` (theme.icons); the SDK bundles no icon pack. */
|
|
333
|
+
export function svgOf(doc: Document, ic: Glyph, style: CssStyle | undefined): SVGSVGElement {
|
|
334
|
+
const svg = doc.createElementNS(SVG_NS, "svg");
|
|
335
|
+
svg.setAttribute("viewBox", ic.viewBox ?? "0 0 24 24");
|
|
336
|
+
for (const [k, v] of Object.entries(ic.attrs ?? {})) setAttr(svg, svgAttr(k), v);
|
|
337
|
+
svg.setAttribute("aria-hidden", "true");
|
|
338
|
+
setStyle(svg, style);
|
|
339
|
+
for (const [tag, attrs] of Array.isArray(ic.children) ? ic.children : []) {
|
|
340
|
+
const child = doc.createElementNS(SVG_NS, tag);
|
|
341
|
+
for (const [k, v] of Object.entries(attrs ?? {})) setAttr(child, svgAttr(k), v);
|
|
342
|
+
svg.appendChild(child);
|
|
343
|
+
}
|
|
344
|
+
return svg;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** `Icon`: a named glyph whose drawing is served. Name is literal or bound (`bind.name`). */
|
|
348
|
+
export function iconView(ctx: RenderCtx): Prim {
|
|
349
|
+
let svg: SVGSVGElement | null = null;
|
|
350
|
+
let drawn: Glyph | undefined;
|
|
351
|
+
return {
|
|
352
|
+
get nodes() {
|
|
353
|
+
return svg ? [svg] : [];
|
|
354
|
+
},
|
|
355
|
+
update(node, data) {
|
|
356
|
+
const name = node.bind?.name ? (data[node.bind.name] as string) : (node.icon ?? node.name);
|
|
357
|
+
const ic = name ? ((ctx.theme.icons ?? {}) as Record<string, Glyph>)[name] : undefined;
|
|
358
|
+
if (!ic) {
|
|
359
|
+
svg = null;
|
|
360
|
+
drawn = undefined;
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (ic !== drawn || !svg) {
|
|
364
|
+
svg = svgOf(ctx.doc, ic, styleToCss(node.style ?? {}, ctx.theme));
|
|
365
|
+
drawn = ic;
|
|
366
|
+
} else setStyle(svg, styleToCss(node.style ?? {}, ctx.theme));
|
|
367
|
+
},
|
|
368
|
+
destroy() {},
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* `Skeleton`: the loading placeholder. Every dimension is served (theme.skeleton); the motion
|
|
374
|
+
* is the primitive's own, overridable by a served `skeleton.shimmer` and off when it is false.
|
|
375
|
+
* DEFENSIVE BY LAW: a missing token leaves that dimension unset, never a crash.
|
|
376
|
+
*/
|
|
377
|
+
export function skeletonView(ctx: RenderCtx): Prim & { variant(v: string | undefined): void } {
|
|
378
|
+
const doc = ctx.doc;
|
|
379
|
+
let nodes: Node[] = [];
|
|
380
|
+
let built: { variant: string; theme: ResolvedTheme } | null = null;
|
|
381
|
+
let variant = "text";
|
|
382
|
+
const build = () => {
|
|
383
|
+
const theme = ctx.theme;
|
|
384
|
+
if (built && built.variant === variant && built.theme === theme) return;
|
|
385
|
+
built = { variant, theme };
|
|
386
|
+
const sk = (theme.skeleton ?? {}) as Record<string, any>;
|
|
387
|
+
const SHIMMER_BUILTIN: CssStyle = {
|
|
388
|
+
backgroundImage: "linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.55) 50%, rgba(255,255,255,0) 100%)",
|
|
389
|
+
backgroundSize: "200% 100%",
|
|
390
|
+
animation: "uno-skeleton-shimmer 1.4s ease-in-out infinite",
|
|
391
|
+
};
|
|
392
|
+
const shimmer: CssStyle = sk.shimmer === false ? {} : typeof sk.shimmer === "object" ? (sk.shimmer as CssStyle) : SHIMMER_BUILTIN;
|
|
393
|
+
const keyframes = () => {
|
|
394
|
+
const s = doc.createElement("style");
|
|
395
|
+
s.textContent = "@keyframes uno-skeleton-shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}";
|
|
396
|
+
return s;
|
|
397
|
+
};
|
|
398
|
+
const div = (style: CssStyle, kids: Node[] = []) => {
|
|
399
|
+
const d = doc.createElement("div");
|
|
400
|
+
setStyle(d, style);
|
|
401
|
+
setChildren(d, kids);
|
|
402
|
+
return d;
|
|
403
|
+
};
|
|
404
|
+
const bar = (spec: string) => {
|
|
405
|
+
const [w, h] = String(spec).split(" ");
|
|
406
|
+
return div({ width: w, height: h, background: sk.fill, borderRadius: sk.barRadius, ...shimmer });
|
|
407
|
+
};
|
|
408
|
+
const kf = sk.shimmer === false ? [] : [keyframes()];
|
|
409
|
+
if (variant === "image") nodes = [...kf, div({ width: "100%", height: "100%", minHeight: sk.image?.minHeight, background: sk.fill, borderRadius: sk.radius, ...shimmer })];
|
|
410
|
+
else if (variant === "card")
|
|
411
|
+
nodes = [
|
|
412
|
+
...kf,
|
|
413
|
+
div(
|
|
414
|
+
{
|
|
415
|
+
display: "flex",
|
|
416
|
+
flexDirection: "column",
|
|
417
|
+
gap: sk.gap,
|
|
418
|
+
padding: sk.card?.padding,
|
|
419
|
+
border: theme.borderWidth?.thin && sk.fill ? `${theme.borderWidth.thin} solid ${sk.fill}` : undefined,
|
|
420
|
+
borderRadius: sk.radius,
|
|
421
|
+
},
|
|
422
|
+
(Array.isArray(sk.card?.bars) ? (sk.card.bars as string[]) : []).map(bar),
|
|
423
|
+
),
|
|
424
|
+
];
|
|
425
|
+
else nodes = [div({ display: "flex", flexDirection: "column", gap: sk.gap }, [...kf, ...(Array.isArray(sk.text?.bars) ? (sk.text.bars as string[]) : []).map(bar)])];
|
|
426
|
+
};
|
|
427
|
+
return {
|
|
428
|
+
get nodes() {
|
|
429
|
+
return nodes;
|
|
430
|
+
},
|
|
431
|
+
variant(v) {
|
|
432
|
+
variant = v ?? "text";
|
|
433
|
+
build();
|
|
434
|
+
},
|
|
435
|
+
update(node) {
|
|
436
|
+
variant = (node.variant as string | undefined) ?? "text";
|
|
437
|
+
build();
|
|
438
|
+
},
|
|
439
|
+
destroy() {},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* `Select`: the irreducible `<select>`. It reports a change as a controlled Input does,
|
|
445
|
+
* `("input", { field, text })`. The options are data (`bind.options` or literal `options`).
|
|
446
|
+
*/
|
|
447
|
+
export function selectView(ctx: RenderCtx): Prim {
|
|
448
|
+
const doc = ctx.doc;
|
|
449
|
+
const el = doc.createElement("select");
|
|
450
|
+
let field: string | undefined;
|
|
451
|
+
let value = "";
|
|
452
|
+
el.addEventListener("change", () => {
|
|
453
|
+
if (field != null) ctx.onAction?.("input", { text: el.value, field });
|
|
454
|
+
if (el.value !== value && Array.from(el.options).some((o) => o.value === value)) el.value = value;
|
|
455
|
+
});
|
|
456
|
+
return {
|
|
457
|
+
nodes: [el],
|
|
458
|
+
update(node, data) {
|
|
459
|
+
field = node.bind?.value;
|
|
460
|
+
value = field != null ? String(data[field] ?? "") : "";
|
|
461
|
+
const raw = node.bind?.options ? data[node.bind.options] : (node as { options?: unknown }).options;
|
|
462
|
+
const options = (Array.isArray(raw) ? raw : []).map((o) =>
|
|
463
|
+
o != null && typeof o === "object"
|
|
464
|
+
? { value: String((o as Data).value ?? (o as Data).label ?? ""), label: String((o as Data).label ?? (o as Data).value ?? "") }
|
|
465
|
+
: { value: String(o), label: String(o) },
|
|
466
|
+
);
|
|
467
|
+
const { style, disabled } = nodeChrome(node, data, ctx.theme);
|
|
468
|
+
setStyle(el, style);
|
|
469
|
+
setAttr(el, "disabled", disabled);
|
|
470
|
+
setAttr(el, "data-unoverse-id", inspectId(node));
|
|
471
|
+
// The placeholder is CONTENT: an unanswered select says what it wants.
|
|
472
|
+
const want = [...(node.placeholder != null ? [{ value: "", label: String(node.placeholder), placeholder: true }] : []), ...options.map((o) => ({ ...o, placeholder: false }))];
|
|
473
|
+
const opts = Array.from(el.options);
|
|
474
|
+
want.forEach((w, i) => {
|
|
475
|
+
const o = opts[i] ?? doc.createElement("option");
|
|
476
|
+
setAttr(o, "value", w.value);
|
|
477
|
+
setAttr(o, "disabled", w.placeholder);
|
|
478
|
+
if (o.textContent !== w.label) o.textContent = w.label;
|
|
479
|
+
if (!opts[i]) el.appendChild(o);
|
|
480
|
+
});
|
|
481
|
+
for (const o of opts.slice(want.length)) o.remove();
|
|
482
|
+
// A value no option holds shows the first enabled option, as it always has.
|
|
483
|
+
const shown = want.some((w) => w.value === value) ? value : (want.find((w) => !w.placeholder)?.value ?? "");
|
|
484
|
+
if (el.value !== shown) el.value = shown;
|
|
485
|
+
},
|
|
486
|
+
destroy() {},
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* `Input`: the irreducible `<input>` (or `<textarea>` with `multiline`). CONTROLLED when
|
|
492
|
+
* `bind.value` is set: the value comes from the data and each change is reported as
|
|
493
|
+
* `("input", { text, field })`. Otherwise it keeps its own draft. Enter submits; with
|
|
494
|
+
* `multiline`, Cmd/Ctrl+Enter does. Generic constraints only (`maxLength`, numeric
|
|
495
|
+
* `inputMode`, `autoComplete`, `inputType`, `advanceOnFill`); no chrome, no UX shape.
|
|
496
|
+
*/
|
|
497
|
+
export function inputView(ctx: RenderCtx): Prim {
|
|
498
|
+
const doc = ctx.doc;
|
|
499
|
+
let el: HTMLInputElement | HTMLTextAreaElement | null = null;
|
|
500
|
+
let multiline = false;
|
|
501
|
+
let node: UnoverseNode;
|
|
502
|
+
let data: Data;
|
|
503
|
+
let local = "";
|
|
504
|
+
const field = () => node.bind?.value;
|
|
505
|
+
const value = () => (field() != null ? String(data[field()!] ?? "") : local);
|
|
506
|
+
const maxLength = () => (node as { maxLength?: number }).maxLength;
|
|
507
|
+
const numeric = () => (node as { inputMode?: string }).inputMode === "numeric";
|
|
508
|
+
const inputType = () => (node as { inputType?: string }).inputType;
|
|
509
|
+
const advanceOnFill = () => (node as { advanceOnFill?: boolean }).advanceOnFill === true;
|
|
510
|
+
const accept = (t: string) => {
|
|
511
|
+
const kept = numeric() ? t.replace(/[^0-9]/g, "") : t;
|
|
512
|
+
const max = maxLength();
|
|
513
|
+
return max != null ? kept.slice(0, max) : kept;
|
|
514
|
+
};
|
|
515
|
+
// Scoped to this render's ROOT: the SDK renders inside a shadow root.
|
|
516
|
+
const stepFocus = (dir: 1 | -1) => {
|
|
517
|
+
const self = el as HTMLInputElement | null;
|
|
518
|
+
if (!self || multiline) return;
|
|
519
|
+
const root = self.getRootNode() as Document | ShadowRoot;
|
|
520
|
+
const peers = Array.from(root.querySelectorAll<HTMLInputElement>("input[data-uno-advance]"));
|
|
521
|
+
const next = peers[peers.indexOf(self) + dir];
|
|
522
|
+
if (!next) return;
|
|
523
|
+
next.focus();
|
|
524
|
+
next.select();
|
|
525
|
+
};
|
|
526
|
+
// Show the value the data holds (a controlled field reverts a change the data refused).
|
|
527
|
+
const show = () => {
|
|
528
|
+
if (!el || inputType() === "file") return;
|
|
529
|
+
const v = value();
|
|
530
|
+
if (el.value !== v) el.value = v;
|
|
531
|
+
};
|
|
532
|
+
const setValue = (raw: string) => {
|
|
533
|
+
const t = accept(raw);
|
|
534
|
+
const max = maxLength();
|
|
535
|
+
// Focus moves BEFORE the store round-trip, so a fast typist's next key lands next door.
|
|
536
|
+
if (advanceOnFill() && max != null && t.length >= max) stepFocus(1);
|
|
537
|
+
if (field() != null) ctx.onAction?.("input", { text: t, field: field() });
|
|
538
|
+
else local = t;
|
|
539
|
+
show();
|
|
540
|
+
};
|
|
541
|
+
const submit = () => {
|
|
542
|
+
const t = value().trim();
|
|
543
|
+
if (!t) return;
|
|
544
|
+
ctx.onAction?.(nodeAction(node, "submit"), { text: t });
|
|
545
|
+
if (field() == null) {
|
|
546
|
+
local = "";
|
|
547
|
+
show();
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
const build = () => {
|
|
551
|
+
if (multiline) {
|
|
552
|
+
const ta = doc.createElement("textarea");
|
|
553
|
+
ta.addEventListener("input", () => setValue(ta.value));
|
|
554
|
+
ta.addEventListener("keydown", (e) => {
|
|
555
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
556
|
+
e.preventDefault();
|
|
557
|
+
submit();
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
return ta;
|
|
561
|
+
}
|
|
562
|
+
const input = doc.createElement("input");
|
|
563
|
+
input.addEventListener("input", () => {
|
|
564
|
+
// A FILE FIELD REPORTS ITS FILES, not its value (a fake path); the bytes are the host's.
|
|
565
|
+
if (inputType() === "file") {
|
|
566
|
+
const picked = Array.from(input.files ?? []).map((f) => ({ name: f.name, size: f.size, type: f.type }));
|
|
567
|
+
if (field() != null) ctx.onAction?.("input", { text: picked.map((f) => f.name).join(", "), field: field(), files: picked });
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
setValue(input.value);
|
|
571
|
+
});
|
|
572
|
+
input.addEventListener("keydown", (e) => {
|
|
573
|
+
// Backspace in an already-empty field of a sequence steps back.
|
|
574
|
+
if (advanceOnFill() && e.key === "Backspace" && value() === "") {
|
|
575
|
+
e.preventDefault();
|
|
576
|
+
stepFocus(-1);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
580
|
+
e.preventDefault();
|
|
581
|
+
submit();
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
return input;
|
|
585
|
+
};
|
|
586
|
+
return {
|
|
587
|
+
get nodes() {
|
|
588
|
+
return el ? [el] : [];
|
|
589
|
+
},
|
|
590
|
+
update(n, d) {
|
|
591
|
+
node = n;
|
|
592
|
+
data = d;
|
|
593
|
+
const ml = (n as { multiline?: boolean }).multiline === true;
|
|
594
|
+
if (!el || ml !== multiline) {
|
|
595
|
+
multiline = ml;
|
|
596
|
+
el = build();
|
|
597
|
+
}
|
|
598
|
+
const { style, disabled } = nodeChrome(n, d, ctx.theme);
|
|
599
|
+
setStyle(el, style);
|
|
600
|
+
setAttr(el, "disabled", disabled);
|
|
601
|
+
setAttr(el, "placeholder", n.placeholder);
|
|
602
|
+
setAttr(el, "maxlength", maxLength());
|
|
603
|
+
if (multiline) {
|
|
604
|
+
setAttr(el, "rows", (n as { rows?: number }).rows ?? 3);
|
|
605
|
+
if (el.textContent !== value()) el.textContent = value();
|
|
606
|
+
} else {
|
|
607
|
+
const file = inputType() === "file";
|
|
608
|
+
// A file input is never controlled: assigning its value is a security error.
|
|
609
|
+
setAttr(el, "value", file ? undefined : value());
|
|
610
|
+
setAttr(el, "type", inputType());
|
|
611
|
+
setAttr(el, "inputmode", numeric() ? "numeric" : undefined);
|
|
612
|
+
setAttr(el, "autocomplete", (n as { autoComplete?: string }).autoComplete);
|
|
613
|
+
setAttr(el, "data-uno-advance", advanceOnFill() ? "" : undefined);
|
|
614
|
+
}
|
|
615
|
+
show();
|
|
616
|
+
},
|
|
617
|
+
destroy() {},
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const ORB_STATES = new Set(["working", "searching", "solving", "listening", "connecting", "weaving", "composing", "breathing", "shaping", "halo", "sonar", "shimmer", "pulse"]);
|
|
622
|
+
const ORB_LABELS: Record<string, string> = {
|
|
623
|
+
working: "Working…",
|
|
624
|
+
searching: "Searching…",
|
|
625
|
+
solving: "Solving…",
|
|
626
|
+
listening: "Listening…",
|
|
627
|
+
connecting: "Connecting…",
|
|
628
|
+
weaving: "Weaving…",
|
|
629
|
+
composing: "Composing…",
|
|
630
|
+
breathing: "Thinking…",
|
|
631
|
+
shaping: "Shaping…",
|
|
632
|
+
halo: "Waiting…",
|
|
633
|
+
sonar: "Listening…",
|
|
634
|
+
shimmer: "Thinking…",
|
|
635
|
+
pulse: "Responding…",
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
/** #rgb / #rrggbb / rgb()/rgba() → an rgb tuple; anything else → null (neutral ink). */
|
|
639
|
+
function cssColorToRgb(c?: string): [number, number, number] | null {
|
|
640
|
+
if (!c) return null;
|
|
641
|
+
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(c.trim());
|
|
642
|
+
if (hex) {
|
|
643
|
+
const h = hex[1].length === 3 ? [...hex[1]].map((x) => x + x).join("") : hex[1];
|
|
644
|
+
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)) as [number, number, number];
|
|
645
|
+
}
|
|
646
|
+
const fn = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(c.trim());
|
|
647
|
+
return fn ? [Number(fn[1]), Number(fn[2]), Number(fn[3])] : null;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* `Orb`: the procedural "the assistant is doing something" animation on a 2D canvas (../orb).
|
|
652
|
+
* Every knob is data or literal (`bind.<knob>` wins). An unknown state degrades to the calm
|
|
653
|
+
* `halo`, never the loud sphere. Brand ink comes from the style's `color` token.
|
|
654
|
+
*/
|
|
655
|
+
export function orbView(ctx: RenderCtx): Prim {
|
|
656
|
+
const el = ctx.doc.createElement("canvas");
|
|
657
|
+
let drawn = "";
|
|
658
|
+
let stop: (() => void) | null = null;
|
|
659
|
+
let alive = true;
|
|
660
|
+
return {
|
|
661
|
+
nodes: [el],
|
|
662
|
+
update(node, data) {
|
|
663
|
+
const n = node as UnoverseNode & { state?: string; size?: number | string; speed?: number | string; theme?: string };
|
|
664
|
+
const knob = (name: "state" | "size" | "speed") => (node.bind?.[name] ? (data[node.bind[name] as string] as string | number | undefined) : undefined) ?? n[name];
|
|
665
|
+
const raw = knob("state") as string | undefined;
|
|
666
|
+
const state = (raw && ORB_STATES.has(raw) ? raw : "halo") as OrbState;
|
|
667
|
+
const size = Number(knob("size")) > 0 ? Number(knob("size")) : 64;
|
|
668
|
+
const speed = Number(knob("speed")) > 0 ? Number(knob("speed")) : 1;
|
|
669
|
+
const theme: OrbTheme = n.theme === "dark" || n.theme === "light" ? n.theme : "auto";
|
|
670
|
+
const css = styleToCss(node.style ?? {}, ctx.theme, data);
|
|
671
|
+
const ink = cssColorToRgb(css.color as string | undefined);
|
|
672
|
+
setAttr(el, "role", "img");
|
|
673
|
+
setAttr(el, "aria-label", ORB_LABELS[state]);
|
|
674
|
+
setStyle(el, { width: size, height: size, display: "block", ...css });
|
|
675
|
+
const key = JSON.stringify([state, size, speed, theme, ink]);
|
|
676
|
+
if (key === drawn) return;
|
|
677
|
+
drawn = key;
|
|
678
|
+
stop?.();
|
|
679
|
+
stop = null;
|
|
680
|
+
ctx.mounted(() => {
|
|
681
|
+
if (alive && drawn === key) stop = startOrb(el, { state, size, speed, theme, ink });
|
|
682
|
+
});
|
|
683
|
+
},
|
|
684
|
+
destroy() {
|
|
685
|
+
alive = false;
|
|
686
|
+
stop?.();
|
|
687
|
+
},
|
|
688
|
+
};
|
|
689
|
+
}
|