@unoverse-platform/studio 0.1.25 → 0.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/screens/AtomsView.tsx +40 -2
- package/screens/ComponentsView.tsx +112 -21
- package/screens/NativeAppHost.tsx +8 -4
- package/screens/SourceView.tsx +1 -1
- package/screens/TemplatesView.tsx +52 -22
- package/screens/states.ts +27 -4
- package/src/PublishDialog.tsx +1 -1
- package/vendor/sdk/appEngine.tsx +44 -3
- package/vendor/sdk/lib/UnoverseComponent.tsx +1 -1
- package/vendor/sdk/lib/core/actions.ts +20 -6
- 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 +30 -0
- package/vendor/sdk/lib/core/store.ts +65 -38
- package/vendor/sdk/lib/core/templates.ts +252 -0
- package/vendor/sdk/lib/core/types.ts +40 -7
- package/vendor/sdk/lib/index.ts +9 -0
- package/vendor/sdk/lib/orb/ThinkingOrb.tsx +127 -0
- package/vendor/sdk/lib/orb/engine/braid.ts +50 -0
- package/vendor/sdk/lib/orb/engine/core.ts +157 -0
- package/vendor/sdk/lib/orb/engine/lattice.ts +205 -0
- package/vendor/sdk/lib/orb/engine/morph.ts +138 -0
- package/vendor/sdk/lib/orb/engine/orbits.ts +80 -0
- package/vendor/sdk/lib/orb/engine/profiles.ts +162 -0
- package/vendor/sdk/lib/orb/engine/registry.ts +24 -0
- package/vendor/sdk/lib/orb/engine/ribbon.ts +93 -0
- package/vendor/sdk/lib/orb/engine/types.ts +14 -0
- package/vendor/sdk/lib/orb/engine/web.ts +98 -0
- package/vendor/sdk/lib/orb/index.ts +20 -0
- package/vendor/sdk/lib/orb/presets.ts +104 -0
- package/vendor/sdk/lib/orb/theme.ts +83 -0
- package/vendor/sdk/lib/orb/types.ts +73 -0
- package/vendor/sdk/lib/primitives.tsx +114 -5
- package/vendor/sdk/lib/render.tsx +25 -5
- package/vendor/sdk/lib/style.ts +6 -2
- package/vendor/sdk/lib/template.tsx +77 -218
- package/vendor/sdk/main.tsx +7 -0
- package/vendor/skills/unoverse-create/SKILL.md +18 -18
- package/vendor/skills/unoverse-create/references/agent-skill.md +12 -12
- package/vendor/skills/unoverse-create/references/block.md +5 -5
- package/vendor/skills/unoverse-create/references/component.md +150 -75
- package/vendor/skills/unoverse-create/references/node.md +1 -1
- package/vendor/skills/unoverse-create/references/template.md +142 -82
- package/vendor/skills/unoverse-create/references/workflow.md +15 -15
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TEMPLATE DERIVATIONS — the pure template-tier brain (framework-agnostic).
|
|
3
|
+
*
|
|
4
|
+
* Everything a template "decides" is DERIVED here from the store, never stored:
|
|
5
|
+
* the active layout (name-sync), the active view (with the pinned/guest-control
|
|
6
|
+
* law), which pointers a reaction surface selects, which views are claimed, and
|
|
7
|
+
* the app's declared width. `StreamedUnoverseTemplate` (the React binding) calls
|
|
8
|
+
* these; a platform port (Flutter/Swift/Kotlin) re-implements its own binding and
|
|
9
|
+
* calls the SAME derivations — this file IS the port list for the template tier.
|
|
10
|
+
*
|
|
11
|
+
* No React, no DOM: these functions read (def, store) and return values. Guarded
|
|
12
|
+
* by server/tests/sdk/sdk-core-portable.test.ts.
|
|
13
|
+
*/
|
|
14
|
+
import type { ComponentStore } from "./store";
|
|
15
|
+
import type { UnoverseDefinition, UnoverseNode } from "./types";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* THE PUBLIC AXIS (STATE_MODEL v2 §2): a component's public state lives in its slice
|
|
19
|
+
* under `view`; `defaultState` is the read-compatible legacy alias during migration.
|
|
20
|
+
* Every derivation reads through these two helpers so the alias lives in ONE place.
|
|
21
|
+
*/
|
|
22
|
+
export const viewOf = (slice: Record<string, unknown> | null | undefined): string | undefined => {
|
|
23
|
+
const v = slice?.view ?? slice?.defaultState;
|
|
24
|
+
return typeof v === "string" ? v : undefined;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Is a `select.where` field the public axis? Accepts both spellings during migration. */
|
|
28
|
+
export const isViewField = (f: unknown): boolean => f === "view" || f === "defaultState";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* THE TEMPLATE'S DECLARED ORDER (STATE_MODEL v2 §5 rule 5) — its priority ladder and
|
|
32
|
+
* arrival-scan order. Served from the manifest's `stateOrder`. Legacy defs without one
|
|
33
|
+
* fall back to the v1 claim set (layout names + surface claims) with v1 recency
|
|
34
|
+
* semantics — see resolveActiveView.
|
|
35
|
+
*/
|
|
36
|
+
export function orderedClaims(def: UnoverseDefinition | null | undefined): string[] {
|
|
37
|
+
if (def?.stateOrder?.length) return def.stateOrder;
|
|
38
|
+
const legacy = new Set<string>(Object.keys(def?.layouts ?? {}));
|
|
39
|
+
if (def?.layouts) for (const l of Object.values(def.layouts)) collectSurfacedViews(l, legacy);
|
|
40
|
+
else if (def?.root) collectSurfacedViews(def.root, legacy);
|
|
41
|
+
return [...legacy];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A width literal is passed through only when it plausibly IS a width (contains a
|
|
45
|
+
* digit — "320px", "40%", "calc(...)"). An unresolvable NAME returns undefined so
|
|
46
|
+
* it can never emit invalid CSS (or an invalid native constraint). */
|
|
47
|
+
export const cssWidth = (w: unknown): string | undefined => (typeof w === "string" && /\d/.test(w) ? w : undefined);
|
|
48
|
+
|
|
49
|
+
/** The author's declared prop defaults (def.props[k].default) → a flat data object. Content, not policy. */
|
|
50
|
+
export function propDefaults(props?: Record<string, unknown>): Record<string, unknown> {
|
|
51
|
+
const out: Record<string, unknown> = {};
|
|
52
|
+
for (const [k, v] of Object.entries(props ?? {})) {
|
|
53
|
+
if (v && typeof v === "object" && "default" in (v as Record<string, unknown>)) {
|
|
54
|
+
out[k] = (v as { default: unknown }).default;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Format a turn timestamp as relative copy ("just now" / "5 min ago"). Neutral
|
|
61
|
+
* projection of a model value (like `text`), not UX policy. Mirrors legacy
|
|
62
|
+
* ChatHistoryItem.formatRelativeTime. Empty string when unstamped. */
|
|
63
|
+
export function formatRelative(ts?: number): string {
|
|
64
|
+
if (!ts) return "";
|
|
65
|
+
const diff = Math.floor((Date.now() - ts) / 1000);
|
|
66
|
+
if (diff < 60) return "just now";
|
|
67
|
+
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
|
|
68
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)} hr ago`;
|
|
69
|
+
return new Date(ts).toLocaleDateString();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Walk a template tree and collect every VIEW a reaction surface claims — the `eq`
|
|
74
|
+
* value of each `ComponentSlot.select.where` on the public axis. One instance renders
|
|
75
|
+
* in exactly ONE placeholder (STATE_MODEL v2 §5 rule 6): a view a surface shows must
|
|
76
|
+
* not also render in the flow; a view nothing claims stays in the flow (the fallback).
|
|
77
|
+
* v2 note: with a declared `stateOrder` this is only the per-layout slot inventory;
|
|
78
|
+
* the CLAIM list itself is orderedClaims().
|
|
79
|
+
*/
|
|
80
|
+
export function collectSurfacedViews(node: unknown, out: Set<string> = new Set()): Set<string> {
|
|
81
|
+
if (Array.isArray(node)) {
|
|
82
|
+
for (const n of node) collectSurfacedViews(n, out);
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
if (!node || typeof node !== "object") return out;
|
|
86
|
+
const n = node as Record<string, unknown>;
|
|
87
|
+
const sel = n.select as { where?: { field?: string; eq?: unknown } } | undefined;
|
|
88
|
+
if (n.type === "ComponentSlot" && isViewField(sel?.where?.field) && typeof sel?.where?.eq === "string") {
|
|
89
|
+
out.add(sel.where.eq);
|
|
90
|
+
}
|
|
91
|
+
for (const v of Object.values(n)) if (v && typeof v === "object") collectSurfacedViews(v, out);
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* STATE-OWNED APP WIDTH (docs/design/05, layouts era): the app's outer width is
|
|
97
|
+
* declared by its LAYOUT's data, never the manifest. Two declaration points, both
|
|
98
|
+
* the neutral `appWidth` key (a named app size resolved via theme.appSize):
|
|
99
|
+
* - a plain node's `appWidth` = an always-open panel (the core column) — the
|
|
100
|
+
* layout's constant term.
|
|
101
|
+
* - a ComponentSlot surface's `appWidth` = its panel width, counted only while
|
|
102
|
+
* the surface is OCCUPIED (its view is the active state).
|
|
103
|
+
* The caller passes the ACTIVE layout (resolveActiveLayout) — layout swaps are
|
|
104
|
+
* whole-arrangement swaps, so the width is always the active layout's total.
|
|
105
|
+
* Activation is DERIVED, never stored. No declared width → undefined: the host
|
|
106
|
+
* keeps its own default, the sizing twin of "unmatched view falls back to inline".
|
|
107
|
+
* There is no other path.
|
|
108
|
+
*/
|
|
109
|
+
export function computeAppWidth(root: unknown, store: ComponentStore, appSize?: Record<string, string>, def?: UnoverseDefinition | null): string | undefined {
|
|
110
|
+
// Resolve a declared width: a NAMED SIZE via theme.appSize (like any token), else raw
|
|
111
|
+
// CSS — guarded so an unresolvable name can never emit invalid CSS.
|
|
112
|
+
const rw = (w: unknown): string | undefined => cssWidth(typeof w === "string" ? (appSize?.[w] ?? w) : undefined);
|
|
113
|
+
const r = root as Record<string, unknown> | null | undefined;
|
|
114
|
+
const widths: string[] = [];
|
|
115
|
+
// Gather the tree's width-bearing nodes: always-open core panels and the reaction
|
|
116
|
+
// SURFACES (view → panel width).
|
|
117
|
+
const surfaces = new Map<string, string | undefined>();
|
|
118
|
+
const walk = (node: unknown): void => {
|
|
119
|
+
if (Array.isArray(node)) {
|
|
120
|
+
for (const c of node) walk(c);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!node || typeof node !== "object") return;
|
|
124
|
+
const n = node as Record<string, unknown> & { appWidth?: string; type?: string; select?: { where?: { field?: string; eq?: unknown } } };
|
|
125
|
+
if (n !== r) {
|
|
126
|
+
if (n.type === "ComponentSlot") {
|
|
127
|
+
const claim = n.select?.where;
|
|
128
|
+
// `appWidth: flex` — the surface takes the REMAINING host space. DATA WINS: an
|
|
129
|
+
// org may define `flex` as a real appSize token (bpp: calc(100% − chat)) and
|
|
130
|
+
// that resolves like any name; the marker below is the theme-free fallback,
|
|
131
|
+
// resolved as "fill the host" when the view is active.
|
|
132
|
+
if (isViewField(claim?.field) && typeof claim?.eq === "string")
|
|
133
|
+
surfaces.set(claim.eq, rw(n.appWidth) ?? (n.appWidth === "flex" ? "flex" : undefined));
|
|
134
|
+
} else if (n.appWidth !== undefined) {
|
|
135
|
+
const w = rw(n.appWidth);
|
|
136
|
+
if (w) widths.push(w); // a plain panel (the core column) is ALWAYS open
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (const v of Object.values(n)) if (v && typeof v === "object") walk(v);
|
|
140
|
+
};
|
|
141
|
+
walk(r);
|
|
142
|
+
// ONE STATE AT A TIME: the app = core + the ACTIVE state's panel. The active state
|
|
143
|
+
// comes from the ONE shared derivation (resolveActiveView) — this function used to
|
|
144
|
+
// keep its own recency loop, and the two copies diverged once (blank panel); never
|
|
145
|
+
// re-introduce a private loop here. Pass `def` so the v2 ladder applies; without it
|
|
146
|
+
// the derivation runs in legacy mode over this tree's surface claims.
|
|
147
|
+
const active = resolveActiveView(def, store, new Set(surfaces.keys())).view;
|
|
148
|
+
const activePanel = active ? surfaces.get(active) : undefined;
|
|
149
|
+
// An ACTIVE flex surface fills the host viewport (the host caps at its pane); the
|
|
150
|
+
// surface flexes into whatever the fixed panels leave.
|
|
151
|
+
if (activePanel === "flex") return "100%";
|
|
152
|
+
if (activePanel) widths.push(activePanel);
|
|
153
|
+
if (!widths.length) return undefined;
|
|
154
|
+
return widths.length === 1 ? widths[0] : `calc(${widths.join(" + ")})`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* THE TEMPLATE'S STATE OWNS ITS LAYOUT (STATE_MODEL v2 §5 rules 1+4). The active
|
|
159
|
+
* layout resolves from the active STATE's declaration — `layouts` is keyed by state
|
|
160
|
+
* name (the same-name convention), falling back to `defaultLayout` when no state is
|
|
161
|
+
* active. A layout is never addressable on its own. Single-layout templates
|
|
162
|
+
* (`layouts` absent) resolve to `root`: the classic behavior, untouched.
|
|
163
|
+
*/
|
|
164
|
+
export function resolveActiveLayout(def: UnoverseDefinition | null | undefined, store: ComponentStore): UnoverseNode | undefined {
|
|
165
|
+
if (!def) return undefined;
|
|
166
|
+
const layouts = def.layouts;
|
|
167
|
+
if (!layouts) return def.root;
|
|
168
|
+
const { view } = resolveActiveView(def, store);
|
|
169
|
+
return layouts[view] ?? layouts[def.defaultLayout ?? "main"] ?? def.root;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* THE TEMPLATE'S ACTIVE STATE — the ONE derivation every consumer shares.
|
|
174
|
+
*
|
|
175
|
+
* v2 (STATE_MODEL §5 rule 5): with a declared `stateOrder` the template walks ITS OWN
|
|
176
|
+
* order top-down and enters the first of its states that ANY hosted component's view
|
|
177
|
+
* currently matches. Priority is list position, nothing else; ties within one state
|
|
178
|
+
* go to the most recent write (`limit: 1` law). Without a `stateOrder` (legacy defs)
|
|
179
|
+
* the v1 rule applies: latest surfaced view among `validViews` wins.
|
|
180
|
+
*
|
|
181
|
+
* PURE PRIORITY (final ruling, 2026-08-09): the ladder alone decides — no pinned
|
|
182
|
+
* flag, no guest-control override. Order the ladder by PROMINENCE (a state the
|
|
183
|
+
* guest taps into ranks above the state it came from: focus > detail > rail) and
|
|
184
|
+
* every interaction resolves by the same walk. Layout choice and surface gating
|
|
185
|
+
* MUST both read this function — two loops that each derive "the active state"
|
|
186
|
+
* disagree eventually, and the whole panel goes blank.
|
|
187
|
+
*/
|
|
188
|
+
export function resolveActiveView(
|
|
189
|
+
def: UnoverseDefinition | null | undefined,
|
|
190
|
+
store: ComponentStore,
|
|
191
|
+
validViews?: Set<string>,
|
|
192
|
+
): { view: string; byGuest: boolean } {
|
|
193
|
+
const order = def?.stateOrder?.length ? def.stateOrder : undefined;
|
|
194
|
+
const valid = order ? new Set(order) : (validViews ?? new Set(orderedClaims(def)));
|
|
195
|
+
// One pass over the timeline: the best (latest) match per view.
|
|
196
|
+
const best = new Map<string, { at: number; byGuest: boolean }>();
|
|
197
|
+
for (const turn of store.getResponses())
|
|
198
|
+
for (const p of turn.components) {
|
|
199
|
+
const { chatId, nodeId } = store.split(p);
|
|
200
|
+
const v = viewOf(store.get(chatId, nodeId) as Record<string, unknown>);
|
|
201
|
+
if (!v || !valid.has(v)) continue;
|
|
202
|
+
const t = store.touchOf(p);
|
|
203
|
+
const b = best.get(v);
|
|
204
|
+
if (!b || t > b.at) best.set(v, { at: t, byGuest: store.interactionTouchOf(p) >= t });
|
|
205
|
+
}
|
|
206
|
+
if (order) {
|
|
207
|
+
// THE LADDER: first declared state with any match wins. No word is special.
|
|
208
|
+
for (const name of order) {
|
|
209
|
+
const b = best.get(name);
|
|
210
|
+
if (b) return { view: name, byGuest: b.byGuest };
|
|
211
|
+
}
|
|
212
|
+
return { view: "", byGuest: false };
|
|
213
|
+
}
|
|
214
|
+
// Legacy recency: the latest matched view wins.
|
|
215
|
+
let view = "";
|
|
216
|
+
let byGuest = false;
|
|
217
|
+
let at = -1;
|
|
218
|
+
for (const [v, b] of best)
|
|
219
|
+
if (b.at > at) {
|
|
220
|
+
at = b.at;
|
|
221
|
+
view = v;
|
|
222
|
+
byGuest = b.byGuest;
|
|
223
|
+
}
|
|
224
|
+
return { view, byGuest };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Resolve a ComponentSlot's `select` against the store → ordered pointers. */
|
|
228
|
+
export function selectPointers(store: ComponentStore, node: UnoverseNode): string[] {
|
|
229
|
+
const sel = node.select ?? {};
|
|
230
|
+
let pointers =
|
|
231
|
+
sel.from === "all"
|
|
232
|
+
? store.getResponses().flatMap((r) => r.components)
|
|
233
|
+
: (store.latestResponse()?.components ?? []);
|
|
234
|
+
|
|
235
|
+
// `where` — select by COMPONENT STATE (the reaction contract, STATE_MODEL §5b): any
|
|
236
|
+
// component whose own slice matches, ordered newest state-write first so `limit: 1`
|
|
237
|
+
// = "most recent wins". The generic replacement for type-pinned surfaces — a
|
|
238
|
+
// template reacts to whatever component enters the state, never to a named type.
|
|
239
|
+
if (sel.where) {
|
|
240
|
+
const { field, eq, ne } = sel.where;
|
|
241
|
+
pointers = pointers.filter((p) => {
|
|
242
|
+
const { chatId, nodeId } = store.split(p);
|
|
243
|
+
const slice = store.get(chatId, nodeId) as Record<string, unknown>;
|
|
244
|
+
// The public axis reads through the alias (view ?? defaultState); other fields raw.
|
|
245
|
+
const v = isViewField(field) ? viewOf(slice) : slice[field];
|
|
246
|
+
return ne !== undefined ? v !== ne : v === eq;
|
|
247
|
+
});
|
|
248
|
+
pointers = [...pointers].sort((a, b) => store.touchOf(b) - store.touchOf(a));
|
|
249
|
+
}
|
|
250
|
+
if (sel.limit != null) pointers = pointers.slice(0, sel.limit);
|
|
251
|
+
return pointers;
|
|
252
|
+
}
|
|
@@ -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,7 +24,20 @@ 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)
|
|
16
|
-
|
|
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;
|
|
32
|
+
placeholder?: string; // Input/Select: empty-field copy (content, not style)
|
|
33
|
+
/** Input: WHAT THE FIELD IS, handed to the platform (`date`, `tel`, `file`, `url`, …) so
|
|
34
|
+
* it offers its own picker or keypad. The same class of generic constraint as
|
|
35
|
+
* `maxLength` and `inputMode`: the SDK holds no list of types and no opinion about
|
|
36
|
+
* them, and a definition never hand-rolls a calendar out of Boxes. */
|
|
37
|
+
inputType?: string;
|
|
38
|
+
/** Select: the closed set to choose from, as `{ value, label }` entries or plain
|
|
39
|
+
* strings. Bound via `bind.options`, or literal for a set that never changes. */
|
|
40
|
+
options?: unknown[];
|
|
17
41
|
/** Box: keep the scroll pinned to the newest content — smooth-scroll to the end when
|
|
18
42
|
* content grows (new turn or streaming text), unless the user has scrolled up. Generic
|
|
19
43
|
* scroll-container behavior, opt-in via data (e.g. the conversation state's scroll box). */
|
|
@@ -101,16 +125,21 @@ export interface UnoverseDefinition {
|
|
|
101
125
|
* microapp has an EMPTY (or absent) props block. */
|
|
102
126
|
props?: Record<string, unknown>;
|
|
103
127
|
/** INTERNAL state + initial values — the keys the component's own actions write
|
|
104
|
-
* (step, phase, its
|
|
105
|
-
* merged into the render scope beneath
|
|
128
|
+
* (step, phase, its `view` on the public axis; `defaultState` is the legacy alias).
|
|
129
|
+
* Never external, never workbench controls; merged into the render scope beneath
|
|
130
|
+
* the live slice. */
|
|
106
131
|
state?: Record<string, unknown>;
|
|
107
132
|
root: UnoverseNode;
|
|
108
|
-
/** TEMPLATE LAYOUTS (
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
133
|
+
/** TEMPLATE LAYOUTS (STATE_MODEL v2 §5 rule 1): a state OWNS its layout, and this
|
|
134
|
+
* map is keyed by STATE NAME (the same-name convention). The active entry is the
|
|
135
|
+
* active state's (resolveActiveView → the stateOrder ladder), falling back to
|
|
136
|
+
* `defaultLayout`. Absent → classic single-layout behavior via `root`. */
|
|
112
137
|
layouts?: Record<string, UnoverseNode>;
|
|
113
138
|
defaultLayout?: string;
|
|
139
|
+
/** THE DECLARED STATE ORDER (STATE_MODEL v2 §5 rules 3+5) — the template's priority
|
|
140
|
+
* ladder and arrival-scan order, served from the manifest. First entry outranks
|
|
141
|
+
* all below it; no name is special. Absent → legacy v1 recency semantics. */
|
|
142
|
+
stateOrder?: string[];
|
|
114
143
|
}
|
|
115
144
|
|
|
116
145
|
// ---- The interaction vocabulary (UNOVERSE_SPEC.md §2e-3) ----
|
|
@@ -136,6 +165,10 @@ export interface ActionSpec {
|
|
|
136
165
|
values?: ActionValue[]; // setValue / setTemplateValue patches (the dev's chosen keys)
|
|
137
166
|
send?: string[]; // server route: which scope fields to include (default: all)
|
|
138
167
|
then?: ActionSpec | string; // optional chained follow-up
|
|
168
|
+
/** Carried here by the renderer, NOT authored on the action: a node declares
|
|
169
|
+
* `analytics:` beside `action:`, and the leaf attaches it so the ONE interpreter
|
|
170
|
+
* (`dispatchAction`) stays the only place an interaction is understood. */
|
|
171
|
+
analytics?: AnalyticsSpec;
|
|
139
172
|
}
|
|
140
173
|
|
|
141
174
|
/** A data condition, evaluated against the render data scope. Pick ONE test:
|
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";
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// The ThinkingOrb component. One shared clock (performance.now) keeps
|
|
2
|
+
// every mounted orb in phase; each instance runs its own rAF loop but
|
|
3
|
+
// pauses automatically while offscreen (IntersectionObserver) or when
|
|
4
|
+
// the tab is hidden (visibilitychange). Reduced-motion users get a
|
|
5
|
+
// static representative frame that still follows the live theme.
|
|
6
|
+
|
|
7
|
+
import { useEffect, useRef } from 'react';
|
|
8
|
+
import { MODE_DRAWS } from './engine/registry';
|
|
9
|
+
import { setInk } from './engine/core';
|
|
10
|
+
import { resolvePreset } from './presets';
|
|
11
|
+
import { useReducedMotion, useResolvedDark } from './theme';
|
|
12
|
+
import type { OrbSize, ThinkingOrbProps } from './types';
|
|
13
|
+
|
|
14
|
+
const LABELS: Record<string, string> = {
|
|
15
|
+
working: 'Working…',
|
|
16
|
+
searching: 'Searching…',
|
|
17
|
+
solving: 'Solving…',
|
|
18
|
+
listening: 'Listening…',
|
|
19
|
+
connecting: 'Connecting…',
|
|
20
|
+
weaving: 'Weaving…',
|
|
21
|
+
composing: 'Composing…',
|
|
22
|
+
breathing: 'Thinking…',
|
|
23
|
+
shaping: 'Shaping…'
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function ThinkingOrb({
|
|
27
|
+
state = 'working',
|
|
28
|
+
size = 64,
|
|
29
|
+
theme = 'auto',
|
|
30
|
+
speed = 1,
|
|
31
|
+
paused = false,
|
|
32
|
+
ink = null,
|
|
33
|
+
style,
|
|
34
|
+
'aria-label': ariaLabel,
|
|
35
|
+
...rest
|
|
36
|
+
}: ThinkingOrbProps & { ink?: [number, number, number] | null }) {
|
|
37
|
+
const ref = useRef<HTMLCanvasElement | null>(null);
|
|
38
|
+
const dark = useResolvedDark(theme, ref);
|
|
39
|
+
const reduced = useReducedMotion();
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const canvas = ref.current;
|
|
43
|
+
if (!canvas) return;
|
|
44
|
+
const dpr = Math.min(2, (typeof devicePixelRatio !== 'undefined' && devicePixelRatio) || 1);
|
|
45
|
+
canvas.width = Math.round(size * dpr);
|
|
46
|
+
canvas.height = Math.round(size * dpr);
|
|
47
|
+
const ctx = canvas.getContext('2d');
|
|
48
|
+
if (!ctx) return;
|
|
49
|
+
|
|
50
|
+
// LOCAL EXTENSION (Unoverse): `size` is any positive number — the canvas and
|
|
51
|
+
// draw run at the true size (the engine's radiusScale compensates), while the
|
|
52
|
+
// TUNING snaps to the nearest of the two baked presets (20 inline / 64 avatar).
|
|
53
|
+
const preset: OrbSize = size <= 32 ? 20 : 64;
|
|
54
|
+
const { mode, speed: baseSpeed, opts } = resolvePreset(state, preset);
|
|
55
|
+
const draw = MODE_DRAWS[mode];
|
|
56
|
+
const effSpeed = baseSpeed * speed;
|
|
57
|
+
|
|
58
|
+
const frame = (tSec: number) => {
|
|
59
|
+
// Ink is a module-level current (frames are synchronous); set per frame so
|
|
60
|
+
// orbs with different brand tokens on one page never bleed into each other.
|
|
61
|
+
setInk(ink);
|
|
62
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
63
|
+
ctx.clearRect(0, 0, size, size);
|
|
64
|
+
draw(ctx, size, tSec, dark, opts);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// reduced motion → one static, deterministic frame
|
|
68
|
+
if (reduced) {
|
|
69
|
+
frame(0.6);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let raf = 0;
|
|
74
|
+
let running = false;
|
|
75
|
+
const loop = () => {
|
|
76
|
+
frame((performance.now() / 1000) * effSpeed);
|
|
77
|
+
if (running) raf = requestAnimationFrame(loop);
|
|
78
|
+
};
|
|
79
|
+
const start = () => {
|
|
80
|
+
if (running || paused) return;
|
|
81
|
+
running = true;
|
|
82
|
+
raf = requestAnimationFrame(loop);
|
|
83
|
+
};
|
|
84
|
+
const stop = () => {
|
|
85
|
+
running = false;
|
|
86
|
+
cancelAnimationFrame(raf);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// draw at least one frame even when paused/offscreen
|
|
90
|
+
frame((performance.now() / 1000) * effSpeed);
|
|
91
|
+
|
|
92
|
+
// pause offscreen + on hidden tabs — free when not visible
|
|
93
|
+
let visible = true;
|
|
94
|
+
const io =
|
|
95
|
+
typeof IntersectionObserver !== 'undefined'
|
|
96
|
+
? new IntersectionObserver(([entry]) => {
|
|
97
|
+
visible = entry.isIntersecting;
|
|
98
|
+
if (visible && document.visibilityState !== 'hidden') start();
|
|
99
|
+
else stop();
|
|
100
|
+
})
|
|
101
|
+
: null;
|
|
102
|
+
io?.observe(canvas);
|
|
103
|
+
const onVis = () => {
|
|
104
|
+
if (document.visibilityState === 'hidden') stop();
|
|
105
|
+
else if (visible) start();
|
|
106
|
+
};
|
|
107
|
+
document.addEventListener('visibilitychange', onVis);
|
|
108
|
+
if (!io) start();
|
|
109
|
+
|
|
110
|
+
return () => {
|
|
111
|
+
stop();
|
|
112
|
+
io?.disconnect();
|
|
113
|
+
document.removeEventListener('visibilitychange', onVis);
|
|
114
|
+
};
|
|
115
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- ink compared by value: a tuple is a fresh identity every render
|
|
116
|
+
}, [state, size, dark, speed, paused, reduced, ink?.join(",")]);
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<canvas
|
|
120
|
+
ref={ref}
|
|
121
|
+
role="img"
|
|
122
|
+
aria-label={ariaLabel ?? LABELS[state]}
|
|
123
|
+
style={{ width: size, height: size, display: 'block', ...style }}
|
|
124
|
+
{...rest}
|
|
125
|
+
/>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Braid: three strands plait around the sphere — the "weaving" state.
|
|
2
|
+
// Each strand runs pole to pole on a helix, and a radial breathing term
|
|
3
|
+
// makes them trade places, reading as the over/under of a plait.
|
|
4
|
+
|
|
5
|
+
import type { Dot, ModeDraw } from './types';
|
|
6
|
+
import { fibDir, frac, makeProj, paint, radiusScale } from './core';
|
|
7
|
+
|
|
8
|
+
export const drawBraid: ModeDraw = (ctx, size, t, dark, o) => {
|
|
9
|
+
const cx = size / 2;
|
|
10
|
+
const cy = size / 2;
|
|
11
|
+
const R = (size / 2) * 0.76;
|
|
12
|
+
const pt = makeProj(t * 0.4, 0.3, cx, cy, 1);
|
|
13
|
+
const rs = radiusScale(size, o.rsPow ?? 0.6);
|
|
14
|
+
|
|
15
|
+
const dots: Dot[] = [];
|
|
16
|
+
const ghostN = o.ghostN ?? 150;
|
|
17
|
+
for (let i = 0; i < ghostN; i++) {
|
|
18
|
+
const d = fibDir(i, ghostN);
|
|
19
|
+
const [px, py, z] = pt(d[0] * R, d[1] * R, d[2] * R);
|
|
20
|
+
const depth = (z / R + 1) / 2;
|
|
21
|
+
dots.push({ x: px, y: py, z, r: 0.8 * rs, white: 0.78, a: 0.1 + 0.22 * depth });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const strandN = o.strandN ?? 52;
|
|
25
|
+
const turns = o.turns ?? 3;
|
|
26
|
+
for (let s = 0; s < 3; s++) {
|
|
27
|
+
const phase = (s / 3) * 2 * Math.PI;
|
|
28
|
+
for (let i = 0; i < strandN; i++) {
|
|
29
|
+
// u walks pole to pole; the frac() drift slides the whole strand along
|
|
30
|
+
const u = (frac(i / strandN + t * 0.045) * 2 - 1) * 0.96;
|
|
31
|
+
const surf = Math.sqrt(Math.max(0, 1 - u * u));
|
|
32
|
+
const endFade = Math.min(1, (1 - Math.abs(u)) / 0.1);
|
|
33
|
+
const a = u * Math.PI * turns + phase;
|
|
34
|
+
// radial breathing: strands trade places — the over/under of a plait
|
|
35
|
+
const weave = 1 + 0.075 * Math.sin(u * Math.PI * turns * 2 + phase * 2 + t * 0.8);
|
|
36
|
+
const rr = surf * R * weave;
|
|
37
|
+
const [px, py, zr] = pt(Math.cos(a) * rr, u * R * weave, Math.sin(a) * rr);
|
|
38
|
+
const depth = (zr / R + 1) / 2;
|
|
39
|
+
dots.push({
|
|
40
|
+
x: px,
|
|
41
|
+
y: py,
|
|
42
|
+
z: zr,
|
|
43
|
+
r: ((o.rBase ?? 1.2) + (o.rDepth ?? 1.8) * depth) * rs,
|
|
44
|
+
white: 0.55 - 0.45 * depth,
|
|
45
|
+
a: endFade * (0.45 + 0.55 * depth)
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
paint(ctx, dots, dark, o.rMin);
|
|
50
|
+
};
|