@unoverse-platform/studio 0.1.81 → 0.1.83

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.
@@ -118,6 +118,29 @@ function nodeChrome(node: UnoverseNode, data: Record<string, unknown>, theme: Re
118
118
  styleEls.push(<style key="span-stack">{`@container (max-width:${w}){.${cls}{grid-column:1 / -1 !important}}`}</style>);
119
119
  }
120
120
  }
121
+ // `columns` AS A BREAKPOINT MAP — the responsive grid: column counts per named band
122
+ // ({mobile: "1", tablet: "2", desktop: "3", full: "4"}), applied by CONTAINER width
123
+ // (the nearest `container` ancestor — a grid in a narrow panel stacks on a wide
124
+ // screen, which is what SDUI needs). `mobile` is the base band; a band not named
125
+ // inherits the previous one. Thresholds are SERVED (`theme.breakpoint.*`); the SDK
126
+ // holds no widths. The rule also marks the grid `container-type: inline-size`, so
127
+ // its own children (`span`) can query it, same as the inline `columns` form.
128
+ if (s?.columns && typeof s.columns === "object") {
129
+ const map = s.columns as Record<string, unknown>;
130
+ const base = map.mobile ?? map.tablet ?? map.desktop ?? map.full;
131
+ if (base != null) {
132
+ const cls = `uno-cols-${hashStr(JSON.stringify(map))}`;
133
+ let rules = `.${cls}{display:grid;grid-template-columns:repeat(${String(base)},minmax(0,1fr));align-content:start;container-type:inline-size}`;
134
+ for (const band of ["tablet", "desktop", "full"] as const) {
135
+ const w = theme.breakpoint?.[band];
136
+ if (map[band] != null && w) {
137
+ rules += `@container (min-width:${w}){.${cls}{grid-template-columns:repeat(${String(map[band])},minmax(0,1fr))}}`;
138
+ }
139
+ }
140
+ classes.push(cls);
141
+ styleEls.push(<style key="columns">{rules}</style>);
142
+ }
143
+ }
121
144
  // `hideAbove` — the mirror: hide when the container is WIDER than the width. Together
122
145
  // these are the whole layout-switch mechanism for space-responsive components: the
123
146
  // compact layout carries hideAbove, the full layout hideBelow, and the container's
