Skip to main content

ns-ui

Log Viewer ASCII Tail

A live `tail -f` log pane with a real ring buffer, a per-line severity glyph column, and follow-mode that detaches the instant you scroll up and reports how many lines arrived while you were reading.

Usage

registry/core/log-viewer-ascii-tail/demo.tsx
"use client";

import { LogViewerAsciiTail } from "./component";

export default function LogViewerAsciiTailDemo() {
  return (
    // h-screen, not min-h-screen: the pane below is `h-full`, and a parent with
    // only a min-height leaves that percentage indefinite — the log would grow
    // to its full 3600px of rows instead of scrolling inside a bounded pane.
    <div className="flex h-screen flex-col gap-4 overflow-hidden bg-background px-6 py-6">
      <div className="flex items-baseline justify-between gap-4">
        <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
          ns-ui / log-viewer-ascii-tail
        </p>
        <p className="font-mono text-[11px] text-ns-muted">
          edge fleet · eu-central-1 · streaming
        </p>
      </div>

      <div className="min-h-0 flex-1">
        <LogViewerAsciiTail aria-label="Edge fleet log, live tail" />
      </div>
    </div>
  );
}

Component

registry/core/log-viewer-ascii-tail/component.tsx
"use client";

import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
} from "react";

// ---------------------------------------------------------------------------
// TailPane — a `tail -f` log viewer with a real bounded ring buffer, an ASCII
// severity column and follow-mode that detaches the instant you scroll up.
//
// The three things that make it honest rather than a log-shaped animation:
//  1. memory is bounded — appends overwrite the oldest slot of a fixed
//     Array(capacity); nothing grows without limit, however long it runs.
//  2. the stream is deterministic — one LCG drives both the inter-line delay
//     and the severity draw, so cadence jitters like real traffic instead of
//     ticking, and the same seed always replays the same log.
//  3. appends never render per-line — they land in a ref and flush to React
//     through ONE rAF-coalesced setState per frame, and only the rows inside
//     the viewport are in the DOM (fixed 18px rows, absolute positioning).
// ---------------------------------------------------------------------------

export type LogSeverity = "DEBUG" | "INFO" | "WARN" | "ERROR";

export type LogLine = {
  /** monotonically increasing sequence number; also its ring slot (seq % capacity) */
  seq: number;
  /** pre-formatted HH:MM:SS.mmm wall clock */
  time: string;
  sev: LogSeverity;
  service: string;
  message: string;
};

export interface LogViewerAsciiTailProps {
  /** ring buffer size — appends past this overwrite the oldest line. Default 2000. */
  capacity?: number;
  /** LCG seed; the same seed replays the same stream exactly. Default 20240804. */
  seed?: number;
  /** lines synthesised before the stream starts, so the pane is dense at rest. Default 200. */
  initialLines?: number;
  /** service column values drawn from. */
  services?: string[];
  /** extra classes merged onto the rendered root element */
  className?: string;
  /** accessible name for the log pane */
  "aria-label"?: string;
}

const ROW_H = 18; // px — fixed, which is what makes windowing trivial
const OVERSCAN = 6; // rows rendered beyond the viewport, each side
const NEAR_BOTTOM = 8; // px — inside this, follow re-attaches
const SERVICE_W = 11; // chars, padded so the pipes form true vertical rules

const LEVELS: LogSeverity[] = ["DEBUG", "INFO", "WARN", "ERROR"];
const LEVEL_RANK: Record<LogSeverity, number> = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };

const GLYPH: Record<LogSeverity, string> = {
  DEBUG: "·", // ·
  INFO: "─", // ─
  WARN: "≡", // ≡
  ERROR: "█", // █
};

// glyph AND weight together, never hue alone. The faintest ink is a muted
// tint, NOT `text-border` — --border is tuned for 1px hairlines and is
// invisible as type on the light theme (#ebebeb on #ffffff).
const FAINT = "text-ns-muted/45";

const SEV_INK: Record<LogSeverity, string> = {
  DEBUG: FAINT,
  INFO: "text-ns-muted",
  WARN: "text-foreground font-medium",
  ERROR: "text-ns-accent font-semibold",
};

const DEFAULT_SERVICES = [
  "api-gateway",
  "auth-worker",
  "orders-svc",
  "ledger-sync",
  "edge-cache",
  "search-idx",
];

