@unoverse-platform/studio 0.1.25 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) 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/SourceView.tsx +1 -1
  6. package/screens/TemplatesView.tsx +52 -22
  7. package/screens/states.ts +27 -4
  8. package/src/PublishDialog.tsx +1 -1
  9. package/vendor/sdk/appEngine.tsx +44 -3
  10. package/vendor/sdk/lib/UnoverseComponent.tsx +1 -1
  11. package/vendor/sdk/lib/core/actions.ts +20 -6
  12. package/vendor/sdk/lib/core/analytics.ts +89 -0
  13. package/vendor/sdk/lib/core/client.ts +1 -1
  14. package/vendor/sdk/lib/core/index.ts +30 -0
  15. package/vendor/sdk/lib/core/store.ts +65 -38
  16. package/vendor/sdk/lib/core/templates.ts +252 -0
  17. package/vendor/sdk/lib/core/types.ts +40 -7
  18. package/vendor/sdk/lib/index.ts +9 -0
  19. package/vendor/sdk/lib/orb/ThinkingOrb.tsx +127 -0
  20. package/vendor/sdk/lib/orb/engine/braid.ts +50 -0
  21. package/vendor/sdk/lib/orb/engine/core.ts +157 -0
  22. package/vendor/sdk/lib/orb/engine/lattice.ts +205 -0
  23. package/vendor/sdk/lib/orb/engine/morph.ts +138 -0
  24. package/vendor/sdk/lib/orb/engine/orbits.ts +80 -0
  25. package/vendor/sdk/lib/orb/engine/profiles.ts +162 -0
  26. package/vendor/sdk/lib/orb/engine/registry.ts +24 -0
  27. package/vendor/sdk/lib/orb/engine/ribbon.ts +93 -0
  28. package/vendor/sdk/lib/orb/engine/types.ts +14 -0
  29. package/vendor/sdk/lib/orb/engine/web.ts +98 -0
  30. package/vendor/sdk/lib/orb/index.ts +20 -0
  31. package/vendor/sdk/lib/orb/presets.ts +104 -0
  32. package/vendor/sdk/lib/orb/theme.ts +83 -0
  33. package/vendor/sdk/lib/orb/types.ts +73 -0
  34. package/vendor/sdk/lib/primitives.tsx +114 -5
  35. package/vendor/sdk/lib/render.tsx +25 -5
  36. package/vendor/sdk/lib/style.ts +6 -2
  37. package/vendor/sdk/lib/template.tsx +77 -218
  38. package/vendor/sdk/main.tsx +7 -0
  39. package/vendor/skills/unoverse-create/SKILL.md +18 -18
  40. package/vendor/skills/unoverse-create/references/agent-skill.md +12 -12
  41. package/vendor/skills/unoverse-create/references/block.md +5 -5
  42. package/vendor/skills/unoverse-create/references/component.md +150 -75
  43. package/vendor/skills/unoverse-create/references/node.md +1 -1
  44. package/vendor/skills/unoverse-create/references/template.md +142 -82
  45. package/vendor/skills/unoverse-create/references/workflow.md +15 -15
