Zum Inhalt springen
UI Kit
Accent

Schreibassistent

Schreibhilfe im Dokument. Vorschläge im Text nach Art, Umschreiben in einem gewählten Ton, Änderungen zum Annehmen oder Ablehnen, eine Leiste an markierten Wörtern und ein Entwurf, Punkt für Punkt aus einer Gliederung geschrieben.

Eine von 3 Components in KI-Schreiben und Erstellen

5 Variants

Variants

Vorschläge im Text

Unterstreichungen nach Art im Text, daneben eine Karte mit Änderung und Grund. Übernehmen ersetzt die Wörter und lässt sie kurz aufleuchten, der Rest bleibt in einer Liste.

Im Studio öffnen

In einem Ton umschreiben

Original neben der Umschrift, Töne als Auswahl, der neue Text läuft in fester Höhe mit Wortzahl ein. Ersetzen, darunter einfügen oder neu versuchen.

Im Studio öffnen

Änderungen prüfen

Vorgeschlagene Änderungen als markierte Stellen im Text, jede mit Annehmen und Ablehnen, der Grund unter dem Text und ein Zähler im Kopf. Rückgängig holt alles zurück.

Im Studio öffnen

Leiste an der Auswahl

Über den markierten Wörtern schwebt eine Leiste mit Verbessern, Kürzen, Vereinfachen und Übersetzen, das Ergebnis öffnet sich darunter mit Ersetzen. Eine freie Frage geht mit deinem eigenen Modell.

Im Studio öffnen

Entwurf aus Gliederung

Links die Punkte zum Einbeziehen, der Entwurf schreibt sich beim Hereinscrollen Absatz für Absatz. Zeigen auf einen Punkt hebt seinen Absatz hervor.

Im Studio öffnen

Details

Was Schreibassistent mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/writing-assistant.tsx
Bringt mit
utilskiticonrevealmotion
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 42 eigene Texte

Props

Die Schnittstelle WritingAssistantProps aus components/ui/writing-assistant.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    WritingAssistantVariant

    inline-suggestionsrewrite-tonediff-reviewselection-toolbaroutline-draft

  • titleoptional
    string
  • docoptional
    string

    Name of the document

  • textoptional
    string

    The text, blank lines make paragraphs

  • issuesoptional
    WritingIssue[]

    inline-suggestions

  • tonesoptional
    WritingTone[]

    rewrite-tone

  • segmentsoptional
    WritingSegment[]

    diff-review

  • selectionoptional
    string

    selection-toolbar: the words that start selected

  • actionsoptional
    WritingAction[]
  • onActionoptional
    (action: string, selected: string) => string | Promise<string>

    selection-toolbar: produce a result for any selection, without it only the start selection has results

  • outlineoptional
    WritingPoint[]

    outline-draft

Eingebaute Texte

Diese Texte schreibt Schreibassistent selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 42 Texte anzeigen
suggestionsEnglisch SuggestionsDeutsch Vorschläge
grammarEnglisch GrammarDeutsch Grammatik
clarityEnglisch ClarityDeutsch Klarheit
toneEnglisch ToneDeutsch Ton
styleEnglisch StyleDeutsch Stil
acceptEnglisch AcceptDeutsch Übernehmen
dismissEnglisch DismissDeutsch Ignorieren
acceptAllEnglisch Accept allDeutsch Alle übernehmen
doneEnglisch All suggestions handled.Deutsch Alle Vorschläge erledigt.
leftEnglisch {n} openDeutsch {n} offen
originalEnglisch Your textDeutsch Dein Text
rewritingEnglisch WritingDeutsch Schreibt
useTextEnglisch Replace textDeutsch Text ersetzen
insertBelowEnglisch Insert belowDeutsch Darunter einfügen
againEnglisch Try againDeutsch Neu versuchen
wordsEnglisch {n} wordsDeutsch {n} Wörter
appliedEnglisch Text replaced.Deutsch Text ersetzt.
insertedEnglisch Inserted below your text.Deutsch Unter deinem Text eingefügt.
undoEnglisch UndoDeutsch Rückgängig
changeEnglisch Change {n} of {total}Deutsch Änderung {n} von {total}
prevEnglisch Previous changeDeutsch Vorherige Änderung
nextEnglisch Next changeDeutsch Nächste Änderung
rejectEnglisch RejectDeutsch Ablehnen
rejectAllEnglisch Reject allDeutsch Alle ablehnen
reviewLeftEnglisch {n} of {total} changes leftDeutsch {n} von {total} Änderungen offen
reviewDoneEnglisch Review finishedDeutsch Durchsicht fertig
selectedEnglisch Selected textDeutsch Markierter Text
askEnglisch Ask about the selectionDeutsch Frage zur Auswahl
sendEnglisch AskDeutsch Fragen
askPlaceholderEnglisch Make it sound less formalDeutsch Weniger förmlich formulieren
replaceEnglisch ReplaceDeutsch Ersetzen
closeEnglisch CloseDeutsch Schließen
selectHintEnglisch Select text to see the toolsDeutsch Markiere Text, dann erscheinen die Werkzeuge
noResultEnglisch This demo has results for the marked words only.Deutsch Diese Demo hat Ergebnisse nur für die markierten Wörter.
outlineEnglisch OutlineDeutsch Gliederung
draftEnglisch DraftDeutsch Entwurf
writeEnglisch Write the draftDeutsch Entwurf schreiben
writingEnglisch Writing paragraph {n} of {total}Deutsch Schreibt Absatz {n} von {total}
rewriteDraftEnglisch Write againDeutsch Neu schreiben
includeEnglisch Include {name}Deutsch {name} einbeziehen
reviewEnglisch Drafts are a start. Check facts, names and numbers before you send anything.Deutsch Entwürfe sind ein Anfang. Prüf Fakten, Namen und Zahlen, bevor du etwas verschickst.
issueEnglisch {kind} suggestionDeutsch Vorschlag zu {kind}

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Schreibassistent reagiert selbst auf die Tasten Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-busy, aria-checked, aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen button, radio, radiogroup, status und toolbar.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 968 Zeilen
components/ui/writing-assistant.tsx
'use client';

