@unoverse-platform/studio 0.1.101 → 0.1.102

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 (34) hide show
  1. package/local/sample-identity/brand.yaml +2 -0
  2. package/package.json +1 -1
  3. package/screens/TemplatesLibraryView.tsx +58 -98
  4. package/screens/TemplatesView.tsx +16 -0
  5. package/vendor/base/src/definitions/load/assets.ts +65 -0
  6. package/vendor/base/src/definitions/load/compile/component.ts +1 -0
  7. package/vendor/base/src/definitions/load/compile/index.ts +1 -1
  8. package/vendor/base/src/definitions/load/compile/sections.ts +26 -116
  9. package/vendor/base/src/definitions/load/compile/template.ts +41 -25
  10. package/vendor/base/src/definitions/load/expand/walk.ts +4 -1
  11. package/vendor/base/src/definitions/load/load.ts +6 -0
  12. package/vendor/base/src/definitions/model.ts +20 -41
  13. package/vendor/base/src/definitions/registry/homes.ts +9 -1
  14. package/vendor/base/src/lint/design/file.mjs +29 -5
  15. package/vendor/base/src/lint/design/vocabulary.mjs +1 -1
  16. package/vendor/base/src/lint/design/walk.mjs +31 -156
  17. package/vendor/base/src/manifests/executor/context.ts +1 -1
  18. package/vendor/base/src/manifests/executor/toolBridge.ts +3 -29
  19. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +35 -12
  20. package/vendor/base/src/manifests/runtime/duplex/session.ts +123 -48
  21. package/vendor/base/src/manifests/runtime/index.ts +1 -1
  22. package/vendor/base/src/platform/audioManager.ts +89 -14
  23. package/vendor/base/src/platform/clientTransport.ts +8 -0
  24. package/vendor/base/src/security/permissions.ts +2 -2
  25. package/vendor/sdk/appEngine.tsx +104 -46
  26. package/vendor/sdk/lib/UnoverseComponent.tsx +58 -5
  27. package/vendor/sdk/lib/app.tsx +9 -5
  28. package/vendor/sdk/lib/connection.tsx +14 -3
  29. package/vendor/sdk/lib/core/actions.ts +5 -2
  30. package/vendor/sdk/lib/index.ts +2 -2
  31. package/vendor/sdk/lib/primitives.tsx +38 -4
  32. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +6 -1
  33. package/vendor/sdk/lib/voice.tsx +96 -13
  34. package/vendor/sdk/pageInterfaces.tsx +383 -0
@@ -10,10 +10,13 @@
10
10
  */
11
11
  import { useEffect, useMemo, useRef, useState } from "react";
12
12
  import { postToEmbedHost } from "./mcpAppBridge";
