@unoverse-platform/studio 0.1.24 → 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.24",
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": {
@@ -186,16 +186,23 @@ export function DesignSystemView({
186
186
  bucket: "space",
187
187
  body: (
188
188
  <div className="space-y-2 rounded-xl p-6" style={{ background: raised, border: `1px solid ${borderC}` }}>
189
- {Object.entries(theme.space).map(([name, val]) => (
190
- <div key={name} className="flex items-center gap-4">
191
- <div className="w-20 shrink-0 text-[11px]" style={{ color: sub }}>
192
- {name}
193
- {isOverride("space", name) && <OverrideMark />}
189
+ {/* BY SIZE, not by load order. The scale is assembled from several token files
190
+ (the foundation's, then the org's), so `Object.entries` listed 1.5 after 200
191
+ and every later addition below that: a scale that reads as an unordered pile
192
+ rather than a scale. Sorting here is a DISPLAY concern; the token files stay
193
+ as they are. */}
194
+ {Object.entries(theme.space)
195
+ .sort(([, a], [, b]) => parseFloat(String(a)) - parseFloat(String(b)))
196
+ .map(([name, val]) => (
197
+ <div key={name} className="flex items-center gap-4">
198
+ <div className="w-20 shrink-0 text-[11px]" style={{ color: sub }}>
199
+ {name}
200
+ {isOverride("space", name) && <OverrideMark />}
201
+ </div>
202
+ <div className="h-3 rounded" style={{ width: val, background: accent }} />
203
+ <div className="text-[10px]" style={{ color: sub }}>{val}</div>
194
204
  </div>
195
- <div className="h-3 rounded" style={{ width: val, background: accent }} />
196
- <div className="text-[10px]" style={{ color: sub }}>{val}</div>
197
- </div>
198
- ))}
205
+ ))}
199
206
  </div>
200
207
  ),
201
208
  },
@@ -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:
@@ -175,6 +195,10 @@ export interface ResolvedTheme {
175
195
  radius: Record<string, string>;
176
196
  shadow: Record<string, string>;
177
197
  borderWidth: Record<string, string>;
198
+ /** Font weights (theme.weight ← font.weight.*) — SERVED, like every other token. The
199
+ * SDK held these as a literal four-name map, so a weight an org defined in its own
200
+ * tokens was unreachable and rendered as nothing at all. */
201
+ weight: Record<string, string | number>;
178
202
  /** Line-height tokens (theme.lineHeight ← font.lineHeight) — tight/snug/normal/relaxed. */
179
203
  lineHeight: Record<string, string | number>;
180
204
  text: Record<string, Record<string, string | number>>;
@@ -184,6 +208,12 @@ export interface ResolvedTheme {
184
208
  * width blocks `appWidth` references ("chat"/"rail"/"panel" → raw host-facing CSS).
185
209
  * Resolved by the SDK like every other token — one pipeline. */
186
210
  appSize?: Record<string, string>;
211
+ /** Grid behaviour (theme.grid ← rx/styles/semantic/grid) — `stackBelow`, the width
212
+ * at which a columns grid stacks. Served: the SDK holds no threshold of its own. */
213
+ grid?: Record<string, string>;
214
+ /** PAGE WIDTHS by name (theme.layout ← rx/styles/semantic/layout) — resolved by `dim()`
215
+ * alongside the space scale, so `maxWidth: "reading"` works like any dimension. */
216
+ layout?: Record<string, string>;
187
217
  /** Named keyframes (theme.keyframes ← rx/styles/semantic/keyframes.json), injected
188
218
  * once per render root. Lets any definition animate via `style.animation` — no SDK edit. */
189
219
  keyframes: Record<string, Record<string, Record<string, string>>>;
@@ -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 {
@@ -92,17 +92,37 @@ function nodeChrome(node: UnoverseNode, data: Record<string, unknown>, theme: Re
92
92
  // the COMPONENT (not the viewport) is mobile-width. Correct for SDUI: a component reacts to
93
93
  // its slot, not the page. Generic vocab, value served; the SDK authors none.
94
94
  if (s?.hideBelow) {
95
- const w = theme.space[s.hideBelow as string] ?? (s.hideBelow as string);
95
+ const w = theme.space[s.hideBelow as string] ?? theme.layout?.[s.hideBelow as string] ?? (s.hideBelow as string);
96
96
  const cls = `uno-hidebelow-${hashStr(String(w))}`;
97
97
  classes.push(cls);
98
98
  styleEls.push(<style key="hideBelow">{`@container (max-width:${w}){.${cls}{display:none !important}}`}</style>);
99
99
  }
100
+ // `span` STACKS. A child covering several of its grid's columns must give that up when
101
+ // the grid runs out of room, or four quarter-width cards keep being quarters in a 300px
102
+ // rail and the content is crushed. Below the threshold every spanning child takes the
103
+ // whole row (`1 / -1`), which IS the stack: one per row, full width, in source order.
104
+ //
105
+ // The query targets the GRID (style.ts marks it `container-type: inline-size`), so this
106
+ // reacts to the space the grid actually has, not the viewport. A component in a narrow
107
+ // panel on a wide screen stacks correctly, which a viewport breakpoint cannot do.
108
+ //
109
+ // The threshold is SERVED (`theme.grid.stackBelow`), overridable per grid with
110
+ // `style.stackBelow`. The SDK holds no width of its own.
111
+ if (s?.span) {
112
+ const raw = (s.stackBelow as string) ?? theme.grid?.stackBelow ?? "";
113
+ const w = theme.space[raw] ?? theme.layout?.[raw] ?? raw;
114
+ if (w) {
115
+ const cls = `uno-stack-${hashStr(String(w))}`;
116
+ classes.push(cls);
117
+ styleEls.push(<style key="span-stack">{`@container (max-width:${w}){.${cls}{grid-column:1 / -1 !important}}`}</style>);
118
+ }
119
+ }
100
120
  // `hideAbove` — the mirror: hide when the container is WIDER than the width. Together
101
121
  // these are the whole layout-switch mechanism for space-responsive components: the
102
122
  // compact layout carries hideAbove, the full layout hideBelow, and the container's
103
123
  // width (granted by the template's surface) picks the rendering — never a state key.
104
124
  if (s?.hideAbove) {
105
- const w = theme.space[s.hideAbove as string] ?? (s.hideAbove as string);
125
+ const w = theme.space[s.hideAbove as string] ?? theme.layout?.[s.hideAbove as string] ?? (s.hideAbove as string);
106
126
  const cls = `uno-hideabove-${hashStr(String(w))}`;
107
127
  classes.push(cls);
108
128
  styleEls.push(<style key="hideAbove">{`@container (min-width:${w}){.${cls}{display:none !important}}`}</style>);
@@ -312,7 +332,7 @@ export function ImageView({ node, data, theme }: { node: UnoverseNode; data: Rec
312
332
  export function ButtonView({ node, data, theme, onAction, children }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; onAction?: ActionHandler; children?: ReactNode }): ReactNode {
313
333
  const { className, styleEls, style, disabled } = nodeChrome(node, data, theme);
314
334
  return (
315
- <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)}>
316
336
  {styleEls}
317
337
  {node.children?.length ? children : bound(node.bind?.label, data)}
318
338
  </button>
@@ -430,15 +450,64 @@ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode;
430
450
  const field = node.bind?.value;
431
451
  const [local, setLocal] = useState("");
432
452
  const value = field != null ? String(data[field] ?? "") : local;
453
+ // GENERIC constraints on what the field ACCEPTS — the same class of fact as
454
+ // `multiline`/`rows`, never a layout and never a named UX. `maxLength` caps the
455
+ // length; `inputMode: "numeric"` asks the platform for a digit keypad AND limits
456
+ // the value to 0-9. An OTP box is `maxLength: 1` + `inputMode: "numeric"` in a
457
+ // DEFINITION — the SDK knows nothing about OTP, verification, or six boxes.
458
+ const maxLength = (node as { maxLength?: number }).maxLength;
459
+ const numeric = (node as { inputMode?: string }).inputMode === "numeric";
460
+ // `autoComplete` — the browser's autofill hint, passed straight through. Without one,
461
+ // Chrome GUESSES from shape: a row of short numeric boxes reads as a card/ID number and
462
+ // it offers to save the digits. The definition says what the field IS (a one-time code,
463
+ // a postal code, "off") and the guess never happens. Generic pass-through: the SDK holds
464
+ // no list of values and no idea what a one-time code is.
465
+ const autoComplete = (node as { autoComplete?: string }).autoComplete;
466
+ // `maxLength` is enforced by the element itself, but `inputMode` is only a HINT —
467
+ // a desktop keyboard types straight past it — so the digit rule is applied here.
468
+ const accept = (t: string) => {
469
+ const kept = numeric ? t.replace(/[^0-9]/g, "") : t;
470
+ return maxLength != null ? kept.slice(0, maxLength) : kept;
471
+ };
472
+ // CONTROLLED: report the change as an "input" event carrying WHICH field changed.
473
+ // The component dispatcher writes that field into this instance's slice (local two-way
474
+ // binding, §2e-3); the template composer's channel reads `text` and ignores `field`
475
+ // (its bound field is the shared `draft`). The SDK authors no UX — just the fact.
476
+ // `advanceOnFill` — when this field is FULL, focus moves to the next field that opted
477
+ // in; Backspace in an empty one moves back. A generic fact about a field that is one
478
+ // of a SEQUENCE, opted into per field so an ordinary `maxLength` text box never yanks
479
+ // focus away the moment it fills. The SDK still knows nothing about codes or six boxes:
480
+ // it walks the marked peers in document order and moves focus one step.
481
+ const advanceOnFill = (node as { advanceOnFill?: boolean }).advanceOnFill === true;
482
+ const el = useRef<HTMLInputElement>(null);
483
+ const stepFocus = (dir: 1 | -1) => {
484
+ const self = el.current;
485
+ if (!self) return;
486
+ // Scope the walk to this render's ROOT — the SDK renders inside a shadow root, so
487
+ // `document` would miss the peers entirely (and, on a page with two instances,
488
+ // could reach the wrong ones).
489
+ const root = self.getRootNode() as Document | ShadowRoot;
490
+ const peers = Array.from(root.querySelectorAll<HTMLInputElement>("input[data-uno-advance]"));
491
+ const next = peers[peers.indexOf(self) + dir];
492
+ if (!next) return;
493
+ next.focus();
494
+ next.select();
495
+ };
433
496
  // CONTROLLED: report the change as an "input" event carrying WHICH field changed.
434
497
  // The component dispatcher writes that field into this instance's slice (local two-way
435
498
  // binding, §2e-3); the template composer's channel reads `text` and ignores `field`
436
499
  // (its bound field is the shared `draft`). The SDK authors no UX — just the fact.
437
- const setValue = (t: string) => (field != null ? onAction?.("input", { text: t, field }) : setLocal(t));
500
+ const setValue = (raw: string) => {
501
+ const t = accept(raw);
502
+ // Move BEFORE the store round-trip: focus is DOM state, independent of the re-render,
503
+ // so a fast typist's next keystroke lands in the next field rather than this one.
504
+ if (advanceOnFill && maxLength != null && t.length >= maxLength) stepFocus(1);
505
+ return field != null ? onAction?.("input", { text: t, field }) : setLocal(t);
506
+ };
438
507
  const submit = () => {
439
508
  const t = value.trim();
440
509
  if (!t) return;
441
- onAction?.(node.action ?? "submit", { text: t });
510
+ onAction?.(nodeAction(node, "submit"), { text: t });
442
511
  if (field == null) setLocal("");
443
512
  };
444
513
  const { style, disabled } = nodeChrome(node, data, theme);
@@ -452,6 +521,7 @@ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode;
452
521
  disabled={disabled}
453
522
  value={value}
454
523
  placeholder={node.placeholder}
524
+ maxLength={maxLength}
455
525
  rows={(node as { rows?: number }).rows ?? 3}
456
526
  onChange={(e) => setValue(e.target.value)}
457
527
  onKeyDown={(e) => {
@@ -465,12 +535,24 @@ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode;
465
535
  }
466
536
  return (
467
537
  <input
538
+ ref={el}
468
539
  style={style}
469
540
  disabled={disabled}
470
541
  value={value}
471
542
  placeholder={node.placeholder}
543
+ maxLength={maxLength}
544
+ inputMode={numeric ? "numeric" : undefined}
545
+ autoComplete={autoComplete}
546
+ data-uno-advance={advanceOnFill ? "" : undefined}
472
547
  onChange={(e) => setValue(e.target.value)}
473
548
  onKeyDown={(e) => {
549
+ // Backspace in an ALREADY-empty field steps back, so correcting a sequence never
550
+ // needs the mouse. A field with content just deletes it (the default).
551
+ if (advanceOnFill && e.key === "Backspace" && value === "") {
552
+ e.preventDefault();
553
+ stepFocus(-1);
554
+ return;
555
+ }
474
556
  if (e.key === "Enter" && !e.shiftKey) {
475
557
  e.preventDefault();
476
558
  submit();
@@ -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
@@ -7,7 +7,6 @@
7
7
  import type { CSSProperties } from "react";
8
8
  import { resolveValue, matches, type ResolvedTheme, type StyleVariant } from "./core";
9
9
 
10
- const WEIGHTS: Record<string, number> = { normal: 400, medium: 500, semibold: 600, bold: 700 };
11
10
 
12
11
  // Resolution cache: style objects come from CACHED definitions, so they are
13
12
  // referentially stable across renders — resolving the same (style, theme) pair per
@@ -76,7 +75,10 @@ function computeCss(s: Record<string, unknown>, theme: ResolvedTheme, data?: Rec
76
75
  const space = (v: string) => theme.space[v] ?? v;
77
76
  // Resolve a dimension: a `space.*` scale step (e.g. "8" → 2rem) → its value; `full` → 100%;
78
77
  // else pass through. ONE scale for spacing AND element sizes (Tailwind-style w-5/p-5).
79
- const dim = (v: unknown) => theme.space[v as string] ?? (v === "full" ? "100%" : (v as CSSProperties["width"]));
78
+ // A dimension is a scale step ("8") OR a named PAGE WIDTH ("reading"). Both live on
79
+ // the same scale — `layout.*` are aliases onto it — so one lookup order covers both.
80
+ const dim = (v: unknown) =>
81
+ theme.space[v as string] ?? theme.layout?.[v as string] ?? (v === "full" ? "100%" : (v as CSSProperties["width"]));
80
82
 
81
83
  if (s.width != null) css.width = dim(s.width);
82
84
  if (s.height != null) css.height = dim(s.height);
@@ -114,12 +116,23 @@ function computeCss(s: Record<string, unknown>, theme: ResolvedTheme, data?: Rec
114
116
  if (s.columns) {
115
117
  css.display = "grid";
116
118
  css.gridTemplateColumns = `repeat(${s.columns}, minmax(0, 1fr))`;
119
+ // THE GRID IS ITS OWN CONTAINER, so a child's `span` can collapse against the grid's
120
+ // width rather than the page's. A grid cannot query itself, but its children can query
121
+ // it — which is exactly the direction the stacking rule runs (primitives.tsx). Without
122
+ // this a grid would need a `container` ancestor to stack, and would silently not stack
123
+ // wherever one was missing.
124
+ (css as Record<string, unknown>).containerType = "inline-size";
117
125
  // Pack rows at the top. Without this, a columns-grid inside a flex:1 panel
118
126
  // inherits CSS's default align-content:stretch and distributes the panel's
119
127
  // leftover height BETWEEN rows — the phantom vertical gap between card rows.
120
128
  css.alignContent = "start";
121
129
  }
122
130
  if (s.cursor) css.cursor = s.cursor as CSSProperties["cursor"];
131
+ // `span` — how many of the parent grid's columns this child covers: on a 12-column
132
+ // grid, 6 is a half and 3 is a quarter. The grid owns the gaps, so spans never overflow
133
+ // the way percentage widths in a flex row do. Below the stacking width the child takes
134
+ // the full row instead (the rule injected in primitives.tsx).
135
+ if (s.span) css.gridColumn = `span ${s.span}`;
123
136
  if (s.align) css.alignItems = s.align === "start" ? "flex-start" : (s.align as CSSProperties["alignItems"]);
124
137
  if (s.justify) css.justifyContent = s.justify as CSSProperties["justifyContent"];
125
138
  // Explicit text alignment (inherited by children) — needed because a Button's
@@ -168,7 +181,12 @@ function computeCss(s: Record<string, unknown>, theme: ResolvedTheme, data?: Rec
168
181
  if (s.overflow) css.overflow = s.overflow as CSSProperties["overflow"];
169
182
  if (s.fit) css.objectFit = s.fit as CSSProperties["objectFit"];
170
183
  if (s.font && theme.text[s.font as string]) Object.assign(css, theme.text[s.font as string]);
171
- if (s.weight) css.fontWeight = WEIGHTS[s.weight as string] ?? (s.weight as CSSProperties["fontWeight"]);
184
+ // `weight` — a SERVED token like every other value here. This was the one scale the
185
+ // SDK owned outright: a literal four-name map, which meant the foundation's own
186
+ // `light`/`black` were unreachable and an org that added a weight to its tokens got
187
+ // nothing at all (the unknown word reached CSS, CSS dropped the declaration, and the
188
+ // text kept whatever it inherited — silently). A raw number still passes through.
189
+ if (s.weight) css.fontWeight = (theme.weight[s.weight as string] ?? s.weight) as CSSProperties["fontWeight"];
172
190
  // `lineHeight` — override the text style's line-height (e.g. tighten a single-line
173
191
  // card title). Applied AFTER `font` so it wins. Resolves a lineHeight token (tight/…).
174
192
  if (s.lineHeight != null) css.lineHeight = (theme.lineHeight[s.lineHeight as string] ?? s.lineHeight) as CSSProperties["lineHeight"];
@@ -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.