import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';

export type WritingAssistantVariant = 'inline-suggestions' | 'rewrite-tone' | 'diff-review' | 'selection-toolbar' | 'outline-draft';

export interface WritingIssue {
  id: string;
  kind: 'grammar' | 'clarity' | 'tone' | 'style';
  /** The exact words in the text */
  find: string;
  replace: string;
  reason: string;
}

export interface WritingTone {
  id: string;
  label: string;
  icon?: IconName;
  /** The rewritten text for this tone */
  result: string;
}

export interface WritingSegment {
  /** Plain text between changes */
  text?: string;
  /** A change: id plus what goes and what comes */
  id?: string;
  del?: string;
  ins?: string;
  reason?: string;
}

export interface WritingAction {
  id: string;
  label: string;
  icon?: IconName;
  /** The result for the selected words */
  result: string;
}

export interface WritingPoint {
  id: string;
  point: string;
  /** The paragraph written for this point */
  draft: string;
}

export interface WritingAssistantProps {
  variant?: WritingAssistantVariant;
  title?: string;
  /** Name of the document */
  doc?: string;
  /** The text, blank lines make paragraphs */
  text?: string;
  /** inline-suggestions */
  issues?: WritingIssue[];
  /** rewrite-tone */
  tones?: WritingTone[];
  /** diff-review */
  segments?: WritingSegment[];
  /** selection-toolbar: the words that start selected */
  selection?: string;
  actions?: WritingAction[];
  /** selection-toolbar: produce a result for any selection, without it only the start selection has results */
  onAction?: (action: string, selected: string) => string | Promise<string>;
  /** outline-draft */
  outline?: WritingPoint[];
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    suggestions: 'Suggestions',
    grammar: 'Grammar',
    clarity: 'Clarity',
    tone: 'Tone',
    style: 'Style',
    accept: 'Accept',
    dismiss: 'Dismiss',
    acceptAll: 'Accept all',
    done: 'All suggestions handled.',
    left: '{n} open',
    
    original: 'Your text',
    
    rewriting: 'Writing',
    useText: 'Replace text',
    insertBelow: 'Insert below',
    again: 'Try again',
    words: '{n} words',
    applied: 'Text replaced.',
    inserted: 'Inserted below your text.',
    undo: 'Undo',
    
    change: 'Change {n} of {total}',
    prev: 'Previous change',
    next: 'Next change',
    reject: 'Reject',
    rejectAll: 'Reject all',
    
    
    reviewLeft: '{n} of {total} changes left',
    reviewDone: 'Review finished',
    selected: 'Selected text',
    ask: 'Ask about the selection',
    send: 'Ask',
    askPlaceholder: 'Make it sound less formal',
    
    replace: 'Replace',
    close: 'Close',
    selectHint: 'Select text to see the tools',
    noResult: 'This demo has results for the marked words only.',
    outline: 'Outline',
    draft: 'Draft',
    write: 'Write the draft',
    writing: 'Writing paragraph {n} of {total}',
    rewriteDraft: 'Write again',
    include: 'Include {name}',
    review: 'Drafts are a start. Check facts, names and numbers before you send anything.',
    issue: '{kind} suggestion',
  },
  de: {
    suggestions: 'Vorschläge',
    grammar: 'Grammatik',
    clarity: 'Klarheit',
    tone: 'Ton',
    style: 'Stil',
    accept: 'Übernehmen',
    dismiss: 'Ignorieren',
    acceptAll: 'Alle übernehmen',
    done: 'Alle Vorschläge erledigt.',
    left: '{n} offen',
    
    original: 'Dein Text',
    
    rewriting: 'Schreibt',
    useText: 'Text ersetzen',
    insertBelow: 'Darunter einfügen',
    again: 'Neu versuchen',
    words: '{n} Wörter',
    applied: 'Text ersetzt.',
    inserted: 'Unter deinem Text eingefügt.',
    undo: 'Rückgängig',
    
    change: 'Änderung {n} von {total}',
    prev: 'Vorherige Änderung',
    next: 'Nächste Änderung',
    reject: 'Ablehnen',
    rejectAll: 'Alle ablehnen',
    
    
    reviewLeft: '{n} von {total} Änderungen offen',
    reviewDone: 'Durchsicht fertig',
    selected: 'Markierter Text',
    ask: 'Frage zur Auswahl',
    send: 'Fragen',
    askPlaceholder: 'Weniger förmlich formulieren',
    
    replace: 'Ersetzen',
    close: 'Schließen',
    selectHint: 'Markiere Text, dann erscheinen die Werkzeuge',
    noResult: 'Diese Demo hat Ergebnisse nur für die markierten Wörter.',
    outline: 'Gliederung',
    draft: 'Entwurf',
    write: 'Entwurf schreiben',
    writing: 'Schreibt Absatz {n} von {total}',
    rewriteDraft: 'Neu schreiben',
    include: '{name} einbeziehen',
    review: 'Entwürfe sind ein Anfang. Prüf Fakten, Namen und Zahlen, bevor du etwas verschickst.',
    issue: 'Vorschlag zu {kind}',
  },
};

type Texts = (typeof TEXTS)['en'];
type P = WritingAssistantProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const words = (s: string) => (s.trim() ? s.trim().split(/\s+/).length : 0);

/** State seeded from a prop that starts over when the prop changes (the Studio edits props live) */
function useSynced<T>(value: T) {
  const [state, setState] = useState(value);
  const key = JSON.stringify(value);
  const seen = useRef(key);
  if (seen.current !== key) {
    seen.current = key;
    setState(value);
  }
  return [state, setState] as const;
}

