Merge fix/companion-flicker-probe: the kitten holds still, for real this time
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user