@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,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,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RING MODES — a LOCAL Unoverse addition to the vendored thinking-orbs engine.
|
|
3
|
+
* Kept in its own file so an upstream update never conflicts with it.
|
|
4
|
+
*
|
|
5
|
+
* WHY THESE EXIST. Every vendored mode draws a full 3-D sphere, which is right when the
|
|
6
|
+
* orb IS the subject. Put a face in the middle and the sphere stops being the subject:
|
|
7
|
+
* you see the leftover rim of a globe fighting the portrait. These modes are ANNULAR BY
|
|
8
|
+
* CONSTRUCTION — every mark lives between `rIn` and `rOut`, so the centre is empty by
|
|
9
|
+
* design and the animation frames the face instead of colliding with it.
|
|
10
|
+
*
|
|
11
|
+
* THE MATERIAL IS LIGHT, NOT INK. An earlier version bent the engine's halftone language
|
|
12
|
+
* into a circle and produced a ring of beads: hard-edged, evenly spaced, mechanically
|
|
13
|
+
* distributed — a loading spinner, not a presence. The premium reference (Apple's
|
|
14
|
+
* Intelligence glow) is built by one technique: the same path stroked several times, each
|
|
15
|
+
* pass wider, blurrier and fainter than the last, so a crisp core sits inside a bloom of
|
|
16
|
+
* its own light. That is `glow()`. Everything else is which path, and how it moves.
|
|
17
|
+
*
|
|
18
|
+
* NOTHING MAY LOOK LIKE A SPINNER. A bright head chasing a tail around a circle is the
|
|
19
|
+
* single most dated shape in this space, so no mode owns a travelling OBJECT. Where a
|
|
20
|
+
* mode needs direction, the LIGHT moves through a still form (a conic gradient sweeping
|
|
21
|
+
* its luminance) rather than a mark moving along a path — the difference between a lamp
|
|
22
|
+
* behind a curtain and a torch on a track.
|
|
23
|
+
*
|
|
24
|
+
* House style otherwise: no perfect circles (radii warp under drifting low-frequency
|
|
25
|
+
* noise), no countable frequencies (rates are incommensurate, so patterns never repeat on
|
|
26
|
+
* a beat), and no pass at full strength — the ring reads as light around the subject,
|
|
27
|
+
* never as chrome on top of it.
|
|
28
|
+
*
|
|
29
|
+
* Blur is `shadowBlur`, deliberately: `ctx.filter` is unsupported in older Safari and the
|
|
30
|
+
* vendored engine avoids it on purpose, whereas a shadow at zero offset is a real blurred
|
|
31
|
+
* copy of the stroke and works everywhere the rest of this engine runs.
|
|
32
|
+
*/
|
|
33
|
+
import { inkCss, vnoise } from './core';
|
|
34
|
+
import type { ModeDraw } from './types';
|
|
35
|
+
import type { ModeOpts } from './profiles';
|
|
36
|
+
|
|
37
|
+
const TAU = Math.PI * 2;
|
|
38
|
+
|
|
39
|
+
/** The annulus a ring mode may draw in, plus a scale factor off the 200px reference. */
|
|
40
|
+
function band(size: number, o: ModeOpts) {
|
|
41
|
+
const c = size / 2;
|
|
42
|
+
const R = size / 2;
|
|
43
|
+
// Defaults clear a face occupying ~0.56 of the box (avatar-orb's 112px in 200px) and
|
|
44
|
+
// its shadow; `rIn`/`rOut` retune the band for any other face ratio.
|
|
45
|
+
const rIn = R * (o.rIn ?? 0.62);
|
|
46
|
+
const rOut = R * (o.rOut ?? 0.97);
|
|
47
|
+
return { cx: c, cy: c, rIn, rOut, rMid: (rIn + rOut) / 2, span: (rOut - rIn) / 2, s: size / 200 };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type Band = ReturnType<typeof band>;
|
|
51
|
+
/** How a pass paints at a given alpha — a flat ink, or a gradient that varies round the ring. */
|
|
52
|
+
type PaintAt = (alpha: number) => string | CanvasGradient;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* THE GLOW. One path, stroked four times: a wide, heavily blurred, nearly transparent
|
|
56
|
+
* bloom first, then progressively tighter and stronger passes, ending on a thin crisp
|
|
57
|
+
* core. Painted in that order so the core lands on top of its own halo.
|
|
58
|
+
*/
|
|
59
|
+
function glow(
|
|
60
|
+
ctx: CanvasRenderingContext2D,
|
|
61
|
+
path: () => void,
|
|
62
|
+
w: number,
|
|
63
|
+
alpha: number,
|
|
64
|
+
paintAt: PaintAt,
|
|
65
|
+
shadowAt: (alpha: number) => string,
|
|
66
|
+
): void {
|
|
67
|
+
const passes = [
|
|
68
|
+
{ lw: w * 3.4, blur: 26, a: alpha * 0.14 },
|
|
69
|
+
{ lw: w * 2.1, blur: 14, a: alpha * 0.26 },
|
|
70
|
+
{ lw: w * 1.25, blur: 6, a: alpha * 0.5 },
|
|
71
|
+
{ lw: w * 0.5, blur: 0, a: alpha },
|
|
72
|
+
];
|
|
73
|
+
ctx.lineCap = 'round';
|
|
74
|
+
ctx.lineJoin = 'round';
|
|
75
|
+
for (const p of passes) {
|
|
76
|
+
if (p.a < 0.004) continue;
|
|
77
|
+
ctx.save();
|
|
78
|
+
ctx.beginPath();
|
|
79
|
+
path();
|
|
80
|
+
ctx.lineWidth = Math.max(0.4, p.lw);
|
|
81
|
+
ctx.strokeStyle = paintAt(p.a);
|
|
82
|
+
if (p.blur) {
|
|
83
|
+
ctx.shadowBlur = p.blur * (w / 3);
|
|
84
|
+
// a shadow takes a colour, never a gradient — so it uses the flat ink
|
|
85
|
+
ctx.shadowColor = shadowAt(p.a);
|
|
86
|
+
}
|
|
87
|
+
ctx.stroke();
|
|
88
|
+
ctx.restore();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Flat ink of a given depth — the ordinary case. */
|
|
93
|
+
const flat = (white: number, dark: boolean): PaintAt => (a) => inkCss(white, dark, a);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A conic gradient whose luminance peaks once and falls away round the ring, so the ring
|
|
97
|
+
* is brighter on one side without anything travelling along it. `at` rotates the peak.
|
|
98
|
+
* Falls back to flat ink where `createConicGradient` is unavailable (older Safari), which
|
|
99
|
+
* costs the directionality and nothing else.
|
|
100
|
+
*/
|
|
101
|
+
function sweepPaint(ctx: CanvasRenderingContext2D, b: Band, white: number, dark: boolean, at: number, floor = 0.14): PaintAt {
|
|
102
|
+
const make = (ctx as unknown as { createConicGradient?: (a: number, x: number, y: number) => CanvasGradient })
|
|
103
|
+
.createConicGradient;
|
|
104
|
+
if (typeof make !== 'function') return flat(white, dark);
|
|
105
|
+
return (alpha) => {
|
|
106
|
+
const g = make.call(ctx, at, b.cx, b.cy);
|
|
107
|
+
const STOPS = 16;
|
|
108
|
+
for (let i = 0; i <= STOPS; i++) {
|
|
109
|
+
const f = i / STOPS;
|
|
110
|
+
// cos² window: full at the peak, easing to `floor` on the far side, seamless at the wrap
|
|
111
|
+
const env = ((Math.cos(f * TAU) + 1) / 2) ** 2;
|
|
112
|
+
g.addColorStop(f, inkCss(white, dark, alpha * (floor + (1 - floor) * env)));
|
|
113
|
+
}
|
|
114
|
+
return g;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A closed curve through `pts`, smoothed with quadratics through the midpoints. */
|
|
119
|
+
function closedCurve(ctx: CanvasRenderingContext2D, pts: Array<[number, number]>): void {
|
|
120
|
+
const n = pts.length;
|
|
121
|
+
const mx = (a: [number, number], b: [number, number]): [number, number] => [
|
|
122
|
+
(a[0] + b[0]) / 2,
|
|
123
|
+
(a[1] + b[1]) / 2,
|
|
124
|
+
];
|
|
125
|
+
const start = mx(pts[n - 1], pts[0]);
|
|
126
|
+
ctx.moveTo(start[0], start[1]);
|
|
127
|
+
for (let i = 0; i < n; i++) {
|
|
128
|
+
const p = pts[i];
|
|
129
|
+
const m = mx(p, pts[(i + 1) % n]);
|
|
130
|
+
ctx.quadraticCurveTo(p[0], p[1], m[0], m[1]);
|
|
131
|
+
}
|
|
132
|
+
ctx.closePath();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Sample a warped ring: radius = rMid + span · f(angle). */
|
|
136
|
+
function ringPts(b: Band, n: number, f: (a: number, i: number) => number): Array<[number, number]> {
|
|
137
|
+
const pts: Array<[number, number]> = [];
|
|
138
|
+
for (let i = 0; i < n; i++) {
|
|
139
|
+
const a = (i / n) * TAU;
|
|
140
|
+
const rr = b.rMid + b.span * f(a, i);
|
|
141
|
+
pts.push([b.cx + Math.cos(a) * rr, b.cy + Math.sin(a) * rr]);
|
|
142
|
+
}
|
|
143
|
+
return pts;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* `halo` — a soft aura, breathing. Two slow lobes drift against each other under a
|
|
148
|
+
* noise wander, so the form is never a circle and never repeats on a countable beat.
|
|
149
|
+
* Nothing arrives, nothing leaves: for an assistant at rest.
|
|
150
|
+
*/
|
|
151
|
+
export const drawHalo: ModeDraw = (ctx, size, t, dark, o) => {
|
|
152
|
+
const b = band(size, o);
|
|
153
|
+
const breathe = 0.5 + 0.5 * Math.sin(t * 0.5);
|
|
154
|
+
const pts = ringPts(b, Math.round(o.samples ?? 88), (a, i) => {
|
|
155
|
+
const lobes = Math.sin(a * 2 + t * 0.33) * 0.34 + Math.sin(a * 3 - t * 0.21) * 0.22;
|
|
156
|
+
const wander = (vnoise(i * 0.05, t * 0.16) - 0.5) * 0.9;
|
|
157
|
+
return 0.3 * lobes + 0.22 * wander + 0.16 * (breathe - 0.5);
|
|
158
|
+
});
|
|
159
|
+
const white = o.white ?? 0.26;
|
|
160
|
+
glow(ctx, () => closedCurve(ctx, pts), (o.w ?? 3) * b.s, 0.34 + 0.2 * breathe, flat(white, dark), flat(white, dark) as (a: number) => string);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `sonar` / `pulse` — concentric rings of light, travelling INWARD (`dir: -1`) or
|
|
165
|
+
* OUTWARD (`dir: +1`). One painter, two meanings, because they are the same gesture
|
|
166
|
+
* mirrored: rings arriving are sound reaching a listener, rings leaving are a voice
|
|
167
|
+
* carrying. Each swells and settles over its life so none ever pops into existence.
|
|
168
|
+
*/
|
|
169
|
+
export const drawRipple: ModeDraw = (ctx, size, t, dark, o) => {
|
|
170
|
+
const b = band(size, o);
|
|
171
|
+
const waves = Math.round(o.waves ?? 2);
|
|
172
|
+
const rate = o.rate ?? 0.2;
|
|
173
|
+
const out = (o.dir ?? -1) > 0;
|
|
174
|
+
const white = o.white ?? 0.32;
|
|
175
|
+
const ink = flat(white, dark);
|
|
176
|
+
// a still, very faint seat where the rings are born (outward) or land (inward)
|
|
177
|
+
const seat = out ? b.rIn + b.span * 0.15 : b.rMid + b.span * 0.55;
|
|
178
|
+
glow(ctx, () => ctx.arc(b.cx, b.cy, seat, 0, TAU), (o.w ?? 2.4) * b.s, 0.07, ink, ink as (a: number) => string);
|
|
179
|
+
for (let w = 0; w < waves; w++) {
|
|
180
|
+
const f = (((t * rate + w / waves) % 1) + 1) % 1; // 0 → 1 over a wave's life
|
|
181
|
+
const rr = out ? b.rIn + (b.rOut - b.rIn) * f : b.rOut - (b.rOut - b.rIn) * f;
|
|
182
|
+
const fade = Math.sin(Math.PI * f) ** 1.3; // eases in AND out
|
|
183
|
+
if (fade < 0.02) continue;
|
|
184
|
+
const pts = ringPts(b, Math.round(o.samples ?? 72), (a) => (rr - b.rMid) / b.span + Math.sin(a * 2 - t * 0.3) * 0.1);
|
|
185
|
+
glow(ctx, () => closedCurve(ctx, pts), (o.w ?? 2.6) * b.s, (o.peak ?? 0.3) * fade, ink, ink as (a: number) => string);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* `shimmer` — thinking. NOT a mark going round: the ring holds still (drifting only as
|
|
191
|
+
* every mode here drifts) and its LUMINANCE moves, two conic sweeps at incommensurate,
|
|
192
|
+
* counter-rotating rates passing through each other. Where they coincide the ring
|
|
193
|
+
* brightens for a moment and then the moment passes. Reads as something being considered,
|
|
194
|
+
* with no spinner anywhere in it.
|
|
195
|
+
*/
|
|
196
|
+
export const drawShimmer: ModeDraw = (ctx, size, t, dark, o) => {
|
|
197
|
+
const b = band(size, o);
|
|
198
|
+
const white = o.white ?? 0.2;
|
|
199
|
+
const ink = flat(white, dark) as (a: number) => string;
|
|
200
|
+
const rate = o.rate ?? 0.5;
|
|
201
|
+
const peak = o.peak ?? 0.46;
|
|
202
|
+
const samples = Math.round(o.samples ?? 84);
|
|
203
|
+
// THREE rings at different radii, each lit by its own sweep at its own rate. One ring
|
|
204
|
+
// varying in brightness is too quiet to read as activity; three at incommensurate rates
|
|
205
|
+
// drift through each other, so bright regions build and part without ever lining up
|
|
206
|
+
// twice the same way. Depth does the work a faster spin would otherwise be asked to do.
|
|
207
|
+
const layers = [
|
|
208
|
+
{ seat: 0, rate: rate, alpha: 1, w: 1 },
|
|
209
|
+
{ seat: -0.62, rate: -rate * 0.61, alpha: 0.62, w: 0.72 },
|
|
210
|
+
{ seat: 0.6, rate: rate * 0.37, alpha: 0.52, w: 0.66 },
|
|
211
|
+
];
|
|
212
|
+
for (const L of layers) {
|
|
213
|
+
const phase = t * L.rate;
|
|
214
|
+
const pts = ringPts(b, samples, (a, i) => {
|
|
215
|
+
// the ring SWELLS where its light is — luminance and form move together, which is
|
|
216
|
+
// what makes it read as one lit object rather than a texture over a circle
|
|
217
|
+
const lit = ((Math.cos(a - phase) + 1) / 2) ** 2;
|
|
218
|
+
const lobes = Math.sin(a * 2 + t * 0.19) * 0.26 + Math.sin(a * 3 - t * 0.13) * 0.16;
|
|
219
|
+
const wander = (vnoise(i * 0.05 + L.seat * 7, t * 0.1) - 0.5) * 0.6;
|
|
220
|
+
return L.seat * 0.42 + 0.2 * lobes + 0.16 * wander + 0.22 * lit;
|
|
221
|
+
});
|
|
222
|
+
glow(
|
|
223
|
+
ctx,
|
|
224
|
+
() => closedCurve(ctx, pts),
|
|
225
|
+
(o.w ?? 2.8) * b.s * L.w,
|
|
226
|
+
peak * L.alpha,
|
|
227
|
+
sweepPaint(ctx, b, white, dark, phase, 0.05),
|
|
228
|
+
ink,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/** Base profiles for the ring modes (same role as engine/profiles BASE_PROFILES). */
|
|
234
|
+
export const RING_PROFILES: Record<string, ModeOpts> = {
|
|
235
|
+
halo: { samples: 88, w: 3, white: 0.26 },
|
|
236
|
+
sonar: { samples: 72, waves: 2, rate: 0.2, dir: -1, w: 2.6, white: 0.32, peak: 0.3 },
|
|
237
|
+
pulse: { samples: 72, waves: 3, rate: 0.26, dir: 1, w: 2.6, white: 0.3, peak: 0.34 },
|
|
238
|
+
shimmer: { samples: 84, rate: 0.5, w: 2.8, white: 0.2, peak: 0.46 },
|
|
239
|
+
};
|
|
@@ -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,181 @@
|
|
|
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 { RING_PROFILES } from './engine/rings';
|
|
9
|
+
import type { OrbSize, OrbState } from './types';
|
|
10
|
+
|
|
11
|
+
export type ModeKey =
|
|
12
|
+
| 'orbits'
|
|
13
|
+
| 'globe'
|
|
14
|
+
| 'rubik'
|
|
15
|
+
| 'wave'
|
|
16
|
+
| 'web'
|
|
17
|
+
| 'braid'
|
|
18
|
+
| 'ribbon'
|
|
19
|
+
| 'ring'
|
|
20
|
+
| 'morph'
|
|
21
|
+
// LOCAL (Unoverse): annular modes for framing a face — engine/rings.ts
|
|
22
|
+
| 'halo'
|
|
23
|
+
| 'sonar'
|
|
24
|
+
| 'shimmer'
|
|
25
|
+
| 'pulse';
|
|
26
|
+
|
|
27
|
+
export const STATE_TO_MODE: Record<OrbState, ModeKey> = {
|
|
28
|
+
working: 'orbits',
|
|
29
|
+
searching: 'globe',
|
|
30
|
+
solving: 'rubik',
|
|
31
|
+
listening: 'wave',
|
|
32
|
+
connecting: 'web',
|
|
33
|
+
weaving: 'braid',
|
|
34
|
+
composing: 'ribbon',
|
|
35
|
+
breathing: 'ring',
|
|
36
|
+
shaping: 'morph',
|
|
37
|
+
// LOCAL (Unoverse): each annular state maps 1:1 to its own painter.
|
|
38
|
+
halo: 'halo',
|
|
39
|
+
sonar: 'sonar',
|
|
40
|
+
shimmer: 'shimmer',
|
|
41
|
+
pulse: 'pulse'
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* THE ENERGY STANDARD (local to Unoverse).
|
|
46
|
+
*
|
|
47
|
+
* `speed: 1` must mean ONE felt pace across the whole vocabulary, or a design decision
|
|
48
|
+
* taken against one state is wrong for the next — which is exactly what a shared knob on
|
|
49
|
+
* a design-system component promises. The unit is a VISUAL CYCLE: the time for a mode's
|
|
50
|
+
* dominant motion to come back round (a swell to travel, a wave to arrive, a head to lap).
|
|
51
|
+
*
|
|
52
|
+
* The annular modes are solved to it exactly (see their block below). The vendored sphere
|
|
53
|
+
* modes keep their author's relative tuning — hand-made per mode and better than anything
|
|
54
|
+
* re-derived blind — scaled by ONE factor, because that tuning was made for an orb that IS
|
|
55
|
+
* the subject at chat-avatar scale; behind a face, or beside content, it reads hot.
|
|
56
|
+
*/
|
|
57
|
+
const CYCLE_SECONDS = 4;
|
|
58
|
+
/**
|
|
59
|
+
* A state that is DOING something earns more energy than one at rest — waiting and
|
|
60
|
+
* listening are receptive and should sit still; thinking and responding are work in
|
|
61
|
+
* progress and read as stalled at the resting pace. Same standard, shorter cycle.
|
|
62
|
+
*/
|
|
63
|
+
const ACTIVE_CYCLE = 2.5;
|
|
64
|
+
const SPHERE_CALM = 0.45;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Solve a mode's baked speed from its OWN dominant rate, so one cycle lands on the
|
|
68
|
+
* standard. `turns` is what the rate is measured against — a full revolution for motion
|
|
69
|
+
* around the ring, or 1 for a mode whose rate is already "lives per unit t" (sonar).
|
|
70
|
+
* Retuning a rate constant in engine/rings only means updating the number passed here.
|
|
71
|
+
*/
|
|
72
|
+
const solve = (ratePerT: number, turns = 2 * Math.PI, cycle = CYCLE_SECONDS) => turns / (ratePerT * cycle);
|
|
73
|
+
/** Inline scale reads a touch quicker — a small mark needs more motion to register. */
|
|
74
|
+
const SMALL = 1.2;
|
|
75
|
+
|
|
76
|
+
interface Preset {
|
|
77
|
+
speed: number;
|
|
78
|
+
count: number;
|
|
79
|
+
size: number;
|
|
80
|
+
/** Extra mode opts merged verbatim after scaling. */
|
|
81
|
+
extra?: ModeOpts;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const PRESETS: Record<ModeKey, Record<OrbSize, Preset>> = {
|
|
85
|
+
orbits: {
|
|
86
|
+
64: { speed: 1.885, count: 1, size: 1 },
|
|
87
|
+
20: { speed: 3.9, count: 0.238, size: 2.4 }
|
|
88
|
+
},
|
|
89
|
+
globe: {
|
|
90
|
+
64: { speed: 2.015, count: 0.42, size: 1.15, extra: { scanMul: 4.08, dimBase: 0.45 } },
|
|
91
|
+
20: { speed: 2.665, count: 0.105, size: 1.75, extra: { scanMul: 4.335, dimBase: 0.45 } }
|
|
92
|
+
},
|
|
93
|
+
rubik: {
|
|
94
|
+
64: { speed: 1.82, count: 0.35, size: 1.05 },
|
|
95
|
+
20: { speed: 1.95, count: 0.088, size: 1.9 }
|
|
96
|
+
},
|
|
97
|
+
wave: {
|
|
98
|
+
64: { speed: 4.388, count: 0.341, size: 1 },
|
|
99
|
+
20: { speed: 3.998, count: 0.105, size: 1.6 }
|
|
100
|
+
},
|
|
101
|
+
web: {
|
|
102
|
+
64: { speed: 3.315, count: 1.35, size: 0.95 },
|
|
103
|
+
20: { speed: 6.63, count: 0.25, size: 1.52 }
|
|
104
|
+
},
|
|
105
|
+
braid: {
|
|
106
|
+
64: { speed: 1.625, count: 0.5, size: 1 },
|
|
107
|
+
20: { speed: 2.75, count: 0.1125, size: 1.36 }
|
|
108
|
+
},
|
|
109
|
+
ribbon: {
|
|
110
|
+
64: { speed: 2.34, count: 0.25, size: 0.85, extra: { spin: 0, bandMul: 3.9, wobMul: 1 } },
|
|
111
|
+
20: { speed: 3.12, count: 0.051, size: 1.073, extra: { spin: 0, bandMul: 4.94, wobMul: 1 } }
|
|
112
|
+
},
|
|
113
|
+
ring: {
|
|
114
|
+
64: { speed: 3.24, count: 0.25, size: 0.956, extra: { spin: 0, bandMul: 3.627, wobMul: 0.368 } },
|
|
115
|
+
20: { speed: 3.78, count: 0.028, size: 1.622, extra: { spin: 0, bandMul: 3.968, wobMul: 0.565 } }
|
|
116
|
+
},
|
|
117
|
+
morph: {
|
|
118
|
+
64: { speed: 2.405, count: 0.702, size: 0.395, extra: { spread: 1.45 } },
|
|
119
|
+
20: { speed: 2.08, count: 0.53, size: 1.011, extra: { spread: 1.45 } }
|
|
120
|
+
},
|
|
121
|
+
// LOCAL (Unoverse): the annular set, tuned to THE STANDARD above rather than by eye.
|
|
122
|
+
// Each speed is solved, not guessed, from that mode's own dominant rate so one visual
|
|
123
|
+
// cycle lands on CYCLE_SECONDS: halo breathes at 0.5, a sonar wave lives 1/0.2, a
|
|
124
|
+
// shimmer sweep laps at 0.5, a pulse wave lives 1/0.26. Change a rate constant in
|
|
125
|
+
// engine/rings and the only edit here is the number handed to solve(). Resting states
|
|
126
|
+
// (halo, sonar) use CYCLE_SECONDS; working ones (shimmer, pulse) use ACTIVE_CYCLE.
|
|
127
|
+
// At inline scale the band is a few px wide, so samples drop and strokes thin.
|
|
128
|
+
// `count`/`size` stay 1: scaleCounts/scaleRadii only know the vendored modes' key
|
|
129
|
+
// names, so a multiplier would silently do nothing to a ring mode's samples/w.
|
|
130
|
+
halo: {
|
|
131
|
+
64: { speed: solve(0.5), count: 1, size: 1 },
|
|
132
|
+
20: { speed: solve(0.5) * SMALL, count: 1, size: 1, extra: { rIn: 0.5, rOut: 0.98, samples: 56, w: 2.2 } }
|
|
133
|
+
},
|
|
134
|
+
sonar: {
|
|
135
|
+
// rate is "lives per unit t", not an angle — so it solves against 1 turn, not 2π
|
|
136
|
+
64: { speed: solve(0.2, 1), count: 1, size: 1 },
|
|
137
|
+
20: { speed: solve(0.2, 1) * SMALL, count: 1, size: 1, extra: { rIn: 0.44, rOut: 0.98, samples: 48, waves: 2, w: 2 } }
|
|
138
|
+
},
|
|
139
|
+
shimmer: {
|
|
140
|
+
64: { speed: solve(0.5, 2 * Math.PI, ACTIVE_CYCLE), count: 1, size: 1 },
|
|
141
|
+
20: {
|
|
142
|
+
speed: solve(0.5, 2 * Math.PI, ACTIVE_CYCLE) * SMALL,
|
|
143
|
+
count: 1,
|
|
144
|
+
size: 1,
|
|
145
|
+
extra: { rIn: 0.5, rOut: 0.98, samples: 56, w: 2.2 }
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
pulse: {
|
|
149
|
+
64: { speed: solve(0.26, 1, ACTIVE_CYCLE), count: 1, size: 1 },
|
|
150
|
+
20: { speed: solve(0.26, 1, ACTIVE_CYCLE) * SMALL, count: 1, size: 1, extra: { rIn: 0.44, rOut: 0.98, samples: 48, waves: 2, w: 2 } }
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export interface Resolved {
|
|
155
|
+
mode: ModeKey;
|
|
156
|
+
speed: number;
|
|
157
|
+
opts: ModeOpts;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const cache = new Map<string, Resolved>();
|
|
161
|
+
|
|
162
|
+
/** Resolve a (state, size) pair to its mode + fully-scaled draw options. */
|
|
163
|
+
export function resolvePreset(state: OrbState, size: OrbSize): Resolved {
|
|
164
|
+
const key = `${state}-${size}`;
|
|
165
|
+
const hit = cache.get(key);
|
|
166
|
+
if (hit) return hit;
|
|
167
|
+
|
|
168
|
+
const mode = STATE_TO_MODE[state];
|
|
169
|
+
const preset = PRESETS[mode][size];
|
|
170
|
+
let opts: ModeOpts = { ...(BASE_PROFILES[mode] ?? RING_PROFILES[mode]) };
|
|
171
|
+
if (preset.count !== 1) opts = scaleCounts(opts, preset.count);
|
|
172
|
+
if (preset.size !== 1) opts = scaleRadii(opts, preset.size);
|
|
173
|
+
if (preset.extra) opts = { ...opts, ...preset.extra };
|
|
174
|
+
|
|
175
|
+
// The sphere set is pulled into the ring set's energy band; the ring set is already
|
|
176
|
+
// solved to it, so it passes through untouched (see THE ENERGY STANDARD).
|
|
177
|
+
const speed = preset.speed * (RING_PROFILES[mode] ? 1 : SPHERE_CALM);
|
|
178
|
+
const resolved: Resolved = { mode, speed, opts };
|
|
179
|
+
cache.set(key, resolved);
|
|
180
|
+
return resolved;
|
|
181
|
+
}
|