@unoverse-platform/studio 0.1.0

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.
Files changed (57) hide show
  1. package/bin/studio.mjs +124 -0
  2. package/index.html +12 -0
  3. package/local/catalog.ts +105 -0
  4. package/local/keys.ts +118 -0
  5. package/local/nodes.ts +77 -0
  6. package/local/plugin.ts +401 -0
  7. package/local/scaffold.d.mts +13 -0
  8. package/local/scaffold.mjs +85 -0
  9. package/package.json +43 -0
  10. package/public/logo.png +0 -0
  11. package/screens/AtomsView.tsx +206 -0
  12. package/screens/ComponentsView.tsx +551 -0
  13. package/screens/ConfigForm.tsx +217 -0
  14. package/screens/DesignSystemView.tsx +324 -0
  15. package/screens/IdentityHeader.tsx +88 -0
  16. package/screens/PromptBlocksView.tsx +137 -0
  17. package/screens/SkillsView.tsx +167 -0
  18. package/screens/TemplatesView.tsx +1005 -0
  19. package/screens/index.ts +26 -0
  20. package/screens/states.ts +44 -0
  21. package/src/App.tsx +252 -0
  22. package/src/ConnectUniverse.tsx +232 -0
  23. package/src/NewProject.tsx +89 -0
  24. package/src/NodeKeys.tsx +98 -0
  25. package/src/NodesView.tsx +443 -0
  26. package/src/PublishDialog.tsx +309 -0
  27. package/src/UniverseSession.tsx +121 -0
  28. package/src/host.ts +165 -0
  29. package/src/index.css +7 -0
  30. package/src/main.tsx +11 -0
  31. package/src/universes.ts +210 -0
  32. package/vendor/sdk/appEngine.tsx +219 -0
  33. package/vendor/sdk/lib/UnoverseComponent.tsx +120 -0
  34. package/vendor/sdk/lib/connection.tsx +302 -0
  35. package/vendor/sdk/lib/core/actions.ts +84 -0
  36. package/vendor/sdk/lib/core/client.ts +134 -0
  37. package/vendor/sdk/lib/core/conditions.ts +43 -0
  38. package/vendor/sdk/lib/core/connection.ts +142 -0
  39. package/vendor/sdk/lib/core/index.ts +32 -0
  40. package/vendor/sdk/lib/core/store.ts +455 -0
  41. package/vendor/sdk/lib/core/types.ts +194 -0
  42. package/vendor/sdk/lib/index.ts +59 -0
  43. package/vendor/sdk/lib/isolate.tsx +70 -0
  44. package/vendor/sdk/lib/primitives.tsx +453 -0
  45. package/vendor/sdk/lib/realtime/audioUtils.ts +40 -0
  46. package/vendor/sdk/lib/realtime/types.ts +45 -0
  47. package/vendor/sdk/lib/realtime/useAudioCapture.ts +237 -0
  48. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +145 -0
  49. package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +164 -0
  50. package/vendor/sdk/lib/render.tsx +178 -0
  51. package/vendor/sdk/lib/streamed.tsx +65 -0
  52. package/vendor/sdk/lib/style.ts +227 -0
  53. package/vendor/sdk/lib/template.tsx +521 -0
  54. package/vendor/sdk/lib/theme.ts +55 -0
  55. package/vendor/sdk/lib/voice.tsx +311 -0
  56. package/vendor/sdk/main.tsx +96 -0
  57. package/vite.config.ts +70 -0
