Skip to content
UI Kit
Accent color

Version history

Version history for documents. A version list with a marked preview and restore, two versions side by side, inline changes with a way through them, named versions and a careful restore.

One of 3 components in Import, export and history

5 variants

Variants

Version timeline

Versions grouped by day with author and name, the picked one as a page with added and removed words marked, and a restore you can undo.

Open in Studio

Side by side

Pick two versions, swap them, read them in two columns or one, unchanged paragraphs fold away until you open them.

Open in Studio

Inline changes

The document with every change in place, a counter that steps through them and a list of changes, on phones as a bar at the bottom.

Open in Studio

Named versions

Every version on a line with words added and removed, naming in place, and two picks that open a comparison.

Open in Studio

Restore

What changes paragraph by paragraph, restore as the current version or as a copy, and a result you can undo.

Open in Studio

Details

What Version history brings along, read from the registry and the source.

Area
App
Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/version-history.tsx
Brings along
utilskitbuttonicondialogsidebar
npm packages
None
Languages
English and German, 55 texts of its own

Props

The VersionHistoryProps interface from components/ui/version-history.tsx, with the comments from the code.

  • variantoptional
    VersionHistoryVariant

    timelineside-by-sideinline-diffnamed-versionsrestore

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titlerequired
    string
  • subtitleoptional
    string
  • nowoptional
    string

    ISO date and time that counts as now

  • versionsoptional
    DocVersion[]

    Newest first. The first one is the current version.

  • defaultVersionoptional
    string

    The version that starts out picked

  • onActionoptional
    (action: string, data: Record<string, unknown>) => void | Promise<void>

    Called on restore, rename and compare. Without it the block simulates the request.

Built-in texts

Version history writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 55 texts
versionsEnglish VersionsGerman Versionen
currentEnglish Current versionGerman Aktuelle Version
namedEnglish NamedGerman Benannt
autoEnglish Saved automaticallyGerman Automatisch gespeichert
onlyNamedEnglish Only named versionsGerman Nur benannte Versionen
restoreEnglish Restore this versionGerman Diese Version wiederherstellen
restoringEnglish Restoring…German Wird wiederhergestellt…
restoredEnglish Restored. The version from {when} is now the current one.German Wiederhergestellt. Die Version von {when} ist jetzt die aktuelle.
undoEnglish UndoGerman Rückgängig
changesEnglish {add} added, {del} removedGerman {add} hinzugefügt, {del} entfernt
noChangesEnglish No changes to the version before.German Keine Änderungen zur Version davor.
showChangesEnglish Show changesGerman Änderungen zeigen
beforeEnglish BeforeGerman Vorher
afterEnglish AfterGerman Nachher
olderEnglish Older versionGerman Ältere Version
newerEnglish Newer versionGerman Neuere Version
compareEnglish CompareGerman Vergleichen
withEnglish withGerman mit
swapEnglish Swap versionsGerman Versionen tauschen
unifiedEnglish One columnGerman Eine Spalte
splitEnglish Two columnsGerman Zwei Spalten
viewEnglish ViewGerman Ansicht
lineEnglish Line {n}German Zeile {n}
unchangedEnglish {n} unchanged paragraphsGerman {n} unveränderte Absätze
expandEnglish Show {n} unchanged paragraphsGerman {n} unveränderte Absätze zeigen
changeEnglish Change {n} of {total}German Änderung {n} von {total}
prevChangeEnglish Previous changeGerman Vorherige Änderung
nextChangeEnglish Next changeGerman Nächste Änderung
addedEnglish AddedGerman Hinzugefügt
changedLabelEnglish ChangedGerman Geändert
removedEnglish RemovedGerman Entfernt
wordsEnglish {n} wordsGerman {n} Wörter
nameItEnglish Name this versionGerman Version benennen
nameHintEnglish A name keeps a version forever. Unnamed ones are merged after 30 days.German Ein Name hält eine Version für immer. Unbenannte werden nach 30 Tagen zusammengefasst.
namePlaceholderEnglish e.g. Sent to clientGerman zum Beispiel An Kunden geschickt
saveEnglish Save nameGerman Namen speichern
renameEnglish Rename {name}German {name} umbenennen
pickTwoEnglish Pick two versions to compareGerman Zwei Versionen zum Vergleichen wählen
pickedOneEnglish Pick one more versionGerman Noch eine Version wählen
compareTwoEnglish Compare these twoGerman Diese zwei vergleichen
pickEnglish Pick {name} for comparingGerman {name} zum Vergleichen wählen
whatChangesEnglish What changesGerman Was sich ändert
restoreTitleEnglish Restore the version from {when}?German Version von {when} wiederherstellen?
restoreNamedEnglish Restore “{name}”?German „{name}“ wiederherstellen?
restoreTextEnglish The current text is kept as a version of its own, so nothing gets lost.German Der aktuelle Text bleibt als eigene Version erhalten, nichts geht verloren.
asNewEnglish Restore as the current versionGerman Als aktuelle Version wiederherstellen
asNewTextEnglish Everyone sees the restored text right away.German Alle sehen den wiederhergestellten Text sofort.
asCopyEnglish Restore as a copyGerman Als Kopie wiederherstellen
asCopyTextEnglish A new document next to this one, the current text stays.German Ein neues Dokument neben diesem, der aktuelle Text bleibt.
paragraphsChangedEnglish {n} paragraphs changeGerman {n} Absätze ändern sich
copyMadeEnglish Copy created as “{name} (copy)”.German Kopie als „{name} (Kopie)“ angelegt.
byEnglish by {name}German von {name}
todayEnglish TodayGerman Heute
yesterdayEnglish YesterdayGerman Gestern
previewOfEnglish Version from {when}German Version von {when}

Accessibility

Read from the source of the component.

  • Version history handles the keys Escape, Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
  • Sets aria-checked, aria-current, aria-label, aria-labelledby and aria-live, so screen readers announce purpose and state.
  • Uses the roles radio, radiogroup and status.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,183 lines
components/ui/version-history.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Dialog } from '@/components/ui/dialog';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';

export type VersionHistoryVariant = 'timeline' | 'side-by-side' | 'inline-diff' | 'named-versions' | 'restore';

export interface DocVersion {
  id: string;
  /** Who saved it */
  author: string;
  /** ISO date and time */
  time: string;
  /** A name makes it a named version, e.g. Sent to client */
  name?: string;
  /** The document as paragraphs. The first one is shown as the title. */
  content: string[];
  /** Saved by the app on its own */
  auto?: boolean;
}

