feat(desktop): zoom the pet toward the cursor

Alt+wheel now scales about the pixel under the pointer instead of growing from a
corner, so the pet stays put under the cursor instead of running away. In-window
shifts its top-left; the overlay repositions its OS window (cursor-anchored on
wheel, bottom-center for slider-driven changes).
This commit is contained in:
Brooklyn Nicholson 2026-06-26 00:37:45 -05:00
parent dd980aaba1
commit 7d1b72a15d
3 changed files with 83 additions and 33 deletions

View file

@ -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<DragState | null>(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<PetZoomAnchor | null>(null)
const petRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLInputElement | null>(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'
}}

View file

@ -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 <slug>` 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

View file

@ -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<T extends HTMLElement>(
ref: RefObject<T | null>,
onScale: (scale: number) => void,
onScale: (scale: number, anchor: PetZoomAnchor) => void,
ready: boolean
): void {
useEffect(() => {
@ -36,7 +45,10 @@ export function usePetZoomGesture<T extends HTMLElement>(
}
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 })