feat(desktop): Alt+wheel to scale the pet, never cropped

Hold Alt/Option and scroll over the mascot to resize it (same on Mac and
Windows); the modifier keeps a plain scroll passing through to the page. The
gesture drives the same `display.pet.scale` path as the settings slider.

The popped-out overlay grows its OS window to fit the pet at any scale (anchored
bottom-center) so the sprite is never clipped by the window edge, and the
in-window pet re-clamps against its actual size so growing near an edge can't
crop it. Also makes the overlay click-through per-pixel: only solid sprite
pixels (plus bubble / mail button) are interactive, transparent margins pass
clicks through.
This commit is contained in:
Brooklyn Nicholson 2026-06-26 00:33:22 -05:00
parent 27c486e3b1
commit dd980aaba1
8 changed files with 256 additions and 35 deletions

View file

@ -6031,19 +6031,32 @@ ipcMain.handle('hermes:pet-overlay:close', async () => {
return { ok: true }
})
// Drag: the overlay reports a new absolute screen position (it already knows the
// pointer's screen coords), we just move the window.
// Drag/resize: the overlay reports new absolute screen bounds (it already knows
// the pointer's screen coords). Drag keeps the size constant; the wheel-to-scale
// gesture grows/shrinks it so the sprite is never cropped by the window edge.
// The window is created non-resizable (no stray edge-drag on the transparent
// frameless panel), which on Windows/Linux also blocks programmatic setBounds
// sizing — so briefly flip resizable on whenever the size actually changes.
ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => {
if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) {
return
}
petOverlayWindow.setBounds({
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(80, Math.round(bounds.width)),
height: Math.max(80, Math.round(bounds.height))
})
const win = petOverlayWindow
const width = Math.max(80, Math.round(bounds.width))
const height = Math.max(80, Math.round(bounds.height))
const [curW, curH] = win.getSize()
const resizing = width !== curW || height !== curH
if (resizing && !win.isResizable()) {
win.setResizable(true)
}
win.setBounds({ x: Math.round(bounds.x), y: Math.round(bounds.y), width, height })
if (resizing) {
win.setResizable(false)
}
})
// Click-through: the overlay window is a full rectangle but only the pet pixels
// should be interactive. The renderer toggles this as the cursor enters/leaves

View file

