@unoverse-platform/studio 0.1.25 → 0.1.26

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -432,7 +432,7 @@ export function SourceView({
432
432
  /**
433
433
  * TWO FILE SHAPES, TWO WAYS IN.
434
434
  *
435
- * An ENVELOPE (`coursecard.yaml`) holds its tree under `root:`, and that tree IS
435
+ * An ENVELOPE (`course-card.yaml`) holds its tree under `root:`, and that tree IS
436
436
  * the composed root, so the file path maps by dropping one level. A PARTIAL (a
437
437
  * layout) is a bare node included somewhere inside the composed tree, so it has to
438
438
  * be found first. Treating everything as a partial meant an envelope never matched
@@ -112,7 +112,7 @@ interface Def {
112
112
  autoTrigger?: boolean;
113
113
  default?: boolean;
114
114
  inputSchema?: Record<string, unknown>;
115
- // Per-STATE mock seeding: `{ "<state>": ["coursecard", "coursecard"] }` — the
115
+ // Per-STATE mock seeding: `{ "<state>": ["course-card", "course-card"] }` — the
116
116
  // author decides what each state's preview shows (a repeated name = several
117
117
  // instances). Supersedes the legacy flat `previewComponents`.
118
118
  // Per-state mock: an ARRAY seeds components; an OBJECT is authored template-state
@@ -468,7 +468,7 @@ export function TemplatesView({
468
468
  // what a real arrival-in-focus or a component's own expand button would do.
469
469
  function seed(comps: Def[]) {
470
470
  // nodeId is per-INSTANCE (`name-i`), so previewComponents may repeat a name to
471
- // seed several instances — e.g. ["coursecard", "coursecard"] previews a rail of cards.
471
+ // seed several instances — e.g. ["course-card", "course-card"] previews a rail of cards.
472
472
  comps.forEach((c, i) =>
473
473
  store.apply({
474
474
  type: "COMPONENT_INIT",
@@ -122,7 +122,7 @@ export function PublishDialog({
122
122
  * own status, and the statuses sort so the ones that will move sit at the top.
123
123
  *
124
124
  * The kind is singular here, because a row is one thing. `bpp` is a style set and
125
- * `bppchatlayout` is an app; without that word they read as five apps, which is how a
125
+ * `bpp-chat-layout` is an app; without that word they read as five apps, which is how a
126
126
  * developer concludes their components were never collected when they were.
127
127
  */
128
128
  const KIND_LABEL: Record<string, string> = {
@@ -17,6 +17,9 @@ import {
17
17
  useUnoverseTheme,
18
18
  computeAppWidth,
19
19
  resolveActiveLayout,
20
+ setAnalyticsSink,
21
+ setAnalyticsContext,
22
+ setAnalyticsDebug,
20
23
  type UseVoiceServiceConfig,
21
24
  } from "./lib";
22
25
 
@@ -27,10 +30,17 @@ export interface AppConfig {
27
30
  getAccessToken?: () => Promise<string | null> | string | null;
28
31
  userId?: string;
29
32
  conversationId: string;
33
+ /** Where this app is running, reported on every analytics event
34
+ * (UNOVERSE_ANALYTICS.md §surfaces). Host-supplied: the app cannot know which frame
35
+ * it was placed in. */
36
+ surface?: string;
37
+ /** Log each analytics event as it fires. Server-delivered with the rest of the tenant
38
+ * config, never a client-side flag someone can turn on against a live site. */
39
+ analyticsDebug?: boolean;
30
40
  }
31
41
 
32
42
  export function AppEngine(cfg: AppConfig) {
33
- const { serverUrl, apiUrl, templateId, getAccessToken, userId, conversationId } = cfg;
43
+ const { serverUrl, apiUrl, templateId, getAccessToken, userId, conversationId, surface, analyticsDebug } = cfg;
34
44
 
35
45
  const store = useMemo(() => new ComponentStore(), []);
36
46
  const client = useMemo(
@@ -80,6 +90,21 @@ export function AppEngine(cfg: AppConfig) {
80
90
  // inbound `unoverse:config` channel), re-reporting whenever a panel surface opens or
81
91
  // closes: the app grows by the panel's declared width, then shrinks back. The host
82
92
  // animates the resize. Only posts on an ACTUAL width change (guard).
93
+ // ANALYTICS (UNOVERSE_ANALYTICS.md). The SDK produces events and knows no destination;
94
+ // this is the pipe out. Events cross to the HOST REALM, where the page's own analytics
95
+ // tag lives with the visitor's cookie and consent state already resolved — which is the
96
+ // whole reason measurement runs client-side rather than from a server that cannot read
97
+ // either. With no parent (a first-party page, an MCP app host) the post is a silent
98
+ // no-op, so a surface with nothing to report to costs nothing.
99
+ useEffect(() => {
100
+ setAnalyticsDebug(!!analyticsDebug);
101
+ setAnalyticsContext({ surface, conversation_id: conversationId, user_id: userId });
102
+ setAnalyticsSink((event) => {
103
+ window.parent?.postMessage({ type: "unoverse:event", ...event }, "*");
104
+ });
105
+ return () => setAnalyticsSink(null);
106
+ }, [surface, conversationId, userId, analyticsDebug]);
107
+
83
108
  useEffect(() => {
84
109
  const report = () => {
85
110
  // NAME-SYNC: width is computed off the ACTIVE layout (multi-layout templates
@@ -38,7 +38,7 @@ export function useUnoverseInstance(store?: ComponentStore, chatId = "", nodeId
38
38
 
39
39
  export interface UnoverseComponentProps {
40
40
  client: UnoverseClient;
41
- /** STATIC source: the definition to render, e.g. "unoverse://components/Card". */
41
+ /** STATIC source: the definition to render, e.g. "unoverse://components/card". */
42
42
  uri?: string;
43
43
  /** STATIC source: instance data bound into the definition. */
44
44
  data?: Record<string, unknown>;
@@ -9,6 +9,7 @@
9
9
  * `{{binding}}` resolution, an optional chained `then`, and a hard split between
10
10
  * LOCAL `setValue` and a server route for anything computed/transactional.
11
11
  */
12
+ import { emitAnalytics, resolveAnalyticsParams } from "./analytics";
12
13
  import type { ComponentStore } from "./store";
13
14
  import type { ActionSpec } from "./types";
14
15
 
@@ -44,6 +45,15 @@ export function dispatchAction(
44
45
  if (action == null) return;
45
46
  const spec: ActionSpec = typeof action === "string" ? { type: action } : action;
46
47
 
48
+ // ANALYTICS FIRST, and unconditionally. Reporting must not depend on what the action
49
+ // then does, nor on it succeeding: an interaction that happened is news whether or not
50
+ // the write, the route or the chained `then` works out. This is also why it is here and
51
+ // not in a leaf — one interpreter sees every interaction, so an event cannot be missed
52
+ // by an author writing an unusual definition.
53
+ if (spec.analytics?.event) {
54
+ emitAnalytics(spec.analytics.event, resolveAnalyticsParams(spec.analytics, scope, resolveValue));
55
+ }
56
+
47
57
  if (spec.type === "setValue") {
48
58
  // Write the dev's chosen keys into THIS COMPONENT's OWN slice (chatId:nodeId) — its own
49
59
  // view state: tab / edit / wizard-step / displayState / anything. Read back via
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Experience analytics — the emit side (docs/unoverse/UNOVERSE_ANALYTICS.md).
3
+ *
4
+ * The SDK produces events and knows NOTHING about where they go. No GA, no gtag, no
5
+ * dataLayer, no vendor of any kind appears here. A host registers a sink; without one
6
+ * every emit is a no-op, which is what makes a preview, a test, or an MCP app host with
7
+ * no page to report to cost nothing.
8
+ *
9
+ * Events are NEWS, never instruction: nothing downstream may branch on one. The trigger
10
+ * originates in a browser the visitor controls, so a path that could act on an event
11
+ * would be a path a visitor could drive.
12
+ */
13
+
14
+ import type { AnalyticsSpec } from "./types";
15
+
16
+ /** What a sink receives. `context` is the platform's own vocabulary, kept separate from
17
+ * the author's `params` so the two can never collide on a key. */
18
+ export interface AnalyticsEvent {
19
+ event: string;
20
+ params: Record<string, unknown>;
21
+ context: AnalyticsContext;
22
+ }
23
+
24
+ export interface AnalyticsContext {
25
+ /** Where this is running: `unoverse_assistant` | `unoverse_client` | `mcp_app_host`. */
26
+ surface?: string;
27
+ /** The journey key. The SAME id the chat, the session and agent memory use. */
28
+ conversation_id?: string;
29
+ user_id?: string;
30
+ }
31
+
32
+ export type AnalyticsSink = (event: AnalyticsEvent) => void;
33
+
34
+ let sink: AnalyticsSink | null = null;
35
+ let context: AnalyticsContext = {};
36
+ let debug = false;
37
+
38
+ /** Registered by the host (the channel), which owns identity and the destination.
39
+ * Passing null removes it, so a torn-down host stops receiving. */
40
+ export function setAnalyticsSink(next: AnalyticsSink | null): void {
41
+ sink = next;
42
+ }
43
+
44
+ /** Session facts, supplied once by the channel. The SDK never invents them. */
45
+ export function setAnalyticsContext(next: AnalyticsContext): void {
46
+ context = { ...context, ...next };
47
+ }
48
+
49
+ /** Server-delivered, never a client-side flag: log each event as it fires. */
50
+ export function setAnalyticsDebug(next: boolean): void {
51
+ debug = next;
52
+ }
53
+
54
+ /**
55
+ * Emit one event. Failure is SILENT and CLOSED: a throwing sink never breaks a render
56
+ * and never blocks the interaction that produced it.
57
+ */
58
+ export function emitAnalytics(event: string, params?: Record<string, unknown>): void {
59
+ if (!event) return;
60
+ const payload: AnalyticsEvent = { event, params: params ?? {}, context: { ...context } };
61
+ // Matches the platform's existing log convention (`[unoverse:lifecycle] …`), so one
62
+ // console filter catches it among a host page's own noise.
63
+ if (debug) console.log(`[unoverse:analytics] ${event}`, payload.params, payload.context);
64
+ if (!sink) return;
65
+ try {
66
+ sink(payload);
67
+ } catch (err) {
68
+ console.warn(`[unoverse:analytics] sink failed for ${event}: ${(err as Error)?.message ?? err}`);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Resolve a declared `params` block against the data scope at the interaction site,
74
+ * using the SAME `{{path}}` binding an action's values already use. Keys resolving to
75
+ * `undefined` are dropped rather than sent as empty, so an absent field is absent in the
76
+ * report instead of arriving as a misleading blank.
77
+ */
78
+ export function resolveAnalyticsParams(
79
+ spec: AnalyticsSpec | undefined,
80
+ scope: Record<string, unknown>,
81
+ resolve: (value: unknown, scope: Record<string, unknown>) => unknown,
82
+ ): Record<string, unknown> {
83
+ const out: Record<string, unknown> = {};
84
+ for (const [key, value] of Object.entries(spec?.params ?? {})) {
85
+ const v = resolve(value, scope);
86
+ if (v !== undefined && v !== null && v !== "") out[key] = v;
87
+ }
88
+ return out;
89
+ }
@@ -114,7 +114,7 @@ export class UnoverseClient {
114
114
  return this.withSession(() => this.client.callTool({ name, arguments: args }));
115
115
  }
116
116
 
117
- /** resources/read a definition by URI (e.g. unoverse://components/Card). Cached. */
117
+ /** resources/read a definition by URI (e.g. unoverse://components/card). Cached. */
118
118
  async readDefinition(uri: string): Promise<UnoverseDefinition> {
119
119
  const cached = this.cache.get(uri);
120
120
  if (cached) return cached;
@@ -28,5 +28,17 @@ export {
28
28
  // The action interpreter (§2e-3) — local `setValue` store write + server route.
29
29
  export { dispatchAction, resolveValue, type ActionContext } from "./actions";
30
30
 
31
+ // Experience analytics (UNOVERSE_ANALYTICS.md) — the SDK emits; the host decides where to.
32
+ export {
33
+ emitAnalytics,
34
+ setAnalyticsSink,
35
+ setAnalyticsContext,
36
+ setAnalyticsDebug,
37
+ resolveAnalyticsParams,
38
+ type AnalyticsEvent,
39
+ type AnalyticsContext,
40
+ type AnalyticsSink,
41
+ } from "./analytics";
42
+
31
43
  // The shared "react to state" predicate (visibleWhen / Switch / style.when).
32
44
  export { matches, visible, selectCase } from "./conditions";
@@ -5,6 +5,17 @@
5
5
  * SHAPE (values live on the server). Pure types — no runtime, no imports.
6
6
  */
7
7
 
8
+ /** Report an interaction to the host's analytics (UNOVERSE_ANALYTICS.md). Declared on a
9
+ * node beside its `action`. The SDK produces the event and knows nothing about where it
10
+ * goes: no vendor, no destination, no identity appears in this shape. */
11
+ export interface AnalyticsSpec {
12
+ /** The event name. A GA4 standard name where one fits, else `unoverse_`-prefixed. */
13
+ event: string;
14
+ /** Sent with it. `{{path}}` resolves against the data scope at the interaction site,
15
+ * the same binding an action's `values` already use. */
16
+ params?: Record<string, unknown>;
17
+ }
18
+
8
19
  // ---- The neutral definition shape (mirrors apps/unoverse/definitions/*.json) ----
9
20
 
10
21
  export interface UnoverseNode {
@@ -13,6 +24,11 @@ export interface UnoverseNode {
13
24
  bind?: Record<string, string>; // target prop -> data field
14
25
  visibleWhen?: VisibleWhen; // render only when the condition holds (truthy field, or {field,eq/ne})
15
26
  action?: string | ActionSpec; // dispatched on interaction (bare string → server, or a setValue envelope)
27
+ /** Report this interaction to the host's analytics (UNOVERSE_ANALYTICS.md). Opt-in per
28
+ * node: no block, no event. Sits beside `action` because each interaction carries its
29
+ * own params, drawn from the data already in scope right here. The SDK only produces
30
+ * the event; where it goes is the channel's business. */
31
+ analytics?: AnalyticsSpec;
16
32
  placeholder?: string; // Input: empty-field copy (content, not style)
17
33
  /** Box: keep the scroll pinned to the newest content — smooth-scroll to the end when
18
34
  * content grows (new turn or streaming text), unless the user has scrolled up. Generic
@@ -136,6 +152,10 @@ export interface ActionSpec {
136
152
  values?: ActionValue[]; // setValue / setTemplateValue patches (the dev's chosen keys)
137
153
  send?: string[]; // server route: which scope fields to include (default: all)
138
154
  then?: ActionSpec | string; // optional chained follow-up
155
+ /** Carried here by the renderer, NOT authored on the action: a node declares
156
+ * `analytics:` beside `action:`, and the leaf attaches it so the ONE interpreter
157
+ * (`dispatchAction`) stays the only place an interaction is understood. */
158
+ analytics?: AnalyticsSpec;
139
159
  }
140
160
 
141
161
  /** A data condition, evaluated against the render data scope. Pick ONE test:
@@ -30,6 +30,11 @@ export {
30
30
  matches,
31
31
  visible,
32
32
  selectCase,
33
+ emitAnalytics,
34
+ setAnalyticsSink,
35
+ setAnalyticsContext,
36
+ setAnalyticsDebug,
37
+ resolveAnalyticsParams,
33
38
  } from "./core";
34
39
  export type {
35
40
  ResolvedTheme,
@@ -56,4 +61,8 @@ export type {
56
61
  ServerWorkflowState,
57
62
  ServerTemplateData,
58
63
  ServerSessionReady,
64
+ AnalyticsSpec,
65
+ AnalyticsEvent,
66
+ AnalyticsContext,
67
+ AnalyticsSink,
59
68
  } from "./core";
@@ -23,7 +23,7 @@ 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
27
 
28
28
  /** Read a bound data field as a string (for Text/Image/Button content). */
29
29
  function bound(field: string | undefined, data: Record<string, unknown>): string | undefined {
@@ -332,7 +332,7 @@ export function ImageView({ node, data, theme }: { node: UnoverseNode; data: Rec
332
332
  export function ButtonView({ node, data, theme, onAction, children }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; onAction?: ActionHandler; children?: ReactNode }): ReactNode {
333
333
  const { className, styleEls, style, disabled } = nodeChrome(node, data, theme);
334
334
  return (
335
- <button className={className} style={style} disabled={disabled} data-unoverse-id={inspectId(node)} onClick={() => onAction?.(node.action ?? "click", data)}>
335
+ <button className={className} style={style} disabled={disabled} data-unoverse-id={inspectId(node)} onClick={() => onAction?.(nodeAction(node, "click"), data)}>
336
336
  {styleEls}
337
337
  {node.children?.length ? children : bound(node.bind?.label, data)}
338
338
  </button>
@@ -507,7 +507,7 @@ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode;
507
507
  const submit = () => {
508
508
  const t = value.trim();
509
509
  if (!t) return;
510
- onAction?.(node.action ?? "submit", { text: t });
510
+ onAction?.(nodeAction(node, "submit"), { text: t });
511
511
  if (field == null) setLocal("");
512
512
  };
513
513
  const { style, disabled } = nodeChrome(node, data, theme);
@@ -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
@@ -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.