@unoverse-platform/studio 0.1.25 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/screens/AtomsView.tsx +40 -2
  3. package/screens/ComponentsView.tsx +112 -21
  4. package/screens/NativeAppHost.tsx +8 -4
  5. package/screens/SourceView.tsx +1 -1
  6. package/screens/TemplatesView.tsx +52 -22
  7. package/screens/states.ts +27 -4
  8. package/src/PublishDialog.tsx +1 -1
  9. package/vendor/sdk/appEngine.tsx +44 -3
  10. package/vendor/sdk/lib/UnoverseComponent.tsx +1 -1
  11. package/vendor/sdk/lib/core/actions.ts +20 -6
  12. package/vendor/sdk/lib/core/analytics.ts +89 -0
  13. package/vendor/sdk/lib/core/client.ts +1 -1
  14. package/vendor/sdk/lib/core/index.ts +30 -0
  15. package/vendor/sdk/lib/core/store.ts +65 -38
  16. package/vendor/sdk/lib/core/templates.ts +252 -0
  17. package/vendor/sdk/lib/core/types.ts +40 -7
  18. package/vendor/sdk/lib/index.ts +9 -0
  19. package/vendor/sdk/lib/orb/ThinkingOrb.tsx +127 -0
  20. package/vendor/sdk/lib/orb/engine/braid.ts +50 -0
  21. package/vendor/sdk/lib/orb/engine/core.ts +157 -0
  22. package/vendor/sdk/lib/orb/engine/lattice.ts +205 -0
  23. package/vendor/sdk/lib/orb/engine/morph.ts +138 -0
  24. package/vendor/sdk/lib/orb/engine/orbits.ts +80 -0
  25. package/vendor/sdk/lib/orb/engine/profiles.ts +162 -0
  26. package/vendor/sdk/lib/orb/engine/registry.ts +24 -0
  27. package/vendor/sdk/lib/orb/engine/ribbon.ts +93 -0
  28. package/vendor/sdk/lib/orb/engine/types.ts +14 -0
  29. package/vendor/sdk/lib/orb/engine/web.ts +98 -0
  30. package/vendor/sdk/lib/orb/index.ts +20 -0
  31. package/vendor/sdk/lib/orb/presets.ts +104 -0
  32. package/vendor/sdk/lib/orb/theme.ts +83 -0
  33. package/vendor/sdk/lib/orb/types.ts +73 -0
  34. package/vendor/sdk/lib/primitives.tsx +114 -5
  35. package/vendor/sdk/lib/render.tsx +25 -5
  36. package/vendor/sdk/lib/style.ts +6 -2
  37. package/vendor/sdk/lib/template.tsx +77 -218
  38. package/vendor/sdk/main.tsx +7 -0
  39. package/vendor/skills/unoverse-create/SKILL.md +18 -18
  40. package/vendor/skills/unoverse-create/references/agent-skill.md +12 -12
  41. package/vendor/skills/unoverse-create/references/block.md +5 -5
  42. package/vendor/skills/unoverse-create/references/component.md +150 -75
  43. package/vendor/skills/unoverse-create/references/node.md +1 -1
  44. package/vendor/skills/unoverse-create/references/template.md +142 -82
  45. package/vendor/skills/unoverse-create/references/workflow.md +15 -15
