Files
petal/web/src/components/Companion/useCardOverlap.ts
T
prosolis f82f2b589d Measure the corner the kitten sits in, not the kitten
The overlap test read the badge's own box, which bobs, shrinks 10% when it
yields, and grows 4% on hover — so the mascot's answer to "is a card in my way"
depended on what it was currently doing. Against the live build, with three
cards up and no bubble, it flipped between drawn widths of 293 and 264 on its
own: the 0.9 yield scale, cycling.

Measure a probe span instead. It sits exactly where the badge sits, never
animates, and moves the size variable to .petal-corner so both are sized from
the same number. Nothing the mascot does can now change what it yields to.

Cards also settle a pixel from the corner routinely — a rail re-pack, a resize,
a browser bar appearing — so hold a yield until the card has retreated 24px
rather than deciding on the exact edge.

Verified in a real browser over CDP: seven overlap depths against the mascot's
top edge, each settling once and holding, drawn width steady where it used to
swing.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 22:21:33 -07:00

107 lines
4.3 KiB
TypeScript

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<HTMLElement | null>): CardOverlap {
const [overlap, setOverlap] = useState<CardOverlap>({ 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
}