@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.
- package/LICENSE +201 -201
- package/README.md +46 -43
- package/dist/accessories.css +14 -14
- package/dist/avatar.css +62 -62
- package/dist/composer/ComposerMascot.d.ts +1 -1
- package/dist/composer/ComposerMascot.d.ts.map +1 -1
- package/dist/composer/ComposerMascot.js +2 -2
- package/dist/composer/ComposerMascot.js.map +1 -1
- package/dist/composer/styles/energy.css +99 -99
- package/dist/composer/styles/expressions.css +20 -20
- package/dist/composer/styles/feedback.css +188 -188
- package/dist/composer/styles/focus.css +72 -72
- package/dist/composer/styles/foundation.css +301 -287
- package/dist/composer/styles/idle.css +438 -438
- package/dist/composer/styles/overdrive.css +264 -264
- package/dist/composer/styles/props.css +21 -21
- package/dist/composer/types.d.ts +1 -0
- package/dist/composer/types.d.ts.map +1 -1
- package/dist/rig.css +265 -265
- package/dist/styles.css +13 -13
- package/package.json +56 -55
- package/src/AnimatedDecoration.tsx +43 -0
- package/src/AppearanceRig.tsx +15 -0
- package/src/Avatar.tsx +85 -0
- package/src/DecorationShapes.tsx +8 -0
- package/src/Decorations.tsx +31 -0
- package/src/Faces.tsx +37 -0
- package/src/HatDecorations.tsx +75 -0
- package/src/HeldDecorations.tsx +48 -0
- package/src/MascotRig.tsx +191 -0
- package/src/ObjectDecorations.tsx +55 -0
- package/src/accessories.css +14 -0
- package/src/appearanceModel.ts +65 -0
- package/src/avatar.css +62 -0
- package/src/characters.ts +5 -0
- package/src/composer/ComposerMascot.tsx +521 -0
- package/src/composer/ComposerMascotShadow.tsx +5 -0
- package/src/composer/constants.ts +68 -0
- package/src/composer/mascotHandoff.ts +19 -0
- package/src/composer/styles/energy.css +99 -0
- package/src/composer/styles/expressions.css +20 -0
- package/src/composer/styles/feedback.css +188 -0
- package/src/composer/styles/focus.css +72 -0
- package/src/composer/styles/foundation.css +301 -0
- package/src/composer/styles/idle.css +438 -0
- package/src/composer/styles/overdrive.css +264 -0
- package/src/composer/styles/props.css +21 -0
- package/src/composer/types.ts +32 -0
- package/src/composer/useComposerAvatarBehavior.ts +97 -0
- package/src/composer/useComposerMotion.ts +15 -0
- package/src/composer/useComposerProfile.ts +50 -0
- package/src/decorationCatalog.ts +45 -0
- package/src/decorationMotion.ts +68 -0
- package/src/environment.ts +46 -0
- package/src/index.ts +5 -0
- package/src/palette.ts +52 -0
- package/src/react.ts +6 -0
- package/src/rig.css +265 -0
- package/src/styles.css +13 -0
- package/src/useEventReplay.ts +13 -0
- 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
|
+
}
|