Surface every outstanding suggestion as a card in the right-hand
whitespace, vertically aligned to the text it flags — so the writer sees
the whole queue at once instead of hovering each highlight. Cards stack
with collision avoidance, link both ways with their highlight (hover/click
↔ soft text wash, driven through the decoration plugin so it survives
edit repaints), and carry the same Accept / Dismiss / Ask Petal actions.
The rail is a progressive enhancement: it mounts only when there's room
beside the editor, otherwise the existing inline hover card is unchanged.
Stacked cards that reach the bottom-right corner tuck behind the
companion mascot (z-order).
When a card is expanded, the Ask Petal bubble now opens with the
Simplified-Chinese translation of the explanation (the English stays in
the card body) instead of repeating the same text twice — a new
POST /api/suggestions/{id}/translate one-shot LLM endpoint, loaded
lazily on open with an English fallback.
Verified live against the local LLM via the uitest harness: rail
stacking, hover↔text wash, expand/Ask Petal, accept-from-rail, narrow
fallback, and the Mandarin bubble.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
120 lines
3.9 KiB
TypeScript
120 lines
3.9 KiB
TypeScript
import { useState } from 'react'
|
|
import type { Suggestion } from '../../api/client'
|
|
import { AskPetal } from './AskPetal'
|
|
import { TYPE_META } from './suggestionMeta'
|
|
|
|
interface Props {
|
|
suggestion: Suggestion
|
|
style: React.CSSProperties
|
|
onAccept: (s: Suggestion) => 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
|
|
}
|
|
|
|
// 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,
|
|
onAccept,
|
|
onDismiss,
|
|
onPointerEnter,
|
|
onPointerLeave,
|
|
onExpandChange,
|
|
}: Props) {
|
|
const meta = TYPE_META[suggestion.type]
|
|
const hasReplacement = suggestion.replacement.trim() !== ''
|
|
const [asking, setAsking] = useState(false)
|
|
|
|
function toggleAsking() {
|
|
setAsking((prev) => {
|
|
const next = !prev
|
|
onExpandChange(next)
|
|
return next
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-label={`${meta.label} suggestion`}
|
|
onMouseEnter={onPointerEnter}
|
|
onMouseLeave={onPointerLeave}
|
|
className="petal-suggestion-card absolute z-20 p-3.5 text-sm"
|
|
style={{
|
|
width: asking ? 340 : 300,
|
|
background: 'var(--color-surface)',
|
|
border: '1px solid 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)' }}
|
|
>
|
|
{meta.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>
|
|
</div>
|
|
)
|
|
}
|