Rewrite Preview
A prose rewrite review panel — sentence-level tints, per-paragraph Accept / Keep verdicts, measured delta chips and a one-shot Apply that hands back the merged draft.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/rewrite-preview.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "RewritePreview" component: a review panel
for a prose rewrite, where SENTENCES carry the tints and PARAGRAPHS carry the
verdicts. Dependencies: lucide-react, shadcn Button + Badge, a cn() helper. No
diff library — ship the alignment so the granularity can be built into it.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
children and onChange. Spread the rest on the root.
- original: string — the author's text; paragraphs split on a blank line.
- rewritten: string — the model's version of it.
- segments?: { id?, original, rewritten, note? }[] — skip paragraph alignment and
review exactly these pairs; `note` is the model's one-line rationale. An empty
side means the paragraph is introduced (original "") or dropped (rewritten "").
- view?: "split" | "unified", defaultView?, onViewChange?.
- deltas?: { label, direction?: "up"|"down"|"flat", emphasis? }[] — qualitative
chips from the model ("more formal"), appended to the measured ones.
- showMeasuredDeltas?: boolean (default true).
- defaultDecision?: "pending" | "accepted" | "kept" (default "pending").
- onDecide?: ({ id, decision, segment }) => void
- onApply?: ({ text, accepted, kept, pending }) => void
- onRegenerate?: () => void | Promise<void> — omit it and no Regenerate button
is rendered; a returned promise drives the busy state.
- busy?: boolean (forced busy, ORed with the internal one)
- splitMinWidth?: number (default 560), reviseThreshold?: number (default 0.4),
matchThreshold?: number (default 0.3), shortcuts?: boolean (default true),
announce?: "polite" | "off".
- Label props: label, acceptLabel, keepLabel, acceptAllLabel, resetLabel,
regenerateLabel, applyLabel.
Behavior — alignment (the reason this is not a line diff)
- Split each side into paragraphs on blank lines, then each paragraph into
sentences. A sentence ends at . ! ? … only when whitespace follows, the word
before is not a known abbreviation (Mr./e.g./U.S./initials) and the next
character is not lower-case — so "3.14", "file.txt" and "e.g. later" stay in
one unit. A hard line break is a boundary of its own (bullet lines, verse).
- Match units by SIMILARITY, not equality: a rewrite almost never reproduces a
sentence verbatim, and an equality diff would report every paragraph as "all
removed, all added". Score = blend of multiset Dice over lower-cased words and
the overlap coefficient (shared words / shorter side); use Dice alone below
four words, where one shared "the" would read as a match. The overlap term is
what rescues "make it shorter" rewrites, which Dice punishes for length alone.
- Align with Needleman–Wunsch: free gaps, match score = sim - threshold, matrix
filled from the end so the traceback walks forward and emits runs in reading
order. A pair therefore only wins when it beats the threshold; ties go to the
removal so a replacement reads "old struck through, then new". Cap the matrix
(~40k cells) and degrade to one removal run + one addition run above it.
- Classify each pair: identical word keys → equal (case/punctuation-only edits
are NOT changes); paired but different → revised; unpaired → added / removed.
A paragraph is `equal` only when none of its sentences moved.
- Paragraph pairing uses the same routine with a looser threshold, which is what
produces "new paragraph" and "dropped" segments instead of a bogus 1:1 map.
Behavior — verdicts and the merged draft
- Every changed paragraph is pending → accepted (take the rewrite) → or kept
(keep the author's text); Undo returns it to pending. `equal` paragraphs need
no verdict and always flow through.
- The merged draft is DERIVED, never stored: accepted → rewritten, kept and
pending → original, blank results dropped, joined by a blank line. Pending
means "not touched yet", so an untouched review applies as the original text.
- Accept all moves every changed paragraph to accepted; Reset returns them all
to defaultDecision. Both are one click away from each other.
- Apply fires onApply once per (text + verdict set). Hold the last applied key
in a ref as well as in state: two clicks land in the same task and share one
closure, so a state flag alone would let the second one commit. Changing any
verdict re-arms Apply; a new rewrite resets every verdict, because the old
ones describe text that is gone.
- Detect "a new rewrite" from a CONTENT signature, not prop identity: a caller
that builds `segments` inline hands back a new array every parent render and
would otherwise wipe the reviewer's work each time.
- Regenerate: latch busy synchronously in a ref (not state — several clicks land
in one task), wrap the callback in new Promise(resolve => resolve(fn())) so a
synchronous throw becomes a rejection instead of pinning the panel busy, and
release the latch in both branches. Ignore the result if the component
unmounted; re-arm the alive flag on mount so StrictMode's mount → cleanup →
mount rehearsal cannot leave it stuck.
Behavior — layout, keyboard, screen readers
- Split degrades to unified below splitMinWidth, measured on the component's own
box with a ResizeObserver (a media query is wrong: the same panel sits in a
full-page editor and in a 320px sidebar). Measure the same box on the first
layout effect and on every resize, and disconnect the observer on unmount.
- Unchanged paragraphs fold into a native <details> whose <summary> is the first
line — context you can open, with no state, no JS and no ARIA of your own.
- Keyboard: A accepts, K keeps, U undoes the paragraph the focus is inside.
Listen on the LIST, resolve the paragraph with closest("[data-segment-id]"),
and bail out on modifier keys, inputs and contenteditable so a typed letter is
never swallowed. Do not add a tab stop per paragraph — focus already lands on
its buttons.
- Deciding replaces the two buttons with a verdict line plus Undo, so focus
would fall to <body>. Move it in a layout effect (the new button does not
exist before the commit) and only when the focus was already inside that
paragraph — Accept all must not yank it across the page.
- Busy state: buttons stay focusable with aria-disabled (never the native
disabled attribute) and every handler re-checks the latch. The shimmer overlay
is aria-hidden and pointer-events-none, so the previous rewrite stays readable
and selectable while the next one is generated.
- One status line doubles as the live region (role="status" aria-live="polite");
do not add a second sr-only copy or a screen reader reads every verdict twice.
announce="off" leaves the same sentence as inert text.
Rendering & styling
- Semantic tokens only: bg-card, border, text-foreground, text-muted-foreground,
bg-muted/40 for a kept paragraph, bg-primary/5 + border-primary/40 for an
accepted one, bg-primary/10 + decoration-primary for insertions, and
bg-destructive/10 + decoration-destructive for deletions. No hex, no rgb(),
no oklch().
- Insertions are <ins>, deletions are <del> — the elements already mean this.
Keep the whitespace between two sentences OUTSIDE the mark, or the underline
runs through the gap and reads as one long smear.
- Prose uses whitespace-pre-wrap + wrap-anywhere so a hard-wrapped draft keeps
its line breaks and an unbreakable URL cannot widen the panel.
- Chips: measured ones (±% length, words, sentences, words per sentence,
paragraphs) as outline badges with tabular-nums; the model's qualitative ones
after them, `emphasis` painting one in the primary token.
- Motion is decorative only: the regeneration sweep is motion-reduce:hidden, the
verdict settle is motion-reduce:[animation:none], spinners are
motion-reduce:animate-none. Keyframes ship in a React 19 hoisted <style
href precedence> so duplicate instances dedupe.
- Every control has a focus-visible ring and an aria-label that names the
paragraph it acts on; the split columns carry sr-only "Your text:" /
"Rewrite:" prefixes so the two sides are distinguishable when read linearly.
Customization levers
- reviseThreshold (0.4) is the dial between "one revision" and "a removal plus
an addition": raise it for surgical copy-edits where only near-identical
sentences should pair, lower it for heavy rewrites you still want paired.
matchThreshold (0.3) does the same for paragraphs.
- splitMinWidth: the fold point. Set it to 0 to keep two columns everywhere, or
pass view="unified" and drop the toggle entirely for narrow sidebars.
- Decision policy: defaultDecision="accepted" turns the panel into opt-OUT
review (every paragraph pre-accepted, still reversible) — the right default
when your users trust the model and only want to veto.
- Blocks you can drop: the delta chips (showMeasuredDeltas={false} and no
deltas), the Regenerate button (omit onRegenerate), the shortcut hint
(shortcuts={false}), the whole footer (nothing changed → it hides itself).
- Segmentation: pass `segments` to review model-supplied pairs — that is also
the escape hatch for texts that are not paragraph-per-blank-line (Markdown
headings, list items, subtitle cues) and the only way to show a per-paragraph
rationale.
- Sentence splitting: extend the abbreviation set for your domain, or make the
splitter a prop if you rewrite CJK text and want a different terminator set.
- Tints: swap destructive for muted on deletions if a rewrite should not read as
an error, or drop the strikethrough and show only the accepted side.Concepts
- Two granularities — sentences carry the tints and paragraphs carry the verdicts: a word-level diff would drown a prose rewrite in confetti, and a document-level "accept everything" button is not a review. The decision unit is the smallest thing a writer actually judges.
- Similarity pairing — sentences are matched by how many words they share, not by equality, so a reworded sentence is shown as one revision (old struck, new inserted) instead of an unrelated deletion followed by an unrelated insertion; below the threshold they deliberately fall apart again.
- Merged draft as derived state — the result is recomposed from the verdicts on every change (accepted → rewrite, kept and pending → your text) rather than being edited in place, so any verdict can be undone without the text drifting.
- One-shot apply — the commit is locked to the pair (text, verdict set): a double click cannot fire
onApplytwice, while changing any single verdict re-arms it, and a fresh rewrite clears every verdict because they describe text that no longer exists. - Container-measured fold — the split view collapses on the panel's own measured width, not on a viewport breakpoint, because the same review panel lives in a full-page editor and in a narrow sidebar.
- Unchanged is context — paragraphs the model left alone fold into a native disclosure instead of disappearing, so the reviewer can still see where the changed ones sit in the document.
AI Selection Menu
An Ask-AI row that floats over a text selection — action shortcuts, submenus, a free-form question field, one captured excerpt and a keyboard path in.
Assistant Avatar
An assistant identity chip whose ring carries the state — a portrait or a seed-derived token gradient inside, ripples while listening, a conic sweep while thinking, equalizer ticks while speaking.