@unoverse-platform/studio 0.1.85 → 0.1.87

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.
@@ -14,8 +14,8 @@
14
14
  * definition, and `setValue` writes the same instance either way.
15
15
  */
16
16
  import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type CSSProperties } from "react";
17
- import { dispatchAction, type ComponentStore, type ResolvedTheme, type UnoverseClient, type UnoverseDefinition } from "./core";
18
- import { renderNode, keyframesCss, type ActionHandler } from "./render";
17
+ import { dispatchAction, matches, type ComponentStore, type ResolvedTheme, type UnoverseClient, type UnoverseDefinition } from "./core";
18
+ import { renderNode, keyframesCss, type ActionHandler, type SlotResolver } from "./render";
19
19
  import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
20
20
  import { IsolatedRoot } from "./isolate";
21
21
 
@@ -192,7 +192,53 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
192
192
  const keyframes = <style>{keyframesCss(activeTheme)}</style>;
193
193
  // The shared pieces this definition NAMES travel beside it and are threaded through
194
194
  // every recursion, so a reference nested in a Switch branch or an Each item resolves too.
195
- const tree = renderNode(def.root, merged, handleAction, activeTheme, undefined, undefined, def.defs);
195
+ // A DEFINITION THAT CARRIES SLOTS RESOLVES THEM — the rule the SDK already applies to
196
+ // an app tree, applied here too. Without it a rendered TEMPLATE drew its frame and every
197
+ // section resolved to null (render.tsx: `slot ? slot(node) : null`), so a page that
198
+ // STREAMED IN rather than being mounted by an app came out blank. Mounted templates were
199
+ // fine because `app.tsx` hands that path a resolver; instances never got one, and a
200
+ // definition rendering its own parts nowhere is a renderer bug, not a design decision.
201
+ //
202
+ // Generic and dumb, like the rest of it: the slot's claim picks instances out of the
203
+ // SAME store by the face they wear, in the order the store holds them. It knows nothing
204
+ // about templates, days or cards — only "which instances match this claim".
205
+ //
206
+ // TEMPLATES ONLY, and the boundary matters: an ordinary component may carry the
207
+ // conversation's FLOW slot (`select: {}` inside a chat's assistant turn), and that one
208
+ // belongs to the app's timeline, which scopes it to a single turn's unclaimed
209
+ // components. Resolving it here too rendered every instance in the store a second time
210
+ // — the composed page appeared in its surface AND in the chat beside it.
211
+ const resolveSlot: SlotResolver | undefined = store && (def as { sections?: unknown }).sections
212
+ ? (node, key) => {
213
+ const claim = node.select?.where;
214
+ const pointers = store
215
+ .getResponses()
216
+ .flatMap((r) => r.components)
217
+ .filter((p) => {
218
+ const { chatId: c, nodeId: n } = store.split(p);
219
+ if (n === nodeId) return false; // never claim the instance drawing the slot
220
+ return claim ? matches(claim, (store.get(c, n) ?? {}) as Record<string, unknown>) : true;
221
+ })
222
+ .slice(0, node.select?.limit ?? undefined);
223
+ if (!pointers.length)
224
+ return node.fallback ? renderNode(node.fallback, {}, handleAction, activeTheme, key ?? "fb", undefined, def.defs) : null;
225
+ return pointers.map((p) => {
226
+ const { chatId: c, nodeId: n } = store.split(p);
227
+ return (
228
+ <UnoverseComponent
229
+ key={n}
230
+ client={client}
231
+ store={store}
232
+ chatId={c}
233
+ nodeId={n}
234
+ onAction={onAction}
235
+ theme={activeTheme}
236
+ />
237
+ );
238
+ });
239
+ }
240
+ : undefined;
241
+ const tree = renderNode(def.root, merged, handleAction, activeTheme, undefined, resolveSlot, def.defs);
196
242
  // The isolation container MIRRORS the component root's declared sizing intent (§4a:
197
243
  // the component owns its size; the host responds). A root that declares
198
244
  // `height: "full"` needs a definite chain — the container passes the host's height
