@springbrand/message-panel 0.1.3-alpha.9 → 0.2.0-alpha.37

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 (67) hide show
  1. package/cloud-os/README.md +4 -3
  2. package/cloud-os/assets/followup-arrow.svg +3 -0
  3. package/cloud-os/assets/loading-corner.svg +3 -0
  4. package/cloud-os/assets/loading-mark.svg +16 -0
  5. package/cloud-os/assets/loading-spark.svg +3 -0
  6. package/cloud-os/assets/model-selected.svg +5 -0
  7. package/cloud-os/assets/thinking-star.svg +9 -0
  8. package/cloud-os/capability-chip.tsx +1 -1
  9. package/cloud-os/chat/cloud-os-chat-messages.tsx +441 -98
  10. package/cloud-os/chat/code-runner.tsx +106 -0
  11. package/cloud-os/chat/image-generation.tsx +76 -0
  12. package/cloud-os/chat/loading-state.tsx +25 -0
  13. package/cloud-os/chat/markdown-message.tsx +144 -41
  14. package/cloud-os/chat/range.ts +8 -0
  15. package/cloud-os/chat/rich-blocks.tsx +285 -227
  16. package/cloud-os/chat/surfaces.tsx +90 -0
  17. package/cloud-os/chat/tool-call.tsx +94 -0
  18. package/cloud-os/chat/tool-presentation.ts +31 -22
  19. package/cloud-os/chat/tool-rows.tsx +195 -143
  20. package/cloud-os/chat/tool-timeline.tsx +123 -0
  21. package/cloud-os/chat/transcript-model.ts +258 -20
  22. package/cloud-os/chat/web-search.tsx +135 -0
  23. package/cloud-os/composer/cloud-os-chat-input.tsx +29 -88
  24. package/cloud-os/composer/cloud-os-model-select.tsx +105 -0
  25. package/cloud-os/file-view.tsx +195 -12
  26. package/cloud-os/index.ts +24 -3
  27. package/cloud-os/layout/cloud-os-workspace-split.tsx +60 -3
  28. package/cloud-os/primitives/collapsible.tsx +21 -0
  29. package/cloud-os/primitives/workshop-controls.tsx +2 -2
  30. package/cloud-os/styles/cloud-os.css +132 -1
  31. package/demo/camel-chat-showcase.tsx +21 -13
  32. package/demo/chat-scenarios.ts +214 -40
  33. package/demo/cloud-os-chat-showcase.tsx +37 -14
  34. package/demo/fixtures.ts +4 -5
  35. package/demo/message-panel-gallery.tsx +16 -2
  36. package/package.json +8 -4
  37. package/springbrand-pet/LICENSE.bloub +21 -0
  38. package/springbrand-pet/bot/cycles.test.ts +221 -0
  39. package/springbrand-pet/bot/cycles.ts +225 -0
  40. package/springbrand-pet/bot/decor.ts +285 -0
  41. package/springbrand-pet/bot/engine.test.ts +543 -0
  42. package/springbrand-pet/bot/engine.ts +558 -0
  43. package/springbrand-pet/bot/expressions.test.ts +135 -0
  44. package/springbrand-pet/bot/expressions.ts +192 -0
  45. package/springbrand-pet/bot/eyefit.ts +455 -0
  46. package/springbrand-pet/bot/face.test.ts +101 -0
  47. package/springbrand-pet/bot/face.ts +179 -0
  48. package/springbrand-pet/bot/math.ts +42 -0
  49. package/springbrand-pet/bot/profiles.ts +22 -0
  50. package/springbrand-pet/bot/repere.ts +31 -0
  51. package/springbrand-pet/bot/shape.test.ts +97 -0
  52. package/springbrand-pet/bot/shape.ts +310 -0
  53. package/springbrand-pet/bot/skins.test.ts +325 -0
  54. package/springbrand-pet/bot/skins.ts +161 -0
  55. package/springbrand-pet/bot/states.ts +616 -0
  56. package/springbrand-pet/drag.test.ts +19 -0
  57. package/springbrand-pet/index.tsx +569 -0
  58. package/springbrand-pet/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  59. package/springbrand-pet/vitest.config.ts +9 -0
  60. package/src/camel/camel-chat-messages.tsx +78 -78
  61. package/src/camel/camel-tool-presentation.tsx +19 -15
  62. package/src/camel/camel-turn.ts +1 -17
  63. package/src/composer/composer-attachments.tsx +1 -1
  64. package/src/composer/composer.tsx +2 -2
  65. package/src/contracts.ts +0 -2
  66. package/src/message-panel.tsx +1 -24
  67. package/src/parts/index.tsx +103 -64
