@unoverse-platform/studio 0.1.26 → 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/TemplatesView.tsx +50 -20
- package/screens/states.ts +27 -4
- package/vendor/sdk/appEngine.tsx +18 -2
- package/vendor/sdk/lib/core/actions.ts +10 -6
- package/vendor/sdk/lib/core/index.ts +18 -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 +20 -7
- 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 +111 -2
- package/vendor/sdk/lib/render.tsx +9 -5
- package/vendor/sdk/lib/style.ts +6 -2
- package/vendor/sdk/lib/template.tsx +77 -218
- 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
|
@@ -28,6 +28,24 @@ 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
|
+
// The pure template-tier derivations (STATE_MODEL v2: the stateOrder ladder, state-
|
|
32
|
+
// owns-layout resolution, the pinned law, slot selection, claimed views, app width).
|
|
33
|
+
// Framework-agnostic — the React binding (template.tsx) and every platform port call
|
|
34
|
+
// these, never re-derive them.
|
|
35
|
+
export {
|
|
36
|
+
collectSurfacedViews,
|
|
37
|
+
computeAppWidth,
|
|
38
|
+
cssWidth,
|
|
39
|
+
formatRelative,
|
|
40
|
+
isViewField,
|
|
41
|
+
orderedClaims,
|
|
42
|
+
propDefaults,
|
|
43
|
+
resolveActiveLayout,
|
|
44
|
+
resolveActiveView,
|
|
45
|
+
selectPointers,
|
|
46
|
+
viewOf,
|
|
47
|
+
} from "./templates";
|
|
48
|
+
|
|
31
49
|
// Experience analytics (UNOVERSE_ANALYTICS.md) — the SDK emits; the host decides where to.
|
|
32
50
|
export {
|
|
33
51
|
emitAnalytics,
|
|
@@ -102,6 +102,26 @@ export class ComponentStore {
|
|
|
102
102
|
this.maxTurns = opts?.maxTurns ?? 100;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* RETRACT ONE SLICE to its declared base (STATE_MODEL v2 §8 item 6). The public
|
|
107
|
+
* axis is `view` with `defaultState` as the legacy alias; the retract target is the
|
|
108
|
+
* slice's declared initial (`initialView`, seeded at spawn) falling back to
|
|
109
|
+
* `"inline"` (the universal fallback). Returns true when the slice changed. This is
|
|
110
|
+
* the ONE home for the reset every boundary uses (new turn, guest close, template
|
|
111
|
+
* swap) — never re-hardcode `"inline"` at a call site.
|
|
112
|
+
*/
|
|
113
|
+
private retractSlice(key: string, slice: Record<string, unknown>): boolean {
|
|
114
|
+
const current = (slice.view ?? slice.defaultState) as unknown;
|
|
115
|
+
const base = typeof slice.initialView === "string" ? slice.initialView : "inline";
|
|
116
|
+
if (typeof current !== "string" || current === base) return false;
|
|
117
|
+
const next: Record<string, unknown> = { ...slice };
|
|
118
|
+
if ("view" in next || !("defaultState" in next)) next.view = base;
|
|
119
|
+
if ("defaultState" in next) next.defaultState = base;
|
|
120
|
+
this.data.set(key, next);
|
|
121
|
+
this.touches.set(key, ++this.touchSeq);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
105
125
|
// ============ writes ============
|
|
106
126
|
|
|
107
127
|
/** The current composer draft (a key in template state). */
|
|
@@ -131,12 +151,7 @@ export class ComponentStore {
|
|
|
131
151
|
// slices persist; only the active VIEW resets) — swapping back re-presents nothing
|
|
132
152
|
// until something surfaces again.
|
|
133
153
|
if (this.activeTemplateName !== null && this.activeTemplateName !== name) {
|
|
134
|
-
for (const [key, slice] of this.data)
|
|
135
|
-
if (typeof slice.defaultState === "string" && slice.defaultState !== "inline") {
|
|
136
|
-
this.data.set(key, { ...slice, defaultState: "inline" });
|
|
137
|
-
this.touches.set(key, ++this.touchSeq);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
154
|
+
for (const [key, slice] of this.data) this.retractSlice(key, slice);
|
|
140
155
|
}
|
|
141
156
|
this.activeTemplateName = name;
|
|
142
157
|
this.activeTemplateProps = props;
|
|
@@ -152,15 +167,40 @@ export class ComponentStore {
|
|
|
152
167
|
* calls this when a template-chrome `setValue defaultState:"inline"` fires (a card that
|
|
153
168
|
* closes ITSELF still uses the plain component write; template chrome has no card to hit).
|
|
154
169
|
*/
|
|
170
|
+
/**
|
|
171
|
+
* ARRIVALS REFRESH THEIR STATE (THE SEVEN RULES, 2026-08-09, rule 5): when a
|
|
172
|
+
* component ARRIVES into state X (a server placement — never a guest write),
|
|
173
|
+
* components from EARLIER turns sitting in X retract to inline. New cards
|
|
174
|
+
* replace the rail's old cards; a new wizard replaces the old wizard — latest
|
|
175
|
+
* wins, at every rung. Same-turn siblings coexist (one answer's card set is one
|
|
176
|
+
* delivery, so eviction is keyed by TURN, not by instance). Durable
|
|
177
|
+
* (`lifetime: "conversation"`) slices opt out, as with every retract. Whether a
|
|
178
|
+
* refresh is VISIBLE is the ladder's business alone (rule 4: higher states mask;
|
|
179
|
+
* lower states update silently underneath).
|
|
180
|
+
*/
|
|
181
|
+
private supersede(arrivingChatId: string, view: unknown): void {
|
|
182
|
+
if (typeof view !== "string" || !view || view === "inline") return;
|
|
183
|
+
let changed = false;
|
|
184
|
+
for (const [key, slice] of this.data) {
|
|
185
|
+
if (key.startsWith(`${arrivingChatId}:`)) continue; // same turn = same delivery
|
|
186
|
+
if (slice.lifetime === "conversation") continue;
|
|
187
|
+
const v = (slice.view ?? slice.defaultState) as unknown;
|
|
188
|
+
if (v !== view) continue;
|
|
189
|
+
const next: Record<string, unknown> = { ...slice };
|
|
190
|
+
if ("view" in next || !("defaultState" in next)) next.view = "inline";
|
|
191
|
+
if ("defaultState" in next) next.defaultState = "inline";
|
|
192
|
+
this.data.set(key, next);
|
|
193
|
+
this.touches.set(key, ++this.touchSeq);
|
|
194
|
+
changed = true;
|
|
195
|
+
}
|
|
196
|
+
if (changed) this.bump();
|
|
197
|
+
}
|
|
198
|
+
|
|
155
199
|
closeSurfaces(): void {
|
|
156
200
|
let changed = false;
|
|
157
201
|
for (const [key, slice] of this.data) {
|
|
158
202
|
if (slice.lifetime === "conversation") continue;
|
|
159
|
-
if (
|
|
160
|
-
this.data.set(key, { ...slice, defaultState: "inline" });
|
|
161
|
-
this.touches.set(key, ++this.touchSeq);
|
|
162
|
-
changed = true;
|
|
163
|
-
}
|
|
203
|
+
if (this.retractSlice(key, slice)) changed = true;
|
|
164
204
|
}
|
|
165
205
|
if (changed) this.bump();
|
|
166
206
|
}
|
|
@@ -212,6 +252,9 @@ export class ComponentStore {
|
|
|
212
252
|
this.types.set(key, event.component.type);
|
|
213
253
|
this.placeInTimeline(event.chatId, key); // structure: place the pointer ONCE
|
|
214
254
|
this.merge(key, event.component.props ?? {}); // data: seed
|
|
255
|
+
// Rule 5 — an arrival carrying a public state refreshes that state.
|
|
256
|
+
const seeded = event.component.props as Record<string, unknown> | undefined;
|
|
257
|
+
this.supersede(event.chatId, seeded?.view ?? seeded?.defaultState);
|
|
215
258
|
} else {
|
|
216
259
|
// NEW ACTIVITY reads newest-at-the-bottom: if this component is no longer the
|
|
217
260
|
// LAST pointer in its turn (something was placed after it), fresh server data
|
|
@@ -225,42 +268,26 @@ export class ComponentStore {
|
|
|
225
268
|
this.bump();
|
|
226
269
|
}
|
|
227
270
|
this.merge(key, event.data);
|
|
271
|
+
// Rule 5 — a server data-write that PLACES a component into a public state
|
|
272
|
+
// (the host's arrival placement rides COMPONENT_DATA) refreshes that state.
|
|
273
|
+
this.supersede(event.chatId, (event.data as Record<string, unknown> | undefined)?.view ?? (event.data as Record<string, unknown> | undefined)?.defaultState);
|
|
228
274
|
}
|
|
229
275
|
}
|
|
230
276
|
|
|
231
|
-
/** Append a user message turn. Response identity = its chatId.
|
|
277
|
+
/** Append a user message turn. Response identity = its chatId.
|
|
278
|
+
*
|
|
279
|
+
* RULE 7 (the seven rules, 2026-08-09): typing changes NOTHING by itself. The
|
|
280
|
+
* old new-turn reset (`beginExchange`, the "two lifetimes" chat-layer wipe) is
|
|
281
|
+
* GONE — only arrivals move the screen (rule 5's same-state refresh) and only
|
|
282
|
+
* closings release it (rule 6's ladder re-look). A wizard mid-flow survives the
|
|
283
|
+
* guest's next message; a stale rail is cleared by the cards that replace it,
|
|
284
|
+
* never by the message that asked for them. */
|
|
232
285
|
addUserMessage(chatId: string, text: string): void {
|
|
233
286
|
this.timeline.push({ role: "user", id: `u${this.seq++}`, chatId, text, createdAt: Date.now() });
|
|
234
|
-
this.beginExchange();
|
|
235
287
|
this.evict();
|
|
236
288
|
this.bump();
|
|
237
289
|
}
|
|
238
290
|
|
|
239
|
-
/**
|
|
240
|
-
* TWO LIFETIMES, ONE STORE (docs/design/04): CONVERSATION state (turns + each
|
|
241
|
-
* instance's data — durable, append-only, the stream owns it) vs CHAT state (the
|
|
242
|
-
* present interaction: each instance's active VIEW, the template's chrome). A new
|
|
243
|
-
* user turn advances the conversation, so the chat layer returns to its universal
|
|
244
|
-
* default: every instance goes back to `inline` — surfaces empty, the template
|
|
245
|
-
* derives its base state, panels retract. Data is untouched; history keeps every
|
|
246
|
-
* card. (A future durable, conversation-scoped component — a cart, an itinerary —
|
|
247
|
-
* will opt out of this explicitly; today nothing does.)
|
|
248
|
-
*/
|
|
249
|
-
private beginExchange(): void {
|
|
250
|
-
for (const [key, slice] of this.data) {
|
|
251
|
-
// DURABLE conversation-scoped surface (docs/design/04 §Two lifetimes): the
|
|
252
|
-
// producer marked the slice `lifetime: "conversation"` (a cart, an itinerary,
|
|
253
|
-
// a composed page) — it opted out of the new-turn reset and stays surfaced
|
|
254
|
-
// until replaced, closed, or the template swaps. Generic: a key the producer
|
|
255
|
-
// wrote, not a UI concept.
|
|
256
|
-
if (slice.lifetime === "conversation") continue;
|
|
257
|
-
if (typeof slice.defaultState === "string" && slice.defaultState !== "inline") {
|
|
258
|
-
this.data.set(key, { ...slice, defaultState: "inline" });
|
|
259
|
-
this.touches.set(key, ++this.touchSeq);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
291
|
/**
|
|
265
292
|
* Open (or re-open) the assistant response for chatId in streaming state, WITHOUT
|
|
266
293
|
* attaching a component. Mirrors legacy WORKFLOW_STARTED (processWorkflowState.ts):
|
|
@@ -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
|
+
}
|
|
@@ -29,7 +29,15 @@ export interface UnoverseNode {
|
|
|
29
29
|
* own params, drawn from the data already in scope right here. The SDK only produces
|
|
30
30
|
* the event; where it goes is the channel's business. */
|
|
31
31
|
analytics?: AnalyticsSpec;
|
|
32
|
-
placeholder?: string; // Input: empty-field copy (content, not style)
|
|
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[];
|
|
33
41
|
/** Box: keep the scroll pinned to the newest content — smooth-scroll to the end when
|
|
34
42
|
* content grows (new turn or streaming text), unless the user has scrolled up. Generic
|
|
35
43
|
* scroll-container behavior, opt-in via data (e.g. the conversation state's scroll box). */
|
|
@@ -117,16 +125,21 @@ export interface UnoverseDefinition {
|
|
|
117
125
|
* microapp has an EMPTY (or absent) props block. */
|
|
118
126
|
props?: Record<string, unknown>;
|
|
119
127
|
/** INTERNAL state + initial values — the keys the component's own actions write
|
|
120
|
-
* (step, phase, its
|
|
121
|
-
* 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. */
|
|
122
131
|
state?: Record<string, unknown>;
|
|
123
132
|
root: UnoverseNode;
|
|
124
|
-
/** TEMPLATE LAYOUTS (
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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`. */
|
|
128
137
|
layouts?: Record<string, UnoverseNode>;
|
|
129
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[];
|
|
130
143
|
}
|
|
131
144
|
|
|
132
145
|
// ---- The interaction vocabulary (UNOVERSE_SPEC.md §2e-3) ----
|
|
@@ -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
|
+
};
|