@@ -39,6 +39,8 @@ 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
+ import { MountArrangement } from "./transition/index";
43
+ import { guestHeldPointers } from "./transition/guestHold";
42
44
 
43
45
  /**
44
46
  * FLIP wrapper for a multi-occupant slot: when arrivals reorder the list (a refreshed
@@ -217,6 +219,29 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
217
219
  };
218
220
  }, [client, uri]);
219
221
 
222
+ // A PAGE THAT STREAMED IN is on screen exactly like a mounted one, and claims exactly
223
+ // as much — but the app never named it, so nothing has fetched its definition. Its
224
+ // ADDRESS says what it is (`unoverse://templates/…`, §9c), which is all the SDK needs
225
+ // to learn what it claims. Fetched once per instance, into the same map the mounts use.
226
+ useEffect(() => {
227
+ let alive = true;
228
+ for (const p of store.getResponses().flatMap((r) => r.components)) {
229
+ const { chatId: c, nodeId: n } = store.split(p);
230
+ const url = store.getComponentUrl(c, n);
231
+ if (!url || !url.includes("://templates/")) continue;
232
+ const key = url.replace(/^unoverse:\/\/templates\//, "");
233
+ if (mounted[key]) continue;
234
+ client
235
+ .readDefinition(url)
236
+ .then((t) => alive && t && setMounted((m) => (m[key] ? m : { ...m, [key]: t })))
237
+ .catch((e) => console.warn(`[unoverse] streamed template "${key}" failed to load:`, e));
238
+ }
239
+ return () => {
240
+ alive = false;
241
+ };
242
+ }, [client, store, structureVersion, mounted]);
243
+
244
+
220
245
  // ARRIVAL PLACEMENT (STATE_MODEL v2 §5 rule 3): nobody sends a state — the HOST
221
246
  // places the actor at spawn. For each arrived component whose slice has no view yet:
222
247
  // 1. the component's DECLARED INITIAL, if this template has a state for it (the
@@ -240,7 +265,7 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
240
265
  if (!menu) continue;
241
266
  const initial = typeof slice.initialView === "string" ? slice.initialView : undefined;
242
267
  const face = initial && order.includes(initial) ? initial : order.find((s) => menu.includes(s));
243
- if (face) store.apply({ type: "COMPONENT_DATA", chatId, nodeId, data: { view: face } });
268
+ if (face) store.apply({ type: "COMPONENT_DATA", chatId, nodeId, data: { state: face } });
244
269
  }
245
270
  }, [def, store, structureVersion]);
246
271
 
@@ -260,6 +285,14 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
260
285
  // it holds must not also render in the flow — one instance, one placeholder.
261
286
  for (const tpl of Object.values(mounted)) {
262
287
  for (const name of Object.keys(tpl.layouts ?? {})) views.add(name);
288
+ // A TEMPLATE CLAIMS WHAT ITS SECTIONS CLAIM, not just its state names. A grid-page
289
+ // holds its content in its own states, so the names were enough; a rich page holds
290
+ // its content in PARTS — a written hero, a section head, the band's cards — and
291
+ // each wears the section's face. Without these the parts are unclaimed at app
292
+ // level, so the SDK draws the page in its surface AND every piece of it again in
293
+ // the conversation flow (observed live 2026-08-30: the whole page, twice).
294
+ for (const secs of Object.values((tpl as { sections?: Record<string, { face?: string }[]> }).sections ?? {}))
295
+ for (const sec of secs) if (sec?.face) views.add(sec.face);
263
296
  }
264
297
  return views;
265
298
  }, [activeRoot, def, mounted]);
@@ -419,38 +452,95 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
419
452
  // nothing collapses.
420
453
  if (spec?.template) {
421
454
  const tpl = mounted[spec.template];
422
- const active = tpl ? resolveActiveLayout(tpl, store) : undefined;
455
+ if (!tpl) return null;
423
456
  // HOLD: LATEST (basic form, UNOVERSE_TEMPLATE_MODEL §9b): the mount holds
424
- // only the CURRENT delivery — the guest's send clears the set.
457
+ // only the CURRENT delivery — spent by the settle or the clear, never the
458
+ // guest's send. A judged DIRECTOR_VERDICT narrows it further (§7); no
459
+ // verdict, or a verdict holding nothing that survives, falls back to the
460
+ // whole delivery.
461
+ const active = resolveActiveLayout(tpl, store);
425
462
  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>;
463
+ const dirScope = store.directorScope(delivery);
464
+ const tplLayouts = (tpl as { layouts?: Record<string, UnoverseNode> }).layouts ?? {};
465
+ // THE GUEST'S HAND OUTRANKS THE SETTLE (§6b rule 3, state priority): an
466
+ // instance the guest holds open in a non-base face (Detail View) stays in
467
+ // the mount's scope through deliveries and settles — a new batch landing
468
+ // below must not unseat it. The hold releases when the guest writes the
469
+ // face back to the base (✕), and the CURRENT arrangement takes over.
470
+ const layoutNames = Object.keys(tplLayouts);
471
+ const baseLayout = (tpl as { defaultLayout?: string }).defaultLayout ?? layoutNames[0] ?? null;
472
+ const guestHeld = guestHeldPointers(
473
+ store.getResponses().flatMap((r) => r.components),
474
+ {
475
+ touchOf: (p) => store.touchOf(p),
476
+ interactionTouchOf: (p) => store.interactionTouchOf(p),
477
+ viewOf: (p) => {
478
+ const { chatId, nodeId } = store.split(p);
479
+ return viewOf(store.get(chatId, nodeId) as Record<string, unknown> | undefined);
480
+ },
481
+ },
482
+ layoutNames,
483
+ baseLayout,
484
+ );
485
+ const scope = guestHeld.length ? new Set([...(dirScope ?? delivery), ...guestHeld]) : (dirScope ?? delivery);
486
+ const held = active ? collectSlotPointers(active, store, scope) : [];
487
+ // THE TRANSITION MODEL (§13): the mount double-buffers — this render
488
+ // derives the NEXT arrangement; MountArrangement commits it only at a
489
+ // boundary (verdict change, guest layout change, un-directed mount), so
490
+ // a data ripple can never move the shelf, and EMPTY COLLAPSES only when
491
+ // an arrangement holding nothing is COMMITTED. Content inside held
492
+ // instances stays live: the closure reads slices at render time.
493
+ const nextArrangement = {
494
+ layoutName: active ? (Object.entries(tplLayouts).find(([, t]) => t === active)?.[0] ?? null) : null,
495
+ held,
496
+ verdictSig: dirScope ? [...dirScope].sort().join("|") : null,
497
+ };
498
+ const motion = ((theme as { motion?: { mount?: string } }).motion?.mount ?? "") === "transition";
499
+ return (
500
+ <MountArrangement
501
+ key={key ?? cname}
502
+ next={nextArrangement}
503
+ directed={
504
+ // THE ONE HOME (§9b clause 5): the grant is the template's own
505
+ // director SECTION, compiled into the served def's `sections`;
506
+ // the placement's `director:` block is the legacy fallback.
507
+ Object.values(((tpl as { sections?: Record<string, { kind?: string }[]> }).sections ?? {}))
508
+ .flat()
509
+ .some((s) => s?.kind === "director") || Boolean((spec as { director?: unknown }).director)
510
+ }
511
+ motion={motion}
512
+ render={(arr) => {
513
+ if (!arr.held.length) return null;
514
+ const arrActive = (arr.layoutName ? tplLayouts[arr.layoutName] : undefined) ?? active;
515
+ if (!arrActive) return null;
516
+ const arrScope = new Set(arr.held);
517
+ const tree = renderNode(arrActive, store.getAppState() as Record<string, unknown>, dispatch, theme, `${String(key ?? cname)}-tpl`, makeResolveSlot(arrScope), tpl.defs);
518
+ // A PLACE DECLARES ITS WIDTH ONCE (docs/design/05), same law as a slot
519
+ // panel: `appWidth` resolves via theme.appSize and sizes the frame box —
520
+ // `flex` is the served "all remaining space" name. Without it the frame's
521
+ // own styles decide (and a width-less frame beside a fixed-width chat
522
+ // collapsed to its padding — observed live 2026-08-28).
523
+ const declaredW = (node as { appWidth?: string }).appWidth;
524
+ const placeW = cssWidth(typeof declaredW === "string" ? (theme.appSize?.[declaredW] ?? declaredW) : undefined);
525
+ // THEME-FREE FALLBACK (same law as computeAppWidth's marker): a theme without
526
+ // an `appSize.flex` token must still mean "fill the remaining host space" —
527
+ // an unresolved name collapsed the place to its padding (observed live
528
+ // 2026-08-28, the light theme's missing `flex`).
529
+ const flexFallback = !placeW && declaredW === "flex";
530
+ const rawFrame = (node as { frame?: UnoverseNode }).frame;
531
+ if (rawFrame) {
532
+ const frame = placeW
533
+ ? ({ ...rawFrame, style: { ...(rawFrame.style ?? {}), width: placeW, flex: "0 0 auto" } } as UnoverseNode)
534
+ : flexFallback
535
+ ? ({ ...rawFrame, style: { ...(rawFrame.style ?? {}), flex: "1 1 0%", minWidth: 0 } } as UnoverseNode)
536
+ : rawFrame;
537
+ const innerSlot: SlotResolver = () => tree;
538
+ return renderNode(frame, store.getAppState() as Record<string, unknown>, dispatch, theme, `${String(key ?? cname)}-frame`, innerSlot, def?.defs);
539
+ }
540
+ return <div style={{ display: "flex", flexDirection: "column", minHeight: 0, ...(placeW ? { width: placeW, flex: "0 0 auto" } : flexFallback ? { flex: "1 1 0%", minWidth: 0 } : {}) }}>{tree}</div>;
541
+ }}
542
+ />
543
+ );
454
544
  }
455
545
  const stateNames = spec?.states ? Object.keys(spec.states) : [];
456
546
  const appState = store.getAppState() as Record<string, unknown>;
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
15
15
  import { emitAnalytics } from "./core/analytics";
16
- import { applyServerMessage, resolveLatch, type ComponentStore, type ServerMessage } from "./core";
16
+ import { applyServerMessage, viewOf, type ComponentStore, type ServerMessage } from "./core";
17
17
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
18
18
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
19
19
  // Interpreter-based JSON-schema validator (no `new Function`). The SDK's default Ajv
@@ -344,58 +344,59 @@ export function useUnoverseConnection(
344
344
  (text: string) => {
345
345
  const message = text.trim();
346
346
  if (!message) return;
347
- const turn = mintTurn(); // ONE turn id shared by the user message, the assistant response, and the run
348
- store.addUserMessage(turn, message); // optimistic user turn
349
- // Optimistically open the assistant turn too, so the avatar + thinking dots
350
- // appear IMMEDIATELY on send instead of waiting for the backend's
351
- // WORKFLOW_STARTED. startResponse is idempotent — the later WORKFLOW_STARTED
352
- // (and the first COMPONENT_INIT) reuse this same turn (responseFor by chatId).
353
- store.startResponse(turn);
354
347
  /**
355
348
  * THE LATCH RIDES THE ORDINARY SEND (docs/MCP_COMPLETE_GUIDE.md §The Component Latch).
356
349
  *
357
- * Resolved HERE rather than in a host, because both hosts call this one function and
350
+ * Handled HERE rather than in a host, because both hosts call this one function and
358
351
  * two hosts each deciding "what happens on send" is how the composer regression
359
- * happened. The user turn is still minted normally — the guest said something and it
360
- * belongs in the timeline; the latch only tells the workflow WHERE the answer lands.
352
+ * happened.
361
353
  *
362
- * `state` is the component's live slice, so the workflow is told what the page
363
- * currently holds without a round trip, and it stays right after local `setValue`
364
- * writes the server never saw. Sent as one `latch` object so an unlatched send is
365
- * byte-for-byte what it was before.
354
+ * READ, NOT RE-DERIVED. `app.tsx` runs `resolveLatch` every render and publishes
355
+ * `latched`/`latchKey` into app state for the composer's pill; this reads that same
356
+ * published fact. Deriving it again here is what left the send inert for two weeks:
357
+ * `resolveLatch` needs the app DEFINITION and this file holds a raw MCP `Client` with
358
+ * no definition cache, so the second derivation could never see `def.latches` and
359
+ * silently answered "not latched" on every send.
366
360
  */
