j3w1 / Diff viewVue · HTML · React · Astro

J3W1 / DESIGN SYSTEM

Diff view

Unified and split diffs as native tables; added, removed and modified rows keep their textual gutter marker, word-level changes are underlines, conflicts are double borders.

stable · LIGHT DOM · 1.1.0

Light DOM retains static content, native semantics, label relationships and host composition without duplicating controls.

Live variants

unified

title.js, old against new, unified
Old lineNew lineChangeText
@@ -3,7 +3,8 @@ export function title(container)
33 const name = container.name ?? "untitled";
4 removed let label = name;
4+ added let label = `${name} (${container.children.length})`;
55 if (label.length > MAX_LENGTH) {
66~ modified label = label.slice(0, MAX_LENGTH) + "…";
77 }

<j3w1-diff-view id="example-diff-view-unified"><div class="diff-view-scroll" tabindex="0" role="region" aria-label="Source differences"><table class="diff-view diff-view-unified">
  <caption class="sr-only">title.js, old against new, unified</caption>
  <thead class="sr-only">
    <tr><th scope="col">Old line</th><th scope="col">New line</th><th scope="col">Change</th><th scope="col">Text</th></tr>
  </thead>
  <tbody>
    <tr class="diff-view-row diff-view-row-hunk">
      <td class="diff-view-ln"></td><td class="diff-view-ln"></td><td class="diff-view-marker"> </td>
      <td class="diff-view-text"><span class="diff-view-range">@@ -3,7 +3,8 @@</span> export function title(container)</td>
    </tr>
    <tr class="diff-view-row diff-view-row-context">
      <td class="diff-view-ln">3</td><td class="diff-view-ln">3</td><td class="diff-view-marker"> </td>
      <td class="diff-view-text">  const name = container.name ?? "untitled";</td>
    </tr>
    <tr class="diff-view-row diff-view-row-removed">
      <td class="diff-view-ln">4</td><td class="diff-view-ln"></td><td class="diff-view-marker">−<span class="sr-only"> removed</span></td>
      <td class="diff-view-text">  let label = name;</td>
    </tr>
    <tr class="diff-view-row diff-view-row-added diff-view-row-active">
      <td class="diff-view-ln"></td><td class="diff-view-ln">4</td><td class="diff-view-marker">+<span class="sr-only"> added</span></td>
      <td class="diff-view-text">  let label = `${name} (${container.children.length})`;</td>
    </tr>
    <tr class="diff-view-row diff-view-row-context">
      <td class="diff-view-ln">5</td><td class="diff-view-ln">5</td><td class="diff-view-marker"> </td>
      <td class="diff-view-text">  if (label.length &gt; MAX_LENGTH) {</td>
    </tr>
    <tr class="diff-view-row diff-view-row-modified">
      <td class="diff-view-ln">6</td><td class="diff-view-ln">6</td><td class="diff-view-marker">~<span class="sr-only"> modified</span></td>
      <td class="diff-view-text">    label = label.slice(0, MAX_LENGTH) + "…";</td>
    </tr>
    <tr class="diff-view-row diff-view-row-context">
      <td class="diff-view-ln">7</td><td class="diff-view-ln">7</td><td class="diff-view-marker"> </td>
      <td class="diff-view-text">  }</td>
    </tr>
    <tr class="diff-view-row diff-view-row-fold">
      <td class="diff-view-fold-cell" colspan="4">
        <button class="diff-view-fold" type="button" aria-expanded="false" aria-controls="example-diff-view-unified-dv-u-fold">
          <svg class="diff-view-chevron" width="12" height="12" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M6 3l5 5-5 5" fill="none" stroke="currentColor" stroke-width="1.5"></path></svg>
          <span class="diff-view-fold-collapsed">… 12 unchanged lines</span><span class="diff-view-fold-expanded">Hide 12 unchanged lines</span>
        </button>
      </td>
    </tr>
  </tbody>
  <tbody class="diff-view-folded" id="example-diff-view-unified-dv-u-fold" hidden="">
    <tr class="diff-view-row diff-view-row-context">
      <td class="diff-view-ln">8</td><td class="diff-view-ln">8</td><td class="diff-view-marker"> </td>
      <td class="diff-view-text">  return escape(label);</td>
    </tr>
    <tr class="diff-view-row diff-view-row-context">
      <td class="diff-view-ln">9</td><td class="diff-view-ln">9</td><td class="diff-view-marker"> </td>
      <td class="diff-view-text">}</td>
    </tr>
    <tr class="diff-view-row diff-view-row-context">
      <td class="diff-view-ln">10</td><td class="diff-view-ln">10</td><td class="diff-view-marker"> </td>
      <td class="diff-view-text">… ten more context lines …</td>
    </tr>
  </tbody>