@@ -0,0 +1,453 @@
1
+ /**
2
+ * ════════════════════════════════════════════════════════════════════════════
3
+ * EVERY PRIMITIVE LIVES HERE — render.tsx is the dispatcher and renders NONE.
4
+ * ════════════════════════════════════════════════════════════════════════════
5
+ *
6
+ * This file is the CLOSED set of leaf primitives — the web realization of the
7
+ * irreducible neutral elements: Box, Text, Image, Button, Icon, Skeleton, Input,
8
+ * Markdown (+ the Unknown fallback). If you are writing a `<div>`, `<button>`,
9
+ * `<span>`, `<img>`, `<input>`, `<svg>` … it goes HERE, never in render.tsx.
10
+ * render.tsx ONLY dispatches `node.type` → one of these components. (A build
11
+ * guard, test/dispatcher-only.test.mjs, fails if a raw element appears in
12
+ * render.tsx — so this separation can't silently rot again.)
13
+ *
14
+ * ⛔ A primitive renders ONE generic element and NOTHING about a specific UX.
15
+ * No icons-in-the-input, no send buttons, no pills, no cards, no loaders.
16
+ * Those are LAYOUTS — compose them in a DEFINITION from generic primitives.
17
+ * Every style VALUE is served (theme.*); this file authors none. The bar to
18
+ * ADD a primitive is high — it must be an irreducible element at the tier of
19
+ * Text/Image (see FRAMEWORK.md), never a composite.
20
+ * ════════════════════════════════════════════════════════════════════════════
21
+ */
22
+ import { createElement, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
23
+ import Markdown from "markdown-to-jsx";
24
+ import type { ResolvedTheme, UnoverseNode } from "./core";
25
+ import { styleToCss, cssDecls } from "./style";
26
+ import type { ActionHandler } from "./render";
27
+
28
+ /** Read a bound data field as a string (for Text/Image/Button content). */
29
+ function bound(field: string | undefined, data: Record<string, unknown>): string | undefined {
30
+ return field ? (data[field] as string | undefined) : undefined;
31
+ }
32
+
33
+ /** Stable class name from a string (identical state specs share one injected rule). */
34
+ function hashStr(s: string): string {
35
+ let h = 0;
36
+ for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
37
+ return (h >>> 0).toString(36);
38
+ }
39
+
40
+ // Interaction states are CSS pseudo-classes — inline styles can't express them, so we
41
+ // resolve each state's tokens → a scoped rule and inject it (deduped by class hash).
42
+ // Generic vocab (any element), values served; the SDK authors none. Add a state here.
43
+ const INTERACTION_STATES: [string, string][] = [
44
+ ["hover", ":hover"],
45
+ ["active", ":active"],
46
+ ];
47
+
48
+ // A state rule is a pure function of (spec object, theme) — and spec objects come from
49
+ // cached defs, so they're referentially stable. Resolve + serialize + hash once, not
50
+ // per element per render.
51
+ const stateRuleCache = new WeakMap<ResolvedTheme, WeakMap<object, { cls: string; rule: string }>>();
52
+ function stateRule(theme: ResolvedTheme, spec: Record<string, unknown>, key: string, pseudo: string): { cls: string; rule: string } {
53
+ let bySpec = stateRuleCache.get(theme);
54
+ if (!bySpec) {
55
+ bySpec = new WeakMap();
56
+ stateRuleCache.set(theme, bySpec);
57
+ }
58
+ let hit = bySpec.get(spec);
59
+ if (!hit) {
60
+ // !important — the base style is inline, which beats a class selector otherwise.
61
+ const decls = cssDecls(styleToCss(spec, theme), true);
62
+ const cls = `uno-${key}-${hashStr(decls)}`;
63
+ hit = { cls, rule: `.${cls}${pseudo}{${decls}}` };
64
+ bySpec.set(spec, hit);
65
+ }
66
+ return hit;
67
+ }
68
+
69
+ /**
70
+ * Resolve the generic per-element "chrome" shared by interactive primitives:
71
+ * the base inline style, hover/active rules (injected as <style>), and the
72
+ * data-driven `disabled` state (known at render via `disabledWhen` → merges the
73
+ * `disabled` style + sets the attr). All values are served; this authors none.
74
+ */
75
+ function nodeChrome(node: UnoverseNode, data: Record<string, unknown>, theme: ResolvedTheme) {
76
+ const base = styleToCss(node.style, theme, data);
77
+ const disabled = node.disabledWhen ? !!data[node.disabledWhen] : false;
78
+ const style = disabled && node.style?.disabled ? { ...base, ...styleToCss(node.style.disabled as Record<string, unknown>, theme) } : base;
79
+ const classes: string[] = [];
80
+ const styleEls: ReactNode[] = [];
81
+ const s = node.style as Record<string, unknown> | undefined;
82
+ for (const [key, pseudo] of INTERACTION_STATES) {
83
+ const spec = s?.[key];
84
+ if (!spec) continue;
85
+ const { cls, rule } = stateRule(theme, spec as Record<string, unknown>, key, pseudo);
86
+ classes.push(cls);
87
+ styleEls.push(<style key={key}>{rule}</style>);
88
+ }
89
+ // `hideBelow` — responsive visibility via a CONTAINER query (inline styles can't express
90
+ // one): hides the element when its nearest container ancestor (an element with the
91
+ // `container` style) is narrower than a SERVED width token — e.g. drop a side image when
92
+ // the COMPONENT (not the viewport) is mobile-width. Correct for SDUI: a component reacts to
93
+ // its slot, not the page. Generic vocab, value served; the SDK authors none.
94
+ if (s?.hideBelow) {
95
+ const w = theme.space[s.hideBelow as string] ?? (s.hideBelow as string);
96
+ const cls = `uno-hidebelow-${hashStr(String(w))}`;
97
+ classes.push(cls);
98
+ styleEls.push(<style key="hideBelow">{`@container (max-width:${w}){.${cls}{display:none !important}}`}</style>);
99
+ }
100
+ // `hideAbove` — the mirror: hide when the container is WIDER than the width. Together
101
+ // these are the whole layout-switch mechanism for space-responsive components: the
102
+ // compact layout carries hideAbove, the full layout hideBelow, and the container's
103
+ // width (granted by the template's surface) picks the rendering — never a state key.
104
+ if (s?.hideAbove) {
105
+ const w = theme.space[s.hideAbove as string] ?? (s.hideAbove as string);
106
+ const cls = `uno-hideabove-${hashStr(String(w))}`;
107
+ classes.push(cls);
108
+ styleEls.push(<style key="hideAbove">{`@container (min-width:${w}){.${cls}{display:none !important}}`}</style>);
109
+ }
110
+ return { className: classes.join(" ") || undefined, styleEls, style, disabled };
111
+ }
112
+
113
+ /** `Box`/`Stack`/`Row`/`Column` — the generic container (`<div>`). Children are
114
+ * pre-rendered by the dispatcher; layout/look come entirely from node.style. */
115
+ export function BoxView({ node, data, theme, children }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; children?: ReactNode }): ReactNode {
116
+ const { className, styleEls, style } = nodeChrome(node, data, theme);
117
+ const scrollRef = useAutoScrollToEnd(node.autoScroll === true);
118
+ const revealRef = useRevealWhenStuck(node.revealOnStick === true);
119
+ return (
120
+ <div ref={scrollRef ?? revealRef} className={className} style={style}>
121
+ {styleEls}
122
+ {children}
123
+ </div>
124
+ );
125
+ }
126
+
127
+ /**
128
+ * Generic "stick to the newest content" behavior for a scroll container (opt-in via
129
+ * `node.autoScroll`). Smooth-scrolls to the bottom whenever content grows — a new turn
130
+ * (childList change) OR streaming text (a child resizing) — but only while the user is
131
+ * already near the bottom, so scrolling up to read history is never yanked back down.
132
+ * NOT chat-specific: any `overflow:auto` Box can opt in. The SDK owns behaviour, never
133
+ * styles — the scroll container itself is defined in `rx/` (the conversation state box).
134
+ */
135
+ function useAutoScrollToEnd(enabled: boolean) {
136
+ const ref = useRef<HTMLDivElement | null>(null);
137
+ useEffect(() => {
138
+ if (!enabled) return;
139
+ const el = ref.current;
140
+ if (!el) return;
141
+ const nearBottom = () => el.scrollHeight - el.scrollTop - el.clientHeight < 140;
142
+ const stick = () => {
143
+ if (nearBottom()) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
144
+ };
145
+ el.scrollTop = el.scrollHeight; // land at the end on mount (no animation)
146
+ const ro = new ResizeObserver(stick); // catches streaming text growing a turn's height
147
+ const observeKids = () => {
148
+ for (const kid of Array.from(el.children)) ro.observe(kid);
149
+ };
150
+ observeKids();
151
+ const mo = new MutationObserver(() => {
152
+ observeKids(); // new turns → observe them too
153
+ stick();
154
+ });
155
+ mo.observe(el, { childList: true, subtree: true });
156
+ return () => {
157
+ ro.disconnect();
158
+ mo.disconnect();
159
+ };
160
+ }, [enabled]);
161
+ return enabled ? ref : undefined;
162
+ }
163
+
164
+ /**
165
+ * Generic "sticky bar that reveals only once you've scrolled past the element before it"
166
+ * (opt-in via `node.revealOnStick`). Pins the bar at the top (its own `position: sticky`
167
+ * + `top` in `rx/`) and, using an IntersectionObserver on the bar's PREVIOUS sibling as
168
+ * the sentinel (e.g. a hero image), keeps it hidden while that sibling is on screen and
169
+ * fades it in once the sibling scrolls away. A negative margin removes the bar's flow
170
+ * footprint so it OVERLAYS the content (no gap at the top). NOT card-specific: any sticky
171
+ * bar can opt in. The SDK owns behaviour, never styles — the bar's look lives in `rx/`.
172
+ */
173
+ function useRevealWhenStuck(enabled: boolean) {
174
+ const ref = useRef<HTMLDivElement | null>(null);
175
+ useEffect(() => {
176
+ if (!enabled) return;
177
+ const el = ref.current;
178
+ if (!el) return;
179
+ const sentinel = el.previousElementSibling; // reveal the bar once THIS scrolls away
180
+ // Overlay, not push: cancel the bar's own height so content sits under it, bar pins on top.
181
+ el.style.marginBottom = `-${el.offsetHeight}px`;
182
+ el.style.transition = "opacity 200ms ease, transform 200ms ease";
183
+ const hide = () => {
184
+ el.style.opacity = "0";
185
+ el.style.transform = "translateY(-8px)";
186
+ el.style.pointerEvents = "none";
187
+ };
188
+ const show = () => {
189
+ el.style.opacity = "1";
190
+ el.style.transform = "translateY(0)";
191
+ el.style.pointerEvents = "auto";
192
+ };
193
+ hide();
194
+ if (!sentinel) return void show(); // no sentinel to hide behind → just show
195
+ const io = new IntersectionObserver(([e]) => (e.isIntersecting ? hide() : show()), { threshold: 0 });
196
+ io.observe(sentinel);
197
+ return () => io.disconnect();
198
+ }, [enabled]);
199
+ return enabled ? ref : undefined;
200
+ }
201
+
202
+ /** `Text` — a bound string (`<span>`). */
203
+ export function TextView({ node, data, theme }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme }): ReactNode {
204
+ // Literal `value` fallback: components are microapps — static copy is HARDCODED in the
205
+ // 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>;
207
+ }
208
+
209
+ /** `Image` — a bound OR literal src (`<img>`). */
210
+ 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 ?? ""} />;
212
+ }
213
+
214
+ /** `Button` — fires `onAction(node.action, data)`. Content is a bound label OR composed
215
+ * children (so an Icon can sit in a button). The SDK authors no button styling. */
216
+ export function ButtonView({ node, data, theme, onAction, children }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; onAction?: ActionHandler; children?: ReactNode }): ReactNode {
217
+ const { className, styleEls, style, disabled } = nodeChrome(node, data, theme);
218
+ return (
219
+ <button className={className} style={style} disabled={disabled} onClick={() => onAction?.(node.action ?? "click", data)}>
220
+ {styleEls}
221
+ {node.children?.length ? children : bound(node.bind?.label, data)}
222
+ </button>
223
+ );
224
+ }
225
+
226
+ /** Unknown node type — render children so the tree doesn't break (degenerate fallback). */
227
+ export function UnknownView({ node, theme, children }: { node: UnoverseNode; theme: ResolvedTheme; children?: ReactNode }): ReactNode {
228
+ return (
229
+ <div style={styleToCss(node.style, theme)} data-unoverse-unknown={node.type}>
230
+ {children}
231
+ </div>
232
+ );
233
+ }
234
+
235
+ /**
236
+ * `Icon` — leaf primitive (the irreducible SVG, like Text→<span>/Image→<img>).
237
+ * Renders a named glyph whose element DATA is SERVED (theme.icons): the server sources
238
+ * it from an icon pack (control plane) and serves `{ viewBox, attrs, children }`, where
239
+ * children are raw SVG elements `[tag, attrs]`. The SDK NEVER bundles a pack — it's
240
+ * pack-agnostic, just rendering served elements. Size = node.style width/height, colour
241
+ * = node.style color (→ `currentColor`). Name is literal (`node.icon`) or bound
242
+ * (`bind.name`). NOT a composite — one glyph, no buttons/chrome.
243
+ */
244
+ export function IconView({ name, style, theme }: { name?: string; style?: Record<string, unknown>; theme: ResolvedTheme }): ReactNode {
245
+ const set = (theme.icons ?? {}) as Record<string, { viewBox?: string; attrs?: Record<string, unknown>; children?: [string, Record<string, unknown>][] }>;
246
+ const ic = name ? set[name] : undefined;
247
+ if (!ic) return null;
248
+ const children = Array.isArray(ic.children) ? ic.children : [];
249
+ return (
250
+ <svg viewBox={ic.viewBox ?? "0 0 24 24"} {...(ic.attrs ?? {})} aria-hidden="true" style={styleToCss(style ?? {}, theme)}>
251
+ {children.map(([tag, attrs], i) => createElement(tag, { key: i, ...attrs }))}
252
+ </svg>
253
+ );
254
+ }
255
+
256
+ /**
257
+ * `Skeleton` — built-in loading placeholder. ALL dimensions are DATA, fetched
258
+ * from the theme (theme.skeleton ← rx/styles/semantic/skeleton.json). The SDK
259
+ * authors nothing here but structure (flex/full). `bars` are 'width height' pairs.
260
+ */
261
+ export function SkeletonView({ variant = "text", theme }: { variant?: string; theme: ResolvedTheme }): ReactNode {
262
+ // DEFENSIVE BY LAW: a loading GHOST must never crash the app. Every token read
263
+ // tolerates absence (missing token = that dimension simply unset — we still author
264
+ // no values); a theme without a skeleton recipe renders inert placeholders, never
265
+ // a TypeError that unmounts the whole tree (observed live: blank panel).
266
+ const sk = (theme.skeleton ?? {}) as Record<string, any>;
267
+ // SELF-CONTAINED shimmer: the loading ghost is generic chrome, so its motion lives
268
+ // WITH the primitive — no cross-file dependency on an org's keyframes recipe that
269
+ // silently breaks when absent. The sweep is a neutral white-alpha overlay (brand
270
+ // colors still come from the served `fill`); a served `skeleton.shimmer` object
271
+ // overrides it, and `skeleton.shimmer: false` disables motion entirely.
272
+ const SHIMMER_BUILTIN: CSSProperties = {
273
+ backgroundImage: "linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.55) 50%, rgba(255,255,255,0) 100%)",
274
+ backgroundSize: "200% 100%",
275
+ animation: "uno-skeleton-shimmer 1.4s ease-in-out infinite",
276
+ };
277
+ const shimmer: CSSProperties = sk.shimmer === false ? {} : typeof sk.shimmer === "object" ? (sk.shimmer as CSSProperties) : SHIMMER_BUILTIN;
278
+ const shimmerKeyframes =
279
+ sk.shimmer === false ? null : (
280
+ <style>{"@keyframes uno-skeleton-shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}"}</style>
281
+ );
282
+ const bar = (spec: string, i: number): ReactNode => {
283
+ const [w, h] = String(spec).split(" ");
284
+ return <div key={i} style={{ width: w, height: h, background: sk.fill, borderRadius: sk.barRadius, ...shimmer }} />;
285
+ };
286
+ if (variant === "image")
287
+ return (
288
+ <>
289
+ {shimmerKeyframes}
290
+ <div style={{ width: "100%", height: "100%", minHeight: sk.image?.minHeight, background: sk.fill, borderRadius: sk.radius, ...shimmer }} />
291
+ </>
292
+ );
293
+ if (variant === "card")
294
+ return (
295
+ <>
296
+ {shimmerKeyframes}
297
+ <div
298
+ style={{
299
+ display: "flex",
300
+ flexDirection: "column",
301
+ gap: sk.gap,
302
+ padding: sk.card?.padding,
303
+ border: theme.borderWidth?.thin && sk.fill ? `${theme.borderWidth.thin} solid ${sk.fill}` : undefined,
304
+ borderRadius: sk.radius,
305
+ }}
306
+ >
307
+ {(Array.isArray(sk.card?.bars) ? (sk.card.bars as string[]) : []).map(bar)}
308
+ </div>
309
+ </>
310
+ );
311
+ return (
312
+ <div style={{ display: "flex", flexDirection: "column", gap: sk.gap }}>
313
+ {shimmerKeyframes}
314
+ {(Array.isArray(sk.text?.bars) ? (sk.text.bars as string[]) : []).map(bar)}
315
+ </div>
316
+ );
317
+ }
318
+
319
+ /**
320
+ * `Input` — single-line composer (the irreducible `<input>`, like Text→<span>).
321
+ * Holds its own draft text (local, not store state); on Enter it fires
322
+ * `onAction(node.action, { text })` and clears. The SDK authors NO styling here —
323
+ * look comes entirely from `node.style` (data), exactly like Button. `placeholder`
324
+ * is copy (content), not a style value.
325
+ *
326
+ * ⛔ DO NOT add icons, send buttons, pills, or any composer chrome here. That is
327
+ * UX — compose it in a DEFINITION from generic primitives (Box + Icon + Input).
328
+ * A primitive renders ONE generic element; it never encodes a specific layout.
329
+ */
330
+ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; onAction?: ActionHandler }): ReactNode {
331
+ // CONTROLLED when `bind.value` is set: value comes from shared data and changes are
332
+ // emitted as an "input" action (the channel writes it to the store). This is what lets
333
+ // a sibling send button submit the draft. Otherwise the field keeps its own local text.
334
+ const field = node.bind?.value;
335
+ const [local, setLocal] = useState("");
336
+ const value = field != null ? String(data[field] ?? "") : local;
337
+ // CONTROLLED: report the change as an "input" event carrying WHICH field changed.
338
+ // The component dispatcher writes that field into this instance's slice (local two-way
339
+ // binding, §2e-3); the template composer's channel reads `text` and ignores `field`
340
+ // (its bound field is the shared `draft`). The SDK authors no UX — just the fact.
341
+ const setValue = (t: string) => (field != null ? onAction?.("input", { text: t, field }) : setLocal(t));
342
+ const submit = () => {
343
+ const t = value.trim();
344
+ if (!t) return;
345
+ onAction?.(node.action ?? "submit", { text: t });
346
+ if (field == null) setLocal("");
347
+ };
348
+ const { style, disabled } = nodeChrome(node, data, theme);
349
+ return (
350
+ <input
351
+ style={style}
352
+ disabled={disabled}
353
+ value={value}
354
+ placeholder={node.placeholder}
355
+ onChange={(e) => setValue(e.target.value)}
356
+ onKeyDown={(e) => {
357
+ if (e.key === "Enter" && !e.shiftKey) {
358
+ e.preventDefault();
359
+ submit();
360
+ }
361
+ }}
362
+ />
363
+ );
364
+ }
365
+
366
+ /**
367
+ * markdown-to-jsx overrides for the `Markdown` primitive.
368
+ *
369
+ * ⛔ AUTHORS ZERO STYLE VALUES. Every element style is read verbatim from the
370
+ * SERVED prose recipe (`theme.prose` ← rx/styles/semantic/prose.json). The SDK
371
+ * only maps element → recipe key and supplies BEHAVIOUR (target=_blank, image
372
+ * error-fallback). No hex, no px/rem, no weights here — they live in rx/.
373
+ *
374
+ * Built once per theme (stable component identities so streaming re-renders don't
375
+ * remount images and drop their broken-state).
376
+ */
377
+ function buildMarkdownOverrides(theme: ResolvedTheme, inheritLinkColor = false) {
378
+ const p = theme.prose as Record<string, CSSProperties | Record<string, string> | string>;
379
+ const s = (k: string) => (p[k] as CSSProperties | undefined) ?? {};
380
+ // Heading element → served text-style NAME (the map lives in the recipe, not here).
381
+ const headings = (p.heading as Record<string, string>) ?? {};
382
+ const head = (tag: string) => (theme.text[headings[tag]] as CSSProperties | undefined) ?? {};
383
+
384
+ // Served external-link glyph (theme.icons.externalLink), styled by served prose.linkIcon.
385
+ // The icon inherits the link's colour via `currentColor`. The SDK authors no geometry.
386
+ const li = (theme.icons?.externalLink ?? {}) as { viewBox?: string; attrs?: Record<string, unknown>; children?: [string, Record<string, unknown>][] };
387
+ const linkArrow = li.children?.length ? (
388
+ <svg viewBox={li.viewBox ?? "0 0 24 24"} {...(li.attrs ?? {})} aria-hidden="true" style={s("linkIcon")}>
389
+ {li.children.map(([tag, attrs], i) => createElement(tag, { key: i, ...attrs }))}
390
+ </svg>
391
+ ) : null;
392
+ // A definition that declares `color` on its Markdown node owns its anchors'
393
+ // color too (`inherit` — no value authored here); the served recipe stays the
394
+ // default for undeclared nodes (chat prose).
395
+ const ExternalLink = ({ children, ...a }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
396
+ <a {...a} target="_blank" rel="noopener noreferrer" style={inheritLinkColor ? { ...s("link"), color: "inherit" } : s("link")}>
397
+ {children}
398
+ {linkArrow}
399
+ </a>
400
+ );
401
+ // Wide tables scroll instead of crushing columns (legacy ScrollableTable).
402
+ const ScrollableTable = (a: React.TableHTMLAttributes<HTMLTableElement>) => (
403
+ <div style={s("tableWrap")}>
404
+ <table {...a} style={s("table")} />
405
+ </div>
406
+ );
407
+ // Broken external image URLs degrade to alt text, not a broken-image icon.
408
+ const MarkdownImage = ({ src, alt, title }: React.ImgHTMLAttributes<HTMLImageElement>) => {
409
+ const [broken, setBroken] = useState(false);
410
+ if (!src || broken) return <span style={s("imageFallback")}>{alt || "Image unavailable"}</span>;
411
+ return (
412
+ <img src={src as string} alt={alt || ""} title={title} loading="lazy" decoding="async" onError={() => setBroken(true)} style={s("image")} />
413
+ );
414
+ };
415
+
416
+ return {
417
+ p: { props: { style: s("paragraph") } },
418
+ a: { component: ExternalLink },
419
+ table: { component: ScrollableTable },
420
+ img: { component: MarkdownImage },
421
+ th: { props: { style: { ...s("cell"), ...s("headerCell") } } },
422
+ td: { props: { style: s("cell") } },
423
+ ul: { props: { style: { ...s("list"), listStyleType: p.bulletStyle as string } } },
424
+ ol: { props: { style: { ...s("list"), listStyleType: p.orderedStyle as string } } },
425
+ li: { props: { style: s("listItem") } },
426
+ h1: { props: { style: head("h1") } },
427
+ h2: { props: { style: head("h2") } },
428
+ h3: { props: { style: head("h3") } },
429
+ h4: { props: { style: head("h4") } },
430
+ h5: { props: { style: head("h5") } },
431
+ h6: { props: { style: head("h6") } },
432
+ };
433
+ }
434
+
435
+ /**
436
+ * `Markdown` leaf primitive (SPEC §2d:197). Renders a bound markdown string; the
437
+ * web impl is markdown-to-jsx (a per-platform primitive impl, like Text→<span>).
438
+ * Container takes node.style (tokens); elements map to served theme tokens via
439
+ * buildMarkdownOverrides. The SDK authors ZERO style values.
440
+ */
441
+ export function MarkdownView({ node, data, theme }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme }): ReactNode {
442
+ const value = (node.bind?.value ? (data[node.bind.value] as string) : "") ?? "";
443
+ // Explicit opt-in ONLY (a CTA pill whose label must match its declared color):
444
+ // `inheritLinkColor: true` on the node. Declaring a body color alone never
445
+ // hijacks anchors — chat prose keeps the served link recipe.
446
+ const inheritLink = (node as { inheritLinkColor?: boolean }).inheritLinkColor === true;
447
+ const overrides = useMemo(() => buildMarkdownOverrides(theme, inheritLink), [theme, inheritLink]);
448
+ return (
449
+ <div style={styleToCss(node.style, theme, data)}>
450
+ <Markdown options={{ overrides, forceBlock: true }}>{value}</Markdown>
451
+ </div>
452
+ );
453
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Audio utility functions for PCM conversion and processing.
3
+ * Ported verbatim from gravity-client/src/realtime/audioUtils.ts (the proven,
4
+ * live implementation). Only the two helpers the continuous-capture path uses.
5
+ */
6
+
7
+ /**
8
+ * Convert Float32Array audio (range -1..1, from the Web Audio API) to Int16Array
9
+ * PCM (16-bit signed).
10
+ */
11
+ export function float32ToInt16(float32Audio: Float32Array): Int16Array {
12
+ const int16Data = new Int16Array(float32Audio.length);
13
+ for (let i = 0; i < float32Audio.length; i++) {
14
+ const s = Math.max(-1, Math.min(1, float32Audio[i]));
15
+ int16Data[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
16
+ }
17
+ return int16Data;
18
+ }
19
+
20
+ /**
21
+ * Downsample a Float32 PCM frame from `fromRate` to `toRate` using linear
22
+ * interpolation. Returns the source frame unchanged if rates already match.
23
+ *
24
+ * Mic capture picks the native device rate (typically 44100 / 48000 Hz) and the
25
+ * realtime LLM expects 16000 Hz.
26
+ */
27
+ export function downsampleFloat32(input: Float32Array, fromRate: number, toRate: number): Float32Array {
28
+ if (fromRate === toRate || input.length === 0) return input;
29
+ const ratio = fromRate / toRate;
30
+ const outLen = Math.floor(input.length / ratio);
31
+ const out = new Float32Array(outLen);
32
+ for (let i = 0; i < outLen; i++) {
33
+ const srcIdx = i * ratio;
34
+ const i0 = Math.floor(srcIdx);
35
+ const i1 = Math.min(i0 + 1, input.length - 1);
36
+ const frac = srcIdx - i0;
37
+ out[i] = input[i0] * (1 - frac) + input[i1] * frac;
38
+ }
39
+ return out;
40
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Shared types for realtime audio streaming.
3
+ *
4
+ * Ported verbatim from gravity-client/src/realtime/types.ts (the proven, live
5
+ * implementation) — the Unoverse `voice` native service reuses the same audio
6
+ * lane (`/ws/gravity`) and the same control-channel AUDIO_STATE vocabulary, so
7
+ * the contract is identical. See docs/VOICE_STREAMING_GUIDE.md.
8
+ */
9
+
10
+ export type ConnectionStatus = "idle" | "connecting" | "connected" | "error" | "ended";
11
+
12
+ /**
13
+ * Control-channel audio state events emitted by the server, in lockstep with the
14
+ * audio frames on the SAME WS lane (they are the control half of the live-audio
15
+ * conversation — NOT component/UI data; they never ride the MCP stream).
16
+ */
17
+ export type AudioState =
18
+ | "SESSION_READY"
19
+ | "SESSION_ENDED"
20
+ | "SPEECH_STARTED"
21
+ | "SPEECH_ENDED"
22
+ | "USER_SPEECH_STARTED"
23
+ | "USER_SPEECH_ENDED"
24
+ | "TOOL_USE"
25
+ | "TOOL_USE_COMPLETED";
26
+
27
+ export interface AudioStateMessage {
28
+ type: "audioState";
29
+ state: AudioState;
30
+ metadata?: Record<string, unknown>;
31
+ }
32
+
33
+ export interface ControlMessage {
34
+ type: string;
35
+ state?: AudioState;
36
+ audioState?: AudioState;
37
+ metadata?: Record<string, unknown>;
38
+ [key: string]: unknown;
39
+ }
40
+
41
+ export interface AudioStateEvent {
42
+ state: AudioState;
43
+ metadata?: Record<string, unknown>;
44
+ message?: string;
45
+ }