361
+ const app = store.getAppState() as Record<string, unknown>;
362
+ const held =
363
+ app.latched === true && typeof app.latchKey === "string" && app.latchKey ? store.split(app.latchKey) : null;
367
364
  /**
368
- * UNFINISHED, and deliberately shipped inert rather than guessed at.
369
- *
370
- * `peekDefinition` is the SYNCHRONOUS cached read the send path needs (a send cannot
371
- * await a resources/read), and it lives on `UnoverseClient` — the wrapper holding the
372
- * definition cache, constructed in appEngine.tsx. THIS file holds a raw MCP `Client`
373
- * (line 137), which has no cache and no such method, so the lookup finds nothing here.
365
+ * THE RUN REUSES THE LATCHED TURN; the guest's message does not.
374
366
  *
375
- * Optional-called through a narrow type so the shape is honest: null is already a
376
- * legitimate answer meaning "no opinion", `resolveLatch` is not called for it, and an
377
- * unlatched send is byte-for-byte what it was before. The day this ref carries the
378
- * wrapper, latching starts working with no change here.
367
+ * The user turn is minted normally either way — the guest said something and it
368
+ * belongs in the timeline where they said it. What the latch changes is where the
369
+ * ANSWER opens: `startResponse` and the trigger both take the held component's own
370
+ * chatId, so every render in that run keys `chatId:nodeId` to what is already on
371
+ * screen, and a repeated COMPONENT_INIT on a seen key is already a props merge
372
+ * (applyServerMessage). Hydrate in place, by construction, with no new return path.
379
373
  */