@ -46,7 +46,8 @@ import {
import { $paneOpen } from '../store/panes'
import { respondToApprovalAction } from '../store/native-notifications'
import { setPetActivity } from '../store/pet'
import { setPetOverlayOpenAppHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay'
import { setPetScale } from '../store/pet-gallery'
import { setPetOverlayOpenAppHandler, setPetOverlayScaleHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import {
$activeGatewayProfile,
@ -939,6 +940,8 @@ export function DesktopController() {
submitTextRef.current = submitText
const resumeSessionRef = useRef(resumeSession)
resumeSessionRef.current = resumeSession
const requestGatewayRef = useRef(requestGateway)
requestGatewayRef.current = requestGateway
useEffect(() => {
if (isSecondaryWindow()) {
@ -946,6 +949,9 @@ export function DesktopController() {
}
setPetOverlaySubmitHandler(text => void submitTextRef.current(text))
// Alt+wheel resize from the popped-out pet — persist it through this
// window's gateway (the overlay has none) so it survives restart.
setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale))
// Mail icon: $sessions is ordered most-recent-first; the pet is global (not
// per session) so "most recent" is the right target. main.cjs already raised
// the window before forwarding this.
@ -960,6 +966,7 @@ export function DesktopController() {
return () => {
setPetOverlaySubmitHandler(null)
setPetOverlayOpenAppHandler(null)
setPetOverlayScaleHandler(null)
}
}, [])

View file

@ -1,12 +1,24 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
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 { Mail } from '@/lib/icons'
import { $petActivity, $petInfo, setPetInfo } from '@/store/pet'
import { overlayWindowSize } from '@/store/pet-overlay'
import { setAwaitingResponse, setBusy } from '@/store/session'
// Fallbacks mirror pet-sprite's defaults; the gateway normally sends real values.
const DEFAULT_FRAME_W = 192
const DEFAULT_FRAME_H = 208
const DEFAULT_SCALE = 0.33
// 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.
const ALPHA_HIT_THRESHOLD = 16
/**
* The pop-out overlay's only view: a transparent, draggable mascot with a mini
* composer.
@ -81,12 +93,53 @@ export function PetOverlayApp() {
return off
}, [])
// Click-through: make only the sprite (or an open composer) interactive. With
// ignore+forward, the renderer still receives mousemove so we can re-enable
// hit-testing the moment the cursor returns to the pet.
// Click-through: make only the *solid* sprite pixels (plus the bubble / mail
// button / open composer) interactive — clicks on the transparent rectangle
// around the art pass through to whatever's behind. With ignore+forward, the
// renderer still receives mousemove so we can re-arm the moment the cursor
// returns to a solid pixel.
useEffect(() => {
setIgnore(true)
// True when the point sits on a solid sprite pixel or on the pet's other
// interactive chrome (bubble, mail button). Over the canvas we sample the
// rendered alpha; elsewhere inside the pet (bubble/button) we trust DOM
// hit-testing. Anything else is transparent backdrop.
const isInteractiveAt = (x: number, y: number): boolean => {
const pet = petRef.current
const target = document.elementFromPoint(x, y)
if (!pet || !target || !pet.contains(target)) {
return false
}
if (!(target instanceof HTMLCanvasElement)) {
return true
}
const rect = target.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) {
return true
}
const ctx = target.getContext('2d')
if (!ctx) {
return true
}
const px = Math.floor((x - rect.left) * (target.width / rect.width))
const py = Math.floor((y - rect.top) * (target.height / rect.height))
try {
return ctx.getImageData(px, py, 1, 1).data[3] >= ALPHA_HIT_THRESHOLD
} catch {
// Tainted/zero-size read — fail open so the pet stays grabbable.
return true
}
}
const onMove = (ev: MouseEvent) => {
if (dragRef.current || composerOpenRef.current) {
setIgnore(false)
@ -94,15 +147,7 @@ export function PetOverlayApp() {
return
}
const el = petRef.current
if (!el) {
return
}
const r = el.getBoundingClientRect()
const over = ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom
setIgnore(!over)
setIgnore(!isInteractiveAt(ev.clientX, ev.clientY))
}
window.addEventListener('mousemove', onMove)
@ -231,6 +276,52 @@ export function PetOverlayApp() {
window.hermesDesktop?.petOverlay?.control({ type: 'open-app' })
}
// 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) => {
setPetInfo({ ...$petInfo.get(), scale: next })
window.hermesDesktop?.petOverlay?.control({ scale: next, type: 'scale' })
}, [])
usePetZoomGesture(petRef, onScale, Boolean(info.enabled && info.spritesheetBase64))
// 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.
useEffect(() => {
if (!info.enabled || !info.spritesheetBase64) {
return
}
const { width, height } = overlayWindowSize(
info.frameW ?? DEFAULT_FRAME_W,
info.frameH ?? DEFAULT_FRAME_H,
info.scale ?? DEFAULT_SCALE
)
const curW = window.outerWidth
const curH = window.outerHeight
if (width === curW && height === curH) {
return
}
const bounds = {
height,
width,
x: Math.round(window.screenX + (curW - width) / 2),
y: Math.round(window.screenY + (curH - height))
}
window.hermesDesktop?.petOverlay?.setBounds(bounds)
window.hermesDesktop?.petOverlay?.control({ bounds, type: 'bounds' })
}, [info.enabled, info.spritesheetBase64, info.scale, info.frameW, info.frameH])
if (!info.enabled || !info.spritesheetBase64) {
return null
}

View file

@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { persistString, storedString } from '@/lib/storage'
import { $petInfo, clearPetUnread, type PetInfo, petProfile, setPetInfo } from '@/store/pet'
import { resetPetGallery } from '@/store/pet-gallery'
import { resetPetGallery, setPetScale } from '@/store/pet-gallery'
import { $petOverlayActive, initPetOverlayBridge, popOutPet, restorePetOverlay } from '@/store/pet-overlay'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { $gatewayState } from '@/store/session'
@ -12,6 +12,7 @@ import { isSecondaryWindow } from '@/store/windows'
import { useTheme } from '@/themes/context'
import { PetSprite } from './pet-sprite'
import { 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.
@ -105,6 +106,7 @@ export function FloatingPet() {
// speech bubble (a container child) never renders flipped/backwards.
const spriteWrapRef = useRef<HTMLDivElement | null>(null)
const petW = (info.frameW ?? 192) * (info.scale ?? 0.33)
const petH = (info.frameH ?? 208) * (info.scale ?? 0.33)
// Soft contact shadow, sized off the pet so every scale/species grounds the
// same way (cf. lairp's per-actor feet ellipse). Lighter on light backgrounds.
const shadowW = Math.round(petW * 0.55)
@ -115,6 +117,16 @@ export function FloatingPet() {
// state is only committed on release.
const dragRef = useRef<{ dx: number; dy: number; x: number; y: number } | null>(null)
// 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]
)
// Fetch pet.info on connect. Poll quickly while inactive so an in-app
// `/pet <slug>` appears, then slowly while active so regenerated spritesheets
// and row-count metadata replace the cached base64 payload.
@ -235,12 +247,13 @@ export function FloatingPet() {
restorePetOverlay()
}, [active])
// A window resize must never strand the pet off-screen — re-clamp the
// committed position (and persist it) whenever the viewport shrinks.
// Never strand or crop the pet: re-clamp (and persist) whenever the viewport
// shrinks or the pet's own size changes (wheel/slider). `clamp` carries the
// current size, so depending on it covers both triggers.
useEffect(() => {
const onResize = () =>
const reclamp = () =>
setPosition(prev => {
const next = clampToViewport(prev)
const next = clamp(prev)
if (next.x === prev.x && next.y === prev.y) {
return prev
@ -251,10 +264,11 @@ export function FloatingPet() {
return next
})
window.addEventListener('resize', onResize)
reclamp()
window.addEventListener('resize', reclamp)
return () => window.removeEventListener('resize', onResize)
}, [])
return () => window.removeEventListener('resize', reclamp)
}, [clamp])
const onPointerDown = useCallback((e: React.PointerEvent) => {
const el = containerRef.current
@ -289,7 +303,7 @@ export function FloatingPet() {
return
}
const next = clampToViewport({ x: e.clientX - drag.dx, y: e.clientY - drag.dy })
const next = clamp({ x: e.clientX - drag.dx, y: e.clientY - drag.dy })
drag.x = next.x
drag.y = next.y
// Mutate the DOM directly — no setState, so no re-render while dragging. The
@ -302,7 +316,7 @@ export function FloatingPet() {
spriteWrapRef.current.style.transform = facing(next.x, petW)
}
},
[petW]
[clamp, petW]
)
const onPointerUp = useCallback((e: React.PointerEvent) => {
@ -323,6 +337,12 @@ 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])
usePetZoomGesture(containerRef, onScale, active && !overlayActive)
// While popped out, the desktop overlay window owns the mascot — hide the
// in-window one so there aren't two.
if (!info.enabled || !info.spritesheetBase64 || overlayActive) {

View file

@ -117,7 +117,9 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
return
}
const ctx = canvas.getContext('2d')
// willReadFrequently: the pop-out overlay samples this canvas's alpha under
// the cursor (per-pixel click-through), so opt into the CPU-readback path.
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) {
return

View file

@ -0,0 +1,46 @@
import { type RefObject, useEffect } from 'react'
import { $petInfo } from '@/store/pet'
import { nextScaleFromWheel } from '@/store/pet-gallery'
/**
* Wire Alt/Option+wheel-to-scale onto a pet element: hold Alt and scroll up to
* grow the pet, down to shrink identical on Mac and Windows. The modifier is
* required so a plain scroll over the pet still scrolls the page underneath
* (we only `preventDefault` while Alt is held).
*
* Native + non-passive so the conditional `preventDefault` actually takes (and
* keeps Electron from treating the gesture as a page zoom). Scale is read live
* from `$petInfo` so rapid steps compound without re-binding the listener.
*
* `ready` must track whether the pet element is actually rendered (both callers
* return null until a pet is enabled). It's a dependency so the listener
* (re)binds the moment the element mounts `ref.current` changing alone would
* never re-run the effect.
*/
export function usePetZoomGesture<T extends HTMLElement>(
ref: RefObject<T | null>,
onScale: (scale: number) => void,
ready: boolean
): void {
useEffect(() => {
const el = ref.current
if (!el || !ready) {
return
}
const onWheel = (event: WheelEvent) => {
if (!event.altKey) {
return
}
event.preventDefault()
onScale(nextScaleFromWheel($petInfo.get().scale, event.deltaY))
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => el.removeEventListener('wheel', onWheel)
}, [ref, onScale, ready])
}

View file

@ -333,6 +333,21 @@ export const PET_SCALE_MAX = 3.0
export const PET_SCALE_DEFAULT = 0.33
export const clampPetScale = (n: number) => Math.max(PET_SCALE_MIN, Math.min(PET_SCALE_MAX, n))
// Wheel → scale. Multiplicative so one notch feels the same at any size. Tuned
// for a discrete mouse-wheel notch (deltaY ≈ ±100); trackpad two-finger scroll
// (smaller deltas) just resizes more gently, which is fine.
const WHEEL_SCALE_K = 0.0015
/**
* Next pet scale for one mouse-wheel step over the pet. Scrolling up (deltaY < 0)
* grows it, scrolling down shrinks it; the result is clamped to the slider's range.
*/
export function nextScaleFromWheel(current: number | undefined, deltaY: number): number {
const base = current ?? PET_SCALE_DEFAULT
return clampPetScale(base * Math.exp(-deltaY * WHEEL_SCALE_K))
}
let scalePersist: ReturnType<typeof setTimeout> | undefined
/**

View file

@ -55,6 +55,7 @@ export type PetOverlayControl =
| { type: 'bounds'; bounds: PetOverlayBounds }
| { type: 'open-app' }
| { type: 'toggle-app' }
| { type: 'scale'; scale: number }
// Persisted across restarts: was the pet popped out, and where on the desktop
// did the user leave it. Keyed v1; bump if the bounds shape ever changes.
@ -103,10 +104,24 @@ const OVERLAY_PAD_Y = 200
const OVERLAY_MIN_W = 240
const OVERLAY_MIN_H = 300
/**
* Window bounds (width/height) that fully contain the pet at a given scale, plus
* the padding for its bubble/composer/drag margins. The single source of truth
* for both the initial pop-out size and the live wheel-to-scale resize, so the
* sprite is never cropped by the window edge no matter how big it's scaled.
*/
export function overlayWindowSize(frameW: number, frameH: number, scale: number): { width: number; height: number } {
return {
width: Math.max(OVERLAY_MIN_W, Math.round(frameW * scale + OVERLAY_PAD_X)),
height: Math.max(OVERLAY_MIN_H, Math.round(frameH * scale + OVERLAY_PAD_Y))
}
}
let stateUnsubs: Array<() => void> = []
let controlUnsub: (() => void) | null = null
let submitHandler: ((text: string) => void) | null = null
let openAppHandler: (() => void) | null = null
let scaleHandler: ((scale: number) => void) | null = null
function currentPayload(): PetOverlayStatePayload {
return {
@ -173,8 +188,10 @@ export function popOutPet(petRect: PetOverlayBounds): void {
return
}
const width = Math.max(OVERLAY_MIN_W, Math.round(petRect.width + OVERLAY_PAD_X))
const height = Math.max(OVERLAY_MIN_H, Math.round(petRect.height + OVERLAY_PAD_Y))
// Size the window off the pet's scale (not the measured rect, which includes
// the shadow) so it matches the live resize math exactly — no jump on open.
const pet = $petInfo.get()
const { width, height } = overlayWindowSize(pet.frameW ?? 192, pet.frameH ?? 208, pet.scale ?? 0.33)
const x = Math.round(petRect.x - (width - petRect.width) / 2)
const y = Math.round(petRect.y - (height - petRect.height) / 2)
@ -223,6 +240,11 @@ export function setPetOverlayOpenAppHandler(fn: (() => void) | null): void {
openAppHandler = fn
}
/** Register the handler that persists a scale resized via the overlay's Alt+wheel gesture. */
export function setPetOverlayScaleHandler(fn: ((scale: number) => void) | null): void {
scaleHandler = fn
}
/**
* Wire the overlayrenderer control channel once. Returns a disposer. Idempotent
* a second call while already wired is a no-op.
@ -245,6 +267,11 @@ export function initPetOverlayBridge(): () => void {
} else if (payload?.type === 'bounds' && payload.bounds) {
// The user dragged the overlay to a new desktop spot — remember it.
saveBounds(payload.bounds)
} else if (payload?.type === 'scale' && typeof payload.scale === 'number') {
// The user resized the popped-out pet (Alt+wheel) — persist it through
// the main renderer's gateway; the new scale rides $petInfo back to the
// overlay on the next push, keeping both surfaces in sync.
scaleHandler?.(payload.scale)
} else if (payload?.type === 'open-app') {
// Mail icon: surface the app on the most recent thread (main.cjs already
// focused the window before forwarding this) and mark it read.