Suggestions: right-margin comment rail + Mandarin explanations

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
This commit is contained in:
prosolis
2026-06-26 12:23:02 -07:00
parent 035dcf5d25
commit 82e2bcc777
12 changed files with 629 additions and 41 deletions

View File

@@ -1,9 +1,11 @@
import { useEffect, useRef, useState } from 'react'
import { streamSuggestionChat, type ChatMessage } from '../../api/client'
import { api, streamSuggestionChat, type ChatMessage } from '../../api/client'
interface Props {
suggestionId: string
// Pre-populates Petal's first bubble so the conversation opens with context.
// The English explanation (shown in the card body). Petal's opening bubble is
// its Simplified-Chinese translation, fetched on open — so the panel doesn't
// just repeat the same English text twice. Falls back to this on failure.
explanation: string
}
@@ -17,9 +19,10 @@ const CHAT_FONT = "'Nunito', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC
// the card (unmounting) clears it. Each send streams Petal's reply token-by-
// token into the latest assistant bubble.
export function AskPetal({ suggestionId, explanation }: Props) {
const [messages, setMessages] = useState<ChatMessage[]>([
{ role: 'assistant', content: explanation },
])
// Opening bubble starts empty (caret-only) and fills with the Mandarin
// translation once it lands; `seeding` drives that loading caret.
const [messages, setMessages] = useState<ChatMessage[]>([{ role: 'assistant', content: '' }])
const [seeding, setSeeding] = useState(true)
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
@@ -36,6 +39,31 @@ export function AskPetal({ suggestionId, explanation }: Props) {
inputRef.current?.focus()
}, [])
// Fetch the Chinese translation of the explanation to seed the first bubble.
// Only replaces the seed bubble if the user hasn't started chatting yet (the
// conversation always opens with this one assistant turn). Falls back to the
// English explanation if the translation can't be fetched.
useEffect(() => {
let cancelled = false
api
.translateSuggestion(suggestionId)
.then((res) => {
if (cancelled) return
const text = res.translation.trim() || explanation
setMessages((prev) => (prev.length === 1 ? [{ role: 'assistant', content: text }] : prev))
})
.catch(() => {
if (cancelled) return
setMessages((prev) => (prev.length === 1 ? [{ role: 'assistant', content: explanation }] : prev))
})
.finally(() => {
if (!cancelled) setSeeding(false)
})
return () => {
cancelled = true
}
}, [suggestionId, explanation])
async function send() {
const text = input.trim()
if (!text || streaming) return
@@ -86,7 +114,12 @@ export function AskPetal({ suggestionId, explanation }: Props) {
style={{ maxHeight: 220 }}
>
{messages.map((m, i) => (
<Bubble key={i} role={m.role} content={m.content} streaming={streaming && i === messages.length - 1} />
<Bubble
key={i}
role={m.role}
content={m.content}
streaming={(streaming && i === messages.length - 1) || (seeding && i === 0)}
/>
))}
</div>

View File

@@ -18,7 +18,8 @@ import type { EditorView } from '@tiptap/pm/view'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Toolbar } from '../Toolbar/Toolbar'
import { SuggestionCard } from './SuggestionCard'
import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight'
import { SuggestionRail, type RailItem } from './SuggestionRail'
import { SuggestionHighlight, setSuggestions, setActiveSuggestion, findRange } from './SuggestionHighlight'
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
import { MisspellCard } from './MisspellCard'
import { WordCard } from './WordCard'
@@ -219,6 +220,18 @@ export function EditorCore({
const rewriteReqRef = useRef(0)
// The in-document Find & Replace bar (Ctrl/Cmd+F).
const [findOpen, setFindOpen] = useState(false)
// The right-margin comment rail. `railItems` carries each anchored suggestion's
// vertical offset; `railEnabled` is true only when the viewport has room for the
// column beside the editor (otherwise we fall back to the inline hover cards).
// `railExpandedId` is the card showing its full explanation + Ask Petal, and
// `activeId` is the suggestion currently emphasized (hovered text or card).
const [railItems, setRailItems] = useState<RailItem[]>([])
const [railEnabled, setRailEnabled] = useState(false)
const [railExpandedId, setRailExpandedId] = useState<string | null>(null)
const [activeId, setActiveId] = useState<string | null>(null)
// A stable handle to the latest recompute so the editor's onUpdate (captured
// once at construction) can trigger a re-measure without stale closures.
const recomputeRailRef = useRef<() => void>(() => {})
const editor = useEditor({
extensions: [
@@ -282,6 +295,8 @@ export function EditorCore({
content_text: editor.getText(),
word_count: editor.storage.characterCount.words(),
})
// Edits reflow the text, so the rail anchors need re-measuring.
recomputeRailRef.current()
},
onSelectionUpdate: ({ editor }) => {
// A selection supersedes the hover gloss; an empty one clears the bubble.
@@ -333,8 +348,87 @@ export function EditorCore({
setSuggestions(editor.state, editor.view.dispatch, suggestions)
// Close the card if its suggestion is gone.
setHover((h) => (h && suggestions.some((s) => s.id === h.suggestion.id) ? h : null))
// Drop any rail expand/emphasis that points at a now-removed suggestion.
setRailExpandedId((id) => (id && suggestions.some((s) => s.id === id) ? id : null))
setActiveId((id) => (id && suggestions.some((s) => s.id === id) ? id : null))
}, [editor, suggestions])
// Re-measure the margin rail: whether there's room for the column beside the
// editor, and where each suggestion's highlight sits vertically. Anchors are
// taken from the live decoration DOM (keyed by data-suggestion-id) relative to
// the wrapper — stable under scroll since text and wrapper scroll together.
// Deferred a frame so decoration DOM and reflow have settled.
const recomputeRail = useCallback(() => {
requestAnimationFrame(() => {
const wrapper = wrapperRef.current
if (!wrapper) return
const wrapRect = wrapper.getBoundingClientRect()
// Need room for the 300px column + its 32px gutter (see .petal-rail), plus
// a little breathing space to the viewport edge.
setRailEnabled(window.innerWidth - wrapRect.right >= 348)
const seen = new Set<string>()
const items: RailItem[] = []
wrapper.querySelectorAll<HTMLElement>('.petal-suggestion[data-suggestion-id]').forEach((el) => {
const id = el.getAttribute('data-suggestion-id')
if (!id || seen.has(id)) return
const s = suggestions.find((x) => x.id === id)
if (!s) return
seen.add(id)
items.push({ suggestion: s, anchorTop: el.getBoundingClientRect().top - wrapRect.top })
})
setRailItems(items)
})
}, [suggestions])
useEffect(() => {
recomputeRailRef.current = recomputeRail
}, [recomputeRail])
// Re-anchor when the suggestion set changes (after the decorations repaint),
// and keep the rail in sync with viewport/editor width changes (room + reflow).
useEffect(() => {
recomputeRail()
const wrapper = wrapperRef.current
const ro = wrapper ? new ResizeObserver(() => recomputeRail()) : null
if (wrapper && ro) ro.observe(wrapper)
window.addEventListener('resize', recomputeRail)
return () => {
ro?.disconnect()
window.removeEventListener('resize', recomputeRail)
}
}, [recomputeRail])
// Emphasize the flagged text for the active suggestion, mirroring the rail
// card ↔ text link both ways. Driven through the decoration plugin (not an
// imperative DOM class) so it survives the repaints that fire on every edit.
useEffect(() => {
if (!editor) return
setActiveSuggestion(editor.state, editor.view.dispatch, railEnabled ? activeId : null)
}, [editor, activeId, railEnabled])
// Scroll a suggestion's highlight to the middle of the viewport (clicking its
// rail card jumps the editor to the flagged text).
const scrollToSuggestion = useCallback((id: string) => {
const el = wrapperRef.current?.querySelector(
`.petal-suggestion[data-suggestion-id="${CSS.escape(id)}"]`,
)
el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
}, [])
// Clicking a rail card's body jumps to the text and toggles its detail panel.
const activateRailCard = useCallback(
(id: string) => {
setActiveId(id)
scrollToSuggestion(id)
setRailExpandedId((cur) => (cur === id ? null : id))
},
[scrollToSuggestion],
)
const toggleRailExpand = useCallback((id: string) => {
setRailExpandedId((cur) => (cur === id ? null : id))
}, [])
const openCardFor = useCallback(
(id: string, el: HTMLElement) => {
const wrapper = wrapperRef.current
@@ -366,10 +460,17 @@ export function EditorCore({
if (!target) return
const id = target.getAttribute('data-suggestion-id')
if (!id) return
// With the rail open the card already lives in the margin — hovering the
// text just emphasizes its card (and the highlight) rather than popping a
// second, redundant floating card.
if (railEnabled) {
setActiveId(id)
return
}
clearTimeout(closeTimer.current)
openCardFor(id, target)
},
[openCardFor],
[openCardFor, railEnabled],
)
const scheduleClose = useCallback(() => {
@@ -389,35 +490,53 @@ export function EditorCore({
// pointer can bridge the small gap from text to card.
const handleMouseOut = useCallback(
(e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('.petal-suggestion')) scheduleClose()
if (!(e.target as HTMLElement).closest('.petal-suggestion')) return
if (railEnabled) {
setActiveId(null)
return
}
scheduleClose()
},
[scheduleClose],
[scheduleClose, railEnabled],
)
const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
// Accept applies the replacement to the document, plays a little confetti
// burst where the card sat, then notifies the parent.
// burst over the flagged text, then notifies the parent. The confetti is
// anchored to the highlight itself (captured before the replacement removes it),
// so it fires in the right spot whether the accept came from the hover card or
// the margin rail.
const handleAccept = useCallback(
(s: Suggestion) => {
const wrapper = wrapperRef.current
const el = wrapper?.querySelector(
`.petal-suggestion[data-suggestion-id="${CSS.escape(s.id)}"]`,
) as HTMLElement | null
let burst: { top: number; left: number } | null = null
if (wrapper && el) {
const wrapRect = wrapper.getBoundingClientRect()
const elRect = el.getBoundingClientRect()
burst = { top: elRect.top - wrapRect.top, left: elRect.right - wrapRect.left }
}
if (editor && s.replacement.trim() !== '') {
const range = findRange(editor.state.doc, s.original)
if (range) {
editor.chain().focus().insertContentAt(range, s.replacement).run()
}
}
setHover((h) => {
if (h) {
setConfetti({ top: h.top, left: h.left + 16 })
clearTimeout(confettiTimer.current)
confettiTimer.current = setTimeout(() => setConfetti(null), 720)
}
return h
})
// Fall back to the hover card's position if the highlight wasn't found.
if (!burst && hover) burst = { top: hover.top, left: hover.left + 16 }
if (burst) {
setConfetti(burst)
clearTimeout(confettiTimer.current)
confettiTimer.current = setTimeout(() => setConfetti(null), 720)
}
closeCard()
setRailExpandedId(null)
onAccept(s)
},
[editor, onAccept, closeCard],
[editor, onAccept, closeCard, hover],
)
const handleDismiss = useCallback(
@@ -439,7 +558,16 @@ export function EditorCore({
const suggestionEl = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null
if (suggestionEl) {
const id = suggestionEl.getAttribute('data-suggestion-id')
if (id) openCardFor(id, suggestionEl)
if (id) {
// With the rail open, a tap emphasizes and expands its margin card
// instead of opening a floating one.
if (railEnabled) {
setActiveId(id)
setRailExpandedId(id)
} else {
openCardFor(id, suggestionEl)
}
}
return
}
if (!editor || !spellChecker) return
@@ -461,7 +589,7 @@ export function EditorCore({
setWordInfo(null)
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
},
[editor, spellChecker, closeCard, openCardFor],
[editor, spellChecker, closeCard, openCardFor, railEnabled],
)
const replaceMisspelling = useCallback(
@@ -854,7 +982,7 @@ export function EditorCore({
onAdd={addMisspellingToDict}
/>
)}
{hover && (
{hover && !railEnabled && (
<SuggestionCard
suggestion={hover.suggestion}
style={{ top: hover.top, left: hover.left }}
@@ -865,6 +993,18 @@ export function EditorCore({
onExpandChange={setPinned}
/>
)}
{railEnabled && railItems.length > 0 && (
<SuggestionRail
items={railItems}
activeId={activeId}
expandedId={railExpandedId}
onAccept={handleAccept}
onDismiss={handleDismiss}
onHover={setActiveId}
onActivate={activateRailCard}
onToggleExpand={toggleRailExpand}
/>
)}
</div>
</div>
)

View File

@@ -1,15 +1,7 @@
import { useState } from 'react'
import type { Suggestion, SuggestionType } from '../../api/client'
import type { Suggestion } from '../../api/client'
import { AskPetal } from './AskPetal'
// Per-type accent color + human label, mirroring the design tokens.
const TYPE_META: Record<SuggestionType, { color: string; label: string }> = {
grammar: { color: 'var(--color-mint)', label: 'Grammar' },
phrasing: { color: 'var(--color-peach)', label: 'Phrasing' },
idiom: { color: 'var(--color-lavender)', label: 'Idiom' },
clarity: { color: 'var(--color-sky)', label: 'Clarity' },
voice: { color: 'var(--color-honey)', label: 'Voice' },
}
import { TYPE_META } from './suggestionMeta'
interface Props {
suggestion: Suggestion

View File

@@ -16,9 +16,17 @@ export const suggestionPluginKey = new PluginKey<PluginState>('petalSuggestions'
interface PluginState {
suggestions: Suggestion[]
// The suggestion currently emphasized from its margin card / a hover, or null.
// Carried in plugin state (not an imperative DOM class) so it survives the
// decoration repaints that fire on every document change.
activeId: string | null
decorations: DecorationSet
}
// Meta carried on a transaction to update the plugin: either a fresh suggestion
// list or a change to which suggestion is emphasized.
type SuggestionMeta = { suggestions: Suggestion[] } | { activeId: string | null }
// mapOffset converts a character offset within a textblock's flattened text into
// an absolute ProseMirror position, accounting for inline atoms (e.g. hard
// breaks) that occupy a position but contribute no text.
@@ -130,14 +138,15 @@ export function findRange(doc: PMNode, search: string): { from: number; to: numb
return result
}
function buildDecorations(doc: PMNode, suggestions: Suggestion[]): DecorationSet {
function buildDecorations(doc: PMNode, suggestions: Suggestion[], activeId: string | null): DecorationSet {
const decos: Decoration[] = []
for (const s of suggestions) {
const range = findRange(doc, s.original)
if (!range) continue
const active = s.id === activeId ? ' petal-suggestion-active' : ''
decos.push(
Decoration.inline(range.from, range.to, {
class: `petal-suggestion petal-suggestion-${s.type}`,
class: `petal-suggestion petal-suggestion-${s.type}${active}`,
'data-suggestion-id': s.id,
}),
)
@@ -148,10 +157,22 @@ function buildDecorations(doc: PMNode, suggestions: Suggestion[]): DecorationSet
// setSuggestions pushes a new suggestion list into the plugin. Decorations are
// rebuilt against the current document immediately.
export function setSuggestions(state: EditorState, dispatch: (tr: Transaction) => void, suggestions: Suggestion[]) {
const tr = state.tr.setMeta(suggestionPluginKey, suggestions)
const tr = state.tr.setMeta(suggestionPluginKey, { suggestions } satisfies SuggestionMeta)
dispatch(tr)
}
// setActiveSuggestion marks one suggestion (or none) as emphasized, rebuilding
// the decorations so the flagged text gets the active wash. Driven by the margin
// rail's hover/selection so the card↔text link reads both ways.
export function setActiveSuggestion(
state: EditorState,
dispatch: (tr: Transaction) => void,
activeId: string | null,
) {
if (suggestionPluginKey.getState(state)?.activeId === activeId) return
dispatch(state.tr.setMeta(suggestionPluginKey, { activeId } satisfies SuggestionMeta))
}
export const SuggestionHighlight = Extension.create({
name: 'suggestionHighlight',
@@ -160,17 +181,29 @@ export const SuggestionHighlight = Extension.create({
new Plugin<PluginState>({
key: suggestionPluginKey,
state: {
init: () => ({ suggestions: [], decorations: DecorationSet.empty }),
init: () => ({ suggestions: [], activeId: null, decorations: DecorationSet.empty }),
apply(tr, value, _oldState, newState) {
const meta = tr.getMeta(suggestionPluginKey) as Suggestion[] | undefined
if (meta) {
return { suggestions: meta, decorations: buildDecorations(newState.doc, meta) }
const meta = tr.getMeta(suggestionPluginKey) as SuggestionMeta | undefined
if (meta && 'suggestions' in meta) {
return {
suggestions: meta.suggestions,
activeId: value.activeId,
decorations: buildDecorations(newState.doc, meta.suggestions, value.activeId),
}
}
if (meta && 'activeId' in meta) {
return {
suggestions: value.suggestions,
activeId: meta.activeId,
decorations: buildDecorations(newState.doc, value.suggestions, meta.activeId),
}
}
// On any document change, re-anchor by string against the new doc.
if (tr.docChanged) {
return {
suggestions: value.suggestions,
decorations: buildDecorations(newState.doc, value.suggestions),
activeId: value.activeId,
decorations: buildDecorations(newState.doc, value.suggestions, value.activeId),
}
}
return value

View File

@@ -0,0 +1,211 @@
import { forwardRef, useLayoutEffect, useRef, useState } from 'react'
import type { Suggestion } from '../../api/client'
import { AskPetal } from './AskPetal'
import { TYPE_META } from './suggestionMeta'
// Vertical breathing room kept between stacked cards when their natural anchors
// would otherwise collide.
const CARD_GAP = 12
export interface RailItem {
suggestion: Suggestion
// Vertical offset (px) of the suggestion's highlight, relative to the editor
// wrapper — the position the card wants to sit beside.
anchorTop: number
}
interface Props {
items: RailItem[]
// The suggestion currently emphasized (its highlight hovered, or this card
// hovered) — gets a lifted, ring-accented treatment so the text↔card link reads.
activeId: string | null
// The card expanded to show the full explanation + Ask Petal, or null.
expandedId: string | null
onAccept: (s: Suggestion) => void
onDismiss: (s: Suggestion) => void
// Pointer entering/leaving a card, so the matching highlight can light up.
onHover: (id: string | null) => void
// A card's body was clicked — scroll its highlight into view and toggle expand.
onActivate: (id: string) => void
onToggleExpand: (id: string) => void
}
// SuggestionRail is the right-margin "comment column": every outstanding
// suggestion as a card, vertically aligned to the text it flags, so the writer
// sees the whole queue at once instead of hunting highlight by highlight. Cards
// are anchored to their highlight's vertical position and pushed down only as far
// as needed to avoid overlapping their neighbor above (a la doc-editor comments).
export function SuggestionRail({
items,
activeId,
expandedId,
onAccept,
onDismiss,
onHover,
onActivate,
onToggleExpand,
}: Props) {
// Measured resolved tops keyed by suggestion id (after collision avoidance).
const [tops, setTops] = useState<Record<string, number>>({})
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map())
// Bumped whenever a card's own height changes (Ask Petal streaming in, wrapping
// text) so the stack re-packs and cards below don't get overlapped.
const [measureTick, setMeasureTick] = useState(0)
const observerRef = useRef<ResizeObserver | null>(null)
if (!observerRef.current && typeof ResizeObserver !== 'undefined') {
observerRef.current = new ResizeObserver(() => setMeasureTick((t) => t + 1))
}
useLayoutEffect(() => () => observerRef.current?.disconnect(), [])
// Sort by anchor, then greedily stack: each card sits at its anchor unless that
// would overlap the previous card, in which case it slides down just enough.
// Re-runs whenever the anchors or the expanded card (which changes a height)
// shift. Reads live offsetHeight so variable-length explanations pack tightly.
const ordered = [...items].sort((a, b) => a.anchorTop - b.anchorTop)
const layoutKey = ordered.map((i) => `${i.suggestion.id}:${Math.round(i.anchorTop)}`).join('|')
useLayoutEffect(() => {
let cursor = -Infinity
const next: Record<string, number> = {}
for (const { suggestion, anchorTop } of ordered) {
const h = cardRefs.current.get(suggestion.id)?.offsetHeight ?? 96
const top = Math.max(anchorTop, cursor)
next[suggestion.id] = top
cursor = top + h + CARD_GAP
}
setTops((prev) => {
const ids = Object.keys(next)
if (ids.length === Object.keys(prev).length && ids.every((id) => prev[id] === next[id])) return prev
return next
})
// layoutKey captures anchor positions; expandedId/measureTick capture height
// changes (toggling detail, Ask Petal streaming in).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutKey, expandedId, measureTick])
return (
<div className="petal-rail petal-no-print" aria-label="Suggestions">
{ordered.map(({ suggestion }) => (
<RailCard
key={suggestion.id}
ref={(el) => {
const prev = cardRefs.current.get(suggestion.id)
if (prev && prev !== el) observerRef.current?.unobserve(prev)
if (el) {
cardRefs.current.set(suggestion.id, el)
observerRef.current?.observe(el)
} else {
cardRefs.current.delete(suggestion.id)
}
}}
suggestion={suggestion}
top={tops[suggestion.id] ?? 0}
active={activeId === suggestion.id}
expanded={expandedId === suggestion.id}
onAccept={onAccept}
onDismiss={onDismiss}
onHover={onHover}
onActivate={onActivate}
onToggleExpand={onToggleExpand}
/>
))}
</div>
)
}
interface CardProps {
suggestion: Suggestion
top: number
active: boolean
expanded: boolean
onAccept: (s: Suggestion) => void
onDismiss: (s: Suggestion) => void
onHover: (id: string | null) => void
onActivate: (id: string) => void
onToggleExpand: (id: string) => void
}
const RailCard = forwardRef<HTMLDivElement, CardProps>(function RailCard(
{ suggestion, top, active, expanded, onAccept, onDismiss, onHover, onActivate, onToggleExpand },
ref,
) {
const meta = TYPE_META[suggestion.type]
const hasReplacement = suggestion.replacement.trim() !== ''
return (
<div
ref={ref}
role="group"
aria-label={`${meta.label} suggestion`}
onMouseEnter={() => onHover(suggestion.id)}
onMouseLeave={() => onHover(null)}
className={`petal-rail-card${active ? ' petal-rail-card-active' : ''}`}
style={{ top, borderLeftColor: meta.color }}
>
<div className="flex items-center justify-between">
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[0.68rem] font-bold"
style={{ background: meta.color, color: 'var(--color-plum)' }}
>
{meta.label}
</span>
<button
type="button"
aria-label="Dismiss suggestion"
onClick={() => onDismiss(suggestion)}
className="petal-rail-x"
>
</button>
</div>
{/* The body toggles the expanded explanation + Ask Petal and points the
editor at the flagged text. */}
<button type="button" onClick={() => onActivate(suggestion.id)} className="mt-2 block w-full text-left">
{hasReplacement && (
<span className="flex flex-col gap-0.5" style={{ fontFamily: 'var(--font-body)' }}>
<span className="truncate text-[0.9rem] line-through" style={{ color: 'var(--color-muted)' }}>
{suggestion.original}
</span>
<span className="truncate text-[0.9rem] font-medium" style={{ color: 'var(--color-plum)' }}>
{suggestion.replacement}
</span>
</span>
)}
<span
className={`mt-1.5 block text-[0.82rem] leading-snug${expanded ? '' : ' line-clamp-2'}`}
style={{ color: 'var(--color-plum)' }}
>
{suggestion.explanation}
</span>
</button>
{expanded && <AskPetal suggestionId={suggestion.id} explanation={suggestion.explanation} />}
<div className="mt-2.5 flex items-center gap-2">
{hasReplacement && (
<button
type="button"
onClick={() => onAccept(suggestion)}
className="rounded-full px-3 py-1 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={() => onToggleExpand(suggestion.id)}
className="rounded-full px-2.5 py-1 text-xs font-bold transition-colors"
style={{
background: expanded ? 'var(--color-surface-alt)' : 'transparent',
color: 'var(--color-accent-hover)',
}}
>
{expanded ? 'Hide Petal' : 'Ask Petal ✨'}
</button>
</div>
</div>
)
})

View File

@@ -0,0 +1,12 @@
import type { SuggestionType } from '../../api/client'
// Per-type accent color + human label, mirroring the design tokens. Shared by the
// inline hover SuggestionCard and the margin SuggestionRail so a given suggestion
// type reads the same color/name wherever it surfaces.
export const TYPE_META: Record<SuggestionType, { color: string; label: string }> = {
grammar: { color: 'var(--color-mint)', label: 'Grammar' },
phrasing: { color: 'var(--color-peach)', label: 'Phrasing' },
idiom: { color: 'var(--color-lavender)', label: 'Idiom' },
clarity: { color: 'var(--color-sky)', label: 'Clarity' },
voice: { color: 'var(--color-honey)', label: 'Voice' },
}