380
- const cache = clientRef.current as unknown as
381
- | { peekDefinition?: (uri: string) => Parameters<typeof resolveLatch>[0] }
382
- | null;
383
- const tmpl = store.getActiveApp().name;
384
- const def = tmpl ? (cache?.peekDefinition?.(`unoverse://apps/${tmpl}/definition`) ?? null) : null;
385
- const held = def ? resolveLatch(def, store) : null;
374
+ const turn = mintTurn();
375
+ store.addUserMessage(turn, message); // optimistic user turn
376
+ const run = held ? held.chatId : turn;
377
+ // Optimistically open the assistant turn too, so the avatar + thinking dots
378
+ // appear IMMEDIATELY on send instead of waiting for the backend's
379
+ // WORKFLOW_STARTED. startResponse is idempotent — the later WORKFLOW_STARTED
380
+ // (and the first COMPONENT_INIT) reuse this same turn (responseFor by chatId), and
381
+ // while latched that turn is the page's own, which is exactly the point.
382
+ store.startResponse(run);
386
383
  trigger(
387
384
  held
388
385
  ? {
389
386
  message,
387
+ // `state` is the component's live slice, so the workflow is told what the page
388
+ // currently holds without a round trip, and it stays right after local
389
+ // `setValue` writes the server never saw. Sent as one `latch` object so an
390
+ // unlatched send is byte-for-byte what it was before.
390
391
  latch: {
391
392
  chatId: held.chatId,
392
393
  nodeId: held.nodeId,
393
- view: held.view,
394
+ view: viewOf(store.get(held.chatId, held.nodeId) as Record<string, unknown>) ?? "",
394
395
  state: store.get(held.chatId, held.nodeId) ?? {},
395
396
  },
396
397
  }
397
398
  : { message },
398
- turn,
399
+ run,
399
400
  );
