@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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/screens/AtomsView.tsx +40 -2
  3. package/screens/ComponentsView.tsx +112 -21
  4. package/screens/NativeAppHost.tsx +8 -4
  5. package/screens/TemplatesView.tsx +50 -20
  6. package/screens/states.ts +27 -4
  7. package/vendor/sdk/appEngine.tsx +18 -2
  8. package/vendor/sdk/lib/core/actions.ts +10 -6
  9. package/vendor/sdk/lib/core/index.ts +18 -0
  10. package/vendor/sdk/lib/core/store.ts +65 -38
  11. package/vendor/sdk/lib/core/templates.ts +252 -0
  12. package/vendor/sdk/lib/core/types.ts +20 -7
  13. package/vendor/sdk/lib/orb/ThinkingOrb.tsx +127 -0
  14. package/vendor/sdk/lib/orb/engine/braid.ts +50 -0
  15. package/vendor/sdk/lib/orb/engine/core.ts +157 -0
  16. package/vendor/sdk/lib/orb/engine/lattice.ts +205 -0
  17. package/vendor/sdk/lib/orb/engine/morph.ts +138 -0
  18. package/vendor/sdk/lib/orb/engine/orbits.ts +80 -0
  19. package/vendor/sdk/lib/orb/engine/profiles.ts +162 -0
  20. package/vendor/sdk/lib/orb/engine/registry.ts +24 -0
  21. package/vendor/sdk/lib/orb/engine/ribbon.ts +93 -0
  22. package/vendor/sdk/lib/orb/engine/types.ts +14 -0
  23. package/vendor/sdk/lib/orb/engine/web.ts +98 -0
  24. package/vendor/sdk/lib/orb/index.ts +20 -0
  25. package/vendor/sdk/lib/orb/presets.ts +104 -0
  26. package/vendor/sdk/lib/orb/theme.ts +83 -0
  27. package/vendor/sdk/lib/orb/types.ts +73 -0
  28. package/vendor/sdk/lib/primitives.tsx +111 -2
  29. package/vendor/sdk/lib/render.tsx +9 -5
  30. package/vendor/sdk/lib/style.ts +6 -2
  31. package/vendor/sdk/lib/template.tsx +77 -218
  32. package/vendor/skills/unoverse-create/SKILL.md +18 -18
  33. package/vendor/skills/unoverse-create/references/agent-skill.md +12 -12
  34. package/vendor/skills/unoverse-create/references/block.md +5 -5
  35. package/vendor/skills/unoverse-create/references/component.md +150 -75
  36. package/vendor/skills/unoverse-create/references/node.md +1 -1
  37. package/vendor/skills/unoverse-create/references/template.md +142 -82
  38. 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 (typeof slice.defaultState === "string" && slice.defaultState !== "inline") {
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 defaultState signal). Never external, never workbench controls;
121
- * merged into the render scope beneath the live slice. */
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 (name-sync, docs/design/05): present only when the template
125
- * ships multiple layouts/ files. Each is a full arrangement; the active one is
126
- * the layout whose NAME matches the latest surfaced component view, falling back
127
- * to `defaultLayout`. Absent → classic single-layout behavior via `root`. */
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
+ };