@dotcraft/avatar 0.6.3 → 0.6.4

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 (61) 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 +2 -2
  8. package/dist/composer/ComposerMascot.js.map +1 -1
  9. package/dist/composer/styles/energy.css +99 -99
  10. package/dist/composer/styles/expressions.css +20 -20
  11. package/dist/composer/styles/feedback.css +188 -188
  12. package/dist/composer/styles/focus.css +72 -72
  13. package/dist/composer/styles/foundation.css +301 -287
  14. package/dist/composer/styles/idle.css +438 -438
  15. package/dist/composer/styles/overdrive.css +264 -264
  16. package/dist/composer/styles/props.css +21 -21
  17. package/dist/composer/types.d.ts +1 -0
  18. package/dist/composer/types.d.ts.map +1 -1
  19. package/dist/rig.css +265 -265
  20. package/dist/styles.css +13 -13
  21. package/package.json +56 -55
  22. package/src/AnimatedDecoration.tsx +43 -0
  23. package/src/AppearanceRig.tsx +15 -0
  24. package/src/Avatar.tsx +85 -0
  25. package/src/DecorationShapes.tsx +8 -0
  26. package/src/Decorations.tsx +31 -0
  27. package/src/Faces.tsx +37 -0
  28. package/src/HatDecorations.tsx +75 -0
  29. package/src/HeldDecorations.tsx +48 -0
  30. package/src/MascotRig.tsx +191 -0
  31. package/src/ObjectDecorations.tsx +55 -0
  32. package/src/accessories.css +14 -0
  33. package/src/appearanceModel.ts +65 -0
  34. package/src/avatar.css +62 -0
  35. package/src/characters.ts +5 -0
  36. package/src/composer/ComposerMascot.tsx +521 -0
  37. package/src/composer/ComposerMascotShadow.tsx +5 -0
  38. package/src/composer/constants.ts +68 -0
  39. package/src/composer/mascotHandoff.ts +19 -0
  40. package/src/composer/styles/energy.css +99 -0
  41. package/src/composer/styles/expressions.css +20 -0
  42. package/src/composer/styles/feedback.css +188 -0
  43. package/src/composer/styles/focus.css +72 -0
  44. package/src/composer/styles/foundation.css +301 -0
  45. package/src/composer/styles/idle.css +438 -0
  46. package/src/composer/styles/overdrive.css +264 -0
  47. package/src/composer/styles/props.css +21 -0
  48. package/src/composer/types.ts +32 -0
  49. package/src/composer/useComposerAvatarBehavior.ts +97 -0
  50. package/src/composer/useComposerMotion.ts +15 -0
  51. package/src/composer/useComposerProfile.ts +50 -0
  52. package/src/decorationCatalog.ts +45 -0
  53. package/src/decorationMotion.ts +68 -0
  54. package/src/environment.ts +46 -0
  55. package/src/index.ts +5 -0
  56. package/src/palette.ts +52 -0
  57. package/src/react.ts +6 -0
  58. package/src/rig.css +265 -0
  59. package/src/styles.css +13 -0
  60. package/src/useEventReplay.ts +13 -0
  61. package/src/useGesture.ts +51 -0
