diff --git a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx index 062001fbb49..dbcee4b29ed 100644 --- a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx +++ b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { PetBubble } from '@/components/pet/pet-bubble' import { PetSprite } from '@/components/pet/pet-sprite' -import { usePetZoomGesture } from '@/components/pet/use-pet-zoom-gesture' +import { type PetZoomAnchor, usePetZoomGesture } from '@/components/pet/use-pet-zoom-gesture' import { Mail } from '@/lib/icons' import { $petActivity, $petInfo, setPetInfo } from '@/store/pet' import { overlayWindowSize } from '@/store/pet-overlay' @@ -14,6 +14,10 @@ const DEFAULT_FRAME_W = 192 const DEFAULT_FRAME_H = 208 const DEFAULT_SCALE = 0.33 +// Must match the root's paddingBottom — the sprite renders bottom-centered, this +// many px above the window's bottom edge. Used to anchor the resize. +const PET_PADDING_BOTTOM = 24 + // A sprite pixel counts as "solid" (interactive) at/above this alpha (0-255). // Low enough to catch anti-aliased edges, high enough that the faint halo around // the art still clicks through. @@ -63,6 +67,9 @@ export function PetOverlayApp() { const [unread, setUnread] = useState(false) const dragRef = useRef(null) + // Last Alt+wheel anchor, consumed by the resize effect to zoom toward the + // cursor; null means a non-wheel scale change (slider) → anchor bottom-center. + const zoomAnchorRef = useRef(null) const petRef = useRef(null) const inputRef = useRef(null) const ignoreRef = useRef(true) @@ -278,9 +285,10 @@ export function PetOverlayApp() { // Alt+wheel over the popped-out pet resizes it. The overlay has no gateway, // so paint the new scale locally for instant feedback, then ask the main - // renderer to persist it (it pushes the reconciled scale back). The window - // itself is grown to fit by the effect below. - const onScale = useCallback((next: number) => { + // renderer to persist it (it pushes the reconciled scale back). Stash the + // cursor anchor for the resize effect; the window itself is grown to fit there. + const onScale = useCallback((next: number, anchor: PetZoomAnchor) => { + zoomAnchorRef.current = anchor setPetInfo({ ...$petInfo.get(), scale: next }) window.hermesDesktop?.petOverlay?.control({ scale: next, type: 'scale' }) }, []) @@ -289,10 +297,10 @@ export function PetOverlayApp() { // Grow/shrink the OS overlay window to fit the pet at its current scale so the // sprite is never cropped — covers both the wheel gesture here and a scale - // changed from the app's settings slider (pushed in as a state update). The - // pet renders bottom-center, so anchor the window's bottom-center while - // resizing (it stays put; only the transparent headroom changes). New bounds - // are persisted so the pet reopens at the right size. + // changed from the app's settings slider (pushed in as a state update). With a + // wheel anchor we zoom toward the cursor (keep the pixel under it fixed); + // otherwise we anchor the bottom-center (the pet's feet stay planted). New + // bounds are persisted so the pet reopens at the right size. useEffect(() => { if (!info.enabled || !info.spritesheetBase64) { return @@ -308,14 +316,28 @@ export function PetOverlayApp() { const curH = window.outerHeight if (width === curW && height === curH) { + zoomAnchorRef.current = null + return } + const anchor = zoomAnchorRef.current + zoomAnchorRef.current = null + + // The sprite's bottom-center sits at (curW/2, curH - paddingBottom) and + // scales about that point. Solve for the new window origin that holds either + // the cursor pixel (wheel) or that bottom-center (slider) at a fixed spot. + const dx = anchor ? anchor.clientX - curW / 2 : 0 + const dy = anchor ? anchor.clientY - (curH - PET_PADDING_BOTTOM) : 0 + const ratio = anchor?.ratio ?? 1 + const anchorX = anchor?.clientX ?? curW / 2 + const anchorY = anchor?.clientY ?? curH - PET_PADDING_BOTTOM + const bounds = { height, width, - x: Math.round(window.screenX + (curW - width) / 2), - y: Math.round(window.screenY + (curH - height)) + x: Math.round(window.screenX + anchorX - dx * ratio - width / 2), + y: Math.round(window.screenY + anchorY - dy * ratio - (height - PET_PADDING_BOTTOM)) } window.hermesDesktop?.petOverlay?.setBounds(bounds) @@ -342,7 +364,7 @@ export function PetOverlayApp() { flexDirection: 'column', height: '100vh', justifyContent: 'flex-end', - paddingBottom: 24, + paddingBottom: PET_PADDING_BOTTOM, userSelect: 'none', width: '100vw' }} diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx index 424a55c4e3d..b82d07ee717 100644 --- a/apps/desktop/src/components/pet/floating-pet.tsx +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -12,12 +12,15 @@ import { isSecondaryWindow } from '@/store/windows' import { useTheme } from '@/themes/context' import { PetSprite } from './pet-sprite' -import { usePetZoomGesture } from './use-pet-zoom-gesture' +import { type PetZoomAnchor, usePetZoomGesture } from './use-pet-zoom-gesture' // v2: positions are now top/left anchored (v1 stored bottom-anchored values, // which dragged inverted). Bumping the key discards stale v1 coordinates. const POSITION_KEY = 'hermes.desktop.pet-position.v2' +// Stand-in pet size for the pre-load clamp (real size flows in with `info`). +const NOMINAL_PET_PX = 96 + interface Point { x: number y: number @@ -42,11 +45,13 @@ function samePetRevision(info: PetInfo, meta: PetInfoMeta): boolean { ) } -function clampToViewport({ x, y }: Point): Point { - const maxX = Math.max(0, (window.innerWidth || 800) - 80) - const maxY = Math.max(0, (window.innerHeight || 600) - 80) - - return { x: Math.min(Math.max(0, x), maxX), y: Math.min(Math.max(0, y), maxY) } +// Keep a w×h box fully inside the viewport. Pre-pet-load callers pass a nominal +// size; the live size flows in once `info` arrives. +function clampPoint(x: number, y: number, w: number, h: number): Point { + return { + x: Math.min(Math.max(0, x), Math.max(0, (window.innerWidth || 800) - w)), + y: Math.min(Math.max(0, y), Math.max(0, (window.innerHeight || 600) - h)) + } } // The sprite art faces left by default, so mirror it when the pet's center sits @@ -63,7 +68,7 @@ function loadPosition(): Point { const parsed = JSON.parse(raw) as Point if (typeof parsed.x === 'number' && typeof parsed.y === 'number') { - return clampToViewport(parsed) + return clampPoint(parsed.x, parsed.y, NOMINAL_PET_PX, NOMINAL_PET_PX) } } } catch { @@ -71,7 +76,7 @@ function loadPosition(): Point { } // Default: lower-left corner (top/left anchored). - return clampToViewport({ x: 24, y: (window.innerHeight || 600) - 220 }) + return clampPoint(24, (window.innerHeight || 600) - 220, NOMINAL_PET_PX, NOMINAL_PET_PX) } /** @@ -119,13 +124,7 @@ export function FloatingPet() { // Keep the *whole* pet on-screen at its current size, so growing it near an // edge can't leave the window cropping it. Shared by drag + the reclamp effect. - const clamp = useCallback( - ({ x, y }: Point): Point => ({ - x: Math.min(Math.max(0, x), Math.max(0, (window.innerWidth || 800) - petW)), - y: Math.min(Math.max(0, y), Math.max(0, (window.innerHeight || 600) - petH)) - }), - [petW, petH] - ) + const clamp = useCallback(({ x, y }: Point): Point => clampPoint(x, y, petW, petH), [petW, petH]) // Fetch pet.info on connect. Poll quickly while inactive so an in-app // `/pet ` appears, then slowly while active so regenerated spritesheets @@ -337,10 +336,27 @@ export function FloatingPet() { } }, []) - // Alt+wheel over the pet resizes it, persisting to `display.pet.scale` through - // the same path as the settings slider, so the two agree. Growing it re-runs - // the reclamp effect above (via `clamp`), so it never ends up cropped. - const onScale = useCallback((next: number) => setPetScale(requestGateway, next), [requestGateway]) + // Alt+wheel over the pet resizes it (persisted via the same path as the + // settings slider). Zoom toward the cursor — shift the top-left so the pixel + // under the pointer stays put — so the pet grows in place instead of running + // off. The reclamp effect (via `clamp`) still guarantees it stays on-screen. + const onScale = useCallback( + (next: number, { clientX, clientY, ratio }: PetZoomAnchor) => { + setPetScale(requestGateway, next) + setPosition(prev => { + const at = clampPoint( + clientX - (clientX - prev.x) * ratio, + clientY - (clientY - prev.y) * ratio, + (info.frameW ?? 192) * next, + (info.frameH ?? 208) * next + ) + persistString(POSITION_KEY, JSON.stringify(at)) + + return at + }) + }, + [requestGateway, info.frameW, info.frameH] + ) usePetZoomGesture(containerRef, onScale, active && !overlayActive) // While popped out, the desktop overlay window owns the mascot — hide the diff --git a/apps/desktop/src/components/pet/use-pet-zoom-gesture.ts b/apps/desktop/src/components/pet/use-pet-zoom-gesture.ts index 0b10864e3ea..34a67e24888 100644 --- a/apps/desktop/src/components/pet/use-pet-zoom-gesture.ts +++ b/apps/desktop/src/components/pet/use-pet-zoom-gesture.ts @@ -1,7 +1,16 @@ import { type RefObject, useEffect } from 'react' import { $petInfo } from '@/store/pet' -import { nextScaleFromWheel } from '@/store/pet-gallery' +import { nextScaleFromWheel, PET_SCALE_DEFAULT } from '@/store/pet-gallery' + +/** Where the gesture happened + how much it scaled — lets callers zoom toward the + * cursor (keep the pixel under it fixed) instead of growing from a corner. */ +export interface PetZoomAnchor { + clientX: number + clientY: number + /** new / old scale, clamp-aware (1 at a bound, so the pet doesn't drift). */ + ratio: number +} /** * Wire Alt/Option+wheel-to-scale onto a pet element: hold Alt and scroll up to @@ -20,7 +29,7 @@ import { nextScaleFromWheel } from '@/store/pet-gallery' */ export function usePetZoomGesture( ref: RefObject, - onScale: (scale: number) => void, + onScale: (scale: number, anchor: PetZoomAnchor) => void, ready: boolean ): void { useEffect(() => { @@ -36,7 +45,10 @@ export function usePetZoomGesture( } event.preventDefault() - onScale(nextScaleFromWheel($petInfo.get().scale, event.deltaY)) + + const base = $petInfo.get().scale ?? PET_SCALE_DEFAULT + const next = nextScaleFromWheel(base, event.deltaY) + onScale(next, { clientX: event.clientX, clientY: event.clientY, ratio: next / base }) } el.addEventListener('wheel', onWheel, { passive: false })