diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 5d1f35ba450..9974ead119a 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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 diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index d36e63fe74f..c1e731f8d68 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -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) } }, []) 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 1fcd21169f0..ae3c1860b7c 100644 --- a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx +++ b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx @@ -1,12 +1,28 @@ 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 { 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' 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 + +// 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. +const ALPHA_HIT_THRESHOLD = 16 + /** * The pop-out overlay's only view: a transparent, draggable mascot with a mini * composer. @@ -51,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) @@ -81,12 +100,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 +154,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 +283,65 @@ 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). 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' }) + }, []) + + 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). 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 + } + + 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) { + zoomAnchorRef.current = null + + return + } + + const anchor = zoomAnchorRef.current + zoomAnchorRef.current = null + + // The sprite scales about its bottom-center, at window-local (curW/2, + // curH - paddingBottom). Hold the anchor pixel fixed on screen as it scales; + // with no wheel anchor we pin the bottom-center itself (ratio 1 ⇒ no shift). + const ratio = anchor?.ratio ?? 1 + const ax = anchor?.clientX ?? curW / 2 + const ay = anchor?.clientY ?? curH - PET_PADDING_BOTTOM + + const bounds = { + height, + width, + x: Math.round(window.screenX + ax - (ax - curW / 2) * ratio - width / 2), + y: Math.round(window.screenY + ay - (ay - (curH - PET_PADDING_BOTTOM)) * ratio - (height - PET_PADDING_BOTTOM)) + } + + 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 } @@ -251,7 +362,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 2ceba5ac0c8..b82d07ee717 100644 --- a/apps/desktop/src/components/pet/floating-pet.tsx +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -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,11 +12,15 @@ import { isSecondaryWindow } from '@/store/windows' import { useTheme } from '@/themes/context' import { PetSprite } from './pet-sprite' +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 @@ -41,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 @@ -62,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 { @@ -70,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) } /** @@ -105,6 +111,7 @@ export function FloatingPet() { // speech bubble (a container child) never renders flipped/backwards. const spriteWrapRef = useRef(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 +122,10 @@ 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 => 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 // and row-count metadata replace the cached base64 payload. @@ -235,12 +246,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 +263,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 +302,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 +315,7 @@ export function FloatingPet() { spriteWrapRef.current.style.transform = facing(next.x, petW) } }, - [petW] + [clamp, petW] ) const onPointerUp = useCallback((e: React.PointerEvent) => { @@ -323,6 +336,29 @@ export function FloatingPet() { } }, []) + // 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 // in-window one so there aren't two. if (!info.enabled || !info.spritesheetBase64 || overlayActive) { diff --git a/apps/desktop/src/components/pet/pet-sprite.tsx b/apps/desktop/src/components/pet/pet-sprite.tsx index 455f6a956aa..fe2e0a6daaf 100644 --- a/apps/desktop/src/components/pet/pet-sprite.tsx +++ b/apps/desktop/src/components/pet/pet-sprite.tsx @@ -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 diff --git a/apps/desktop/src/components/pet/use-pet-zoom-gesture.ts b/apps/desktop/src/components/pet/use-pet-zoom-gesture.ts new file mode 100644 index 00000000000..34a67e24888 --- /dev/null +++ b/apps/desktop/src/components/pet/use-pet-zoom-gesture.ts @@ -0,0 +1,58 @@ +import { type RefObject, useEffect } from 'react' + +import { $petInfo } from '@/store/pet' +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 + * 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( + ref: RefObject, + onScale: (scale: number, anchor: PetZoomAnchor) => void, + ready: boolean +): void { + useEffect(() => { + const el = ref.current + + if (!el || !ready) { + return + } + + const onWheel = (event: WheelEvent) => { + if (!event.altKey) { + return + } + + event.preventDefault() + + 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 }) + + return () => el.removeEventListener('wheel', onWheel) + }, [ref, onScale, ready]) +} diff --git a/apps/desktop/src/store/pet-gallery.ts b/apps/desktop/src/store/pet-gallery.ts index d5aa9ea7d52..40d629552fe 100644 --- a/apps/desktop/src/store/pet-gallery.ts +++ b/apps/desktop/src/store/pet-gallery.ts @@ -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 | undefined /** diff --git a/apps/desktop/src/store/pet-overlay.ts b/apps/desktop/src/store/pet-overlay.ts index 3fda5e83b3c..1ab1b64ad23 100644 --- a/apps/desktop/src/store/pet-overlay.ts +++ b/apps/desktop/src/store/pet-overlay.ts @@ -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 overlay→renderer 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.