Kitten yields to cards: fade, shrink, click-through; plus UX review doc

When a suggestion card drifts into the mascot's corner, the kitten turns
translucent (15%), steps back 10% (standalone `scale` so it composes with
the bob animation), and lets clicks pass through to the card. It wakes
while its bubble or the picker is open, or once the corner clears.

Also adds UX_REVIEW_2026-07-27.md — the hands-on review of the live deploy
turned into implementation-ready items (repro, location, fix, acceptance).

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
prosolis
2026-07-27 21:57:20 -07:00
parent 9c40a8ad3f
commit ec9fba9252
4 changed files with 290 additions and 2 deletions
@@ -3,6 +3,7 @@ import type { SaveStatus } from '../../hooks/useAutoSave'
import { useCompanion, type Mood } from './useCompanion'
import { LottiePlayer } from './LottiePlayer'
import { COMPANIONS, DEFAULT_COMPANION } from './companions'
import { useCardOverlap } from './useCardOverlap'
import { onPrefsScopeChange, readPref, writePref } from '../../lib/prefs'
import { usePack } from '../../i18n'
@@ -86,6 +87,14 @@ export function PetalCompanion({
const companion = COMPANIONS.find((c) => c.id === companionId) ?? COMPANIONS[0]
const [pickerOpen, setPickerOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const badgeRef = useRef<HTMLButtonElement>(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).
const crowded = useCardOverlap(badgeRef)
const faded = crowded && !pickerOpen && !bubble
// Awake companions (no sleeping clip) don't visibly nap — when the engine
// dozes them, keep their normal idle pose instead of a sleepy face. Only a
@@ -239,11 +248,14 @@ export function PetalCompanion({
)}
<button
ref={badgeRef}
type="button"
onClick={() => setPickerOpen((o) => !o)}
title="Choose a companion"
aria-label="Choose a companion"
className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`}
className={`petal-companion select-none ${faded ? 'petal-companion-faded' : 'pointer-events-auto'}${
napping ? ' petal-companion-sleep' : ''
}`}
style={{
// Size scales with the viewport — see --petal-companion-size in index.css.
width: 'var(--petal-companion-size)',
@@ -0,0 +1,54 @@
import { useEffect, 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
// True while any suggestion card (.petal-rail-card) overlaps the given element.
// Used to fade the corner mascot out of the way when the rail grows down into
// its corner, so a card is never hidden (or made unclickable) by the kitten.
export function useCardOverlap(ref: RefObject<HTMLElement | null>): boolean {
const [overlapped, setOverlapped] = useState(false)
useEffect(() => {
let raf = 0
const check = () => {
const el = ref.current
if (!el) return
const r = el.getBoundingClientRect()
let hit = false
for (const card of document.querySelectorAll('.petal-rail-card')) {
const b = card.getBoundingClientRect()
if (b.left < r.right && b.right > r.left && b.top < r.bottom && b.bottom > r.top) {
hit = true
break
}
}
setOverlapped(hit)
}
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 overlapped
}