@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.
Files changed (42) 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 +51 -21
  6. package/screens/states.ts +27 -4
  7. package/vendor/sdk/appEngine.tsx +20 -4
  8. package/vendor/sdk/lib/UnoverseComponent.tsx +8 -2
  9. package/vendor/sdk/lib/core/actions.ts +10 -6
  10. package/vendor/sdk/lib/core/client.ts +39 -12
  11. package/vendor/sdk/lib/core/index.ts +21 -0
  12. package/vendor/sdk/lib/core/refs.ts +117 -0
  13. package/vendor/sdk/lib/core/store.ts +65 -38
  14. package/vendor/sdk/lib/core/templates.ts +270 -0
  15. package/vendor/sdk/lib/core/types.ts +27 -7
  16. package/vendor/sdk/lib/orb/ThinkingOrb.tsx +131 -0
  17. package/vendor/sdk/lib/orb/engine/braid.ts +50 -0
  18. package/vendor/sdk/lib/orb/engine/core.ts +167 -0
  19. package/vendor/sdk/lib/orb/engine/lattice.ts +205 -0
  20. package/vendor/sdk/lib/orb/engine/morph.ts +138 -0
  21. package/vendor/sdk/lib/orb/engine/orbits.ts +80 -0
  22. package/vendor/sdk/lib/orb/engine/profiles.ts +162 -0
  23. package/vendor/sdk/lib/orb/engine/registry.ts +31 -0
  24. package/vendor/sdk/lib/orb/engine/ribbon.ts +93 -0
  25. package/vendor/sdk/lib/orb/engine/rings.ts +239 -0
  26. package/vendor/sdk/lib/orb/engine/types.ts +14 -0
  27. package/vendor/sdk/lib/orb/engine/web.ts +98 -0
  28. package/vendor/sdk/lib/orb/index.ts +20 -0
  29. package/vendor/sdk/lib/orb/presets.ts +181 -0
  30. package/vendor/sdk/lib/orb/theme.ts +83 -0
  31. package/vendor/sdk/lib/orb/types.ts +84 -0
  32. package/vendor/sdk/lib/primitives.tsx +116 -2
  33. package/vendor/sdk/lib/render.tsx +32 -8
  34. package/vendor/sdk/lib/style.ts +6 -2
  35. package/vendor/sdk/lib/template.tsx +86 -224
  36. package/vendor/skills/unoverse-create/SKILL.md +18 -18
  37. package/vendor/skills/unoverse-create/references/agent-skill.md +12 -12
  38. package/vendor/skills/unoverse-create/references/block.md +5 -5
  39. package/vendor/skills/unoverse-create/references/component.md +150 -75
  40. package/vendor/skills/unoverse-create/references/node.md +1 -1
  41. package/vendor/skills/unoverse-create/references/template.md +142 -82
  42. 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 defaultState signal). Never external, never workbench controls;
121
- * merged into the render scope beneath the live slice. */
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 (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`. */
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
+ }