13
+ import { PageInterfaces, type Binding } from "./pageInterfaces";
13
14
  import {
14
15
  UnoverseClient,
15
16
  ComponentStore,
16
17
  StreamedUnoverseApp,
18
+ INERT_VOICE,
19
+ useVoiceService,
17
20
  useUnoverseConnection,
18
21
  useUnoverseTheme,
19
22
  computeAppWidth,
@@ -23,6 +26,7 @@ import {
23
26
  setAnalyticsDebug,
24
27
  type UseVoiceServiceConfig,
25
28
  type UnoverseDefinition,
29
+ type ActionMeta,
26
30
  } from "./lib";
27
31
 
28
32
  export interface AppConfig {
@@ -184,6 +188,48 @@ export function AppEngine(cfg: AppConfig) {
184
188
  return store.subscribe(report);
185
189
  }, [store, def, theme]);
186
190
 
191
+ // VOICE (§2e-1): a `service: "voice"` app runs the native voice service — which now lives
192
+ // in the shared renderer (StreamedUnoverseApp), identical to Studio. The host only
193
+ // supplies the connection identity it already holds; startCall/endCall/toggleMute are
194
+ // answered inside the renderer, so this host wires NO voice buttons. Absent → inert.
195
+ const voiceConfig = useMemo<UseVoiceServiceConfig | undefined>(
196
+ () =>
197
+ // THE SERVICE NEEDS NO WORKFLOW OF ITS OWN. It is the page's microphone, speaker and
198
+ // audio lane; a call names the workflow it runs (a page template's binding,
199
+ // UNOVERSE_HOST_PAGE.md rule 4), and an app with a binding lends it as the default.
200
+ binding.service === "voice"
201
+ ? {
202
+ // START_CALL REST triggers the workflow on the gateway (user token). The audio
203
+ // WS lane is derived by the SDK from the client's server origin (see the renderer)
204
+ // — the host passes NO wsUrl (its srcdoc `location.origin` is "null").
205
+ apiUrl: apiUrl ?? serverUrl,
206
+ getAccessToken: getAccessToken as UseVoiceServiceConfig["getAccessToken"],
207
+ session: {
208
+ userId: userId ?? "",
209
+ conversationId,
210
+ chatId: conversationId,
211
+ workflowId: binding.workflow ?? "",
212
+ targetTriggerNode: binding.trigger ?? "",
213
+ },
214
+ // Producer: writes callState/isMuted into TEMPLATE STATE so the voice states switch.
215
+ store,
216
+ }
217
+ : undefined,
218
+ [binding.service, binding.workflow, binding.trigger, apiUrl, serverUrl, getAccessToken, userId, conversationId, store],
219
+ );
220
+
221
+ // ONE CALL FOR THE PAGE. The service runs here, once, so the app and the page's own
222
+ // templates (an avatar in the navigation) start, follow and end the same call.
223
+ const voice = useVoiceService(
224
+ voiceConfig ? { ...voiceConfig, wsUrl: client.serverOrigin.replace(/^http/, "ws") } : INERT_VOICE,
225
+ );
226
+
227
+ // CONNECTED TO THE TASK UNTIL THE PAGE CLOSES. The avatar is a button that starts a task's
228
+ // workflow; from that press the page is in the task (its steps, its submit) until the task
229
+ // closes or the visitor leaves. Whether the workflow has a voice is its own business, so
230
+ // nothing here waits on a call (UNOVERSE_HOST_PAGE.md rule 4).
231
+ const [inTask, setInTask] = useState(false);
232
+
187
233
  // SDK's native connection: opens /stream, register_session, feeds the store. Outbound
188
234
  // sendMessage fires the workflow DIRECTLY (REST, user token) — no server broker.
189
235
  const conn = useUnoverseConnection(
@@ -197,9 +243,16 @@ export function AppEngine(cfg: AppConfig) {
197
243
  appId,
198
244
  },
199
245
  store,
200
- // Open only once BOTH are ready: the user identity AND the manifest binding (read async).
201
- // Avoids the transient "connection missing workflowId/userId/conversationId" warning.
202
- { enabled: !!userId && !!binding.workflow, onAppSwitch: switchApp },
246
+ // Open once the user is known AND there is something to hear from: the app's own workflow,
247
+ // or a task the page is in. An app with no workflow (a website's page app) connects when the
248
+ // avatar starts a task and stays connected to it; nothing is held open while the visitor
249
+ // only reads.
250
+ {
251
+ enabled: !!userId && (!!binding.workflow || voice.state.isCallActive || inTask),
252
+ onAppSwitch: switchApp,
253
+ // A voice in the running workflow asks the page in: join its call, never start one.
254
+ onJoinCall: (workflowId) => void voice.joinCall(workflowId),
255
+ },
203
256
  );
204
257
 
205
258
  // STATUS TO THE HOST. In native-host mode the iframe owns the only real
@@ -225,39 +278,59 @@ export function AppEngine(cfg: AppConfig) {
225
278
  });
226
279
  }, [conn.isReady, conn.isConnected, conn.refusal, failure]);
227
280
 
