@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 +1 -1
- package/screens/DesignSystemView.tsx +16 -9
- package/screens/SourceView.tsx +1 -1
- package/screens/TemplatesView.tsx +2 -2
- package/src/PublishDialog.tsx +1 -1
- package/vendor/sdk/appEngine.tsx +26 -1
- package/vendor/sdk/lib/UnoverseComponent.tsx +1 -1
- package/vendor/sdk/lib/core/actions.ts +10 -0
- package/vendor/sdk/lib/core/analytics.ts +89 -0
- package/vendor/sdk/lib/core/client.ts +1 -1
- package/vendor/sdk/lib/core/index.ts +12 -0
- package/vendor/sdk/lib/core/types.ts +30 -0
- package/vendor/sdk/lib/index.ts +9 -0
- package/vendor/sdk/lib/primitives.tsx +88 -6
- package/vendor/sdk/lib/render.tsx +16 -0
- package/vendor/sdk/lib/style.ts +21 -3
- package/vendor/sdk/main.tsx +7 -0
package/package.json
CHANGED
|
@@ -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
|
-
{
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
<div className="text-[10px]" style={{ color: sub }}>{val}</div>
|
|
197
|
-
</div>
|
|
198
|
-
))}
|
|
205
|
+
))}
|
|
199
206
|
</div>
|
|
200
207
|
),
|
|
201
208
|
},
|
package/screens/SourceView.tsx
CHANGED
|
@@ -432,7 +432,7 @@ export function SourceView({
|
|
|
432
432
|
/**
|
|
433
433
|
* TWO FILE SHAPES, TWO WAYS IN.
|
|
434
434
|
*
|
|
435
|
-
* An ENVELOPE (`
|
|
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>": ["
|
|
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. ["
|
|
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",
|
package/src/PublishDialog.tsx
CHANGED
|
@@ -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
|
-
* `
|
|
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> = {
|
package/vendor/sdk/appEngine.tsx
CHANGED
|
@@ -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/
|
|
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/
|
|
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>>>;
|
package/vendor/sdk/lib/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 = (
|
|
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
|
|
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
|
package/vendor/sdk/lib/style.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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"];
|
package/vendor/sdk/main.tsx
CHANGED
|
@@ -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.
|