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
| Case | Use it for |
|---|---|
| Live tails | Deploy output, agent runs, build streams; append rows as events arrive. |
| Historic streams | A fetched window of events, newest first or last — your ordering. |
| Timeline instead | Curated milestones with commentary, not raw lines. |
| Table instead | Structured events that need sortable, comparable columns. |
Examples
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
| Class | Effect |
|---|---|
.soma-log | The pane: monospace (via --soma-font-family-mono), bordered, inset surface (--soma-surface-inset), scrolls beyond 320px; shows the focus ring when focused. |
.soma-log-line | One event row (no wrapping; the pane scrolls horizontally). |
.soma-log-warn / .soma-log-error | On a line: warning/danger subtle background, level text in the matching -subtle-fg color. |
.soma-log-time | Timestamp: 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-level | Fixed-width (44px min) bold level tag. |
.soma-log-text | The 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.