@@ -0,0 +1,569 @@
1
+ /**
2
+ * SpringBrand desktop pet for the message-panel package.
3
+ *
4
+ * The framework-free animation engine in ./bot is adapted from bloub by
5
+ * Jérémy Perret under the MIT License; see LICENSE.bloub. This file is the
6
+ * isolated React adapter and SpringBrand desktop-pet behavior.
7
+ */
8
+ import {
9
+ useCallback,
10
+ useEffect,
11
+ useId,
12
+ useMemo,
13
+ useRef,
14
+ useState,
15
+ type CSSProperties,
16
+ type PointerEvent as ReactPointerEvent,
17
+ } from "react";
18
+
19
+ import { NOTIF_BLUE } from "./bot/decor";
20
+ import { BotEngine, type BotFrame, type Look } from "./bot/engine";
21
+ import {
22
+ DEFAULT_EXPRESSION,
23
+ EXPRESSION_BY_ID,
24
+ type ExpressionId,
25
+ } from "./bot/expressions";
26
+ import { clamp, easings } from "./bot/math";
27
+ import { DEMI_VIEWBOX, RAYON } from "./bot/repere";
28
+ import {
29
+ SHAPE_BY_ID,
30
+ mixHex,
31
+ } from "./bot/skins";
32
+ import { STATE_BY_ID, type StateId } from "./bot/states";
33
+
34
+ type Interest = {
35
+ label: string;
36
+ symbol: string;
37
+ state: StateId;
38
+ };
39
+
40
+ const INTERESTS: readonly Interest[] = [
41
+ { label: "Observing", symbol: "◌", state: "idle" },
42
+ { label: "Thinking up an idea", symbol: "✦", state: "thinking" },
43
+ { label: "Just blinking", symbol: "⌁", state: "wink" },
44
+ { label: "Found something new", symbol: "?", state: "wide" },
45
+ { label: "Staying alert", symbol: "!", state: "alert" },
46
+ { label: "New message", symbol: "•", state: "notify" },
47
+ { label: "A sudden thought", symbol: "!", state: "exclaim" },
48
+ { label: "Taking a break", symbol: "z", state: "sleep" },
49
+ { label: "Hatching an idea", symbol: "○", state: "egg" },
50
+ { label: "Organizing things", symbol: "⬡", state: "hexagon" },
51
+ { label: "Making something fun", symbol: "▶", state: "play" },
52
+ { label: "Listening to music", symbol: "♪", state: "orbit" },
53
+ { label: "Burst of inspiration", symbol: "✺", state: "burst" },
54
+ { label: "Out exploring", symbol: "↗", state: "comet" },
55
+ { label: "Spinning around", symbol: "∞", state: "swirl" },
56
+ ] as const;
57
+
58
+ const EXPRESSIONS: readonly ExpressionId[] = [
59
+ "surpris",
60
+ "heureux",
61
+ "hilare",
62
+ "excite",
63
+ "fier",
64
+ "blase",
65
+ ] as const;
66
+
67
+ const PAPER = "#f9f9f9";
68
+ const YAW_MAX = 16;
69
+ const PITCH_MAX = 13;
70
+ const PITCH = 10;
71
+ const TURN = 26;
72
+ const SPIN = 360;
73
+ const TURN_TIME = 1.1;
74
+
75
+ function lookTarget({
76
+ nx,
77
+ ny,
78
+ tour,
79
+ pointer,
80
+ }: {
81
+ nx: number;
82
+ ny: number;
83
+ tour: number;
84
+ pointer: boolean;
85
+ }): Look {
86
+ return {
87
+ yaw: -TURN + nx * YAW_MAX,
88
+ pitch: PITCH - ny * PITCH_MAX,
89
+ mix: tour,
90
+ spin: SPIN * (1 - tour),
91
+ wander: pointer ? 0 : 1,
92
+ };
93
+ }
94
+
95
+ function chooseDifferent(current: number, length: number): number {
96
+ if (length < 2) return current;
97
+ return (current + 1 + Math.floor(Math.random() * (length - 1))) % length;
98
+ }
99
+
100
+ export function constrainPetDrag(
101
+ delta: { x: number; y: number },
102
+ rect: { left: number; top: number; right: number; bottom: number },
103
+ viewport: { width: number; height: number },
104
+ margin = 8,
105
+ ): { x: number; y: number } {
106
+ return {
107
+ x: clamp(delta.x, margin - rect.left, viewport.width - margin - rect.right),
108
+ y: clamp(delta.y, margin - rect.top, viewport.height - margin - rect.bottom),
109
+ };
110
+ }
111
+
112
+ const stateNames = [...STATE_BY_ID.keys()];
113
+ if (stateNames.length !== 15 || new Set(stateNames).size !== stateNames.length) {
114
+ throw new Error("SpringBrand pet requires the complete 15-state bloub catalogue");
115
+ }
116
+
117
+ export type SpringBrandPetProps = {
118
+ ariaLabel?: string;
119
+ className?: string;
120
+ size?: number;
121
+ style?: CSSProperties;
122
+ };
123
+
124
+ /**
125
+ * Desktop-only pet with the complete bloub state engine, a SpringBrand skin,
126
+ * pointer-following eyes, and randomized interests. Click to change interest.
127
+ */
128
+ export function SpringBrandPet({
129
+ ariaLabel = "SpringBrand pet",
130
+ className,
131
+ size = 144,
132
+ style,
133
+ }: SpringBrandPetProps) {
134
+ const rawId = useId();
135
+ const id = useMemo(() => rawId.replace(/:/g, ""), [rawId]);
136
+ const host = useRef<HTMLButtonElement | null>(null);
137
+ const pointer = useRef<{ x: number; y: number } | null>(null);
138
+ const aiming = useRef(false);
139
+ const turnSince = useRef(0);
140
+ const interestIndex = useRef(0);
141
+ const changedAt = useRef(0);
142
+ const engine = useRef<BotEngine | null>(null);
143
+ const drag = useRef<{
144
+ pointerId: number;
145
+ startX: number;
146
+ startY: number;
147
+ originX: number;
148
+ originY: number;
149
+ rect: { left: number; top: number; right: number; bottom: number };
150
+ } | null>(null);
151
+ const dragged = useRef(false);
152
+
153
+ if (!engine.current) {
154
+ engine.current = new BotEngine(
155
+ RAYON,
156
+ "idle",
157
+ SHAPE_BY_ID.get("springbrand")?.radii ?? null,
158
+ EXPRESSION_BY_ID.get(DEFAULT_EXPRESSION) ?? null,
159
+ );
160
+ }
161
+
162
+ const [interest, setInterest] = useState(INTERESTS[0]!);
163
+ const [view, setView] = useState<{ frame: BotFrame; now: number }>(() => ({
164
+ frame: engine.current!.sample(0),
165
+ now: 0,
166
+ }));
167
+ const [desktop, setDesktop] = useState(false);
168
+ const [reducedMotion, setReducedMotion] = useState(false);
169
+ const [offset, setOffset] = useState({ x: 0, y: 0 });
170
+
171
+ const renderAt = useCallback((now: number) => {
172
+ const currentEngine = engine.current!;
173
+ const definition = STATE_BY_ID.get(currentEngine.state);
174
+ const box = host.current?.getBoundingClientRect();
175
+
176
+ if (definition?.baseFace && box?.width && box.height) {
177
+ if (!aiming.current) turnSince.current = now;
178
+ const halfWidth = Math.max(1, window.innerWidth / 2);
179
+ const halfHeight = Math.max(1, window.innerHeight / 2);
180
+ currentEngine.setLook(
181
+ lookTarget({
182
+ nx: pointer.current
183
+ ? clamp(
184
+ (pointer.current.x - (box.left + box.width / 2)) / halfWidth,
185
+ -1,
186
+ 1,
187
+ )
188
+ : 0,
189
+ ny: pointer.current
190
+ ? clamp(
191
+ (pointer.current.y - (box.top + box.height / 2)) / halfHeight,
192
+ -1,
193
+ 1,
194
+ )
195
+ : 0,
196
+ tour: easings.easeOutQuint(clamp((now - turnSince.current) / TURN_TIME)),
197
+ pointer: pointer.current !== null,
198
+ }),
199
+ now,
200
+ );
201
+ aiming.current = true;
202
+ } else if (aiming.current) {
203
+ currentEngine.setLook(null, now, TURN_TIME);
204
+ aiming.current = false;
205
+ }
206
+
207
+ setView({ frame: currentEngine.sample(now), now });
208
+ }, []);
209
+
210
+ const changeInterest = useCallback(() => {
211
+ const next = chooseDifferent(interestIndex.current, INTERESTS.length);
212
+ const now = performance.now() / 1000;
213
+ const nextInterest = INTERESTS[next]!;
214
+ const nextExpression = EXPRESSIONS[Math.floor(Math.random() * EXPRESSIONS.length)]!;
215
+ interestIndex.current = next;
216
+ changedAt.current = now;
217
+ engine.current!.setExpression(EXPRESSION_BY_ID.get(nextExpression) ?? null, now);
218
+ engine.current!.setState(nextInterest.state, now);
219
+ setInterest(nextInterest);
220
+ renderAt(now);
221
+ }, [renderAt]);
222
+
223
+ useEffect(() => {
224
+ const desktopQuery = window.matchMedia("(min-width: 768px)");
225
+ const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
226
+ const sync = () => {
227
+ setDesktop(desktopQuery.matches);
228
+ setReducedMotion(motionQuery.matches);
229
+ };
230
+ sync();
231
+ desktopQuery.addEventListener("change", sync);
232
+ motionQuery.addEventListener("change", sync);
233
+ return () => {
234
+ desktopQuery.removeEventListener("change", sync);
235
+ motionQuery.removeEventListener("change", sync);
236
+ };
237
+ }, []);
238
+
239
+ useEffect(() => {
240
+ const onPointerMove = (event: PointerEvent) => {
241
+ if (event.pointerType === "touch") return;
242
+ pointer.current = { x: event.clientX, y: event.clientY };
243
+ if (reducedMotion) renderAt(performance.now() / 1000);
244
+ };
245
+ const onPointerLeave = () => {
246
+ pointer.current = null;
247
+ if (reducedMotion) renderAt(performance.now() / 1000);
248
+ };
249
+ window.addEventListener("pointermove", onPointerMove, { passive: true });
250
+ document.addEventListener("pointerleave", onPointerLeave);
251
+ return () => {
252
+ window.removeEventListener("pointermove", onPointerMove);
253
+ document.removeEventListener("pointerleave", onPointerLeave);
254
+ };
255
+ }, [reducedMotion, renderAt]);
256
+
257
+ useEffect(() => {
258
+ const now = performance.now() / 1000;
259
+ changedAt.current = now;
260
+ engine.current!.reset("idle", now);
261
+ renderAt(now);
262
+ if (reducedMotion) return;
263
+
264
+ let animationFrame = 0;
265
+ const tick = (milliseconds: number) => {
266
+ renderAt(milliseconds / 1000);
267
+ animationFrame = requestAnimationFrame(tick);
268
+ };
269
+ animationFrame = requestAnimationFrame(tick);
270
+ return () => cancelAnimationFrame(animationFrame);
271
+ }, [reducedMotion, renderAt]);
272
+
273
+ useEffect(() => {
274
+ if (reducedMotion) return;
275
+ const duration = STATE_BY_ID.get(interest.state)?.duration ?? 2;
276
+ const timer = window.setTimeout(
277
+ changeInterest,
278
+ duration * 1000 + 2_000 + Math.random() * 3_000,
279
+ );
280
+ return () => window.clearTimeout(timer);
281
+ }, [changeInterest, interest, reducedMotion]);
282
+
283
+ const bubbleAge = view.now - changedAt.current;
284
+ const bubbleOpacity = reducedMotion
285
+ ? 1
286
+ : clamp(bubbleAge / 0.2) * clamp((3.1 - bubbleAge) / 0.5);
287
+ const frame = view.frame;
288
+ const maskId = `${id}-mask`;
289
+ const bodyGradientId = `${id}-body`;
290
+
291
+ const beginDrag = (event: ReactPointerEvent<HTMLButtonElement>) => {
292
+ if (event.button !== 0) return;
293
+ const rect = event.currentTarget.getBoundingClientRect();
294
+ event.currentTarget.setPointerCapture(event.pointerId);
295
+ drag.current = {
296
+ pointerId: event.pointerId,
297
+ startX: event.clientX,
298
+ startY: event.clientY,
299
+ originX: offset.x,
300
+ originY: offset.y,
301
+ rect,
302
+ };
303
+ dragged.current = false;
304
+ };
305
+
306
+ const moveDrag = (event: ReactPointerEvent<HTMLButtonElement>) => {
307
+ const session = drag.current;
308
+ if (!session || session.pointerId !== event.pointerId) return;
309
+ const delta = {
310
+ x: event.clientX - session.startX,
311
+ y: event.clientY - session.startY,
312
+ };
313
+ if (!dragged.current && Math.hypot(delta.x, delta.y) < 4) return;
314
+ dragged.current = true;
315
+ const next = constrainPetDrag(delta, session.rect, {
316
+ width: window.innerWidth,
317
+ height: window.innerHeight,
318
+ });
319
+ setOffset({ x: session.originX + next.x, y: session.originY + next.y });
320
+ };
321
+
322
+ const endDrag = (event: ReactPointerEvent<HTMLButtonElement>) => {
323
+ if (drag.current?.pointerId !== event.pointerId) return;
324
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
325
+ event.currentTarget.releasePointerCapture(event.pointerId);
326
+ }
327
+ drag.current = null;
328
+ };
329
+
330
+ if (!desktop) return null;
331
+
332
+ const renderDots = (key: string) =>
333
+ frame.dots.map((dot, index) => {
334
+ const fill =
335
+ dot.color ??
336
+ (dot.depth === undefined
337
+ ? `url(#${bodyGradientId})`
338
+ : mixHex(PAPER, "#ff8b2c", dot.depth));
339
+ if (dot.d) {
340
+ return (
341
+ <path
342
+ key={`${key}-${index}`}
343
+ d={dot.d}
344
+ transform={`translate(${dot.x} ${dot.y}) rotate(${dot.rot ?? 0}) scale(${RAYON})`}
345
+ fill={fill}
346
+ opacity={dot.opacity}
347
+ />
348
+ );
349
+ }
350
+ return (
351
+ <circle
352
+ key={`${key}-${index}`}
353
+ cx={dot.x}
354
+ cy={dot.y}
355
+ r={dot.r}
356
+ fill={fill}
357
+ opacity={dot.opacity}
358
+ />
359
+ );
360
+ });
361
+
362
+ return (
363
+ <button
364
+ ref={host}
365
+ type="button"
366
+ onClick={() => {
367
+ if (dragged.current) {
368
+ dragged.current = false;
369
+ return;
370
+ }
371
+ changeInterest();
372
+ }}
373
+ onPointerDown={beginDrag}
374
+ onPointerMove={moveDrag}
375
+ onPointerUp={endDrag}
376
+ onPointerCancel={endDrag}
377
+ aria-label={`${ariaLabel}: ${interest.label}. Drag to move or click to change interest.`}
378
+ className={className}
379
+ style={{
380
+ position: "fixed",
381
+ right: "max(20px, env(safe-area-inset-right))",
382
+ bottom: "max(18px, env(safe-area-inset-bottom))",
383
+ zIndex: 60,
384
+ width: size,
385
+ height: size,
386
+ margin: 0,
387
+ padding: 0,
388
+ border: 0,
389
+ borderRadius: "50%",
390
+ background: "transparent",
391
+ cursor: drag.current ? "grabbing" : "grab",
392
+ color: "inherit",
393
+ filter: "drop-shadow(0 16px 20px rgba(98, 47, 0, 0.22))",
394
+ WebkitTapHighlightColor: "transparent",
395
+ ...style,
396
+ transform: `translate3d(${offset.x}px, ${offset.y}px, 0)`,
397
+ touchAction: "none",
398
+ userSelect: "none",
399
+ }}
400
+ >
401
+ <span
402
+ aria-hidden="true"
403
+ style={{
404
+ position: "absolute",
405
+ right: size * 0.58,
406
+ bottom: size * 0.82,
407
+ display: "flex",
408
+ alignItems: "center",
409
+ gap: 6,
410
+ width: "max-content",
411
+ maxWidth: 150,
412
+ padding: "7px 10px",
413
+ border: "1px solid rgba(255, 153, 56, 0.25)",
414
+ borderRadius: 14,
415
+ background:
416
+ "color-mix(in srgb, var(--background, white) 88%, transparent)",
417
+ boxShadow: "0 8px 24px rgba(43, 25, 8, 0.12)",
418
+ color: "var(--foreground, #24180f)",
419
+ fontSize: 12,
420
+ lineHeight: "16px",
421
+ whiteSpace: "nowrap",
422
+ opacity: bubbleOpacity,
423
+ transform: `translateY(${(1 - bubbleOpacity) * 6}px)`,
424
+ pointerEvents: "none",
425
+ backdropFilter: "blur(10px)",
426
+ }}
427
+ >
428
+ <span style={{ color: "#ff7a00", fontWeight: 700 }}>
429
+ {interest.symbol}
430
+ </span>
431
+ {interest.label}
432
+ </span>
433
+
434
+ <svg
435
+ width={size}
436
+ height={size}
437
+ viewBox={`${-DEMI_VIEWBOX} ${-DEMI_VIEWBOX} ${DEMI_VIEWBOX * 2} ${DEMI_VIEWBOX * 2}`}
438
+ aria-hidden="true"
439
+ style={{ display: "block", overflow: "visible" }}
440
+ >
441
+ <defs>
442
+ <linearGradient
443
+ id={bodyGradientId}
444
+ x1={-RAYON}
445
+ y1={-RAYON}
446
+ x2={RAYON}
447
+ y2={RAYON}
448
+ gradientUnits="userSpaceOnUse"
449
+ >
450
+ <stop stopColor="#ffd078" />
451
+ <stop offset="0.56" stopColor="#ff9938" />
452
+ <stop offset="1" stopColor="#ff6b1a" />
453
+ </linearGradient>
454
+
455
+ <mask
456
+ id={maskId}
457
+ maskUnits="userSpaceOnUse"
458
+ x={-DEMI_VIEWBOX}
459
+ y={-DEMI_VIEWBOX}
460
+ width={DEMI_VIEWBOX * 2}
461
+ height={DEMI_VIEWBOX * 2}
462
+ >
463
+ <path d={frame.bodyPath} fill="white" />
464
+ {frame.eyes.map((eye, index) => (
465
+ <path
466
+ key={`eye-${index}`}
467
+ d={eye.d}
468
+ transform={eye.matrix}
469
+ opacity={eye.alpha}
470
+ fill="black"
471
+ />
472
+ ))}
473
+ {frame.notch && (
474
+ <circle
475
+ cx={frame.notch.x}
476
+ cy={frame.notch.y}
477
+ r={frame.notch.r}
478
+ fill="black"
479
+ />
480
+ )}
481
+ </mask>
482
+
483
+ {frame.arcs.map((arc) => (
484
+ <linearGradient
485
+ key={`gradient-${arc.id}`}
486
+ id={`${id}-${arc.id}`}
487
+ gradientUnits="userSpaceOnUse"
488
+ x1={arc.grad.x1}
489
+ y1={arc.grad.y1}
490
+ x2={arc.grad.x2}
491
+ y2={arc.grad.y2}
492
+ >
493
+ {arc.grad.stops.map((color, index) => (
494
+ <stop
495
+ key={`${arc.id}-stop-${index}`}
496
+ offset={index / (arc.grad.stops.length - 1)}
497
+ stopColor={color}
498
+ />
499
+ ))}
500
+ </linearGradient>
501
+ ))}
502
+ </defs>
503
+
504
+ <g fill="none" strokeLinecap="round">
505
+ {frame.arcs.map((arc) => (
506
+ <path
507
+ key={`back-${arc.id}`}
508
+ d={arc.back}
509
+ stroke={`url(#${id}-${arc.id})`}
510
+ strokeWidth={arc.width}
511
+ opacity={arc.opacity}
512
+ />
513
+ ))}
514
+ </g>
515
+
516
+ {frame.dotsBehind && <g>{renderDots("behind")}</g>}
517
+
518
+ <g opacity={frame.bodyAlpha}>
519
+ <path d={frame.bodyPath} fill="var(--background, #f9f9f9)" />
520
+ <g mask={`url(#${maskId})`}>
521
+ <rect
522
+ x={-DEMI_VIEWBOX}
523
+ y={-DEMI_VIEWBOX}
524
+ width={DEMI_VIEWBOX * 2}
525
+ height={DEMI_VIEWBOX * 2}
526
+ fill={`url(#${bodyGradientId})`}
527
+ />
528
+ <ellipse
529
+ cx={-34}
530
+ cy={-52}
531
+ rx={62}
532
+ ry={34}
533
+ fill="white"
534
+ opacity={0.13}
535
+ transform="rotate(-24)"
536
+ />
537
+ </g>
538
+ </g>
539
+
540
+ {!frame.dotsBehind && <g>{renderDots("front")}</g>}
541
+
542
+ {frame.notif && (
543
+ <circle
544
+ cx={frame.notif.x}
545
+ cy={frame.notif.y}
546
+ r={frame.notif.r}
547
+ fill={NOTIF_BLUE}
548
+ />
549
+ )}
550
+
551
+ <g fill="none" strokeLinecap="round">
552
+ {frame.arcs.map((arc) => (
553
+ <path
554
+ key={`front-${arc.id}`}
555
+ d={arc.front}
556
+ stroke={`url(#${id}-${arc.id})`}
557
+ strokeWidth={arc.width}
558
+ opacity={arc.opacity}
559
+ />
560
+ ))}
561
+ </g>
562
+ </svg>
563
+ </button>
564
+ );
565
+ }
566
+
567
+ export { EXPRESSIONS as SPRINGBRAND_PET_EXPRESSIONS } from "./bot/expressions";
568
+ export { SHAPES as SPRINGBRAND_PET_SHAPES } from "./bot/skins";
569
+ export { STATES as SPRINGBRAND_PET_STATES } from "./bot/states";
@@ -0,0 +1 @@
1
+ {"version":"4.1.10","results":[[":bot/cycles.test.ts",{"duration":44.62495899999999,"failed":false}],[":bot/skins.test.ts",{"duration":3091.3955,"failed":false}],[":bot/engine.test.ts",{"duration":77.19429199999999,"failed":false}],[":bot/shape.test.ts",{"duration":8.478082999999998,"failed":false}],[":bot/expressions.test.ts",{"duration":6.141874999999999,"failed":false}],[":bot/face.test.ts",{"duration":2.4124999999999943,"failed":false}],[":drag.test.ts",{"duration":1.4348750000000052,"failed":false}]]}
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ root: import.meta.dirname,
5
+ test: {
6
+ environment: "node",
7
+ include: ["bot/*.test.ts", "*.test.ts"],
8
+ },
9
+ });