400
401
  },
401
402
  [mintTurn, store, trigger],
@@ -69,8 +69,8 @@ export function dispatchAction(
69
69
  // on the channel path every other server action uses; the server decides whether
70
70
  // any hook cares. The payload carries both spellings during the migration (the
71
71
  // server's lifecycle scope historically read `layout`).
72
- if (ctx.nodeId && ("view" in patch || "defaultState" in patch)) {
73
- const entered = patch.view ?? patch.defaultState;
72
+ if (ctx.nodeId && ("state" in patch || "view" in patch || "defaultState" in patch)) {
73
+ const entered = patch.state ?? patch.view ?? patch.defaultState;
74
74
  ctx.sendToServer?.("viewEntered", { layout: entered, view: entered });
75
75
  }
76
76
  } else if (spec.type === "setAppValue") {
@@ -15,17 +15,18 @@ import type { ComponentStore } from "./store";
15
15
  import type { UnoverseDefinition, UnoverseNode } from "./types";
16
16
 
17
17
  /**
18
- * THE PUBLIC AXIS (STATE_MODEL v2 §2): a component's public state lives in its slice
19
- * under `view`; `defaultState` is the read-compatible legacy alias during migration.
20
- * Every derivation reads through these two helpers so the alias lives in ONE place.
18
+ * THE PUBLIC AXIS: a component's public state lives in its slice under `state`
19
+ * (the word ruled 2026-08-29, finishing the v2 rename journey); `view` and
20
+ * `defaultState` are the read-compatible legacy aliases during migration.
21
+ * Every derivation reads through these two helpers so the aliases live in ONE place.
21
22
  */
22
23
  export const viewOf = (slice: Record<string, unknown> | null | undefined): string | undefined => {
23
- const v = slice?.view ?? slice?.defaultState;
24
+ const v = slice?.state ?? slice?.view ?? slice?.defaultState;
24
25
  return typeof v === "string" ? v : undefined;
25
26
  };
26
27
 
27
- /** Is a `select.where` field the public axis? Accepts both spellings during migration. */
28
- export const isViewField = (f: unknown): boolean => f === "view" || f === "defaultState";
28
+ /** Is a `select.where` field the public axis? Accepts every spelling during migration. */
29
+ export const isViewField = (f: unknown): boolean => f === "state" || f === "view" || f === "defaultState";
29
30
 
30
31
  /**
31
32
  * THE TEMPLATE'S DECLARED ORDER (STATE_MODEL v2 §5 rule 5) — its priority ladder and
@@ -270,11 +271,13 @@ export function resolveActiveView(
270
271
  * conversation is currently ADDRESSED AT, or none.
271
272
  *
272
273
  * DERIVED, never stored, for the same reason `lifecycle` is (STATE_MODEL §3): nothing
273
- * registers a latch and nothing unregisters one. A component becomes latchable by carrying
274
- * `latch` on its slice (seeded from its manifest with the render); it stops holding the
275
- * latch when its slice evicts or leaves the surface. There is no set, no stack, and no
276
- * "latch this one too" — this function returns ONE holder or null, so two latches are not
277
- * a bug to prevent but a state that cannot be represented.
274
+ * registers a latch and nothing unregisters one. THE STATE IS WHAT IS LATCHABLE, not the
275
+ * component (moved off the manifest 2026-08-31): an instance holds the latch while it is
276
+ * wearing a state the app declared a `latch` on, and stops the moment it wears one that
277
+ * says nothing — retracted inline, superseded, evicted, all the same answer with no code.
278
+ * There is no set, no stack, and no "latch this one too" — this function returns ONE
279
+ * holder or null, so two latches are not a bug to prevent but a state that cannot be
280
+ * represented.
278
281
  *
279
282
  * SAME WALK AS `resolveActiveView`, deliberately: the ladder is the ladder. A latchable
280
283
  * slice must be sitting in one of the template's declared states to hold the latch, and
@@ -289,14 +292,16 @@ export function resolveLatch(
289
292
  def: UnoverseDefinition | null | undefined,
290
293
  store: ComponentStore,
291
294
  dismissed?: string,
292
- ): { key: string; chatId: string; nodeId: string; latch: Record<string, unknown>; view: string } | null {
295
+ ): { key: string; chatId: string; nodeId: string; latch: { title: string; icon?: string }; view: string } | null {
293
296
  const order = def?.stateOrder?.length ? def.stateOrder : undefined;
294
297
  if (!order) return null; // no ladder, no ranking — abstain rather than guess a holder
298
+ const latches = def?.latches;
299
+ if (!latches || !Object.keys(latches).length) return null; // no state is addressable here
295
300
  // `key` is the component key this candidate came from, carried so the return can name the
296
301
  // FULL key (line 269) rather than re-deriving it from chatId, which reaches the turn and
297
302
  // not the component inside it.
298
303
  let best:
299
- | { key: string; chatId: string; nodeId: string; latch: Record<string, unknown>; view: string; rank: number; at: number }
304
+ | { key: string; chatId: string; nodeId: string; latch: { title: string; icon?: string }; view: string; rank: number; at: number }
300
305
  | null = null;
301
306
  for (const turn of store.getResponses())
302
307
  for (const p of turn.components) {
@@ -307,15 +312,18 @@ export function resolveLatch(
307
312
  if (dismissed && dismissed === p) continue;
308
313
  const { chatId, nodeId } = store.split(p);
309
314
  const slice = store.get(chatId, nodeId) as Record<string, unknown>;
310
- const latch = slice?.latch;
311
- if (!latch || typeof latch !== "object") continue;
312
315
  const view = viewOf(slice);
313
316
  if (!view) continue;
314
317
  const rank = order.indexOf(view);
315
318
  if (rank < 0) continue; // not on the surface (inline/retracted): holds nothing
319
+ // THE STATE DECIDES. Same lookup for every instance, so nothing has to be told it is
320
+ // addressable and nothing has to be untold — the face it currently wears is the whole
321
+ // question, and a face with no `latch:` is simply not in this map.
322
+ const latch = latches[view];
323
+ if (!latch) continue;
316
324
  const at = store.touchOf(p);
317
325
  if (!best || rank < best.rank || (rank === best.rank && at > best.at))
318
- best = { key: p, chatId, nodeId, latch: latch as Record<string, unknown>, view, rank, at };
326
+ best = { key: p, chatId, nodeId, latch, view, rank, at };
319
327
  }
320
328
  return best ? { key: best.key, chatId: best.chatId, nodeId: best.nodeId, latch: best.latch, view: best.view } : null;
321
329
  }
@@ -323,11 +331,16 @@ export function resolveLatch(
323
331
  /** Resolve a ComponentSlot's `select` against the store → ordered pointers. */
324
332
  export function selectPointers(store: ComponentStore, node: UnoverseNode, scope?: Set<string>): string[] {
325
333
  const sel = (node.select ?? {}) as { from?: string; type?: string; where?: { field?: string; eq?: unknown; ne?: unknown }; limit?: number };
334
+ // A SCOPED selection (the mount's hold: latest) spans ALL turns and the SCOPE is
335
+ // the boundary — never the latest assistant turn. The guest's send opens a new
336
+ // (empty) turn immediately, so latest-turn scoping emptied the mount at every
337
+ // send and refilled it as renders landed: the shelf flicker ruled out in
338
+ // UNOVERSE_TEMPLATE_MODEL §12e. The delivery scope is touch-based for exactly
339
+ // this reason (turn position says nothing about freshness).
326
340
  let pointers =
327
- sel.from === "all"
341
+ sel.from === "all" || scope
328
342
  ? store.getResponses().flatMap((r) => r.components)
329
343
  : (store.latestResponse()?.components ?? []);
330
- // A SCOPED selection (the mount's hold: latest) sees only the given pointers.
331
344
  if (scope) pointers = pointers.filter((p) => scope.has(p));
332
345
 
333
346
  // `where` — select by COMPONENT STATE (the reaction contract, STATE_MODEL §5b): any
@@ -10,9 +10,21 @@
10
10
  */
11
11
  import type { Condition, VisibleWhen, UnoverseNode } from "./types";
12
12
 
13
+ /** THE PUBLIC AXIS ALIASES (`state` is the ruled word, 2026-08-29; `view` and
14
+ * `defaultState` are the legacy spellings): a lookup on any axis word falls
15
+ * through the aliases, so a layout authored in either era reads a slice keyed in
16
+ * either era. Lives HERE so every "react to state" mechanism shares it. */
17
+ const AXIS_WORDS = ["state", "view", "defaultState"] as const;
18
+ function scopeValue(data: Record<string, unknown>, field: string): unknown {
19
+ const v = data[field];
20
+ if (v !== undefined || !(AXIS_WORDS as readonly string[]).includes(field)) return v;
21
+ for (const w of AXIS_WORDS) if (data[w] !== undefined) return data[w];
22
+ return undefined;
23
+ }
24
+
13
25
  /** Does a `Condition` hold against the data scope? `eq`/`ne`/`in`, else a truthy test. */
14
26
  export function matches(cond: Condition, data: Record<string, unknown>): boolean {
15
- const v = data[cond.field];
27
+ const v = scopeValue(data, cond.field);
16
28
  if ("eq" in cond) return v === cond.eq;
17
29
  if ("ne" in cond) return v !== cond.ne;
18
30
  if ("in" in cond) return Array.isArray(cond.in) && cond.in.includes(v);
@@ -24,7 +36,7 @@ export function matches(cond: Condition, data: Record<string, unknown>): boolean
24
36
  * `Condition`. Returns whether the node should render. */
25
37
  export function visible(vw: VisibleWhen, data: Record<string, unknown>): boolean {
26
38
  if (typeof vw === "string") {
27
- const v = data[vw];
39
+ const v = scopeValue(data, vw);
28
40
  return !(v == null || v === "" || v === false || (Array.isArray(v) && v.length === 0));
29
41
  }
30
42
  return matches(vw, data);
@@ -38,6 +50,6 @@ export function selectCase(
38
50
  ): UnoverseNode | undefined {
39
51
  const cases = node.cases;
40
52
  if (!cases) return undefined;
41
- const key = node.on != null ? String(data[node.on] ?? "") : "";
53
+ const key = node.on != null ? String(scopeValue(data, node.on) ?? "") : "";
42
54
  return cases[key] ?? cases.default;
43
55
  }
@@ -140,10 +140,26 @@ export function applyServerMessage(store: ComponentStore, msg: ServerMessage, se
140
140
  case "COMPONENTS_CLEAR": {
141
141
  // Rule 5's empty delivery (STATE_MODEL §5, amended 2026-08-26): a turn that ran
142
142
  // with cards enabled and surfaced none owns the screen — earlier-turn components
143
- // in public states retract to inline. The sender decided WHEN; this only obeys.
143
+ // in public states retract to inline, and the delivery is SPENT (§12e: the
144
+ // confirmed-empty boundary — the mount collapses now, not at the guest's send).
145
+ // The sender decided WHEN; this only obeys.
146
+ store.spendDelivery();
144
147
  store.supersedeAll(typeof msg.chatId === "string" ? msg.chatId : undefined);
145
148
  return;
146
149
  }
150
+ case "DIRECTOR_VERDICT": {
151
+ // The director's judged hold for the delivery (UNOVERSE_TEMPLATE_MODEL §7):
152
+ // ids and order only, judged server-side at run completion. Each item names
153
+ // the chatId it was DELIVERED under (turn or conversation — the durable
154
+ // lane), so the pointer is exact. This only obeys — exactly the
155
+ // COMPONENTS_CLEAR contract, for the held-something verdict.
156
+ const raw = (msg as { items?: unknown }).items;
157
+ const items = Array.isArray(raw)
158
+ ? raw.filter((x): x is { chatId: string; nodeId: string } => !!x && typeof (x as { chatId?: unknown }).chatId === "string" && typeof (x as { nodeId?: unknown }).nodeId === "string")
159
+ : [];
160
+ if (items.length) store.applyDirectorVerdict(items);
161
+ return;
162
+ }
147
163
  // No AUDIO_STATE case: voice is a NATIVE SERVICE on the WS audio lane (useVoiceService),
148
164
  // NOT the structured MCP stream. Audio-state events never arrive here (UNOVERSE_SPEC §2e-1).
149
165
  case "APP_DATA": {