@annondeveloper/ui-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +215 -0
  3. package/dist/chunk-5OKSXPWK.js +270 -0
  4. package/dist/chunk-5OKSXPWK.js.map +1 -0
  5. package/dist/cli/index.js +430 -0
  6. package/dist/form.d.ts +65 -0
  7. package/dist/form.js +148 -0
  8. package/dist/form.js.map +1 -0
  9. package/dist/index.d.ts +942 -0
  10. package/dist/index.js +2812 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/select-nnBJUO8U.d.ts +26 -0
  13. package/package.json +114 -0
  14. package/src/components/animated-counter.stories.tsx +68 -0
  15. package/src/components/animated-counter.tsx +85 -0
  16. package/src/components/avatar.tsx +106 -0
  17. package/src/components/badge.stories.tsx +70 -0
  18. package/src/components/badge.tsx +97 -0
  19. package/src/components/button.stories.tsx +101 -0
  20. package/src/components/button.tsx +67 -0
  21. package/src/components/card.tsx +128 -0
  22. package/src/components/checkbox.stories.tsx +64 -0
  23. package/src/components/checkbox.tsx +58 -0
  24. package/src/components/confirm-dialog.stories.tsx +96 -0
  25. package/src/components/confirm-dialog.tsx +145 -0
  26. package/src/components/data-table.stories.tsx +125 -0
  27. package/src/components/data-table.tsx +791 -0
  28. package/src/components/dropdown-menu.tsx +111 -0
  29. package/src/components/empty-state.stories.tsx +42 -0
  30. package/src/components/empty-state.tsx +43 -0
  31. package/src/components/filter-pill.stories.tsx +71 -0
  32. package/src/components/filter-pill.tsx +45 -0
  33. package/src/components/form-input.stories.tsx +91 -0
  34. package/src/components/form-input.tsx +77 -0
  35. package/src/components/log-viewer.tsx +212 -0
  36. package/src/components/metric-card.tsx +141 -0
  37. package/src/components/pipeline-stage.tsx +134 -0
  38. package/src/components/popover.tsx +72 -0
  39. package/src/components/port-status-grid.tsx +102 -0
  40. package/src/components/progress.tsx +128 -0
  41. package/src/components/radio-group.tsx +162 -0
  42. package/src/components/select.stories.tsx +52 -0
  43. package/src/components/select.tsx +92 -0
  44. package/src/components/severity-timeline.tsx +125 -0
  45. package/src/components/sheet.tsx +164 -0
  46. package/src/components/skeleton.stories.tsx +64 -0
  47. package/src/components/skeleton.tsx +62 -0
  48. package/src/components/slider.tsx +208 -0
  49. package/src/components/sparkline.tsx +104 -0
  50. package/src/components/status-badge.stories.tsx +84 -0
  51. package/src/components/status-badge.tsx +71 -0
  52. package/src/components/status-pulse.stories.tsx +56 -0
  53. package/src/components/status-pulse.tsx +78 -0
  54. package/src/components/success-checkmark.stories.tsx +67 -0
  55. package/src/components/success-checkmark.tsx +53 -0
  56. package/src/components/tabs.tsx +177 -0
  57. package/src/components/threshold-gauge.tsx +149 -0
  58. package/src/components/time-range-selector.tsx +86 -0
  59. package/src/components/toast.stories.tsx +70 -0
  60. package/src/components/toast.tsx +48 -0
  61. package/src/components/toggle-switch.stories.tsx +66 -0
  62. package/src/components/toggle-switch.tsx +51 -0
  63. package/src/components/tooltip.tsx +62 -0
  64. package/src/components/truncated-text.stories.tsx +56 -0
  65. package/src/components/truncated-text.tsx +80 -0
  66. package/src/components/uptime-tracker.tsx +138 -0
  67. package/src/components/utilization-bar.tsx +103 -0
  68. package/src/theme.css +178 -0
  69. package/src/utils.ts +123 -0
