Kitten yields to panels too; redo bug not reproducible

The overlap hook only watched .petal-rail-card, so the History and Garden
drawers still sat under the mascot — with a real control ("写作证明 ·
Writing passport") buried under the halo on the live build.

Match [role="dialog"][aria-modal="true"] as well. Both drawers already
render it, so this covers them and any future drawer without a selector
list to keep in sync.

Two things the follow-up note didn't anticipate:

- The hook now reports { cards, modal } separately. A card overlap still
  lets the kitten wake for a bubble; a modal overlap yields
  unconditionally — a cheer isn't worth covering the panel she just
  opened on purpose.
- The speech bubble is its own layer, so fading the badge didn't hide it.
  Hold it back while a panel is open; useCompanion keeps it in state, so
  it reappears when she closes the panel.

Also guard the poll's setState on value equality, so the 500 ms tick
stops re-rendering the companion for an unchanged answer.

UX_REVIEW item 1 (redo does not re-apply an accepted suggestion) is
recorded as NOT REPRODUCIBLE. Read the prosemirror-history state directly
and hooked view.dispatch: redo works pressed immediately, after an 18 s
pause that lets a full re-check land, and with the editor never focused.
The doc's hypothesis is false — every re-check transaction is
decoration-only, which prosemirror-history ignores, and canRedo stayed
true throughout. Two real findings from that dig are written into the doc
instead: keyboard undo dies when focus isn't in the editor, and an undone
suggestion stays accepted server-side so its card doesn't reliably return.

Item 5's premise is also partly wrong and now re-scoped: the Chinese
sentence does produce a card with an English rendering, just labeled
Clarity rather than a first-class Translate type.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
prosolis
2026-07-27 22:12:30 -07:00
parent ec9fba9252
commit be9aa13287
3 changed files with 116 additions and 27 deletions
+38 -13
View File
@@ -5,11 +5,31 @@ import { useEffect, useState, type RefObject } from 'react'
// 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)
// What the mascot yields to. Suggestion cards stack down into its corner; the
// History and Garden drawers cover it outright, and their footer controls sat
// under the halo. Matching on the modal role rather than each panel's own class
// 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.
const CARD = '.petal-rail-card'
const MODAL = '[role="dialog"][aria-modal="true"]'
export interface CardOverlap {
// A suggestion card reaches the mascot. It should get out of the way, but may
// still wake up when it has something to say.
cards: boolean
// A modal panel (History, Garden) covers the mascot's corner. Nothing the
// kitten wants to say is worth covering a dialog the writer opened on
// purpose, so this yields unconditionally.
modal: boolean
}
// Reports what, if anything, the mascot should yield to at the given element.
// 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 })
useEffect(() => {
let raf = 0
@@ -18,15 +38,20 @@ export function useCardOverlap(ref: RefObject<HTMLElement | null>): boolean {
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
const hits = (selector: string) => {
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) {
return true
}
}
return false
}
setOverlapped(hit)
const next = { cards: hits(CARD), modal: hits(MODAL) }
// Same-value object identity would re-render on every poll tick.
setOverlap((prev) =>
prev.cards === next.cards && prev.modal === next.modal ? prev : next,
)
}
const schedule = () => {
@@ -50,5 +75,5 @@ export function useCardOverlap(ref: RefObject<HTMLElement | null>): boolean {
}
}, [ref])
return overlapped
return overlap
}