Diff

Summary

A line-based change view: each .soma-diff-line is a -gutter (line number) plus -text (whitespace preserved), with -add/-del tinting and a -hunk header row. Keep the +/- markers in the text: the color reinforces the change, it never carries it alone. This is the substrate for plan-diff interactions ("this will change X — approve?"); gutters use muted, not subtle, text.

Markup contract (axe-gated): long lines scroll horizontally, so the block must be keyboard-reachable: it carries tabindex="0" and an aria-label.

When to use

CaseUse it for
Plan diffsWhat an action will change before it runs, paired with approve/reject controls (the Cortex PlanDiff composition; see Cortex).
Config driftCurrent vs desired state, surfacing what reconciliation would touch.
Audit detailWhat a past change actually did, line by line.
Not a review UINo side-by-side, syntax highlighting or comment threads; that's an app-level tool, not this component.

Examples

Add / del / context lines

-add and -del tint; unmarked lines are context:

@@ service "api-backend" @@
12 memory = 512
13- replicas = 3
13+ replicas = 6

The +/- markers stay in the text — color only echoes them.

Gutter numbers

Muted, end-aligned, non-selectable:

@@ config "logging" @@
4 format = "json"
5- level = "debug"
5+ level = "info"
6+ retention = "30d"

Gutters are user-select: none (copying the diff grabs only the text) and use muted (AA-checked) color, never subtle. A hunk row's gutter stays empty.

Multi-hunk

One pane, several -hunk sections:

@@ deployment "api-backend" @@
18 image = "api:2.3.0"
19- replicas = 3
19+ replicas = 6
@@ autoscaler "api-backend" @@
41 min = 2
42- max = 4
42+ max = 8
43+ scale_down_delay = "5m"

Each -hunk row restarts the context — gutter numbering jumps with it. One focusable pane covers the whole plan.

HTML

The pane and the four line kinds: context (unmarked), -del, -add, and a -hunk header. tabindex="0" and aria-label are required, not decoration: without the tabindex, keyboard users cannot scroll long lines into view.

<div class="soma-diff" tabindex="0" aria-label="Plan diff">
  <div class="soma-diff-line soma-diff-hunk"><span class="soma-diff-gutter"></span>
    <span class="soma-diff-text">@@ service "api-backend" @@</span></div>
  <div class="soma-diff-line"><span class="soma-diff-gutter">12</span>
    <span class="soma-diff-text">  memory   = 512</span></div>
  <div class="soma-diff-line soma-diff-del"><span class="soma-diff-gutter">13</span>
    <span class="soma-diff-text">- replicas = 3</span></div>
  <div class="soma-diff-line soma-diff-add"><span class="soma-diff-gutter">13</span>
    <span class="soma-diff-text">+ replicas = 6</span></div>
</div>

Whitespace in -text is preserved (white-space: pre): indentation and alignment survive exactly as emitted, which is why the context lines above carry two leading spaces to stay aligned with the +/- lines:

<span class="soma-diff-text">  memory   = 512</span>   <!-- two spaces kept -->

A hunk header names the section that follows; its gutter span is present but empty (the columns stay aligned):

<div class="soma-diff-line soma-diff-hunk">
  <span class="soma-diff-gutter"></span>
  <span class="soma-diff-text">@@ autoscaler "api-backend" @@</span>
</div>

CSS classes

ClassEffect
.soma-diffThe block: monospace, bordered, inset surface (--soma-surface-inset), scrolls horizontally on long lines; shows the focus ring when focused.
.soma-diff-lineOne line: gutter + text.
.soma-diff-add / .soma-diff-delSuccess/danger subtle background with matching -subtle-fg text (gutter included); keep the +/- in the text.
.soma-diff-hunkSection header row (info tint); leave its gutter empty.
.soma-diff-gutterLine number: muted text (informational, AA-checked), end-aligned, 52px, non-selectable so copied diffs grab only the text.
.soma-diff-textThe line content; whitespace is preserved (pre).

Rendering from data

Producing the diff is the consumer's concern — a plan endpoint usually returns it as structured lines. Render with textContent (diff payloads quote user data; never innerHTML them), map the kind to the line class, and leave hunk gutters empty:

const KIND_CLASS = { add: 'soma-diff-add', del: 'soma-diff-del', hunk: 'soma-diff-hunk' };

function renderDiff(pane, lines) {
  // lines: [{kind: 'hunk'|'add'|'del'|'context', no?, text}]
  pane.replaceChildren(...lines.map(({ kind, no, text }) => {
    const line = document.createElement('div');
    line.className = 'soma-diff-line';
    if (KIND_CLASS[kind]) line.classList.add(KIND_CLASS[kind]);

    const gutter = document.createElement('span');
    gutter.className = 'soma-diff-gutter';
    gutter.textContent = kind === 'hunk' ? '' : String(no);

    const body = document.createElement('span');
    body.className = 'soma-diff-text';
    body.textContent = text;             // '+ replicas = 6' — marker included

    line.append(gutter, body);
    return line;
  }));
}

const plan = await fetch('/api/plans/4821/diff').then((r) => r.json());
renderDiff(document.querySelector('#plan-diff'), plan.lines);

The plan-diff composition

The Cortex ApprovalCard wraps this component: a widget whose body holds the diff pane and whose footer is a .soma-form-actions row of approve/reject buttons — the diff itself stays inert. The pane's aria-label should name the plan ("Plan diff for deploy #4821"), because that label is what a screen-reader user hears before deciding:

<section class="soma-widget">
  <header class="soma-widget-header">
    <h3 class="soma-widget-title">Scale api-backend to 6 replicas</h3>
  </header>
  <div class="soma-widget-body">
    <div class="soma-diff" id="plan-diff" tabindex="0"
         aria-label="Plan diff for deploy #4821"></div>
    <div class="soma-form-actions">
      <button class="soma-button soma-button-primary">Approve</button>
      <button class="soma-button">Reject</button>
    </div>
  </div>
</section>

See the Nware Cortex kit for the full trust-primitive mapping (PlanDiff, ApprovalCard and friends).

JavaScript

None in Soma — the diff is CSS-only. The renderer above is the consumer's wiring pattern; any approve/reject controls around it are ordinary buttons.