@@ -0,0 +1,111 @@
1
+ 'use client'
2
+
3
+ import { type ReactNode } from 'react'
4
+ import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
5
+ import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
6
+ import type { LucideIcon } from 'lucide-react'
7
+ import { cn } from '../utils'
8
+
9
+ /** A single menu item definition. */
10
+ export interface MenuItem {
11
+ /** Display label. */
12
+ label: string
13
+ /** Optional icon component. */
14
+ icon?: LucideIcon
15
+ /** Callback when item is clicked. */
16
+ onClick: () => void
17
+ /** Visual variant — danger for destructive actions. */
18
+ variant?: 'default' | 'danger'
19
+ /** Whether this item is disabled. */
20
+ disabled?: boolean
21
+ }
22
+
23
+ export interface DropdownMenuProps {
24
+ /** Trigger element that opens the menu. */
25
+ trigger: ReactNode
26
+ /** Array of menu item definitions. */
27
+ items: MenuItem[]
28
+ /** Alignment of the menu relative to the trigger. */
29
+ align?: 'start' | 'center' | 'end'
30
+ /** Additional class name for the content container. */
31
+ className?: string
32
+ }
33
+
34
+ const contentVariants = {
35
+ hidden: { opacity: 0, scale: 0.95, y: -4 },
36
+ visible: { opacity: 1, scale: 1, y: 0 },
37
+ exit: { opacity: 0, scale: 0.95, y: -4 },
38
+ }
39
+
40
+ /**
41
+ * @description A context/action dropdown menu built on Radix DropdownMenu.
42
+ * Features Framer Motion entry/exit animations, keyboard accessibility,
43
+ * and a danger variant for destructive actions.
44
+ */
45
+ export function DropdownMenu({ trigger, items, align = 'end', className }: DropdownMenuProps) {
46
+ const prefersReducedMotion = useReducedMotion()
47
+
48
+ return (
49
+ <DropdownMenuPrimitive.Root>
50
+ <DropdownMenuPrimitive.Trigger asChild>{trigger}</DropdownMenuPrimitive.Trigger>
51
+
52
+ <AnimatePresence>
53
+ <DropdownMenuPrimitive.Portal>
54
+ <DropdownMenuPrimitive.Content
55
+ align={align}
56
+ sideOffset={6}
57
+ asChild
58
+ >
59
+ <motion.div
60
+ initial="hidden"
61
+ animate="visible"
62
+ exit="exit"
63
+ variants={contentVariants}
64
+ transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
65
+ className={cn(
66
+ 'z-50 min-w-[160px] overflow-hidden rounded-xl p-1',
67
+ 'border border-[hsl(var(--border-default))]',
68
+ 'bg-[hsl(var(--bg-elevated))] shadow-xl',
69
+ 'focus:outline-none',
70
+ className,
71
+ )}
72
+ >
73
+ {items.map((item, i) => {
74
+ const Icon = item.icon
75
+ const isDanger = item.variant === 'danger'
76
+
77
+ return (
78
+ <DropdownMenuPrimitive.Item
79
+ key={i}
80
+ disabled={item.disabled}
81
+ onSelect={item.onClick}
82
+ className={cn(
83
+ 'relative flex items-center gap-2.5 px-3 py-2 text-sm rounded-lg',
84
+ 'outline-none cursor-pointer select-none transition-colors duration-100',
85
+ 'data-[disabled]:pointer-events-none data-[disabled]:opacity-40',
86
+ isDanger
87
+ ? 'text-[hsl(var(--status-critical))] data-[highlighted]:bg-[hsl(var(--status-critical)/0.1)]'
88
+ : 'text-[hsl(var(--text-primary))] data-[highlighted]:bg-[hsl(var(--bg-overlay))]',
89
+ )}
90
+ >
91
+ {Icon && (
92
+ <Icon
93
+ className={cn(
94
+ 'h-4 w-4 shrink-0',
95
+ isDanger
96
+ ? 'text-[hsl(var(--status-critical))]'
97
+ : 'text-[hsl(var(--text-secondary))]',
98
+ )}
99
+ />
100
+ )}
101
+ {item.label}
102
+ </DropdownMenuPrimitive.Item>
103
+ )
104
+ })}
105
+ </motion.div>
106
+ </DropdownMenuPrimitive.Content>
107
+ </DropdownMenuPrimitive.Portal>
108
+ </AnimatePresence>
109
+ </DropdownMenuPrimitive.Root>
110
+ )
111
+ }
@@ -0,0 +1,42 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import { Inbox, Search, ServerCrash, Plus } from 'lucide-react'
3
+ import { EmptyState } from './empty-state'
4
+ import { Button } from './button'
5
+
6
+ const meta: Meta<typeof EmptyState> = {
7
+ title: 'Components/EmptyState',
8
+ component: EmptyState,
9
+ tags: ['autodocs'],
10
+ }
11
+ export default meta
12
+ type Story = StoryObj<typeof EmptyState>
13
+
14
+ export const Default: Story = {
15
+ args: {
16
+ icon: Inbox,
17
+ title: 'No entities found',
18
+ description: 'There are no entities matching your filters. Try broadening your search.',
19
+ },
20
+ }
21
+
22
+ export const WithActions: Story = {
23
+ args: {
24
+ icon: ServerCrash,
25
+ title: 'No devices discovered',
26
+ description: 'Run a network scan to discover devices on your network.',
27
+ actions: (
28
+ <>
29
+ <Button variant="primary"><Plus className="h-4 w-4" /> Run Discovery</Button>
30
+ <Button variant="outline">Import CSV</Button>
31
+ </>
32
+ ),
33
+ },
34
+ }
35
+
36
+ export const SearchEmpty: Story = {
37
+ args: {
38
+ icon: Search,
39
+ title: 'No results',
40
+ description: 'No rows match your search or filter criteria.',
41
+ },
42
+ }
@@ -0,0 +1,43 @@
1
+ 'use client'
2
+
3
+ import { cn } from '../utils'
4
+ import type { LucideIcon } from 'lucide-react'
5
+
6
+ export interface EmptyStateProps {
7
+ /** Icon component displayed in the center. */
8
+ icon: LucideIcon
9
+ /** Title text. */
10
+ title: string
11
+ /** Description text. */
12
+ description: string
13
+ /** Optional action buttons rendered below the description. */
14
+ actions?: React.ReactNode
15
+ className?: string
16
+ }
17
+
18
+ /**
19
+ * @description A decorative empty state placeholder with icon, title, description, and optional actions.
20
+ * Features a subtle gradient background and glass-morphism styling.
21
+ */
22
+ export function EmptyState({ icon: Icon, title, description, actions, className }: EmptyStateProps) {
23
+ return (
24
+ <div
25
+ className={cn(
26
+ 'relative overflow-hidden flex flex-col items-center justify-center rounded-2xl border border-dashed border-[hsl(var(--border-default))]',
27
+ 'bg-[hsl(var(--bg-surface)/0.6)] backdrop-blur-xl px-6 py-12 text-center',
28
+ className,
29
+ )}
30
+ >
31
+ {/* Decorative gradient background */}
32
+ <div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-[hsl(var(--brand-primary)/0.04)] to-transparent" />
33
+ <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_50%_0%,hsl(var(--brand-primary)/0.06),transparent_70%)]" />
34
+
35
+ <div className="relative mb-4 flex size-14 items-center justify-center rounded-2xl bg-[hsl(var(--bg-elevated))] border border-[hsl(var(--border-subtle)/0.6)] shadow-[0_1px_3px_hsl(0_0%_0%/0.12)]">
36
+ <Icon className="size-6 text-[hsl(var(--text-disabled))]" />
37
+ </div>
38
+ <h3 className="relative text-heading-3 text-[hsl(var(--text-primary))] mb-1">{title}</h3>
39
+ <p className="relative text-body text-[hsl(var(--text-secondary))] max-w-sm">{description}</p>
40
+ {actions && <div className="relative mt-6 flex gap-3">{actions}</div>}
41
+ </div>
42
+ )
43
+ }
@@ -0,0 +1,71 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import { useState } from 'react'
3
+ import { FilterPill } from './filter-pill'
4
+
5
+ const meta: Meta<typeof FilterPill> = {
6
+ title: 'Components/FilterPill',
7
+ component: FilterPill,
8
+ tags: ['autodocs'],
9
+ }
10
+ export default meta
11
+ type Story = StoryObj<typeof FilterPill>
12
+
13
+ export const Active: Story = {
14
+ args: {
15
+ label: 'Network Devices',
16
+ active: true,
17
+ onClick: () => {},
18
+ },
19
+ }
20
+
21
+ export const Inactive: Story = {
22
+ args: {
23
+ label: 'Firewalls',
24
+ active: false,
25
+ onClick: () => {},
26
+ },
27
+ }
28
+
29
+ export const WithCount: Story = {
30
+ args: {
31
+ label: 'Hosts',
32
+ count: 42,
33
+ active: true,
34
+ onClick: () => {},
35
+ },
36
+ }
37
+
38
+ export const WithCountInactive: Story = {
39
+ args: {
40
+ label: 'VMs',
41
+ count: 128,
42
+ active: false,
43
+ onClick: () => {},
44
+ },
45
+ }
46
+
47
+ export const FilterGroup: Story = {
48
+ render: () => {
49
+ const [active, setActive] = useState('all')
50
+ const filters = [
51
+ { key: 'all', label: 'All', count: 312 },
52
+ { key: 'network', label: 'Network', count: 47 },
53
+ { key: 'compute', label: 'Compute', count: 89 },
54
+ { key: 'storage', label: 'Storage', count: 12 },
55
+ { key: 'firewall', label: 'Firewall', count: 8 },
56
+ ]
57
+ return (
58
+ <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
59
+ {filters.map(f => (
60
+ <FilterPill
61
+ key={f.key}
62
+ label={f.label}
63
+ count={f.count}
64
+ active={active === f.key}
65
+ onClick={() => setActive(f.key)}
66
+ />
67
+ ))}
68
+ </div>
69
+ )
70
+ },
71
+ }
@@ -0,0 +1,45 @@
1
+ 'use client'
2
+
3
+ import { cn } from '../utils'
4
+
5
+ export interface FilterPillProps {
6
+ /** Label text for the pill. */
7
+ label: string
8
+ /** Optional count displayed after the label. */
9
+ count?: number
10
+ /** Whether this pill is currently active/selected. */
11
+ active: boolean
12
+ /** Click handler. */
13
+ onClick: () => void
14
+ className?: string
15
+ }
16
+
17
+ /**
18
+ * @description A rounded pill-style filter toggle with active state and optional count.
19
+ * Uses CSS custom property tokens for dark/light mode compatibility.
20
+ */
21
+ export function FilterPill({ label, count, active, onClick, className }: FilterPillProps) {
22
+ return (
23
+ <button
24
+ type="button"
25
+ onClick={onClick}
26
+ className={cn(
27
+ 'rounded-full px-3 py-1 text-xs transition-colors whitespace-nowrap',
28
+ active
29
+ ? 'bg-[hsl(var(--brand-primary))]/10 text-[hsl(var(--brand-primary))] font-medium'
30
+ : 'bg-[hsl(var(--bg-elevated))] text-[hsl(var(--text-secondary))] hover:bg-[hsl(var(--bg-overlay))]',
31
+ className,
32
+ )}
33
+ >
34
+ {label}
35
+ {count != null && (
36
+ <span className={cn(
37
+ 'ml-1.5 tabular-nums',
38
+ active ? 'text-[hsl(var(--brand-primary))]/70' : 'text-[hsl(var(--text-tertiary))]',
39
+ )}>
40
+ {count}
41
+ </span>
42
+ )}
43
+ </button>
44
+ )
45
+ }
@@ -0,0 +1,91 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import { useState } from 'react'
3
+ import { FormInput } from './form-input'
4
+
5
+ const meta: Meta<typeof FormInput> = {
6
+ title: 'Components/FormInput',
7
+ component: FormInput,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ type: { control: 'select', options: ['text', 'email', 'password', 'number', 'url'] },
11
+ required: { control: 'boolean' },
12
+ disabled: { control: 'boolean' },
13
+ },
14
+ }
15
+ export default meta
16
+ type Story = StoryObj<typeof FormInput>
17
+
18
+ export const Default: Story = {
19
+ render: (args) => {
20
+ const [value, setValue] = useState('')
21
+ return <FormInput {...args} value={value} onChange={setValue} />
22
+ },
23
+ args: {
24
+ label: 'Hostname',
25
+ placeholder: 'e.g. switch-core-01',
26
+ },
27
+ }
28
+
29
+ export const WithLabel: Story = {
30
+ render: () => {
31
+ const [value, setValue] = useState('admin@netrak.io')
32
+ return <FormInput label="Email" type="email" value={value} onChange={setValue} />
33
+ },
34
+ }
35
+
36
+ export const WithHint: Story = {
37
+ render: () => {
38
+ const [value, setValue] = useState('')
39
+ return (
40
+ <FormInput
41
+ label="API Key"
42
+ value={value}
43
+ onChange={setValue}
44
+ hint="Generate a key from Settings > API Keys."
45
+ placeholder="sk-..."
46
+ />
47
+ )
48
+ },
49
+ }
50
+
51
+ export const Required: Story = {
52
+ render: () => {
53
+ const [value, setValue] = useState('')
54
+ return (
55
+ <FormInput
56
+ label="Device Name"
57
+ value={value}
58
+ onChange={setValue}
59
+ required
60
+ placeholder="Required field"
61
+ />
62
+ )
63
+ },
64
+ }
65
+
66
+ export const Disabled: Story = {
67
+ render: () => (
68
+ <FormInput
69
+ label="Read Only"
70
+ value="Cannot edit this"
71
+ onChange={() => {}}
72
+ disabled
73
+ />
74
+ ),
75
+ }
76
+
77
+ export const Password: Story = {
78
+ render: () => {
79
+ const [value, setValue] = useState('')
80
+ return (
81
+ <FormInput
82
+ label="Password"
83
+ type="password"
84
+ value={value}
85
+ onChange={setValue}
86
+ required
87
+ placeholder="Enter password"
88
+ />
89
+ )
90
+ },
91
+ }
@@ -0,0 +1,77 @@
1
+ 'use client'
2
+
3
+ import { cn } from '../utils'
4
+
5
+ // ── Shared class constants ──────────────────────────────────────────────────
6
+ // Import these in any page that needs raw class strings (e.g. for <textarea>)
7
+
8
+ export const INPUT_CLS = cn(
9
+ 'w-full rounded-lg border border-[hsl(var(--border-default))]',
10
+ 'bg-[hsl(var(--bg-base))] px-3 py-2 text-sm',
11
+ 'text-[hsl(var(--text-primary))] placeholder:text-[hsl(var(--text-tertiary))]',
12
+ 'focus:outline-none focus:ring-2 focus:ring-[hsl(var(--brand-primary))]',
13
+ 'disabled:opacity-50 disabled:cursor-not-allowed',
14
+ )
15
+
16
+ export const LABEL_CLS = cn(
17
+ 'mb-1.5 block text-xs font-medium uppercase tracking-wider',
18
+ 'text-[hsl(var(--text-secondary))]',
19
+ )
20
+
21
+ export const TEXTAREA_CLS = cn(
22
+ INPUT_CLS,
23
+ 'resize-none font-mono text-xs leading-relaxed',
24
+ )
25
+
26
+ export interface FormInputProps {
27
+ /** Label text displayed above the input. */
28
+ label: string
29
+ /** Current input value. */
30
+ value: string
31
+ /** Callback when value changes. */
32
+ onChange: (value: string) => void
33
+ /** HTML input type. */
34
+ type?: string
35
+ /** Placeholder text. */
36
+ placeholder?: string
37
+ /** Mark the field as required. */
38
+ required?: boolean
39
+ /** Disable the input. */
40
+ disabled?: boolean
41
+ /** Help text shown below the input. */
42
+ hint?: string
43
+ className?: string
44
+ /** HTML autocomplete attribute. */
45
+ autoComplete?: string
46
+ }
47
+
48
+ /**
49
+ * @description A themed form input with label, validation indicator, and optional hint text.
50
+ * Uses CSS custom property tokens for dark/light mode compatibility.
51
+ */
52
+ export function FormInput({
53
+ label, value, onChange, type = 'text',
54
+ placeholder, required, disabled, hint, className, autoComplete,
55
+ }: FormInputProps) {
56
+ return (
57
+ <div className={cn('space-y-1.5', className)}>
58
+ <label className={LABEL_CLS}>
59
+ {label}
60
+ {required && <span className="text-[hsl(var(--status-critical))] ml-0.5">*</span>}
61
+ </label>
62
+ <input
63
+ type={type}
64
+ value={value}
65
+ onChange={(e) => onChange(e.target.value)}
66
+ placeholder={placeholder}
67
+ required={required}
68
+ disabled={disabled}
69
+ autoComplete={autoComplete}
70
+ className={INPUT_CLS}
71
+ />
72
+ {hint && (
73
+ <p className="text-[10px] text-[hsl(var(--text-tertiary))]">{hint}</p>
74
+ )}
75
+ </div>
76
+ )
77
+ }
@@ -0,0 +1,212 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
5
+ import { ArrowDown, Search } from 'lucide-react'
6
+ import { cn } from '../utils'
7
+
8
+ export interface LogEntry {
9
+ /** ISO 8601 timestamp. */
10
+ time: string
11
+ /** Log level. */
12
+ level: 'error' | 'warn' | 'info' | 'debug' | 'trace'
13
+ /** Log message content. */
14
+ message: string
15
+ /** Optional source identifier. */
16
+ source?: string
17
+ }
18
+
19
+ export interface LogViewerProps {
20
+ /** Log entries to display. */
21
+ entries: LogEntry[]
22
+ /** Maximum container height in pixels. */
23
+ maxHeight?: number
24
+ /** Auto-scroll to bottom on new entries. */
25
+ autoScroll?: boolean
26
+ /** Show timestamps column. */
27
+ showTimestamps?: boolean
28
+ /** Show level badge. */
29
+ showLevel?: boolean
30
+ /** Callback when a log entry is clicked. */
31
+ onEntryClick?: (entry: LogEntry) => void
32
+ className?: string
33
+ }
34
+
35
+ const levelBorder: Record<string, string> = {
36
+ error: 'border-l-[hsl(var(--status-critical))]',
37
+ warn: 'border-l-[hsl(var(--status-warning))]',
38
+ info: 'border-l-[hsl(var(--brand-primary))]',
39
+ debug: 'border-l-[hsl(var(--text-tertiary))]',
40
+ trace: 'border-l-[hsl(var(--text-disabled))]',
41
+ }
42
+
43
+ const levelText: Record<string, string> = {
44
+ error: 'text-[hsl(var(--status-critical))]',
45
+ warn: 'text-[hsl(var(--status-warning))]',
46
+ info: 'text-[hsl(var(--brand-primary))]',
47
+ debug: 'text-[hsl(var(--text-tertiary))]',
48
+ trace: 'text-[hsl(var(--text-disabled))]',
49
+ }
50
+
51
+ const levelLabel: Record<string, string> = {
52
+ error: 'ERR',
53
+ warn: 'WRN',
54
+ info: 'INF',
55
+ debug: 'DBG',
56
+ trace: 'TRC',
57
+ }
58
+
59
+ function formatTs(iso: string): string {
60
+ try {
61
+ const d = new Date(iso)
62
+ return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
63
+ } catch {
64
+ return iso
65
+ }
66
+ }
67
+
68
+ /**
69
+ * @description A log stream viewer with severity-colored borders, auto-scroll,
70
+ * search filtering, and a "new entries" indicator when scrolled up.
71
+ * Designed for real-time log tailing in monitoring dashboards.
72
+ */
73
+ export function LogViewer({
74
+ entries,
75
+ maxHeight = 400,
76
+ autoScroll = true,
77
+ showTimestamps = true,
78
+ showLevel = true,
79
+ onEntryClick,
80
+ className,
81
+ }: LogViewerProps) {
82
+ const reduced = useReducedMotion()
83
+ const containerRef = useRef<HTMLDivElement>(null)
84
+ const [isAtBottom, setIsAtBottom] = useState(true)
85
+ const [filter, setFilter] = useState('')
86
+ const [newCount, setNewCount] = useState(0)
87
+ const prevLenRef = useRef(entries.length)
88
+
89
+ const checkAtBottom = useCallback(() => {
90
+ const el = containerRef.current
91
+ if (!el) return
92
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 32
93
+ setIsAtBottom(atBottom)
94
+ if (atBottom) setNewCount(0)
95
+ }, [])
96
+
97
+ // Auto-scroll on new entries
98
+ useEffect(() => {
99
+ const added = entries.length - prevLenRef.current
100
+ prevLenRef.current = entries.length
101
+
102
+ if (added > 0 && !isAtBottom) {
103
+ setNewCount(prev => prev + added)
104
+ }
105
+
106
+ if (autoScroll && isAtBottom && containerRef.current) {
107
+ containerRef.current.scrollTop = containerRef.current.scrollHeight
108
+ }
109
+ }, [entries.length, autoScroll, isAtBottom])
110
+
111
+ const scrollToBottom = useCallback(() => {
112
+ if (containerRef.current) {
113
+ containerRef.current.scrollTop = containerRef.current.scrollHeight
114
+ setNewCount(0)
115
+ setIsAtBottom(true)
116
+ }
117
+ }, [])
118
+
119
+ const lowerFilter = filter.toLowerCase()
120
+ const filtered = filter
121
+ ? entries.filter(e =>
122
+ e.message.toLowerCase().includes(lowerFilter) ||
123
+ e.source?.toLowerCase().includes(lowerFilter) ||
124
+ e.level.includes(lowerFilter)
125
+ )
126
+ : entries
127
+
128
+ return (
129
+ <div className={cn('relative rounded-xl border border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-surface))] overflow-hidden', className)}>
130
+ {/* Search bar */}
131
+ <div className="flex items-center gap-2 px-3 py-2 border-b border-[hsl(var(--border-subtle))] bg-[hsl(var(--bg-elevated))]">
132
+ <Search className="size-3.5 text-[hsl(var(--text-tertiary))] shrink-0" />
133
+ <input
134
+ type="text"
135
+ value={filter}
136
+ onChange={e => setFilter(e.target.value)}
137
+ placeholder="Filter logs..."
138
+ className="flex-1 bg-transparent text-[0.75rem] text-[hsl(var(--text-primary))] placeholder:text-[hsl(var(--text-disabled))] outline-none font-mono"
139
+ />
140
+ {filter && (
141
+ <span className="text-[0.6875rem] text-[hsl(var(--text-tertiary))] tabular-nums shrink-0">
142
+ {filtered.length} / {entries.length}
143
+ </span>
144
+ )}
145
+ </div>
146
+
147
+ {/* Log entries */}
148
+ <div
149
+ ref={containerRef}
150
+ onScroll={checkAtBottom}
151
+ className="overflow-y-auto"
152
+ style={{ maxHeight }}
153
+ >
154
+ {filtered.map((entry, i) => (
155
+ <div
156
+ key={`${entry.time}-${i}`}
157
+ onClick={() => onEntryClick?.(entry)}
158
+ className={cn(
159
+ 'flex items-start gap-2 px-3 py-1 border-l-2 font-mono text-[0.75rem] leading-5',
160
+ 'hover:bg-[hsl(var(--bg-elevated))] transition-colors',
161
+ onEntryClick && 'cursor-pointer',
162
+ levelBorder[entry.level] ?? levelBorder.info,
163
+ )}
164
+ >
165
+ {showTimestamps && (
166
+ <span className="text-[hsl(var(--text-tertiary))] tabular-nums shrink-0 select-all">
167
+ {formatTs(entry.time)}
168
+ </span>
169
+ )}
170
+ {showLevel && (
171
+ <span className={cn('font-semibold shrink-0 w-[2.5ch]', levelText[entry.level] ?? levelText.info)}>
172
+ {levelLabel[entry.level] ?? entry.level.toUpperCase().slice(0, 3)}
173
+ </span>
174
+ )}
175
+ {entry.source && (
176
+ <span className="text-[hsl(var(--text-secondary))] shrink-0">
177
+ [{entry.source}]
178
+ </span>
179
+ )}
180
+ <span className="text-[hsl(var(--text-primary))] break-all min-w-0">
181
+ {entry.message}
182
+ </span>
183
+ </div>
184
+ ))}
185
+ </div>
186
+
187
+ {/* New entries indicator */}
188
+ <AnimatePresence>
189
+ {newCount > 0 && !isAtBottom && (
190
+ <motion.button
191
+ type="button"
192
+ initial={reduced ? false : { opacity: 0, y: 8 }}
193
+ animate={{ opacity: 1, y: 0 }}
194
+ exit={{ opacity: 0, y: 8 }}
195
+ transition={{ duration: 0.15 }}
196
+ onClick={scrollToBottom}
197
+ className={cn(
198
+ 'absolute bottom-3 left-1/2 -translate-x-1/2 z-10',
199
+ 'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full',
200
+ 'bg-[hsl(var(--brand-primary))] text-[hsl(var(--text-on-brand))]',
201
+ 'text-[0.6875rem] font-medium shadow-lg cursor-pointer',
202
+ 'hover:opacity-90 transition-opacity',
203
+ )}
204
+ >
205
+ <ArrowDown className="size-3" />
206
+ {newCount} new {newCount === 1 ? 'entry' : 'entries'}
207
+ </motion.button>
208
+ )}
209
+ </AnimatePresence>
210
+ </div>
211
+ )
212
+ }