@springbrand/message-panel 0.1.3-alpha.8 → 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 +38 -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,616 @@
1
+ import {
2
+ COMET_DOT,
3
+ COMET_RIBBONS,
4
+ DOT_PEAK,
5
+ DOT_R,
6
+ DOT_X,
7
+ NOTIF_ANGLE,
8
+ NOTIF_DIST,
9
+ NOTIF_MARGIN,
10
+ NOTIF_POP,
11
+ NOTIF_R,
12
+ RINGS,
13
+ SWOOSH,
14
+ particles,
15
+ type ArcSpec,
16
+ type DotRender
17
+ } from './decor'
18
+ import { EYE_H, EYE_SPLIT, EYE_W, REST_GAZE, type HeadGaze } from './face'
19
+ import { TAU, clamp, easings } from './math'
20
+ import {
21
+ circle,
22
+ hullOfCircles,
23
+ polyPath,
24
+ profileFromPolygon,
25
+ silhouette,
26
+ type Silhouette
27
+ } from './shape'
28
+
29
+ export interface EyeCfg {
30
+ /** largeur locale (axe court de la gelule), en unites de rayon de boule */
31
+ w: number
32
+ /** hauteur locale (axe long) */
33
+ h: number
34
+ /** 1 = ouvert, 0 = ferme */
35
+ open: number
36
+ /**
37
+ * Inclinaison propre de la gelule, en degres, positif = le haut part a
38
+ * droite. Appliquee APRES le repere tangent de la sphere. Sans elle, les deux
39
+ * yeux penchent forcement du meme cote (le roulis de tete) et la colere comme
40
+ * la tristesse, qui demandent des inclinaisons en miroir, sont hors de portee.
41
+ */
42
+ tilt?: number
43
+ }
44
+
45
+ export interface Pose {
46
+ /** silhouette du corps, en unites de rayon de boule */
47
+ sil: Silhouette
48
+ /** decalage global du corps ET des yeux */
49
+ offX: number
50
+ offY: number
51
+ gaze: HeadGaze
52
+ /** demi-ecart des yeux sur la sphere, en degres */
53
+ split: number
54
+ /** [oeil interieur, oeil exterieur] */
55
+ eyes: [EyeCfg, EyeCfg]
56
+ /** opacite des yeux : sert aux etats sans visage */
57
+ eyeAlpha: number
58
+ bodyAlpha: number
59
+ dots: DotRender[]
60
+ arcs: ArcSpec[]
61
+ notif: { x: number; y: number; r: number; notch: number } | null
62
+ /** true = le decor passe derriere le corps (particules de l'eclatement) */
63
+ dotsBehind: boolean
64
+ }
65
+
66
+ const pair = (w: number, h: number): [EyeCfg, EyeCfg] => [
67
+ { w, h, open: 1 },
68
+ { w, h, open: 1 }
69
+ ]
70
+
71
+ function base(over: Partial<Pose> = {}): Pose {
72
+ return {
73
+ sil: circle(1),
74
+ offX: 0,
75
+ offY: 0,
76
+ gaze: { ...REST_GAZE },
77
+ split: EYE_SPLIT,
78
+ eyes: pair(EYE_W, EYE_H),
79
+ eyeAlpha: 1,
80
+ bodyAlpha: 1,
81
+ dots: [],
82
+ arcs: [],
83
+ notif: null,
84
+ dotsBehind: false,
85
+ ...over
86
+ }
87
+ }
88
+
89
+ /* --------------------------------------------------- formes non radiales */
90
+
91
+ /**
92
+ * Barre du "!" vertical : enveloppe convexe de deux cercles.
93
+ * Mesure : cercle haut (0, -0.505) r 0.132, cercle bas (0, +0.130) r 0.075,
94
+ * flancs rectilignes. Elle est donc tronconique (rapport haut/bas 1.76).
95
+ */
96
+ const BAR_UPRIGHT_CY = -0.1875
97
+ const BAR_UPRIGHT = profileFromPolygon(
98
+ hullOfCircles(0, -0.505, 0.132, 0, 0.13, 0.075),
99
+ 0,
100
+ BAR_UPRIGHT_CY
101
+ )
102
+
103
+ /** Barre du "!" penche : capsule pure (largeur constante 0.269, longueur 0.776). */
104
+ const BAR_ITALIC = profileFromPolygon(hullOfCircles(0, -0.2535, 0.1345, 0, 0.2535, 0.1345), 0, 0)
105
+
106
+ const barUpright = (pose: Partial<Silhouette> = {}): Silhouette => ({
107
+ radii: [...BAR_UPRIGHT],
108
+ rot: 0,
109
+ cx: 0,
110
+ cy: BAR_UPRIGHT_CY,
111
+ sx: 1,
112
+ sy: 1,
113
+ ...pose
114
+ })
115
+
116
+ const barItalic = (pose: Partial<Silhouette> = {}): Silhouette => ({
117
+ radii: [...BAR_ITALIC],
118
+ rot: 0,
119
+ cx: 0,
120
+ cy: 0,
121
+ sx: 1,
122
+ sy: 1,
123
+ ...pose
124
+ })
125
+
126
+ /**
127
+ * Le point du "!" penche n'est pas un disque : c'est une goutte, bout rond
128
+ * (r 0.118) du cote de la barre et pointe effilee a l'oppose, longueur 0.300
129
+ * dans l'axe du glyphe. Centree sur le barycentre du bout rond.
130
+ */
131
+ const TEAR = polyPath(hullOfCircles(0, 0, 0.118, 0, 0.172, 0.012))
132
+
133
+ /**
134
+ * Le triangle ne tourne pas sur lui-meme : son centre decrit un cercle de
135
+ * rayon 0.213 autour de l'origine (mesure). C'est ce decalage qui donne
136
+ * l'impression qu'il bascule au lieu de pivoter sur place.
137
+ */
138
+ const TRI_ORBIT = 0.213
139
+
140
+ function spinningTriangle(rot: number): Silhouette {
141
+ return silhouette('triangle', {
142
+ rot,
143
+ cx: -TRI_ORBIT * Math.sin(rot),
144
+ cy: TRI_ORBIT * Math.cos(rot)
145
+ })
146
+ }
147
+
148
+ /* ------------------------------------------------------------------ etats */
149
+
150
+ export type StateId =
151
+ | 'idle'
152
+ | 'thinking'
153
+ | 'wink'
154
+ | 'wide'
155
+ | 'alert'
156
+ | 'notify'
157
+ | 'exclaim'
158
+ | 'sleep'
159
+ | 'egg'
160
+ | 'hexagon'
161
+ | 'play'
162
+ | 'orbit'
163
+ | 'burst'
164
+ | 'comet'
165
+ /** transition d'interface, pas une animation du catalogue : hors `SEQUENCE` */
166
+ | 'swirl'
167
+
168
+ export interface StateDef {
169
+ id: StateId
170
+ /** duree de maintien quand la sequence complete est jouee */
171
+ duration: number
172
+ /**
173
+ * duree en dessous de laquelle l'animation est coupee avant d'aboutir : le
174
+ * "!" ne revient pas, le corps reste eclate. Elle se lit dans les constantes
175
+ * de `pose` ci-dessous, elle ne se choisit pas. Absente = l'etat ignore le
176
+ * temps ou boucle, n'importe quelle duree lui va (voir `MIN_BLOCK`).
177
+ */
178
+ minDuration?: number
179
+ /** duree du morph d'entree */
180
+ morph: number
181
+ /** true = l'entree est masquee par un clignement, comme dans la video */
182
+ blinkIn: boolean
183
+ /**
184
+ * true = le corps est la silhouette "au repos", donc remplacable par la forme
185
+ * choisie dans le personnalisateur. Les etats qui dessinent leur propre forme
186
+ * (le "!", les points, l'oeuf, le triangle...) valent false : c'est cette forme
187
+ * la qui EST l'animation.
188
+ */
189
+ baseBody: boolean
190
+ /**
191
+ * true = l'etat porte le visage "au repos", donc remplacable par l'expression
192
+ * choisie. Seul `idle` : les autres etats a visage ont une expression relevee
193
+ * sur la video, c'est precisement ce qu'on reproduit.
194
+ */
195
+ baseFace: boolean
196
+ pose(local: number): Pose
197
+ }
198
+
199
+ /** Onde de pulsation qui parcourt les trois points de gauche a droite. */
200
+ function dotPulse(t: number, index: number): number {
201
+ const p = ((((t - index * 0.5) / 1.5) % 1) + 1) % 1
202
+ const k = p < 0.5 ? 0.5 - 0.5 * Math.cos(p * TAU) : 0
203
+ return clamp(k * 2)
204
+ }
205
+
206
+ export const STATES: StateDef[] = [
207
+ {
208
+ id: 'idle',
209
+ duration: 2.4,
210
+ morph: 0.45,
211
+ blinkIn: false,
212
+ baseFace: true,
213
+ baseBody: true,
214
+ pose: () => base()
215
+ },
216
+
217
+ {
218
+ id: 'thinking',
219
+ duration: 2.6,
220
+ morph: 0.4,
221
+ baseFace: false,
222
+ baseBody: false,
223
+ blinkIn: true,
224
+ pose: (t) => {
225
+ const mid = dotPulse(t, 1)
226
+ // Les points lateraux sortent des flancs de la boule : dans la video ils
227
+ // restent fusionnes avec elle 1-2 frames avant de se detacher.
228
+ const emerge = 0.3 + 0.7 * easings.easeOutCubic(clamp(t / 0.3))
229
+ return base({
230
+ // la boule DEVIENT le point du milieu : le morph reste continu
231
+ sil: circle(DOT_R * (1 + (DOT_PEAK - 1) * mid), { cx: DOT_X[1]! }),
232
+ eyeAlpha: 0,
233
+ dots: [0, 2].map((i) => {
234
+ const k = dotPulse(t, i)
235
+ return {
236
+ x: DOT_X[i]! * emerge,
237
+ y: 0,
238
+ r: DOT_R * (1 + (DOT_PEAK - 1) * k),
239
+ opacity: 0.55 + 0.45 * k
240
+ }
241
+ })
242
+ })
243
+ }
244
+ },
245
+
246
+ {
247
+ id: 'wink',
248
+ duration: 1.6,
249
+ morph: 0.3,
250
+ blinkIn: true,
251
+ baseFace: false,
252
+ baseBody: true,
253
+ pose: () =>
254
+ base({
255
+ gaze: { yaw: -5.37, pitch: 4.55, roll: 6.7 },
256
+ split: 16.25,
257
+ // L'oeil ferme n'est pas l'oeil ouvert ecrase : c'est un tiret
258
+ // horizontal PLUS LARGE que l'oeil ouvert (0.447 contre 0.236).
259
+ eyes: [
260
+ { w: 0.236, h: 0.464, open: 1 },
261
+ { w: 0.447, h: 0.089, open: 1 }
262
+ ]
263
+ })
264
+ },
265
+
266
+ {
267
+ id: 'wide',
268
+ duration: 1.8,
269
+ morph: 0.55,
270
+ blinkIn: true,
271
+ baseFace: false,
272
+ baseBody: true,
273
+ pose: () =>
274
+ base({
275
+ gaze: { yaw: 6.92, pitch: -21.96, roll: 11.6 },
276
+ split: 18.43,
277
+ eyes: pair(0.356, 0.875)
278
+ })
279
+ },
280
+
281
+ {
282
+ id: 'alert',
283
+ duration: 2.4,
284
+ // le "!" revient en place a 1.6 + 0.4
285
+ minDuration: 2,
286
+ morph: 0.45,
287
+ baseFace: false,
288
+ baseBody: false,
289
+ blinkIn: false,
290
+ pose: (t) => {
291
+ // Course mesuree : -0.087 -> +0.732 en 1.5 s, ease-in-out, micro-overshoot.
292
+ const p = clamp(t / 1.5)
293
+ const travel = easings.easeInOutCubic(p) * 0.82 - 0.087
294
+ const back = t > 1.6 ? clamp((t - 1.6) / 0.4) : 0
295
+ const x = travel * (1 - back) + 0.1 * back
296
+ // Vibration secondaire a 2.5 Hz, barre et point en opposition de phase.
297
+ const buzz = Math.sin(t * 2.5 * TAU) * 0.005
298
+ const tilt = (17.7 * Math.PI) / 180
299
+ return base({
300
+ sil: barItalic({ rot: tilt, cx: x, cy: -0.325 - buzz }),
301
+ eyeAlpha: 0,
302
+ dots: [
303
+ {
304
+ // le point suit l'axe du glyphe, a 0.580 du centre de la barre
305
+ x: x - Math.sin(tilt) * 0.58,
306
+ y: -0.325 + Math.cos(tilt) * 0.58 + buzz * 2.8,
307
+ r: 0.118,
308
+ d: TEAR,
309
+ rot: (tilt * 180) / Math.PI,
310
+ opacity: 1
311
+ }
312
+ ]
313
+ })
314
+ }
315
+ },
316
+
317
+ {
318
+ id: 'notify',
319
+ duration: 2.2,
320
+ morph: 0.5,
321
+ blinkIn: true,
322
+ baseFace: false,
323
+ baseBody: true,
324
+ pose: (t) => {
325
+ // Pop du point bleu : pic a +14 % vers 0.3 s puis stabilisation.
326
+ const p = clamp(t / 0.45)
327
+ const pop = 1 + (NOTIF_POP - 1) * Math.sin(p * Math.PI) * (1 - p * 0.35)
328
+ const r = NOTIF_R * (p < 1 ? pop : 1)
329
+ const a = (NOTIF_ANGLE * Math.PI) / 180
330
+ return base({
331
+ // le regard part a l'oppose de la pastille
332
+ gaze: { yaw: -21.94, pitch: -5.82, roll: -12.2 },
333
+ split: 18.89,
334
+ eyes: pair(0.505, 0.498),
335
+ notif: {
336
+ x: Math.cos(a) * NOTIF_DIST,
337
+ y: Math.sin(a) * NOTIF_DIST,
338
+ r,
339
+ notch: r + NOTIF_MARGIN
340
+ }
341
+ })
342
+ }
343
+ },
344
+
345
+ {
346
+ id: 'exclaim',
347
+ duration: 2,
348
+ morph: 0.45,
349
+ baseFace: false,
350
+ baseBody: false,
351
+ blinkIn: false,
352
+ pose: () =>
353
+ base({
354
+ sil: barUpright(),
355
+ eyeAlpha: 0,
356
+ dots: [{ x: -0.012, y: 0.526, r: 0.113, opacity: 1 }]
357
+ })
358
+ },
359
+
360
+ {
361
+ id: 'sleep',
362
+ duration: 2.4,
363
+ morph: 0.5,
364
+ baseFace: false,
365
+ baseBody: false,
366
+ blinkIn: false,
367
+ pose: (t) =>
368
+ base({
369
+ // Rebond vertical mesure : +-0.19 autour de +0.11, periode 0.6 s.
370
+ sil: circle(0.1585, { cy: 0.11 + Math.sin(t * (TAU / 0.6)) * 0.19 }),
371
+ eyeAlpha: 0
372
+ })
373
+ },
374
+
375
+ {
376
+ id: 'egg',
377
+ duration: 1.8,
378
+ morph: 0.4,
379
+ baseFace: false,
380
+ baseBody: false,
381
+ blinkIn: true,
382
+ pose: () =>
383
+ base({
384
+ sil: silhouette('egg'),
385
+ gaze: { yaw: 19.97, pitch: 26.01, roll: -17.1 },
386
+ // les yeux se resserrent comme le corps
387
+ split: 11.07,
388
+ eyes: pair(0.164, 0.385)
389
+ })
390
+ },
391
+
392
+ {
393
+ id: 'hexagon',
394
+ duration: 1.6,
395
+ morph: 0.4,
396
+ baseFace: false,
397
+ baseBody: false,
398
+ blinkIn: true,
399
+ pose: () =>
400
+ base({
401
+ sil: silhouette('hexagon'),
402
+ gaze: { yaw: 23.11, pitch: 24.42, roll: -13.3 },
403
+ split: 13.37,
404
+ eyes: pair(0.177, 0.411)
405
+ })
406
+ },
407
+
408
+ {
409
+ id: 'play',
410
+ duration: 2,
411
+ morph: 0.5,
412
+ baseFace: false,
413
+ baseBody: false,
414
+ blinkIn: true,
415
+ pose: (t) => {
416
+ // Le triangle reste quasi immobile pendant que le bouquet le traverse.
417
+ const fade = clamp(t / 0.35) * clamp((2.2 - t) / 0.5)
418
+ return base({
419
+ sil: spinningTriangle(0),
420
+ gaze: { yaw: 12, pitch: -8, roll: -6 },
421
+ split: 15,
422
+ eyes: pair(0.18, 0.34),
423
+ // le bouquet balaie de la droite vers la gauche par-dessus le triangle
424
+ arcs: SWOOSH.map((s, i) => ({
425
+ id: `sw${i}`,
426
+ seed: { ...s, cx: 0.45 - t * 0.42 },
427
+ t,
428
+ opacity: fade
429
+ }))
430
+ })
431
+ }
432
+ },
433
+
434
+ {
435
+ id: 'orbit',
436
+ duration: 3.4,
437
+ // le corps a fini de se relacher du triangle vers la boule a 1.6 + 0.9
438
+ minDuration: 2.5,
439
+ morph: 0.6,
440
+ baseFace: false,
441
+ baseBody: false,
442
+ blinkIn: false,
443
+ pose: (t) => {
444
+ // Rotation mesuree : rampe sur 0.35 s puis 1.25 tour/s (sens antihoraire).
445
+ const ramp = easings.easeInOutCubic(clamp(t / 0.35))
446
+ const rot = -TAU * 1.25 * t * ramp
447
+ // Le corps se relache du triangle vers la boule pendant l'orbite.
448
+ const back = easings.easeInOutCubic(clamp((t - 1.6) / 0.9))
449
+ const tri = spinningTriangle(rot)
450
+ const ball = circle(1, { rot })
451
+ const sil: Silhouette = {
452
+ radii: tri.radii.map((r, i) => r + (ball.radii[i]! - r) * back),
453
+ rot,
454
+ cx: tri.cx * (1 - back),
455
+ cy: tri.cy * (1 - back),
456
+ sx: 1,
457
+ sy: 1
458
+ }
459
+ const fade = clamp(t / 0.8) * clamp((3.6 - t) / 0.9)
460
+ return base({
461
+ sil,
462
+ // les yeux filent autour de la sphere ~3x plus vite que la silhouette
463
+ gaze: {
464
+ yaw: REST_GAZE.yaw + Math.sin(t * 6.5) * 65 * (1 - back),
465
+ pitch: -4 + back * 32,
466
+ roll: -13
467
+ },
468
+ eyes: pair(0.18, 0.34 + back * 0.07),
469
+ // les anneaux entrent un par un sur 0.8 s
470
+ arcs: RINGS.map((s, i) => ({
471
+ id: `rg${i}`,
472
+ seed: s,
473
+ t,
474
+ opacity: fade * clamp((t - i * 0.13) / 0.3)
475
+ }))
476
+ })
477
+ }
478
+ },
479
+
480
+ {
481
+ /**
482
+ * Entree dans la vue des reglages.
483
+ *
484
+ * SEUL etat qui n'est pas releve sur la video : il est CHOISI, comme la
485
+ * couleur `--ink`. Il emprunte le vocabulaire d'`orbit` — les memes anneaux,
486
+ * avec leurs parametres mesures — mais coupe court : 1 s au lieu de 3,4, la
487
+ * moitie des anneaux, et aucun triangle.
488
+ *
489
+ * Les deux drapeaux a `true` sont tout l'interet de cet etat :
490
+ *
491
+ * - `baseBody` laisse la forme choisie remplacer le corps, donc la vue peut
492
+ * imposer le cercle et le galet ou la goutte y MORPHENT au lieu de sauter ;
493
+ * - `baseFace` fait porter le visage de repos, donc le suivi du curseur
494
+ * s'applique des cette entree. Un etat qui aurait sa propre pose de regard
495
+ * (comme `orbit`) rendrait la main a l'etat suivant en pleine course, et
496
+ * les yeux sauteraient d'un coup a la reprise.
497
+ *
498
+ * Il n'est volontairement PAS dans `SEQUENCE` : ce n'est pas une animation du
499
+ * catalogue, c'est une transition d'interface.
500
+ */
501
+ id: 'swirl',
502
+ // un peu plus que le tour du regard (`TURN_TIME`, 1,1 s) : les yeux doivent
503
+ // etre poses a gauche avant que les anneaux ne s'effacent
504
+ duration: 1.3,
505
+ minDuration: 1.3,
506
+ morph: 0.3,
507
+ baseFace: true,
508
+ baseBody: true,
509
+ // le morph de forme est masque par un clignement, comme partout ailleurs
510
+ blinkIn: true,
511
+ pose: (t) =>
512
+ base({
513
+ // trois anneaux sur les six d'`orbit` : la moitie du bouquet suffit a le
514
+ // reconnaitre, et c'est autant d'arcs en moins a rasteriser par image
515
+ arcs: RINGS.slice(0, 3).map((s, i) => ({
516
+ id: `sw${i}`,
517
+ seed: s,
518
+ t,
519
+ // ils entrent l'un apres l'autre puis s'effacent avant la fin du bloc,
520
+ // pour que la reprise au repos se fasse sur une image deja propre
521
+ opacity: clamp((t - i * 0.06) / 0.14) * clamp((1.22 - t) / 0.34)
522
+ }))
523
+ })
524
+ },
525
+
526
+ {
527
+ id: 'burst',
528
+ duration: 2.6,
529
+ // le corps est recompose a 1.7 + 0.7
530
+ minDuration: 2.4,
531
+ morph: 0.4,
532
+ baseFace: false,
533
+ baseBody: false,
534
+ blinkIn: false,
535
+ pose: (t) => {
536
+ // Effondrement mesure : 1.0 -> 0.166 en 0.7 s, ease-out, sans rebond.
537
+ const collapse = 1 - 0.834 * easings.easeOutQuint(clamp(t / 0.7))
538
+ const regrow = easings.easeOutQuint(clamp((t - 1.7) / 0.7))
539
+ return base({
540
+ sil: circle(collapse + (1 - collapse) * regrow),
541
+ eyeAlpha: clamp((t - 1.85) / 0.4),
542
+ dots: particles(t, 1),
543
+ dotsBehind: true
544
+ })
545
+ }
546
+ },
547
+
548
+ {
549
+ id: 'comet',
550
+ duration: 2.4,
551
+ // le point se recompose a 1.85 + 0.6 = 2.45, soit 0.05 s apres la coupe de
552
+ // la video : ce reliquat se termine pendant le fondu suivant, comme dans la
553
+ // reference. On ne descend donc pas sous la duree mesuree.
554
+ minDuration: 2.4,
555
+ morph: 0.45,
556
+ baseFace: false,
557
+ baseBody: false,
558
+ blinkIn: false,
559
+ pose: (t) => {
560
+ const collapse = 1 - (1 - COMET_DOT) * easings.easeOutQuint(clamp(t / 0.55))
561
+ const regrow = easings.easeOutQuint(clamp((t - 1.85) / 0.6))
562
+ const fade = clamp((t - 0.15) / 0.25) * clamp((1.95 - t) / 0.3)
563
+ return base({
564
+ // Le point derive de 0.035 vers le bas puis remonte (wobble mesure).
565
+ sil: circle(collapse + (1 - collapse) * regrow, {
566
+ cy: Math.sin(clamp(t / 1.7) * Math.PI) * 0.035
567
+ }),
568
+ eyeAlpha: clamp((t - 2) / 0.35),
569
+ arcs: COMET_RIBBONS.map((s, i) => ({ id: `cm${i}`, seed: s, t, opacity: fade }))
570
+ })
571
+ }
572
+ }
573
+ ]
574
+
575
+ export const STATE_BY_ID = new Map(STATES.map((s) => [s.id, s]))
576
+
577
+ /** Ordre de lecture de la sequence complete, calque sur la video de reference. */
578
+ /**
579
+ * Date, en temps local, ou chaque etat est le plus lisible : c'est la pose que
580
+ * montrent les vignettes et la planche. Rendu deterministe, donc comparable
581
+ * d'une execution a l'autre. Le type force a couvrir tout nouvel etat.
582
+ */
583
+ export const POSES: Record<StateId, number> = {
584
+ idle: 1,
585
+ thinking: 1.1,
586
+ wink: 0.8,
587
+ wide: 0.8,
588
+ alert: 0.75,
589
+ notify: 0.9,
590
+ exclaim: 0.8,
591
+ sleep: 0.45,
592
+ egg: 0.8,
593
+ hexagon: 0.8,
594
+ play: 0.9,
595
+ orbit: 1.2,
596
+ swirl: 0.5,
597
+ burst: 0.45,
598
+ comet: 1.15
599
+ }
600
+
601
+ export const SEQUENCE: StateId[] = [
602
+ 'idle',
603
+ 'thinking',
604
+ 'wink',
605
+ 'wide',
606
+ 'alert',
607
+ 'notify',
608
+ 'exclaim',
609
+ 'sleep',
610
+ 'egg',
611
+ 'hexagon',
612
+ 'play',
613
+ 'orbit',
614
+ 'burst',
615
+ 'comet'
616
+ ]
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { constrainPetDrag } from "./index";
4
+
5
+ describe("constrainPetDrag", () => {
6
+ it("keeps the pet inside the viewport margin", () => {
7
+ const rect = { left: 100, top: 100, right: 244, bottom: 244 };
8
+ const viewport = { width: 800, height: 600 };
9
+
10
+ expect(constrainPetDrag({ x: -500, y: 500 }, rect, viewport)).toEqual({
11
+ x: -92,
12
+ y: 348,
13
+ });
14
+ expect(constrainPetDrag({ x: 20, y: -30 }, rect, viewport)).toEqual({
15
+ x: 20,
16
+ y: -30,
17
+ });
18
+ });
19
+ });