@unoverse-platform/studio 0.1.84 → 0.1.86

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.
@@ -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>;
@@ -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
@@ -323,11 +324,16 @@ export function resolveLatch(
323
324
  /** Resolve a ComponentSlot's `select` against the store → ordered pointers. */
324
325
  export function selectPointers(store: ComponentStore, node: UnoverseNode, scope?: Set<string>): string[] {
325
326
  const sel = (node.select ?? {}) as { from?: string; type?: string; where?: { field?: string; eq?: unknown; ne?: unknown }; limit?: number };
327
+ // A SCOPED selection (the mount's hold: latest) spans ALL turns and the SCOPE is
328
+ // the boundary — never the latest assistant turn. The guest's send opens a new
329
+ // (empty) turn immediately, so latest-turn scoping emptied the mount at every
330
+ // send and refilled it as renders landed: the shelf flicker ruled out in
331
+ // UNOVERSE_TEMPLATE_MODEL §12e. The delivery scope is touch-based for exactly
332
+ // this reason (turn position says nothing about freshness).
326
333
  let pointers =
327
- sel.from === "all"
334
+ sel.from === "all" || scope
328
335
  ? store.getResponses().flatMap((r) => r.components)
329
336
  : (store.latestResponse()?.components ?? []);
330
- // A SCOPED selection (the mount's hold: latest) sees only the given pointers.
331
337
  if (scope) pointers = pointers.filter((p) => scope.has(p));
332
338
 
333
339
  // `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": {
@@ -94,8 +94,15 @@ export class ComponentStore {
94
94
  // ComponentSlot.select.where's "most recent state-write wins" ordering (§5b).
95
95
  // Generic bookkeeping: core stamps WHEN a slice changed, never reads WHAT. ---
96
96
  private touches = new Map<string, number>();
97
- /** The touch sequence at the guest's last send — the CURRENT DELIVERY's floor. */
97
+ /** The CURRENT DELIVERY's floor — advanced by the settle (applyDirectorVerdict)
98
+ * and the confirmed-empty clear (spendDelivery), NEVER by the guest's send
99
+ * (§12e ruling 2: the replacement is the boundary). */
98
100
  private deliveryWatermark = 0;
101
+ /** A judged DIRECTOR_VERDICT's held pointers for the current delivery (null = no
102
+ * verdict — the mount holds the whole delivery). Generic bookkeeping: core stores
103
+ * WHICH slices the sender's verdict held, never why (the judging happened
104
+ * server-side; this only obeys, exactly like COMPONENTS_CLEAR). */
105
+ private directorHeld: Set<string> | null = null;
99
106
  /** Slices whose LATEST write was a guest interaction (`mergeComponentState`) — value is
100
107
  * that write's touch seq. Powers `pinned` layouts: guest writes move them, arrivals don't. */
101
108
  private interactionTouches = new Map<string, number>();
@@ -129,12 +136,22 @@ export class ComponentStore {
129
136
  * the ONE home for the reset every boundary uses (new turn, guest close, template
130
137
  * swap) — never re-hardcode `"inline"` at a call site.
131
138
  */
139
+ /** THE FACE-WRITE KEY: the spelling this slice already speaks — `state` (the
140
+ * ruled word, 2026-08-29), else the legacy alias it was seeded with. New
141
+ * slices always land on `state`. */
142
+ private faceKey(slice: Record<string, unknown>): "state" | "view" | "defaultState" {
143
+ if ("state" in slice) return "state";
144
+ if ("view" in slice) return "view";
145
+ if ("defaultState" in slice) return "defaultState";
146
+ return "state";
147
+ }
148
+
132
149
  private retractSlice(key: string, slice: Record<string, unknown>): boolean {
133
- const current = (slice.view ?? slice.defaultState) as unknown;
150
+ const current = (slice.state ?? slice.view ?? slice.defaultState) as unknown;
134
151
  const base = typeof slice.initialView === "string" ? slice.initialView : "inline";
135
152
  if (typeof current !== "string" || current === base) return false;
136
153
  const next: Record<string, unknown> = { ...slice };
137
- if ("view" in next || !("defaultState" in next)) next.view = base;
154
+ next[this.faceKey(next)] = base;
138
155
  if ("defaultState" in next) next.defaultState = base;
139
156
  this.data.set(key, next);
140
157
  this.touches.set(key, ++this.touchSeq);
@@ -210,10 +227,10 @@ export class ComponentStore {
210
227
  for (const [key, slice] of this.data) {
211
228
  if (arrivingChatId && key.startsWith(`${arrivingChatId}:`)) continue;
212
229
  if (slice.lifetime === "conversation") continue;
213
- const v = (slice.view ?? slice.defaultState) as unknown;
230
+ const v = (slice.state ?? slice.view ?? slice.defaultState) as unknown;
214
231
  if (typeof v !== "string" || !v || v === "inline") continue;
215
232
  const next: Record<string, unknown> = { ...slice };
216
- if ("view" in next || !("defaultState" in next)) next.view = "inline";
233
+ next[this.faceKey(next)] = "inline";
217
234
  if ("defaultState" in next) next.defaultState = "inline";
218
235
  this.data.set(key, next);
219
236
  this.touches.set(key, ++this.touchSeq);
@@ -227,11 +244,17 @@ export class ComponentStore {
227
244
  let changed = false;
228
245
  for (const [key, slice] of this.data) {
229
246
  if (key.startsWith(`${arrivingChatId}:`)) continue; // same turn = same delivery
247
+ // A DIRECTOR-HELD slice is under the director's authority (§12e): its
248
+ // replacement belongs to the settle, never to the mechanical refresh — a
249
+ // new batch's first card must not retract the held shelf mid-search (the
250
+ // between-turns flicker, observed live after the durable shield retired).
251
+ // Un-directed apps are untouched: no verdict, no hold, rule 5 as ever.
252
+ if (this.directorHeld?.has(key)) continue;
230
253
  if (slice.lifetime === "conversation") continue;
231
- const v = (slice.view ?? slice.defaultState) as unknown;
254
+ const v = (slice.state ?? slice.view ?? slice.defaultState) as unknown;
232
255
  if (v !== view) continue;
233
256
  const next: Record<string, unknown> = { ...slice };
234
- if ("view" in next || !("defaultState" in next)) next.view = "inline";
257
+ next[this.faceKey(next)] = "inline";
235
258
  if ("defaultState" in next) next.defaultState = "inline";
236
259
  this.data.set(key, next);
237
260
  this.touches.set(key, ++this.touchSeq);
@@ -257,8 +280,8 @@ export class ComponentStore {
257
280
  let changed = false;
258
281
  for (const [key, slice] of this.data) {
259
282
  if (slice.lifetime === "conversation") continue;
260
- if (!below.has((slice.view ?? slice.defaultState) as string)) continue;
261
- this.data.set(key, { ...slice, ...("defaultState" in slice ? { defaultState: "inline" } : { view: "inline" }) });
283
+ if (!below.has((slice.state ?? slice.view ?? slice.defaultState) as string)) continue;
284
+ this.data.set(key, { ...slice, [this.faceKey(slice)]: "inline", ...("defaultState" in slice ? { defaultState: "inline" } : {}) });
262
285
  this.touches.set(key, ++this.touchSeq);
263
286
  changed = true;
264
287
  }
@@ -331,6 +354,24 @@ export class ComponentStore {
331
354
  this.bump();
332
355
  }
333
356
 
357
+ /**
358
+ * THE GUEST'S HAND OUTRANKS AN ARRIVAL'S RE-FACE (STATE_MODEL guest-control law,
359
+ * TEMPLATE_MODEL §6b rule 3: the director — and its deliveries — sit UNDER the
360
+ * guest's hand). Once the slice's LATEST write is the guest's own (an open Detail
361
+ * View), an inbound payload keeps its content but loses its face words: a
362
+ * re-delivered card must refresh WHAT the guest is reading, never yank them out
363
+ * of it (observed live 2026-08-29: a broad follow-up re-served the open card,
364
+ * its seeded `view: "grid"` overwrote the guest's `page`, the ladder fell to
365
+ * grid). The guest releases the face themselves (✕); from then on the server
366
+ * writes it again as ever.
367
+ */
368
+ private shieldGuestFace(key: string, payload: Record<string, unknown>): Record<string, unknown> {
369
+ if (!("state" in payload) && !("view" in payload) && !("defaultState" in payload)) return payload;
370
+ if ((this.interactionTouches.get(key) ?? -1) < (this.touches.get(key) ?? 0)) return payload;
371
+ const { state: _s, view: _v, defaultState: _d, ...content } = payload;
372
+ return content;
373
+ }
374
+
334
375
  /** Apply a streamed event (COMPONENT_INIT or COMPONENT_DATA). */
335
376
  apply(event: UnoverseEvent): void {
336
377
  const key = `${event.chatId}:${event.nodeId}`;
@@ -338,10 +379,10 @@ export class ComponentStore {
338
379
  this.types.set(key, event.component.type);
339
380
  if (event.component.componentUrl) this.urls.set(key, event.component.componentUrl);
340
381
  this.placeInTimeline(event.chatId, key); // structure: place the pointer ONCE
341
- this.merge(key, event.component.props ?? {}); // data: seed
382
+ this.merge(key, this.shieldGuestFace(key, event.component.props ?? {})); // data: seed
342
383
  // Rule 5 — an arrival carrying a public state refreshes that state.
343
384
  const seeded = event.component.props as Record<string, unknown> | undefined;
344
- this.supersede(event.chatId, seeded?.view ?? seeded?.defaultState);
385
+ this.supersede(event.chatId, seeded?.state ?? seeded?.view ?? seeded?.defaultState);
345
386
  } else {
346
387
  // NEW ACTIVITY reads newest-at-the-bottom: if this component is no longer the
347
388
  // LAST pointer in its turn (something was placed after it), fresh server data
@@ -354,10 +395,10 @@ export class ComponentStore {
354
395
  resp.components.push(key);
355
396
  this.bump();
356
397
  }
357
- this.merge(key, event.data);
398
+ this.merge(key, this.shieldGuestFace(key, event.data));
358
399
  // Rule 5 — a server data-write that PLACES a component into a public state
359
400
  // (the host's arrival placement rides COMPONENT_DATA) refreshes that state.
360
- this.supersede(event.chatId, (event.data as Record<string, unknown> | undefined)?.view ?? (event.data as Record<string, unknown> | undefined)?.defaultState);
401
+ this.supersede(event.chatId, (event.data as Record<string, unknown> | undefined)?.state ?? (event.data as Record<string, unknown> | undefined)?.view ?? (event.data as Record<string, unknown> | undefined)?.defaultState);
361
402
  }
362
403
  }
363
404
 
@@ -370,9 +411,11 @@ export class ComponentStore {
370
411
  * guest's next message; a stale rail is cleared by the cards that replace it,
371
412
  * never by the message that asked for them. */
372
413
  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;
414
+ // RULE 7 RESTORED (UNOVERSE_TEMPLATE_MODEL §12e ruling 2): typing changes
415
+ // NOTHING. The delivery boundary is the REPLACEMENT — the judged batch's
416
+ // settle (applyDirectorVerdict) or the confirmed-empty clear (spendDelivery)
417
+ // — never the message that asked for it. The held set stays on screen
418
+ // through the whole search.
376
419
  this.timeline.push({ role: "user", id: `u${this.seq++}`, chatId, text, createdAt: Date.now() });
377
420
  this.evict();
378
421
  this.bump();
@@ -564,11 +607,10 @@ export class ComponentStore {
564
607
  getTimeline(): readonly Turn[] {
565
608
  return this.timeline;
566
609
  }
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. */
610
+ /** THE CURRENT DELIVERY (hold: latest — UNOVERSE_TEMPLATE_MODEL §9b, boundary per
611
+ * §12e): the pointers placed since the last SETTLE or CLEAR. A mounted template
612
+ * holds only these; the set survives the guest's next send and is spent exactly
613
+ * when its replacement arrives (or emptiness is confirmed) — never sooner. */
572
614
  currentDeliveryPointers(): Set<string> {
573
615
  // TOUCH-BASED, not turn-based: durable-keyed instances (conversation-chatId
574
616
  // renders) file into ONE assistant turn across the whole conversation, so turn
@@ -581,6 +623,47 @@ export class ComponentStore {
581
623
  if ((this.touches.get(p) ?? 0) > this.deliveryWatermark) out.add(p);
582
624
  return out;
583
625
  }
626
+ /** THE DIRECTOR'S HOLD (UNOVERSE_TEMPLATE_MODEL §7): the verdict's pointers that are
627
+ * still in the given delivery. null = no verdict, or nothing it held survives — the
628
+ * caller falls back to the whole delivery (fail open, §6c rule 9: the screen never
629
+ * pays for a stale or broken verdict). */
630
+ directorScope(delivery: Set<string>): Set<string> | null {
631
+ if (!this.directorHeld) return null;
632
+ const out = new Set<string>();
633
+ for (const p of this.directorHeld) if (delivery.has(p)) out.add(p);
634
+ return out.size ? out : null;
635
+ }
636
+ /** Apply a judged DIRECTOR_VERDICT: these instances are the delivery's held set, in
637
+ * this order. Each item carries the chatId it was DELIVERED under (the turn's, or
638
+ * the conversation's for durable renders) so the pointer is exact. ORDER IS RECENCY
639
+ * — the strongest is re-stamped LAST so the existing touch-desc sort draws it
640
+ * first: the platform's one ordering word, no new machinery. Only slices that
641
+ * exist are stamped; unknown ids are ignored (selection only — a verdict can
642
+ * never invent an instance). */
643
+ applyDirectorVerdict(items: Array<{ chatId: string; nodeId: string }>): void {
644
+ // THE SETTLE IS THE DELIVERY BOUNDARY (§12e ruling 2): spend everything
645
+ // older, then stamp the held set above the new line — one atomic replace,
646
+ // so the previous cards survive the whole search and leave exactly when
647
+ // their replacement arrives.
648
+ this.deliveryWatermark = this.touchSeq;
649
+ const held = new Set<string>();
650
+ for (let i = items.length - 1; i >= 0; i--) {
651
+ const p = `${items[i].chatId}:${items[i].nodeId}`;
652
+ if (!this.data.has(p)) continue;
653
+ held.add(p);
654
+ this.touches.set(p, ++this.touchSeq);
655
+ }
656
+ this.directorHeld = held.size ? held : null;
657
+ this.bump();
658
+ }
659
+ /** The confirmed-empty boundary (COMPONENTS_CLEAR): the run delivered nothing,
660
+ * so the current set is spent with nothing replacing it — the mount collapses.
661
+ * Same spend as the settle, without a new held set. */
662
+ spendDelivery(): void {
663
+ this.deliveryWatermark = this.touchSeq;
664
+ this.directorHeld = null;
665
+ this.bump();
666
+ }
584
667
  /** Just the assistant responses, in order. */
585
668
  getResponses(): AssistantTurn[] {
586
669
  return this.timeline.filter((t): t is AssistantTurn => t.role === "assistant");
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The double-buffered mount (UNOVERSE_TEMPLATE_MODEL §13.3): renders its LAST
3
+ * COMMITTED arrangement and commits the next one only when the decision allows —
4
+ * so a data ripple can never move the shelf, and the boundary commit is the one
5
+ * designed (and, with theme motion, animated) change. Content inside held
6
+ * instances stays live automatically: the render closure reads slices from the
7
+ * store at render time; only membership and order are buffered here.
8
+ *
9
+ * Fail open (§13.5): a "hold" with no boundary inside the grace window commits
10
+ * anyway — the screen never pays for machinery failure.
11
+ *
12
+ * RENDER-LAYER state, deliberately: the portable core stays frozen and "no UI
13
+ * state in the store" holds.
14
+ */
15
+
16
+ import React, { useEffect, useRef, useState } from "react";
17
+ import type { ReactNode } from "react";
18
+ import { decideCommit, type Arrangement } from "./decide";
19
+ import { commitArrangement } from "./viewTransition";
20
+
21
+ /** A held-back change commits anyway after this long without a boundary. */
22
+ const GRACE_MS = 4000;
23
+
24
+ export function MountArrangement({
25
+ next,
26
+ directed,
27
+ motion,
28
+ render,
29
+ }: {
30
+ /** The arrangement the store derives RIGHT NOW. */
31
+ next: Arrangement;
32
+ /** The placement grants a director — every delivery settles, so membership
33
+ * waits for the settle (decide.ts). */
34
+ directed: boolean;
35
+ /** Theme-requested motion (§13.4) — absent theme word = instant commits. */
36
+ motion: boolean;
37
+ /** Build the tree for an arrangement; slices are read live inside. */
38
+ render: (arrangement: Arrangement) => ReactNode;
39
+ }) {
40
+ const [committed, setCommitted] = useState<Arrangement | null>(null);
41
+ const graceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
42
+ const decision = decideCommit(committed, next, directed);
43
+
44
+ useEffect(() => {
45
+ if (decision === "none") return;
46
+ if (decision === "hold") {
47
+ if (!graceTimer.current) {
48
+ graceTimer.current = setTimeout(() => {
49
+ graceTimer.current = null;
50
+ setCommitted(next);
51
+ }, GRACE_MS);
52
+ }
53
+ return;
54
+ }
55
+ if (graceTimer.current) {
56
+ clearTimeout(graceTimer.current);
57
+ graceTimer.current = null;
58
+ }
59
+ if (decision === "transition") commitArrangement(() => setCommitted(next), motion);
60
+ else setCommitted(next);
61
+ });
62
+
63
+ useEffect(
64
+ () => () => {
65
+ if (graceTimer.current) clearTimeout(graceTimer.current);
66
+ },
67
+ [],
68
+ );
69
+
70
+ return <>{render(committed ?? next)}</>;
71
+ }