export interface VersionHistoryProps {
  variant?: VersionHistoryVariant;
  /** The app frame. Leave it out (or null) for a standalone screen. */
  sidebar?: Omit<SidebarProps, 'children'> | null;
  title: string;
  subtitle?: string;
  /** ISO date and time that counts as now */
  now?: string;
  /** Newest first. The first one is the current version. */
  versions?: DocVersion[];
  /** The version that starts out picked */
  defaultVersion?: string;
  /** Called on restore, rename and compare. Without it the block simulates the request. */
  onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    versions: 'Versions',
    current: 'Current version',
    named: 'Named',
    auto: 'Saved automatically',
    onlyNamed: 'Only named versions',
    restore: 'Restore this version',
    restoring: 'Restoring…',
    restored: 'Restored. The version from {when} is now the current one.',
    undo: 'Undo',
    changes: '{add} added, {del} removed',
    noChanges: 'No changes to the version before.',
    showChanges: 'Show changes',
    before: 'Before',
    after: 'After',
    older: 'Older version',
    newer: 'Newer version',
    compare: 'Compare',
    with: 'with',
    swap: 'Swap versions',
    unified: 'One column',
    split: 'Two columns',
    view: 'View',
    line: 'Line {n}',
    unchanged: '{n} unchanged paragraphs',
    expand: 'Show {n} unchanged paragraphs',
    change: 'Change {n} of {total}',
    prevChange: 'Previous change',
    nextChange: 'Next change',
    added: 'Added',
    changedLabel: 'Changed',
    removed: 'Removed',
    words: '{n} words',
    nameIt: 'Name this version',
    nameHint: 'A name keeps a version forever. Unnamed ones are merged after 30 days.',
    namePlaceholder: 'e.g. Sent to client',
    save: 'Save name',
    rename: 'Rename {name}',
    pickTwo: 'Pick two versions to compare',
    pickedOne: 'Pick one more version',
    compareTwo: 'Compare these two',
    pick: 'Pick {name} for comparing',
    whatChanges: 'What changes',
    restoreTitle: 'Restore the version from {when}?',
    restoreNamed: 'Restore “{name}”?',
    restoreText: 'The current text is kept as a version of its own, so nothing gets lost.',
    asNew: 'Restore as the current version',
    asNewText: 'Everyone sees the restored text right away.',
    asCopy: 'Restore as a copy',
    asCopyText: 'A new document next to this one, the current text stays.',
    paragraphsChanged: '{n} paragraphs change',
    copyMade: 'Copy created as “{name} (copy)”.',
    by: 'by {name}',
    today: 'Today',
    yesterday: 'Yesterday',
    previewOf: 'Version from {when}',
  },
  de: {
    versions: 'Versionen',
    current: 'Aktuelle Version',
    named: 'Benannt',
    auto: 'Automatisch gespeichert',
    onlyNamed: 'Nur benannte Versionen',
    restore: 'Diese Version wiederherstellen',
    restoring: 'Wird wiederhergestellt…',
    restored: 'Wiederhergestellt. Die Version von {when} ist jetzt die aktuelle.',
    undo: 'Rückgängig',
    changes: '{add} hinzugefügt, {del} entfernt',
    noChanges: 'Keine Änderungen zur Version davor.',
    showChanges: 'Änderungen zeigen',
    before: 'Vorher',
    after: 'Nachher',
    older: 'Ältere Version',
    newer: 'Neuere Version',
    compare: 'Vergleichen',
    with: 'mit',
    swap: 'Versionen tauschen',
    unified: 'Eine Spalte',
    split: 'Zwei Spalten',
    view: 'Ansicht',
    line: 'Zeile {n}',
    unchanged: '{n} unveränderte Absätze',
    expand: '{n} unveränderte Absätze zeigen',
    change: 'Änderung {n} von {total}',
    prevChange: 'Vorherige Änderung',
    nextChange: 'Nächste Änderung',
    added: 'Hinzugefügt',
    changedLabel: 'Geändert',
    removed: 'Entfernt',
    words: '{n} Wörter',
    nameIt: 'Version benennen',
    nameHint: 'Ein Name hält eine Version für immer. Unbenannte werden nach 30 Tagen zusammengefasst.',
    namePlaceholder: 'zum Beispiel An Kunden geschickt',
    save: 'Namen speichern',
    rename: '{name} umbenennen',
    pickTwo: 'Zwei Versionen zum Vergleichen wählen',
    pickedOne: 'Noch eine Version wählen',
    compareTwo: 'Diese zwei vergleichen',
    pick: '{name} zum Vergleichen wählen',
    whatChanges: 'Was sich ändert',
    restoreTitle: 'Version von {when} wiederherstellen?',
    restoreNamed: '„{name}“ wiederherstellen?',
    restoreText: 'Der aktuelle Text bleibt als eigene Version erhalten, nichts geht verloren.',
    asNew: 'Als aktuelle Version wiederherstellen',
    asNewText: 'Alle sehen den wiederhergestellten Text sofort.',
    asCopy: 'Als Kopie wiederherstellen',
    asCopyText: 'Ein neues Dokument neben diesem, der aktuelle Text bleibt.',
    paragraphsChanged: '{n} Absätze ändern sich',
    copyMade: 'Kopie als „{name} (Kopie)“ angelegt.',
    by: 'von {name}',
    today: 'Heute',
    yesterday: 'Gestern',
    previewOf: 'Version von {when}',
  },
};

type Texts = (typeof TEXTS)['en'];

const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

/* ------------------------------------------------------------------ */
/* Motion of the app screens                                           */
/* ------------------------------------------------------------------ */
/*
 * The same small set in every app block (auth, dashboard, files, inbox …),
 * kept in the block so it ships on its own:
 *   useEnter   entrance that waits until its area scrolls in. The area gets
 *              data-kitm-scope and data-kitm={state}, children take kitm-wipe
 *              (charts draw from the left), kitm-grow-x, kitm-grow-y (bars),
 *              kitm-up and kitm-pop, with --kitm-d as delay.
 *   useGlide   a marker inside the active item ([data-glide]) glides over
 *              from where the previous one stood: tabs, segments, steps.
 *   kitm-in, kitm-fly, kitm-tick for things that appear after an action.
 * The server renders the end state, without JavaScript nothing waits longer
 * than 2.5 s, with reduced motion nothing moves. Only transform, opacity and
 * clip-path change, so no layout shifts.
 */
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const KITM_EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const KITM_FX: [string, string, number][] = [
  ['wipe', 'clip-path:inset(0 100% 0 0)', 1100],
  ['grow-x', 'transform:scaleX(0)', 900],
  ['grow-y', 'transform:scaleY(0)', 800],
  ['up', 'opacity:0;transform:translateY(10px)', 600],
  ['pop', 'opacity:0;transform:scale(.92)', 450],
];
const KITM_CSS =
  KITM_FX.map(([k, v]) => `@keyframes kitm-${k}{from{${v}}}@keyframes kitm-${k}-h{from{${v}}}`).join('') +
  '@keyframes kitm-fly{from{opacity:0;transform:translateX(24px)}}@keyframes kitm-sheen{to{transform:translateX(130%)}}.kitm-sheen{transform:translateX(-130%)}@keyframes kitm-blink{50%{opacity:0}}@keyframes kitm-shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}@keyframes kitm-ring{20%{transform:rotate(14deg)}40%{transform:rotate(-11deg)}60%{transform:rotate(7deg)}80%{transform:rotate(-3deg)}}@keyframes kitm-next{from{opacity:0;transform:translateX(18px)}}@keyframes kitm-prev{from{opacity:0;transform:translateX(-18px)}}.kitm-caret{display:inline-block;width:.45em;height:1.1em;margin-left:.15em;vertical-align:-.2em;border-radius:2px;background:color-mix(in oklab,var(--primary) 80%,var(--foreground))}@keyframes kitm-flash{from{background-color:color-mix(in oklab,var(--primary) 14%,transparent)}}' +
  '.kitm-grow-x{transform-origin:left}.kitm-grow-y{transform-origin:bottom}' +
  '@media (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k, , d]) => `[data-kitm=play] .kitm-${k}{animation:kitm-${k} var(--kitm-t,${d}ms) var(--ease-kit) var(--kitm-d,0ms) both}[data-kitm=wait] .kitm-${k}{animation:kitm-${k}-h 0s 1s both paused}`).join('') +
  '[data-kitm=play] .kitm-sheen{animation:kitm-sheen 1.6s var(--ease-kit) var(--kitm-d,400ms) both}.kitm-in{animation:kitm-up var(--kitm-t,.5s) var(--ease-kit) var(--kitm-d,0ms) both}.kitm-fly{animation:kitm-fly .55s var(--ease-kit) var(--kitm-d,0ms) both}.kitm-tick{animation:kitm-wipe .36s var(--ease-kit) both}.kitm-caret{animation:kitm-blink 1.05s steps(1) infinite}.kitm-shake{animation:kitm-shake .4s ease-in-out}.kitm-ring{transform-origin:50% 8%;animation:kitm-ring .9s var(--ease-kit) both}.kitm-next{animation:kitm-next .45s var(--ease-kit) both}.kitm-prev{animation:kitm-prev .45s var(--ease-kit) both}.kitm-flash{animation:kitm-flash 1.6s ease-out both}' +
  '}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k]) => `[data-kitm-scope]:not([data-kitm]) .kitm-${k}{animation:kitm-${k}-h 0s 2.5s both}`).join('') +
  '}';

function KitmStyle() {
  // React hoists it once into the head, however many app blocks render
  return (
    <style href="kitm" precedence="default">
      {KITM_CSS}
    </style>
  );
}

