@dotcraft/avatar 0.6.3 → 0.6.5

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 (83) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +46 -43
  3. package/dist/accessories.css +14 -14
  4. package/dist/avatar.css +62 -62
  5. package/dist/composer/ComposerMascot.d.ts +1 -1
  6. package/dist/composer/ComposerMascot.d.ts.map +1 -1
  7. package/dist/composer/ComposerMascot.js +33 -107
  8. package/dist/composer/ComposerMascot.js.map +1 -1
  9. package/dist/composer/MascotIdleStage.d.ts +11 -0
  10. package/dist/composer/MascotIdleStage.d.ts.map +1 -0
  11. package/dist/composer/MascotIdleStage.js +9 -0
  12. package/dist/composer/MascotIdleStage.js.map +1 -0
  13. package/dist/composer/constants.d.ts +12 -1
  14. package/dist/composer/constants.d.ts.map +1 -1
  15. package/dist/composer/constants.js +9 -4
  16. package/dist/composer/constants.js.map +1 -1
  17. package/dist/composer/styles/energy.css +99 -99
  18. package/dist/composer/styles/expressions.css +20 -20
  19. package/dist/composer/styles/feedback.css +188 -188
  20. package/dist/composer/styles/focus.css +72 -72
  21. package/dist/composer/styles/foundation.css +301 -287
  22. package/dist/composer/styles/idle.css +449 -438
  23. package/dist/composer/styles/overdrive.css +264 -264
  24. package/dist/composer/styles/props.css +21 -21
  25. package/dist/composer/types.d.ts +1 -0
  26. package/dist/composer/types.d.ts.map +1 -1
  27. package/dist/composer/useComposerAvatarBehavior.d.ts +1 -0
  28. package/dist/composer/useComposerAvatarBehavior.d.ts.map +1 -1
  29. package/dist/composer/useComposerAvatarBehavior.js +2 -2
  30. package/dist/composer/useComposerAvatarBehavior.js.map +1 -1
  31. package/dist/composer/useMascotActiveIdle.d.ts +33 -0
  32. package/dist/composer/useMascotActiveIdle.d.ts.map +1 -0
  33. package/dist/composer/useMascotActiveIdle.js +128 -0
  34. package/dist/composer/useMascotActiveIdle.js.map +1 -0
  35. package/dist/react.d.ts +5 -0
  36. package/dist/react.d.ts.map +1 -1
  37. package/dist/react.js +4 -0
  38. package/dist/react.js.map +1 -1
  39. package/dist/rig.css +265 -265
  40. package/dist/styles.css +13 -13
  41. package/package.json +56 -55
  42. package/src/AnimatedDecoration.tsx +43 -0
  43. package/src/AppearanceRig.tsx +15 -0
  44. package/src/Avatar.tsx +85 -0
  45. package/src/DecorationShapes.tsx +8 -0
  46. package/src/Decorations.tsx +31 -0
  47. package/src/Faces.tsx +37 -0
  48. package/src/HatDecorations.tsx +75 -0
  49. package/src/HeldDecorations.tsx +48 -0
  50. package/src/MascotRig.tsx +191 -0
  51. package/src/ObjectDecorations.tsx +55 -0
  52. package/src/accessories.css +14 -0
  53. package/src/appearanceModel.ts +65 -0
  54. package/src/avatar.css +62 -0
  55. package/src/characters.ts +5 -0
  56. package/src/composer/ComposerMascot.tsx +445 -0
  57. package/src/composer/ComposerMascotShadow.tsx +5 -0
  58. package/src/composer/MascotIdleStage.tsx +23 -0
  59. package/src/composer/constants.ts +89 -0
  60. package/src/composer/mascotHandoff.ts +19 -0
  61. package/src/composer/styles/energy.css +99 -0
  62. package/src/composer/styles/expressions.css +20 -0
  63. package/src/composer/styles/feedback.css +188 -0
  64. package/src/composer/styles/focus.css +72 -0
  65. package/src/composer/styles/foundation.css +301 -0
  66. package/src/composer/styles/idle.css +449 -0
  67. package/src/composer/styles/overdrive.css +264 -0
  68. package/src/composer/styles/props.css +21 -0
  69. package/src/composer/types.ts +32 -0
  70. package/src/composer/useComposerAvatarBehavior.ts +98 -0
  71. package/src/composer/useComposerMotion.ts +15 -0
  72. package/src/composer/useComposerProfile.ts +50 -0
  73. package/src/composer/useMascotActiveIdle.ts +170 -0
  74. package/src/decorationCatalog.ts +45 -0
  75. package/src/decorationMotion.ts +68 -0
  76. package/src/environment.ts +46 -0
  77. package/src/index.ts +5 -0
  78. package/src/palette.ts +52 -0
  79. package/src/react.ts +11 -0
  80. package/src/rig.css +265 -0
  81. package/src/styles.css +13 -0
  82. package/src/useEventReplay.ts +13 -0
  83. package/src/useGesture.ts +51 -0
