diff --git a/internal/llm/prompts.go b/internal/llm/prompts.go index da70a33..f24d24e 100644 --- a/internal/llm/prompts.go +++ b/internal/llm/prompts.go @@ -165,3 +165,24 @@ func RewriteMessages(text, style string) []Message { {Role: "user", Content: text}, } } + +// translateSystemPrompt drives the explanation translator: it renders a +// suggestion's English explanation into Simplified Chinese so an ESL reader sees +// the "why" in her first language. Strict about returning ONLY the translation +// (no quotes, no pinyin, no English echo) so it can drop straight into the chat +// bubble. Kept warm and plain — these are short, friendly one-liners. +const translateSystemPrompt = `You are Petal, a warm writing assistant. Translate the English text the user ` + + `sends into natural, friendly Simplified Chinese (Mandarin). It is a short explanation of a writing ` + + `suggestion, written for a native Chinese speaker learning English. + +Respond with ONLY the Simplified Chinese translation. No quotation marks, no pinyin, no English, no preamble — ` + + `just the translated sentence.` + +// TranslateMessages builds the message array for translating one short English +// explanation into Simplified Chinese. +func TranslateMessages(text string) []Message { + return []Message{ + {Role: "system", Content: translateSystemPrompt}, + {Role: "user", Content: text}, + } +} diff --git a/internal/llm/translate.go b/internal/llm/translate.go new file mode 100644 index 0000000..f74c767 --- /dev/null +++ b/internal/llm/translate.go @@ -0,0 +1,23 @@ +package llm + +import ( + "context" +) + +// RunTranslate renders a short English explanation into Simplified Chinese. It +// is a one-shot Complete (the result seeds the Ask Petal bubble), kept at a low +// temperature so the translation is faithful rather than creative. Output is +// trimmed of any stray surrounding quotes the model may add. +func RunTranslate(ctx context.Context, client LLMClient, text string) (string, error) { + out, err := client.Complete(ctx, CompletionRequest{ + Messages: TranslateMessages(text), + MaxTokens: 512, + Temperature: 0.2, + TopP: 0.9, + RepetitionPenalty: 1.1, + }) + if err != nil { + return "", err + } + return cleanRewrite(out), nil +} diff --git a/internal/suggestions/handlers.go b/internal/suggestions/handlers.go index c1a6a33..8aa1218 100644 --- a/internal/suggestions/handlers.go +++ b/internal/suggestions/handlers.go @@ -55,6 +55,7 @@ func (h *Handler) Routes() chi.Router { r.Post("/{id}/accept", h.accept) r.Post("/{id}/dismiss", h.dismiss) r.Post("/{id}/chat", h.chat) + r.Post("/{id}/translate", h.translate) return r } diff --git a/internal/suggestions/translate.go b/internal/suggestions/translate.go new file mode 100644 index 0000000..a7a7c74 --- /dev/null +++ b/internal/suggestions/translate.go @@ -0,0 +1,57 @@ +package suggestions + +import ( + "database/sql" + "errors" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "gitea.parodia.dev/drwily/petal/internal/db" + "gitea.parodia.dev/drwily/petal/internal/llm" +) + +type translateResponse struct { + Translation string `json:"translation"` +} + +// translate renders a suggestion's English explanation into Simplified Chinese +// for the Ask Petal bubble, so the ESL reader sees the "why" in her first +// language instead of a second copy of the same English text. The explanation is +// loaded server-side from the suggestion id (scoped to the local user) and never +// trusted from the client, mirroring chat (spec Note #10). +func (h *Handler) translate(w http.ResponseWriter, r *http.Request) { + sugID := chi.URLParam(r, "id") + + var explanation string + err := h.DB.QueryRow( + `SELECT s.explanation + FROM suggestions s + JOIN documents d ON d.id = s.doc_id + WHERE s.id = ? AND d.user_id = ?`, + sugID, db.LocalUserID, + ).Scan(&explanation) + if errors.Is(err, sql.ErrNoRows) { + errorJSON(w, http.StatusNotFound, "suggestion not found") + return + } + if err != nil { + serverError(w, err) + return + } + + explanation = strings.TrimSpace(explanation) + if explanation == "" { + writeJSON(w, http.StatusOK, translateResponse{Translation: ""}) + return + } + + out, err := llm.RunTranslate(r.Context(), h.Client, explanation) + if err != nil { + errorJSON(w, http.StatusBadGateway, "translate failed: "+err.Error()) + return + } + + writeJSON(w, http.StatusOK, translateResponse{Translation: out}) +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 0ec3dbe..c376488 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -147,6 +147,10 @@ export const api = { req(`/suggestions/${id}/accept`, { method: 'POST' }), dismissSuggestion: (id: string) => req(`/suggestions/${id}/dismiss`, { method: 'POST' }), + // Simplified-Chinese rendering of a suggestion's explanation, for the Ask Petal + // opening bubble (the explanation itself stays English in the card body). + translateSuggestion: (id: string) => + req<{ translation: string }>(`/suggestions/${id}/translate`, { method: 'POST' }), // Version history. listVersions returns metadata only (no bodies); getVersion // loads one full snapshot for preview; snapshotDoc takes an explicit restore diff --git a/web/src/components/Editor/AskPetal.tsx b/web/src/components/Editor/AskPetal.tsx index 5832192..fc4f8a6 100644 --- a/web/src/components/Editor/AskPetal.tsx +++ b/web/src/components/Editor/AskPetal.tsx @@ -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([ - { 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([{ role: 'assistant', content: '' }]) + const [seeding, setSeeding] = useState(true) const [input, setInput] = useState('') const [streaming, setStreaming] = useState(false) const scrollRef = useRef(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) => ( - + ))} diff --git a/web/src/components/Editor/EditorCore.tsx b/web/src/components/Editor/EditorCore.tsx index 54a30c3..f28bb7e 100644 --- a/web/src/components/Editor/EditorCore.tsx +++ b/web/src/components/Editor/EditorCore.tsx @@ -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([]) + const [railEnabled, setRailEnabled] = useState(false) + const [railExpandedId, setRailExpandedId] = useState(null) + const [activeId, setActiveId] = useState(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() + const items: RailItem[] = [] + wrapper.querySelectorAll('.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 && ( )} + {railEnabled && railItems.length > 0 && ( + + )} ) diff --git a/web/src/components/Editor/SuggestionCard.tsx b/web/src/components/Editor/SuggestionCard.tsx index c9646dd..7c016e9 100644 --- a/web/src/components/Editor/SuggestionCard.tsx +++ b/web/src/components/Editor/SuggestionCard.tsx @@ -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 = { - 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 diff --git a/web/src/components/Editor/SuggestionHighlight.ts b/web/src/components/Editor/SuggestionHighlight.ts index 13d4f4b..b21918e 100644 --- a/web/src/components/Editor/SuggestionHighlight.ts +++ b/web/src/components/Editor/SuggestionHighlight.ts @@ -16,9 +16,17 @@ export const suggestionPluginKey = new PluginKey('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({ 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 diff --git a/web/src/components/Editor/SuggestionRail.tsx b/web/src/components/Editor/SuggestionRail.tsx new file mode 100644 index 0000000..3295edd --- /dev/null +++ b/web/src/components/Editor/SuggestionRail.tsx @@ -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>({}) + const cardRefs = useRef>(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(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 = {} + 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 ( +
+ {ordered.map(({ suggestion }) => ( + { + 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} + /> + ))} +
+ ) +} + +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(function RailCard( + { suggestion, top, active, expanded, onAccept, onDismiss, onHover, onActivate, onToggleExpand }, + ref, +) { + const meta = TYPE_META[suggestion.type] + const hasReplacement = suggestion.replacement.trim() !== '' + + return ( +
onHover(suggestion.id)} + onMouseLeave={() => onHover(null)} + className={`petal-rail-card${active ? ' petal-rail-card-active' : ''}`} + style={{ top, borderLeftColor: meta.color }} + > +
+ + {meta.label} + + +
+ + {/* The body toggles the expanded explanation + Ask Petal and points the + editor at the flagged text. */} + + + {expanded && } + +
+ {hasReplacement && ( + + )} + +
+
+ ) +}) diff --git a/web/src/components/Editor/suggestionMeta.ts b/web/src/components/Editor/suggestionMeta.ts new file mode 100644 index 0000000..10b02f7 --- /dev/null +++ b/web/src/components/Editor/suggestionMeta.ts @@ -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 = { + 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' }, +} diff --git a/web/src/index.css b/web/src/index.css index 3e8c29f..d33fedd 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -223,6 +223,67 @@ button, a, input { animation: petal-suggestion-in 200ms ease both; } +/* Text emphasized from its margin card (or a hover) — a soft wash so the + card↔text link reads at a glance, both directions. */ +.petal-suggestion-active { + background: var(--color-surface-alt); + box-shadow: 0 0 0 3px var(--color-surface-alt); + border-radius: 3px; +} + +/* --- Suggestion margin rail ------------------------------------------------- + The right-hand "comment column": every outstanding suggestion as a card, + vertically aligned to the text it flags, so the whole queue is visible at a + glance instead of one-highlight-at-a-time on hover. It floats in the whitespace + just right of the editor column and is only mounted when there's room for it + (EditorCore measures the gap). Cards are absolutely positioned by a resolved + top (anchor + collision avoidance), so the container only anchors the column. */ +.petal-rail { + position: absolute; + top: 0; + left: 100%; + margin-left: 32px; + width: 300px; + /* Below the companion mascot (z-40): where a stacked card reaches the + bottom-right corner it simply tucks behind the sleeping cat. */ + z-index: 10; +} +.petal-rail-card { + position: absolute; + left: 0; + right: 0; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-left: 3px solid var(--color-border); /* type color set inline */ + border-radius: var(--radius-card); + box-shadow: var(--shadow-soft); + padding: 0.7rem 0.85rem; + /* `top` eases so re-stacking (accept/dismiss/edit) glides instead of jumping. + The entrance uses fill `backwards` so its translateY doesn't linger and fight + the active-state transform once it's done. */ + transition: top 240ms cubic-bezier(0.2, 0.7, 0.3, 1), box-shadow 200ms ease, + transform 200ms ease, border-color 200ms ease; + animation: petal-suggestion-in 220ms ease backwards; +} +.petal-rail-card-active { + transform: translateX(-5px); + box-shadow: 0 8px 26px rgba(180, 130, 160, 0.24); + border-top-color: var(--color-accent); + border-right-color: var(--color-accent); + border-bottom-color: var(--color-accent); +} +.petal-rail-x { + color: var(--color-muted); + font-size: 0.72rem; + line-height: 1; + padding: 2px 5px; + border-radius: var(--radius-pill); +} +.petal-rail-x:hover { + background: var(--color-surface-alt); + color: var(--color-plum); +} + /* --- Find & Replace --------------------------------------------------------- In-document search (Ctrl/Cmd+F). Every match gets a soft honey wash; the current match is brighter with a rose ring so it stands out as you step