228
- // VOICE (§2e-1): a `service: "voice"` app runs the native voice service — which now lives
229
- // in the shared renderer (StreamedUnoverseApp), identical to Studio. The host only
230
- // supplies the connection identity it already holds; startCall/endCall/toggleMute are
231
- // answered inside the renderer, so this host wires NO voice buttons. Absent → inert.
232
- const voiceConfig = useMemo<UseVoiceServiceConfig | undefined>(
233
- () =>
234
- binding.service === "voice" && binding.workflow
235
- ? {
236
- // START_CALL REST triggers the workflow on the gateway (user token). The audio
237
- // WS lane is derived by the SDK from the client's server origin (see the renderer)
238
- // — the host passes NO wsUrl (its srcdoc `location.origin` is "null").
239
- apiUrl: apiUrl ?? serverUrl,
240
- getAccessToken: getAccessToken as UseVoiceServiceConfig["getAccessToken"],
241
- session: {
242
- userId: userId ?? "",
243
- conversationId,
244
- chatId: conversationId,
245
- workflowId: binding.workflow,
246
- targetTriggerNode: binding.trigger ?? "",
247
- },
248
- // Producer: writes callState/isMuted into TEMPLATE STATE so the voice states switch.
249
- store,
250
- }
251
- : undefined,
252
- [binding.service, binding.workflow, binding.trigger, apiUrl, serverUrl, getAccessToken, userId, conversationId, store],
253
- );
281
+ // THE SPEC'S ANSWERS, AND NOTHING ELSE (UNOVERSE_MCP_COMPLETE_GUIDE.md, "How the SDK answers an
282
+ // elicitation"): while the server is ELICITING on this session, a component's `submit` answers
283
+ // `accept` with its slice and its `cancel` answers `cancel`. Every other action (a state it
284
+ // enters, a step, a tab) is the component's own and goes to the server as usual. Without a
285
+ // pending elicitation, a submit falls back to the data-plane `user_action` waiter (a workflow
286
+ // node waiting on a component). One way for every component, the app's and the page's alike.
287
+ const toServer = (t: string, data: Record<string, unknown>, meta?: ActionMeta) => {
288
+ const state = (meta?.state ?? data) as Record<string, unknown>;
289
+ if ((t === "submit" || t === "cancel") && conn.resolveElicitation(state, t === "cancel" ? "cancel" : "accept")) return;
290
+ conn.sendAction(t, {
291
+ componentNodeId: meta?.nodeId,
292
+ componentType: meta?.type,
293
+ // The component's OWN chatId is the TURN id — the waiting node execution is
294
+ // keyed `<turn>:<nodeId>`, not the conversation-level chatId.
295
+ componentChatId: meta?.chatId,
296
+ componentState: state,
297
+ });
298
+ };
299
+
300
+ // A PAGE TEMPLATE'S TASK, started from its avatar: the template's own workflow, run at its
301
+ // trigger over REST with the user's token, as any request to a workflow (§5a). `metadata.task`
302
+ // names the task its Task Runner loads. From here the page is in the task.
303
+ const startTask = async (b: Binding, task: string) => {
304
+ if (!b.workflow) return;
305
+ setInTask(true);
306
+ const token = getAccessToken ? await getAccessToken() : null;
307
+ fetch(`${apiUrl ?? serverUrl}/api/workflows/${b.workflow}/execute`, {
308
+ method: "POST",
309
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
310
+ body: JSON.stringify({
311
+ input: {
312
+ message: "Start",
313
+ chatId: conversationId,
314
+ conversationId,
315
+ userId,
316
+ providerId: "gravity-ds",
317
+ metadata: { targetTriggerNode: b.trigger, workflowId: b.workflow, task },
318
+ },
319
+ conversationId,
320
+ }),
321
+ }).catch((e) => console.error("[unoverse] task start failed", e));
322
+ };
254
323
 
255
324
  return (
325
+ <>
326
+ {/* The page's own templates, drawn into its elements by this one runtime (pageInterfaces.tsx). */}
327
+ <PageInterfaces org={appId.split("/")[0]} client={client} voice={voice} appStore={store} toServer={toServer} startTask={startTask} />
256
328
  <StreamedUnoverseApp
257
329
  client={client}
258
330
  store={store}
259
331
  uri={`unoverse://apps/${appId}/definition`}
260
332
  voice={voiceConfig}
333
+ voiceService={voice}
261
334
  onAction={(action, data, meta) => {
262
335
  const t = typeof action === "string" ? action : (action as any)?.type;
263
336
  if (t === "close") {
@@ -312,24 +385,9 @@ export function AppEngine(cfg: AppConfig) {
312
385
  store.setDraft(typeof (data as any)?.text === "string" ? (data as any).text : "");
313
386
  return;
314
387
  }
315
- // THE SPEC'S ANSWERS, AND NOTHING ELSE (UNOVERSE_MCP_COMPLETE_GUIDE.md, "How the SDK
316
- // answers an elicitation"): while the server is ELICITING on this session, the
317
- // component's `submit` answers `accept` with its slice and its `cancel` answers
318
- // `cancel`. Every other action (a state it enters, a step, a tab) is the component's
319
- // own and goes to the server as usual. Without a pending elicitation, a submit falls
320
- // back to the data-plane `user_action` waiter (a workflow node waiting on a component).
321
- if (!t) return;
322
- const state = (meta?.state ?? data) as Record<string, unknown>;
323
- if ((t === "submit" || t === "cancel") && conn.resolveElicitation(state, t === "cancel" ? "cancel" : "accept")) return;
324
- conn.sendAction(t, {
325
- componentNodeId: meta?.nodeId,
326
- componentType: meta?.type,
327
- // The component's OWN chatId is the TURN id — the waiting node execution is
328
- // keyed `<turn>:<nodeId>`, not the conversation-level chatId.
329
- componentChatId: meta?.chatId,
330
- componentState: state,
331
- });
388
+ if (t) toServer(t, data, meta);
332
389
  }}
333
390
  />
391
+ </>
334
392
  );
335
393
  }
