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
This commit is contained in:
prosolis
2026-07-28 22:21:33 -07:00
parent 5cd3aeabde
commit f82f2b589d
3 changed files with 55 additions and 23 deletions
+32 -20
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, type RefObject } from 'react'
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
@@ -11,6 +11,11 @@ const POLL_MS = 500
// 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"]'
@@ -24,27 +29,22 @@ export interface CardOverlap {
modal: boolean
}
// The mascot shrinks a step when it yields (.petal-companion-faded), and
// getBoundingClientRect reports the *scaled* box. Measuring that lets the
// mascot shrink out of its own overlap test: it yields, stops overlapping,
// wakes back up, overlaps again — pulsing forever against a card that sits just
// at its edge. The layout box doesn't move when it yields, so measure that. The
// transform origin is the centre, so it's the visual centre ± the layout size.
function layoutRect(el: HTMLElement) {
const r = el.getBoundingClientRect()
const halfW = el.offsetWidth / 2
const halfH = el.offsetHeight / 2
const cx = (r.left + r.right) / 2
const cy = (r.top + r.bottom) / 2
return { left: cx - halfW, right: cx + halfW, top: cy - halfH, bottom: cy + halfH }
}
// 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
@@ -52,17 +52,29 @@ export function useCardOverlap(ref: RefObject<HTMLElement | null>): CardOverlap
const check = () => {
const el = ref.current
if (!el) return
const r = layoutRect(el)
const hits = (selector: string) => {
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 && b.right > r.left && b.top < r.bottom && b.bottom > r.top) {
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 next = { cards: hits(CARD), modal: hits(MODAL) }
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,