Files
petal/web/src/components/Editor/SuggestionCard.tsx
T
prosolis c6bf36bddf Triage the whole queue from the keyboard, and never type an n
The last of the UX review's item 8. Ctrl+. and Ctrl+, step through the
underlines from anywhere in the text; the card that opens takes focus and
answers Tab / Shift+Tab / Enter / Del / ? / Esc itself. Answering a card
advances to the next by itself, and the last one closes and puts the caret
back in the prose — so a document is triaged in five presses of Enter.

The item asked for bare Tab or n/p. Neither can exist in a text editor: an
unmodified letter is a letter. They work fine once a card holds focus, which
is where the item wanted them; getting there needs a chord that is safe to
press mid-sentence, and mid-composition, so the entry keys are IME-guarded
like every other binding.

The queue is the underlines read off the decoration DOM in document order,
not the suggestion list: a stop she cannot see is worse than one she never
visits, and it guarantees the card can anchor itself.

Escape is stopped at the card. Unhandled it would also have left
distraction-free mode, restoring the sidebar and — via the rail-follows-the-
mode fix — pulling the rail out from under her mid-triage.

The legend is bilingual and leads with the pair language, unlike the card's
English buttons: those name what she is learning, this is an instruction for
operating Petal, like the status bar. Key names are as printed on her
keyboard (Entrée, Suppr, Intro, Supr). The es pack's own punctuation test
caught the "?" and is right in general; the key cap is one named exemption.

Verified in a real browser at 1517x810 on a fresh database with no model,
over CDP — a keystroke feature deserves real keystrokes. Both layouts, wrap
in both directions, the accept/dismiss/advance loop, Ask Petal and back, the
full triage-to-empty criterion, and Accept-all clicked from a keyboard card.
The wiring has no unit test for the reason items 6, 7 and 8 recorded: jsdom
has no layout. triage.ts is pure and tested; browser-verified is written
down as browser-verified.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 21:11:42 -07:00