@@ -0,0 +1,162 @@
1
+ // Density profiles + the multiplier machinery that scales them. The base
2
+ // rows are inkform's `fine` profiles; each shipped preset (state × size)
3
+ // applies count / radius multipliers on top, resolved once per mount.
4
+
5
+ export interface ModeOpts {
6
+ [key: string]: number | undefined;
7
+ }
8
+
9
+ // 2-D lattices (rings × dots-per-ring) come in pairs — each side takes
10
+ // √scale so the TOTAL dot count scales by `scale`; flat lists scale
11
+ // linearly. `iconD` sets the morph outline's sampling density.
12
+ const COUNT_PAIRS: ReadonlyArray<readonly [string, string]> = [
13
+ ['latRings', 'lonDensity'],
14
+ ['rings', 'lonDensity'],
15
+ ['lanes', 'segs']
16
+ ];
17
+ const COUNT_KEYS = ['orbitN', 'ghostN', 'nodeN', 'strandN', 'signals'] as const;
18
+ const ICON_DENSITY_KEYS = ['iconD'] as const;
19
+
20
+ // Every key that sets a dot's rendered radius — scaling all of them keeps
21
+ // a dot's near/far falloff intact while shrinking or growing the mark.
22
+ const RADIUS_KEYS = [
23
+ 'rBase',
24
+ 'rDepth',
25
+ 'rActive',
26
+ 'rDot',
27
+ 'ghostR',
28
+ 'partR',
29
+ 'partRDepth',
30
+ 'nodeR',
31
+ 'nodeRDepth'
32
+ ] as const;
33
+
34
+ export function scaleCounts(opts: ModeOpts, scale: number): ModeOpts {
35
+ const out: ModeOpts = { ...opts };
36
+ const done = new Set<string>();
37
+ const rt = Math.sqrt(scale);
38
+ for (const [a, b] of COUNT_PAIRS) {
39
+ const va = out[a];
40
+ const vb = out[b];
41
+ if (va != null && vb != null && !done.has(a) && !done.has(b)) {
42
+ out[a] = Math.max(2, Math.round(va * rt));
43
+ out[b] = Math.max(2, Math.round(vb * rt));
44
+ done.add(a);
45
+ done.add(b);
46
+ }
47
+ }
48
+ for (const k of COUNT_KEYS) {
49
+ const v = out[k];
50
+ // 0 means the mode opted out of that layer entirely (ring has no ghost
51
+ // sphere) — scaling must not resurrect it as a single stray dot
52
+ if (v != null && v !== 0 && !done.has(k)) out[k] = Math.max(1, Math.round(v * scale));
53
+ }
54
+ for (const k of ICON_DENSITY_KEYS) {
55
+ const v = out[k];
56
+ if (v != null) out[k] = Math.max(0.02, v * scale);
57
+ }
58
+ return out;
59
+ }
60
+
61
+ export function scaleRadii(opts: ModeOpts, scale: number): ModeOpts {
62
+ const out: ModeOpts = { ...opts };
63
+ for (const k of RADIUS_KEYS) {
64
+ const v = out[k];
65
+ if (v != null) out[k] = v * scale;
66
+ }
67
+ // remember the multiplier itself — spacing-derived radii (the morph
68
+ // outline) use it, since they aren't based on any single radius key
69
+ out.rSizeMul = (out.rSizeMul ?? 1) * scale;
70
+ return out;
71
+ }
72
+
73
+ /** Base (fine) profiles per mode, before preset multipliers. */
74
+ export const BASE_PROFILES: Record<string, ModeOpts> = {
75
+ globe: {
76
+ latRings: 17,
77
+ lonDensity: 44,
78
+ rBase: 0.6,
79
+ rDepth: 1.7,
80
+ rBoost: 1.0,
81
+ inkFar: 0.62,
82
+ inkSpan: 0.54,
83
+ rsPow: 0.6,
84
+ rMin: 0.3
85
+ },
86
+ orbits: {
87
+ orbitN: 12,
88
+ ghostN: 40,
89
+ ghostR: 0.9,
90
+ ghostA: 0.5,
91
+ particles: 3,
92
+ partR: 1.2,
93
+ partRDepth: 1.6,
94
+ rsPow: 0.6,
95
+ rMin: 0.3
96
+ },
97
+ rubik: {
98
+ latRings: 15,
99
+ lonDensity: 40,
100
+ moveCount: 14,
101
+ rBase: 0.6,
102
+ rDepth: 1.7,
103
+ rActive: 0.3,
104
+ inkFar: 0.62,
105
+ inkSpan: 0.54,
106
+ rsPow: 0.6,
107
+ rMin: 0.3
108
+ },
109
+ wave: {
110
+ rings: 15,
111
+ lonDensity: 40,
112
+ rBase: 0.6,
113
+ rDepth: 1.7,
114
+ rsPow: 0.6,
115
+ rMin: 0.3
116
+ },
117
+ web: {
118
+ nodeN: 30,
119
+ thr: 0.72,
120
+ signals: 5,
121
+ nodeR: 1.4,
122
+ nodeRDepth: 1.8,
123
+ lineW: 0.8,
124
+ rsPow: 0.6,
125
+ rMin: 0.3
126
+ },
127
+ braid: {
128
+ strandN: 52,
129
+ turns: 3.0,
130
+ ghostN: 150,
131
+ rBase: 1.2,
132
+ rDepth: 1.8,
133
+ rsPow: 0.6,
134
+ rMin: 0.3
135
+ },
136
+ ribbon: {
137
+ lanes: 5,
138
+ segs: 88,
139
+ ghostN: 150,
140
+ rBase: 1.1,
141
+ rDepth: 1.7,
142
+ rsPow: 0.6,
143
+ rMin: 0.3
144
+ },
145
+ // ring shares ribbon's painter; faceOn cancels the camera tilt and moves
146
+ // the undulation onto the radius, and there is no ghost sphere behind it
147
+ ring: {
148
+ lanes: 5,
149
+ segs: 88,
150
+ ghostN: 0,
151
+ faceOn: 1,
152
+ rBase: 1.1,
153
+ rDepth: 1.7,
154
+ rsPow: 0.6,
155
+ rMin: 0.3
156
+ },
157
+ morph: {
158
+ rDot: 0.021,
159
+ iconD: 1,
160
+ rMin: 0.25
161
+ }
162
+ };
@@ -0,0 +1,24 @@
1
+ // Mode key → frame painter. Kept separate from the presets so tree
2
+ // shaking can in principle drop unused modes in custom builds.
3
+
4
+ import type { ModeKey } from '../presets';
5
+ import type { ModeDraw } from './types';
6
+ import { drawBraid } from './braid';
7
+ import { drawGlobe, drawRubik, drawWave } from './lattice';
8
+ import { drawMorph } from './morph';
9
+ import { drawOrbits } from './orbits';
10
+ import { drawRibbon } from './ribbon';
11
+ import { drawWeb } from './web';
12
+
13
+ export const MODE_DRAWS: Record<ModeKey, ModeDraw> = {
14
+ orbits: drawOrbits,
15
+ globe: drawGlobe,
16
+ rubik: drawRubik,
17
+ wave: drawWave,
18
+ web: drawWeb,
19
+ braid: drawBraid,
20
+ ribbon: drawRibbon,
21
+ // ring shares ribbon's painter — the `faceOn` profile flag switches it
22
+ ring: drawRibbon,
23
+ morph: drawMorph
24
+ };
@@ -0,0 +1,93 @@
1
+ // Ribbon: an undulating sash of parallel strands rides a great circle —
2
+ // the "composing" state. The tuned preset freezes the 3D tumble
3
+ // (spin 0), leaving the traveling undulation on a fixed band.
4
+ //
5
+ // The same painter also drives "breathing" (ring), via the `faceOn` flag:
6
+ // a face-on circle whose radius — not its out-of-plane offset — undulates,
7
+ // so it reads as a ring slowly morphing rather than a sash in orbit.
8
+
9
+ import type { Dot, ModeDraw } from './types';
10
+ import { fibDir, makeProj, paint, radiusScale } from './core';
11
+
12
+ export const drawRibbon: ModeDraw = (ctx, size, t, dark, o) => {
13
+ const cx = size / 2;
14
+ const cy = size / 2;
15
+ const R = (size / 2) * 0.78;
16
+ // spin scales the 3D tumble; spin=0 freezes the band's orientation,
17
+ // leaving only the traveling undulation
18
+ const spin = o.spin ?? 1;
19
+ const camTilt = 0.3;
20
+ const pt = makeProj(t * 0.1 * spin, camTilt, cx, cy, 1);
21
+ const rs = radiusScale(size, o.rsPow ?? 0.6);
22
+
23
+ const dots: Dot[] = [];
24
+ const ghostN = o.ghostN ?? 150;
25
+ for (let i = 0; i < ghostN; i++) {
26
+ const d = fibDir(i, ghostN);
27
+ const [px, py, z] = pt(d[0] * R, d[1] * R, d[2] * R);
28
+ const depth = (z / R + 1) / 2;
29
+ dots.push({ x: px, y: py, z, r: 0.8 * rs, white: 0.78, a: 0.1 + 0.22 * depth });
30
+ }
31
+
32
+ // The band plane, precessing (frozen when spin=0). The projection squashes
33
+ // the band's great circle vertically by cos(ta + camTilt); face-on sets
34
+ // ta = -camTilt so that term is 1 and the band reads as a true circle
35
+ // rather than ribbon's tilted ellipse.
36
+ const ya = t * 0.24 * spin;
37
+ const ta = o.faceOn ? -camTilt : 0.55 + 0.3 * Math.sin(t * 0.18) * spin;
38
+ const ux = Math.cos(ya);
39
+ const uy = 0;
40
+ const uz = Math.sin(ya);
41
+ const vx = -uz * Math.sin(ta);
42
+ const vy = Math.cos(ta);
43
+ const vz = ux * Math.sin(ta);
44
+ // plane normal n = u × v
45
+ const nx = uy * vz - uz * vy;
46
+ const ny = uz * vx - ux * vz;
47
+ const nz = ux * vy - uy * vx;
48
+
49
+ // Radial lobes swell past R, so pull the base radius in by (most of) the
50
+ // wobble amplitude. The silhouette then stays inside the frame however far
51
+ // the deformation is pushed, while lobes keep getting deeper relative to
52
+ // the mean radius.
53
+ const wobAmp = 0.23 * (o.wobMul ?? 1);
54
+ const baseR = o.faceOn ? R / (1 + 0.85 * wobAmp) : R;
55
+
56
+ const baseLanes = o.lanes ?? 5;
57
+ const segs = o.segs ?? 88;
58
+ const lanes = Math.max(1, Math.round(baseLanes * (o.bandMul ?? 1)));
59
+ for (let w = 0; w < lanes; w++) {
60
+ const laneOff = (w - (lanes - 1) / 2) * 0.075;
61
+ const edge = Math.abs(w - (lanes - 1) / 2) / Math.max(1, (lanes - 1) / 2);
62
+ for (let k = 0; k < segs; k++) {
63
+ const a = (k / segs) * 2 * Math.PI;
64
+ // the undulation: two traveling waves along the band; wobMul
65
+ // scales the deformation — 0 is a clean band
66
+ const wob =
67
+ (0.16 * Math.sin(a * 3 - t * 1.7 + w * 0.22) + 0.07 * Math.sin(a * 5 + t * 1.1)) * (o.wobMul ?? 1);
68
+ // A normal-direction wobble is cancelled by the re-normalisation below:
69
+ // the point lands back on the sphere, so the silhouette is pinned at R
70
+ // and the deformation can only ever pull dots inward. Face-on instead
71
+ // modulates the in-plane RADIUS, so lobes genuinely swell outward and
72
+ // pinch inward. Ribbon keeps the original out-of-plane sash wobble.
73
+ const radial = o.faceOn ? 1 + wob : 1;
74
+ const off = o.faceOn ? laneOff : laneOff + wob;
75
+ const x = ux * Math.cos(a) + vx * Math.sin(a) + nx * off;
76
+ const y = uy * Math.cos(a) + vy * Math.sin(a) + ny * off;
77
+ const z = uz * Math.cos(a) + vz * Math.sin(a) + nz * off;
78
+ const l = Math.sqrt(x * x + y * y + z * z);
79
+ const rr = baseR * radial;
80
+ const [px, py, zr] = pt((x / l) * rr, (y / l) * rr, (z / l) * rr);
81
+ const depth = (zr / R + 1) / 2;
82
+ dots.push({
83
+ x: px,
84
+ y: py,
85
+ z: zr,
86
+ r: ((o.rBase ?? 1.1) + (o.rDepth ?? 1.7) * depth) * (1 - 0.25 * edge) * rs,
87
+ white: 0.52 - 0.44 * depth + 0.18 * edge,
88
+ a: 0.4 + 0.6 * depth
89
+ });
90
+ }
91
+ }
92
+ paint(ctx, dots, dark, o.rMin);
93
+ };
@@ -0,0 +1,14 @@
1
+ // Engine-level contracts shared by every mode implementation.
2
+
3
+ import type { ModeOpts } from './profiles';
4
+
5
+ export type { Dot, Line } from './core';
6
+
7
+ /** One frame painter: draws a mode into a 2D context at CSS-px `size`. */
8
+ export type ModeDraw = (
9
+ ctx: CanvasRenderingContext2D,
10
+ size: number,
11
+ t: number,
12
+ dark: boolean,
13
+ opts: ModeOpts
14
+ ) => void;
@@ -0,0 +1,98 @@
1
+ // Web: a constellation wires itself — the "connecting" state. Nodes drift
2
+ // on the sphere under slow value noise; any pair closer than `thr` grows an
3
+ // edge, and bright packets run along randomly re-picked node pairs.
4
+
5
+ import type { Dot, Line, ModeDraw } from './types';
6
+ import { fibDir, frac, hashD, lerp, makeProj, paint, paintLines, radiusScale, vnoise } from './core';
7
+
8
+ export const drawWeb: ModeDraw = (ctx, size, t, dark, o) => {
9
+ const cx = size / 2;
10
+ const cy = size / 2;
11
+ const R = (size / 2) * 0.8 * (o.spread ?? 1);
12
+ // note the projector carries the radius as its scale, so node vectors stay
13
+ // unit-length and distances below are in unit-sphere space
14
+ const pt = makeProj(t * 0.12, 0.32, cx, cy, R);
15
+ const rs = radiusScale(size, o.rsPow ?? 0.6);
16
+
17
+ const nodeN = o.nodeN ?? 30;
18
+ const thr = o.thr ?? 0.72;
19
+ const nodeR = o.nodeR ?? 1.4;
20
+ const nodeRDepth = o.nodeRDepth ?? 1.8;
21
+
22
+ // nodes: fib lattice + slow noise wander, renormalised to the surface
23
+ const nodes: Array<[number, number, number]> = [];
24
+ for (let i = 0; i < nodeN; i++) {
25
+ const d = fibDir(i, nodeN);
26
+ const x = d[0] + 0.3 * (vnoise(i * 0.31 + 9, t * 0.24) - 0.5) * 2;
27
+ const y = d[1] + 0.3 * (vnoise(i * 0.53 + 27, t * 0.21) - 0.5) * 2;
28
+ const z = d[2] + 0.3 * (vnoise(i * 0.77 + 55, t * 0.27) - 0.5) * 2;
29
+ const l = Math.sqrt(x * x + y * y + z * z);
30
+ nodes.push([x / l, y / l, z / l]);
31
+ }
32
+
33
+ const lines: Line[] = [];
34
+ const dots: Dot[] = [];
35
+
36
+ // edges between close neighbours, alpha by proximity + depth
37
+ for (let i = 0; i < nodeN; i++) {
38
+ for (let j = i + 1; j < nodeN; j++) {
39
+ const dx = nodes[i][0] - nodes[j][0];
40
+ const dy = nodes[i][1] - nodes[j][1];
41
+ const dz = nodes[i][2] - nodes[j][2];
42
+ const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
43
+ if (dist >= thr) continue;
44
+ const [x1, y1, z1] = pt(nodes[i][0], nodes[i][1], nodes[i][2]);
45
+ const [x2, y2, z2] = pt(nodes[j][0], nodes[j][1], nodes[j][2]);
46
+ const depth = ((z1 + z2) / 2 + 1) / 2;
47
+ lines.push({
48
+ x1,
49
+ y1,
50
+ x2,
51
+ y2,
52
+ white: 0.42,
53
+ a: (1 - dist / thr) * (0.3 + 0.55 * depth),
54
+ w: Math.max(0.6, (o.lineW ?? 0.8) * rs)
55
+ });
56
+ }
57
+ }
58
+
59
+ for (let i = 0; i < nodeN; i++) {
60
+ const [px, py, z] = pt(nodes[i][0], nodes[i][1], nodes[i][2]);
61
+ const depth = (z + 1) / 2;
62
+ const pulse = 1 + 0.25 * Math.sin(t * 1.4 + i * 2.7);
63
+ dots.push({
64
+ x: px,
65
+ y: py,
66
+ z,
67
+ r: (nodeR + nodeRDepth * depth) * pulse * rs,
68
+ white: 0.55 - 0.45 * depth
69
+ });
70
+ }
71
+
72
+ // signals: bright packets running between paired nodes
73
+ const signals = o.signals ?? 5;
74
+ for (let s = 0; s < signals; s++) {
75
+ const seg = Math.floor(t * 0.55 + s * 7.31);
76
+ const a = Math.floor(hashD(seg, s * 3.1 + 1.7) * nodeN);
77
+ const b = Math.floor(hashD(seg, s * 5.7 + 4.2) * nodeN);
78
+ if (a === b) continue;
79
+ const f = frac(t * 0.55 + s * 7.31);
80
+ const x = lerp(nodes[a][0], nodes[b][0], f);
81
+ const y = lerp(nodes[a][1], nodes[b][1], f);
82
+ const z = lerp(nodes[a][2], nodes[b][2], f);
83
+ const l = Math.max(1e-6, Math.sqrt(x * x + y * y + z * z));
84
+ const [px, py, zr] = pt(x / l, y / l, z / l);
85
+ const depth = (zr + 1) / 2;
86
+ dots.push({
87
+ x: px,
88
+ y: py,
89
+ z: zr,
90
+ r: (nodeR * 1.5 + nodeRDepth * depth) * rs,
91
+ white: 0.05,
92
+ a: 0.5 + 0.5 * depth
93
+ });
94
+ }
95
+
96
+ paintLines(ctx, lines, dark);
97
+ paint(ctx, dots, dark, o.rMin);
98
+ };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The Orb primitive's drawing engine — VENDORED from `thinking-orbs`
3
+ * (https://github.com/Jakubantalik/thinking-orbs, MIT © 2026 Jakub Antalik),
4
+ * with two LOCAL EXTENSIONS, each marked at its site: free numeric `size`
5
+ * (tuning snaps to the nearest baked preset — ThinkingOrb.tsx) and a brand
6
+ * `ink` the painters tint toward, fed from the served `color` token
7
+ * (engine/core.ts setInk). Nine hand-tuned "assistant is doing something" animations,
8
+ * drawn procedurally on a plain 2D canvas — no WebGL, no filters — which is
9
+ * what makes the spec portable: each native SDK (iOS/Compose/Flutter/RN)
10
+ * reimplements the same draw loops with its platform's own 2D canvas.
11
+ *
12
+ * This is the sanctioned shape for rich motion (the SkeletonView precedent):
13
+ * the SDK owns ONE generic draw behavior; every authoring decision — which
14
+ * state, what size, what speed — stays DATA in the definition (`type: Orb`).
15
+ * Keyframe animations (styles/semantic/keyframes) remain the tool for
16
+ * element motion; the Orb is for pictures computed per-frame, which keyframe
17
+ * interpolation cannot express (no per-frame math, no depth occlusion).
18
+ */
19
+ export { ThinkingOrb } from "./ThinkingOrb";
20
+ export type { ThinkingOrbProps, OrbState, OrbSize, OrbTheme } from "./types";
@@ -0,0 +1,104 @@
1
+ // The shipped tunings: nine states × two sizes, baked from the inkform
2
+ // mini-page tuning session. `count`/`size` are multipliers over the base
3
+ // fine profiles; `speed` multiplies the shared clock. Resolved once per
4
+ // (state, size) pair and cached — the render loop sees plain numbers.
5
+
6
+ import type { ModeOpts } from './engine/profiles';
7
+ import { BASE_PROFILES, scaleCounts, scaleRadii } from './engine/profiles';
8
+ import type { OrbSize, OrbState } from './types';
9
+
10
+ export type ModeKey =
11
+ | 'orbits'
12
+ | 'globe'
13
+ | 'rubik'
14
+ | 'wave'
15
+ | 'web'
16
+ | 'braid'
17
+ | 'ribbon'
18
+ | 'ring'
19
+ | 'morph';
20
+
21
+ export const STATE_TO_MODE: Record<OrbState, ModeKey> = {
22
+ working: 'orbits',
23
+ searching: 'globe',
24
+ solving: 'rubik',
25
+ listening: 'wave',
26
+ connecting: 'web',
27
+ weaving: 'braid',
28
+ composing: 'ribbon',
29
+ breathing: 'ring',
30
+ shaping: 'morph'
31
+ };
32
+
33
+ interface Preset {
34
+ speed: number;
35
+ count: number;
36
+ size: number;
37
+ /** Extra mode opts merged verbatim after scaling. */
38
+ extra?: ModeOpts;
39
+ }
40
+
41
+ const PRESETS: Record<ModeKey, Record<OrbSize, Preset>> = {
42
+ orbits: {
43
+ 64: { speed: 1.885, count: 1, size: 1 },
44
+ 20: { speed: 3.9, count: 0.238, size: 2.4 }
45
+ },
46
+ globe: {
47
+ 64: { speed: 2.015, count: 0.42, size: 1.15, extra: { scanMul: 4.08, dimBase: 0.45 } },
48
+ 20: { speed: 2.665, count: 0.105, size: 1.75, extra: { scanMul: 4.335, dimBase: 0.45 } }
49
+ },
50
+ rubik: {
51
+ 64: { speed: 1.82, count: 0.35, size: 1.05 },
52
+ 20: { speed: 1.95, count: 0.088, size: 1.9 }
53
+ },
54
+ wave: {
55
+ 64: { speed: 4.388, count: 0.341, size: 1 },
56
+ 20: { speed: 3.998, count: 0.105, size: 1.6 }
57
+ },
58
+ web: {
59
+ 64: { speed: 3.315, count: 1.35, size: 0.95 },
60
+ 20: { speed: 6.63, count: 0.25, size: 1.52 }
61
+ },
62
+ braid: {
63
+ 64: { speed: 1.625, count: 0.5, size: 1 },
64
+ 20: { speed: 2.75, count: 0.1125, size: 1.36 }
65
+ },
66
+ ribbon: {
67
+ 64: { speed: 2.34, count: 0.25, size: 0.85, extra: { spin: 0, bandMul: 3.9, wobMul: 1 } },
68
+ 20: { speed: 3.12, count: 0.051, size: 1.073, extra: { spin: 0, bandMul: 4.94, wobMul: 1 } }
69
+ },
70
+ ring: {
71
+ 64: { speed: 3.24, count: 0.25, size: 0.956, extra: { spin: 0, bandMul: 3.627, wobMul: 0.368 } },
72
+ 20: { speed: 3.78, count: 0.028, size: 1.622, extra: { spin: 0, bandMul: 3.968, wobMul: 0.565 } }
73
+ },
74
+ morph: {
75
+ 64: { speed: 2.405, count: 0.702, size: 0.395, extra: { spread: 1.45 } },
76
+ 20: { speed: 2.08, count: 0.53, size: 1.011, extra: { spread: 1.45 } }
77
+ }
78
+ };
79
+
80
+ export interface Resolved {
81
+ mode: ModeKey;
82
+ speed: number;
83
+ opts: ModeOpts;
84
+ }
85
+
86
+ const cache = new Map<string, Resolved>();
87
+
88
+ /** Resolve a (state, size) pair to its mode + fully-scaled draw options. */
89
+ export function resolvePreset(state: OrbState, size: OrbSize): Resolved {
90
+ const key = `${state}-${size}`;
91
+ const hit = cache.get(key);
92
+ if (hit) return hit;
93
+
94
+ const mode = STATE_TO_MODE[state];
95
+ const preset = PRESETS[mode][size];
96
+ let opts: ModeOpts = { ...BASE_PROFILES[mode] };
97
+ if (preset.count !== 1) opts = scaleCounts(opts, preset.count);
98
+ if (preset.size !== 1) opts = scaleRadii(opts, preset.size);
99
+ if (preset.extra) opts = { ...opts, ...preset.extra };
100
+
101
+ const resolved: Resolved = { mode, speed: preset.speed, opts };
102
+ cache.set(key, resolved);
103
+ return resolved;
104
+ }
@@ -0,0 +1,83 @@
1
+ // Theme resolution: explicit prop → ancestor data-theme/.dark|.light
2
+ // class (watched live) → prefers-color-scheme (subscribed live).
3
+ // SSR-safe: everything runs in effects; the pre-mount fallback is dark.
4
+
5
+ import type { RefObject } from 'react';
6
+ import { useEffect, useState } from 'react';
7
+ import type { OrbTheme } from './types';
8
+
9
+ function ancestorTheme(el: Element | null): boolean | null {
10
+ let node: Element | null = el;
11
+ while (node) {
12
+ const attr = node.getAttribute('data-theme');
13
+ if (attr === 'dark') return true;
14
+ if (attr === 'light') return false;
15
+ if (node.classList.contains('dark')) return true;
16
+ if (node.classList.contains('light')) return false;
17
+ node = node.parentElement;
18
+ }
19
+ return null;
20
+ }
21
+
22
+ function systemDark(): boolean {
23
+ return typeof matchMedia === 'undefined' || matchMedia('(prefers-color-scheme: dark)').matches;
24
+ }
25
+
26
+ /** Resolve the effective dark/light substrate for a mounted element. */
27
+ export function useResolvedDark(theme: OrbTheme, hostRef: RefObject<Element | null>): boolean {
28
+ const [dark, setDark] = useState(true);
29
+
30
+ useEffect(() => {
31
+ if (theme === 'dark') {
32
+ setDark(true);
33
+ return;
34
+ }
35
+ if (theme === 'light') {
36
+ setDark(false);
37
+ return;
38
+ }
39
+
40
+ const resolve = () => {
41
+ const fromTree = ancestorTheme(hostRef.current);
42
+ setDark(fromTree ?? systemDark());
43
+ };
44
+ resolve();
45
+
46
+ // live OS/browser theme switches
47
+ const mq = typeof matchMedia !== 'undefined' ? matchMedia('(prefers-color-scheme: dark)') : null;
48
+ const onMq = () => resolve();
49
+ mq?.addEventListener('change', onMq);
50
+
51
+ // live app-level toggles: watch class/data-theme flips on ancestors
52
+ let mo: MutationObserver | null = null;
53
+ if (typeof MutationObserver !== 'undefined' && hostRef.current) {
54
+ mo = new MutationObserver(resolve);
55
+ mo.observe(document.documentElement, {
56
+ attributes: true,
57
+ attributeFilter: ['class', 'data-theme'],
58
+ subtree: true
59
+ });
60
+ }
61
+
62
+ return () => {
63
+ mq?.removeEventListener('change', onMq);
64
+ mo?.disconnect();
65
+ };
66
+ }, [theme, hostRef]);
67
+
68
+ return dark;
69
+ }
70
+
71
+ /** Live `prefers-reduced-motion` — reduced users get a static frame. */
72
+ export function useReducedMotion(): boolean {
73
+ const [reduced, setReduced] = useState(false);
74
+ useEffect(() => {
75
+ if (typeof matchMedia === 'undefined') return;
76
+ const mq = matchMedia('(prefers-reduced-motion: reduce)');
77
+ setReduced(mq.matches);
78
+ const on = (e: MediaQueryListEvent) => setReduced(e.matches);
79
+ mq.addEventListener('change', on);
80
+ return () => mq.removeEventListener('change', on);
81
+ }, []);
82
+ return reduced;
83
+ }
@@ -0,0 +1,73 @@
1
+ import type { CSSProperties, CanvasHTMLAttributes } from 'react';
2
+
3
+ /**
4
+ * The nine shipped states — each a hand-tuned animation:
5
+ * - `working` — particles on tilted orbits
6
+ * - `searching` — a scan meridian sweeps a dotted globe
7
+ * - `solving` — bands scramble in quarter turns, then click back
8
+ * - `listening` — a waveform rolls through latitude rings
9
+ * - `connecting` — a constellation wires itself, packets running the edges
10
+ * - `weaving` — three strands plait around the sphere
11
+ * - `composing` — an undulating multi-band sash
12
+ * - `breathing` — a face-on ring slowly morphing
13
+ * - `shaping` — a dotted outline morphs circle → triangle → square
14
+ */
15
+ export type OrbState =
16
+ | 'working'
17
+ | 'searching'
18
+ | 'solving'
19
+ | 'listening'
20
+ | 'connecting'
21
+ | 'weaving'
22
+ | 'composing'
23
+ | 'breathing'
24
+ | 'shaping';
25
+
26
+ /**
27
+ * Rendered size in CSS pixels. Exactly two tuned presets ship:
28
+ * 64 (chat-avatar scale) and 20 (inline-text scale). Each size carries
29
+ * its own dot count, dot size and speed tuning — they are separate
30
+ * designs, not a scale factor.
31
+ */
32
+ export type OrbSize = 64 | 20;
33
+
34
+ /**
35
+ * Theme mode.
36
+ *
37
+ * - `auto` (default) resolves in three layers, live-updating on change:
38
+ * 1. a `data-theme="dark|light"` attribute or `dark`/`light` class on
39
+ * any ancestor (the Tailwind / shadcn convention), watched via
40
+ * `MutationObserver`;
41
+ * 2. otherwise `matchMedia('(prefers-color-scheme: dark)')`,
42
+ * subscribed for live OS/browser theme switches;
43
+ * 3. during SSR (no DOM) the first client render resolves the theme
44
+ * before anything is painted — the canvas is client-only.
45
+ * - `dark` / `light` pin the palette regardless of context.
46
+ *
47
+ * Dark renders light ink on the transparent canvas (for dark
48
+ * backgrounds); light renders dark ink (for light backgrounds).
49
+ */
50
+ export type OrbTheme = 'auto' | 'dark' | 'light';
51
+
52
+ /** Props for the ThinkingOrb React component. */
53
+ export interface ThinkingOrbProps extends Omit<CanvasHTMLAttributes<HTMLCanvasElement>, 'style'> {
54
+ /** Which animation to show. @default 'working' */
55
+ state?: OrbState;
56
+
57
+ /** Tuned size preset — 64 or 20 CSS px. @default 64 */
58
+ size?: OrbSize;
59
+
60
+ /** Theme mode; `auto` detects from the host project. @default 'auto' */
61
+ theme?: OrbTheme;
62
+
63
+ /**
64
+ * Animation speed multiplier on top of the preset's baked speed.
65
+ * @default 1
66
+ */
67
+ speed?: number;
68
+
69
+ /** Freeze the animation on the current frame. @default false */
70
+ paused?: boolean;
71
+
72
+ style?: CSSProperties;
73
+ }