@unoverse-platform/studio 0.1.26 → 0.1.28
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 +51 -21
- package/screens/states.ts +27 -4
- package/vendor/sdk/appEngine.tsx +20 -4
- package/vendor/sdk/lib/UnoverseComponent.tsx +8 -2
- package/vendor/sdk/lib/core/actions.ts +10 -6
- package/vendor/sdk/lib/core/client.ts +39 -12
- package/vendor/sdk/lib/core/index.ts +21 -0
- package/vendor/sdk/lib/core/refs.ts +117 -0
- package/vendor/sdk/lib/core/store.ts +65 -38
- package/vendor/sdk/lib/core/templates.ts +270 -0
- package/vendor/sdk/lib/core/types.ts +27 -7
- package/vendor/sdk/lib/orb/ThinkingOrb.tsx +131 -0
- package/vendor/sdk/lib/orb/engine/braid.ts +50 -0
- package/vendor/sdk/lib/orb/engine/core.ts +167 -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 +31 -0
- package/vendor/sdk/lib/orb/engine/ribbon.ts +93 -0
- package/vendor/sdk/lib/orb/engine/rings.ts +239 -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 +181 -0
- package/vendor/sdk/lib/orb/theme.ts +83 -0
- package/vendor/sdk/lib/orb/types.ts +84 -0
- package/vendor/sdk/lib/primitives.tsx +116 -2
- package/vendor/sdk/lib/render.tsx +32 -8
- package/vendor/sdk/lib/style.ts +6 -2
- package/vendor/sdk/lib/template.tsx +86 -224
- 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,270 @@
|
|
|
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
|
+
const defs = def?.defs as Record<string, unknown> | undefined;
|
|
40
|
+
if (def?.layouts) for (const l of Object.values(def.layouts)) collectSurfacedViews(l, legacy, defs);
|
|
41
|
+
else if (def?.root) collectSurfacedViews(def.root, legacy, defs);
|
|
42
|
+
return [...legacy];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A width literal is passed through only when it plausibly IS a width (contains a
|
|
46
|
+
* digit — "320px", "40%", "calc(...)"). An unresolvable NAME returns undefined so
|
|
47
|
+
* it can never emit invalid CSS (or an invalid native constraint). */
|
|
48
|
+
export const cssWidth = (w: unknown): string | undefined => (typeof w === "string" && /\d/.test(w) ? w : undefined);
|
|
49
|
+
|
|
50
|
+
/** The author's declared prop defaults (def.props[k].default) → a flat data object. Content, not policy. */
|
|
51
|
+
export function propDefaults(props?: Record<string, unknown>): Record<string, unknown> {
|
|
52
|
+
const out: Record<string, unknown> = {};
|
|
53
|
+
for (const [k, v] of Object.entries(props ?? {})) {
|
|
54
|
+
if (v && typeof v === "object" && "default" in (v as Record<string, unknown>)) {
|
|
55
|
+
out[k] = (v as { default: unknown }).default;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Format a turn timestamp as relative copy ("just now" / "5 min ago"). Neutral
|
|
62
|
+
* projection of a model value (like `text`), not UX policy. Mirrors legacy
|
|
63
|
+
* ChatHistoryItem.formatRelativeTime. Empty string when unstamped. */
|
|
64
|
+
export function formatRelative(ts?: number): string {
|
|
65
|
+
if (!ts) return "";
|
|
66
|
+
const diff = Math.floor((Date.now() - ts) / 1000);
|
|
67
|
+
if (diff < 60) return "just now";
|
|
68
|
+
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
|
|
69
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)} hr ago`;
|
|
70
|
+
return new Date(ts).toLocaleDateString();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Walk a template tree and collect every VIEW a reaction surface claims — the `eq`
|
|
75
|
+
* value of each `ComponentSlot.select.where` on the public axis. One instance renders
|
|
76
|
+
* in exactly ONE placeholder (STATE_MODEL v2 §5 rule 6): a view a surface shows must
|
|
77
|
+
* not also render in the flow; a view nothing claims stays in the flow (the fallback).
|
|
78
|
+
* v2 note: with a declared `stateOrder` this is only the per-layout slot inventory;
|
|
79
|
+
* the CLAIM list itself is orderedClaims().
|
|
80
|
+
*/
|
|
81
|
+
export function collectSurfacedViews(node: unknown, out: Set<string> = new Set(), defs?: Record<string, unknown>): Set<string> {
|
|
82
|
+
if (Array.isArray(node)) {
|
|
83
|
+
for (const n of node) collectSurfacedViews(n, out, defs);
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
if (!node || typeof node !== "object") return out;
|
|
87
|
+
const n = node as Record<string, unknown>;
|
|
88
|
+
// A REFERENCED definition is a GRAPH, so a structural walk has to follow the edges. The
|
|
89
|
+
// chrome shared by four arrangements travels once, and the slots that claim views live
|
|
90
|
+
// INSIDE it — stopping at the reference would report that nothing claims anything, and
|
|
91
|
+
// every surfaced component would fall back into the flow.
|
|
92
|
+
if (n.type === "Ref" && typeof n.ref === "string") {
|
|
93
|
+
const piece = defs?.[n.ref];
|
|
94
|
+
if (piece) collectSurfacedViews(piece, out, defs);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
const sel = n.select as { where?: { field?: string; eq?: unknown } } | undefined;
|
|
98
|
+
if (n.type === "ComponentSlot" && isViewField(sel?.where?.field) && typeof sel?.where?.eq === "string") {
|
|
99
|
+
out.add(sel.where.eq);
|
|
100
|
+
}
|
|
101
|
+
for (const v of Object.values(n)) if (v && typeof v === "object") collectSurfacedViews(v, out, defs);
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* STATE-OWNED APP WIDTH (docs/design/05, layouts era): the app's outer width is
|
|
107
|
+
* declared by its LAYOUT's data, never the manifest. Two declaration points, both
|
|
108
|
+
* the neutral `appWidth` key (a named app size resolved via theme.appSize):
|
|
109
|
+
* - a plain node's `appWidth` = an always-open panel (the core column) — the
|
|
110
|
+
* layout's constant term.
|
|
111
|
+
* - a ComponentSlot surface's `appWidth` = its panel width, counted only while
|
|
112
|
+
* the surface is OCCUPIED (its view is the active state).
|
|
113
|
+
* The caller passes the ACTIVE layout (resolveActiveLayout) — layout swaps are
|
|
114
|
+
* whole-arrangement swaps, so the width is always the active layout's total.
|
|
115
|
+
* Activation is DERIVED, never stored. No declared width → undefined: the host
|
|
116
|
+
* keeps its own default, the sizing twin of "unmatched view falls back to inline".
|
|
117
|
+
* There is no other path.
|
|
118
|
+
*/
|
|
119
|
+
export function computeAppWidth(root: unknown, store: ComponentStore, appSize?: Record<string, string>, def?: UnoverseDefinition | null): string | undefined {
|
|
120
|
+
// Resolve a declared width: a NAMED SIZE via theme.appSize (like any token), else raw
|
|
121
|
+
// CSS — guarded so an unresolvable name can never emit invalid CSS.
|
|
122
|
+
const rw = (w: unknown): string | undefined => cssWidth(typeof w === "string" ? (appSize?.[w] ?? w) : undefined);
|
|
123
|
+
const r = root as Record<string, unknown> | null | undefined;
|
|
124
|
+
const widths: string[] = [];
|
|
125
|
+
// Gather the tree's width-bearing nodes: always-open core panels and the reaction
|
|
126
|
+
// SURFACES (view → panel width).
|
|
127
|
+
const surfaces = new Map<string, string | undefined>();
|
|
128
|
+
const walk = (node: unknown): void => {
|
|
129
|
+
if (Array.isArray(node)) {
|
|
130
|
+
for (const c of node) walk(c);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (!node || typeof node !== "object") return;
|
|
134
|
+
const n = node as Record<string, unknown> & { appWidth?: string; type?: string; ref?: string; select?: { where?: { field?: string; eq?: unknown } } };
|
|
135
|
+
// Follow references: the width-bearing panels of a referenced form live inside the
|
|
136
|
+
// shared pieces, so stopping here would size the app from the arrangement's skeleton
|
|
137
|
+
// alone (see collectSurfacedViews for the same reason, stated once).
|
|
138
|
+
if (n.type === "Ref" && typeof n.ref === "string") {
|
|
139
|
+
const piece = (def?.defs as Record<string, unknown> | undefined)?.[n.ref];
|
|
140
|
+
if (piece) walk(piece);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (n !== r) {
|
|
144
|
+
if (n.type === "ComponentSlot") {
|
|
145
|
+
const claim = n.select?.where;
|
|
146
|
+
// `appWidth: flex` — the surface takes the REMAINING host space. DATA WINS: an
|
|
147
|
+
// org may define `flex` as a real appSize token (bpp: calc(100% − chat)) and
|
|
148
|
+
// that resolves like any name; the marker below is the theme-free fallback,
|
|
149
|
+
// resolved as "fill the host" when the view is active.
|
|
150
|
+
if (isViewField(claim?.field) && typeof claim?.eq === "string")
|
|
151
|
+
surfaces.set(claim.eq, rw(n.appWidth) ?? (n.appWidth === "flex" ? "flex" : undefined));
|
|
152
|
+
} else if (n.appWidth !== undefined) {
|
|
153
|
+
const w = rw(n.appWidth);
|
|
154
|
+
if (w) widths.push(w); // a plain panel (the core column) is ALWAYS open
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const v of Object.values(n)) if (v && typeof v === "object") walk(v);
|
|
158
|
+
};
|
|
159
|
+
walk(r);
|
|
160
|
+
// ONE STATE AT A TIME: the app = core + the ACTIVE state's panel. The active state
|
|
161
|
+
// comes from the ONE shared derivation (resolveActiveView) — this function used to
|
|
162
|
+
// keep its own recency loop, and the two copies diverged once (blank panel); never
|
|
163
|
+
// re-introduce a private loop here. Pass `def` so the v2 ladder applies; without it
|
|
164
|
+
// the derivation runs in legacy mode over this tree's surface claims.
|
|
165
|
+
const active = resolveActiveView(def, store, new Set(surfaces.keys())).view;
|
|
166
|
+
const activePanel = active ? surfaces.get(active) : undefined;
|
|
167
|
+
// An ACTIVE flex surface fills the host viewport (the host caps at its pane); the
|
|
168
|
+
// surface flexes into whatever the fixed panels leave.
|
|
169
|
+
if (activePanel === "flex") return "100%";
|
|
170
|
+
if (activePanel) widths.push(activePanel);
|
|
171
|
+
if (!widths.length) return undefined;
|
|
172
|
+
return widths.length === 1 ? widths[0] : `calc(${widths.join(" + ")})`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* THE TEMPLATE'S STATE OWNS ITS LAYOUT (STATE_MODEL v2 §5 rules 1+4). The active
|
|
177
|
+
* layout resolves from the active STATE's declaration — `layouts` is keyed by state
|
|
178
|
+
* name (the same-name convention), falling back to `defaultLayout` when no state is
|
|
179
|
+
* active. A layout is never addressable on its own. Single-layout templates
|
|
180
|
+
* (`layouts` absent) resolve to `root`: the classic behavior, untouched.
|
|
181
|
+
*/
|
|
182
|
+
export function resolveActiveLayout(def: UnoverseDefinition | null | undefined, store: ComponentStore): UnoverseNode | undefined {
|
|
183
|
+
if (!def) return undefined;
|
|
184
|
+
const layouts = def.layouts;
|
|
185
|
+
if (!layouts) return def.root;
|
|
186
|
+
const { view } = resolveActiveView(def, store);
|
|
187
|
+
return layouts[view] ?? layouts[def.defaultLayout ?? "main"] ?? def.root;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* THE TEMPLATE'S ACTIVE STATE — the ONE derivation every consumer shares.
|
|
192
|
+
*
|
|
193
|
+
* v2 (STATE_MODEL §5 rule 5): with a declared `stateOrder` the template walks ITS OWN
|
|
194
|
+
* order top-down and enters the first of its states that ANY hosted component's view
|
|
195
|
+
* currently matches. Priority is list position, nothing else; ties within one state
|
|
196
|
+
* go to the most recent write (`limit: 1` law). Without a `stateOrder` (legacy defs)
|
|
197
|
+
* the v1 rule applies: latest surfaced view among `validViews` wins.
|
|
198
|
+
*
|
|
199
|
+
* PURE PRIORITY (final ruling, 2026-08-09): the ladder alone decides — no pinned
|
|
200
|
+
* flag, no guest-control override. Order the ladder by PROMINENCE (a state the
|
|
201
|
+
* guest taps into ranks above the state it came from: focus > detail > rail) and
|
|
202
|
+
* every interaction resolves by the same walk. Layout choice and surface gating
|
|
203
|
+
* MUST both read this function — two loops that each derive "the active state"
|
|
204
|
+
* disagree eventually, and the whole panel goes blank.
|
|
205
|
+
*/
|
|
206
|
+
export function resolveActiveView(
|
|
207
|
+
def: UnoverseDefinition | null | undefined,
|
|
208
|
+
store: ComponentStore,
|
|
209
|
+
validViews?: Set<string>,
|
|
210
|
+
): { view: string; byGuest: boolean } {
|
|
211
|
+
const order = def?.stateOrder?.length ? def.stateOrder : undefined;
|
|
212
|
+
const valid = order ? new Set(order) : (validViews ?? new Set(orderedClaims(def)));
|
|
213
|
+
// One pass over the timeline: the best (latest) match per view.
|
|
214
|
+
const best = new Map<string, { at: number; byGuest: boolean }>();
|
|
215
|
+
for (const turn of store.getResponses())
|
|
216
|
+
for (const p of turn.components) {
|
|
217
|
+
const { chatId, nodeId } = store.split(p);
|
|
218
|
+
const v = viewOf(store.get(chatId, nodeId) as Record<string, unknown>);
|
|
219
|
+
if (!v || !valid.has(v)) continue;
|
|
220
|
+
const t = store.touchOf(p);
|
|
221
|
+
const b = best.get(v);
|
|
222
|
+
if (!b || t > b.at) best.set(v, { at: t, byGuest: store.interactionTouchOf(p) >= t });
|
|
223
|
+
}
|
|
224
|
+
if (order) {
|
|
225
|
+
// THE LADDER: first declared state with any match wins. No word is special.
|
|
226
|
+
for (const name of order) {
|
|
227
|
+
const b = best.get(name);
|
|
228
|
+
if (b) return { view: name, byGuest: b.byGuest };
|
|
229
|
+
}
|
|
230
|
+
return { view: "", byGuest: false };
|
|
231
|
+
}
|
|
232
|
+
// Legacy recency: the latest matched view wins.
|
|
233
|
+
let view = "";
|
|
234
|
+
let byGuest = false;
|
|
235
|
+
let at = -1;
|
|
236
|
+
for (const [v, b] of best)
|
|
237
|
+
if (b.at > at) {
|
|
238
|
+
at = b.at;
|
|
239
|
+
view = v;
|
|
240
|
+
byGuest = b.byGuest;
|
|
241
|
+
}
|
|
242
|
+
return { view, byGuest };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Resolve a ComponentSlot's `select` against the store → ordered pointers. */
|
|
246
|
+
export function selectPointers(store: ComponentStore, node: UnoverseNode): string[] {
|
|
247
|
+
const sel = node.select ?? {};
|
|
248
|
+
let pointers =
|
|
249
|
+
sel.from === "all"
|
|
250
|
+
? store.getResponses().flatMap((r) => r.components)
|
|
251
|
+
: (store.latestResponse()?.components ?? []);
|
|
252
|
+
|
|
253
|
+
// `where` — select by COMPONENT STATE (the reaction contract, STATE_MODEL §5b): any
|
|
254
|
+
// component whose own slice matches, ordered newest state-write first so `limit: 1`
|
|
255
|
+
// = "most recent wins". The generic replacement for type-pinned surfaces — a
|
|
256
|
+
// template reacts to whatever component enters the state, never to a named type.
|
|
257
|
+
if (sel.where) {
|
|
258
|
+
const { field, eq, ne } = sel.where;
|
|
259
|
+
pointers = pointers.filter((p) => {
|
|
260
|
+
const { chatId, nodeId } = store.split(p);
|
|
261
|
+
const slice = store.get(chatId, nodeId) as Record<string, unknown>;
|
|
262
|
+
// The public axis reads through the alias (view ?? defaultState); other fields raw.
|
|
263
|
+
const v = isViewField(field) ? viewOf(slice) : slice[field];
|
|
264
|
+
return ne !== undefined ? v !== ne : v === eq;
|
|
265
|
+
});
|
|
266
|
+
pointers = [...pointers].sort((a, b) => store.touchOf(b) - store.touchOf(a));
|
|
267
|
+
}
|
|
268
|
+
if (sel.limit != null) pointers = pointers.slice(0, sel.limit);
|
|
269
|
+
return pointers;
|
|
270
|
+
}
|
|
@@ -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). */
|
|
@@ -113,20 +121,32 @@ export interface UnoverseDefinition {
|
|
|
113
121
|
* theme (`unoverse://theme/{org}/light`). Universal components carry no org. */
|
|
114
122
|
org?: string;
|
|
115
123
|
description?: string;
|
|
124
|
+
/**
|
|
125
|
+
* REFERENCE, DON'T COPY: the shared pieces this definition NAMES, by ref name, each
|
|
126
|
+
* shipped once instead of pasted at every use. Present only when the definition was
|
|
127
|
+
* read as `<uri>@refs`; the bare URI still serves fully inlined trees, so a renderer
|
|
128
|
+
* without `Ref` support is never handed one it cannot draw.
|
|
129
|
+
*/
|
|
130
|
+
defs?: Record<string, UnoverseNode>;
|
|
116
131
|
/** EXTERNAL contract — the rare workflow-fed inputs (`input: true`). A contained
|
|
117
132
|
* microapp has an EMPTY (or absent) props block. */
|
|
118
133
|
props?: Record<string, unknown>;
|
|
119
134
|
/** INTERNAL state + initial values — the keys the component's own actions write
|
|
120
|
-
* (step, phase, its
|
|
121
|
-
* merged into the render scope beneath
|
|
135
|
+
* (step, phase, its `view` on the public axis; `defaultState` is the legacy alias).
|
|
136
|
+
* Never external, never workbench controls; merged into the render scope beneath
|
|
137
|
+
* the live slice. */
|
|
122
138
|
state?: Record<string, unknown>;
|
|
123
139
|
root: UnoverseNode;
|
|
124
|
-
/** TEMPLATE LAYOUTS (
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
140
|
+
/** TEMPLATE LAYOUTS (STATE_MODEL v2 §5 rule 1): a state OWNS its layout, and this
|
|
141
|
+
* map is keyed by STATE NAME (the same-name convention). The active entry is the
|
|
142
|
+
* active state's (resolveActiveView → the stateOrder ladder), falling back to
|
|
143
|
+
* `defaultLayout`. Absent → classic single-layout behavior via `root`. */
|
|
128
144
|
layouts?: Record<string, UnoverseNode>;
|
|
129
145
|
defaultLayout?: string;
|
|
146
|
+
/** THE DECLARED STATE ORDER (STATE_MODEL v2 §5 rules 3+5) — the template's priority
|
|
147
|
+
* ladder and arrival-scan order, served from the manifest. First entry outranks
|
|
148
|
+
* all below it; no name is special. Absent → legacy v1 recency semantics. */
|
|
149
|
+
stateOrder?: string[];
|
|
130
150
|
}
|
|
131
151
|
|
|
132
152
|
// ---- The interaction vocabulary (UNOVERSE_SPEC.md §2e-3) ----
|
|
@@ -0,0 +1,131 @@
|
|
|
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
|
+
halo: 'Waiting…',
|
|
25
|
+
sonar: 'Listening…',
|
|
26
|
+
shimmer: 'Thinking…',
|
|
27
|
+
pulse: 'Responding…'
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function ThinkingOrb({
|
|
31
|
+
state = 'working',
|
|
32
|
+
size = 64,
|
|
33
|
+
theme = 'auto',
|
|
34
|
+
speed = 1,
|
|
35
|
+
paused = false,
|
|
36
|
+
ink = null,
|
|
37
|
+
style,
|
|
38
|
+
'aria-label': ariaLabel,
|
|
39
|
+
...rest
|
|
40
|
+
}: ThinkingOrbProps & { ink?: [number, number, number] | null }) {
|
|
41
|
+
const ref = useRef<HTMLCanvasElement | null>(null);
|
|
42
|
+
const dark = useResolvedDark(theme, ref);
|
|
43
|
+
const reduced = useReducedMotion();
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const canvas = ref.current;
|
|
47
|
+
if (!canvas) return;
|
|
48
|
+
const dpr = Math.min(2, (typeof devicePixelRatio !== 'undefined' && devicePixelRatio) || 1);
|
|
49
|
+
canvas.width = Math.round(size * dpr);
|
|
50
|
+
canvas.height = Math.round(size * dpr);
|
|
51
|
+
const ctx = canvas.getContext('2d');
|
|
52
|
+
if (!ctx) return;
|
|
53
|
+
|
|
54
|
+
// LOCAL EXTENSION (Unoverse): `size` is any positive number — the canvas and
|
|
55
|
+
// draw run at the true size (the engine's radiusScale compensates), while the
|
|
56
|
+
// TUNING snaps to the nearest of the two baked presets (20 inline / 64 avatar).
|
|
57
|
+
const preset: OrbSize = size <= 32 ? 20 : 64;
|
|
58
|
+
const { mode, speed: baseSpeed, opts } = resolvePreset(state, preset);
|
|
59
|
+
const draw = MODE_DRAWS[mode];
|
|
60
|
+
const effSpeed = baseSpeed * speed;
|
|
61
|
+
|
|
62
|
+
const frame = (tSec: number) => {
|
|
63
|
+
// Ink is a module-level current (frames are synchronous); set per frame so
|
|
64
|
+
// orbs with different brand tokens on one page never bleed into each other.
|
|
65
|
+
setInk(ink);
|
|
66
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
67
|
+
ctx.clearRect(0, 0, size, size);
|
|
68
|
+
draw(ctx, size, tSec, dark, opts);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// reduced motion → one static, deterministic frame
|
|
72
|
+
if (reduced) {
|
|
73
|
+
frame(0.6);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let raf = 0;
|
|
78
|
+
let running = false;
|
|
79
|
+
const loop = () => {
|
|
80
|
+
frame((performance.now() / 1000) * effSpeed);
|
|
81
|
+
if (running) raf = requestAnimationFrame(loop);
|
|
82
|
+
};
|
|
83
|
+
const start = () => {
|
|
84
|
+
if (running || paused) return;
|
|
85
|
+
running = true;
|
|
86
|
+
raf = requestAnimationFrame(loop);
|
|
87
|
+
};
|
|
88
|
+
const stop = () => {
|
|
89
|
+
running = false;
|
|
90
|
+
cancelAnimationFrame(raf);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// draw at least one frame even when paused/offscreen
|
|
94
|
+
frame((performance.now() / 1000) * effSpeed);
|
|
95
|
+
|
|
96
|
+
// pause offscreen + on hidden tabs — free when not visible
|
|
97
|
+
let visible = true;
|
|
98
|
+
const io =
|
|
99
|
+
typeof IntersectionObserver !== 'undefined'
|
|
100
|
+
? new IntersectionObserver(([entry]) => {
|
|
101
|
+
visible = entry.isIntersecting;
|
|
102
|
+
if (visible && document.visibilityState !== 'hidden') start();
|
|
103
|
+
else stop();
|
|
104
|
+
})
|
|
105
|
+
: null;
|
|
106
|
+
io?.observe(canvas);
|
|
107
|
+
const onVis = () => {
|
|
108
|
+
if (document.visibilityState === 'hidden') stop();
|
|
109
|
+
else if (visible) start();
|
|
110
|
+
};
|
|
111
|
+
document.addEventListener('visibilitychange', onVis);
|
|
112
|
+
if (!io) start();
|
|
113
|
+
|
|
114
|
+
return () => {
|
|
115
|
+
stop();
|
|
116
|
+
io?.disconnect();
|
|
117
|
+
document.removeEventListener('visibilitychange', onVis);
|
|
118
|
+
};
|
|
119
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- ink compared by value: a tuple is a fresh identity every render
|
|
120
|
+
}, [state, size, dark, speed, paused, reduced, ink?.join(",")]);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<canvas
|
|
124
|
+
ref={ref}
|
|
125
|
+
role="img"
|
|
126
|
+
aria-label={ariaLabel ?? LABELS[state]}
|
|
127
|
+
style={{ width: size, height: size, display: 'block', ...style }}
|
|
128
|
+
{...rest}
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Shared primitives for the dotted 3D thought-orbs. Ported from inkform
|
|
2
|
+
// (PlotterLab's HalftoneSphere lineage): honestly 3D — rotated,
|
|
3
|
+
// depth-shaded, z-sorted. Depth is carried by dot size and ink weight
|
|
4
|
+
// alone. Plain 2D canvas fills only: no ctx.filter, no SVG filters, so
|
|
5
|
+
// every mode renders identically in Chrome, Safari and Firefox.
|
|
6
|
+
|
|
7
|
+
export interface Dot {
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
z: number;
|
|
11
|
+
r: number;
|
|
12
|
+
/** Ink value: 0 = darkest ink on paper. Mirrored on dark themes. */
|
|
13
|
+
white: number;
|
|
14
|
+
a?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A stroked edge between two projected points (the `connecting` web). */
|
|
18
|
+
export interface Line {
|
|
19
|
+
x1: number;
|
|
20
|
+
y1: number;
|
|
21
|
+
x2: number;
|
|
22
|
+
y2: number;
|
|
23
|
+
/** Ink value, same convention as `Dot.white`. */
|
|
24
|
+
white: number;
|
|
25
|
+
a?: number;
|
|
26
|
+
w: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type Projector = (x: number, y: number, z: number) => [number, number, number];
|
|
30
|
+
|
|
31
|
+
export function lerp(a: number, b: number, f: number): number {
|
|
32
|
+
return a + (b - a) * f;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function frac(x: number): number {
|
|
36
|
+
return x - Math.floor(x);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Value noise on a 2D lattice — smooth, deterministic, cheap. */
|
|
40
|
+
export function vnoise(x: number, y: number): number {
|
|
41
|
+
const xi = Math.floor(x);
|
|
42
|
+
const yi = Math.floor(y);
|
|
43
|
+
let fx = x - xi;
|
|
44
|
+
let fy = y - yi;
|
|
45
|
+
fx = fx * fx * (3 - 2 * fx);
|
|
46
|
+
fy = fy * fy * (3 - 2 * fy);
|
|
47
|
+
const a = hashD(xi, yi);
|
|
48
|
+
const b = hashD(xi + 1, yi);
|
|
49
|
+
const c = hashD(xi, yi + 1);
|
|
50
|
+
const d = hashD(xi + 1, yi + 1);
|
|
51
|
+
return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Deterministic hash in [0, 1). */
|
|
55
|
+
export function hashD(a: number, b: number): number {
|
|
56
|
+
const h = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453;
|
|
57
|
+
return h - Math.floor(h);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Stable directions on a unit sphere (Fibonacci lattice). */
|
|
61
|
+
export function fibDir(i: number, n: number): [number, number, number] {
|
|
62
|
+
const golden = Math.PI * (3 - Math.sqrt(5));
|
|
63
|
+
const y = 1 - (2 * (i + 0.5)) / n;
|
|
64
|
+
const rad = Math.sqrt(1 - y * y);
|
|
65
|
+
const a = i * golden;
|
|
66
|
+
return [rad * Math.cos(a), y, rad * Math.sin(a)];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Shortest signed angular distance, wrapped to (-π, π]. */
|
|
70
|
+
export function angleDelta(a: number, b: number): number {
|
|
71
|
+
return Math.atan2(Math.sin(a - b), Math.cos(a - b));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Shared spin + tilt + orthographic projection. */
|
|
75
|
+
export function makeProj(yaw: number, tilt: number, cx: number, cy: number, scale: number): Projector {
|
|
76
|
+
const st = Math.sin(tilt);
|
|
77
|
+
const ct = Math.cos(tilt);
|
|
78
|
+
const sy = Math.sin(yaw);
|
|
79
|
+
const cyw = Math.cos(yaw);
|
|
80
|
+
return (x, y, z) => {
|
|
81
|
+
const x1 = x * cyw + z * sy;
|
|
82
|
+
const z1 = -x * sy + z * cyw;
|
|
83
|
+
const y1 = y * ct - z1 * st;
|
|
84
|
+
const z2 = y * st + z1 * ct;
|
|
85
|
+
return [cx + x1 * scale, cy - y1 * scale, z2];
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* LOCAL EXTENSION (Unoverse): an optional brand INK the painters tint toward.
|
|
91
|
+
* The upstream engine paints matte grayscale only; the design system's LAW 1
|
|
92
|
+
* says every colour is a served token, so the host resolves its `color` token
|
|
93
|
+
* and hands the rgb here before each frame. null = the upstream neutral ink.
|
|
94
|
+
* A module-level current-ink is safe because frames draw synchronously.
|
|
95
|
+
*/
|
|
96
|
+
let INK: [number, number, number] | null = null;
|
|
97
|
+
export function setInk(rgb: [number, number, number] | null): void {
|
|
98
|
+
INK = rgb;
|
|
99
|
+
}
|
|
100
|
+
/** Depth-shaded channel: neutral grayscale, or the ink mixed toward the substrate. */
|
|
101
|
+
function shade(w: number, dark: boolean): [number, number, number] {
|
|
102
|
+
if (!INK) {
|
|
103
|
+
const g = Math.round((dark ? 1 - w : w) * 255);
|
|
104
|
+
return [g, g, g];
|
|
105
|
+
}
|
|
106
|
+
// Light substrate: near dots (w→0) are pure ink, far dots fade to paper.
|
|
107
|
+
// Dark substrate: the ink is pre-lightened so it pops, far dots fade to black.
|
|
108
|
+
const [r, g, b] = INK;
|
|
109
|
+
const mixTo = dark ? 0 : 255;
|
|
110
|
+
const base: [number, number, number] = dark
|
|
111
|
+
? [r + (255 - r) * 0.35, g + (255 - g) * 0.35, b + (255 - b) * 0.35]
|
|
112
|
+
: [r, g, b];
|
|
113
|
+
return [0, 1, 2].map((i) => Math.round(base[i] + (mixTo - base[i]) * w)) as [number, number, number];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* LOCAL (Unoverse): the shaded ink as a CSS colour, for modes that STROKE rather than
|
|
118
|
+
* stipple (engine/rings). Same depth language as `paint` — `white` 0 is full ink, 1 is
|
|
119
|
+
* the substrate — so a stroked mode and a dotted one read as the same material.
|
|
120
|
+
*/
|
|
121
|
+
export function inkCss(white: number, dark: boolean, alpha: number): string {
|
|
122
|
+
const [r, g, b] = shade(Math.min(1, Math.max(0, white)), dark);
|
|
123
|
+
return `rgba(${r},${g},${b},${alpha})`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Painter: z-sort far→near, matte depth-shaded dots. On dark substrates the
|
|
128
|
+
* ink value is mirrored (1 - white) so near dots read bright — the same
|
|
129
|
+
* depth language on an inverted substrate.
|
|
130
|
+
*/
|
|
131
|
+
export function paint(ctx: CanvasRenderingContext2D, dots: Dot[], dark: boolean, rMin = 0.3): void {
|
|
132
|
+
dots.sort((a, b) => a.z - b.z);
|
|
133
|
+
for (const d of dots) {
|
|
134
|
+
const alpha = d.a ?? 1;
|
|
135
|
+
if (alpha < 0.02) continue;
|
|
136
|
+
const w = Math.min(1, Math.max(0, d.white));
|
|
137
|
+
const [r, g, b] = shade(w, dark);
|
|
138
|
+
ctx.fillStyle = `rgba(${r},${g},${b},${alpha})`;
|
|
139
|
+
ctx.beginPath();
|
|
140
|
+
ctx.arc(d.x, d.y, Math.max(rMin, d.r), 0, Math.PI * 2);
|
|
141
|
+
ctx.fill();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Stroke pass for edge-based modes. Runs before `paint` so nodes sit on top. */
|
|
146
|
+
export function paintLines(ctx: CanvasRenderingContext2D, lines: Line[], dark: boolean): void {
|
|
147
|
+
for (const l of lines) {
|
|
148
|
+
const alpha = l.a ?? 1;
|
|
149
|
+
if (alpha < 0.02) continue;
|
|
150
|
+
const w = Math.min(1, Math.max(0, l.white));
|
|
151
|
+
const [r, g, b] = shade(w, dark);
|
|
152
|
+
ctx.strokeStyle = `rgba(${r},${g},${b},${alpha})`;
|
|
153
|
+
ctx.lineWidth = l.w;
|
|
154
|
+
ctx.beginPath();
|
|
155
|
+
ctx.moveTo(l.x1, l.y1);
|
|
156
|
+
ctx.lineTo(l.x2, l.y2);
|
|
157
|
+
ctx.stroke();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Dot radii were tuned for a 300pt frame; sub-linear scaling keeps small
|
|
163
|
+
* spinners legible. Lower pow = radii shrink less with size.
|
|
164
|
+
*/
|
|
165
|
+
export function radiusScale(size: number, pow: number): number {
|
|
166
|
+
return (size / 300) ** pow;
|
|
167
|
+
}
|