claude-pet 2.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.
Potentially problematic release.
This version of claude-pet might be problematic. Click here for more details.
- package/.claude/commands/feed.md +28 -0
- package/.claude/commands/name.md +28 -0
- package/.claude/commands/pet.md +29 -0
- package/.claude/commands/play.md +29 -0
- package/.claude/settings.local.json +41 -0
- package/.github/workflows/AGENTS.md +60 -0
- package/.github/workflows/build.yml +87 -0
- package/AGENTS.md +66 -0
- package/LICENSE +15 -0
- package/README.md +292 -0
- package/bin/claude-pet.js +42 -0
- package/build/AGENTS.md +50 -0
- package/build/dmg-background.png +0 -0
- package/build/entitlements.mac.plist +14 -0
- package/build/icon.ico +0 -0
- package/build/icon.png +0 -0
- package/build/installerHeader.bmp +0 -0
- package/build/installerSidebar.bmp +0 -0
- package/build/tray-icon.png +0 -0
- package/dist/main/core/badge-manager.js +49 -0
- package/dist/main/core/badge-registry.js +72 -0
- package/dist/main/core/badge-triggers.js +45 -0
- package/dist/main/core/contextual-messages.js +372 -0
- package/dist/main/core/messages.js +440 -0
- package/dist/main/core/mood-engine.js +145 -0
- package/dist/main/core/pet-messages.js +612 -0
- package/dist/main/core/pet-state-engine.js +232 -0
- package/dist/main/core/quote-collection.js +60 -0
- package/dist/main/core/quote-registry.js +175 -0
- package/dist/main/core/quote-triggers.js +62 -0
- package/dist/main/core/usage-tracker.js +625 -0
- package/dist/main/main/auto-launch.js +39 -0
- package/dist/main/main/auto-updater.js +98 -0
- package/dist/main/main/event-watcher.js +174 -0
- package/dist/main/main/ipc-handlers.js +89 -0
- package/dist/main/main/main.js +422 -0
- package/dist/main/main/preload.js +93 -0
- package/dist/main/main/settings-window.js +49 -0
- package/dist/main/main/share-card.js +139 -0
- package/dist/main/main/skin-manager.js +118 -0
- package/dist/main/main/tray.js +88 -0
- package/dist/main/shared/i18n.js +392 -0
- package/dist/main/shared/types.js +25 -0
- package/dist/main/shared/utils.js +9 -0
- package/dist/renderer/assets/claude-pet.png +0 -0
- package/dist/renderer/assets/index-BMnMEuOf.js +9 -0
- package/dist/renderer/assets/index-qzlrlqpX.css +1 -0
- package/dist/renderer/index.html +30 -0
- package/dist/renderer/share-card-template/card.html +148 -0
- package/docs/AGENTS.md +42 -0
- package/docs/images/angry.png +0 -0
- package/docs/images/character.webp +0 -0
- package/docs/images/claude-mama.png +0 -0
- package/docs/images/happy.png +0 -0
- package/docs/images/proud.png +0 -0
- package/docs/images/share-card-example.png +0 -0
- package/docs/images/worried_1.png +0 -0
- package/docs/images/worried_2.png +0 -0
- package/docs/spritesheet-bugs.md +240 -0
- package/docs/superpowers/plans/2026-03-10-compact-widget.md +888 -0
- package/docs/superpowers/plans/2026-03-10-viral-features.md +1874 -0
- package/docs/superpowers/plans/2026-03-14-update-ux.md +362 -0
- package/docs/superpowers/plans/2026-03-14-v1.1-features.md +2139 -0
- package/docs/superpowers/specs/2026-03-10-compact-widget-design.md +150 -0
- package/docs/superpowers/specs/2026-03-10-viral-features-design.md +217 -0
- package/docs/superpowers/specs/2026-03-14-streak-calendar-design.md +26 -0
- package/docs/superpowers/specs/2026-03-14-update-ux-design.md +172 -0
- package/docs/superpowers/specs/2026-03-14-v1.1-features-design.md +342 -0
- package/electron-builder.yml +75 -0
- package/package.json +48 -0
- package/scripts/AGENTS.md +60 -0
- package/scripts/install.ps1 +47 -0
- package/scripts/install.sh +98 -0
- package/scripts/make-icon.js +119 -0
- package/scripts/notarize.js +18 -0
- package/src/AGENTS.md +47 -0
- package/src/core/AGENTS.md +58 -0
- package/src/core/__tests__/AGENTS.md +60 -0
- package/src/core/__tests__/badge-triggers.test.ts +83 -0
- package/src/core/__tests__/contextual-messages.test.ts +87 -0
- package/src/core/__tests__/pet-state-engine.test.ts +350 -0
- package/src/core/__tests__/quote-collection.test.ts +62 -0
- package/src/core/__tests__/quote-triggers.test.ts +110 -0
- package/src/core/badge-manager.ts +50 -0
- package/src/core/badge-registry.ts +71 -0
- package/src/core/badge-triggers.ts +41 -0
- package/src/core/contextual-messages.ts +381 -0
- package/src/core/pet-messages.ts +615 -0
- package/src/core/pet-state-engine.ts +272 -0
- package/src/core/quote-collection.ts +63 -0
- package/src/core/quote-registry.ts +181 -0
- package/src/core/quote-triggers.ts +64 -0
- package/src/core/usage-tracker.ts +680 -0
- package/src/main/AGENTS.md +70 -0
- package/src/main/auto-launch.ts +38 -0
- package/src/main/auto-updater.ts +106 -0
- package/src/main/event-watcher.ts +159 -0
- package/src/main/ipc-handlers.ts +107 -0
- package/src/main/main.ts +425 -0
- package/src/main/preload.ts +111 -0
- package/src/main/settings-window.ts +50 -0
- package/src/main/share-card.ts +153 -0
- package/src/main/skin-manager.ts +119 -0
- package/src/main/tray.ts +94 -0
- package/src/renderer/AGENTS.md +62 -0
- package/src/renderer/App.tsx +270 -0
- package/src/renderer/assets/claude-mama.png +0 -0
- package/src/renderer/assets/claude-pet.png +0 -0
- package/src/renderer/components/AGENTS.md +50 -0
- package/src/renderer/components/Character.tsx +327 -0
- package/src/renderer/components/SpeechBubble.tsx +182 -0
- package/src/renderer/components/UsageIndicator.tsx +268 -0
- package/src/renderer/electron.d.ts +34 -0
- package/src/renderer/hooks/AGENTS.md +55 -0
- package/src/renderer/hooks/usePetState.ts +59 -0
- package/src/renderer/hooks/useWidgetMode.ts +18 -0
- package/src/renderer/index.html +29 -0
- package/src/renderer/main.tsx +13 -0
- package/src/renderer/pages/AGENTS.md +53 -0
- package/src/renderer/pages/Collection.tsx +252 -0
- package/src/renderer/pages/Settings.tsx +815 -0
- package/src/renderer/share-card-template/card.html +148 -0
- package/src/renderer/styles/AGENTS.md +50 -0
- package/src/renderer/styles/styles.css +166 -0
- package/src/shared/AGENTS.md +48 -0
- package/src/shared/i18n.ts +395 -0
- package/src/shared/types.ts +163 -0
- package/src/shared/utils.ts +6 -0
- package/tsconfig.json +16 -0
- package/tsconfig.main.json +12 -0
- package/tsconfig.renderer.json +12 -0
- package/vite.config.ts +47 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
# Compact Widget Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Transform the Claude Mama widget from a fixed 250x300 always-expanded window to a compact mini mode with hover/auto-expand and direct drag-to-move.
|
|
6
|
+
|
|
7
|
+
**Architecture:** The window stays at a fixed 200x250 transparent size. Mini/expanded states are pure CSS transitions — no window resizing. Hit-test based `setIgnoreMouseEvents` switching enables drag on the character area while keeping the rest click-through. Expansion direction is calculated from screen position.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Electron 40, React 19, TypeScript, electron-store
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-03-10-compact-widget-design.md`
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Chunk 1: IPC & Main Process Foundation
|
|
16
|
+
|
|
17
|
+
### Task 1: Add new IPC channels and update types
|
|
18
|
+
|
|
19
|
+
**Files:**
|
|
20
|
+
- Modify: `src/shared/types.ts` — add new IPC channel constants, make `position` optional in MamaSettings
|
|
21
|
+
- Modify: `src/main/preload.ts` — expose new channels
|
|
22
|
+
- Modify: `src/renderer/electron.d.ts` — add type declarations
|
|
23
|
+
|
|
24
|
+
- [ ] **Step 1: Add IPC channel constants and update MamaSettings**
|
|
25
|
+
|
|
26
|
+
In `src/shared/types.ts`, add to the `IPC_CHANNELS` const:
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
SET_IGNORE_MOUSE: 'mama:set-ignore-mouse',
|
|
30
|
+
SAVE_POSITION: 'mama:save-position',
|
|
31
|
+
SHOW_CONTEXT_MENU: 'mama:show-context-menu',
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
In the `MamaSettings` interface, make `position` optional to allow gradual migration:
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- [ ] **Step 2: Add preload bridge methods**
|
|
41
|
+
|
|
42
|
+
In `src/main/preload.ts`, add to the `CHANNELS` const:
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
SET_IGNORE_MOUSE: 'mama:set-ignore-mouse',
|
|
46
|
+
SAVE_POSITION: 'mama:save-position',
|
|
47
|
+
SHOW_CONTEXT_MENU: 'mama:show-context-menu',
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Add to the `contextBridge.exposeInMainWorld` object:
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
setIgnoreMouse: (ignore: boolean): void => {
|
|
54
|
+
ipcRenderer.send(CHANNELS.SET_IGNORE_MOUSE, ignore);
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
savePosition: (x: number, y: number): void => {
|
|
58
|
+
ipcRenderer.send(CHANNELS.SAVE_POSITION, x, y);
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
showContextMenu: (): void => {
|
|
62
|
+
ipcRenderer.send(CHANNELS.SHOW_CONTEXT_MENU);
|
|
63
|
+
},
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- [ ] **Step 3: Add TypeScript declarations**
|
|
67
|
+
|
|
68
|
+
In `src/renderer/electron.d.ts`, add to the `ElectronAPI` interface:
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
setIgnoreMouse(ignore: boolean): void;
|
|
72
|
+
savePosition(x: number, y: number): void;
|
|
73
|
+
showContextMenu(): void;
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- [ ] **Step 4: Verify TypeScript compiles**
|
|
77
|
+
|
|
78
|
+
Run: `npx tsc -p tsconfig.main.json --noEmit`
|
|
79
|
+
Expected: No errors
|
|
80
|
+
|
|
81
|
+
- [ ] **Step 5: Commit**
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
git add src/shared/types.ts src/main/preload.ts src/renderer/electron.d.ts
|
|
85
|
+
git commit -m "feat(ipc): add set-ignore-mouse and save-position channels"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Task 2: Handle new IPC in main process
|
|
89
|
+
|
|
90
|
+
**Files:**
|
|
91
|
+
- Modify: `src/main/main.ts` — window size, position restore, IPC listeners
|
|
92
|
+
- Modify: `src/main/ipc-handlers.ts` — remove preset position logic, add new handlers
|
|
93
|
+
|
|
94
|
+
- [ ] **Step 1: Update window creation in main.ts**
|
|
95
|
+
|
|
96
|
+
In `createWindow()`, change window dimensions and position loading:
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
const winWidth = 200;
|
|
100
|
+
const winHeight = 250;
|
|
101
|
+
|
|
102
|
+
// Restore saved position or default to bottom-right
|
|
103
|
+
const store = getStore();
|
|
104
|
+
const savedPos = (store as any).get('windowPosition', null) as { x: number; y: number } | null;
|
|
105
|
+
|
|
106
|
+
let x: number, y: number;
|
|
107
|
+
if (savedPos) {
|
|
108
|
+
x = savedPos.x;
|
|
109
|
+
y = savedPos.y;
|
|
110
|
+
} else {
|
|
111
|
+
x = width - winWidth - 16;
|
|
112
|
+
y = height - winHeight - 16;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const win = new BrowserWindow({
|
|
116
|
+
width: winWidth,
|
|
117
|
+
height: winHeight,
|
|
118
|
+
x,
|
|
119
|
+
y,
|
|
120
|
+
// ... rest unchanged
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
- [ ] **Step 2: Add IPC listeners in main.ts with screen bounds clamping**
|
|
125
|
+
|
|
126
|
+
After `registerIpcHandlers(win, collectionManager);`, add:
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
ipcMain.on(IPC_CHANNELS.SET_IGNORE_MOUSE, (_event, ignore: boolean) => {
|
|
130
|
+
if (win && !win.isDestroyed()) {
|
|
131
|
+
if (ignore) {
|
|
132
|
+
win.setIgnoreMouseEvents(true, { forward: true });
|
|
133
|
+
} else {
|
|
134
|
+
win.setIgnoreMouseEvents(false);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
ipcMain.on(IPC_CHANNELS.SAVE_POSITION, (_event, rawX: number, rawY: number) => {
|
|
140
|
+
// Use nearest display for multi-monitor support
|
|
141
|
+
const display = screen.getDisplayNearestPoint({ x: rawX, y: rawY });
|
|
142
|
+
const { x: areaX, y: areaY, width: areaW, height: areaH } = display.workArea;
|
|
143
|
+
const [winW, winH] = [200, 250];
|
|
144
|
+
const x = Math.max(areaX, Math.min(rawX, areaX + areaW - winW));
|
|
145
|
+
const y = Math.max(areaY, Math.min(rawY, areaY + areaH - winH));
|
|
146
|
+
|
|
147
|
+
const store = getStore();
|
|
148
|
+
(store as any).set('windowPosition', { x, y });
|
|
149
|
+
|
|
150
|
+
// Snap window if it was dragged out of bounds
|
|
151
|
+
if (win && !win.isDestroyed() && (x !== rawX || y !== rawY)) {
|
|
152
|
+
win.setPosition(x, y);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
ipcMain.on(IPC_CHANNELS.SHOW_CONTEXT_MENU, () => {
|
|
157
|
+
const locale = getStore().get('locale', 'ko') as Locale;
|
|
158
|
+
const menu = Menu.buildFromTemplate([
|
|
159
|
+
{ label: 'Settings...', click: () => showSettingsWindow() },
|
|
160
|
+
{ type: 'separator' },
|
|
161
|
+
{ label: isVisible ? 'Hide Mama' : 'Show Mama', click: () => {
|
|
162
|
+
if (win.isVisible()) win.hide(); else win.show();
|
|
163
|
+
}},
|
|
164
|
+
{ label: 'Quit', click: () => app.quit() },
|
|
165
|
+
]);
|
|
166
|
+
menu.popup({ window: win });
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
- [ ] **Step 3: Remove old applyPosition from ipc-handlers.ts**
|
|
171
|
+
|
|
172
|
+
In `src/main/ipc-handlers.ts`:
|
|
173
|
+
- Remove the `applyPosition()` function entirely
|
|
174
|
+
- Remove the `if (settings.position && mainWindow ...)` block from `SETTINGS_SET` handler
|
|
175
|
+
- Remove `position` from the store defaults (keep other defaults)
|
|
176
|
+
|
|
177
|
+
- [ ] **Step 4: Verify TypeScript compiles**
|
|
178
|
+
|
|
179
|
+
Run: `npx tsc -p tsconfig.main.json --noEmit`
|
|
180
|
+
Expected: No errors
|
|
181
|
+
|
|
182
|
+
- [ ] **Step 5: Commit**
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
git add src/main/main.ts src/main/ipc-handlers.ts
|
|
186
|
+
git commit -m "feat(main): dynamic mouse events, position save/restore with bounds clamping"
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Chunk 2: Renderer Components
|
|
190
|
+
|
|
191
|
+
### Task 3: Create useWidgetMode hook
|
|
192
|
+
|
|
193
|
+
**Files:**
|
|
194
|
+
- Create: `src/renderer/hooks/useWidgetMode.ts`
|
|
195
|
+
|
|
196
|
+
This hook manages the mini/expanded state with hover and auto-expand logic.
|
|
197
|
+
|
|
198
|
+
- [ ] **Step 1: Create the hook**
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
202
|
+
|
|
203
|
+
export type WidgetMode = 'mini' | 'expanded';
|
|
204
|
+
export type ExpandDirection = 'up' | 'down';
|
|
205
|
+
|
|
206
|
+
interface UseWidgetModeReturn {
|
|
207
|
+
mode: WidgetMode;
|
|
208
|
+
direction: ExpandDirection;
|
|
209
|
+
hasNewMessage: boolean;
|
|
210
|
+
onCharacterEnter: () => void;
|
|
211
|
+
onCharacterLeave: () => void;
|
|
212
|
+
onCharacterClick: () => void;
|
|
213
|
+
notifyNewMessage: () => void;
|
|
214
|
+
clearNewMessage: () => void;
|
|
215
|
+
scheduleCollapse: (delay: number) => void;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function useWidgetMode(): UseWidgetModeReturn {
|
|
219
|
+
const [mode, setMode] = useState<WidgetMode>('mini');
|
|
220
|
+
const [direction, setDirection] = useState<ExpandDirection>('up');
|
|
221
|
+
const [hasNewMessage, setHasNewMessage] = useState(false);
|
|
222
|
+
const collapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
223
|
+
const isHovering = useRef(false);
|
|
224
|
+
|
|
225
|
+
const cancelCollapse = () => {
|
|
226
|
+
if (collapseTimer.current) {
|
|
227
|
+
clearTimeout(collapseTimer.current);
|
|
228
|
+
collapseTimer.current = null;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const scheduleCollapse = useCallback((delay: number) => {
|
|
233
|
+
cancelCollapse();
|
|
234
|
+
collapseTimer.current = setTimeout(() => {
|
|
235
|
+
if (!isHovering.current) {
|
|
236
|
+
setMode('mini');
|
|
237
|
+
}
|
|
238
|
+
collapseTimer.current = null;
|
|
239
|
+
}, delay);
|
|
240
|
+
}, []);
|
|
241
|
+
|
|
242
|
+
const computeDirection = useCallback((): ExpandDirection => {
|
|
243
|
+
const screenH = window.screen.availHeight;
|
|
244
|
+
const winY = window.screenY;
|
|
245
|
+
const winH = window.outerHeight;
|
|
246
|
+
return (winY + winH / 2) > (screenH / 2) ? 'up' : 'down';
|
|
247
|
+
}, []);
|
|
248
|
+
|
|
249
|
+
const onCharacterEnter = useCallback(() => {
|
|
250
|
+
isHovering.current = true;
|
|
251
|
+
cancelCollapse();
|
|
252
|
+
setDirection(computeDirection());
|
|
253
|
+
setMode('expanded');
|
|
254
|
+
setHasNewMessage(false);
|
|
255
|
+
}, [computeDirection]);
|
|
256
|
+
|
|
257
|
+
const onCharacterLeave = useCallback(() => {
|
|
258
|
+
isHovering.current = false;
|
|
259
|
+
scheduleCollapse(1000);
|
|
260
|
+
}, [scheduleCollapse]);
|
|
261
|
+
|
|
262
|
+
const onCharacterClick = useCallback(() => {
|
|
263
|
+
setDirection(computeDirection());
|
|
264
|
+
if (mode === 'mini') {
|
|
265
|
+
setMode('expanded');
|
|
266
|
+
setHasNewMessage(false);
|
|
267
|
+
} else {
|
|
268
|
+
setMode('mini');
|
|
269
|
+
}
|
|
270
|
+
}, [mode, computeDirection]);
|
|
271
|
+
|
|
272
|
+
// Called on message rotation — shows pulsing dot instead of auto-expanding
|
|
273
|
+
const notifyNewMessage = useCallback(() => {
|
|
274
|
+
if (mode === 'mini') {
|
|
275
|
+
setHasNewMessage(true);
|
|
276
|
+
}
|
|
277
|
+
}, [mode]);
|
|
278
|
+
|
|
279
|
+
const clearNewMessage = useCallback(() => {
|
|
280
|
+
setHasNewMessage(false);
|
|
281
|
+
}, []);
|
|
282
|
+
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
return () => { cancelCollapse(); };
|
|
285
|
+
}, []);
|
|
286
|
+
|
|
287
|
+
return { mode, direction, hasNewMessage, onCharacterEnter, onCharacterLeave, onCharacterClick, notifyNewMessage, clearNewMessage, scheduleCollapse };
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Note: `scheduleCollapse` is exposed separately from `onCharacterLeave` so that auto-expand completion can request collapse without corrupting `isHovering` state.
|
|
292
|
+
|
|
293
|
+
- [ ] **Step 2: Verify TypeScript compiles**
|
|
294
|
+
|
|
295
|
+
Run: `npx tsc --noEmit`
|
|
296
|
+
Expected: No errors
|
|
297
|
+
|
|
298
|
+
- [ ] **Step 3: Commit**
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
git add src/renderer/hooks/useWidgetMode.ts
|
|
302
|
+
git commit -m "feat(hook): add useWidgetMode for mini/expand state management"
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### Task 4: Update SpeechBubble with onComplete callback
|
|
306
|
+
|
|
307
|
+
**Files:**
|
|
308
|
+
- Modify: `src/renderer/components/SpeechBubble.tsx`
|
|
309
|
+
|
|
310
|
+
- [ ] **Step 1: Add onComplete prop**
|
|
311
|
+
|
|
312
|
+
Update the interface:
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
interface SpeechBubbleProps {
|
|
316
|
+
message: string;
|
|
317
|
+
mood: string;
|
|
318
|
+
onComplete?: () => void;
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Update function signature:
|
|
323
|
+
|
|
324
|
+
```typescript
|
|
325
|
+
export function SpeechBubble({ message, mood, onComplete }: SpeechBubbleProps) {
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
In the auto-hide timeout (where it sets `setAnimState('out')`), call onComplete after fade-out:
|
|
329
|
+
|
|
330
|
+
```typescript
|
|
331
|
+
hideTimerRef.current = setTimeout(() => {
|
|
332
|
+
setAnimState('out');
|
|
333
|
+
setTimeout(() => {
|
|
334
|
+
setVisible(false);
|
|
335
|
+
onComplete?.();
|
|
336
|
+
}, 400);
|
|
337
|
+
}, 4000);
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
- [ ] **Step 2: Verify TypeScript compiles**
|
|
341
|
+
|
|
342
|
+
Run: `npx tsc --noEmit`
|
|
343
|
+
Expected: No errors
|
|
344
|
+
|
|
345
|
+
- [ ] **Step 3: Commit**
|
|
346
|
+
|
|
347
|
+
```bash
|
|
348
|
+
git add src/renderer/components/SpeechBubble.tsx
|
|
349
|
+
git commit -m "feat(bubble): add onComplete callback for collapse trigger"
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### Task 5: Add MiniBar component and update Character with forwardRef
|
|
353
|
+
|
|
354
|
+
**Files:**
|
|
355
|
+
- Modify: `src/renderer/components/UsageIndicator.tsx` — add MiniBar export
|
|
356
|
+
- Modify: `src/renderer/components/Character.tsx` — reduce to 60px, forwardRef, mouse event props
|
|
357
|
+
|
|
358
|
+
- [ ] **Step 1: Add MiniBar to UsageIndicator.tsx**
|
|
359
|
+
|
|
360
|
+
Add before the `styles` const:
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
/** Compact single-line bar for mini mode */
|
|
364
|
+
export function MiniBar({ utilizationPercent, mood }: {
|
|
365
|
+
utilizationPercent: number;
|
|
366
|
+
mood: Expression;
|
|
367
|
+
}) {
|
|
368
|
+
const clamped = clamp(utilizationPercent);
|
|
369
|
+
const color = MOOD_COLORS[mood] ?? '#9ca3af';
|
|
370
|
+
return (
|
|
371
|
+
<div style={{
|
|
372
|
+
display: 'flex',
|
|
373
|
+
alignItems: 'center',
|
|
374
|
+
gap: 4,
|
|
375
|
+
background: 'rgba(0, 0, 0, 0.65)',
|
|
376
|
+
borderRadius: 6,
|
|
377
|
+
padding: '3px 6px',
|
|
378
|
+
backdropFilter: 'blur(6px)',
|
|
379
|
+
border: '1px solid rgba(255, 255, 255, 0.1)',
|
|
380
|
+
}}>
|
|
381
|
+
<div style={{
|
|
382
|
+
width: 36,
|
|
383
|
+
height: 5,
|
|
384
|
+
background: 'rgba(255, 255, 255, 0.25)',
|
|
385
|
+
borderRadius: 3,
|
|
386
|
+
overflow: 'hidden',
|
|
387
|
+
}}>
|
|
388
|
+
<div style={{
|
|
389
|
+
height: '100%',
|
|
390
|
+
width: `${clamped}%`,
|
|
391
|
+
background: color,
|
|
392
|
+
borderRadius: 3,
|
|
393
|
+
transition: 'width 0.5s ease',
|
|
394
|
+
}} />
|
|
395
|
+
</div>
|
|
396
|
+
<span style={{
|
|
397
|
+
fontSize: 9,
|
|
398
|
+
color: '#ffffff',
|
|
399
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
400
|
+
fontWeight: 700,
|
|
401
|
+
textShadow: '0 1px 3px rgba(0,0,0,1)',
|
|
402
|
+
}}>
|
|
403
|
+
{clamped.toFixed(0)}%
|
|
404
|
+
</span>
|
|
405
|
+
</div>
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
- [ ] **Step 2: Update Character.tsx with forwardRef, reduced size, 80x80 hit area, and cursor**
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
import React, { CSSProperties, forwardRef } from 'react';
|
|
414
|
+
import { MamaMood, MamaErrorExpression } from '../../shared/types';
|
|
415
|
+
import mamaPng from '../assets/claude-mama.png';
|
|
416
|
+
|
|
417
|
+
type Expression = MamaMood | MamaErrorExpression;
|
|
418
|
+
|
|
419
|
+
interface CharacterProps {
|
|
420
|
+
expression: Expression;
|
|
421
|
+
hasNewMessage?: boolean;
|
|
422
|
+
onMouseEnter?: () => void;
|
|
423
|
+
onMouseLeave?: () => void;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const IMG_W = 60;
|
|
427
|
+
const IMG_H = 60;
|
|
428
|
+
const HIT_AREA = 80; // 10px invisible padding for Fitts's law
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Change `MoodOverlay` pixel scale: `const px = 2.5;`
|
|
432
|
+
|
|
433
|
+
Convert export to forwardRef:
|
|
434
|
+
|
|
435
|
+
```typescript
|
|
436
|
+
export const Character = forwardRef<HTMLDivElement, CharacterProps>(
|
|
437
|
+
function Character({ expression, hasNewMessage, onMouseEnter, onMouseLeave }, ref) {
|
|
438
|
+
// Outer hit area (80x80, invisible padding)
|
|
439
|
+
const hitAreaStyle: CSSProperties = {
|
|
440
|
+
position: 'relative',
|
|
441
|
+
width: HIT_AREA,
|
|
442
|
+
height: HIT_AREA,
|
|
443
|
+
display: 'flex',
|
|
444
|
+
alignItems: 'center',
|
|
445
|
+
justifyContent: 'center',
|
|
446
|
+
cursor: 'grab',
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const containerStyle: CSSProperties = {
|
|
450
|
+
position: 'relative',
|
|
451
|
+
width: IMG_W,
|
|
452
|
+
height: IMG_H,
|
|
453
|
+
animation: MOOD_ANIMATIONS[expression],
|
|
454
|
+
WebkitAppRegion: 'drag' as any,
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
// ... imgStyle and auraStyle unchanged ...
|
|
458
|
+
|
|
459
|
+
return (
|
|
460
|
+
<div
|
|
461
|
+
ref={ref}
|
|
462
|
+
style={hitAreaStyle}
|
|
463
|
+
onMouseEnter={onMouseEnter}
|
|
464
|
+
onMouseLeave={onMouseLeave}
|
|
465
|
+
>
|
|
466
|
+
<div style={containerStyle}>
|
|
467
|
+
{auraStyle && <div style={auraStyle as CSSProperties} />}
|
|
468
|
+
<img src={mamaPng} alt="Claude Mama" style={imgStyle} draggable={false} />
|
|
469
|
+
<MoodOverlay expression={expression} />
|
|
470
|
+
</div>
|
|
471
|
+
{hasNewMessage && (
|
|
472
|
+
<div style={{
|
|
473
|
+
position: 'absolute',
|
|
474
|
+
top: 4,
|
|
475
|
+
right: 4,
|
|
476
|
+
width: 10,
|
|
477
|
+
height: 10,
|
|
478
|
+
borderRadius: '50%',
|
|
479
|
+
background: '#ef4444',
|
|
480
|
+
animation: 'pulse-dot 1.5s ease-in-out infinite',
|
|
481
|
+
boxShadow: '0 0 6px rgba(239, 68, 68, 0.6)',
|
|
482
|
+
}} />
|
|
483
|
+
)}
|
|
484
|
+
</div>
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
);
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
- [ ] **Step 3: Verify TypeScript compiles**
|
|
491
|
+
|
|
492
|
+
Run: `npx tsc --noEmit`
|
|
493
|
+
Expected: No errors
|
|
494
|
+
|
|
495
|
+
- [ ] **Step 4: Commit**
|
|
496
|
+
|
|
497
|
+
```bash
|
|
498
|
+
git add src/renderer/components/UsageIndicator.tsx src/renderer/components/Character.tsx
|
|
499
|
+
git commit -m "feat(ui): add MiniBar, reduce character to 60px with forwardRef"
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
### Task 6: Update App.tsx with mini/expand layout
|
|
503
|
+
|
|
504
|
+
**Files:**
|
|
505
|
+
- Modify: `src/renderer/App.tsx`
|
|
506
|
+
|
|
507
|
+
Dependencies: Tasks 3-5 must be complete (useWidgetMode, SpeechBubble onComplete, MiniBar, Character forwardRef).
|
|
508
|
+
|
|
509
|
+
- [ ] **Step 1: Integrate useWidgetMode and restructure MainView**
|
|
510
|
+
|
|
511
|
+
```typescript
|
|
512
|
+
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
513
|
+
import { useMamaState } from './hooks/useMamaState';
|
|
514
|
+
import { useWidgetMode } from './hooks/useWidgetMode';
|
|
515
|
+
import { Character } from './components/Character';
|
|
516
|
+
import { SpeechBubble } from './components/SpeechBubble';
|
|
517
|
+
import { WeeklyBar, FiveHourBar, MiniBar, OfflineLabel } from './components/UsageIndicator';
|
|
518
|
+
import Settings from './pages/Settings';
|
|
519
|
+
import { Locale } from '../shared/types';
|
|
520
|
+
import { t } from '../shared/i18n';
|
|
521
|
+
|
|
522
|
+
function MainView() {
|
|
523
|
+
const mamaState = useMamaState();
|
|
524
|
+
const { mode, direction, hasNewMessage, onCharacterEnter, onCharacterLeave, onCharacterClick, notifyNewMessage, clearNewMessage, scheduleCollapse } = useWidgetMode();
|
|
525
|
+
const [locale, setLocale] = useState<Locale>('ko');
|
|
526
|
+
const [showDragHint, setShowDragHint] = useState(false);
|
|
527
|
+
const prevMessageRef = useRef<string>('');
|
|
528
|
+
const mouseDownPos = useRef<{ x: number; y: number } | null>(null);
|
|
529
|
+
|
|
530
|
+
useEffect(() => {
|
|
531
|
+
window.electronAPI.getSettings().then((s) => {
|
|
532
|
+
if (s && (s as { locale?: Locale }).locale) {
|
|
533
|
+
setLocale((s as { locale: Locale }).locale);
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
}, []);
|
|
537
|
+
|
|
538
|
+
// First-run drag hint
|
|
539
|
+
useEffect(() => {
|
|
540
|
+
const hintShown = localStorage.getItem('firstRunHintShown');
|
|
541
|
+
if (!hintShown) {
|
|
542
|
+
setShowDragHint(true);
|
|
543
|
+
localStorage.setItem('firstRunHintShown', 'true');
|
|
544
|
+
setTimeout(() => setShowDragHint(false), 3000);
|
|
545
|
+
}
|
|
546
|
+
}, []);
|
|
547
|
+
|
|
548
|
+
const mood = mamaState?.mood ?? 'sleeping';
|
|
549
|
+
const message = mamaState?.message ?? t(locale, 'loading_message');
|
|
550
|
+
const utilizationPercent = mamaState?.utilizationPercent ?? 0;
|
|
551
|
+
const fiveHourPercent = mamaState?.fiveHourPercent ?? null;
|
|
552
|
+
const fiveHourResetsAt = mamaState?.fiveHourResetsAt ?? null;
|
|
553
|
+
const dataSource = mamaState?.dataSource ?? 'none';
|
|
554
|
+
|
|
555
|
+
// Notify new message (pulsing dot) instead of auto-expanding
|
|
556
|
+
useEffect(() => {
|
|
557
|
+
if (message !== prevMessageRef.current) {
|
|
558
|
+
prevMessageRef.current = message;
|
|
559
|
+
if (mamaState) {
|
|
560
|
+
notifyNewMessage();
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}, [message, mamaState, notifyNewMessage]);
|
|
564
|
+
|
|
565
|
+
const isExpanded = mode === 'expanded';
|
|
566
|
+
|
|
567
|
+
// Click vs drag detection (5px threshold)
|
|
568
|
+
const handleCharacterMouseDown = useCallback((e: React.MouseEvent) => {
|
|
569
|
+
mouseDownPos.current = { x: e.screenX, y: e.screenY };
|
|
570
|
+
}, []);
|
|
571
|
+
|
|
572
|
+
const handleCharacterMouseUp = useCallback((e: React.MouseEvent) => {
|
|
573
|
+
if (mouseDownPos.current) {
|
|
574
|
+
const dx = Math.abs(e.screenX - mouseDownPos.current.x);
|
|
575
|
+
const dy = Math.abs(e.screenY - mouseDownPos.current.y);
|
|
576
|
+
if (dx < 5 && dy < 5) {
|
|
577
|
+
onCharacterClick();
|
|
578
|
+
}
|
|
579
|
+
mouseDownPos.current = null;
|
|
580
|
+
}
|
|
581
|
+
}, [onCharacterClick]);
|
|
582
|
+
|
|
583
|
+
// Right-click context menu
|
|
584
|
+
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
|
585
|
+
e.preventDefault();
|
|
586
|
+
window.electronAPI.showContextMenu();
|
|
587
|
+
}, []);
|
|
588
|
+
|
|
589
|
+
const handleBubbleComplete = () => {
|
|
590
|
+
scheduleCollapse(1000);
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const bubble = isExpanded ? (
|
|
594
|
+
<SpeechBubble message={message} mood={mood} onComplete={handleBubbleComplete} />
|
|
595
|
+
) : null;
|
|
596
|
+
|
|
597
|
+
const character = (
|
|
598
|
+
<div
|
|
599
|
+
onMouseDown={handleCharacterMouseDown}
|
|
600
|
+
onMouseUp={handleCharacterMouseUp}
|
|
601
|
+
onContextMenu={handleContextMenu}
|
|
602
|
+
style={{ position: 'relative' }}
|
|
603
|
+
>
|
|
604
|
+
<Character
|
|
605
|
+
expression={mood}
|
|
606
|
+
hasNewMessage={hasNewMessage}
|
|
607
|
+
onMouseEnter={onCharacterEnter}
|
|
608
|
+
onMouseLeave={onCharacterLeave}
|
|
609
|
+
/>
|
|
610
|
+
{showDragHint && (
|
|
611
|
+
<div style={{
|
|
612
|
+
position: 'absolute',
|
|
613
|
+
bottom: -20,
|
|
614
|
+
left: '50%',
|
|
615
|
+
transform: 'translateX(-50%)',
|
|
616
|
+
background: 'rgba(0,0,0,0.8)',
|
|
617
|
+
color: '#fff',
|
|
618
|
+
fontSize: 10,
|
|
619
|
+
padding: '2px 8px',
|
|
620
|
+
borderRadius: 4,
|
|
621
|
+
whiteSpace: 'nowrap',
|
|
622
|
+
animation: 'fade-out 3s ease forwards',
|
|
623
|
+
}}>
|
|
624
|
+
Drag me to move!
|
|
625
|
+
</div>
|
|
626
|
+
)}
|
|
627
|
+
</div>
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
const usageBars = dataSource === 'none' ? (
|
|
631
|
+
<OfflineLabel locale={locale} />
|
|
632
|
+
) : isExpanded ? (
|
|
633
|
+
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 6, marginTop: 6 }}>
|
|
634
|
+
<WeeklyBar utilizationPercent={utilizationPercent} resetsAt={mamaState?.resetsAt ?? null} mood={mood} />
|
|
635
|
+
{fiveHourPercent != null && (
|
|
636
|
+
<FiveHourBar fiveHourPercent={fiveHourPercent} fiveHourResetsAt={fiveHourResetsAt} />
|
|
637
|
+
)}
|
|
638
|
+
</div>
|
|
639
|
+
) : (
|
|
640
|
+
<MiniBar utilizationPercent={utilizationPercent} mood={mood} />
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
return (
|
|
644
|
+
<div style={{
|
|
645
|
+
width: '100%',
|
|
646
|
+
height: '100%',
|
|
647
|
+
display: 'flex',
|
|
648
|
+
flexDirection: 'column',
|
|
649
|
+
alignItems: 'center',
|
|
650
|
+
justifyContent: direction === 'up' ? 'flex-end' : 'flex-start',
|
|
651
|
+
background: 'transparent',
|
|
652
|
+
padding: '8px 0',
|
|
653
|
+
transition: 'all 300ms ease',
|
|
654
|
+
}}>
|
|
655
|
+
{direction === 'up' ? (
|
|
656
|
+
<>
|
|
657
|
+
{bubble}
|
|
658
|
+
{bubble && <div style={{ height: 6 }} />}
|
|
659
|
+
{character}
|
|
660
|
+
<div style={{ marginTop: 4 }}>{usageBars}</div>
|
|
661
|
+
</>
|
|
662
|
+
) : (
|
|
663
|
+
<>
|
|
664
|
+
{character}
|
|
665
|
+
<div style={{ marginTop: 4 }}>{usageBars}</div>
|
|
666
|
+
{bubble && <div style={{ height: 6 }} />}
|
|
667
|
+
{bubble}
|
|
668
|
+
</>
|
|
669
|
+
)}
|
|
670
|
+
</div>
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ... App component unchanged
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
- [ ] **Step 2: Verify TypeScript compiles and build**
|
|
678
|
+
|
|
679
|
+
Run: `npx tsc --noEmit && npm run build`
|
|
680
|
+
Expected: No errors
|
|
681
|
+
|
|
682
|
+
- [ ] **Step 3: Commit**
|
|
683
|
+
|
|
684
|
+
```bash
|
|
685
|
+
git add src/renderer/App.tsx
|
|
686
|
+
git commit -m "feat(ui): integrate mini/expand modes with direction-aware layout"
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
## Chunk 3: Hit-Test Mouse Events & Drag Position
|
|
690
|
+
|
|
691
|
+
### Task 7: Implement hit-test pointer event switching with IPC deduplication and CSS animations
|
|
692
|
+
|
|
693
|
+
**Files:**
|
|
694
|
+
- Modify: `src/renderer/App.tsx` — add mousemove listener for hit-test
|
|
695
|
+
- Modify: `src/renderer/components/Character.tsx` — already has forwardRef from Task 5
|
|
696
|
+
- Modify: `src/renderer/styles/styles.css` — add pulse-dot and fade-out animations
|
|
697
|
+
|
|
698
|
+
- [ ] **Step 1: Add hit-test logic to MainView**
|
|
699
|
+
|
|
700
|
+
Add a global mousemove handler with IPC deduplication to avoid flooding:
|
|
701
|
+
|
|
702
|
+
```typescript
|
|
703
|
+
// Inside MainView, add:
|
|
704
|
+
const characterRef = useRef<HTMLDivElement>(null);
|
|
705
|
+
const lastIgnoreRef = useRef(true);
|
|
706
|
+
|
|
707
|
+
useEffect(() => {
|
|
708
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
709
|
+
if (characterRef.current) {
|
|
710
|
+
const rect = characterRef.current.getBoundingClientRect();
|
|
711
|
+
const isOverCharacter =
|
|
712
|
+
e.clientX >= rect.left &&
|
|
713
|
+
e.clientX <= rect.right &&
|
|
714
|
+
e.clientY >= rect.top &&
|
|
715
|
+
e.clientY <= rect.bottom;
|
|
716
|
+
|
|
717
|
+
const shouldIgnore = !isOverCharacter;
|
|
718
|
+
if (shouldIgnore !== lastIgnoreRef.current) {
|
|
719
|
+
lastIgnoreRef.current = shouldIgnore;
|
|
720
|
+
window.electronAPI.setIgnoreMouse(shouldIgnore);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
726
|
+
return () => document.removeEventListener('mousemove', handleMouseMove);
|
|
727
|
+
}, []);
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
Pass `characterRef` to the Character component:
|
|
731
|
+
|
|
732
|
+
```typescript
|
|
733
|
+
<Character
|
|
734
|
+
ref={characterRef}
|
|
735
|
+
expression={mood}
|
|
736
|
+
hasNewMessage={hasNewMessage}
|
|
737
|
+
onMouseEnter={onCharacterEnter}
|
|
738
|
+
onMouseLeave={onCharacterLeave}
|
|
739
|
+
/>
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
- [ ] **Step 1.5: Add CSS animations for pulsing dot and drag hint**
|
|
743
|
+
|
|
744
|
+
In `src/renderer/styles/styles.css`, add:
|
|
745
|
+
|
|
746
|
+
```css
|
|
747
|
+
@keyframes pulse-dot {
|
|
748
|
+
0%, 100% { opacity: 1; transform: scale(1); }
|
|
749
|
+
50% { opacity: 0.5; transform: scale(1.3); }
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
@keyframes fade-out {
|
|
753
|
+
0%, 70% { opacity: 1; }
|
|
754
|
+
100% { opacity: 0; }
|
|
755
|
+
}
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
- [ ] **Step 2: Verify TypeScript compiles and build**
|
|
759
|
+
|
|
760
|
+
Run: `npx tsc --noEmit && npm run build`
|
|
761
|
+
Expected: No errors
|
|
762
|
+
|
|
763
|
+
- [ ] **Step 3: Commit**
|
|
764
|
+
|
|
765
|
+
```bash
|
|
766
|
+
git add src/renderer/App.tsx
|
|
767
|
+
git commit -m "feat: hit-test based dynamic pointer event switching with IPC dedup"
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
### Task 8: Save position on drag end
|
|
771
|
+
|
|
772
|
+
**Files:**
|
|
773
|
+
- Modify: `src/renderer/App.tsx` — add drag-aware position save
|
|
774
|
+
|
|
775
|
+
- [ ] **Step 1: Detect drag end and save position**
|
|
776
|
+
|
|
777
|
+
Electron's `-webkit-app-region: drag` handles the actual dragging. Since JS drag events don't fire for `-webkit-app-region`, we detect position changes via mouseup on the character:
|
|
778
|
+
|
|
779
|
+
```typescript
|
|
780
|
+
// Inside MainView, add:
|
|
781
|
+
useEffect(() => {
|
|
782
|
+
let dragCheckInterval: ReturnType<typeof setInterval> | null = null;
|
|
783
|
+
let lastX = window.screenX;
|
|
784
|
+
let lastY = window.screenY;
|
|
785
|
+
|
|
786
|
+
const startTracking = () => {
|
|
787
|
+
lastX = window.screenX;
|
|
788
|
+
lastY = window.screenY;
|
|
789
|
+
dragCheckInterval = setInterval(() => {
|
|
790
|
+
if (window.screenX !== lastX || window.screenY !== lastY) {
|
|
791
|
+
lastX = window.screenX;
|
|
792
|
+
lastY = window.screenY;
|
|
793
|
+
}
|
|
794
|
+
}, 100);
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
const stopTracking = () => {
|
|
798
|
+
if (dragCheckInterval) {
|
|
799
|
+
clearInterval(dragCheckInterval);
|
|
800
|
+
dragCheckInterval = null;
|
|
801
|
+
}
|
|
802
|
+
// Save final position if changed
|
|
803
|
+
if (window.screenX !== lastX || window.screenY !== lastY) {
|
|
804
|
+
window.electronAPI.savePosition(window.screenX, window.screenY);
|
|
805
|
+
} else if (lastX !== 0 || lastY !== 0) {
|
|
806
|
+
// Save position on any mouseup in case drag happened
|
|
807
|
+
window.electronAPI.savePosition(window.screenX, window.screenY);
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
document.addEventListener('mousedown', startTracking);
|
|
812
|
+
document.addEventListener('mouseup', stopTracking);
|
|
813
|
+
return () => {
|
|
814
|
+
document.removeEventListener('mousedown', startTracking);
|
|
815
|
+
document.removeEventListener('mouseup', stopTracking);
|
|
816
|
+
if (dragCheckInterval) clearInterval(dragCheckInterval);
|
|
817
|
+
};
|
|
818
|
+
}, []);
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
- [ ] **Step 2: Commit**
|
|
822
|
+
|
|
823
|
+
```bash
|
|
824
|
+
git add src/renderer/App.tsx
|
|
825
|
+
git commit -m "feat: save window position on drag end"
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
## Chunk 4: Settings Cleanup & Final Verification
|
|
829
|
+
|
|
830
|
+
### Task 9: Remove position dropdown from Settings and clean up types
|
|
831
|
+
|
|
832
|
+
**Files:**
|
|
833
|
+
- Modify: `src/renderer/pages/Settings.tsx` — remove position UI
|
|
834
|
+
- Modify: `src/shared/types.ts` — remove `position` field from MamaSettings entirely
|
|
835
|
+
|
|
836
|
+
- [ ] **Step 1: Remove position section from Settings.tsx**
|
|
837
|
+
|
|
838
|
+
Remove the `POSITIONS` const, `POS_KEYS` const, and the entire `{/* Position */}` JSX section. Remove `position` from the local state default object.
|
|
839
|
+
|
|
840
|
+
- [ ] **Step 2: Remove `position` from MamaSettings in types.ts**
|
|
841
|
+
|
|
842
|
+
Delete the `position` field entirely from the `MamaSettings` interface (was made optional in Task 1).
|
|
843
|
+
|
|
844
|
+
- [ ] **Step 3: Verify build**
|
|
845
|
+
|
|
846
|
+
Run: `npm run build`
|
|
847
|
+
Expected: No errors
|
|
848
|
+
|
|
849
|
+
- [ ] **Step 4: Commit**
|
|
850
|
+
|
|
851
|
+
```bash
|
|
852
|
+
git add src/renderer/pages/Settings.tsx src/shared/types.ts
|
|
853
|
+
git commit -m "chore: remove position dropdown and type (replaced by drag)"
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
### Task 10: Final verification
|
|
857
|
+
|
|
858
|
+
- [ ] **Step 1: Run all tests**
|
|
859
|
+
|
|
860
|
+
Run: `npx vitest run`
|
|
861
|
+
Expected: All tests pass
|
|
862
|
+
|
|
863
|
+
- [ ] **Step 2: Full build**
|
|
864
|
+
|
|
865
|
+
Run: `npm run build`
|
|
866
|
+
Expected: Successful
|
|
867
|
+
|
|
868
|
+
- [ ] **Step 3: Manual test in dev mode**
|
|
869
|
+
|
|
870
|
+
Run: `npm run dev`
|
|
871
|
+
|
|
872
|
+
Verify:
|
|
873
|
+
1. Widget starts in mini mode (small character + single bar)
|
|
874
|
+
2. Hover over character → expands with full bars, cursor shows `grab`
|
|
875
|
+
3. Move mouse away → collapses after ~1 second
|
|
876
|
+
4. Message rotation → pulsing red dot appears on character (no auto-expand)
|
|
877
|
+
5. Hover/click after dot appears → expands, dot disappears, speech bubble shows
|
|
878
|
+
6. Click character (non-drag) → toggles expand/collapse
|
|
879
|
+
7. Right-click character → context menu with Settings, Hide, Quit
|
|
880
|
+
8. Drag character → window moves, cursor shows `grabbing`, position saved
|
|
881
|
+
9. Restart app → window appears at saved position
|
|
882
|
+
10. Widget at bottom of screen → bubble expands upward
|
|
883
|
+
11. Widget at top of screen → bubble expands downward
|
|
884
|
+
12. Drag to screen edge → position clamped to visible area (multi-monitor aware)
|
|
885
|
+
13. First launch → "Drag me to move!" hint fades after 3s
|
|
886
|
+
14. Hit area feels comfortable (80x80, larger than visible 60x60 character)
|
|
887
|
+
|
|
888
|
+
- [ ] **Step 4: Commit any fixes**
|