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.

Files changed (133) hide show
  1. package/.claude/commands/feed.md +28 -0
  2. package/.claude/commands/name.md +28 -0
  3. package/.claude/commands/pet.md +29 -0
  4. package/.claude/commands/play.md +29 -0
  5. package/.claude/settings.local.json +41 -0
  6. package/.github/workflows/AGENTS.md +60 -0
  7. package/.github/workflows/build.yml +87 -0
  8. package/AGENTS.md +66 -0
  9. package/LICENSE +15 -0
  10. package/README.md +292 -0
  11. package/bin/claude-pet.js +42 -0
  12. package/build/AGENTS.md +50 -0
  13. package/build/dmg-background.png +0 -0
  14. package/build/entitlements.mac.plist +14 -0
  15. package/build/icon.ico +0 -0
  16. package/build/icon.png +0 -0
  17. package/build/installerHeader.bmp +0 -0
  18. package/build/installerSidebar.bmp +0 -0
  19. package/build/tray-icon.png +0 -0
  20. package/dist/main/core/badge-manager.js +49 -0
  21. package/dist/main/core/badge-registry.js +72 -0
  22. package/dist/main/core/badge-triggers.js +45 -0
  23. package/dist/main/core/contextual-messages.js +372 -0
  24. package/dist/main/core/messages.js +440 -0
  25. package/dist/main/core/mood-engine.js +145 -0
  26. package/dist/main/core/pet-messages.js +612 -0
  27. package/dist/main/core/pet-state-engine.js +232 -0
  28. package/dist/main/core/quote-collection.js +60 -0
  29. package/dist/main/core/quote-registry.js +175 -0
  30. package/dist/main/core/quote-triggers.js +62 -0
  31. package/dist/main/core/usage-tracker.js +625 -0
  32. package/dist/main/main/auto-launch.js +39 -0
  33. package/dist/main/main/auto-updater.js +98 -0
  34. package/dist/main/main/event-watcher.js +174 -0
  35. package/dist/main/main/ipc-handlers.js +89 -0
  36. package/dist/main/main/main.js +422 -0
  37. package/dist/main/main/preload.js +93 -0
  38. package/dist/main/main/settings-window.js +49 -0
  39. package/dist/main/main/share-card.js +139 -0
  40. package/dist/main/main/skin-manager.js +118 -0
  41. package/dist/main/main/tray.js +88 -0
  42. package/dist/main/shared/i18n.js +392 -0
  43. package/dist/main/shared/types.js +25 -0
  44. package/dist/main/shared/utils.js +9 -0
  45. package/dist/renderer/assets/claude-pet.png +0 -0
  46. package/dist/renderer/assets/index-BMnMEuOf.js +9 -0
  47. package/dist/renderer/assets/index-qzlrlqpX.css +1 -0
  48. package/dist/renderer/index.html +30 -0
  49. package/dist/renderer/share-card-template/card.html +148 -0
  50. package/docs/AGENTS.md +42 -0
  51. package/docs/images/angry.png +0 -0
  52. package/docs/images/character.webp +0 -0
  53. package/docs/images/claude-mama.png +0 -0
  54. package/docs/images/happy.png +0 -0
  55. package/docs/images/proud.png +0 -0
  56. package/docs/images/share-card-example.png +0 -0
  57. package/docs/images/worried_1.png +0 -0
  58. package/docs/images/worried_2.png +0 -0
  59. package/docs/spritesheet-bugs.md +240 -0
  60. package/docs/superpowers/plans/2026-03-10-compact-widget.md +888 -0
  61. package/docs/superpowers/plans/2026-03-10-viral-features.md +1874 -0
  62. package/docs/superpowers/plans/2026-03-14-update-ux.md +362 -0
  63. package/docs/superpowers/plans/2026-03-14-v1.1-features.md +2139 -0
  64. package/docs/superpowers/specs/2026-03-10-compact-widget-design.md +150 -0
  65. package/docs/superpowers/specs/2026-03-10-viral-features-design.md +217 -0
  66. package/docs/superpowers/specs/2026-03-14-streak-calendar-design.md +26 -0
  67. package/docs/superpowers/specs/2026-03-14-update-ux-design.md +172 -0
  68. package/docs/superpowers/specs/2026-03-14-v1.1-features-design.md +342 -0
  69. package/electron-builder.yml +75 -0
  70. package/package.json +48 -0
  71. package/scripts/AGENTS.md +60 -0
  72. package/scripts/install.ps1 +47 -0
  73. package/scripts/install.sh +98 -0
  74. package/scripts/make-icon.js +119 -0
  75. package/scripts/notarize.js +18 -0
  76. package/src/AGENTS.md +47 -0
  77. package/src/core/AGENTS.md +58 -0
  78. package/src/core/__tests__/AGENTS.md +60 -0
  79. package/src/core/__tests__/badge-triggers.test.ts +83 -0
  80. package/src/core/__tests__/contextual-messages.test.ts +87 -0
  81. package/src/core/__tests__/pet-state-engine.test.ts +350 -0
  82. package/src/core/__tests__/quote-collection.test.ts +62 -0
  83. package/src/core/__tests__/quote-triggers.test.ts +110 -0
  84. package/src/core/badge-manager.ts +50 -0
  85. package/src/core/badge-registry.ts +71 -0
  86. package/src/core/badge-triggers.ts +41 -0
  87. package/src/core/contextual-messages.ts +381 -0
  88. package/src/core/pet-messages.ts +615 -0
  89. package/src/core/pet-state-engine.ts +272 -0
  90. package/src/core/quote-collection.ts +63 -0
  91. package/src/core/quote-registry.ts +181 -0
  92. package/src/core/quote-triggers.ts +64 -0
  93. package/src/core/usage-tracker.ts +680 -0
  94. package/src/main/AGENTS.md +70 -0
  95. package/src/main/auto-launch.ts +38 -0
  96. package/src/main/auto-updater.ts +106 -0
  97. package/src/main/event-watcher.ts +159 -0
  98. package/src/main/ipc-handlers.ts +107 -0
  99. package/src/main/main.ts +425 -0
  100. package/src/main/preload.ts +111 -0
  101. package/src/main/settings-window.ts +50 -0
  102. package/src/main/share-card.ts +153 -0
  103. package/src/main/skin-manager.ts +119 -0
  104. package/src/main/tray.ts +94 -0
  105. package/src/renderer/AGENTS.md +62 -0
  106. package/src/renderer/App.tsx +270 -0
  107. package/src/renderer/assets/claude-mama.png +0 -0
  108. package/src/renderer/assets/claude-pet.png +0 -0
  109. package/src/renderer/components/AGENTS.md +50 -0
  110. package/src/renderer/components/Character.tsx +327 -0
  111. package/src/renderer/components/SpeechBubble.tsx +182 -0
  112. package/src/renderer/components/UsageIndicator.tsx +268 -0
  113. package/src/renderer/electron.d.ts +34 -0
  114. package/src/renderer/hooks/AGENTS.md +55 -0
  115. package/src/renderer/hooks/usePetState.ts +59 -0
  116. package/src/renderer/hooks/useWidgetMode.ts +18 -0
  117. package/src/renderer/index.html +29 -0
  118. package/src/renderer/main.tsx +13 -0
  119. package/src/renderer/pages/AGENTS.md +53 -0
  120. package/src/renderer/pages/Collection.tsx +252 -0
  121. package/src/renderer/pages/Settings.tsx +815 -0
  122. package/src/renderer/share-card-template/card.html +148 -0
  123. package/src/renderer/styles/AGENTS.md +50 -0
  124. package/src/renderer/styles/styles.css +166 -0
  125. package/src/shared/AGENTS.md +48 -0
  126. package/src/shared/i18n.ts +395 -0
  127. package/src/shared/types.ts +163 -0
  128. package/src/shared/utils.ts +6 -0
  129. package/tsconfig.json +16 -0
  130. package/tsconfig.main.json +12 -0
  131. package/tsconfig.renderer.json +12 -0
  132. package/vite.config.ts +47 -0
  133. 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**