# Diff Table

> AI-proposed table edits you include or skip, then apply.

- **Slug:** `diff-table`
- **Kind:** component · **Category:** Data
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/primitives/DiffTable.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/diff-table
- **All files in one bundle:** https://viewership.ai/ui/components/diff-table.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/diff-table.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/ui/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy this file into `src/components/viewership/diff-table/`:

   - `DiffTable.astro` (raw: https://viewership.ai/ui/raw/diff-table/DiffTable.astro.txt)

3. Copy its registry dependencies, each into its own sibling folder under `src/components/viewership/`:

   - `button`: `Button.astro` (docs: https://viewership.ai/ui/components/button.md)

npm packages: none.

## Usage

```astro
---
import DiffTable from "../components/viewership/diff-table/DiffTable.astro";
---

<DiffTable />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import DiffTable from "./DiffTable.astro";
import type { DiffColumn, DiffRow } from "./DiffTable.astro";

const columns: DiffColumn[] = [
  { key: "asset", label: "Asset", kind: "title" },
  { key: "channel", label: "Channel", kind: "tag" },
  { key: "owner", label: "Owner" },
];

const rows: DiffRow[] = [
  { key: "faq", cells: { asset: "Old FAQ Post", channel: "Blog", owner: "dana-w" }, change: "remove" },
  { key: "recap", cells: { asset: "Recap Thread", channel: "Social", owner: "marcus-t" }, change: "remove" },
  { key: "trail", cells: { asset: "Trail Guide", channel: "Blog", owner: "priya-s" } },
  { key: "care", cells: { asset: "Gear Care Email", channel: "Email", owner: "dana-w" }, change: "add" },
];
---

<div class="stage">
  <DiffTable
    columns={columns}
    rows={rows}
    tagTones={{ Blog: "accent", Social: "neutral", Email: "green" }}
    labels={{ title: "Proposed content cleanup" }}
  />
</div>

<style>
  .stage {
    display: flex;
    width: 100%;
    justify-content: center;
  }
</style>
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `columns` | `{ key: string; label: string; width?: string; kind?: "title" \| "tag" \| "text" }[]` |  | Column order and headers. The first column names the row. |
| `rows` | `{ key: string; cells: Record<string, string>; change?: "remove" \| "add" }[]` |  | Table data. Rows with `change` are proposed edits. |
| `tagTones` | `Record<string, "accent" \| "neutral" \| "green" \| "orange" \| "red">` | `{}` | Dot color per tag value. |
| `labels` | `Partial<DiffTableLabels>` |  | Text overrides: title, hint, removal(s), addition(s), apply, applied, includeRemove, includeAdd, removedRow, addedRow. |
| `autoplay` | `boolean` | `true` | Play when scrolled into view. Reduced motion and no-JS show the final diff. |
| `loop` | `boolean` | `false` | Replay after a 4s hold until interaction. |
| `vs-diff-toggle` | `CustomEvent<{ key: string; included: boolean }>` |  | Fires when a change is included or skipped. |
| `vs-diff-apply` | `CustomEvent<{ removed: string[]; added: string[]; skipped: string[] }>` |  | Fires on Apply with row keys by kind. The table only shows a confirmation. |

## Source

### `src/components/viewership/diff-table/DiffTable.astro`

```astro
---
/**
 * DiffTable: AI-proposed edits sweeping through tabular data.
 *
 * Ported from Beautiful UI `components/primitives/DiffTable.tsx`
 * (MIT, © 2026 Shane Levine).
 *
 * The proposed edit plays once when the table scrolls into view and rests on
 * the completed diff: removals turn red and strike through, then added rows
 * grow in with include checkboxes and a summary footer. Clicking a changed
 * row toggles it. "Apply" reports the included edits; the table itself
 * changes nothing. Without JS or with reduced motion, the final diff renders.
 *
 * Events (bubbling, on the root):
 *   vs-diff-toggle  detail: { key, included }
 *   vs-diff-apply   detail: { removed: string[], added: string[], skipped: string[] }
 *
 * @example
 * <DiffTable columns={[{ key: "asset", label: "Asset", kind: "title" }]} rows={[{ key: "a", cells: { asset: "Gear Guide" }, change: "add" }]} />
 */
import type { HTMLAttributes } from "astro/types";
import Button from "../button/Button.astro";

type Tone = "accent" | "neutral" | "green" | "orange" | "red";

export interface DiffColumn {
  key: string;
  label: string;
  /** CSS width for the column, e.g. "34%". */
  width?: string;
  /** "title" = strong first column, "tag" = dot pill, "text" = muted text (default). */
  kind?: "title" | "tag" | "text";
}

export interface DiffRow {
  key: string;
  cells: Record<string, string>;
  /** The proposed edit for this row, if any. */
  change?: "remove" | "add";
}

export interface DiffTableLabels {
  title: string;
  hint: string;
  removal: string;
  removals: string;
  addition: string;
  additions: string;
  /** {n} is replaced. */
  apply: string;
  applyPlural: string;
  applied: string;
  appliedPlural: string;
  /** Checkbox names; {name} is the row's first cell. */
  includeRemove: string;
  includeAdd: string;
  /** Screen-reader suffix on changed rows. */
  removedRow: string;
  addedRow: string;
}

type Props = HTMLAttributes<"div"> & {
  columns: DiffColumn[];
  rows: DiffRow[];
  /** Dot color per tag value (for "tag" columns). */
  tagTones?: Record<string, Tone>;
  labels?: Partial<DiffTableLabels>;
  /** Play the sweep when scrolled into view. */
  autoplay?: boolean;
  /** Replay after a hold, until someone interacts. */
  loop?: boolean;
};

const { columns = [], rows = [], tagTones = {}, labels, autoplay = true, loop = false, class: className, ...rest } = Astro.props;

const l: DiffTableLabels = {
  title: "Proposed changes",
  hint: "Click changed rows to toggle",
  removal: "removal",
  removals: "removals",
  addition: "addition",
  additions: "additions",
  apply: "Apply {n} change",
  applyPlural: "Apply {n} changes",
  applied: "{n} edit applied",
  appliedPlural: "{n} edits applied",
  includeRemove: "Include: remove {name}",
  includeAdd: "Include: add {name}",
  removedRow: "proposed removal",
  addedRow: "proposed addition",
  ...labels,
};

const uid = `vs-diff-${Math.random().toString(36).slice(2, 8)}`;
const TONE: Record<Tone, string> = {
  accent: "var(--vs-accent)",
  neutral: "var(--vs-ink-3)",
  green: "var(--vs-green)",
  orange: "var(--vs-orange)",
  red: "var(--vs-red)",
};
const DEFAULT_WIDTHS = ["34%", "30%", "36%"];
const removals = rows.filter((r) => r.change === "remove").length;
const additions = rows.filter((r) => r.change === "add").length;
const total = removals + additions;
const plural = (n: number, one: string, many: string) => (n === 1 ? one : many).replace("{n}", String(n));
const summary = `${removals} ${removals === 1 ? l.removal : l.removals} · ${additions} ${additions === 1 ? l.addition : l.additions}`;
const lastCol = columns.length - 1;
const checkPath = '<path d="M20 6L9 17l-5-5" />';
---

<div
  class:list={["vs-diff-table", className]}
  data-tinted
  data-settled
  data-autoplay={autoplay ? "" : undefined}
  data-loop={loop ? "" : undefined}
  data-labels={JSON.stringify({
    removal: l.removal,
    removals: l.removals,
    addition: l.addition,
    additions: l.additions,
    apply: l.apply,
    applyPlural: l.applyPlural,
    applied: l.applied,
    appliedPlural: l.appliedPlural,
  })}
  {...rest}
>
  <div class="card">
    <div class="bar">
      <span class="title" id={`${uid}-title`}>{l.title}</span>
      <span class="hint">{l.hint}</span>
    </div>

    <table class="dt-table" aria-labelledby={`${uid}-title`}>
      <colgroup>
        {columns.map((c, i) => <col style={`width: ${c.width ?? DEFAULT_WIDTHS[i] ?? "auto"}`} />)}
      </colgroup>
      <thead>
        <tr>
          {columns.map((c) => <th scope="col" class="th">{c.label}</th>)}
        </tr>
      </thead>
      <tbody>
        {rows.map((row) => {
          const name = row.cells[columns[0]?.key ?? ""] ?? row.key;
          return (
            <tr class="row" data-row={row.key} data-change={row.change}>
              {columns.map((c, ci) => {
                const value = row.cells[c.key] ?? "";
                const kind = c.kind ?? (ci === 0 ? "title" : "text");
                const content = (
                  <div class="pad">
                    {kind === "tag" ? (
                      <span class="tag">
                        <span class="tag-dot" style={`background: ${TONE[tagTones[value] ?? "neutral"]}`}></span>
                        <span class="tag-label">{value}</span>
                      </span>
                    ) : ci === lastCol && row.change ? (
                      <span class="end">
                        <span class="truncate">{value}</span>
                        <label class="mark-wrap">
                          <input
                            class="native"
                            type="checkbox"
                            checked
                            aria-label={(row.change === "remove" ? l.includeRemove : l.includeAdd).replace("{name}", name)}
                            data-include={row.key}
                          />
                          <span class:list={["mark", `mark--${row.change}`]} aria-hidden="true">
                            <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" set:html={checkPath} />
                          </span>
                        </label>
                      </span>
                    ) : (
                      <span class="truncate">
                        {value}
                        {ci === 0 && row.change && <span class="vh">, {row.change === "remove" ? l.removedRow : l.addedRow}</span>}
                      </span>
                    )}
                  </div>
                );
                return (
                  <td class:list={["cell", `cell--${kind}`, ci === lastCol && "cell--last"]}>
                    {row.change === "add" ? (
                      <div class="dt-grow">
                        <div class="grow-clip">{content}</div>
                      </div>
                    ) : (
                      content
                    )}
                  </td>
                );
              })}
            </tr>
          );
        })}
      </tbody>
    </table>

    <div class="footer">
      <div class="pending" data-pending>
        <span class="summary" aria-live="polite" data-summary>{summary}</span>
        <Button variant="accent" size="sm" class="apply" disabled={total === 0} data-apply>
          {plural(total, l.apply, l.applyPlural)}
        </Button>
      </div>
      <span class="applied" role="status" tabindex="-1" hidden data-applied>
        <span class="applied-check" aria-hidden="true">
          <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" set:html={checkPath} />
        </span>
        <span data-applied-text></span>
      </span>
    </div>
  </div>
</div>

<script>
  const STAGE_DELAYS = [180, 260];
  const LOOP_HOLD = 4000;

  interface Labels {
    removal: string;
    removals: string;
    addition: string;
    additions: string;
    apply: string;
    applyPlural: string;
    applied: string;
    appliedPlural: string;
  }

  function init(root: HTMLElement) {
    const l = JSON.parse(root.dataset.labels || "{}") as Labels;
    const rows = [...root.querySelectorAll<HTMLTableRowElement>("tr[data-change]")];
    const boxes = [...root.querySelectorAll<HTMLInputElement>("[data-include]")];
    const summary = root.querySelector<HTMLElement>("[data-summary]")!;
    const apply = root.querySelector<HTMLButtonElement>("[data-apply]")!;
    const pending = root.querySelector<HTMLElement>("[data-pending]")!;
    const applied = root.querySelector<HTMLElement>("[data-applied]")!;
    const appliedText = root.querySelector<HTMLElement>("[data-applied-text]")!;
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const loop = root.hasAttribute("data-loop");

    let stage = 2;
    let accepted = false;
    let touched = false;
    let visible = false;
    let timer: number | undefined;

    const plural = (n: number, one: string, many: string) => (n === 1 ? one : many).replace("{n}", String(n));
    const changeOf = (box: HTMLInputElement) => box.closest<HTMLElement>("tr")?.dataset.change;
    const counts = () => {
      const on = boxes.filter((b) => b.checked);
      return {
        removals: on.filter((b) => changeOf(b) === "remove").length,
        additions: on.filter((b) => changeOf(b) === "add").length,
      };
    };

    const renderSummary = () => {
      const { removals, additions } = counts();
      const total = removals + additions;
      summary.textContent = `${removals} ${removals === 1 ? l.removal : l.removals} · ${additions} ${additions === 1 ? l.addition : l.additions}`;
      apply.textContent = plural(total, l.apply, l.applyPlural);
      apply.disabled = total === 0;
    };

    const setStage = (next: number) => {
      stage = next;
      root.toggleAttribute("data-tinted", stage >= 1);
      root.toggleAttribute("data-settled", stage >= 2);
      // Changed rows are only operable once the diff has settled.
      boxes.forEach((b) => (b.disabled = stage < 2 || accepted));
    };

    const toggle = (box: HTMLInputElement) => {
      touched = true;
      renderSummary();
      root.dispatchEvent(
        new CustomEvent("vs-diff-toggle", { bubbles: true, detail: { key: box.dataset.include, included: box.checked } }),
      );
    };

    boxes.forEach((box) => box.addEventListener("change", () => toggle(box)));

    // The whole changed row is a pointer target for its checkbox.
    rows.forEach((row) => {
      const box = row.querySelector<HTMLInputElement>("[data-include]");
      if (!box) return;
      row.addEventListener("click", (event) => {
        if (stage < 2 || accepted || (event.target as Element).closest("label, input")) return;
        box.checked = !box.checked;
        toggle(box);
      });
    });

    apply.addEventListener("click", () => {
      if (accepted) return;
      accepted = true;
      touched = true;
      window.clearTimeout(timer);
      const { removals, additions } = counts();
      appliedText.textContent = plural(removals + additions, l.applied, l.appliedPlural);
      pending.hidden = true;
      applied.hidden = false;
      root.setAttribute("data-accepted", "");
      setStage(2);
      applied.focus();
      const key = (b: HTMLInputElement) => b.dataset.include ?? "";
      root.dispatchEvent(
        new CustomEvent("vs-diff-apply", {
          bubbles: true,
          detail: {
            removed: boxes.filter((b) => b.checked && changeOf(b) === "remove").map(key),
            added: boxes.filter((b) => b.checked && changeOf(b) === "add").map(key),
            skipped: boxes.filter((b) => !b.checked).map(key),
          },
        }),
      );
    });

    const scheduleLoop = () => {
      window.clearTimeout(timer);
      if (!loop || touched || accepted || !visible || stage < 2) return;
      timer = window.setTimeout(() => {
        if (root.matches(":hover") || root.contains(document.activeElement)) return scheduleLoop();
        play();
      }, LOOP_HOLD);
    };

    const play = () => {
      window.clearTimeout(timer);
      boxes.forEach((b) => (b.checked = true));
      renderSummary();
      setStage(0);
      let i = 0;
      const step = () => {
        setStage(stage + 1);
        i += 1;
        if (i < STAGE_DELAYS.length) timer = window.setTimeout(step, STAGE_DELAYS[i]);
        else scheduleLoop();
      };
      timer = window.setTimeout(step, STAGE_DELAYS[0]);
    };

    renderSummary();
    setStage(2);
    if (reduce || !root.hasAttribute("data-autoplay")) return;

    // Hold the plain table until it is seen, then sweep the edit through it.
    setStage(0);
    let started = false;
    const io = new IntersectionObserver(
      ([entry]) => {
        if (!root.isConnected) return teardown();
        visible = Boolean(entry?.isIntersecting);
        if (!visible) {
          if (stage >= 2) window.clearTimeout(timer);
          return;
        }
        if (!started) {
          started = true;
          play();
        } else scheduleLoop();
      },
      { threshold: 0.3 },
    );
    io.observe(root);

    function teardown() {
      window.clearTimeout(timer);
      io.disconnect();
    }
    document.addEventListener("astro:before-swap", teardown, { once: true });
  }

  const boot = () =>
    document.querySelectorAll<HTMLElement>(".vs-diff-table:not([data-vs-ready])").forEach((root) => {
      root.dataset.vsReady = "";
      init(root);
    });
  boot();
  document.addEventListener("astro:page-load", boot);
</script>

<style>
  .vs-diff-table {
    width: 100%;
    max-width: 380px;
  }
  .vh {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    border: 0;
  }
  .card {
    position: relative;
    overflow: hidden;
    border-radius: var(--vs-radius-card);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-card);
  }
  .bar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    padding: 10px 12px;
    border-bottom: 1px solid var(--vs-line);
  }
  .title {
    font-size: 12.5px;
    font-weight: 500;
    color: var(--vs-ink);
  }
  .hint {
    font-size: 11px;
    color: var(--vs-ink-3);
    visibility: hidden;
  }
  [data-settled]:not([data-accepted]) .hint {
    visibility: visible;
  }

  /* ── table ── */
  .dt-table {
    width: 100%;
    table-layout: fixed;
    border-collapse: collapse;
    text-align: left;
  }
  .th {
    padding: 10px 12px;
    border-bottom: 1px solid var(--vs-line);
    font-size: 12px;
    font-weight: 500;
    color: var(--vs-ink-3);
  }
  .row {
    border-bottom: 1px solid var(--vs-line);
    transition:
      background-color 150ms ease-out,
      filter 150ms ease-out;
  }
  .row:last-child {
    border-bottom: 0;
  }
  .cell {
    padding: 0;
    vertical-align: middle;
  }
  .pad {
    padding: 10px 12px;
  }
  .truncate {
    display: block;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .cell--title {
    font-size: 13px;
    font-weight: 500;
    font-variant-numeric: tabular-nums;
    color: var(--vs-ink);
    transition: color 200ms ease-out;
  }
  .cell--text {
    font-size: 12.5px;
    color: var(--vs-ink-2);
    transition: color 200ms ease-out;
  }
  .tag {
    display: inline-flex;
    height: 22px;
    align-items: center;
    gap: 6px;
    padding: 0 8px;
    border-radius: var(--vs-radius-pill);
    background: var(--vs-inset);
    box-shadow: var(--vs-shadow-hairline);
    font-size: 11.5px;
    font-weight: 500;
    transition: opacity 200ms ease-out;
  }
  .tag-dot {
    width: 6px;
    height: 6px;
    flex-shrink: 0;
    border-radius: 50%;
  }
  .tag-label {
    color: var(--vs-ink-2);
    white-space: nowrap;
  }
  .end {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
  }

  /* removals: red tint + strike, once the sweep reaches them */
  [data-tinted] .row[data-change="remove"]:has(.native:checked) {
    background: var(--vs-red-tint);
  }
  [data-tinted] .row[data-change="remove"]:has(.native:checked) :is(.cell--title, .cell--text) {
    color: var(--vs-red);
  }
  [data-tinted] .row[data-change="remove"]:has(.native:checked) .tag {
    opacity: 0.55;
  }
  [data-tinted] .row[data-change="remove"]:has(.native:checked) .cell--last .truncate {
    text-decoration-line: line-through;
    text-decoration-color: color-mix(in srgb, var(--vs-red) 50%, transparent);
  }

  /* additions: grow in once settled; green while included */
  .row[data-change="add"] .tag {
    background: var(--vs-surface);
  }
  .row[data-change="add"] :is(.cell--title, .cell--text) {
    color: var(--vs-ink-3);
  }
  .row[data-change="add"]:has(.native:checked) {
    background: var(--vs-green-tint);
  }
  .row[data-change="add"]:has(.native:checked) :is(.cell--title, .cell--text) {
    color: var(--vs-green);
  }
  .dt-grow {
    display: grid;
    grid-template-rows: 0fr;
    opacity: 0;
    visibility: hidden;
    transition:
      grid-template-rows 200ms var(--vs-ease-out),
      opacity 200ms var(--vs-ease-out),
      visibility 0s 200ms;
  }
  [data-settled] .dt-grow {
    grid-template-rows: 1fr;
    opacity: 1;
    visibility: visible;
    transition:
      grid-template-rows 200ms var(--vs-ease-out),
      opacity 200ms var(--vs-ease-out);
  }
  .grow-clip {
    min-height: 0;
    overflow: hidden;
  }

  /* changed rows are the control once the diff settles */
  [data-settled]:not([data-accepted]) .row[data-change] {
    cursor: pointer;
  }
  [data-settled]:not([data-accepted]) .row[data-change]:hover {
    filter: brightness(0.985);
  }

  /* ── include mark (a native checkbox) ── */
  .mark-wrap {
    position: relative;
    display: flex;
    width: 24px;
    height: 24px;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    margin: -3px;
    cursor: pointer;
  }
  .row[data-change="remove"] .mark-wrap {
    visibility: hidden;
  }
  [data-settled] .row[data-change="remove"] .mark-wrap {
    visibility: visible;
  }
  .native {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    margin: 0;
    opacity: 0;
    cursor: inherit;
  }
  .native:disabled {
    cursor: default;
  }
  .mark {
    display: flex;
    width: 18px;
    height: 18px;
    align-items: center;
    justify-content: center;
    border-radius: 5px;
    background: var(--vs-inset);
    box-shadow: var(--vs-shadow-hairline);
    color: transparent;
    transform: scale(0.92);
    transition:
      background-color 150ms ease-out,
      color 150ms ease-out,
      transform 150ms ease-out;
  }
  .native:checked + .mark {
    color: var(--vs-surface);
    box-shadow: none;
    transform: scale(1);
  }
  .native:checked + .mark--remove {
    background: var(--vs-red);
  }
  .native:checked + .mark--add {
    background: var(--vs-green);
  }
  .native:focus-visible + .mark {
    box-shadow: var(--vs-focus-ring);
  }

  /* ── footer ── */
  .footer {
    display: none;
    min-height: 44px;
    align-items: center;
    padding: 10px;
    border-top: 1px solid var(--vs-line);
    animation: vs-fade-up 180ms var(--vs-ease-out) both;
  }
  [data-settled] .footer {
    display: flex;
  }
  .pending {
    display: flex;
    width: 100%;
    align-items: center;
    justify-content: space-between;
  }
  .pending[hidden] {
    display: none;
  }
  .summary {
    font-size: 11.5px;
    font-variant-numeric: tabular-nums;
    color: var(--vs-ink-3);
  }
  .pending :global(.apply) {
    font-size: 12px;
  }
  .applied {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 4px 10px 4px 4px;
    border-radius: var(--vs-radius-pill);
    background: var(--vs-green-tint);
    color: var(--vs-green);
    font-size: 12.5px;
    font-weight: 500;
    outline: none;
    animation: vs-pop-in 180ms var(--vs-ease-out) both;
  }
  .applied[hidden] {
    display: none;
  }
  .applied-check {
    display: flex;
    width: 18px;
    height: 18px;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    background: var(--vs-green);
    color: var(--vs-surface);
  }

  @media (prefers-reduced-motion: reduce) {
    .row,
    .cell--title,
    .cell--text,
    .tag,
    .dt-grow,
    [data-settled] .dt-grow,
    .mark {
      transition: none;
    }
    .footer,
    .applied {
      animation: none;
    }
  }
  @media (forced-colors: active) {
    .native:checked + .mark {
      background: Highlight;
      color: HighlightText;
    }
    [data-tinted] .row[data-change="remove"]:has(.native:checked) .cell--title {
      text-decoration-line: line-through;
    }
  }
</style>
```