const MESSAGES: Record<LogSeverity, string[]> = {
  DEBUG: [
    "cache hit ratio 0.94 over 512 keys",
    "span emitted trace=9f2a1c parent=3bd881",
    "pool idle 12/32 connections",
    "heartbeat ack in 4ms",
    "resolved 3 dns records ttl=30s",
    "compacted 18 segments into 6",
    "token introspection cached 240s",
  ],
  INFO: [
    "GET /v2/orders 200 in 38ms",
    "POST /v2/checkout 201 in 112ms",
    "session refreshed for user 84213",
    "published 128 events to orders.v2",
    "shard 4 rebalanced in 1.8s",
    "index snapshot written 41MB",
    "GET /health 200 in 2ms",
    "invoice 4471 settled in 74ms",
  ],
  WARN: [
    "retry 2/5 after connection reset",
    "queue depth 8420 above soft limit",
    "p99 latency 812ms over budget",
    "clock skew 340ms against peer 3",
    "rate limit 80% consumed for key ak_19",
  ],
  ERROR: [
    "upstream 502 after 3 retries",
    "deadline exceeded on shard 7",
    "checksum mismatch on segment 41",
    "connection refused to ledger:5432",
  ],
};

// deterministic LCG — one stream drives delay, severity, service and message
function nextSeed(s: number): number {
  return (s * 1664525 + 1013904223) >>> 0;
}