248 lines
9.3 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import type { Suggestion, SuggestionType } from '../../api/client'
import { usePack } from '../../i18n'
import { fromIME } from '../../lib/ime'
import { AskPetal } from './AskPetal'
import { TYPE_META, batchLabel, typeLabel } from './suggestionMeta'
import type { Direction } from './triage'
interface Props {
suggestion: Suggestion
style: React.CSSProperties
// How many pending suggestions share this card's type (including this one).
// Two or more offers to take the whole category in one step.
batchCount: number
onAccept: (s: Suggestion) => void
onAcceptAll: (type: SuggestionType) => void
onDismiss: (s: Suggestion) => void
onPointerEnter: () => void
onPointerLeave: () => void
// Pins the card open while the Ask Petal panel is expanded, so the chat isn't
// dismissed by the hover-close timer when the pointer drifts away.
onExpandChange: (expanded: boolean) => void
// How far the card reaches below the wrapper's top, in wrapper coordinates —
// the same report the rail makes (item 4). The card is absolutely positioned
// and so adds no layout height of its own; without this, an Ask Petal
// conversation that runs past the last line of text has no scrollable page
// under it and its Accept button simply can't be reached. 0 means "nothing to
// cover", which is what an unmounted card reports on its way out.
onExtent?: (bottom: number) => void
// Keyboard triage (item 8). The card was opened by a keystroke rather than a
// pointer, so it takes focus and answers the keys itself: the writer is
// walking the underlines and never touches the mouse.
keyboard?: boolean
// Move to the next/previous underline, and leave triage entirely.
onStep?: (dir: Direction) => void
onExit?: () => void
}
// SuggestionCard is the hover panel for a single suggestion: a colored type tag,
// the original → replacement diff, the friendly explanation, and accept/dismiss
// actions. Voice flags carry no replacement, so the diff row is hidden and only
// Dismiss is offered (awareness-only).
export function SuggestionCard({
suggestion,
style,
batchCount,
onAccept,
onAcceptAll,
onDismiss,
onPointerEnter,
onPointerLeave,
onExpandChange,
onExtent,
keyboard = false,
onStep,
onExit,
}: Props) {
const pack = usePack()
const meta = TYPE_META[suggestion.type]
const label = typeLabel(suggestion.type, pack)
const hasReplacement = suggestion.replacement.trim() !== ''
const [asking, setAsking] = useState(false)
const cardRef = useRef<HTMLDivElement>(null)
// Report the card's reach while it is open, and withdraw it on the way out.
// A ResizeObserver rather than a one-shot measure because the card grows
// after it is mounted: the Ask Petal panel opens, and then the reply streams
// into it token by token.
useEffect(() => {
const el = cardRef.current
if (!el || !onExtent) return
const report = () => onExtent(el.offsetTop + el.offsetHeight)
report()
const observer = new ResizeObserver(report)
observer.observe(el)
return () => {
observer.disconnect()
onExtent(0)
}
}, [onExtent])
// In triage the card is where the keys land, so it has to hold focus — and it
// has to re-take it on every step, because stepping keeps this same component
// mounted and only swaps the suggestion inside it. `preventScroll` for the
// reason AskPetal's input gives: the card is an absolutely-positioned overlay,
// and letting the browser "reveal" it would jump the document out from under
// the sentence she is reading. The span is scrolled to deliberately elsewhere.
useEffect(() => {
if (keyboard) cardRef.current?.focus({ preventScroll: true })
}, [keyboard, suggestion.id])
// The triage keys. Only bound in keyboard mode: a card opened by the pointer
// never holds focus, and stealing Tab from one that somehow did would break
// ordinary focus movement for no gain.
function handleKeyDown(e: React.KeyboardEvent) {
if (!keyboard || fromIME(e)) return
const target = e.target as HTMLElement
// Ask Petal's question field is a text input inside this card. While it has
// focus it owns every key it can use — Tab, Enter and the letters are hers
// to type — and only Escape is taken, to step back out to the card.
const typing = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement
if (e.key === 'Escape') {
e.preventDefault()
// Escape also leaves distraction-free mode (App's window listener), which
// would restore the sidebar and pull the rail out from under her mid-
// triage. In triage this key means "this card", or "triage" — never "the
// writing mode".
e.stopPropagation()
if (typing) cardRef.current?.focus({ preventScroll: true })
else onExit?.()
return
}
if (typing) return
if (e.key === 'Tab') {
e.preventDefault()
onStep?.(e.shiftKey ? -1 : 1)
} else if (e.key === 'Enter') {
// An awareness-only card has nothing to accept; Enter on it does nothing
// rather than quietly meaning something else.
if (!hasReplacement) return
e.preventDefault()
onAccept(suggestion)
} else if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault()
onDismiss(suggestion)
} else if (e.key === '?') {
e.preventDefault()
// Opening hands focus to the panel's own input; Escape there comes back
// here, and ? then closes it again.
toggleAsking()
}
}
function toggleAsking() {
setAsking((prev) => {
const next = !prev
onExpandChange(next)
return next
})
}
return (
<div
ref={cardRef}
role="dialog"
aria-label={`${label} suggestion`}
tabIndex={keyboard ? -1 : undefined}
onKeyDown={handleKeyDown}
onMouseEnter={onPointerEnter}
onMouseLeave={onPointerLeave}
className="petal-suggestion-card absolute z-20 p-3.5 text-sm focus:outline-none"
style={{
width: asking ? 340 : 300,
background: 'var(--color-surface)',
// In triage the card is the only thing holding focus, and the writer has
// no pointer under it to say so. The accent border is that answer — the
// browser's own focus ring on a 300px panel reads as an error state.
border: `1px solid ${keyboard ? 'var(--color-accent)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-card)',
boxShadow: 'var(--shadow-soft)',
...style,
}}
>
<span
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-bold"
style={{ background: meta.color, color: 'var(--color-plum)' }}
>
{label}
</span>
{hasReplacement && (
<div className="mt-2.5 flex flex-col gap-1" style={{ fontFamily: 'var(--font-body)' }}>
<span className="text-[0.95rem] line-through" style={{ color: 'var(--color-muted)' }}>
{suggestion.original}
</span>
<span className="text-[0.95rem] font-medium" style={{ color: 'var(--color-plum)' }}>
{suggestion.replacement}
</span>
</div>
)}
<p className="mt-2.5 leading-snug" style={{ color: 'var(--color-plum)' }}>
{suggestion.explanation}
</p>
<button
type="button"
onClick={toggleAsking}
className="mt-2 rounded-full px-2.5 py-1 text-xs font-bold transition-colors"
style={{
background: asking ? 'var(--color-surface-alt)' : 'transparent',
color: 'var(--color-accent-hover)',
}}
>
{asking ? 'Hide Petal' : 'Ask Petal ✨'}
</button>
{asking && <AskPetal suggestionId={suggestion.id} explanation={suggestion.explanation} />}
<div className="mt-3 flex items-center gap-2">
{hasReplacement && (
<button
type="button"
onClick={() => onAccept(suggestion)}
className="rounded-full px-3.5 py-1.5 text-xs font-bold text-white"
style={{ background: 'var(--color-accent)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
>
Accept
</button>
)}
<button
type="button"
onClick={() => onDismiss(suggestion)}
className="rounded-full px-3.5 py-1.5 text-xs font-semibold"
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-muted)' }}
>
Dismiss
</button>
</div>
{hasReplacement && batchCount > 1 && (
<button
type="button"
onClick={() => onAcceptAll(suggestion.type)}
className="petal-accept-all mt-2 w-full rounded-full py-1.5 text-xs font-bold"
style={{ color: 'var(--color-plum)', borderColor: meta.color }}
>
{batchLabel(suggestion.type, batchCount)}
</button>
)}
{keyboard && (
<div
className="mt-2.5 border-t pt-2 text-[0.65rem] leading-tight"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-muted)' }}
>
<div>{pack.editor.triageHint.native}</div>
<div>{pack.editor.triageHint.en}</div>
</div>
)}
</div>
)
}