/** Entrance state of an area: undefined on the server, then wait (below the fold) or play */
function useEnter<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
  useEffect(() => {
    const el = ref.current;
    if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
    setState('wait');
    const io = new IntersectionObserver(
      ([e]) => {
        if (!e?.isIntersecting) return;
        setState('play');
        io.disconnect();
      },
      { rootMargin: '0px 0px -8% 0px' },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return [ref, { 'data-kitm-scope': '', 'data-kitm': state }] as const;
}

type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
  const r = el.getBoundingClientRect();
  const b = root.getBoundingClientRect();
  return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};

/**
 * The marker ([data-glide]) is rendered inside the active item, so the server
 * and a page without JavaScript show it in place. After a change it starts
 * where the old one stood and glides into the new item.
 */
function useGlide<T extends HTMLElement>(key: unknown) {
  const ref = useRef<T>(null);
  const before = useRef<Box | null>(null);
  // Read during render, while the DOM still shows the old marker
  if (typeof window !== 'undefined' && ref.current) {
    const m = ref.current.querySelector('[data-glide]');
    before.current = m ? boxOf(m, ref.current) : null;
  }
  useIso(() => {
    const root = ref.current;
    const m = root?.querySelector<HTMLElement>('[data-glide]');
    const a = before.current;
    if (!root || !m || !a || prefersReducedMotion()) return;
    const b = boxOf(m, root);
    if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
    m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: KITM_EASE });
  }, [key]);
  return ref;
}

/* ------------------------------------------------------------------ */
/* Diff                                                                */
/* ------------------------------------------------------------------ */

type Part = { kind: 'same' | 'add' | 'del'; text: string };

/** Longest common subsequence diff of two token lists. Small documents only, it is O(n·m). */
function diff(a: string[], b: string[]): { kind: Part['kind']; a?: number; b?: number }[] {
  const n = a.length;
  const m = b.length;
  const dp = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
  for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
  const out: { kind: Part['kind']; a?: number; b?: number }[] = [];
  let i = 0;
  let j = 0;
  while (i < n && j < m) {
    if (a[i] === b[j]) out.push({ kind: 'same', a: i++, b: j++ });
    else if (dp[i + 1][j] >= dp[i][j + 1]) out.push({ kind: 'del', a: i++ });
    else out.push({ kind: 'add', b: j++ });
  }
  while (i < n) out.push({ kind: 'del', a: i++ });
  while (j < m) out.push({ kind: 'add', b: j++ });
  return out;
}

/** Word level diff of two texts, joined into runs */
function wordDiff(a: string, b: string): Part[] {
  // Spaces and punctuation are tokens of their own, so a changed comma does not mark the whole word
  const ta = a.split(/(\s+|[;:!?]|[.,](?=\s|$))/).filter(Boolean);
  const tb = b.split(/(\s+|[;:!?]|[.,](?=\s|$))/).filter(Boolean);
  const ops = diff(ta, tb);
  const out: Part[] = [];
  for (const o of ops) {
    const text = o.kind === 'add' ? tb[o.b!] : ta[o.a!];
    const last = out[out.length - 1];
    if (last && last.kind === o.kind) last.text += text;
    else out.push({ kind: o.kind, text });
  }
  return out;
}

const words = (s: string) => s.split(/\s+/).filter(Boolean).length;

/** Paragraph rows for a two-way view: same, changed (with a word diff), added, removed */
type Row = { kind: 'same' | 'change' | 'add' | 'del'; a?: string; b?: string; parts?: Part[] };
/** Share of words two paragraphs have in common, 0 to 1 */
function likeness(x: string, y: string) {
  const split = (s: string) => s.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, '').split(/\s+/).filter(Boolean);
  const wx = split(x);
  const wy = split(y);
  const set = new Set(wy);
  const common = wx.filter((w) => set.has(w)).length;
  // Dice coefficient: shared words against the length of both
  return (2 * common) / Math.max(1, wx.length + wy.length);
}

function rows(a: string[], b: string[]): Row[] {
  const ops = diff(a, b);
  const out: Row[] = [];
  for (let k = 0; k < ops.length; ) {
    const o = ops[k];
    if (o.kind === 'same') {
      out.push({ kind: 'same', a: a[o.a!], b: b[o.b!] });
      k++;
      continue;
    }
    // A run of removed and added paragraphs: pair the ones that are clearly the same paragraph rewritten
    const dels: number[] = [];
    const adds: number[] = [];
    while (k < ops.length && ops[k].kind !== 'same') {
      if (ops[k].kind === 'del') dels.push(ops[k].a!);
      else adds.push(ops[k].b!);
      k++;
    }
    let ai = 0;
    for (const d of dels) {
      let best = -1;
      let score = 0.34;
      for (let j = ai; j < adds.length; j++) {
        const sc = likeness(a[d], b[adds[j]]);
        if (sc > score) {
          score = sc;
          best = j;
        }
      }
      if (best < 0) {
        out.push({ kind: 'del', a: a[d] });
        continue;
      }
      for (; ai < best; ai++) out.push({ kind: 'add', b: b[adds[ai]] });
      out.push({ kind: 'change', a: a[d], b: b[adds[best]], parts: wordDiff(a[d], b[adds[best]]) });
      ai = best + 1;
    }
    for (; ai < adds.length; ai++) out.push({ kind: 'add', b: b[adds[ai]] });
  }
  return out;
}

function stats(a: string[], b: string[]) {
  let add = 0;
  let del = 0;
  for (const r of rows(a, b)) {
    if (r.kind === 'add') add += words(r.b!);
    else if (r.kind === 'del') del += words(r.a!);
    else if (r.kind === 'change') for (const p of r.parts!) p.kind === 'add' ? (add += words(p.text)) : p.kind === 'del' ? (del += words(p.text)) : 0;
  }
  return { add, del };
}

/* ------------------------------------------------------------------ */
/* Parts                                                               */
/* ------------------------------------------------------------------ */