@@ -13,7 +13,7 @@
13
13
  * holding. It never mattered to the component: the same `renderNode` walks the same
14
14
  * definition, and `setValue` writes the same instance either way.
15
15
  */
16
- import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type CSSProperties } from "react";
16
+ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";
17
17
  import { dispatchAction, matches, type ComponentStore, type ResolvedTheme, type UnoverseClient, type UnoverseDefinition } from "./core";
18
18
  import { renderNode, keyframesCss, type ActionHandler, type SlotResolver } from "./render";
19
19
  import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
@@ -74,9 +74,22 @@ export interface UnoverseComponentProps {
74
74
  * container. Flow/rail instances stay content-sized (never set there).
75
75
  */
76
76
  fill?: boolean;
77
+ /**
78
+ * WHAT A `Place` IN THIS DEFINITION DRAWS. An app's places are filled by the screen
79
+ * (app.tsx); a place inside a component or template is filled by its host, which alone
80
+ * knows what belongs there (a fullscreen template's place holds the page's avatar).
81
+ * Without it a place draws nothing.
82
+ */
83
+ place?: (name: string) => ReactNode;
84
+ /**
85
+ * THE APP'S STATE, when it lives in another store (live source only). A template a website
86
+ * page draws keeps its own slice in its own store, and shares one app state with the rest
87
+ * of the page: it reads that state beneath its own values, and `setAppValue` writes there.
88
+ */
89
+ appStore?: ComponentStore;
77
90
  }
78
91
 
79
- export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, extraData, onAction, theme, isolate = true, fill = false }: UnoverseComponentProps) {
92
+ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, extraData, onAction, theme, isolate = true, fill = false, place, appStore }: UnoverseComponentProps) {
80
93
  const [def, setDef] = useState<UnoverseDefinition | null>(null);
81
94
  const [error, setError] = useState<string | null>(null);
82
95
  // A component's OWN view state (a tab, a step, an expanded face) moves by writing its own
@@ -88,6 +101,10 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
88
101
  // LIVE SOURCE. The hook runs either way (hooks cannot be conditional) and no-ops without a
89
102
  // store, so one component serves both paths without a wrapper deciding for it.
90
103
  const live = useUnoverseInstance(store, chatId, nodeId);
104
+ const appState = useSyncExternalStore(
105
+ useCallback((cb: () => void) => (appStore ? appStore.subscribe(cb) : () => {}), [appStore]),
106
+ useCallback(() => appStore?.getAppState(), [appStore]),
107
+ );
91
108
  // The SERVER-SENT address first: an org component's URI is org-qualified
92
109
  // (`unoverse://components/<org>/<name>`) and the bare type cannot rebuild it once two
93
110
  // orgs share a name. The rebuild-from-type stays as the fallback for events that
@@ -150,6 +167,7 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
150
167
  store,
151
168
  chatId: chatId ?? "",
152
169
  nodeId: nodeId ?? "",
170
+ appStore,
153
171
  // Server actions carry the INSTANCE context (which component + its current
154
172
  // slice) so the channel can forward the submitted state with the action.
155
173
  sendToServer: onAction
@@ -174,9 +192,41 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
174
192
  }
175
193
  onAction?.(action, payload, meta);
176
194
  },
177
- [store, chatId, nodeId, onAction, live.type],
195
+ [store, chatId, nodeId, onAction, live.type, appStore],
178
196
  );
179
197
 
