@unoverse-platform/studio 0.1.81 → 0.1.82

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.
@@ -12,7 +12,7 @@
12
12
  * This slice handles ComponentSlot + Skeleton (Tier-1, e.g. KeyService). The
13
13
  * chat primitives (Conversation/Input/…) come in later slices.
14
14
  */
15
- import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";
15
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";
16
16
  import {
17
17
  collectSurfacedViews,
18
18
  cssWidth,
@@ -39,6 +39,66 @@ import { renderNode, styleToCss, keyframesCss, type ActionHandler, type SlotReso
39
39
  // existing consumers keep their import path.
40
40
  export { collectSurfacedViews, computeAppWidth, orderedClaims, resolveActiveLayout, resolveActiveView, selectPointers, viewOf } from "./core";
41
41
  import { UnoverseComponent } from "./UnoverseComponent";
42
+
43
+ /**
44
+ * FLIP wrapper for a multi-occupant slot: when arrivals reorder the list (a refreshed
45
+ * card floating to the front, a new card pushing the rest along), the existing cards
46
+ * SLIDE to their new position instead of jumping. Generic list behavior, no styling
47
+ * owned: each child sits in an unstyled measuring div; on every commit the previous
48
+ * rect is compared by key and the delta is played back as a transform that eases to
49
+ * identity. A key seen for the first time animates nothing (arrivals appear in place).
50
+ */
51
+ function FlipList({ items }: { items: { key: string; node: ReactNode }[] }) {
52
+ const positions = useRef(new Map<string, { left: number; top: number }>());
53
+ const order = useRef("");
54
+ const els = useRef(new Map<string, HTMLDivElement>());
55
+ useLayoutEffect(() => {
56
+ // ANIMATE ONLY A REORDER. The template re-renders for plenty of reasons that are not
57
+ // this list (typing writes the draft into template state every keystroke), and chrome
58
+ // above the list growing or the page scrolling shifts every measured position a
59
+ // little — playing those back made the cards wobble while the guest typed. Order is
60
+ // the thing this animates, so unchanged order = remeasure silently and do nothing.
61
+ // Positions are layout-relative (offsetLeft/Top), not viewport-relative, for the
62
+ // same reason: scroll must not read as movement.
63
+ const nextOrder = items.map((i) => i.key).join(" ");
64
+ // The guest's OS-level reduce-motion setting wins outright: reorders land instantly.
65
+ const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
66
+ const moved = !reduced && order.current !== "" && order.current !== nextOrder;
67
+ const next = new Map<string, { left: number; top: number }>();
68
+ for (const [k, el] of els.current) {
69
+ const pos = { left: el.offsetLeft, top: el.offsetTop };
70
+ next.set(k, pos);
71
+ const prev = positions.current.get(k);
72
+ const dx = prev && moved ? prev.left - pos.left : 0;
73
+ const dy = prev && moved ? prev.top - pos.top : 0;
74
+ if (dx || dy) {
75
+ el.style.transition = "none";
76
+ el.style.transform = `translate(${dx}px, ${dy}px)`;
77
+ requestAnimationFrame(() => {
78
+ el.style.transition = "transform 240ms ease";
79
+ el.style.transform = "";
80
+ });
81
+ }
82
+ }
83
+ positions.current = next;
84
+ order.current = nextOrder;
85
+ });
86
+ return (
87
+ <>
88
+ {items.map(({ key, node }) => (
89
+ <div
90
+ key={key}
91
+ ref={(el) => {
92
+ if (el) els.current.set(key, el);
93
+ else els.current.delete(key);
94
+ }}
95
+ >
96
+ {node}
97
+ </div>
98
+ ))}
99
+ </>
100
+ );
101
+ }
42
102
  import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
43
103
  import { IsolatedRoot } from "./isolate";
44
104
  import { useVoiceService, type UseVoiceServiceConfig } from "./voice";
@@ -59,7 +119,7 @@ export interface StreamedUnoverseAppProps {
59
119
  * hook drawn ABOVE a Container's content — Studio uses it for per-container state
60
120
  * tabs. The SDK stays styleless: it hands the facts (name, the container's states,
61
121
  * the held interface's active state, a setter) and draws whatever comes back. */
62
- containerChrome?: (info: { name: string; states: string[]; active?: string; set: (state: string) => void }) => ReactNode;
122
+ templateChrome?: (info: { name: string; states: string[]; active?: string; set: (state: string) => void }) => ReactNode;
63
123
  /** The `voice` NATIVE SERVICE config (UNOVERSE_SPEC §2e-1). Present ONLY for a
64
124
  * `service: "voice"` app — the host supplies the connection identity (apiUrl/wsUrl/
65
125
  * token/session) it already holds; the renderer OWNS the service so every host is
@@ -77,9 +137,28 @@ const INERT_VOICE: UseVoiceServiceConfig = {
77
137
  session: { userId: "", conversationId: "", workflowId: "", targetTriggerNode: "" },
78
138
  };
79
139
 
80
- export function StreamedUnoverseApp({ client, store, uri, onAction, theme: themeProp, props, isolate = true, voice: voiceConfig, containerChrome }: StreamedUnoverseAppProps) {
140
+ /** Every pointer a mounted template layout's slots currently select — its occupancy. */
141
+ function collectSlotPointers(node: unknown, store: ComponentStore, scope?: Set<string>): string[] {
142
+ const out: string[] = [];
143
+ const walk = (n: unknown): void => {
144
+ if (!n || typeof n !== "object") return;
145
+ const un = n as UnoverseNode & { children?: unknown[]; template?: unknown; frame?: unknown; cases?: Record<string, unknown> };
146
+ if (un.type === "ComponentSlot") out.push(...selectPointers(store, un, scope));
147
+ (un.children ?? []).forEach(walk);
148
+ if (un.template) walk(un.template);
149
+ if (un.frame) walk(un.frame);
150
+ if (un.cases) Object.values(un.cases).forEach(walk);
151
+ };
152
+ walk(node);
153
+ return [...new Set(out)];
154
+ }
155
+
156
+ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: themeProp, props, isolate = true, voice: voiceConfig, templateChrome }: StreamedUnoverseAppProps) {
81
157
  const [def, setDef] = useState<UnoverseDefinition | null>(null);
82
158
  const [error, setError] = useState<string | null>(null);
159
+ // MOUNTED TEMPLATES: an app entry with `use:` names a template definition; it is
160
+ // fetched once per app def and its own machinery runs inside the Template place.
161
+ const [mounted, setMounted] = useState<Record<string, UnoverseDefinition>>({});
83
162
  // The voice native service lives HERE (shared), not in each host. Called every render
84
163
  // (rules of hooks); inert until a `startCall` only a voice template has a button for.
85
164
  // The audio WS lane travels WITH the SDK: derive it from the client's own server origin
@@ -111,9 +190,27 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
111
190
  let alive = true;
112
191
  setDef(null);
113
192
  setError(null);
193
+ setMounted({});
114
194
  client
115
195
  .readDefinition(uri)
116
- .then((d) => alive && setDef(d))
196
+ .then((d) => {
197
+ if (!alive) return;
198
+ setDef(d);
199
+ const entries = ((d as { templates?: Record<string, { template?: string }> }).templates ?? {});
200
+ for (const entry of Object.values(entries)) {
201
+ const use = entry?.template;
202
+ if (!use) continue;
203
+ // A failed fetch may be a transient (dev-server restart mid-selection):
204
+ // retry once, and never fail SILENTLY — an empty mount with no console
205
+ // trace cost a debugging session (2026-08-28).
206
+ const turi = use.includes("://") ? use : `unoverse://templates/${use}`;
207
+ client
208
+ .readDefinition(turi)
209
+ .catch(() => new Promise((r) => setTimeout(r, 1500)).then(() => client.readDefinition(turi)))
210
+ .then((t) => alive && t && setMounted((m) => ({ ...m, [use]: t })))
211
+ .catch((e) => console.warn(`[unoverse] mounted template "${use}" failed to load:`, e));
212
+ }
213
+ })
117
214
  .catch((e) => alive && setError(String(e?.message ?? e)));
118
215
  return () => {
119
216
  alive = false;
@@ -159,8 +256,13 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
159
256
  const surfacedViews = useMemo(() => {
160
257
  const views = new Set<string>(orderedClaims(def));
161
258
  if (activeRoot) collectSurfacedViews(activeRoot, views, def?.defs as Record<string, unknown> | undefined);
259
+ // A MOUNTED template claims every state it declares (base included): instances
260
+ // it holds must not also render in the flow — one instance, one placeholder.
261
+ for (const tpl of Object.values(mounted)) {
262
+ for (const name of Object.keys(tpl.layouts ?? {})) views.add(name);
263
+ }
162
264
  return views;
163
- }, [activeRoot, def]);
265
+ }, [activeRoot, def, mounted]);
164
266
 
165
267
  // RULE 4: the winner clears the states below it — WHEN A DELIVERY PUT IT THERE.
166
268
  // An arrival taking a higher rung means the screen has moved on, so stale claims below
@@ -274,7 +376,11 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
274
376
 
275
377
  const leaf = (pointer: string, extraData?: Record<string, unknown>, fill?: boolean) => {
276
378
  const { chatId, nodeId } = store.split(pointer);
277
- return <UnoverseComponent key={pointer} client={client} store={store} chatId={chatId} nodeId={nodeId} onAction={dispatch} theme={theme} extraData={extraData} fill={fill} />;
379
+ // Keyed by the INSTANCE (nodeId), not the pointer: the same instance re-delivered in
380
+ // a later turn keeps its React identity, so a refresh reconciles in place instead of
381
+ // remounting (the remount was the visible flicker). Unique among siblings — a turn's
382
+ // nodeIds are unique, and selectPointers dedupes cross-turn copies to one.
383
+ return <UnoverseComponent key={nodeId} client={client} store={store} chatId={chatId} nodeId={nodeId} onAction={dispatch} theme={theme} extraData={extraData} fill={fill} />;
278
384
  };
279
385
 
280
386
  // THE ACTIVE STATE (docs/design/04): a template is in exactly ONE state at a time —
@@ -292,27 +398,69 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
292
398
  const surfacedView = resolveActiveView(def, store, surfacedViews).view;
293
399
 
294
400
  // ComponentSlot — filter the timeline to matching component pointers (KeyService).
295
- const resolveSlot = (node: UnoverseNode, key?: React.Key): ReactNode => {
296
- // A CONTAINER (UNOVERSE_INTERFACE_MODEL §3): the layout places it by NAME and owns
297
- // its width; the manifest's `containers:` block owns its claim. Resolved here into
298
- // the slot it is — one authoring word (`state`), translated to the wire key.
299
- if (node.type === "Container") {
300
- // A CONTAINER (UNOVERSE_INTERFACE_MODEL §3): its states each HOLD AN INTERFACE —
301
- // the page state holds the deal, the chat state holds text-chat — and the tabs
302
- // switch between them. The container's ACTIVE state is app chrome (`setAppValue`
303
- // territory, the one thing an app owns), keyed `container:<name>`; the shorthand
304
- // `interface:` is a one-state container and never draws tabs.
401
+ // A FACTORY: a mounted template's slots select within a SCOPE (the current
402
+ // delivery — hold: latest, basic form); the app's own slots stay unscoped.
403
+ const makeResolveSlot = (scope?: Set<string>): SlotResolver => {
404
+ const resolveSlot: SlotResolver = (node: UnoverseNode, key?: React.Key): ReactNode => {
405
+ // A TEMPLATE PLACE (the container noun folded into templates, 2026-08-28): the
406
+ // layout places it by NAME and owns its width; the manifest's `templates:` block
407
+ // owns what it holds. An entry with `use:` MOUNTS a template definition (its own
408
+ // states, shapes and slots run here over the shared store); an entry with
409
+ // states/interface keeps the old holder behavior (a template with one shape).
410
+ if (node.type === "Template") {
305
411
  const cname = (node as { name?: string }).name ?? "";
306
- const spec = (def as { containers?: Record<string, { states?: Record<string, { interface?: string; width?: string }>; interface?: string }> } | null)
307
- ?.containers?.[cname];
412
+ const spec = (def as { templates?: Record<string, { template?: string; states?: Record<string, { interface?: string; width?: string }>; interface?: string }> } | null)
413
+ ?.templates?.[cname];
414
+ // THE MOUNT: `template:` names a template definition (the holder grammar:
415
+ // a place names what it holds); its OWN machinery runs —
416
+ // resolveActiveLayout derives its state from the instances' faces, its slots
417
+ // claim by face, sub-states stay contained in each instance's slice. EMPTY
418
+ // COLLAPSES: nothing held → nothing drawn (frame included) — a place with
419
+ // nothing collapses.
420
+ if (spec?.template) {
421
+ const tpl = mounted[spec.template];
422
+ const active = tpl ? resolveActiveLayout(tpl, store) : undefined;
423
+ // HOLD: LATEST (basic form, UNOVERSE_TEMPLATE_MODEL §9b): the mount holds
424
+ // only the CURRENT delivery — the guest's send clears the set.
425
+ const delivery = store.currentDeliveryPointers();
426
+ const held = active ? collectSlotPointers(active, store, delivery) : [];
427
+ if (!tpl) return null;
428
+ if (!active) return null;
429
+ if (!held.length) return null;
430
+ const tree = renderNode(active, store.getAppState() as Record<string, unknown>, dispatch, theme, `${String(key ?? cname)}-tpl`, makeResolveSlot(delivery), tpl.defs);
431
+ // A PLACE DECLARES ITS WIDTH ONCE (docs/design/05), same law as a slot
432
+ // panel: `appWidth` resolves via theme.appSize and sizes the frame box —
433
+ // `flex` is the served "all remaining space" name. Without it the frame's
434
+ // own styles decide (and a width-less frame beside a fixed-width chat
435
+ // collapsed to its padding — observed live 2026-08-28).
436
+ const declaredW = (node as { appWidth?: string }).appWidth;
437
+ const placeW = cssWidth(typeof declaredW === "string" ? (theme.appSize?.[declaredW] ?? declaredW) : undefined);
438
+ // THEME-FREE FALLBACK (same law as computeAppWidth's marker): a theme without
439
+ // an `appSize.flex` token must still mean "fill the remaining host space" —
440
+ // an unresolved name collapsed the place to its padding (observed live
441
+ // 2026-08-28, the light theme's missing `flex`).
442
+ const flexFallback = !placeW && declaredW === "flex";
443
+ const rawFrame = (node as { frame?: UnoverseNode }).frame;
444
+ if (rawFrame) {
445
+ const frame = placeW
446
+ ? ({ ...rawFrame, style: { ...(rawFrame.style ?? {}), width: placeW, flex: "0 0 auto" } } as UnoverseNode)
447
+ : flexFallback
448
+ ? ({ ...rawFrame, style: { ...(rawFrame.style ?? {}), flex: "1 1 0%", minWidth: 0 } } as UnoverseNode)
449
+ : rawFrame;
450
+ const innerSlot: SlotResolver = () => tree;
451
+ return renderNode(frame, store.getAppState() as Record<string, unknown>, dispatch, theme, `${String(key ?? cname)}-frame`, innerSlot, def?.defs);
452
+ }
453
+ return <div key={key} style={{ display: "flex", flexDirection: "column", minHeight: 0, ...(placeW ? { width: placeW, flex: "0 0 auto" } : flexFallback ? { flex: "1 1 0%", minWidth: 0 } : {}) }}>{tree}</div>;
454
+ }
308
455
  const stateNames = spec?.states ? Object.keys(spec.states) : [];
309
456
  const appState = store.getAppState() as Record<string, unknown>;
310
- const active = (appState[`container:${cname}`] as string | undefined) ?? stateNames[0];
457
+ const active = (appState[`template:${cname}`] as string | undefined) ?? stateNames[0];
311
458
  const heldInterface = spec?.states ? spec.states[active ?? ""]?.interface : spec?.interface;
312
459
  // AN EMPTY STATE HOLDS NOTHING (`closed: {}` is a collapsed drawer): a declared
313
460
  // container whose active state names no interface renders EMPTY — never a bare
314
461
  // slot, which would grab every interface in the store and un-collapse the drawer.
315
462
  const emptyState = Boolean(spec?.states) && !heldInterface;
463
+ // (legacy holder path continues below unchanged)
316
464
  node = {
317
465
  ...node,
318
466
  type: "ComponentSlot",
@@ -322,8 +470,8 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
322
470
  ? ({ limit: 0 } as never)
323
471
  : (node.select ?? {}),
324
472
  } as UnoverseNode;
325
- if (containerChrome && spec) {
326
- const set = (state: string) => store.mergeAppState({ [`container:${cname}`]: state });
473
+ if (templateChrome && spec) {
474
+ const set = (state: string) => store.mergeAppState({ [`template:${cname}`]: state });
327
475
  const inner = node;
328
476
  // THE STATE'S WIDTH: a container state may declare one (a collapsed drawer's slim
329
477
  // edge). Resolved like every panel width (app-size name, else raw CSS); absent,
@@ -332,7 +480,7 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
332
480
  const stateW = declaredW ? (cssWidth(theme.appSize?.[declaredW] ?? declaredW) ?? declaredW) : undefined;
333
481
  return (
334
482
  <div key={key} style={{ display: "flex", flexDirection: "column", minHeight: 0, height: "100%", ...(stateW ? { width: stateW, flex: "0 0 auto", overflow: "hidden" } : {}) }}>
335
- {containerChrome({ name: cname, states: stateNames, active, set })}
483
+ {templateChrome({ name: cname, states: stateNames, active, set })}
336
484
  <div style={{ flex: 1, minHeight: 0, overflow: "auto" }}>{resolveSlot(inner, `${String(key ?? cname)}-inner`)}</div>
337
485
  </div>
338
486
  );
@@ -350,7 +498,7 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
350
498
  if (isViewField(claim?.field) && typeof claim?.eq === "string" && declaredClaim && claim.eq !== surfacedView) {
351
499
  return node.fallback ? renderNode(node.fallback, {}, dispatch, theme, key ?? "fallback", undefined, def?.defs) : null;
352
500
  }
353
- const pointers = selectPointers(store, node);
501
+ const pointers = selectPointers(store, node, scope);
354
502
  if (pointers.length === 0) {
355
503
  return node.fallback ? renderNode(node.fallback, {}, dispatch, theme, key ?? "fallback", undefined, def?.defs) : null;
356
504
  }
@@ -364,7 +512,12 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
364
512
  // height — the component's `height: "full"` finally has a definite container,
365
513
  // with zero per-face styling. Multi-occupant surfaces (a rail) stay content-sized.
366
514
  const fill = node.select?.limit === 1;
367
- const innerSlot: SlotResolver = () => pointers.map((p) => leaf(p, undefined, fill));
515
+ // Multi-occupant surfaces (a rail) animate reorders; a single occupant fills its
516
+ // frame directly — a measuring wrapper there would break the height chain.
517
+ const innerSlot: SlotResolver = () =>
518
+ fill
519
+ ? pointers.map((p) => leaf(p, undefined, fill))
520
+ : <FlipList items={pointers.map((p) => ({ key: store.split(p).nodeId, node: leaf(p) }))} />;
368
521
  // A panel declares its width ONCE (docs/design/05): `appWidth` grows the app
369
522
  // (computeAppWidth) AND sizes the frame box here — the two can never disagree.
370
523
  // Named sizes resolve via theme.appSize like any token; unresolvable → no width.
@@ -377,8 +530,11 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
377
530
  : node.frame;
378
531
  return renderNode(frame, store.getAppState() as Record<string, unknown>, dispatch, theme, key ?? "frame", innerSlot, def?.defs);
379
532
  }
380
- return pointers.map((p) => leaf(p));
533
+ return <FlipList key={key ?? "slot"} items={pointers.map((p) => ({ key: store.split(p).nodeId, node: leaf(p) }))} />;
534
+ };
535
+ return resolveSlot;
381
536
  };
537
+ const resolveSlot = makeResolveSlot();
382
538
 
383
539
  // Timeline — GENERIC iterator. Walks the timeline and renders the `user` / `assistant`
384
540
  // DATA sub-tree per turn. Zero chat UX in the SDK: bubbles, alignment, structure all live
@@ -238,18 +238,20 @@ export function useUnoverseConnection(
238
238
  };
239
239
 
240
240
  // Inject the bearer on every underlying request (fresh per call → refresh-safe).
241
- const authFetch = getAccessToken
242
- ? async (url: string | URL, init?: RequestInit): Promise<Response> => {
243
- const token = await getAccessToken();
244
- const headers = new Headers(init?.headers);
245
- if (token) headers.set("Authorization", `Bearer ${token}`);
246
- return fetch(url, { ...init, headers });
247
- }
248
- : undefined;
241
+ // ALWAYS wrapped: dev tunnels need the ngrok bypass header, token or not
242
+ // (ngrok free interstitial = 200 HTML no CORS = dead /stream in sandboxed
243
+ // hosts; header is hostname-gated so prod requests are untouched).
244
+ const authFetch = async (url: string | URL, init?: RequestInit): Promise<Response> => {
245
+ const token = getAccessToken ? await getAccessToken() : undefined;
246
+ const headers = new Headers(init?.headers);
247
+ if (token) headers.set("Authorization", `Bearer ${token}`);
248
+ if (String(url).includes("ngrok")) headers.set("ngrok-skip-browser-warning", "true");
249
+ return fetch(url, { ...init, headers });
250
+ };
249
251
  try {
250
252
  const transport = new StreamableHTTPClientTransport(
251
253
  new URL(`${streamUrl}${STREAM_ENDPOINT}`),
252
- authFetch ? { fetch: authFetch } : undefined,
254
+ { fetch: authFetch },
253
255
  );
254
256
  await client.connect(transport);
255
257
  if (cancelled || clientRef.current !== client) {
@@ -122,6 +122,7 @@ export function computeAppWidth(root: unknown, store: ComponentStore, appSize?:
122
122
  const rw = (w: unknown): string | undefined => cssWidth(typeof w === "string" ? (appSize?.[w] ?? w) : undefined);
123
123
  const r = root as Record<string, unknown> | null | undefined;
124
124
  const widths: string[] = [];
125
+ let flexOpen = false;
125
126
  // Gather the tree's width-bearing nodes: always-open core panels and the reaction
126
127
  // SURFACES (view → panel width).
127
128
  const surfaces = new Map<string, string | undefined>();
@@ -152,6 +153,12 @@ export function computeAppWidth(root: unknown, store: ComponentStore, appSize?:
152
153
  } else if (n.appWidth !== undefined) {
153
154
  const w = rw(n.appWidth);
154
155
  if (w) widths.push(w); // a plain panel (the core column) is ALWAYS open
156
+ // The `flex` MARKER on an always-open place (a mounted Template) — same law
157
+ // as the active flex surface below: the panel completes the viewport, so the
158
+ // app is the full host width. Without this the marker was silently DROPPED
159
+ // here and the app box computed to the chat alone, flexing the place to 0px
160
+ // (measured live 2026-08-28: grid column track 0px, cards 667px tall).
161
+ else if (n.appWidth === "flex") flexOpen = true;
155
162
  }
156
163
  }
157
164
  for (const v of Object.values(n)) if (v && typeof v === "object") walk(v);
@@ -166,7 +173,7 @@ export function computeAppWidth(root: unknown, store: ComponentStore, appSize?:
166
173
  const activePanel = active ? surfaces.get(active) : undefined;
167
174
  // An ACTIVE flex surface fills the host viewport (the host caps at its pane); the
168
175
  // surface flexes into whatever the fixed panels leave.
169
- if (activePanel === "flex") return "100%";
176
+ if (flexOpen || activePanel === "flex") return "100%";
170
177
  if (activePanel) widths.push(activePanel);
171
178
  if (!widths.length) return undefined;
172
179
  return widths.length === 1 ? widths[0] : `calc(${widths.join(" + ")})`;
@@ -210,16 +217,32 @@ export function resolveActiveView(
210
217
  ): { view: string; byGuest: boolean } {
211
218
  const order = def?.stateOrder?.length ? def.stateOrder : undefined;
212
219
  const valid = order ? new Set(order) : (validViews ?? new Set(orderedClaims(def)));
220
+ // A STATE THAT MOUNTS A TEMPLATE claims the template's own states (loader-stamped
221
+ // `claims`, UNOVERSE_TEMPLATE_MODEL §12): an instance arriving in any of them is a
222
+ // match FOR THE MOUNT STATE — "the grid holds cards" is what enters `courses`, and
223
+ // the empty grid is what releases it. Same walk, one more spelling of a match.
224
+ const aliasOf = new Map<string, string>();
225
+ for (const [sname, entry] of Object.entries((def?.templates ?? {}) as Record<string, { template?: string; claims?: string[] }>)) {
226
+ if (!entry?.template || !valid.has(sname)) continue;
227
+ for (const c of entry.claims ?? []) aliasOf.set(c, sname);
228
+ }
229
+ // A mount-claim match counts only within THE CURRENT DELIVERY (hold: latest):
230
+ // released instances must release the state too, or the app would sit in an
231
+ // arrangement whose template just emptied.
232
+ const delivery = aliasOf.size ? store.currentDeliveryPointers() : null;
213
233
  // One pass over the timeline: the best (latest) match per view.
214
234
  const best = new Map<string, { at: number; byGuest: boolean }>();
215
235
  for (const turn of store.getResponses())
216
236
  for (const p of turn.components) {
217
237
  const { chatId, nodeId } = store.split(p);
218
238
  const v = viewOf(store.get(chatId, nodeId) as Record<string, unknown>);
219
- if (!v || !valid.has(v)) continue;
239
+ if (!v) continue;
240
+ const aliasTarget = aliasOf.get(v);
241
+ const target = valid.has(v) ? v : aliasTarget && delivery?.has(p) ? aliasTarget : undefined;
242
+ if (!target) continue;
220
243
  const t = store.touchOf(p);
221
- const b = best.get(v);
222
- if (!b || t > b.at) best.set(v, { at: t, byGuest: store.interactionTouchOf(p) >= t });
244
+ const b = best.get(target);
245
+ if (!b || t > b.at) best.set(target, { at: t, byGuest: store.interactionTouchOf(p) >= t });
223
246
  }
224
247
  if (order) {
225
248
  // THE LADDER: first declared state with any match wins. No word is special.
@@ -298,12 +321,14 @@ export function resolveLatch(
298
321
  }
299
322
 
300
323
  /** Resolve a ComponentSlot's `select` against the store → ordered pointers. */
301
- export function selectPointers(store: ComponentStore, node: UnoverseNode): string[] {
324
+ export function selectPointers(store: ComponentStore, node: UnoverseNode, scope?: Set<string>): string[] {
302
325
  const sel = (node.select ?? {}) as { from?: string; type?: string; where?: { field?: string; eq?: unknown; ne?: unknown }; limit?: number };
303
326
  let pointers =
304
327
  sel.from === "all"
305
328
  ? store.getResponses().flatMap((r) => r.components)
306
329
  : (store.latestResponse()?.components ?? []);
330
+ // A SCOPED selection (the mount's hold: latest) sees only the given pointers.
331
+ if (scope) pointers = pointers.filter((p) => scope.has(p));
307
332
 
308
333
  // `where` — select by COMPONENT STATE (the reaction contract, STATE_MODEL §5b): any
309
334
  // component whose own slice matches, ordered newest state-write first so `limit: 1`
@@ -313,6 +338,18 @@ export function selectPointers(store: ComponentStore, node: UnoverseNode): strin
313
338
  // interface holds it whatever state it is in. Placement, not reaction — the one case
314
339
  // where selecting by definition name is the point rather than the anti-pattern.
315
340
  if (sel.type) pointers = pointers.filter((p) => store.typeOf(p) === sel.type);
341
+ // ONE INSTANCE, ONE CARD. An instance is its nodeId; the same instance re-delivered in
342
+ // a later turn (a row card re-surfaced by a later search) is a REFRESH of that card,
343
+ // never a sibling. `from: "all"` spans turns and would otherwise show both copies, so
344
+ // keep the most recently written pointer per instance. Per-turn selections are
345
+ // unaffected — a turn's nodeIds are already unique.
346
+ const byInstance = new Map<string, string>();
347
+ for (const p of pointers) {
348
+ const { nodeId } = store.split(p);
349
+ const held = byInstance.get(nodeId);
350
+ if (!held || store.touchOf(p) >= store.touchOf(held)) byInstance.set(nodeId, p);
351
+ }
352
+ if (byInstance.size !== pointers.length) pointers = [...byInstance.values()];
316
353
  if (sel.where) {
317
354
  const { field, eq, ne } = sel.where;
318
355
  const within = (sel.where as { in?: unknown[] }).in;
@@ -66,18 +66,21 @@ export class UnoverseClient {
66
66
  this.connecting = (async () => {
67
67
  const getToken = this.options.getAccessToken;
68
68
  // Inject the bearer on every underlying request (fresh per call → refresh-safe).
69
- const authFetch = getToken
70
- ? async (url: string | URL, init?: RequestInit): Promise<Response> => {
71
- const token = await getToken();
72
- const headers = new Headers(init?.headers);
73
- if (token) headers.set("Authorization", `Bearer ${token}`);
74
- return fetch(url, { ...init, headers });
75
- }
76
- : undefined;
77
- const transport = new StreamableHTTPClientTransport(
78
- new URL(this.url),
79
- authFetch ? { fetch: authFetch } : undefined,
80
- );
69
+ // ALWAYS wrapped (not only when a token getter exists): dev tunnels need the
70
+ // ngrok bypass header below, token or not.
71
+ const authFetch = async (url: string | URL, init?: RequestInit): Promise<Response> => {
72
+ const token = getToken ? await getToken() : undefined;
73
+ const headers = new Headers(init?.headers);
74
+ if (token) headers.set("Authorization", `Bearer ${token}`);
75
+ // DEV TUNNEL ONLY: ngrok's free tier answers browser requests with an
76
+ // interstitial (200 HTML, no CORS) unless this header rides along — which
77
+ // read as "blocked by CORS" inside ChatGPT's widget sandbox (observed live
78
+ // 2026-08-28). Hostname-gated so production traffic never grows a
79
+ // preflight-triggering custom header.
80
+ if (String(url).includes("ngrok")) headers.set("ngrok-skip-browser-warning", "true");
81
+ return fetch(url, { ...init, headers });
82
+ };
83
+ const transport = new StreamableHTTPClientTransport(new URL(this.url), { fetch: authFetch });
81
84
  await this.client.connect(transport);
82
85
  this.connected = true;
83
86
  })().catch((err) => {
@@ -27,6 +27,19 @@
27
27
  /** A pointer into the data plane — the `${chatId}:${nodeId}` key. */
28
28
  export type Pointer = string;
29
29
 
30
+ /** Structural equality for wire values: identity first, JSON compare for the rest.
31
+ * Wire payloads are plain JSON, so stringify is faithful there; anything it cannot
32
+ * serialize (cycles, functions) counts as CHANGED, which errs on re-rendering. */
33
+ function sameValue(a: unknown, b: unknown): boolean {
34
+ if (Object.is(a, b)) return true;
35
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
36
+ try {
37
+ return JSON.stringify(a) === JSON.stringify(b);
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
30
43
  export interface UserTurn {
31
44
  role: "user";
32
45
  id: string;
@@ -81,6 +94,8 @@ export class ComponentStore {
81
94
  // ComponentSlot.select.where's "most recent state-write wins" ordering (§5b).
82
95
  // Generic bookkeeping: core stamps WHEN a slice changed, never reads WHAT. ---
83
96
  private touches = new Map<string, number>();
97
+ /** The touch sequence at the guest's last send — the CURRENT DELIVERY's floor. */
98
+ private deliveryWatermark = 0;
84
99
  /** Slices whose LATEST write was a guest interaction (`mergeComponentState`) — value is
85
100
  * that write's touch seq. Powers `pinned` layouts: guest writes move them, arrivals don't. */
86
101
  private interactionTouches = new Map<string, number>();
@@ -355,6 +370,9 @@ export class ComponentStore {
355
370
  * guest's next message; a stale rail is cleared by the cards that replace it,
356
371
  * never by the message that asked for them. */
357
372
  addUserMessage(chatId: string, text: string): void {
373
+ // The guest's send is the delivery boundary (hold: latest, basic form): every
374
+ // pointer not server-touched after this moment leaves the current set.
375
+ this.deliveryWatermark = this.touchSeq;
358
376
  this.timeline.push({ role: "user", id: `u${this.seq++}`, chatId, text, createdAt: Date.now() });
359
377
  this.evict();
360
378
  this.bump();
@@ -401,8 +419,12 @@ export class ComponentStore {
401
419
  this.timeline.push(resp);
402
420
  this.evict();
403
421
  }
404
- if (!resp.components.includes(key)) resp.components.push(key);
405
- this.bump();
422
+ // A reload of an already-placed pointer changes no structure — bump only when
423
+ // something actually moved, so re-delivering a card does not re-walk the template.
424
+ if (!resp.components.includes(key)) {
425
+ resp.components.push(key);
426
+ this.bump();
427
+ }
406
428
  }
407
429
 
408
430
  private responseFor(chatId: string): AssistantTurn | undefined {
@@ -434,7 +456,16 @@ export class ComponentStore {
434
456
  private merge(key: string, patch: Record<string, unknown>): void {
435
457
  // merge-not-replace. Fresh object identity per merge = the per-slice snapshot
436
458
  // React bindings key on (useSyncExternalStore bails out for untouched slices).
437
- this.data.set(key, { ...(this.data.get(key) ?? {}), ...patch });
459
+ //
460
+ // AN UNCHANGED MERGE IS A TRUE NO-OP: a reload that re-delivers the same props must
461
+ // not bump recency (which would reorder recency-sorted slots) or mint a new snapshot
462
+ // (which would re-render the card). Cheap identity check per key first; only keys
463
+ // that fail it pay for the structural compare — wire payloads are plain JSON, so
464
+ // JSON.stringify is a faithful equality there, and a non-serializable value simply
465
+ // counts as changed.
466
+ const prev = this.data.get(key);
467
+ if (prev && Object.keys(patch).every((k) => k in prev && sameValue(prev[k], patch[k]))) return;
468
+ this.data.set(key, { ...(prev ?? {}), ...patch });
438
469
  this.touches.set(key, ++this.touchSeq);
439
470
  this.bumpData();
440
471
  }
@@ -533,6 +564,23 @@ export class ComponentStore {
533
564
  getTimeline(): readonly Turn[] {
534
565
  return this.timeline;
535
566
  }
567
+ /** THE CURRENT DELIVERY (hold: latest, basic form — UNOVERSE_TEMPLATE_MODEL §9b):
568
+ * the pointers placed since the guest's LAST SEND. A mounted template holds only
569
+ * these, so the guest's own send clears the set — nothing stale ever survives a
570
+ * turn, and the disappearance happens at the guest's action, never a background
571
+ * delivery's. */
572
+ currentDeliveryPointers(): Set<string> {
573
+ // TOUCH-BASED, not turn-based: durable-keyed instances (conversation-chatId
574
+ // renders) file into ONE assistant turn across the whole conversation, so turn
575
+ // position says nothing about freshness. A pointer is current when it was
576
+ // WRITTEN since the guest's last send — which also makes a re-delivered card
577
+ // part of the new set, exactly as hold: latest wants.
578
+ const out = new Set<string>();
579
+ for (const r of this.getResponses())
580
+ for (const p of r.components)
581
+ if ((this.touches.get(p) ?? 0) > this.deliveryWatermark) out.add(p);
582
+ return out;
583
+ }
536
584
  /** Just the assistant responses, in order. */
537
585
  getResponses(): AssistantTurn[] {
538
586
  return this.timeline.filter((t): t is AssistantTurn => t.role === "assistant");
@@ -78,9 +78,20 @@ export interface UnoverseNode {
78
78
  user?: UnoverseNode;
79
79
  /** Timeline: data sub-tree rendered for an assistant turn (its ComponentSlot is scoped to that turn). */
80
80
  assistant?: UnoverseNode;
81
- /** Each: the per-item subtree, rendered once per element of the bound array (`bind.items`),
82
- * with that element as its data scope. The node's own `style` is the list container. */
83
- template?: UnoverseNode;
81
+ /** TWO node types read this, and the shape says which.
82
+ *
83
+ * Each: the per-item subtree, rendered once per element of the bound array (`bind.items`),
84
+ * with that element as its data scope. The node's own `style` is the list container.
85
+ *
86
+ * Template: a STRING naming what the place holds (UNOVERSE_TEMPLATE_MODEL.md — holder
87
+ * grammar, deliberately the same shape as `interface:`), e.g. `template: grid-page`.
88
+ * A renderer branches on the node's `type`, never on this key alone. */
89
+ template?: UnoverseNode | string;
90
+ /** Template: the judgment granted to THIS placement — which items the director may select,
91
+ * whether it may order them, and the rules it works to. Opaque to the renderer, which
92
+ * carries it to the loader rather than reading into it; the placed node owns the mount,
93
+ * so the grant belongs on the placement and not on the template it holds. */
94
+ director?: Record<string, unknown>;
84
95
  /** Literal content — components are microapps: static copy/lists/images are HARDCODED
85
96
  * in the def; props exist only for state (UNOVERSE_AUTHORING.md). `bind` wins when set. */
86
97
  value?: unknown; // Text
@@ -188,6 +199,10 @@ export interface UnoverseDefinition {
188
199
  * active state's (resolveActiveView → the stateOrder ladder), falling back to
189
200
  * `defaultLayout`. Absent → classic single-layout behavior via `root`. */
190
201
  layouts?: Record<string, UnoverseNode>;
202
+ /** APP PLACES (UNOVERSE_TEMPLATE_MODEL §12): manifest `templates:` block plus any
203
+ * state entry that mounts a template (`template:`); the loader stamps the mounted
204
+ * template's own state names as `claims` — the mount state's reaction claim. */
205
+ templates?: Record<string, { template?: string; claims?: string[]; preview?: string[]; director?: Record<string, unknown> } & Record<string, unknown>>;
191
206
  defaultLayout?: string;
192
207
  /** THE DECLARED STATE ORDER (STATE_MODEL v2 §5 rules 3+5) — the template's priority
193
208
  * ladder and arrival-scan order, served from the manifest. First entry outranks
@@ -251,6 +266,8 @@ export type StyleVariant = Condition & { apply: Record<string, unknown> };
251
266
  // it does NOT bundle them. A brand change in design/styles is therefore refresh-only,
252
267
  // exactly like a definition change (UNOVERSE_SPEC §2d-1).
253
268
  export interface ResolvedTheme {
269
+ /** BREAKPOINTS (`breakpoint.*`) — named container-width bands responsive style maps key on. Served. */
270
+ breakpoint?: Record<string, string>;
254
271
  color: Record<string, string>;
255
272
  /** Webfont stylesheet URLs (served `fonts.stylesheets` data) — injected into
256
273
  * document.head by the SDK (shadow roots can't register @font-face). */