const MIN = 60000;
const toMin = (iso: string) => Math.round(Date.parse(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z')) / MIN);

type P = Omit<VersionHistoryProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; now: string; locale: Locale; versions: DocVersion[] };

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function run(onAction: VersionHistoryProps['onAction'], action: string, data: Record<string, unknown>) {
  if (onAction) await onAction(action, data);
  else await wait(prefersReducedMotion() ? 0 : 700);
}

function useWhen(locale: Locale, now: string, t: Texts) {
  return useMemo(() => {
    const tag = locale === 'de' ? 'de-DE' : 'en-US';
    const time = new Intl.DateTimeFormat(tag, { hour: tag === 'de-DE' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' });
    const date = new Intl.DateTimeFormat(tag, { day: 'numeric', month: 'short', timeZone: 'UTC' });
    const dayOf = (iso: string) => Math.floor(toMin(iso) / 1440);
    const label = (iso: string) => {
      const d = dayOf(now) - dayOf(iso);
      return d === 0 ? t.today : d === 1 ? t.yesterday : date.format(new Date(toMin(iso) * MIN));
    };
    return {
      day: label,
      time: (iso: string) => time.format(new Date(toMin(iso) * MIN)),
      full: (iso: string) => `${label(iso)}, ${time.format(new Date(toMin(iso) * MIN))}`,
      dayKey: dayOf,
    };
  }, [locale, now, t]);
}

function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
  return (
    <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
      <div className="min-w-0">
        {subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
        <h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
      </div>
      {children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
    </div>
  );
}

function Main({ children, className }: { children: React.ReactNode; className?: string }) {
  return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}

function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
  const ref = useGlide<HTMLDivElement>(value);
  const onKey = (e: React.KeyboardEvent, i: number) => {
    const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
    if (!dir) return;
    e.preventDefault();
    const next = (i + dir + options.length) % options.length;
    onChange(options[next].value);
    ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
  };
  return (
    <div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 rounded-lg bg-muted p-1', className)}>
      {options.map((o, i) => (
        <button
          key={o.value}
          type="button"
          role="radio"
          aria-checked={value === o.value}
          tabIndex={value === o.value ? 0 : -1}
          onClick={() => onChange(o.value)}
          onKeyDown={(e) => onKey(e, i)}
          className={cn(
            'relative inline-flex h-11 min-w-11 sm:h-10 sm:min-w-0 flex-1 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] sm:h-8',
            value === o.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
          )}
        >
          {value === o.value && <span data-glide aria-hidden className="absolute inset-0 rounded-md bg-background shadow-[var(--shadow-sm)] ring-1 ring-border" />}
          <span className="relative inline-flex items-center gap-1.5">{o.label}</span>
        </button>
      ))}
    </div>
  );
}

function Live({ text }: { text: string }) {
  return (
    <p role="status" aria-live="polite" className="sr-only">
      {text}
    </p>
  );
}

function Initials({ name, index = 0, className }: { name: string; index?: number; className?: string }) {
  return (
    <span aria-hidden className={cn('grid size-7 shrink-0 place-items-center rounded-full text-[10.5px] font-semibold text-foreground', className)} style={{ background: `color-mix(in oklab, var(--primary) ${10 + (index % 4) * 7}%, var(--muted))` }}>
      {name
        .split(/\s+/)
        .map((w) => w[0])
        .slice(0, 2)
        .join('')}
    </span>
  );
}

/** Inline marks for added and removed words */
function Marked({ parts }: { parts: Part[] }) {
  return (
    <>
      {parts.map((p, i) =>
        p.kind === 'same' ? (
          <span key={i}>{p.text}</span>
        ) : p.kind === 'add' ? (
          <ins key={i} className="rounded-[3px] bg-[color-mix(in_oklab,var(--success)_20%,transparent)] px-0.5 text-foreground no-underline decoration-[color-mix(in_oklab,var(--success)_60%,transparent)] [box-decoration-break:clone]">
            {p.text}
          </ins>
        ) : (
          <del key={i} className="rounded-[3px] bg-[color-mix(in_oklab,var(--destructive)_14%,transparent)] px-0.5 text-muted-foreground decoration-[color-mix(in_oklab,var(--destructive)_60%,transparent)] [box-decoration-break:clone]">
            {p.text}
          </del>
        ),
      )}
    </>
  );
}

/** The document drawn as a page, title first */
function Page({ children, className }: { children: React.ReactNode; className?: string }) {
  return <article className={cn('rounded-xl border border-border bg-card px-5 py-6 shadow-[var(--highlight),var(--shadow-sm)] sm:px-10 sm:py-10', className)}>{children}</article>;
}

function Para({ i, children, className }: { i: number; children: React.ReactNode; className?: string }) {
  return i === 0 ? <h2 className={cn('text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]', className)}>{children}</h2> : <p className={cn('mt-4 text-pretty text-[15px] leading-[1.75] text-foreground/90', className)}>{children}</p>;
}

/* ------------------------------------------------------------------ */
/* timeline                                                            */
/* ------------------------------------------------------------------ */

function Timeline({ t, title, subtitle, versions, defaultVersion, now, locale, onAction }: P) {
  const id = useId();
  const when = useWhen(locale, now, t);
  const [list, setList] = useState(versions);
  const [sel, setSel] = useState(defaultVersion ?? versions[1]?.id ?? versions[0]?.id ?? '');
  const [named, setNamed] = useState(false);
  const [marks, setMarks] = useState(true);
  const [busy, setBusy] = useState(false);
  const [msg, setMsg] = useState('');
  const [open, setOpen] = useState(false);
  const [scope, enter] = useEnter<HTMLDivElement>();
  const listRef = useGlide<HTMLDivElement>(sel);
  const shown = list.filter((v, i) => !named || v.name || i === 0);
  const idx = list.findIndex((v) => v.id === sel);
  const v = list[idx];
  const prev = list[idx + 1];
  const st = v && prev ? stats(prev.content, v.content) : { add: 0, del: 0 };
  const days = useMemo(() => {
    const m = new Map<number, DocVersion[]>();
    for (const x of shown) m.set(when.dayKey(x.time), [...(m.get(when.dayKey(x.time)) ?? []), x]);
    return [...m];
  }, [shown, when]);
  const restore = async () => {
    if (!v) return;
    setBusy(true);
    await run(onAction, 'restore', { id: v.id });
    const copy: DocVersion = { ...v, id: `${v.id}-r${Date.now()}`, time: now, name: undefined, auto: false };
    setList((l) => [copy, ...l]);
    setSel(copy.id);
    setBusy(false);
    setMsg(fill(t.restored, { when: when.full(v.time) }));
  };
  const pick = (x: string) => {
    setSel(x);
    setOpen(true);
    setMsg('');
  };
  const doc = v && (
    <div key={v.id} className="kitm-in">
      <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
        <div className="min-w-0">
          <p className="text-[12.5px] text-muted-foreground">{idx === 0 ? t.current : fill(t.previewOf, { when: when.full(v.time) })}</p>
          <p className="mt-0.5 text-[13px] tabular-nums">{prev ? <span className="text-muted-foreground"><span className="font-medium text-success-foreground">+{fill(t.words, { n: st.add })}</span> · <span className="font-medium text-destructive">−{fill(t.words, { n: st.del })}</span></span> : <span className="text-muted-foreground">{t.noChanges}</span>}</p>
        </div>
        <div className="flex items-center gap-2">
          <label className="flex min-h-11 cursor-pointer items-center gap-2 text-[13px] text-foreground sm:min-h-0">
            <input type="checkbox" checked={marks} onChange={(e) => setMarks(e.target.checked)} className="size-4 accent-[var(--primary)]" />
            {t.showChanges}
          </label>
          {idx > 0 && (
            <Button size="sm" onClick={restore} disabled={busy} iconLeft={busy ? 'loader' : 'rotate-ccw'}>
              {busy ? t.restoring : t.restore}
            </Button>
          )}
        </div>
      </div>
      <Page>
        {marks && prev
          ? rows(prev.content, v.content).map((r, i) =>
              r.kind === 'same' ? (
                <Para key={i} i={i}>
                  {r.b}
                </Para>
              ) : r.kind === 'change' ? (
                <Para key={i} i={i}>
                  <Marked parts={r.parts!} />
                </Para>
              ) : r.kind === 'add' ? (
                <Para key={i} i={i} className="-mx-2 rounded-md border-s-2 border-success bg-[color-mix(in_oklab,var(--success)_9%,transparent)] px-2">
                  {r.b}
                </Para>
              ) : (
                <Para key={i} i={i} className="-mx-2 rounded-md border-s-2 border-destructive bg-[color-mix(in_oklab,var(--destructive)_7%,transparent)] px-2 text-muted-foreground line-through decoration-[color-mix(in_oklab,var(--destructive)_50%,transparent)]">
                  {r.a}
                </Para>
              ),
            )
          : v.content.map((c, i) => (
              <Para key={i} i={i}>
                {c}
              </Para>
            ))}
      </Page>
    </div>
  );
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      {msg && (
        <p role="status" className="kitm-in mt-5 flex items-center gap-3 rounded-xl border border-[color-mix(in_oklab,var(--success)_40%,var(--border))] bg-[color-mix(in_oklab,var(--success)_8%,var(--card))] px-4 py-3 text-[13.5px] text-foreground">
          <Icon name="check-circle" size={17} className="shrink-0 text-success-foreground" />
          <span className="min-w-0 flex-1">{msg}</span>
          <button
            type="button"
            onClick={() => {
              setList((l) => l.slice(1));
              setSel(list[1]?.id ?? '');
              setMsg('');
            }}
            className="h-11 shrink-0 rounded-md px-2 text-[13px] font-medium text-foreground hover:bg-accent sm:h-8"
          >
            {t.undo}
          </button>
        </p>
      )}
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
        {/* Desktop: the document with the version list on the right. Tablets and phones: the list first, a version opens as its own view. */}
        <section aria-label={t.previewOf} className={cn('kitm-up min-w-0 lg:order-1', !open && 'max-lg:hidden')}>
          <button type="button" onClick={() => setOpen(false)} className="-ms-1 mb-3 inline-flex h-11 items-center gap-1.5 rounded-md px-1 text-[13px] font-medium text-muted-foreground hover:text-foreground lg:hidden">
            <Icon name="arrow-left" size={15} />
            {t.versions}
          </button>
          {doc}
        </section>
        <aside aria-labelledby={`${id}-h`} className={cn('kitm-up [--kitm-d:100ms] min-w-0 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] lg:order-2 lg:sticky lg:top-6', open && 'max-lg:hidden')}>
          <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3.5">
            <h2 id={`${id}-h`} className="text-[14px] font-semibold text-foreground">
              {t.versions}
            </h2>
            <label className="flex cursor-pointer items-center gap-2 text-[12.5px] text-muted-foreground">
              <input type="checkbox" checked={named} onChange={(e) => setNamed(e.target.checked)} className="size-4 accent-[var(--primary)]" />
              {t.onlyNamed}
            </label>
          </div>
          <div ref={listRef} className="relative max-h-[38rem] overflow-y-auto overscroll-contain py-2">
            {days.map(([d, vs]) => (
              <div key={d} className="px-2 pb-2">
                <p className="px-2 pb-1 pt-2 text-[11.5px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">{when.day(vs[0].time)}</p>
                <ul>
                  {vs.map((x) => {
                    const i = list.indexOf(x);
                    return (
                      <li key={x.id}>
                        <button type="button" aria-current={x.id === sel ? 'true' : undefined} onClick={() => pick(x.id)} className={cn('relative flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left transition-colors', x.id === sel ? '' : 'hover:bg-[color-mix(in_oklab,var(--foreground)_4%,transparent)]', i === 0 && 'kitm-fly')}>
                          {x.id === sel && <span data-glide aria-hidden className="absolute inset-0 rounded-lg bg-[color-mix(in_oklab,var(--primary)_10%,transparent)] ring-1 ring-[color-mix(in_oklab,var(--primary)_25%,transparent)]" />}
                          <Initials name={x.author} index={i} className="relative" />
                          <span className="relative min-w-0 flex-1">
                            <span className="flex items-center gap-1.5 text-[13.5px] font-medium text-foreground">
                              <span className="truncate">{x.name ?? when.time(x.time)}</span>
                              {i === 0 && <span className="shrink-0 rounded bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] px-1.5 py-px text-[10.5px] font-semibold text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">{t.current}</span>}
                            </span>
                            <span className="block truncate text-[12px] text-muted-foreground">
                              {x.name ? `${when.time(x.time)} · ` : ''}
                              {x.auto ? t.auto : x.author}
                            </span>
                          </span>
                          {x.name && <Icon name="bookmark" size={14} className="relative shrink-0 text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]" />}
                        </button>
                      </li>
                    );
                  })}
                </ul>
              </div>
            ))}
          </div>
        </aside>
      </div>
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* side-by-side                                                        */
/* ------------------------------------------------------------------ */

function VersionPick({ label, value, onChange, versions, when, t }: { label: string; value: string; onChange: (v: string) => void; versions: DocVersion[]; when: ReturnType<typeof useWhen>; t: Texts }) {
  return (
    <label className="grid min-w-0 gap-1.5 text-[12px] font-medium text-muted-foreground">
      {label}
      <span className="relative block">
        <select value={value} onChange={(e) => onChange(e.target.value)} className="h-11 w-full min-w-0 cursor-pointer appearance-none truncate rounded-lg border border-input bg-background ps-3 pe-9 text-base font-normal text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 focus-visible:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-10 sm:text-[13.5px]">
          {versions.map((v, i) => (
            <option key={v.id} value={v.id}>
              {i === 0 ? t.current : v.name ? `${v.name} · ${when.full(v.time)}` : `${when.full(v.time)} · ${v.author}`}
            </option>
          ))}
        </select>
        <Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
      </span>
    </label>
  );
}

function SideBySide({ t, title, subtitle, versions, now, locale }: P) {
  const when = useWhen(locale, now, t);
  const [a, setA] = useState(versions[Math.min(3, versions.length - 1)]?.id ?? '');
  const [b, setB] = useState(versions[0]?.id ?? '');
  const [mode, setMode] = useState<'split' | 'unified'>('split');
  const [openGaps, setOpenGaps] = useState<Set<number>>(new Set());
  const [scope, enter] = useEnter<HTMLDivElement>();
  const va = versions.find((x) => x.id === a);
  const vb = versions.find((x) => x.id === b);
  const rs = va && vb ? rows(va.content, vb.content) : [];
  const st = va && vb ? stats(va.content, vb.content) : { add: 0, del: 0 };
  // Runs of three or more unchanged paragraphs fold into one line
  const blocks: ({ gap: number; from: number; rows: Row[] } | { row: Row; n: number })[] = [];
  for (let i = 0; i < rs.length; ) {
    if (rs[i].kind === 'same') {
      let j = i;
      while (j < rs.length && rs[j].kind === 'same') j++;
      if (j - i >= 3 && i > 0) blocks.push({ gap: j - i, from: i, rows: rs.slice(i, j) });
      else for (let k = i; k < j; k++) blocks.push({ row: rs[k], n: k });
      i = j;
    } else blocks.push({ row: rs[i], n: i++ });
  }
  const cell = (side: 'a' | 'b', r: Row) => {
    const text = side === 'a' ? r.a : r.b;
    if (text === undefined) return <span aria-hidden className="block h-full min-h-6 rounded bg-[repeating-linear-gradient(135deg,transparent_0_6px,color-mix(in_oklab,var(--foreground)_5%,transparent)_6px_7px)]" />;
    if (r.kind === 'change') return <Marked parts={r.parts!.filter((p) => p.kind === 'same' || p.kind === (side === 'a' ? 'del' : 'add'))} />;
    return text;
  };
  const tone = (side: 'a' | 'b', r: Row) => (r.kind === 'same' ? '' : side === 'a' ? (r.kind === 'add' ? '' : 'bg-[color-mix(in_oklab,var(--destructive)_6%,transparent)]') : r.kind === 'del' ? '' : 'bg-[color-mix(in_oklab,var(--success)_7%,transparent)]');
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <Segmented
          label={t.view}
          value={mode}
          onChange={setMode}
          className="max-md:hidden"
          options={[
            { value: 'split', label: <><Icon name="columns" size={14} />{t.split}</> },
            { value: 'unified', label: <><Icon name="list" size={14} />{t.unified}</> },
          ]}
        />
      </Header>
      <div ref={scope} {...enter} className="mt-6 grid gap-4">
        <div className="kitm-up grid grid-cols-[minmax(0,1fr)_auto] items-end gap-3 sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]">
          <VersionPick label={t.before} value={a} onChange={setA} versions={versions} when={when} t={t} />
          <button
            type="button"
            aria-label={t.swap}
            onClick={() => {
              setA(b);
              setB(a);
            }}
            className="group col-start-2 row-span-2 row-start-1 grid size-11 place-items-center self-center rounded-lg border border-border bg-card text-muted-foreground shadow-[var(--shadow-sm)] hover:text-foreground sm:col-start-auto sm:row-span-1 sm:row-start-auto sm:size-10 sm:self-end"
          >
            <Icon name="arrows-left-right" size={16} className="transition-transform duration-500 ease-[var(--ease-kit)] group-active:rotate-180" />
          </button>
          <div className="col-start-1 sm:col-start-auto">
            <VersionPick label={t.after} value={b} onChange={setB} versions={versions} when={when} t={t} />
          </div>
        </div>
        <p key={`${a}-${b}`} className="kitm-in flex flex-wrap items-center gap-x-4 gap-y-1 text-[13px] tabular-nums">
          <span className="inline-flex items-center gap-1.5 font-medium text-success-foreground">
            <span aria-hidden className="size-2 rounded-sm bg-success" />+{fill(t.words, { n: st.add })}
          </span>
          <span className="inline-flex items-center gap-1.5 font-medium text-destructive">
            <span aria-hidden className="size-2 rounded-sm bg-destructive" />−{fill(t.words, { n: st.del })}
          </span>
        </p>
        {/* Desktop and tablets: two columns or one. Phones: always one column. */}
        <section key={`${a}-${b}-${mode}`} className="kitm-up [--kitm-d:80ms] overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
          <div className={cn('hidden border-b border-border bg-surface text-[12px] font-medium text-muted-foreground', mode === 'split' && 'md:grid md:grid-cols-[3rem_minmax(0,1fr)_minmax(0,1fr)]')}>
            <span />
            <span className="border-s border-border px-4 py-2.5">{va ? when.full(va.time) : ''}</span>
            <span className="border-s border-border px-4 py-2.5">{vb ? when.full(vb.time) : ''}</span>
          </div>
          <div className="divide-y divide-border text-[14px] leading-[1.7]">
            {blocks.map((bl, k) =>
              'gap' in bl ? (
                openGaps.has(bl.from) ? (
                  bl.rows.map((r, j) => (
                    <div key={`g${bl.from}-${j}`} className={cn('kitm-in grid grid-cols-[3rem_minmax(0,1fr)] text-muted-foreground', mode === 'split' && 'md:grid-cols-[3rem_minmax(0,1fr)_minmax(0,1fr)]')}>
                      <span className="py-2.5 pe-3 text-right text-[11.5px] tabular-nums text-subtle-foreground">{bl.from + j + 1}</span>
                      <p className={cn('border-s border-border px-4 py-2.5', mode === 'split' && 'md:hidden')}>{r.b}</p>
                      {mode === 'split' && <p className="hidden border-s border-border px-4 py-2.5 md:block">{r.a}</p>}
                      {mode === 'split' && <p className="hidden border-s border-border px-4 py-2.5 md:block">{r.b}</p>}
                    </div>
                  ))
                ) : (
                  <button key={`g${k}`} type="button" onClick={() => setOpenGaps((s) => new Set(s).add(bl.from))} className="flex w-full items-center justify-center gap-2 bg-[color-mix(in_oklab,var(--foreground)_2.5%,var(--card))] py-2.5 text-[12.5px] font-medium text-muted-foreground hover:text-foreground">
                    <Icon name="arrows-up-down" size={14} />
                    {fill(t.expand, { n: bl.gap })}
                  </button>
                )
              ) : (
                <div key={k} className={cn('grid grid-cols-[3rem_minmax(0,1fr)]', mode === 'split' && 'md:grid-cols-[3rem_minmax(0,1fr)_minmax(0,1fr)]')}>
                  <span className="py-2.5 pe-3 text-right text-[11.5px] tabular-nums text-subtle-foreground">{bl.n + 1}</span>
                  {/* One column: removed, then added, changed with both marks */}
                  <div className={cn('border-s border-border', mode === 'split' && 'md:hidden')}>
                    {bl.row.kind === 'same' && <p className="px-4 py-2.5 text-foreground/90">{bl.row.b}</p>}
                    {bl.row.kind === 'change' && (
                      <p className="bg-[color-mix(in_oklab,var(--primary)_5%,transparent)] px-4 py-2.5">
                        <Marked parts={bl.row.parts!} />
                      </p>
                    )}
                    {bl.row.kind === 'del' && <p className="border-s-2 border-destructive bg-[color-mix(in_oklab,var(--destructive)_7%,transparent)] px-4 py-2.5 text-muted-foreground line-through decoration-[color-mix(in_oklab,var(--destructive)_50%,transparent)]">{bl.row.a}</p>}
                    {bl.row.kind === 'add' && <p className="border-s-2 border-success bg-[color-mix(in_oklab,var(--success)_8%,transparent)] px-4 py-2.5">{bl.row.b}</p>}
                  </div>
                  {mode === 'split' && (
                    <>
                      <p className={cn('hidden border-s border-border px-4 py-2.5 text-foreground/90 md:block', tone('a', bl.row))}>{cell('a', bl.row)}</p>
                      <p className={cn('hidden border-s border-border px-4 py-2.5 text-foreground/90 md:block', tone('b', bl.row))}>{cell('b', bl.row)}</p>
                    </>
                  )}
                </div>
              ),
            )}
          </div>
        </section>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* inline-diff                                                         */
/* ------------------------------------------------------------------ */

function InlineDiff({ t, title, subtitle, versions, defaultVersion, now, locale }: P) {
  const when = useWhen(locale, now, t);
  const newer = versions.find((v) => v.id === defaultVersion) ?? versions[0];
  const older = versions[versions.indexOf(newer) + 1] ?? newer;
  const rs = useMemo(() => rows(older.content, newer.content), [older, newer]);
  const changes = rs.map((r, i) => ({ r, i })).filter((x) => x.r.kind !== 'same');
  const [at, setAt] = useState(0);
  const refs = useRef<(HTMLElement | null)[]>([]);
  const [scope, enter] = useEnter<HTMLDivElement>();
  const st = stats(older.content, newer.content);
  const go = (n: number) => {
    const k = (n + changes.length) % changes.length;
    setAt(k);
    const el = refs.current[changes[k]?.i];
    el?.scrollIntoView({ block: 'center', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
    el?.focus({ preventScroll: true });
  };
  const nav = (
    <div className="flex items-center gap-1">
      <button type="button" aria-label={t.prevChange} onClick={() => go(at - 1)} disabled={!changes.length} className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 sm:size-9">
        <Icon name="chevron-up" size={17} />
      </button>
      <span key={at} className="kitm-in min-w-[7.5rem] text-center text-[13px] tabular-nums text-foreground">
        {changes.length ? fill(t.change, { n: at + 1, total: changes.length }) : t.noChanges}
      </span>
      <button type="button" aria-label={t.nextChange} onClick={() => go(at + 1)} disabled={!changes.length} className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 sm:size-9">
        <Icon name="chevron-down" size={17} />
      </button>
    </div>
  );
  return (
    <Main className="pb-24 lg:pb-8">
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_17rem] lg:items-start">
        <Page className="kitm-up">
          {rs.map((r, i) => {
            const k = changes.findIndex((c) => c.i === i);
            const active = k === at;
            const body = r.kind === 'same' ? r.b : r.kind === 'change' ? <Marked parts={r.parts!} /> : r.kind === 'add' ? r.b : r.a;
            return (
              <div
                key={i}
                ref={(el) => {
                  refs.current[i] = el;
                }}
                tabIndex={k >= 0 ? -1 : undefined}
                className={cn('relative -mx-3 rounded-lg px-3 outline-none transition-[background-color,box-shadow] duration-500', k >= 0 && 'scroll-mt-24', active && 'bg-[color-mix(in_oklab,var(--primary)_6%,transparent)] shadow-[inset_2px_0_0_var(--primary)]')}
                onClick={() => k >= 0 && setAt(k)}
              >
                <Para i={i} className={cn(i === 0 ? 'py-1' : 'mt-0 py-2', r.kind === 'del' && 'text-muted-foreground line-through decoration-[color-mix(in_oklab,var(--destructive)_55%,transparent)]', r.kind === 'add' && 'text-foreground')}>
                  {r.kind === 'add' && <span className="sr-only">{t.added}: </span>}
                  {r.kind === 'del' && <span className="sr-only">{t.removed}: </span>}
                  {r.kind === 'add' ? <ins className="bg-[color-mix(in_oklab,var(--success)_16%,transparent)] no-underline [box-decoration-break:clone]">{body}</ins> : body}
                </Para>
              </div>
            );
          })}
        </Page>
        {/* Desktop: a panel with the change count and the way through them. Tablets and phones: a bar at the bottom. */}
        <aside className="kitm-up [--kitm-d:120ms] hidden rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] lg:sticky lg:top-6 lg:block">
          <p className="text-[12.5px] text-muted-foreground">
            {when.full(older.time)} → {when.full(newer.time)}
          </p>
          <p className="mt-1 flex items-center gap-2 text-[13px] tabular-nums">
            <span className="font-semibold text-success-foreground">+{st.add}</span>
            <span className="font-semibold text-destructive">−{st.del}</span>
            <span aria-hidden className="ms-1 flex h-2 flex-1 overflow-hidden rounded-full bg-track">
              <span className="bg-success" style={{ flexGrow: st.add }} />
              <span className="bg-destructive" style={{ flexGrow: st.del }} />
            </span>
          </p>
          <div className="mt-4 border-t border-border pt-3">{nav}</div>
          <ol className="mt-2 grid grid-cols-[minmax(0,1fr)] gap-0.5">
            {changes.map((c, k) => (
              <li key={c.i}>
                <button type="button" onClick={() => go(k)} className={cn('flex w-full items-center gap-2.5 rounded-md px-2 py-2 text-left text-[12.5px] transition-colors', k === at ? 'bg-[color-mix(in_oklab,var(--primary)_10%,transparent)] text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground')}>
                  <span aria-hidden className={cn('size-2 shrink-0 rounded-sm', c.r.kind === 'add' ? 'bg-success' : c.r.kind === 'del' ? 'bg-destructive' : 'bg-primary')} />
                  <span className="min-w-0 flex-1 truncate">{(c.r.b ?? c.r.a ?? '').slice(0, 60)}</span>
                </button>
              </li>
            ))}
          </ol>
        </aside>
      </div>
      <div className="fixed inset-x-3 bottom-[calc(env(safe-area-inset-bottom)+12px)] z-30 mx-auto flex max-w-[26rem] items-center justify-between gap-2 rounded-xl border border-border bg-[color-mix(in_oklab,var(--popover)_92%,transparent)] p-1.5 ps-3.5 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-xl lg:hidden">
        <span className="text-[12.5px] font-semibold tabular-nums">
          <span className="text-success-foreground">+{st.add}</span> <span className="text-destructive">−{st.del}</span>
        </span>
        {nav}
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* named-versions                                                      */
/* ------------------------------------------------------------------ */

function NamedVersions({ t, title, subtitle, versions, now, locale, onAction }: P) {
  const id = useId();
  const when = useWhen(locale, now, t);
  const [list, setList] = useState(versions);
  const [picked, setPicked] = useState<string[]>([]);
  const [naming, setNaming] = useState('');
  const [draft, setDraft] = useState('');
  const [compare, setCompare] = useState<[string, string] | null>(null);
  const [scope, enter] = useEnter<HTMLDivElement>();
  const toggle = (vid: string) => setPicked((p) => (p.includes(vid) ? p.filter((x) => x !== vid) : [...p.slice(-1), vid]));
  const saveName = (vid: string) => {
    const n = draft.trim();
    if (!n) return setNaming('');
    setList((l) => l.map((x) => (x.id === vid ? { ...x, name: n } : x)));
    setNaming('');
    void run(onAction, 'name', { id: vid, name: n });
  };
  const [ca, cb] = compare ? [list.find((x) => x.id === compare[0])!, list.find((x) => x.id === compare[1])!] : [];
  const ordered = ca && cb && list.indexOf(ca) < list.indexOf(cb) ? [cb, ca] : [ca, cb];
  return (
    <Main className="pb-28">
      <Header title={title} subtitle={subtitle} />
      <p className="mt-2 max-w-[44rem] text-pretty text-[13.5px] text-muted-foreground">{t.nameHint}</p>
      <div ref={scope} {...enter} className="mt-6">
        <ol className="relative grid gap-3 ps-6 sm:ps-8">
          <span aria-hidden className="absolute bottom-4 start-[7px] top-4 w-px bg-border sm:start-[11px]" />
          {list.map((v, i) => {
            const prev = list[i + 1];
            const st = prev ? stats(prev.content, v.content) : null;
            const on = picked.includes(v.id);
            return (
              <li key={v.id} className="kitm-up relative" style={{ '--kitm-d': `${i * 60}ms` } as React.CSSProperties}>
                <span aria-hidden className={cn('absolute -start-6 top-5 grid size-[15px] place-items-center rounded-full border-2 border-background sm:-start-8 sm:size-[23px]', v.name ? 'bg-primary' : 'bg-border-strong')}>
                  {v.name && <Icon name="bookmark" size={11} className="hidden text-primary-foreground sm:block" />}
                </span>
                <div className={cn('flex items-start gap-3 rounded-xl border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow] duration-300 sm:items-center sm:gap-4 sm:p-5', on ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] shadow-[var(--highlight),0_0_0_3px_color-mix(in_oklab,var(--primary)_12%,transparent)]' : 'border-border')}>
                  <label className="-m-3.5 flex shrink-0 cursor-pointer items-center p-3.5 sm:m-0 sm:p-0" title={fill(t.pick, { name: v.name ?? when.full(v.time) })}>
                    <input type="checkbox" checked={on} onChange={() => toggle(v.id)} aria-label={fill(t.pick, { name: v.name ?? when.full(v.time) })} className="size-[18px] accent-[var(--primary)]" />
                  </label>
                  <div className="min-w-0 flex-1">
                    {naming === v.id ? (
                      <form
                        onSubmit={(e) => {
                          e.preventDefault();
                          saveName(v.id);
                        }}
                        className="flex flex-col gap-2 sm:flex-row"
                      >
                        <label htmlFor={`${id}-n`} className="sr-only">
                          {t.nameIt}
                        </label>
                        <input id={`${id}-n`} autoFocus value={draft} placeholder={t.namePlaceholder} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => e.key === 'Escape' && setNaming('')} className="h-11 min-w-0 flex-1 rounded-lg border border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] bg-background px-3 text-base text-foreground shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] outline-none sm:h-9 sm:text-[14px]" />
                        <Button type="submit" size="sm">
                          {t.save}
                        </Button>
                      </form>
                    ) : (
                      <p className="flex flex-wrap items-center gap-2">
                        <span className={cn('text-[15px] font-semibold', v.name ? 'text-foreground' : 'text-muted-foreground')}>{v.name ?? when.full(v.time)}</span>
                        {i === 0 && <span className="rounded bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] px-1.5 py-px text-[11px] font-semibold text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">{t.current}</span>}
                      </p>
                    )}
                    <p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12.5px] text-muted-foreground">
                      <span className="inline-flex items-center gap-1.5">
                        <Initials name={v.author} index={i} className="size-5 text-[8.5px]" />
                        {v.auto ? t.auto : v.author}
                      </span>
                      {v.name && <span>{when.full(v.time)}</span>}
                      {st && (
                        <span className="tabular-nums">
                          <span className="text-success-foreground">+{st.add}</span> <span className="text-destructive">−{st.del}</span>
                        </span>
                      )}
                    </p>
                  </div>
                  {naming !== v.id && (
                    <button
                      type="button"
                      aria-label={v.name ? fill(t.rename, { name: v.name }) : t.nameIt}
                      onClick={() => {
                        setDraft(v.name ?? '');
                        setNaming(v.id);
                      }}
                      className="-me-2 -mt-1 inline-flex h-11 min-w-11 shrink-0 items-center justify-center gap-1.5 rounded-md px-3 text-[13px] font-medium text-foreground transition-colors hover:bg-accent sm:m-0 sm:h-9"
                    >
                      <Icon name={v.name ? 'edit' : 'bookmark'} size={15} />
                      {!v.name && <span className="max-sm:sr-only">{t.nameIt}</span>}
                    </button>
                  )}
                </div>
              </li>
            );
          })}
        </ol>
      </div>
      {/* The compare bar: grows with the picks */}
      <div className="fixed inset-x-0 bottom-0 z-30 px-3 pb-[calc(env(safe-area-inset-bottom)+12px)] sm:px-6">
        <div className="mx-auto flex max-w-[40rem] items-center gap-3 rounded-xl border border-border bg-[color-mix(in_oklab,var(--popover)_92%,transparent)] p-2.5 ps-4 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-xl">
          <span aria-hidden className="flex shrink-0 gap-1">
            {[0, 1].map((k) => (
              <span key={k} className={cn('size-2.5 rounded-full transition-colors duration-300', picked[k] ? 'bg-primary' : 'bg-border-strong')} />
            ))}
          </span>
          <p key={picked.length} className="kitm-in min-w-0 flex-1 truncate text-[13.5px] text-foreground">
            {picked.length === 0 ? t.pickTwo : picked.length === 1 ? t.pickedOne : t.compareTwo}
          </p>
          <Button size="sm" disabled={picked.length < 2} onClick={() => setCompare([picked[0], picked[1]])} iconLeft="columns">
            {t.compare}
          </Button>
        </div>
      </div>
      <Dialog open={Boolean(compare)} onOpenChange={(v) => !v && setCompare(null)} size="xl" title={ordered[0] && ordered[1] ? `${ordered[0].name ?? when.full(ordered[0].time)} → ${ordered[1].name ?? when.full(ordered[1].time)}` : t.compare}>
        <div className="max-h-[min(65dvh,36rem)] overflow-y-auto pb-6">
          {ordered[0] && ordered[1] && (
            <div className="grid gap-3 text-[14.5px] leading-[1.75]">
              {rows(ordered[0].content, ordered[1].content).map((r, i) => (
                <p key={i} className={cn('rounded-md px-2', r.kind === 'add' && 'border-s-2 border-success bg-[color-mix(in_oklab,var(--success)_8%,transparent)]', r.kind === 'del' && 'border-s-2 border-destructive bg-[color-mix(in_oklab,var(--destructive)_7%,transparent)] text-muted-foreground line-through', i === 0 && 'text-[18px] font-semibold')}>
                  {r.kind === 'change' ? <Marked parts={r.parts!} /> : r.kind === 'del' ? r.a : r.b}
                </p>
              ))}
            </div>
          )}
        </div>
      </Dialog>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* restore                                                             */
/* ------------------------------------------------------------------ */

function Restore({ t, title, subtitle, versions, defaultVersion, now, locale, onAction }: P) {
  const id = useId();
  const when = useWhen(locale, now, t);
  const target = versions.find((v) => v.id === defaultVersion) ?? versions[versions.length - 1];
  const current = versions[0];
  const [how, setHow] = useState<'new' | 'copy'>('new');
  const [state, setState] = useState<'idle' | 'busy' | 'done'>('idle');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const rs = rows(current.content, target.content);
  const changed = rs.filter((r) => r.kind !== 'same');
  const st = stats(current.content, target.content);
  const go = async () => {
    setState('busy');
    await run(onAction, how === 'new' ? 'restore' : 'restore-copy', { id: target.id });
    setState('done');
  };
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_24rem] lg:items-start">
        <section className="kitm-up min-w-0 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
          <div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
            <h2 className="text-[14px] font-semibold text-foreground">{t.whatChanges}</h2>
            <p className="text-[12.5px] tabular-nums text-muted-foreground">
              {fill(t.paragraphsChanged, { n: changed.length })} · <span className="text-success-foreground">+{st.add}</span> <span className="text-destructive">−{st.del}</span>
            </p>
          </div>
          <ol className="divide-y divide-border">
            {changed.map((r, i) => (
              <li key={i} className="kitm-up grid gap-2 px-4 py-4 sm:px-5" style={{ '--kitm-d': `${i * 60}ms` } as React.CSSProperties}>
                <p className="flex items-center gap-2 text-[11.5px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">
                  <span aria-hidden className={cn('size-2 rounded-sm', r.kind === 'add' ? 'bg-success' : r.kind === 'del' ? 'bg-destructive' : 'bg-primary')} />
                  {r.kind === 'add' ? t.added : r.kind === 'del' ? t.removed : t.changedLabel}
                </p>
                <p className="text-pretty text-[14px] leading-[1.7] text-foreground/90">{r.kind === 'change' ? <Marked parts={r.parts!} /> : r.kind === 'del' ? <del className="text-muted-foreground decoration-[color-mix(in_oklab,var(--destructive)_55%,transparent)]">{r.a}</del> : <ins className="bg-[color-mix(in_oklab,var(--success)_16%,transparent)] no-underline">{r.b}</ins>}</p>
              </li>
            ))}
          </ol>
        </section>
        <aside className="kitm-up [--kitm-d:120ms] rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] lg:sticky lg:top-6">
          {state === 'done' ? (
            <div className="kitm-in px-5 py-8 text-center">
              <span className="mx-auto grid size-12 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_15%,transparent)] text-success-foreground">
                <Icon name="check" size={22} strokeWidth={2.4} className="kitm-pop" />
              </span>
              <p className="mt-4 text-pretty text-[14px] text-foreground">{how === 'new' ? fill(t.restored, { when: when.full(target.time) }) : fill(t.copyMade, { name: target.content[0] ?? '' })}</p>
              <Button variant="outline" size="sm" className="mt-5" onClick={() => setState('idle')}>
                {t.undo}
              </Button>
            </div>
          ) : (
            <>
              <div className="border-b border-border p-4 sm:p-5">
                <p className="flex items-center gap-3">
                  <Initials name={target.author} className="size-9 text-[11.5px]" />
                  <span className="min-w-0">
                    <span className="block truncate text-[14px] font-semibold text-foreground">{target.name ? fill(t.restoreNamed, { name: target.name }) : fill(t.restoreTitle, { when: when.full(target.time) })}</span>
                    <span className="block truncate text-[12.5px] text-muted-foreground">{fill(t.by, { name: target.author })}</span>
                  </span>
                </p>
                <p className="mt-3 text-pretty text-[13px] leading-snug text-muted-foreground">{t.restoreText}</p>
              </div>
              <fieldset className="grid gap-2 p-4 sm:p-5">
                <legend className="sr-only">{t.restore}</legend>
                {(['new', 'copy'] as const).map((k) => (
                  <label key={k} className={cn('relative flex cursor-pointer items-start gap-3 rounded-lg border p-3.5 transition-[border-color,background-color] duration-200', how === k ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]' : 'border-border hover:border-border-strong')}>
                    <input type="radio" name={`${id}-how`} checked={how === k} onChange={() => setHow(k)} className="peer sr-only" />
                    <span aria-hidden className={cn('mt-0.5 grid size-[18px] shrink-0 place-items-center rounded-full border transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)]', how === k ? 'border-primary bg-primary' : 'border-border-strong')}>
                      <span className={cn('size-[7px] rounded-full bg-primary-foreground transition-transform duration-300', how === k ? 'scale-100' : 'scale-0')} />
                    </span>
                    <span className="min-w-0">
                      <span className="block text-[13.5px] font-medium text-foreground">{k === 'new' ? t.asNew : t.asCopy}</span>
                      <span className="mt-0.5 block text-pretty text-[12.5px] leading-snug text-muted-foreground">{k === 'new' ? t.asNewText : t.asCopyText}</span>
                    </span>
                  </label>
                ))}
                <Button className="mt-2 w-full" onClick={go} disabled={state === 'busy'} iconLeft={state === 'busy' ? 'loader' : 'rotate-ccw'}>
                  {state === 'busy' ? t.restoring : how === 'new' ? t.asNew : t.asCopy}
                </Button>
              </fieldset>
            </>
          )}
        </aside>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* VersionHistory                                                      */
/* ------------------------------------------------------------------ */

/**
 * Version history for documents and pages: a version list with a marked
 * preview and restore, two versions side by side, changes inline with a way
 * through them, named versions to compare and a careful restore.
 *   <VersionHistory variant="timeline" title="History" versions={[{ id, author, time, content }]} />
 */
export function VersionHistory({ variant = 'timeline', sidebar, labels, className, now = '2026-10-19T14:30', versions = [], ...props }: VersionHistoryProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const p: P = { ...props, t, now, locale, versions };
  const body = (() => {
    switch (variant) {
      case 'side-by-side':
        return <SideBySide {...p} />;
      case 'inline-diff':
        return <InlineDiff {...p} />;
      case 'named-versions':
        return <NamedVersions {...p} />;
      case 'restore':
        return <Restore {...p} />;
      default:
        return <Timeline {...p} />;
    }
  })();
  if (!sidebar)
    return (
      <div className={cn('min-h-dvh bg-background', className)}>
        <KitmStyle />
        {body}
      </div>
    );
  return (
    <Sidebar mobile="drawer" {...sidebar} className={className}>
      <KitmStyle />
      {body}
    </Sidebar>
  );
}

Frequent questions

How do I install Version history?

With aiw add @ai-website/version-history. The command puts components/ui/version-history.tsx into your project, the code is yours from then on. It brings along button, icon, dialog and sidebar. It needs no further npm packages.

Which variants does Version history have?

5 variants, Version timeline, Side by side, Inline changes, Named versions and Restore. You pick one with the variant prop, for example variant="side-by-side".

Can I translate the texts of Version history?

Yes. Version history carries 55 texts of its own in English and German, such as “Versions”, “Current version” and “Named”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Version history?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Version history with aiw_components and install it with aiw_add @ai-website/version-history. Name the variant in your prompt, for example variant="timeline", and the assistant writes your content straight into the props.

Can I adjust Version history without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching VersionHistory code to copy.

Often searched

Jump to

Copied to the clipboard