const CSS =
  '@keyframes wra-up{from{opacity:0;transform:translateY(8px)}}@keyframes wra-pop{from{opacity:0;transform:translateY(4px) scale(.97)}}@keyframes wra-flash{from{background-color:color-mix(in oklab,var(--success) 30%,transparent)}}@keyframes wra-out{to{opacity:0;transform:translateX(16px)}}' +
  '@media (prefers-reduced-motion:no-preference){.wra-up{animation:wra-up .45s var(--ease-kit) var(--wra-d,0ms) both}.wra-pop{animation:wra-pop .24s var(--ease-kit) both}.wra-flash{animation:wra-flash 1.6s ease-out both}.wra-out{animation:wra-out .3s var(--ease-kit) both}}';

function Style() {
  return (
    <style href="kit-writing-assistant" precedence="default">
      {CSS}
    </style>
  );
}

/** Text that types itself once it is set, the full text on the server and with reduced motion */
function useStream(full: string, run: boolean, speed = 3, nonce = 0) {
  const [n, setN] = useState(full.length);
  useEffect(() => {
    if (!run || prefersReducedMotion()) return setN(full.length);
    setN(0);
    const id = window.setInterval(() => setN((v) => (v >= full.length ? (window.clearInterval(id), v) : v + speed)), 16);
    return () => window.clearInterval(id);
  }, [full, run, speed, nonce]);
  return [full.slice(0, n), n < full.length] as const;
}

/* ------------------------------------------------------------------ */
/* Shared parts                                                        */
/* ------------------------------------------------------------------ */

const KIND: Record<WritingIssue['kind'], { line: string; dot: string; soft: string }> = {
  grammar: { line: 'decoration-[color-mix(in_oklab,var(--destructive)_80%,transparent)]', dot: 'bg-destructive', soft: 'bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)]' },
  clarity: { line: 'decoration-[color-mix(in_oklab,var(--primary)_85%,transparent)]', dot: 'bg-primary', soft: 'bg-[color-mix(in_oklab,var(--primary)_12%,transparent)]' },
  tone: { line: 'decoration-[color-mix(in_oklab,var(--warning)_90%,transparent)]', dot: 'bg-warning', soft: 'bg-[color-mix(in_oklab,var(--warning)_16%,transparent)]' },
  style: { line: 'decoration-[color-mix(in_oklab,var(--success)_85%,transparent)]', dot: 'bg-success', soft: 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)]' },
};

function Stage({ title, children, className, wide }: { title?: string; children: React.ReactNode; className?: string; wide?: boolean }) {
  return (
    <section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', className)}>
      <div className="kit-container">
        <div className={cn('mx-auto w-full', wide ? 'max-w-[68rem]' : 'max-w-[48rem]')}>
          {title && (
            <Reveal className="mb-6 sm:mb-8">
              <h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]">{title}</h2>
            </Reveal>
          )}
          {children}
        </div>
      </div>
    </section>
  );
}

/** The sheet the text sits on, with the document name in a quiet header */
function Paper({ doc, children, className, aside }: { doc?: string; children: React.ReactNode; className?: string; aside?: React.ReactNode }) {
  return (
    <div className={cn('relative rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]', className)}>
      {doc && (
        <div className="flex h-12 items-center gap-2 border-b border-border px-4 text-[13px] text-muted-foreground sm:px-6">
          <Icon name="file" size={15} />
          <span className="min-w-0 flex-1 truncate">{doc}</span>
          {aside}
        </div>
      )}
      <div className="px-5 py-6 sm:px-8 sm:py-8">{children}</div>
    </div>
  );
}

const prose = 'space-y-4 text-pretty text-[16px] leading-[1.8] text-foreground';
const btn = 'inline-flex h-11 items-center justify-center gap-1.5 rounded-lg px-3.5 text-[13px] font-medium transition-colors sm:h-9';
const primaryBtn = cn(btn, 'bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]');
const quietBtn = cn(btn, 'border border-border bg-card text-foreground hover:bg-accent');

/* ------------------------------------------------------------------ */
/* inline-suggestions                                                  */
/* ------------------------------------------------------------------ */

