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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user