Compare commits

..
2 Commits
Author SHA1 Message Date
prosolis db9cfb7abf Merge fix/companion-flicker-probe: the kitten holds still, for real this time 2026-07-28 22:21:33 -07:00
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
3 changed files with 55 additions and 23 deletions
@@ -88,13 +88,18 @@ export function PetalCompanion({
const [pickerOpen, setPickerOpen] = useState(false) const [pickerOpen, setPickerOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null) const rootRef = useRef<HTMLDivElement>(null)
const badgeRef = useRef<HTMLButtonElement>(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 // When suggestion cards stack down into the corner, the kitten fades to
// translucent and shrinks a step so the card stays readable and clickable. // 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 // 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 // interacted with (picker open) — except under an open History or Garden
// panel, where even a cheer would cover the controls she just reached for. // 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) const faded = crowded.modal || (crowded.cards && !pickerOpen && !bubble)
// Awake companions (no sleeping clip) don't visibly nap — when the engine // Awake companions (no sleeping clip) don't visibly nap — when the engine
@@ -135,7 +140,7 @@ export function PetalCompanion({
return ( return (
<div <div
ref={rootRef} 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 && ( {pickerOpen && (
<div <div
@@ -251,6 +256,16 @@ export function PetalCompanion({
</div> </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 <button
ref={badgeRef} ref={badgeRef}
type="button" 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 // 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 // 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 // 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 // in sync. Anything that doesn't actually reach the corner still won't trip the
// rect test below. // 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 CARD = '.petal-rail-card'
const MODAL = '[role="dialog"][aria-modal="true"]' const MODAL = '[role="dialog"][aria-modal="true"]'
@@ -24,27 +29,22 @@ export interface CardOverlap {
modal: boolean 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. // 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 // 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 // into its corner, so nothing is ever hidden (or made unclickable) by the
// kitten. // kitten.
export function useCardOverlap(ref: RefObject<HTMLElement | null>): CardOverlap { export function useCardOverlap(ref: RefObject<HTMLElement | null>): CardOverlap {
const [overlap, setOverlap] = useState<CardOverlap>({ cards: false, modal: false }) 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(() => { useEffect(() => {
let raf = 0 let raf = 0
@@ -52,17 +52,29 @@ export function useCardOverlap(ref: RefObject<HTMLElement | null>): CardOverlap
const check = () => { const check = () => {
const el = ref.current const el = ref.current
if (!el) return if (!el) return
const r = layoutRect(el) const r = el.getBoundingClientRect()
const hits = (selector: string) => { // 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)) { for (const other of document.querySelectorAll(selector)) {
const b = other.getBoundingClientRect() 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 true
} }
} }
return false 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. // Same-value object identity would re-render on every poll tick.
setOverlap((prev) => setOverlap((prev) =>
prev.cards === next.cards && prev.modal === next.modal ? prev : next, prev.cards === next.cards && prev.modal === next.modal ? prev : next,
+6 -1
View File
@@ -422,10 +422,15 @@ button, a, input {
/* --- Companion kitten ------------------------------------------------------- /* --- Companion kitten -------------------------------------------------------
The cozy corner mascot. Gently bobs while awake, settles and sways slowly 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. */ 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 /* 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. */ ~2× on a large desktop. Tune the middle (vw) term to taste. */
--petal-companion-size: clamp(9rem, 15.3vw, 18rem); --petal-companion-size: clamp(9rem, 15.3vw, 18rem);
}
.petal-companion {
animation: petal-bob 3.2s ease-in-out infinite; animation: petal-bob 3.2s ease-in-out infinite;
/* Shrink toward its corner when fading out of a card's way. `scale` is a /* 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. */ separate property from `transform` so it composes with the bob keyframes. */