@@ -23,7 +23,8 @@ import { createElement, useEffect, useMemo, useRef, useState, type CSSProperties
23
23
  import Markdown from "markdown-to-jsx";
24
24
  import type { ResolvedTheme, UnoverseNode } from "./core";
25
25
  import { styleToCss, cssDecls } from "./style";
26
- import type { ActionHandler } from "./render";
26
+ import { nodeAction, type ActionHandler } from "./render";
27
+ import { ThinkingOrb, type OrbState, type OrbSize, type OrbTheme } from "./orb";
27
28
 
28
29
  /** Read a bound data field as a string (for Text/Image/Button content). */
29
30
  function bound(field: string | undefined, data: Record<string, unknown>): string | undefined {
@@ -332,13 +333,51 @@ export function ImageView({ node, data, theme }: { node: UnoverseNode; data: Rec
332
333
  export function ButtonView({ node, data, theme, onAction, children }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; onAction?: ActionHandler; children?: ReactNode }): ReactNode {
333
334
  const { className, styleEls, style, disabled } = nodeChrome(node, data, theme);
334
335
  return (
335
- <button className={className} style={style} disabled={disabled} data-unoverse-id={inspectId(node)} onClick={() => onAction?.(node.action ?? "click", data)}>
336
+ <button className={className} style={style} disabled={disabled} data-unoverse-id={inspectId(node)} onClick={() => onAction?.(nodeAction(node, "click"), data)}>
336
337
  {styleEls}
337
338
  {node.children?.length ? children : bound(node.bind?.label, data)}
338
339
  </button>
339
340
  );
340
341
  }
341
342
 
343
+ /**
344
+ * `Orb` — a procedural "the assistant is doing something" animation, drawn
345
+ * per-frame on a 2D canvas (see ./orb — vendored thinking-orbs, MIT). This is
346
+ * the ONE class of motion keyframe data cannot express (per-frame math, depth
347
+ * occlusion), so like Skeleton's shimmer the drawing lives WITH the primitive;
348
+ * every authoring decision stays data: `state` (which of the nine animations —
349
+ * bindable), `size` (64 avatar / 20 inline), `speed`, `theme` override.
350
+ * Unknown/unbound state degrades to "working", never a crash.
351
+ */
352
+ const ORB_STATES = new Set(["working", "searching", "solving", "listening", "connecting", "weaving", "composing", "breathing", "shaping"]);
353
+ export function OrbView({ node, data, theme }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme }): ReactNode {
354
+ const n = node as UnoverseNode & { state?: string; size?: number | string; speed?: number | string; theme?: string };
355
+ // Every knob is data-driven OR literal, like Icon: `bind.<knob>` wins, the attribute is the fallback.
356
+ const knob = (name: "state" | "size" | "speed") =>
357
+ (node.bind?.[name] ? (data[node.bind[name] as string] as string | number | undefined) : undefined) ?? n[name];
358
+ const raw = knob("state") as string | undefined;
359
+ const state = raw && ORB_STATES.has(raw) ? (raw as OrbState) : "working";
360
+ const size = Number(knob("size")) > 0 ? Number(knob("size")) : 64;
361
+ const speed = Number(knob("speed")) > 0 ? Number(knob("speed")) : 1;
362
+ const orbTheme: OrbTheme = n.theme === "dark" || n.theme === "light" ? n.theme : "auto";
363
+ // Brand ink comes from the STYLE vocabulary (`color: <token>`), never a prop — LAW 1:
364
+ // a definition names a token, the theme serves the value, the engine tints toward it.
365
+ const css = styleToCss(node.style ?? {}, theme);
366
+ return <ThinkingOrb state={state} size={size as OrbSize} speed={speed} theme={orbTheme} ink={cssColorToRgb(css.color as string | undefined)} style={css} />;
367
+ }
368
+
369
+ /** #rgb / #rrggbb / rgb()/rgba() → an rgb tuple; anything else (gradients, names) → null (neutral ink). */
370
+ function cssColorToRgb(c?: string): [number, number, number] | null {
371
+ if (!c) return null;
372
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(c.trim());
373
+ if (hex) {
374
+ const h = hex[1].length === 3 ? [...hex[1]].map((x) => x + x).join("") : hex[1];
375
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)) as [number, number, number];
376
+ }
377
+ const fn = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(c.trim());
378
+ return fn ? [Number(fn[1]), Number(fn[2]), Number(fn[3])] : null;
379
+ }
380
+
342
381
  /** Unknown node type — render children so the tree doesn't break (degenerate fallback). */