@@ -0,0 +1,264 @@
1
+ [data-mascot-context='max'] .composer-mascot-motion {
2
+ --mascot-max-edge: oklch(from var(--mascot-energy-accent)
3
+ clamp(0.28, calc(l + 0.065), 0.96) clamp(0, calc(c * 0.96), 0.3) calc(h + 4));
4
+ --mascot-max-shift: oklch(from var(--mascot-energy-accent)
5
+ clamp(0.28, calc(l + 0.1), 0.96) clamp(0, calc(c * 0.92), 0.3) calc(h + 40));
6
+ --mascot-max-bright: oklch(from var(--mascot-energy-accent)
7
+ clamp(0.28, calc(l + 0.18), 0.96) clamp(0, calc(c * 0.68), 0.3) calc(h + 18));
8
+ --mascot-max-edge-bold: rgb(from var(--mascot-max-edge) r g b / 1);
9
+ --mascot-max-edge-faint: rgb(from var(--mascot-max-edge) r g b / 0.24);
10
+ --mascot-max-shift-bold: rgb(from var(--mascot-max-shift) r g b / 1);
11
+ --mascot-max-shift-medium: rgb(from var(--mascot-max-shift) r g b / 0.88);
12
+ --mascot-max-shift-soft: rgb(from var(--mascot-max-shift) r g b / 0.44);
13
+ --mascot-max-bright-medium: rgb(from var(--mascot-max-bright) r g b / 0.88);
14
+ --mascot-max-blur-hair: 1.4px;
15
+ --mascot-max-blur-edge: 2.4px;
16
+ --mascot-max-blur-glow: 6.8px;
17
+ }
18
+
19
+ .composer-mascot-motion::after {
20
+ content: '';
21
+ position: absolute;
22
+ top: 50%;
23
+ left: 50%;
24
+ z-index: 3;
25
+ width: 104px;
26
+ height: 90px;
27
+ border-radius: 48%;
28
+ opacity: 0;
29
+ pointer-events: none;
30
+ transform: translate(-50%, -50%);
31
+ transform-origin: center;
32
+ }
33
+
34
+ [data-mascot-context='max'] .composer-mascot-motion::after {
35
+ background:
36
+ radial-gradient(circle, var(--mascot-max-bright) 0 0.55px, transparent 1.1px) 13% 42% / 3px 3px no-repeat,
37
+ radial-gradient(circle, var(--mascot-max-edge) 0 0.45px, transparent 1px) 27% 74% / 3px 3px no-repeat,
38
+ radial-gradient(circle, var(--mascot-max-shift) 0 0.5px, transparent 1.05px) 42% 18% / 3px 3px no-repeat,
39
+ radial-gradient(circle, var(--mascot-max-bright) 0 0.45px, transparent 1px) 68% 80% / 3px 3px no-repeat,
40
+ radial-gradient(circle, var(--mascot-max-edge) 0 0.55px, transparent 1.1px) 82% 28% / 3px 3px no-repeat,
41
+ radial-gradient(circle, var(--mascot-max-shift) 0 0.4px, transparent 0.95px) 91% 62% / 3px 3px no-repeat;
42
+ filter: drop-shadow(0 0 1.6px color-mix(in srgb, var(--mascot-max-edge) 32%, transparent));
43
+ opacity: 0.18;
44
+ animation: composer-mascot-max-micrograin 6.2s ease-in-out infinite;
45
+ }
46
+
47
+ @keyframes composer-mascot-max-overdrive-edge {
48
+ 0%, 100% {
49
+ filter:
50
+ drop-shadow(-0.6px 0 var(--mascot-max-blur-hair) var(--mascot-max-edge-bold))
51
+ drop-shadow(0.7px 0 var(--mascot-max-blur-edge) var(--mascot-max-shift-medium))
52
+ drop-shadow(0 0 var(--mascot-max-blur-glow) var(--mascot-max-edge-faint));
53
+ }
54
+ 46% {
55
+ filter:
56
+ drop-shadow(0.2px -0.75px var(--mascot-max-blur-hair) var(--mascot-max-shift-bold))
57
+ drop-shadow(-0.2px 0.8px var(--mascot-max-blur-edge) var(--mascot-max-bright-medium))
58
+ drop-shadow(0 0 var(--mascot-max-blur-glow) var(--mascot-max-shift-soft));
59
+ }
60
+ }
61
+
62
+ @keyframes composer-mascot-max-coat-dark {
63
+ 0%, 34%, 100% { stop-color: var(--mascot-body-dark); }
64
+ 14% {
65
+ stop-color: oklch(from var(--mascot-body-dark)
66
+ clamp(0.28, calc(l + 0.09), 0.96) clamp(0, calc(c * 0.9), 0.3) calc(h + 14));
67
+ }
68
+ }
69
+
70
+ @keyframes composer-mascot-max-coat-mid {
71
+ 0%, 24%, 68%, 100% { stop-color: var(--mascot-body-mid); }
72
+ 44% {
73
+ stop-color: oklch(from var(--mascot-body-mid)
74
+ clamp(0.28, calc(l + 0.14), 0.96) clamp(0, calc(c * 0.76), 0.3) calc(h + 31));
75
+ }
76
+ }
77
+
78
+ @keyframes composer-mascot-max-coat-light {
79
+ 0%, 50%, 94%, 100% { stop-color: var(--mascot-body-light); }
80
+ 72% {
81
+ stop-color: oklch(from var(--mascot-body-light)
82
+ clamp(0.28, calc(l + 0.15), 0.96) clamp(0, calc(c * 0.58), 0.3) calc(h + 41));
83
+ }
84
+ }
85
+
86
+ @keyframes composer-mascot-max-coat-mark-dark {
87
+ 0%, 44%, 100% { stop-color: var(--mascot-mark-dark); }
88
+ 20% {
89
+ stop-color: oklch(from var(--mascot-mark-dark)
90
+ clamp(0.28, calc(l + 0.1), 0.96) clamp(0, calc(c * 0.82), 0.3) calc(h + 18));
91
+ }
92
+ }
93
+
94
+ @keyframes composer-mascot-max-coat-mark-light {
95
+ 0%, 40%, 88%, 100% { stop-color: var(--mascot-mark-energy); }
96
+ 64% {
97
+ stop-color: oklch(from var(--mascot-mark-energy)
98
+ clamp(0.28, calc(l + 0.15), 0.96) clamp(0, calc(c * 0.6), 0.3) calc(h + 38));
99
+ }
100
+ }
101
+
102
+ @keyframes composer-mascot-max-micrograin {
103
+ 0%, 100% { opacity: 0.08; transform: translate(-51%, -49%) scale(0.98); }
104
+ 38% { opacity: 0.28; transform: translate(-49%, -52%) scale(1.02); }
105
+ 68% { opacity: 0.13; transform: translate(-48%, -54%) scale(1.04); }
106
+ }
107
+
108
+ [data-mascot-profile-transition='active'] .composer-mascot-character-stage {
109
+ transform-origin: 50% 52%;
110
+ animation: composer-mascot-profile-character 1.24s linear both;
111
+ }
112
+
113
+ [data-mascot-profile-transition='active'] .composer-mascot-motion::before {
114
+ content: '';
115
+ position: absolute;
116
+ top: 52%;
117
+ left: 50%;
118
+ z-index: 1;
119
+ width: 64px;
120
+ height: 64px;
121
+ box-sizing: border-box;
122
+ border: 1px solid transparent;
123
+ border-radius: 50%;
124
+ background: radial-gradient(
125
+ circle,
126
+ #010106 0 34%,
127
+ #05050c 38%,
128
+ rgba(232, 236, 255, 0.78) 42%,
129
+ rgba(135, 145, 180, 0.18) 48%,
130
+ transparent 66%
131
+ );
132
+ opacity: 0;
133
+ pointer-events: none;
134
+ transform: translate(-50%, -50%) scale(0.16);
135
+ transform-origin: center;
136
+ animation:
137
+ composer-mascot-profile-hole 1.24s linear both,
138
+ composer-mascot-profile-rim 1.24s linear both;
139
+ }
140
+
141
+ [data-mascot-profile-transition='active'] .composer-mascot-motion::after {
142
+ top: 52%;
143
+ left: 50%;
144
+ z-index: 1;
145
+ width: 86px;
146
+ height: 86px;
147
+ border-radius: 50%;
148
+ color: var(--mascot-profile-from-accent);
149
+ background:
150
+ radial-gradient(circle, currentColor 0 0.9px, transparent 1.55px) 50% 2% / 4px 4px no-repeat,
151
+ radial-gradient(circle, currentColor 0 0.76px, transparent 1.4px) 78% 12% / 4px 4px no-repeat,
152
+ radial-gradient(circle, rgba(255, 255, 255, 0.88) 0 0.65px, transparent 1.3px) 96% 42% / 4px 4px no-repeat,
153
+ radial-gradient(circle, currentColor 0 0.82px, transparent 1.48px) 88% 76% / 4px 4px no-repeat,
154
+ radial-gradient(circle, currentColor 0 0.9px, transparent 1.55px) 55% 98% / 4px 4px no-repeat,
155
+ radial-gradient(circle, rgba(255, 255, 255, 0.8) 0 0.65px, transparent 1.3px) 19% 86% / 4px 4px no-repeat,
156
+ radial-gradient(circle, currentColor 0 0.76px, transparent 1.4px) 3% 55% / 4px 4px no-repeat,
157
+ radial-gradient(circle, currentColor 0 0.82px, transparent 1.48px) 14% 20% / 4px 4px no-repeat;
158
+ filter: drop-shadow(0 0 3px currentColor);
159
+ opacity: 0;
160
+ animation:
161
+ composer-mascot-profile-particles 1.24s linear both,
162
+ composer-mascot-profile-particle-color 1.24s linear both;
163
+ }
164
+
165
+ [data-composer-root]:has([data-mascot-profile-transition='active']) .composer-mascot-contact-shadow {
166
+ animation: composer-mascot-profile-contact-shadow 1.24s linear both;
167
+ }
168
+
169
+ @keyframes composer-mascot-profile-character {
170
+ 0%, 100% {
171
+ opacity: 1;
172
+ transform: translateY(0) rotate(0) scale(1);
173
+ filter: none;
174
+ }
175
+ 18% {
176
+ opacity: 1;
177
+ transform: translateY(0) rotate(0) scale(1.015, 0.99);
178
+ filter: saturate(1.03);
179
+ }
180
+ 34% {
181
+ opacity: 0.96;
182
+ transform: translateY(0) rotate(1.2deg) scale(0.76, 0.8);
183
+ filter: brightness(0.96) saturate(1.08);
184
+ }
185
+ 46% {
186
+ opacity: 0.14;
187
+ transform: translateY(0) rotate(-4deg) scale(0.13);
188
+ filter: brightness(0.72) saturate(1.18) blur(0.7px);
189
+ }
190
+ 48%, 52% {
191
+ opacity: 0;
192
+ transform: translateY(0) rotate(-6deg) scale(0.02);
193
+ filter: brightness(0.58) saturate(1.2) blur(1px);
194
+ }
195
+ 64% {
196
+ opacity: 0.95;
197
+ transform: translateY(0) rotate(1.8deg) scale(1.1, 0.92);
198
+ filter: brightness(1.12);
199
+ }
200
+ 76% {
201
+ opacity: 1;
202
+ transform: translateY(0) rotate(-0.8deg) scale(0.97, 1.035);
203
+ filter: none;
204
+ }
205
+ 89% {
206
+ opacity: 1;
207
+ transform: translateY(0) rotate(0.3deg) scale(1.01, 0.995);
208
+ filter: none;
209
+ }
210
+ }
211
+
212
+ @keyframes composer-mascot-profile-hole {
213
+ 0%, 12%, 100% { opacity: 0; transform: translate(-50%, -50%) scale(0.16); }
214
+ 30% { opacity: 0.72; transform: translate(-50%, -50%) scale(0.72); }
215
+ 46%, 54% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
216
+ 72% { opacity: 0.54; transform: translate(-50%, -50%) scale(0.66); }
217
+ 88% { opacity: 0; transform: translate(-50%, -50%) scale(0.2); }
218
+ }
219
+
220
+ @keyframes composer-mascot-profile-particles {
221
+ 0%, 14%, 100% { opacity: 0; transform: translate(-50%, -50%) rotate(0) scale(1); }
222
+ 20% { opacity: 0.45; transform: translate(-50%, -50%) rotate(8deg) scale(1); }
223
+ 34% { opacity: 0.92; transform: translate(-50%, -50%) rotate(38deg) scale(0.72); }
224
+ 48% { opacity: 0.12; transform: translate(-50%, -50%) rotate(86deg) scale(0.16); }
225
+ 49.5% { opacity: 0; transform: translate(-50%, -50%) rotate(96deg) scale(0.08); }
226
+ 50.5% { opacity: 0; transform: translate(-50%, -50%) rotate(104deg) scale(1); }
227
+ 64% { opacity: 0.82; transform: translate(-50%, -50%) rotate(146deg) scale(0.7); }
228
+ 82% { opacity: 0.08; transform: translate(-50%, -50%) rotate(206deg) scale(0.14); }
229
+ 86% { opacity: 0; transform: translate(-50%, -50%) rotate(218deg) scale(0.08); }
230
+ }
231
+
232
+ @keyframes composer-mascot-profile-particle-color {
233
+ 0%, 42% { color: var(--mascot-profile-from-accent); }
234
+ 49.5%, 50.5% { color: rgba(240, 244, 255, 0.96); }
235
+ 58%, 100% { color: var(--mascot-profile-to-accent); }
236
+ }
237
+
238
+ @keyframes composer-mascot-profile-rim {
239
+ 0%, 16% {
240
+ border-color: color-mix(in srgb, var(--mascot-profile-from-accent) 72%, transparent);
241
+ box-shadow: 0 0 12px color-mix(in srgb, var(--mascot-profile-from-accent) 42%, transparent);
242
+ }
243
+ 34% {
244
+ border-color: color-mix(in srgb, var(--mascot-profile-from-accent) 84%, white);
245
+ box-shadow: 0 0 15px color-mix(in srgb, var(--mascot-profile-from-accent) 48%, transparent);
246
+ }
247
+ 50% {
248
+ border-color: rgba(246, 248, 255, 0.92);
249
+ box-shadow: 0 0 17px rgba(220, 226, 255, 0.46);
250
+ }
251
+ 66% {
252
+ border-color: color-mix(in srgb, var(--mascot-profile-to-accent) 84%, white);
253
+ box-shadow: 0 0 15px color-mix(in srgb, var(--mascot-profile-to-accent) 48%, transparent);
254
+ }
255
+ 84%, 100% {
256
+ border-color: color-mix(in srgb, var(--mascot-profile-to-accent) 72%, transparent);
257
+ box-shadow: 0 0 12px color-mix(in srgb, var(--mascot-profile-to-accent) 36%, transparent);
258
+ }
259
+ }
260
+
261
+ @keyframes composer-mascot-profile-contact-shadow {
262
+ 0%, 100% { opacity: 1; transform: translateX(50%) scaleX(1); }
263
+ 46%, 54% { opacity: 0; transform: translateX(50%) scaleX(0.25); }
264
+ }
@@ -0,0 +1,21 @@
1
+ @keyframes composer-mascot-land {
2
+ 0%, 100% { transform: translateY(0) scale(1, 1); }
3
+ 35% { transform: translateY(2px) scale(1.12, 0.86); }
4
+ 70% { transform: translateY(-2px) scale(0.97, 1.03); }
5
+ }
6
+
7
+ .composer-mascot-land {
8
+ animation: composer-mascot-land 380ms ease-out;
9
+ transform-origin: bottom center;
10
+ }
11
+
12
+ @media (prefers-reduced-motion: reduce) {
13
+ .composer-mascot-motion,
14
+ .composer-mascot-motion * {
15
+ animation: none !important;
16
+ transition: none !important;
17
+ }
18
+ }
19
+
20
+ [data-composer-mascot-motion='off'],
21
+ [data-composer-mascot-motion='off'] * { animation: none !important; transition: none !important; }
@@ -0,0 +1,32 @@
1
+ import type { ReactNode } from 'react'
2
+ export type MascotExpression = 'neutral' | 'happy' | 'operator' | 'sleep'
3
+ export type MascotLight = 'default' | 'error' | 'success'
4
+ export interface ComposerMascotContext {
5
+ size: number
6
+ activity: 'error' | 'success' | 'sleeping' | 'dragging' | 'decision' | 'working' | 'focused' | 'idle'
7
+ expression: MascotExpression
8
+ light: MascotLight
9
+ submitRevision: number
10
+ reasoningEffort: 'off' | 'low' | 'medium' | 'high' | 'extraHigh'
11
+ speed: 'standard' | 'fast'
12
+ contextMax: boolean
13
+ reducedMotion: boolean
14
+ }
15
+ export interface ComposerMascotProps {
16
+ name?: string
17
+ motion?: 'system' | 'on' | 'off'
18
+ theme?: 'light' | 'dark'
19
+ focused?: boolean
20
+ dragOver?: boolean
21
+ bounceSignal?: number
22
+ interaction?: { expression?: MascotExpression; light?: MascotLight; hold?: 'sign'; bubble?: ReactNode }
23
+ reasoningEffort?: ComposerMascotContext['reasoningEffort']
24
+ speed?: ComposerMascotContext['speed']
25
+ contextMax?: boolean
26
+ anchorOffset?: number
27
+ anchorPushSignal?: number
28
+ handoff?: boolean
29
+ renderCharacter?: (character: ReactNode, context: ComposerMascotContext) => ReactNode
30
+ renderMenu?: (position: { x: number; y: number }, close: () => void) => ReactNode
31
+ onNameRendered?: (name: string | undefined) => void
32
+ }
@@ -0,0 +1,98 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import type { AvatarPose } from '../index.js'
3
+ import type { MascotExpression } from './types.js'
4
+
5
+ export type ComposerAvatarGesture = 'blink' | 'look-left' | 'look-right' | 'antenna-bob'
6
+
7
+ interface ComposerAvatarBehaviorOptions {
8
+ semanticPose: AvatarPose
9
+ baseExpression: MascotExpression
10
+ focused: boolean
11
+ dragOver: boolean
12
+ sleeping: boolean
13
+ waving: boolean
14
+ activeIdle: boolean
15
+ bounceSignal: number
16
+ reducedMotion: boolean
17
+ directionalGestures?: boolean
18
+ }
19
+
20
+ export function useComposerAvatarBehavior(options: ComposerAvatarBehaviorOptions): {
21
+ pose: AvatarPose
22
+ expression: 'base' | 'happy' | 'operator' | 'sleep' | undefined
23
+ gesture: ComposerAvatarGesture | undefined
24
+ gestureSequence: number
25
+ clearGesture: () => void
26
+ completeGesture: (sequence: number) => void
27
+ } {
28
+ const [receiptAcknowledging, setReceiptAcknowledging] = useState(false)
29
+ const [gesture, setGesture] = useState<ComposerAvatarGesture>()
30
+ const [gestureSequence, setGestureSequence] = useState(0)
31
+ const previousBounce = useRef(options.bounceSignal)
32
+
33
+ useEffect(() => {
34
+ if (options.reducedMotion || options.semanticPose !== 'idle') {
35
+ previousBounce.current = options.bounceSignal
36
+ setReceiptAcknowledging(false)
37
+ return
38
+ }
39
+ if (options.bounceSignal === previousBounce.current) return
40
+ previousBounce.current = options.bounceSignal
41
+ setReceiptAcknowledging(true)
42
+ const timer = window.setTimeout(() => setReceiptAcknowledging(false), 350)
43
+ return () => window.clearTimeout(timer)
44
+ }, [options.bounceSignal, options.reducedMotion, options.semanticPose])
45
+
46
+ useEffect(() => {
47
+ if (options.sleeping || options.activeIdle || options.reducedMotion) {
48
+ setGesture(undefined)
49
+ return
50
+ }
51
+ if (options.baseExpression !== 'neutral' && options.baseExpression !== 'happy') return
52
+ let timer = 0
53
+ const schedule = (): void => {
54
+ timer = window.setTimeout(() => {
55
+ if (!document.hidden) {
56
+ const value = Math.random()
57
+ const next = value < 0.5
58
+ ? 'blink'
59
+ : value < 0.72
60
+ ? (Math.random() < 0.5 ? 'look-left' : 'look-right')
61
+ : value < 0.86
62
+ ? 'antenna-bob'
63
+ : undefined
64
+ if (next && (options.directionalGestures !== false || (next !== 'look-left' && next !== 'look-right'))) {
65
+ setGesture(next)
66
+ setGestureSequence((sequence) => sequence + 1)
67
+ }
68
+ }
69
+ schedule()
70
+ }, 2600 + Math.random() * 3200)
71
+ }
72
+ schedule()
73
+ return () => { window.clearTimeout(timer) }
74
+ }, [options.sleeping, options.activeIdle, options.reducedMotion, options.baseExpression, options.directionalGestures])
75
+
76
+ const clearGesture = useCallback(() => setGesture(undefined), [])
77
+ const completeGesture = useCallback((sequence: number) => {
78
+ setGesture((current) => sequence === gestureSequence ? undefined : current)
79
+ }, [gestureSequence])
80
+
81
+ const expression = options.semanticPose === 'idle'
82
+ ? options.waving || options.focused
83
+ ? 'happy'
84
+ : options.dragOver || options.baseExpression === 'operator'
85
+ ? 'operator'
86
+ : options.sleeping
87
+ ? 'sleep'
88
+ : 'base'
89
+ : undefined
90
+ return {
91
+ pose: options.semanticPose === 'idle' && receiptAcknowledging ? 'acknowledge' : options.semanticPose,
92
+ expression,
93
+ gesture,
94
+ gestureSequence,
95
+ clearGesture,
96
+ completeGesture
97
+ }
98
+ }
@@ -0,0 +1,15 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { prefersReducedMotion } from './constants.js'
3
+ export function useComposerMotion(mode: 'system' | 'on' | 'off') {
4
+ const [reduced, setReduced] = useState(prefersReducedMotion)
5
+ useEffect(() => {
6
+ const update = () => setReduced(prefersReducedMotion())
7
+ const media = window.matchMedia?.('(prefers-reduced-motion: reduce)')
8
+ media?.addEventListener?.('change', update)
9
+ const observer = new MutationObserver(update)
10
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-reduce-motion'] })
11
+ update()
12
+ return () => { media?.removeEventListener?.('change', update); observer.disconnect() }
13
+ }, [])
14
+ return mode === 'on' || (mode === 'system' && !reduced)
15
+ }
@@ -0,0 +1,50 @@
1
+ import { useLayoutEffect, useRef, useState } from 'react'
2
+ import { deriveAppearance, mascotPaletteOf } from '../index.js'
3
+ import { mascotNameKey, MASCOT_PROFILE_TRANSITION_SWAP_MS, MASCOT_PROFILE_TRANSITION_DURATION_MS, type MascotProfileTransition } from './constants.js'
4
+ export function useComposerProfile(mascotName: string | undefined, reduced: boolean) {
5
+ const [renderedMascotAvatar, setRenderedMascotAvatar] = useState(mascotName)
6
+ const [transitionState, setTransitionState] = useState<{ revision: number; transition: MascotProfileTransition | null }>({ revision: 0, transition: null })
7
+ const renderedMascotAvatarRef = useRef(renderedMascotAvatar)
8
+ const targetMascotAvatarRef = useRef(mascotName)
9
+ renderedMascotAvatarRef.current = renderedMascotAvatar
10
+ targetMascotAvatarRef.current = mascotName
11
+ const targetAvatarKey = mascotNameKey(mascotName)
12
+ useLayoutEffect(() => {
13
+ const currentAvatar = renderedMascotAvatarRef.current
14
+ if (targetAvatarKey === mascotNameKey(currentAvatar)) {
15
+ setTransitionState(current => ({ ...current, transition: null }))
16
+ return undefined
17
+ }
18
+
19
+ const nextAvatar = targetMascotAvatarRef.current
20
+ if (reduced) {
21
+ renderedMascotAvatarRef.current = nextAvatar
22
+ setRenderedMascotAvatar(nextAvatar)
23
+ setTransitionState(current => ({ ...current, transition: null }))
24
+ return undefined
25
+ }
26
+
27
+ setTransitionState(current => ({
28
+ revision: current.revision + 1,
29
+ transition: {
30
+ fromAccent: mascotPaletteOf(deriveAppearance(currentAvatar ?? '')).accent,
31
+ toAccent: mascotPaletteOf(deriveAppearance(nextAvatar ?? '')).accent
32
+ }
33
+ }))
34
+
35
+ const swapTimer = window.setTimeout(() => {
36
+ renderedMascotAvatarRef.current = nextAvatar
37
+ setRenderedMascotAvatar(nextAvatar)
38
+ }, MASCOT_PROFILE_TRANSITION_SWAP_MS)
39
+ const finishTimer = window.setTimeout(() => {
40
+ setTransitionState(current => ({ ...current, transition: null }))
41
+ }, MASCOT_PROFILE_TRANSITION_DURATION_MS)
42
+
43
+ return () => {
44
+ window.clearTimeout(swapTimer)
45
+ window.clearTimeout(finishTimer)
46
+ }
47
+ }, [targetAvatarKey, reduced])
48
+
49
+ return { avatar: renderedMascotAvatar, profileTransition: transitionState.transition, profileTransitionRevision: transitionState.revision }
50
+ }
@@ -0,0 +1,170 @@
1
+ import { useCallback, useEffect, useRef, useState, type AnimationEvent } from 'react'
2
+ import {
3
+ MASCOT_ACTIVE_IDLE_ACTIVITY_THROTTLE_MS,
4
+ MASCOT_ACTIVE_IDLE_HOLD_MS,
5
+ MASCOT_ACTIVE_IDLE_JITTER_MS,
6
+ MASCOT_ACTIVE_IDLE_MIN_MS,
7
+ MASCOT_ACTIVE_IDLE_TRAVEL_MS,
8
+ pickMascotActiveIdle,
9
+ type MascotActiveIdle,
10
+ type MascotActiveIdleMotion,
11
+ type MascotActiveIdlePhase,
12
+ type MascotActiveIdlePlan,
13
+ type MascotIdleDirection
14
+ } from './constants.js'
15
+
16
+ export interface MascotActiveIdleAttributes {
17
+ 'data-mascot-active-idle'?: MascotActiveIdleMotion
18
+ 'data-mascot-idle-phase'?: MascotActiveIdlePhase
19
+ 'data-mascot-idle-direction'?: MascotIdleDirection
20
+ 'data-mascot-idle-travel'?: MascotIdleDirection
21
+ }
22
+
23
+ export interface MascotActiveIdleOptions {
24
+ /** True while the character is free to wander: idle, awake, unfocused, and motion allowed. */
25
+ enabled: boolean
26
+ onStart?: () => void
27
+ /** Runs on user activity, before the schedule-reset throttle. */
28
+ onActivity?: () => void
29
+ /** Decides direction and motion pool at launch; `null` skips this round. */
30
+ plan?: () => MascotActiveIdlePlan | null
31
+ /** Whether pointer movement aborts a trip in flight; it always defers the next one. */
32
+ interruptOnPointerMove?: boolean
33
+ }
34
+
35
+ export interface MascotActiveIdleHandle {
36
+ activeIdle: MascotActiveIdle | null
37
+ activityRevision: number
38
+ className: 'composer-mascot-active-idle' | undefined
39
+ attributes: MascotActiveIdleAttributes
40
+ cancel: () => void
41
+ onAnimationEnd: (event: AnimationEvent<Element>) => void
42
+ }
43
+
44
+ const RETRY_MS = 5000
45
+ const LEG_SETTLE_MS = 160
46
+
47
+ function opposite(direction: MascotIdleDirection): MascotIdleDirection {
48
+ return direction === 'left' ? 'right' : 'left'
49
+ }
50
+
51
+ function finisherOf(trip: MascotActiveIdle): string {
52
+ if (trip.motion === 'hop') return 'composer-mascot-idle-hop-travel'
53
+ if (trip.motion === 'rocket') return 'composer-mascot-idle-rocket-flight-x'
54
+ return trip.phase === 'outbound' ? 'composer-mascot-idle-hover-launch-body' : 'composer-mascot-idle-hover-land-body'
55
+ }
56
+
57
+ function advance(trip: MascotActiveIdle | null): MascotActiveIdle | null {
58
+ if (!trip) return null
59
+ if (trip.phase === 'outbound') return { ...trip, phase: 'away' }
60
+ if (trip.phase === 'away') return { ...trip, phase: 'inbound', travel: opposite(trip.direction) }
61
+ return null
62
+ }
63
+
64
+ /**
65
+ * Schedules the traveling idle antics (hop, rocket, hover) after a quiet stretch. Any host that
66
+ * renders the stage classes (see MascotIdleStage) can spread the returned attributes on its root.
67
+ */
68
+ export function useMascotActiveIdle(options: MascotActiveIdleOptions): MascotActiveIdleHandle {
69
+ const { enabled, interruptOnPointerMove = true } = options
70
+ const [activeIdle, setActiveIdle] = useState<MascotActiveIdle | null>(null)
71
+ const [activityRevision, setActivityRevision] = useState(0)
72
+ const optionsRef = useRef(options)
73
+ optionsRef.current = options
74
+ const activeRef = useRef(activeIdle)
75
+ activeRef.current = activeIdle
76
+ const lastActivityRef = useRef(0)
77
+ const lastMotionRef = useRef<MascotActiveIdleMotion | null>(null)
78
+
79
+ const cancel = useCallback(() => setActiveIdle(null), [])
80
+
81
+ const markActivity = useCallback((interrupt: boolean) => {
82
+ if (interrupt) setActiveIdle(null)
83
+ optionsRef.current.onActivity?.()
84
+ const now = Date.now()
85
+ if (now - lastActivityRef.current < MASCOT_ACTIVE_IDLE_ACTIVITY_THROTTLE_MS) return
86
+ lastActivityRef.current = now
87
+ setActivityRevision((value) => value + 1)
88
+ }, [])
89
+
90
+ useEffect(() => {
91
+ const interrupt = (): void => markActivity(true)
92
+ const pointerMove = (): void => {
93
+ if (Date.now() - lastActivityRef.current >= MASCOT_ACTIVE_IDLE_ACTIVITY_THROTTLE_MS) {
94
+ markActivity(interruptOnPointerMove)
95
+ }
96
+ }
97
+ window.addEventListener('keydown', interrupt)
98
+ window.addEventListener('pointerdown', interrupt)
99
+ window.addEventListener('pointermove', pointerMove, { passive: true })
100
+ window.addEventListener('wheel', interrupt, { passive: true })
101
+ window.addEventListener('focusin', interrupt)
102
+ return () => {
103
+ window.removeEventListener('keydown', interrupt)
104
+ window.removeEventListener('pointerdown', interrupt)
105
+ window.removeEventListener('pointermove', pointerMove)
106
+ window.removeEventListener('wheel', interrupt)
107
+ window.removeEventListener('focusin', interrupt)
108
+ }
109
+ }, [markActivity, interruptOnPointerMove])
110
+
111
+ useEffect(() => {
112
+ if (!enabled) {
113
+ setActiveIdle(null)
114
+ return undefined
115
+ }
116
+ let timer = 0
117
+ const wait = (): number => MASCOT_ACTIVE_IDLE_MIN_MS + Math.random() * MASCOT_ACTIVE_IDLE_JITTER_MS
118
+ const start = (): void => {
119
+ if (document.hidden || activeRef.current) {
120
+ timer = window.setTimeout(start, RETRY_MS)
121
+ return
122
+ }
123
+ const plan = optionsRef.current.plan?.()
124
+ if (plan === null) {
125
+ timer = window.setTimeout(start, wait())
126
+ return
127
+ }
128
+ const motion = pickMascotActiveIdle(Math.random(), lastMotionRef.current, plan?.motions)
129
+ lastMotionRef.current = motion
130
+ optionsRef.current.onStart?.()
131
+ const direction = plan?.direction ?? 'left'
132
+ setActiveIdle({ motion, phase: 'outbound', direction, travel: direction })
133
+ }
134
+ timer = window.setTimeout(start, wait())
135
+ return () => window.clearTimeout(timer)
136
+ }, [enabled, activityRevision])
137
+
138
+ useEffect(() => {
139
+ if (!activeIdle) return undefined
140
+ const delay = activeIdle.phase === 'away'
141
+ ? MASCOT_ACTIVE_IDLE_HOLD_MS[activeIdle.motion]
142
+ : MASCOT_ACTIVE_IDLE_TRAVEL_MS[activeIdle.motion] + LEG_SETTLE_MS
143
+ const timer = window.setTimeout(() => setActiveIdle(advance), delay)
144
+ return () => window.clearTimeout(timer)
145
+ }, [activeIdle])
146
+
147
+ const onAnimationEnd = useCallback((event: AnimationEvent<Element>) => {
148
+ setActiveIdle((current) => {
149
+ if (!current || event.animationName !== finisherOf(current)) return current
150
+ return current.phase === 'outbound' ? { ...current, phase: 'away' } : null
151
+ })
152
+ }, [])
153
+
154
+ const attributes: MascotActiveIdleAttributes = activeIdle
155
+ ? {
156
+ 'data-mascot-active-idle': activeIdle.motion,
157
+ 'data-mascot-idle-phase': activeIdle.phase,
158
+ 'data-mascot-idle-direction': activeIdle.direction,
159
+ 'data-mascot-idle-travel': activeIdle.travel
160
+ }
161
+ : {}
162
+ return {
163
+ activeIdle,
164
+ activityRevision,
165
+ className: activeIdle ? 'composer-mascot-active-idle' : undefined,
166
+ attributes,
167
+ cancel,
168
+ onAnimationEnd
169
+ }
170
+ }