import { useEffect, useRef, useState, type RefObject } from 'react' // How often to re-measure outside of scroll/resize events. Cards re-pack when // suggestions arrive, expand, or get accepted — none of which fire an event we // can hear from here, so a slow poll picks those up. const POLL_MS = 500 // What the mascot yields to. Suggestion cards stack down into its corner; the // History and Garden drawers cover it outright, and their footer controls sat // under the halo. Matching on the modal role rather than each panel's own class // means a future drawer is covered the day it's written, without a list to keep // in sync. Anything that doesn't actually reach the corner still won't trip the // rect test below. // How far a card must retreat before an already-yielded mascot comes back. Wide // enough to cover a re-pack or a browser bar appearing, small enough that a card // genuinely scrolled away still wakes it. const HOLD_PX = 24 const CARD = '.petal-rail-card' const MODAL = '[role="dialog"][aria-modal="true"]' export interface CardOverlap { // A suggestion card reaches the mascot. It should get out of the way, but may // still wake up when it has something to say. cards: boolean // A modal panel (History, Garden) covers the mascot's corner. Nothing the // kitten wants to say is worth covering a dialog the writer opened on // purpose, so this yields unconditionally. modal: boolean } // Reports what, if anything, the mascot should yield to at the given element. // // Pass the probe span, never the badge itself. The badge shrinks when it yields // (.petal-companion-faded) and bobs continuously (petal-bob), and // getBoundingClientRect reports the *transformed* box — so measuring the badge // lets it shrink out of its own overlap test, wake up, overlap again, and pulse // forever against a card resting at its edge. The probe holds the corner box // still whatever the mascot is doing, which is what makes the test settle. // Used to fade the corner mascot out of the way when a card or panel reaches // into its corner, so nothing is ever hidden (or made unclickable) by the // kitten. export function useCardOverlap(ref: RefObject): CardOverlap { const [overlap, setOverlap] = useState({ cards: false, modal: false }) // check() runs on a timer and reads the previous answer, which state alone // wouldn't hand it without re-subscribing every render. const current = useRef(overlap) useEffect(() => { let raf = 0 const check = () => { const el = ref.current if (!el) return const r = el.getBoundingClientRect() // Cards settle a pixel or two from the corner all the time — the rail // re-packs, the window resizes, a browser bar appears. Yielding is a // visible move, so once yielded, hold it until the card has clearly gone: // decide on a box grown by HOLD_PX rather than flipping on the exact edge. const hits = (selector: string, held: boolean) => { const pad = held ? HOLD_PX : 0 for (const other of document.querySelectorAll(selector)) { const b = other.getBoundingClientRect() if ( b.left < r.right + pad && b.right > r.left - pad && b.top < r.bottom + pad && b.bottom > r.top - pad ) { return true } } return false } const prev = current.current const next = { cards: hits(CARD, prev.cards), modal: hits(MODAL, prev.modal) } current.current = next // Same-value object identity would re-render on every poll tick. setOverlap((prev) => prev.cards === next.cards && prev.modal === next.modal ? prev : next, ) } const schedule = () => { if (raf) return raf = requestAnimationFrame(() => { raf = 0 check() }) } check() // Capture phase so scrolls inside nested scrollers (History panel, rail) count. window.addEventListener('scroll', schedule, true) window.addEventListener('resize', schedule) const timer = window.setInterval(check, POLL_MS) return () => { window.removeEventListener('scroll', schedule, true) window.removeEventListener('resize', schedule) window.clearInterval(timer) cancelAnimationFrame(raf) } }, [ref]) return overlap }