198
+ // ARROW KEYS STEP A TEMPLATE. A template's steps (the served stateTree: `on` + its
199
+ // substates) move with Left and Right while it holds the main place, through the same
200
+ // setValue its own Back and Next make, so the step is saved and reported exactly as a
201
+ // click would be. Forms (components) are left alone: their steps are confirmations.
202
+ // Typing in a field never steps.
203
+ const slice = store ? sourceData : localSlice;
204
+ useEffect(() => {
205
+ const d = def as (UnoverseDefinition & {
206
+ kind?: string;
207
+ stateTree?: { name: string; on?: string; states?: { name: string }[] }[];
208
+ statePlaces?: Record<string, string>;
209
+ }) | null;
210
+ if (d?.kind !== "template") return;
211
+ const face = String((slice as Record<string, unknown> | undefined)?.state ?? d.state?.state ?? "");
212
+ const tree = (d.stateTree ?? []).find((s) => s.name === face);
213
+ const steps = tree?.states?.map((s) => s.name) ?? [];
214
+ if (!tree?.on || steps.length < 2 || d.statePlaces?.[face] !== "main") return;
215
+ const on = tree.on;
216
+ const onKey = (e: KeyboardEvent) => {
217
+ if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
218
+ const t = e.target as HTMLElement | null;
219
+ if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
220
+ const cur = String((slice as Record<string, unknown> | undefined)?.[on] ?? d.state?.[on] ?? steps[0]);
221
+ const i = steps.indexOf(cur) + (e.key === "ArrowRight" ? 1 : -1);
222
+ if (i < 0 || i >= steps.length) return;
223
+ e.preventDefault();
224
+ handleAction({ type: "setValue", values: [{ key: on, value: steps[i] }] }, {});
225
+ };
226
+ document.addEventListener("keydown", onKey);
227
+ return () => document.removeEventListener("keydown", onKey);
228
+ }, [def, slice, handleAction]);
229
+
180
230
  // Loading/error hints carry NO styling — the SDK owns zero styles (tokens or hints).
181
231
  if (error) return <div>Unoverse error: {error}</div>;
182
232
  if (store && !live.type) return <div>waiting for COMPONENT_INIT…</div>;
@@ -186,7 +236,7 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
186
236
  // when its actions write them. Props stay the EXTERNAL contract.
187
237
  // The local slice sits ON TOP: a value the component wrote itself is the current one.
188
238
  const merged = store
189
- ? { ...propDefaults, ...(def?.state ?? {}), ...(sourceData ?? {}) }
239
+ ? { ...propDefaults, ...(appState ?? {}), ...(def?.state ?? {}), ...(sourceData ?? {}) }
190
240
  : { ...propDefaults, ...(def?.state ?? {}), ...(sourceData ?? {}), ...localSlice };
191
241
  // Inject the served keyframes + apply the SERVED base render-root (theme.root — DS typography
192
242
  // + font-smoothing). When ISOLATED, theme.root is applied to the shadow-root container inside
@@ -212,8 +262,10 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
212
262
  // belongs to the app's timeline, which scopes it to a single turn's unclaimed
213
263
  // components. Resolving it here too rendered every instance in the store a second time
214
264
  // — the composed page appeared in its surface AND in the chat beside it.
215
- const resolveSlot: SlotResolver | undefined = store && (def as { sections?: unknown }).sections
265
+ const resolveSlot: SlotResolver | undefined = (store && (def as { sections?: unknown }).sections) || place
216
266
  ? (node, key) => {
267
+ if (node.type === "Place") return place?.(String((node as { name?: unknown }).name ?? "")) ?? null;
268
+ if (!store) return null;
217
269
  const claim = node.select?.where;
218
270
  const pointers = store
219
271
  .getResponses()
@@ -237,6 +289,7 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
237
289
  nodeId={n}
238
290
  onAction={onAction}
239
291
  theme={activeTheme}
292
+ appStore={appStore}
240
293
  />
241
294
  );
242
295
  });
@@ -104,7 +104,7 @@ function FlipList({ items }: { items: { key: string; node: ReactNode }[] }) {
104
104
  }
105
105
  import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
106
106
  import { IsolatedRoot } from "./isolate";
107
- import { useVoiceService, type UseVoiceServiceConfig } from "./voice";
107
+ import { useVoiceService, type UseVoiceServiceConfig, type VoiceService } from "./voice";
108
108
 