@@ -129,7 +129,10 @@ export function renderNode(
129
129
  // nothing elastic remains for a resize to land on. Slot panels are injected at their
130
130
  // frame (template.tsx); a layout root never carries appWidth (guard-enforced).
131
131
  const panelW = (node as { appWidth?: string }).appWidth;
132
- if (panelW && node.type !== "ComponentSlot") {
132
+ // ComponentSlot AND Template keep their appWidth: both are slot-dispatched places
133
+ // whose width applies to their FRAME (app.tsx), not to a box of their own —
134
+ // consuming it here left the mount frame width-less (measured live 2026-08-28).
135
+ if (panelW && node.type !== "ComponentSlot" && node.type !== "Template") {
133
136
  const w = cssWidth(theme.appSize?.[panelW] ?? panelW);
134
137
  if (w) node = { ...node, appWidth: undefined, style: { ...(node.style ?? {}), width: w, flex: "0 0 auto" } } as UnoverseNode;
135
138
  }
@@ -146,7 +149,7 @@ export function renderNode(
146
149
  if (node.type === "Orb") return <OrbView key={key} node={node} data={data} theme={theme} />;
147
150
  if (node.type === "Input") return <InputView key={key} node={node} data={data} theme={theme} onAction={onAction} />;
148
151
  if (node.type === "Markdown") return <MarkdownView key={key} node={node} data={data} theme={theme} />;
149
- if (node.type === "ComponentSlot" || node.type === "Timeline" || node.type === "Container") return slot ? slot(node, key) : null;
152
+ if (node.type === "ComponentSlot" || node.type === "Timeline" || node.type === "Template") return slot ? slot(node, key) : null;
150
153
 
151
154
  // `Ref` — REFERENCE, NOT COPY. The design system's shared pieces travel once and are
152
155
  // cached; a definition names one and specialises it here (core/refs.ts: `props` remap,
@@ -180,7 +183,11 @@ export function renderNode(
180
183
  // in the def (props exist only for state — UNOVERSE_AUTHORING.md). bind wins.
181
184
  const src = node.bind?.items ? data[node.bind.items] : node.items;
182
185
  const items = Array.isArray(src) ? src : [];
183
- const tpl = node.template;
186
+ // `template` is read by two node types and the shape says which: a SUBTREE here on Each,
187
+ // a holder NAME (string) on Template, which this branch never sees. Narrow rather than
188
+ // assert, so a definition that puts the wrong one here renders nothing instead of
189
+ // throwing inside the map.
190
+ const tpl = typeof node.template === "object" ? node.template : undefined;
184
191
  // WHERE AN ITEM SITS is something only the renderer knows, and a numbered list cannot
185
192
  // be authored without it: the data carries no ordinal, and a template cannot count.
186
193
  // `index` is 0-based for logic, `position` 1-based because that is what a reader sees.
@@ -121,7 +121,10 @@ function computeCss(s: Record<string, unknown>, theme: ResolvedTheme, data?: Rec
121
121
  // correct for SDUI: the component reacts to its slot, not the page viewport). Value served.
122
122
  if (s.container) (css as Record<string, unknown>).containerType = s.container;
123
123
  // `columns` → an equal-width CSS grid (generic layout; the welcome card grid uses it).
124
- if (s.columns) {
124
+ // A MAP value ({mobile, tablet, ...}) is the RESPONSIVE form — handled in
125
+ // primitives.tsx as an injected rule, since inline styles cannot express the
126
+ // container queries it needs.
127
+ if (s.columns && typeof s.columns !== "object") {
125
128
  css.display = "grid";
126
129
  css.gridTemplateColumns = `repeat(${s.columns}, minmax(0, 1fr))`;
127
130
  // THE GRID IS ITS OWN CONTAINER, so a child's `span` can collapse against the grid's
@@ -7,6 +7,7 @@
7
7
  import { createRoot, type Root } from "react-dom/client";
8
8
  import { AppEngine, type AppConfig } from "./appEngine";
9
9
  import { connectMcpAppHost } from "./mcpAppBridge";
10
+ import { ResponseView } from "./responseView";
10
11
 
11
12
  type HostConfig = Omit<AppConfig, "getAccessToken"> & { token?: string };
12
13
 
@@ -82,13 +83,35 @@ connectMcpAppHost((r) => {
82
83
  // hardcode/guess it — a baked URL ties the bundle to one environment and hides a
83
84
  // missing config. If it's absent, fail loudly and don't mount.
84
85
  const serverUrl = (meta["unoverse/serverUrl"] as string) || (out.serverUrl as string);
85
- const appId = (out.app as string) || (meta["unoverse/app"] as string);
86
86
  const conversationId = (out.conversationId as string) || (meta["unoverse/conversationId"] as string);
87
87
  const token = (meta["unoverse/token"] as string) || undefined;
88
88
  const userId = (meta["unoverse/userId"] as string) || undefined;
89
+
90
+ // THE NAMING RULE (UNOVERSE_MCP_APP_PROTOCOL § THE NAMING RULE): the RESULT names
91
+ // its interface. `interface.kind === "components"` → render exactly those (the
92
+ // response view — no app mount, no session, no home). `interface.kind === "app"`
93
+ // or a legacy `app`/`unoverse/app` boot fact (the app tools' results, internal
94
+ // hosts) → the named app mounts. Nothing mounts by default: a result naming
95
+ // nothing renders nothing, loudly.
96
+ const iface = out.interface as { kind?: string; components?: Record<string, unknown>[]; app?: string } | undefined;
97
+ if (iface?.kind === "components" && Array.isArray(iface.components)) {
98
+ if (!serverUrl) {
99
+ // eslint-disable-next-line no-console
100
+ console.warn("[unoverse:boot] response result carried no serverUrl — cannot resolve definitions");
101
+ return;
102
+ }
103
+ // eslint-disable-next-line no-console
104
+ console.log("[unoverse:boot] rendering RESPONSE from MCP host", { serverUrl, components: iface.components.length });
105
+ const el = document.getElementById("root")!;
106
+ if (!root) root = createRoot(el);
107
+ root.render(<ResponseView serverUrl={serverUrl} events={iface.components} />);
108
+ return;
109
+ }
110
+
111
+ const appId = (iface?.kind === "app" ? iface.app : undefined) || (out.app as string) || (meta["unoverse/app"] as string);
89
112
  if (!appId || !serverUrl) {
90
113
  // eslint-disable-next-line no-console
91
- console.warn("[unoverse:boot] cannot mount — tool result carried no serverUrl/app", {
114
+ console.warn("[unoverse:boot] cannot mount — result named no interface (no components, no app)", {
92
115
  serverUrl,
93
116
  appId,
94
117
  structuredContent: out,
@@ -97,6 +120,6 @@ connectMcpAppHost((r) => {
97
120
  return;
98
121
  }
99
122
  // eslint-disable-next-line no-console
100
- console.log("[unoverse:boot] mounting from MCP host", { serverUrl, appId, conversationId, userId, hasToken: Boolean(token) });
123
+ console.log("[unoverse:boot] mounting NAMED app from MCP host", { serverUrl, appId, conversationId, userId, hasToken: Boolean(token) });
101
124
  apply({ serverUrl, appId, conversationId, userId, token });
102
125
  });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * THE RESPONSE VIEW — what an UNNAMED result renders (THE NAMING RULE,
3
+ * docs/mcp/UNOVERSE_MCP_APP_PROTOCOL § THE NAMING RULE).
4
+ *
5
+ * An MCP Action's result carries `interface: { kind: "components", components }` —
6
+ * the component events the run produced. This view renders EXACTLY those: seed a
7
+ * fresh store with the events, resolve the theme, paint each instance with the
8
+ * standard renderer. No app mount, no session registration, no home state, no
9
+ * auto-trigger — an app mounts only when something NAMES it (a channel, a row, or
10
+ * a result declaring `kind: "app"`).
11
+ *
12
+ * Definitions and theme still resolve over the result's serverUrl (`/mcp` reads) —
13
+ * the response is data; the rendering vocabulary lives on the universe.
14
+ */
15
+ import { useMemo } from "react";
16
+ import {
17
+ UnoverseClient,
18
+ ComponentStore,
19
+ StreamedUnoverseComponent,
20
+ useUnoverseTheme,
21
+ applyServerMessage,
22
+ } from "./lib";
23
+
24
+ export interface ResponseViewProps {
25
+ serverUrl: string;
26
+ /** The run's component events (COMPONENT_INIT / COMPONENT_DATA), as collected
27
+ * server-side and carried in the result's `interface.components`. */
28
+ events: Record<string, unknown>[];
29
+ }
30
+
31
+ export function ResponseView({ serverUrl, events }: ResponseViewProps) {
32
+ const client = useMemo(() => new UnoverseClient(`${serverUrl}/mcp`, {}), [serverUrl]);
33
+
34
+ // Seed once per result: the same pure mapper the live stream uses, so a seeded
35
+ // component is indistinguishable from a streamed one (merge-not-replace and all).
36
+ const store = useMemo(() => {
37
+ const s = new ComponentStore();
38
+ const seen = new Set<string>();
39
+ for (const e of events) {
40
+ try {
41
+ applyServerMessage(s, e as never, seen);
42
+ } catch {
43
+ /* one malformed event must not blank the whole response */
44
+ }
45
+ }
46
+ return s;
47
+ }, [events]);
48
+
49
+ const theme = useUnoverseTheme(client);
50
+
51
+ // One card per INSTANCE, in arrival order (the events carry chatId+nodeId — the
52
+ // store's own keying — so no pointer parsing).
53
+ const instances = useMemo(() => {
54
+ const out: { chatId: string; nodeId: string }[] = [];
55
+ const seen = new Set<string>();
56
+ for (const e of events) {
57
+ const ev = e as { type?: string; chatId?: string; nodeId?: string };
58
+ if (ev.type !== "COMPONENT_INIT" || !ev.chatId || !ev.nodeId) continue;
59
+ const key = `${ev.chatId}:${ev.nodeId}`;
60
+ if (seen.has(key)) continue;
61
+ seen.add(key);
62
+ out.push({ chatId: ev.chatId, nodeId: ev.nodeId });
63
+ }
64
+ return out;
65
+ }, [events]);
66
+
67
+ if (!instances.length) return null;
68
+ return (
69
+ <div style={{ display: "flex", flexDirection: "column", gap: 12, padding: 12 }}>
70
+ {instances.map((i) => (
71
+ <StreamedUnoverseComponent
72
+ key={`${i.chatId}:${i.nodeId}`}
73
+ client={client}
74
+ store={store}
75
+ chatId={i.chatId}
76
+ nodeId={i.nodeId}
77
+ theme={theme ?? undefined}
78
+ />
79
+ ))}
80
+ </div>
81
+ );
82
+ }