@@ -0,0 +1,65 @@
1
+ // Frozen v1 draw order. IDs are serialized; positions are only sampling weights.
2
+ export const primaryIds = ['none', 'baseball-cap', 'bucket-hat', 'beret', 'beanie', 'top-hat', 'wizard-hat', 'chef-hat', 'party-hat', 'crown', 'hard-hat', 'nightcap', 'straw-hat', 'poop', 'banana', 'fried-egg', 'rubber-duck', 'paper-boat', 'traffic-cone', 'sprout', 'donut'] as const
3
+ export const heldIds = ['task-board', 'wrench', 'shield', 'magnifier', 'control-panel'] as const
4
+ export type HeldId = typeof heldIds[number]
5
+ export const secondaryIds = ['none', 'forehead-goggles', ...heldIds] as const
6
+ export type PrimaryId = typeof primaryIds[number]
7
+ export type SecondaryId = typeof secondaryIds[number]
8
+ export type DecorationId = Exclude<PrimaryId | SecondaryId, 'none'>
9
+ export interface Appearance {
10
+ version: 2
11
+ palette: number
12
+ baseFace: number
13
+ primary: PrimaryId
14
+ secondary: SecondaryId
15
+ }
16
+
17
+ // Explicit allow-list, including the undecorated head. No cartesian-product fallback.
18
+ const faceAccessories = ['none', ...heldIds] as const
19
+ export const compatibility: Record<PrimaryId, readonly SecondaryId[]> = {
20
+ none: [...faceAccessories, 'forehead-goggles'],
21
+ 'baseball-cap': faceAccessories, 'bucket-hat': faceAccessories, beret: faceAccessories,
22
+ beanie: faceAccessories, 'top-hat': faceAccessories, 'wizard-hat': faceAccessories,
23
+ 'chef-hat': faceAccessories, 'party-hat': faceAccessories, crown: faceAccessories,
24
+ 'hard-hat': faceAccessories, nightcap: faceAccessories, 'straw-hat': faceAccessories,
25
+ poop: [...faceAccessories, 'forehead-goggles'], banana: [...faceAccessories, 'forehead-goggles'],
26
+ 'fried-egg': [...faceAccessories, 'forehead-goggles'], 'rubber-duck': [...faceAccessories, 'forehead-goggles'],
27
+ 'paper-boat': [...faceAccessories, 'forehead-goggles'], 'traffic-cone': [...faceAccessories, 'forehead-goggles'],
28
+ sprout: [...faceAccessories, 'forehead-goggles'], donut: [...faceAccessories, 'forehead-goggles'],
29
+ }
30
+ export function isCompatible(primary: PrimaryId, secondary: SecondaryId) {
31
+ return compatibility[primary].includes(secondary)
32
+ }
33
+ export function withPrimary(appearance: Appearance, primary: PrimaryId): Appearance {
34
+ return { ...appearance, primary, secondary: isCompatible(primary, appearance.secondary) ? appearance.secondary : 'none' }
35
+ }
36
+ export function hashSeed(seed: string): number {
37
+ let value = 0x811c9dc5
38
+ for (let index = 0; index < seed.length; index++) value = Math.imul(value ^ seed.charCodeAt(index), 0x01000193)
39
+ // Avalanche avoids correlations between adjacent sample IDs and independent dimensions.
40
+ value ^= value >>> 16; value = Math.imul(value, 0x7feb352d)
41
+ value ^= value >>> 15; value = Math.imul(value, 0x846ca68b)
42
+ return (value ^ (value >>> 16)) >>> 0
43
+ }
44
+ export function isHeld(id: SecondaryId): id is HeldId { return (heldIds as readonly string[]).includes(id) }
45
+ export const originalAppearance: Appearance = { version: 2, palette: -1, baseFace: 0, primary: 'none', secondary: 'none' }
46
+ export function normalizeName(name: string): string { return name.trim().normalize('NFC') }
47
+ export function deriveAppearance(name: string): Appearance {
48
+ const seed = normalizeName(name)
49
+ if (!seed) return { ...originalAppearance }
50
+ const draw = (dimension: string) => hashSeed(JSON.stringify(['dotcraft-avatar', 1, seed, dimension])) / 0x100000000
51
+ const primary = primaryIds[Math.floor(draw('primary') * primaryIds.length)]
52
+ const choices = compatibility[primary].filter(id => id !== 'none')
53
+ const secondaryDraw = (dimension: string) => hashSeed(JSON.stringify(['dotcraft-avatar', 2, seed, dimension])) / 0x100000000
54
+ const secondary = secondaryDraw('secondary-presence') < .5 ? 'none' : choices[Math.floor(secondaryDraw('secondary-item') * choices.length)]
55
+ return { version: 2, palette: Math.floor(draw('palette') * 12), baseFace: Math.floor(draw('face') * 5), primary, secondary }
56
+ }
57
+ export function sampleSeed(seed: string, round: number, index: number) {
58
+ return JSON.stringify([seed, round, index])
59
+ }
60
+ export function appearanceWall(seed: string, round = 0) {
61
+ return Array.from({ length: 100 }, (_, index) => {
62
+ const id = sampleSeed(seed, round, index)
63
+ return { id, appearance: deriveAppearance(id) }
64
+ })
65
+ }
package/src/avatar.css ADDED
@@ -0,0 +1,62 @@
1
+ .dca-robot {
2
+ display: inline-flex;
3
+ flex: none;
4
+ vertical-align: middle;
5
+ isolation: isolate;
6
+ }
7
+
8
+ .dca-canvas { overflow: visible; }
9
+ .dca-body-motion { transform-origin: 512px 760px; }
10
+ .dca-robot[data-motion='on'] .dca-body-motion {
11
+ transition: transform 260ms cubic-bezier(.22, .7, .3, 1);
12
+ }
13
+
14
+ /* The production rig owns arm geometry, paired paint layers and all arm motion. */
15
+ .dca-robot[data-compact='true'] .dca-decoration-detail { display: none; }
16
+ .dca-robot[data-exiting='true'] :is(.dca-part-prop-laptop, .dca-part-prop-sign) {
17
+ opacity: 0;
18
+ transition: opacity 140ms ease;
19
+ }
20
+
21
+ .dca-robot[data-pose='thinking'] .dca-body-motion { transform: rotate(-4deg); }
22
+ .dca-robot[data-pose='working'] .dca-body-motion { transform: translateY(12px) rotate(2deg); }
23
+ .dca-robot[data-pose='blocked'] .dca-body-motion { transform: translateY(22px) scaleY(.96); }
24
+ .dca-robot[data-pose='greeting'] .dca-body-motion { transform: rotate(-3deg); }
25
+
26
+ .dca-robot[data-motion='on'][data-pose='idle'] .dca-body-motion { animation: dca-breathe 5s ease-in-out infinite; }
27
+ .dca-robot[data-motion='on'][data-pose='idle'] .dca-part-caret { animation: dca-cursor 7s step-end infinite; }
28
+ .dca-robot[data-motion='on'][data-pose='working'] .dca-body-motion { animation: dca-work var(--dca-loop) ease-in-out infinite; }
29
+ .dca-robot[data-motion='on'][data-pose='done'] .dca-body-motion { animation: dca-celebrate 800ms ease-in-out both; }
30
+ .dca-robot[data-motion='on'][data-pose='acknowledge'] .dca-body-motion { animation: dca-nod 350ms ease-in-out both; }
31
+
32
+ .dca-robot[data-paused='true'] * { animation-play-state: paused !important; }
33
+ .dca-robot[data-exiting='true'] .dca-body-motion { animation: none !important; }
34
+ .dca-robot[data-motion='off'] * { animation: none !important; transition: none !important; }
35
+
36
+ @keyframes dca-breathe {
37
+ 0%, 100% { transform: translateY(0) scaleY(1); }
38
+ 50% { transform: translateY(-9px) scaleY(1.008); }
39
+ }
40
+ @keyframes dca-cursor { 0%, 93%, 100% { opacity: 1; } 95%, 98% { opacity: .12; } }
41
+ @keyframes dca-work {
42
+ 0%, 100% { transform: translateY(12px) rotate(2deg); }
43
+ 50% { transform: translateY(5px) rotate(0deg); }
44
+ }
45
+ @keyframes dca-celebrate {
46
+ 0%, 100% { transform: translateY(0); }
47
+ 20% { transform: translateY(10px) scaleY(.97); }
48
+ 55% { transform: translateY(-30px) rotate(-3deg); }
49
+ }
50
+ @keyframes dca-nod {
51
+ 0%, 100% { transform: translateY(0); }
52
+ 38% { transform: translateY(17px) scaleY(.97); }
53
+ 68% { transform: translateY(-8px); }
54
+ }
55
+
56
+
57
+ .dca-robot[data-pose='sleep'] .dca-body-motion { transform: translateY(18px) rotate(4deg); }
58
+ .dca-robot[data-pose='sleep'] .dca-part-light { opacity: .45; }
59
+ .dca-robot[data-motion='off'][data-pose='greeting'] :is(.dca-part-arm-r-w, .dca-part-arm-r-b) { transform: translate(-48px, 88px) rotate(-110deg) scaleY(.8); }
60
+ @media (prefers-reduced-motion: reduce) {
61
+ .dca-robot[data-mode='system'] * { animation: none !important; transition: none !important; }
62
+ }
@@ -0,0 +1,5 @@
1
+ export type AvatarState = 'idle' | 'thinking' | 'working' | 'waiting' | 'blocked' | 'done'
2
+ export type AvatarPose = AvatarState | 'greeting' | 'acknowledge' | 'sleep'
3
+ export type MotionMode = 'system' | 'on' | 'off'
4
+ export type AvatarExpression = 'base' | 'happy' | 'operator' | 'sleep'
5
+ export type AvatarGesture = 'blink' | 'look-left' | 'look-right' | 'antenna-bob'
@@ -0,0 +1,521 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type AnimationEvent, type CSSProperties } from 'react'
2
+ import { Avatar } from '../Avatar.js'
3
+ import { deriveAppearance, mascotPaletteOf, type AvatarPose } from '../index.js'
4
+ import { useComposerAvatarBehavior } from './useComposerAvatarBehavior.js'
5
+ import { consumeMascotHandoff, recordMascotHandoff } from './mascotHandoff.js'
6
+ import { useComposerProfile } from './useComposerProfile.js'
7
+ import { useComposerMotion } from './useComposerMotion.js'
8
+ import { MASCOT_SIZE, MASCOT_SCALE, MASCOT_HIDDEN_RATIO, MASCOT_RAISE, MASCOT_SLEEP_AFTER_MS, MASCOT_WAVE_DURATION_MS, MASCOT_ACTIVE_IDLE_MIN_MS, MASCOT_ACTIVE_IDLE_JITTER_MS, MASCOT_ACTIVE_IDLE_ACTIVITY_THROTTLE_MS, MASCOT_ACTIVE_IDLE_TRAVEL_MS, MASCOT_ACTIVE_IDLE_HOLD_MS, MASCOT_SPARKLES, pickMascotActiveIdle, type MascotActiveIdleState, type MascotActiveIdleMotion } from './constants.js'
9
+ import type { ComposerMascotProps, ComposerMascotContext, MascotExpression, MascotLight } from './types.js'
10
+ export function ComposerMascot({ name, motion = 'system', theme = 'dark', focused = false, dragOver = false, bounceSignal = 0, interaction, reasoningEffort = 'off', speed = 'standard', contextMax = false, anchorOffset = 0, anchorPushSignal = 0, handoff = false, renderCharacter, renderMenu, onNameRendered }: ComposerMascotProps) {
11
+ const reduced = !useComposerMotion(motion)
12
+ const { avatar, profileTransition, profileTransitionRevision } = useComposerProfile(name, reduced)
13
+ useEffect(() => { onNameRendered?.(avatar) }, [avatar, onNameRendered])
14
+ const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
15
+ const [ambientSleeping, setSleeping] = useState(false)
16
+ const sleeping = ambientSleeping || interaction?.expression === 'sleep'
17
+ const [waving, setWaving] = useState(false)
18
+ const [greetingSequence, setGreetingSequence] = useState(0)
19
+ const [startled, setStartled] = useState(false)
20
+ const [launching, setLaunching] = useState(false)
21
+ const [cheering, setCheering] = useState(false)
22
+ const [sparkling, setSparkling] = useState(false)
23
+ const [shaking, setShaking] = useState(false)
24
+ const [nodding, setNodding] = useState(false)
25
+ const [landing, setLanding] = useState(false)
26
+ const [pushLift, setPushLift] = useState(false)
27
+ const [activeIdle, setActiveIdle] = useState<MascotActiveIdleState | null>(null)
28
+ const [activityRevision, setActivityRevision] = useState(0)
29
+ const rootRef = useRef<HTMLDivElement | null>(null)
30
+ const lastActivityRef = useRef(0)
31
+ const lastActiveIdleRef = useRef<MascotActiveIdleMotion | null>(null)
32
+
33
+ const baseExpression: MascotExpression =
34
+ interaction?.expression ?? (dragOver ? 'operator' : focused ? 'happy' : 'neutral')
35
+ const light: MascotLight = interaction?.light ?? 'default'
36
+ const hasMenu = renderMenu != null
37
+ const bubble = interaction?.bubble ?? null
38
+ const holdSign = interaction?.hold === 'sign'
39
+
40
+ const laptopActive =
41
+ !sleeping &&
42
+ !dragOver &&
43
+ !holdSign &&
44
+ bubble == null &&
45
+ baseExpression === 'operator' &&
46
+ light === 'default'
47
+ const expression: MascotExpression = sleeping ? 'sleep' : waving ? 'happy' : baseExpression
48
+ const semanticAvatarPose: AvatarPose = light === 'error'
49
+ ? 'blocked'
50
+ : light === 'success'
51
+ ? 'done'
52
+ : sleeping
53
+ ? 'sleep'
54
+ : waving
55
+ ? 'greeting'
56
+ : holdSign || bubble != null
57
+ ? 'waiting'
58
+ : laptopActive
59
+ ? 'working'
60
+ : 'idle'
61
+ const avatarBehavior = useComposerAvatarBehavior({
62
+ semanticPose: semanticAvatarPose,
63
+ baseExpression,
64
+ focused,
65
+ dragOver,
66
+ sleeping,
67
+ waving,
68
+ activeIdle: activeIdle != null,
69
+ bounceSignal,
70
+ reducedMotion: reduced
71
+ })
72
+ const mascotPalette = mascotPaletteOf(deriveAppearance(avatar ?? ''))
73
+ const activity: ComposerMascotContext["activity"] = light === 'error'
74
+ ? 'error'
75
+ : light === 'success'
76
+ ? 'success'
77
+ : sleeping
78
+ ? 'sleeping'
79
+ : dragOver
80
+ ? 'dragging'
81
+ : holdSign
82
+ ? 'decision'
83
+ : baseExpression === 'operator'
84
+ ? 'working'
85
+ : focused
86
+ ? 'focused'
87
+ : 'idle'
88
+ const context: ComposerMascotContext = {
89
+ size: MASCOT_SIZE,
90
+ activity,
91
+ expression,
92
+ light,
93
+ submitRevision: bounceSignal,
94
+ reasoningEffort,
95
+ speed,
96
+ contextMax,
97
+ reducedMotion: reduced
98
+ }
99
+ const ambient =
100
+ !focused &&
101
+ !dragOver &&
102
+ !bubble &&
103
+ !holdSign &&
104
+ menuPos == null &&
105
+ baseExpression === 'neutral' &&
106
+ light === 'default'
107
+
108
+ const markActivity = useCallback(() => {
109
+ setActiveIdle(null)
110
+ setSleeping((current) => {
111
+ if (current && !reduced) setStartled(true)
112
+ return false
113
+ })
114
+ const now = Date.now()
115
+ if (now - lastActivityRef.current < MASCOT_ACTIVE_IDLE_ACTIVITY_THROTTLE_MS) return
116
+ lastActivityRef.current = now
117
+ setActivityRevision((value) => value + 1)
118
+ }, [reduced])
119
+
120
+ useEffect(() => {
121
+ const markPointerMoveActivity = (): void => {
122
+ if (Date.now() - lastActivityRef.current >= MASCOT_ACTIVE_IDLE_ACTIVITY_THROTTLE_MS) {
123
+ markActivity()
124
+ }
125
+ }
126
+ window.addEventListener('keydown', markActivity)
127
+ window.addEventListener('pointerdown', markActivity)
128
+ window.addEventListener('pointermove', markPointerMoveActivity, { passive: true })
129
+ window.addEventListener('wheel', markActivity, { passive: true })
130
+ window.addEventListener('focusin', markActivity)
131
+ return () => {
132
+ window.removeEventListener('keydown', markActivity)
133
+ window.removeEventListener('pointerdown', markActivity)
134
+ window.removeEventListener('pointermove', markPointerMoveActivity)
135
+ window.removeEventListener('wheel', markActivity)
136
+ window.removeEventListener('focusin', markActivity)
137
+ }
138
+ }, [markActivity])
139
+
140
+ useEffect(() => {
141
+ if (!ambient || sleeping || reduced) {
142
+ setActiveIdle(null)
143
+ return undefined
144
+ }
145
+
146
+ let timer = 0
147
+ const start = (): void => {
148
+ if (document.hidden) {
149
+ timer = window.setTimeout(start, 5000)
150
+ return
151
+ }
152
+ const motion = pickMascotActiveIdle(Math.random(), lastActiveIdleRef.current)
153
+ lastActiveIdleRef.current = motion
154
+ avatarBehavior.clearGesture()
155
+ setActiveIdle({ motion, phase: 'outbound' })
156
+ }
157
+ timer = window.setTimeout(
158
+ start,
159
+ MASCOT_ACTIVE_IDLE_MIN_MS + Math.random() * MASCOT_ACTIVE_IDLE_JITTER_MS
160
+ )
161
+ return () => window.clearTimeout(timer)
162
+ }, [ambient, activityRevision, sleeping, reduced, avatarBehavior.clearGesture])
163
+
164
+ useEffect(() => {
165
+ if (!activeIdle) return undefined
166
+ const delay = activeIdle.phase === 'away'
167
+ ? MASCOT_ACTIVE_IDLE_HOLD_MS[activeIdle.motion]
168
+ : MASCOT_ACTIVE_IDLE_TRAVEL_MS[activeIdle.motion] + 160
169
+ const timer = window.setTimeout(() => {
170
+ setActiveIdle((current) => {
171
+ if (!current) return null
172
+ if (current.phase === 'outbound') return { ...current, phase: 'away' }
173
+ if (current.phase === 'away') return { ...current, phase: 'inbound' }
174
+ return null
175
+ })
176
+ }, delay)
177
+ return () => window.clearTimeout(timer)
178
+ }, [activeIdle])
179
+
180
+ useLayoutEffect(() => {
181
+ if (!handoff) return undefined
182
+ const el = rootRef.current
183
+ if (!el) return undefined
184
+ let timer = 0
185
+ const dy = reduced ? null : consumeMascotHandoff(el)
186
+ if (dy != null) {
187
+ const rising = dy > 0
188
+ el.style.transition = 'none'
189
+ el.style.transform = `translateY(${dy}px)`
190
+ void el.offsetHeight
191
+ el.style.transition = rising
192
+ ? 'transform 420ms cubic-bezier(0.34, 1.56, 0.64, 1)'
193
+ : 'transform 300ms cubic-bezier(0.55, 0, 0.8, 0.9)'
194
+ el.style.transform = 'translateY(0)'
195
+ if (rising) setStartled(true)
196
+ timer = window.setTimeout(() => {
197
+ el.style.transition = ''
198
+ el.style.transform = ''
199
+ if (!rising) setLanding(true)
200
+ }, rising ? 430 : 310)
201
+ }
202
+ return () => {
203
+ window.clearTimeout(timer)
204
+ recordMascotHandoff(el)
205
+ }
206
+ }, [handoff])
207
+
208
+ const previousAnchorOffsetRef = useRef(anchorOffset)
209
+ useLayoutEffect(() => {
210
+ const el = rootRef.current
211
+ const previousOffset = previousAnchorOffsetRef.current
212
+ previousAnchorOffsetRef.current = anchorOffset
213
+ if (!el || previousOffset === anchorOffset || reduced) return undefined
214
+
215
+ if (anchorOffset > previousOffset) {
216
+
217
+ el.style.transition = ''
218
+ el.style.transform = ''
219
+ setLanding(false)
220
+ return undefined
221
+ }
222
+
223
+ const currentVisualTop = el.getBoundingClientRect().top
224
+ const offsetDelta = anchorOffset - previousOffset
225
+ el.style.transition = 'none'
226
+ el.style.transform = ''
227
+ const targetTop = el.getBoundingClientRect().top
228
+ const dy = currentVisualTop + offsetDelta - targetTop
229
+ if (Math.abs(dy) < 1) return undefined
230
+
231
+ const rising = dy > 0
232
+ let timer = 0
233
+ el.style.transform = `translateY(${dy}px)`
234
+ void el.offsetHeight
235
+ el.style.transition = rising
236
+ ? 'transform 420ms cubic-bezier(0.34, 1.56, 0.64, 1)'
237
+ : 'transform 300ms cubic-bezier(0.55, 0, 0.8, 0.9)'
238
+ el.style.transform = 'translateY(0)'
239
+ if (rising) {
240
+ setStartled(true)
241
+ } else {
242
+
243
+ setStartled(false)
244
+ setPushLift(false)
245
+ }
246
+ timer = window.setTimeout(() => {
247
+ el.style.transition = ''
248
+ el.style.transform = ''
249
+ if (!rising) setLanding(true)
250
+ }, rising ? 430 : 310)
251
+
252
+ return () => window.clearTimeout(timer)
253
+ }, [anchorOffset])
254
+
255
+ const previousAnchorPushSignalRef = useRef(anchorPushSignal)
256
+ useEffect(() => {
257
+ if (anchorPushSignal === previousAnchorPushSignalRef.current) return
258
+ previousAnchorPushSignalRef.current = anchorPushSignal
259
+ if (!reduced) setPushLift(true)
260
+ }, [anchorPushSignal])
261
+
262
+ const prevBounceRef = useRef(bounceSignal)
263
+ useEffect(() => {
264
+ if (bounceSignal === prevBounceRef.current) return
265
+ prevBounceRef.current = bounceSignal
266
+ if (!reduced) setLaunching(true)
267
+ }, [bounceSignal])
268
+
269
+ const prevLightRef = useRef(light)
270
+ useEffect(() => {
271
+ const prev = prevLightRef.current
272
+ prevLightRef.current = light
273
+ if (light === prev || reduced) return
274
+ if (light === 'success') {
275
+ setCheering(true)
276
+ setSparkling(true)
277
+ } else if (light === 'error') {
278
+ setShaking(true)
279
+ }
280
+ }, [light])
281
+
282
+ useEffect(() => {
283
+ if (!ambient || reduced) {
284
+ setSleeping(false)
285
+ return
286
+ }
287
+ if (sleeping || activeIdle) return
288
+ const timer = window.setTimeout(() => setSleeping(true), MASCOT_SLEEP_AFTER_MS)
289
+ return () => window.clearTimeout(timer)
290
+ }, [ambient, activityRevision, sleeping, activeIdle, reduced])
291
+
292
+ const wake = useCallback(() => {
293
+ setSleeping(false)
294
+ if (!reduced) setStartled(true)
295
+ }, [reduced])
296
+
297
+ useEffect(() => {
298
+ if (!waving) return
299
+ const timer = window.setTimeout(() => setWaving(false), MASCOT_WAVE_DURATION_MS)
300
+ return () => window.clearTimeout(timer)
301
+ }, [waving])
302
+
303
+ useEffect(() => {
304
+ if (!focused || reduced) return
305
+ const onKeyDown = (event: KeyboardEvent): void => {
306
+ if (event.ctrlKey || event.metaKey || event.altKey) return
307
+ setNodding(true)
308
+ }
309
+ window.addEventListener('keydown', onKeyDown)
310
+ return () => window.removeEventListener('keydown', onKeyDown)
311
+ }, [focused, reduced])
312
+
313
+ const onAnimationEnd = (event: AnimationEvent<HTMLDivElement>): void => {
314
+ if (activeIdle) {
315
+ const expected = activeIdle.motion === 'hop'
316
+ ? 'composer-mascot-idle-hop-travel'
317
+ : activeIdle.motion === 'rocket'
318
+ ? 'composer-mascot-idle-rocket-flight-x'
319
+ : activeIdle.phase === 'outbound'
320
+ ? 'composer-mascot-idle-hover-launch-body'
321
+ : 'composer-mascot-idle-hover-land-body'
322
+ if (event.animationName === expected) {
323
+ setActiveIdle((current) => {
324
+ if (!current) return null
325
+ return current.phase === 'outbound' ? { ...current, phase: 'away' } : null
326
+ })
327
+ }
328
+ }
329
+ switch (event.animationName) {
330
+ case 'composer-mascot-launch':
331
+ setLaunching(false)
332
+ break
333
+ case 'composer-mascot-cheer':
334
+ setCheering(false)
335
+ break
336
+ case 'composer-mascot-sparkle':
337
+ setSparkling(false)
338
+ break
339
+ case 'composer-mascot-shake':
340
+ setShaking(false)
341
+ break
342
+ case 'composer-mascot-startle':
343
+ setStartled(false)
344
+ break
345
+ case 'composer-mascot-nod':
346
+ setNodding(false)
347
+ break
348
+ case 'composer-mascot-land':
349
+ setLanding(false)
350
+ break
351
+ case 'composer-mascot-push-lift':
352
+ setPushLift(false)
353
+ break
354
+ }
355
+ }
356
+
357
+ const poseTransform = sleeping
358
+ ? 'translateY(2px) rotate(2.6deg) scale(0.985)'
359
+ : light === 'error'
360
+ ? 'translateY(2px) rotate(-3deg) scale(0.98)'
361
+ : focused
362
+ ? 'scale(1.1)'
363
+ : 'scale(1)'
364
+
365
+ const shotClass = cheering
366
+ ? 'composer-mascot-cheer'
367
+ : shaking
368
+ ? 'composer-mascot-shake'
369
+ : pushLift
370
+ ? 'composer-mascot-push-lift'
371
+ : startled
372
+ ? 'composer-mascot-startle'
373
+ : landing
374
+ ? 'composer-mascot-land'
375
+ : launching
376
+ ? 'composer-mascot-launch'
377
+ : nodding
378
+ ? 'composer-mascot-nod'
379
+ : undefined
380
+
381
+ const loopClass = sleeping
382
+ ? 'composer-mascot-sleep-breathe'
383
+ : dragOver
384
+ ? 'composer-mascot-eager'
385
+ : baseExpression === 'operator' && light === 'default'
386
+ ? 'composer-mascot-think'
387
+ : 'composer-mascot-breathe'
388
+
389
+ const rootClassName =
390
+ [
391
+ activeIdle ? 'composer-mascot-active-idle' : null,
392
+ sleeping ? 'composer-mascot-sleeping' : null,
393
+ ]
394
+ .filter(Boolean)
395
+ .join(' ') || undefined
396
+
397
+ const character = <Avatar name={avatar ?? ''} size={MASCOT_SIZE} state={avatarBehavior.pose}
398
+ expression={avatarBehavior.expression} gesture={avatarBehavior.gesture}
399
+ gestureSequence={avatarBehavior.gestureSequence} onGestureComplete={avatarBehavior.completeGesture}
400
+ eventSequence={bounceSignal + greetingSequence} motion={reduced ? 'off' : 'on'} />
401
+ return (
402
+ <div
403
+
404
+ aria-hidden={interaction ? undefined : true}
405
+ data-composer-mascot-motion={reduced ? 'off' : 'on'}
406
+ ref={rootRef}
407
+ className={rootClassName}
408
+ data-mascot-name={avatar ?? ''}
409
+ data-mascot-theme={theme}
410
+ data-mascot-effort={reasoningEffort}
411
+ data-mascot-speed={speed}
412
+ data-mascot-context={contextMax ? 'max' : 'default'}
413
+ data-mascot-profile-transition={profileTransition ? 'active' : 'idle'}
414
+ data-mascot-active-idle={activeIdle?.motion}
415
+ data-mascot-idle-phase={activeIdle?.phase}
416
+ data-mascot-anchor-offset={anchorOffset}
417
+ onAnimationEnd={onAnimationEnd}
418
+ style={{
419
+ '--mascot-body-dark': mascotPalette.bodyD,
420
+ '--mascot-body-mid': mascotPalette.bodyM,
421
+ '--mascot-body-light': mascotPalette.bodyL,
422
+ '--mascot-mark-dark': mascotPalette.markD,
423
+ '--mascot-mark-energy': mascotPalette.markM,
424
+ '--mascot-energy-accent': mascotPalette.accent,
425
+ '--mascot-profile-from-accent': profileTransition?.fromAccent ?? mascotPalette.accent,
426
+ '--mascot-profile-to-accent': profileTransition?.toAccent ?? mascotPalette.accent,
427
+ position: 'absolute',
428
+ right: '40px',
429
+ top: `${-(MASCOT_SIZE * (1 - MASCOT_HIDDEN_RATIO)) - MASCOT_RAISE - anchorOffset}px`,
430
+ zIndex: 0,
431
+ pointerEvents: 'none'
432
+ } as CSSProperties}
433
+ >
434
+ {bubble && (
435
+ <div
436
+ style={{
437
+ position: 'absolute',
438
+ right: 0,
439
+ bottom: 'calc(100% + 8px)',
440
+ zIndex: 5,
441
+ pointerEvents: 'auto'
442
+ }}
443
+ >
444
+ {bubble}
445
+ </div>
446
+ )}
447
+ <div
448
+ key={profileTransitionRevision}
449
+ className="composer-mascot-motion"
450
+ style={{
451
+ transformOrigin: 'bottom center',
452
+ transform: `scale(${MASCOT_SCALE})`,
453
+
454
+ filter: `drop-shadow(0 5.3px 7.3px color-mix(in srgb, ${mascotPalette.shadow} 20%, transparent))`
455
+ }}
456
+ >
457
+ <div
458
+ style={{
459
+ transformOrigin: 'bottom center',
460
+ transition: 'transform 280ms cubic-bezier(0.34, 1.56, 0.64, 1)',
461
+ transform: poseTransform
462
+ }}
463
+ >
464
+ <div className={shotClass}>
465
+ <div className={loopClass}>
466
+ <div
467
+ className="composer-mascot-jelly"
468
+ style={{ pointerEvents: 'auto', cursor: hasMenu ? 'context-menu' : undefined }}
469
+ onMouseEnter={sleeping ? wake : undefined}
470
+ onClick={() => {
471
+ if (sleeping) {
472
+ wake()
473
+ return
474
+ }
475
+ if (!reduced) {
476
+ setWaving(true)
477
+ setGreetingSequence((value) => value + 1)
478
+ }
479
+ }}
480
+ onContextMenu={
481
+ hasMenu
482
+ ? (e) => {
483
+ e.preventDefault()
484
+ setMenuPos({ x: e.clientX, y: e.clientY })
485
+ }
486
+ : undefined
487
+ }
488
+ >
489
+ <div className="composer-mascot-fast-echo">
490
+ <div className="composer-mascot-character-stage">
491
+ {renderCharacter ? renderCharacter(character, context) : character}
492
+ </div>
493
+ </div>
494
+ </div>
495
+ </div>
496
+ </div>
497
+ </div>
498
+
499
+ {sleeping && (
500
+ <div aria-hidden className="composer-mascot-zzz">
501
+ <span>z</span>
502
+ <span>z</span>
503
+ <span>z</span>
504
+ </div>
505
+ )}
506
+ {sparkling && (
507
+ <div aria-hidden className="composer-mascot-sparkles">
508
+ {MASCOT_SPARKLES.map((s, i) => (
509
+ <i
510
+ key={i}
511
+ style={{ '--dx': s.dx, '--dy': s.dy, animationDelay: s.delay } as CSSProperties}
512
+ />
513
+ ))}
514
+ </div>
515
+ )}
516
+ </div>
517
+
518
+ {menuPos && renderMenu?.(menuPos, () => setMenuPos(null))}
519
+ </div>
520
+ )
521
+ }
@@ -0,0 +1,5 @@
1
+ import { deriveAppearance, mascotPaletteOf } from '../index.js'
2
+ export function ComposerMascotShadow({ name = '' }: { name?: string }) {
3
+ const palette = mascotPaletteOf(deriveAppearance(name))
4
+ return <div aria-hidden className="composer-mascot-contact-shadow" style={{ background: `radial-gradient(50% 100% at 50% 0%, color-mix(in srgb, ${palette.shadow} 10%, transparent) 0%, transparent 72%)` }} />
5
+ }