</table></div></j3w1-diff-view>

split

title.js, old on the left, new on the right
Old lineOld changeOld textNew lineNew changeNew text
@@ -3,7 +3,8 @@ export function title(container)
3 const name = container.name ?? "untitled"; 3 const name = container.name ?? "untitled";
4 removed let label = name; 4+ added let label = `${name} (${container.children.length})`;
5 if (label.length > MAX_LENGTH) { 5 if (label.length > MAX_LENGTH) {
6~ modified label = label.slice(0, MAX_LENGTH); 6~ modified label = label.slice(0, MAX_LENGTH) + "…";
7+ added label = label.trimEnd();

<j3w1-diff-view id="example-diff-view-split"><div class="diff-view-scroll" tabindex="0" role="region" aria-label="Source differences"><table class="diff-view diff-view-split">
  <caption class="sr-only">title.js, old on the left, new on the right</caption>
  <thead class="sr-only">
    <tr><th scope="col">Old line</th><th scope="col">Old change</th><th scope="col">Old text</th><th scope="col">New line</th><th scope="col">New change</th><th scope="col">New text</th></tr>
  </thead>
  <tbody>
    <tr class="diff-view-row diff-view-row-hunk">
      <td class="diff-view-fold-cell" colspan="6"><span class="diff-view-range">@@ -3,7 +3,8 @@</span> export function title(container)</td>
    </tr>
    <tr class="diff-view-row diff-view-row-context">
      <td class="diff-view-ln diff-view-side-old">3</td><td class="diff-view-marker diff-view-side-old"> </td><td class="diff-view-text diff-view-side-old">  const name = container.name ?? "untitled";</td>
      <td class="diff-view-ln diff-view-side-new">3</td><td class="diff-view-marker diff-view-side-new"> </td><td class="diff-view-text diff-view-side-new">  const name = container.name ?? "untitled";</td>
    </tr>
    <tr class="diff-view-row diff-view-row-pair diff-view-row-active">
      <td class="diff-view-ln diff-view-side-old diff-view-cell-removed">4</td><td class="diff-view-marker diff-view-side-old diff-view-cell-removed">−<span class="sr-only"> removed</span></td><td class="diff-view-text diff-view-side-old diff-view-cell-removed">  let label = name;</td>
      <td class="diff-view-ln diff-view-side-new diff-view-cell-added">4</td><td class="diff-view-marker diff-view-side-new diff-view-cell-added">+<span class="sr-only"> added</span></td><td class="diff-view-text diff-view-side-new diff-view-cell-added">  let label = `${name} (${container.children.length})`;</td>
    </tr>
    <tr class="diff-view-row diff-view-row-context">
      <td class="diff-view-ln diff-view-side-old">5</td><td class="diff-view-marker diff-view-side-old"> </td><td class="diff-view-text diff-view-side-old">  if (label.length &gt; MAX_LENGTH) {</td>
      <td class="diff-view-ln diff-view-side-new">5</td><td class="diff-view-marker diff-view-side-new"> </td><td class="diff-view-text diff-view-side-new">  if (label.length &gt; MAX_LENGTH) {</td>
    </tr>
    <tr class="diff-view-row diff-view-row-modified">
      <td class="diff-view-ln diff-view-side-old">6</td><td class="diff-view-marker diff-view-side-old">~<span class="sr-only"> modified</span></td><td class="diff-view-text diff-view-side-old">    label = label.slice(0, MAX_LENGTH);</td>
      <td class="diff-view-ln diff-view-side-new">6</td><td class="diff-view-marker diff-view-side-new">~<span class="sr-only"> modified</span></td><td class="diff-view-text diff-view-side-new">    label = label.slice(0, MAX_LENGTH) + "…";</td>
    </tr>
    <tr class="diff-view-row diff-view-row-added">
      <td class="diff-view-ln diff-view-side-old"></td><td class="diff-view-marker diff-view-side-old"> </td><td class="diff-view-text diff-view-side-old diff-view-cell-blank"></td>
      <td class="diff-view-ln diff-view-side-new">7</td><td class="diff-view-marker diff-view-side-new">+<span class="sr-only"> added</span></td><td class="diff-view-text diff-view-side-new">    label = label.trimEnd();</td>
    </tr>
    <tr class="diff-view-row diff-view-row-fold">
      <td class="diff-view-fold-cell" colspan="6">
        <button class="diff-view-fold" type="button" aria-expanded="false" aria-controls="example-diff-view-split-dv-s-fold">
          <svg class="diff-view-chevron" width="12" height="12" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M6 3l5 5-5 5" fill="none" stroke="currentColor" stroke-width="1.5"></path></svg>
          <span class="diff-view-fold-collapsed">… 12 unchanged lines</span><span class="diff-view-fold-expanded">Hide 12 unchanged lines</span>
        </button>
      </td>
    </tr>
  </tbody>
  <tbody class="diff-view-folded" id="example-diff-view-split-dv-s-fold" hidden="">
    <tr class="diff-view-row diff-view-row-context">
      <td class="diff-view-ln diff-view-side-old">7</td><td class="diff-view-marker diff-view-side-old"> </td><td class="diff-view-text diff-view-side-old">  }</td>
      <td class="diff-view-ln diff-view-side-new">8</td><td class="diff-view-marker diff-view-side-new"> </td><td class="diff-view-text diff-view-side-new">  }</td>
    </tr>
    <tr class="diff-view-row diff-view-row-context">
      <td class="diff-view-ln diff-view-side-old">8</td><td class="diff-view-marker diff-view-side-old"> </td><td class="diff-view-text diff-view-side-old">  return escape(label);</td>
      <td class="diff-view-ln diff-view-side-new">9</td><td class="diff-view-marker diff-view-side-new"> </td><td class="diff-view-text diff-view-side-new">  return escape(label);</td>
    </tr>
  </tbody>
</table></div></j3w1-diff-view>

word-level

title.js, unified with word-level changes
Old lineNew lineChangeText
@@ -4,3 +4,3 @@ export function title(container)
4 removed let label = name;
4+ added let label = `${name} (${count})`;
5 removed if (label.length > 48) {
5+ added if (label.length > MAX_LENGTH) {

<j3w1-diff-view id="example-diff-view-word-level"><div class="diff-view-scroll" tabindex="0" role="region" aria-label="Source differences"><table class="diff-view diff-view-word-level">
  <caption class="sr-only">title.js, unified with word-level changes</caption>
  <thead class="sr-only">
    <tr><th scope="col">Old line</th><th scope="col">New line</th><th scope="col">Change</th><th scope="col">Text</th></tr>
  </thead>
  <tbody>
    <tr class="diff-view-row diff-view-row-hunk">
      <td class="diff-view-ln"></td><td class="diff-view-ln"></td><td class="diff-view-marker"> </td>
      <td class="diff-view-text"><span class="diff-view-range">@@ -4,3 +4,3 @@</span> export function title(container)</td>
    </tr>
    <tr class="diff-view-row diff-view-row-removed">
      <td class="diff-view-ln">4</td><td class="diff-view-ln"></td><td class="diff-view-marker">−<span class="sr-only"> removed</span></td>
      <td class="diff-view-text">  let label = <del class="diff-view-word-removed">name;</del></td>
    </tr>
    <tr class="diff-view-row diff-view-row-added diff-view-row-active">
      <td class="diff-view-ln"></td><td class="diff-view-ln">4</td><td class="diff-view-marker">+<span class="sr-only"> added</span></td>
      <td class="diff-view-text">  let label = <ins class="diff-view-word-added">`${name} (${count})`;</ins></td>
    </tr>
    <tr class="diff-view-row diff-view-row-removed">
      <td class="diff-view-ln">5</td><td class="diff-view-ln"></td><td class="diff-view-marker">−<span class="sr-only"> removed</span></td>
      <td class="diff-view-text">  if (label.length &gt; <del class="diff-view-word-removed">48</del>) {</td>
    </tr>
    <tr class="diff-view-row diff-view-row-added">
      <td class="diff-view-ln"></td><td class="diff-view-ln">5</td><td class="diff-view-marker">+<span class="sr-only"> added</span></td>
      <td class="diff-view-text">  if (label.length &gt; <ins class="diff-view-word-added">MAX_LENGTH</ins>) {</td>
    </tr>
    <tr class="diff-view-row diff-view-row-fold">
      <td class="diff-view-fold-cell" colspan="4">
        <button class="diff-view-fold" type="button" aria-expanded="false" aria-controls="example-diff-view-word-level-dv-w-fold">
          <svg class="diff-view-chevron" width="12" height="12" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M6 3l5 5-5 5" fill="none" stroke="currentColor" stroke-width="1.5"></path></svg>
          <span class="diff-view-fold-collapsed">… 12 unchanged lines</span><span class="diff-view-fold-expanded">Hide 12 unchanged lines</span>
        </button>
      </td>
    </tr>
  </tbody>
  <tbody class="diff-view-folded" id="example-diff-view-word-level-dv-w-fold" hidden="">
    <tr class="diff-view-row diff-view-row-context">
      <td class="diff-view-ln">6</td><td class="diff-view-ln">6</td><td class="diff-view-marker"> </td>
      <td class="diff-view-text">    label = label.slice(0, MAX_LENGTH) + "…";</td>
    </tr>
    <tr class="diff-view-row diff-view-row-context">
      <td class="diff-view-ln">7</td><td class="diff-view-ln">7</td><td class="diff-view-marker"> </td>
      <td class="diff-view-text">  }</td>
    </tr>
  </tbody>
</table></div></j3w1-diff-view>

conflict

title.js, merge conflict between main and feature/gaps
LineChangeText
3 const name = container.name ?? "untitled";
4 <<<<<<< HEAD (main)
5 ours let label = name;
6 =======
7+ theirs let label = `${name} (${count})`;
8 >>>>>>> feature/gaps
9 if (label.length > MAX_LENGTH) {

<j3w1-diff-view id="example-diff-view-conflict"><div class="diff-view-scroll" tabindex="0" role="region" aria-label="Source differences"><table class="diff-view diff-view-conflict">
  <caption class="sr-only">title.js, merge conflict between main and feature/gaps</caption>
  <thead class="sr-only">
    <tr><th scope="col">Line</th><th scope="col">Change</th><th scope="col">Text</th></tr>
  </thead>
  <tbody>
    <tr class="diff-view-row diff-view-row-context">
      <td class="diff-view-ln">3</td><td class="diff-view-marker"> </td>
      <td class="diff-view-text">  const name = container.name ?? "untitled";</td>
    </tr>
    <tr class="diff-view-row diff-view-row-conflict diff-view-row-conflict-start">
      <td class="diff-view-ln">4</td><td class="diff-view-marker"> </td>
      <td class="diff-view-text">&lt;&lt;&lt;&lt;&lt;&lt;&lt; HEAD (main)</td>
    </tr>
    <tr class="diff-view-row diff-view-row-conflict diff-view-row-ours diff-view-row-active">
      <td class="diff-view-ln">5</td><td class="diff-view-marker">−<span class="sr-only"> ours</span></td>
      <td class="diff-view-text">  let label = name;</td>
    </tr>
    <tr class="diff-view-row diff-view-row-conflict diff-view-row-conflict-mid">
      <td class="diff-view-ln">6</td><td class="diff-view-marker"> </td>
      <td class="diff-view-text">=======</td>
    </tr>
    <tr class="diff-view-row diff-view-row-conflict diff-view-row-theirs">
      <td class="diff-view-ln">7</td><td class="diff-view-marker">+<span class="sr-only"> theirs</span></td>
      <td class="diff-view-text">  let label = `${name} (${count})`;</td>
    </tr>
    <tr class="diff-view-row diff-view-row-conflict diff-view-row-conflict-end">
      <td class="diff-view-ln">8</td><td class="diff-view-marker"> </td>
      <td class="diff-view-text">&gt;&gt;&gt;&gt;&gt;&gt;&gt; feature/gaps</td>
    </tr>
    <tr class="diff-view-row diff-view-row-context">
      <td class="diff-view-ln">9</td><td class="diff-view-marker"> </td>
      <td class="diff-view-text">  if (label.length &gt; MAX_LENGTH) {</td>
    </tr>
    <tr class="diff-view-row diff-view-row-fold">
      <td class="diff-view-fold-cell" colspan="3">
        <button class="diff-view-fold" type="button" aria-expanded="false" aria-controls="example-diff-view-conflict-dv-c-fold">
          <svg class="diff-view-chevron" width="12" height="12" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M6 3l5 5-5 5" fill="none" stroke="currentColor" stroke-width="1.5"></path></svg>
          <span class="diff-view-fold-collapsed">… 12 unchanged lines</span><span class="diff-view-fold-expanded">Hide 12 unchanged lines</span>
        </button>
      </td>
    </tr>
  </tbody>
  <tbody class="diff-view-folded" id="example-diff-view-conflict-dv-c-fold" hidden="">
    <tr class="diff-view-row diff-view-row-context">
      <td class="diff-view-ln">10</td><td class="diff-view-marker"> </td>
      <td class="diff-view-text">    label = label.slice(0, MAX_LENGTH) + "…";</td>
    </tr>
    <tr class="diff-view-row diff-view-row-context">
      <td class="diff-view-ln">11</td><td class="diff-view-marker"> </td>
      <td class="diff-view-text">  }</td>
    </tr>
  </tbody>
</table></div></j3w1-diff-view>

Install and register

Use the exact version. If npm publication is pending, install the release tarball from the installation guide.

npm install --save-exact @j3w1/ui@1.1.0
import '@j3w1/ui/tokens.css';
import '@j3w1/ui/styles/diff-view.css';
import '@j3w1/ui/register/diff-view';

Insert the maintained markup above after loading these imports. Its native children provide accessible names and form behavior; the empty custom tag alone does not create a control.

Vue SFC, React, Astro and plain HTML integration →

Copy the complete implementation

npx --package=@j3w1/ui@1.1.0 j3w1-ui copy diff-view --out ./vendor/j3w1/diff-view

The CLI verifies every file before writing and refuses overwrites. Serve the output over HTTP and open index.html. Keep its modules, CSS, tokens and licence notices together.

Public API

attributes

NameType / signatureMeaning
disabledCustomEventDisable the native controls owned by this instance.
loadingCustomEventMark the primary control busy and prevent duplicate activation.

properties

NameType / signatureMeaning
disabledbooleanDelegates disabled state to native children.
namestringNative control name; application form keys remain explicit.

methods

NameType / signatureMeaning
refresh(): voidReconnect behavior after replacing native child structure.
focus(options?: FocusOptions): voidFocus the first usable native control.
setText(text: string): voidReplace the bounded read-only text view; no code or shell execution occurs.

events

NameType / signatureMeaning
j3w1-actionCustomEventRequest a developer-view action; editing and execution belong to the host.

Dependencies and verification

Registered components: none. Style dependencies: none.

Package presence establishes an available implementation. Actual passes depend on the tested artifact and environment. Read specification execution evidence; read packed-consumer evidence; inspect the complete state matrix.

Full implementation manifest, API and token dependency closure

Canonical specification

Purpose

How changed text is shown: line rows with a fill and a textual marker, word-level changes as underlines that leave text contrast untouched, hunk headers, collapsible unchanged regions, and merge conflicts in a double border. The +, and ~ markers are content, present in every state and every profile.

Anatomy

Root .diff-view (the table) with the variant class .diff-view-unified, -split, -word-level or -conflict. Row classes: .diff-view-row, -row-context, -row-added, -row-removed, -row-modified, -row-hunk, -row-fold, -row-conflict with -row-conflict-start, -row-conflict-mid and -row-conflict-end for the marker lines. Cells: .diff-view-ln, -marker, -text, -fold-cell, and -range for the @@ span; the split variant adds -side-old and -side-new on cells and, on a -row-pair, -cell-removed and -cell-added per side (an empty -cell-blank faces an added line); the conflict variant adds -row-ours and -row-theirs. Word-level spans: .diff-view-word-added (2px underline in color.diff.added.emphasis) and .diff-view-word-removed (underline plus line-through in color.diff.removed.emphasis). The fold button is .diff-view-fold with an inline SVG chevron .diff-view-chevron and two labels, -fold-collapsed and -fold-expanded; the folded rows are a <tbody class="diff-view-folded" hidden>. Line numbers use color.text.muted rather than color.code.line-number because the modified fill would drop the subtle step to 4.45:1.

States

StateVisualNon-colour channel
defaultadded rows color.diff.added.bg with + in color.diff.added.gutter; removed color.diff.removed.bg with in color.diff.removed.gutter; modified color.diff.modified.bg with ~ in color.diff.modified.gutter; hunk rows color.diff.header-bg; conflicts in a 3px double color.diff.conflict-borderthe markers; the double border
hoverthe row’s number and marker cells → color.interaction.hover.bg; the text cell keeps its fillcursor: default; only on hover-capable pointers
selectedthe whole row filled color.interaction.selection.bg with color.interaction.selection.text; the marker staysaria-current (or :target for a permalinked line); the marker glyph
focus-visible1px dashed color.interaction.focus.ring at −2px around the row; on a selected row the ring is color.interaction.focus.ring-containerthe ring
expandedthe folded <tbody> is shown; the chevron points down; the button reads “Hide 12 unchanged lines”aria-expanded="true"; the rows appear
collapsedthe folded <tbody> is hidden; the chevron points to the inline end; ”… 12 unchanged lines”aria-expanded="false"; the ellipsis

Precedence: selected > hover; focus-visible is always drawn. A selected row loses its diff fill but never its marker, which is why the marker is content.

Keyboard

A plain table has no row focus. The official web distribution places it in a named, focusable scroll region so keyboard users can inspect wide lines without page overflow. The region uses the canonical container focus ring. Within the table, the fold buttons are the only stops and toggle with Enter or Space, the chevron turning within the motion budget. Hosts that expose the diff as a grid own row navigation and draw the ring on the focused row; the theme adds no binding of its own.

Accessibility

The caption names both files. Every changed row carries its marker in a cell of its own, so a screen reader announces + or before the line and a monochrome print keeps the distinction. Word-level changes use text-decoration (underline, or underline and line-through), never a fill, so the row text stays at 7.56:1 or better on every diff fill. The marker measures 4.24:1 on the removed fill: it is bold, sits on its own fill and is a glyph, so it is held to the 3:1 graphics floor and recorded here. Folded hunks use aria-expanded and aria-controls on a real button; the hidden rows are hidden, not visually clipped, so they are not read while collapsed. Conflict markers are the literal <<<<<<<, ======= and >>>>>>> lines in color.text.bright on color.diff.header-bg, inside the double border.

Portability

A table with cell backgrounds, borders and text decoration is expressible in every toolkit. Hosts without double borders draw two 1px lines and record it; hosts without line-through keep the underline and the marker. The three extension hues are confined to the diff group here and change with the profile; the markers do not.

Non-examples

Red and green fills with no +/ marker. Word-level changes drawn as a bright fill that pushes the text below 4.5:1. A removed line rendered in text.disabled or struck through as a whole. Collapsed hunks that are only visually clipped and still read out. Conflict blocks in purple. Hover that recolours the code text. Round-cornered hunk cards floating on the page. A selected row that drops the marker because “the colour says it”.

j3w1 / command

  • SPEC Overview
  • SPEC Foundations
  • SPEC Components
  • SPEC Patterns
  • SPEC Tokens
  • SPEC Tools
  • SPEC Implement
  • SPEC For agents
  • SPEC Ports
  • SPEC Releases
  • DEMO Vue application demo
  • SPEC Complete UI theme specification