109
109
  export interface StreamedUnoverseAppProps {
110
110
  client: UnoverseClient;
@@ -130,12 +130,15 @@ export interface StreamedUnoverseAppProps {
130
130
  * shared home for the voice buttons: `startCall`/`endCall`/`toggleMute` are answered
131
131
  * HERE, not hand-wired per host (that drift is what broke the demo's Start Call). */
132
132
  voice?: UseVoiceServiceConfig;
133
+ /** The voice service itself, when the host already runs one to share with the page's own
134
+ * templates (appEngine). Given, the renderer uses it instead of starting a second. */
135
+ voiceService?: VoiceService;
133
136
  }
134
137
 
135
138
  /** Inert voice config for non-voice templates — the hook is called unconditionally
136
139
  * (rules of hooks) but nothing opens until a `startCall` this template never fires.
137
140
  * No `store` → not a producer, so it writes no callState into ordinary templates. */
138
- const INERT_VOICE: UseVoiceServiceConfig = {
141
+ export const INERT_VOICE: UseVoiceServiceConfig = {
139
142
  apiUrl: "",
140
143
  session: { userId: "", conversationId: "", workflowId: "", targetTriggerNode: "" },
141
144
  };
@@ -156,7 +159,7 @@ function collectSlotPointers(node: unknown, store: ComponentStore, scope?: Set<s
156
159
  return [...new Set(out)];
157
160
  }
158
161
 
159
- export function StreamedUnoverseApp({ client, store, uri, onAction, theme: themeProp, props, isolate = true, voice: voiceConfig, templateChrome }: StreamedUnoverseAppProps) {
162
+ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: themeProp, props, isolate = true, voice: voiceConfig, voiceService, templateChrome }: StreamedUnoverseAppProps) {
160
163
  const [def, setDef] = useState<UnoverseDefinition | null>(null);
161
164
  const [error, setError] = useState<string | null>(null);
162
165
  // MOUNTED TEMPLATES: an app entry with `use:` names a template definition; it is
@@ -167,9 +170,10 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
167
170
  // The audio WS lane travels WITH the SDK: derive it from the client's own server origin
168
171
  // (same server that hosts /mcp + /stream + /ws/gravity), NEVER from the host — a host's
169
172
  // `location.origin` is "null" in a sandboxed srcdoc iframe. Overrides any host wsUrl.
170
- const voice = useVoiceService(
171
- voiceConfig ? { ...voiceConfig, wsUrl: client.serverOrigin.replace(/^http/, "ws") } : INERT_VOICE,
173
+ const own = useVoiceService(
174
+ voiceConfig && !voiceService ? { ...voiceConfig, wsUrl: client.serverOrigin.replace(/^http/, "ws") } : INERT_VOICE,
172
175
  );
176
+ const voice = voiceService ?? own;
173
177
  // No theme passed → fetch from the server (the SDK owns zero token values). The
174
178
  // theme REF comes from the definition itself: a template belongs to a client org,
175
179
  // and that org names its theme — `<org>/light`. Wait for the def before fetching
@@ -118,7 +118,7 @@ export function useUnoverseConnection(
118
118
  config: UnoverseConnectionConfig,
119
119
  session: UnoverseSessionParams,
120
120
  store: ComponentStore,
121
- options: { enabled?: boolean; onAppSwitch?: (app: string) => void } = {},
121
+ options: { enabled?: boolean; onAppSwitch?: (app: string) => void; onJoinCall?: (workflowId?: string) => void } = {},
122
122
  ): UnoverseConnection {
123
123
  const enabled = options.enabled ?? true;
124
124
  // Read through a ref so a host's inline arrow does not tear down the stream on
@@ -126,6 +126,8 @@ export function useUnoverseConnection(
126
126
  const onAppSwitchRef = useRef(options.onAppSwitch);
127
127
  onAppSwitchRef.current = options.onAppSwitch;
128
128
  const onAppSwitch = (app: string) => onAppSwitchRef.current?.(app);
129
+ const onJoinCallRef = useRef(options.onJoinCall);
130
+ onJoinCallRef.current = options.onJoinCall;
129
131
  // Read through a ref too: the server follows app switches itself, so a new app must not
130
132
  // tear the stream down; the next registration simply names it.
131
133
  const appIdRef = useRef(session.appId);
@@ -152,8 +154,10 @@ export function useUnoverseConnection(
152
154
  // ---- connection lifecycle (MCP stream) ----
153
155
  useEffect(() => {
154
156
  if (!enabled) return; // mock mode — no live stream
155
- if (!workflowId || !userId || !conversationId) {
156
- console.warn("[unoverse] connection missing workflowId/userId/conversationId");
157
+ // No workflowId is fine: a page app with none still hears its conversation (the server
158
+ // takes a signed-in session without one; a guest's still needs a public trigger).
159
+ if (!userId || !conversationId) {
160
+ console.warn("[unoverse] connection missing userId/conversationId");
157
161
  return;
158
162
  }
159
163
  let cancelled = false;
@@ -267,6 +271,13 @@ export function useUnoverseConnection(
267
271
  if (typeof next === "string" && next) onAppSwitch?.(next);
268
272
  return;
269
273
  }
274
+ // A VOICE IN THE WORKFLOW ASKS THE PAGE TO JOIN (VOICE_STREAMING_GUIDE.md, "Call
275
+ // Initialization"). The host's voice service joins; a host without one ignores it.
276
+ if (evt.type === "JOIN_CALL") {
277
+ const workflowId = (msg as unknown as { workflowId?: unknown }).workflowId;
278
+ onJoinCallRef.current?.(typeof workflowId === "string" ? workflowId : undefined);
279
+ return;
280
+ }
270
281
  applyServerMessage(store, msg, seenRef.current);
271
282
  };
272
283
 
@@ -19,6 +19,9 @@ export interface ActionContext {
19
19
  nodeId: string;
20
20
  /** Route a non-`setValue` action to the workflow (channel-provided, e.g. USER_ACTION). */
21
21
  sendToServer?: (type: string, data: Record<string, unknown>) => void;
22
+ /** Where APP state lives, when it is not `store`: a template a website page draws keeps its
23
+ * own slice in its own store and shares the app's state with everything on the page. */
24
+ appStore?: ComponentStore;
22
25
  }
23
26
 
24
27
  /** Resolve a value: a `"{{path}}"` template reads from `scope` (dot-path); anything
@@ -86,7 +89,7 @@ export function dispatchAction(
86
89
  // concept — only "write a key to component state" and "write a key to template state".
87
90
  const patch: Record<string, unknown> = {};
88
91
  for (const { key, value } of spec.values ?? []) patch[key] = resolveValue(value, scope);
89
- ctx.store.mergeAppState(patch);
92
+ (ctx.appStore ?? ctx.store).mergeAppState(patch);
90
93
  } else if (spec.type === "input") {
91
94
  // A controlled Input changed — write its bound field back into the state bag that
92
95
  // OWNS this tree (local two-way binding; every keystroke is local, never a server
@@ -98,7 +101,7 @@ export function dispatchAction(
98
101
  const field = typeof scope.field === "string" ? scope.field : undefined;
99
102
  if (field) {
100
103
  if (ctx.nodeId) ctx.store.apply({ type: "COMPONENT_DATA", chatId: ctx.chatId, nodeId: ctx.nodeId, data: { [field]: scope.text } });
101
- else ctx.store.mergeAppState({ [field]: scope.text });
104
+ else (ctx.appStore ?? ctx.store).mergeAppState({ [field]: scope.text });
102
105
  }
103
106
  } else {
104
107
  // SERVER — narrow the payload by `send` if given, else forward the whole scope.
@@ -2,10 +2,10 @@ export { UnoverseComponent } from "./UnoverseComponent";
2
2
  export type { UnoverseComponentProps } from "./UnoverseComponent";
3
3
  export { StreamedUnoverseComponent, useUnoverseInstance } from "./streamed";
4
4
  export type { StreamedUnoverseComponentProps } from "./streamed";
5
- export { StreamedUnoverseApp, selectPointers, computeAppWidth, resolveActiveLayout } from "./app";
5
+ export { StreamedUnoverseApp, INERT_VOICE, selectPointers, computeAppWidth, resolveActiveLayout } from "./app";
6
6
  export type { StreamedUnoverseAppProps } from "./app";
7
7
  export { renderNode, styleToCss, keyframesCss } from "./render";
8
- export type { ActionHandler, SlotResolver } from "./render";
8
+ export type { ActionHandler, ActionMeta, SlotResolver } from "./render";
9
9
  export { useUnoverseTheme } from "./theme";
10
10
  export { IsolatedRoot } from "./isolate";
11
11
  export { useUnoverseConnection } from "./connection";
@@ -107,17 +107,28 @@ function nodeChrome(node: UnoverseNode, data: Record<string, unknown>, theme: Re
107
107
  // reacts to the space the grid actually has, not the viewport. A component in a narrow
108
108
  // panel on a wide screen stacks correctly, which a viewport breakpoint cannot do.
109
109
  //
110
- // The threshold is SERVED (`theme.grid.stackBelow`), overridable per grid with
111
- // `style.stackBelow`. The SDK holds no width of its own.
110
+ // The threshold is SERVED (`theme.grid.stackBelow`). The SDK holds no width of its own.
112
111
  if (s?.span) {
113
- const raw = (s.stackBelow as string) ?? theme.grid?.stackBelow ?? "";
112
+ const raw = theme.grid?.stackBelow ?? "";
114
113
  const w = theme.space[raw] ?? theme.layout?.[raw] ?? raw;
115
114
  if (w) {
116
115
  const cls = `uno-stack-${hashStr(String(w))}`;
117
116
  classes.push(cls);
118
- styleEls.push(<style key="span-stack">{`@container (max-width:${w}){.${cls}{grid-column:1 / -1 !important}}`}</style>);
117
+ styleEls.push(<style key="span-stack">{`@container (max-width:${w}){.${cls}:not(.uno-gridstack > *){grid-column:1 / -1 !important}}`}</style>);
119
118
  }
120
119
  }
120
+ // A GRID'S OWN `stackBelow` overrides the served threshold for that grid: below it, EVERY
121
+ // child takes the whole row, spanning or not, so a two-up list stacks as a 7/5 split does.
122
+ // The children query the grid itself (it is `container-type: inline-size`), and the
123
+ // served threshold above steps aside for them (`uno-gridstack`), so a grid may hold its
124
+ // shape longer as well as give it up sooner.
125
+ if (s?.columns && s.stackBelow) {
126
+ const raw = s.stackBelow as string;
127
+ const w = theme.space[raw] ?? theme.layout?.[raw] ?? raw;
128
+ const cls = `uno-gridstack-${hashStr(String(w))}`;
129
+ classes.push("uno-gridstack", cls);
130
+ styleEls.push(<style key="grid-stack">{`@container (max-width:${w}){.${cls} > *{grid-column:1 / -1 !important}}`}</style>);
131
+ }
121
132
  // `columns` AS A BREAKPOINT MAP — the responsive grid: column counts per named band
122
133
  // ({mobile: "1", tablet: "2", desktop: "3", full: "4"}), applied by CONTAINER width
123
134
  // (the nearest `container` ancestor — a grid in a narrow panel stacks on a wide
@@ -183,6 +194,29 @@ export function BoxView({ node, data, theme, children, onAction }: { node: Unove
183
194
  // A selection host must anchor its floating affordance; default to relative only when
184
195
  // the definition declared no position of its own (sticky/absolute roots keep theirs).
185
196
  const hostStyle = sel && !(style as CSSProperties).position ? ({ ...style, position: "relative" } as CSSProperties) : style;
197
+ // A STAGE (`style.stage: 1280x720`): the Box is drawn at that fixed size and scaled as one
198
+ // piece to the width it is given, the way slide tools render a slide. Its layout never
199
+ // reflows, so what is aligned stays aligned on every screen.
200
+ const stage = /^(\d+)x(\d+)$/.exec(String((node.style as { stage?: unknown } | undefined)?.stage ?? ""));
201
+ const stageW = stage ? Number(stage[1]) : 0;
202
+ const stageRef = useRef<HTMLDivElement>(null);
203
+ const [scale, setScale] = useState(1);
204
+ useLayoutEffect(() => {
205
+ const el = stageRef.current;
206
+ if (!stageW || !el) return;
207
+ const ro = new ResizeObserver(() => setScale(el.clientWidth / stageW));
208
+ ro.observe(el);
209
+ return () => ro.disconnect();
210
+ }, [stageW]);
211
+ if (stage)
212
+ return (
213
+ <div ref={stageRef} style={{ position: "relative", width: "100%", aspectRatio: `${stage[1]} / ${stage[2]}` }}>
214
+ <div className={className} style={{ ...hostStyle, position: "absolute", top: 0, left: 0, width: stageW, height: Number(stage[2]), transform: `scale(${scale})`, transformOrigin: "0 0" }} data-unoverse-id={inspectId(node)}>
215
+ {styleEls}
216
+ {children}
217
+ </div>
218
+ </div>
219
+ );
186
220
  return (
187
221
  <div ref={scrollRef ?? revealRef ?? selRef} className={className} style={hostStyle} data-unoverse-id={inspectId(node)}>
188
222
  {styleEls}
@@ -30,6 +30,8 @@ export interface UseAudioPlaybackReturn {
30
30
  isPlaying: boolean;
31
31
  /** Mark that no more chunks are coming — isPlaying clears when the last finishes. */
32
32
  markAsLastChunk: () => void;
33
+ /** Wake the speaker, inside a click. */
34
+ unlock: () => void;
33
35
  }
34
36
 
35
37
  interface QueuedAudio {
@@ -148,5 +150,8 @@ export function useAudioPlayback(): UseAudioPlaybackReturn {
148
150
  };
149
151
  }, [stopAll]);
150
152
 
151
- return { playAudio, stopAll, isPlaying, markAsLastChunk };
153
+ // Wakes the speaker. Called inside a click, because a browser plays nothing it was not woken for.
154
+ const unlock = useCallback(() => void getAudioContext(), [getAudioContext]);
155
+
156
+ return { playAudio, stopAll, isPlaying, markAsLastChunk, unlock };
152
157
  }