343
382
  export function UnknownView({ node, theme, children }: { node: UnoverseNode; theme: ResolvedTheme; children?: ReactNode }): ReactNode {
344
383
  return (
@@ -432,6 +471,58 @@ export function SkeletonView({ variant = "text", theme }: { variant?: string; th
432
471
  );
433
472
  }
434
473
 
474
+ /**
475
+ * `Select` — the irreducible `<select>`, like Text→<span> and Input→<input>.
476
+ *
477
+ * THE ONE CONTROL THAT CANNOT BE COMPOSED. Every other form field in this platform is
478
+ * built from Box, Text, Button and Input, as data. A closed set of two hundred countries
479
+ * is where that stops: laying them out is unusable, filtering them is logic a definition
480
+ * may not carry, and a hand-rolled menu forfeits the OS picker, type-to-search, keyboard
481
+ * navigation and screen-reader semantics that the real element gives for nothing.
482
+ *
483
+ * It reports a change exactly as a controlled Input does — `onAction("input", { field,
484
+ * text })` — so the bound field name IS the key the answer lands in, and a select needs no
485
+ * mechanism the platform does not already have.
486
+ *
487
+ * ⛔ NO CHROME HERE. No custom arrow, no popup, no styling: `node.style` is the whole look,
488
+ * and the options are DATA (`bind.options`, entries of `{ value, label }` or plain
489
+ * strings). A searchable picker is a different control and does not belong in this one.
490
+ */
491
+ export function SelectView({ node, data, theme, onAction }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; onAction?: ActionHandler }): ReactNode {
492
+ const field = node.bind?.value;
493
+ const value = field != null ? String(data[field] ?? "") : "";
494
+ const raw = node.bind?.options ? data[node.bind.options] : (node as { options?: unknown }).options;
495
+ const options = (Array.isArray(raw) ? raw : []).map((o) =>
496
+ o != null && typeof o === "object"
497
+ ? { value: String((o as Record<string, unknown>).value ?? (o as Record<string, unknown>).label ?? ""), label: String((o as Record<string, unknown>).label ?? (o as Record<string, unknown>).value ?? "") }
498
+ : { value: String(o), label: String(o) },
499
+ );
500
+ const { style, disabled } = nodeChrome(node, data, theme);
501
+ return (
502
+ <select
503
+ style={style}
504
+ disabled={disabled}
505
+ value={value}
506
+ data-unoverse-id={inspectId(node)}
507
+ onChange={(e) => field != null && onAction?.("input", { text: e.target.value, field })}
508
+ >
509
+ {/* The placeholder is CONTENT, exactly as it is on an Input: an unanswered select
510
+ must say what it wants rather than pre-selecting the first option and claiming an
511
+ answer nobody gave. */}
512
+ {node.placeholder != null && (
513
+ <option value="" disabled>
514
+ {node.placeholder}
515
+ </option>
516
+ )}
517
+ {options.map((o) => (
518
+ <option key={o.value} value={o.value}>
519
+ {o.label}
520
+ </option>
521
+ ))}
522
+ </select>
523
+ );
524
+ }
525
+
435
526
  /**
436
527
  * `Input` — single-line composer (the irreducible `<input>`, like Text→<span>).
437
528
  * Holds its own draft text (local, not store state); on Enter it fires
@@ -463,6 +554,11 @@ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode;
463
554
  // a postal code, "off") and the guess never happens. Generic pass-through: the SDK holds
464
555
  // no list of values and no idea what a one-time code is.
465
556
  const autoComplete = (node as { autoComplete?: string }).autoComplete;
557
+ // `type` — WHAT THE FIELD IS, passed to the platform: `date` gets the OS date picker,
558
+ // `tel` the phone keypad, `file` the file chooser. The same class of generic constraint
559
+ // as `maxLength` and `inputMode`, and the reason a definition never has to hand-roll a
560
+ // calendar out of Boxes. The SDK holds no list of types and no opinion about them.
561
+ const inputType = (node as { inputType?: string }).inputType;
466
562
  // `maxLength` is enforced by the element itself, but `inputMode` is only a HINT —
467
563
  // a desktop keyboard types straight past it — so the digit rule is applied here.
468
564
  const accept = (t: string) => {
@@ -507,7 +603,7 @@ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode;
507
603
  const submit = () => {
508
604
  const t = value.trim();
509
605
  if (!t) return;
510
- onAction?.(node.action ?? "submit", { text: t });
606
+ onAction?.(nodeAction(node, "submit"), { text: t });
511
607
  if (field == null) setLocal("");
512
608
  };
513
609
  const { style, disabled } = nodeChrome(node, data, theme);
@@ -538,13 +634,26 @@ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode;
538
634
  ref={el}
539
635
  style={style}
540
636
  disabled={disabled}
541
- value={value}
637
+ // A file input is never controlled: assigning its value is a security error in every
638
+ // browser, so the chosen name lives in the data and the element stays uncontrolled.
639
+ value={inputType === "file" ? undefined : value}
542
640
  placeholder={node.placeholder}
641
+ type={inputType}
543
642
  maxLength={maxLength}
544
643
  inputMode={numeric ? "numeric" : undefined}
545
644
  autoComplete={autoComplete}
546
645
  data-uno-advance={advanceOnFill ? "" : undefined}
547
- onChange={(e) => setValue(e.target.value)}
646
+ onChange={(e) => {
647
+ // A FILE FIELD REPORTS ITS FILES, not its value: the DOM's value for one is a fake
648
+ // path, and the bytes are the host's business (an upload target, a stored
649
+ // reference) rather than the renderer's. The SDK states what was chosen and stops.
650
+ if (inputType === "file") {
651
+ const picked = Array.from(e.target.files ?? []).map((f) => ({ name: f.name, size: f.size, type: f.type }));
652
+ if (field != null) onAction?.("input", { text: picked.map((f) => f.name).join(", "), field, files: picked });
653
+ return;
654
+ }
655
+ setValue(e.target.value);
656
+ }}
548
657
  onKeyDown={(e) => {
549
658
  // Backspace in an ALREADY-empty field steps back, so correcting a sequence never
550
659
  // needs the mouse. A field with content just deletes it (the default).
@@ -35,8 +35,8 @@
35
35
  */
36
36
  import type { ReactNode } from "react";
37
37
  import type { ActionSpec, ResolvedTheme, UnoverseNode } from "./core";
38
- import { visible, selectCase } from "./core";
39
- import { BoxView, TextView, ImageView, ButtonView, UnknownView, SkeletonView, InputView, MarkdownView, IconView } from "./primitives";
38
+ import { visible, selectCase, cssWidth } from "./core";
39
+ import { BoxView, TextView, ImageView, ButtonView, UnknownView, SkeletonView, InputView, SelectView, MarkdownView, IconView, OrbView } from "./primitives";
40
40
 
41
41
  // The style + animation interpreter lives in ./style; re-exported so consumers keep
42
42
  // importing it from "./render".
@@ -57,6 +57,22 @@ export interface ActionMeta {
57
57
 
58
58
  export type ActionHandler = (action: string | ActionSpec, data: Record<string, unknown>, meta?: ActionMeta) => void;
59
59
 
60
+ /**
61
+ * A leaf's action, with the node's declared `analytics` attached (UNOVERSE_ANALYTICS.md).
62
+ *
63
+ * Authors write `analytics:` beside `action:` on the node, where the data it reports is
64
+ * already in scope. The two travel together from here so `dispatchAction` remains the ONE
65
+ * place an interaction is understood: leaves stay dumb, and no leaf can forget to report.
66
+ * A node with no `analytics` block passes its action through untouched, unallocated.
67
+ */
68
+ export function nodeAction(node: UnoverseNode, fallback: string): string | ActionSpec {
69
+ const action = node.action ?? fallback;
70
+ if (!node.analytics?.event) return action;
71
+ return typeof action === "string"
72
+ ? { type: action, analytics: node.analytics }
73
+ : { ...action, analytics: node.analytics };
74
+ }
75
+
60
76
  /**
61
77
  * Resolves a `ComponentSlot` node against the store (provided by the template
62
78
  * renderer). Returns the rendered leaves (or the slot's fallback). Components
@@ -64,9 +80,9 @@ export type ActionHandler = (action: string | ActionSpec, data: Record<string, u
64
80
  */
65
81
  export type SlotResolver = (node: UnoverseNode, key?: React.Key) => ReactNode;
66
82
 
67
- /** A usable appWidth is CSS ("360px", "min(50vw, 760px)") — an UNRESOLVED size name
68
- * has no digits; treat it as absent rather than emitting invalid CSS. */
69
- export const cssWidth = (w: unknown): string | undefined => (typeof w === "string" && /\d/.test(w) ? w : undefined);
83
+ // cssWidth moved to core/templates.ts (computeAppWidth needs it and core imports
84
+ // nothing from the React layer); re-exported to keep this import path working.
85
+ export { cssWidth };
70
86
 
71
87
  export function renderNode(
72
88
  node: UnoverseNode,
@@ -119,6 +135,8 @@ export function renderNode(
119
135
  // through `with` — so without this an atom can be data-driven or literal, never both.
120
136
  if (node.type === "Icon")
121
137
  return <IconView key={key} name={node.bind?.name ? (data[node.bind.name] as string) : (node.icon ?? node.name)} style={node.style} theme={theme} />;
138
+ // Orb: procedural thinking/working animation — state is a literal or bound (`bind.state`).
139
+ if (node.type === "Orb") return <OrbView key={key} node={node} data={data} theme={theme} />;
122
140
  if (node.type === "Input") return <InputView key={key} node={node} data={data} theme={theme} onAction={onAction} />;
123
141
  if (node.type === "Markdown") return <MarkdownView key={key} node={node} data={data} theme={theme} />;
124
142
  if (node.type === "ComponentSlot" || node.type === "Timeline") return slot ? slot(node, key) : null;
@@ -181,6 +199,8 @@ export function renderNode(
181
199
  return <TextView key={key} node={node} data={data} theme={theme} />;
182
200
  case "Image":
183
201
  return <ImageView key={key} node={node} data={data} theme={theme} />;
202
+ case "Select":
203
+ return <SelectView key={key} node={node} data={data} theme={theme} onAction={onAction} />;
184
204
  case "Button":
185
205
  return (
186
206
  <ButtonView key={key} node={node} data={data} theme={theme} onAction={onAction}>
@@ -223,17 +223,21 @@ export function cssDecls(css: CSSProperties, important = false): string {
223
223
  * into a CSS string, injected ONCE per render root (<style>). This authors zero values —
224
224
  * it only structures DATA into `@keyframes uno-<name>{ <stop>{ <prop>:<val> } }`. Adding a
225
225
  * new animation = adding a keyframe to rx/ + a `style.animation` ref in a definition. No
226
- * SDK edit, no new primitive. Keyframe decls use real CSS prop names (transform, opacity…).
226
+ * SDK edit, no new primitive. Keyframe decls use real CSS prop names, KEBAB-CASE
227
+ * (`box-shadow`, never `boxShadow`) — that is the canonical authored form, lint-enforced.
228
+ * Property keys are still kebab-normalized here, exactly as Studio's gallery serializer
229
+ * does, so the two renderers can never disagree on a key that slips past the lint.
227
230
  */
228
231
  export function keyframesCss(theme: ResolvedTheme): string {
229
232
  const hit = keyframesCache.get(theme);
230
233
  if (hit !== undefined) return hit;
231
234
  const kf = (theme.keyframes ?? {}) as Record<string, Record<string, Record<string, string>>>;
235
+ const kebab = (s: string) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
232
236
  const css = Object.entries(kf)
233
237
  .map(
234
238
  ([name, stops]) =>
235
239
  `@keyframes uno-${name}{${Object.entries(stops)
236
- .map(([stop, decls]) => `${stop}{${Object.entries(decls).map(([p, v]) => `${p}:${v}`).join(";")}}`)
240
+ .map(([stop, decls]) => `${stop}{${Object.entries(decls).map(([p, v]) => `${kebab(p)}:${v}`).join(";")}}`)
237
241
  .join("")}}`,
238
242
  )
239
243
  .join("");
@@ -13,8 +13,30 @@
13
13
  * chat primitives (Conversation/Input/…) come in later slices.
14
14
  */
15
15
  import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";
16
- import { dispatchAction, type ComponentStore, type ResolvedTheme, type UnoverseClient, type UnoverseDefinition, type UnoverseNode } from "./core";
17
- import { renderNode, styleToCss, keyframesCss, cssWidth, type ActionHandler, type SlotResolver } from "./render";
16
+ import {
17
+ collectSurfacedViews,
18
+ cssWidth,
19
+ dispatchAction,
20
+ formatRelative,
21
+ isViewField,
22
+ orderedClaims,
23
+ propDefaults,
24
+ resolveActiveLayout,
25
+ resolveActiveView,
26
+ selectPointers,
27
+ viewOf,
28
+ type ComponentStore,
29
+ type ResolvedTheme,
30
+ type UnoverseClient,
31
+ type UnoverseDefinition,
32
+ type UnoverseNode,
33
+ } from "./core";
34
+ import { renderNode, styleToCss, keyframesCss, type ActionHandler, type SlotResolver } from "./render";
35
+
36
+ // The pure template derivations moved to core/templates.ts (the portable brain —
37
+ // framework-agnostic, guarded by sdk-core-portable.test.ts). Re-exported here so
38
+ // existing consumers keep their import path.
39
+ export { collectSurfacedViews, computeAppWidth, orderedClaims, resolveActiveLayout, resolveActiveView, selectPointers, viewOf } from "./core";
18
40
  import { UnoverseComponent } from "./UnoverseComponent";
19
41
  import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
20
42
  import { IsolatedRoot } from "./isolate";
@@ -49,203 +71,6 @@ const INERT_VOICE: UseVoiceServiceConfig = {
49
71
  session: { userId: "", conversationId: "", workflowId: "", targetTriggerNode: "" },
50
72
  };
51
73
 
52
- /** The author's declared prop defaults (def.props[k].default) → a flat data object. Content, not policy. */
53
- function propDefaults(props?: Record<string, unknown>): Record<string, unknown> {
54
- const out: Record<string, unknown> = {};
55
- for (const [k, v] of Object.entries(props ?? {})) {
56
- if (v && typeof v === "object" && "default" in (v as Record<string, unknown>)) {
57
- out[k] = (v as { default: unknown }).default;
58
- }
59
- }
60
- return out;
61
- }
62
-
63
- /** Format a turn timestamp as relative copy ("just now" / "5 min ago"). Neutral
64
- * projection of a model value (like `text`), not UX policy. Mirrors legacy
65
- * ChatHistoryItem.formatRelativeTime. Empty string when unstamped. */
66
- function formatRelative(ts?: number): string {
67
- if (!ts) return "";
68
- const diff = Math.floor((Date.now() - ts) / 1000);
69
- if (diff < 60) return "just now";
70
- if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
71
- if (diff < 86400) return `${Math.floor(diff / 3600)} hr ago`;
72
- return new Date(ts).toLocaleDateString();
73
- }
74
-
75
- /**
76
- * Walk a template tree and collect every VIEW a reaction surface claims — the `eq`
77
- * value of each `ComponentSlot.select.where { field: "defaultState" }`. State-selected
78
- * UI (STATE_MODEL §5b): a component instance renders in exactly ONE placeholder. If a
79
- * surface shows a given view, the conversation flow (the `inline` placeholder) must NOT
80
- * also render instances in that view — else the same instance paints twice. A view no
81
- * surface claims is absent from this set, so it stays in the flow (the fallback).
82
- * Exported for testing.
83
- */
84
- export function collectSurfacedViews(node: unknown, out: Set<string> = new Set()): Set<string> {
85
- if (Array.isArray(node)) {
86
- for (const n of node) collectSurfacedViews(n, out);
87
- return out;
88
- }
89
- if (!node || typeof node !== "object") return out;
90
- const n = node as Record<string, unknown>;
91
- const sel = n.select as { where?: { field?: string; eq?: unknown } } | undefined;
92
- if (n.type === "ComponentSlot" && sel?.where?.field === "defaultState" && typeof sel.where.eq === "string") {
93
- out.add(sel.where.eq);
94
- }
95
- for (const v of Object.values(n)) if (v && typeof v === "object") collectSurfacedViews(v, out);
96
- return out;
97
- }
98
-
99
- /**
100
- * STATE-OWNED APP WIDTH (docs/design/05, layouts era): the app's outer width is
101
- * declared by its LAYOUT's data, never the manifest. Two declaration points, both
102
- * the neutral `appWidth` key (a named app size resolved via theme.appSize):
103
- * - a plain node's `appWidth` = an always-open panel (the core column) — the
104
- * layout's constant term.
105
- * - a ComponentSlot surface's `appWidth` = its panel width, counted only while
106
- * the surface is OCCUPIED (its view is the active state).
107
- * The caller passes the ACTIVE layout (resolveActiveLayout) — layout swaps are
108
- * whole-arrangement swaps, so the width is always the active layout's total.
109
- * Activation is DERIVED, never stored. No declared width → undefined: the host
110
- * keeps its own default, the sizing twin of "unmatched view falls back to inline".
111
- * There is no other path.
112
- */
113
- export function computeAppWidth(root: unknown, store: ComponentStore, appSize?: Record<string, string>): string | undefined {
114
- // Resolve a declared width: a NAMED SIZE via theme.appSize (like any token), else raw
115
- // CSS — guarded so an unresolvable name can never emit invalid CSS.
116
- const rw = (w: unknown): string | undefined => cssWidth(typeof w === "string" ? (appSize?.[w] ?? w) : undefined);
117
- const r = root as Record<string, unknown> | null | undefined;
118
- const widths: string[] = [];
119
- // Gather the tree's width-bearing nodes: always-open core panels and the reaction
120
- // SURFACES (view → panel width).
121
- const surfaces = new Map<string, string | undefined>();
122
- const walk = (node: unknown): void => {
123
- if (Array.isArray(node)) {
124
- for (const c of node) walk(c);
125
- return;
126
- }
127
- if (!node || typeof node !== "object") return;
128
- const n = node as Record<string, unknown> & { appWidth?: string; type?: string; select?: { where?: { field?: string; eq?: unknown } } };
129
- if (n !== r) {
130
- if (n.type === "ComponentSlot") {
131
- const claim = n.select?.where;
132
- // `appWidth: flex` — the surface takes the REMAINING host space. DATA WINS: an
133
- // org may define `flex` as a real appSize token (bpp: calc(100% − chat)) and
134
- // that resolves like any name; the marker below is the theme-free fallback,
135
- // resolved as "fill the host" when the view is active.
136
- if (claim?.field === "defaultState" && typeof claim.eq === "string")
137
- surfaces.set(claim.eq, rw(n.appWidth) ?? (n.appWidth === "flex" ? "flex" : undefined));
138
- } else if (n.appWidth !== undefined) {
139
- const w = rw(n.appWidth);
140
- if (w) widths.push(w); // a plain panel (the core column) is ALWAYS open
141
- }
142
- }
143
- for (const v of Object.values(n)) if (v && typeof v === "object") walk(v);
144
- };
145
- walk(r);
146
- // ONE STATE AT A TIME (docs/design/04): the app = core + the ACTIVE state's panel —
147
- // the most recently surfaced view. Panel combinations cannot exist, so the width is
148
- // always one of a small known set, bounded by construction.
149
- let active = "";
150
- let activeAt = -1;
151
- for (const turn of store.getResponses())
152
- for (const p of turn.components) {
153
- const { chatId, nodeId } = store.split(p);
154
- const view = (store.get(chatId, nodeId) as Record<string, unknown>)?.defaultState;
155
- if (typeof view === "string" && surfaces.has(view) && store.touchOf(p) > activeAt) {
156
- activeAt = store.touchOf(p);
157
- active = view;
158
- }
159
- }
160
- const activePanel = active ? surfaces.get(active) : undefined;
161
- // An ACTIVE flex surface fills the host viewport (the host caps at its pane); the
162
- // surface flexes into whatever the fixed panels leave.
163
- if (activePanel === "flex") return "100%";
164
- if (activePanel) widths.push(activePanel);
165
- if (!widths.length) return undefined;
166
- return widths.length === 1 ? widths[0] : `calc(${widths.join(" + ")})`;
167
- }
168
-
169
- /**
170
- * TEMPLATE LAYOUTS — name-sync (docs/design/05). A template with `layouts` is a set
171
- * of full arrangements, the template-tier twin of a component's faces: the ACTIVE
172
- * layout is the one whose NAME matches the latest surfaced component view, falling
173
- * back to `defaultLayout` when nothing (or nothing matching) is surfaced. Same
174
- * derivation as the active state — read off the store, never stored. Single-layout
175
- * templates (`layouts` absent) resolve to `root`: the classic behavior, untouched.
176
- */
177
- export function resolveActiveLayout(def: UnoverseDefinition | null | undefined, store: ComponentStore): UnoverseNode | undefined {
178
- if (!def) return undefined;
179
- const layouts = def.layouts;
180
- if (!layouts) return def.root;
181
- const { view } = resolveActiveView(def, store, new Set(Object.keys(layouts)));
182
- return layouts[view] ?? layouts[def.defaultLayout ?? "main"] ?? def.root;
183
- }
184
-
185
- /**
186
- * THE ACTIVE VIEW — the ONE derivation every consumer shares (docs/design/04 + the
187
- * guest-control law). The latest surfaced component view among `validViews`, with
188
- * PINNED enforcement: while a pinned layout's view is surfaced, a background arrival
189
- * cannot displace it — only a guest write anywhere can. Layout choice and surface
190
- * gating MUST both read this function: two loops that each pick "the latest view"
191
- * disagree the moment pin overrides one of them — the layout renders view A while
192
- * every surface inside it gates against view B, and the whole panel goes blank.
193
- */
194
- export function resolveActiveView(
195
- def: UnoverseDefinition | null | undefined,
196
- store: ComponentStore,
197
- validViews: Set<string>,
198
- ): { view: string; byGuest: boolean } {
199
- let view = "";
200
- let at = -1;
201
- let byGuest = false;
202
- let pinnedView = "";
203
- let pinnedAt = -1;
204
- for (const turn of store.getResponses())
205
- for (const p of turn.components) {
206
- const { chatId, nodeId } = store.split(p);
207
- const v = (store.get(chatId, nodeId) as Record<string, unknown>)?.defaultState;
208
- if (typeof v !== "string" || !validViews.has(v)) continue;
209
- const t = store.touchOf(p);
210
- if (t > at) {
211
- at = t;
212
- view = v;
213
- byGuest = store.interactionTouchOf(p) >= t;
214
- }
215
- if ((def?.layouts?.[v] as { pinned?: boolean } | undefined)?.pinned && t > pinnedAt) {
216
- pinnedAt = t;
217
- pinnedView = v;
218
- }
219
- }
220
- if (pinnedView && view !== pinnedView && !byGuest) return { view: pinnedView, byGuest: false };
221
- return { view, byGuest };
222
- }
223
-
224
- /** Resolve a ComponentSlot's `select` against the store → ordered pointers. Exported for testing. */
225
- export function selectPointers(store: ComponentStore, node: UnoverseNode): string[] {
226
- const sel = node.select ?? {};
227
- let pointers =
228
- sel.from === "all"
229
- ? store.getResponses().flatMap((r) => r.components)
230
- : (store.latestResponse()?.components ?? []);
231
-
232
- // `where` — select by COMPONENT STATE (the reaction contract, STATE_MODEL §5b): any
233
- // component whose own slice matches, ordered newest state-write first so `limit: 1`
234
- // = "most recent wins". The generic replacement for type-pinned surfaces — a
235
- // template reacts to whatever component enters the state, never to a named type.
236
- if (sel.where) {
237
- const { field, eq, ne } = sel.where;
238
- pointers = pointers.filter((p) => {
239
- const { chatId, nodeId } = store.split(p);
240
- const v = (store.get(chatId, nodeId) as Record<string, unknown>)[field];
241
- return ne !== undefined ? v !== ne : v === eq;
242
- });
243
- pointers = [...pointers].sort((a, b) => store.touchOf(b) - store.touchOf(a));
244
- }
245
- if (sel.limit != null) pointers = pointers.slice(0, sel.limit);
246
- return pointers;
247
- }
248
-
249
74
  export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme: themeProp, props, isolate = true, voice: voiceConfig }: StreamedUnoverseTemplateProps) {
250
75
  const [def, setDef] = useState<UnoverseDefinition | null>(null);
251
76
  const [error, setError] = useState<string | null>(null);
@@ -271,7 +96,7 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
271
96
  // to their own slices — so streaming chunks never re-walk the whole template tree.
272
97
  // Stable subscribe fn: an inline arrow would tear down + re-create the subscription
273
98
  // every render.
274
- useSyncExternalStore(
99
+ const structureVersion = useSyncExternalStore(
275
100
  useCallback((cb) => store.subscribe(cb), [store]),
276
101
  useCallback(() => store.getStructureVersion(), [store]),
277
102
  );
@@ -289,18 +114,51 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
289
114
  };
290
115
  }, [client, uri]);
291
116
 
292
- // NAME-SYNC: the tree this template presents — the layout matching the latest
293
- // surfaced component view (multi-layout templates), else the classic root. The
294
- // resolved node is a stable per-def object, so downstream memos key off it.
117
+ // ARRIVAL PLACEMENT (STATE_MODEL v2 §5 rule 3): nobody sends a state — the HOST
118
+ // places the actor at spawn. For each arrived component whose slice has no view yet:
119
+ // 1. the component's DECLARED INITIAL, if this template has a state for it (the
120
+ // component's preference stands whenever the host can honor it);
121
+ // 2. else the SCAN — the first name in THIS template's declared order that the
122
+ // component's public menu (`publicStates`, server-injected at init) also has;
123
+ // 3. no overlap at all → leave unset: the component wakes in its own initial and
124
+ // rides inline (rule 6).
125
+ // The write is a data merge (never an interaction touch — placement is not the
126
+ // guest's hand), it happens ONCE per instance (a placed slice has a view), and the
127
+ // component owns the axis from then on: the template never writes it again.
128
+ useEffect(() => {
129
+ const order = def?.stateOrder;
130
+ if (!order?.length) return;
131
+ for (const turn of store.getResponses())
132
+ for (const p of turn.components) {
133
+ const { chatId, nodeId } = store.split(p);
134
+ const slice = store.get(chatId, nodeId) as Record<string, unknown> | undefined;
135
+ if (!slice || viewOf(slice) !== undefined) continue;
136
+ const menu = Array.isArray(slice.publicStates) ? (slice.publicStates as unknown[]) : undefined;
137
+ if (!menu) continue;
138
+ const initial = typeof slice.initialView === "string" ? slice.initialView : undefined;
139
+ const face = initial && order.includes(initial) ? initial : order.find((s) => menu.includes(s));
140
+ if (face) store.apply({ type: "COMPONENT_DATA", chatId, nodeId, data: { view: face } });
141
+ }
142
+ }, [def, store, structureVersion]);
143
+
144
+ // THE SEVEN RULES (2026-08-09) need no effect here: a higher state MASKS the ones
145
+ // below (rule 4 — the ladder alone decides what shows; lower states refresh
146
+ // silently underneath via the store's arrival supersession), and releasing the top
147
+ // state is just the ladder looking again (rule 6). The earlier cancellation WRITE
148
+ // (cancelBelow) was superseded by this pure-masking ruling and deleted.
149
+
150
+ // STATE OWNS LAYOUT (STATE_MODEL v2): the tree this template presents — the active
151
+ // state's layout (multi-layout templates), else the classic root. The resolved node
152
+ // is a stable per-def object, so downstream memos key off it.
295
153
  const activeRoot = def ? resolveActiveLayout(def, store) : undefined;
296
154
 
297
- // The set of VIEWS this template's reaction surfaces claim — computed per active
298
- // layout: layout NAMES claim their view (name-sync) alongside the surfaces inside.
299
- // The timeline uses it to keep a claimed instance out of the flow (one instance → one
300
- // placeholder).
155
+ // The CLAIM set — the states this template reacts to. v2: the declared order
156
+ // (stateOrder) IS the claim list; legacy defs fall back to layout names + surface
157
+ // claims. The timeline uses it to keep a claimed instance out of the flow (one
158
+ // instance → one placeholder).
301
159
  const surfacedViews = useMemo(() => {
302
- const views = activeRoot ? collectSurfacedViews(activeRoot) : new Set<string>();
303
- for (const name of Object.keys(def?.layouts ?? {})) views.add(name);
160
+ const views = new Set<string>(orderedClaims(def));
161
+ if (activeRoot) collectSurfacedViews(activeRoot, views);
304
162
  return views;
305
163
  }, [activeRoot, def]);
306
164
 
@@ -326,16 +184,17 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
326
184
  if (action === "backToChat") voice.endCall();
327
185
  }
328
186
  // GUEST CLOSE from TEMPLATE CHROME: a ✕ on the layout itself (a rail/panel header —
329
- // not inside a single card) fires the CloseButton atom's `setValue defaultState:"inline"`.
330
- // Template chrome has no card slice to write, so that write would be inert; instead it
331
- // means "un-surface the current view" → the store retracts every surface to base (the
332
- // same reset a new turn runs). A card that closes ITSELF keeps using the plain component
333
- // write (real ids), untouched. `then` still chains via dispatchAction below.
187
+ // not inside a single card) fires the CloseButton atom's `setValue view:"inline"`
188
+ // (legacy defs write `defaultState`). Template chrome has no card slice to write, so
189
+ // that write would be inert; instead it means "un-surface the current view" → the
190
+ // store retracts every surface to its declared base (the same reset a new turn
191
+ // runs). A card that closes ITSELF keeps using the plain component write (real
192
+ // ids), untouched. `then` still chains via dispatchAction below.
334
193
  if (
335
194
  action && typeof action === "object" &&
336
195
  (action as { type?: string }).type === "setValue" &&
337
196
  (action as { values?: { key?: string; value?: unknown }[] }).values?.some(
338
- (v) => v.key === "defaultState" && v.value === "inline",
197
+ (v) => isViewField(v.key) && v.value === "inline",
339
198
  )
340
199
  ) {
341
200
  store.closeSurfaces();
@@ -375,7 +234,7 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
375
234
  // data is untouched and re-presents the moment its state is active again.
376
235
  // Surfaces never stack; panel combinations cannot exist.
377
236
  const claim = node.select?.where;
378
- if (claim?.field === "defaultState" && typeof claim.eq === "string" && claim.eq !== surfacedView) {
237
+ if (isViewField(claim?.field) && typeof claim?.eq === "string" && claim.eq !== surfacedView) {
379
238
  return node.fallback ? renderNode(node.fallback, {}, dispatch, theme, key ?? "fallback") : null;
380
239
  }
381
240
  const pointers = selectPointers(store, node);
@@ -427,8 +286,8 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
427
286
  turn.role === "assistant"
428
287
  ? turn.components.filter((p) => {
429
288
  const { chatId, nodeId } = store.split(p);
430
- const view = (store.get(chatId, nodeId) as Record<string, unknown> | undefined)?.defaultState;
431
- return typeof view !== "string" || !surfacedViews.has(view);
289
+ const view = viewOf(store.get(chatId, nodeId) as Record<string, unknown> | undefined);
290
+ return !view || !surfacedViews.has(view);
432
291
  })
433
292
  : [];
434
293
  // Slot scoped to this turn's UNCLAIMED components (assistant turns only).
@@ -29,6 +29,13 @@ function render() {
29
29
  templateId={current.templateId}
30
30
  userId={current.userId}
31
31
  conversationId={current.conversationId}
32
+ // ANALYTICS (UNOVERSE_ANALYTICS.md). Host-supplied: the same bundle boots inside a
33
+ // customer's page, our own client, and an MCP app host, and it cannot tell which
34
+ // from in here. A host that says nothing gets `mcp_app_host`, the one surface with
35
+ // no parent page to report to, so an unconfigured boot reports as the surface where
36
+ // events go nowhere rather than mislabelling itself as a customer embed.
37
+ surface={current.surface ?? "mcp_app_host"}
38
+ analyticsDebug={current.analyticsDebug}
32
39
  // Only claim auth when the host actually sent a token. A fabricated accessor on a
33
40
  // tokenless boot made the SDK skip its guest-id substitution (connection.tsx), so
34
41
  // the session registered a non-guest id and the public-entry gate refused it.