function InlineSuggestions({ t, title, doc, text = '', issues = [], className }: P) {
  const [body, setBody] = useSynced(text);
  const [open, setOpen] = useState<string[]>(issues.map((i) => i.id));
  const [active, setActive] = useState<string | null>(issues[0]?.id ?? null);
  const [fresh, setFresh] = useState<string | null>(null);
  const list = issues.filter((i) => open.includes(i.id) && body.includes(i.find));
  const cur = list.find((i) => i.id === active) ?? list[0] ?? null;

  const accept = (i: WritingIssue) => {
    setBody((b) => b.replace(i.find, i.replace));
    setOpen((o) => o.filter((x) => x !== i.id));
    setFresh(i.replace);
    setActive(list.find((x) => x.id !== i.id)?.id ?? null);
  };
  const dismiss = (i: WritingIssue) => {
    setOpen((o) => o.filter((x) => x !== i.id));
    setActive(list.find((x) => x.id !== i.id)?.id ?? null);
  };

  // Mark every open issue and the last accepted replacement inside the paragraphs
  const render = (para: string) => {
    const marks = [...list.map((i) => ({ at: para.indexOf(i.find), len: i.find.length, issue: i as WritingIssue | null })), ...(fresh && para.includes(fresh) ? [{ at: para.indexOf(fresh), len: fresh.length, issue: null }] : [])].filter((m) => m.at >= 0).sort((a, b) => a.at - b.at);
    const out: React.ReactNode[] = [];
    let pos = 0;
    marks.forEach((m, k) => {
      if (m.at < pos) return;
      out.push(<Fragment key={`t${k}`}>{para.slice(pos, m.at)}</Fragment>);
      const seg = para.slice(m.at, m.at + m.len);
      if (m.issue) {
        const i = m.issue;
        const on = i.id === active;
        out.push(
          // A span stays in the flow of the text, a button would wrap as a block
          <span
            key={i.id}
            role="button"
            tabIndex={0}
            aria-pressed={on}
            aria-label={`${seg}. ${fill(t.issue, { kind: t[i.kind] })}`}
            onClick={() => setActive(i.id)}
            onKeyDown={(e) => {
              if (e.key !== 'Enter' && e.key !== ' ') return;
              e.preventDefault();
              setActive(i.id);
            }}
            className={cn('cursor-pointer rounded-[3px] underline decoration-2 underline-offset-[5px] transition-colors duration-200 [box-decoration-break:clone]', KIND[i.kind].line, on && KIND[i.kind].soft)}
          >
            {seg}
          </span>,
        );
      } else {
        out.push(
          <span key={`f${k}`} className="wra-flash rounded-[3px]">
            {seg}
          </span>,
        );
      }
      pos = m.at + m.len;
    });
    out.push(<Fragment key="end">{para.slice(pos)}</Fragment>);
    return out;
  };

  const counts = (['grammar', 'clarity', 'tone', 'style'] as const).map((k) => ({ k, n: list.filter((i) => i.kind === k).length }));

  return (
    <Stage title={title} className={className} wide>
      <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start lg:gap-6">
        <Reveal delay={60}>
          <Paper doc={doc}>
            <div className={prose}>
              {body.split(/\n{2,}/).map((para, i) => (
                <p key={i}>{render(para)}</p>
              ))}
            </div>
          </Paper>
        </Reveal>
        <Reveal delay={160} className="lg:sticky lg:top-6">
          <aside aria-label={t.suggestions} className="rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
            <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3">
              <p className="text-[13px] font-medium text-foreground">{t.suggestions}</p>
              <span className="text-[12px] tabular-nums text-muted-foreground" aria-live="polite">
                {fill(t.left, { n: list.length })}
              </span>
            </div>
            <div className="flex flex-wrap gap-x-4 gap-y-1.5 px-4 pt-3 text-[12px] text-muted-foreground">
              {counts.map(({ k, n }) => (
                <span key={k} className={cn('inline-flex items-center gap-1.5 transition-opacity', !n && 'opacity-50')}>
                  <span className={cn('size-2 rounded-full', KIND[k].dot)} />
                  {t[k]} <span className="tabular-nums text-foreground">{n}</span>
                </span>
              ))}
            </div>
            <div className="p-3">
              {cur ? (
                <div key={cur.id} className="wra-pop rounded-xl border border-border bg-background/60 p-3.5">
                  <p className="flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground">
                    <span className={cn('size-2 rounded-full', KIND[cur.kind].dot)} />
                    {t[cur.kind]}
                  </p>
                  <p className="mt-2 text-[14px] leading-relaxed">
                    <del className="text-muted-foreground decoration-[color-mix(in_oklab,var(--destructive)_60%,transparent)]">{cur.find}</del>
                    <Icon name="arrow-right" size={13} className="mx-1.5 inline text-subtle-foreground" />
                    <ins className="font-medium text-foreground no-underline">{cur.replace}</ins>
                  </p>
                  <p className="mt-2 text-pretty text-[12.5px] leading-relaxed text-muted-foreground">{cur.reason}</p>
                  <div className="mt-3 flex gap-2">
                    <button type="button" onClick={() => accept(cur)} className={cn(primaryBtn, 'flex-1')}>
                      <Icon name="check" size={15} />
                      {t.accept}
                    </button>
                    <button type="button" onClick={() => dismiss(cur)} className={cn(quietBtn, 'flex-1')}>
                      {t.dismiss}
                    </button>
                  </div>
                </div>
              ) : (
                <p className="wra-pop flex items-center gap-2 rounded-xl bg-[color-mix(in_oklab,var(--success)_10%,transparent)] px-3.5 py-3 text-[13px] text-success-foreground">
                  <Icon name="check-circle" size={16} />
                  {t.done}
                </p>
              )}
              {list.length > 1 && (
                <ul className="mt-2 space-y-0.5">
                  {list
                    .filter((i) => i.id !== cur?.id)
                    .map((i) => (
                      <li key={i.id}>
                        <button type="button" onClick={() => setActive(i.id)} className="flex min-h-11 w-full items-center gap-2.5 rounded-lg px-2 text-left text-[13px] hover:bg-accent sm:min-h-9">
                          <span className={cn('size-2 shrink-0 rounded-full', KIND[i.kind].dot)} />
                          <span className="min-w-0 flex-1 truncate text-muted-foreground">{`${i.find} → ${i.replace}`}</span>
                        </button>
                      </li>
                    ))}
                </ul>
              )}
              {list.length > 1 && (
                <button type="button" onClick={() => (setBody((b) => list.reduce((acc, i) => acc.replace(i.find, i.replace), b)), setOpen([]), setFresh(null))} className={cn(quietBtn, 'mt-2 w-full')}>
                  {t.acceptAll}
                </button>
              )}
            </div>
          </aside>
        </Reveal>
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* rewrite-tone                                                        */
/* ------------------------------------------------------------------ */

function RewriteTone({ t, title, doc, text = '', tones = [], className }: P) {
  const [tone, setTone] = useState(tones[0]?.id);
  const [round_, setRound] = useState(0);
  const [applied, setApplied] = useState<'replace' | 'below' | null>(null);
  const [ref, inView] = useInView<HTMLDivElement>();
  // Streams once when it first comes into view, not again on every return
  const [seen, setSeen] = useState(false);
  useEffect(() => {
    if (inView) setSeen(true);
  }, [inView]);
  const cur = tones.find((x) => x.id === tone) ?? tones[0];
  const [shown, busy] = useStream(cur?.result ?? '', seen, 3, round_);
  const before = words(text);
  const after = words(cur?.result ?? '');
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);

  return (
    <Stage title={title} className={className} wide>
      <div ref={ref} className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
        <Reveal delay={60}>
          <Paper doc={doc} aside={<span className="tabular-nums">{fill(t.words, { n: before })}</span>}>
            <p className="mb-2 text-[12px] font-medium text-subtle-foreground">{t.original}</p>
            <div className={cn(prose, applied === 'replace' && 'text-muted-foreground line-through decoration-[color-mix(in_oklab,var(--foreground)_30%,transparent)]')}>
              {text.split(/\n{2,}/).map((p, i) => (
                <p key={i}>{p}</p>
              ))}
            </div>
            {applied && (
              <div className="wra-up mt-4 rounded-xl border border-[color-mix(in_oklab,var(--success)_40%,var(--border))] bg-[color-mix(in_oklab,var(--success)_7%,var(--card))] p-4 text-[15px] leading-[1.75] text-foreground">
                {cur?.result}
              </div>
            )}
          </Paper>
        </Reveal>
        <Reveal delay={160}>
          <div className="rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
            <div className="border-b border-border p-3">
              <div role="radiogroup" aria-label={t.tone} className="grid grid-cols-2 gap-1.5 sm:grid-cols-4">
                {tones.map((x, i) => {
                  const on = x.id === cur?.id;
                  return (
                    <button
                      key={x.id}
                      ref={(el) => {
                        tabs.current[i] = el;
                      }}
                      type="button"
                      role="radio"
                      aria-checked={on}
                      tabIndex={on ? 0 : -1}
                      onClick={() => (setTone(x.id), setApplied(null))}
                      onKeyDown={(e) => {
                        const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
                        if (!d) return;
                        e.preventDefault();
                        const n = (i + d + tones.length) % tones.length;
                        setTone(tones[n].id);
                        setApplied(null);
                        tabs.current[n]?.focus();
                      }}
                      className={cn('inline-flex min-h-11 items-center justify-center gap-1.5 rounded-lg border px-2 text-[13px] font-medium transition-colors duration-200 sm:min-h-10', on ? 'border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-foreground' : 'border-border text-muted-foreground hover:text-foreground')}
                    >
                      <Icon name={x.icon ?? 'sparkle'} size={15} className={on ? 'text-primary' : undefined} />
                      {x.label}
                    </button>
                  );
                })}
              </div>
            </div>
            <div className="p-5 sm:p-6" aria-live="polite" aria-busy={busy}>
              <p className="mb-2 flex items-center justify-between text-[12px] font-medium text-subtle-foreground">
                <span className="flex items-center gap-1.5">
                  <Icon name="sparkle" size={13} className="text-primary" />
                  {busy ? t.rewriting : cur?.label}
                </span>
                <span className="tabular-nums">
                  {fill(t.words, { n: after })}
                  <span className={cn('ml-1.5', after < before ? 'text-success-foreground' : 'text-muted-foreground')}>
                    {after < before ? '−' : '+'}
                    {Math.abs(after - before)}
                  </span>
                </span>
              </p>
              {/* The full text holds the height, the streamed text lies on top */}
              <div className="relative text-[16px] leading-[1.8]">
                <p aria-hidden className="invisible">
                  {cur?.result}
                </p>
                <p className="absolute inset-0 text-foreground">
                  {shown}
                  {busy && <span aria-hidden className="ml-0.5 inline-block h-[1.05em] w-0.5 translate-y-[0.18em] animate-kit-pulse bg-primary" />}
                </p>
              </div>
            </div>
            <div className="flex flex-wrap gap-2 border-t border-border bg-surface/60 p-3">
              <button type="button" disabled={busy} onClick={() => setApplied('replace')} className={cn(primaryBtn, 'disabled:opacity-50')}>
                <Icon name="check" size={15} />
                {t.useText}
              </button>
              <button type="button" disabled={busy} onClick={() => setApplied('below')} className={cn(quietBtn, 'disabled:opacity-50')}>
                {t.insertBelow}
              </button>
              <button type="button" disabled={busy} onClick={() => (setRound((r) => r + 1), setApplied(null))} className={cn(btn, 'ml-auto min-w-11 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50')}>
                <Icon name="refresh" size={15} />
                <span className="max-sm:sr-only">{t.again}</span>
              </button>
            </div>
          </div>
          {applied && (
            <p role="status" className="wra-up mt-3 flex items-center justify-between gap-3 rounded-xl border border-border bg-card px-4 py-2 text-[13px] text-muted-foreground">
              {applied === 'below' ? t.inserted : t.applied}
              <button type="button" onClick={() => setApplied(null)} className={cn(btn, '-my-1 px-2 text-foreground hover:bg-accent')}>
                {t.undo}
              </button>
            </p>
          )}
        </Reveal>
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* diff-review                                                         */
/* ------------------------------------------------------------------ */

function DiffReview({ t, title, doc, segments = [], className }: P) {
  const changes = segments.filter((s) => s.id);
  const [state, setState] = useState<Record<string, 'accepted' | 'rejected'>>({});
  const [focus, setFocus] = useState<string | null>(changes[0]?.id ?? null);
  const left = changes.filter((c) => !state[c.id!]).length;
  const decide = (id: string, v: 'accepted' | 'rejected') => {
    setState((s) => ({ ...s, [id]: v }));
    setFocus(changes.find((c) => c.id !== id && !state[c.id!])?.id ?? null);
  };
  const all = (v: 'accepted' | 'rejected') => setState(Object.fromEntries(changes.map((c) => [c.id!, state[c.id!] ?? v])));
  const share = changes.length ? (changes.length - left) / changes.length : 0;

  return (
    <Stage title={title} className={className}>
      <Reveal delay={60}>
        <Paper
          doc={doc}
          aside={
            <span className="flex items-center gap-2 tabular-nums" aria-live="polite">
              <span className="hidden h-1 w-16 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)] sm:block">
                <span className="block h-full origin-left rounded-full bg-primary transition-transform duration-500" style={{ transform: `scaleX(${round(share, 3)})` }} />
              </span>
              {left ? fill(t.reviewLeft, { n: left, total: changes.length }) : t.reviewDone}
            </span>
          }
        >
          <p className={cn(prose, 'leading-[2]')}>
            {segments.map((s, i) => {
              if (!s.id) return <Fragment key={i}>{s.text}</Fragment>;
              const st = state[s.id];
              if (st === 'accepted') return <span key={s.id} className="wra-flash rounded-[3px]">{s.ins}</span>;
              if (st === 'rejected') return <Fragment key={s.id}>{s.del}</Fragment>;
              const on = focus === s.id;
              return (
                <span
                  key={s.id}
                  role="button"
                  tabIndex={0}
                  aria-pressed={on}
                  onClick={() => setFocus(s.id!)}
                  onKeyDown={(e) => {
                    if (e.key !== 'Enter' && e.key !== ' ') return;
                    e.preventDefault();
                    setFocus(s.id!);
                  }}
                  className={cn('cursor-pointer rounded-md px-0.5 py-0.5 transition-[background-color,box-shadow] duration-200 [box-decoration-break:clone]', on && 'bg-accent shadow-[0_0_0_1px_var(--border-strong)]')}
                >
                  {s.del && <del className="rounded-[3px] bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)] text-muted-foreground decoration-[color-mix(in_oklab,var(--destructive)_70%,transparent)] [box-decoration-break:clone]">{s.del}</del>}
                  {s.del && s.ins && ' '}
                  {s.ins && <ins className="rounded-[3px] bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-foreground no-underline [box-decoration-break:clone]">{s.ins}</ins>}
                </span>
              );
            })}
          </p>
        </Paper>
      </Reveal>
      {(() => {
        const open = changes.filter((x) => !state[x.id!]);
        const c = open.find((x) => x.id === focus) ?? open[0];
        if (!c) return null;
        const i = open.indexOf(c);
        const move = (dir: number) => setFocus(open[(i + dir + open.length) % open.length].id!);
        return (
          <div key={c.id} className="wra-up mt-3 rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)]" aria-live="polite">
            <div className="flex items-center justify-between gap-3">
              <p className="text-[12px] font-medium tabular-nums text-subtle-foreground">{fill(t.change, { n: i + 1, total: open.length })}</p>
              <span className="flex">
                <button type="button" aria-label={t.prev} disabled={open.length < 2} onClick={() => move(-1)} className="grid size-11 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 sm:size-8">
                  <Icon name="chevron-left" size={16} />
                </button>
                <button type="button" aria-label={t.next} disabled={open.length < 2} onClick={() => move(1)} className="grid size-11 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 sm:size-8">
                  <Icon name="chevron-right" size={16} />
                </button>
              </span>
            </div>
            {c.reason && (
              <p className="mt-1 flex items-start gap-2 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">
                <Icon name="sparkle" size={14} className="mt-1 shrink-0 text-primary" />
                {c.reason}
              </p>
            )}
            <div className="mt-3 flex gap-2">
              <button type="button" onClick={() => decide(c.id!, 'accepted')} className={cn(primaryBtn, 'flex-1 sm:flex-none')}>
                <Icon name="check" size={15} />
                {t.accept}
              </button>
              <button type="button" onClick={() => decide(c.id!, 'rejected')} className={cn(quietBtn, 'flex-1 sm:flex-none')}>
                {t.reject}
              </button>
            </div>
          </div>
        );
      })()}
      <div className="mt-3 flex flex-wrap items-center gap-2">
        <button type="button" disabled={!left} onClick={() => all('accepted')} className={cn(quietBtn, 'disabled:opacity-50')}>
          {t.acceptAll}
        </button>
        <button type="button" disabled={!left} onClick={() => all('rejected')} className={cn(quietBtn, 'disabled:opacity-50')}>
          {t.rejectAll}
        </button>
        {Object.keys(state).length > 0 && (
          <button type="button" onClick={() => setState({})} className={cn(btn, 'ml-auto text-muted-foreground hover:bg-accent hover:text-foreground')}>
            <Icon name="undo" size={15} />
            {t.undo}
          </button>
        )}
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* selection-toolbar                                                   */
/* ------------------------------------------------------------------ */

function SelectionToolbar({ t, title, doc, text = '', selection = '', actions = [], onAction, className }: P) {
  const [body, setBody] = useSynced(text);
  const [sel, setSel] = useState(selection);
  const [result, setResult] = useState<{ action: WritingAction; text: string } | null>(null);
  const [ask, setAsk] = useState('');
  const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
  // A toolbar wider than the paper scrolls, the cut edge fades instead of ending hard
  const [fade, setFade] = useState<'' | 'kit-fade-end' | 'kit-fade-x' | 'kit-fade-start'>('');
  const edge = (el: HTMLElement | null) => {
    if (!el) return;
    const max = el.scrollWidth - el.clientWidth;
    setFade(max <= 1 ? '' : el.scrollLeft <= 1 ? 'kit-fade-end' : el.scrollLeft >= max - 1 ? 'kit-fade-start' : 'kit-fade-x');
  };
  const [done, setDone] = useState<string | null>(null);
  const box = useRef<HTMLDivElement>(null);
  const mark = useRef<HTMLElement>(null);
  const bar = useRef<HTMLDivElement>(null);

  const measure = () => {
    const b = box.current?.getBoundingClientRect();
    const m = mark.current?.getBoundingClientRect();
    const w = bar.current?.offsetWidth ?? 0;
    if (!b || !m) return setPos(null);
    const left = Math.max(8, Math.min(b.width - w - 8, m.left - b.left + m.width / 2 - w / 2));
    setPos({ left: Math.round(left), top: Math.round(m.top - b.top - 8) });
    edge(bar.current);
  };
  useIso(() => {
    measure();
    window.addEventListener('resize', measure);
    return () => window.removeEventListener('resize', measure);
  }, [sel, body]);

  const run = async (a: WritingAction) => {
    const out = sel === selection ? a.result : onAction ? await onAction(a.id, sel) : '';
    setResult({ action: a, text: out });
  };
  const apply = () => {
    if (!result?.text) return;
    setBody((b) => b.replace(sel, result.text));
    setDone(result.text);
    setSel('');
    setResult(null);
  };
  const pick = () => {
    const s = window.getSelection();
    const str = s?.toString().trim() ?? '';
    if (!str || !box.current?.contains(s!.anchorNode) || !body.includes(str) || str === sel) return;
    setSel(str);
    setResult(null);
    s?.removeAllRanges();
  };

  const paras = body.split(/\n{2,}/);
  return (
    <Stage title={title} className={className}>
      <Reveal delay={60}>
        <Paper doc={doc}>
          <div ref={box} className="relative" onMouseUp={pick} onKeyUp={(e) => e.shiftKey && pick()}>
            {sel && (
              <div
                ref={bar}
                role="toolbar"
                aria-label={t.selected}
                onScroll={(e) => edge(e.currentTarget)}
                className={cn(fade, "wra-pop kit-no-scrollbar absolute z-20 flex max-w-[calc(100%-16px)] -translate-y-full items-center gap-0.5 overflow-x-auto rounded-xl border border-border bg-card p-1 shadow-[var(--highlight),var(--shadow-lg)]")}
                style={pos ? { left: pos.left, top: pos.top } : { left: 8, top: -8, visibility: 'hidden' }}
              >
                {actions.map((a) => (
                  <button key={a.id} type="button" aria-pressed={result?.action.id === a.id} onClick={() => run(a)} className={cn('inline-flex h-11 items-center gap-1.5 rounded-lg px-2.5 text-[13px] font-medium transition-colors sm:h-8', result?.action.id === a.id ? 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground')}>
                    <Icon name={a.icon ?? 'sparkle'} size={15} className="max-sm:hidden" />
                    {a.label}
                  </button>
                ))}
              </div>
            )}
            <div className={cn(prose, 'pt-10')}>
              {paras.map((p, i) => {
                const at = sel ? p.indexOf(sel) : -1;
                const fresh = done && p.includes(done) ? p.indexOf(done) : -1;
                if (at >= 0)
                  return (
                    <p key={i}>
                      {p.slice(0, at)}
                      <mark ref={mark as React.RefObject<HTMLElement>} className="rounded-[3px] bg-[color-mix(in_oklab,var(--primary)_24%,transparent)] text-foreground [box-decoration-break:clone]">
                        {sel}
                      </mark>
                      {p.slice(at + sel.length)}
                    </p>
                  );
                if (fresh >= 0)
                  return (
                    <p key={i}>
                      {p.slice(0, fresh)}
                      <span className="wra-flash rounded-[3px]">{done}</span>
                      {p.slice(fresh + (done?.length ?? 0))}
                    </p>
                  );
                return <p key={i}>{p}</p>;
              })}
            </div>
          </div>
        </Paper>
      </Reveal>
      <div className="mt-3" aria-live="polite">
        {result ? (
          <div key={result.action.id} className="wra-up rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5">
            <p className="flex items-center gap-1.5 text-[12px] font-medium text-subtle-foreground">
              <Icon name={result.action.icon ?? 'sparkle'} size={13} className="text-primary" />
              {result.action.label}
            </p>
            <p className="mt-2 text-[15px] leading-relaxed text-foreground">{result.text || t.noResult}</p>
            <div className="mt-3 flex flex-wrap gap-2">
              {result.text && (
                <button type="button" onClick={apply} className={primaryBtn}>
                  <Icon name="check" size={15} />
                  {t.replace}
                </button>
              )}
              <button type="button" onClick={() => setResult(null)} className={quietBtn}>
                {t.close}
              </button>
            </div>
          </div>
        ) : sel ? (
          <form
            className="flex gap-2"
            onSubmit={async (e) => {
              e.preventDefault();
              if (!ask.trim()) return;
              const out = onAction ? await onAction(ask, sel) : '';
              setResult({ action: { id: 'ask', label: ask, result: out }, text: out });
              setAsk('');
            }}
          >
            <label className="flex h-11 min-w-0 flex-1 items-center gap-2 rounded-xl border border-border bg-card px-3 shadow-[var(--shadow-sm)] focus-within:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] focus-within:shadow-[var(--shadow-sm),0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]">
              <Icon name="sparkle" size={15} className="shrink-0 text-primary" />
              <span className="sr-only">{t.ask}</span>
              <input value={ask} onChange={(e) => setAsk(e.target.value)} placeholder={t.askPlaceholder} className="w-0 min-w-0 flex-1 bg-transparent text-[14px] text-foreground outline-none placeholder:text-subtle-foreground focus-visible:outline-none" />
            </label>
            <button type="submit" disabled={!ask.trim()} className={cn(quietBtn, 'h-11 disabled:opacity-50 sm:h-11')}>
              {t.send}
            </button>
          </form>
        ) : (
          <p className="flex items-center gap-2 px-1 text-[13px] text-muted-foreground">
            <Icon name="info" size={14} />
            {t.selectHint}
          </p>
        )}
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* outline-draft                                                       */
/* ------------------------------------------------------------------ */

function OutlineDraft({ t, title, doc, outline = [], className }: P) {
  const [view, inView] = useInView<HTMLDivElement>('0px 0px -20% 0px');
  const started = useRef(false);
  const [on, setOn] = useState<string[]>(outline.map((o) => o.id));
  const [step, setStep] = useState(outline.length); // paragraphs written so far
  const [hover, setHover] = useState<string | null>(null);
  const picked = outline.filter((o) => on.includes(o.id));
  const writing = step < picked.length;
  const [shown] = useStream(picked[step]?.draft ?? '', writing, 4);
  useEffect(() => {
    if (!writing || shown.length < (picked[step]?.draft.length ?? 0)) return;
    const id = window.setTimeout(() => setStep((s) => s + 1), 250);
    return () => window.clearTimeout(id);
  }, [writing, shown, step, picked]);
  const start = () => setStep(prefersReducedMotion() ? picked.length : 0);
  useEffect(() => {
    if (!inView || started.current || prefersReducedMotion()) return;
    // The lock is set when the timer fires: strict mode clears the first timer and
    // starts a second one, and scrolling away and back never starts the draft again
    const id = window.setTimeout(() => {
      started.current = true;
      setStep(0);
    }, 500);
    return () => window.clearTimeout(id);
  }, [inView]);

  return (
    <Stage title={title} className={className} wide>
      <div ref={view} className="grid grid-cols-1 gap-4 lg:grid-cols-[19rem_minmax(0,1fr)] lg:items-start lg:gap-6">
        <Reveal delay={60} className="lg:sticky lg:top-6">
          <div className="rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
            <p className="border-b border-border px-4 py-3 text-[13px] font-medium text-foreground">{t.outline}</p>
            <Stagger as="ol" step={60} className="p-2">
              {outline.map((o, i) => {
                const inc = on.includes(o.id);
                return (
                  <li key={o.id} onPointerEnter={() => setHover(o.id)} onPointerLeave={() => setHover(null)}>
                    <label className={cn('flex min-h-11 cursor-pointer items-start gap-3 rounded-lg px-2 py-2 transition-colors', hover === o.id && inc ? 'bg-accent' : 'hover:bg-accent/60')}>
                      <input
                        type="checkbox"
                        checked={inc}
                        disabled={writing}
                        onChange={() => setOn((s) => (s.includes(o.id) ? s.filter((x) => x !== o.id) : outline.filter((x) => s.includes(x.id) || x.id === o.id).map((x) => x.id)))}
                        aria-label={fill(t.include, { name: o.point })}
                        className="mt-0.5 size-[18px] shrink-0 accent-[var(--primary)]"
                      />
                      <span className={cn('text-[13.5px] leading-snug', inc ? 'text-foreground' : 'text-muted-foreground line-through')}>
                        <span className="mr-1.5 tabular-nums text-subtle-foreground">{i + 1}.</span>
                        {o.point}
                      </span>
                    </label>
                  </li>
                );
              })}
            </Stagger>
            <div className="border-t border-border p-3">
              <button type="button" onClick={start} disabled={writing || !picked.length} className={cn(primaryBtn, 'w-full disabled:opacity-60')}>
                <Icon name={step >= picked.length ? 'refresh' : 'sparkle'} size={15} className={cn(writing && 'animate-spin')} />
                {writing ? fill(t.writing, { n: step + 1, total: picked.length }) : step >= picked.length && step > 0 ? t.rewriteDraft : t.write}
              </button>
            </div>
          </div>
        </Reveal>
        <Reveal delay={160}>
          <Paper doc={doc}>
            <p className="mb-3 text-[12px] font-medium text-subtle-foreground">{t.draft}</p>
            <div className={prose}>
              {picked.map((o, i) => {
                const full = i < step;
                const now = i === step && writing;
                return (
                  <p
                    key={o.id}
                    onPointerEnter={() => setHover(o.id)}
                    onPointerLeave={() => setHover(null)}
                    className={cn('relative -mx-3 rounded-lg px-3 py-1 transition-colors duration-300', hover === o.id && 'bg-accent/70')}
                  >
                    <span aria-hidden className={cn('absolute -left-1 top-2.5 hidden h-[calc(100%-1.25rem)] w-0.5 rounded-full transition-colors sm:block', hover === o.id ? 'bg-primary' : 'bg-transparent')} />
                    {/* The finished text always holds the height, so nothing moves while it writes */}
                    <span className={cn('relative block', !full && !now && 'overflow-hidden')}>
                      <span aria-hidden={!full || undefined} className={full ? undefined : "invisible"}>{o.draft}</span>
                      {now && (
                        <span className="absolute inset-0">
                          {shown}
                          <span aria-hidden className="ml-0.5 inline-block h-[1.05em] w-0.5 translate-y-[0.18em] animate-kit-pulse bg-primary" />
                        </span>
                      )}
                      {!full && !now && (
                        <span aria-hidden className="absolute inset-0 block space-y-3 pt-2.5">
                          <span className="block h-2.5 w-11/12 rounded-full bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)]" />
                          <span className="block h-2.5 w-4/5 rounded-full bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)]" />
                          <span className="block h-2.5 w-2/3 rounded-full bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)]" />
                        </span>
                      )}
                    </span>
                  </p>
                );
              })}
            </div>
          </Paper>
          <p className="mt-3 flex items-start gap-2 px-1 text-[12.5px] text-muted-foreground">
            <Icon name="info" size={14} className="mt-px shrink-0" />
            {t.review}
          </p>
        </Reveal>
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */

/**
 * Writing help in a document: inline suggestions by kind, rewriting in a
 * chosen tone, tracked changes to accept or reject, a toolbar on the
 * selected words and a draft written point by point from an outline.
 */
export function WritingAssistant({ variant = 'inline-suggestions', labels, ...props }: WritingAssistantProps) {
  const t = useTexts(TEXTS, labels);
  const p: P = { ...props, t };
  const body = (() => {
    switch (variant) {
      case 'rewrite-tone':
        return <RewriteTone {...p} />;
      case 'diff-review':
        return <DiffReview {...p} />;
      case 'selection-toolbar':
        return <SelectionToolbar {...p} />;
      case 'outline-draft':
        return <OutlineDraft {...p} />;
      default:
        return <InlineSuggestions {...p} />;
    }
  })();
  return (
    <>
      <Style />
      {body}
    </>
  );
}

Häufige Fragen

Wie installiere ich Schreibassistent?

Mit aiw add @ai-website/writing-assistant. Der Befehl legt die Datei components/ui/writing-assistant.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, reveal und motion. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Schreibassistent?

5 Varianten, Vorschläge im Text, In einem Ton umschreiben, Änderungen prüfen, Leiste an der Auswahl und Entwurf aus Gliederung. Du wählst sie mit der Prop variant, zum Beispiel variant="rewrite-tone".

Kann ich die Texte von Schreibassistent übersetzen?

Ja. Schreibassistent bringt 42 eigene Texte auf Deutsch und Englisch mit, etwa „Vorschläge“, „Grammatik“ und „Klarheit“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Schreibassistent einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Schreibassistent mit aiw_components und installieren sie mit aiw_add @ai-website/writing-assistant. Nenn im Prompt die Variante, etwa variant="inline-suggestions", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Schreibassistent ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden WritingAssistant-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert