Log viewer

Summary

A scrollable monospace pane of event lines, each a -time + -level + -text row, with -warn/-error tinting whole lines. The level text (INFO/WARN/ERROR) carries the semantics; the tint only reinforces it. Timestamps use muted, not subtle, text: they are informational and must meet AA.

Markup contract (axe-gated): the pane scrolls, so it must be keyboard-reachable: it carries tabindex="0" and an aria-label, plus role="log" so appended lines are announced politely to screen readers.

When to use

CaseUse it for
Live tailsDeploy output, agent runs, build streams; append rows as events arrive.
Historic streamsA fetched window of events, newest first or last — your ordering.
Timeline insteadCurated milestones with commentary, not raw lines.
Table insteadStructured events that need sortable, comparable columns.

Examples

Levels & timestamps

Info default · -warn · -error:

10:32:15INFODeployment completed successfully (v2.3.1)
10:15:00WARNCPU usage alert triggered
09:58:41ERRORImage pull failed: registry timeout

Every line: muted -time, bold fixed-width -level, then the text. The level word carries the meaning; -warn/-error tint only echoes it.

Connection status composition

Dot + text above a live tail:

Connected — streaming live

10:32:15INFONeuron cortex-7 picked up task #4821
10:32:18INFOPlan generated, awaiting approval

A .soma-badge-dot recolored inline plus a status line. When the stream drops, swap both together: the words announce the state, the color only echoes it.

Tall pane

Caps at 320px, then scrolls:

10:31:02INFODeploy requested: api-backend v2.3.1 → production
10:31:03INFOPlan approved by tomas (approval #1082)
10:31:05INFOPulling image registry.nsysware.com/api:2.3.1
10:31:19WARNRegistry responded slowly (4.2s) — retrying layer 3/7
10:31:27INFOImage pulled (7 layers, 182 MB)
10:31:29INFORolling update started: 6 replicas, surge 1
10:31:41INFOReplica 1/6 healthy
10:31:53INFOReplica 2/6 healthy
10:32:04ERRORReplica 3/6 failed readiness probe (timeout after 10s)
10:32:06INFORestarting replica 3/6
10:32:18INFOReplica 3/6 healthy
10:32:30INFOReplica 4/6 healthy
10:32:42INFOReplica 5/6 healthy
10:32:47WARNp95 latency elevated during rollout (312ms)
10:32:54INFOReplica 6/6 healthy
10:32:56INFODraining old replicas
10:33:10INFOOld replicas terminated
10:33:12INFOSmoke checks: 24/24 passing
10:33:15INFOp95 latency back to baseline (178ms)
10:33:17INFODeploy completed successfully

Past 320px the pane scrolls. This is why tabindex="0" is part of the contract: Tab into the pane, then arrow keys scroll it. Long lines also scroll horizontally (no wrapping).

Live append

Consumer JS appends rows; scroll pins to the bottom:

10:40:00INFOStream opened

Each click appends a row via the snippet in the JavaScript section. The pane sticks to the bottom only while you're already there: scroll up, keep appending, and your position holds.

HTML

The pane and one line of each kind. role="log", tabindex="0" and aria-label are required, not decoration: without the tabindex, keyboard users cannot scroll the pane.

<div class="soma-log" role="log" tabindex="0" aria-label="Event log">
  <div class="soma-log-line">
    <span class="soma-log-time">10:32:15</span>
    <span class="soma-log-level">INFO</span>
    <span class="soma-log-text">Deployment completed successfully</span>
  </div>
  <div class="soma-log-line soma-log-warn">…</div>
  <div class="soma-log-line soma-log-error">…</div>
</div>

Level tints go on the line, and the level word stays in the text; the tint may never be the only signal:

<div class="soma-log-line soma-log-warn">
  <span class="soma-log-time">10:15:00</span>
  <span class="soma-log-level">WARN</span>
  <span class="soma-log-text">CPU usage alert triggered</span>
</div>

All three spans are structural, but only -text is mandatory content; a stream without timestamps simply omits the -time span:

<div class="soma-log-line">
  <span class="soma-log-level">INFO</span>
  <span class="soma-log-text">Cache warmed (2,412 keys)</span>
</div>

CSS classes

ClassEffect
.soma-logThe pane: monospace (via --soma-font-family-mono), bordered, inset surface (--soma-surface-inset), scrolls beyond 320px; shows the focus ring when focused.
.soma-log-lineOne event row (no wrapping; the pane scrolls horizontally).
.soma-log-warn / .soma-log-errorOn a line: warning/danger subtle background, level text in the matching -subtle-fg color.
.soma-log-timeTimestamp: muted text (informational, AA-checked), never subtle. Tabular figures on top of the pane's mono stack keep the timestamp column aligned across lines.
.soma-log-levelFixed-width (44px min) bold level tag.
.soma-log-textThe message body.

The focus contract

Both scroll axes are keyboard-driven only when the pane itself can take focus, hence tabindex="0" on .soma-log, with the standard Soma focus ring on :focus-visible. The aria-label names what the pane is a log of ("Deploy log for api-backend v2.3.1", not "Log"), because the label is what a screen-reader user hears when they land on it. role="log" makes the pane a polite live region: rows appended later are announced without interrupting, which is exactly right for a tail — and a reason not to blast hundreds of rows in one batch insert. For a bulk backfill, build the rows in a fragment and append once.

Appending lines from JS

Streaming is the consumer's loop — the component is just the markup contract. Build rows with textContent (log payloads are untrusted; never innerHTML them), and pin the scroll to the bottom only when the reader is already there; stealing the scroll position from someone reading an earlier error is the classic log-viewer bug:

function appendLine(log, { time, level, text }) {
  // Pinned only if the reader is already at (or within a line of) the bottom.
  const pinned = log.scrollTop + log.clientHeight >= log.scrollHeight - 4;

  const line = document.createElement('div');
  line.className = 'soma-log-line';
  if (level === 'WARN') line.classList.add('soma-log-warn');
  if (level === 'ERROR') line.classList.add('soma-log-error');

  for (const [cls, value] of [['time', time], ['level', level], ['text', text]]) {
    const span = document.createElement('span');
    span.className = `soma-log-${cls}`;
    span.textContent = value;          // untrusted payloads stay text
    line.appendChild(span);
  }
  log.appendChild(line);

  if (pinned) log.scrollTop = log.scrollHeight;
}

Feed it from any stream — a WebSocket tail:

const log = document.querySelector('#deploy-log');
const socket = new WebSocket('wss://…/deploys/4821/tail');

socket.addEventListener('message', (e) => {
  appendLine(log, JSON.parse(e.data));   // {time, level, text}
});

Cap retained rows for long-running tails, or the DOM grows without bound. Drop from the top after appending:

const MAX_LINES = 2000;
while (log.children.length > MAX_LINES) log.firstElementChild.remove();

The live-append example above, exactly as this page wires it:

const live = document.getElementById('log-live');
const now = () => new Date().toLocaleTimeString('en-GB');

document.getElementById('log-add-info').addEventListener('click', () =>
  appendLine(live, { time: now(), level: 'INFO', text: 'Replica healthy' }));
document.getElementById('log-add-warn').addEventListener('click', () =>
  appendLine(live, { time: now(), level: 'WARN', text: 'Slow response (2.1s)' }));
document.getElementById('log-add-error').addEventListener('click', () =>
  appendLine(live, { time: now(), level: 'ERROR', text: 'Readiness probe failed' }));

JavaScript

None in Soma — the log viewer is CSS-only. The append loop, scroll pinning and row cap above are the consumer's wiring pattern, with role="log" handling the announcements for free.