From 8c1c9532599a5327f2839af46b886a0bb0a35890 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:27:03 +0100 Subject: [PATCH] feat: add LogViewer composite for timestamped, severity-colored log display Scrollable log viewer with auto-scroll behavior, level badges (info/warn/ error/debug) with semantic colors, monospace font, and role="log" for accessibility. Includes 7 tests and barrel exports. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../composites/LogViewer/LogViewer.module.css | 75 ++++++++++++++++++ .../composites/LogViewer/LogViewer.test.tsx | 56 ++++++++++++++ .../composites/LogViewer/LogViewer.tsx | 77 +++++++++++++++++++ src/design-system/composites/index.ts | 2 + 4 files changed, 210 insertions(+) create mode 100644 src/design-system/composites/LogViewer/LogViewer.module.css create mode 100644 src/design-system/composites/LogViewer/LogViewer.test.tsx create mode 100644 src/design-system/composites/LogViewer/LogViewer.tsx diff --git a/src/design-system/composites/LogViewer/LogViewer.module.css b/src/design-system/composites/LogViewer/LogViewer.module.css new file mode 100644 index 0000000..70a468c --- /dev/null +++ b/src/design-system/composites/LogViewer/LogViewer.module.css @@ -0,0 +1,75 @@ +.container { + overflow-y: auto; + background: var(--bg-inset); + border-radius: var(--radius-md); + padding: 8px 0; + font-family: var(--font-mono); +} + +.line { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 3px 12px; + line-height: 1.5; +} + +.line:hover { + background: var(--bg-hover); +} + +.timestamp { + flex-shrink: 0; + font-size: 11px; + color: var(--text-muted); + min-width: 56px; +} + +.levelBadge { + flex-shrink: 0; + font-size: 9px; + font-weight: 600; + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.3px; + padding: 1px 6px; + border-radius: 9999px; + line-height: 1.5; + white-space: nowrap; +} + +.levelInfo { + color: var(--running); + background: color-mix(in srgb, var(--running) 12%, transparent); +} + +.levelWarn { + color: var(--warning); + background: color-mix(in srgb, var(--warning) 12%, transparent); +} + +.levelError { + color: var(--error); + background: color-mix(in srgb, var(--error) 12%, transparent); +} + +.levelDebug { + color: var(--text-muted); + background: color-mix(in srgb, var(--text-muted) 10%, transparent); +} + +.message { + font-size: 12px; + font-family: var(--font-mono); + color: var(--text-primary); + word-break: break-word; + line-height: 1.5; +} + +.empty { + padding: 24px; + text-align: center; + color: var(--text-faint); + font-size: 12px; + font-family: var(--font-body); +} diff --git a/src/design-system/composites/LogViewer/LogViewer.test.tsx b/src/design-system/composites/LogViewer/LogViewer.test.tsx new file mode 100644 index 0000000..5f4e723 --- /dev/null +++ b/src/design-system/composites/LogViewer/LogViewer.test.tsx @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { LogViewer, type LogEntry } from './LogViewer' + +const entries: LogEntry[] = [ + { timestamp: '2024-01-15T10:30:00Z', level: 'info', message: 'Server started' }, + { timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' }, + { timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' }, + { timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' }, +] + +describe('LogViewer', () => { + it('renders entries with timestamps and messages', () => { + render() + expect(screen.getByText('Server started')).toBeInTheDocument() + expect(screen.getByText('High memory usage')).toBeInTheDocument() + expect(screen.getByText('Connection failed')).toBeInTheDocument() + expect(screen.getByText('Query executed in 3ms')).toBeInTheDocument() + }) + + it('renders level badges with correct text (INFO, WARN, ERROR, DEBUG)', () => { + render() + expect(screen.getByText('INFO')).toBeInTheDocument() + expect(screen.getByText('WARN')).toBeInTheDocument() + expect(screen.getByText('ERROR')).toBeInTheDocument() + expect(screen.getByText('DEBUG')).toBeInTheDocument() + }) + + it('renders with custom maxHeight (number)', () => { + const { container } = render() + const el = container.firstElementChild as HTMLElement + expect(el.style.maxHeight).toBe('300px') + }) + + it('renders with string maxHeight', () => { + const { container } = render() + const el = container.firstElementChild as HTMLElement + expect(el.style.maxHeight).toBe('50vh') + }) + + it('handles empty entries', () => { + render() + expect(screen.getByText('No log entries.')).toBeInTheDocument() + }) + + it('accepts className prop', () => { + const { container } = render() + const el = container.firstElementChild as HTMLElement + expect(el.classList.contains('custom-class')).toBe(true) + }) + + it('has role="log" for accessibility', () => { + render() + expect(screen.getByRole('log')).toBeInTheDocument() + }) +}) diff --git a/src/design-system/composites/LogViewer/LogViewer.tsx b/src/design-system/composites/LogViewer/LogViewer.tsx new file mode 100644 index 0000000..4839d76 --- /dev/null +++ b/src/design-system/composites/LogViewer/LogViewer.tsx @@ -0,0 +1,77 @@ +import { useRef, useEffect, useCallback } from 'react' +import styles from './LogViewer.module.css' + +export interface LogEntry { + timestamp: string + level: 'info' | 'warn' | 'error' | 'debug' + message: string +} + +export interface LogViewerProps { + entries: LogEntry[] + maxHeight?: number | string + className?: string +} + +const LEVEL_CLASS: Record = { + info: styles.levelInfo, + warn: styles.levelWarn, + error: styles.levelError, + debug: styles.levelDebug, +} + +function formatTime(iso: string): string { + try { + return new Date(iso).toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + } catch { + return iso + } +} + +export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProps) { + const scrollRef = useRef(null) + const isAtBottomRef = useRef(true) + + const handleScroll = useCallback(() => { + const el = scrollRef.current + if (!el) return + isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 20 + }, []) + + useEffect(() => { + const el = scrollRef.current + if (el && isAtBottomRef.current) { + el.scrollTop = el.scrollHeight + } + }, [entries]) + + const heightStyle = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight + + return ( +
+ {entries.map((entry, i) => ( +
+ {formatTime(entry.timestamp)} + + {entry.level.toUpperCase()} + + {entry.message} +
+ ))} + {entries.length === 0 && ( +
No log entries.
+ )} +
+ ) +} diff --git a/src/design-system/composites/index.ts b/src/design-system/composites/index.ts index 5e457a1..6578721 100644 --- a/src/design-system/composites/index.ts +++ b/src/design-system/composites/index.ts @@ -20,6 +20,8 @@ export type { KpiItem, KpiStripProps } from './KpiStrip/KpiStrip' export type { FeedEvent } from './EventFeed/EventFeed' export { FilterBar } from './FilterBar/FilterBar' export { LineChart } from './LineChart/LineChart' +export { LogViewer } from './LogViewer/LogViewer' +export type { LogEntry, LogViewerProps } from './LogViewer/LogViewer' export { LoginDialog } from './LoginForm/LoginDialog' export type { LoginDialogProps } from './LoginForm/LoginDialog' export { LoginForm } from './LoginForm/LoginForm'