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
| Old line | New line | Change | Text |
|---|---|---|---|
| @@ -3,7 +3,8 @@ export function title(container) | |||
| 3 | 3 | const name = container.name ?? "untitled"; | |
| 4 | − removed | let label = name; | |
| 4 | + added | let label = `${name} (${container.children.length})`; | |
| 5 | 5 | if (label.length > MAX_LENGTH) { | |
| 6 | 6 | ~ modified | label = label.slice(0, MAX_LENGTH) + "…"; |
| 7 | 7 | } | |
| 8 | 8 | return escape(label); | |
| 9 | 9 | } | |
| 10 | 10 | … ten more context lines … | |
<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 > 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
| Old line | Old change | Old text | New line | New change | New 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(); | |||
| 7 | } | 8 | } | ||
| 8 | return escape(label); | 9 | return escape(label); | ||
<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 > 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 > 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
| Old line | New line | Change | Text |
|---|---|---|---|
| @@ -4,3 +4,3 @@ export function title(container) | |||
| 4 | − removed | let label = |
|
| 4 | + added | let label = `${name} (${count})`; | |
| 5 | − removed | if (label.length > |
|
| 5 | + added | if (label.length > MAX_LENGTH) { | |
| 6 | 6 | label = label.slice(0, MAX_LENGTH) + "…"; | |
| 7 | 7 | } | |
<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 > <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 > <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
| Line | Change | Text |
|---|---|---|
| 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) { | |
| 10 | label = label.slice(0, MAX_LENGTH) + "…"; | |
| 11 | } | |
<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"><<<<<<< 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">>>>>>>> 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 > 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.0import '@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.
Copy the complete implementation
npx --package=@j3w1/ui@1.1.0 j3w1-ui copy diff-view --out ./vendor/j3w1/diff-viewThe 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
| Name | Type / signature | Meaning |
|---|---|---|
disabled | CustomEvent | Disable the native controls owned by this instance. |
loading | CustomEvent | Mark the primary control busy and prevent duplicate activation. |
properties
| Name | Type / signature | Meaning |
|---|---|---|
disabled | boolean | Delegates disabled state to native children. |
name | string | Native control name; application form keys remain explicit. |
methods
| Name | Type / signature | Meaning |
|---|---|---|
refresh | (): void | Reconnect behavior after replacing native child structure. |
focus | (options?: FocusOptions): void | Focus the first usable native control. |
setText | (text: string): void | Replace the bounded read-only text view; no code or shell execution occurs. |
events
| Name | Type / signature | Meaning |
|---|---|---|
j3w1-action | CustomEvent | Request 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
| State | Visual | Non-colour channel |
|---|---|---|
| default | added 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-border | the markers; the double border |
| hover | the row’s number and marker cells → color.interaction.hover.bg; the text cell keeps its fill | cursor: default; only on hover-capable pointers |
| selected | the whole row filled color.interaction.selection.bg with color.interaction.selection.text; the marker stays | aria-current (or :target for a permalinked line); the marker glyph |
| focus-visible | 1px dashed color.interaction.focus.ring at −2px around the row; on a selected row the ring is color.interaction.focus.ring-container | the ring |
| expanded | the folded <tbody> is shown; the chevron points down; the button reads “Hide 12 unchanged lines” | aria-expanded="true"; the rows appear |
| collapsed | the 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”.