clawmate 1.0.0
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/electron-builder.yml +23 -0
- package/index.js +341 -0
- package/main/ai-bridge.js +261 -0
- package/main/ai-connector.js +169 -0
- package/main/autostart.js +42 -0
- package/main/desktop-path.js +33 -0
- package/main/file-ops.js +146 -0
- package/main/index.js +128 -0
- package/main/ipc-handlers.js +104 -0
- package/main/manifest.js +70 -0
- package/main/platform.js +33 -0
- package/main/store.js +45 -0
- package/main/tray.js +125 -0
- package/openclaw.plugin.json +15 -0
- package/package.json +30 -0
- package/preload/preload.js +57 -0
- package/renderer/css/effects.css +87 -0
- package/renderer/css/launcher.css +127 -0
- package/renderer/css/pet.css +109 -0
- package/renderer/css/speech.css +72 -0
- package/renderer/first-run.html +90 -0
- package/renderer/index.html +62 -0
- package/renderer/js/ai-controller.js +206 -0
- package/renderer/js/app.js +107 -0
- package/renderer/js/character.js +396 -0
- package/renderer/js/interactions.js +164 -0
- package/renderer/js/memory.js +322 -0
- package/renderer/js/mode-manager.js +82 -0
- package/renderer/js/pet-engine.js +228 -0
- package/renderer/js/speech.js +116 -0
- package/renderer/js/state-machine.js +175 -0
- package/renderer/js/time-aware.js +93 -0
- package/renderer/launcher.html +58 -0
- package/shared/constants.js +80 -0
- package/shared/messages.js +103 -0
- package/shared/personalities.js +106 -0
- package/skills/launch-pet/index.js +64 -0
- package/skills/launch-pet/skill.json +26 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="ko">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<title>ClawMate - 첫 만남</title>
|
|
6
|
+
<link rel="stylesheet" href="css/launcher.css">
|
|
7
|
+
<style>
|
|
8
|
+
.intro-text {
|
|
9
|
+
text-align: center;
|
|
10
|
+
color: #666;
|
|
11
|
+
font-size: 14px;
|
|
12
|
+
margin: 20px 0;
|
|
13
|
+
line-height: 1.6;
|
|
14
|
+
}
|
|
15
|
+
.welcome-anim {
|
|
16
|
+
font-size: 48px;
|
|
17
|
+
animation: bounce 1s ease-in-out infinite alternate;
|
|
18
|
+
text-align: center;
|
|
19
|
+
margin: 20px 0;
|
|
20
|
+
}
|
|
21
|
+
@keyframes bounce {
|
|
22
|
+
0% { transform: translateY(0); }
|
|
23
|
+
100% { transform: translateY(-10px); }
|
|
24
|
+
}
|
|
25
|
+
</style>
|
|
26
|
+
</head>
|
|
27
|
+
<body>
|
|
28
|
+
<div class="launcher">
|
|
29
|
+
<div class="launcher-header">
|
|
30
|
+
<div class="welcome-anim">\u{1F99E}</div>
|
|
31
|
+
<h1>환영합니다!</h1>
|
|
32
|
+
<p class="subtitle">ClawMate에 오신 걸 환영해요</p>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div class="intro-text">
|
|
36
|
+
<p>바탕화면 위를 돌아다니며 함께하는 작은 친구를 소개합니다.</p>
|
|
37
|
+
<p>오래 함께할수록 친구의 모습이 점점 변해갈 거예요!</p>
|
|
38
|
+
<p><strong>어떤 모드로 시작할까요?</strong></p>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div class="launcher-body">
|
|
42
|
+
<div class="mode-card" id="mode-pet" onclick="selectMode('pet')">
|
|
43
|
+
<div class="mode-icon">\u{1F990}</div>
|
|
44
|
+
<h2>Clawby</h2>
|
|
45
|
+
<p>귀여운 동반자</p>
|
|
46
|
+
<span class="mode-desc">장난기 많고 다정한 꼬마 랍스터!</span>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div class="mode-card" id="mode-incarnation" onclick="selectMode('incarnation')">
|
|
50
|
+
<div class="mode-icon">\u{1F980}</div>
|
|
51
|
+
<h2>OpenClaw</h2>
|
|
52
|
+
<p>육체를 얻은 존재</p>
|
|
53
|
+
<span class="mode-desc">침착하고 카리스마 있는 Claw!</span>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<button class="start-btn" id="start-btn" disabled onclick="startFirstRun()">
|
|
58
|
+
모드를 선택해주세요
|
|
59
|
+
</button>
|
|
60
|
+
|
|
61
|
+
<p class="tip" style="text-align:center;color:#999;font-size:11px;margin-top:12px;">
|
|
62
|
+
나중에 3번 연속 클릭이나 트레이 메뉴에서 모드를 바꿀 수 있어요
|
|
63
|
+
</p>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<script>
|
|
67
|
+
let selectedMode = null;
|
|
68
|
+
|
|
69
|
+
function selectMode(mode) {
|
|
70
|
+
selectedMode = mode;
|
|
71
|
+
document.querySelectorAll('.mode-card').forEach(c => c.classList.remove('selected'));
|
|
72
|
+
document.getElementById('mode-' + mode).classList.add('selected');
|
|
73
|
+
const btn = document.getElementById('start-btn');
|
|
74
|
+
btn.disabled = false;
|
|
75
|
+
btn.textContent = mode === 'pet' ? 'Clawby와 시작!' : 'OpenClaw과 시작!';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function startFirstRun() {
|
|
79
|
+
if (!selectedMode) return;
|
|
80
|
+
await window.clawmate.setMode(selectedMode);
|
|
81
|
+
await window.clawmate.saveMemory({
|
|
82
|
+
firstRunDate: new Date().toISOString(),
|
|
83
|
+
totalDays: 0,
|
|
84
|
+
totalClicks: 0,
|
|
85
|
+
});
|
|
86
|
+
window.close();
|
|
87
|
+
}
|
|
88
|
+
</script>
|
|
89
|
+
</body>
|
|
90
|
+
</html>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="ko">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>ClawMate</title>
|
|
7
|
+
<link rel="stylesheet" href="css/pet.css">
|
|
8
|
+
<link rel="stylesheet" href="css/speech.css">
|
|
9
|
+
<link rel="stylesheet" href="css/effects.css">
|
|
10
|
+
<style>
|
|
11
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
12
|
+
html, body {
|
|
13
|
+
width: 100%;
|
|
14
|
+
height: 100%;
|
|
15
|
+
overflow: hidden;
|
|
16
|
+
background: transparent;
|
|
17
|
+
user-select: none;
|
|
18
|
+
-webkit-app-region: no-drag;
|
|
19
|
+
}
|
|
20
|
+
#world {
|
|
21
|
+
position: absolute;
|
|
22
|
+
top: 0; left: 0;
|
|
23
|
+
width: 100%;
|
|
24
|
+
height: 100%;
|
|
25
|
+
pointer-events: none;
|
|
26
|
+
}
|
|
27
|
+
#pet-container {
|
|
28
|
+
position: absolute;
|
|
29
|
+
pointer-events: auto;
|
|
30
|
+
cursor: pointer;
|
|
31
|
+
z-index: 1000;
|
|
32
|
+
}
|
|
33
|
+
#speech-container {
|
|
34
|
+
position: absolute;
|
|
35
|
+
pointer-events: none;
|
|
36
|
+
z-index: 1001;
|
|
37
|
+
}
|
|
38
|
+
</style>
|
|
39
|
+
</head>
|
|
40
|
+
<body>
|
|
41
|
+
<div id="world">
|
|
42
|
+
<div id="pet-container"></div>
|
|
43
|
+
<div id="speech-container"></div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<!-- Shared 데이터 -->
|
|
47
|
+
<script src="../shared/messages.js"></script>
|
|
48
|
+
<script src="../shared/personalities.js"></script>
|
|
49
|
+
|
|
50
|
+
<!-- 모듈 -->
|
|
51
|
+
<script src="js/character.js"></script>
|
|
52
|
+
<script src="js/state-machine.js"></script>
|
|
53
|
+
<script src="js/pet-engine.js"></script>
|
|
54
|
+
<script src="js/speech.js"></script>
|
|
55
|
+
<script src="js/interactions.js"></script>
|
|
56
|
+
<script src="js/time-aware.js"></script>
|
|
57
|
+
<script src="js/mode-manager.js"></script>
|
|
58
|
+
<script src="js/memory.js"></script>
|
|
59
|
+
<script src="js/ai-controller.js"></script>
|
|
60
|
+
<script src="js/app.js"></script>
|
|
61
|
+
</body>
|
|
62
|
+
</html>
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI 행동 컨트롤러
|
|
3
|
+
*
|
|
4
|
+
* OpenClaw이 연결되면 → AI가 모든 행동을 결정
|
|
5
|
+
* OpenClaw이 끊기면 → 자율 모드 (기존 FSM) 로 폴백
|
|
6
|
+
*
|
|
7
|
+
* OpenClaw AI가 결정하는 것:
|
|
8
|
+
* - 언제 뭐라고 말할지
|
|
9
|
+
* - 어디로 움직일지
|
|
10
|
+
* - 어떤 감정을 표현할지
|
|
11
|
+
* - 파일을 집을지 말지
|
|
12
|
+
* - 사용자 행동에 어떻게 반응할지
|
|
13
|
+
*/
|
|
14
|
+
const AIController = (() => {
|
|
15
|
+
let connected = false;
|
|
16
|
+
let autonomousMode = true; // AI 미연결 시 자율 모드
|
|
17
|
+
let pendingDecision = null;
|
|
18
|
+
let lastAIAction = 0;
|
|
19
|
+
|
|
20
|
+
// AI 연결 상태에 따라 preload를 통해 IPC로 통신
|
|
21
|
+
// (main 프로세스의 AIBridge가 WebSocket 관리)
|
|
22
|
+
|
|
23
|
+
function init() {
|
|
24
|
+
// main 프로세스에서 AI 명령이 오면 실행
|
|
25
|
+
if (window.clawmate.onAICommand) {
|
|
26
|
+
window.clawmate.onAICommand((command) => {
|
|
27
|
+
handleAICommand(command);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (window.clawmate.onAIConnected) {
|
|
32
|
+
window.clawmate.onAIConnected(() => {
|
|
33
|
+
connected = true;
|
|
34
|
+
autonomousMode = false;
|
|
35
|
+
Speech.show('OpenClaw 연결됨... 의식이 깨어난다.');
|
|
36
|
+
StateMachine.forceState('excited');
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (window.clawmate.onAIDisconnected) {
|
|
41
|
+
window.clawmate.onAIDisconnected(() => {
|
|
42
|
+
connected = false;
|
|
43
|
+
autonomousMode = true;
|
|
44
|
+
Speech.show('...혼자가 됐다. 알아서 놀아야지!');
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* OpenClaw AI로부터 온 명령 실행
|
|
51
|
+
*/
|
|
52
|
+
function handleAICommand(command) {
|
|
53
|
+
const { type, payload } = command;
|
|
54
|
+
lastAIAction = Date.now();
|
|
55
|
+
|
|
56
|
+
switch (type) {
|
|
57
|
+
case 'speak':
|
|
58
|
+
Speech.show(payload.text);
|
|
59
|
+
break;
|
|
60
|
+
|
|
61
|
+
case 'think':
|
|
62
|
+
Speech.show(`...${payload.text}...`);
|
|
63
|
+
break;
|
|
64
|
+
|
|
65
|
+
case 'action':
|
|
66
|
+
StateMachine.forceState(payload.state);
|
|
67
|
+
if (payload.duration) {
|
|
68
|
+
setTimeout(() => {
|
|
69
|
+
if (!autonomousMode) {
|
|
70
|
+
StateMachine.forceState('idle');
|
|
71
|
+
}
|
|
72
|
+
}, payload.duration);
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
|
|
76
|
+
case 'move':
|
|
77
|
+
PetEngine.setPosition(payload.x, payload.y);
|
|
78
|
+
if (payload.speed) PetEngine.setSpeedMultiplier(payload.speed);
|
|
79
|
+
break;
|
|
80
|
+
|
|
81
|
+
case 'emote':
|
|
82
|
+
applyEmotion(payload.emotion);
|
|
83
|
+
break;
|
|
84
|
+
|
|
85
|
+
case 'carry_file':
|
|
86
|
+
StateMachine.forceState('carrying');
|
|
87
|
+
Speech.show(`${payload.fileName} 집었다!`);
|
|
88
|
+
break;
|
|
89
|
+
|
|
90
|
+
case 'drop_file':
|
|
91
|
+
StateMachine.forceState('idle');
|
|
92
|
+
Speech.show('내려놨다!');
|
|
93
|
+
break;
|
|
94
|
+
|
|
95
|
+
case 'set_mode':
|
|
96
|
+
ModeManager.applyMode(payload.mode);
|
|
97
|
+
break;
|
|
98
|
+
|
|
99
|
+
case 'evolve':
|
|
100
|
+
// AI가 직접 진화 결정
|
|
101
|
+
if (typeof Memory !== 'undefined') {
|
|
102
|
+
Speech.show(window._messages?.evolution?.[`stage_${payload.stage}`] || '변하고 있어...!');
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
|
|
106
|
+
case 'accessorize':
|
|
107
|
+
// 임시 악세사리
|
|
108
|
+
break;
|
|
109
|
+
|
|
110
|
+
case 'ai_decision':
|
|
111
|
+
// 종합 의사결정 — 여러 행동을 순서대로 실행
|
|
112
|
+
executeDecision(payload);
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* AI 종합 의사결정 실행
|
|
119
|
+
* OpenClaw이 상황을 분석하고 내린 복합적 결정
|
|
120
|
+
*
|
|
121
|
+
* 예시:
|
|
122
|
+
* {
|
|
123
|
+
* action: 'walking',
|
|
124
|
+
* speech: '오늘 바탕화면이 좀 어지럽네...',
|
|
125
|
+
* emotion: 'curious',
|
|
126
|
+
* reasoning: '바탕화면 파일이 15개 이상 감지됨'
|
|
127
|
+
* }
|
|
128
|
+
*/
|
|
129
|
+
function executeDecision(decision) {
|
|
130
|
+
if (decision.emotion) {
|
|
131
|
+
applyEmotion(decision.emotion);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (decision.action) {
|
|
135
|
+
StateMachine.forceState(decision.action);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (decision.speech) {
|
|
139
|
+
setTimeout(() => Speech.show(decision.speech), 300);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (decision.moveTo) {
|
|
143
|
+
PetEngine.setPosition(decision.moveTo.x, decision.moveTo.y);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 감정 → 행동 매핑
|
|
149
|
+
*/
|
|
150
|
+
function applyEmotion(emotion) {
|
|
151
|
+
const emotionMap = {
|
|
152
|
+
happy: 'excited',
|
|
153
|
+
curious: 'walking',
|
|
154
|
+
sleepy: 'sleeping',
|
|
155
|
+
scared: 'scared',
|
|
156
|
+
playful: 'playing',
|
|
157
|
+
proud: 'excited',
|
|
158
|
+
neutral: 'idle',
|
|
159
|
+
focused: 'idle',
|
|
160
|
+
affectionate: 'interacting',
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const state = emotionMap[emotion] || 'idle';
|
|
164
|
+
StateMachine.forceState(state);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// === 사용자 이벤트 → OpenClaw에 리포트 ===
|
|
168
|
+
|
|
169
|
+
function reportClick(position) {
|
|
170
|
+
if (window.clawmate.reportToAI) {
|
|
171
|
+
window.clawmate.reportToAI('click', { position });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function reportDrag(from, to) {
|
|
176
|
+
if (window.clawmate.reportToAI) {
|
|
177
|
+
window.clawmate.reportToAI('drag', { from, to });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function reportCursorNear(distance) {
|
|
182
|
+
if (window.clawmate.reportToAI) {
|
|
183
|
+
window.clawmate.reportToAI('cursor_near', { distance });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function reportDesktopChange(files) {
|
|
188
|
+
if (window.clawmate.reportToAI) {
|
|
189
|
+
window.clawmate.reportToAI('desktop_changed', { files });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isConnected() {
|
|
194
|
+
return connected;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function isAutonomous() {
|
|
198
|
+
return autonomousMode;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
init, handleAICommand, isConnected, isAutonomous,
|
|
203
|
+
reportClick, reportDrag, reportCursorNear, reportDesktopChange,
|
|
204
|
+
executeDecision,
|
|
205
|
+
};
|
|
206
|
+
})();
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClawMate 렌더러 초기화
|
|
3
|
+
*
|
|
4
|
+
* 아키텍처:
|
|
5
|
+
* OpenClaw AI (뇌) ←→ AI Bridge (WebSocket) ←→ AI Controller (렌더러)
|
|
6
|
+
* ↓
|
|
7
|
+
* StateMachine / PetEngine / Speech
|
|
8
|
+
*
|
|
9
|
+
* OpenClaw 연결 시: AI가 모든 행동/말/감정 결정
|
|
10
|
+
* OpenClaw 미연결 시: 자율 모드 (FSM 기반) 로 혼자 놀기
|
|
11
|
+
*/
|
|
12
|
+
(async function initClawMate() {
|
|
13
|
+
const petContainer = document.getElementById('pet-container');
|
|
14
|
+
|
|
15
|
+
// 캐릭터 캔버스 생성
|
|
16
|
+
Character.createCanvas(petContainer);
|
|
17
|
+
|
|
18
|
+
// 기본 색상 설정 (Pet 모드)
|
|
19
|
+
Character.setColorMap({
|
|
20
|
+
primary: '#ff4f40',
|
|
21
|
+
secondary: '#ff775f',
|
|
22
|
+
dark: '#3a0a0d',
|
|
23
|
+
eye: '#ffffff',
|
|
24
|
+
pupil: '#111111',
|
|
25
|
+
claw: '#ff4f40',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// 상태 변화 콜백
|
|
29
|
+
StateMachine.setOnStateChange((prevState, newState) => {
|
|
30
|
+
if (newState === 'sleeping') {
|
|
31
|
+
const pet = document.getElementById('pet-container');
|
|
32
|
+
for (let i = 0; i < 3; i++) {
|
|
33
|
+
const z = document.createElement('div');
|
|
34
|
+
z.className = 'sleep-z';
|
|
35
|
+
z.textContent = 'z';
|
|
36
|
+
pet.appendChild(z);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (prevState === 'sleeping') {
|
|
41
|
+
document.querySelectorAll('.sleep-z').forEach(el => el.remove());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (newState === 'excited') {
|
|
45
|
+
Interactions.spawnStarEffect();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 상태 변화를 OpenClaw에 리포트
|
|
49
|
+
if (window.clawmate.reportToAI) {
|
|
50
|
+
window.clawmate.reportToAI('state_change', {
|
|
51
|
+
from: prevState, to: newState,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// 이동 엔진 초기화
|
|
57
|
+
PetEngine.init(petContainer);
|
|
58
|
+
|
|
59
|
+
// 모드 매니저 초기화
|
|
60
|
+
await ModeManager.init();
|
|
61
|
+
|
|
62
|
+
// 메모리 초기화 (진화 상태 포함)
|
|
63
|
+
await Memory.init();
|
|
64
|
+
|
|
65
|
+
// AI 컨트롤러 초기화 (OpenClaw 연결 관리)
|
|
66
|
+
AIController.init();
|
|
67
|
+
|
|
68
|
+
// 상호작용 초기화
|
|
69
|
+
Interactions.init();
|
|
70
|
+
|
|
71
|
+
// 시간 인식 초기화 (자율 모드에서만 주도적으로 동작)
|
|
72
|
+
TimeAware.init();
|
|
73
|
+
|
|
74
|
+
// 엔진 시작
|
|
75
|
+
PetEngine.start();
|
|
76
|
+
|
|
77
|
+
// 말풍선 위치 업데이트 루프
|
|
78
|
+
setInterval(() => {
|
|
79
|
+
Speech.updatePosition();
|
|
80
|
+
}, 100);
|
|
81
|
+
|
|
82
|
+
// AI 연결 상태 표시
|
|
83
|
+
const connected = await window.clawmate.isAIConnected();
|
|
84
|
+
if (connected) {
|
|
85
|
+
Speech.show('OpenClaw과 연결됨. 지시를 기다리는 중...');
|
|
86
|
+
} else {
|
|
87
|
+
Speech.show('안녕! 나 혼자서도 잘 놀 수 있어!');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
addDynamicStyles();
|
|
91
|
+
console.log('ClawMate initialized (AI Bridge: ws://127.0.0.1:9320)');
|
|
92
|
+
})();
|
|
93
|
+
|
|
94
|
+
function addDynamicStyles() {
|
|
95
|
+
const style = document.createElement('style');
|
|
96
|
+
style.textContent = `
|
|
97
|
+
@keyframes pulse-aura {
|
|
98
|
+
0%, 100% { opacity: 0.5; transform: scale(1); }
|
|
99
|
+
50% { opacity: 0.8; transform: scale(1.05); }
|
|
100
|
+
}
|
|
101
|
+
@keyframes spin-slow {
|
|
102
|
+
0% { transform: rotate(0deg); }
|
|
103
|
+
100% { transform: rotate(360deg); }
|
|
104
|
+
}
|
|
105
|
+
`;
|
|
106
|
+
document.head.appendChild(style);
|
|
107
|
+
}
|