function formatClock(msOfDay: number): string {
  const ms = Math.floor(msOfDay) % 86_400_000;
  const h = Math.floor(ms / 3_600_000);
  const m = Math.floor(ms / 60_000) % 60;
  const s = Math.floor(ms / 1000) % 60;
  const f = Math.floor(ms) % 1000;
  return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(f).padStart(3, "0")}`;
}

function drawSeverity(r: number): LogSeverity {
  if (r < 0.42) return "DEBUG";
  if (r < 0.82) return "INFO";
  if (r < 0.95) return "WARN";
  return "ERROR";
}

/** splits a message around every case-insensitive occurrence of `q` */
function inkMatches(message: string, q: string) {
  if (!q) return [{ text: message, hit: false }];
  const out: { text: string; hit: boolean }[] = [];
  const hay = message.toLowerCase();
  const needle = q.toLowerCase();
  let i = 0;
  for (;;) {
    const at = hay.indexOf(needle, i);
    if (at === -1) break;
    if (at > i) out.push({ text: message.slice(i, at), hit: false });
    out.push({ text: message.slice(at, at + needle.length), hit: true });
    i = at + needle.length;
  }
  if (i < message.length) out.push({ text: message.slice(i), hit: false });
  return out;
}

const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;

export function LogViewerAsciiTail({
  capacity = 2000,
  seed = 20240804,
  initialLines = 200,
  services = DEFAULT_SERVICES,
  className = "",
  "aria-label": ariaLabel = "Application log, live tail",
}: LogViewerAsciiTailProps) {
  // an inline `services={[...]}` prop would otherwise be a new array every
  // render, re-creating appendOne and re-running the seed effect forever;
  // rebuilding from a joined key makes the identity depend on the CONTENT
  const serviceKey = services.join("|");
  const serviceList = useMemo(() => serviceKey.split("|"), [serviceKey]);

  // --- ring buffer ---------------------------------------------------------
  const ringRef = useRef<(LogLine | undefined)[]>([]);
  const totalRef = useRef(0); // total lines ever appended; head slot = total % capacity
  const rngRef = useRef(seed >>> 0);
  const clockRef = useRef(43_471_882); // 12:04:31.882, a fixed start so SSR/CSR agree

  // --- flush plumbing (one setState per frame, never one per line) ----------
  const [tick, setTick] = useState(0);
  const rafRef = useRef(0);
  const newRef = useRef(0);
  const [newCount, setNewCount] = useState(0);

  // --- follow mode ---------------------------------------------------------
  const paneRef = useRef<HTMLDivElement>(null);
  const followRef = useRef(true);
  const [follow, setFollow] = useState(true);

  // --- windowing -----------------------------------------------------------
  const [range, setRange] = useState({ start: 0, end: 60 });

  // --- filters -------------------------------------------------------------
  const [minLevel, setMinLevel] = useState<LogSeverity>("DEBUG");
  const [query, setQuery] = useState("");

  // --- motion / lifecycle --------------------------------------------------
  const [reduced, setReduced] = useState(false);
  const [running, setRunning] = useState(true);
  const runningRef = useRef(true);
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  const scheduleFlush = useCallback(() => {
    if (rafRef.current) return;
    rafRef.current = requestAnimationFrame(() => {
      rafRef.current = 0;
      setNewCount(newRef.current);
      setTick((t) => t + 1);
    });
  }, []);

  /** synthesise exactly one line and write it into its ring slot */
  const appendOne = useCallback(() => {
    let s = rngRef.current;

    s = nextSeed(s);
    const delay = 90 + (s / 4_294_967_296) * 260;
    clockRef.current += delay;

    s = nextSeed(s);
    const sev = drawSeverity(s / 4_294_967_296);

    s = nextSeed(s);
    const service =
      serviceList[Math.floor((s / 4_294_967_296) * serviceList.length)] ?? serviceList[0]!;

    s = nextSeed(s);
    const pool = MESSAGES[sev];
    const message = pool[Math.floor((s / 4_294_967_296) * pool.length)] ?? pool[0]!;

    rngRef.current = s;

    const seq = totalRef.current;
    ringRef.current[seq % capacity] = {
      seq,
      time: formatClock(clockRef.current),
      sev,
      service,
      message,
    };
    totalRef.current = seq + 1;
    if (!followRef.current) newRef.current += 1;
    return delay;
  }, [capacity, serviceList]);

  // reduced motion
  useEffect(() => {
    const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
    const update = () => {
      setReduced(mql.matches);
      if (mql.matches) {
        setRunning(false);
        runningRef.current = false;
      }
    };
    update();
    mql.addEventListener("change", update);
    return () => mql.removeEventListener("change", update);
  }, []);

  // seed the buffer once — the pane is dense and legible before any motion
  useEffect(() => {
    ringRef.current = new Array(capacity);
    totalRef.current = 0;
    rngRef.current = seed >>> 0;
    clockRef.current = 43_471_882;
    for (let i = 0; i < initialLines; i++) appendOne();
    newRef.current = 0;
    setNewCount(0);
    setTick((t) => t + 1);
  }, [capacity, seed, initialLines, appendOne]);

  // the stream: a self-rescheduling timeout whose delay is drawn from the LCG,
  // paused while the tab is hidden and while `running` is false
  useEffect(() => {
    runningRef.current = running;
    if (!running) return;

    let cancelled = false;
    // document.hidden truly stops the generator (no polling); visibilitychange
    // restarts it, so a backgrounded tab costs nothing.
    const step = () => {
      if (cancelled || document.hidden) {
        timerRef.current = null;
        return;
      }
      const delay = appendOne();
      scheduleFlush();
      timerRef.current = setTimeout(step, delay);
    };
    timerRef.current = setTimeout(step, 120);

    const onVis = () => {
      if (!document.hidden && !cancelled && timerRef.current === null) {
        timerRef.current = setTimeout(step, 120);
      }
    };
    document.addEventListener("visibilitychange", onVis);

    return () => {
      cancelled = true;
      document.removeEventListener("visibilitychange", onVis);
      if (timerRef.current) clearTimeout(timerRef.current);
      timerRef.current = null;
    };
  }, [running, appendOne, scheduleFlush]);

  useEffect(() => () => cancelAnimationFrame(rafRef.current), []);

  // --- derived views -------------------------------------------------------
  const lines = useMemo(() => {
    const total = totalRef.current;
    const from = Math.max(0, total - capacity);
    const out: LogLine[] = [];
    for (let i = from; i < total; i++) {
      const l = ringRef.current[i % capacity];
      if (l) out.push(l);
    }
    return out;
    // tick is the flush signal; the data itself lives in refs
  }, [tick, capacity]);

  const rows = useMemo(() => {
    const min = LEVEL_RANK[minLevel];
    const q = query.trim().toLowerCase();
    return lines.filter(
      (l) =>
        LEVEL_RANK[l.sev] >= min &&
        (q === "" ||
          l.message.toLowerCase().includes(q) ||
          l.service.toLowerCase().includes(q))
    );
  }, [lines, minLevel, query]);

  // --- scroll plumbing -----------------------------------------------------
  const measure = useCallback(() => {
    const el = paneRef.current;
    if (!el) return;
    const start = Math.max(0, Math.floor(el.scrollTop / ROW_H) - OVERSCAN);
    const end = Math.ceil((el.scrollTop + el.clientHeight) / ROW_H) + OVERSCAN;
    setRange((r) => (r.start === start && r.end === end ? r : { start, end }));
  }, []);

  const attach = useCallback(() => {
    followRef.current = true;
    newRef.current = 0;
    setNewCount(0);
    setFollow(true);
    const el = paneRef.current;
    if (el) el.scrollTop = el.scrollHeight;
    measure();
  }, [measure]);

  const onScroll = useCallback(() => {
    const el = paneRef.current;
    if (!el) return;
    const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
    if (distance > NEAR_BOTTOM) {
      if (followRef.current) {
        followRef.current = false;
        setFollow(false);
      }
    } else if (!followRef.current) {
      followRef.current = true;
      newRef.current = 0;
      setNewCount(0);
      setFollow(true);
    }
    measure();
  }, [measure]);

  // pin to the bottom while following — after every flush and every filter change
  useIsoLayoutEffect(() => {
    const el = paneRef.current;
    if (!el) return;
    if (followRef.current) el.scrollTop = el.scrollHeight;
    measure();
  }, [rows, measure]);

  const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    const el = paneRef.current;
    if (!el) return;
    if (e.key === "End") {
      e.preventDefault();
      attach();
    } else if (e.key === "Home") {
      e.preventDefault();
      el.scrollTop = 0;
    } else if (e.key === "ArrowDown") {
      e.preventDefault();
      el.scrollTop += ROW_H;
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      el.scrollTop -= ROW_H;
    } else if (e.key === "PageDown") {
      e.preventDefault();
      el.scrollTop += el.clientHeight - ROW_H;
    } else if (e.key === "PageUp") {
      e.preventDefault();
      el.scrollTop -= el.clientHeight - ROW_H;
    }
  };

  // roving tabindex: selecting with the arrow keys must MOVE focus too,
  // otherwise the ring is left behind on a button that is now tabIndex=-1
  const radioRefs = useRef<Partial<Record<LogSeverity, HTMLButtonElement | null>>>({});

  const moveLevel = (to: LogSeverity) => {
    setMinLevel(to);
    radioRefs.current[to]?.focus();
  };

  const onLevelKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    const i = LEVELS.indexOf(minLevel);
    if (e.key === "ArrowRight" || e.key === "ArrowDown") {
      e.preventDefault();
      moveLevel(LEVELS[Math.min(LEVELS.length - 1, i + 1)]!);
    } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
      e.preventDefault();
      moveLevel(LEVELS[Math.max(0, i - 1)]!);
    } else if (e.key === "Home") {
      e.preventDefault();
      moveLevel(LEVELS[0]!);
    } else if (e.key === "End") {
      e.preventDefault();
      moveLevel(LEVELS[LEVELS.length - 1]!);
    }
  };

  const visible = rows.slice(range.start, range.end);
  const inputId = "log-tail-filter";

  return (
    <div
      className={`flex h-full min-h-0 flex-col overflow-hidden rounded-md border border-border bg-background font-mono text-xs ${className}`}
    >
      {/* header: the level control doubles as the glyph legend */}
      <div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border px-3 py-2">
        <span className="text-ns-muted">
          tail -f <span className="text-foreground">/var/log/edge.log</span>
        </span>

        <div
          role="radiogroup"
          aria-label="Minimum severity"
          onKeyDown={onLevelKeyDown}
          className="flex items-center overflow-hidden rounded-sm border border-border"
        >
          {LEVELS.map((lv) => {
            const on = lv === minLevel;
            return (
              <button
                key={lv}
                ref={(node) => {
                  radioRefs.current[lv] = node;
                }}
                type="button"
                role="radio"
                aria-checked={on}
                tabIndex={on ? 0 : -1}
                onClick={() => setMinLevel(lv)}
                className={`px-2 py-1 whitespace-pre transition-colors duration-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ns-accent ${
                  on ? "bg-surface text-foreground" : "text-ns-muted hover:text-foreground"
                }`}
              >
                <span className={on ? "" : SEV_INK[lv]}>{GLYPH[lv]}</span> {lv}
              </button>
            );
          })}
        </div>

        <div className="flex items-center gap-2">
          <label htmlFor={inputId} className="text-ns-muted">
            grep
          </label>
          <input
            id={inputId}
            type="search"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="502"
            spellCheck={false}
            className="w-28 rounded-sm border border-border bg-surface px-2 py-1 font-mono text-xs text-foreground placeholder:text-ns-muted/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ns-accent"
          />
        </div>

        <span className="ml-auto tabular-nums text-ns-muted">
          {rows.length}/{lines.length} lines · ring {capacity}
        </span>
      </div>

      <div className="relative flex min-h-0 flex-1">
        {/* the log pane */}
        <div
          ref={paneRef}
          data-log-pane
          role="log"
          aria-live="off"
          aria-label={ariaLabel}
          tabIndex={0}
          onScroll={onScroll}
          onKeyDown={onKeyDown}
          className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ns-accent"
        >
          <div className="relative w-full" style={{ height: rows.length * ROW_H }}>
            {visible.map((l, i) => (
              <div
                key={l.seq}
                className="absolute inset-x-0 flex items-center whitespace-pre px-3 text-ns-muted transition-colors duration-100 hover:bg-surface hover:text-foreground"
                style={{ top: (range.start + i) * ROW_H, height: ROW_H }}
              >
                <span className="tabular-nums">{l.time}</span>
                <span className={FAINT}>{" │ "}</span>
                <span className={SEV_INK[l.sev]}>{GLYPH[l.sev]}</span>
                <span className={FAINT}>{" │ "}</span>
                <span>{l.service.padEnd(SERVICE_W)}</span>
                <span className={FAINT}>{" │ "}</span>
                <span>
                  {inkMatches(l.message, query.trim()).map((seg, k) => (
                    <span key={k} className={seg.hit ? "text-ns-accent" : undefined}>
                      {seg.text}
                    </span>
                  ))}
                </span>
              </div>
            ))}
          </div>
        </div>

        {/* detach bar — the count of what arrived while you were reading */}
        {!follow && (
          <button
            type="button"
            onClick={attach}
            className="absolute inset-x-0 bottom-0 border-t border-border bg-surface py-1.5 text-center whitespace-pre text-ns-muted transition-colors duration-100 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ns-accent"
          >
            {"└─ "}
            <span className="tabular-nums text-ns-accent">{newCount}</span>
            {` new line${newCount === 1 ? "" : "s"} ─ End to follow ─┘`}
          </button>
        )}
      </div>

      {reduced && (
        <div className="flex items-center justify-between gap-3 border-t border-border px-3 py-2">
          <span className="text-ns-muted">
            {running
              ? `stream running — ${lines.length} lines in the ring`
              : `stream paused — ${lines.length} lines loaded`}
          </span>
          <button
            type="button"
            onClick={() => setRunning((r) => !r)}
            className="rounded-sm border border-border px-2 py-1 whitespace-pre text-ns-muted transition-colors duration-100 hover:border-ns-muted hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ns-accent"
          >
            {running ? "■ pause stream" : "▸ resume stream"}
          </button>
        </div>
      )}
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/log-viewer-ascii-tail.tsx, so point the import there.

Use when Pick log-viewer-ascii-tail when the subject is an unbounded, still-arriving log stream that a person needs to read while it moves — it is the only component here with a bounded ring buffer, windowed row rendering, and a follow/detach contract that counts what arrived while you were scrolled up. Reach for progress-telegraph-log instead when the lines are the finite, ordered sub-steps of one operation with per-step elapsed cost, not an open-ended firehose; reach for streaming-token-settle when the thing arriving is model output rather than service telemetry.

Install

npx shadcn add https://design.helpmarq.com/r/log-viewer-ascii-tail.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build <LogViewerAsciiTail capacity? seed? initialLines? services? className? aria-label?> as a Geist Mono `tail -f` pane whose honesty is structural rather than decorative. RING BUFFER: a ref-held `Array(capacity)` (default 2000) with a monotonic `total` counter; a line's ring slot is `seq % capacity`, so an append overwrites the oldest slot and memory is bounded no matter how long the stream runs — there is no growing array anywhere on the path. The ordered view is read back from `max(0, total-capacity)` to `total`. STREAM: one deterministic LCG, `s = (s * 1664525 + 1013904223) >>> 0`, drives everything, so a given `seed` replays byte-identically: draw one for the inter-line delay `90 + (s/2^32)*260` ms (cadence therefore jitters like real traffic instead of ticking on a fixed interval, and the same draw advances a virtual wall clock so timestamps agree with the delays), one for the severity from a weighted table (DEBUG 0.42, INFO 0.40, WARN 0.13, ERROR 0.05), one for the service, one for the message. The generator is a self-rescheduling setTimeout, never setInterval. FLUSH: appends land in the ref and are coalesced into ONE rAF-scheduled setState per frame — a burst of lines inside a single frame costs a single render, never one render per line. RENDERING: rows are a fixed 18px tall and absolutely positioned inside a spacer of `rows.length * 18`, and only the rows intersecting the viewport (plus 6 of overscan each side) are in the DOM, so a full 2000-line buffer is a few dozen nodes. LINE FORMAT: `12:04:31.882 │ · │ api-gateway │ upstream 502 after 3 retries` — the service column is padEnd'd to 11 chars and the separators are drawn in `text-ns-muted/45`, so the pipes read as true faint vertical rules down the pane (NOT `text-border` — `--border` is tuned for 1px hairlines and is invisible as type on the light theme). SEVERITY COLUMN: glyph AND weight together, never hue alone — DEBUG `·` in `text-ns-muted/45`, INFO `─` in `text-ns-muted`, WARN `≡` in `text-foreground font-medium`, ERROR `█` in `text-ns-accent font-semibold`. The four-glyph column is the component's whole visual texture at rest: a mostly quiet field of `·` and `─` with the occasional accent `█` standing out, which is what makes an error burst findable without reading a word. FOLLOW: while following, every flush sets `scrollTop = scrollHeight`; the scroll handler drops follow the moment `scrollHeight - scrollTop - clientHeight > 8`, and a sticky bottom button renders `└─ 37 new lines ─ End to follow ─┘` with the count (inked in `text-ns-accent`) of appends since detach. Clicking that bar, pressing End, or scrolling back inside 8px re-attaches and zeroes the counter — the counter is the component's actual promise: nothing scrolled past you unannounced. Home/ArrowUp/ArrowDown/PageUp/PageDown also scroll the pane from the keyboard. There is no minimap, gutter or density index of any kind — the severity column and the line count are the whole read. FILTER: a `role=radiogroup` minimum-level segmented control with roving tabindex and Arrow/Home/End navigation (selecting with the keyboard also MOVES focus to the newly checked radio, so the ring never strands on a now-`tabIndex=-1` button) — each option shows its own severity glyph, so the control doubles as the legend for the column — plus a substring `input[type=search]` matched against message and service; matches are re-inked to `text-ns-accent` in place by splitting the message around each occurrence. Both re-derive the visible set straight from the ring buffer, and the header shows `visible/total lines · ring N`. ACCESSIBILITY: the pane is `role=log` with an `aria-label`, tabIndex=0, and an explicit `aria-live="off"` — a live region emitting ten lines a second is unusable with a screen reader, so announcement is deliberately opt-out and the pane is navigable as a focusable scroll region instead. Every focusable control (pane, level radios, filter input, the detach bar) carries a visible `focus-visible` accent outline. LIFECYCLE: `document.hidden` stops the generator entirely (the timeout is not rescheduled) and `visibilitychange` restarts it. REDUCED MOTION: `prefers-reduced-motion: reduce` seeds the same `initialLines` (default 200) of real data and then does not start the generator at all, rendering a `▸ resume stream` toggle instead — the data on screen is honest, only the motion is opt-in. THEME: every colour is a token (`--background --foreground --ns-muted --border --surface --ns-accent`); there is no hex anywhere, no canvas, and no dependencies. Row hover raises a line from `text-ns-muted` to `text-foreground` over a `bg-surface` chip in 100ms and relaxes back on pointer-out.

Props

PropTypeDefaultDescription
capacity?number2000ring buffer size — appends past this overwrite the oldest line. Default 2000.
seed?number20240804LCG seed; the same seed replays the same stream exactly. Default 20240804.
initialLines?number200lines synthesised before the stream starts, so the pane is dense at rest. Default 200.
services?string[]DEFAULT_SERVICESservice column values drawn from.
className?string—extra classes merged onto the rendered root element
aria-label?string"Application log, live tail"accessible name for the log pane