Merge fix/companion-flicker-probe: the kitten holds still, for real this time

This commit is contained in:
prosolis
2026-07-28 22:21:33 -07:00
3 changed files with 55 additions and 23 deletions
@@ -88,13 +88,18 @@ export function PetalCompanion({
const [pickerOpen, setPickerOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const badgeRef = useRef<HTMLButtonElement>(null)
// Stand-in for the badge's corner, used only for measuring — see
// useCardOverlap. It sits exactly where the badge sits but never bobs, shrinks
// or hovers, so what the mascot yields to can't depend on whether it is
// currently yielding.
const probeRef = useRef<HTMLSpanElement>(null)
// When suggestion cards stack down into the corner, the kitten fades to
// translucent and shrinks a step so the card stays readable and clickable.
// It wakes back up whenever it has something to say (bubble) or is being
// interacted with (picker open) — except under an open History or Garden
// panel, where even a cheer would cover the controls she just reached for.
const crowded = useCardOverlap(badgeRef)
const crowded = useCardOverlap(probeRef)
const faded = crowded.modal || (crowded.cards && !pickerOpen && !bubble)
// Awake companions (no sleeping clip) don't visibly nap — when the engine
@@ -135,7 +140,7 @@ export function PetalCompanion({
return (
<div
ref={rootRef}
className="pointer-events-none fixed bottom-4 right-4 z-40 flex flex-col items-end gap-2"
className="petal-corner pointer-events-none fixed bottom-4 right-4 z-40 flex flex-col items-end gap-2"
>
{pickerOpen && (
<div
@@ -251,6 +256,16 @@ export function PetalCompanion({
</div>
)}
<span
ref={probeRef}
aria-hidden
className="pointer-events-none absolute bottom-0 right-0"
style={{
width: 'var(--petal-companion-size, 9rem)',
height: 'var(--petal-companion-size, 9rem)',
}}
/>
<button
ref={badgeRef}
type="button"
+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,
+6 -1
View File
@@ -422,10 +422,15 @@ button, a, input {
/* --- Companion kitten -------------------------------------------------------
The cozy corner mascot. Gently bobs while awake, settles and sways slowly
while napping; its speech bubble pops in; little zzz drift up when asleep. */
.petal-companion {
/* The whole corner, badge and bubble and measuring probe alike. The size lives
here rather than on the badge so the probe — a sibling, not a child — is sized
from the same number the mascot is. */
.petal-corner {
/* Mascot size scales with the viewport width: ~original on a laptop, up to
~2× on a large desktop. Tune the middle (vw) term to taste. */
--petal-companion-size: clamp(9rem, 15.3vw, 18rem);
}
.petal-companion {
animation: petal-bob 3.2s ease-in-out infinite;
/* Shrink toward its corner when fading out of a card's way. `scale` is a
separate property from `transform` so it composes with the bob keyframes. */