AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowDownRight,
  ArrowUpRight,
  Check,
  Columns2,
  ListChecks,
  Loader2,
  Minus,
  RotateCcw,
  Rows2,
  Sparkles,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/rewrite-preview.json

Prompt

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 onApply twice, 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.

On This Page